From b6bb92fa29b21d6b8eae358274b1f8ae50a6a6f3 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:43:50 +0300 Subject: [PATCH 1/2] feat: unload far-away areas past a budget and regenerate them in place Co-Authored-By: Claude Opus 5.5 --- apps/web/src/area-budget.test.ts | 183 ++++++++++++++++++ apps/web/src/area-budget.ts | 136 +++++++++++++ apps/web/src/ghost-shelf.ts | 9 +- apps/web/src/map-page-text.test.ts | 12 ++ apps/web/src/map-page-text.ts | 3 +- apps/web/src/use-area-world.test.ts | 49 +++++ apps/web/src/use-area-world.ts | 100 +++++++--- apps/web/src/world-app.tsx | 51 ++++- apps/web/src/world-map-model.test.ts | 20 +- apps/web/src/world-map-model.ts | 47 ++++- apps/web/src/world-map-overlay.tsx | 3 +- apps/web/src/world-map.test.tsx | 17 ++ apps/web/src/world-map.tsx | 19 +- packages/types/src/types.ts | 15 ++ packages/world-generator/src/area-checks.ts | 15 +- .../src/area-store-evict.test.ts | 168 ++++++++++++++++ packages/world-generator/src/area-store.ts | 77 ++++++-- .../world-generator/src/area-zones.test.ts | 14 ++ packages/world-generator/src/areas.ts | 12 +- 19 files changed, 886 insertions(+), 64 deletions(-) create mode 100644 apps/web/src/area-budget.test.ts create mode 100644 apps/web/src/area-budget.ts create mode 100644 packages/world-generator/src/area-store-evict.test.ts diff --git a/apps/web/src/area-budget.test.ts b/apps/web/src/area-budget.test.ts new file mode 100644 index 0000000..ecc8d97 --- /dev/null +++ b/apps/web/src/area-budget.test.ts @@ -0,0 +1,183 @@ +import { ENTRANCE_ID } from "@repo/code-graph/ids"; +import { checkAreas } from "@repo/world-generator/area-checks"; +import type { AreaStore } from "@repo/world-generator/area-store"; +import { describe, expect, it } from "vitest"; + +import { + areaOfRoom, + createUnloader, + evictions, + pinnedAreas, +} from "./area-budget"; +import { examples } from "./examples"; +import { storeFor, targetAreaOf } from "./use-area-world"; +import { codeGraphOf } from "./world-from-code"; + +/** The lazy store of the repo example: the entrance and five modules. */ +const repoStore = (seed = 1): AreaStore => { + const { codeGraph, error } = codeGraphOf(examples.repo); + if (codeGraph === null) { + throw new Error(`repo: ${error}`); + } + const source = storeFor(seed, { + codeGraph, + codeError: null, + eager: false, + rooms: 15, + preset: null, + }); + if (source.store === null) { + throw new Error(source.error); + } + return source.store; +}; + +const loadedOf = (store: AreaStore) => + store.snapshot().world.areas.map(({ id }) => id); + +const NO_PINS = { roomId: null, units: [], returnUnit: null } as const; + +describe("evictions", () => { + const seen = new Map([ + ["a", 3], + ["b", 1], + ["c", 2], + ["d", 4], + ]); + const loaded = ["a", "b", "c", "d"]; + + it("unloads nothing within the budget", () => { + expect(evictions({ loaded, pinned: new Set(), seen, budget: 4 })).toEqual( + [] + ); + }); + + it("unloads the least recently seen first, down to the budget", () => { + expect(evictions({ loaded, pinned: new Set(), seen, budget: 2 })).toEqual([ + "b", + "c", + ]); + }); + + it("never unloads a pinned area, and pins win over the budget", () => { + expect( + evictions({ loaded, pinned: new Set(["b"]), seen, budget: 2 }) + ).toEqual(["c", "a"]); + expect( + evictions({ loaded, pinned: new Set(["a", "b", "c"]), seen, budget: 1 }) + ).toEqual(["d"]); + }); +}); + +describe("pinnedAreas and areaOfRoom", () => { + it("pins the entry, the area here and the areas of the units given", () => { + const store = repoStore(); + const [first, second] = store.snapshot().world.pending; + store.ensure(first ?? ""); + store.ensure(second ?? ""); + const { world } = store.snapshot(); + const unit = [...world.areaOf].find(([, area]) => area === second)?.[0]; + expect([...pinnedAreas(world, first ?? null, [unit ?? ""])]).toEqual([ + ENTRANCE_ID, + first, + second, + ]); + expect([...pinnedAreas(world, null, [])]).toEqual([ENTRANCE_ID]); + }); + + it("finds a corridor's area by its layout", () => { + const store = repoStore(); + const { world } = store.snapshot(); + const [entrance] = world.areas; + expect(areaOfRoom(world, null)).toBeUndefined(); + expect(areaOfRoom(world, "no such room")).toBeUndefined(); + expect(areaOfRoom(world, ENTRANCE_ID)).toBe(ENTRANCE_ID); + // A room missing from `areaOf` (a corridor) is found by layout. + const unmapped = { ...world, areaOf: new Map() }; + expect(areaOfRoom(unmapped, entrance?.layout.rooms[0]?.id ?? null)).toBe( + ENTRANCE_ID + ); + }); +}); + +describe("createUnloader", () => { + it("keeps a walk through portals within the budget and the world valid", () => { + for (const seed of [1, 2, 3]) { + const store = repoStore(seed); + const unload = createUnloader(store, 2); + const walked: string[] = []; + for (;;) { + // Through a portal of a loaded area into one not generated yet. + const { world } = store.snapshot(); + const next = world.areas + .flatMap((area) => area.graph.portals ?? []) + .map((portal) => targetAreaOf(world, portal.id)) + .find((area) => area !== null && world.pending.includes(area)); + if (next === undefined || next === null) { + break; + } + store.ensure(next); + unload({ ...NO_PINS, roomId: next }); + walked.push(next); + expect(loadedOf(store)).toEqual([ENTRANCE_ID, next]); + expect(checkAreas(store.snapshot().world)).toEqual([]); + } + const { world } = store.snapshot(); + expect(walked.length).toBeGreaterThan(1); + expect(world.unloaded.map(({ id }) => id)).toEqual(walked.slice(0, -1)); + } + }); + + it("unloads the least recently visited and never what was just loaded", () => { + const store = repoStore(); + const unload = createUnloader(store, 3); + const [a, b, c] = store.snapshot().world.pending; + if (a === undefined || b === undefined || c === undefined) { + throw new Error("the repo example has fewer than three modules"); + } + store.ensure(a); + unload({ ...NO_PINS, roomId: a }); + store.ensure(b); + unload({ ...NO_PINS, roomId: b }); + // Ahead of the player in b: c is generated before they step in. + store.ensure(c); + unload({ ...NO_PINS, roomId: b }); + expect(loadedOf(store)).toEqual([ENTRANCE_ID, b, c]); + }); + + it("keeps the units given and the area last stood in between rooms", () => { + const store = repoStore(); + const unload = createUnloader(store, 1); + const [a, b] = store.snapshot().world.pending; + store.ensure(a ?? ""); + unload({ ...NO_PINS, roomId: a ?? null }); + store.ensure(b ?? ""); + const unit = [...store.snapshot().world.areaOf].find( + ([, area]) => area === b + )?.[0]; + // Over the budget of one: the pins win, and only what is pinned stays. + unload({ ...NO_PINS, units: [unit ?? ""] }); + expect(loadedOf(store)).toEqual([ENTRANCE_ID, a, b]); + unload(NO_PINS); + expect(loadedOf(store)).toEqual([ENTRANCE_ID, a]); + }); + + it("generates the area of the return unit again when it was unloaded", () => { + const store = repoStore(); + const unload = createUnloader(store, 2); + const [a, b] = store.snapshot().world.pending; + store.ensure(a ?? ""); + unload({ ...NO_PINS, roomId: a ?? null }); + store.ensure(b ?? ""); + unload({ ...NO_PINS, roomId: b ?? null }); + expect(loadedOf(store)).toEqual([ENTRANCE_ID, b]); + const caller = [...store.snapshot().world.areaOf].find( + ([, area]) => area === a + )?.[0]; + unload({ ...NO_PINS, roomId: b ?? null, returnUnit: caller ?? null }); + expect(loadedOf(store)).toEqual([ENTRANCE_ID, b, a]); + // Pinned now, so the next call keeps it although it is over the budget. + unload({ ...NO_PINS, roomId: b ?? null, returnUnit: caller ?? null }); + expect(loadedOf(store)).toEqual([ENTRANCE_ID, b, a]); + }); +}); diff --git a/apps/web/src/area-budget.ts b/apps/web/src/area-budget.ts new file mode 100644 index 0000000..e9ef263 --- /dev/null +++ b/apps/web/src/area-budget.ts @@ -0,0 +1,136 @@ +import type { AreaWorld } from "@repo/types"; +import type { AreaStore } from "@repo/world-generator/area-store"; + +// Which areas to unload as the player walks a large world: the built areas +// past a budget, least recently visited first, never one the player stands +// in or is about to land in. An unloaded area comes back the same, where it +// was, the next time anything needs it (`AreaStore.ensure`). + +/** + * Built areas kept at most, unless more are pinned. §18 of the plan: about + * 1.4 MB of heap per built area, and GPU buffers per area seen. + */ +const MAX_LOADED_AREAS = 12; + +/** + * The area of a room: its unit's area, or for a corridor (not a unit) the + * area whose layout has it; undefined for no room or an unknown one. + */ +const areaOfRoom = ( + world: AreaWorld, + roomId: string | null +): string | undefined => + roomId === null + ? undefined + : (world.areaOf.get(roomId) ?? + world.areas.find((area) => + area.layout.rooms.some((room) => room.id === roomId) + )?.id); + +/** + * The areas never unloaded now: the entry (the player's start), the area + * `here` and the area of every unit given (where the next teleport, step or + * Backspace lands). Bounded by what the caller passes, never by the stack. + */ +const pinnedAreas = ( + world: AreaWorld, + here: string | null, + units: readonly string[] +): ReadonlySet => + new Set([ + world.entry, + ...(here === null ? [] : [here]), + ...units.flatMap((unit) => areaOfRoom(world, unit) ?? []), + ]); + +type EvictionInput = { + /** The built areas. */ + readonly loaded: readonly string[]; + readonly pinned: ReadonlySet; + /** When each area was last visited or loaded; higher is more recent. */ + readonly seen: ReadonlyMap; + readonly budget: number; +}; + +/** + * The areas to unload to get down to `budget`: unpinned ones, least + * recently seen first. When the pins alone are over the budget they win: + * every unpinned area goes, and no pinned one. + */ +const evictions = ({ + loaded, + pinned, + seen, + budget, +}: EvictionInput): readonly string[] => { + const over = loaded.length - budget; + if (over <= 0) { + return []; + } + return loaded + .filter((id) => !pinned.has(id)) + .toSorted((a, b) => (seen.get(a) ?? 0) - (seen.get(b) ?? 0)) + .slice(0, over); +}; + +/** What the player needs loaded now. */ +type Pins = { + /** The room the player stands in, or null between rooms. */ + readonly roomId: string | null; + /** Units a teleport or a step may land in next. */ + readonly units: readonly string[]; + /** + * Where the next Backspace or return portal lands (the top frame's + * caller): kept, and generated again when it was unloaded. + */ + readonly returnUnit: string | null; +}; + +/** + * Unloads `store`'s areas past `budget` each time it is called, after any + * change: a fresh load counts as a visit, so the area just generated for + * the portal ahead is never the first to go. Remembers the last area the + * player stood in, for the moments they stand in none. + */ +const createUnloader = ( + store: AreaStore, + budget = MAX_LOADED_AREAS +): ((pins: Pins) => void) => { + const seen = new Map(); + let tick = 0; + let here: string | null = null; + return ({ roomId, units, returnUnit }) => { + const { world } = store.snapshot(); + here = areaOfRoom(world, roomId) ?? here; + tick += 1; + for (const { id } of world.areas) { + if (!seen.has(id)) { + seen.set(id, tick); + } + } + if (here !== null) { + seen.set(here, tick); + } + const pinned = pinnedAreas( + world, + here, + returnUnit === null ? units : [...units, returnUnit] + ); + const loaded = world.areas.map(({ id }) => id); + for (const id of evictions({ loaded, pinned, seen, budget })) { + store.evict(id); + // Generated again, it counts as fresh. + seen.delete(id); + } + const back = areaOfRoom(world, returnUnit); + if ( + back !== undefined && + store.snapshot().world.unloaded.some(({ id }) => id === back) + ) { + store.ensure(back); + } + }; +}; + +export { areaOfRoom, createUnloader, evictions, MAX_LOADED_AREAS, pinnedAreas }; +export type { Pins }; diff --git a/apps/web/src/ghost-shelf.ts b/apps/web/src/ghost-shelf.ts index 9c0483b..47cdede 100644 --- a/apps/web/src/ghost-shelf.ts +++ b/apps/web/src/ghost-shelf.ts @@ -27,12 +27,13 @@ const ghostGroups = (world: AreaWorld): readonly (readonly string[])[] => { /** * Every ghost's slot: rows on a shelf AREA_GAP above every generated area - * (-Z), as wide as they are, each zone's ghosts starting a row of their - * own. A function of the world alone; the shelf moves out as the world - * grows towards it, and a ghost leaves it once its area is generated. + * (-Z), unloaded ones included, as wide as they are, each zone's ghosts + * starting a row of their own. A function of the world alone; the shelf + * moves out as the world grows towards it, and a ghost leaves it once its + * area is generated. */ const ghostSlots = (world: AreaWorld): ReadonlyMap => { - const rects = world.areas.map((area) => area.bounds); + const rects = [...world.areas, ...world.unloaded].map((area) => area.bounds); const minX = rects.length === 0 ? 0 : Math.min(...rects.map((r) => r.minX)); const maxX = rects.length === 0 ? 0 : Math.max(...rects.map((r) => r.maxX)); const top = diff --git a/apps/web/src/map-page-text.test.ts b/apps/web/src/map-page-text.test.ts index 1c92f95..8dda285 100644 --- a/apps/web/src/map-page-text.test.ts +++ b/apps/web/src/map-page-text.test.ts @@ -24,6 +24,18 @@ describe("mapHeader", () => { ); }); + it("counts an unloaded area as generated", () => { + const store = codeAreaStore(repo(), 1); + const [first] = store.snapshot().world.pending; + store.ensure(first ?? ""); + store.evict(first ?? ""); + const { world } = store.snapshot(); + const total = 2 + world.pending.length; + expect(mapHeader({ name: "repo", seed: 1, world })).toBe( + `repo · seed 1 · 2/${total} areas · 0 failed` + ); + }); + it("counts every area once all are generated", () => { const store = codeAreaStore(repo(), 1); store.ensureAll(); diff --git a/apps/web/src/map-page-text.ts b/apps/web/src/map-page-text.ts index c50e685..bcc7e4f 100644 --- a/apps/web/src/map-page-text.ts +++ b/apps/web/src/map-page-text.ts @@ -11,7 +11,8 @@ type HeaderOptions = { /** `repo · seed 7 · 3/9 areas · 1 failed`. */ const mapHeader = ({ name, seed, world }: HeaderOptions): string => { - const built = world.areas.length; + // An unloaded area was generated: it counts as built. + const built = world.areas.length + world.unloaded.length; const failed = world.failed.length; const total = built + world.pending.length + failed; return `${name} · seed ${seed} · ${built}/${total} areas · ${failed} failed`; diff --git a/apps/web/src/use-area-world.test.ts b/apps/web/src/use-area-world.test.ts index fa5a9cd..395fbf3 100644 --- a/apps/web/src/use-area-world.test.ts +++ b/apps/web/src/use-area-world.test.ts @@ -14,6 +14,7 @@ import { builtModules, candidatesOf, ensureNext, + portalUnits, storeFor, targetAreaOf, warningsOf, @@ -231,6 +232,29 @@ describe("a lazy walk through the repo example", () => { // The entrance did not move: the player's start is where it was. expect(after.built.start).toBe(before.built.start); }); + + it("lands a portal into an unloaded area once it is ensured again", () => { + const store = storeOf(1); + const portal = modulePortalId(ENTRANCE_ID, "src/index.ts"); + const state = { frames: [], roomId: ENTRANCE_ID }; + const event = { type: "portal", portalId: portal } as const; + store.ensure("src/index.ts"); + const first = createNavigator(mergeAreas(store.snapshot().world)).step( + state, + event + ); + store.evict("src/index.ts"); + expect( + createNavigator(mergeAreas(store.snapshot().world)).step(state, event) + .teleport + ).toBeNull(); + store.ensure("src/index.ts"); + const again = createNavigator(mergeAreas(store.snapshot().world)).step( + state, + event + ); + expect(again.teleport).toEqual(first.teleport); + }); }); /** `s.run()` may be `Base.run` or the override in another file. */ @@ -270,6 +294,13 @@ describe("a choice with a candidate in another module", () => { "fast.ts::Fast.run", ]); expect(candidatesOf(world, "no such portal")).toEqual([]); + // Its far side is its own unit, then every candidate. + expect(portalUnits(world, choice.id)).toEqual([ + choice.to, + "base.ts::Base.run", + "fast.ts::Fast.run", + ]); + expect(portalUnits(world, "no such portal")).toEqual([]); const state = { frames: [], roomId: "main.ts::main" }; const event = { type: "choose", @@ -328,6 +359,24 @@ describe("modules and zones of a workspace", () => { expect(builtModules(zoned.snapshot().world, true)).toBe(3); }); + it("counts an unloaded module as built, in the zone too", () => { + const zoned = store(); + zoned.ensure("//zone:web"); + zoned.ensure("apps/web/src/main.ts"); + zoned.ensure("apps/web/src/util.ts"); + zoned.evict("apps/web/src/util.ts"); + const { world } = zoned.snapshot(); + expect(world.unloaded.map(({ id }) => id)).toEqual([ + "apps/web/src/util.ts", + ]); + expect(builtModules(world, true)).toBe(2); + expect(zoneProgress(world, "apps/web/src/main.ts")).toEqual({ + name: "web", + built: 2, + total: 2, + }); + }); + it("counts a random world's one area", () => { const random = storeOf(1, { codeGraph: null }); expect(builtModules(random.snapshot().world, false)).toBe(1); diff --git a/apps/web/src/use-area-world.ts b/apps/web/src/use-area-world.ts index 3f47c47..dc90a19 100644 --- a/apps/web/src/use-area-world.ts +++ b/apps/web/src/use-area-world.ts @@ -10,6 +10,9 @@ import { presets } from "@repo/world-generator/presets"; import type { PresetName } from "@repo/world-generator/presets"; import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { areaOfRoom, createUnloader } from "~/area-budget"; +import type { Pins } from "~/area-budget"; + // The world on screen as a store of areas: a code world generates its // entrance at once and each module when the player first heads for it; a // random or preset world is one area, generated at once. @@ -35,23 +38,35 @@ type AreaView = { readonly current: () => GeneratedWorld | null; /** Rooms (and corridors) of every area so far. */ readonly rooms: number; - /** Generates the area a portal leads into, unless it exists or failed. */ + /** + * Generates the area a portal leads into, unless it exists or failed; + * an unloaded one comes back where it was. + */ readonly ensurePortal: (portalId: string) => void; /** - * Generates the area of a unit (a choice's candidate), unless it exists - * or failed; true when the area is generated once it returns, from the - * store as of now (a failure on this call included). + * Generates the area of a unit (a choice's candidate, a frame's caller), + * unless it exists or failed; true when the area is generated once it + * returns, from the store as of now (a failure on this call included). */ readonly ensureUnit: (unitId: string) => boolean; /** A choice portal's candidates, in order; none for any other portal. */ readonly candidatesOf: (portalId: string) => readonly string[]; + /** + * Unloads the areas past the budget that `pins` does not need, least + * recently visited first (`createUnloader`); call after every change. + * Does nothing when eager: every area is wanted then. + */ + readonly unload: (pins: AreaPins) => void; /** Generates the first pending area; false once none is left. */ readonly ensureNext: () => boolean; /** The area a portal leads into when that area could not be generated. */ readonly failedArea: (portalId: string) => string | null; /** Whether a unit's area could not be generated. */ readonly failedUnit: (unitId: string) => boolean; - /** Modules generated so far and in all; areas of other worlds count as one. */ + /** + * Modules generated so far (unloaded ones too) and in all; areas of other + * worlds count as one. + */ readonly modules: { readonly built: number; readonly total: number }; /** Milliseconds the last area generated on the way took; null when eager. */ readonly lastMs: number | null; @@ -62,6 +77,13 @@ type AreaView = { readonly error: string | null; }; +/** + * What the player needs loaded: the room they stand in, the units the + * next step may land in, and the portal ahead (its far side and, for a + * choice, its candidates). + */ +type AreaPins = Pins & { readonly portalId: string | null }; + /** A zone's name and its module areas generated so far and in all. */ type ZoneProgress = { readonly name: string; @@ -156,6 +178,20 @@ const candidatesOf = ( return []; }; +/** + * Every unit a portal of `world` may land in: its far side, then a + * choice's candidates; none for an unknown portal. + */ +const portalUnits = (world: AreaWorld, portalId: string): readonly string[] => { + for (const area of world.areas) { + const portal = area.graph.portals?.find(({ id }) => id === portalId); + if (portal !== undefined) { + return [portal.to, ...(portal.candidates ?? [])]; + } + } + return []; +}; + /** * Generates the first area still pending and reports true, or false when * none is: called until false, it generates them in `ensureAll`'s order. A @@ -188,16 +224,22 @@ const warningsOf = ( ), ]; +/** Every area generated so far, unloaded since or not. */ +const generatedIds = (world: AreaWorld): readonly string[] => [ + ...world.areas.map(({ id }) => id), + ...world.unloaded.map(({ id }) => id), +]; + /** - * The modules generated so far: every area of a code world but its - * entrance and its zones' hubs; a random or preset world's one area. + * The modules generated so far, unloaded since or not: every area of a + * code world but its entrance and its zones' hubs; a random or preset + * world's one area. */ const builtModules = (world: AreaWorld, code: boolean): number => code - ? world.areas.filter( - (area) => area.id !== world.entry && !isZoneHub(area.id) - ).length - : world.areas.length; + ? generatedIds(world).filter((id) => id !== world.entry && !isZoneHub(id)) + .length + : generatedIds(world).length; /** * The zone the room is in (any of its rooms or corridors, or its hub's) @@ -208,14 +250,10 @@ const zoneProgress = ( world: AreaWorld, roomId: string | null ): ZoneProgress | null => { - if (roomId === null) { + const areaId = areaOfRoom(world, roomId); + if (areaId === undefined) { return null; } - const areaId = - world.areaOf.get(roomId) ?? - world.areas.find((area) => - area.layout.rooms.some((room) => room.id === roomId) - )?.id; const zone = world.zones.find((candidate) => candidate.areas.some((id) => id === areaId) ); @@ -224,7 +262,7 @@ const zoneProgress = ( } // Its hub comes first and is not a module. const modules = zone.areas.slice(1); - const built = new Set(world.areas.map((area) => area.id)); + const built = new Set(generatedIds(world)); return { name: zone.name, built: modules.filter((id) => built.has(id)).length, @@ -268,8 +306,9 @@ const useAreaWorld = (seed: number, options: WorldOptions): AreaView => { if (store === null || now === undefined) { return; } + // A no-op for a built or failed area: pending and unloaded ones generate. const area = targetAreaOf(now.world, portalId); - if (area !== null && now.world.pending.includes(area)) { + if (area !== null) { store.ensure(area); } }, @@ -285,13 +324,26 @@ const useAreaWorld = (seed: number, options: WorldOptions): AreaView => { if (area === undefined) { return false; } - if (now.world.pending.includes(area)) { - store.ensure(area); - } - return store.snapshot().world.areas.some(({ id }) => id === area); + return "area" in store.ensure(area); }, [store] ); + // One per store: its visits start over with the world. + const unloader = useMemo( + () => (store === null || options.eager ? null : createUnloader(store)), + [store, options.eager] + ); + const unload = useCallback( + ({ portalId, units, ...pins }: AreaPins) => { + if (store === null || unloader === null) { + return; + } + const ahead = + portalId === null ? [] : portalUnits(store.snapshot().world, portalId); + unloader({ ...pins, units: [...units, ...ahead] }); + }, + [store, unloader] + ); const candidates = useCallback( (portalId: string) => store === null ? [] : candidatesOf(store.snapshot().world, portalId), @@ -328,6 +380,7 @@ const useAreaWorld = (seed: number, options: WorldOptions): AreaView => { ensurePortal, ensureUnit, candidatesOf: candidates, + unload, ensureNext: next, failedArea, failedUnit, @@ -343,6 +396,7 @@ export { builtModules, candidatesOf, ensureNext, + portalUnits, storeFor, targetAreaOf, useAreaWorld, diff --git a/apps/web/src/world-app.tsx b/apps/web/src/world-app.tsx index eb8d5ea..2bcc8b2 100644 --- a/apps/web/src/world-app.tsx +++ b/apps/web/src/world-app.tsx @@ -57,11 +57,30 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => { // Generated once per seed, then grown an area at a time: it never // regenerates while the player moves. const generated = useAreaWorld(seed, worldOptions); - const { ensurePortal, ensureUnit, candidatesOf } = generated; + const { ensurePortal, ensureUnit, candidatesOf, unload } = generated; // The target a candidate was last picked at (see the key handler). const picked = useRef(null); const navigation = useNavigation(seed, generated.current); - const { onRoomChange, back, home, choose, onPortal: stepPortal } = navigation; + const { + onRoomChange, + back: pop, + home, + choose, + onPortal: stepPortal, + } = navigation; + const top = navigation.frames.at(-1) ?? null; + // Backspace and a return portal land in the top frame's caller, which + // may have been unloaded: generate it first, so the teleport lands. + const returnUnit = top?.callerRoomId ?? null; + const ensureReturn = useCallback(() => { + if (returnUnit !== null) { + ensureUnit(returnUnit); + } + }, [returnUnit, ensureUnit]); + const back = useCallback(() => { + ensureReturn(); + pop(); + }, [ensureReturn, pop]); const onRoom = useCallback( (id: string | null) => { @@ -77,9 +96,10 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => { const onPortal = useCallback( (portalId: string) => { ensurePortal(portalId); + ensureReturn(); stepPortal(portalId); }, - [ensurePortal, stepPortal] + [ensurePortal, ensureReturn, stepPortal] ); // The portal in front of the player leads into an area not generated yet: @@ -91,6 +111,31 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => { } }, [target, ensurePortal, generated.world]); + // Keep the world within its budget of built areas after every change (a + // load included, not only a step into another area). Kept: where the + // player is, where navigation says they are (ahead of the Player after a + // teleport), the top frame's ends and the portal ahead; never the whole + // stack, so the pins stay bounded. An unload changes the world and runs + // this once more, with nothing left to unload. + const calleeUnit = top?.calleeRoomId ?? null; + const portalAhead = target?.kind === "portal" ? target.portalId : null; + useEffect(() => { + unload({ + roomId, + units: [navigation.roomId, calleeUnit].filter((unit) => unit !== null), + returnUnit, + portalId: portalAhead, + }); + }, [ + unload, + generated.world, + roomId, + navigation.roomId, + calleeUnit, + returnUnit, + portalAhead, + ]); + // A new seed or a reopened line starts without the last sample, which // described another world or an earlier moment. useEffect(() => { diff --git a/apps/web/src/world-map-model.test.ts b/apps/web/src/world-map-model.test.ts index fd636b1..c7ca893 100644 --- a/apps/web/src/world-map-model.test.ts +++ b/apps/web/src/world-map-model.test.ts @@ -93,7 +93,7 @@ const overlaps = (a: Rect, b: Rect): boolean => const ghostRects = (model: WorldMapModel): ReadonlyMap => new Map( model.areas - .filter((area) => area.state !== "generated") + .filter((area) => area.state === "pending" || area.state === "failed") .map((area) => [area.id, area.rect]) ); @@ -274,6 +274,24 @@ describe("worldMapModel", () => { expect(rowOf(LIB_ZONE)).toBeLessThan(rowOf(MAIN) ?? 0); }); + it("draws an unloaded area as its outline in its band, not as a ghost", () => { + const store = codeAreaStore(workspace(), 1); + store.ensure(WEB_ZONE); + store.ensure(MAIN); + const loaded = worldMapModel(store.snapshot().world); + store.evict(MAIN); + const model = worldMapModel(store.snapshot().world); + const main = areaOf(model, MAIN); + const outline = { state: "unloaded", rooms: [] }; + expect(main).toEqual({ ...areaOf(loaded, MAIN), ...outline }); + expect(model.zones).toEqual(loaded.zones); + // Not also a ghost, and the shelf stays clear above where it is. + for (const ghost of ghostRects(model).values()) { + expect(ghost.maxZ).toBeLessThanOrEqual(main.rect.minZ - AREA_GAP); + } + expect(ghostRects(model).has(MAIN)).toBe(false); + }); + it("bands each zone round each of its areas, counting its modules", () => { const graph = workspace(); const store = codeAreaStore(graph, 1); diff --git a/apps/web/src/world-map-model.ts b/apps/web/src/world-map-model.ts index 3c4c53c..b54c455 100644 --- a/apps/web/src/world-map-model.ts +++ b/apps/web/src/world-map-model.ts @@ -7,6 +7,7 @@ import type { Point, Rect, RoomKind, + UnloadedArea, } from "@repo/types"; import { roomBounds } from "@repo/world-generator/geometry"; import { areaAt } from "@repo/world-generator/locate"; @@ -16,9 +17,10 @@ import { ghostSlots } from "~/ghost-shelf"; // What the whole-world map draws, worked out without React or the DOM: every // area of the world, generated or not, the zones' bands, the links between // areas and the player. An area not generated yet has no place in the world, -// so it is drawn as a ghost on a shelf above the world (`ghostSlots`). +// so it is drawn as a ghost on a shelf above the world (`ghostSlots`); one +// unloaded keeps its place, drawn as an outline without rooms. -type AreaState = "generated" | "pending" | "failed"; +type AreaState = "generated" | "unloaded" | "pending" | "failed"; type MapAreaRoom = { readonly id: string; @@ -34,9 +36,9 @@ type MapArea = { readonly kind: "entrance" | "zone" | "module"; readonly zoneId: string | null; readonly state: AreaState; - /** Its footprint once generated, otherwise its ghost's slot. */ + /** Its footprint once generated (unloaded since or not), else its ghost's slot. */ readonly rect: Rect; - /** Its rooms and corridors; none for a ghost. */ + /** Its rooms and corridors; none for a ghost or an unloaded area. */ readonly rooms: readonly MapAreaRoom[]; /** Why it could not be generated, for a failed area. */ readonly error: string | null; @@ -47,11 +49,14 @@ type MapArea = { type MapZone = { readonly id: string; readonly name: string; - /** Its band: each of its generated areas, padded; ghosts sit on the shelf. */ + /** + * Its band: each of its generated areas, unloaded ones too, padded; + * ghosts sit on the shelf. + */ readonly rects: readonly Rect[]; /** Where its name goes: its hub's band rect, or its first; null with none. */ readonly rect: Rect | null; - /** Its module areas (not its hub) generated so far and in all. */ + /** Its module areas (not its hub) generated so far, unloaded too, and in all. */ readonly built: number; readonly total: number; }; @@ -150,6 +155,23 @@ const generatedArea = ( entry: area.id === world.entry, }); +/** An unloaded area: its outline where it was, without rooms. */ +const unloadedArea = ( + world: AreaWorld, + area: UnloadedArea, + label: string +): MapArea => ({ + id: area.id, + label, + kind: kindOf(world, area.id), + zoneId: area.zoneId, + state: "unloaded", + rect: area.bounds, + rooms: [], + error: null, + entry: area.id === world.entry, +}); + /** Pending areas, then failed ones, each as a ghost in its slot. */ const ghostAreas = ( world: AreaWorld, @@ -181,13 +203,16 @@ const zonesOf = ( world: AreaWorld, areas: ReadonlyMap ): readonly MapZone[] => { - const built = new Set(world.areas.map((area) => area.id)); + const built = new Set([ + ...world.areas.map((area) => area.id), + ...world.unloaded.map((area) => area.id), + ]); return world.zones.map((zone): MapZone => { // Its hub comes first and is not a module. const [hub] = zone.areas; const generated = zone.areas.flatMap((id) => { const area = areas.get(id); - return area?.state === "generated" ? [area] : []; + return area !== undefined && built.has(area.id) ? [area] : []; }); const rects = generated.map((area) => padded(area.rect, ZONE_PAD)); const labelled = @@ -301,7 +326,8 @@ const labelFontSize = (bounds: Rect): number => /** * The whole world for the map: every generated area with its rooms, every - * pending or failed one as a ghost on the shelf above the world, each + * unloaded one as its outline, every pending or failed one as a ghost on + * the shelf above the world, each * zone's band, the links between areas, and the player when placed. */ const worldMapModel = ( @@ -315,6 +341,9 @@ const worldMapModel = ( ): WorldMapModel => { const areas = [ ...world.areas.map((area) => generatedArea(world, area, labelOf(area.id))), + ...world.unloaded.map((area) => + unloadedArea(world, area, labelOf(area.id)) + ), ...ghostAreas(world, errors, labelOf), ]; const byId = new Map(areas.map((area) => [area.id, area])); diff --git a/apps/web/src/world-map-overlay.tsx b/apps/web/src/world-map-overlay.tsx index c222d07..9345049 100644 --- a/apps/web/src/world-map-overlay.tsx +++ b/apps/web/src/world-map-overlay.tsx @@ -61,7 +61,8 @@ const WorldMapOverlay = ({ element?.addEventListener("click", keepClick); return () => element?.removeEventListener("click", keepClick); }, []); - const built = world.areas.length; + // An unloaded area was generated: it counts as built. + const built = world.areas.length + world.unloaded.length; const total = built + world.pending.length + world.failed.length; return ( diff --git a/apps/web/src/world-map.test.tsx b/apps/web/src/world-map.test.tsx index 814acb8..3021e4a 100644 --- a/apps/web/src/world-map.test.tsx +++ b/apps/web/src/world-map.test.tsx @@ -86,6 +86,23 @@ describe("WorldMap", () => { expect(done).not.toContain('data-area-state="pending"'); }); + it("draws an unloaded area dotted, with its label and no rooms", () => { + const graph = repo(); + const store = codeAreaStore(graph, 1); + const labelOf = (id: string) => areaLabelOf(graph, id); + store.ensure("src/index.ts"); + store.evict("src/index.ts"); + const markup = render(store.snapshot().world, { labelOf }); + expect(countOf(markup, 'data-area-state="unloaded"')).toBe(1); + expect(markup).toContain('stroke-dasharray="1 2"'); + expect(markup).toContain(">src/index.ts"); + const group = markup.slice( + markup.indexOf('data-area="src/index.ts"'), + markup.indexOf("", markup.indexOf('data-area="src/index.ts"')) + ); + expect(countOf(group, "map-detail")).toBe(1); + }); + it("draws a failed area red with its message", () => { const world = codeAreaStore(repo(), 1).snapshot().world; const [failed, ...pending] = world.pending; diff --git a/apps/web/src/world-map.tsx b/apps/web/src/world-map.tsx index 81888aa..51ce13b 100644 --- a/apps/web/src/world-map.tsx +++ b/apps/web/src/world-map.tsx @@ -49,6 +49,21 @@ const outlineOf = (area: MapArea): string => { return area.entry ? BRIGHT : PLAIN; }; +/** A pending ghost dashed; an unloaded area dotted, where it was. */ +const DASHES: Readonly> = { + generated: undefined, + unloaded: "1 2", + pending: "4 3", + failed: undefined, +}; + +const strokeOpacityOf = (area: MapArea): number => { + if (area.state === "generated") { + return 0.7; + } + return area.state === "unloaded" ? 0.4 : 0.6; +}; + type LayersProps = { readonly model: WorldMapModel; /** Prefix of this map's clip path ids, unique on the page. */ @@ -109,8 +124,8 @@ const Layers = ({ model, clip }: LayersProps) => { fill={area.state === "failed" ? FAILED : "none"} fillOpacity={0.15} stroke={colour} - strokeOpacity={area.state === "generated" ? 0.7 : 0.6} - strokeDasharray={area.state === "pending" ? "4 3" : undefined} + strokeOpacity={strokeOpacityOf(area)} + strokeDasharray={DASHES[area.state]} vectorEffect="non-scaling-stroke" /> {area.rooms.map((room) => ( diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index c85f533..4a4c280 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -382,6 +382,14 @@ type AreaZone = { readonly bounds: Rect | null; }; +/** An area built once and evicted since: its place is kept for it. */ +type UnloadedArea = { + readonly id: string; + /** The footprint it had, and will have again once generated. */ + readonly bounds: Rect; + readonly zoneId: string | null; +}; + type AreaWorld = { readonly seed: number; /** The area the player starts in. */ @@ -398,6 +406,12 @@ type AreaWorld = { * portals into them never land. */ readonly failed: readonly string[]; + /** + * Areas built once and evicted since, in the order they were: neither + * built nor pending. Portals into them are dark until they are generated + * again, in the same place. + */ + readonly unloaded: readonly UnloadedArea[]; /** The area of every unit (graph room) of every area, pending ones too. */ readonly areaOf: ReadonlyMap; /** The zones, in strip order; empty when nothing is zoned. */ @@ -435,6 +449,7 @@ export type { RoomData, RoomKind, RoomSize, + UnloadedArea, Vec3, WallSegment, WallSide, diff --git a/packages/world-generator/src/area-checks.ts b/packages/world-generator/src/area-checks.ts index 8bcaa1b..e263673 100644 --- a/packages/world-generator/src/area-checks.ts +++ b/packages/world-generator/src/area-checks.ts @@ -27,15 +27,19 @@ const shared = ( /** * Every portal into another area: it leads to a unit some area has, lands * inside that unit's room there, clear of the walls; one into an area not - * generated yet, or one that failed, only has to name a unit of it. - * Returns the areas each area's placed portals reach. + * generated yet, one that failed or one unloaded only has to name a unit + * of it. Returns the areas each area's placed portals reach. */ const checkCrossings = ( world: AreaWorld, failures: string[] ): ReadonlyMap> => { const byId = new Map(world.areas.map((area) => [area.id, area])); - const pending = new Set([...world.pending, ...world.failed]); + const pending = new Set([ + ...world.pending, + ...world.failed, + ...world.unloaded.map(({ id }) => id), + ]); const reaches = new Map>(); for (const area of world.areas) { const own = new Set(); @@ -152,7 +156,8 @@ const checkZones = ( * `checkLayout`; room and portal ids unique across areas; areas at least * AREA_GAP apart, each start room at its offset; portals into other areas * landing in their unit; every area reachable from the entry over placed - * portals; and the zones as `checkZones` has them. + * portals, unless one is unloaded (it may have been the only way in, and + * its portals are gone with it); and the zones as `checkZones` has them. */ const checkAreas = (world: AreaWorld): string[] => { const failures: string[] = []; @@ -194,7 +199,7 @@ const checkAreas = (world: AreaWorld): string[] => { reaches, new Set(world.areas.map((area) => area.id)) ); - for (const area of world.areas) { + for (const area of world.unloaded.length === 0 ? world.areas : []) { if (!seen.has(area.id)) { failures.push(`area ${area.id} is unreachable from ${world.entry}`); } diff --git a/packages/world-generator/src/area-store-evict.test.ts b/packages/world-generator/src/area-store-evict.test.ts new file mode 100644 index 0000000..fa03ead --- /dev/null +++ b/packages/world-generator/src/area-store-evict.test.ts @@ -0,0 +1,168 @@ +import type { AreaWorld } from "@repo/types"; +import { describe, expect, it } from "vitest"; + +import { checkAreas } from "./area-checks"; +import { createAreaStore } from "./area-store"; +import type { AreaSpec } from "./area-store"; +import { pendingPortals } from "./areas"; +import { areaGraphs } from "./portal-world"; +import { generateWorld } from "./world-generator"; + +// The three test areas: one leads to two, two to three. + +const specsOf = ( + seed: number, + calls?: Map +): readonly AreaSpec[] => + areaGraphs.map(([id, graph]) => ({ + id, + units: graph.rooms.map((room) => room.id), + generate: () => { + calls?.set(id, (calls.get(id) ?? 0) + 1); + return generateWorld({ seed, graph, corridorPrefix: `${id}/corridor-` }); + }, + })); + +/** In rows (`packOffsets`): no area has a parent. */ +const rowsOf = (seed: number, calls?: Map) => + createAreaStore({ seed, entry: "one", specs: specsOf(seed, calls) }); + +/** Each beside the one it is reached from (`placeNext`). */ +const parentedOf = (seed: number, calls?: Map) => + createAreaStore({ + seed, + entry: "one", + specs: specsOf(seed, calls).map((spec): AreaSpec => { + const parent = { two: "one", three: "two" }[spec.id]; + return parent === undefined ? spec : { ...spec, parent }; + }), + }); + +const areaIn = (world: AreaWorld, id: string) => + world.areas.find((area) => area.id === id); + +describe("createAreaStore evict", () => { + for (const [name, make] of [ + ["rows", rowsOf], + ["parents", parentedOf], + ] as const) { + it(`generates an evicted area again the same and in its place (${name})`, () => { + const calls = new Map(); + const store = make(2, calls); + store.ensure("two"); + const before = areaIn(store.snapshot().world, "two"); + expect(store.evict("two")).toBe(true); + expect(areaIn(store.snapshot().world, "two")).toBeUndefined(); + expect("area" in store.ensure("two")).toBe(true); + const after = areaIn(store.snapshot().world, "two"); + expect(after).not.toBe(before); + expect(after?.offset).toEqual(before?.offset); + expect(after?.bounds).toEqual(before?.bounds); + expect(after?.layout.rooms).toEqual(before?.layout.rooms); + expect(after?.built.colliders).toEqual(before?.built.colliders); + expect(after?.built.portals).toEqual(before?.built.portals); + expect(calls.get("two")).toBe(2); + expect(store.snapshot().world.unloaded).toEqual([]); + expect(checkAreas(store.snapshot().world)).toEqual([]); + }); + + it(`places later areas the same with or without an eviction (${name})`, () => { + const plain = make(3); + plain.ensure("two"); + plain.ensure("three"); + const evicting = make(3); + evicting.ensure("two"); + evicting.evict("two"); + // Three is placed while two is unloaded: two's place stays taken. + evicting.ensure("three"); + evicting.ensure("two"); + for (const id of ["two", "three"]) { + expect( + areaIn(evicting.snapshot().world, id)?.offset, + `${name} ${id}` + ).toEqual(areaIn(plain.snapshot().world, id)?.offset); + } + expect(checkAreas(evicting.snapshot().world)).toEqual([]); + }); + } + + it("lists an evicted area as unloaded, not pending, and passes checkAreas", () => { + const store = rowsOf(1); + store.ensure("two"); + const bounds = areaIn(store.snapshot().world, "two")?.bounds; + store.evict("two"); + const { world } = store.snapshot(); + expect(world.areas.map(({ id }) => id)).toEqual(["one"]); + expect(world.unloaded).toEqual([{ id: "two", bounds, zoneId: null }]); + expect(world.pending).toEqual(["three"]); + expect(world.failed).toEqual([]); + expect(world.areaOf.get("work")).toBe("two"); + // Area one's portals into two are dark again, until two comes back. + const [one] = world.areas; + expect(one && pendingPortals(world, one)).toEqual([ + "module:hub>hub2", + "portal:main>work", + ]); + expect(checkAreas(world)).toEqual([]); + }); + + it("never evicts the entry, an area not built or an unknown one", () => { + const store = rowsOf(1); + let notified = 0; + store.subscribe(() => { + notified += 1; + }); + const before = store.snapshot(); + expect(store.evict("one")).toBe(false); + expect(store.evict("two")).toBe(false); + expect(store.evict("nowhere")).toBe(false); + expect(store.snapshot()).toBe(before); + expect(notified).toBe(0); + store.ensure("two"); + store.evict("two"); + expect(store.evict("two")).toBe(false); + }); + + it("publishes an eviction and keeps the last generation time", () => { + const store = rowsOf(1); + store.ensure("two"); + const grown = store.snapshot(); + let notified = 0; + store.subscribe(() => { + notified += 1; + }); + store.evict("two"); + expect(notified).toBe(1); + expect(store.snapshot()).not.toBe(grown); + expect(store.snapshot().lastMs).toBe(grown.lastMs); + }); + + it("keeps the outline of an area that fails to come back", () => { + let generated = 0; + const specs = specsOf(1).map((spec): AreaSpec => + spec.id === "two" + ? { + ...spec, + generate: () => { + generated += 1; + if (generated > 1) { + throw new Error("changed"); + } + return spec.generate(); + }, + } + : spec + ); + const store = createAreaStore({ seed: 1, entry: "one", specs }); + store.ensure("two"); + store.evict("two"); + expect(store.ensure("two")).toEqual({ error: "Error: changed" }); + expect(store.ensure("two")).toEqual({ error: "Error: changed" }); + expect(generated).toBe(2); + const { world, errors } = store.snapshot(); + expect(world.unloaded.map(({ id }) => id)).toEqual(["two"]); + expect(world.failed).toEqual([]); + expect(world.pending).not.toContain("two"); + expect(errors.get("two")).toBe("Error: changed"); + }); +}); diff --git a/packages/world-generator/src/area-store.ts b/packages/world-generator/src/area-store.ts index a812b75..61922f3 100644 --- a/packages/world-generator/src/area-store.ts +++ b/packages/world-generator/src/area-store.ts @@ -1,9 +1,17 @@ // Areas generated when they are first needed: the entry at once, every other // one on `ensure`, placed in the order they were asked for. An area never // moves once placed (the packing is a function of the sequence so far), so -// a world that grows keeps every room, portal and landing it had. - -import type { AreaWorld, BuiltArea, GeneratedWorld, Rect } from "@repo/types"; +// a world that grows keeps every room, portal and landing it had. An area +// can be evicted and generated again: its place stays reserved, and +// generation is deterministic, so it comes back the same and where it was. + +import type { + AreaWorld, + BuiltArea, + GeneratedWorld, + Rect, + UnloadedArea, +} from "@repo/types"; import { areaZones, @@ -46,8 +54,16 @@ type AreaStore = { readonly snapshot: () => AreaState; /** Called once per change; returns the unsubscribe. */ readonly subscribe: (listener: () => void) => () => void; - /** Generates the area unless it exists; a failure is kept, not retried. */ + /** + * Generates the area unless it exists, in its old place when it was + * evicted; a failure is kept, not retried. + */ readonly ensure: (areaId: string) => Ensured; + /** + * Drops a built area but keeps its place; true when it did. Never the + * entry: the player's start is in it. + */ + readonly evict: (areaId: string) => boolean; /** Every area, in spec order. */ readonly ensureAll: () => void; }; @@ -79,7 +95,10 @@ const createAreaStore = ({ /** Every placed area, where it is; and its bounds round the origin. */ const placed: PlacedSlot[] = []; const unmoved: Rect[] = []; + /** Each placed area's slot, kept after an eviction. */ + const slots = new Map(); const areas: BuiltArea[] = []; + const unloaded = new Map(); const errors = new Map(); /** Beside its parent with parents, else in rows as `packOffsets` packs. */ @@ -95,6 +114,18 @@ const createAreaStore = ({ const generate = (spec: AreaSpec): BuiltArea => { const world = spec.generate(); + const saved = slots.get(spec.id); + if (saved !== undefined) { + // Evicted: back in its reserved place, which nothing else took. + const area = placeArea( + { id: spec.id, world, zone: spec.zone }, + saved.offset + ); + unloaded.delete(spec.id); + built.set(spec.id, area); + areas.push(area); + return area; + } const rect = layoutBounds(world.layout.rooms); const slot = slotOf(spec.id, rect); const area = placeArea( @@ -102,7 +133,9 @@ const createAreaStore = ({ slot.offset ); // Only a placed area takes up room: a throw above leaves no trace. - placed.push({ ...slot, rect: area.bounds }); + const kept = { ...slot, rect: area.bounds }; + placed.push(kept); + slots.set(spec.id, kept); unmoved.push(rect); built.set(spec.id, area); areas.push(area); @@ -124,10 +157,12 @@ const createAreaStore = ({ areas: [...areas], pending: specs .map((spec) => spec.id) - .filter((id) => !built.has(id) && !errors.has(id)), - failed: [...errors.keys()], + .filter((id) => !built.has(id) && !errors.has(id) && !unloaded.has(id)), + // One unloaded that failed to come back stays an outline, not a ghost. + failed: [...errors.keys()].filter((id) => !unloaded.has(id)), + unloaded: [...unloaded.values()], areaOf, - zones: areaZones(zones, specs, areas), + zones: areaZones(zones, specs, areas, [...unloaded.values()]), }, errors: new Map(errors), lastMs, @@ -140,8 +175,8 @@ const createAreaStore = ({ generate(first); let state = stateOf(null); - const publish = (started: number) => { - state = stateOf(Date.now() - started); + const publish = (lastMs: number | null) => { + state = stateOf(lastMs); for (const listener of listeners) { listener(); } @@ -165,16 +200,33 @@ const createAreaStore = ({ const started = Date.now(); try { const area = generate(spec); - publish(started); + publish(Date.now() - started); return { area }; } catch (error) { const message = String(error); errors.set(areaId, message); - publish(started); + publish(Date.now() - started); return { error: message }; } }; + const evict = (areaId: string): boolean => { + const area = built.get(areaId); + if (area === undefined || areaId === entry) { + return false; + } + built.delete(areaId); + areas.splice(areas.indexOf(area), 1); + unloaded.set(areaId, { + id: areaId, + bounds: area.bounds, + zoneId: area.zoneId, + }); + // Nothing was generated: the HUD keeps the last generation's time. + publish(state.lastMs); + return true; + }; + return { snapshot: () => state, subscribe: (listener) => { @@ -184,6 +236,7 @@ const createAreaStore = ({ }; }, ensure, + evict, ensureAll: () => { for (const spec of specs) { ensure(spec.id); diff --git a/packages/world-generator/src/area-zones.test.ts b/packages/world-generator/src/area-zones.test.ts index 7cae691..0f18674 100644 --- a/packages/world-generator/src/area-zones.test.ts +++ b/packages/world-generator/src/area-zones.test.ts @@ -190,6 +190,20 @@ describe("createAreaStore with zones", () => { expect(checkAreas(world)).toEqual([]); }); + it("keeps a zone's bounds and areas after its areas are evicted", () => { + const store = storeOf(1); + for (const id of ["two", "four", "three"]) { + store.ensure(id); + } + const before = store.snapshot().world.zones; + store.evict("two"); + store.evict("four"); + const { world } = store.snapshot(); + expect(world.zones).toEqual(before); + expect(world.unloaded.map(({ zoneId }) => zoneId)).toEqual(["a", "a"]); + expect(checkAreas(world)).toEqual([]); + }); + it("places areas as the zoned eager assembly does in the same order", () => { const store = storeOf(2); for (const id of ["three", "two", "four"]) { diff --git a/packages/world-generator/src/areas.ts b/packages/world-generator/src/areas.ts index 3053fd3..7ac2495 100644 --- a/packages/world-generator/src/areas.ts +++ b/packages/world-generator/src/areas.ts @@ -11,6 +11,7 @@ import type { Point, Rect, RoomData, + UnloadedArea, WorldLayout, } from "@repo/types"; @@ -174,15 +175,19 @@ const parentsOf = ( /** * Each zone with every area of it (built or not, hub first, then in member - * order) and the bounds of its built ones. + * order) and the bounds of its built ones and its unloaded ones, which keep + * their place: a zone never shrinks. */ const areaZones = ( zones: readonly ZoneSpec[], members: readonly { readonly id: string; readonly zone?: string }[], - areas: readonly BuiltArea[] + areas: readonly BuiltArea[], + unloaded: readonly UnloadedArea[] = [] ): readonly AreaZone[] => zones.map((zone) => { - const built = areas.filter((area) => area.zoneId === zone.id); + const built = [...areas, ...unloaded].filter( + (area) => area.zoneId === zone.id + ); return { id: zone.id, name: zone.name, @@ -253,6 +258,7 @@ const assembleAreas = ( areas, pending: [], failed: [], + unloaded: [], areaOf, zones: areaZones(zones, parts, areas), }; From 35a5908ef1be62bf92dc0acf585e2428ecceea0c Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:05:02 +0300 Subject: [PATCH 2/2] fix: address review findings Co-Authored-By: Claude Opus 5.5 --- apps/web/src/area-budget.test.ts | 9 +++++---- apps/web/src/area-budget.ts | 29 ++++++++++++++--------------- apps/web/src/use-area-world.ts | 19 +++++++++++++++++++ apps/web/src/world-app.tsx | 21 +++++++++++++-------- 4 files changed, 51 insertions(+), 27 deletions(-) diff --git a/apps/web/src/area-budget.test.ts b/apps/web/src/area-budget.test.ts index ecc8d97..7834c26 100644 --- a/apps/web/src/area-budget.test.ts +++ b/apps/web/src/area-budget.test.ts @@ -35,7 +35,7 @@ const repoStore = (seed = 1): AreaStore => { const loadedOf = (store: AreaStore) => store.snapshot().world.areas.map(({ id }) => id); -const NO_PINS = { roomId: null, units: [], returnUnit: null } as const; +const NO_PINS = { roomId: null, units: [], needed: [] } as const; describe("evictions", () => { const seen = new Map([ @@ -162,7 +162,7 @@ describe("createUnloader", () => { expect(loadedOf(store)).toEqual([ENTRANCE_ID, a]); }); - it("generates the area of the return unit again when it was unloaded", () => { + it("generates the area of a needed unit again when it was unloaded", () => { const store = repoStore(); const unload = createUnloader(store, 2); const [a, b] = store.snapshot().world.pending; @@ -174,10 +174,11 @@ describe("createUnloader", () => { const caller = [...store.snapshot().world.areaOf].find( ([, area]) => area === a )?.[0]; - unload({ ...NO_PINS, roomId: b ?? null, returnUnit: caller ?? null }); + const needed = [caller ?? ""]; + unload({ ...NO_PINS, roomId: b ?? null, needed }); expect(loadedOf(store)).toEqual([ENTRANCE_ID, b, a]); // Pinned now, so the next call keeps it although it is over the budget. - unload({ ...NO_PINS, roomId: b ?? null, returnUnit: caller ?? null }); + unload({ ...NO_PINS, roomId: b ?? null, needed }); expect(loadedOf(store)).toEqual([ENTRANCE_ID, b, a]); }); }); diff --git a/apps/web/src/area-budget.ts b/apps/web/src/area-budget.ts index e9ef263..47256e3 100644 --- a/apps/web/src/area-budget.ts +++ b/apps/web/src/area-budget.ts @@ -80,10 +80,11 @@ type Pins = { /** Units a teleport or a step may land in next. */ readonly units: readonly string[]; /** - * Where the next Backspace or return portal lands (the top frame's - * caller): kept, and generated again when it was unloaded. + * Units the player is in or lands in next without a step that generates + * it (where navigation has them, the top frame's caller for Backspace and + * return portals): kept, and generated again when unloaded. */ - readonly returnUnit: string | null; + readonly needed: readonly string[]; }; /** @@ -99,7 +100,7 @@ const createUnloader = ( const seen = new Map(); let tick = 0; let here: string | null = null; - return ({ roomId, units, returnUnit }) => { + return ({ roomId, units, needed }) => { const { world } = store.snapshot(); here = areaOfRoom(world, roomId) ?? here; tick += 1; @@ -111,23 +112,21 @@ const createUnloader = ( if (here !== null) { seen.set(here, tick); } - const pinned = pinnedAreas( - world, - here, - returnUnit === null ? units : [...units, returnUnit] - ); + const pinned = pinnedAreas(world, here, [...units, ...needed]); const loaded = world.areas.map(({ id }) => id); for (const id of evictions({ loaded, pinned, seen, budget })) { store.evict(id); // Generated again, it counts as fresh. seen.delete(id); } - const back = areaOfRoom(world, returnUnit); - if ( - back !== undefined && - store.snapshot().world.unloaded.some(({ id }) => id === back) - ) { - store.ensure(back); + for (const unit of needed) { + const area = areaOfRoom(world, unit); + if ( + area !== undefined && + store.snapshot().world.unloaded.some(({ id }) => id === area) + ) { + store.ensure(area); + } } }; }; diff --git a/apps/web/src/use-area-world.ts b/apps/web/src/use-area-world.ts index dc90a19..694494e 100644 --- a/apps/web/src/use-area-world.ts +++ b/apps/web/src/use-area-world.ts @@ -43,6 +43,11 @@ type AreaView = { * an unloaded one comes back where it was. */ readonly ensurePortal: (portalId: string) => void; + /** + * Generates the area a portal leads into only while it is pending: for + * the portal ahead, before any step, so an unloaded one waits for one. + */ + readonly preparePortal: (portalId: string) => void; /** * Generates the area of a unit (a choice's candidate, a frame's caller), * unless it exists or failed; true when the area is generated once it @@ -314,6 +319,19 @@ const useAreaWorld = (seed: number, options: WorldOptions): AreaView => { }, [store] ); + const preparePortal = useCallback( + (portalId: string) => { + const now = store?.snapshot(); + if (store === null || now === undefined) { + return; + } + const area = targetAreaOf(now.world, portalId); + if (area !== null && now.world.pending.includes(area)) { + store.ensure(area); + } + }, + [store] + ); const ensureUnit = useCallback( (unitId: string) => { const now = store?.snapshot(); @@ -378,6 +396,7 @@ const useAreaWorld = (seed: number, options: WorldOptions): AreaView => { current, rooms, ensurePortal, + preparePortal, ensureUnit, candidatesOf: candidates, unload, diff --git a/apps/web/src/world-app.tsx b/apps/web/src/world-app.tsx index 2bcc8b2..8b9de9c 100644 --- a/apps/web/src/world-app.tsx +++ b/apps/web/src/world-app.tsx @@ -57,7 +57,8 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => { // Generated once per seed, then grown an area at a time: it never // regenerates while the player moves. const generated = useAreaWorld(seed, worldOptions); - const { ensurePortal, ensureUnit, candidatesOf, unload } = generated; + const { ensurePortal, preparePortal, ensureUnit, candidatesOf, unload } = + generated; // The target a candidate was last picked at (see the key handler). const picked = useRef(null); const navigation = useNavigation(seed, generated.current); @@ -104,26 +105,30 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => { // The portal in front of the player leads into an area not generated yet: // generate it now, before the player steps in. Also run when the world - // changes, as a target kept across a new seed reports no change. + // changes, as a target kept across a new seed reports no change. Not an + // unloaded one: looking along a wall of portals would generate and unload + // areas in turn; it stays dark until stepped into (`onPortal`). useEffect(() => { if (target?.kind === "portal") { - ensurePortal(target.portalId); + preparePortal(target.portalId); } - }, [target, ensurePortal, generated.world]); + }, [target, preparePortal, generated.world]); // Keep the world within its budget of built areas after every change (a // load included, not only a step into another area). Kept: where the // player is, where navigation says they are (ahead of the Player after a // teleport), the top frame's ends and the portal ahead; never the whole - // stack, so the pins stay bounded. An unload changes the world and runs - // this once more, with nothing left to unload. + // stack, so the pins stay bounded. Where navigation has the player and + // the top frame's caller are generated again if unloaded, so a pop seen + // after a stale render still lands on floor. An unload changes the world + // and runs this once more, with nothing left to unload. const calleeUnit = top?.calleeRoomId ?? null; const portalAhead = target?.kind === "portal" ? target.portalId : null; useEffect(() => { unload({ roomId, - units: [navigation.roomId, calleeUnit].filter((unit) => unit !== null), - returnUnit, + units: calleeUnit === null ? [] : [calleeUnit], + needed: [navigation.roomId, returnUnit].filter((unit) => unit !== null), portalId: portalAhead, }); }, [