Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
8.1 kB · 215 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216import { describe, expect, it } from 'vitest'import { reduce } from './reducer.js'import { balance, resourceIds } from './config.js'import { validateStationActivity } from './validate.js'import { assignRoles, closeLobby, createLobby, evaluateStart, isPlayer, joinAction, pickSaboteur, startGame, validateDeclaration, type GameDeclaration,} from './lobby.js'import { openNav, standardStations } from './testutil.js'import type { GameState } from './types.js'
const ROSTER = ['did:a', 'did:b', 'did:c', 'did:d', 'did:e']const DECL: GameDeclaration = { players: ROSTER, roundDurationSec: 600, resourceCount: 4 }const STATIONS = standardStations(resourceIds(4), 3)
function fold(actions: ReturnType<typeof startGame>): GameState { if (actions.kind !== 'start') throw new Error('expected start') let s: GameState | undefined for (const a of actions.actions) s = reduce(s, a) return s!}
describe('declaration', () => { it('accepts a 4-10 unique-player roster', () => { expect(validateDeclaration(DECL).ok).toBe(true) }) it('rejects too few, too many, or duplicate players', () => { expect(validateDeclaration({ ...DECL, players: ['did:a', 'did:b', 'did:c'] }).ok).toBe(false) expect( validateDeclaration({ ...DECL, players: Array.from({ length: 11 }, (_, i) => `did:${i}`) }).ok, ).toBe(false) expect(validateDeclaration({ ...DECL, players: ['did:a', 'did:a', 'did:b', 'did:c'] }).ok).toBe( false, ) }) it('authorizes only roster DIDs to push (subscribing is open to all)', () => { expect(isPlayer(DECL, 'did:a')).toBe(true) expect(isPlayer(DECL, 'did:x')).toBe(false) })})
describe('dev solo mode (minPlayers = 1)', () => { const SOLO: GameDeclaration = { players: ['did:solo'], roundDurationSec: 600, resourceCount: 4 }
it('rejects a one-player roster by default but accepts it when minPlayers is lowered', () => { expect(validateDeclaration(SOLO).ok).toBe(false) expect(validateDeclaration(SOLO, 1).ok).toBe(true) })
it('starts a one-player round with that player as the saboteur', () => { const res = closeLobby({ declaration: SOLO, connectedDids: ['did:solo'], saboteurRoll: 0, startTime: 0, minPlayers: 1, }) expect(res.kind).toBe('start') if (res.kind !== 'start') return expect(res.saboteurDid).toBe('did:solo') expect(res.action.type).toBe('roundStarted') if (res.action.type === 'roundStarted') expect(res.action.roles['did:solo']).toBe('saboteur') })
it('still voids a one-player lobby under the default minimum', () => { expect(closeLobby({ declaration: SOLO, connectedDids: ['did:solo'], saboteurRoll: 0, startTime: 0 }).kind).toBe( 'void', ) })})
describe('role assignment', () => { it('picks a saboteur deterministically from a roll', () => { expect(pickSaboteur(ROSTER, 0)).toBe('did:a') expect(pickSaboteur(ROSTER, 0.99)).toBe('did:e') }) it('assigns exactly one saboteur, the rest workers', () => { const roles = assignRoles(ROSTER, 'did:c') expect(roles['did:c']).toBe('saboteur') expect(Object.values(roles).filter((r) => r === 'saboteur')).toHaveLength(1) expect(Object.values(roles).filter((r) => r === 'worker')).toHaveLength(4) })})
describe('evaluateStart (count only — no saboteur-absent void)', () => { it('starts with everyone connected', () => { expect(evaluateStart(ROSTER, ROSTER)).toEqual({ kind: 'start', players: ROSTER }) }) it('starts with the connected subset, dropping absent players', () => { expect(evaluateStart(ROSTER, ['did:a', 'did:b', 'did:c', 'did:d'])).toEqual({ kind: 'start', players: ['did:a', 'did:b', 'did:c', 'did:d'], }) }) it('voids when fewer than the minimum connect', () => { expect(evaluateStart(ROSTER, ['did:a', 'did:b', 'did:c'])).toEqual({ kind: 'void', reason: 'not-enough-players', }) })})
describe('grace period', () => { it('opens a movement-only lobby world before the round starts', () => { let s = reduce(undefined, createLobby({ declaration: DECL, stations: STATIONS, nav: openNav(), startTime: 0 })) expect(s.phase).toBe('lobby') // players trickle in during grace s = reduce(s, joinAction('did:a', { pos: { x: 0, y: 0 }, serverTime: 100 })) s = reduce(s, joinAction('did:b', { pos: { x: 0, y: 0 }, serverTime: 5_000 })) expect(Object.keys(s.players)).toHaveLength(2)
// MOVEMENT works during the grace period s = reduce(s, { type: 'setDestination', serverTime: 6_000, did: 'did:a', target: { x: 80, y: 0 } }) const dest = s.players['did:a']!.dest expect(dest).not.toBeNull() expect(s.players['did:a']!.path.length).toBeGreaterThan(0) s = reduce(s, { type: 'tick', serverTime: 10_000_000 }) expect(s.players['did:a']!.pos).toEqual(dest) expect(s.players['did:a']!.path).toHaveLength(0)
// ...but station activities are blocked until the round actually starts expect(validateStationActivity(s, 'did:a', 'res-wood', 'collect')).toEqual({ ok: false, reason: 'not-in-round', }) expect(s.phase).toBe('lobby') // still warming up })
it('picks the saboteur from the CONNECTED players at grace-end and finalizes balance', () => { let s = reduce(undefined, createLobby({ declaration: DECL, stations: STATIONS, nav: openNav(), startTime: 0 })) // roster of 5; e never connects for (const [i, did] of ['did:a', 'did:b', 'did:c', 'did:d'].entries()) { s = reduce(s, joinAction(did, { serverTime: 100 * (i + 1) })) } // config in the lobby was sized for the roster (5) expect(s.config.slotCooldownMs).toBe(balance({ playerCount: 5, roundDurationMs: 600_000, resourceCount: 4 }).config.slotCooldownMs)
const res = closeLobby({ declaration: DECL, connectedDids: ['did:a', 'did:b', 'did:c', 'did:d'], saboteurRoll: 0.99, // last of the participating four startTime: 30_000, }) expect(res.kind).toBe('start') if (res.kind !== 'start') return expect(res.saboteurDid).toBe('did:d') // from connected, never the absent 'did:e'
s = reduce(s, res.action) expect(s.phase).toBe('round') expect(s.players['did:d']!.role).toBe('saboteur') expect(s.endsAt).toBe(30_000 + 600 * 1_000) // balance finalized for the 4 who actually played (not the roster of 5) expect(s.config.slotCooldownMs).toBe(balance({ playerCount: 4, roundDurationMs: 600_000, resourceCount: 4 }).config.slotCooldownMs) })
it('voids if too few have connected when grace ends', () => { expect( closeLobby({ declaration: DECL, connectedDids: ['did:a', 'did:b', 'did:c'], saboteurRoll: 0, startTime: 0 }), ).toEqual({ kind: 'void', reason: 'not-enough-players' }) })})
describe('startGame (instant, no grace)', () => { it('produces a runnable round with the saboteur drawn from the connected players', () => { const res = startGame({ declaration: DECL, connectedDids: ROSTER, saboteurRoll: 0.5, // index 2 of 5 -> 'did:c' stations: STATIONS, nav: openNav(), startTime: 1_000, }) expect(res.kind).toBe('start') if (res.kind !== 'start') return expect(res.saboteurDid).toBe('did:c') const s = fold(res) expect(s.phase).toBe('round') expect(Object.keys(s.players)).toHaveLength(5) expect(s.players['did:c']!.role).toBe('saboteur') })
it('balances to the connected count, not the roster', () => { const res = startGame({ declaration: DECL, // roster of 5 connectedDids: ['did:a', 'did:b', 'did:c', 'did:d'], saboteurRoll: 0, stations: STATIONS, nav: openNav(), startTime: 0, }) const s = fold(res) expect(Object.keys(s.players)).toHaveLength(4) })
it('voids when too few connect', () => { expect( startGame({ declaration: DECL, connectedDids: ['did:a', 'did:b'], saboteurRoll: 0, stations: STATIONS, nav: openNav(), startTime: 0, }), ).toEqual({ kind: 'void', reason: 'not-enough-players' }) })})