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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ world. The world shows what _can_ run, not a trace of what did run.
| E | Show the current room's source code |
| Tab | Map of the whole world |
| M | Hide the map of the rooms you walked |
| 1–9 | Pick a callee at a violet choice portal |
| Backspace | Back to where this function was entered |
| R | Back to the entrance |
| Esc | Release the mouse |
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/choice-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";

import { choiceDigit } from "./choice-keys";

describe("choiceDigit", () => {
it("reads 1 to 9 from the number row and the keypad", () => {
expect(choiceDigit("Digit1")).toBe(1);
expect(choiceDigit("Digit9")).toBe(9);
expect(choiceDigit("Numpad2")).toBe(2);
});

it("ignores 0 and every other key", () => {
expect(choiceDigit("Digit0")).toBeNull();
expect(choiceDigit("Numpad0")).toBeNull();
expect(choiceDigit("KeyE")).toBeNull();
expect(choiceDigit("Digit10")).toBeNull();
});
});
12 changes: 12 additions & 0 deletions apps/web/src/choice-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// The keys that pick a candidate at a choice portal: 1 to 9, on the number
// row or the keypad, by physical key so the layout does not matter.

const DIGIT_KEY = /^(?:Digit|Numpad)([1-9])$/u;

/** The candidate number a key picks, 1 to 9, or null for any other key. */
const choiceDigit = (code: string): number | null => {
const match = DIGIT_KEY.exec(code);
return match === null ? null : Number(match[1]);
};

export { choiceDigit };
3 changes: 2 additions & 1 deletion apps/web/src/hud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@ const hints: readonly (readonly [string, string])[] = [
["Esc", "release the mouse"],
];

/** Only a code world has source to show. */
/** Only a code world has source to show, and calls to choose between. */
const codeHints: readonly (readonly [string, string])[] = [
["E", "show the source"],
["Wheel", "scroll the source"],
["1–9", "pick a callee at a choice portal"],
];

/** `6 modules` once all are generated, `2/6 modules · last 14 ms` on the way. */
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/map-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const PORTAL_COLOURS: Record<PortalKind, string> = {
jump: "#9ca3af",
marker: "#57534e",
module: "#4ade80",
choice: "#a78bfa",
};

const NO_ROOMS: ReadonlySet<string> = new Set();
Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/use-area-world.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { describe, expect, it } from "vitest";
import { examples } from "./examples";
import {
builtModules,
candidatesOf,
ensureNext,
storeFor,
targetAreaOf,
Expand Down Expand Up @@ -232,6 +233,65 @@ describe("a lazy walk through the repo example", () => {
});
});

/** `s.run()` may be `Base.run` or the override in another file. */
const overrides = () => {
const { codeGraph, error } = codeGraphOf([
{ path: "base.ts", source: "export class Base { run() {} }" },
{
path: "fast.ts",
source:
'import { Base } from "./base";\nexport class Fast extends Base { run() {} }',
},
{
path: "main.ts",
source:
'import { Base } from "./base";\nimport "./fast";\nexport function main(s: Base) { s.run(); }',
},
]);
if (codeGraph === null) {
throw new Error(`overrides: ${error}`);
}
return codeGraph;
};

describe("a choice with a candidate in another module", () => {
it("lists the candidates and lands in the far one once its area is ensured", () => {
const store = storeOf(1, { codeGraph: overrides(), eager: false });
store.ensure("main.ts");
const { world } = store.snapshot();
const choice = world.areas
.flatMap((area) => area.graph.portals ?? [])
.find((portal) => portal.kind === "choice");
if (choice === undefined) {
throw new Error("no choice portal in main.ts");
}
expect(candidatesOf(world, choice.id)).toEqual([
"base.ts::Base.run",
"fast.ts::Fast.run",
]);
expect(candidatesOf(world, "no such portal")).toEqual([]);
const state = { frames: [], roomId: "main.ts::main" };
const event = {
type: "choose",
portalId: choice.id,
calleeId: "fast.ts::Fast.run",
} as const;
expect(
createNavigator(mergeAreas(world)).step(state, event).teleport
).toBeNull();
const area = world.areaOf.get("fast.ts::Fast.run");
expect(area).toBe("fast.ts");
store.ensure(area ?? "");
expect(checkAreas(store.snapshot().world)).toEqual([]);
const entered = createNavigator(mergeAreas(store.snapshot().world)).step(
state,
event
);
expect(entered.state.roomId).toBe("fast.ts::Fast.run");
expect(entered.teleport).not.toBeNull();
});
});

