Initial Commit

This commit is contained in:
ookami125 2026-08-18 00:52:29 -04:00
commit 35f3810632
90 changed files with 29267 additions and 0 deletions

View file

@ -0,0 +1,70 @@
# Experiment 000 — Resonance Bench
## Question
Does interacting with a consistent, programmable-feeling magical system create enough curiosity that the player voluntarily asks and tests their own questions?
## Hypotheses
The primary hypothesis is that phase, timing, conservation, storage, and feedback can form a legible curiosity chain. Competing explanations are that abstract engineering needs world consequences, that circuit-like manipulation is not intrinsically compelling, or that interface friction masks the idea. See [`hypothesis.md`](hypothesis.md).
## Difference From Previous Experiment
This is the baseline experiment.
## Expected Result
Strong support is not merely finishing the three observations. It is changing a working network to answer a new question, especially after discovering resonance, cancellation, or timing behavior. A clean stop after prescribed objectives, boredom after understanding the laws, and interface frustration imply different next experiments.
## Controls
- No time pressure, enemies, unlocks, randomized parts, progression, or world-fiction consequences.
- Every operation is available from the beginning.
- The three fixed wells provide the same signals on every run.
- Objectives observe behavior and do not award capabilities.
## How To Run
From this directory:
```bash
./run.sh
```
Then open <http://localhost:8000>. The prototype also works by opening `prototype/index.html` directly, although serving it avoids browser-specific local-file restrictions.
## What To Pay Attention To
- After you satisfy an observation, do you want to alter the network anyway?
- Did any result make you ask a new question about the laws?
- Which moments felt like reasoning, and which felt like wiring work?
When you are done—even if boredom arrives quickly—use **Export JSONL**, then report what happened. The most useful first feedback is: when you wanted to stop, one thing you tried that was not required (if any), and what you wanted to try next (if anything).
## Result
Playtested on 2026-08-16. The player completed all three observations in roughly three minutes or less and felt no reason to continue.
- No voluntary experiment followed completion.
- The player did not notice that sources were cyclic or that flux had defined direction.
- The observation predicates were permissive enough that arbitrary timing and direction would still produce competent solutions.
- A merely working solution felt fully sufficient; there was no meaningful reason to improve, vary, or interrogate it.
No JSONL was available for this interpretation; the player's direct report is decisive for the primary behavioral question.
## Interpretation
Experiment 000 did **not** produce the desired curiosity chain. This is a failure of the experiment as built, not yet a clean rejection of programmable magic.
The observations failed to make the central laws causally relevant. They measured loose occupancy and threshold events rather than behavior whose outcome depended on phase and timing. Consequently:
- completion is not evidence that the player formed the intended mental model;
- lack of post-completion optimization is unsurprising because improvement had no consequence;
- cycles and vector direction were implementation details rather than perceived phenomena;
- the prototype's expressive space appeared exhausted as soon as its three binary predicates were satisfied.
This lowers confidence that an abstract signal bench is intrinsically compelling in its current form. It increases confidence that legibility must come from visible consequences, not explanatory text, and that objectives must discriminate between structurally different outputs if they are meant to expose a law.
## Next Best Experiment
Build a controlled successor that retains deterministic cyclic sources and transformations but maps output vectors onto visible motion in a small world. Direction becomes literal direction; timing changes trajectories; cycles create observable repeated behavior. This tests whether embodied consequence makes the same knowledge interesting, versus the competing explanation that the signal substrate itself is too limited.

View file

@ -0,0 +1,26 @@
# Experiment 000 Hypothesis
## Primary hypothesis
A small set of consistent signal laws—vector phase, conservation through splitting, propagation loss, delay, accumulation, thresholds, and feedback—will create legible interactions that prompt the player to form and test an unrequired question.
## Competing explanations
1. The interaction is intrinsically interesting, even with abstract objectives.
2. The laws are interesting but require more embodied or consequential output.
3. The activity feels like ordinary circuit assembly and produces no meaningful curiosity.
4. The idea has promise, but this interface hides causality or makes construction too laborious to judge it.
## Strong evidence
- The player keeps changing a completed network to test an idea.
- A result surprises them, becomes understandable, and suggests another experiment.
- They exploit feedback, cancellation, timing, or accumulation in an approach not dictated by the objectives.
## Weak or negative evidence
- They wire only the minimum path described by the UI and stop.
- They understand the system but feel no desire to manipulate it.
- Most time is spent fighting selection, wiring, deletion, or readability.
- Success comes from unexplained trial and error rather than a revised mental model.

View file

