From 4a207862fe67373773c81857290c3ab343101e97 Mon Sep 17 00:00:00 2001 From: Kartios Date: Mon, 24 Aug 2026 23:21:48 -0700 Subject: [PATCH] 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`. --- src/journey/state.ts | 66 ++++++++++++--------- src/journey/types.ts | 21 +++++-- src/main.ts | 23 ++++---- src/test/actorOfficeHandoff.test.ts | 3 +- src/test/journey.test.ts | 92 ++++++++++++++++++----------- 5 files changed, 124 insertions(+), 81 deletions(-) diff --git a/src/journey/state.ts b/src/journey/state.ts index 8374624..8a99a1a 100644 --- a/src/journey/state.ts +++ b/src/journey/state.ts @@ -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); +} + +/** + * 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.scale === "office" && - isOfficeId(value.officeId) && - (value.priorCity === "bay-area" || value.priorCity === "socal") && - OFFICES[value.officeId] === value.priorCity - ); + 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 }; } diff --git a/src/journey/types.ts b/src/journey/types.ts index 8948212..2a47f25 100644 --- a/src/journey/types.ts +++ b/src/journey/types.ts @@ -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 } diff --git a/src/main.ts b/src/main.ts index 28c7965..de5d732 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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) { diff --git a/src/test/actorOfficeHandoff.test.ts b/src/test/actorOfficeHandoff.test.ts index 28994a6..7acd89b 100644 --- a/src/test/actorOfficeHandoff.test.ts +++ b/src/test/actorOfficeHandoff.test.ts @@ -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); diff --git a/src/test/journey.test.ts b/src/test/journey.test.ts index 4702f68..6eb3479 100644 --- a/src/test/journey.test.ts +++ b/src/test/journey.test.ts @@ -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" },