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

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:
Ilia Denisov
2026-07-10 06:26:49 +02:00
parent d2d6955cbf
commit 82648a4398
25 changed files with 76 additions and 130 deletions
-1
View File
@@ -575,7 +575,6 @@ function decodeStateViewTable(v: fb.StateView): StateView {
rack,
bagLen: v.bagLen(),
hintsRemaining: v.hintsRemaining(),
walletBalance: v.walletBalance(),
hintUnlockLeftSeconds: v.hintUnlockLeftSeconds(),
};
}
+1 -1
View File
@@ -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 {
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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. */
-1
View File
@@ -435,7 +435,6 @@ export class LocalSource implements GameLoopSource {
rack,
bagLen: entry.game.bagLength,
hintsRemaining: 1,
walletBalance: 0,
hintUnlockLeftSeconds: this.hintUnlockLeft(entry),
locked,
};
+4 -5
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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(() => {