From 7fc1301b31f997171c9119143fd2217046925e3c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 7 Jul 2026 00:42:43 +0200 Subject: [PATCH 1/2] feat(hint): unify the vs_ai idle hint online + offline (server-enforced, monotonic) Online vs_ai hints were broken: #207 made the vs_ai hint button always-enabled and wallet-free (assuming all vs_ai = the offline idle-gate), but the backend still served online vs_ai from the allowance/wallet, so over-clicking hit ErrNoHintsLeft -> a generic error toast. Now online vs_ai uses the SAME idle-gate model as offline. Backend: Hint() for a vs_ai game skips the allowance/wallet and increments no hints_used (owner: vs_ai counts toward no hint statistic), and is idle-gated from the SERVER clock -- it returns ErrHintLocked (code hint_locked) until the robot's last move + 30 min, else serves the top move. GameState/StateView expose hint_unlock_left_seconds (server-computed seconds remaining; 0 for a human game / first move / not-your-turn). Pure helper hintUnlockLeftSeconds unit-tested. Wire: StateView gains hint_unlock_left_seconds (FlatBuffers, additive); pkg/wire + gateway transcode carry it (round-trip test). Client: the gate counts down from a MONOTONIC clock (performance.now()) anchored to the source's seconds-left when it lands (on load from the view; to the full window when the robot moves), so a client clock change cannot skew it and a relaunch re-reads a fresh value. The vs_ai hint button (online + offline) shows the lock + toast; doHint handles the hint_locked backstop by re-syncing. Replaces #207's absolute hintUnlockAtMs on the wire/model/delta (the offline record keeps the absolute for persistence; the view exposes seconds-left). Docs FUNCTIONAL(+_ru)/ARCHITECTURE updated. Verified: go build/vet + game/server/transcode tests (+ new hintUnlockLeftSeconds); ui check 0 / unit 490 / e2e 198 (one pre-existing webkit offline flake) / app entry 114.2/115. --- backend/internal/game/helpers_test.go | 23 +++++++++ backend/internal/game/service.go | 51 ++++++++++++++++++-- backend/internal/game/types.go | 9 ++++ backend/internal/server/dto.go | 30 +++++++----- backend/internal/server/handlers.go | 3 ++ docs/ARCHITECTURE.md | 15 +++--- docs/FUNCTIONAL.md | 10 ++-- docs/FUNCTIONAL_ru.md | 9 ++-- gateway/internal/backendclient/api.go | 15 +++--- gateway/internal/transcode/encode.go | 15 +++--- gateway/internal/transcode/transcode_test.go | 6 +-- pkg/fbs/scrabble.fbs | 7 +++ pkg/fbs/scrabblefb/StateView.go | 17 ++++++- pkg/wire/build.go | 16 +++--- ui/src/game/Game.svelte | 49 ++++++++++++++----- ui/src/gen/fbs/scrabblefb/state-view.ts | 14 +++++- ui/src/lib/codec.ts | 1 + ui/src/lib/gamedelta.ts | 12 +---- ui/src/lib/hints.test.ts | 24 ++++----- ui/src/lib/hints.ts | 26 ++++------ ui/src/lib/localgame/source.ts | 18 ++++--- ui/src/lib/model.ts | 11 +++-- 22 files changed, 254 insertions(+), 127 deletions(-) diff --git a/backend/internal/game/helpers_test.go b/backend/internal/game/helpers_test.go index 3c6d0bd..0cca088 100644 --- a/backend/internal/game/helpers_test.go +++ b/backend/internal/game/helpers_test.go @@ -69,6 +69,29 @@ func TestHintsRemaining(t *testing.T) { } } +func TestHintUnlockLeftSeconds(t *testing.T) { + now := time.Now() + // A gated vs_ai game on the caller's turn, the robot having moved 10 min ago (turn started then). + gated := Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)} + cases := []struct { + name string + g Game + seat int + want int + }{ + {"non-vs_ai is open", Game{VsAI: false, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}, 0, 0}, + {"not the caller's turn is open", gated, 1, 0}, + {"human first move (no robot move) is open", Game{VsAI: true, ToMove: 0, MoveCount: 0, TurnStartedAt: now}, 0, 0}, + {"robot moved 10 min ago leaves 20 min", gated, 0, 20 * 60}, + {"robot moved past the window is open", Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-40 * time.Minute)}, 0, 0}, + } + for _, c := range cases { + if got := hintUnlockLeftSeconds(c.g, c.seat, now); got != c.want { + t.Errorf("%s: hintUnlockLeftSeconds = %d, want %d", c.name, got, c.want) + } + } +} + func TestAllowedTimeout(t *testing.T) { if !allowedTimeout(24 * time.Hour) { t.Error("24h must be allowed") diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 162adaf..2026429 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -1058,10 +1058,31 @@ func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Varia return svc.store.MarkChangesApplied(ctx, variant.String(), version) } -// Hint reveals the top-scoring legal play for the requesting player on their -// turn, spending one hint from their per-game allowance and then their profile -// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as -// appropriate. +// hintIdleWindow is how long a vs_ai player must be stuck on a turn (since the robot's last move) +// before the idle hint unlocks. Mirrors the offline client (lib/hints HINT_GATE_MS = 30 min). +const hintIdleWindow = 30 * time.Minute + +// hintUnlockLeftSeconds is the seconds until g's vs_ai idle hint unlocks for seat, measured from now: +// the robot's last move (the current turn's start, on the human's turn) plus the window, floored at +// 0 and ceiled to whole seconds. It is 0 for a non-vs_ai game, when it is not seat's turn, or on the +// human's first move (MoveCount 0, no robot move yet) — the gate is an anti-frustration aid, not a +// first-move tax. The client anchors a monotonic countdown to it, so a client clock cannot skew it. +func hintUnlockLeftSeconds(g Game, seat int, now time.Time) int { + if !g.VsAI || g.ToMove != seat || g.MoveCount < 1 { + return 0 + } + left := g.TurnStartedAt.Add(hintIdleWindow).Sub(now) + if left <= 0 { + return 0 + } + return int((left + time.Second - 1) / time.Second) // ceil to whole seconds (no math import) +} + +// Hint reveals the top-scoring legal play for the requesting player on their turn. For a human game +// it spends one hint from the per-game allowance then the profile wallet (ErrHintsDisabled / +// ErrNoHintsLeft / ErrNoHintAvailable). For a vs_ai game the hint is unlimited and wallet-free but +// idle-gated from the server clock (ErrHintLocked until the window elapses) and counts toward no +// hint statistic. func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) { pre, err := svc.store.GetGame(ctx, gameID) if err != nil { @@ -1080,6 +1101,26 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint if !pre.HintsAllowed { return HintResult{}, ErrHintsDisabled } + if pre.VsAI { + // vs_ai: unlimited and wallet-free, but idle-gated from the server clock — and counted toward + // no hint statistic. Enforce the gate (the client normally pre-gates from the view's + // HintUnlockLeftSeconds; this is the authoritative backstop), then serve the top move without + // touching the allowance, the wallet or hints_used. + if hintUnlockLeftSeconds(pre, seat, svc.clock()) > 0 { + return HintResult{}, ErrHintLocked + } + unlock := svc.locks.lock(gameID) + defer unlock() + g, err := svc.liveGame(ctx, pre) + if err != nil { + return HintResult{}, err + } + move, ok := g.HintView() + if !ok { + return HintResult{}, ErrNoHintAvailable + } + return HintResult{Move: move}, nil + } acc, err := svc.accounts.GetByID(ctx, accountID) if err != nil { return HintResult{}, err @@ -1208,6 +1249,8 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) BagLen: g.BagLen(), HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance), WalletBalance: acc.HintBalance, + // vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn). + HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()), }, nil } diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index d39f231..26b173e 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -65,6 +65,10 @@ var ( ErrNoHintsLeft = errors.New("game: no hints remaining") // ErrNoHintAvailable is returned when the player has no legal play to reveal. ErrNoHintAvailable = errors.New("game: no legal move to suggest") + // ErrHintLocked is returned when a vs_ai game's idle hint is still gated (the player has not yet + // been stuck the idle window since the robot's last move). The client normally pre-gates from + // StateView.HintUnlockLeftSeconds, so this is the authoritative server-clock backstop. + ErrHintLocked = errors.New("game: hint is not available yet") // ErrGameLimitReached is returned when an account already holds MaxActiveQuickGames // simultaneous quick games and tries to create another game (quick or by invitation). ErrGameLimitReached = errors.New("game: simultaneous game limit reached") @@ -240,6 +244,11 @@ type StateView struct { // WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds // it in with the per-game allowance), so the client keeps the wallet live across games. WalletBalance int + // HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left + // until the idle hint unlocks (the robot's last move plus the idle window, from the server clock); + // 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is + // unlimited and wallet-free; this idle gate replaces the allowance/wallet for it. + HintUnlockLeftSeconds int } // HistoryMove is one decoded journal row, independent of any dictionary. diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 08620d8..15a2734 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -163,13 +163,16 @@ type alphabetEntryDTO struct { // stateDTO is a player's view of a game. Rack carries wire alphabet indices (a // blank is engine.BlankIndex). Alphabet is present only when the request asked for it. type stateDTO struct { - Game gameDTO `json:"game"` - Seat int `json:"seat"` - Rack []int `json:"rack"` - BagLen int `json:"bag_len"` - HintsRemaining int `json:"hints_remaining"` - WalletBalance int `json:"wallet_balance"` - Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"` + Game gameDTO `json:"game"` + Seat int `json:"seat"` + Rack []int `json:"rack"` + BagLen int `json:"bag_len"` + HintsRemaining int `json:"hints_remaining"` + WalletBalance int `json:"wallet_balance"` + // HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human + // game / first move / not your turn). The client anchors a monotonic countdown to it. + HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"` + Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"` } // matchDTO reports whether the caller has been paired into a game. @@ -315,12 +318,13 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) { return stateDTO{}, err } dto := stateDTO{ - Game: gameDTOFromGame(v.Game), - Seat: v.Seat, - Rack: rack, - BagLen: v.BagLen, - HintsRemaining: v.HintsRemaining, - WalletBalance: v.WalletBalance, + Game: gameDTOFromGame(v.Game), + Seat: v.Seat, + Rack: rack, + BagLen: v.BagLen, + HintsRemaining: v.HintsRemaining, + WalletBalance: v.WalletBalance, + HintUnlockLeftSeconds: v.HintUnlockLeftSeconds, } if includeAlphabet { tab, err := engine.AlphabetTable(v.Game.Variant) diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 0909f67..6fc7d9c 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -239,6 +239,9 @@ func statusForError(err error) (int, string) { return http.StatusConflict, "no_hint_available" case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft): return http.StatusConflict, "hint_unavailable" + case errors.Is(err, game.ErrHintLocked): + // A vs_ai idle hint is still gated (the server-clock backstop); the client shows the lock. + return http.StatusConflict, "hint_locked" case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver): return http.StatusUnprocessableEntity, "illegal_play" case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken): diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e7f2cb9..5fbd441 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1252,13 +1252,14 @@ an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lo device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry creates one through the in-browser engine — the same game screen then drives it, the robot replying locally; online-only affordances (the Stats tab, the friend/random options in New Game) are disabled -or hidden. A local `vs_ai` hint is unlimited and wallet-free but idle-gated (unlocked ~30 min into a -stuck turn). The gate is a **persisted wall-clock unlock instant** (`hintUnlockAtMs` on the record + -the game view, stamped from the robot's reply, carried on the move delta), so the wait survives a -relaunch — but it is **sanitised on read** (capped at `now + window`, `lib/hints` + `source.ts`) so a -device clock the player sets **back** cannot push the unlock away and freeze the gate; a clock set -**forward** merely opens the hint early, harmless for a solo game. An online `vs_ai` game will gate the -same way but from the server's clock (a follow-up). To have data ready before the switch, the **Profile advertises the current dictionary +or hidden. A `vs_ai` hint (online and offline alike) is unlimited and wallet-free but idle-gated +(unlocked ~30 min into a stuck turn), and counts toward no hint statistic. The **source** reports the +**seconds left** (`hint_unlock_left_seconds` on the game view) — computed by the **backend from the +server clock** online (which also enforces the gate, returning `hint_locked` for an early request), +and by the offline source from the device clock (persisted, capped at the window). The **client +anchors a MONOTONIC countdown** (`performance.now()`, `lib/hints`) to that seconds-left when it lands +(on load, and to the full window when the robot moves), so a client clock change cannot skew it, and +a relaunch re-reads a fresh value so the wait is not forgotten. To have data ready before the switch, the **Profile advertises the current dictionary version per variant** (`dict_versions`, filled from the registry on the existing cold-start profile request — no extra round-trip), and an eligible installed PWA (standalone web + confirmed email) **background-preloads** those dictionaries diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index cae0761..b8e60ea 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -220,11 +220,11 @@ personal hint wallet once the per-game allowance is spent. Against the robot anti-frustration aid: one unlocks only after the player has been **stuck ~30 minutes on a turn** (timed from the robot's last move; the very first move, before the robot has played, is exempt). While gated the hint button carries a small **🔒 lock** and a -tap shows how long remains; the lock lifts live at the mark. The wait **persists across -leaving and reopening the app**, so a stuck turn is not forgotten. It stays robust to a -device clock change: the remaining is capped at the window, so a clock set back cannot -freeze it, and a clock set forward merely opens the hint early — harmless in a solo game. -The game ends when the +tap shows how long remains; the lock lifts live at the mark. The same gate applies **online and +offline**: the countdown runs on a **steady in-app timer**, not the device clock, so changing the +clock cannot skew it, and a relaunch re-reads the remaining time so a stuck turn is not forgotten. +Online the **server enforces** it from its own clock (which the player cannot touch); a vs_ai hint +counts toward no hint statistic. The game ends when the bag empties and a player clears their rack, after 6 consecutive scoreless turns, by resignation, or by the per-game move timeout (5 minutes to 24 hours, default 24 hours): a missed turn auto-resigns, except while the player is inside their diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index fd53471..b91b12e 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -227,10 +227,11 @@ e-mail) либо ввод фразы. Активные игры форфейтя анти-фрустрация: подсказка открывается, только когда игрок **застрял на ходу ~30 минут** (отсчёт от последнего хода робота; самый первый ход, до хода робота, исключён). Пока гейт закрыт, кнопка подсказки несёт маленький **🔒 замок**, а тап показывает, сколько осталось; -замок снимается вживую в нужный момент. Отсчёт **переживает выход и повторный вход в -приложение**, так что застрявший ход не забывается. И он устойчив к переводу часов: остаток -ограничен окном, поэтому перевод назад не заморозит гейт, а перевод вперёд просто откроет -подсказку раньше — безвредно в соло-игре. Партия +замок снимается вживую в нужный момент. Один и тот же гейт работает **онлайн и офлайн**: отсчёт +идёт по **ровному внутриигровому таймеру**, а не по системным часам, поэтому их перевод его не +собьёт, а повторный вход перечитывает остаток — застрявший ход не забывается. Онлайн гейт +**принуждает сервер** по своим часам (их игрок не тронет); vs_ai-подсказка не учитывается ни в +одной статистике подсказок. Партия завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа): пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 0eca8d4..33091bd 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -181,13 +181,14 @@ type AlphabetEntryJSON struct { // StateResp is a player's view of a game. Rack carries wire alphabet indices; // Alphabet is present only when the request asked for it. type StateResp struct { - Game GameResp `json:"game"` - Seat int `json:"seat"` - Rack []int `json:"rack"` - BagLen int `json:"bag_len"` - HintsRemaining int `json:"hints_remaining"` - WalletBalance int `json:"wallet_balance"` - Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"` + Game GameResp `json:"game"` + Seat int `json:"seat"` + Rack []int `json:"rack"` + BagLen int `json:"bag_len"` + HintsRemaining int `json:"hints_remaining"` + WalletBalance int `json:"wallet_balance"` + HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"` + Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"` } // MatchResp reports an auto-match outcome. diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 7ced0cc..4c7af5c 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -299,13 +299,14 @@ func toWireState(s backendclient.StateResp) wire.StateView { alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value} } return wire.StateView{ - Game: toWireGame(s.Game), - Seat: s.Seat, - Rack: s.Rack, - BagLen: s.BagLen, - HintsRemaining: s.HintsRemaining, - WalletBalance: s.WalletBalance, - Alphabet: alphabet, + Game: toWireGame(s.Game), + Seat: s.Seat, + Rack: s.Rack, + BagLen: s.BagLen, + HintsRemaining: s.HintsRemaining, + WalletBalance: s.WalletBalance, + HintUnlockLeftSeconds: s.HintUnlockLeftSeconds, + Alphabet: alphabet, } } diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go index a2545af..9429423 100644 --- a/gateway/internal/transcode/transcode_test.go +++ b/gateway/internal/transcode/transcode_test.go @@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) { if r.URL.Path != "/api/v1/user/games/g-1/state" { t.Errorf("unexpected path %q", r.URL.Path) } - _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3}`)) + _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3,"hint_unlock_left_seconds":1200}`)) }) defer cleanup() @@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) { t.Fatalf("handler: %v", err) } st := fb.GetRootAsStateView(payload, 0) - if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 { - t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance()) + if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 || st.HintUnlockLeftSeconds() != 1200 { + t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance(), st.HintUnlockLeftSeconds()) } game := st.Game(nil) if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 5a9e01b..3cc877e 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -316,6 +316,13 @@ table StateView { // the wallet is a single global figure the client keeps live across games (added trailing — // backward-compatible). wallet_balance:int; + // hint_unlock_left_seconds is, for a vs_ai game, how many seconds until the idle hint unlocks + // (the robot's last move plus the idle window, computed from the SERVER clock online / the device + // clock offline, capped at the window and floored at 0); 0 for a human's first move (no robot move + // yet) or a non-vs_ai game. The client anchors a MONOTONIC countdown (performance.now()) to it, so a + // client clock change cannot skew it — see lib/hints. Seconds granularity is enough; sent trailing + // (additive, backward-compatible). + hint_unlock_left_seconds:int; } // GameActionRequest carries just a game id (pass / resign / hint / history). diff --git a/pkg/fbs/scrabblefb/StateView.go b/pkg/fbs/scrabblefb/StateView.go index 9ae7566..2957e44 100644 --- a/pkg/fbs/scrabblefb/StateView.go +++ b/pkg/fbs/scrabblefb/StateView.go @@ -156,8 +156,20 @@ func (rcv *StateView) MutateWalletBalance(n int32) bool { return rcv._tab.MutateInt32Slot(16, n) } +func (rcv *StateView) HintUnlockLeftSeconds() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *StateView) MutateHintUnlockLeftSeconds(n int32) bool { + return rcv._tab.MutateInt32Slot(18, n) +} + func StateViewStart(builder *flatbuffers.Builder) { - builder.StartObject(7) + builder.StartObject(8) } func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0) @@ -186,6 +198,9 @@ func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) fl func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) { builder.PrependInt32Slot(6, walletBalance, 0) } +func StateViewAddHintUnlockLeftSeconds(builder *flatbuffers.Builder, hintUnlockLeftSeconds int32) { + builder.PrependInt32Slot(7, hintUnlockLeftSeconds, 0) +} func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/wire/build.go b/pkg/wire/build.go index ca58f76..51f1081 100644 --- a/pkg/wire/build.go +++ b/pkg/wire/build.go @@ -87,13 +87,14 @@ type AlphabetEntry struct { // (wire alphabet indices), bag size and hint budget. Alphabet is set only when the // recipient may not have cached the variant's display table yet. type StateView struct { - Game GameView - Seat int - Rack []int - BagLen int - HintsRemaining int - WalletBalance int - Alphabet []AlphabetEntry + Game GameView + Seat int + Rack []int + BagLen int + HintsRemaining int + WalletBalance int + HintUnlockLeftSeconds int + Alphabet []AlphabetEntry } // AccountRef is a referenced account with its display name resolved. @@ -256,6 +257,7 @@ func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT { fb.StateViewAddBagLen(b, int32(s.BagLen)) fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining)) fb.StateViewAddWalletBalance(b, int32(s.WalletBalance)) + fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds)) if hasAlphabet { fb.StateViewAddAlphabet(b, alphabet) } diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 2025966..c8c9334 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -23,7 +23,7 @@ import { centre, premiumGrid } from '../lib/premiums'; import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; - import { hintsLeft, hintGateRemainingMs, hintLockMinutes } from '../lib/hints'; + import { hintsLeft, hintGateRemainingMs, hintLockMinutes, HINT_GATE_MS } from '../lib/hints'; import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share'; import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; @@ -168,19 +168,30 @@ // hint was spent in another game (see lib/hints). const hintCount = $derived(hintsLeft(view, app.profile?.hintBalance ?? 0)); // A vs_ai game's hint is unlimited and wallet-free, but idle-gated (an anti-frustration aid: it - // unlocks only after the player has been stuck ~30 min on a turn). The unlock is a persisted - // wall-clock instant carried on the game view (`hintUnlockAtMs`, stamped by the source off the - // robot's reply and kept fresh by the move delta), so the wait survives leaving and reopening the - // app. It is sanitised on read (source + lib/hints cap it at now + the window) so a device clock - // set BACK cannot freeze the gate; a clock set forward just opens the hint early, harmless for a - // solo game. `now` ticks so the 🔒 lifts live at the mark. 0 = open (the human's first move). - let now = $state(Date.now()); - const hintUnlockAt = $derived(view?.game.vsAi ? (view.hintUnlockAtMs ?? 0) : 0); - const hintRemaining = $derived(hintGateRemainingMs(hintUnlockAt, now)); + // unlocks only after the player has been stuck ~30 min on a turn). The source (the SERVER clock + // online, the device clock offline) tells us the SECONDS LEFT (view.hintUnlockLeftSeconds); we + // anchor a MONOTONIC countdown (performance.now()) to it, so a client clock change cannot skew it. + // A fresh value arrives on load (armHintGate below); when the robot moves the wait is the full + // window. `monoNow` ticks so the 🔒 lifts live at the mark. + let hintGateStart = $state(null); // performance.now() at the last anchor; null = open + let gateLeftMs = $state(0); + let monoNow = $state(performance.now()); + const hintRemaining = $derived(hintGateRemainingMs(hintGateStart, gateLeftMs, monoNow)); const hintGated = $derived(hintRemaining > 0); + // Anchor the countdown to a freshly received seconds-left (or open the gate). Called on every state + // load from the view, and on the robot's move to the full window (the robot just moved). + function armHintGate(leftSeconds: number): void { + if (leftSeconds > 0) { + hintGateStart = performance.now(); + gateLeftMs = leftSeconds * 1000; + } else { + hintGateStart = null; + gateLeftMs = 0; + } + } $effect(() => { if (!view?.game.vsAi) return; - const iv = setInterval(() => (now = Date.now()), 10_000); + const iv = setInterval(() => (monoNow = performance.now()), 10_000); return () => clearInterval(iv); }); // RACK_SIZE mirrors the engine's rules.RackSize (7 for every current variant). The exchange @@ -225,6 +236,8 @@ source.draftGet(id).catch(() => ''), ]); view = st; + // Anchor the vs_ai idle-hint countdown to the freshly fetched seconds-left (0 = open / non-vs_ai). + armHintGate(st.game.vsAi ? (st.hintUnlockLeftSeconds ?? 0) : 0); syncWallet(st.walletBalance); // Seed the unread flag from the authoritative state (the live stream only raises it). seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages); @@ -345,7 +358,11 @@ // While composing, reload so a draft overlapping the new move is reconciled; otherwise apply // the move as a delta with no fetch. if (placement.pending.length > 0) void load(); - else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen, hintUnlockAtMs: e.hintUnlockAtMs })); + else { + applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen })); + // The robot just moved (vs_ai) → the human's turn begins with the full idle window. + if (view?.game.vsAi) armHintGate(HINT_GATE_MS / 1000); + } } else if (e.kind === 'your_turn' && e.gameId === id) { // The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch // only if we missed the move (our cached count trails the event's). @@ -873,7 +890,7 @@ // vs_ai: the idle gate replaces the wallet. While it is still closed, a tap only explains when the // hint unlocks (no wallet is ever spent) — matching the 🔒 badge. Measured fresh at tap time. if (view?.game.vsAi) { - const remaining = hintGateRemainingMs(hintUnlockAt, Date.now()); + const remaining = hintGateRemainingMs(hintGateStart, gateLeftMs, performance.now()); if (remaining > 0) { showToast(t('game.hintLockedIn', { n: hintLockMinutes(remaining) }), 'info'); return; @@ -915,6 +932,12 @@ // The backend does not spend a hint when there is no move. if (e instanceof GatewayError && e.code === 'no_hint_available') { showToast(t('game.noHintOptions'), 'info'); + } else if (e instanceof GatewayError && e.code === 'hint_locked') { + // The server-clock backstop refused a vs_ai hint the client thought was open (a rare client + // clock desync): re-sync the countdown from a fresh fetch and explain the gate. + await load(); + const left = hintGateRemainingMs(hintGateStart, gateLeftMs, performance.now()); + showToast(t('game.hintLockedIn', { n: hintLockMinutes(left || 60_000) }), 'info'); } else { handleError(e); } diff --git a/ui/src/gen/fbs/scrabblefb/state-view.ts b/ui/src/gen/fbs/scrabblefb/state-view.ts index f7a1ac8..6d546fd 100644 --- a/ui/src/gen/fbs/scrabblefb/state-view.ts +++ b/ui/src/gen/fbs/scrabblefb/state-view.ts @@ -74,8 +74,13 @@ walletBalance():number { return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; } +hintUnlockLeftSeconds():number { + const offset = this.bb!.__offset(this.bb_pos, 18); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + static startStateView(builder:flatbuffers.Builder) { - builder.startObject(7); + builder.startObject(8); } static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) { @@ -130,12 +135,16 @@ static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) { builder.addFieldInt32(6, walletBalance, 0); } +static addHintUnlockLeftSeconds(builder:flatbuffers.Builder, hintUnlockLeftSeconds:number) { + builder.addFieldInt32(7, hintUnlockLeftSeconds, 0); +} + static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return 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 { +static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset, walletBalance:number, hintUnlockLeftSeconds:number):flatbuffers.Offset { StateView.startStateView(builder); StateView.addGame(builder, gameOffset); StateView.addSeat(builder, seat); @@ -144,6 +153,7 @@ static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offse StateView.addHintsRemaining(builder, hintsRemaining); StateView.addAlphabet(builder, alphabetOffset); StateView.addWalletBalance(builder, walletBalance); + StateView.addHintUnlockLeftSeconds(builder, hintUnlockLeftSeconds); return StateView.endStateView(builder); } } diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 3b5cc12..dc8657d 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -461,6 +461,7 @@ function decodeStateViewTable(v: fb.StateView): StateView { bagLen: v.bagLen(), hintsRemaining: v.hintsRemaining(), walletBalance: v.walletBalance(), + hintUnlockLeftSeconds: v.hintUnlockLeftSeconds(), }; } diff --git a/ui/src/lib/gamedelta.ts b/ui/src/lib/gamedelta.ts index d03a99e..b5ead5c 100644 --- a/ui/src/lib/gamedelta.ts +++ b/ui/src/lib/gamedelta.ts @@ -11,9 +11,6 @@ export interface MoveDelta { move?: MoveRecord; game?: GameView; bagLen: number; - /** For a vs_ai game, the refreshed idle-hint unlock instant (the robot's reply re-arms the gate), - * so the cached view stays current without a refetch; absent leaves the prior value. */ - hintUnlockAtMs?: number; } /** @@ -53,12 +50,7 @@ export function applyMoveDelta(cached: CachedGame | undefined, d: MoveDelta): De if (next > have + 1) return { refetch: true }; // a gap — an intermediate move was missed // The actor's own move changed their rack (a draw), which opponent_moved does not carry. if (d.move.player === cached.view.seat) return { refetch: true }; - const view: StateView = { - ...cached.view, - game: d.game, - bagLen: d.bagLen, - hintUnlockAtMs: d.hintUnlockAtMs ?? cached.view.hintUnlockAtMs, - }; + const view: StateView = { ...cached.view, game: d.game, bagLen: d.bagLen }; return { cache: { view, moves: [...cached.moves, d.move] }, refetch: false }; } @@ -87,7 +79,7 @@ export function applyGameOver(cached: CachedGame | undefined, game: GameView | u */ export function advanceCached(cached: CachedGame | undefined, e: PushEvent): CachedGame | undefined { if (e.kind === 'opponent_moved') { - return applyMoveDelta(cached, { move: e.move, game: e.game, bagLen: e.bagLen, hintUnlockAtMs: e.hintUnlockAtMs }).cache; + return applyMoveDelta(cached, { move: e.move, game: e.game, bagLen: e.bagLen }).cache; } if (e.kind === 'game_over') return applyGameOver(cached, e.game).cache; if (e.kind === 'opponent_joined' && e.state) return applyOpponentJoined(cached, e.state); diff --git a/ui/src/lib/hints.test.ts b/ui/src/lib/hints.test.ts index 0eff9b8..cb81f4b 100644 --- a/ui/src/lib/hints.test.ts +++ b/ui/src/lib/hints.test.ts @@ -31,25 +31,19 @@ describe('hintsLeft', () => { }); }); -describe('hintGateRemainingMs (the vs_ai idle-hint gate)', () => { - it('is 0 when the gate is open — no unlock time (the human first move, or a non-gated game)', () => { - expect(hintGateRemainingMs(undefined, 5000, 1000)).toBe(0); - expect(hintGateRemainingMs(0, 5000, 1000)).toBe(0); +describe('hintGateRemainingMs (the vs_ai idle-hint gate, monotonic)', () => { + it('is 0 when the gate is open — a null anchor (the human first move, or a non-gated game)', () => { + expect(hintGateRemainingMs(null, 60_000, 5000)).toBe(0); }); - it('is 0 once the unlock instant has passed (the gate is open)', () => { - expect(hintGateRemainingMs(1000, 1000, 1000)).toBe(0); - expect(hintGateRemainingMs(1000, 1500, 1000)).toBe(0); + it('is the window remaining minus the monotonic time elapsed since the anchor', () => { + // anchored at mono 1000 with 60s left; 20s of monotonic time later -> 40s left. + expect(hintGateRemainingMs(1000, 60_000, 1000 + 20_000)).toBe(40_000); }); - it('is the wall-clock time remaining while still gated', () => { - expect(hintGateRemainingMs(1000, 400, 1000)).toBe(600); - }); - - it('clamps to the window, so a clock set back cannot freeze the gate above the window', () => { - // now shoved far into the past (device clock moved back): the raw remaining would exceed the - // window, but the cap keeps it at the window so the wait stays bounded and self-heals. - expect(hintGateRemainingMs(1000, -100_000, 1000)).toBe(1000); + it('is 0 once the elapsed monotonic time reaches (or passes) the window', () => { + expect(hintGateRemainingMs(1000, 60_000, 1000 + 60_000)).toBe(0); + expect(hintGateRemainingMs(1000, 60_000, 1000 + 90_000)).toBe(0); }); }); diff --git a/ui/src/lib/hints.ts b/ui/src/lib/hints.ts index e95db2e..9b200c7 100644 --- a/ui/src/lib/hints.ts +++ b/ui/src/lib/hints.ts @@ -30,23 +30,17 @@ export const HINT_GATE_MS = 30 * 60 * 1000; /** * hintGateRemainingMs returns how long (in milliseconds) until a vs_ai game's idle hint unlocks — 0 - * once it is available. hintUnlockAtMs is the wall-clock instant the hint opens (the robot's last - * move plus the idle window), persisted so the wait survives leaving and reopening the app; 0 or - * undefined means the gate is open (the human's first move, or a non-gated game). - * - * The result is CLAMPED to the window. A wall-clock timestamp is the only way to carry idle time - * across an app relaunch, but a device clock the player changes (or an auto-sync) could push the - * unlock far into the future and freeze the gate; capping the remaining at the window means the wait - * is never longer than intended and self-heals (the caller re-reads a sanitised value on load). A - * clock moved forward simply opens the hint early — harmless for this solo anti-frustration aid. + * once it is available. It counts down from a MONOTONIC clock (performance.now()), so a client clock + * change cannot skew it: gateStartMono is performance.now() captured when the source's "seconds left" + * was received, gateLeftMs is that seconds-left in ms, and monoNow is performance.now() now; the + * remaining is gateLeftMs minus the monotonic time elapsed since the anchor. gateStartMono null means + * the gate is open (the human's first move, or a non-gated game). The duration comes from the source + * (the SERVER clock online, the device clock offline) and is re-fetched on every load, so the wait + * both persists across a relaunch and stays immune to a live clock change. */ -export function hintGateRemainingMs( - hintUnlockAtMs: number | undefined, - nowMs: number, - gateMs: number = HINT_GATE_MS, -): number { - if (!hintUnlockAtMs) return 0; - return Math.min(gateMs, Math.max(0, hintUnlockAtMs - nowMs)); +export function hintGateRemainingMs(gateStartMono: number | null, gateLeftMs: number, monoNow: number): number { + if (gateStartMono === null) return 0; + return Math.max(0, gateLeftMs - (monoNow - gateStartMono)); } /** hintLockMinutes rounds a remaining-milliseconds gap up to whole minutes for the "available in N diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index cb9b127..0060d69 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -290,10 +290,9 @@ export class LocalSource implements GameLoopSource { private emitRobot(entry: Live, moves: LocalMove[]): void { const set = this.listeners.get(entry.record.id); if (!set) return; - const hintUnlockAtMs = this.hintUnlock(entry); for (const m of moves) { const game = this.gameView(entry); - const ev: PushEvent = { kind: 'opponent_moved', gameId: entry.record.id, move: this.moveRecord(entry.record.variant, m), game, bagLen: entry.game.bagLength, hintUnlockAtMs }; + const ev: PushEvent = { kind: 'opponent_moved', gameId: entry.record.id, move: this.moveRecord(entry.record.variant, m), game, bagLen: entry.game.bagLength }; for (const cb of set) cb(ev); } if (entry.game.isOver) { @@ -342,11 +341,14 @@ export class LocalSource implements GameLoopSource { // --- shape builders -------------------------------------------------------- - // The vs_ai idle-hint unlock instant, sanitised on read: capped at now + the window so a device - // clock set back cannot push it far ahead and freeze the gate (the client counts down from here). - // 0 while open (a human-first opening, no robot move yet). - private hintUnlock(entry: Live): number { - return entry.record.hintUnlockAtMs ? Math.min(entry.record.hintUnlockAtMs, Date.now() + HINT_GATE_MS) : 0; + // The vs_ai idle-hint seconds left, from the stored unlock instant (device wall clock, persisted + // for the offline record) minus now — capped at the window so a device clock set back cannot push + // it far ahead and freeze the gate, ceiled to whole seconds and floored at 0. The client anchors a + // monotonic countdown to this; 0 while open (a human-first opening, no robot move yet). + private hintUnlockLeft(entry: Live): number { + if (!entry.record.hintUnlockAtMs) return 0; + const leftMs = Math.min(HINT_GATE_MS, entry.record.hintUnlockAtMs - Date.now()); + return leftMs > 0 ? Math.ceil(leftMs / 1000) : 0; } private stateView(entry: Live): StateView { @@ -359,7 +361,7 @@ export class LocalSource implements GameLoopSource { bagLen: entry.game.bagLength, hintsRemaining: 1, walletBalance: 0, - hintUnlockAtMs: this.hintUnlock(entry), + hintUnlockLeftSeconds: this.hintUnlockLeft(entry), }; } diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index d0ca8dd..7dbe543 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -82,10 +82,11 @@ export interface StateView { bagLen: number; hintsRemaining: number; walletBalance: number; - /** For a vs_ai game, the wall-clock ms at which the idle hint unlocks — persisted so the wait - * survives a relaunch, and sanitised on read so a device clock set back cannot freeze it — or - * 0/undefined while open (the human's first move, or a non-vs_ai game). See lib/hints. */ - hintUnlockAtMs?: number; + /** For a vs_ai game, the seconds until the idle hint unlocks (computed by the source: the SERVER + * clock online, the device clock offline) — 0/undefined while open (the human's first move, or a + * non-vs_ai game). The client anchors a MONOTONIC countdown (performance.now()) to it on receipt, + * so a client clock change cannot skew it; a relaunch re-fetches a fresh value. See lib/hints. */ + hintUnlockLeftSeconds?: number; } export interface MoveResult { @@ -425,7 +426,7 @@ export interface GameList { */ export type PushEvent = | { kind: 'your_turn'; gameId: string; deadlineUnix: number; opponentName: string; moveCount: number } - | { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number; hintUnlockAtMs?: number } + | { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number } | { kind: 'game_over'; gameId: string; result: string; scoreLine: string; game?: GameView } | { kind: 'chat_message'; message: ChatMessage } | { kind: 'nudge'; gameId: string; fromUserId: string; senderName: string } -- 2.52.0 From 53311cbc95f66f6f7247ca5e6874df7bf16f234c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 7 Jul 2026 00:59:06 +0200 Subject: [PATCH 2/2] fix(hint): arm the vs_ai idle-gate from the warm cache so the lock shows without a delay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entering a vs_ai game from the lobby armed the idle-hint countdown only in load() (after the gameState round-trip), so the lock popped in after a visible delay. Arm it on the instant warm-cache render in onMount too — the preloadGames-warmed StateView carries hint_unlock_left_seconds; load() then refreshes the snapshot. A first move (0 seconds left) stays open. --- ui/src/game/Game.svelte | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index c8c9334..09e884d 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -290,6 +290,9 @@ if (cached) { view = cached.view; moves = cached.moves; + // Arm the vs_ai idle-hint countdown from the cached seconds-left so the 🔒 shows at once on a + // warm open (no wait for load()); the cached value is a snapshot, refreshed by load() below. + armHintGate(cached.view.game.vsAi ? (cached.view.hintUnlockLeftSeconds ?? 0) : 0); // Apply the cached draft synchronously so a warm/preloaded open paints the pending tiles // already on the board (no full-rack-then-jump). load() then refreshes in the background. applyDraft(cached.view, cached.draft ?? ''); -- 2.52.0