// Test scaffolding for the engine (pure; no test-runner imports). Builds a standard game and // folds action sequences through the reducer. import { reduce } from './reducer.js' import { balance, timerFor } from './config.js' import { buildNav } from './nav.js' import type { Action } from './actions.js' import type { ActivityKind, GameState, NavGrid, Rect, StationSeed, Vec } from './types.js' /** A wide-open nav grid (no obstacles) covering the standard test map. */ export function openNav(): NavGrid { return buildNav([], { cellSize: 16, bounds: { x: -100, y: -100, w: 600, h: 400 } }) } /** A generous area around a point so tests can stand "in" a station easily. */ function areaAround(x: number, y: number): Rect { return { x: x - 8, y: y - 8, w: 16, h: 16 } } export const DIDS = ['did:s', 'did:a', 'did:b', 'did:c'] as const const ORIGIN: Vec = { x: 0, y: 0 } const cap = (over: Partial): Omit => ({ collect: [], deposit: [], scoreboard: [], meeting: false, ...over, }) /** Build a standard map: one resource station per resource + deposit/scoreboard/meeting. */ export function standardStations(resources: string[], slots: number): StationSeed[] { const seeds: StationSeed[] = resources.map((r, i) => ({ id: `res-${r}`, pos: { x: i * 10, y: 0 }, area: areaAround(i * 10, 0), slotCount: slots, ...cap({ collect: [r] }), })) seeds.push( { id: 'deposit', pos: { x: 0, y: 50 }, area: areaAround(0, 50), slotCount: slots, ...cap({ deposit: [...resources] }) }, { id: 'scoreboard', pos: { x: 50, y: 50 }, area: areaAround(50, 50), slotCount: 1, ...cap({ scoreboard: [...resources] }) }, { id: 'meeting', pos: { x: 100, y: 50 }, area: areaAround(100, 50), slotCount: 1, ...cap({ meeting: true }) }, ) return seeds } export interface SetupOpts { playerCount?: number dids?: readonly string[] /** also start the round (assign first did = saboteur, rest workers) */ startRound?: boolean } /** A created (or started) game with players joined. */ export function setup(opts: SetupOpts = {}): GameState { const dids = opts.dids ?? DIDS const playerCount = opts.playerCount ?? dids.length const { config } = balance({ playerCount, roundDurationMs: 600_000, resourceCount: 4 }) // tests exercise concurrent slot use, so give the map a comfortable slot count regardless of // what the balance formula would pick for this player count (that's covered by its own test) let s = reduce(undefined, { type: 'gameCreated', serverTime: 0, code: 'ABCD', config, stations: standardStations(config.resources, 3), nav: openNav(), }) dids.forEach((did, i) => { s = reduce(s, { type: 'playerJoined', serverTime: 0, did, handle: did.replace('did:', ''), spriteRef: null, pos: { x: i, y: 0 }, }) }) if (opts.startRound) { const roles: Record = {} const spawns: Record = {} dids.forEach((did, i) => { roles[did] = i === 0 ? 'saboteur' : 'worker' spawns[did] = ORIGIN }) s = reduce(s, { type: 'roundStarted', serverTime: 1, roles, spawns, endsAt: 1 + config.roundDurationMs, }) } return s } /** Fold a sequence of actions. */ export function run(state: GameState, ...actions: Action[]): GameState { return actions.reduce((s, a) => reduce(s, a), state) } /** * Open a vote the way the game now does — a successful callVote at a 'meeting' station (there is no * standalone vote-start action). Resolves at `at + timer1`, after which `state.vote` is set. */ export function openVote(state: GameState, did: string, at: number): GameState { return doActivity(state, did, 'meeting', 0, 'callVote', at) } /** * Perform a full station activity (start + resolve) for `did`, advancing time by the activity's * timer. Returns the new state. `at` is the start time; resolution happens at `at + timer`. */ export function doActivity( state: GameState, did: string, stationId: string, slot: number, kind: ActivityKind, at: number, rng?: { sabotageTriggered?: boolean; fakeRepairExtra?: number }, resourceId?: string, ): GameState { const endsAt = at + timerFor(kind, state.config) // collect/pretendCollect target a specific resource; default to the station's first collectable // so existing tests need not thread it through (the server always supplies one). const chosen = resourceId ?? (kind === 'collect' || kind === 'pretendCollect' ? state.stations[stationId]?.collect[0] : undefined) const started = reduce(state, { type: 'actionStarted', serverTime: at, did, stationId, slot, kind, endsAt, resourceId: chosen, }) return reduce(started, { type: 'actionResolved', serverTime: endsAt, did, rng }) }