feat(stats): show the best move word per game variant #81

Merged
developer merged 17 commits from feature/stats-best-move-word into development 2026-06-17 22:17:27 +00:00
27 changed files with 224 additions and 34 deletions
Showing only changes of commit f9faebfa91 - Show all commits
+1
View File
@@ -74,6 +74,7 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play
Rack: rack, Rack: rack,
BagLen: v.BagLen, BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining, HintsRemaining: v.HintsRemaining,
WalletBalance: v.WalletBalance,
} }
if includeAlphabet { if includeAlphabet {
tab, err := engine.AlphabetTable(v.Game.Variant) tab, err := engine.AlphabetTable(v.Game.Variant)
+2 -1
View File
@@ -1042,7 +1042,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
} }
walletAfter-- 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 // 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), Rack: g.Hand(seat),
BagLen: g.BagLen(), BagLen: g.BagLen(),
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance), HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
WalletBalance: acc.HintBalance,
}, nil }, nil
} }
+7 -1
View File
@@ -194,10 +194,13 @@ type MoveResult struct {
} }
// HintResult is a revealed hint and the requesting player's remaining hint // 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 { type HintResult struct {
Move engine.MoveRecord Move engine.MoveRecord
HintsRemaining int HintsRemaining int
WalletBalance int
} }
// EvalResult previews a tentative play without committing it. Dir is the // EvalResult previews a tentative play without committing it. Dir is the
@@ -220,6 +223,9 @@ type StateView struct {
Rack []string Rack []string
BagLen int BagLen int
HintsRemaining 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. // HistoryMove is one decoded journal row, independent of any dictionary.
+12 -2
View File
@@ -408,6 +408,15 @@ func TestHintPolicy(t *testing.T) {
if _, err := svc.Hint(ctx, g.ID, seats[0]); err != nil { // spends the allowance if _, err := svc.Hint(ctx, g.ID, seats[0]); err != nil { // spends the allowance
t.Fatalf("first hint: %v", err) 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) { if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err) t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
} }
@@ -416,8 +425,9 @@ func TestHintPolicy(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("wallet hint: %v", err) t.Fatalf("wallet hint: %v", err)
} }
if res.HintsRemaining != 1 { // The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone.
t.Errorf("hints remaining = %d, want 1", res.HintsRemaining) 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{ off, err := svc.Create(ctx, game.CreateParams{
+1
View File
@@ -83,6 +83,7 @@ func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT
Rack: s.Rack, Rack: s.Rack,
BagLen: s.BagLen, BagLen: s.BagLen,
HintsRemaining: s.HintsRemaining, HintsRemaining: s.HintsRemaining,
WalletBalance: s.WalletBalance,
Alphabet: alphabet, Alphabet: alphabet,
}) })
} }
+1
View File
@@ -56,6 +56,7 @@ type PlayerState struct {
Rack []int Rack []int
BagLen int BagLen int
HintsRemaining int HintsRemaining int
WalletBalance int
Alphabet []AlphabetLetter Alphabet []AlphabetLetter
} }
+2
View File
@@ -140,6 +140,7 @@ type stateDTO struct {
Rack []int `json:"rack"` Rack []int `json:"rack"`
BagLen int `json:"bag_len"` BagLen int `json:"bag_len"`
HintsRemaining int `json:"hints_remaining"` HintsRemaining int `json:"hints_remaining"`
WalletBalance int `json:"wallet_balance"`
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"` Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
} }
@@ -291,6 +292,7 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
Rack: rack, Rack: rack,
BagLen: v.BagLen, BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining, HintsRemaining: v.HintsRemaining,
WalletBalance: v.WalletBalance,
} }
if includeAlphabet { if includeAlphabet {
tab, err := engine.AlphabetTable(v.Game.Variant) tab, err := engine.AlphabetTable(v.Game.Variant)
+2
View File
@@ -20,6 +20,7 @@ import (
type hintResultDTO struct { type hintResultDTO struct {
Move moveRecordDTO `json:"move"` Move moveRecordDTO `json:"move"`
HintsRemaining int `json:"hints_remaining"` HintsRemaining int `json:"hints_remaining"`
WalletBalance int `json:"wallet_balance"`
} }
// evalResultDTO is an unlimited move preview: legality, score, the words formed // 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{ c.JSON(http.StatusOK, hintResultDTO{
Move: moveRecordDTOFrom(h.Move), Move: moveRecordDTOFrom(h.Move),
HintsRemaining: h.HintsRemaining, HintsRemaining: h.HintsRemaining,
WalletBalance: h.WalletBalance,
}) })
} }
+7 -1
View File
@@ -347,7 +347,13 @@ Key points:
placement and leaves the commit to the player. When the rack has no legal move the 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 service spends **nothing** and returns `ErrNoHintAvailable` — surfaced as the distinct
result code `no_hint_available` (separate from `hint_unavailable`) so the UI can say 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 - **Word-check tool**: unlimited dictionary lookups against the game's pinned
dictionary; each result offers a **complaint** (complainant, game, variant, dictionary; each result offers a **complaint** (complainant, game, variant,
dict_version, word, the disputed result, an optional note) that lands in the admin dict_version, word, the disputed result, an optional note) that lands in the admin
+2
View File
@@ -162,6 +162,7 @@ type StateResp struct {
Rack []int `json:"rack"` Rack []int `json:"rack"`
BagLen int `json:"bag_len"` BagLen int `json:"bag_len"`
HintsRemaining int `json:"hints_remaining"` HintsRemaining int `json:"hints_remaining"`
WalletBalance int `json:"wallet_balance"`
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"` Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
} }
@@ -331,6 +332,7 @@ func (c *Client) ChatPost(ctx context.Context, userID, gameID, body, clientIP st
type HintResultResp struct { type HintResultResp struct {
Move MoveRecordResp `json:"move"` Move MoveRecordResp `json:"move"`
HintsRemaining int `json:"hints_remaining"` HintsRemaining int `json:"hints_remaining"`
WalletBalance int `json:"wallet_balance"`
} }
// EvalResultResp is an unlimited move preview. Dir is the orientation the backend // EvalResultResp is an unlimited move preview. Dir is the orientation the backend
+2
View File
@@ -223,6 +223,7 @@ func toWireState(s backendclient.StateResp) wire.StateView {
Rack: s.Rack, Rack: s.Rack,
BagLen: s.BagLen, BagLen: s.BagLen,
HintsRemaining: s.HintsRemaining, HintsRemaining: s.HintsRemaining,
WalletBalance: s.WalletBalance,
Alphabet: alphabet, Alphabet: alphabet,
} }
} }
@@ -278,6 +279,7 @@ func encodeHintResult(r backendclient.HintResultResp) []byte {
fb.HintResultStart(b) fb.HintResultStart(b)
fb.HintResultAddMove(b, move) fb.HintResultAddMove(b, move)
fb.HintResultAddHintsRemaining(b, int32(r.HintsRemaining)) fb.HintResultAddHintsRemaining(b, int32(r.HintsRemaining))
fb.HintResultAddWalletBalance(b, int32(r.WalletBalance))
b.Finish(fb.HintResultEnd(b)) b.Finish(fb.HintResultEnd(b))
return b.FinishedBytes() return b.FinishedBytes()
} }
+6 -6
View File
@@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
if r.URL.Path != "/api/v1/user/games/g-1/state" { if r.URL.Path != "/api/v1/user/games/g-1/state" {
t.Errorf("unexpected path %q", r.URL.Path) 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() defer cleanup()
@@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
t.Fatalf("handler: %v", err) t.Fatalf("handler: %v", err)
} }
st := fb.GetRootAsStateView(payload, 0) st := fb.GetRootAsStateView(payload, 0)
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 1 { if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 {
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d", st.BagLen(), st.RackLength(), st.HintsRemaining()) 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) game := st.Game(nil)
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 { 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" { if r.URL.Path != "/api/v1/user/games/g-3/hint" {
t.Errorf("unexpected path %q", r.URL.Path) 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() defer cleanup()
@@ -328,8 +328,8 @@ func TestHintRoundTrip(t *testing.T) {
t.Fatalf("handler: %v", err) t.Fatalf("handler: %v", err)
} }
hr := fb.GetRootAsHintResult(payload, 0) hr := fb.GetRootAsHintResult(payload, 0)
if hr.HintsRemaining() != 2 { if hr.HintsRemaining() != 2 || hr.WalletBalance() != 1 {
t.Errorf("hints remaining = %d, want 2", hr.HintsRemaining()) t.Errorf("hint decoded wrong: hints=%d wallet=%d", hr.HintsRemaining(), hr.WalletBalance())
} }
var move fb.MoveRecord var move fb.MoveRecord
hr.Move(&move) hr.Move(&move)
+11 -1
View File
@@ -240,6 +240,12 @@ table StateView {
bag_len:int; bag_len:int;
hints_remaining:int; hints_remaining:int;
alphabet:[AlphabetEntry]; 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). // GameActionRequest carries just a game id (pass / resign / hint / history).
@@ -290,10 +296,14 @@ table ComplaintRequest {
note:string; 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 { table HintResult {
move:MoveRecord; move:MoveRecord;
hints_remaining:int; hints_remaining:int;
wallet_balance:int;
} }
// DraftRequest saves the player's client-side composition for a game: a single // DraftRequest saves the player's client-side composition for a game: a single
+16 -1
View File
@@ -66,8 +66,20 @@ func (rcv *HintResult) MutateHintsRemaining(n int32) bool {
return rcv._tab.MutateInt32Slot(6, n) 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) { func HintResultStart(builder *flatbuffers.Builder) {
builder.StartObject(2) builder.StartObject(3)
} }
func HintResultAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) { func HintResultAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(move), 0) 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) { func HintResultAddHintsRemaining(builder *flatbuffers.Builder, hintsRemaining int32) {
builder.PrependInt32Slot(1, hintsRemaining, 0) builder.PrependInt32Slot(1, hintsRemaining, 0)
} }
func HintResultAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) {
builder.PrependInt32Slot(2, walletBalance, 0)
}
func HintResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func HintResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+16 -1
View File
@@ -144,8 +144,20 @@ func (rcv *StateView) AlphabetLength() int {
return 0 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) { func StateViewStart(builder *flatbuffers.Builder) {
builder.StartObject(6) builder.StartObject(7)
} }
func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) { func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0) 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 { func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4) 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 { func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+2
View File
@@ -89,6 +89,7 @@ type StateView struct {
Rack []int Rack []int
BagLen int BagLen int
HintsRemaining int HintsRemaining int
WalletBalance int
Alphabet []AlphabetEntry Alphabet []AlphabetEntry
} }
@@ -250,6 +251,7 @@ func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT {
fb.StateViewAddRack(b, rack) fb.StateViewAddRack(b, rack)
fb.StateViewAddBagLen(b, int32(s.BagLen)) fb.StateViewAddBagLen(b, int32(s.BagLen))
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining)) fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
if hasAlphabet { if hasAlphabet {
fb.StateViewAddAlphabet(b, alphabet) fb.StateViewAddAlphabet(b, alphabet)
} }
+27 -4
View File
@@ -18,6 +18,7 @@
import { centre, premiumGrid } from '../lib/premiums'; import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey } from '../lib/variants'; import { variantNameKey } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { hintsLeft } from '../lib/hints';
import { shareOrDownloadGcg } from '../lib/share'; import { shareOrDownloadGcg } from '../lib/share';
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache'; import { patchLobbyGame } from '../lib/lobbycache';
@@ -132,6 +133,10 @@
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open')); const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat); const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status === 'finished'); 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 // 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 // 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). // exchange regardless (engine rejects when bag.Len() < rules.RackSize).
@@ -154,6 +159,13 @@
return MOVE_LABELS.has(action) ? t(`move.${action}` as MessageKey) : action; 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() { async function load() {
try { try {
// Ask for the alphabet table only on a per-variant cache miss (the first open of a // 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(() => ''), gateway.draftGet(id).catch(() => ''),
]); ]);
view = st; view = st;
syncWallet(st.walletBalance);
// Seed the unread flag from the authoritative state (the live stream only raises it). // Seed the unread flag from the authoritative state (the live stream only raises it).
seedChatUnread(id, st.game.unreadChat); seedChatUnread(id, st.game.unreadChat);
moves = hist.moves; moves = hist.moves;
@@ -615,7 +628,16 @@
// applyMoveResult renders the actor's own just-committed move from the response — the move, the // 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. // post-move game and the refilled rack — without a follow-up game.state + game.history.
function applyMoveResult(r: MoveResult) { 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 // 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. // moving is already cleared server-side, so reconcile the unread flag from it.
seedChatUnread(id, r.game.unreadChat); seedChatUnread(id, r.game.unreadChat);
@@ -698,7 +720,8 @@
recenter++; recenter++;
} }
if (isCoarse()) zoomed = true; if (isCoarse()) zoomed = true;
view = { ...view, hintsRemaining: h.hintsRemaining }; view = { ...view, hintsRemaining: h.hintsRemaining, walletBalance: h.walletBalance };
syncWallet(h.walletBalance);
recompute(); recompute();
} }
} catch (e) { } catch (e) {
@@ -1107,10 +1130,10 @@
<TapConfirm <TapConfirm
triggerClass="tab" triggerClass="tab"
label={t('game.hint')} label={t('game.hint')}
disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0} disabled={busy || !isMyTurn || !connection.online || hintCount <= 0}
onconfirm={doHint} 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> <span class="lbl">{t('game.hint')}</span>
</TapConfirm> </TapConfirm>
{#if placement.pending.length > 0} {#if placement.pending.length > 0}
+12 -2
View File
@@ -33,8 +33,13 @@ hintsRemaining():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; 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) { static startHintResult(builder:flatbuffers.Builder) {
builder.startObject(2); builder.startObject(3);
} }
static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) { static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) {
@@ -45,15 +50,20 @@ static addHintsRemaining(builder:flatbuffers.Builder, hintsRemaining:number) {
builder.addFieldInt32(1, hintsRemaining, 0); builder.addFieldInt32(1, hintsRemaining, 0);
} }
static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) {
builder.addFieldInt32(2, walletBalance, 0);
}
static endHintResult(builder:flatbuffers.Builder):flatbuffers.Offset { static endHintResult(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; 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.startHintResult(builder);
HintResult.addMove(builder, moveOffset); HintResult.addMove(builder, moveOffset);
HintResult.addHintsRemaining(builder, hintsRemaining); HintResult.addHintsRemaining(builder, hintsRemaining);
HintResult.addWalletBalance(builder, walletBalance);
return HintResult.endHintResult(builder); return HintResult.endHintResult(builder);
} }
} }
+12 -2
View File
@@ -69,8 +69,13 @@ alphabetLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; 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) { static startStateView(builder:flatbuffers.Builder) {
builder.startObject(6); builder.startObject(7);
} }
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) { static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
@@ -121,12 +126,16 @@ static startAlphabetVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4); builder.startVector(4, numElems, 4);
} }
static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) {
builder.addFieldInt32(6, walletBalance, 0);
}
static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset { static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; 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.startStateView(builder);
StateView.addGame(builder, gameOffset); StateView.addGame(builder, gameOffset);
StateView.addSeat(builder, seat); StateView.addSeat(builder, seat);
@@ -134,6 +143,7 @@ static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offse
StateView.addBagLen(builder, bagLen); StateView.addBagLen(builder, bagLen);
StateView.addHintsRemaining(builder, hintsRemaining); StateView.addHintsRemaining(builder, hintsRemaining);
StateView.addAlphabet(builder, alphabetOffset); StateView.addAlphabet(builder, alphabetOffset);
StateView.addWalletBalance(builder, walletBalance);
return StateView.endStateView(builder); return StateView.endStateView(builder);
} }
} }
+2 -1
View File
@@ -383,6 +383,7 @@ function decodeStateViewTable(v: fb.StateView): StateView {
rack, rack,
bagLen: v.bagLen(), bagLen: v.bagLen(),
hintsRemaining: v.hintsRemaining(), hintsRemaining: v.hintsRemaining(),
walletBalance: v.walletBalance(),
}; };
} }
@@ -409,7 +410,7 @@ export function decodeMoveResult(buf: Uint8Array): MoveResult {
export function decodeHintResult(buf: Uint8Array): HintResult { export function decodeHintResult(buf: Uint8Array): HintResult {
const r = fb.HintResult.getRootAsHintResult(new ByteBuffer(buf)); const r = fb.HintResult.getRootAsHintResult(new ByteBuffer(buf));
const m = r.move(); 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 { export function decodeEvalResult(buf: Uint8Array): EvalResult {
+1 -1
View File
@@ -22,7 +22,7 @@ function gameView(id: string): GameView {
} }
function view(id: string, rack: string[] = ['A', 'B']): StateView { 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 { function move(player: number): MoveRecord {
+4 -4
View File
@@ -27,7 +27,7 @@ function move(player: number): MoveRecord {
} }
function cache(moveCount: number, seat = 0, over = false): CachedGame { 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: [] }; return { view, moves: [] };
} }
@@ -37,7 +37,7 @@ function delta(moveCount: number, player: number, bagLen = 47): MoveDelta {
describe('seedInitialState', () => { describe('seedInitialState', () => {
it('wraps an initial view with an empty journal', () => { 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: [] }); 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: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'opp', displayName: 'Opp', 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", () => { 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. // 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()); const res = applyOpponentJoined(cached, joinedState());
expect(res?.view.game.status).toBe('active'); expect(res?.view.game.status).toBe('active');
expect(res?.view.game.seats).toHaveLength(2); expect(res?.view.game.seats).toHaveLength(2);
+32
View File
@@ -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);
});
});
+26
View File
@@ -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<StateView, 'hintsRemaining' | 'walletBalance'> | null,
walletBalance: number,
): number {
if (!view) return 0;
const allowance = Math.max(0, view.hintsRemaining - view.walletBalance);
return allowance + Math.max(0, walletBalance);
}
+11 -4
View File
@@ -295,7 +295,10 @@ export class MockGateway implements GatewayClient {
seat: this.mySeat(g), seat: this.mySeat(g),
rack: [...g.rack], rack: [...g.rack],
bagLen: g.bagLen, 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<HintResult> { async hint(gameId: string): Promise<HintResult> {
const g = this.game(gameId); const g = this.game(gameId);
if (g.hintsRemaining <= 0) throw new GatewayError('hint_unavailable'); if (g.hintsRemaining <= 0 && this.profile.hintBalance <= 0) throw new GatewayError('hint_unavailable');
g.hintsRemaining -= 1; // 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'; const letter = g.rack.find((l) => l !== '?') ?? 'A';
return { return {
move: { move: {
@@ -411,7 +417,8 @@ export class MockGateway implements GatewayClient {
score: valueForLetter(g.view.variant, letter), score: valueForLetter(g.view.variant, letter),
total: 0, total: 0,
}, },
hintsRemaining: g.hintsRemaining, hintsRemaining: g.hintsRemaining + this.profile.hintBalance,
walletBalance: this.profile.hintBalance,
}; };
} }
+6 -1
View File
@@ -67,13 +67,17 @@ export interface MoveRecord {
total: number; 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 { export interface StateView {
game: GameView; game: GameView;
seat: number; seat: number;
rack: string[]; rack: string[];
bagLen: number; bagLen: number;
hintsRemaining: number; hintsRemaining: number;
walletBalance: number;
} }
export interface MoveResult { export interface MoveResult {
@@ -87,6 +91,7 @@ export interface MoveResult {
export interface HintResult { export interface HintResult {
move: MoveRecord; move: MoveRecord;
hintsRemaining: number; hintsRemaining: number;
walletBalance: number;
} }
export interface EvalResult { export interface EvalResult {
+1 -1
View File
@@ -34,7 +34,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
} }
function stateView(id: string): StateView { 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(() => { beforeEach(() => {