1
0

fix: outdoors is one California, not three boards the player travels between

Journey location was `california | bay-area | socal | office`. Entering HQ
required a metro scale, leaving restored that metro, and finishing a Drive
route called `switchCity`. On the merged board those events dumped you onto
`?city=sf` while the world you had been driving was still California.

Outdoors is California. An office is the other scale. Leaving it returns
you to California; arriving at San Francisco flies the authored pose on
this board. `?city=sf` remains a capture renderer. Old sessions that stored
a metro scale coerce to California.

`navigate-to-city` / `return-to-california` stay as events so leftover
callers compile; they reject with `already-there`.
This commit is contained in:
2026-08-24 23:21:48 -07:00
parent 92126df420
commit 4a207862fe
5 changed files with 124 additions and 81 deletions
+38 -28
View File
@@ -1,6 +1,10 @@
/**
* Pure journey state machine spanning corridor, city boards, vehicles, and offices.
* Pure journey state machine spanning the California world, vehicles, and offices.
* No render or browser types belong here; the same reducer can run on a server.
*
* Outdoors is always `california`. `navigate-to-city` / `return-to-california`
* are kept so old sessions and `?one=0` callers still compile; they cannot
* move the actor onto a metro board, because that board is not a place.
*/
import type {
@@ -69,15 +73,24 @@ function isOfficeId(value: unknown): value is JourneyOfficeId {
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;
if (value.scale === "california") return true;
return value.scale === "office" && isOfficeId(value.officeId);
}
return (
value.scale === "office" &&
isOfficeId(value.officeId) &&
(value.priorCity === "bay-area" || value.priorCity === "socal") &&
OFFICES[value.officeId] === value.priorCity
);
/**
* V1 sessions written when Bay Area and SoCal were journey scales.
*
* Those scales are geography now. A stored `bay-area` / `socal` is California;
* an office's `priorCity` is dropped because leaving an office always returns
* to California. Unknown office ids still fail closed.
*/
function coerceLocation(value: unknown): unknown {
if (!isRecord(value)) return value;
if (value.scale === "bay-area" || value.scale === "socal") return { scale: "california" };
if (value.scale === "office" && isOfficeId(value.officeId)) {
return { scale: "office", officeId: value.officeId };
}
return value;
}
/** Runtime validation at persistence and multiplayer trust boundaries. */
@@ -121,10 +134,8 @@ function accept(state: JourneyState): JourneyTransition {
}
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;
if (location.scale !== "california") return null;
return direction === 1 ? 0 : 1;
}
/**
@@ -148,18 +159,14 @@ export function transitionJourney(state: JourneyState, event: JourneyEvent): Jou
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 } });
return reject(state, "already-there");
}
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" } });
return reject(state, "already-there");
}
case "select-route": {
@@ -194,7 +201,7 @@ export function transitionJourney(state: JourneyState, event: JourneyEvent): Jou
const progress = event.endpoint === "san-francisco" ? 1 : 0;
return accept({
...state,
location: { scale: ENDPOINT_CITY[event.endpoint] },
location: { scale: "california" },
route: { ...state.route, progress },
});
}
@@ -217,24 +224,20 @@ export function transitionJourney(state: JourneyState, event: JourneyEvent): Jou
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.location.scale !== "california") 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 } });
return accept({ ...state, location: { scale: "california" } });
}
case "sign-in-actor-swap": {
@@ -272,11 +275,18 @@ export function decodeJourneySnapshot(payload: string | unknown): JourneySnapsho
if (!isRecord(parsed) || parsed.version !== 1) {
return { ok: false, error: "journey: unsupported snapshot version" };
}
if (!isJourneyState(parsed.state)) {
if (!isRecord(parsed.state)) {
return { ok: false, error: "journey: invalid snapshot state" };
}
const migrated = {
...parsed.state,
location: coerceLocation(parsed.state.location),
};
if (!isJourneyState(migrated)) {
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 state = JSON.parse(JSON.stringify(migrated)) as JourneyState;
const snapshot: JourneySnapshotV1 = { version: 1, state };
return { ok: true, snapshot, state };
}
+15 -6
View File
@@ -1,6 +1,14 @@
/** Serializable contracts for moving one actor between Tera's world scales. */
/**
* Serializable contracts for moving one actor through Tera.
*
* Outdoors is one California. Bay Area and Southern California are places on
* that board — `?city=sf` is a capture renderer, not a player-facing scale.
* The other scale is an office: you are inside a building, and leaving it
* returns you to California, not to a metro board the product no longer is.
*/
export type JourneyScale = "california" | "bay-area" | "socal" | "office";
export type JourneyScale = "california" | "office";
/** Which metro an office or a route end sits in. Geography, not a journey scale. */
export type JourneyCity = "bay-area" | "socal";
export type JourneyMode = "observe" | "play";
export type JourneyActorKind = "humanoid" | "dog" | "crow";
@@ -23,13 +31,9 @@ export interface JourneyActor {
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 {
@@ -56,6 +60,11 @@ export type JourneyOfficeId = "lumbridge-hq" | "frontier-valley" | "mateo-court"
export type JourneyEvent =
| { type: "set-mode"; mode: JourneyMode }
/**
* Leftover of the three-board product. Outdoors is already California, so
* both events reject with `already-there` (or `leave-office-first`). The
* renderer still honours `?city=` via `switchCity`; that is not a journey.
*/
| { type: "navigate-to-city"; city: JourneyCity }
| { type: "return-to-california" }
| { type: "select-route"; routeId: JourneyRouteId; direction: 1 | -1 }
+13 -10
View File
@@ -435,9 +435,13 @@ function dispatchJourney(event: JourneyEvent): boolean {
return true;
}
/**
* Leftover of the three-board product. The journey cannot leave California
* except through a door; this still exists because `switchCity` for `?city=sf`
* used to need it, and the events are what old sessions dispatch.
*/
function journeyToCity(city: JourneyCity): void {
if (journey.location.scale === "office") dispatchJourney({ type: "leave-office" });
if (journey.location.scale !== "california") dispatchJourney({ type: "return-to-california" });
dispatchJourney({ type: "navigate-to-city", city });
}
@@ -3414,7 +3418,10 @@ function syncJourneyVehicle(now: number): void {
const endpoint = journey.route.direction === 1 ? "san-francisco" : "los-angeles";
dispatchJourney({ type: "reach-route-endpoint", endpoint });
dispatchJourney({ type: "exit-vehicle" });
switchCity(endpoint === "san-francisco" ? "sf" : "socal");
officeId = endpoint === "san-francisco" ? "lumbridge-hq" : "mateo-court";
requestControlMode("overview");
city.flyTo(endpoint === "san-francisco" ? "san-francisco" : "los-angeles");
renderChrome();
return;
}
if (now - journeySyncedAt < 500) return;
@@ -3571,8 +3578,6 @@ async function enterOffice() {
};
attachCurrentWebcamFace();
moveRealtimePresence();
const desiredCity: JourneyCity = officeId === "mateo-court" ? "socal" : "bay-area";
journeyToCity(desiredCity);
dispatchJourney({ type: "enter-office", officeId: officeId as "lumbridge-hq" | "frontier-valley" | "mateo-court" });
showPlan();
showDetail(null);
@@ -5522,12 +5527,10 @@ function activePlace(): LadderRung | null {
/**
* Go to a rung, on this board or another one.
*
* The cross-board case is the whole reason the ladder exists, and it has to
* dispatch the journey event: `journey/state.ts` models a board change as an
* explicit event and gates `enter-office` on `OFFICES[officeId] === location.scale`.
* A board that changed without one leaves the door into Mateo Court rejecting
* with `wrong-city` while the Southland is visibly on screen, and nothing throws.
* `switchCity` already dispatches; this routes through it rather than around it.
* The cross-board case is the whole reason the ladder exists, and it is a
* renderer change (`?city=` / `?one=0`), not a journey scale. Outdoors is
* California either way; `switchCity` still updates `wantedCity` so capture
* deep links keep aiming at the board they named.
*/
function goToPlace(rung: LadderRung): void {
if (rung.board !== cityId) {
+1 -2
View File
@@ -67,7 +67,6 @@ describe("city and office actor acceptance handoff", () => {
profile: {},
};
let journey = createJourney({ actor: journeyActor });
journey = journeyReducer(journey, { type: "navigate-to-city", city: "bay-area" });
assert.equal(actorKindForPresence(false, "outdoors"), "crow");
const cityActor = createSceneActor({
@@ -108,7 +107,7 @@ describe("city and office actor acceptance handoff", () => {
assert.ok(walker.state().position.x < 5, "the same controller cannot cross the solid wall");
journey = journeyReducer(journey, { type: "leave-office" });
assert.equal(journey.location.scale, "bay-area");
assert.equal(journey.location.scale, "california");
assert.deepEqual(journey.actor, journeyActor);
assert.deepEqual(cityActor.state(), parkedCityState, "the outdoor actor stays parked during the office visit");
assert.deepEqual(cityActor.state().identity, identity);
+57 -35
View File
@@ -32,7 +32,7 @@ const KARTI: JourneyActor = {
};
function playFromLosAngeles(): JourneyState {
let state = createJourney({ actor: CROW, location: { scale: "socal" }, mode: "play" });
let state = createJourney({ actor: CROW, location: { scale: "california" }, mode: "play" });
state = journeyReducer(state, {
type: "select-route",
routeId: "la-sf-i-5",
@@ -43,24 +43,23 @@ function playFromLosAngeles(): JourneyState {
}
describe("journey state machine", () => {
it("navigates explicitly between California and either detailed city board", () => {
it("treats metro boards as geography, not as a journey scale", () => {
const california = createJourney({ actor: CROW, mode: "play" });
const actor = california.actor;
const bayArea = journeyReducer(california, {
type: "navigate-to-city",
city: "bay-area",
});
assert.deepEqual(bayArea.location, { scale: "bay-area" });
assert.equal(bayArea.actor, actor);
const returned = journeyReducer(bayArea, { type: "return-to-california" });
assert.deepEqual(returned.location, { scale: "california" });
assert.equal(returned.actor, actor);
const southern = journeyReducer(returned, { type: "navigate-to-city", city: "socal" });
assert.deepEqual(southern.location, { scale: "socal" });
assert.deepEqual(
transitionJourney(california, { type: "navigate-to-city", city: "bay-area" }),
{ accepted: false, state: california, reason: "already-there" },
);
assert.deepEqual(
transitionJourney(california, { type: "return-to-california" }),
{ accepted: false, state: california, reason: "already-there" },
);
assert.deepEqual(california.location, { scale: "california" });
assert.equal(endpointCity("los-angeles"), "socal");
assert.equal(endpointCity("san-francisco"), "bay-area");
assert.equal(officeCity("mateo-court"), "socal");
});
it("requires leaving an office or vehicle before board navigation", () => {
it("requires leaving an office or vehicle before the leftover board events", () => {
let driving = playFromLosAngeles();
assert.deepEqual(
transitionJourney(driving, { type: "navigate-to-city", city: "bay-area" }),
@@ -68,8 +67,7 @@ describe("journey state machine", () => {
);
driving = journeyReducer(driving, { type: "exit-vehicle" });
const bayArea = journeyReducer(driving, { type: "navigate-to-city", city: "bay-area" });
let office = journeyReducer(bayArea, {
let office = journeyReducer(driving, {
type: "enter-office",
officeId: "lumbridge-hq",
});
@@ -78,20 +76,17 @@ describe("journey state machine", () => {
{ accepted: false, state: office, reason: "leave-office-first" },
);
office = journeyReducer(office, { type: "leave-office" });
assert.equal(journeyReducer(office, { type: "return-to-california" }).location.scale, "california");
assert.deepEqual(office.location, { scale: "california" });
});
it("maps route endpoints honestly onto the detailed city boards", () => {
assert.equal(endpointCity("los-angeles"), "socal");
assert.equal(endpointCity("san-francisco"), "bay-area");
it("arrives at a route end still on California", () => {
let northbound = playFromLosAngeles();
northbound = journeyReducer(northbound, { type: "update-route-progress", progress: 0.74 });
northbound = journeyReducer(northbound, {
type: "reach-route-endpoint",
endpoint: "san-francisco",
});
assert.equal(northbound.location.scale, "bay-area");
assert.equal(northbound.location.scale, "california");
assert.equal(northbound.route?.progress, 1);
assert.equal(northbound.vehicle?.vehicleId, "model-x-hero");
@@ -106,24 +101,22 @@ describe("journey state machine", () => {
type: "reach-route-endpoint",
endpoint: "los-angeles",
});
assert.equal(southbound.location.scale, "socal");
assert.equal(southbound.location.scale, "california");
assert.equal(southbound.route?.progress, 0);
});
it("preserves identity and prior city across office doors", () => {
let state = createJourney({ actor: KARTI, location: { scale: "bay-area" }, mode: "play" });
it("opens an office from California and leaves onto the same world", () => {
let state = createJourney({ actor: KARTI, location: { scale: "california" }, mode: "play" });
const actor = state.actor;
state = journeyReducer(state, { type: "enter-office", officeId: "frontier-valley" });
assert.deepEqual(state.location, {
scale: "office",
officeId: "frontier-valley",
priorCity: "bay-area",
});
assert.equal(state.actor, actor);
state = journeyReducer(state, { type: "leave-office" });
assert.deepEqual(state.location, { scale: "bay-area" });
assert.deepEqual(state.location, { scale: "california" });
assert.equal(state.actor, actor);
assert.equal(officeCity("mateo-court"), "socal");
});
it("swaps a signed-in actor without disturbing their journey", () => {
@@ -140,7 +133,6 @@ describe("journey state machine", () => {
const state = createJourney({ actor: CROW });
const badEvents: JourneyEvent[] = [
{ type: "enter-vehicle", vehicleId: "model-x-hero" },
{ type: "enter-office", officeId: "lumbridge-hq" },
{ type: "leave-office" },
{ type: "update-route-progress", progress: 0.5 },
{ type: "reach-route-endpoint", endpoint: "san-francisco" },
@@ -164,7 +156,7 @@ describe("journey state machine", () => {
});
it("requires play mode, a route, and exiting the car before an office", () => {
const observer = createJourney({ actor: CROW, location: { scale: "socal" } });
const observer = createJourney({ actor: CROW, location: { scale: "california" } });
assert.equal(
transitionJourney(observer, { type: "enter-vehicle", vehicleId: "x" }).accepted,
false,
@@ -208,8 +200,7 @@ describe("journey state machine", () => {
...createJourney({ actor: CROW }),
location: {
scale: "office",
officeId: "mateo-court",
priorCity: "bay-area",
officeId: "not-an-office",
},
},
}).ok,
@@ -217,8 +208,39 @@ describe("journey state machine", () => {
);
});
it("reads a three-board session as California", () => {
const outdoor = decodeJourneySnapshot({
version: 1,
state: {
...createJourney({ actor: CROW, mode: "play" }),
location: { scale: "bay-area" },
},
});
assert.equal(outdoor.ok, true);
if (outdoor.ok) assert.deepEqual(outdoor.state.location, { scale: "california" });
const inside = decodeJourneySnapshot({
version: 1,
state: {
...createJourney({ actor: KARTI, mode: "play" }),
location: {
scale: "office",
officeId: "lumbridge-hq",
priorCity: "bay-area",
},
},
});
assert.equal(inside.ok, true);
if (inside.ok) {
assert.deepEqual(inside.state.location, {
scale: "office",
officeId: "lumbridge-hq",
});
}
});
it("is deterministic for the same initial state and event log", () => {
const initial = createJourney({ actor: CROW, location: { scale: "socal" }, mode: "play" });
const initial = createJourney({ actor: CROW, location: { scale: "california" }, mode: "play" });
const events: JourneyEvent[] = [
{ type: "select-route", routeId: "la-sf-us-101", direction: 1 },
{ type: "enter-vehicle", vehicleId: "model-x-hero" },