Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
22 kB · 605 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606// The pure game reducer: reduce(state, action) -> state.//// Invariants (see PLAN.md §3.7):// - Pure: no wall-clock reads, no RNG. Time comes from action.serverTime; random outcomes// come baked into the action (e.g. ResolveRng).// - `state.now` advances monotonically to max(now, serverTime).// - `state.seq` counts applied actions (the subscription cursor).// - Never mutates the input state; always returns a new object for anything that changed.
import { aStar } from './nav.js'import { isCollectLike } from './config.js'import type { Action } from './actions.js'import type { Carry, Did, Facing, GameState, Player, Slot, Station, StationState, Vec,} from './types.js'
export function reduce(state: GameState | undefined, action: Action): GameState { if (action.type === 'gameCreated') return createGame(action) if (!state) throw new Error('first action applied to the engine must be gameCreated') const now = Math.max(state.now, action.serverTime) // integrate path-following over the elapsed time, then apply the action's own effect const moved = advanceMovement(state, now) const next = apply({ ...moved, now }, action, now) return { ...next, seq: next.seq + 1 }}
function createGame( action: Extract<Action, { type: 'gameCreated' }>,): GameState { const stations: GameState['stations'] = {} for (const seed of action.stations) { stations[seed.id] = { id: seed.id, pos: seed.pos, area: seed.area, slots: Array.from({ length: seed.slotCount }, (): Slot => ({ occupantDid: null, collectCooldownUntil: 0, })), collect: seed.collect, deposit: seed.deposit, scoreboard: seed.scoreboard, meeting: seed.meeting, state: 'working', collectionsSinceSabotage: 0, sabotagedAt: null, revealedSabotageTo: [], } } const resources: Record<string, number> = {} for (const r of action.config.resources) resources[r] = 0 return { code: action.code, phase: 'lobby', now: action.serverTime, config: action.config, startedAt: null, endsAt: null, players: {}, stations, resources, debris: {}, vote: null, outcome: null, saboteurGraceEndsAt: null, nav: action.nav ?? null, seq: 1, }}
function apply(state: GameState, action: Action, now: number): GameState { switch (action.type) { case 'gameCreated': return state // handled in reduce()
case 'playerJoined': { if (state.phase !== 'lobby' || state.players[action.did]) return state const player: Player = { did: action.did, handle: action.handle, spriteRef: action.spriteRef, role: null, status: 'alive', pos: action.pos, facing: 'south', carrying: null, active: null, dest: null, path: [], } return { ...state, players: { ...state.players, [action.did]: player } } }
case 'playerDisconnected': { const leaving = state.players[action.did] const next = setPlayer(state, action.did, (p) => ({ ...p, status: 'disconnected' })) // §3.6: the round can't continue without the saboteur — arm the grace timer. (Workers just // idle in place.) A kicked saboteur never reaches here (no disconnect fires for non-alive). if (state.phase === 'round' && leaving?.role === 'saboteur') { return { ...next, saboteurGraceEndsAt: now + state.config.saboteurGraceMs } } return next }
case 'playerReconnected': { const returning = state.players[action.did] const next = setPlayer(state, action.did, (p) => p.status === 'disconnected' ? { ...p, status: 'alive' } : p, ) // the saboteur came back in time — disarm the abort. if (returning?.role === 'saboteur') return { ...next, saboteurGraceEndsAt: null } return next }
case 'roundStarted': { if (state.phase !== 'lobby') return state const players: Record<Did, Player> = {} for (const [did, p] of Object.entries(state.players)) { players[did] = { ...p, role: action.roles[did] ?? p.role, pos: action.spawns[did] ?? p.pos, dest: null, path: [], } } return { ...state, phase: 'round', players, config: action.config ?? state.config, // finalize balancing for the real participant count startedAt: action.serverTime, endsAt: action.endsAt, } }
case 'playerMoved': { // low-level teleport (spawns/debug): set position directly and cancel any path const p = state.players[action.did] if (!p || p.status !== 'alive' || p.active) return state return setPlayer(state, action.did, (pl) => ({ ...pl, pos: action.pos, facing: action.facing, dest: null, path: [], })) }
case 'setDestination': { const p = state.players[action.did] if (!p || p.status !== 'alive' || p.active) return state // no nav grid → fall back to a straight line to the target const path = state.nav ? aStar(state.nav, p.pos, action.target) : [action.target] if (path === null || path.length === 0) { // unreachable, or already there: clear any movement return setPlayer(state, action.did, (pl) => ({ ...pl, dest: null, path: [] })) } const last = path[path.length - 1]! return setPlayer(state, action.did, (pl) => ({ ...pl, dest: last, path })) }
case 'actionStarted': { const p = state.players[action.did] const station = state.stations[action.stationId] if (!p || p.status !== 'alive' || p.active || !station) return state // While carrying, only a few activities run WITHOUT first discarding the item: a real carrier // may deposit; a fake carrier may fakeDeposit (clean disposal) or breakStation (the cost-1 // carve-out, which spends the fake — see its resolve). Every other // activity is a "discard and X" compound: the server emits an itemDropped first, so by the time // its actionStarted lands the player is empty-handed and this guard is moot. A bare non-deposit // action while still carrying (no preceding drop) is therefore rejected here. if (p.carrying) { const fake = !p.carrying.authentic const noDiscardOk = action.kind === 'deposit' ? p.carrying.authentic : action.kind === 'fakeDeposit' || action.kind === 'breakStation' ? fake : false if (!noDiscardOk) return state } const slot = station.slots[action.slot] if (!slot || slot.occupantDid !== null) return state // cooldown gates Collect AND its disguise pretendCollect (so a fake collect can't be done // on a depleted slot when a real one couldn't — that would be a tell). Other activities // can use a cooling slot freely. if (isCollectLike(action.kind) && slot.collectCooldownUntil > now) return state const withPlayer = setPlayer(state, action.did, (pl) => ({ ...pl, dest: null, // stop moving to act path: [], active: { kind: action.kind, stationId: action.stationId, slot: action.slot, startedAt: now, endsAt: action.endsAt, resourceId: action.resourceId ?? null, }, })) return setSlot(withPlayer, action.stationId, action.slot, { occupantDid: action.did, }) }
case 'actionCancelled': { const p = state.players[action.did] if (!p || !p.active) return state const freed = setSlot(state, p.active.stationId, p.active.slot, { occupantDid: null, }) return setPlayer(freed, action.did, (pl) => ({ ...pl, active: null })) }
case 'actionResolved': return applyResolve(state, action.did, action.rng, now)
case 'itemDropped': { const p = state.players[action.did] if (!p || !p.carrying) return state const debris = { id: action.debrisId, pos: action.pos, authentic: p.carrying.authentic, resourceId: p.carrying.resourceId, expiresAt: now + state.config.debrisTtlMs, inspectedBy: [], } const withDebris = { ...state, debris: { ...state.debris, [action.debrisId]: debris }, } return setPlayer(withDebris, action.did, (pl) => ({ ...pl, carrying: null })) }
case 'debrisInspected': { const d = state.debris[action.debrisId] if (!d || d.inspectedBy.includes(action.did)) return state return { ...state, debris: { ...state.debris, [action.debrisId]: { ...d, inspectedBy: [...d.inspectedBy, action.did], }, }, } }
case 'debrisExpired': { if (!state.debris[action.debrisId]) return state const debris = { ...state.debris } delete debris[action.debrisId] return { ...state, debris } }
case 'voteCast': { const voter = state.players[action.voterDid] const target = state.players[action.targetDid] if (!state.vote || !voter || voter.status !== 'alive' || !target) return state return { ...state, vote: { ...state.vote, ballots: { ...state.vote.ballots, [action.voterDid]: action.targetDid }, }, } }
case 'voteEnded': { if (!state.vote) return state let next: GameState = { ...state, vote: null } if (action.kickedDid) { const kicked = state.players[action.kickedDid] if (kicked) { if (kicked.active) { next = setSlot(next, kicked.active.stationId, kicked.active.slot, { occupantDid: null, }) } next = setPlayer(next, action.kickedDid, (pl) => ({ ...pl, status: 'kicked', active: null, carrying: null, dest: null, path: [], })) } } return next }
case 'roundEnded': { if (state.phase === 'ended') return state return { ...state, phase: 'ended', outcome: action.outcome, vote: null } }
case 'tick': return state // time already advanced by the wrapper }}
// --- activity resolution ----------------------------------------------------
function applyResolve( state: GameState, did: Did, rng: { sabotageTriggered?: boolean; fakeRepairExtra?: number } | undefined, now: number,): GameState { const p = state.players[did] if (!p || !p.active) return state const act = p.active const station = state.stations[act.stationId]
let stationState: StationState | undefined = station?.state let counter = station?.collectionsSinceSabotage ?? 0 let revealed = station?.revealedSabotageTo ?? [] let sabotagedAt = station?.sabotagedAt ?? null let carry: Carry | null = p.carrying let resources = state.resources let openVote = false // set by a successful callVote; the vote is opened after the station write
switch (act.kind) { case 'collect': { // which resource is chosen rides on the action (a station may collect several); it must be one // this station actually collects. if (!station || act.resourceId === null || !station.collect.includes(act.resourceId)) break if (station.state === 'working') { carry = { authentic: true, resourceId: act.resourceId } } else if (station.state === 'sabotaged') { counter = counter + 1 if (rng?.sabotageTriggered) { stationState = 'broken' revealed = [] // broken is visible to all; clear stale per-viewer knowledge sabotagedAt = null } else { carry = { authentic: true, resourceId: act.resourceId } } } // broken: collect is disabled — no effect (defensive) break } case 'pretendCollect': { // A fake collect NEVER rolls the break dice and never advances the sabotage counter — the // saboteur must be able to "collect" cover without tripping their own trap (a real repair is // usually followed by a real collect, so a fakeRepair + fakeCollect should look identical). if (!station || act.resourceId === null || !station.collect.includes(act.resourceId)) break if (station.state !== 'broken') { carry = { authentic: false, resourceId: act.resourceId } } break } case 'repair': { stationState = 'working' counter = 0 revealed = [] sabotagedAt = null break } case 'inspect': { if (station?.state === 'sabotaged' && !revealed.includes(did)) { revealed = [...revealed, did] } break } case 'breakStation': { stationState = 'broken' revealed = [] sabotagedAt = null // The fake-carry carve-out: breaking while holding a fake SPENDS it — the fake is lost (no // debris), the saboteur's clean alternative to a fakeDeposit at a deposit point they can only // break under cover. Empty-handed and post-discard breaks have nothing in hand to clear. if (p.carrying && !p.carrying.authentic) carry = null break } case 'fakeRepair': { // Re-sabotages a BROKEN station (broken -> sabotaged, with grace). Does nothing on a working // or already-sabotaged station — the menu only offers it when broken. if (station?.state === 'broken') { stationState = 'sabotaged' counter = -(rng?.fakeRepairExtra ?? 1) // extra grace before the dice can trigger sabotagedAt = now revealed = [did] // the saboteur knows their own work; others see a "repaired" station } break } case 'sabotage': { stationState = 'sabotaged' counter = 0 sabotagedAt = now // The saboteur has free knowledge of their own action (others must inspect to learn). // Inspecting later just tells the saboteur what they already know — it's worker cover. // The trigger TIMING (counter/sabotagedAt) never leaves the server for anyone. revealed = [did] break } case 'deposit': { // deposit banks the CARRIED resource; the station must accept it (validated upstream, guarded // here for determinism). Sabotage affects deposit exactly like collect: an authentic deposit // at a sabotaged station rolls the failure dice (on a hit the resource is LOST and the station // breaks). A fake deposit never rolls and never counts — it just vanishes (saboteur cover). if (p.carrying && station && station.deposit.includes(p.carrying.resourceId)) { const id = p.carrying.resourceId if (p.carrying.authentic) { if (station.state === 'sabotaged') { counter = counter + 1 if (rng?.sabotageTriggered) { stationState = 'broken' revealed = [] // broken is visible to all; clear stale per-viewer knowledge sabotagedAt = null } else { resources = { ...resources, [id]: (resources[id] ?? 0) + 1 } } } else { // working (broken deposits are blocked upstream) resources = { ...resources, [id]: (resources[id] ?? 0) + 1 } } } carry = null } break } case 'fakeDeposit': { // The saboteur's clean disposal of a fake carry: the item simply vanishes. Like every fake // interaction it NEVER rolls the failure dice and never advances the counter, so it can't trip // a sabotaged deposit point — and it leaves no debris, so (unlike a discard) there's nothing to // inspect. A real carry can't reach here (guarded above); guarded again for determinism. if (p.carrying && !p.carrying.authentic) carry = null break } case 'scoreboard': { // "check resources": info only on success (surfaced via projection/event). But a sabotaged // scoreboard rolls the failure dice like any real interaction — a trip just breaks the station // (nothing carried or banked to lose). Broken is blocked upstream. if (station?.state === 'sabotaged') { counter = counter + 1 if (rng?.sabotageTriggered) { stationState = 'broken' revealed = [] sabotagedAt = null } } break } case 'pretendScoreboard': break // fake check: never rolls, never counts, yields no info — pure cover, like pretendCollect
case 'callVote': { // "call vote" opens a vote on success. A sabotaged voting station rolls the dice first: a trip // breaks it and NO vote opens (the saboteur's way to suppress meetings); otherwise the vote // opens. Broken is blocked upstream. A fake interaction has no callVote analogue (you can't fake // a vote), so every callVote is "real" and rolls. if (station?.state === 'sabotaged') { counter = counter + 1 if (rng?.sabotageTriggered) { stationState = 'broken' revealed = [] sabotagedAt = null } else { openVote = true } } else if (station?.state === 'working') { openVote = true } break } }
// write the station: apply state changes + free the slot; only a Collect starts a cooldown let next = state if (station) { const slots = station.slots.map((s, i): Slot => i === act.slot ? { occupantDid: null, // only a REAL collect depletes the slot. A fake collect deliberately does not: it // doesn't deny workers the slot (low reward) and leaves a tell — a careful observer // can notice the slot stayed collectable when a real collect would have cooled it. collectCooldownUntil: act.kind === 'collect' ? now + state.config.slotCooldownMs : s.collectCooldownUntil, } : s, ) const newStation: Station = { ...station, slots, state: stationState ?? station.state, collectionsSinceSabotage: counter, sabotagedAt, revealedSabotageTo: revealed, } next = { ...next, stations: { ...next.stations, [station.id]: newStation } } } next = { ...next, resources } // a successful callVote opens the vote here, as the resolution's own consequence — the dice-roll // and the vote-open are one atomic, replayable step (there is no separate vote-start action). // Guarded against a double-open. if (openVote && next.phase === 'round' && !next.vote) { next = { ...next, vote: { startedAt: now, endsAt: now + state.config.voteDurationMs, ballots: {} }, } } return setPlayer(next, did, (pl) => ({ ...pl, active: null, carrying: carry }))}
// --- movement integration ---------------------------------------------------
/** Advance every moving player along their path for the elapsed time (state.now -> toNow). */function advanceMovement(state: GameState, toNow: number): GameState { const dt = toNow - state.now if (dt <= 0) return state const step = (state.config.moveSpeedPxPerSec * dt) / 1000 let players = state.players let changed = false for (const [did, p] of Object.entries(state.players)) { if (p.status !== 'alive' || p.path.length === 0) continue const moved = stepAlongPath(p, step) if (moved !== p) { if (!changed) { players = { ...state.players } changed = true } players[did] = moved } } return changed ? { ...state, players } : state}
/** Move a player up to `distance` px along their waypoint path, consuming reached waypoints. */function stepAlongPath(p: Player, distance: number): Player { let pos: Vec = p.pos let facing: Facing = p.facing let remaining = distance let i = 0 while (i < p.path.length && remaining > 0) { const wp = p.path[i]! const dx = wp.x - pos.x const dy = wp.y - pos.y const d = Math.hypot(dx, dy) facing = dirOf(dx, dy, facing) if (d <= remaining) { pos = wp remaining -= d i++ } else { const t = remaining / d pos = { x: pos.x + dx * t, y: pos.y + dy * t } remaining = 0 } } const path = i > 0 ? p.path.slice(i) : p.path const arrived = path.length === 0 return { ...p, pos, facing, path, dest: arrived ? null : p.dest }}
/** Facing from a movement vector — vertical wins ties, matching the Pixi walker. */function dirOf(dx: number, dy: number, fallback: Facing): Facing { if (dx === 0 && dy === 0) return fallback if (Math.abs(dy) >= Math.abs(dx)) return dy < 0 ? 'north' : 'south' return dx < 0 ? 'west' : 'east'}
// --- immutable helpers ------------------------------------------------------
function setPlayer( state: GameState, did: Did, fn: (p: Player) => Player,): GameState { const p = state.players[did] if (!p) return state return { ...state, players: { ...state.players, [did]: fn(p) } }}
function setSlot( state: GameState, stationId: string, slot: number, patch: Partial<Slot>,): GameState { const station = state.stations[stationId] if (!station || !station.slots[slot]) return state const slots = station.slots.map((s, i) => (i === slot ? { ...s, ...patch } : s)) return { ...state, stations: { ...state.stations, [stationId]: { ...station, slots } }, }}