Files
scrabble-game/ui/src/lib/gamedelta.test.ts
T
Ilia Denisov aa765a0c06
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
feat: honest AI opponent in quick game
New Game's quick game gains an explicit opponent selector — 🤖 AI (default)
or 👤 Random player. AI starts a game seated with a pooled robot that joins
and moves at once: no per-move timeout (a 7-day inactivity loss reusing the
turn-timeout sweeper), chat/nudge disabled, no statistics, the opponent shown
as 🤖 everywhere. The random path (disguised robot) is unchanged.

Driven by one game flag (games.vs_ai), set only on AI-started games so the
disguised path is never revealed; Matchmaker.StartVsAI seats the robot
directly (no open pool); the robot replies event-driven via the game service's
after-commit/after-create hook. Wire: vs_ai on EnqueueRequest + GameView.
2026-06-15 20:14:24 +02:00

172 lines
7.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { advanceCached, applyGameOver, applyMoveDelta, applyOpponentJoined, seedInitialState, type MoveDelta } from './gamedelta';
import type { CachedGame } from './gamecache';
import type { GameView, MoveRecord, PushEvent, StateView } from './model';
function gameView(moveCount: number, over = false): GameView {
return {
id: 'g1',
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
status: over ? 'finished' : 'active',
players: 2,
toMove: 1,
turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
moveCount,
endReason: over ? 'standard' : '',
lastActivityUnix: 0,
seats: [],
};
}
function move(player: number): MoveRecord {
return { player, action: 'play', dir: 'H', mainRow: 7, mainCol: 7, tiles: [], words: ['AB'], count: 0, score: 10, total: 10 };
}
function cache(moveCount: number, seat = 0, over = false): CachedGame {
const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 };
return { view, moves: [] };
}
function delta(moveCount: number, player: number, bagLen = 47): MoveDelta {
return { move: move(player), game: gameView(moveCount), bagLen };
}
describe('seedInitialState', () => {
it('wraps an initial view with an empty journal', () => {
const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2 };
expect(seedInitialState(view)).toEqual({ view, moves: [] });
});
});
describe('applyMoveDelta', () => {
it('ignores a delta for a game not in the cache', () => {
expect(applyMoveDelta(undefined, delta(1, 1))).toEqual({ refetch: false });
});
it('refetches when the payload carries no delta (older peer / dropped payload)', () => {
expect(applyMoveDelta(cache(3), { bagLen: 0 })).toEqual({ refetch: true });
});
it('is a no-op for an already-applied move count (idempotent / own echo)', () => {
expect(applyMoveDelta(cache(3), delta(3, 1))).toEqual({ refetch: false });
expect(applyMoveDelta(cache(3), delta(2, 1))).toEqual({ refetch: false });
});
it('refetches on a gap (an intermediate move was missed)', () => {
expect(applyMoveDelta(cache(3), delta(5, 1))).toEqual({ refetch: true });
});
it("refetches the actor's own move (the new rack is not in the delta)", () => {
// seat 0 is this device; the move's player is 0 -> our own move, our rack changed.
expect(applyMoveDelta(cache(3, 0), delta(4, 0))).toEqual({ refetch: true });
});
it("applies an opponent's next move, preserving the rack and appending the move", () => {
const before = cache(3, 0);
const res = applyMoveDelta(before, delta(4, 1, 45));
expect(res.refetch).toBe(false);
expect(res.cache?.view.game.moveCount).toBe(4);
expect(res.cache?.view.bagLen).toBe(45);
expect(res.cache?.view.rack).toEqual(['a', 'b']); // unchanged by an opponent move
expect(res.cache?.view.seat).toBe(0);
expect(res.cache?.moves).toHaveLength(1);
expect(res.cache?.moves[0].player).toBe(1);
expect(before.moves).toHaveLength(0); // input not mutated
});
});
describe('applyGameOver', () => {
it('ignores a finished game not in the cache', () => {
expect(applyGameOver(undefined, gameView(10, true))).toEqual({ refetch: false });
});
it('refetches a missing final summary only when the game is not already finished', () => {
expect(applyGameOver(cache(10, 0, false), undefined)).toEqual({ refetch: true });
expect(applyGameOver(cache(10, 0, true), undefined)).toEqual({ refetch: false });
});
it('refetches when the cached board is behind the final move count', () => {
expect(applyGameOver(cache(9), gameView(10, true))).toEqual({ refetch: true });
});
it('settles the final summary when the board is current', () => {
const res = applyGameOver(cache(10), gameView(10, true));
expect(res.refetch).toBe(false);
expect(res.cache?.view.game.status).toBe('finished');
expect(res.cache?.view.game.moveCount).toBe(10);
});
});
function movedEvent(moveCount: number, player: number, bagLen = 45): PushEvent {
return { kind: 'opponent_moved', gameId: 'g1', move: move(player), game: gameView(moveCount), bagLen };
}
function gameOverEvent(moveCount: number): PushEvent {
return { kind: 'game_over', gameId: 'g1', result: 'win', scoreLine: '10:0', game: gameView(moveCount, true) };
}
describe('advanceCached', () => {
it("advances the cache from an opponent's move for a game not on screen", () => {
const res = advanceCached(cache(3, 0), movedEvent(4, 1, 42));
expect(res?.view.game.moveCount).toBe(4);
expect(res?.view.bagLen).toBe(42);
expect(res?.moves).toHaveLength(1);
});
it('returns undefined on a gap, so the cache is left for the next open to cold-load', () => {
expect(advanceCached(cache(3, 0), movedEvent(6, 1))).toBeUndefined();
});
it('returns undefined for an already-applied move (idempotent re-delivery / own echo)', () => {
expect(advanceCached(cache(4, 0), movedEvent(4, 1))).toBeUndefined();
});
it('settles a game_over event when the cached board is current', () => {
const res = advanceCached(cache(10, 0), gameOverEvent(10));
expect(res?.view.game.status).toBe('finished');
});
it('returns undefined for game_over when the cached board is behind the final count', () => {
expect(advanceCached(cache(9, 0), gameOverEvent(10))).toBeUndefined();
});
it('returns undefined when nothing is cached for the game', () => {
expect(advanceCached(undefined, movedEvent(1, 1))).toBeUndefined();
});
it('ignores event kinds that do not advance a game board', () => {
const yourTurn: PushEvent = { kind: 'your_turn', gameId: 'g1', deadlineUnix: 0, opponentName: 'Ann', moveCount: 4 };
expect(advanceCached(cache(3, 0), yourTurn)).toBeUndefined();
});
});
describe('applyOpponentJoined', () => {
// joinedState is the refreshed StateView an opponent_joined event carries: the open game is now
// active and both seats are filled.
function joinedState(): StateView {
const game: GameView = { ...gameView(0), status: 'active', players: 2, seats: [
{ seat: 0, accountId: 'me', displayName: 'Me', 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 };
}
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.
const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }, moves: [move(0)] };
const res = applyOpponentJoined(cached, joinedState());
expect(res?.view.game.status).toBe('active');
expect(res?.view.game.seats).toHaveLength(2);
expect(res?.view.rack).toEqual(['a', 'b']); // the starter's rack is untouched by the join
expect(res?.view.game.moveCount).toBe(2); // the cached count/board is preserved
expect(res?.moves).toHaveLength(1);
});
it('returns undefined when nothing is cached for the game', () => {
expect(applyOpponentJoined(undefined, joinedState())).toBeUndefined();
});
});