// 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 players: Record stations: Record debris: Record 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 = {} 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 = {} 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 = {} 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 = {} 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 }