boardgame-core/tests/core/game-host.test.ts

637 lines
21 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
registry,
createInitialState,
start,
TicTacToeState,
WinnerType,
PlayerType
} from '@/samples/tic-tac-toe';
import { createGameHost, GameHost } from '@/core/game-host';
import type { PromptEvent } from '@/utils/command';
import { MutableSignal } from '@/utils/mutable-signal';
import {IGameContext} from "../../src";
type TestGameHost = GameHost<TicTacToeState> & {
_context: IGameContext<TicTacToeState>;
context: IGameContext<TicTacToeState>;
}
function createTestHost() {
const host: TestGameHost = createGameHost(
{ registry, createInitialState, start }
);
host.context = host._context;
return { host };
}
function waitForPromptEvent(host: GameHost<any>): Promise<PromptEvent> {
return new Promise(resolve => {
host.context._commands.on('prompt', resolve);
});
}
describe('GameHost', () => {
describe('creation', () => {
it('should create host with initial state', () => {
const { host } = createTestHost();
expect(host.context._state.value.currentPlayer).toBe('X');
expect(host.context._state.value.winner).toBeNull();
expect(host.context._state.value.turn).toBe(0);
expect(Object.keys(host.context._state.value.parts).length).toBe(0);
});
it('should have status "created" by default', () => {
const { host } = createTestHost();
expect(host.status.value).toBe('created');
});
it('should have null activePromptSchema initially', () => {
const { host } = createTestHost();
expect(host.activePromptSchema.value).toBeNull();
});
});
describe('tryInput', () => {
it('should return "No active prompt" when no prompt is active', () => {
const { host } = createTestHost();
const result = host.tryInput('play X 1 1');
expect(result).toBe('No active prompt');
});
it('should accept valid input when prompt is active', async () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
const runPromise = host.start();
const promptEvent = await promptPromise;
expect(promptEvent.schema.name).toBe('play');
expect(host.activePromptSchema.value?.name).toBe('play');
const error = host.tryInput('play X 1 1');
expect(error).toBeNull();
// Cancel to end the game since start runs until game over
const nextPromptPromise = waitForPromptEvent(host);
const nextPrompt = await nextPromptPromise;
nextPrompt.cancel('test cleanup');
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test cleanup');
}
});
it('should reject invalid input', async () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
const runPromise = host.start();
const promptEvent = await promptPromise;
const error = host.tryInput('invalid command');
expect(error).not.toBeNull();
promptEvent.cancel('test cleanup');
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test cleanup');
}
});
it('should return error when disposed', () => {
const { host } = createTestHost();
host.dispose();
const result = host.tryInput('play X 1 1');
expect(result).toBe('GameHost is disposed');
});
});
describe('getActivePromptSchema', () => {
it('should return schema when prompt is active', async () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
const runPromise = host.start();
const promptEvent = await promptPromise;
const schema = host.activePromptSchema.value;
expect(schema).not.toBeNull();
expect(schema?.name).toBe('play');
promptEvent.cancel('test cleanup');
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test cleanup');
}
});
it('should return null when no prompt is active', () => {
const { host } = createTestHost();
expect(host.activePromptSchema.value).toBeNull();
});
});
describe('start', () => {
it('should reset state and run start command', async () => {
const { host } = createTestHost();
// First setup - make one move
let promptPromise = waitForPromptEvent(host);
let runPromise = host.start();
let promptEvent = await promptPromise;
// Make a move
promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
// Wait for next prompt (next turn) and cancel
promptPromise = waitForPromptEvent(host);
promptEvent = await promptPromise;
promptEvent.cancel('test end');
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test end');
}
expect(Object.keys(host.context._state.value.parts).length).toBe(1);
// Setup listener before calling start
const newPromptPromise = waitForPromptEvent(host);
// Reset - should reset state and start new game
const newRunPromise = host.start();
// State should be back to initial
expect(host.context._state.value.currentPlayer).toBe('X');
expect(host.context._state.value.winner).toBeNull();
expect(host.context._state.value.turn).toBe(0);
expect(Object.keys(host.context._state.value.parts).length).toBe(0);
// New game should be running and prompting
const newPrompt = await newPromptPromise;
expect(newPrompt.schema.name).toBe('play');
newPrompt.cancel('test end');
try {
await newRunPromise;
} catch {
// Expected - cancelled
}
});
it('should cancel active prompt during start', async () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
const runPromise = host.start();
await promptPromise;
// Setup should cancel the active prompt and reset state
host.start();
// The original runPromise should be rejected due to cancellation
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toContain('Cancelled');
}
// State should be reset
expect(host.context._state.value.currentPlayer).toBe('X');
expect(host.context._state.value.turn).toBe(0);
});
it('should throw error when disposed', () => {
const { host } = createTestHost();
host.dispose();
expect(() => host.start()).toThrow('GameHost is disposed');
});
});
describe('dispose', () => {
it('should change status to disposed', () => {
const { host } = createTestHost();
host.dispose();
expect(host.status.value).toBe('disposed');
});
it('should cancel active prompt on dispose', async () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
const runPromise = host.start();
await promptPromise;
host.dispose();
// The runPromise should be rejected due to cancellation
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toContain('Cancelled');
}
});
it('should be idempotent', () => {
const { host } = createTestHost();
host.dispose();
host.dispose(); // Should not throw
expect(host.status.value).toBe('disposed');
});
});
describe('events', () => {
it('should emit start event', async () => {
const { host } = createTestHost();
let setupCount = 0;
host.on('start', () => {
setupCount++;
});
// Setup listener before calling setup
const promptPromise = waitForPromptEvent(host);
// Initial setup via reset
const runPromise = host.start();
expect(setupCount).toBe(1);
// State should be running
expect(host.status.value).toBe('running');
// Cancel the background setup command
const prompt = await promptPromise;
prompt.cancel('test end');
try {
await runPromise;
} catch {
// Expected - cancelled
}
});
it('should emit dispose event', () => {
const { host } = createTestHost();
let disposeCount = 0;
host.on('dispose', () => {
disposeCount++;
});
host.dispose();
expect(disposeCount).toBe(1);
});
it('should allow unsubscribing from events', () => {
const { host } = createTestHost();
let setupCount = 0;
const unsubscribe = host.on('start', () => {
setupCount++;
});
unsubscribe();
// No event should be emitted
// (we can't easily test this without triggering setup, but we verify unsubscribe works)
expect(typeof unsubscribe).toBe('function');
});
});
describe('reactive state', () => {
it('should have state that reflects game progress', async () => {
const { host } = createTestHost();
// Initial state
expect(host.context._state.value.currentPlayer).toBe('X');
expect(host.context._state.value.turn).toBe(0);
// Make a move
const promptPromise = waitForPromptEvent(host);
const runPromise = host.start();
const promptEvent = await promptPromise;
promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
// Wait for next prompt and cancel
const nextPromptPromise = waitForPromptEvent(host);
const nextPrompt = await nextPromptPromise;
nextPrompt.cancel('test end');
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test end');
}
expect(host.context._state.value.currentPlayer).toBe('O');
expect(host.context._state.value.turn).toBe(1);
expect(Object.keys(host.context._state.value.parts).length).toBe(1);
});
it('should update activePromptSchema reactively', async () => {
const { host } = createTestHost();
// Initially null
expect(host.activePromptSchema.value).toBeNull();
// Start a command that triggers prompt
const promptPromise = waitForPromptEvent(host);
const runPromise = host.start();
await promptPromise;
// Now schema should be set
expect(host.activePromptSchema.value).not.toBeNull();
expect(host.activePromptSchema.value?.name).toBe('play');
// Cancel and wait
const cancelEvent = host.activePromptSchema.value;
host.context._commands._cancel();
try {
await runPromise;
} catch {
// Expected
}
// Schema should be null again
expect(host.activePromptSchema.value).toBeNull();
});
});
describe('full game', () => {
it('should run a complete game of tic-tac-toe with X winning diagonally', async () => {
const { host } = createTestHost();
// Initial state
expect(host.context._state.value.currentPlayer).toBe('X');
expect(host.context._state.value.winner).toBeNull();
expect(host.context._state.value.turn).toBe(0);
expect(Object.keys(host.context._state.value.parts).length).toBe(0);
// X wins diagonally: (0,0), (1,1), (2,2)
// O plays: (0,1), (2,1)
const moves = [
'play X 0 0', // turn 1: X
'play O 0 1', // turn 2: O
'play X 1 1', // turn 3: X
'play O 2 1', // turn 4: O
'play X 2 2', // turn 5: X wins!
];
// Track prompt events in a queue
const promptEvents: PromptEvent[] = [];
host.context._commands.on('prompt', (e) => {
promptEvents.push(e);
});
// Start setup command (runs game loop until completion)
const setupPromise = host.start();
for (let i = 0; i < moves.length; i++) {
// Wait until the next prompt event arrives
while (i >= promptEvents.length) {
await new Promise(r => setTimeout(r, 10));
}
const promptEvent = promptEvents[i];
expect(promptEvent.schema.name).toBe('play');
// Submit the move
const error = host.tryInput(moves[i]);
expect(error).toBeNull();
// Wait for the command to complete before submitting next move
await new Promise(resolve => setImmediate(resolve));
}
// Wait for setup to complete (game ended with winner)
try {
const finalState = await setupPromise;
expect(finalState.winner).toBe('X');
// Final state checks
expect(host.context._state.value.winner).toBe('X');
expect(host.context._state.value.currentPlayer).toBe('X');
expect(Object.keys(host.context._state.value.parts).length).toBe(5);
// Verify winning diagonal
const parts = Object.values(host.context._state.value.parts);
const xPieces = parts.filter(p => p.player === 'X');
expect(xPieces).toHaveLength(3);
expect(xPieces.some(p => JSON.stringify(p.position) === JSON.stringify([0, 0]))).toBe(true);
expect(xPieces.some(p => JSON.stringify(p.position) === JSON.stringify([1, 1]))).toBe(true);
expect(xPieces.some(p => JSON.stringify(p.position) === JSON.stringify([2, 2]))).toBe(true);
} catch (e) {
// If setup fails due to cancellation, check state directly
const error = e as Error;
if (!error.message.includes('Cancelled')) {
throw e;
}
}
host.dispose();
expect(host.status.value).toBe('disposed');
});
});
describe('currentPlayer in prompt', () => {
it('should have currentPlayer in PromptEvent', async () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
const runPromise = host.start();
const promptEvent = await promptPromise;
expect(promptEvent.currentPlayer).toBe('X');
expect(host.activePromptPlayer.value).toBe('X');
promptEvent.cancel('test cleanup');
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test cleanup');
}
});
it('should update activePromptPlayer reactively', async () => {
const { host } = createTestHost();
// Initially null
expect(host.activePromptPlayer.value).toBeNull();
// First prompt - X's turn
let promptPromise = waitForPromptEvent(host);
let runPromise = host.start();
let promptEvent = await promptPromise;
expect(promptEvent.currentPlayer).toBe('X');
expect(host.activePromptPlayer.value).toBe('X');
// Make a move
promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
// Second prompt - O's turn
promptPromise = waitForPromptEvent(host);
promptEvent = await promptPromise;
expect(promptEvent.currentPlayer).toBe('O');
expect(host.activePromptPlayer.value).toBe('O');
// Cancel
promptEvent.cancel('test cleanup');
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test cleanup');
}
// After prompt ends, player should be null
expect(host.activePromptPlayer.value).toBeNull();
});
});
describe('tryAnswerPrompt', () => {
it('should answer prompt with valid arguments', async () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
const runPromise = host.start();
const promptEvent = await promptPromise;
expect(promptEvent.schema.name).toBe('play');
// Use tryAnswerPrompt with the prompt def
const { prompts } = await import('@/samples/tic-tac-toe');
const error = host.tryAnswerPrompt(prompts.play, 'X', 1, 1);
expect(error).toBeNull();
// Wait for next prompt and cancel
const nextPromptPromise = waitForPromptEvent(host);
const nextPrompt = await nextPromptPromise;
nextPrompt.cancel('test cleanup');
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test cleanup');
}
});
it('should reject invalid arguments', async () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
const runPromise = host.start();
const promptEvent = await promptPromise;
// Use tryAnswerPrompt with invalid position
const { prompts } = await import('@/samples/tic-tac-toe');
const error = host.tryAnswerPrompt(prompts.play, 'X', 5, 5);
expect(error).not.toBeNull();
promptEvent.cancel('test cleanup');
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test cleanup');
}
});
});
describe('addInterruption and clearInterruptions', () => {
it('should add interruption promise to state', async () => {
const { host } = createTestHost();
let resolveInterruption: () => void;
const interruptionPromise = new Promise<void>(resolve => {
resolveInterruption = resolve;
});
// Add interruption
host.addInterruption(interruptionPromise);
// Start the game - produceAsync should wait for interruption
const promptPromise = waitForPromptEvent(host);
const runPromise = host.start();
const promptEvent = await promptPromise;
// Resolve interruption
resolveInterruption!();
// Cancel and cleanup
promptEvent.cancel('test cleanup');
try {
await runPromise;
} catch {
// Expected
}
});
it('should clear all pending interruptions', async () => {
const { host } = createTestHost();
let resolveInterruption1: () => void;
let resolveInterruption2: () => void;
const interruptionPromise1 = new Promise<void>(resolve => {
resolveInterruption1 = resolve;
});
const interruptionPromise2 = new Promise<void>(resolve => {
resolveInterruption2 = resolve;
});
// Add multiple interruptions
host.addInterruption(interruptionPromise1);
host.addInterruption(interruptionPromise2);
// Clear all interruptions
host.clearInterruptions();
// Start the game - should not wait for cleared interruptions
const promptPromise = waitForPromptEvent(host);
const runPromise = host.start();
const promptEvent = await promptPromise;
promptEvent.cancel('test cleanup');
try {
await runPromise;
} catch {
// Expected
}
// Original interruption promises should still be pending
// (they were cleared, not resolved)
});
});
});