@ -0,0 +1,696 @@
(() => {
"use strict";
const workspace = document.querySelector("#workspace");
const nodesLayer = document.querySelector("#nodes");
const wiresLayer = document.querySelector("#wires");
const pendingWire = document.querySelector("#pending-wire");
const palette = document.querySelector("#palette");
const objectivesEl = document.querySelector("#objectives");
const scopesEl = document.querySelector("#scopes");
const runButton = document.querySelector("#run-button");
const stepButton = document.querySelector("#step-button");
const speedInput = document.querySelector("#speed");
const tickReadout = document.querySelector("#tick-readout");
const resetButton = document.querySelector("#reset-button");
const exportButton = document.querySelector("#export-button");
const selectionReadout = document.querySelector("#selection-readout");
const toastEl = document.querySelector("#toast");
const MACHINE_DEFS = {
junction: {
name: "Confluence", glyph: "+", short: "vector sum", description: "Combines every arrival by vector addition, then passes the result unchanged.", config: {}
},
rotor: {
name: "Turner", glyph: "↻", short: "rotate phase", description: "Rotates phase without changing strength.",
config: { turns: { label: "turn", value: 1, options: [[-1, "90°"], [1, "+90°"], [2, "180°"]] } }
},
delay: {
name: "Echo", glyph: "⋯", short: "hold in time", description: "Returns each arrival after a fixed number of ticks.",
config: { delay: { label: "ticks", value: 2, options: [[1, "1"], [2, "2"], [3, "3"], [4, "4"], [5, "5"], [6, "6"]] } }
},
vessel: {
name: "Vessel", glyph: "◇", short: "store & burst", description: "Adds flux to a leaky store. At threshold, releases the entire vector and empties.",
config: { threshold: { label: "release", value: 2.5, options: [[1.5, "1.5"], [2.5, "2.5"], [4, "4.0"], [6, "6.0"]] } }
},
valve: {
name: "Threshold", glyph: "⊣", short: "pass strong flux", description: "Passes an arrival only when its combined strength reaches the threshold.",
config: { threshold: { label: "minimum", value: 1, options: [[0.35, "0.35"], [0.75, "0.75"], [1, "1.0"], [1.5, "1.5"], [2.5, "2.5"]] } }
},
polarizer: {
name: "Polarizer", glyph: "∥", short: "project an axis", description: "Keeps only the signed component parallel to its chosen phase; the rest dissipates.",
config: { axis: { label: "axis", value: 0, options: [[0, "east / west"], [90, "north / south"], [45, "diagonal /"], [135, "diagonal backslash"]] } }
}
};
const SOURCE_DEFS = [
{ id: "well-dawn", name: "Dawn well", glyph: "☼", x: 34, y: 42, phase: 0, strength: 1, period: 2, description: "1.00 east-phase flux every 2 ticks." },
{ id: "well-tide", name: "Tide well", glyph: "≈", x: 34, y: 200, phase: 90, strength: 1.15, period: 3, description: "1.15 north-phase flux every 3 ticks." },
{ id: "well-dusk", name: "Dusk well", glyph: "◒", x: 34, y: 358, phase: 180, strength: .9, period: 5, description: "0.90 west-phase flux every 5 ticks." }
];
const SCOPE_DEFS = [
{ id: "scope-amber", name: "Continuity lens", glyph: "", color: "#f0bf68", description: "Observes continuity: strength > 0.18 on 10 of the latest 12 ticks." },
{ id: "scope-cyan", name: "Impulse lens", glyph: "Ⅱ", color: "#67d7ff", description: "Observes one arrival with strength ≥ 2.40." },
{ id: "scope-violet", name: "Polarity lens", glyph: "Ⅲ", color: "#be92ff", description: "Observes two non-trivial arrivals with nearly opposite phases within 10 ticks." }
];
const OBJECTIVES = [
{ id: "continuity", title: "Continuity", description: "Continuity lens: > 0.18 strength on 10 of 12 recent ticks." },
{ id: "impulse", title: "Impulse", description: "Impulse lens: a single arrival at strength 2.40 or greater." },
{ id: "polarity", title: "Reversal", description: "Polarity lens: opposing phases within a 10-tick window." }
];
const state = {
nodes: new Map(),
edges: new Map(),
mailbox: new Map(),
tick: 0,
running: true,
timer: null,
speed: Number(speedInput.value),
pendingFrom: null,
selectedNode: null,
selectedEdge: null,
nextNodeId: 1,
nextEdgeId: 1,
completed: new Set(),
logs: [],
sessionId: crypto.randomUUID ? crypto.randomUUID() : `session-${Date.now()}`,
startedAt: Date.now()
};
const zero = () => ({ x: 0, y: 0 });
const add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
const scale = (a, amount) => ({ x: a.x * amount, y: a.y * amount });
const magnitude = a => Math.hypot(a.x, a.y);
const degrees = a => magnitude(a) < .0001 ? 0 : (Math.atan2(a.y, a.x) * 180 / Math.PI + 360) % 360;
const fromPolar = (strength, phase) => ({ x: strength * Math.cos(phase * Math.PI / 180), y: strength * Math.sin(phase * Math.PI / 180) });
const rotate = (a, angle) => {
const r = angle * Math.PI / 180;
return { x: a.x * Math.cos(r) - a.y * Math.sin(r), y: a.x * Math.sin(r) + a.y * Math.cos(r) };
};
const sum = vectors => vectors.reduce(add, zero());
const round = n => Math.round(n * 1000) / 1000;
const compactVector = v => ({ x: round(v.x), y: round(v.y), strength: round(magnitude(v)), phase: round(degrees(v)) });
const phaseColor = v => magnitude(v) < .015 ? "#718096" : `hsl(${Math.round((degrees(v) + 165) % 360)} 82% 70%)`;
const phaseName = v => {
if (magnitude(v) < .015) return "quiet";
const names = ["east", "north-east", "north", "north-west", "west", "south-west", "south", "south-east"];
return names[Math.round(degrees(v) / 45) % 8];
};
function logEvent(type, data = {}) {
const event = {
schema: 1,
session_id: state.sessionId,
elapsed_ms: Date.now() - state.startedAt,
tick: state.tick,
type,
...data
};
state.logs.push(JSON.stringify(event));
try { localStorage.setItem("resonance-bench-last-jsonl", state.logs.join("\n") + "\n"); } catch (_) { /* Export still works. */ }
}
function makeConfig(type) {
const result = {};
for (const [key, spec] of Object.entries(MACHINE_DEFS[type]?.config || {})) result[key] = spec.value;
return result;
}
function addNode(type, x, y, extras = {}, shouldLog = true) {
const id = extras.id || `machine-${state.nextNodeId++}`;
const def = MACHINE_DEFS[type] || extras;
const node = {
id, type, x, y,
name: extras.name || def.name,
glyph: extras.glyph || def.glyph,
description: extras.description || def.description,
fixed: Boolean(extras.fixed),
color: extras.color || null,
config: extras.config || makeConfig(type),
source: extras.source || null,
history: [],
internal: {},
live: zero(),
received: zero()
};
state.nodes.set(id, node);
renderNode(node);
if (shouldLog) logEvent("node_added", { node_id: id, operation: type, x: round(x), y: round(y), config: node.config });
updateWorkspaceHint();
return node;
}
function renderNode(node) {
const el = document.createElement("article");
el.className = `node ${node.fixed ? "fixed" : ""} ${node.type === "scope" ? "scope-node" : ""}`;
el.dataset.nodeId = node.id;
el.style.left = `${node.x}px`;
el.style.top = `${node.y}px`;
if (node.color) el.style.setProperty("--node-accent", node.color);
const hasInput = node.type !== "source";
const hasOutput = node.type !== "scope";
const configHtml = Object.entries(MACHINE_DEFS[node.type]?.config || {}).map(([key, spec]) => {
const options = spec.options.map(([value, label]) => `<option value="${value}" ${String(value) === String(node.config[key]) ? "selected" : ""}>${label}</option>`).join("");
return `<label for="${node.id}-${key}">${spec.label}</label><select id="${node.id}-${key}" data-config="${key}">${options}</select>`;
}).join("");
el.innerHTML = `
<header class="node-header">
<span class="node-glyph">${node.glyph}</span>
<span class="node-kind">${node.name}</span>
${node.fixed ? "" : '<button class="node-delete" title="Remove node" aria-label="Remove node">×</button>'}
</header>
${hasInput ? '<button class="port input" title="Flux input" aria-label="Flux input"></button>' : ""}
${hasOutput ? '<button class="port output" title="Flux output" aria-label="Flux output"></button>' : ""}
<div class="node-body">
<div class="node-description">${node.description}</div>
<div class="node-live">
<div class="flux-orb"></div>
<div class="flux-numbers"><strong>quiet</strong><span>x 0.00 · y 0.00</span></div>
</div>
${configHtml ? `<div class="node-config">${configHtml}</div>` : ""}
</div>`;
nodesLayer.append(el);
node.el = el;
el.addEventListener("pointerdown", event => {
if (event.target.closest(".port, button, select, input")) return;
selectNode(node.id);
});
el.querySelector(".node-delete")?.addEventListener("click", () => removeNode(node.id));
el.querySelector(".port.output")?.addEventListener("click", event => beginWire(event, node.id));
el.querySelector(".port.input")?.addEventListener("click", event => finishWire(event, node.id));
el.querySelectorAll("[data-config]").forEach(control => control.addEventListener("change", event => {
const key = event.target.dataset.config;
const before = node.config[key];
node.config[key] = Number(event.target.value);
node.internal = {};
logEvent("node_configured", { node_id: node.id, operation: node.type, parameter: key, before, after: node.config[key] });
}));
installDrag(node, el.querySelector(".node-header"));
}
function installDrag(node, handle) {
handle.addEventListener("pointerdown", event => {
if (event.button !== 0 || event.target.closest("button")) return;
event.preventDefault();
handle.setPointerCapture(event.pointerId);
selectNode(node.id);
const start = { px: event.clientX, py: event.clientY, x: node.x, y: node.y };
let moved = false;
const onMove = moveEvent => {
const rect = workspace.getBoundingClientRect();
const maxX = Math.max(0, rect.width - node.el.offsetWidth);
const maxY = Math.max(0, rect.height - node.el.offsetHeight);
node.x = Math.max(0, Math.min(maxX, start.x + moveEvent.clientX - start.px));
node.y = Math.max(0, Math.min(maxY, start.y + moveEvent.clientY - start.py));
moved ||= Math.abs(moveEvent.clientX - start.px) + Math.abs(moveEvent.clientY - start.py) > 3;
node.el.style.left = `${node.x}px`;
node.el.style.top = `${node.y}px`;
renderWires();
};
const onUp = () => {
handle.removeEventListener("pointermove", onMove);
handle.removeEventListener("pointerup", onUp);
if (moved) logEvent("node_moved", { node_id: node.id, x: round(node.x), y: round(node.y) });
};
handle.addEventListener("pointermove", onMove);
handle.addEventListener("pointerup", onUp);
});
}
function removeNode(id) {
const node = state.nodes.get(id);
if (!node || node.fixed) return;
const connected = [...state.edges.values()].filter(edge => edge.from === id || edge.to === id);
connected.forEach(edge => removeEdge(edge.id, false));
node.el.remove();
state.nodes.delete(id);
if (state.selectedNode === id) clearSelection();
logEvent("node_removed", { node_id: id, operation: node.type, removed_connections: connected.length });
updateWorkspaceHint();
}
function beginWire(event, nodeId) {
event.stopPropagation();
cancelPendingWire();
state.pendingFrom = nodeId;
state.nodes.get(nodeId).el.querySelector(".port.output").classList.add("pending");
pendingWire.hidden = false;
const move = moveEvent => drawPending(moveEvent.clientX, moveEvent.clientY);
const cancel = cancelEvent => {
if (cancelEvent.target.closest?.(".port.input")) return;
cancelPendingWire();
};
state.pendingMove = move;
state.pendingCancel = cancel;
window.addEventListener("pointermove", move);
window.addEventListener("click", cancel, { capture: true, once: true });
}
function finishWire(event, toId) {
if (!state.pendingFrom) return;
event.stopPropagation();
event.preventDefault();
const fromId = state.pendingFrom;
cancelPendingWire();
if (state.edges.values().some(edge => edge.from === fromId && edge.to === toId)) {
showToast("That link already exists.");
return;
}
addEdge(fromId, toId);
}
function cancelPendingWire() {
if (state.pendingFrom) state.nodes.get(state.pendingFrom)?.el.querySelector(".port.output")?.classList.remove("pending");
if (state.pendingMove) window.removeEventListener("pointermove", state.pendingMove);
state.pendingFrom = null;
state.pendingMove = null;
state.pendingCancel = null;
pendingWire.hidden = true;
pendingWire.setAttribute("d", "");
}
function portPoint(node, output) {
return { x: node.x + (output ? node.el.offsetWidth : 0), y: node.y + 51 + 7.5 };
}
function curvePath(a, b) {
const distance = Math.abs(b.x - a.x);
const bend = Math.max(52, Math.min(210, distance * .48));
const direction = b.x >= a.x ? 1 : -1;
return `M ${a.x} ${a.y} C ${a.x + bend * direction} ${a.y}, ${b.x - bend * direction} ${b.y}, ${b.x} ${b.y}`;
}
function drawPending(clientX, clientY) {
const node = state.nodes.get(state.pendingFrom);
if (!node) return;
const rect = workspace.getBoundingClientRect();
pendingWire.setAttribute("d", curvePath(portPoint(node, true), { x: clientX - rect.left, y: clientY - rect.top }));
}
function addEdge(from, to, shouldLog = true) {
const id = `link-${state.nextEdgeId++}`;
const edge = { id, from, to, signal: zero(), el: null };
state.edges.set(id, edge);
renderWires();
if (shouldLog) logEvent("link_added", { link_id: id, from, to, closes_cycle: pathExists(to, from) });
return edge;
}
function pathExists(start, target) {
const seen = new Set();
const stack = [start];
while (stack.length) {
const current = stack.pop();
if (current === target) return true;
if (seen.has(current)) continue;
seen.add(current);
for (const edge of state.edges.values()) if (edge.from === current) stack.push(edge.to);
}
return false;
}
function removeEdge(id, shouldLog = true) {
const edge = state.edges.get(id);
if (!edge) return;
edge.el?.remove();
state.edges.delete(id);
if (state.selectedEdge === id) clearSelection();
if (shouldLog) logEvent("link_removed", { link_id: id, from: edge.from, to: edge.to });
}
function renderWires() {
for (const edge of state.edges.values()) {
if (!edge.el) {
const ns = "http://www.w3.org/2000/svg";
const group = document.createElementNS(ns, "g");
group.classList.add("wire");
group.dataset.edgeId = edge.id;
const hit = document.createElementNS(ns, "path");
hit.classList.add("wire-hit");
const visible = document.createElementNS(ns, "path");
visible.classList.add("wire-visible");
const flow = document.createElementNS(ns, "path");
flow.classList.add("wire-flow");
group.append(hit, visible, flow);
hit.addEventListener("click", event => { event.stopPropagation(); selectEdge(edge.id); });
hit.addEventListener("contextmenu", event => { event.preventDefault(); removeEdge(edge.id); });
wiresLayer.append(group);
edge.el = group;
}
const from = state.nodes.get(edge.from);
const to = state.nodes.get(edge.to);
if (!from || !to) continue;
const d = curvePath(portPoint(from, true), portPoint(to, false));
edge.el.querySelectorAll("path").forEach(path => path.setAttribute("d", d));
updateEdgeVisual(edge);
}
}
function updateEdgeVisual(edge) {
if (!edge.el) return;
const power = magnitude(edge.signal);
edge.el.classList.toggle("active", power > .015);
edge.el.classList.toggle("selected", state.selectedEdge === edge.id);
edge.el.style.setProperty("--flow-color", phaseColor(edge.signal));
edge.el.style.setProperty("--flow-opacity", String(Math.min(.95, .35 + power * .25)));
edge.el.style.setProperty("--flow-width", `${Math.min(7, 1.5 + power * 1.2)}px`);
}
function selectNode(id) {
clearSelection();
state.selectedNode = id;
state.nodes.get(id)?.el.classList.add("selected");
const node = state.nodes.get(id);
selectionReadout.textContent = node ? `${node.name} · ${node.id}` : "Nothing selected";
}
function selectEdge(id) {
clearSelection();
state.selectedEdge = id;
const edge = state.edges.get(id);
edge?.el.classList.add("selected");
selectionReadout.textContent = edge ? `link · ${state.nodes.get(edge.from)?.name}${state.nodes.get(edge.to)?.name}` : "Nothing selected";
}
function clearSelection() {
if (state.selectedNode) state.nodes.get(state.selectedNode)?.el.classList.remove("selected");
if (state.selectedEdge) state.edges.get(state.selectedEdge)?.el.classList.remove("selected");
state.selectedNode = null;
state.selectedEdge = null;
selectionReadout.textContent = "Nothing selected";
}
function machineOutput(node, input) {
switch (node.type) {
case "junction": return input;
case "rotor": return rotate(input, node.config.turns * 90);
case "delay": {
node.internal.queue ||= [];
node.internal.queue.push({ ...input });
return node.internal.queue.length > node.config.delay ? node.internal.queue.shift() : zero();
}
case "vessel": {
node.internal.stored ||= zero();
node.internal.stored = scale(add(node.internal.stored, input), .99);
if (magnitude(node.internal.stored) >= node.config.threshold) {
const released = node.internal.stored;
node.internal.stored = zero();
logEvent("vessel_released", { node_id: node.id, flux: compactVector(released) });
return released;
}
return zero();
}
case "valve": return magnitude(input) >= node.config.threshold ? input : zero();
case "polarizer": {
const axis = fromPolar(1, node.config.axis);
const projection = input.x * axis.x + input.y * axis.y;
return scale(axis, projection);
}
default: return zero();
}
}
function simulationStep() {
state.tick += 1;
const nextMailbox = new Map();
const outputs = new Map();
for (const node of state.nodes.values()) {
const arrivals = state.mailbox.get(node.id) || [];
const input = sum(arrivals);
node.received = input;
let output = zero();
if (node.type === "source") {
if (state.tick % node.source.period === 0) output = fromPolar(node.source.strength, node.source.phase);
} else if (node.type === "scope") {
node.history.push({ tick: state.tick, ...input });
if (node.history.length > 24) node.history.shift();
} else {
output = machineOutput(node, input);
}
node.live = node.type === "scope" ? input : output;
outputs.set(node.id, output);
updateNodeVisual(node);
}
for (const node of state.nodes.values()) {
const outgoing = [...state.edges.values()].filter(edge => edge.from === node.id);
const output = outputs.get(node.id) || zero();
const share = outgoing.length ? scale(output, .92 / outgoing.length) : zero();
for (const edge of outgoing) {
edge.signal = share;
if (!nextMailbox.has(edge.to)) nextMailbox.set(edge.to, []);
nextMailbox.get(edge.to).push({ ...share });
updateEdgeVisual(edge);
}
}
state.mailbox = nextMailbox;
tickReadout.textContent = `tick ${state.tick}`;
evaluateObjectives();
renderScopes();
if (state.tick % 20 === 0) {
logEvent("network_snapshot", {
nodes: state.nodes.size,
links: state.edges.size,
completed_observations: [...state.completed],
active_links: [...state.edges.values()].filter(edge => magnitude(edge.signal) > .015).length
});
}
}
function updateNodeVisual(node) {
if (!node.el) return;
const vector = node.live;
const power = magnitude(vector);
const orb = node.el.querySelector(".flux-orb");
const color = phaseColor(vector);
orb.style.setProperty("--phase", `${-degrees(vector)}deg`);
orb.style.setProperty("--power", String(Math.min(1, power / 2)));
orb.style.setProperty("--flux-color", color);
const label = node.el.querySelector(".flux-numbers strong");
const detail = node.el.querySelector(".flux-numbers span");
if (node.type === "vessel" && power < .015 && magnitude(node.internal.stored || zero()) > .015) {
const stored = node.internal.stored;
label.textContent = `holding ${magnitude(stored).toFixed(2)}`;
detail.textContent = `${phaseName(stored)} · not released`;
} else {
label.textContent = power < .015 ? "quiet" : `${power.toFixed(2)} · ${phaseName(vector)}`;
detail.textContent = `x ${vector.x.toFixed(2)} · y ${vector.y.toFixed(2)}`;
}
}
function evaluateObjectives() {
const amber = state.nodes.get("scope-amber")?.history || [];
const cyan = state.nodes.get("scope-cyan")?.history || [];
const violet = state.nodes.get("scope-violet")?.history || [];
const recentAmber = amber.slice(-12);
if (recentAmber.length === 12 && recentAmber.filter(v => magnitude(v) > .18).length >= 10) completeObjective("continuity");
if (cyan.some(v => magnitude(v) >= 2.4)) completeObjective("impulse");
const recentViolet = violet.slice(-10).filter(v => magnitude(v) > .2);
let opposite = false;
for (let i = 0; i < recentViolet.length; i++) {
for (let j = i + 1; j < recentViolet.length; j++) {
const a = recentViolet[i], b = recentViolet[j];
const dot = (a.x * b.x + a.y * b.y) / (magnitude(a) * magnitude(b));
if (dot < -.86) opposite = true;
}
}
if (opposite) completeObjective("polarity");
}
function completeObjective(id) {
if (state.completed.has(id)) return;
state.completed.add(id);
document.querySelector(`[data-objective="${id}"]`)?.classList.add("complete");
const objective = OBJECTIVES.find(item => item.id === id);
logEvent("observation_completed", { observation: id, nodes: state.nodes.size, links: state.edges.size });
showToast(`${objective.title} observed. What does that make you want to try?`);
if (state.completed.size === OBJECTIVES.length) {
setTimeout(() => showToast("All three are observed. The experiment starts now: alter a working network to answer your own question."), 1700);
logEvent("all_observations_completed", { nodes: state.nodes.size, links: state.edges.size });
}
}
function renderScopes() {
for (const def of SCOPE_DEFS) {
const node = state.nodes.get(def.id);
const card = scopesEl.querySelector(`[data-scope="${def.id}"]`);
if (!node || !card) continue;
const history = node.history;
const latest = history.at(-1) || zero();
card.querySelector("header span").textContent = magnitude(latest) < .015 ? "quiet" : `${magnitude(latest).toFixed(2)} @ ${phaseName(latest)}`;
const padded = [...Array(Math.max(0, 24 - history.length)).fill(zero()), ...history];
const points = component => padded.map((v, index) => `${index * (240 / 23)},${44 - Math.max(-4, Math.min(4, v[component])) * 10}`).join(" ");
const traceX = card.querySelector(".trace-x");
const traceY = card.querySelector(".trace-y");
traceX.setAttribute("points", points("x"));
traceY.setAttribute("points", points("y"));
traceX.style.opacity = padded.some(v => Math.abs(v.x) > .015) ? "1" : "0";
traceY.style.opacity = padded.some(v => Math.abs(v.y) > .015) ? ".55" : "0";
}
}
function startTimer() {
clearInterval(state.timer);
if (state.running) state.timer = setInterval(simulationStep, state.speed);
}
function toggleRunning() {
state.running = !state.running;
runButton.textContent = state.running ? "Pause" : "Run";
runButton.classList.toggle("primary", state.running);
logEvent(state.running ? "simulation_resumed" : "simulation_paused");
startTimer();
}
function resetBench() {
const removedNodes = [...state.nodes.values()].filter(node => !node.fixed).length;
const removedLinks = state.edges.size;
for (const node of [...state.nodes.values()]) if (!node.fixed) removeNode(node.id);
for (const edge of [...state.edges.values()]) removeEdge(edge.id, false);
state.tick = 0;
state.mailbox.clear();
state.completed.clear();
for (const node of state.nodes.values()) {
node.history = [];
node.internal = {};
node.live = zero();
node.received = zero();
updateNodeVisual(node);
}
document.querySelectorAll(".objective").forEach(el => el.classList.remove("complete"));
tickReadout.textContent = "tick 0";
renderScopes();
logEvent("bench_reset", { removed_nodes: removedNodes, removed_links: removedLinks });
showToast("Bench reset. The event log was kept.");
}
async function exportLog() {
const button = $("export-button"), filename = `resonance-bench-${state.sessionId}.jsonl`;
logEvent("log_exported", { events_before_export: state.logs.length, completed_observations: [...state.completed], destination: "repository" }); button.disabled = true; button.textContent = "Saving…";
try {
const response = await fetch("/api/playtest-log", { method: "POST", headers: { "Content-Type": "application/x-ndjson", "X-Playtest-Filename": filename }, body: state.logs.join("\n") + "\n" });
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || `HTTP ${response.status}`);
const result = await response.json(); logEvent("log_saved", { path: result.path, events: result.events }); showToast(`Saved directly to ${result.path}.`);
} catch (error) {
logEvent("log_save_failed", { error: String(error) }); const blob = new Blob([state.logs.join("\n") + "\n"], { type: "application/x-ndjson" }), link = document.createElement("a");
link.href = URL.createObjectURL(blob); link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(link.href), 500); showToast("Direct save failed; downloaded the JSONL instead.");
} finally { button.disabled = false; button.textContent = "Save JSONL"; }
}
let toastTimer;
function showToast(message) {
toastEl.textContent = message;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 3400);
}
function updateWorkspaceHint() {
workspace.classList.toggle("no-machines", [...state.nodes.values()].every(node => node.fixed));
}
function placeMachine(type) {
const rect = workspace.getBoundingClientRect();
const machineCount = [...state.nodes.values()].filter(node => !node.fixed).length;
const x = Math.max(210, Math.min(rect.width - 390, rect.width * .42 + (machineCount % 3) * 28));
const y = Math.max(24, Math.min(rect.height - 150, 55 + (machineCount * 93) % Math.max(110, rect.height - 180)));
const node = addNode(type, x, y);
selectNode(node.id);
}
function buildStaticUI() {
for (const [type, def] of Object.entries(MACHINE_DEFS)) {
const button = document.createElement("button");
button.title = def.description;
button.innerHTML = `<span class="glyph">${def.glyph}</span><strong>${def.name}</strong><span>${def.short}</span>`;
button.addEventListener("click", () => placeMachine(type));
palette.append(button);
}
for (const objective of OBJECTIVES) {
const el = document.createElement("div");
el.className = "objective";
el.dataset.objective = objective.id;
el.innerHTML = `<strong>${objective.title}</strong><span>${objective.description}</span>`;
objectivesEl.append(el);
}
for (const def of SCOPE_DEFS) {
const card = document.createElement("article");
card.className = "scope-card";
card.dataset.scope = def.id;
card.style.setProperty("--scope-color", def.color);
card.innerHTML = `
<header>${def.name}<span>quiet</span></header>
<svg viewBox="0 0 240 88" preserveAspectRatio="none" aria-label="x and y flux component history">
<path class="scope-grid" d="M0 4H240 M0 24H240 M0 44H240 M0 64H240 M0 84H240"></path>
<path class="scope-zero" d="M0 44H240"></path>
<polyline class="scope-trace trace-y" opacity=".38" stroke-dasharray="3 3"></polyline>
<polyline class="scope-trace trace-x"></polyline>
</svg>`;
scopesEl.append(card);
}
}
function initializeBench() {
const rect = workspace.getBoundingClientRect();
const top = 34;
const bottom = Math.max(top, rect.height - 122);
const verticalSpacing = Math.min(158, (bottom - top) / 2);
SOURCE_DEFS.forEach((source, index) => {
addNode("source", source.x, top + index * verticalSpacing, {
...source, fixed: true, source: { phase: source.phase, strength: source.strength, period: source.period }
}, false);
});
SCOPE_DEFS.forEach((scope, index) => addNode("scope", Math.max(205, rect.width - 205), top + index * verticalSpacing, { ...scope, fixed: true }, false));
renderScopes();
renderWires();
updateWorkspaceHint();
}
runButton.addEventListener("click", toggleRunning);
stepButton.addEventListener("click", () => {
if (state.running) toggleRunning();
simulationStep();
logEvent("simulation_stepped");
});
speedInput.addEventListener("input", () => {
const before = state.speed;
state.speed = Number(speedInput.value);
startTimer();
logEvent("tempo_changed", { before_ms: before, after_ms: state.speed });
});
resetButton.addEventListener("click", resetBench);
exportButton.addEventListener("click", exportLog);
workspace.addEventListener("click", event => {
if (event.target === workspace || event.target.classList.contains("grid-glow") || event.target === nodesLayer) clearSelection();
});
workspace.addEventListener("pointermove", event => {
if (state.pendingFrom) drawPending(event.clientX, event.clientY);
});
window.addEventListener("keydown", event => {
if (!["Delete", "Backspace"].includes(event.key) || event.target.matches("input, select")) return;
if (state.selectedEdge) removeEdge(state.selectedEdge);
else if (state.selectedNode) removeNode(state.selectedNode);
});
window.addEventListener("resize", renderWires);
document.addEventListener("visibilitychange", () => logEvent(document.hidden ? "page_hidden" : "page_visible"));
window.addEventListener("beforeunload", () => logEvent("session_unloaded", { completed_observations: [...state.completed] }));
buildStaticUI();
initializeBench();
logEvent("session_started", {
experiment: "000_magic_language",
viewport: { width: window.innerWidth, height: window.innerHeight },
fixed_sources: SOURCE_DEFS.map(source => ({ id: source.id, phase: source.phase, strength: source.strength, period: source.period }))
});
startTimer();
})();

