feat: add playable actors and seamless journey state
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
export {
|
||||
createJourney,
|
||||
decodeJourneySnapshot,
|
||||
encodeJourneySnapshot,
|
||||
endpointCity,
|
||||
isJourneyState,
|
||||
journeyReducer,
|
||||
officeCity,
|
||||
transitionJourney,
|
||||
type CreateJourneyOptions,
|
||||
} from "./state.ts";
|
||||
export {
|
||||
clearJourneySession,
|
||||
loadJourneySession,
|
||||
saveJourneySession,
|
||||
} from "./persistence.ts";
|
||||
export type {
|
||||
JourneyActor,
|
||||
JourneyActorKind,
|
||||
JourneyActorProfile,
|
||||
JourneyCity,
|
||||
JourneyEvent,
|
||||
JourneyLocation,
|
||||
JourneyMode,
|
||||
JourneyOfficeId,
|
||||
JourneyRouteEndpoint,
|
||||
JourneyRouteId,
|
||||
JourneyRouteProgress,
|
||||
JourneyScale,
|
||||
JourneySessionClearResult,
|
||||
JourneySessionLoadResult,
|
||||
JourneySessionSaveResult,
|
||||
JourneySnapshotDecodeResult,
|
||||
JourneySnapshotV1,
|
||||
JourneyState,
|
||||
JourneyTransition,
|
||||
JourneyTransitionRejection,
|
||||
JourneyVehiclePossession,
|
||||
JourneyStorageAdapter,
|
||||
} from "./types.ts";
|
||||
@@ -0,0 +1,67 @@
|
||||
/** Failure-contained session persistence with no dependency on browser globals. */
|
||||
|
||||
import { decodeJourneySnapshot, encodeJourneySnapshot } from "./state.ts";
|
||||
import type {
|
||||
JourneySessionClearResult,
|
||||
JourneySessionLoadResult,
|
||||
JourneySessionSaveResult,
|
||||
JourneyState,
|
||||
JourneyStorageAdapter,
|
||||
} from "./types.ts";
|
||||
|
||||
function validKey(key: string): boolean {
|
||||
return typeof key === "string" && key.trim().length > 0;
|
||||
}
|
||||
|
||||
export function saveJourneySession(
|
||||
storage: JourneyStorageAdapter,
|
||||
key: string,
|
||||
state: JourneyState,
|
||||
): JourneySessionSaveResult {
|
||||
if (!validKey(key)) return { ok: false, error: "journey: session key is empty" };
|
||||
let encoded: string;
|
||||
try {
|
||||
encoded = encodeJourneySnapshot(state);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : "journey: snapshot encoding failed",
|
||||
};
|
||||
}
|
||||
try {
|
||||
storage.setItem(key, encoded);
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: "journey: session storage is unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
export function loadJourneySession(
|
||||
storage: JourneyStorageAdapter,
|
||||
key: string,
|
||||
): JourneySessionLoadResult {
|
||||
if (!validKey(key)) return { status: "invalid", error: "journey: session key is empty" };
|
||||
let encoded: string | null;
|
||||
try {
|
||||
encoded = storage.getItem(key);
|
||||
} catch {
|
||||
return { status: "unavailable", error: "journey: session storage is unavailable" };
|
||||
}
|
||||
if (encoded === null) return { status: "missing" };
|
||||
const decoded = decodeJourneySnapshot(encoded);
|
||||
if (!decoded.ok) return { status: "invalid", error: decoded.error };
|
||||
return { status: "loaded", state: decoded.state };
|
||||
}
|
||||
|
||||
export function clearJourneySession(
|
||||
storage: JourneyStorageAdapter,
|
||||
key: string,
|
||||
): JourneySessionClearResult {
|
||||
if (!validKey(key)) return { ok: false, error: "journey: session key is empty" };
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: "journey: session storage is unavailable" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/** Serializable contracts for moving one actor between Tera's world scales. */
|
||||
|
||||
export type JourneyScale = "california" | "bay-area" | "socal" | "office";
|
||||
export type JourneyCity = "bay-area" | "socal";
|
||||
export type JourneyMode = "observe" | "play";
|
||||
export type JourneyActorKind = "humanoid" | "dog" | "crow";
|
||||
export type JourneyRouteId = "la-sf-us-101" | "la-sf-i-5";
|
||||
export type JourneyRouteEndpoint = "los-angeles" | "san-francisco";
|
||||
|
||||
export interface JourneyActorProfile {
|
||||
displayName: string;
|
||||
/** Optional URL or application-owned asset key; never interpreted by the reducer. */
|
||||
face?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface JourneyActor {
|
||||
id: string;
|
||||
kind: JourneyActorKind;
|
||||
signedIn: boolean;
|
||||
profile: JourneyActorProfile;
|
||||
}
|
||||
|
||||
export type JourneyLocation =
|
||||
| { scale: "california" }
|
||||
| { scale: "bay-area" }
|
||||
| { scale: "socal" }
|
||||
| {
|
||||
scale: "office";
|
||||
officeId: JourneyOfficeId;
|
||||
/** The detailed board to restore when the actor leaves through the door. */
|
||||
priorCity: JourneyCity;
|
||||
};
|
||||
|
||||
export interface JourneyRouteProgress {
|
||||
routeId: JourneyRouteId;
|
||||
/** 1 follows the authored LA-to-SF route; -1 travels back toward LA. */
|
||||
direction: 1 | -1;
|
||||
/** Normalized distance measured from Los Angeles, regardless of direction. */
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface JourneyVehiclePossession {
|
||||
vehicleId: string;
|
||||
}
|
||||
|
||||
export interface JourneyState {
|
||||
actor: JourneyActor;
|
||||
location: JourneyLocation;
|
||||
mode: JourneyMode;
|
||||
vehicle: JourneyVehiclePossession | null;
|
||||
route: JourneyRouteProgress | null;
|
||||
}
|
||||
|
||||
export type JourneyOfficeId = "lumbridge-hq" | "frontier-valley" | "mateo-court";
|
||||
|
||||
export type JourneyEvent =
|
||||
| { type: "set-mode"; mode: JourneyMode }
|
||||
| { type: "navigate-to-city"; city: JourneyCity }
|
||||
| { type: "return-to-california" }
|
||||
| { type: "select-route"; routeId: JourneyRouteId; direction: 1 | -1 }
|
||||
| { type: "update-route-progress"; progress: number }
|
||||
| { type: "reach-route-endpoint"; endpoint: JourneyRouteEndpoint }
|
||||
| { type: "enter-vehicle"; vehicleId: string }
|
||||
| { type: "exit-vehicle" }
|
||||
| { type: "enter-office"; officeId: JourneyOfficeId }
|
||||
| { type: "leave-office" }
|
||||
| { type: "sign-in-actor-swap"; actor: JourneyActor };
|
||||
|
||||
export type JourneyTransitionRejection =
|
||||
| "invalid-state"
|
||||
| "invalid-event"
|
||||
| "wrong-scale"
|
||||
| "wrong-city"
|
||||
| "wrong-endpoint"
|
||||
| "observe-only"
|
||||
| "route-required"
|
||||
| "vehicle-required"
|
||||
| "vehicle-occupied"
|
||||
| "exit-vehicle-first"
|
||||
| "leave-office-first"
|
||||
| "already-there"
|
||||
| "already-outside";
|
||||
|
||||
export type JourneyTransition =
|
||||
| { accepted: true; state: JourneyState }
|
||||
| { accepted: false; state: JourneyState; reason: JourneyTransitionRejection };
|
||||
|
||||
export interface JourneySnapshotV1 {
|
||||
version: 1;
|
||||
state: JourneyState;
|
||||
}
|
||||
|
||||
export type JourneySnapshotDecodeResult =
|
||||
| { ok: true; snapshot: JourneySnapshotV1; state: JourneyState }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** Smallest common contract implemented by localStorage, sessionStorage, and server adapters. */
|
||||
export interface JourneyStorageAdapter {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
export type JourneySessionSaveResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export type JourneySessionLoadResult =
|
||||
| { status: "loaded"; state: JourneyState }
|
||||
| { status: "missing" }
|
||||
| { status: "invalid"; error: string }
|
||||
| { status: "unavailable"; error: string };
|
||||
|
||||
export type JourneySessionClearResult = { ok: true } | { ok: false; error: string };
|
||||
Reference in New Issue
Block a user