/** An app of two modules importing a library of one, as workspace packages. */
const workspace = () =>
buildCodeGraph([
Expand Down
57 changes: 57 additions & 0 deletions apps/web/src/use-area-world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,20 @@ type AreaView = {
readonly rooms: number;
/** Generates the area a portal leads into, unless it exists or failed. */
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).
*/
readonly ensureUnit: (unitId: string) => boolean;
/** A choice portal's candidates, in order; none for any other portal. */
readonly candidatesOf: (portalId: string) => readonly string[];
/** 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. */
readonly modules: { readonly built: number; readonly total: number };
/** Milliseconds the last area generated on the way took; null when eager. */
Expand Down Expand Up @@ -132,6 +142,20 @@ const targetAreaOf = (world: AreaWorld, portalId: string): string | null => {
return null;
};

/** A choice portal's candidates in `world`, or none for any other portal. */
const candidatesOf = (
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.kind === "choice" ? (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
Expand Down Expand Up @@ -251,6 +275,28 @@ const useAreaWorld = (seed: number, options: WorldOptions): AreaView => {
},
[store]
);
const ensureUnit = useCallback(
(unitId: string) => {
const now = store?.snapshot();
if (store === null || now === undefined) {
return false;
}
const area = now.world.areaOf.get(unitId);
if (area === undefined) {
return false;
}
if (now.world.pending.includes(area)) {
store.ensure(area);
}
return store.snapshot().world.areas.some(({ id }) => id === area);
},
[store]
);
const candidates = useCallback(
(portalId: string) =>
store === null ? [] : candidatesOf(store.snapshot().world, portalId),
[store]
);
const next = useCallback(
() => (store === null ? false : ensureNext(store)),
[store]
Expand All @@ -265,6 +311,13 @@ const useAreaWorld = (seed: number, options: WorldOptions): AreaView => {
},
[state]
);
const failedUnit = useCallback(
(unitId: string) => {
const area = state?.world.areaOf.get(unitId);
return area !== undefined && state?.errors.has(area) === true;
},
[state]
);
const total = options.codeGraph?.modules.length ?? 1;
const built =
state === null ? 0 : builtModules(state.world, options.codeGraph !== null);
Expand All @@ -273,8 +326,11 @@ const useAreaWorld = (seed: number, options: WorldOptions): AreaView => {
current,
rooms,
ensurePortal,
ensureUnit,
candidatesOf: candidates,
ensureNext: next,
failedArea,
failedUnit,
modules: { built, total },
lastMs: options.eager ? null : (state?.lastMs ?? null),
warnings,
Expand All @@ -285,6 +341,7 @@ const useAreaWorld = (seed: number, options: WorldOptions): AreaView => {

export {
builtModules,
candidatesOf,
ensureNext,
storeFor,
targetAreaOf,
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/use-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ type Navigation = {
readonly placement: Teleport | null;
readonly onRoomChange: (roomId: string | null) => void;
readonly onPortal: (portalId: string) => void;
/** Enters one of a choice portal's candidates. */
readonly choose: (portalId: string, calleeId: string) => void;
readonly back: () => void;
readonly home: () => void;
};
Expand Down Expand Up @@ -102,6 +104,11 @@ const useNavigation = (
(portalId: string) => step({ type: "portal", portalId }),
[step]
),
choose: useCallback(
(portalId: string, calleeId: string) =>
step({ type: "choose", portalId, calleeId }),
[step]
),
back: useCallback(() => step({ type: "back" }), [step]),
home: useCallback(() => step({ type: "home" }), [step]),
};
Expand Down
52 changes: 45 additions & 7 deletions apps/web/src/world-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { Effects } from "@repo/renderer/effects";
import { Player } from "@repo/renderer/player";
import { World } from "@repo/renderer/world";
import type { Target } from "@repo/world-generator/interaction";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import { choiceDigit } from "~/choice-keys";
import { Hud } from "~/hud";
import { mapKeyAction } from "~/map-keys";
import { EMPTY_VISITS, visit, visitedIn } from "~/map-model";
Expand Down Expand Up @@ -49,9 +50,11 @@ 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 } = generated;
const { ensurePortal, ensureUnit, candidatesOf } = generated;
// The target a candidate was last picked at (see the key handler).
const picked = useRef<Target | null>(null);
const navigation = useNavigation(seed, generated.current);
const { onRoomChange, back, home, onPortal: stepPortal } = navigation;
const { onRoomChange, back, home, choose, onPortal: stepPortal } = navigation;

const onRoom = useCallback(
(id: string | null) => {
Expand Down Expand Up @@ -84,8 +87,10 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
// N: next seed (the only thing that regenerates the world) and Q: the
// rendering quality, in any pointer state. Backspace and R navigate, E
// shows the source and M the local map, only while walking, so a free
// pointer leaves them alone. Tab shows the world map while walking and,
// like Esc once the pointer is free, closes it (`map-keys`).
// pointer leaves them alone; so do 1 to 9, which pick a candidate at a
// choice portal (its area is generated first, so the pick lands). Tab
// shows the world map while walking and, like Esc once the pointer is
// free, closes it (`map-keys`).
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
// Ctrl/Cmd+R and friends belong to the browser.
Expand Down Expand Up @@ -117,11 +122,39 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
home();
} else if (locked && event.code === "KeyE") {
setSourceOpen((open) => !open);
} else if (
locked &&
target?.kind === "portal" &&
picked.current !== target
) {
const digit = choiceDigit(event.code);
const candidate =
digit === null ? undefined : candidatesOf(target.portalId)[digit - 1];
if (candidate !== undefined) {
// One pick per sighting of the portal: a second key before the
// teleport is seen would push a second frame for a recursive
// candidate, whose unit is the one the player is already in. A
// candidate whose area failed goes nowhere, so it leaves the
// other keys free.
if (ensureUnit(candidate)) {
picked.current = target;
}
choose(target.portalId, candidate);
}
}
};
globalThis.addEventListener("keydown", onKeyDown);
return () => globalThis.removeEventListener("keydown", onKeyDown);
}, [locked, worldMapOpen, back, home]);
}, [
locked,
worldMapOpen,
back,
home,
target,
candidatesOf,
ensureUnit,
choose,
]);

// Keep the URL shareable: it always names the seed on screen. Not for a
// zip or folder: no URL can name those files, and a `?seed` would reload
Expand Down Expand Up @@ -223,7 +256,12 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
)}
prompt={
failed === null
? promptOf(codeGraph, navigation.frames, target)
? promptOf(
codeGraph,
navigation.frames,
target,
generated.failedUnit
)
: `closed · ${failed} could not be laid out`
}
warnings={generated.warnings}
Expand Down
Loading
Loading