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
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.
172 lines
7.2 KiB
TypeScript
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();
|
|
});
|
|
});
|