Files
scrabble-game/backend/internal/game/eventwire.go
T
Ilia Denisov f9faebfa91
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
fix(hint): stop the hint count going stale across games
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.
2026-06-17 16:50:16 +02:00

91 lines
3.0 KiB
Go

package game
import (
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/notify"
)
// The mappers below project the game domain into the wire-agnostic notify.* input
// structs the enriched live events carry. They keep the wire schema out of the
// game package: notify owns the FlatBuffers encoding, this file only resolves the
// values (seat display names, last-activity sort key) into its input shapes.
// gameSummary projects a game.Game into the notify.GameSummary embedded in enriched
// events. names is the seat-indexed display-name slice from seatNames; LastActivityUnix
// mirrors the gateway view (the current turn's start while active, the finish time once
// finished).
func gameSummary(g Game, names []string) notify.GameSummary {
seats := make([]notify.SeatStanding, 0, len(g.Seats))
for _, s := range g.Seats {
name := ""
if s.Seat >= 0 && s.Seat < len(names) {
name = names[s.Seat]
}
// An open game's still-empty opponent seat carries no account: send an empty id
// (not the nil-UUID string) so the client renders it as "searching for opponent".
accountID := ""
if s.AccountID != uuid.Nil {
accountID = s.AccountID.String()
}
seats = append(seats, notify.SeatStanding{
Seat: s.Seat,
AccountID: accountID,
DisplayName: name,
Score: s.Score,
HintsUsed: s.HintsUsed,
IsWinner: s.IsWinner,
})
}
last := g.TurnStartedAt
if g.FinishedAt != nil {
last = *g.FinishedAt
}
return notify.GameSummary{
ID: g.ID.String(),
Variant: g.Variant.String(),
DictVersion: g.DictVersion,
Status: g.Status,
Players: g.Players,
ToMove: g.ToMove,
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI,
MoveCount: g.MoveCount,
EndReason: g.EndReason,
Seats: seats,
LastActivityUnix: last.Unix(),
}
}
// playerState projects a StateView into the notify.PlayerState carried by the
// match_found / game_started events. The rack is re-encoded to wire alphabet indices;
// the variant alphabet display table is embedded when includeAlphabet is set (an
// initial view whose recipient may not have cached the variant yet).
func playerState(v StateView, names []string, includeAlphabet bool) (notify.PlayerState, error) {
rack, err := engine.EncodeRack(v.Game.Variant, v.Rack)
if err != nil {
return notify.PlayerState{}, err
}
ps := notify.PlayerState{
Game: gameSummary(v.Game, names),
Seat: v.Seat,
Rack: rack,
BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining,
WalletBalance: v.WalletBalance,
}
if includeAlphabet {
tab, err := engine.AlphabetTable(v.Game.Variant)
if err != nil {
return notify.PlayerState{}, err
}
ps.Alphabet = make([]notify.AlphabetLetter, len(tab))
for i, e := range tab {
ps.Alphabet[i] = notify.AlphabetLetter{Index: int(e.Index), Letter: e.Letter, Value: e.Value}
}
}
return ps, nil
}