Two compilers that walk a graph the same way walk it through one emitter (#85)
game-maker / suite (push) Failing after 2m22s
game-maker / gate (push) Successful in 2m53s

Refactoring round two: the particle and shader compilers share emitGraph.ts (cycle detection,
duplicate names, emit order) and keep only their rules. FontLibrary and TextureLibrary sit on
FileLibrary (base URL, loading/failed bookkeeping, listeners); SoundLibrary keeps its own
contract. The three Create*Command share ImportFileCommand, the three renames share
RenameCommand - class names stay. bandBounds() in framing.ts and portNameField() in
portSection.ts replace in-file clones, and three comments that told history now state the rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-16 19:28:28 +02:00
1 parent bf0be66c9d
commit d3563897d8
16 files changed
+376 -379

No files matched your search

+2 -2
View File
@@ -19,8 +19,8 @@ const MAX_STEPS_PER_FIRE = 100_000;
/**
* How far exec edges may nest. The interpreter recurses along them, so this is what keeps a flow
* feeding back into itself off the call stack – a job the step budget can no longer do now that a
* legitimate pulse may run tens of thousands of steps.
* feeding back into itself off the call stack – the step budget cannot, since a legitimate pulse
* may run tens of thousands of steps.
*/
const MAX_DEPTH = 1000;
+106
View File
@@ -0,0 +1,106 @@
import { edgesInto, findNode, type Graph, type GraphNode } from './Graph';
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
export interface EmitRules<D extends NodeDef, T> {
isDef: (def: NodeDef) => def is D;
/** Names the node kind in "Not a … node". */
noun: string;
/** A node's outputs from its inputs; called once per node, in dependency order. */
emit: (def: D, inputs: Record<string, T>, node: GraphNode) => Record<string, T>;
/** An input without an edge: the typed-in value or what the node says it means unfed. */
unfed: (node: GraphNode, port: PortDef, def: D | undefined) => T | null;
/** An edge's value on its way into `port`; null (after reporting a problem) when it cannot go in. */
conform?: (node: GraphNode, port: PortDef, value: T) => T | null;
}
export interface GraphEmitter<T> {
/** Per node id, the first thing that keeps the graph from compiling. */
readonly problems: Record<string, string>;
problem(node: GraphNode, text: string): void;
/** What feeds an input, emitting whatever it depends on first. */
input(node: GraphNode, port: PortDef): T | null;
/** Whether the walk reached the node. */
reached(node: GraphNode): boolean;
/** Reports property names that are invalid (`invalid` returns the text) or taken twice. */
checkNames(
nodes: GraphNode[],
nameOf: (node: GraphNode) => string,
invalid: (name: string) => string,
): void;
/** The problem of the first node in graph order, or null when there is none. */
firstProblem(): string | null;
}
/**
* The walk shared by the graph compilers: every node reachable from where `input` is first asked
* is emitted once, before anything that reads it, and a node feeding itself is a problem.
*/
export function graphEmitter<D extends NodeDef, T>(graph: Graph, rules: EmitRules<D, T>): GraphEmitter<T> {
const problems: Record<string, string> = {};
const emitted = new Map<string, Record<string, T>>();
const visiting = new Set<string>();
const problem = (node: GraphNode, text: string) => {
if (!(node.id in problems)) problems[node.id] = text;
};
const emitNode = (node: GraphNode): Record<string, T> | null => {
const done = emitted.get(node.id);
if (done) return done;
if (visiting.has(node.id)) {
problem(node, 'Feeds itself');
return null;
}
const def = nodeDef(node.type);
if (!def || !rules.isDef(def)) {
problem(node, `Not a ${rules.noun} node`);
return null;
}
visiting.add(node.id);
const inputs: Record<string, T> = {};
let complete = true;
for (const port of def.inputs) {
const value = input(node, port);
if (value) inputs[port.key] = value;
else complete = false;
}
visiting.delete(node.id);
if (!complete) return null;
const outputs = rules.emit(def, inputs, node);
emitted.set(node.id, outputs);
return outputs;
};
const input = (node: GraphNode, port: PortDef): T | null => {
const edge = edgesInto(graph, node.id, port.key)[0];
if (edge) {
const source = findNode(graph, edge.from.node);
const value = (source && emitNode(source))?.[edge.from.port];
if (!value) return null;
return rules.conform ? rules.conform(node, port, value) : value;
}
const def = nodeDef(node.type);
return rules.unfed(node, port, def && rules.isDef(def) ? def : undefined);
};
return {
problems,
problem,
input,
reached: (node) => emitted.has(node.id),
checkNames(nodes, nameOf, invalid) {
const names = new Set<string>();
for (const node of nodes) {
const name = nameOf(node);
const text = invalid(name);
if (text) problem(node, text);
else if (names.has(name)) problem(node, `Another property is named "${name}"`);
names.add(name);
}
},
firstProblem() {
const first = graph.nodes.find((n) => n.id in problems);
return first ? problems[first.id]! : null;
},
};
}