Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
8.8 kB · 244 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245// Lobby logic. A `town.deception.game` record (in some PDS, referenced by its AT-URI) declares the// roster (DIDs) and round settings. From it we do role assignment and decide whether a round can// actually start or is void because players didn't connect.//// Access model (enforced later by the wire layer): subscribing to a game's live state is open to// ANYONE, unauthenticated; only the DIDs in the roster may push state up — see isPlayer().//// Pure logic, no I/O. Roles are never written to the public record — the saboteur is secret.
import { balance } from './config.js'import type { Action } from './actions.js'import type { Did, NavGrid, Role, StationSeed, Vec } from './types.js'
export const MIN_PLAYERS = 4export const MAX_PLAYERS = 10
/** Parsed `town.deception.game` record. */export interface GameDeclaration { /** roster by DID — also the only DIDs allowed to push state */ players: Did[] roundDurationSec: number resourceCount: number code?: string mapUri?: string createdAt?: string}
export type DeclarationCheck = { ok: true } | { ok: false; reason: string }
export function validateDeclaration(decl: GameDeclaration, minPlayers: number = MIN_PLAYERS): DeclarationCheck { const n = decl.players.length if (n < minPlayers) return { ok: false, reason: 'too-few-players' } if (n > MAX_PLAYERS) return { ok: false, reason: 'too-many-players' } if (new Set(decl.players).size !== n) return { ok: false, reason: 'duplicate-players' } if (decl.resourceCount < 1) return { ok: false, reason: 'no-resources' } if (decl.roundDurationSec <= 0) return { ok: false, reason: 'bad-duration' } return { ok: true }}
/** Authorization: only roster DIDs may push state. (Subscribing is open to anyone.) */export function isPlayer(decl: GameDeclaration, did: Did): boolean { return decl.players.includes(did)}
/** * Choose the saboteur from the roster given a roll in [0,1). Rolled once (truly random) and the * result is recorded downstream (in the roundStarted action's roles), so replay stays exact. Done * at game creation and kept secret; the round voids if that player never connects. */export function pickSaboteur(players: Did[], roll: number): Did { const idx = Math.min(players.length - 1, Math.max(0, Math.floor(roll * players.length))) return players[idx]!}
export function assignRoles(players: Did[], saboteurDid: Did): Record<Did, Role> { const roles: Record<Did, Role> = {} for (const did of players) roles[did] = did === saboteurDid ? 'saboteur' : 'worker' return roles}
export type VoidReason = 'not-enough-players'
export type StartDecision = | { kind: 'start'; players: Did[] } | { kind: 'void'; reason: VoidReason }
function balanceFor(decl: GameDeclaration, playerCount: number) { return balance({ playerCount, roundDurationMs: decl.roundDurationSec * 1000, resourceCount: decl.resourceCount, })}
/** * Who actually plays, given who connected: the roster ∩ connected (in roster order). Voids if fewer * than `minPlayers` show. Note there is NO "saboteur absent" void — the saboteur is picked from the * connected set at grace-end, so it always exists. (The in-round abandon rule, PLAN §3.6, is the * only saboteur-disappearance path, and it ends the round as 'aborted'.) */export function evaluateStart( rosterDids: Did[], connectedDids: Did[], minPlayers: number = MIN_PLAYERS,): StartDecision { const connected = new Set(connectedDids) const participating = rosterDids.filter((d) => connected.has(d)) if (participating.length < minPlayers) return { kind: 'void', reason: 'not-enough-players' } return { kind: 'start', players: participating }}
// --- grace-period flow ------------------------------------------------------
export interface CreateLobbyInput { declaration: GameDeclaration /** station layout for the chosen map */ stations: StationSeed[] nav?: NavGrid startTime: number}
/** * Start the grace period: build the live world (phase 'lobby') so players can connect and MOVE * around before the round begins. Roles are NOT assigned yet. Config is sized from the roster (the * expected count) — it's finalized for the actual participants at closeLobby(). Station activities * are blocked outside 'round' by the API layer, so the grace world is movement-only. */export function createLobby(input: CreateLobbyInput): Action { const bal = balanceFor(input.declaration, input.declaration.players.length) return { type: 'gameCreated', serverTime: input.startTime, code: input.declaration.code ?? '', config: bal.config, stations: input.stations, nav: input.nav, }}
/** A roster player connecting during the grace period. */export function joinAction( did: Did, opts: { handle?: string; spriteRef?: string | null; pos?: Vec; serverTime: number },): Action { return { type: 'playerJoined', serverTime: opts.serverTime, did, handle: opts.handle ?? did, spriteRef: opts.spriteRef ?? null, pos: opts.pos ?? { x: 0, y: 0 }, }}
export interface CloseLobbyInput { declaration: GameDeclaration connectedDids: Did[] /** RNG roll in [0,1) used to pick the saboteur from the connected players */ saboteurRoll: number spawns?: Record<Did, Vec> startTime: number /** minimum participants required to start (default MIN_PLAYERS); lowered for dev solo mode */ minPlayers?: number}
export type CloseLobbyResult = | { kind: 'start'; action: Action; saboteurDid: Did } | { kind: 'void'; reason: VoidReason }
/** * Grace period over: pick the saboteur FROM THE CONNECTED players and start the round (or void if * too few connected). Returns the `roundStarted` action — which carries the finalized config for * the participant count and the full roles map (the wire layer must redact roles per-viewer). */export function closeLobby(input: CloseLobbyInput): CloseLobbyResult { const decision = evaluateStart(input.declaration.players, input.connectedDids, input.minPlayers) if (decision.kind === 'void') return decision const players = decision.players const saboteurDid = pickSaboteur(players, input.saboteurRoll) const bal = balanceFor(input.declaration, players.length) const spawns: Record<Did, Vec> = {} for (const did of players) spawns[did] = input.spawns?.[did] ?? { x: 0, y: 0 } return { kind: 'start', saboteurDid, action: { type: 'roundStarted', serverTime: input.startTime, roles: assignRoles(players, saboteurDid), spawns, endsAt: input.startTime + bal.config.roundDurationMs, config: bal.config, }, }}
// --- instant start (no grace) ----------------------------------------------
export interface StartGameInput { declaration: GameDeclaration connectedDids: Did[] /** RNG roll in [0,1) used to pick the saboteur from the connected players */ saboteurRoll: number stations: StationSeed[] nav?: NavGrid spawns?: Record<Did, Vec> handles?: Record<Did, string> sprites?: Record<Did, string | null> startTime: number /** minimum participants required to start (default MIN_PLAYERS); lowered for dev solo mode */ minPlayers?: number}
export type StartGameResult = | { kind: 'start'; actions: Action[]; saboteurDid: Did } | { kind: 'void'; reason: VoidReason }
/** * Convenience: spin up a round in one shot (everyone already present, no grace). Builds * gameCreated → playerJoined×N → roundStarted, balanced from the connected count, with the * saboteur picked from the connected players. */export function startGame(input: StartGameInput): StartGameResult { const decl = input.declaration const decision = evaluateStart(decl.players, input.connectedDids, input.minPlayers) if (decision.kind === 'void') return decision
const players = decision.players const saboteurDid = pickSaboteur(players, input.saboteurRoll) const t = input.startTime const bal = balanceFor(decl, players.length) const spawnOf = (did: Did): Vec => input.spawns?.[did] ?? { x: 0, y: 0 }
const actions: Action[] = [ { type: 'gameCreated', serverTime: t, code: decl.code ?? '', config: bal.config, stations: input.stations, nav: input.nav, }, ] for (const did of players) { actions.push( joinAction(did, { handle: input.handles?.[did], spriteRef: input.sprites?.[did] ?? null, pos: spawnOf(did), serverTime: t, }), ) } const spawns: Record<Did, Vec> = {} for (const did of players) spawns[did] = spawnOf(did) actions.push({ type: 'roundStarted', serverTime: t, roles: assignRoles(players, saboteurDid), spawns, endsAt: t + bal.config.roundDurationMs, }) return { kind: 'start', actions, saboteurDid }}