diff --git a/README.md b/README.md index 9344ffe..cb12edb 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/apps/web/src/choice-keys.test.ts b/apps/web/src/choice-keys.test.ts new file mode 100644 index 0000000..060e37f --- /dev/null +++ b/apps/web/src/choice-keys.test.ts @@ -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(); + }); +}); diff --git a/apps/web/src/choice-keys.ts b/apps/web/src/choice-keys.ts new file mode 100644 index 0000000..ee36e4f --- /dev/null +++ b/apps/web/src/choice-keys.ts @@ -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 }; diff --git a/apps/web/src/hud.tsx b/apps/web/src/hud.tsx index 896cfa8..50623a7 100644 --- a/apps/web/src/hud.tsx +++ b/apps/web/src/hud.tsx @@ -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. */ diff --git a/apps/web/src/map-model.ts b/apps/web/src/map-model.ts index 5214f76..12cbf45 100644 --- a/apps/web/src/map-model.ts +++ b/apps/web/src/map-model.ts @@ -63,6 +63,7 @@ const PORTAL_COLOURS: Record = { jump: "#9ca3af", marker: "#57534e", module: "#4ade80", + choice: "#a78bfa", }; const NO_ROOMS: ReadonlySet = new Set(); diff --git a/apps/web/src/use-area-world.test.ts b/apps/web/src/use-area-world.test.ts index e18f699..fa5a9cd 100644 --- a/apps/web/src/use-area-world.test.ts +++ b/apps/web/src/use-area-world.test.ts @@ -12,6 +12,7 @@ import { describe, expect, it } from "vitest"; import { examples } from "./examples"; import { builtModules, + candidatesOf, ensureNext, storeFor, targetAreaOf, @@ -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([ diff --git a/apps/web/src/use-area-world.ts b/apps/web/src/use-area-world.ts index c68b17c..3f47c47 100644 --- a/apps/web/src/use-area-world.ts +++ b/apps/web/src/use-area-world.ts @@ -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. */ @@ -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 @@ -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] @@ -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); @@ -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, @@ -285,6 +341,7 @@ const useAreaWorld = (seed: number, options: WorldOptions): AreaView => { export { builtModules, + candidatesOf, ensureNext, storeFor, targetAreaOf, diff --git a/apps/web/src/use-navigation.ts b/apps/web/src/use-navigation.ts index 3509f08..e953435 100644 --- a/apps/web/src/use-navigation.ts +++ b/apps/web/src/use-navigation.ts @@ -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; }; @@ -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]), }; diff --git a/apps/web/src/world-app.tsx b/apps/web/src/world-app.tsx index bbaa9f9..937a1b1 100644 --- a/apps/web/src/world-app.tsx +++ b/apps/web/src/world-app.tsx @@ -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"; @@ -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(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) => { @@ -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. @@ -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 @@ -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} diff --git a/apps/web/src/world-from-code-markers.test.ts b/apps/web/src/world-from-code-markers.test.ts index 78e7187..65e9c2b 100644 --- a/apps/web/src/world-from-code-markers.test.ts +++ b/apps/web/src/world-from-code-markers.test.ts @@ -25,7 +25,7 @@ const markerPrompts = (name: "external" | "resolution") => { }; describe("markers", () => { - it("never makes an ambiguous or dynamic call a portal or a door", () => { + it("never follows an ambiguous or dynamic call by a call portal or a door", () => { const codeGraph = graphOf("resolution"); const world = worldFromCode(codeGraph, 1); const followed = [ @@ -43,9 +43,53 @@ describe("markers", () => { expect(world.layout.unplacedPortals).toEqual([]); }); - it("names every call of the resolution example the world cannot follow", () => { + it("offers the ambiguous call at a choice portal instead", () => { + const codeGraph = graphOf("resolution"); + // Generating validates the graph and the layout, choice portals included. + const world = worldFromCode(codeGraph, 1); + const choices = (world.graph.portals ?? []).filter( + (portal) => portal.kind === "choice" + ); + expect(choices).toHaveLength(1); + const [choice] = choices; + expect(choice?.to).toBe(choice?.from); + expect(choice?.candidates).toEqual([ + "resolution.ts::Circle.render", + "resolution.ts::Square.render", + ]); + expect( + world.built.portals.some(({ portal }) => portal.id === choice?.id) + ).toBe(true); + }); + + it("words the choice portal's prompt with a key per candidate", () => { + const codeGraph = graphOf("resolution"); + const world = worldFromCode(codeGraph, 1); + const choice = world.built.portals.find( + ({ portal }) => portal.kind === "choice" + ); + const target = { + kind: "portal", + portalId: choice?.portal.id ?? "", + } as const; + expect(promptOf(codeGraph, [], target)).toBe( + "choose · shape.render(…) 1 Circle.render() 2 Square.render()" + ); + expect( + promptOf( + codeGraph, + [], + target, + (unit) => unit === "resolution.ts::Square.render" + ) + ).toBe( + "choose · shape.render(…) 1 Circle.render() 2 Square.render() (closed)" + ); + }); + + it("names every other call of the resolution example the world cannot follow", () => { expect(markerPrompts("resolution").map(([, prompt]) => prompt)).toEqual([ - "closed · shape.render(…) ambiguous: Circle.render() | Square.render(), handlers[…](…) dynamic", + "closed · handlers[…](…) dynamic", "closed · pick(…)(…) dynamic", "closed · onDone(…) dynamic", "closed · console.log(…) external", diff --git a/apps/web/src/world-from-code.ts b/apps/web/src/world-from-code.ts index 13c76a4..8bc46f4 100644 --- a/apps/web/src/world-from-code.ts +++ b/apps/web/src/world-from-code.ts @@ -257,11 +257,16 @@ const doorPrompt = (codeGraph: CodeGraph | null, target: Target): string => { : `→ ${labelOf(codeGraph, target.targetRoomId)}`; }; -/** What stepping through the thing in front of the player would do. */ +/** + * What stepping through the thing in front of the player would do; at a + * choice portal, the keys that pick each candidate, `(closed)` after one + * whose area `failedUnit` reports could not be generated. + */ const promptOf = ( codeGraph: CodeGraph | null, frames: readonly Frame[], - target: Target | null + target: Target | null, + failedUnit: (unitId: string) => boolean = () => false ): string | null => { if (target === null) { return null; @@ -290,6 +295,14 @@ const promptOf = ( if (subject.kind === "zone") { return `→ ${subject.zone.name}`; } + // `choose · shape.render(…) 1 Circle.render() 2 Square.render()`. + if (subject.kind === "choice") { + const options = subject.candidates.map((fn, index) => { + const closed = failedUnit(fn.id) ? " (closed)" : ""; + return `${index + 1} ${labelOf(codeGraph, fn.id)}${closed}`; + }); + return `choose · ${subject.site.calleeName}(…) ${options.join(" ")}`; + } // A marker leads nowhere: it names the calls the world cannot follow. if (subject.kind === "marker") { const nameOf = (id: string): string => diff --git a/packages/code-graph/src/area-links.test.ts b/packages/code-graph/src/area-links.test.ts index 997792b..8f2d2fd 100644 --- a/packages/code-graph/src/area-links.test.ts +++ b/packages/code-graph/src/area-links.test.ts @@ -53,6 +53,29 @@ describe("areaLinksOf", () => { ]); }); + it("links a choice to every other module it offers, once per site", () => { + const graph = chain(); + const [first] = graph.callSites; + if (first === undefined) { + throw new Error("chain has no call sites"); + } + const choice = { + ...first, + id: `${first.callerId}@999`, + calleeId: null, + resolution: "ambiguous" as const, + candidateIds: ["a.ts::helper", "b.ts::three", "c.ts::four"], + }; + const links = areaLinksOf({ + ...graph, + callSites: [...graph.callSites, choice], + }).filter((link) => link.kind === "call" && link.fromArea === "a.ts"); + expect(links).toEqual([ + { fromArea: "a.ts", toArea: "b.ts", kind: "call", count: 3 }, + { fromArea: "a.ts", toArea: "c.ts", kind: "call", count: 1 }, + ]); + }); + it("never links a module to itself", () => { const links = areaLinksOf(chain()); expect(links.filter((link) => link.fromArea === link.toArea)).toEqual([]); diff --git a/packages/code-graph/src/area-links.ts b/packages/code-graph/src/area-links.ts index 2bfd4cd..6c77c91 100644 --- a/packages/code-graph/src/area-links.ts +++ b/packages/code-graph/src/area-links.ts @@ -2,8 +2,9 @@ // what a whole-repository map draws between areas that may still be pending. import type { WorldGraph } from "@repo/types"; +import { CHOICE_MAX } from "@repo/world-generator/config"; -import type { CodeGraph } from "./code-graph"; +import type { CallSite, CodeGraph } from "./code-graph"; import { entranceGraph } from "./entrance"; import { entryModules } from "./entrypoints"; import { moduleLinks } from "./module-links"; @@ -51,10 +52,27 @@ const hubAreaLinks = (graph: CodeGraph): readonly AreaLink[] => { }; /** - * Calls from each module into other modules: resolved call sites by the - * module of their caller (a function, or the module itself for top-level - * calls, which have no call edge) and of their callee, counted per pair, - * callees in the order first called. + * The functions a site's call may enter: its callee, or for an ambiguous + * call offered at a choice portal every candidate; none for the rest. + */ +const calleesOf = (site: CallSite): readonly string[] => { + if (site.calleeId !== null) { + return [site.calleeId]; + } + const candidates = site.candidateIds ?? []; + return site.resolution === "ambiguous" && + candidates.length >= 2 && + candidates.length <= CHOICE_MAX + ? candidates + : []; +}; + +/** + * Calls from each module into other modules: resolved call sites, and + * choices once per module they offer, by the module of their caller (a + * function, or the module itself for top-level calls, which have no call + * edge) and of their callee, counted per pair, callees in the order first + * called. */ const callLinks = ( graph: CodeGraph @@ -65,16 +83,20 @@ const callLinks = ( ]); const counts = new Map>(); for (const site of graph.callSites) { - if (site.calleeId === null) { - continue; - } const from = moduleOf.get(site.callerId); - const to = moduleOf.get(site.calleeId); - if (from === undefined || to === undefined || from === to) { + const tos = new Set( + calleesOf(site).flatMap((id) => { + const to = moduleOf.get(id); + return to === undefined || to === from ? [] : [to]; + }) + ); + if (from === undefined || tos.size === 0) { continue; } const targets = counts.get(from) ?? new Map(); - targets.set(to, (targets.get(to) ?? 0) + 1); + for (const to of tos) { + targets.set(to, (targets.get(to) ?? 0) + 1); + } counts.set(from, targets); } return new Map( diff --git a/packages/code-graph/src/flow-budget.ts b/packages/code-graph/src/flow-budget.ts index d8b545e..52f4178 100644 --- a/packages/code-graph/src/flow-budget.ts +++ b/packages/code-graph/src/flow-budget.ts @@ -14,6 +14,7 @@ import { withBodies, } from "./flow-composite"; import type { CompositeSpec, FlowTree } from "./flow-composite"; +import { distinctChoices, extraSlots } from "./flow-markers"; import { depthOf, specOf } from "./flow-measure"; import type { FlowCallee, FlowRoomSpec } from "./flow-measure"; import { foldedText } from "./flow-text"; @@ -59,6 +60,7 @@ const merge = ( const callees = distinctCallees([...a.callees, ...b.callees]); // The entry room already carries the function's strays. const markers = [...a.markers, ...b.markers]; + const choices = distinctChoices([...a.choices, ...b.choices]); return { kind: "room", id: a.id, @@ -68,12 +70,13 @@ const merge = ( calls, callees, markers, + choices, depth: depthOf( "collapsed", statements, callees.length, entry, - markers.length > 0 + extraSlots(markers, choices) ), floor: depthOf("collapsed", statements, 0, entry), terminal: b.terminal, diff --git a/packages/code-graph/src/flow-composite.ts b/packages/code-graph/src/flow-composite.ts index f94b9f5..f5f188c 100644 --- a/packages/code-graph/src/flow-composite.ts +++ b/packages/code-graph/src/flow-composite.ts @@ -16,6 +16,7 @@ import { } from "@repo/world-generator/config"; import type { BranchNode, LoopNode, SwitchNode } from "./code-graph"; +import { extraSlots } from "./flow-markers"; import { PORT_PITCH } from "./flow-measure"; import type { FlowRoomSpec } from "./flow-measure"; @@ -120,9 +121,9 @@ const southWidth = (count: number): number => (2 * PORTAL_MARGIN + (count - 1) * FLOW_PORTAL_PITCH) / GRID - 1e-9 ) * GRID; -/** The portals on a room's walls: one per callee beyond its ports, a marker. */ +/** The portals on a room's walls: one per callee and choice, a marker. */ const portalSlots = (room: FlowRoomSpec): number => - room.callees.length + (room.markers.length > 0 ? 1 : 0); + room.callees.length + extraSlots(room.markers, room.choices); const laneWidth = (lane: LaneSpec): number => { const boxed = boxedRoom(lane); diff --git a/packages/code-graph/src/flow-fixture.ts b/packages/code-graph/src/flow-fixture.ts index 7f310c6..8b56fa2 100644 --- a/packages/code-graph/src/flow-fixture.ts +++ b/packages/code-graph/src/flow-fixture.ts @@ -190,7 +190,8 @@ const clusterOf = ( /** * Runs the world generator's contract over a cluster: it tiles its box, its * doors (and jumps) share edges and reach every room, its ports lie on the - * boundary, its jump portals lead to its own rooms, its markers are placed. + * boundary, its jump portals lead to its own rooms, its markers and choices + * are placed. */ const valid = (cluster: RoomCluster): RoomCluster => { const callees = new Set([ @@ -203,6 +204,10 @@ const valid = (cluster: RoomCluster): RoomCluster => { : [portal.target] ), ]); + // A choice's candidates that are no room here stand for other units. + const external = [ + ...new Set(cluster.portals.flatMap((portal) => portal.candidates ?? [])), + ].filter((id) => id !== FN && !callees.has(id)); const graph: WorldGraph = { rooms: [ { id: FN, width: cluster.width, depth: cluster.depth, cluster }, @@ -213,8 +218,15 @@ const valid = (cluster: RoomCluster): RoomCluster => { id: portal.id, kind: portal.kind, from: portal.roomId, - to: portal.kind === "marker" ? portal.roomId : (portal.target ?? FN), + to: + portal.kind === "marker" || portal.kind === "choice" + ? portal.roomId + : (portal.target ?? FN), + ...(portal.candidates === undefined + ? {} + : { candidates: portal.candidates }), })), + ...(external.length > 0 ? { external } : {}), }; validateGraph(graph); return cluster; diff --git a/packages/code-graph/src/flow-hang.ts b/packages/code-graph/src/flow-hang.ts index 271bdb6..1ccf728 100644 --- a/packages/code-graph/src/flow-hang.ts +++ b/packages/code-graph/src/flow-hang.ts @@ -11,9 +11,10 @@ import { MIN_SHARED, } from "@repo/world-generator/config"; +import { extraSlots } from "./flow-markers"; import { PORTAL_TAIL } from "./flow-measure"; import type { FlowCallee, FlowRoomSpec } from "./flow-measure"; -import { callPortalId, markerPortalId } from "./ids"; +import { callPortalId, choicePortalId, markerPortalId } from "./ids"; type Side = "east" | "west"; @@ -106,8 +107,9 @@ const portHangs = ( * past the previous port on its wall, so the room grows when the column is * busy on that side. Every other callee gets a pre-placed portal: below the * port at FLOW_PORTAL_PITCH on a wall that has one, from the top of the - * room on a wall that does not, alternating walls. A room with calls the - * world cannot follow gets one marker after them, placed the same way. A + * room on a wall that does not, alternating walls. Each choice, then one + * marker for the calls the world cannot follow, come after them, placed + * the same way. A * `blocked` side wall holds a fallthrough door, so it gets no portals; with * both blocked they go on the south wall, centred (a room walled in like * that neither rejoins nor ends, so nothing else is there). @@ -129,10 +131,10 @@ const hangCallees = ( const primary: Side = trackers.west.lastHi < trackers.east.lastHi ? "west" : "east"; const hangs = portHangs(spec.callees, free, primary); - // One callee per free wall gets a port; the rest get portals, then the - // marker takes one more slot. + // One callee per free wall gets a port; the rest get portals, then each + // choice and the marker take one more slot. const extras = spec.callees.slice(Math.min(spec.callees.length, free.length)); - const slots = extras.length + (spec.markers.length > 0 ? 1 : 0); + const slots = extras.length + extraSlots(spec.markers, spec.choices); const firstExtraWall: Side = free.length === 1 ? (free[0] ?? primary) : primary; const extraWall = (position: number): WallSide => { @@ -219,25 +221,36 @@ const hangCallees = ( } placedOn[wall] += 1; const callee = extras[position]; - portals.push( - callee === undefined - ? { - id: markerPortalId(spec.id), - kind: "marker", - roomId: spec.id, - wall, - along, - label: `${spec.markers.length} call${spec.markers.length === 1 ? "" : "s"}`, - } - : { - id: callPortalId(callee.siteId), - kind: "call", - roomId: spec.id, - wall, - along, - target: callee.unitId, - } - ); + const choice = spec.choices[position - extras.length]; + if (callee !== undefined) { + portals.push({ + id: callPortalId(callee.siteId), + kind: "call", + roomId: spec.id, + wall, + along, + target: callee.unitId, + }); + } else if (choice === undefined) { + portals.push({ + id: markerPortalId(spec.id), + kind: "marker", + roomId: spec.id, + wall, + along, + label: `${spec.markers.length} call${spec.markers.length === 1 ? "" : "s"}`, + }); + } else { + portals.push({ + id: choicePortalId(choice.siteId), + kind: "choice", + roomId: spec.id, + wall, + along, + label: "choose", + candidates: choice.candidates, + }); + } } return { depth, ports, portals }; }; diff --git a/packages/code-graph/src/flow-markers.test.ts b/packages/code-graph/src/flow-markers.test.ts index 1dc844b..39792fd 100644 --- a/packages/code-graph/src/flow-markers.test.ts +++ b/packages/code-graph/src/flow-markers.test.ts @@ -5,6 +5,7 @@ import type { CallSite, CodeGraph, FlowStep, + FunctionNode, SourceSpan, } from "./code-graph"; import { replaceComposite } from "./flow-budget"; @@ -12,7 +13,7 @@ import { clusterOf, FN, fnWith, site, switchNode, valid } from "./flow-fixture"; import { layoutFlow, planFlow } from "./flow-layout"; import { indexSites } from "./flow-text"; import { measureTree } from "./flow-tree"; -import { markerPortalId } from "./ids"; +import { choicePortalId, markerPortalId } from "./ids"; import { portalSubject } from "./subjects"; import { toWorldGraph } from "./world-graph"; @@ -37,6 +38,21 @@ const open = (start: number): CallSite => ({ span: spanOf(start, start + 3), }); +/** An ambiguous call at [start, start + 3) that may call any of `count` methods. */ +const ambiguous = ( + start: number, + count: number, + prefix = "m.ts::C" +): CallSite => ({ + ...open(start), + calleeName: "x.render", + resolution: "ambiguous", + candidateIds: Array.from( + { length: count }, + (_, index) => `${prefix}${index}.render` + ), +}); + /** A plain statement spanning [start, end). */ const stepIn = (start: number, end: number): FlowStep => ({ id: `${FN}@${start}:step`, @@ -82,6 +98,14 @@ const branchIn = ( }, }); +/** A method of module `m.ts` with an empty body: a choice's candidate. */ +const candidate = (id: string): FunctionNode => ({ + ...fnWith([]), + id, + name: "render", + qualifiedName: id.slice("m.ts::".length), +}); + const markersIn = (cluster: ReturnType, roomId: string) => cluster.portals.filter( (portal) => portal.roomId === roomId && portal.kind === "marker" @@ -214,6 +238,140 @@ describe("markers", () => { ).toHaveLength(plan.markers.size); }); + it("offers an ambiguous call with 2 to 9 candidates at a choice, not the marker", () => { + const room = `${FN}@200:step`; + const cluster = valid( + clusterOf( + [stepIn(100, 120), stepIn(200, 240)], + [ambiguous(205, 2), ambiguous(215, 10, "m.ts::D"), open(225)] + ) + ); + // The last room's return portal stays on its south wall. + const portals = cluster.portals.filter( + (portal) => portal.roomId === room && portal.kind !== "return" + ); + expect(portals.map((portal) => portal.kind)).toEqual(["choice", "marker"]); + const [choice, marker] = portals; + expect(choice).toMatchObject({ + id: choicePortalId(`${FN}@205`), + label: "choose", + candidates: ["m.ts::C0.render", "m.ts::C1.render"], + }); + expect(choice).not.toHaveProperty("target"); + // Ten candidates are too many keys: that call stays on the marker. + expect(marker?.label).toBe("2 calls"); + expect( + planFlow(fnWith([stepIn(200, 240)]), [ambiguous(205, 2)]).markers + ).toEqual(new Map()); + }); + + it("gives a room one choice per set of candidates, folded or not", () => { + const room = `${FN}@200:step`; + const cluster = valid( + clusterOf( + [stepIn(200, 240)], + [ambiguous(205, 2), ambiguous(215, 2), ambiguous(225, 3)] + ) + ); + expect( + cluster.portals + .filter((portal) => portal.roomId === room && portal.kind === "choice") + .map((portal) => portal.id) + ).toEqual([choicePortalId(`${FN}@205`), choicePortalId(`${FN}@225`)]); + const steps = Array.from({ length: 70 }, (_, index) => + stepIn(1000 + index * 10, 1000 + index * 10 + 8) + ); + const sites = steps.map((_, index) => ambiguous(1000 + index * 10 + 2, 2)); + const plan = planFlow(fnWith(steps), sites); + expect(plan.folds.size).toBeGreaterThan(0); + const folded = valid(layoutFlow(plan)); + const choices = folded.portals.filter((portal) => portal.kind === "choice"); + // Every room keeps at most one choice for the same two candidates. + expect(new Set(choices.map((portal) => portal.roomId)).size).toBe( + choices.length + ); + }); + + it("puts a choice on its own room in the graph and names its candidates", () => { + const fn = fnWith([stepIn(100, 120)]); + const graph: CodeGraph = { + modules: [ + { + id: "m.ts", + path: "m.ts", + language: "typescript", + lineCount: 1, + imports: [], + exports: [], + }, + ], + functions: [ + fn, + candidate("m.ts::C0.render"), + candidate("m.ts::C1.render"), + ], + classes: [], + callSites: [ambiguous(105, 2)], + edges: [], + }; + const room = `${FN}@100:step`; + const id = choicePortalId(`${FN}@105`); + expect(portalSubject(graph, id)).toMatchObject({ + kind: "choice", + site: { id: `${FN}@105` }, + caller: { id: FN }, + candidates: [{ id: "m.ts::C0.render" }, { id: "m.ts::C1.render" }], + }); + const world = toWorldGraph(graph); + expect(world.portals?.filter((portal) => portal.kind === "choice")).toEqual( + [ + { + id, + kind: "choice", + from: room, + to: room, + label: "choose", + candidates: ["m.ts::C0.render", "m.ts::C1.render"], + }, + ] + ); + // Both candidates are rooms of this module: nothing leaves it. + expect(world.external).toBeUndefined(); + }); + + it("lists a choice's candidates in other modules as external", () => { + const fn = fnWith([stepIn(100, 120)]); + const graph: CodeGraph = { + modules: ["m.ts", "n.ts"].map((id) => ({ + id, + path: id, + language: "typescript", + lineCount: 1, + imports: [], + exports: [], + })), + functions: [ + fn, + { ...fnWith([]), id: "m.ts::C0.render", qualifiedName: "C0.render" }, + { + ...fnWith([]), + id: "n.ts::C1.render", + moduleId: "n.ts", + qualifiedName: "C1.render", + }, + ], + classes: [], + callSites: [ + { + ...ambiguous(105, 2), + candidateIds: ["m.ts::C0.render", "n.ts::C1.render"], + }, + ], + edges: [], + }; + expect(toWorldGraph(graph).external).toEqual(["n.ts::C1.render"]); + }); + it("names a marker's calls and puts it on its own room in the graph", () => { const fn = fnWith([stepIn(100, 120)]); const graph: CodeGraph = { diff --git a/packages/code-graph/src/flow-markers.ts b/packages/code-graph/src/flow-markers.ts index 373ba38..e1627bf 100644 --- a/packages/code-graph/src/flow-markers.ts +++ b/packages/code-graph/src/flow-markers.ts @@ -1,7 +1,10 @@ // Which room shows a call the world cannot follow (ambiguous, dynamic, // external, unresolved): every such site lands in exactly one room, as an -// entry on that room's one marker. Decided as specs are built, so the -// budget counts a marker's wall and folding carries it along. +// entry on that room's one marker, or for an ambiguous call with few enough +// candidates as a choice portal of its own. Decided as specs are built, so +// the budget counts their wall and folding carries them along. + +import { CHOICE_MAX } from "@repo/world-generator/config"; import type { BranchNode, @@ -56,6 +59,63 @@ const headerMarkerSites = ( .map((site) => site.id); }; +/** An ambiguous call the player picks a callee for, at a choice portal. */ +type FlowChoice = { + readonly siteId: string; + /** The functions it may call, in source order. */ + readonly candidates: readonly string[]; +}; + +/** One choice per set of candidates: a room calling `x.render()` twice gets one. */ +const distinctChoices = ( + choices: readonly FlowChoice[] +): readonly FlowChoice[] => { + const seen = new Set(); + return choices.filter((choice) => { + const key = JSON.stringify(choice.candidates); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +}; + +/** + * A room's open sites split in two: ambiguous calls with 2 to CHOICE_MAX + * candidates become choices, everything else stays on the marker. + */ +const splitChoices = ( + ids: readonly string[], + sites: SiteIndex +): { + readonly markers: readonly string[]; + readonly choices: readonly FlowChoice[]; +} => { + const markers: string[] = []; + const choices: FlowChoice[] = []; + for (const id of ids) { + const site = sites.byId.get(id); + const candidates = site?.candidateIds ?? []; + if ( + site?.resolution === "ambiguous" && + candidates.length >= 2 && + candidates.length <= CHOICE_MAX + ) { + choices.push({ siteId: id, candidates }); + } else { + markers.push(id); + } + } + return { markers, choices: distinctChoices(choices) }; +}; + +/** The portals a room needs besides its callees': one per choice, one marker. */ +const extraSlots = ( + markers: readonly string[], + choices: readonly FlowChoice[] +): number => choices.length + (markers.length > 0 ? 1 : 0); + /** A room's markers, with the function's strays when it is the entry room. */ const withStrays = ( markers: readonly string[], @@ -69,4 +129,12 @@ const withStrays = ( return [...sites.strays, ...markers].toSorted((a, b) => start(a) - start(b)); }; -export { headerMarkerSites, markerSitesOf, withStrays }; +export { + distinctChoices, + extraSlots, + headerMarkerSites, + markerSitesOf, + splitChoices, + withStrays, +}; +export type { FlowChoice }; diff --git a/packages/code-graph/src/flow-measure.ts b/packages/code-graph/src/flow-measure.ts index b3288c7..c0fb1e0 100644 --- a/packages/code-graph/src/flow-measure.ts +++ b/packages/code-graph/src/flow-measure.ts @@ -20,7 +20,13 @@ import type { FunctionNode, } from "./code-graph"; import { countStatements, escapingJumps, hasReturn, isTerminal } from "./flow"; -import { markerSitesOf, withStrays } from "./flow-markers"; +import { + extraSlots, + markerSitesOf, + splitChoices, + withStrays, +} from "./flow-markers"; +import type { FlowChoice } from "./flow-markers"; import { flowNodeText, resolvedSites, siteIdsUnder } from "./flow-text"; import type { SiteIndex } from "./flow-text"; import { flowNodeId, parseFlowNodeId, taggedFlowNodeId } from "./ids"; @@ -46,6 +52,8 @@ type FlowRoomSpec = { * the room gets one marker for them all when there are any. */ readonly markers: readonly string[]; + /** Ambiguous calls the player picks a callee for: a choice portal each. */ + readonly choices: readonly FlowChoice[]; /** The depth with both side walls free for its calls: what the budget counts. */ readonly depth: number; /** The depth its contents alone need; the placer grows it for the calls. */ @@ -125,15 +133,18 @@ const PORTAL_TAIL = 1.5; /** * Wall for the callees when both side walls are free: the first two get a * port each (one per side wall), the rest a pre-placed portal each below - * the port, alternating walls, at FLOW_PORTAL_PITCH, and a marker after - * them; PORTAL_TAIL after the last keeps it clear of the corner. A marker - * alone needs a tail on either side of it. + * the port, alternating walls, at FLOW_PORTAL_PITCH, then the `slots` + * other portals (choices, a marker) after them; PORTAL_TAIL after the last + * keeps it clear of the corner. Without callees those portals alternate + * walls from the top, with a tail on either side. */ -const sitesDepth = (callees: number, marker: boolean): number => { +const sitesDepth = (callees: number, slots: number): number => { if (callees === 0) { - return marker ? 2 * PORTAL_TAIL : 0; + return slots === 0 + ? 0 + : 2 * PORTAL_TAIL + (Math.ceil(slots / 2) - 1) * FLOW_PORTAL_PITCH; } - const extras = Math.max(0, callees - 2) + (marker ? 1 : 0); + const extras = Math.max(0, callees - 2) + slots; const extrasPerWall = Math.ceil(extras / 2); return extrasPerWall === 0 ? FLOW_CALL_DEPTH @@ -145,11 +156,11 @@ const depthOf = ( statements: number, callees: number, entry: boolean, - marker = false + slots = 0 ): number => Math.max( baseDepth(role, statements), - sitesDepth(callees, marker), + sitesDepth(callees, slots), entry ? FLOW_TOP_MIN_DEPTH : 0 ); @@ -250,7 +261,10 @@ const specOf = ( const role = roleOf(node); const ids = siteIdsUnder(node); const callees = calleesOf(ids, sites); - const markers = withStrays(markerSitesOf(node, sites), sites, entry); + const { markers, choices } = splitChoices( + withStrays(markerSitesOf(node, sites), sites, entry), + sites + ); const statements = countStatements(node); const terminal = isTerminal(node); return { @@ -262,7 +276,14 @@ const specOf = ( calls: resolvedSites(ids, sites).length, callees, markers, - depth: depthOf(role, statements, callees.length, entry, markers.length > 0), + choices, + depth: depthOf( + role, + statements, + callees.length, + entry, + extraSlots(markers, choices) + ), floor: depthOf(role, statements, 0, entry), terminal, ...exitsOf(node, terminal), @@ -277,7 +298,10 @@ const emptyBodySpec = ( fn: FunctionNode, sites: SiteIndex | null = null ): FlowRoomSpec => { - const markers = sites?.strays ?? []; + const { markers, choices } = + sites === null + ? { markers: [], choices: [] } + : splitChoices(sites.strays, sites); return { kind: "room", id: flowNodeId(fn.id, fn.flow.span.start, "step", "empty"), @@ -287,7 +311,8 @@ const emptyBodySpec = ( calls: 0, callees: [], markers, - depth: depthOf("step", 0, 0, true, markers.length > 0), + choices, + depth: depthOf("step", 0, 0, true, extraSlots(markers, choices)), floor: depthOf("step", 0, 0, true), terminal: false, }; diff --git a/packages/code-graph/src/flow-tree.ts b/packages/code-graph/src/flow-tree.ts index 0e87387..44e5017 100644 --- a/packages/code-graph/src/flow-tree.ts +++ b/packages/code-graph/src/flow-tree.ts @@ -20,7 +20,12 @@ import type { import { isTerminal, jumpsTo } from "./flow"; import { BODY_LANE } from "./flow-composite"; import type { FlowTree, ForkSpec, LaneSpec, LoopSpec } from "./flow-composite"; -import { headerMarkerSites, withStrays } from "./flow-markers"; +import { + extraSlots, + headerMarkerSites, + splitChoices, + withStrays, +} from "./flow-markers"; import { calleesOf, depthOf, emptyBodySpec, specOf } from "./flow-measure"; import type { FlowRoomSpec } from "./flow-measure"; import { @@ -115,8 +120,8 @@ const tagged = ( /** * A room of a composite holding the calls of its header or condition: - * `callSiteIds` the resolved ones, `markers` the ones the world cannot - * follow. + * `callSiteIds` the resolved ones, `markerIds` the ones the world cannot + * follow (its choices among them). */ const headerSpec = ( id: string, @@ -128,7 +133,10 @@ const headerSpec = ( entry: boolean ): FlowRoomSpec => { const callees = calleesOf(callSiteIds, sites); - const markers = withStrays(markerIds, sites, entry); + const { markers, choices } = splitChoices( + withStrays(markerIds, sites, entry), + sites + ); return { kind: "room", id, @@ -138,7 +146,14 @@ const headerSpec = ( calls: resolvedSites(callSiteIds, sites).length, callees, markers, - depth: depthOf(role, 1, callees.length, entry, markers.length > 0), + choices, + depth: depthOf( + role, + 1, + callees.length, + entry, + extraSlots(markers, choices) + ), floor: depthOf(role, 1, 0, entry), terminal: false, }; @@ -157,6 +172,7 @@ const plainSpec = ( calls: 0, callees: [], markers: [], + choices: [], depth: FLOW_LEAF_DEPTH, floor: FLOW_LEAF_DEPTH, terminal: false, diff --git a/packages/code-graph/src/ids.test.ts b/packages/code-graph/src/ids.test.ts index 87ece93..c011818 100644 --- a/packages/code-graph/src/ids.test.ts +++ b/packages/code-graph/src/ids.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { callPortalId, callSiteId, + choicePortalId, classId, ENTRANCE_ID, flowNodeId, @@ -200,6 +201,15 @@ describe("portal ids", () => { }); }); + it("round-trips choice portals", () => { + const choice = choicePortalId("m.ts::f@42"); + expect(choice).toBe("choice:m.ts::f@42"); + expect(parsePortalId(choice)).toEqual({ + kind: "choice", + callSiteId: "m.ts::f@42", + }); + }); + it("round-trips module portals, whatever their paths hold", () => { const portal = modulePortalId("src/index.ts", "src/a>b.ts"); expect(portal).toBe('module:["src/index.ts","src/a>b.ts"]'); diff --git a/packages/code-graph/src/ids.ts b/packages/code-graph/src/ids.ts index 4d9d3e5..be00a37 100644 --- a/packages/code-graph/src/ids.ts +++ b/packages/code-graph/src/ids.ts @@ -186,6 +186,7 @@ const RETURN_PORTAL_PREFIX = "return:"; const JUMP_PORTAL_PREFIX = "jump:"; const MARKER_PORTAL_PREFIX = "marker:"; const MODULE_PORTAL_PREFIX = "module:"; +const CHOICE_PORTAL_PREFIX = "choice:"; /** A call portal is named by the call site it stands for: `portal:demo.ts::main@42`. */ const callPortalId = (callSiteId: string): string => @@ -212,6 +213,13 @@ const jumpPortalId = (roomId: string): string => const markerPortalId = (roomId: string): string => `${MARKER_PORTAL_PREFIX}${roomId}`; +/** + * A choice portal is named by the ambiguous call site it stands for: + * `choice:demo.ts::main@42`. + */ +const choicePortalId = (callSiteId: string): string => + `${CHOICE_PORTAL_PREFIX}${callSiteId}`; + /** * A module portal is named by the hub it stands on and the module it leads * to, as a JSON pair so that any path round-trips and no two pairs share an @@ -246,6 +254,7 @@ type PortalRef = | { readonly kind: "return"; readonly roomId: string } | { readonly kind: "jump"; readonly roomId: string } | { readonly kind: "marker"; readonly roomId: string } + | { readonly kind: "choice"; readonly callSiteId: string } | { readonly kind: "module"; readonly from: string; readonly to: string }; /** What a portal id names, or null for ids that are not portals of this grammar. */ @@ -262,6 +271,12 @@ const parsePortalId = (id: string): PortalRef | null => { if (id.startsWith(MARKER_PORTAL_PREFIX)) { return { kind: "marker", roomId: id.slice(MARKER_PORTAL_PREFIX.length) }; } + if (id.startsWith(CHOICE_PORTAL_PREFIX)) { + return { + kind: "choice", + callSiteId: id.slice(CHOICE_PORTAL_PREFIX.length), + }; + } if (id.startsWith(MODULE_PORTAL_PREFIX)) { const pair = parseModulePair(id.slice(MODULE_PORTAL_PREFIX.length)); return pair === null ? null : { kind: "module", ...pair }; @@ -340,6 +355,7 @@ const uniqueNames = (): ((qualifiedName: string) => string) => { export { callPortalId, callSiteId, + choicePortalId, candidatePaths, classId, ENTRANCE_ID, diff --git a/packages/code-graph/src/subjects.ts b/packages/code-graph/src/subjects.ts index 13b0a71..bde484f 100644 --- a/packages/code-graph/src/subjects.ts +++ b/packages/code-graph/src/subjects.ts @@ -274,6 +274,14 @@ type PortalSubject = /** The calls in that room the world cannot follow, in source order. */ readonly sites: readonly CallSite[]; } + | { + /** An ambiguous call: the player picks which candidate to enter. */ + readonly kind: "choice"; + readonly site: CallSite; + readonly caller: FunctionNode; + /** The functions it may call, in the site's order (`candidateIds`). */ + readonly candidates: readonly FunctionNode[]; + } | { readonly kind: "module"; /** The module whose hub holds it; null for the entrance or a zone's hub. */ @@ -356,6 +364,15 @@ const portalSubject = ( ); const caller = site === undefined ? undefined : functionSubject(graph, site.callerId)?.fn; + if (ref.kind === "choice") { + const candidates = (site?.candidateIds ?? []).flatMap((id) => { + const fn = functionSubject(graph, id)?.fn; + return fn === undefined ? [] : [fn]; + }); + return site === undefined || caller === undefined || candidates.length < 2 + ? null + : { kind: "choice", site, caller, candidates }; + } const callee = site?.calleeId === null || site?.calleeId === undefined ? undefined diff --git a/packages/code-graph/src/world-graph.ts b/packages/code-graph/src/world-graph.ts index e376d50..a2e0512 100644 --- a/packages/code-graph/src/world-graph.ts +++ b/packages/code-graph/src/world-graph.ts @@ -119,6 +119,8 @@ type Realised = { readonly jumps: readonly Portal[]; /** Closed frames, each on and "to" its own room. */ readonly markers: readonly Portal[]; + /** Ambiguous calls, each on and "to" its own room, with its candidates. */ + readonly choices: readonly Portal[]; }; /** @@ -129,7 +131,7 @@ type Realised = { * callers of a shared function, a second room calling the same callee), * one per call site. Portals get their graph form, `from` the flow room * and `to` the callee, the module hub, for a jump a room of the same - * cluster, or for a marker its own room. + * cluster, or for a marker or a choice its own room. */ const realise = ( cluster: RoomCluster, @@ -180,15 +182,24 @@ const realise = ( id: portal.id, kind: portal.kind, from: portal.roomId, - to: portal.kind === "marker" ? portal.roomId : (portal.target ?? hubId), + to: + portal.kind === "marker" || portal.kind === "choice" + ? portal.roomId + : (portal.target ?? hubId), ...(portal.label === undefined ? {} : { label: portal.label }), + ...(portal.candidates === undefined + ? {} + : { candidates: portal.candidates }), }); + const ofKind = (kind: Portal["kind"]): readonly Portal[] => + portals.filter((portal) => portal.kind === kind).map(toPortal); return { cluster: { ...cluster, ports: kept, portals }, - calls: portals.filter((portal) => portal.kind === "call").map(toPortal), - returns: portals.filter((portal) => portal.kind === "return").map(toPortal), - jumps: portals.filter((portal) => portal.kind === "jump").map(toPortal), - markers: portals.filter((portal) => portal.kind === "marker").map(toPortal), + calls: ofKind("call"), + returns: ofKind("return"), + jumps: ofKind("jump"), + markers: ofKind("marker"), + choices: ofKind("choice"), }; }; @@ -277,6 +288,7 @@ const moduleWorld = ( const returns: Portal[] = []; const jumps: Portal[] = []; const markers: Portal[] = []; + const choices: Portal[] = []; for (const fn of functions) { const cluster = clusters.get(fn.id); if (cluster === undefined) { @@ -299,6 +311,7 @@ const moduleWorld = ( returns.push(...realised.returns); jumps.push(...realised.jumps); markers.push(...realised.markers); + choices.push(...realised.choices); } const { hubs, @@ -325,7 +338,7 @@ const moduleWorld = ( lobbies, rooms, connections, - portals: [...calls, ...returns, ...jumps, ...markers], + portals: [...calls, ...returns, ...jumps, ...markers, ...choices], }; }; @@ -336,9 +349,10 @@ const moduleWorld = ( * are many; a top-level class's root methods open off a lobby of their * own, which takes one door of the hub), a door for each call on the * breadth-first tree from those roots through the rooms' ports, a call portal for every other resolved - * call, and a closed marker in every room holding calls the world cannot - * follow (ambiguous, dynamic, external or unresolved; never a portal to a - * guess). The hub holds a module portal to every module it imports or + * call, a choice portal for an ambiguous call with up to CHOICE_MAX + * candidates (the player picks one), and a closed marker in every room + * holding other calls the world cannot follow (ambiguous, dynamic, external + * or unresolved; never a portal to a guess). The hub holds a module portal to every module it imports or * re-exports from; portals into another zone name it (`zoneLabels`). * Portals into other modules lead to units of their areas, listed in * `external`; the hub is the start room. Edges in @@ -381,13 +395,20 @@ const toWorldGraph = ( }; }), ]; - // Only calls and module portals leave the module; the rest stay inside. + // Only calls, module portals and choices' candidates leave the module; + // the rest stay inside. const ids = new Set(rooms.map((room) => room.id)); const external = [ ...new Set( portals - .filter(({ kind }) => kind === "call" || kind === "module") - .map((portal) => portal.to) + .flatMap((portal) => { + if (portal.kind === "choice") { + return portal.candidates ?? []; + } + return portal.kind === "call" || portal.kind === "module" + ? [portal.to] + : []; + }) .filter((to) => !ids.has(to)) ), ].toSorted(); diff --git a/packages/renderer/src/portals.tsx b/packages/renderer/src/portals.tsx index 69df92d..bca7f56 100644 --- a/packages/renderer/src/portals.tsx +++ b/packages/renderer/src/portals.tsx @@ -31,6 +31,7 @@ const LOOKS: readonly Look[] = [ "jump", "marker", "module", + "choice", "pending", ]; @@ -73,8 +74,8 @@ const voidPlanes = ( /** * Every portal of an area: the frames as one mesh (the same dark wood as - * the doorways) and one plane mesh per kind, dark and faintly glowing, or - * for a marker a dull board that closes the frame. A portal into an area + * the doorways) and one plane mesh per kind, dark and faintly glowing (a + * choice violet), or for a marker a dull board that closes the frame. A portal into an area * not generated yet stays dark until it is. */ const Portals = ({ portals, pending }: PortalsProps) => { @@ -111,6 +112,7 @@ const Portals = ({ portals, pending }: PortalsProps) => { jump: surfaces.voidJump, marker: surfaces.markerPanel, module: surfaces.voidModule, + choice: surfaces.voidChoice, pending: surfaces.voidPending, } satisfies Record; return ( diff --git a/packages/renderer/src/surfaces.ts b/packages/renderer/src/surfaces.ts index 981b819..5ab2fd5 100644 --- a/packages/renderer/src/surfaces.ts +++ b/packages/renderer/src/surfaces.ts @@ -28,6 +28,8 @@ type Surfaces = { readonly voidJump: MeshStandardMaterial; /** The dark plane filling a module portal, to another file: green. */ readonly voidModule: MeshStandardMaterial; + /** The dark plane filling a choice portal, an ambiguous call: violet. */ + readonly voidChoice: MeshStandardMaterial; /** The plane of a portal whose far side is not generated yet: dim, no glow. */ readonly voidPending: MeshStandardMaterial; /** The dull, unlit board closing a marker: a call the world cannot follow. */ @@ -124,6 +126,7 @@ const createSurfaces = (anisotropy: number): Surfaces => { const voidReturn = voidMaterial("#4a1f12"); const voidJump = voidMaterial("#2a2a2a"); const voidModule = voidMaterial("#1c3f26"); + const voidChoice = voidMaterial("#3b2a5a"); const voidPending = new MeshStandardMaterial({ color: "#07070a", roughness: 1, @@ -147,6 +150,7 @@ const createSurfaces = (anisotropy: number): Surfaces => { voidReturn, voidJump, voidModule, + voidChoice, voidPending, markerPanel, ]; @@ -161,6 +165,7 @@ const createSurfaces = (anisotropy: number): Surfaces => { voidReturn, voidJump, voidModule, + voidChoice, voidPending, markerPanel, tile: TILE, diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 5ac0e92..c85f533 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -91,8 +91,9 @@ type ClusterDoor = { * A portal on a cluster room's wall. With `wall` and `along` it is placed * already (cluster coordinates); without, the layout finds free wall. * `target`: the unit entered for `call`, a room of this cluster for `jump`, - * absent for `return` (the consumer decides where returns land) and for - * `marker` (it leads nowhere). A marker is always placed already. + * absent for `return` (the consumer decides where returns land), for + * `marker` (it leads nowhere) and for `choice` (the player picks one of its + * `candidates`). A marker or a choice is always placed already. */ type ClusterPortal = { readonly id: string; @@ -102,6 +103,8 @@ type ClusterPortal = { readonly along?: number; readonly target?: string; readonly label?: string; + /** For `choice`: the units the call may enter, in source order. */ + readonly candidates?: readonly string[]; }; type RoomCluster = { @@ -151,9 +154,11 @@ type Connection = { * a plain teleport to `to` that leaves the stack alone. `marker`: a closed * frame that cannot be entered, standing for calls the world cannot follow. * `module`: from a module's hub to another module's hub; it empties the - * stack, as entering a hub does. + * stack, as entering a hub does. `choice`: a call with several possible + * callees; it cannot be walked into, the player picks one of its + * `candidates`, which pushes a frame like a call. */ -type PortalKind = "call" | "return" | "jump" | "marker" | "module"; +type PortalKind = "call" | "return" | "jump" | "marker" | "module" | "choice"; /** * A directed jump drawn as a door-like frame on a wall of `from`. Not part of @@ -167,12 +172,18 @@ type Portal = { /** * Where it leads: for `call` and `return`, a graph room (a unit lands at * its entry), possibly `from`'s own; for `jump`, a room of the same - * cluster as `from`; for `marker`, `from` itself. A `call` or `module` - * portal may instead name a unit of another area (`WorldGraph.external`). + * cluster as `from`; for `marker` and `choice`, `from` itself. A `call` + * or `module` portal may instead name a unit of another area + * (`WorldGraph.external`). */ readonly to: string; /** Free text, like GraphRoom.label (the callee's name). */ readonly label?: string; + /** + * For `choice`: the units the player may pick from, graph rooms or + * `external` ones, in source order. + */ + readonly candidates?: readonly string[]; }; type WorldGraph = { @@ -183,8 +194,9 @@ type WorldGraph = { /** Defaults to the first room. */ readonly start?: string; /** - * Units of other areas that `call` and `module` portals may lead to. None - * is a room of this graph; their arrival is resolved once areas are joined. + * Units of other areas that `call` and `module` portals may lead to, or a + * `choice` portal may offer. None is a room of this graph; their arrival + * is resolved once areas are joined. */ readonly external?: readonly string[]; }; diff --git a/packages/world-generator/src/choices.test.ts b/packages/world-generator/src/choices.test.ts new file mode 100644 index 0000000..904f433 --- /dev/null +++ b/packages/world-generator/src/choices.test.ts @@ -0,0 +1,263 @@ +import type { ClusterPortal, Portal, WorldGraph } from "@repo/types"; +import { describe, expect, it } from "vitest"; + +import { cluster, clusterGraph } from "./cluster-world"; +import { validateGraph } from "./graph"; +import { inAnyTrigger, nearestTarget, portalToEnter } from "./interaction"; +import { createNavigator } from "./navigation"; +import type { NavigationState } from "./navigation"; +import { generateWorld } from "./world-generator"; + +const CHOICE: ClusterPortal = { + id: "choice:fn@5", + kind: "choice", + roomId: "step", + wall: "east", + along: 2, + label: "choose", + candidates: ["callee", "other"], +}; + +const GRAPH_CHOICE: Portal = { + id: "choice:fn@5", + kind: "choice", + from: "step", + to: "step", + label: "choose", + candidates: ["callee", "other"], +}; + +/** The cluster graph with one more cluster portal and one more graph portal. */ +const chosen = ( + clusterPortal: ClusterPortal | null = CHOICE, + portal: Portal | null = GRAPH_CHOICE, + external: readonly string[] = [] +): WorldGraph => ({ + ...clusterGraph, + rooms: clusterGraph.rooms.map((room) => + room.id === "fn" + ? { + ...room, + cluster: { + ...cluster, + portals: [ + ...cluster.portals, + ...(clusterPortal === null ? [] : [clusterPortal]), + ], + }, + } + : room + ), + portals: [ + ...(clusterGraph.portals ?? []), + ...(portal === null ? [] : [portal]), + ], + ...(external.length > 0 ? { external } : {}), +}); + +const withCandidates = (candidates: readonly string[]) => + chosen( + { ...CHOICE, candidates }, + { ...GRAPH_CHOICE, candidates }, + candidates.filter( + (id) => !clusterGraph.rooms.some((room) => room.id === id) + ) + ); + +describe("validateGraph choices", () => { + it("accepts a choice its cluster placed, on and to its own room", () => { + expect(() => validateGraph(chosen())).not.toThrow(); + }); + + it("accepts a candidate of another area", () => { + expect(() => + validateGraph(withCandidates(["callee", "far"])) + ).not.toThrow(); + }); + + it("rejects a choice leading anywhere but its own room", () => { + expect(() => + validateGraph(chosen(CHOICE, { ...GRAPH_CHOICE, to: "callee" })) + ).toThrow(/references an unknown room/); + }); + + it("rejects one candidate, ten, a repeat or an unknown one", () => { + const tooMany = Array.from({ length: 10 }, (_, index) => `far${index}`); + for (const candidates of [["callee"], tooMany, ["callee", "callee"]]) { + expect(() => validateGraph(withCandidates(candidates))).toThrow( + /must offer 2 to 9 distinct known units/ + ); + } + expect(() => + validateGraph( + chosen( + { ...CHOICE, candidates: ["callee", "nowhere"] }, + { ...GRAPH_CHOICE, candidates: ["callee", "nowhere"] } + ) + ) + ).toThrow(/must offer 2 to 9 distinct known units/); + }); + + it("rejects a cluster choice that is not placed or has a target", () => { + expect(() => + validateGraph( + chosen({ + id: CHOICE.id, + kind: "choice", + roomId: "step", + candidates: ["callee", "other"], + }) + ) + ).toThrow(/must be placed by its cluster and have no target/); + expect(() => + validateGraph(chosen({ ...CHOICE, target: "callee" })) + ).toThrow(/must be placed by its cluster and have no target/); + }); + + it("never lets a choice and a marker stand in for each other", () => { + expect(() => validateGraph(chosen({ ...CHOICE, kind: "marker" }))).toThrow( + /does not match its cluster's (?:marker|choice)/ + ); + expect(() => + validateGraph( + chosen(CHOICE, { + id: GRAPH_CHOICE.id, + kind: "marker", + from: "step", + to: "step", + }) + ) + ).toThrow(/does not match its cluster's (?:marker|choice)/); + }); + + it("rejects candidates on any other kind of portal", () => { + expect(() => + validateGraph( + chosen(null, { + id: "portal:fn@9", + kind: "call", + from: "call", + to: "callee", + candidates: ["callee", "other"], + }) + ) + ).toThrow(/only a choice does/); + }); +}); + +describe("a laid out choice", () => { + const world = generateWorld({ seed: 1, graph: chosen() }); + const built = world.built.portals.find( + ({ portal }) => portal.id === CHOICE.id + ); + if (built === undefined) { + throw new Error("choice not built"); + } + const intoWall = { x: -built.normal.x, z: -built.normal.z }; + const position = { + x: (built.trigger.minX + built.trigger.maxX) / 2, + z: (built.trigger.minZ + built.trigger.maxZ) / 2, + }; + const navigator = createNavigator(world); + const inFn: NavigationState = { frames: [], roomId: "fn" }; + + it("is placed with its candidates, never walked into and never disarms the portals", () => { + expect(world.layout.unplacedPortals).toEqual([]); + expect(built.portal.candidates).toEqual(["callee", "other"]); + expect( + portalToEnter( + world.built.portals, + { + position, + forward: intoWall, + velocity: { x: intoWall.x * 2, z: intoWall.z * 2 }, + }, + true + ) + ).toBeNull(); + expect(inAnyTrigger(world.built.portals, position)).toBe(false); + expect( + navigator.step(inFn, { type: "portal", portalId: CHOICE.id }) + ).toEqual({ state: inFn, teleport: null }); + }); + + it("is prompted from in front of it", () => { + expect( + nearestTarget(world.built, "step", { + position: { + x: position.x + built.normal.x, + z: position.z + built.normal.z, + }, + forward: intoWall, + velocity: { x: 0, z: 0 }, + }) + ).toEqual({ kind: "portal", portalId: CHOICE.id }); + }); + + it("enters a picked candidate with a frame and returns to the portal", () => { + const entered = navigator.step(inFn, { + type: "choose", + portalId: CHOICE.id, + calleeId: "other", + }); + expect(entered.state).toEqual({ + frames: [ + { + portalId: CHOICE.id, + callerRoomId: "fn", + calleeRoomId: "other", + returnTo: built.returnPoint, + }, + ], + roomId: "other", + }); + expect(entered.teleport).toEqual(world.built.entries.get("other")); + const back = navigator.step(entered.state, { type: "back" }); + expect(back).toEqual({ + state: { frames: [], roomId: "fn" }, + teleport: built.returnPoint, + }); + }); + + it("ignores a second pick made before the first one landed", () => { + const { state } = navigator.step(inFn, { + type: "choose", + portalId: CHOICE.id, + calleeId: "callee", + }); + expect( + navigator.step(state, { + type: "choose", + portalId: CHOICE.id, + calleeId: "other", + }) + ).toEqual({ state, teleport: null }); + }); + + it("stays put for a candidate not offered or for another portal", () => { + for (const event of [ + { type: "choose", portalId: CHOICE.id, calleeId: "hub" }, + { type: "choose", portalId: "return:fn", calleeId: "callee" }, + { type: "choose", portalId: "nowhere", calleeId: "callee" }, + ] as const) { + expect(navigator.step(inFn, event)).toEqual({ + state: inFn, + teleport: null, + }); + } + }); + + it("stays put for a candidate whose area was never joined", () => { + const far = generateWorld({ + seed: 1, + graph: withCandidates(["callee", "far"]), + }); + expect( + createNavigator(far).step(inFn, { + type: "choose", + portalId: CHOICE.id, + calleeId: "far", + }) + ).toEqual({ state: inFn, teleport: null }); + }); +}); diff --git a/packages/world-generator/src/config.ts b/packages/world-generator/src/config.ts index 89d7aa3..95f70a0 100644 --- a/packages/world-generator/src/config.ts +++ b/packages/world-generator/src/config.ts @@ -94,10 +94,13 @@ const FLOW_STEP_DEPTH_MAX = 8; const FLOW_PORTAL_PITCH = 2; /** An interior is folded until it fits in this many metres and rooms. */ const FLOW_BUDGET = { width: 32, depth: 96, rooms: 64 } as const; +/** The most candidates a choice portal offers: one per digit key, 1 to 9. */ +const CHOICE_MAX = 9; export { AREA_GAP, ARRIVAL_INSET, + CHOICE_MAX, CORRIDOR_LENGTHS, CORRIDOR_WIDTH, CORRIDOR_WIDTHS, diff --git a/packages/world-generator/src/graph.ts b/packages/world-generator/src/graph.ts index b247c71..81a1b86 100644 --- a/packages/world-generator/src/graph.ts +++ b/packages/world-generator/src/graph.ts @@ -1,7 +1,13 @@ import type { Connection, GraphRoom, RoomSize, WorldGraph } from "@repo/types"; import { validateCluster } from "./cluster-graph"; -import { GRID, MAX_DEGREE, MIN_SHARED, ROOM_SIZE_CLASSES } from "./config"; +import { + CHOICE_MAX, + GRID, + MAX_DEGREE, + MIN_SHARED, + ROOM_SIZE_CLASSES, +} from "./config"; import { createRng, nextInt, pick, pickWeighted, shuffle } from "./random"; import type { Rng } from "./random"; @@ -32,8 +38,10 @@ const onGrid = (value: number): boolean => * MIN_SHARED, undirected connections between distinct known rooms with no * duplicates, portals with unique ids from a known room (a cluster's rooms * included) to a known graph room (possibly its own; a jump's to a room of - * the same cluster; a marker's to its own cluster room, which placed it and - * gave it no target; a call's or module's possibly to an `external` unit, + * the same cluster; a marker's or choice's to its own cluster room, which + * placed it and gave it no target, a choice offering 2 to + * CHOICE_MAX distinct known or `external` units; a call's or + * module's possibly to an `external` unit, * none of which is a room here; a module's from a hub), clusters that satisfy * `validateCluster` and whose reserved ports and placed portals name known * rooms and portals, and one connected component over the connections. @@ -66,6 +74,7 @@ const validateGraph = (graph: WorldGraph): void => { const unitOf = new Map(); const clusterJumps = new Map(); const clusterMarkers = new Set(); + const clusterChoices = new Set(); const placedPortals = new Map(); for (const room of graph.rooms) { if (room.cluster === undefined) { @@ -98,6 +107,18 @@ const validateGraph = (graph: WorldGraph): void => { } clusterMarkers.add(portal.id); } + if (portal.kind === "choice") { + if ( + portal.target !== undefined || + portal.wall === undefined || + portal.along === undefined + ) { + throw new Error( + `Choice "${portal.id}" must be placed by its cluster and have no target` + ); + } + clusterChoices.add(portal.id); + } if (portal.wall !== undefined) { if (placedPortals.has(portal.id)) { throw new Error( @@ -160,9 +181,26 @@ const validateGraph = (graph: WorldGraph): void => { known = unitOf.has(portal.to) && unitOf.get(portal.to) === unitOf.get(portal.from); - } else if (portal.kind === "marker") { + } else if (portal.kind === "marker" || portal.kind === "choice") { known = unitOf.has(portal.from) && portal.to === portal.from; } + if (portal.kind === "choice") { + const candidates = portal.candidates ?? []; + if ( + candidates.length < 2 || + candidates.length > CHOICE_MAX || + new Set(candidates).size !== candidates.length || + candidates.some((id) => !ids.has(id) && !external.has(id)) + ) { + throw new Error( + `Choice "${portal.id}" must offer 2 to ${CHOICE_MAX} distinct known units` + ); + } + } else if (portal.candidates !== undefined) { + throw new Error( + `Portal "${portal.id}" (${portal.kind}) offers candidates; only a choice does` + ); + } if (!roomIds.has(portal.from) || !known) { throw new Error( `Portal "${portal.id}" (${portal.from} -> ${portal.to}) references an unknown room` @@ -184,6 +222,12 @@ const validateGraph = (graph: WorldGraph): void => { `Portal "${portal.id}" (${portal.kind}) does not match its cluster's marker` ); } + // So is a choice. + if (clusterChoices.has(portal.id) !== (portal.kind === "choice")) { + throw new Error( + `Portal "${portal.id}" (${portal.kind}) does not match its cluster's choice` + ); + } const placedOn = placedPortals.get(portal.id); if (placedOn !== undefined && placedOn !== portal.from) { throw new Error( diff --git a/packages/world-generator/src/interaction.ts b/packages/world-generator/src/interaction.ts index 2632059..e6ab962 100644 --- a/packages/world-generator/src/interaction.ts +++ b/packages/world-generator/src/interaction.ts @@ -40,9 +40,12 @@ const MIN_APPROACH = 0.2; const dot = (a: Point, b: Point): number => a.x * b.x + a.z * b.z; -/** Portals the player can step through: a marker is a closed frame. */ +/** + * Portals the player can step through: a marker is a closed frame, and a + * choice is entered by picking a candidate, not by walking in. + */ const enterable = (portal: BuiltPortal): boolean => - portal.portal.kind !== "marker"; + portal.portal.kind !== "marker" && portal.portal.kind !== "choice"; /** * The portal the player enters this frame: they are inside its trigger diff --git a/packages/world-generator/src/navigation.ts b/packages/world-generator/src/navigation.ts index c488542..19b1b18 100644 --- a/packages/world-generator/src/navigation.ts +++ b/packages/world-generator/src/navigation.ts @@ -31,6 +31,12 @@ type NavigationEvent = | { readonly type: "room"; readonly roomId: string | null } /** From the player: it stepped into a portal. */ | { readonly type: "portal"; readonly portalId: string } + /** A digit at a choice portal: enter `calleeId`, one of its candidates. */ + | { + readonly type: "choose"; + readonly portalId: string; + readonly calleeId: string; + } /** Backspace: the same as the current room's return portal. */ | { readonly type: "back" } /** R: back to the world start with an empty stack. */ @@ -159,7 +165,7 @@ const createNavigator = (world: World): Navigator => { const enterPortal = (state: NavigationState, portal: BuiltPortal): Step => { const { kind, from, to } = portal.portal; const { arrival } = portal; - if (kind === "marker") { + if (kind === "marker" || kind === "choice") { return stay(state); } if (kind === "return") { @@ -193,6 +199,46 @@ const createNavigator = (world: World): Navigator => { return { state: { frames, roomId: unitOf(to) }, teleport: arrival }; }; + /** + * Enters `calleeId` from a choice portal, pushing a frame like a call + * portal does. Only from the player's own unit, so a second pick made + * before the first one's teleport is seen does nothing (unless the first + * entered that same unit again: the app allows one pick per sighting of + * the portal for that); a candidate that + * is not offered, or whose area was never joined in, stays put. + */ + const choose = ( + state: NavigationState, + portal: BuiltPortal, + calleeId: string + ): Step => { + const { id, kind, from, candidates } = portal.portal; + const arrival = built.entries.get(calleeId); + if ( + kind !== "choice" || + unitOf(from) !== state.roomId || + candidates?.includes(calleeId) !== true || + arrival === undefined + ) { + return stay(state); + } + return { + state: { + frames: [ + ...state.frames, + { + portalId: id, + callerRoomId: unitOf(from), + calleeRoomId: calleeId, + returnTo: portal.returnPoint, + }, + ], + roomId: calleeId, + }, + teleport: arrival, + }; + }; + const moveTo = (state: NavigationState, roomId: string): Step => { const to = unitOf(roomId); const from = state.roomId; @@ -236,6 +282,12 @@ const createNavigator = (world: World): Navigator => { const portal = portals.get(event.portalId); return portal === undefined ? stay(state) : enterPortal(state, portal); } + case "choose": { + const portal = portals.get(event.portalId); + return portal === undefined + ? stay(state) + : choose(state, portal, event.calleeId); + } case "back": { const exit = state.roomId === null ? undefined : returnPortalOf.get(state.roomId);