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
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.
91 lines
3.0 KiB
Go
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
|
|
}
|