View file

@ -0,0 +1,101 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Resonance Bench — Experiment 000</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="topbar">
<div>
<div class="eyebrow">EXPERIMENT 000</div>
<h1>Resonance Bench</h1>
</div>
<div class="sim-controls" aria-label="Simulation controls">
<button id="run-button" class="primary">Pause</button>
<button id="step-button">Step</button>
<label class="speed-label">Tempo
<input id="speed" type="range" min="120" max="850" step="10" value="420">
</label>
<span id="tick-readout" class="tick-readout">tick 0</span>
<button id="reset-button">Reset bench</button>
<button id="export-button">Save JSONL</button>
</div>
</header>
<div class="app-shell">
<aside class="sidebar">
<section class="intro-card">
<h2>Research prompt</h2>
<p>Make the three instruments observe their named phenomena. Then—more importantly—change something just to answer a question of your own.</p>
<p class="small">There are no unlocks or rewards. Finishing is not the point.</p>
</section>
<section>
<div class="section-heading">
<h2>Operations</h2>
<span>click to place</span>
</div>
<div id="palette" class="palette"></div>
</section>
<details class="lawbook" open>
<summary>The five flux laws</summary>
<ol>
<li>Flux is a vector: direction is <strong>phase</strong>; length is <strong>strength</strong>.</li>
<li>A node combines arrivals by vector addition. Aligned flux reinforces; opposed flux cancels.</li>
<li>Every link takes one tick, loses 8%, and divides output evenly among outgoing links.</li>
<li>A closed path returns old flux. Its timing and rotation decide whether it reinforces new flux.</li>
<li>Operations transform flux consistently. Nothing requires discovery or permission.</li>
</ol>
</details>
<section>
<div class="section-heading">
<h2>Observations</h2>
<span>not quests</span>
</div>
<div id="objectives" class="objectives"></div>
</section>
<section class="interaction-help">
<h2>Bench controls</h2>
<p>Drag node headers. Click an output port, then an input port, to weave a link. Click a link to select it; press <kbd>Delete</kbd> to remove selected links or nodes. Right-click also removes a link. Hover anything for detail.</p>
</section>
</aside>
<main class="workspace-wrap">
<div id="workspace" class="workspace" tabindex="0" aria-label="Flux engineering workspace">
<div class="grid-glow"></div>
<svg id="wire-layer" class="wire-layer" aria-hidden="true">
<defs>
<filter id="wire-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur"></feGaussianBlur>
<feMerge><feMergeNode in="blur"></feMergeNode><feMergeNode in="SourceGraphic"></feMergeNode></feMerge>
</filter>
</defs>
<g id="wires"></g>
<path id="pending-wire" class="pending-wire" d="" hidden></path>
</svg>
<div id="nodes" class="nodes"></div>
<div id="empty-hint" class="empty-hint">Choose an operation from the left, or connect a well directly to observe its raw flux.</div>
</div>
<section class="scope-rack">
<div class="scope-title">
<div>
<span class="eyebrow">LIVE INSTRUMENTS</span>
<h2>Last 24 ticks · solid x / dotted y</h2>
</div>
<div id="selection-readout" class="selection-readout">Nothing selected</div>
</div>
<div id="scopes" class="scopes"></div>
</section>
</main>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="app.js"></script>
</body>
</html>

