Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
3.7 kB · 87 lines
TypeScript
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788// API-layer validation: the trust boundary (PLAN.md §5.4). The server runs these checks on an// incoming client intent and only dispatches the corresponding action if it passes. The reducer// keeps its own lighter guards as defense-in-depth, but THIS is where rules like "you must be// standing in the station's area to occupy a slot" are enforced.//// Pure functions over GameState — no I/O — so they're easy to unit test.
import { activityLegal } from './selectors.js'import { isCollectLike } from './config.js'import type { ActivityKind, Did, GameState, Rect, ResourceId, Vec } from './types.js'
export type Ok<T> = { ok: true } & Texport type Err = { ok: false; reason: string }export type Result<T> = Ok<T> | Err
function inArea(p: Vec, a: Rect): boolean { return p.x >= a.x && p.x <= a.x + a.w && p.y >= a.y && p.y <= a.y + a.h}
/** * First slot index this activity can occupy, or -1 if none. Any free slot works for most * activities; a Collect additionally needs the slot to have regenerated (off cooldown). */export function openSlotIndex( state: GameState, stationId: string, forCollect = false,): number { const st = state.stations[stationId] if (!st) return -1 return st.slots.findIndex( (s) => s.occupantDid === null && (!forCollect || s.collectCooldownUntil <= state.now), )}
/** * Validate that `did` may start `kind` at `stationId` right now, and pick the slot to occupy. * Enforces: in-round, player alive & idle, standing inside the station's area, the activity is a * legal option for them, and an open non-cooling slot exists. * * `discard: true` validates the tail of a "discard and X" compound — the player will drop their * carry first, so legality is judged empty-handed (and they must actually be carrying something). * The server, on success, emits the itemDropped before this action's actionStarted. */export function validateStationActivity( state: GameState, did: Did, stationId: string, kind: ActivityKind, resourceId?: ResourceId, discard = false,): Result<{ slot: number }> { if (state.phase !== 'round') return { ok: false, reason: 'not-in-round' } const p = state.players[did] if (!p) return { ok: false, reason: 'no-such-player' } if (p.status !== 'alive') return { ok: false, reason: 'not-alive' } if (p.active) return { ok: false, reason: 'already-busy' }
const st = state.stations[stationId] if (!st) return { ok: false, reason: 'no-such-station' } if (!inArea(p.pos, st.area)) return { ok: false, reason: 'not-in-area' }
// legality (role/capability/state/carry, incl. the discard variant) is slot- and resource-agnostic; // slot availability is checked separately so the caller gets 'no-open-slot' (incl. a cooling // collect) rather than a vague 'illegal-option' if (!activityLegal(state, did, stationId, kind, discard)) { return { ok: false, reason: 'illegal-option' } }
// you can't call a vote while one is already running (a state-level gate the legality core, which // is pure over capabilities, doesn't cover) if (kind === 'callVote' && state.vote) return { ok: false, reason: 'vote-in-progress' }
// collect/pretendCollect target a collectable resource; deposit/fakeDeposit an accepted one. if (kind === 'collect' || kind === 'pretendCollect') { if (!resourceId || !st.collect.includes(resourceId)) return { ok: false, reason: 'bad-resource' } } if (kind === 'deposit' || kind === 'fakeDeposit') { if (!resourceId || !st.deposit.includes(resourceId)) return { ok: false, reason: 'bad-resource' } }
const slot = openSlotIndex(state, stationId, isCollectLike(kind)) if (slot < 0) return { ok: false, reason: 'no-open-slot' }
return { ok: true, slot }}