Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions apps/web/src/area-budget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
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: [], needed: [] } 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<string, string>() };
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 a needed 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];
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, needed });
expect(loadedOf(store)).toEqual([ENTRANCE_ID, b, a]);
});
});
135 changes: 135 additions & 0 deletions apps/web/src/area-budget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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<string> =>
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<string>;
/** When each area was last visited or loaded; higher is more recent. */
readonly seen: ReadonlyMap<string, number>;
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[];
/**
* 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 needed: readonly string[];
};

/**
* 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<string, number>();
let tick = 0;
let here: string | null = null;
return ({ roomId, units, needed }) => {
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, [...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);
}
for (const unit of needed) {
const area = areaOfRoom(world, unit);
if (
area !== undefined &&
store.snapshot().world.unloaded.some(({ id }) => id === area)
) {
store.ensure(area);
}
}
};
};

export { areaOfRoom, createUnloader, evictions, MAX_LOADED_AREAS, pinnedAreas };
export type { Pins };
9 changes: 5 additions & 4 deletions apps/web/src/ghost-shelf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Rect> => {
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 =
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/map-page-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/map-page-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
Loading
Loading