7fc1301b31
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
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.
101 lines
5.5 KiB
TypeScript
101 lines
5.5 KiB
TypeScript
// Pure reducers that advance the per-game cache from live events, so the UI renders a move
|
|
// from the event without a follow-up game.state + game.history fetch. They never touch the network
|
|
// or the cache store — the stream handler applies the returned cache and the game screen acts on
|
|
// `refetch` — which keeps the gap / own-move / idempotency logic unit-testable in isolation.
|
|
|
|
import type { CachedGame } from './gamecache';
|
|
import type { GameView, MoveRecord, PushEvent, StateView } from './model';
|
|
|
|
/** The fields an opponent_moved delta carries that advance a cached game. */
|
|
export interface MoveDelta {
|
|
move?: MoveRecord;
|
|
game?: GameView;
|
|
bagLen: number;
|
|
}
|
|
|
|
/**
|
|
* DeltaResult is the outcome of applying an event: the advanced cache (set only when it changed)
|
|
* and whether the caller must fall back to a full game.state + game.history fetch — a gap, a
|
|
* missing payload, or the actor's own move on a device that has not drawn its new rack yet.
|
|
*/
|
|
export interface DeltaResult {
|
|
cache?: CachedGame;
|
|
refetch: boolean;
|
|
}
|
|
|
|
/**
|
|
* seedInitialState builds a fresh cache entry from a started game's initial view (match_found /
|
|
* game_started). A freshly started game has no moves, so the board replays from an empty journal.
|
|
*/
|
|
export function seedInitialState(state: StateView): CachedGame {
|
|
return { view: state, moves: [] };
|
|
}
|
|
|
|
/**
|
|
* applyMoveDelta advances cached by one move from an opponent_moved delta, keyed on the post-move
|
|
* count so it is idempotent (a re-delivered move, or the echo of one's own move, is a no-op) and
|
|
* gap-safe (a missed move asks for a refetch). An opponent's move leaves the recipient's rack
|
|
* unchanged, so it is preserved; the actor's own move drew a new rack the delta does not carry, so
|
|
* a device still behind on its own move refetches to pick it up.
|
|
*/
|
|
export function applyMoveDelta(cached: CachedGame | undefined, d: MoveDelta): DeltaResult {
|
|
// Nothing cached to advance (the game was never opened on this device): ignore it; the next open
|
|
// cold-loads the game.
|
|
if (!cached) return { refetch: false };
|
|
// An older peer, or a dropped payload, carried no delta: the open game must refetch.
|
|
if (!d.move || !d.game) return { refetch: true };
|
|
const have = cached.view.game.moveCount;
|
|
const next = d.game.moveCount;
|
|
if (next <= have) return { refetch: false }; // already applied (idempotent / own echo)
|
|
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 };
|
|
return { cache: { view, moves: [...cached.moves, d.move] }, refetch: false };
|
|
}
|
|
|
|
/**
|
|
* applyGameOver settles a finished game from a game_over event's final summary (the adjusted
|
|
* scores after rack penalties and the winner). It refetches when the cached board is behind the
|
|
* final move count — the closing move was missed — so history is repaired, and is a harmless
|
|
* re-settle when the closing opponent_moved already finished the game.
|
|
*/
|
|
export function applyGameOver(cached: CachedGame | undefined, game: GameView | undefined): DeltaResult {
|
|
if (!cached) return { refetch: false };
|
|
if (!game) return { refetch: cached.view.game.status !== 'finished' };
|
|
if (cached.view.game.moveCount < game.moveCount) return { refetch: true };
|
|
const view: StateView = { ...cached.view, game };
|
|
return { cache: { view, moves: cached.moves }, refetch: false };
|
|
}
|
|
|
|
/**
|
|
* advanceCached applies a live game event to a cached game off-screen, for the global stream handler
|
|
* to keep a game the player is not currently viewing warm: opening it from the lobby then renders the
|
|
* opponent's move (or the final result) with no stale-board flash. It returns the advanced cache, or
|
|
* undefined when nothing changes — no cache, an idempotent re-delivery, an unrelated event kind, or a
|
|
* gap / missing payload that the next open cold-loads (off-screen there is no game screen to honour a
|
|
* refetch, so a fall-back simply leaves the cache for load() to repair). The mounted game screen owns
|
|
* its own cache, so the caller skips the game currently in view to avoid applying a delta twice.
|
|
*/
|
|
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 }).cache;
|
|
}
|
|
if (e.kind === 'game_over') return applyGameOver(cached, e.game).cache;
|
|
if (e.kind === 'opponent_joined' && e.state) return applyOpponentJoined(cached, e.state);
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* applyOpponentJoined adopts an opponent_joined event into a cached open game: it overlays the
|
|
* refreshed seats, status and player count from the event's StateView onto the cached game, leaving
|
|
* the board, the starter's rack and everything else intact (the join changes who is seated, not the
|
|
* position). It returns undefined when the game is not cached (the next open cold-loads it). It backs
|
|
* both the mounted game board's in-place update and the global handler's off-screen cache warming.
|
|
*/
|
|
export function applyOpponentJoined(cached: CachedGame | undefined, state: StateView): CachedGame | undefined {
|
|
if (!cached) return undefined;
|
|
const game: GameView = { ...cached.view.game, seats: state.game.seats, status: state.game.status, players: state.game.players };
|
|
return { view: { ...cached.view, game }, moves: cached.moves };
|
|
}
|