/** * Pure journey state machine spanning corridor, city boards, vehicles, and offices. * No render or browser types belong here; the same reducer can run on a server. */ import type { JourneyActor, JourneyCity, JourneyEvent, JourneyLocation, JourneyMode, JourneyOfficeId, JourneyRouteEndpoint, JourneyRouteId, JourneySnapshotDecodeResult, JourneySnapshotV1, JourneyState, JourneyTransition, JourneyTransitionRejection, } from "./types.ts"; const ROUTES: ReadonlySet = new Set([ "la-sf-us-101", "la-sf-i-5", ]); const OFFICES: Readonly> = Object.freeze({ "lumbridge-hq": "bay-area", "frontier-valley": "bay-area", "mateo-court": "socal", }); const ENDPOINT_CITY: Readonly> = Object.freeze({ "los-angeles": "socal", "san-francisco": "bay-area", }); export interface CreateJourneyOptions { actor: JourneyActor; location?: JourneyLocation; mode?: JourneyMode; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function finiteUnit(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1; } function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } function isActor(value: unknown): value is JourneyActor { if (!isRecord(value) || !isNonEmptyString(value.id)) return false; if (value.kind !== "humanoid" && value.kind !== "dog" && value.kind !== "crow") return false; if (typeof value.signedIn !== "boolean" || !isRecord(value.profile)) return false; if (!isNonEmptyString(value.profile.displayName)) return false; if (value.profile.face !== undefined && !isNonEmptyString(value.profile.face)) return false; if (value.profile.color !== undefined && !isNonEmptyString(value.profile.color)) return false; return true; } function isOfficeId(value: unknown): value is JourneyOfficeId { return value === "lumbridge-hq" || value === "frontier-valley" || value === "mateo-court"; } function isLocation(value: unknown): value is JourneyLocation { if (!isRecord(value)) return false; if (value.scale === "california" || value.scale === "bay-area" || value.scale === "socal") { return true; } return ( value.scale === "office" && isOfficeId(value.officeId) && (value.priorCity === "bay-area" || value.priorCity === "socal") && OFFICES[value.officeId] === value.priorCity ); } /** Runtime validation at persistence and multiplayer trust boundaries. */ export function isJourneyState(value: unknown): value is JourneyState { if (!isRecord(value) || !isActor(value.actor) || !isLocation(value.location)) return false; if (value.mode !== "observe" && value.mode !== "play") return false; if (value.vehicle !== null) { if (!isRecord(value.vehicle) || !isNonEmptyString(value.vehicle.vehicleId)) return false; if (value.mode !== "play" || value.location.scale === "office") return false; } if (value.route !== null) { if (!isRecord(value.route) || !ROUTES.has(String(value.route.routeId))) return false; if (value.route.direction !== 1 && value.route.direction !== -1) return false; if (!finiteUnit(value.route.progress)) return false; } return true; } export function createJourney(options: CreateJourneyOptions): JourneyState { const state: JourneyState = { actor: cloneActor(options.actor), location: options.location ? { ...options.location } : { scale: "california" }, mode: options.mode ?? "observe", vehicle: null, route: null, }; if (!isJourneyState(state)) throw new Error("journey: invalid initial actor or location"); return state; } function cloneActor(actor: JourneyActor): JourneyActor { return { ...actor, profile: { ...actor.profile } }; } function reject(state: JourneyState, reason: JourneyTransitionRejection): JourneyTransition { return { accepted: false, state, reason }; } function accept(state: JourneyState): JourneyTransition { return { accepted: true, state }; } function routeStartFor(location: JourneyLocation, direction: 1 | -1): number | null { if (location.scale === "socal") return direction === 1 ? 0 : null; if (location.scale === "bay-area") return direction === -1 ? 1 : null; if (location.scale === "california") return direction === 1 ? 0 : 1; return null; } /** * Explainable pure transition. Rejections retain the exact input state object, * making malformed or out-of-order multiplayer events safe to ignore. */ export function transitionJourney(state: JourneyState, event: JourneyEvent): JourneyTransition { if (!isJourneyState(state)) return reject(state, "invalid-state"); if (!isRecord(event) || !isNonEmptyString(event.type)) return reject(state, "invalid-event"); switch (event.type) { case "set-mode": { if (event.mode !== "observe" && event.mode !== "play") return reject(state, "invalid-event"); if (event.mode === "observe" && state.vehicle) return reject(state, "exit-vehicle-first"); if (event.mode === state.mode) return accept(state); return accept({ ...state, mode: event.mode }); } case "navigate-to-city": { if (event.city !== "bay-area" && event.city !== "socal") { return reject(state, "invalid-event"); } if (state.location.scale === "office") return reject(state, "leave-office-first"); if (state.location.scale !== "california") { return reject(state, state.location.scale === event.city ? "already-there" : "wrong-scale"); } if (state.vehicle) return reject(state, "exit-vehicle-first"); return accept({ ...state, location: { scale: event.city } }); } case "return-to-california": { if (state.location.scale === "office") return reject(state, "leave-office-first"); if (state.location.scale === "california") return reject(state, "already-there"); if (state.vehicle) return reject(state, "exit-vehicle-first"); return accept({ ...state, location: { scale: "california" } }); } case "select-route": { if (!ROUTES.has(event.routeId) || (event.direction !== 1 && event.direction !== -1)) { return reject(state, "invalid-event"); } const progress = routeStartFor(state.location, event.direction); if (progress === null) { return reject(state, state.location.scale === "office" ? "wrong-scale" : "wrong-city"); } return accept({ ...state, location: { scale: "california" }, route: { routeId: event.routeId, direction: event.direction, progress }, }); } case "update-route-progress": { if (state.location.scale !== "california") return reject(state, "wrong-scale"); if (!state.route) return reject(state, "route-required"); if (!state.vehicle) return reject(state, "vehicle-required"); if (!finiteUnit(event.progress)) return reject(state, "invalid-event"); return accept({ ...state, route: { ...state.route, progress: event.progress } }); } case "reach-route-endpoint": { if (state.location.scale !== "california") return reject(state, "wrong-scale"); if (!state.route) return reject(state, "route-required"); const expected: JourneyRouteEndpoint = state.route.direction === 1 ? "san-francisco" : "los-angeles"; if (event.endpoint !== expected) return reject(state, "wrong-endpoint"); const progress = event.endpoint === "san-francisco" ? 1 : 0; return accept({ ...state, location: { scale: ENDPOINT_CITY[event.endpoint] }, route: { ...state.route, progress }, }); } case "enter-vehicle": { if (state.mode !== "play") return reject(state, "observe-only"); if (state.location.scale === "office") return reject(state, "wrong-scale"); if (state.vehicle) return reject(state, "vehicle-occupied"); if (!isNonEmptyString(event.vehicleId)) return reject(state, "invalid-event"); if (state.location.scale === "california" && !state.route) { return reject(state, "route-required"); } return accept({ ...state, vehicle: { vehicleId: event.vehicleId } }); } case "exit-vehicle": { if (!state.vehicle) return reject(state, "vehicle-required"); return accept({ ...state, vehicle: null }); } case "enter-office": { if (!isOfficeId(event.officeId)) return reject(state, "invalid-event"); if (state.location.scale !== "bay-area" && state.location.scale !== "socal") { return reject(state, "wrong-scale"); } if (state.vehicle) return reject(state, "exit-vehicle-first"); if (OFFICES[event.officeId] !== state.location.scale) return reject(state, "wrong-city"); return accept({ ...state, location: { scale: "office", officeId: event.officeId, priorCity: state.location.scale, }, }); } case "leave-office": { if (state.location.scale !== "office") return reject(state, "already-outside"); return accept({ ...state, location: { scale: state.location.priorCity } }); } case "sign-in-actor-swap": { if (!isActor(event.actor) || !event.actor.signedIn) return reject(state, "invalid-event"); return accept({ ...state, actor: cloneActor(event.actor) }); } default: return reject(state, "invalid-event"); } } /** Conventional reducer form for stores: rejected events are safe no-ops. */ export function journeyReducer(state: JourneyState, event: JourneyEvent): JourneyState { return transitionJourney(state, event).state; } /** Encode an owned deep copy so callers cannot mutate the persisted snapshot. */ export function encodeJourneySnapshot(state: JourneyState): string { if (!isJourneyState(state)) throw new Error("journey: cannot encode invalid state"); const snapshot: JourneySnapshotV1 = { version: 1, state }; return JSON.stringify(snapshot); } /** Decode JSON or an already-parsed network payload through strict V1 validation. */ export function decodeJourneySnapshot(payload: string | unknown): JourneySnapshotDecodeResult { let parsed: unknown = payload; if (typeof payload === "string") { try { parsed = JSON.parse(payload) as unknown; } catch { return { ok: false, error: "journey: snapshot is not valid JSON" }; } } if (!isRecord(parsed) || parsed.version !== 1) { return { ok: false, error: "journey: unsupported snapshot version" }; } if (!isJourneyState(parsed.state)) { return { ok: false, error: "journey: invalid snapshot state" }; } // JSON round-tripping is intentional: it strips aliases across a reconnect boundary. const state = JSON.parse(JSON.stringify(parsed.state)) as JourneyState; const snapshot: JourneySnapshotV1 = { version: 1, state }; return { ok: true, snapshot, state }; } export function officeCity(officeId: JourneyOfficeId): JourneyCity { return OFFICES[officeId]; } export function endpointCity(endpoint: JourneyRouteEndpoint): JourneyCity { return ENDPOINT_CITY[endpoint]; }