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
+5 -5
View File
@@ -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
+1 -11
View File
@@ -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);
}
-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(() => {