Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
4.9 kB · 143 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144// 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<StationSeed>): Omit<StationSeed, 'id' | 'pos' | 'area' | 'slotCount'> => ({ 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<string, 'worker' | 'saboteur'> = {} const spawns: Record<string, Vec> = {} 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 })}