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. * 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 { import type {
@@ -69,15 +73,24 @@ function isOfficeId(value: unknown): value is JourneyOfficeId {
function isLocation(value: unknown): value is JourneyLocation { function isLocation(value: unknown): value is JourneyLocation {
if (!isRecord(value)) return false; if (!isRecord(value)) return false;
if (value.scale === "california" || value.scale === "bay-area" || value.scale === "socal") { if (value.scale === "california") return true;
return true; return value.scale === "office" && isOfficeId(value.officeId);
}
/**
* 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 ( return value;
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. */ /** 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 { function routeStartFor(location: JourneyLocation, direction: 1 | -1): number | null {
if (location.scale === "socal") return direction === 1 ? 0 : null; if (location.scale !== "california") return null;
if (location.scale === "bay-area") return direction === -1 ? 1 : null; return direction === 1 ? 0 : 1;
if (location.scale === "california") return direction === 1 ? 0 : 1;
return null;
} }
/** /**
@@ -148,18 +159,14 @@ export function transitionJourney(state: JourneyState, event: JourneyEvent): Jou
return reject(state, "invalid-event"); return reject(state, "invalid-event");
} }
if (state.location.scale === "office") return reject(state, "leave-office-first"); 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"); 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": { case "return-to-california": {
if (state.location.scale === "office") return reject(state, "leave-office-first"); 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"); if (state.vehicle) return reject(state, "exit-vehicle-first");
return accept({ ...state, location: { scale: "california" } }); return reject(state, "already-there");
} }
case "select-route": { case "select-route": {
@@ -194,7 +201,7 @@ export function transitionJourney(state: JourneyState, event: JourneyEvent): Jou
const progress = event.endpoint === "san-francisco" ? 1 : 0; const progress = event.endpoint === "san-francisco" ? 1 : 0;
return accept({ return accept({
...state, ...state,
location: { scale: ENDPOINT_CITY[event.endpoint] }, location: { scale: "california" },
route: { ...state.route, progress }, route: { ...state.route, progress },
}); });
} }
@@ -217,24 +224,20 @@ export function transitionJourney(state: JourneyState, event: JourneyEvent): Jou
case "enter-office": { case "enter-office": {
if (!isOfficeId(event.officeId)) return reject(state, "invalid-event"); if (!isOfficeId(event.officeId)) return reject(state, "invalid-event");
if (state.location.scale !== "bay-area" && state.location.scale !== "socal") { if (state.location.scale !== "california") return reject(state, "wrong-scale");
return reject(state, "wrong-scale");
}
if (state.vehicle) return reject(state, "exit-vehicle-first"); if (state.vehicle) return reject(state, "exit-vehicle-first");
if (OFFICES[event.officeId] !== state.location.scale) return reject(state, "wrong-city");
return accept({ return accept({
...state, ...state,
location: { location: {
scale: "office", scale: "office",
officeId: event.officeId, officeId: event.officeId,
priorCity: state.location.scale,
}, },
}); });
} }
case "leave-office": { case "leave-office": {
if (state.location.scale !== "office") return reject(state, "already-outside"); 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": { case "sign-in-actor-swap": {
@@ -272,11 +275,18 @@ export function decodeJourneySnapshot(payload: string | unknown): JourneySnapsho
if (!isRecord(parsed) || parsed.version !== 1) { if (!isRecord(parsed) || parsed.version !== 1) {
return { ok: false, error: "journey: unsupported snapshot version" }; 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" }; return { ok: false, error: "journey: invalid snapshot state" };
} }
// JSON round-tripping is intentional: it strips aliases across a reconnect boundary. // 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 }; const snapshot: JourneySnapshotV1 = { version: 1, state };
return { ok: true, snapshot, 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 JourneyCity = "bay-area" | "socal";
export type JourneyMode = "observe" | "play"; export type JourneyMode = "observe" | "play";
export type JourneyActorKind = "humanoid" | "dog" | "crow"; export type JourneyActorKind = "humanoid" | "dog" | "crow";
@@ -23,13 +31,9 @@ export interface JourneyActor {
export type JourneyLocation = export type JourneyLocation =
| { scale: "california" } | { scale: "california" }
| { scale: "bay-area" }
| { scale: "socal" }
| { | {
scale: "office"; scale: "office";
officeId: JourneyOfficeId; officeId: JourneyOfficeId;
/** The detailed board to restore when the actor leaves through the door. */
priorCity: JourneyCity;
}; };
export interface JourneyRouteProgress { export interface JourneyRouteProgress {
@@ -56,6 +60,11 @@ export type JourneyOfficeId = "lumbridge-hq" | "frontier-valley" | "mateo-court"
export type JourneyEvent = export type JourneyEvent =
| { type: "set-mode"; mode: JourneyMode } | { 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: "navigate-to-city"; city: JourneyCity }
| { type: "return-to-california" } | { type: "return-to-california" }
| { type: "select-route"; routeId: JourneyRouteId; direction: 1 | -1 } | { type: "select-route"; routeId: JourneyRouteId; direction: 1 | -1 }
+13 -10
View File
@@ -435,9 +435,13 @@ function dispatchJourney(event: JourneyEvent): boolean {
return true; 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 { function journeyToCity(city: JourneyCity): void {
if (journey.location.scale === "office") dispatchJourney({ type: "leave-office" }); 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 }); 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"; const endpoint = journey.route.direction === 1 ? "san-francisco" : "los-angeles";
dispatchJourney({ type: "reach-route-endpoint", endpoint }); dispatchJourney({ type: "reach-route-endpoint", endpoint });
dispatchJourney({ type: "exit-vehicle" }); 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; return;
} }
if (now - journeySyncedAt < 500) return; if (now - journeySyncedAt < 500) return;
@@ -3571,8 +3578,6 @@ async function enterOffice() {
}; };
attachCurrentWebcamFace(); attachCurrentWebcamFace();
moveRealtimePresence(); 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" }); dispatchJourney({ type: "enter-office", officeId: officeId as "lumbridge-hq" | "frontier-valley" | "mateo-court" });
showPlan(); showPlan();
showDetail(null); showDetail(null);
@@ -5522,12 +5527,10 @@ function activePlace(): LadderRung | null {
/** /**
* Go to a rung, on this board or another one. * 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 * The cross-board case is the whole reason the ladder exists, and it is a
* dispatch the journey event: `journey/state.ts` models a board change as an * renderer change (`?city=` / `?one=0`), not a journey scale. Outdoors is
* explicit event and gates `enter-office` on `OFFICES[officeId] === location.scale`. * California either way; `switchCity` still updates `wantedCity` so capture
* A board that changed without one leaves the door into Mateo Court rejecting * deep links keep aiming at the board they named.
* with `wrong-city` while the Southland is visibly on screen, and nothing throws.
* `switchCity` already dispatches; this routes through it rather than around it.
*/ */
function goToPlace(rung: LadderRung): void { function goToPlace(rung: LadderRung): void {
if (rung.board !== cityId) { if (rung.board !== cityId) {
+1 -2
View File
@@ -67,7 +67,6 @@ describe("city and office actor acceptance handoff", () => {
profile: {}, profile: {},
}; };
let journey = createJourney({ actor: journeyActor }); let journey = createJourney({ actor: journeyActor });
journey = journeyReducer(journey, { type: "navigate-to-city", city: "bay-area" });
assert.equal(actorKindForPresence(false, "outdoors"), "crow"); assert.equal(actorKindForPresence(false, "outdoors"), "crow");
const cityActor = createSceneActor({ 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"); assert.ok(walker.state().position.x < 5, "the same controller cannot cross the solid wall");
journey = journeyReducer(journey, { type: "leave-office" }); 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(journey.actor, journeyActor);
assert.deepEqual(cityActor.state(), parkedCityState, "the outdoor actor stays parked during the office visit"); assert.deepEqual(cityActor.state(), parkedCityState, "the outdoor actor stays parked during the office visit");
assert.deepEqual(cityActor.state().identity, identity); assert.deepEqual(cityActor.state().identity, identity);
+57 -35
View File
@@ -32,7 +32,7 @@ const KARTI: JourneyActor = {
}; };
function playFromLosAngeles(): JourneyState { 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, { state = journeyReducer(state, {
type: "select-route", type: "select-route",
routeId: "la-sf-i-5", routeId: "la-sf-i-5",
@@ -43,24 +43,23 @@ function playFromLosAngeles(): JourneyState {
} }
describe("journey state machine", () => { 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 california = createJourney({ actor: CROW, mode: "play" });
const actor = california.actor; assert.deepEqual(
const bayArea = journeyReducer(california, { transitionJourney(california, { type: "navigate-to-city", city: "bay-area" }),
type: "navigate-to-city", { accepted: false, state: california, reason: "already-there" },
city: "bay-area", );
}); assert.deepEqual(
assert.deepEqual(bayArea.location, { scale: "bay-area" }); transitionJourney(california, { type: "return-to-california" }),
assert.equal(bayArea.actor, actor); { accepted: false, state: california, reason: "already-there" },
);
const returned = journeyReducer(bayArea, { type: "return-to-california" }); assert.deepEqual(california.location, { scale: "california" });
assert.deepEqual(returned.location, { scale: "california" }); assert.equal(endpointCity("los-angeles"), "socal");
assert.equal(returned.actor, actor); assert.equal(endpointCity("san-francisco"), "bay-area");
const southern = journeyReducer(returned, { type: "navigate-to-city", city: "socal" }); assert.equal(officeCity("mateo-court"), "socal");
assert.deepEqual(southern.location, { scale: "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(); let driving = playFromLosAngeles();
assert.deepEqual( assert.deepEqual(
transitionJourney(driving, { type: "navigate-to-city", city: "bay-area" }), transitionJourney(driving, { type: "navigate-to-city", city: "bay-area" }),
@@ -68,8 +67,7 @@ describe("journey state machine", () => {
); );
driving = journeyReducer(driving, { type: "exit-vehicle" }); driving = journeyReducer(driving, { type: "exit-vehicle" });
const bayArea = journeyReducer(driving, { type: "navigate-to-city", city: "bay-area" }); let office = journeyReducer(driving, {
let office = journeyReducer(bayArea, {
type: "enter-office", type: "enter-office",
officeId: "lumbridge-hq", officeId: "lumbridge-hq",
}); });
@@ -78,20 +76,17 @@ describe("journey state machine", () => {
{ accepted: false, state: office, reason: "leave-office-first" }, { accepted: false, state: office, reason: "leave-office-first" },
); );
office = journeyReducer(office, { type: "leave-office" }); 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", () => { it("arrives at a route end still on California", () => {
assert.equal(endpointCity("los-angeles"), "socal");
assert.equal(endpointCity("san-francisco"), "bay-area");
let northbound = playFromLosAngeles(); let northbound = playFromLosAngeles();
northbound = journeyReducer(northbound, { type: "update-route-progress", progress: 0.74 }); northbound = journeyReducer(northbound, { type: "update-route-progress", progress: 0.74 });
northbound = journeyReducer(northbound, { northbound = journeyReducer(northbound, {
type: "reach-route-endpoint", type: "reach-route-endpoint",
endpoint: "san-francisco", 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.route?.progress, 1);
assert.equal(northbound.vehicle?.vehicleId, "model-x-hero"); assert.equal(northbound.vehicle?.vehicleId, "model-x-hero");
@@ -106,24 +101,22 @@ describe("journey state machine", () => {
type: "reach-route-endpoint", type: "reach-route-endpoint",
endpoint: "los-angeles", endpoint: "los-angeles",
}); });
assert.equal(southbound.location.scale, "socal"); assert.equal(southbound.location.scale, "california");
assert.equal(southbound.route?.progress, 0); assert.equal(southbound.route?.progress, 0);
}); });
it("preserves identity and prior city across office doors", () => { it("opens an office from California and leaves onto the same world", () => {
let state = createJourney({ actor: KARTI, location: { scale: "bay-area" }, mode: "play" }); let state = createJourney({ actor: KARTI, location: { scale: "california" }, mode: "play" });
const actor = state.actor; const actor = state.actor;
state = journeyReducer(state, { type: "enter-office", officeId: "frontier-valley" }); state = journeyReducer(state, { type: "enter-office", officeId: "frontier-valley" });
assert.deepEqual(state.location, { assert.deepEqual(state.location, {
scale: "office", scale: "office",
officeId: "frontier-valley", officeId: "frontier-valley",
priorCity: "bay-area",
}); });
assert.equal(state.actor, actor); assert.equal(state.actor, actor);
state = journeyReducer(state, { type: "leave-office" }); 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(state.actor, actor);
assert.equal(officeCity("mateo-court"), "socal");
}); });
it("swaps a signed-in actor without disturbing their journey", () => { it("swaps a signed-in actor without disturbing their journey", () => {
@@ -140,7 +133,6 @@ describe("journey state machine", () => {
const state = createJourney({ actor: CROW }); const state = createJourney({ actor: CROW });
const badEvents: JourneyEvent[] = [ const badEvents: JourneyEvent[] = [
{ type: "enter-vehicle", vehicleId: "model-x-hero" }, { type: "enter-vehicle", vehicleId: "model-x-hero" },
{ type: "enter-office", officeId: "lumbridge-hq" },
{ type: "leave-office" }, { type: "leave-office" },
{ type: "update-route-progress", progress: 0.5 }, { type: "update-route-progress", progress: 0.5 },
{ type: "reach-route-endpoint", endpoint: "san-francisco" }, { 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", () => { 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( assert.equal(
transitionJourney(observer, { type: "enter-vehicle", vehicleId: "x" }).accepted, transitionJourney(observer, { type: "enter-vehicle", vehicleId: "x" }).accepted,
false, false,
@@ -208,8 +200,7 @@ describe("journey state machine", () => {
...createJourney({ actor: CROW }), ...createJourney({ actor: CROW }),
location: { location: {
scale: "office", scale: "office",
officeId: "mateo-court", officeId: "not-an-office",
priorCity: "bay-area",
}, },
}, },
}).ok, }).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", () => { 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[] = [ const events: JourneyEvent[] = [
{ type: "select-route", routeId: "la-sf-us-101", direction: 1 }, { type: "select-route", routeId: "la-sf-us-101", direction: 1 },
{ type: "enter-vehicle", vehicleId: "model-x-hero" }, { type: "enter-vehicle", vehicleId: "model-x-hero" },