fix(game): show granted/bought hints in-game — finish the D31 wire removal
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
The in-game hint badge ignored the payments hint wallet: loading a game clobbered app.profile.hintBalance with the deprecated StateView.wallet_balance (zeroed in the D31 domain removal but left in the protocol as a dead 0, then synced over the real balance at game load). Finish the removal honestly. StateView carries the per-game allowance alone (hints_remaining); the purchasable wallet lives solely on the profile and the client adds it (lib/hints). Removed StateView.wallet_balance across every layer — backend StateView/DTO, notify.PlayerState (live events), gateway StateResp + wire.StateView, the client model/codec/mock/localgame — and dropped the now-unused wallet arg from hintsRemaining. The FBS field is tombstoned `(deprecated)` (not deleted) so the vtable slots after it stay stable across a rolling deploy; no accessor is generated. HintResult keeps wallet_balance (the real post-spend payments balance the client adopts into the profile). The StateView type no longer has walletBalance, so the clobber cannot return without a compile error. Tests: hintsRemaining (2-arg); hints.hintsLeft (allowance + live wallet, no strip); the game state/hint integration (allowance-only HintsRemaining); gateway transcode; FBS regen.
This commit is contained in:
@@ -251,7 +251,8 @@
|
||||
view = st;
|
||||
// Anchor the vs_ai idle-hint countdown to the freshly fetched seconds-left (0 = open / non-vs_ai).
|
||||
armHintGate(st.game.vsAi ? (st.hintUnlockLeftSeconds ?? 0) : 0);
|
||||
syncWallet(st.walletBalance);
|
||||
// The game state no longer carries the hint wallet (it lives on the profile); do not touch
|
||||
// app.profile.hintBalance here — that would clobber the authoritative payments balance.
|
||||
// Seed the unread flag from the authoritative state (the live stream only raises it).
|
||||
seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages);
|
||||
moves = hist.moves;
|
||||
@@ -833,10 +834,9 @@
|
||||
seat: r.move.player,
|
||||
rack: r.rack,
|
||||
bagLen: r.bagLen,
|
||||
// A move is not a hint, so the per-game allowance and the wallet are unchanged: carry both
|
||||
// forward (their difference is the stable allowance; the badge adds the live wallet).
|
||||
// A move is not a hint, so the per-game allowance is unchanged: carry it forward. The badge
|
||||
// adds the live wallet from the profile.
|
||||
hintsRemaining: view?.hintsRemaining ?? 0,
|
||||
walletBalance: view?.walletBalance ?? 0,
|
||||
};
|
||||
// The move result is an authoritative per-viewer view: a nudge the actor just answered by
|
||||
// moving is already cleared server-side, so reconcile the unread flag from it.
|
||||
@@ -1047,7 +1047,7 @@
|
||||
recenter++;
|
||||
}
|
||||
if (isCoarse() && !landscape) zoomed = true;
|
||||
view = { ...view, hintsRemaining: h.hintsRemaining, walletBalance: h.walletBalance };
|
||||
view = { ...view, hintsRemaining: h.hintsRemaining };
|
||||
syncWallet(h.walletBalance);
|
||||
// The hint is the engine's own top-ranked, fully scored legal move: reuse it as the
|
||||
// preview instead of a redundant evaluate (same engine call, same placement). Cancel any
|
||||
|
||||
@@ -69,11 +69,6 @@ alphabetLength():number {
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
walletBalance():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 16);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
hintUnlockLeftSeconds():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 18);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
@@ -131,10 +126,6 @@ static startAlphabetVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) {
|
||||
builder.addFieldInt32(6, walletBalance, 0);
|
||||
}
|
||||
|
||||
static addHintUnlockLeftSeconds(builder:flatbuffers.Builder, hintUnlockLeftSeconds:number) {
|
||||
builder.addFieldInt32(7, hintUnlockLeftSeconds, 0);
|
||||
}
|
||||
@@ -144,7 +135,7 @@ static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset, walletBalance:number, hintUnlockLeftSeconds:number):flatbuffers.Offset {
|
||||
static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset, hintUnlockLeftSeconds:number):flatbuffers.Offset {
|
||||
StateView.startStateView(builder);
|
||||
StateView.addGame(builder, gameOffset);
|
||||
StateView.addSeat(builder, seat);
|
||||
@@ -152,7 +143,6 @@ static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offse
|
||||
StateView.addBagLen(builder, bagLen);
|
||||
StateView.addHintsRemaining(builder, hintsRemaining);
|
||||
StateView.addAlphabet(builder, alphabetOffset);
|
||||
StateView.addWalletBalance(builder, walletBalance);
|
||||
StateView.addHintUnlockLeftSeconds(builder, hintUnlockLeftSeconds);
|
||||
return StateView.endStateView(builder);
|
||||
}
|
||||
|
||||
@@ -575,7 +575,6 @@ function decodeStateViewTable(v: fb.StateView): StateView {
|
||||
rack,
|
||||
bagLen: v.bagLen(),
|
||||
hintsRemaining: v.hintsRemaining(),
|
||||
walletBalance: v.walletBalance(),
|
||||
hintUnlockLeftSeconds: v.hintUnlockLeftSeconds(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ function gameView(id: string): GameView {
|
||||
}
|
||||
|
||||
function view(id: string, rack: string[] = ['A', 'B']): StateView {
|
||||
return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1, walletBalance: 0 };
|
||||
return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1 };
|
||||
}
|
||||
|
||||
function move(player: number): MoveRecord {
|
||||
|
||||
@@ -28,7 +28,7 @@ function move(player: number): MoveRecord {
|
||||
}
|
||||
|
||||
function cache(moveCount: number, seat = 0, over = false): CachedGame {
|
||||
const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 };
|
||||
const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 };
|
||||
return { view, moves: [] };
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ function delta(moveCount: number, player: number, bagLen = 47): MoveDelta {
|
||||
|
||||
describe('seedInitialState', () => {
|
||||
it('wraps an initial view with an empty journal', () => {
|
||||
const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2, walletBalance: 0 };
|
||||
const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2 };
|
||||
expect(seedInitialState(view)).toEqual({ view, moves: [] });
|
||||
});
|
||||
});
|
||||
@@ -153,12 +153,12 @@ describe('applyOpponentJoined', () => {
|
||||
{ seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false },
|
||||
{ seat: 1, accountId: 'opp', displayName: 'Opp', score: 0, hintsUsed: 0, isWinner: false },
|
||||
] };
|
||||
return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0, walletBalance: 0 };
|
||||
return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0 };
|
||||
}
|
||||
|
||||
it("adopts the joined seats and status while preserving the cached rack and moves", () => {
|
||||
// The cached open game is still "searching": empty seats, status open, the starter's own rack.
|
||||
const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 }, moves: [move(0)] };
|
||||
const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }, moves: [move(0)] };
|
||||
const res = applyOpponentJoined(cached, joinedState());
|
||||
expect(res?.view.game.status).toBe('active');
|
||||
expect(res?.view.game.seats).toHaveLength(2);
|
||||
|
||||
+12
-14
@@ -1,33 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hintsLeft, hintGateRemainingMs, hintLockMinutes } from './hints';
|
||||
|
||||
// view carries only the two fields hintsLeft reads.
|
||||
const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance });
|
||||
// view carries only the field hintsLeft reads (the per-game allowance).
|
||||
const view = (hintsRemaining: number) => ({ hintsRemaining });
|
||||
|
||||
describe('hintsLeft', () => {
|
||||
it('is zero without a view', () => {
|
||||
expect(hintsLeft(null, 5)).toBe(0);
|
||||
});
|
||||
|
||||
it('adds the per-game allowance to the live wallet (fresh view)', () => {
|
||||
// hints_remaining 4 = allowance 1 + wallet 3; live wallet matches the snapshot → 1 + 3.
|
||||
expect(hintsLeft(view(4, 3), 3)).toBe(4);
|
||||
it('adds the per-game allowance to the live wallet', () => {
|
||||
expect(hintsLeft(view(1), 3)).toBe(4); // allowance 1 + wallet 3
|
||||
});
|
||||
|
||||
it('reflects the LIVE wallet, not the per-game snapshot (the staleness fix)', () => {
|
||||
// The view was fetched when the wallet was 3 (allowance 1), but a wallet hint was since spent
|
||||
// in another game, so the live wallet is 2: the count must drop to 1 + 2 = 3, not stay at 4.
|
||||
expect(hintsLeft(view(4, 3), 2)).toBe(3);
|
||||
it('reflects the LIVE wallet, not the view (the staleness fix)', () => {
|
||||
// A wallet hint spent in another game since this view was fetched → the live wallet is 2, so the
|
||||
// count is 1 + 2 = 3. The wallet is always passed live (from the profile), never read off the view.
|
||||
expect(hintsLeft(view(1), 2)).toBe(3);
|
||||
});
|
||||
|
||||
it('shows just the wallet when the per-game allowance is used up', () => {
|
||||
// allowance 0 (hints_remaining 3 == snapshot wallet 3); live wallet 3 → 0 + 3.
|
||||
expect(hintsLeft(view(3, 3), 3)).toBe(3);
|
||||
expect(hintsLeft(view(0), 3)).toBe(3); // allowance 0 + wallet 3
|
||||
});
|
||||
|
||||
it('clamps a non-negative allowance and wallet', () => {
|
||||
expect(hintsLeft(view(2, 3), 0)).toBe(0);
|
||||
expect(hintsLeft(view(1, 0), -5)).toBe(1);
|
||||
it('clamps negatives', () => {
|
||||
expect(hintsLeft(view(1), -5)).toBe(1); // a negative wallet clamps to 0
|
||||
expect(hintsLeft(view(-2), 3)).toBe(3); // a negative allowance clamps to 0
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+9
-14
@@ -1,28 +1,23 @@
|
||||
// Hint-count derivation, kept out of the .svelte component so it is unit-testable.
|
||||
//
|
||||
// The badge shows the per-game hint allowance remaining plus the player's global hint
|
||||
// wallet. The server's hints_remaining folds the two together, but the wallet is global —
|
||||
// shared across every game — so caching the combined number per game makes it go stale the
|
||||
// moment a wallet hint is spent in another game. We therefore split it: the per-game
|
||||
// allowance is hints_remaining - wallet_balance (both from the same fetch, so it is stable
|
||||
// and cacheable), and the wallet is read live from the global profile, never the per-game
|
||||
// snapshot.
|
||||
// The badge shows the per-game hint allowance remaining plus the player's global hint wallet. The
|
||||
// view's hints_remaining is the per-game allowance alone; the wallet is global (shared across every
|
||||
// game) and read live from the profile (payments), never the per-game snapshot — so a wallet hint
|
||||
// spent in another game stays reflected here.
|
||||
|
||||
import type { StateView } from './model';
|
||||
|
||||
/**
|
||||
* hintsLeft is the hint count for the badge: the per-game allowance remaining (the view's
|
||||
* hints_remaining minus the wallet snapshot baked into that same view) plus the live global
|
||||
* wallet balance. Passing the live wallet (not view.walletBalance) is what keeps the count
|
||||
* correct when a wallet hint was spent in another game since this view was fetched.
|
||||
* hintsLeft is the hint count for the badge: the per-game allowance remaining (view.hintsRemaining)
|
||||
* plus the live global wallet balance (passed in from the profile, so it stays correct when a wallet
|
||||
* hint was spent in another game since this view was fetched).
|
||||
*/
|
||||
export function hintsLeft(
|
||||
view: Pick<StateView, 'hintsRemaining' | 'walletBalance'> | null,
|
||||
view: Pick<StateView, 'hintsRemaining'> | null,
|
||||
walletBalance: number,
|
||||
): number {
|
||||
if (!view) return 0;
|
||||
const allowance = Math.max(0, view.hintsRemaining - view.walletBalance);
|
||||
return allowance + Math.max(0, walletBalance);
|
||||
return Math.max(0, view.hintsRemaining) + Math.max(0, walletBalance);
|
||||
}
|
||||
|
||||
/** HINT_GATE_MS is the idle time a vs_ai player must be stuck on a turn before a hint unlocks. */
|
||||
|
||||
@@ -435,7 +435,6 @@ export class LocalSource implements GameLoopSource {
|
||||
rack,
|
||||
bagLen: entry.game.bagLength,
|
||||
hintsRemaining: 1,
|
||||
walletBalance: 0,
|
||||
hintUnlockLeftSeconds: this.hintUnlockLeft(entry),
|
||||
locked,
|
||||
};
|
||||
|
||||
@@ -408,10 +408,9 @@ export class MockGateway implements GatewayClient {
|
||||
seat: this.mySeat(g),
|
||||
rack: [...g.rack],
|
||||
bagLen: g.bagLen,
|
||||
// g.hintsRemaining is the per-game allowance; the wallet is the shared profile balance.
|
||||
// hints_remaining folds the two together (as the backend does), walletBalance is the wallet.
|
||||
hintsRemaining: g.hintsRemaining + this.profile.hintBalance,
|
||||
walletBalance: this.profile.hintBalance,
|
||||
// hintsRemaining is the per-game allowance alone; the wallet lives on the profile and the
|
||||
// client adds it (lib/hints).
|
||||
hintsRemaining: g.hintsRemaining,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -530,7 +529,7 @@ export class MockGateway implements GatewayClient {
|
||||
score: valueForLetter(g.view.variant, letter),
|
||||
total: 0,
|
||||
},
|
||||
hintsRemaining: g.hintsRemaining + this.profile.hintBalance,
|
||||
hintsRemaining: g.hintsRemaining,
|
||||
walletBalance: this.profile.hintBalance,
|
||||
};
|
||||
}
|
||||
|
||||
+2
-5
@@ -78,17 +78,14 @@ export interface MoveRecord {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** A seated player's private view of a game. hintsRemaining folds the per-game allowance
|
||||
* together with the global wallet; walletBalance is the wallet alone, so the client can
|
||||
* derive the per-game allowance (hintsRemaining - walletBalance) and keep the wallet live
|
||||
* across games (see lib/hints). */
|
||||
/** A seated player's private view of a game. hintsRemaining is the per-game allowance alone; the
|
||||
* purchasable hint wallet lives on the profile (payments), and the client adds it (see lib/hints). */
|
||||
export interface StateView {
|
||||
game: GameView;
|
||||
seat: number;
|
||||
rack: string[];
|
||||
bagLen: number;
|
||||
hintsRemaining: number;
|
||||
walletBalance: number;
|
||||
/** For a vs_ai game, the seconds until the idle hint unlocks (computed by the source: the SERVER
|
||||
* clock online, the device clock offline) — 0/undefined while open (the human's first move, or a
|
||||
* non-vs_ai game). The client anchors a MONOTONIC countdown (performance.now()) to it on receipt,
|
||||
|
||||
@@ -35,7 +35,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
|
||||
}
|
||||
|
||||
function stateView(id: string): StateView {
|
||||
return { game: gameView(id), seat: 0, rack: ['A', 'B'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 };
|
||||
return { game: gameView(id), seat: 0, rack: ['A', 'B'], bagLen: 50, hintsRemaining: 1 };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
Reference in New Issue
Block a user