1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/journey/state.ts
T

291 lines
11 KiB
TypeScript

/**
* 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<string> = new Set<JourneyRouteId>([
"la-sf-us-101",
"la-sf-i-5",
]);
const OFFICES: Readonly<Record<JourneyOfficeId, JourneyCity>> = Object.freeze({
"lumbridge-hq": "bay-area",
"frontier-valley": "bay-area",
"mateo-court": "socal",
});
const ENDPOINT_CITY: Readonly<Record<JourneyRouteEndpoint, JourneyCity>> = 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<string, unknown> {
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];
}