// 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 = { ok: true } & T export type Err = { ok: false; reason: string } export type Result = Ok | 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 } }