Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
21 kB · 498 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499import { describe, expect, it } from 'vitest'import { reduce } from './reducer.js'import { timerFor } from './config.js'import { DIDS, doActivity, openVote, run, setup } from './testutil.js'import type { GameState } from './types.js'
const [SAB, A, B] = DIDSconst T1 = (s: GameState) => timerFor('collect', s.config)
describe('lifecycle', () => { it('gameCreated initializes a lobby with zeroed resources and stations (seq starts at 1)', () => { const bare = reduce(undefined, { type: 'gameCreated', serverTime: 0, code: 'ABCD', config: setup().config, stations: [], }) expect(bare.seq).toBe(1)
const s = setup() expect(s.phase).toBe('lobby') expect(Object.keys(s.players)).toHaveLength(4) expect(s.resources).toEqual({ wood: 0, stone: 0, wheat: 0, ore: 0 }) expect(s.stations['res-wood']?.state).toBe('working') })
it('rejects joining after the lobby phase', () => { const s = setup({ startRound: true }) const after = reduce(s, { type: 'playerJoined', serverTime: 5, did: 'did:late', handle: 'late', spriteRef: null, pos: { x: 0, y: 0 }, }) expect(after.players['did:late']).toBeUndefined() })
it('roundStarted assigns roles and spawns and flips to round', () => { const s = setup({ startRound: true }) expect(s.phase).toBe('round') expect(s.players[SAB]?.role).toBe('saboteur') expect(s.players[A]?.role).toBe('worker') expect(s.startedAt).toBe(1) })
it('advances now monotonically and increments seq per action', () => { const s0 = setup({ startRound: true }) const seq0 = s0.seq const s1 = reduce(s0, { type: 'tick', serverTime: 10_000 }) expect(s1.now).toBe(10_000) expect(s1.seq).toBe(seq0 + 1) // a stale serverTime never moves now backwards const s2 = reduce(s1, { type: 'tick', serverTime: 5_000 }) expect(s2.now).toBe(10_000) })
it('does not mutate the input state', () => { const s = setup({ startRound: true }) const snapshot = JSON.stringify(s) reduce(s, { type: 'tick', serverTime: 999 }) doActivity(s, A, 'res-wood', 0, 'collect', 100) expect(JSON.stringify(s)).toBe(snapshot) })})
describe('movement', () => { it('moves an idle player but not one mid-action', () => { let s = setup({ startRound: true }) s = reduce(s, { type: 'playerMoved', serverTime: 2, did: A, pos: { x: 5, y: 5 }, facing: 'east' }) expect(s.players[A]?.pos).toEqual({ x: 5, y: 5 })
s = reduce(s, { type: 'actionStarted', serverTime: 3, did: A, stationId: 'res-wood', slot: 0, kind: 'collect', endsAt: 3 + T1(s), }) s = reduce(s, { type: 'playerMoved', serverTime: 4, did: A, pos: { x: 9, y: 9 }, facing: 'north' }) expect(s.players[A]?.pos).toEqual({ x: 5, y: 5 }) // unchanged: busy })})
describe('pathfinding movement', () => { it('setDestination writes a path toward the target', () => { let s = setup({ startRound: true }) s = reduce(s, { type: 'setDestination', serverTime: 1_000, did: A, target: { x: 80, y: 0 } }) expect(s.players[A]?.path.length).toBeGreaterThan(0) expect(s.players[A]?.dest).not.toBeNull() })
it('ticks advance the player along the path until they arrive at the destination', () => { let s = setup({ startRound: true }) s = reduce(s, { type: 'setDestination', serverTime: 1_000, did: A, target: { x: 80, y: 0 } }) const dest = s.players[A]!.dest! s = reduce(s, { type: 'tick', serverTime: 1_000_000 }) // plenty of time expect(s.players[A]?.pos).toEqual(dest) expect(s.players[A]?.path).toHaveLength(0) expect(s.players[A]?.dest).toBeNull() })
it('a short tick moves the player partway without arriving', () => { let s = setup({ startRound: true }) const start = s.players[A]!.pos s = reduce(s, { type: 'setDestination', serverTime: 1_000, did: A, target: { x: 80, y: 0 } }) s = reduce(s, { type: 'tick', serverTime: 1_100 }) // 100ms => ~14px at 140px/s const p = s.players[A]! expect(p.pos).not.toEqual(start) expect(p.dest).not.toBeNull() expect(p.path.length).toBeGreaterThan(0) })
it('cannot set a destination while mid-action, and starting an action clears the path', () => { let s = setup({ startRound: true }) // mid-action: setDestination is ignored s = reduce(s, { type: 'actionStarted', serverTime: 100, did: A, stationId: 'res-wood', slot: 0, kind: 'collect', endsAt: 100 + T1(s), }) s = reduce(s, { type: 'setDestination', serverTime: 200, did: A, target: { x: 80, y: 0 } }) expect(s.players[A]?.path).toHaveLength(0)
// and: starting an action while pathing clears the path let s2 = setup({ startRound: true }) s2 = reduce(s2, { type: 'setDestination', serverTime: 50, did: B, target: { x: 80, y: 0 } }) expect(s2.players[B]?.path.length).toBeGreaterThan(0) s2 = reduce(s2, { type: 'actionStarted', serverTime: 60, did: B, stationId: 'res-wood', slot: 0, kind: 'collect', endsAt: 60 + T1(s2), }) expect(s2.players[B]?.path).toHaveLength(0) })})
describe('station activity + slots', () => { it('collect at a working station grants a real resource and frees the slot with cooldown', () => { let s = setup({ startRound: true }) const endsAt = 100 + T1(s) s = reduce(s, { type: 'actionStarted', serverTime: 100, did: A, stationId: 'res-wood', slot: 0, kind: 'collect', resourceId: 'wood', endsAt, }) expect(s.stations['res-wood']?.slots[0]?.occupantDid).toBe(A) // occupied mid-action expect(s.players[A]?.active?.kind).toBe('collect')
s = reduce(s, { type: 'actionResolved', serverTime: endsAt, did: A }) expect(s.players[A]?.carrying).toEqual({ authentic: true, resourceId: 'wood' }) expect(s.players[A]?.active).toBeNull() const slot = s.stations['res-wood']?.slots[0] expect(slot?.occupantDid).toBeNull() expect(slot?.collectCooldownUntil).toBe(endsAt + s.config.slotCooldownMs) })
it('a second player cannot take an occupied slot', () => { let s = setup({ startRound: true }) s = reduce(s, { type: 'actionStarted', serverTime: 1, did: A, stationId: 'res-wood', slot: 0, kind: 'collect', endsAt: 1 + T1(s), }) const before = s.players[B]?.active ?? null s = reduce(s, { type: 'actionStarted', serverTime: 2, did: B, stationId: 'res-wood', slot: 0, kind: 'collect', endsAt: 2 + T1(s), }) expect(s.players[B]?.active).toBe(before) // rejected })
it('only a real collect starts the slot cooldown; other activities do not', () => { // a collect cools the slot let s = setup({ startRound: true }) s = doActivity(s, A, 'res-wood', 0, 'collect', 100) expect(s.stations['res-wood']!.slots[0]!.collectCooldownUntil).toBeGreaterThan(s.now) // inspecting a different slot leaves that slot's cooldown at 0 (untouched) s = doActivity(s, B, 'res-wood', 1, 'inspect', 100) expect(s.stations['res-wood']!.slots[1]!.collectCooldownUntil).toBe(0) })
it('while carrying, only deposit is allowed', () => { let s = setup({ startRound: true }) s = doActivity(s, A, 'res-wood', 0, 'collect', 100) // now carrying const blocked = reduce(s, { type: 'actionStarted', serverTime: 200, did: A, stationId: 'res-stone', slot: 0, kind: 'collect', endsAt: 200 + T1(s), }) expect(blocked.players[A]?.active).toBeNull() })})
describe('sabotage mechanics', () => { it('sabotage hides the station as working (in raw state it is sabotaged)', () => { let s = setup({ startRound: true }) s = doActivity(s, SAB, 'res-wood', 0, 'sabotage', 100) expect(s.stations['res-wood']?.state).toBe('sabotaged') expect(s.stations['res-wood']?.collectionsSinceSabotage).toBe(0) })
it('collect on a sabotaged station without a trigger still yields a resource and ramps the counter', () => { let s = setup({ startRound: true }) s = doActivity(s, SAB, 'res-wood', 0, 'sabotage', 100) s = doActivity(s, A, 'res-wood', 1, 'collect', 9_000, { sabotageTriggered: false }) expect(s.players[A]?.carrying).toEqual({ authentic: true, resourceId: 'wood' }) expect(s.stations['res-wood']?.state).toBe('sabotaged') expect(s.stations['res-wood']?.collectionsSinceSabotage).toBe(1) })
it('a triggered collect fails (no resource) and breaks the station', () => { let s = setup({ startRound: true }) s = doActivity(s, SAB, 'res-wood', 0, 'sabotage', 100) s = doActivity(s, A, 'res-wood', 1, 'collect', 9_000, { sabotageTriggered: true }) expect(s.players[A]?.carrying).toBeNull() expect(s.stations['res-wood']?.state).toBe('broken') })
it('repair restores a sabotaged station to working and clears knowledge', () => { let s = setup({ startRound: true }) s = doActivity(s, SAB, 'res-wood', 0, 'sabotage', 100) s = doActivity(s, A, 'res-wood', 1, 'inspect', 9_000) // A learns it is sabotaged expect(s.stations['res-wood']?.revealedSabotageTo).toContain(A) s = doActivity(s, B, 'res-wood', 0, 'repair', 20_000) expect(s.stations['res-wood']?.state).toBe('working') expect(s.stations['res-wood']?.collectionsSinceSabotage).toBe(0) expect(s.stations['res-wood']?.revealedSabotageTo).toHaveLength(0) })
it('breakStation breaks immediately; collect on broken does nothing', () => { let s = setup({ startRound: true }) s = doActivity(s, SAB, 'res-wood', 0, 'breakStation', 100) expect(s.stations['res-wood']?.state).toBe('broken') s = doActivity(s, A, 'res-wood', 1, 'collect', 9_000) expect(s.players[A]?.carrying).toBeNull() })
it('fakeRepair re-sabotages a BROKEN station with grace (negative counter)', () => { let s = setup({ startRound: true }) s = doActivity(s, SAB, 'res-wood', 0, 'breakStation', 100) s = doActivity(s, SAB, 'res-wood', 0, 'fakeRepair', 10_000, { fakeRepairExtra: 2 }) expect(s.stations['res-wood']?.state).toBe('sabotaged') expect(s.stations['res-wood']?.collectionsSinceSabotage).toBe(-2) })
it('fakeRepair does nothing on a working or already-sabotaged station', () => { // working -> stays working let s = setup({ startRound: true }) s = doActivity(s, SAB, 'res-wood', 0, 'fakeRepair', 100) expect(s.stations['res-wood']?.state).toBe('working') // sabotaged -> unchanged (state and the hidden counter both untouched) s = doActivity(s, SAB, 'res-wood', 0, 'sabotage', 10_000) const counter = s.stations['res-wood']!.collectionsSinceSabotage s = doActivity(s, SAB, 'res-wood', 0, 'fakeRepair', 30_000) expect(s.stations['res-wood']?.state).toBe('sabotaged') expect(s.stations['res-wood']?.collectionsSinceSabotage).toBe(counter) })
it('pretendCollect yields a fake resource', () => { let s = setup({ startRound: true }) s = doActivity(s, SAB, 'res-wood', 0, 'pretendCollect', 100) expect(s.players[SAB]?.carrying).toEqual({ authentic: false, resourceId: 'wood' }) })
it('a real collect burns the slot cooldown; a fake collect deliberately does not (the tell)', () => { let real = setup({ startRound: true }) real = doActivity(real, A, 'res-wood', 0, 'collect', 100) expect(real.stations['res-wood']!.slots[0]!.collectCooldownUntil).toBeGreaterThan(real.now)
let fake = setup({ startRound: true }) fake = doActivity(fake, SAB, 'res-wood', 0, 'pretendCollect', 100) expect(fake.stations['res-wood']!.slots[0]!.collectCooldownUntil).toBe(0) // never cooled })
it('fake collect never rolls the break dice, so the saboteur cannot trip their own trap', () => { let s = setup({ startRound: true }) s = doActivity(s, SAB, 'res-wood', 0, 'sabotage', 100) const armed = s.stations['res-wood']!.collectionsSinceSabotage // even with a (would-be) triggering roll, pretendCollect ignores it entirely s = doActivity(s, SAB, 'res-wood', 0, 'pretendCollect', 20_000, { sabotageTriggered: true }) expect(s.stations['res-wood']?.state).toBe('sabotaged') // still armed, not broken expect(s.stations['res-wood']?.collectionsSinceSabotage).toBe(armed) // counter untouched expect(s.players[SAB]?.carrying).toEqual({ authentic: false, resourceId: 'wood' }) })})
describe('deposit / drop / debris', () => { it('depositing a real resource increments the total; a fake deposit does not', () => { let s = setup({ startRound: true }) s = doActivity(s, A, 'res-wood', 0, 'collect', 100) // real wood s = doActivity(s, A, 'deposit', 0, 'deposit', 10_000) expect(s.resources.wood).toBe(1) expect(s.players[A]?.carrying).toBeNull()
s = doActivity(s, SAB, 'res-wood', 0, 'pretendCollect', 20_000) // fake wood s = doActivity(s, SAB, 'deposit', 0, 'fakeDeposit', 30_000) // clean disposal of the fake expect(s.resources.wood).toBe(1) // unchanged — fakes never count expect(s.players[SAB]?.carrying).toBeNull() })
it('dropping creates debris carrying the authenticity; inspect and expiry work', () => { let s = setup({ startRound: true }) s = doActivity(s, SAB, 'res-wood', 0, 'pretendCollect', 100) // fake s = reduce(s, { type: 'itemDropped', serverTime: 5_000, did: SAB, debrisId: 'd1', pos: { x: 1, y: 1 } }) expect(s.players[SAB]?.carrying).toBeNull() expect(s.debris['d1']?.authentic).toBe(false) expect(s.debris['d1']?.expiresAt).toBe(5_000 + s.config.debrisTtlMs)
s = reduce(s, { type: 'debrisInspected', serverTime: 6_000, did: A, debrisId: 'd1' }) expect(s.debris['d1']?.inspectedBy).toContain(A)
s = reduce(s, { type: 'debrisExpired', serverTime: 99_000, debrisId: 'd1' }) expect(s.debris['d1']).toBeUndefined() })})
describe('deposit station sabotage (consistent with collect)', () => { it('an authentic deposit on a sabotaged station is lost and breaks it when the dice hit', () => { let s = setup({ startRound: true }) s = doActivity(s, A, 'res-wood', 0, 'collect', 100) // A carries real wood s = doActivity(s, SAB, 'deposit', 0, 'sabotage', 100) // saboteur arms the deposit point s = doActivity(s, A, 'deposit', 0, 'deposit', 9_000, { sabotageTriggered: true }) expect(s.resources.wood).toBe(0) // lost — not banked expect(s.players[A]?.carrying).toBeNull() // and consumed expect(s.stations['deposit']?.state).toBe('broken') // now visibly broken })
it('a surviving deposit on a sabotaged station still counts and leaves it armed', () => { let s = setup({ startRound: true }) s = doActivity(s, A, 'res-wood', 0, 'collect', 100) s = doActivity(s, SAB, 'deposit', 0, 'sabotage', 100) s = doActivity(s, A, 'deposit', 0, 'deposit', 9_000, { sabotageTriggered: false }) expect(s.resources.wood).toBe(1) expect(s.stations['deposit']?.state).toBe('sabotaged') })
it('a fake deposit never rolls — the saboteur cannot trip their own deposit trap', () => { let s = setup({ startRound: true }) s = doActivity(s, SAB, 'deposit', 0, 'sabotage', 100) // arm it s = doActivity(s, SAB, 'res-wood', 0, 'pretendCollect', 9_000) // carry a fake s = doActivity(s, SAB, 'deposit', 0, 'fakeDeposit', 20_000, { sabotageTriggered: true }) expect(s.resources.wood).toBe(0) // fakes never count expect(s.players[SAB]?.carrying).toBeNull() expect(s.stations['deposit']?.state).toBe('sabotaged') // unaffected — no roll on a fake })})
describe('carry / discard / fakeDeposit', () => { it('the fake-carry break carve-out spends the fake (lost, no debris)', () => { let s = setup({ startRound: true }) s = doActivity(s, SAB, 'res-wood', 0, 'pretendCollect', 100) // SAB fake-carries wood s = doActivity(s, SAB, 'deposit', 0, 'breakStation', 5_000) // break the deposit point under cover expect(s.stations['deposit']?.state).toBe('broken') expect(s.players[SAB]?.carrying).toBeNull() // the fake was spent... expect(Object.keys(s.debris)).toHaveLength(0) // ...cleanly — no incriminating debris (vs a discard) })
it('fakeDeposit consumes the fake without banking or rolling', () => { let s = setup({ startRound: true }) s = doActivity(s, SAB, 'res-wood', 0, 'pretendCollect', 100) s = doActivity(s, SAB, 'deposit', 0, 'fakeDeposit', 5_000) expect(s.players[SAB]?.carrying).toBeNull() expect(s.resources.wood).toBe(0) // never counted })
it('rejects the wrong disposal: real carriers cannot fakeDeposit, fake carriers cannot deposit', () => { // real carrier → fakeDeposit blocked let real = setup({ startRound: true }) real = doActivity(real, A, 'res-wood', 0, 'collect', 100) // real wood const r = reduce(real, { type: 'actionStarted', serverTime: 5_000, did: A, stationId: 'deposit', slot: 0, kind: 'fakeDeposit', resourceId: 'wood', endsAt: 5_000 + T1(real), }) expect(r.players[A]?.active).toBeNull() // rejected, item still in hand
// fake carrier → real deposit blocked (it would bank a phantom resource) let fake = setup({ startRound: true }) fake = doActivity(fake, SAB, 'res-wood', 0, 'pretendCollect', 100) const f = reduce(fake, { type: 'actionStarted', serverTime: 5_000, did: SAB, stationId: 'deposit', slot: 0, kind: 'deposit', resourceId: 'wood', endsAt: 5_000 + T1(fake), }) expect(f.players[SAB]?.active).toBeNull() })
it('"discard and X" runs as an itemDropped then the action (so the action lands empty-handed)', () => { let s = setup({ startRound: true }) s = doActivity(s, A, 'res-wood', 0, 'collect', 100) // A carries real wood // the discard half: a free drop leaves debris carrying the real wood s = reduce(s, { type: 'itemDropped', serverTime: 5_000, did: A, debrisId: 'd', pos: { x: 0, y: 0 } }) expect(s.players[A]?.carrying).toBeNull() expect(s.debris['d']?.resourceId).toBe('wood') // ...then the action proceeds normally now that hands are empty s = doActivity(s, A, 'res-wood', 1, 'inspect', 6_000) expect(s.players[A]?.active).toBeNull() })})
describe('stateful scoreboard + voting stations', () => { it('callVote opens a vote on a working station', () => { let s = setup({ startRound: true }) expect(s.vote).toBeNull() s = doActivity(s, A, 'meeting', 0, 'callVote', 100) expect(s.vote).not.toBeNull() expect(s.vote?.endsAt).toBe(100 + timerFor('callVote', s.config) + s.config.voteDurationMs) })
it('a sabotaged voting station can break on a call — and then no vote opens', () => { let s = setup({ startRound: true }) s = doActivity(s, SAB, 'meeting', 0, 'sabotage', 100) // arm the voting station s = doActivity(s, A, 'meeting', 0, 'callVote', 9_000, { sabotageTriggered: true }) expect(s.stations['meeting']?.state).toBe('broken') expect(s.vote).toBeNull() // the call failed })
it('a surviving call on a sabotaged voting station still opens the vote and leaves it armed', () => { let s = setup({ startRound: true }) s = doActivity(s, SAB, 'meeting', 0, 'sabotage', 100) s = doActivity(s, A, 'meeting', 0, 'callVote', 9_000, { sabotageTriggered: false }) expect(s.vote).not.toBeNull() expect(s.stations['meeting']?.state).toBe('sabotaged') expect(s.stations['meeting']?.collectionsSinceSabotage).toBe(1) })
it('a sabotaged scoreboard breaks when a check trips the dice', () => { let s = setup({ startRound: true }) s = doActivity(s, SAB, 'scoreboard', 0, 'sabotage', 100) s = doActivity(s, A, 'scoreboard', 0, 'scoreboard', 9_000, { sabotageTriggered: true }) expect(s.stations['scoreboard']?.state).toBe('broken') })
it('pretendScoreboard never rolls — the saboteur cannot trip their own scoreboard trap', () => { let s = setup({ startRound: true }) s = doActivity(s, SAB, 'scoreboard', 0, 'sabotage', 100) const armed = s.stations['scoreboard']!.collectionsSinceSabotage s = doActivity(s, SAB, 'scoreboard', 0, 'pretendScoreboard', 9_000, { sabotageTriggered: true }) expect(s.stations['scoreboard']?.state).toBe('sabotaged') // still armed, not broken expect(s.stations['scoreboard']?.collectionsSinceSabotage).toBe(armed) // counter untouched })})
describe('voting', () => { it('starts a vote, records ballots, and kicks the majority target', () => { let s = setup({ startRound: true }) // 4 alive s = openVote(s, A, 100) // A calls a vote at the meeting station; it opens on resolve expect(s.vote).not.toBeNull() s = run( s, { type: 'voteCast', serverTime: 3_000, voterDid: A, targetDid: SAB }, { type: 'voteCast', serverTime: 3_001, voterDid: B, targetDid: SAB }, { type: 'voteCast', serverTime: 3_002, voterDid: DIDS[3], targetDid: SAB }, ) s = reduce(s, { type: 'voteEnded', serverTime: 3_100, kickedDid: SAB }) expect(s.players[SAB]?.status).toBe('kicked') expect(s.players[SAB]?.active).toBeNull() expect(s.vote).toBeNull() })})
describe('round end', () => { it('records the outcome and ends the game', () => { let s = setup({ startRound: true }) s = reduce(s, { type: 'roundEnded', serverTime: 1_000, outcome: 'workers' }) expect(s.phase).toBe('ended') expect(s.outcome).toBe('workers') })})