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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/world-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
const [roomId, setRoomId] = useState<string | null>(null);
const [target, setTarget] = useState<Target | null>(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();
Expand Down
111 changes: 111 additions & 0 deletions apps/web/src/world-from-code-breadcrumb.test.ts
Original file line number Diff line number Diff line change
@@ -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 ↻");
});
});
42 changes: 38 additions & 4 deletions apps/web/src/world-from-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand All @@ -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(" → ");
};

/**
Expand Down
Loading