View file

@ -0,0 +1,186 @@
:root {
color-scheme: dark;
--bg: #080b12;
--panel: #101622;
--panel-2: #151e2d;
--line: #28354a;
--muted: #8d9bb0;
--text: #e7edf6;
--accent: #77e4d4;
--gold: #f0bf68;
--cyan: #67d7ff;
--violet: #be92ff;
--danger: #ff6f7d;
--node-width: 164px;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); }
body {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
overflow: hidden;
}
button, input, select { font: inherit; }
button {
border: 1px solid var(--line);
background: #172131;
color: var(--text);
border-radius: 7px;
padding: 7px 11px;
cursor: pointer;
}
button:hover { border-color: #52667f; background: #1e2b3e; }
button.primary { color: #07100f; background: var(--accent); border-color: var(--accent); font-weight: 700; }
button.primary:hover { background: #9af1e5; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 0; font-size: 21px; letter-spacing: .01em; font-weight: 670; }
h2 { margin-bottom: 8px; font-size: 13px; text-transform: uppercase; letter-spacing: .09em; }
p { line-height: 1.47; }
.eyebrow { color: var(--accent); font-size: 10px; font-weight: 800; letter-spacing: .16em; }
.small { color: var(--muted); font-size: 12px; }
.topbar {
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 8px 18px;
border-bottom: 1px solid var(--line);
background: #0c111a;
}
.sim-controls { display: flex; align-items: center; gap: 8px; }
.speed-label { display: flex; gap: 7px; align-items: center; color: var(--muted); font-size: 12px; }
.speed-label input { direction: rtl; width: 100px; accent-color: var(--accent); }
.tick-readout { min-width: 70px; color: var(--muted); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
.app-shell { height: calc(100vh - 64px); display: grid; grid-template-columns: 286px minmax(0, 1fr); }
.sidebar {
overflow-y: auto;
padding: 15px;
border-right: 1px solid var(--line);
background: #0c111a;
}
.sidebar section, .lawbook { margin-bottom: 18px; }
.intro-card { padding: 13px; border: 1px solid #34575a; border-radius: 9px; background: linear-gradient(145deg, #102026, #111723); }
.intro-card p { margin-bottom: 8px; font-size: 13px; }
.intro-card p:last-child { margin-bottom: 0; }
.section-heading { display: flex; justify-content: space-between; align-items: baseline; }
.section-heading span { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
.palette { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
.palette button { min-height: 60px; text-align: left; padding: 9px; }
.palette button strong { display: block; color: #f4f7fb; font-size: 12px; }
.palette button span { display: block; margin-top: 4px; color: var(--muted); font-size: 10px; line-height: 1.25; }
.palette .glyph { float: right; color: var(--accent); font: 18px ui-monospace, monospace; }
.lawbook { border: 1px solid var(--line); border-radius: 8px; background: var(--panel); }
.lawbook summary { padding: 10px 12px; cursor: pointer; font-size: 12px; font-weight: 700; }
.lawbook ol { margin: 0; padding: 0 13px 11px 30px; color: var(--muted); font-size: 11px; line-height: 1.42; }
.lawbook li + li { margin-top: 6px; }
.lawbook strong { color: var(--text); }
.objectives { display: grid; gap: 7px; }
.objective { position: relative; padding: 9px 10px 9px 28px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel); }
.objective::before { content: "○"; position: absolute; left: 10px; top: 9px; color: var(--muted); }
.objective.complete { border-color: #3a776d; background: #10231f; }
.objective.complete::before { content: "●"; color: var(--accent); }
.objective strong { display: block; font-size: 11px; }
.objective span { display: block; margin-top: 3px; color: var(--muted); font-size: 10px; line-height: 1.3; }
.interaction-help p { color: var(--muted); font-size: 11px; }
kbd { border: 1px solid #46566d; border-bottom-width: 2px; border-radius: 4px; padding: 1px 4px; color: var(--text); background: #161e2a; }
.workspace-wrap { min-width: 0; display: grid; grid-template-rows: minmax(390px, 1fr) 190px; }
.workspace { position: relative; overflow: hidden; outline: none; background-color: #090e17; }
.workspace:focus-visible { box-shadow: inset 0 0 0 2px #385f68; }
.grid-glow { position: absolute; inset: 0; pointer-events: none; background-image: linear-gradient(rgba(108,139,171,.07) 1px, transparent 1px), linear-gradient(90deg, rgba(108,139,171,.07) 1px, transparent 1px), radial-gradient(circle at 50% 45%, rgba(40,84,94,.15), transparent 60%); background-size: 24px 24px, 24px 24px, 100% 100%; }
.wire-layer, .nodes { position: absolute; inset: 0; width: 100%; height: 100%; }
.wire-layer { overflow: visible; pointer-events: none; }
.nodes { pointer-events: none; }
.empty-hint { position: absolute; left: 50%; top: 48%; translate: -50% -50%; width: 340px; text-align: center; color: #536175; font-size: 12px; pointer-events: none; opacity: 0; transition: opacity .2s; }
.workspace.no-machines .empty-hint { opacity: 1; }
.wire-hit { fill: none; stroke: transparent; stroke-width: 16; pointer-events: stroke; cursor: pointer; }
.wire-visible { fill: none; stroke: #344359; stroke-width: 2; pointer-events: none; transition: stroke .12s, stroke-width .12s, opacity .12s; }
.wire-flow { fill: none; stroke: var(--accent); stroke-width: 3; stroke-dasharray: 4 12; opacity: 0; pointer-events: none; animation: flow .6s linear infinite; filter: url(#wire-glow); }
.wire.active .wire-flow { opacity: var(--flow-opacity, .7); stroke: var(--flow-color, var(--accent)); stroke-width: var(--flow-width, 3); }
.wire.selected .wire-visible { stroke: #f1f5fb; stroke-width: 4; }
.pending-wire { fill: none; stroke: var(--accent); stroke-width: 2; stroke-dasharray: 5 5; pointer-events: none; }
@keyframes flow { to { stroke-dashoffset: -16; } }
.node {
position: absolute;
width: var(--node-width);
min-height: 96px;
pointer-events: auto;
border: 1px solid #344258;
border-radius: 9px;
background: linear-gradient(155deg, #182231, #111824);
box-shadow: 0 8px 24px rgba(0,0,0,.28);
user-select: none;
}
.node.selected { border-color: #ecf4ff; box-shadow: 0 0 0 1px #ecf4ff, 0 9px 28px rgba(0,0,0,.4); }
.node.fixed { background: linear-gradient(155deg, #172934, #111923); }
.node.scope-node { border-color: var(--node-accent, #53667e); background: linear-gradient(155deg, #202031, #111824); }
.node-header { height: 31px; display: flex; align-items: center; gap: 7px; padding: 0 9px; border-bottom: 1px solid #2b394c; cursor: grab; font-size: 11px; font-weight: 750; letter-spacing: .035em; }
.node-header:active { cursor: grabbing; }
.node-glyph { color: var(--accent); font: 16px ui-monospace, monospace; }
.node-kind { flex: 1; }
.node-delete { width: 19px; height: 19px; padding: 0; border: 0; background: transparent; color: #78879b; }
.node-delete:hover { background: #39212a; color: var(--danger); }
.node-body { padding: 8px 10px 9px; }
.node-description { min-height: 25px; margin-bottom: 6px; color: var(--muted); font-size: 9px; line-height: 1.3; }
.node-live { display: grid; grid-template-columns: 29px 1fr; gap: 7px; align-items: center; }
.flux-orb { --phase: 0deg; --power: 0; position: relative; width: 26px; height: 26px; border: 1px solid #3c4b60; border-radius: 50%; background: radial-gradient(circle, rgba(119,228,212, calc(var(--power) * .6)), transparent 65%); }
.flux-orb::after { content: ""; position: absolute; width: 10px; height: 2px; top: 11px; left: 12px; transform-origin: 1px 1px; rotate: var(--phase); background: var(--flux-color, #78879b); box-shadow: 0 0 5px var(--flux-color, transparent); }
.flux-orb::before { content: ""; position: absolute; left: 3px; top: 3px; width: 18px; height: 18px; border: 1px solid rgba(255,255,255,.08); border-radius: 50%; }
.flux-numbers { min-width: 0; }
.flux-numbers strong { display: block; overflow: hidden; color: #e9f4f3; font: 10px ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
.flux-numbers span { display: block; margin-top: 2px; color: #78879b; font: 9px ui-monospace, monospace; }
.node-config { display: grid; grid-template-columns: auto 1fr; gap: 4px 6px; align-items: center; margin-top: 7px; border-top: 1px solid #293648; padding-top: 6px; }
.node-config label { color: var(--muted); font-size: 9px; }
.node-config select, .node-config input { min-width: 0; width: 100%; height: 22px; border: 1px solid #334258; border-radius: 4px; background: #0d141f; color: var(--text); padding: 1px 4px; font-size: 9px; }
.port { position: absolute; top: 51px; width: 15px; height: 15px; border: 2px solid #8796aa; border-radius: 50%; background: #111925; cursor: crosshair; z-index: 3; }
.port:hover, .port.pending { scale: 1.25; border-color: #fff; background: var(--accent); box-shadow: 0 0 9px var(--accent); }
.port.input { left: -8px; }
.port.output { right: -8px; }
.port::after { position: absolute; top: 16px; color: #68778b; font-size: 8px; pointer-events: none; }
.port.input::after { content: "IN"; left: 0; }
.port.output::after { content: "OUT"; right: -4px; }
.scope-rack { overflow: hidden; border-top: 1px solid var(--line); background: #0d121c; padding: 11px 16px; }
.scope-title { display: flex; align-items: end; justify-content: space-between; margin-bottom: 8px; }
.scope-title h2 { margin: 2px 0 0; font-size: 12px; }
.selection-readout { color: var(--muted); font: 10px ui-monospace, monospace; }
.scopes { height: 125px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.scope-card { position: relative; overflow: hidden; border: 1px solid var(--line); border-radius: 7px; background: #080c13; padding: 7px 8px; }
.scope-card header { display: flex; justify-content: space-between; color: var(--scope-color); font-size: 10px; font-weight: 750; }
.scope-card header span { color: var(--muted); font: 9px ui-monospace, monospace; }
.scope-card svg { display: block; width: 100%; height: 88px; margin-top: 3px; }
.scope-grid { stroke: #1e2a3b; stroke-width: 1; }
.scope-zero { stroke: #334157; stroke-width: 1; }
.scope-trace { fill: none; stroke: var(--scope-color); stroke-width: 2; vector-effect: non-scaling-stroke; filter: drop-shadow(0 0 3px var(--scope-color)); }
.toast { position: fixed; z-index: 20; left: 50%; bottom: 20px; translate: -50% 12px; max-width: 440px; padding: 10px 15px; border: 1px solid #4e6b68; border-radius: 8px; background: #12231f; box-shadow: 0 10px 30px rgba(0,0,0,.45); opacity: 0; pointer-events: none; transition: opacity .2s, translate .2s; font-size: 12px; }
.toast.show { opacity: 1; translate: -50% 0; }
@media (max-width: 900px) {
body { overflow: auto; }
.topbar { height: auto; align-items: flex-start; flex-direction: column; }
.sim-controls { flex-wrap: wrap; }
.app-shell { height: auto; grid-template-columns: 1fr; }
.sidebar { border-right: 0; border-bottom: 1px solid var(--line); }
.workspace-wrap { height: 760px; }
.scopes { grid-template-columns: 1fr; overflow-y: auto; }
}

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
EXPERIMENT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$EXPERIMENT_DIR/../.." && pwd)"
exec python3 "$REPO_ROOT/tools/playtest_server.py" --directory "$EXPERIMENT_DIR/prototype" --log-directory "$REPO_ROOT/JSONL" --port 8000