From 2755ca98ae0e693c44ee5975bd155a79a43ab6ff Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:36:07 +0300 Subject: [PATCH] feat: fold recursion in the breadcrumb and open the local map at start Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- apps/web/src/world-app.tsx | 2 +- .../src/world-from-code-breadcrumb.test.ts | 111 ++++++++++++++++++ apps/web/src/world-from-code.ts | 42 ++++++- 4 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/world-from-code-breadcrumb.test.ts diff --git a/README.md b/README.md index afd1d3f..9344ffe 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ world. The world shows what _can_ run, not a trace of what did run. | Mouse | Look (click the scene to lock it) | | E | Show the current room's source code | | Tab | Map of the whole world | -| M | Map of the rooms you have walked | +| M | Hide the map of the rooms you walked | | Backspace | Back to where this function was entered | | R | Back to the entrance | | Esc | Release the mouse | diff --git a/apps/web/src/world-app.tsx b/apps/web/src/world-app.tsx index 434a419..a897ed5 100644 --- a/apps/web/src/world-app.tsx +++ b/apps/web/src/world-app.tsx @@ -42,7 +42,7 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => { const [roomId, setRoomId] = useState(null); const [target, setTarget] = useState(null); const [sourceOpen, setSourceOpen] = useState(false); - const [mapOpen, setMapOpen] = useState(false); + const [mapOpen, setMapOpen] = useState(true); const [worldMapOpen, setWorldMapOpen] = useState(false); const [visits, setVisits] = useState(EMPTY_VISITS); const quality = useQuality(); diff --git a/apps/web/src/world-from-code-breadcrumb.test.ts b/apps/web/src/world-from-code-breadcrumb.test.ts new file mode 100644 index 0000000..880bda0 --- /dev/null +++ b/apps/web/src/world-from-code-breadcrumb.test.ts @@ -0,0 +1,111 @@ +import { parseFlowNodeId } from "@repo/code-graph/ids"; +import { createNavigator } from "@repo/world-generator/navigation"; +import type { Frame, NavigationState } from "@repo/world-generator/navigation"; +import { describe, expect, it } from "vitest"; + +import { examples } from "./examples"; +import { breadcrumbOf, codeGraphOf, worldFromCode } from "./world-from-code"; + +const portals = () => { + const { codeGraph, error } = codeGraphOf(examples.portals); + if (codeGraph === null) { + throw new Error(`portals: ${error}`); + } + return codeGraph; +}; + +const fn = (name: string) => `portals.ts::${name}`; + +const frame = (callerRoomId: string, calleeRoomId: string): Frame => ({ + portalId: null, + callerRoomId, + calleeRoomId, + returnTo: { position: { x: 0, z: 0 }, facing: { x: 1, z: 0 } }, +}); + +/** The frames of a call stack that walked `names` in order, ending in the last. */ +const stackOf = (names: readonly string[]) => { + const ids = names.map(fn); + return { + frames: ids.slice(1).map((id, index) => frame(ids[index] ?? "", id)), + roomId: ids.at(-1) ?? null, + }; +}; + +const crumbOf = (names: readonly string[]) => { + const { frames, roomId } = stackOf(names); + return breadcrumbOf(portals(), frames, roomId); +}; + +/** The function a portal's room belongs to. */ +const ownerOf = (roomId: string): string => + parseFlowNodeId(roomId)?.functionId ?? roomId; + +describe("breadcrumbOf", () => { + it("collapses direct recursion into one entry", () => { + expect(crumbOf(["main", "countdown", "countdown", "countdown"])).toBe( + "main() → countdown() ×3 ↻" + ); + }); + + it("keeps mutual recursion spelled out", () => { + expect(crumbOf(["load", "format", "load", "format"])).toBe( + "load() → format() → load() → format()" + ); + }); + + it("keeps the first caller and the last four of a long stack", () => { + expect( + crumbOf(["main", "load", "format", "render", "format", "load", "format"]) + ).toBe("main() → … → render() → format() → load() → format()"); + // A run is one entry: six entries, one of them ×3, stay whole. + expect( + crumbOf([ + "main", + "load", + "format", + "format", + "format", + "render", + "format", + "load", + ]) + ).toBe("main() → load() → format() ×3 ↻ → render() → format() → load()"); + }); + + it("follows a walk through countdown's recursive portal", () => { + const codeGraph = portals(); + const world = worldFromCode(codeGraph, 1); + const navigator = createNavigator(world); + const crumb = (state: NavigationState) => + breadcrumbOf(codeGraph, state.frames, state.roomId); + const firstRoomOf = (fnId: string) => + world.layout.rooms.find((room) => room.cluster === fnId)?.id ?? null; + + // main → countdown is a call door: walking through it pushes a frame. + const inMain = navigator.step(navigator.initial, { + type: "room", + roomId: firstRoomOf(fn("main")), + }).state; + const inCountdown = navigator.step(inMain, { + type: "room", + roomId: firstRoomOf(fn("countdown")), + }).state; + expect(crumb(inCountdown)).toBe("main() → countdown()"); + + const self = world.built.portals.find( + ({ portal }) => + portal.kind === "call" && + ownerOf(portal.from) === fn("countdown") && + ownerOf(portal.to) === fn("countdown") + ); + expect(self).toBeDefined(); + const enter = { type: "portal", portalId: self?.portal.id ?? "" } as const; + const once = navigator.step(inCountdown, enter).state; + const twice = navigator.step(once, enter).state; + expect(crumb(twice)).toBe("main() → countdown() ×3 ↻"); + + const back = navigator.step(twice, { type: "back" }).state; + expect(crumb(back)).toBe("main() → countdown() ×2 ↻"); + }); +}); diff --git a/apps/web/src/world-from-code.ts b/apps/web/src/world-from-code.ts index 5110950..03542e7 100644 --- a/apps/web/src/world-from-code.ts +++ b/apps/web/src/world-from-code.ts @@ -186,7 +186,32 @@ const areaLabelOf = (codeGraph: CodeGraph | null, areaId: string): string => { : relativeToPackage(zone.pkg, module.path); }; -/** `main() → login() → validateUser()`: every caller on the stack, then here. */ +/** Above this many entries the breadcrumb keeps the first and the last four. */ +const BREADCRUMB_MAX_GROUPS = 6; +const BREADCRUMB_TAIL = 4; + +/** Consecutive equal ids folded into one entry with how often it repeats. */ +const runsOf = ( + ids: readonly string[] +): readonly { readonly id: string; readonly count: number }[] => { + const runs: { id: string; count: number }[] = []; + for (const id of ids) { + const last = runs.at(-1); + if (last?.id === id) { + last.count += 1; + } else { + runs.push({ id, count: 1 }); + } + } + return runs; +}; + +/** + * `main() → login() → validateUser()`: every caller on the stack, then here. + * Direct recursion folds into one entry, `countdown() ×3 ↻`, while mutual + * recursion stays spelled out. More than six entries keep the first, `…` + * and the last four. + */ const breadcrumbOf = ( codeGraph: CodeGraph | null, frames: readonly Frame[], @@ -195,9 +220,18 @@ const breadcrumbOf = ( if (codeGraph === null || roomId === null) { return null; } - return [...frames.map((frame) => frame.callerRoomId), roomId] - .map((id) => labelOf(codeGraph, id)) - .join(" → "); + const labels = runsOf([ + ...frames.map((frame) => frame.callerRoomId), + roomId, + ]).map(({ id, count }) => { + const label = labelOf(codeGraph, id); + return count > 1 ? `${label} ×${count} ↻` : label; + }); + const shown = + labels.length > BREADCRUMB_MAX_GROUPS + ? [labels[0], "…", ...labels.slice(-BREADCRUMB_TAIL)] + : labels; + return shown.join(" → "); }; /**