// 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 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 = { $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 = { $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 = { $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 = { $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 = { $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 = { $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 = { $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 = {} 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 = { $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, b: Record): 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 }