From f9faebfa9199588d3afc1f5c48f9aee645070ab9 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 17 Jun 2026 16:50:16 +0200 Subject: [PATCH] fix(hint): stop the hint count going stale across games MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/internal/game/eventwire.go | 1 + backend/internal/game/service.go | 3 +- backend/internal/game/types.go | 8 ++++- backend/internal/inttest/game_test.go | 14 +++++++-- backend/internal/notify/encode.go | 1 + backend/internal/notify/payload.go | 1 + backend/internal/server/dto.go | 2 ++ backend/internal/server/handlers_game.go | 2 ++ docs/ARCHITECTURE.md | 8 ++++- gateway/internal/backendclient/api.go | 2 ++ gateway/internal/transcode/encode.go | 2 ++ gateway/internal/transcode/transcode_test.go | 12 ++++---- pkg/fbs/scrabble.fbs | 12 +++++++- pkg/fbs/scrabblefb/HintResult.go | 17 ++++++++++- pkg/fbs/scrabblefb/StateView.go | 17 ++++++++++- pkg/wire/build.go | 2 ++ ui/src/game/Game.svelte | 31 ++++++++++++++++--- ui/src/gen/fbs/scrabblefb/hint-result.ts | 14 +++++++-- ui/src/gen/fbs/scrabblefb/state-view.ts | 14 +++++++-- ui/src/lib/codec.ts | 3 +- ui/src/lib/gamecache.test.ts | 2 +- ui/src/lib/gamedelta.test.ts | 8 ++--- ui/src/lib/hints.test.ts | 32 ++++++++++++++++++++ ui/src/lib/hints.ts | 26 ++++++++++++++++ ui/src/lib/mock/client.ts | 15 ++++++--- ui/src/lib/model.ts | 7 ++++- ui/src/lib/preload.test.ts | 2 +- 27 files changed, 224 insertions(+), 34 deletions(-) create mode 100644 ui/src/lib/hints.test.ts create mode 100644 ui/src/lib/hints.ts diff --git a/backend/internal/game/eventwire.go b/backend/internal/game/eventwire.go index c9d2b31..6b65685 100644 --- a/backend/internal/game/eventwire.go +++ b/backend/internal/game/eventwire.go @@ -74,6 +74,7 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play Rack: rack, BagLen: v.BagLen, HintsRemaining: v.HintsRemaining, + WalletBalance: v.WalletBalance, } if includeAlphabet { tab, err := engine.AlphabetTable(v.Game.Variant) diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 359cf35..95360d2 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -1042,7 +1042,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint } walletAfter-- } - return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter)}, nil + return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil } // Candidates returns the to-move player's legal plays for a seated player on @@ -1130,6 +1130,7 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) Rack: g.Hand(seat), BagLen: g.BagLen(), HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance), + WalletBalance: acc.HintBalance, }, nil } diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index dbf8c2c..6c941e5 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -194,10 +194,13 @@ type MoveResult struct { } // HintResult is a revealed hint and the requesting player's remaining hint -// budget (per-seat allowance plus profile wallet) after spending one. +// budget (per-seat allowance plus profile wallet) after spending one. WalletBalance is +// the global wallet alone, so the client can keep its live wallet authoritative and +// re-derive the per-game allowance (HintsRemaining - WalletBalance). type HintResult struct { Move engine.MoveRecord HintsRemaining int + WalletBalance int } // EvalResult previews a tentative play without committing it. Dir is the @@ -220,6 +223,9 @@ type StateView struct { Rack []string BagLen int HintsRemaining int + // WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds + // it in with the per-game allowance), so the client keeps the wallet live across games. + WalletBalance int } // HistoryMove is one decoded journal row, independent of any dictionary. diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go index 6b85c70..b9f221d 100644 --- a/backend/internal/inttest/game_test.go +++ b/backend/internal/inttest/game_test.go @@ -408,6 +408,15 @@ func TestHintPolicy(t *testing.T) { if _, err := svc.Hint(ctx, g.ID, seats[0]); err != nil { // spends the allowance t.Fatalf("first hint: %v", err) } + // The allowance is spent before the wallet: with an empty wallet, the state now reports no + // hints left and a zero wallet, so the per-game allowance (HintsRemaining-WalletBalance) is 0. + st, err := svc.GameState(ctx, g.ID, seats[0]) + if err != nil { + t.Fatalf("state: %v", err) + } + if st.HintsRemaining != 0 || st.WalletBalance != 0 { + t.Errorf("after allowance hint: hints=%d wallet=%d, want 0/0", st.HintsRemaining, st.WalletBalance) + } if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) { t.Fatalf("second hint = %v, want ErrNoHintsLeft", err) } @@ -416,8 +425,9 @@ func TestHintPolicy(t *testing.T) { if err != nil { t.Fatalf("wallet hint: %v", err) } - if res.HintsRemaining != 1 { - t.Errorf("hints remaining = %d, want 1", res.HintsRemaining) + // The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone. + if res.HintsRemaining != 1 || res.WalletBalance != 1 { + t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance) } off, err := svc.Create(ctx, game.CreateParams{ diff --git a/backend/internal/notify/encode.go b/backend/internal/notify/encode.go index d2b1914..5c03192 100644 --- a/backend/internal/notify/encode.go +++ b/backend/internal/notify/encode.go @@ -83,6 +83,7 @@ func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT Rack: s.Rack, BagLen: s.BagLen, HintsRemaining: s.HintsRemaining, + WalletBalance: s.WalletBalance, Alphabet: alphabet, }) } diff --git a/backend/internal/notify/payload.go b/backend/internal/notify/payload.go index abb4c95..18c9e27 100644 --- a/backend/internal/notify/payload.go +++ b/backend/internal/notify/payload.go @@ -56,6 +56,7 @@ type PlayerState struct { Rack []int BagLen int HintsRemaining int + WalletBalance int Alphabet []AlphabetLetter } diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 22222e7..f02828b 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -140,6 +140,7 @@ type stateDTO struct { Rack []int `json:"rack"` BagLen int `json:"bag_len"` HintsRemaining int `json:"hints_remaining"` + WalletBalance int `json:"wallet_balance"` Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"` } @@ -291,6 +292,7 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) { Rack: rack, BagLen: v.BagLen, HintsRemaining: v.HintsRemaining, + WalletBalance: v.WalletBalance, } if includeAlphabet { tab, err := engine.AlphabetTable(v.Game.Variant) diff --git a/backend/internal/server/handlers_game.go b/backend/internal/server/handlers_game.go index f6440de..be5e539 100644 --- a/backend/internal/server/handlers_game.go +++ b/backend/internal/server/handlers_game.go @@ -20,6 +20,7 @@ import ( type hintResultDTO struct { Move moveRecordDTO `json:"move"` HintsRemaining int `json:"hints_remaining"` + WalletBalance int `json:"wallet_balance"` } // evalResultDTO is an unlimited move preview: legality, score, the words formed @@ -185,6 +186,7 @@ func (s *Server) handleHint(c *gin.Context) { c.JSON(http.StatusOK, hintResultDTO{ Move: moveRecordDTOFrom(h.Move), HintsRemaining: h.HintsRemaining, + WalletBalance: h.WalletBalance, }) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 02e1c1e..37537a5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -347,7 +347,13 @@ Key points: placement and leaves the commit to the player. When the rack has no legal move the service spends **nothing** and returns `ErrNoHintAvailable` — surfaced as the distinct result code `no_hint_available` (separate from `hint_unavailable`) so the UI can say - "no options" rather than "no hints left". + "no options" rather than "no hints left". The hint count shown to the player is the + per-game allowance remaining **plus** the global wallet; because the wallet is global, + `game.state`/`game.hint` carry it as a separate `wallet_balance` field beside the combined + `hints_remaining`, so the client derives the per-game allowance (`hints_remaining - + wallet_balance`, which it may cache per game) and reads the wallet **live** from the + profile — otherwise a wallet hint spent in one game would leave a stale, too-high count + cached on every other game. - **Word-check tool**: unlimited dictionary lookups against the game's pinned dictionary; each result offers a **complaint** (complainant, game, variant, dict_version, word, the disputed result, an optional note) that lands in the admin diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 7717f20..4bff004 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -162,6 +162,7 @@ type StateResp struct { Rack []int `json:"rack"` BagLen int `json:"bag_len"` HintsRemaining int `json:"hints_remaining"` + WalletBalance int `json:"wallet_balance"` Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"` } @@ -331,6 +332,7 @@ func (c *Client) ChatPost(ctx context.Context, userID, gameID, body, clientIP st type HintResultResp struct { Move MoveRecordResp `json:"move"` HintsRemaining int `json:"hints_remaining"` + WalletBalance int `json:"wallet_balance"` } // EvalResultResp is an unlimited move preview. Dir is the orientation the backend diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 3ab7f12..7b84e57 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -223,6 +223,7 @@ func toWireState(s backendclient.StateResp) wire.StateView { Rack: s.Rack, BagLen: s.BagLen, HintsRemaining: s.HintsRemaining, + WalletBalance: s.WalletBalance, Alphabet: alphabet, } } @@ -278,6 +279,7 @@ func encodeHintResult(r backendclient.HintResultResp) []byte { fb.HintResultStart(b) fb.HintResultAddMove(b, move) fb.HintResultAddHintsRemaining(b, int32(r.HintsRemaining)) + fb.HintResultAddWalletBalance(b, int32(r.WalletBalance)) b.Finish(fb.HintResultEnd(b)) return b.FinishedBytes() } diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go index 9b9e20d..cdd2482 100644 --- a/gateway/internal/transcode/transcode_test.go +++ b/gateway/internal/transcode/transcode_test.go @@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) { if r.URL.Path != "/api/v1/user/games/g-1/state" { t.Errorf("unexpected path %q", r.URL.Path) } - _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":1}`)) + _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3}`)) }) defer cleanup() @@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) { t.Fatalf("handler: %v", err) } st := fb.GetRootAsStateView(payload, 0) - if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 1 { - t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d", st.BagLen(), st.RackLength(), st.HintsRemaining()) + if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 { + t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance()) } game := st.Game(nil) if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 { @@ -317,7 +317,7 @@ func TestHintRoundTrip(t *testing.T) { if r.URL.Path != "/api/v1/user/games/g-3/hint" { t.Errorf("unexpected path %q", r.URL.Path) } - _, _ = w.Write([]byte(`{"move":{"player":0,"action":"play","words":["CAT"],"score":9},"hints_remaining":2}`)) + _, _ = w.Write([]byte(`{"move":{"player":0,"action":"play","words":["CAT"],"score":9},"hints_remaining":2,"wallet_balance":1}`)) }) defer cleanup() @@ -328,8 +328,8 @@ func TestHintRoundTrip(t *testing.T) { t.Fatalf("handler: %v", err) } hr := fb.GetRootAsHintResult(payload, 0) - if hr.HintsRemaining() != 2 { - t.Errorf("hints remaining = %d, want 2", hr.HintsRemaining()) + if hr.HintsRemaining() != 2 || hr.WalletBalance() != 1 { + t.Errorf("hint decoded wrong: hints=%d wallet=%d", hr.HintsRemaining(), hr.WalletBalance()) } var move fb.MoveRecord hr.Move(&move) diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index c6b8cdf..f40f645 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -240,6 +240,12 @@ table StateView { bag_len:int; hints_remaining:int; alphabet:[AlphabetEntry]; + // wallet_balance is the requesting player's global hint-wallet balance, sent apart from + // hints_remaining (which folds the wallet in with the per-game allowance) so the client can + // separate the two: the per-game allowance remaining is hints_remaining - wallet_balance, and + // the wallet is a single global figure the client keeps live across games (added trailing — + // backward-compatible). + wallet_balance:int; } // GameActionRequest carries just a game id (pass / resign / hint / history). @@ -290,10 +296,14 @@ table ComplaintRequest { note:string; } -// HintResult is the top-ranked move plus the remaining hint budget. +// HintResult is the top-ranked move plus the remaining hint budget. wallet_balance is the +// global hint-wallet balance after spending (see StateView.wallet_balance), so the client +// refreshes its live wallet and re-derives the per-game allowance (added trailing — +// backward-compatible). table HintResult { move:MoveRecord; hints_remaining:int; + wallet_balance:int; } // DraftRequest saves the player's client-side composition for a game: a single diff --git a/pkg/fbs/scrabblefb/HintResult.go b/pkg/fbs/scrabblefb/HintResult.go index bad7b75..8d7ba4a 100644 --- a/pkg/fbs/scrabblefb/HintResult.go +++ b/pkg/fbs/scrabblefb/HintResult.go @@ -66,8 +66,20 @@ func (rcv *HintResult) MutateHintsRemaining(n int32) bool { return rcv._tab.MutateInt32Slot(6, n) } +func (rcv *HintResult) WalletBalance() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *HintResult) MutateWalletBalance(n int32) bool { + return rcv._tab.MutateInt32Slot(8, n) +} + func HintResultStart(builder *flatbuffers.Builder) { - builder.StartObject(2) + builder.StartObject(3) } func HintResultAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(move), 0) @@ -75,6 +87,9 @@ func HintResultAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) func HintResultAddHintsRemaining(builder *flatbuffers.Builder, hintsRemaining int32) { builder.PrependInt32Slot(1, hintsRemaining, 0) } +func HintResultAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) { + builder.PrependInt32Slot(2, walletBalance, 0) +} func HintResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/StateView.go b/pkg/fbs/scrabblefb/StateView.go index 2c0138f..9ae7566 100644 --- a/pkg/fbs/scrabblefb/StateView.go +++ b/pkg/fbs/scrabblefb/StateView.go @@ -144,8 +144,20 @@ func (rcv *StateView) AlphabetLength() int { return 0 } +func (rcv *StateView) WalletBalance() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *StateView) MutateWalletBalance(n int32) bool { + return rcv._tab.MutateInt32Slot(16, n) +} + func StateViewStart(builder *flatbuffers.Builder) { - builder.StartObject(6) + builder.StartObject(7) } func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0) @@ -171,6 +183,9 @@ func StateViewAddAlphabet(builder *flatbuffers.Builder, alphabet flatbuffers.UOf func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } +func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) { + builder.PrependInt32Slot(6, walletBalance, 0) +} func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/wire/build.go b/pkg/wire/build.go index 1a47b55..56dba8e 100644 --- a/pkg/wire/build.go +++ b/pkg/wire/build.go @@ -89,6 +89,7 @@ type StateView struct { Rack []int BagLen int HintsRemaining int + WalletBalance int Alphabet []AlphabetEntry } @@ -250,6 +251,7 @@ func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT { fb.StateViewAddRack(b, rack) fb.StateViewAddBagLen(b, int32(s.BagLen)) fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining)) + fb.StateViewAddWalletBalance(b, int32(s.WalletBalance)) if hasAlphabet { fb.StateViewAddAlphabet(b, alphabet) } diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index b098ea3..b8b1232 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -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 @@ - 🛟{#if (view?.hintsRemaining ?? 0) > 0}{view?.hintsRemaining}{/if} + 🛟{#if hintCount > 0}{hintCount}{/if} {t('game.hint')} {#if placement.pending.length > 0} diff --git a/ui/src/gen/fbs/scrabblefb/hint-result.ts b/ui/src/gen/fbs/scrabblefb/hint-result.ts index 511412f..7a6a5f9 100644 --- a/ui/src/gen/fbs/scrabblefb/hint-result.ts +++ b/ui/src/gen/fbs/scrabblefb/hint-result.ts @@ -33,8 +33,13 @@ hintsRemaining():number { return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; } +walletBalance():number { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + static startHintResult(builder:flatbuffers.Builder) { - builder.startObject(2); + builder.startObject(3); } static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) { @@ -45,15 +50,20 @@ static addHintsRemaining(builder:flatbuffers.Builder, hintsRemaining:number) { builder.addFieldInt32(1, hintsRemaining, 0); } +static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) { + builder.addFieldInt32(2, walletBalance, 0); +} + static endHintResult(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createHintResult(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset, hintsRemaining:number):flatbuffers.Offset { +static createHintResult(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset, hintsRemaining:number, walletBalance:number):flatbuffers.Offset { HintResult.startHintResult(builder); HintResult.addMove(builder, moveOffset); HintResult.addHintsRemaining(builder, hintsRemaining); + HintResult.addWalletBalance(builder, walletBalance); return HintResult.endHintResult(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/state-view.ts b/ui/src/gen/fbs/scrabblefb/state-view.ts index 690e12a..f7a1ac8 100644 --- a/ui/src/gen/fbs/scrabblefb/state-view.ts +++ b/ui/src/gen/fbs/scrabblefb/state-view.ts @@ -69,8 +69,13 @@ 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; +} + static startStateView(builder:flatbuffers.Builder) { - builder.startObject(6); + builder.startObject(7); } static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) { @@ -121,12 +126,16 @@ 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 endStateView(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset):flatbuffers.Offset { +static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset, walletBalance:number):flatbuffers.Offset { StateView.startStateView(builder); StateView.addGame(builder, gameOffset); StateView.addSeat(builder, seat); @@ -134,6 +143,7 @@ static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offse StateView.addBagLen(builder, bagLen); StateView.addHintsRemaining(builder, hintsRemaining); StateView.addAlphabet(builder, alphabetOffset); + StateView.addWalletBalance(builder, walletBalance); return StateView.endStateView(builder); } } diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 8a9fd04..069ebd2 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -383,6 +383,7 @@ function decodeStateViewTable(v: fb.StateView): StateView { rack, bagLen: v.bagLen(), hintsRemaining: v.hintsRemaining(), + walletBalance: v.walletBalance(), }; } @@ -409,7 +410,7 @@ export function decodeMoveResult(buf: Uint8Array): MoveResult { export function decodeHintResult(buf: Uint8Array): HintResult { const r = fb.HintResult.getRootAsHintResult(new ByteBuffer(buf)); const m = r.move(); - return { move: m ? decodeMove(m) : emptyMove(), hintsRemaining: r.hintsRemaining() }; + return { move: m ? decodeMove(m) : emptyMove(), hintsRemaining: r.hintsRemaining(), walletBalance: r.walletBalance() }; } export function decodeEvalResult(buf: Uint8Array): EvalResult { diff --git a/ui/src/lib/gamecache.test.ts b/ui/src/lib/gamecache.test.ts index d3b0715..ef3344f 100644 --- a/ui/src/lib/gamecache.test.ts +++ b/ui/src/lib/gamecache.test.ts @@ -22,7 +22,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 }; + return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1, walletBalance: 0 }; } function move(player: number): MoveRecord { diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index 6881605..2f25759 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -27,7 +27,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 }; + const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 }; return { view, moves: [] }; } @@ -37,7 +37,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 }; + const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2, walletBalance: 0 }; expect(seedInitialState(view)).toEqual({ view, moves: [] }); }); }); @@ -152,12 +152,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 }; + return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0, walletBalance: 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 }, moves: [move(0)] }; + 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 res = applyOpponentJoined(cached, joinedState()); expect(res?.view.game.status).toBe('active'); expect(res?.view.game.seats).toHaveLength(2); diff --git a/ui/src/lib/hints.test.ts b/ui/src/lib/hints.test.ts new file mode 100644 index 0000000..66937e4 --- /dev/null +++ b/ui/src/lib/hints.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { hintsLeft } from './hints'; + +// view carries only the two fields hintsLeft reads. +const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance }); + +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('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('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); + }); + + it('clamps a non-negative allowance and wallet', () => { + expect(hintsLeft(view(2, 3), 0)).toBe(0); + expect(hintsLeft(view(1, 0), -5)).toBe(1); + }); +}); diff --git a/ui/src/lib/hints.ts b/ui/src/lib/hints.ts new file mode 100644 index 0000000..8862d4a --- /dev/null +++ b/ui/src/lib/hints.ts @@ -0,0 +1,26 @@ +// 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. + +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. + */ +export function hintsLeft( + view: Pick | null, + walletBalance: number, +): number { + if (!view) return 0; + const allowance = Math.max(0, view.hintsRemaining - view.walletBalance); + return allowance + Math.max(0, walletBalance); +} diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 41c5a6d..78ecca8 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -295,7 +295,10 @@ export class MockGateway implements GatewayClient { seat: this.mySeat(g), rack: [...g.rack], bagLen: g.bagLen, - hintsRemaining: g.hintsRemaining, + // 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, }; } @@ -395,8 +398,11 @@ export class MockGateway implements GatewayClient { async hint(gameId: string): Promise { const g = this.game(gameId); - if (g.hintsRemaining <= 0) throw new GatewayError('hint_unavailable'); - g.hintsRemaining -= 1; + if (g.hintsRemaining <= 0 && this.profile.hintBalance <= 0) throw new GatewayError('hint_unavailable'); + // Spend the per-game allowance first, then the shared wallet — mirroring the backend, so a + // wallet hint in one game lowers the count shown in every other game (the bug this fixes). + if (g.hintsRemaining > 0) g.hintsRemaining -= 1; + else this.profile.hintBalance -= 1; const letter = g.rack.find((l) => l !== '?') ?? 'A'; return { move: { @@ -411,7 +417,8 @@ export class MockGateway implements GatewayClient { score: valueForLetter(g.view.variant, letter), total: 0, }, - hintsRemaining: g.hintsRemaining, + hintsRemaining: g.hintsRemaining + this.profile.hintBalance, + walletBalance: this.profile.hintBalance, }; } diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 69793d6..50d60d9 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -67,13 +67,17 @@ export interface MoveRecord { total: number; } -/** A seated player's private view of a game. */ +/** 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). */ export interface StateView { game: GameView; seat: number; rack: string[]; bagLen: number; hintsRemaining: number; + walletBalance: number; } export interface MoveResult { @@ -87,6 +91,7 @@ export interface MoveResult { export interface HintResult { move: MoveRecord; hintsRemaining: number; + walletBalance: number; } export interface EvalResult { diff --git a/ui/src/lib/preload.test.ts b/ui/src/lib/preload.test.ts index fbcf9e2..4aa53cc 100644 --- a/ui/src/lib/preload.test.ts +++ b/ui/src/lib/preload.test.ts @@ -34,7 +34,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 }; + return { game: gameView(id), seat: 0, rack: ['A', 'B'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 }; } beforeEach(() => {