fix(hint): stop the hint count going stale across games
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m20s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m20s
The in-game hint badge re-fetched on entry and, for a game where it was the player's turn, showed a too-high count that "reset" (e.g. back to 11) — the wallet hint spent in another game was not reflected. Root cause: the server sends one hints_remaining = per-game allowance + global wallet, and the client cached that combined number per game. The wallet is global, so spending a wallet hint in one game left every other game's cached count stale (a my-turn game holds the stalest value: the opponent-moved delta preserves the old number, whereas a game you just moved in re-cached a fresh one). The backend allowance-then-wallet spend order was already correct. Fix: split the two. StateView/HintResult gain a trailing wallet_balance field (the global wallet alone); the client derives the per-game allowance as hints_remaining - wallet_balance (stable, cacheable) and reads the wallet live from the profile, refreshing it from every state/hint response. The badge is allowance + live wallet, so a wallet hint anywhere updates every game at once. - wire: scrabble.fbs StateView/HintResult + pkg/wire.BuildStateView (the single encoder for both the gateway transcode and the backend's event StateView), gateway encode + resp structs, regen. - backend: game StateView/HintResult + service (GameState/Hint) + eventwire + notify PlayerState/encode + server DTOs. - ui: lib/hints.ts (pure hintsLeft), Game.svelte (badge + syncWallet on load/hint, carry wallet_balance through applyMoveResult), codec/model, mock. - docs: ARCHITECTURE §Hint. Tests: hints.ts unit (incl. the staleness case), TestHintPolicy extended (wallet_balance + allowance-first), gateway state/hint round-trips.
This commit is contained in:
+27
-4
@@ -18,6 +18,7 @@
|
||||
import { centre, premiumGrid } from '../lib/premiums';
|
||||
import { variantNameKey } from '../lib/variants';
|
||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||
import { hintsLeft } from '../lib/hints';
|
||||
import { shareOrDownloadGcg } from '../lib/share';
|
||||
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
||||
import { patchLobbyGame } from '../lib/lobbycache';
|
||||
@@ -132,6 +133,10 @@
|
||||
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
|
||||
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
|
||||
const gameOver = $derived(!!view && view.game.status === 'finished');
|
||||
// The hint badge: this game's allowance remaining plus the LIVE global wallet. Reading the
|
||||
// wallet from the profile (not the per-game view snapshot) keeps it correct after a wallet
|
||||
// hint was spent in another game (see lib/hints).
|
||||
const hintCount = $derived(hintsLeft(view, app.profile?.hintBalance ?? 0));
|
||||
// RACK_SIZE mirrors the engine's rules.RackSize (7 for every current variant). The exchange
|
||||
// gate is only a UX guard: the backend stays the source of truth and rejects an under-supplied
|
||||
// exchange regardless (engine rejects when bag.Len() < rules.RackSize).
|
||||
@@ -154,6 +159,13 @@
|
||||
return MOVE_LABELS.has(action) ? t(`move.${action}` as MessageKey) : action;
|
||||
}
|
||||
|
||||
// syncWallet adopts the server's authoritative hint-wallet balance into the global profile.
|
||||
// The wallet is global, so keeping it live here (rather than per-game) is what stops the hint
|
||||
// badge from going stale when a wallet hint was spent in another game.
|
||||
function syncWallet(walletBalance: number) {
|
||||
if (app.profile) app.profile.hintBalance = walletBalance;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
// Ask for the alphabet table only on a per-variant cache miss (the first open of a
|
||||
@@ -167,6 +179,7 @@
|
||||
gateway.draftGet(id).catch(() => ''),
|
||||
]);
|
||||
view = st;
|
||||
syncWallet(st.walletBalance);
|
||||
// Seed the unread flag from the authoritative state (the live stream only raises it).
|
||||
seedChatUnread(id, st.game.unreadChat);
|
||||
moves = hist.moves;
|
||||
@@ -615,7 +628,16 @@
|
||||
// applyMoveResult renders the actor's own just-committed move from the response — the move, the
|
||||
// post-move game and the refilled rack — without a follow-up game.state + game.history.
|
||||
function applyMoveResult(r: MoveResult) {
|
||||
view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 };
|
||||
view = {
|
||||
game: r.game,
|
||||
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).
|
||||
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.
|
||||
seedChatUnread(id, r.game.unreadChat);
|
||||
@@ -698,7 +720,8 @@
|
||||
recenter++;
|
||||
}
|
||||
if (isCoarse()) zoomed = true;
|
||||
view = { ...view, hintsRemaining: h.hintsRemaining };
|
||||
view = { ...view, hintsRemaining: h.hintsRemaining, walletBalance: h.walletBalance };
|
||||
syncWallet(h.walletBalance);
|
||||
recompute();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1107,10 +1130,10 @@
|
||||
<TapConfirm
|
||||
triggerClass="tab"
|
||||
label={t('game.hint')}
|
||||
disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0}
|
||||
disabled={busy || !isMyTurn || !connection.online || hintCount <= 0}
|
||||
onconfirm={doHint}
|
||||
>
|
||||
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
|
||||
<span class="sq">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
|
||||
<span class="lbl">{t('game.hint')}</span>
|
||||
</TapConfirm>
|
||||
{#if placement.pending.length > 0}
|
||||
|
||||
Reference in New Issue
Block a user