Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
15 kB · 400 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401// Adapter between the engine's in-memory shapes and the generated wire types.//// Three encodings bridge the engine's TypeScript and the AT Protocol data model the lexicons// describe (see town.deception.defs):// - float world coordinates -> integer whole pixels (lexicon has no float)// - Record<key, value> maps -> arrays of entry objects keyed by their id// - engine `null` / a field redacted by per-viewer projection -> the field is OMITTED//// `toWireState` encodes a projected snapshot; `projectActionToWire` encodes ONE dispatched action// for one viewer, applying the same per-viewer redaction that selectors.projectFor applies to// state (roles, the activity choice + slot, and resolve RNG are secret to non-actors).
import type { Action } from '../engine/actions.js'import type { Carry, Did, GameConfig, GameState, NavGrid, StationSeed, StationState, Vec } from '../engine/types.js'import type { ProjectedActive, ProjectedCarry, ProjectedDebris, ProjectedPlayer, ProjectedState, ProjectedStation,} from '../engine/selectors.js'import { STUB_WAYPOINTS } from '../engine/selectors.js'import type * as Wire from '../lexgen/types/town/deception/defs.js'import type { Event as WireEvent } from '../lexgen/types/town/deception/subscribeGame.js'
/** A single dispatched action as it travels inside a #event frame (a $type-tagged union member). */export type WireAction = WireEvent['action']
const DEFS = 'town.deception.defs'
// --- scalar / map helpers ---------------------------------------------------
function wvec(v: Vec): Wire.Vec { return { x: Math.round(v.x), y: Math.round(v.y) }}
function wrect(r: { x: number; y: number; w: number; h: number }): Wire.Rect { return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.w), h: Math.round(r.h) }}
function wnav(n: NavGrid): Wire.NavGrid { return { cellSize: Math.round(n.cellSize), cols: n.cols, rows: n.rows, originX: Math.round(n.originX), originY: Math.round(n.originY), blocked: n.blocked, }}
function wconfig(c: GameConfig): Wire.GameConfig { return { resources: c.resources, targets: Object.entries(c.targets).map(([resourceId, target]) => ({ resourceId, target })), roundDurationMs: c.roundDurationMs, timer1Ms: c.timer1Ms, timer2Ms: c.timer2Ms, timer3Ms: c.timer3Ms, slotCooldownMs: c.slotCooldownMs, debrisTtlMs: c.debrisTtlMs, voteDurationMs: c.voteDurationMs, moveSpeedPxPerSec: c.moveSpeedPxPerSec, }}
function wstationSeed(s: StationSeed): Wire.StationSeed { return { id: s.id, pos: wvec(s.pos), area: wrect(s.area), slotCount: s.slotCount, collect: [...s.collect], deposit: [...s.deposit], scoreboard: [...s.scoreboard], meeting: s.meeting, }}
// --- projected state (snapshot body) ----------------------------------------
function wcarry(c: ProjectedCarry): Wire.ProjectedCarry { const out: Wire.ProjectedCarry = { resourceId: c.resourceId } if (c.authentic != null) out.authentic = c.authentic return out}
function wactive(a: ProjectedActive): Wire.ProjectedActive { const out: Wire.ProjectedActive = { stationId: a.stationId, startedAt: a.startedAt, endsAt: a.endsAt, } if (a.kind != null) out.kind = a.kind return out}
function wplayer(p: ProjectedPlayer): Wire.ProjectedPlayer { const out: Wire.ProjectedPlayer = { did: p.did, handle: p.handle, status: p.status, pos: wvec(p.pos), facing: p.facing, path: p.path.map(wvec), } if (p.spriteRef != null) out.spriteRef = p.spriteRef if (p.role != null) out.role = p.role if (p.carrying) out.carrying = wcarry(p.carrying) if (p.active) out.active = wactive(p.active) if (p.dest != null) out.dest = wvec(p.dest) return out}
function wstation(s: ProjectedStation): Wire.ProjectedStation { return { id: s.id, pos: wvec(s.pos), area: wrect(s.area), slotCount: s.slotCount, state: s.state, collect: [...s.collect], deposit: [...s.deposit], scoreboard: [...s.scoreboard], meeting: s.meeting, }}
function wdebris(d: ProjectedDebris): Wire.ProjectedDebris { const out: Wire.ProjectedDebris = { id: d.id, pos: wvec(d.pos), resourceId: d.resourceId } if (d.authentic != null) out.authentic = d.authentic return out}
export function toWireState(s: ProjectedState): Wire.ProjectedState { const out: Wire.ProjectedState = { code: s.code, phase: s.phase, now: s.now, config: wconfig(s.config), resources: Object.entries(s.resources).map(([resourceId, amount]) => ({ resourceId, amount })), players: Object.values(s.players).map(wplayer), stations: Object.values(s.stations).map(wstation), debris: Object.values(s.debris).map(wdebris), seq: s.seq, } if (s.startedAt != null) out.startedAt = s.startedAt if (s.endsAt != null) out.endsAt = s.endsAt if (s.vote) { out.vote = { startedAt: s.vote.startedAt, endsAt: s.vote.endsAt, ballots: Object.entries(s.vote.ballots).map(([voter, target]) => ({ voter, target })), } } if (s.outcome != null) out.outcome = s.outcome return out}
// --- action projection (one #event body, per viewer) ------------------------
/** * Encode one dispatched engine action for `viewer`, redacting what they may not see and converting * to wire shape. `viewer === null` is a spectator. The returned object carries the `$type` the * #event union discriminates on. Mirrors the redaction rules in selectors.projectFor. */export function projectActionToWire(action: Action, viewer: Did | null): WireAction { const t = (hash: string) => `${DEFS}#${hash}` switch (action.type) { case 'gameCreated': { const out: Record<string, unknown> = { $type: t('gameCreated'), serverTime: action.serverTime, code: action.code, config: wconfig(action.config), stations: action.stations.map(wstationSeed), } if (action.nav) out.nav = wnav(action.nav) return out as WireAction } case 'playerJoined': { const out: Record<string, unknown> = { $type: t('playerJoined'), serverTime: action.serverTime, did: action.did, handle: action.handle, pos: wvec(action.pos), } if (action.spriteRef != null) out.spriteRef = action.spriteRef return out as WireAction } case 'playerDisconnected': return { $type: t('playerDisconnected'), serverTime: action.serverTime, did: action.did } as WireAction case 'playerReconnected': return { $type: t('playerReconnected'), serverTime: action.serverTime, did: action.did } as WireAction case 'roundStarted': { const out: Record<string, unknown> = { $type: t('roundStarted'), serverTime: action.serverTime, spawns: Object.entries(action.spawns).map(([did, pos]) => ({ did, pos: wvec(pos) })), endsAt: action.endsAt, } // roles are secret: a viewer learns only their own role at round start const roles = Object.entries(action.roles) .filter(([did]) => viewer !== null && did === viewer) .map(([did, role]) => ({ did, role })) if (roles.length > 0) out.roles = roles if (action.config) out.config = wconfig(action.config) return out as WireAction } case 'playerMoved': { const out: Record<string, unknown> = { $type: t('playerMoved'), serverTime: action.serverTime, did: action.did, pos: wvec(action.pos), facing: action.facing, } // Forward-path stub: the actor sees their whole path; others see only the next STUB_WAYPOINTS // (near-term heading, not the destination). Omitted entirely when there's nothing ahead. const stub = action.did === viewer ? action.path : action.path?.slice(0, STUB_WAYPOINTS) if (stub && stub.length > 0) out.path = stub.map(wvec) return out as WireAction } case 'setDestination': { const out: Record<string, unknown> = { $type: t('setDestination'), serverTime: action.serverTime, did: action.did, } // The destination is secret: only the actor learns where they're headed. Others get nothing // here (their movement, including a short forward stub, streams via playerMoved). if (action.did === viewer) out.target = wvec(action.target) return out as WireAction } case 'actionStarted': { const out: Record<string, unknown> = { $type: t('actionStarted'), serverTime: action.serverTime, did: action.did, stationId: action.stationId, endsAt: action.endsAt, } // the activity choice, the resource, and which slot are secret to everyone but the actor if (action.did === viewer) { out.slot = action.slot out.kind = action.kind if (action.resourceId != null) out.resourceId = action.resourceId } return out as WireAction } case 'actionCancelled': return { $type: t('actionCancelled'), serverTime: action.serverTime, did: action.did } as WireAction case 'actionResolved': { const out: Record<string, unknown> = { $type: t('actionResolved'), serverTime: action.serverTime, did: action.did, } // resolved RNG (the dice / grace) is secret to everyone but the actor if (action.did === viewer && action.rng) { const rng: Record<string, unknown> = {} if (action.rng.sabotageTriggered !== undefined) rng.sabotageTriggered = action.rng.sabotageTriggered if (action.rng.fakeRepairExtra !== undefined) rng.fakeRepairExtra = action.rng.fakeRepairExtra out.rng = rng } return out as WireAction } case 'itemDropped': return { $type: t('itemDropped'), serverTime: action.serverTime, did: action.did, debrisId: action.debrisId, pos: wvec(action.pos), } as WireAction case 'debrisInspected': return { $type: t('debrisInspected'), serverTime: action.serverTime, did: action.did, debrisId: action.debrisId, } as WireAction case 'debrisExpired': return { $type: t('debrisExpired'), serverTime: action.serverTime, debrisId: action.debrisId } as WireAction case 'voteCast': return { $type: t('voteCast'), serverTime: action.serverTime, voterDid: action.voterDid, targetDid: action.targetDid, } as WireAction case 'voteEnded': { const out: Record<string, unknown> = { $type: t('voteEnded'), serverTime: action.serverTime } if (action.kickedDid != null) out.kickedDid = action.kickedDid return out as WireAction } case 'roundEnded': return { $type: t('roundEnded'), serverTime: action.serverTime, outcome: action.outcome } as WireAction case 'tick': return { $type: t('tick'), serverTime: action.serverTime } as WireAction }}
// --- per-viewer resolution effects (computed from post-reduce state) ---------//// The public consequence of an actionResolved / debrisInspected depends on the resulting state,// which projectActionToWire (action + viewer only) can't see. The server calls these at dispatch// time with prev/next GameState and attaches the result to the wire action. The redaction here// mirrors selectors.projectFor exactly.
function wcarrySeen(c: Carry, see: boolean): Wire.ProjectedCarry { const out: Wire.ProjectedCarry = { resourceId: c.resourceId } if (see) out.authentic = c.authentic return out}
/** How a station's true state looks to `viewer` — sabotaged reads as working unless they know. */function visibleStationState( st: GameState['stations'][string], viewer: Did | null, ended: boolean,): StationState { const knows = ended || (viewer !== null && st.revealedSabotageTo.includes(viewer)) return st.state === 'sabotaged' && !knows ? 'working' : st.state}
/** * The per-viewer public consequence of an `actionResolved`, or undefined when there's nothing * observable for this viewer (e.g. scoreboard, inspect of a working station). The affected station * is read from `prev` (resolve clears the actor's `active`). */export function projectResolvedEffect( prev: GameState, next: GameState, did: Did, viewer: Did | null,): Wire.ResolvedEffect | undefined { const ended = next.phase === 'ended' const effect: Wire.ResolvedEffect = {}
// carry tri-state (no null on the wire): set / cleared / unchanged const before = prev.players[did]?.carrying ?? null const after = next.players[did]?.carrying ?? null if ( after && (!before || before.resourceId !== after.resourceId || before.authentic !== after.authentic) ) { effect.carrySet = wcarrySeen(after, viewer === did || ended) } else if (before && !after) { effect.carryCleared = true }
// affected station's new per-viewer state const stationId = prev.players[did]?.active?.stationId if (stationId) { const st = next.stations[stationId] if (st) effect.station = { id: stationId, state: visibleStationState(st, viewer, ended) } }
// banked totals, only when a real deposit changed them (public) if (resourcesChanged(prev.resources, next.resources)) { effect.resources = Object.entries(next.resources).map(([resourceId, amount]) => ({ resourceId, amount, })) }
// a successful callVote opened a vote in this resolution (public to everyone) if (!prev.vote && next.vote) { effect.voteOpened = { startedAt: next.vote.startedAt, endsAt: next.vote.endsAt } }
return effect.carrySet || effect.carryCleared || effect.station || effect.resources || effect.voteOpened ? effect : undefined}
function resourcesChanged(a: Record<string, number>, b: Record<string, number>): boolean { const keys = new Set([...Object.keys(a), ...Object.keys(b)]) for (const k of keys) if ((a[k] ?? 0) !== (b[k] ?? 0)) return true return false}
/** * The debris authenticity revealed to `viewer` by a `debrisInspected`, or undefined if they're not * entitled to know it (everyone but the inspector, until round end). Mirrors projectFor's rule. */export function projectInspectAuthentic( next: GameState, debrisId: string, _inspectorDid: Did, viewer: Did | null,): boolean | undefined { const d = next.debris[debrisId] if (!d) return undefined const knows = next.phase === 'ended' || (viewer !== null && d.inspectedBy.includes(viewer)) return knows ? d.authentic : undefined}