fix(hint): stop the hint count going stale across games
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m20s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m20s
The in-game hint badge re-fetched on entry and, for a game where it was the player's turn, showed a too-high count that "reset" (e.g. back to 11) — the wallet hint spent in another game was not reflected. Root cause: the server sends one hints_remaining = per-game allowance + global wallet, and the client cached that combined number per game. The wallet is global, so spending a wallet hint in one game left every other game's cached count stale (a my-turn game holds the stalest value: the opponent-moved delta preserves the old number, whereas a game you just moved in re-cached a fresh one). The backend allowance-then-wallet spend order was already correct. Fix: split the two. StateView/HintResult gain a trailing wallet_balance field (the global wallet alone); the client derives the per-game allowance as hints_remaining - wallet_balance (stable, cacheable) and reads the wallet live from the profile, refreshing it from every state/hint response. The badge is allowance + live wallet, so a wallet hint anywhere updates every game at once. - wire: scrabble.fbs StateView/HintResult + pkg/wire.BuildStateView (the single encoder for both the gateway transcode and the backend's event StateView), gateway encode + resp structs, regen. - backend: game StateView/HintResult + service (GameState/Hint) + eventwire + notify PlayerState/encode + server DTOs. - ui: lib/hints.ts (pure hintsLeft), Game.svelte (badge + syncWallet on load/hint, carry wallet_balance through applyMoveResult), codec/model, mock. - docs: ARCHITECTURE §Hint. Tests: hints.ts unit (incl. the staleness case), TestHintPolicy extended (wallet_balance + allowance-first), gateway state/hint round-trips.
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user