diff --git a/src/engine/scene.ts b/src/engine/scene.ts index 72ab673..877a618 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -459,6 +459,18 @@ export interface SceneOptions { * `stage.setScene(handle.stageScene)` that presents it. */ present?: boolean; + /** + * Which chapter the camera opens on. Defaults to `chapters[0]`. + * + * The pack's chapter list is a numbered tour and a capture contract — index + * 0 is the state's whole-board pose, and twenty-nine shot-list guards aim at + * buttons by that order. The *visitor* landing is a different question: a + * bare tera.lumbridgecorp.com should open on a city you can read, not on + * 1,551 km of tan. Passing `fidi` here does that without reordering the pack. + * Unknown ids throw rather than silently falling back, so a renamed chapter + * cannot photograph the state under a caption about downtown. + */ + openingChapter?: string; } export interface SceneHandle { @@ -1419,8 +1431,14 @@ export async function createScene( const chapterById = Object.fromEntries(city.chapters.map((c) => [c.id, c])); const first = city.chapters[0]; if (!first) throw new Error(`City "${city.id}" declares no chapters`); + if (options.openingChapter !== undefined && chapterById[options.openingChapter] === undefined) { + throw new Error( + `openingChapter "${options.openingChapter}" is not a chapter of city "${city.id}"`, + ); + } + const landing = chapterById[options.openingChapter ?? first.id] ?? first; - let currentChapter = first.id; + let currentChapter = landing.id; const chapterListeners: ((id: string) => void)[] = []; const controlModeListeners: ((mode: CityControlMode) => void)[] = []; @@ -1518,10 +1536,10 @@ export async function createScene( * Two rules govern everything below and both were learned from a picture. * * **The board settles exactly where the pack said it would.** `openingPose` - * is `chapterPose(first)` unchanged, so the resting frame is the one the pack - * authored, chapter 01 keeps meaning what it says, and a capture of this - * board is the same capture it was before an arrival existed. The wow is the - * *approach*; nothing about the destination is second-guessed here. + * is `chapterPose(landing)` — `chapters[0]` unless the caller named a + * chapter, so a capture that does not pass `openingChapter` is the same + * capture it was, and a bare URL that does can land on a city without + * reordering the pack. The wow is the *approach*. * * **Under `prefers-reduced-motion` there is no move at all.** Not a shorter * one — none: the camera is placed on the resting pose and that is the whole @@ -1529,7 +1547,7 @@ export async function createScene( * capture harness honest, because a screenshot of a board mid-flight is a * screenshot of a different board every time you take it. */ - const openingPose = chapterPose(first); + const openingPose = chapterPose(landing); /** * The opening move, or `null` when there is not one running. diff --git a/src/main.ts b/src/main.ts index ee3f90e..b7fa59d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -339,9 +339,10 @@ const CITIES: { id: string; label: string; city: City }[] = ONE_CALIFORNIA /* * With one board there is nothing to choose: the argument above is entirely * about which of three products a bare URL lands on, and `?one=1` is the answer - * that there are not three. The landing *pose* is a separate question and still - * belongs to the chapter list — `california-overview` frames the state, and the - * metro rungs restored in `cities/unify.ts` are how you get down from it. + * that there are not three. The landing *pose* is a separate question: the + * pack still opens its chapter list on `california-overview` so capture and + * `?city=california` frame the state, and a bare URL passes `openingChapter: + * "fidi"` so a visitor's first frame is a city they can read. */ const DEFAULT_CITY_ID = ONE_CALIFORNIA ? "california" : "sf"; @@ -1916,6 +1917,9 @@ async function buildBoard( const drawsMigration = carriesSky && access.feeds?.birds === true; const handle = await createScene(stage, { city: entry.city, + // Bare URL on one California: land in the city, not on 1,551 km of state. + // `?city=california` (and every capture shot) still opens on chapters[0]. + ...(ONE_CALIFORNIA && CITY_PARAM === null ? { openingChapter: "fidi" } : {}), // The page's one rig, shared with the office. Handed in rather than built // per board; see the note where it is constructed. environment, diff --git a/src/test/integration/sceneWiring.test.ts b/src/test/integration/sceneWiring.test.ts index d913d8a..6a332b0 100644 --- a/src/test/integration/sceneWiring.test.ts +++ b/src/test/integration/sceneWiring.test.ts @@ -601,6 +601,17 @@ test("the water reflects rather than absorbing, now that there is a sky to refle const MAIN = readFileSync(path.join(ROOT, "src/main.ts"), "utf8"); +test("a bare URL on one California opens on FiDi, not on the whole state", () => { + assert.ok( + MAIN.includes('openingChapter: "fidi"'), + "the visitor landing lost its city; they will see 1,551 km of tan", + ); + assert.ok( + MAIN.includes("CITY_PARAM === null"), + "the fidi opening must not apply to ?city=california — that is the capture harness", + ); +}); + test("main.ts makes exactly one call into the chrome, and writes no visibility itself", () => { const applies = [...MAIN.matchAll(/chrome\?\.apply\(chromeState\(/g)].length; assert.equal( diff --git a/src/test/unify.test.ts b/src/test/unify.test.ts index f476dc7..870049b 100644 --- a/src/test/unify.test.ts +++ b/src/test/unify.test.ts @@ -165,6 +165,7 @@ describe("one California's chapters", () => { // Nothing merged twice, which a naive concat of two packs sharing an id // would do without failing anything else. assert.equal(new Set(ids).size, ids.length); + assert.ok(ids.includes("fidi"), "the visitor landing names a chapter the merge dropped"); }); it("restates each merged rung in this board's units, keeping its true metres", () => {