diff --git a/backend/internal/game/eventwire.go b/backend/internal/game/eventwire.go index 6b65685..c9d2b31 100644 --- a/backend/internal/game/eventwire.go +++ b/backend/internal/game/eventwire.go @@ -74,7 +74,6 @@ 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/helpers_test.go b/backend/internal/game/helpers_test.go index 0cca088..c19b994 100644 --- a/backend/internal/game/helpers_test.go +++ b/backend/internal/game/helpers_test.go @@ -55,16 +55,16 @@ func TestPayloadExchangeRoundTrip(t *testing.T) { } func TestHintsRemaining(t *testing.T) { - cases := []struct{ allowance, used, wallet, want int }{ - {1, 0, 3, 4}, - {1, 1, 3, 3}, - {1, 2, 3, 3}, // used past allowance clamps to 0 - {0, 0, 5, 5}, - {2, 1, 0, 1}, + cases := []struct{ allowance, used, want int }{ + {1, 0, 1}, + {1, 1, 0}, + {1, 2, 0}, // used past allowance clamps to 0 + {3, 1, 2}, + {0, 0, 0}, } for _, c := range cases { - if got := hintsRemaining(c.allowance, c.used, c.wallet); got != c.want { - t.Errorf("hintsRemaining(%d,%d,%d) = %d, want %d", c.allowance, c.used, c.wallet, got, c.want) + if got := hintsRemaining(c.allowance, c.used); got != c.want { + t.Errorf("hintsRemaining(%d,%d) = %d, want %d", c.allowance, c.used, got, c.want) } } } diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 5c47d0c..b6c90f8 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -1207,7 +1207,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint return HintResult{}, err } used++ - return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil + return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used), WalletBalance: walletAfter}, nil } // Candidates returns the to-move player's legal plays for a seated player on @@ -1290,11 +1290,9 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) Seat: seat, Rack: g.Hand(seat), BagLen: g.BagLen(), - // The hint wallet moved to payments (svc.hintWallet); the deprecated accounts.hint_balance - // is no longer read, so the wire wallet is 0 and HintsRemaining is the per-seat allowance. - // The client adds the profile's payments hint balance on top (lib/hints.hintsLeft). - HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, 0), - WalletBalance: 0, + // HintsRemaining is the per-seat allowance only; the purchasable wallet lives on the profile + // (payments) and the client adds it (lib/hints.hintsLeft). + HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed), // vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn). HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()), }, nil @@ -1775,10 +1773,10 @@ func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, e return svc.registry.DictBytes(variant, version) } -// hintsRemaining is a player's remaining hint budget: the unspent per-game -// allowance plus the profile wallet. -func hintsRemaining(allowance, used, wallet int) int { - return max(0, allowance-used) + wallet +// hintsRemaining is the unspent per-game hint allowance. The purchasable wallet is separate, +// carried on the profile (payments), and the client adds it (lib/hints.hintsLeft). +func hintsRemaining(allowance, used int) int { + return max(0, allowance-used) } // allowedTimeout reports whether d is one of the offered move clocks. diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index 26b173e..bdade84 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -211,10 +211,9 @@ type MoveResult struct { BagLen int } -// HintResult is a revealed hint and the requesting player's remaining hint -// 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). +// HintResult is a revealed hint with the per-seat allowance remaining (HintsRemaining) and the +// purchasable hint wallet after spending one (WalletBalance, from payments). The client adopts +// WalletBalance into the profile so the badge stays live across games (lib/hints). type HintResult struct { Move engine.MoveRecord HintsRemaining int @@ -241,9 +240,6 @@ 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 // HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left // until the idle hint unlocks (the robot's last move plus the idle window, from the server clock); // 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go index 423805b..18fe3f9 100644 --- a/backend/internal/inttest/game_test.go +++ b/backend/internal/inttest/game_test.go @@ -424,13 +424,13 @@ func TestHintPolicy(t *testing.T) { 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. + // per-game allowance left (HintsRemaining is the allowance alone; the wallet lives on the profile). 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 st.HintsRemaining != 0 { + t.Errorf("after allowance hint: hints=%d, want 0", st.HintsRemaining) } if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) { t.Fatalf("second hint = %v, want ErrNoHintsLeft", err) @@ -444,9 +444,10 @@ func TestHintPolicy(t *testing.T) { if err != nil { t.Fatalf("wallet hint: %v", err) } - // 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) + // The allowance stays exhausted (HintsRemaining is the allowance alone, so 0); the wallet dropped + // 2->1 and WalletBalance carries it (the client adopts it into the profile). + if res.HintsRemaining != 0 || res.WalletBalance != 1 { + t.Errorf("wallet hint: hints=%d wallet=%d, want 0/1", res.HintsRemaining, res.WalletBalance) } // game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total // that feeds the player's lifetime hint statistics, not just the allowance. diff --git a/backend/internal/notify/encode.go b/backend/internal/notify/encode.go index 5c03192..d2b1914 100644 --- a/backend/internal/notify/encode.go +++ b/backend/internal/notify/encode.go @@ -83,7 +83,6 @@ 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 18c9e27..abb4c95 100644 --- a/backend/internal/notify/payload.go +++ b/backend/internal/notify/payload.go @@ -56,7 +56,6 @@ 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 7549539..634d583 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -177,7 +177,6 @@ type stateDTO struct { Rack []int `json:"rack"` BagLen int `json:"bag_len"` HintsRemaining int `json:"hints_remaining"` - WalletBalance int `json:"wallet_balance"` // HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human // game / first move / not your turn). The client anchors a monotonic countdown to it. HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"` @@ -334,7 +333,6 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) { Rack: rack, BagLen: v.BagLen, HintsRemaining: v.HintsRemaining, - WalletBalance: v.WalletBalance, HintUnlockLeftSeconds: v.HintUnlockLeftSeconds, } if includeAlphabet { diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index b07759c..e31b533 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -197,7 +197,6 @@ type StateResp struct { Rack []int `json:"rack"` BagLen int `json:"bag_len"` HintsRemaining int `json:"hints_remaining"` - WalletBalance int `json:"wallet_balance"` HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"` Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"` } diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 46e3aa3..77a9c83 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -405,7 +405,6 @@ func toWireState(s backendclient.StateResp) wire.StateView { Rack: s.Rack, BagLen: s.BagLen, HintsRemaining: s.HintsRemaining, - WalletBalance: s.WalletBalance, HintUnlockLeftSeconds: s.HintUnlockLeftSeconds, Alphabet: alphabet, } diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go index 32a2ca2..267b60e 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":4,"wallet_balance":3,"hint_unlock_left_seconds":1200}`)) + _, _ = 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,"hint_unlock_left_seconds":1200}`)) }) 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() != 4 || st.WalletBalance() != 3 || st.HintUnlockLeftSeconds() != 1200 { - t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance(), st.HintUnlockLeftSeconds()) + if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.HintUnlockLeftSeconds() != 1200 { + t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.HintUnlockLeftSeconds()) } game := st.Game(nil) if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 6189134..5530869 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -330,12 +330,11 @@ 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; + // wallet_balance is deprecated (D31): the purchasable hint wallet moved to the profile (payments), + // and hints_remaining now carries the per-game allowance alone. The field is tombstoned rather than + // deleted so the vtable slots of the fields after it stay stable across a rolling deploy (an old + // SPA served before the deploy must keep reading a new gateway correctly). No accessor is generated. + wallet_balance:int (deprecated); // hint_unlock_left_seconds is, for a vs_ai game, how many seconds until the idle hint unlocks // (the robot's last move plus the idle window, computed from the SERVER clock online / the device // clock offline, capped at the window and floored at 0); 0 for a human's first move (no robot move @@ -393,10 +392,9 @@ table ComplaintRequest { note:string; } -// 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). +// HintResult is the top-ranked move plus hints_remaining (the per-game allowance alone) and +// wallet_balance (the purchasable hint wallet after spending, from payments). The client adopts +// wallet_balance into the profile so the badge stays live across games (see lib/hints). table HintResult { move:MoveRecord; hints_remaining:int; diff --git a/pkg/fbs/scrabblefb/StateView.go b/pkg/fbs/scrabblefb/StateView.go index 2957e44..9cf7ae9 100644 --- a/pkg/fbs/scrabblefb/StateView.go +++ b/pkg/fbs/scrabblefb/StateView.go @@ -144,18 +144,6 @@ 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 (rcv *StateView) HintUnlockLeftSeconds() int32 { o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) if o != 0 { @@ -195,9 +183,6 @@ 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 StateViewAddHintUnlockLeftSeconds(builder *flatbuffers.Builder, hintUnlockLeftSeconds int32) { builder.PrependInt32Slot(7, hintUnlockLeftSeconds, 0) } diff --git a/pkg/wire/build.go b/pkg/wire/build.go index 51f1081..1412ee7 100644 --- a/pkg/wire/build.go +++ b/pkg/wire/build.go @@ -92,7 +92,6 @@ type StateView struct { Rack []int BagLen int HintsRemaining int - WalletBalance int HintUnlockLeftSeconds int Alphabet []AlphabetEntry } @@ -256,7 +255,6 @@ 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)) fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds)) if hasAlphabet { fb.StateViewAddAlphabet(b, alphabet) diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index f6ab404..bf16d99 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -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 diff --git a/ui/src/gen/fbs/scrabblefb/state-view.ts b/ui/src/gen/fbs/scrabblefb/state-view.ts index 6d546fd..91eb112 100644 --- a/ui/src/gen/fbs/scrabblefb/state-view.ts +++ b/ui/src/gen/fbs/scrabblefb/state-view.ts @@ -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); } diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index f1724f9..f99fde5 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -575,7 +575,6 @@ function decodeStateViewTable(v: fb.StateView): StateView { rack, bagLen: v.bagLen(), hintsRemaining: v.hintsRemaining(), - walletBalance: v.walletBalance(), hintUnlockLeftSeconds: v.hintUnlockLeftSeconds(), }; } diff --git a/ui/src/lib/gamecache.test.ts b/ui/src/lib/gamecache.test.ts index 6b8e4fd..5cc4788 100644 --- a/ui/src/lib/gamecache.test.ts +++ b/ui/src/lib/gamecache.test.ts @@ -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 { diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index 67fcaf0..7c6be9a 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -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); diff --git a/ui/src/lib/hints.test.ts b/ui/src/lib/hints.test.ts index cb81f4b..9f83d66 100644 --- a/ui/src/lib/hints.test.ts +++ b/ui/src/lib/hints.test.ts @@ -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 }); }); diff --git a/ui/src/lib/hints.ts b/ui/src/lib/hints.ts index 9b200c7..07726bd 100644 --- a/ui/src/lib/hints.ts +++ b/ui/src/lib/hints.ts @@ -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 | null, + 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); + 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. */ diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index 5f890ed..a10e93d 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -435,7 +435,6 @@ export class LocalSource implements GameLoopSource { rack, bagLen: entry.game.bagLength, hintsRemaining: 1, - walletBalance: 0, hintUnlockLeftSeconds: this.hintUnlockLeft(entry), locked, }; diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 0e6363c..b8c1393 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -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, }; } diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 96ee9f7..d309e42 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -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, diff --git a/ui/src/lib/preload.test.ts b/ui/src/lib/preload.test.ts index 9a53d18..6f2ab7e 100644 --- a/ui/src/lib/preload.test.ts +++ b/ui/src/lib/preload.test.ts @@ -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(() => {