291 lines
11 KiB
TypeScript
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];
|
|
}
|