Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
20 kB · 526 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527// Pure read-side helpers over GameState:// - projectFor(): the per-viewer redaction (PLAN.md §3.5 / §1.11)// - outcome / vote / target computations// - pendingResolutions(): what time-driven actions are due at state.now (server uses this to// emit actionResolved / debrisExpired / voteEnded / roundEnded with any RNG baked in)
import { isCollectLike } from './config.js'import type { ActivityKind, Did, GameState, Outcome, ResourceId, StationState, Vec,} from './types.js'
// --- projection (redaction) -------------------------------------------------
export interface ProjectedActive { stationId: string startedAt: number endsAt: number /** the activity choice is secret to others (you can see dwell time, not collect-vs-sabotage) */ kind: ActivityKind | null // note: which slot is occupied is never exposed}
export interface ProjectedCarry { resourceId: ResourceId /** real/fake is hidden from others (null) until game end */ authentic: boolean | null}
export interface ProjectedPlayer { did: Did handle: string spriteRef: string | null role: GameState['players'][string]['role'] | null status: GameState['players'][string]['status'] pos: GameState['players'][string]['pos'] facing: GameState['players'][string]['facing'] carrying: ProjectedCarry | null active: ProjectedActive | null /** movement target/path — only your own (others are conveyed by streamed position updates) */ dest: Vec | null path: Vec[]}
export interface ProjectedStation { id: string pos: GameState['stations'][string]['pos'] area: GameState['stations'][string]['area'] /** capacity only — per-slot occupancy and cooldowns are secret and never projected */ slotCount: number /** sabotaged looks like 'working' to anyone who hasn't inspected it */ state: StationState /** capability set (in-play resources for collect/deposit/scoreboard) — what this station can do */ collect: ResourceId[] deposit: ResourceId[] scoreboard: ResourceId[] meeting: boolean}
export interface ProjectedDebris { id: string pos: GameState['debris'][string]['pos'] resourceId: ResourceId /** real/fake hidden until this viewer inspects it (or game end) */ authentic: boolean | null}
export interface ProjectedState { code: string phase: GameState['phase'] now: number config: GameState['config'] startedAt: number | null endsAt: number | null resources: Record<ResourceId, number> players: Record<Did, ProjectedPlayer> stations: Record<string, ProjectedStation> debris: Record<string, ProjectedDebris> vote: GameState['vote'] outcome: Outcome | null seq: number}
/** * How many leading waypoints of another player's path a viewer may see. A player's *position* is * not secret (it streams as playerMoved), but their intended *destination* is — so we reveal only a * short forward stub: enough for clients to interpolate smoothly and to drive cosmetic auto-gates, * not enough to read where someone is ultimately walking. `dest` itself stays self-only. */export const STUB_WAYPOINTS = 2
/** * Produce the view a given `viewer` is allowed to see. `viewer === null` is a spectator * (kicked player or non-player). At game end everything is revealed. */export function projectFor(state: GameState, viewer: Did | null): ProjectedState { const ended = state.phase === 'ended'
const players: Record<Did, ProjectedPlayer> = {} for (const [did, p] of Object.entries(state.players)) { const self = did === viewer const seeSecret = self || ended players[did] = { did: p.did, handle: p.handle, spriteRef: p.spriteRef, role: seeSecret ? p.role : null, status: p.status, pos: p.pos, facing: p.facing, carrying: p.carrying ? { resourceId: p.carrying.resourceId, authentic: seeSecret ? p.carrying.authentic : null, } : null, active: p.active ? { stationId: p.active.stationId, startedAt: p.active.startedAt, endsAt: p.active.endsAt, kind: seeSecret ? p.active.kind : null, } : null, dest: seeSecret ? p.dest : null, path: seeSecret ? p.path : p.path.slice(0, STUB_WAYPOINTS), } }
const stations: Record<string, ProjectedStation> = {} for (const [id, s] of Object.entries(state.stations)) { const knows = ended || (viewer !== null && s.revealedSabotageTo.includes(viewer)) const shownState: StationState = s.state === 'sabotaged' && !knows ? 'working' : s.state stations[id] = { id: s.id, pos: s.pos, area: s.area, slotCount: s.slots.length, state: shownState, collect: s.collect, deposit: s.deposit, scoreboard: s.scoreboard, meeting: s.meeting, } }
const debris: Record<string, ProjectedDebris> = {} for (const [id, d] of Object.entries(state.debris)) { const knows = ended || (viewer !== null && d.inspectedBy.includes(viewer)) debris[id] = { id: d.id, pos: d.pos, resourceId: d.resourceId, authentic: knows ? d.authentic : null, } }
return { code: state.code, phase: state.phase, now: state.now, config: state.config, startedAt: state.startedAt, endsAt: state.endsAt, resources: { ...state.resources }, players, stations, debris, vote: state.vote, outcome: state.outcome, seq: state.seq, }}
// --- station interaction (the menu you get when you activate a station) ------
/** A choice offered at a station. Station activities use a timer; the rest are instant intents. * `resourceId` is set for collect / pretendCollect / deposit (a station may handle several). The * client builds the human label from `resourceId` + the resources record — no display strings cross * the wire (keys only). */export interface StationOption { kind: ActivityKind | 'doNothing' resourceId?: ResourceId /** "discard and X": this option first drops the carried item (a free collection drop, leaving * debris) and then performs `kind`. Set only on options taken while carrying something that has to * be dropped first; the server realises it as an itemDropped immediately followed by the action. * Absent (falsy) for plain options and for the no-discard shortcuts (deposit / fakeDeposit / the * fake-carry break carve-out). */ discardFirst?: boolean}
export interface StationInteraction { stationId: string /** the station's state as THIS player sees it (a sabotaged station they don't know about reads * as 'working') — i.e. whether it visibly looks broken */ state: StationState broken: boolean /** can you Collect right now (a collect capability + an open, regenerated slot, not broken)? * Deliberately does NOT distinguish busy-vs-cooling — slot state is secret. */ canCollect: boolean /** capability hints (in-play resources) for client labelling */ collect: ResourceId[] deposit: ResourceId[] scoreboard: ResourceId[] meeting: boolean /** the activities/intents this player may choose here, given role, capabilities, state, and carry */ options: StationOption[]}
/** How a station's true state looks to `did` (sabotaged reads as working unless they know). */function visibleStateFor(state: GameState, did: Did, st: GameState['stations'][string]): StationState { const knows = state.phase === 'ended' || st.revealedSabotageTo.includes(did) return st.state === 'sabotaged' && !knows ? 'working' : st.state}
interface StationCaps { hasCollect: boolean hasDeposit: boolean hasScoreboard: boolean hasMeeting: boolean /** EVERY station carries working/broken/sabotaged state and supports inspect/repair (and, for the * saboteur, sabotage/break/fakeRepair). True whenever the station has any capability at all. */ isStateful: boolean /** may the saboteur break/sabotage this station BARE-HANDED? A deposit point gates that (they must * bring fake-carry cover — the break carve-out); a collect capability re-enables it, and stations * with no deposit capability (collect / scoreboard / voting) allow it freely. */ bareBreakOk: boolean /** its ONLY use is depositing — a pure drop-off point (no collect, scoreboard or meeting) */ depositOnly: boolean}
function capsOf(st: GameState['stations'][string]): StationCaps { const hasCollect = st.collect.length > 0 const hasDeposit = st.deposit.length > 0 return { hasCollect, hasDeposit, hasScoreboard: st.scoreboard.length > 0, hasMeeting: st.meeting, isStateful: hasCollect || hasDeposit || st.scoreboard.length > 0 || st.meeting, bareBreakOk: hasCollect || !hasDeposit, depositOnly: hasDeposit && !hasCollect && st.scoreboard.length === 0 && !st.meeting, }}
/** * The activity *kinds* legal for `did` with EMPTY hands, given role + the station's capabilities and * (visible) state — IGNORING slot/cooldown availability and the specific resource. This is the basis * both for the empty-handed menu AND for the "discard and X" expansion (you can only discard-then-X * if X is legal empty-handed). * * State applies station-wide (PLAN §1.4/§1.5) to EVERY station: working/broken/sabotaged. Broken * blocks the station's real action (repair only); a sabotaged station rolls the failure dice on each * real interaction — collect, deposit, check-resources (scoreboard) and call-vote (meeting) alike. * Inspect/Repair are offered on any station. Break/Sabotage are offered to the saboteur on any * station too, EXCEPT they need cover at a pure deposit point (see {@link StationCaps.bareBreakOk}); * collect/deposit/scoreboard/meeting actions of course each require their own capability. The * saboteur also gets fake variants that never roll: pretendCollect, pretendScoreboard (there is no * fake call-vote — a vote either opens or it doesn't). * * `callVote` here means "capability-legal"; the menu/validator additionally suppress it while a vote * is already in progress (a state-, not capability-, level gate). */function emptyHandedKinds( isSaboteur: boolean, caps: StationCaps, visible: StationState,): ActivityKind[] { const acts: ActivityKind[] = [] if (visible === 'broken') { if (caps.isStateful) acts.push('repair') if (caps.isStateful && isSaboteur) acts.push('fakeRepair') // re-sabotage a broken station return acts } if (caps.hasCollect) { acts.push('collect') if (isSaboteur) acts.push('pretendCollect') } if (caps.hasScoreboard) { acts.push('scoreboard') if (isSaboteur) acts.push('pretendScoreboard') // fake "check resources" — cover that never rolls } if (caps.hasMeeting) acts.push('callVote') if (caps.isStateful) { // Repair is offered on a non-broken station too — it clears a *suspected* (uninspected) sabotage. acts.push('inspect', 'repair') if (isSaboteur) { if (caps.bareBreakOk) acts.push('sabotage', 'breakStation') if (visible === 'sabotaged') acts.push('fakeRepair') // cover that keeps the trap } } return acts}
/** * The activity kinds legal for `did` RIGHT NOW without first discarding — i.e. their no-discard * options. Empty-handed this is {@link emptyHandedKinds}; while carrying it narrows to the shortcuts * that keep/spend the carry directly: a real carrier may `deposit`; a fake carrier may `fakeDeposit` * (clean disposal) or take the cost-1 `breakStation` carve-out (which spends the fake — lost, no * debris — their deposit-point cover). Everything else while carrying is a "discard and X" compound, * surfaced by the menu and validated via {@link activityLegal} with `discard: true`. */export function legalActivities(state: GameState, did: Did, stationId: string): ActivityKind[] { const p = state.players[did] const st = state.stations[stationId] if (!p || !st) return [] const caps = capsOf(st) const visible = visibleStateFor(state, did, st) const carry = p.carrying if (!carry) return emptyHandedKinds(p.role === 'saboteur', caps, visible)
const acts: ActivityKind[] = [] const accepts = caps.hasDeposit && visible !== 'broken' && st.deposit.includes(carry.resourceId) if (carry.authentic) { if (accepts) acts.push('deposit') } else { if (accepts) acts.push('fakeDeposit') // cost-1 break carve-out: a fake carrier may break any non-broken station by SPENDING the fake // (lost, no debris) — their cover at a deposit point that gates bare-handed breaks. if (p.role === 'saboteur' && visible !== 'broken' && caps.isStateful) { acts.push('breakStation') } } return acts}
/** * Is `kind` a legal station activity for `did` (role/caps/state/carry), optionally as the tail of a * "discard and X" compound? IGNORES slot availability and the specific resource — validate.ts layers * those on. `discard: true` means the player will drop their carry first, so legality is judged * empty-handed — and they must actually be carrying something to drop. */export function activityLegal( state: GameState, did: Did, stationId: string, kind: ActivityKind, discard = false,): boolean { const p = state.players[did] const st = state.stations[stationId] if (!p || !st) return false if (discard) { if (!p.carrying) return false return emptyHandedKinds( p.role === 'saboteur', capsOf(st), visibleStateFor(state, did, st), ).includes(kind) } return legalActivities(state, did, stationId).includes(kind)}
/** * What a player can do at a station right now — for rendering the interaction menu. Reflects only * what the player is allowed to know (a sabotaged station they haven't inspected looks working). * Built from the empty-handed legal set plus the carry rules (deposit / fakeDeposit / the cost-1 * break carve-out / "discard and X"), gated by slot availability and fanned out per resource for * collect/pretendCollect. This is the acting player's own view; validate.ts is the source of truth. */export function stationInteraction( state: GameState, did: Did, stationId: string,): StationInteraction | null { const p = state.players[did] const st = state.stations[stationId] if (!p || !st) return null
const caps = capsOf(st) const visible = visibleStateFor(state, did, st) const isSaboteur = p.role === 'saboteur' const carry = p.carrying const hasFreeSlot = st.slots.some((s) => s.occupantDid === null) const hasCollectableSlot = st.slots.some( (s) => s.occupantDid === null && s.collectCooldownUntil <= state.now, ) const canCollect = caps.hasCollect && !carry && visible !== 'broken' && hasCollectableSlot
const voteOpen = state.vote !== null
const options: StationOption[] = [{ kind: 'doNothing' }] // Push an empty-handed-legal kind, honoring slot gating + the collect/pretendCollect resource // fan-out. `discardFirst` tags it as a "discard and X" compound (taken while carrying). const pushKind = (kind: ActivityKind, discardFirst: boolean) => { if (kind === 'callVote' && voteOpen) return // can't call a vote while one is already running const tag = discardFirst ? { discardFirst: true } : {} if (kind === 'collect' || kind === 'pretendCollect') { if (hasCollectableSlot) for (const r of st.collect) options.push({ kind, resourceId: r, ...tag }) } else if (hasFreeSlot) { options.push({ kind, ...tag }) } }
const empties = emptyHandedKinds(isSaboteur, caps, visible)
if (!carry) { for (const k of empties) pushKind(k, false) } else { const accepts = caps.hasDeposit && visible !== 'broken' && st.deposit.includes(carry.resourceId) if (carry.authentic) { // Real carry: deposit (no discard) where accepted. A pure deposit point you CAN deposit at // offers deposit ONLY — "you need to deposit, no breaking". Anywhere else (a multi-use station, // or one that won't take this resource) the empty-handed actions become "discard and X". if (accepts && hasFreeSlot) options.push({ kind: 'deposit', resourceId: carry.resourceId }) if (!(caps.depositOnly && accepts)) { for (const k of empties) pushKind(k, true) } } else { // Fake carry: fakeDeposit (clean) where accepted + the cost-1 break carve-out (spends the fake, // no debris), and "discard and X" for everything else (which drops incriminating fake debris). // Break here is always the carve-out, never a discard. if (accepts && hasFreeSlot) options.push({ kind: 'fakeDeposit', resourceId: carry.resourceId }) const carveBreak = isSaboteur && visible !== 'broken' && caps.isStateful && hasFreeSlot if (carveBreak) options.push({ kind: 'breakStation' }) for (const k of empties) { if (k === 'breakStation' && carveBreak) continue // covered by the carve-out above pushKind(k, true) } } }
return { stationId, state: visible, broken: visible === 'broken', canCollect, collect: st.collect, deposit: st.deposit, scoreboard: st.scoreboard, meeting: st.meeting, options, }}
// --- outcome / win ----------------------------------------------------------
export function workersMetTargets(state: GameState): boolean { return state.config.resources.every( (r) => (state.resources[r] ?? 0) >= (state.config.targets[r] ?? 0), )}
export function saboteurCaught(state: GameState): boolean { return Object.values(state.players).some( (p) => p.role === 'saboteur' && p.status === 'kicked', )}
/** Resolve the three-way outcome from the current totals/kicks. */export function computeOutcome(state: GameState): Outcome { if (workersMetTargets(state)) return 'workers' if (saboteurCaught(state)) return 'no-one' return 'saboteur'}
// --- voting -----------------------------------------------------------------
function aliveCount(state: GameState): number { return Object.values(state.players).filter((p) => p.status === 'alive').length}
/** * The player who currently has a strict majority (> half of alive players) of the ballots, or * null if no one does. Used both for early-end detection and for timeout resolution. */export function computeVoteResult(state: GameState): Did | null { if (!state.vote) return null const threshold = Math.floor(aliveCount(state) / 2) + 1 const tally: Record<Did, number> = {} for (const target of Object.values(state.vote.ballots)) { tally[target] = (tally[target] ?? 0) + 1 } let best: Did | null = null let bestCount = 0 for (const [target, count] of Object.entries(tally)) { if (count > bestCount) { best = target bestCount = count } } return best !== null && bestCount >= threshold ? best : null}
// --- time-driven resolutions ------------------------------------------------
export type Pending = | { kind: 'action'; did: Did } | { kind: 'debris'; debrisId: string } | { kind: 'vote' } | { kind: 'round' } | { kind: 'grace' }
/** Everything that has come due at state.now and needs the server to emit a resolution action. */export function pendingResolutions(state: GameState): Pending[] { const out: Pending[] = [] for (const p of Object.values(state.players)) { if (p.active && p.active.endsAt <= state.now) out.push({ kind: 'action', did: p.did }) } for (const d of Object.values(state.debris)) { if (d.expiresAt <= state.now) out.push({ kind: 'debris', debrisId: d.id }) } // a vote resolves either on its timer or as soon as a majority is reached (checked each tick) if (state.vote && (state.vote.endsAt <= state.now || computeVoteResult(state) !== null)) { out.push({ kind: 'vote' }) } if (state.phase === 'round' && state.endsAt !== null && state.endsAt <= state.now) { out.push({ kind: 'round' }) } // §3.6: saboteur abandoned and didn't return within the grace window → abort. Listed after // 'round' so if both come due together the natural outcome wins (the abort then no-ops). if ( state.phase === 'round' && state.saboteurGraceEndsAt !== null && state.saboteurGraceEndsAt <= state.now ) { out.push({ kind: 'grace' }) } return out}