1
0

feat: add playable actors and seamless journey state

This commit is contained in:
2026-08-11 19:10:14 -07:00
parent 4e313c4a79
commit a2a52bfdae
19 changed files with 2778 additions and 24 deletions
+40
View File
@@ -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";
+67
View File
@@ -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" };
}
}
+290
View File
@@ -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];
}
+113
View File
@@ -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 };