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,72 @@
# Experiment 001 — Flux Familiar
## Question
Does mapping constructed signals onto visible physical motion make phase and timing perceptible, consequential, and interesting enough to invite refinement or further experiments?
## Hypotheses
The leading hypothesis is that Experiment 000 failed partly because its abstract, tolerant predicates did not make its laws matter. The competing hypothesis is that the signal substrate remains too limited even when embodied. See [`hypothesis.md`](hypothesis.md).
## Difference From Previous Experiment
- Flux now applies physical impulses to a visible familiar instead of satisfying abstract lenses.
- Vector phase is literal movement direction and cyclic timing creates a visible trajectory.
- The output space is continuous: routes, impacts, approach angles, speed, and docking stability vary.
- A constellation asks the familiar to wake two stars and settle in a dock; changing constellations alters world geometry while retaining the network.
- The core source, link, phase, splitting, rotation, delay, accumulation, and threshold laws are retained.
## Expected Result
Support for embodiment is continued network revision driven by observed motion: anticipating a turn, correcting an overshoot, deliberately creating a repeated route, or trying a behavior beyond completion. If the player understands the motion but still sees only a shallow routing puzzle, confidence in the underlying signal substrate should fall further.
## Controls
- No time pressure, unlocks, part randomness, enemies, or resource economy.
- Deterministic source cycles and deterministic physics.
- All operations are available immediately.
- Resetting the familiar or changing constellation retains the constructed network.
- Every constellation is selectable from the beginning; none is a progression unlock.
## How To Run
From this directory:
```bash
./run.sh
```
Then open <http://localhost:8000>.
## What To Pay Attention To
- Did watching motion make you predict or care about the next signal?
- Did you revise a working route to change how it behaved?
- After docking, did a new constellation or an idea of your own sound appealing?
Use **Export JSONL** when finished.
## Result
Playtested on 2026-08-16. The player wanted to stop almost immediately.
- Construction felt like awkward steering rather than programming or engineering.
- Inputs and operations had no information about the field, so wiring amounted to brute-force directional movement.
- The route was too simple to offer meaningful refinement.
- The player predicted that the obvious extension—adding field-aware sensors—would turn the task into manually programming an AI and remain tedious. This is a report about a hypothetical variant, not observed playtest behavior.
- Completion supplied no incentive to improve an already adequate solution.
- No voluntary refinement, constellation change, or unrelated experiment occurred.
## Interpretation
Experiment 001 failed and narrows the model substantially.
The failure was not demonstrably only insufficient sensing or output legibility. Directing this autonomous agent toward this static goal was unpromising. More sophisticated feedback control is currently a low-information next step because it would add implementation work without first establishing a reason to value elegance, efficiency, or alternate behavior.
Together, Experiments 000 and 001 suggest that “construct a shallow system, satisfy an arbitrary finite specification, optionally optimize it” is currently the wrong tested frame. The next experiment should not merely add sensors, more nodes, longer routes, or stricter scoring, because that would not distinguish missing depth, missing stakes, missing leverage, and indirect-control friction.
This does not reject systemic magic, automation, or indirect design generally. It is negative evidence about two very small puzzle-like implementations. The next useful exploration probe changes the relationship between player and system: direct action, immediate consequences, and an ongoing situation where understanding changes what the player can accomplish.
## Next Best Experiment
Test direct systemic manipulation in a small action sandbox, with closely related calm and pressured variants. Reuse broad engineering principles—transfer, storage, conversion, momentum, temperature—but remove graph programming and autonomous-agent control. Determine whether external stakes make experimentation instrumentally meaningful or merely conceal another shallow system.

View file

@ -0,0 +1,28 @@
# Experiment 001 Hypothesis
## Primary hypothesis
The same deterministic signal distinctions that were irrelevant in Experiment 000 will become understandable and generative when they directly determine a visible object's trajectory.
## Competing explanations
1. Embodied consequence makes timing, direction, accumulation, and cycles worth reasoning about.
2. Motion improves legibility but produces only a conventional finite routing puzzle.
3. The signal language remains too limited to sustain curiosity regardless of presentation.
4. Direct physical manipulation would be enjoyable, but graph construction adds too much indirection or work.
5. A cared-about world is still missing; a puck-like familiar is only an animated oscilloscope.
## Strong evidence
- The player changes a route after it already completes, because another trajectory seems worth creating.
- Visible motion causes a prediction, surprise, revised mental model, and another test.
- The player deliberately exploits periodicity, phase, storage, or recirculation.
- The player chooses another constellation without being prompted by progression rewards.
## Weak or negative evidence
- Completion is fast and followed by the same sense that nothing remains.
- The familiar is treated as a cursor and the network as awkward controls.
- Random or noisy output eventually succeeds without understanding.
- A single universal controller handles every constellation with trivial changes.

View file

@ -0,0 +1,407 @@
(() => {
"use strict";
const $ = selector => document.querySelector(selector);
const networkEl = $("#network");
const nodesEl = $("#nodes");
const wiresEl = $("#wires");
const pendingEl = $("#pending");
const canvas = $("#arena");
const ctx = canvas.getContext("2d");
const OPERATIONS = {
junction: { name: "Confluence", glyph: "+", short: "add arrivals", description: "Adds simultaneous arrivals as vectors.", config: {} },
rotor: { name: "Turner", glyph: "↻", short: "rotate phase", description: "Rotates direction without changing strength.", config: { turns: { label: "turn", value: 1, options: [[-1, "90°"], [1, "+90°"], [2, "180°"]] } } },
delay: { name: "Echo", glyph: "⋯", short: "delay pulses", description: "Returns each arrival after a fixed delay.", config: { delay: { label: "ticks", value: 2, options: [[1, "1"], [2, "2"], [3, "3"], [4, "4"], [6, "6"], [8, "8"]] } } },
vessel: { name: "Vessel", glyph: "◇", short: "store & burst", description: "Stores a leaky vector, then releases it all.", 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: "reject weak flux", description: "Passes only arrivals above a minimum strength.", config: { threshold: { label: "minimum", value: .75, options: [[.3, "0.30"], [.75, "0.75"], [1, "1.0"], [1.5, "1.5"], [2.5, "2.5"]] } } },
polarizer: { name: "Polarizer", glyph: "∥", short: "keep one axis", description: "Keeps only the signed component on one axis.", config: { axis: { label: "axis", value: 0, options: [[0, "east / west"], [90, "north / south"], [45, "diagonal /"], [135, "other diagonal"]] } } }
};
const SOURCES = [
{ id: "dawn", name: "Dawn well", glyph: "☼", phase: 0, strength: 1, period: 3, description: "East impulse every 3 ticks." },
{ id: "tide", name: "Tide well", glyph: "≈", phase: 90, strength: 1.1, period: 5, description: "North impulse every 5 ticks." },
{ id: "dusk", name: "Dusk well", glyph: "◒", phase: 180, strength: .9, period: 7, description: "West impulse every 7 ticks." }
];
const CONSTELLATIONS = [
{
name: "perimeter",
start: [.16, .79], starA: [.76, .79], starB: [.76, .22], dock: [.22, .22],
obstacles: [{ x: .42, y: .40, w: .16, h: .22 }]
},
{
name: "crosswind",
start: [.18, .50], starA: [.54, .18], starB: [.82, .67], dock: [.25, .82],
obstacles: [{ x: .38, y: .35, w: .09, h: .43 }, { x: .63, y: .18, w: .08, h: .37 }]
},
{
name: "split channel",
start: [.12, .82], starA: [.83, .18], starB: [.18, .31], dock: [.79, .78],
obstacles: [{ x: .32, y: .20, w: .1, h: .60 }, { x: .60, y: .08, w: .1, h: .62 }]
}
];
const state = {
nodes: new Map(), edges: new Map(), mailbox: new Map(),
tick: 0, running: true, speed: Number($("#tempo").value), timer: null,
nextNode: 1, nextEdge: 1, pendingFrom: null, selectedNode: null, selectedEdge: null,
session: crypto.randomUUID ? crypto.randomUUID() : `session-${Date.now()}`,
started: Date.now(), logs: [], constellation: 0,
world: null, completedTick: null
};
const zero = () => ({ x: 0, y: 0 });
const magnitude = v => Math.hypot(v.x, v.y);
const add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
const scale = (v, n) => ({ x: v.x * n, y: v.y * n });
const sum = values => values.reduce(add, zero());
const phase = v => magnitude(v) < .0001 ? 0 : (Math.atan2(v.y, v.x) * 180 / Math.PI + 360) % 360;
const polar = (power, angle) => ({ x: power * Math.cos(angle * Math.PI / 180), y: power * Math.sin(angle * Math.PI / 180) });
const rotate = (v, angle) => {
const r = angle * Math.PI / 180;
return { x: v.x * Math.cos(r) - v.y * Math.sin(r), y: v.x * Math.sin(r) + v.y * Math.cos(r) };
};
const round = n => Math.round(n * 1000) / 1000;
const vecLog = v => ({ x: round(v.x), y: round(v.y), strength: round(magnitude(v)), phase: round(phase(v)) });
const fluxColor = v => magnitude(v) < .01 ? "#718096" : `hsl(${Math.round((phase(v) + 165) % 360)} 84% 70%)`;
const phaseName = v => {
if (magnitude(v) < .01) return "quiet";
return ["east", "north-east", "north", "north-west", "west", "south-west", "south", "south-east"][Math.round(phase(v) / 45) % 8];
};
function log(type, data = {}) {
const row = { schema: 1, experiment: "001_flux_familiar", session_id: state.session, elapsed_ms: Date.now() - state.started, tick: state.tick, type, ...data };
state.logs.push(JSON.stringify(row));
try { localStorage.setItem("flux-familiar-last-jsonl", state.logs.join("\n") + "\n"); } catch (_) {}
}
function defaultConfig(type) {
return Object.fromEntries(Object.entries(OPERATIONS[type]?.config || {}).map(([key, spec]) => [key, spec.value]));
}
function addNode(type, x, y, extra = {}, shouldLog = true) {
const def = OPERATIONS[type] || extra;
const node = {
id: extra.id || `machine-${state.nextNode++}`, type, x, y,
name: extra.name || def.name, glyph: extra.glyph || def.glyph,
description: extra.description || def.description, fixed: Boolean(extra.fixed),
source: extra.source || null, config: extra.config || defaultConfig(type),
internal: {}, live: zero(), received: zero(), el: null
};
state.nodes.set(node.id, node);
renderNode(node);
if (shouldLog) log("node_added", { node_id: node.id, operation: type, x: round(x), y: round(y), config: node.config, after_completion: state.completedTick !== null });
updateHint();
return node;
}
function renderNode(node) {
const el = document.createElement("article");
el.className = `node ${node.fixed ? "fixed" : ""} ${node.type === "bond" ? "bond" : ""}`;
el.dataset.nodeId = node.id;
el.style.left = `${node.x}px`; el.style.top = `${node.y}px`;
const hasInput = node.type !== "source";
const hasOutput = node.type !== "bond";
const config = Object.entries(OPERATIONS[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>${spec.label}</label><select data-config="${key}">${options}</select>`;
}).join("");
el.innerHTML = `
<header><i>${node.glyph}</i><span>${node.name}</span>${node.fixed ? "" : '<button class="delete" aria-label="Remove">×</button>'}</header>
${hasInput ? '<button class="port input" aria-label="Flux input"></button>' : ""}
${hasOutput ? '<button class="port output" aria-label="Flux output"></button>' : ""}
<div class="body"><div class="description">${node.description}</div>
<div class="live"><div class="orb"></div><div class="numbers"><b>quiet</b><span>x 0.00 · y 0.00</span></div></div>
${config ? `<div class="config">${config}</div>` : ""}
</div>`;
nodesEl.append(el); node.el = el;
el.addEventListener("pointerdown", event => { if (!event.target.closest("button,select")) selectNode(node.id); });
el.querySelector(".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("select").forEach(select => select.addEventListener("change", event => {
const key = event.target.dataset.config, before = node.config[key];
node.config[key] = Number(event.target.value); node.internal = {};
log("node_configured", { node_id: node.id, parameter: key, before, after: node.config[key], after_completion: state.completedTick !== null });
}));
installDrag(node, el.querySelector("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 = { mx: event.clientX, my: event.clientY, x: node.x, y: node.y }; let moved = false;
const move = e => {
const rect = networkEl.getBoundingClientRect();
node.x = Math.max(0, Math.min(rect.width - node.el.offsetWidth, start.x + e.clientX - start.mx));
node.y = Math.max(0, Math.min(rect.height - node.el.offsetHeight, start.y + e.clientY - start.my));
moved ||= Math.abs(e.clientX - start.mx) + Math.abs(e.clientY - start.my) > 3;
node.el.style.left = `${node.x}px`; node.el.style.top = `${node.y}px`; renderWires();
};
const up = () => { handle.removeEventListener("pointermove", move); handle.removeEventListener("pointerup", up); if (moved) log("node_moved", { node_id: node.id, x: round(node.x), y: round(node.y) }); };
handle.addEventListener("pointermove", move); handle.addEventListener("pointerup", up);
});
}
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); clearSelection(); updateHint();
log("node_removed", { node_id: id, operation: node.type, removed_links: connected.length, after_completion: state.completedTick !== null });
}
function beginWire(event, id) {
event.stopPropagation(); cancelWire(); state.pendingFrom = id;
state.nodes.get(id).el.querySelector(".output").classList.add("pending"); pendingEl.hidden = false;
const cancel = e => { if (!e.target.closest?.(".port.input")) cancelWire(); };
state.cancelWireListener = cancel; window.addEventListener("click", cancel, { capture: true, once: true });
}
function finishWire(event, to) {
if (!state.pendingFrom) return;
event.stopPropagation(); const from = state.pendingFrom; cancelWire();
if (state.edges.values().some(edge => edge.from === from && edge.to === to)) return toast("That link already exists.");
addEdge(from, to);
}
function cancelWire() {
state.nodes.get(state.pendingFrom)?.el.querySelector(".output")?.classList.remove("pending");
state.pendingFrom = null; pendingEl.hidden = true; pendingEl.setAttribute("d", "");
}
function portPoint(node, output) { return { x: node.x + (output ? node.el.offsetWidth : 0), y: node.y + 52 }; }
function curve(a, b) {
const bend = Math.max(42, Math.min(170, Math.abs(b.x - a.x) * .48)); const sign = b.x >= a.x ? 1 : -1;
return `M ${a.x} ${a.y} C ${a.x + bend * sign} ${a.y}, ${b.x - bend * sign} ${b.y}, ${b.x} ${b.y}`;
}
function addEdge(from, to, shouldLog = true) {
const edge = { id: `link-${state.nextEdge++}`, from, to, signal: zero(), el: null };
const closesCycle = hasPath(to, from); state.edges.set(edge.id, edge); renderWires();
if (shouldLog) log("link_added", { link_id: edge.id, from, to, closes_cycle: closesCycle, after_completion: state.completedTick !== null });
}
function hasPath(start, target) {
const seen = new Set(), stack = [start];
while (stack.length) { const here = stack.pop(); if (here === target) return true; if (seen.has(here)) continue; seen.add(here); for (const e of state.edges.values()) if (e.from === here) stack.push(e.to); }
return false;
}
function removeEdge(id, shouldLog = true) {
const edge = state.edges.get(id); if (!edge) return;
edge.el?.remove(); state.edges.delete(id); clearSelection();
if (shouldLog) log("link_removed", { link_id: id, from: edge.from, to: edge.to, after_completion: state.completedTick !== null });
}
function renderWires() {
for (const edge of state.edges.values()) {
if (!edge.el) {
const ns = "http://www.w3.org/2000/svg", group = document.createElementNS(ns, "g"); group.classList.add("wire"); group.dataset.edgeId = edge.id;
for (const cls of ["wire-hit", "wire-base", "wire-flow"]) { const path = document.createElementNS(ns, "path"); path.classList.add(cls); group.append(path); }
group.querySelector(".wire-hit").addEventListener("click", e => { e.stopPropagation(); selectEdge(edge.id); });
group.querySelector(".wire-hit").addEventListener("contextmenu", e => { e.preventDefault(); removeEdge(edge.id); });
wiresEl.append(group); edge.el = group;
}
const from = state.nodes.get(edge.from), to = state.nodes.get(edge.to); if (!from || !to) continue;
const d = curve(portPoint(from, true), portPoint(to, false)); edge.el.querySelectorAll("path").forEach(p => p.setAttribute("d", d)); updateEdge(edge);
}
}
function updateEdge(edge) {
if (!edge.el) return; const power = magnitude(edge.signal);
edge.el.classList.toggle("active", power > .01); edge.el.classList.toggle("selected", state.selectedEdge === edge.id);
edge.el.style.setProperty("--color", fluxColor(edge.signal)); edge.el.style.setProperty("--opacity", String(Math.min(.95, .35 + power * .25))); edge.el.style.setProperty("--width", `${Math.min(7, 1.5 + power)}px`);
}
function selectNode(id) { clearSelection(); state.selectedNode = id; state.nodes.get(id)?.el.classList.add("selected"); $("#selection").textContent = `${state.nodes.get(id)?.name} · ${id}`; }
function selectEdge(id) { clearSelection(); state.selectedEdge = id; state.edges.get(id)?.el.classList.add("selected"); const e = state.edges.get(id); $("#selection").textContent = `${state.nodes.get(e.from).name}${state.nodes.get(e.to).name}`; }
function clearSelection() { state.nodes.get(state.selectedNode)?.el.classList.remove("selected"); state.edges.get(state.selectedEdge)?.el.classList.remove("selected"); state.selectedNode = null; state.selectedEdge = null; $("#selection").textContent = "nothing selected"; }
function operationOutput(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 = scale(add(node.internal.stored || zero(), input), .99);
if (magnitude(node.internal.stored) >= node.config.threshold) { const out = node.internal.stored; node.internal.stored = zero(); log("vessel_released", { node_id: node.id, flux: vecLog(out) }); return out; }
return zero();
}
case "valve": return magnitude(input) >= node.config.threshold ? input : zero();
case "polarizer": { const axis = polar(1, node.config.axis); return scale(axis, input.x * axis.x + input.y * axis.y); }
default: return zero();
}
}
function step() {
state.tick++;
const next = new Map(), outputs = new Map();
for (const node of state.nodes.values()) {
const input = sum(state.mailbox.get(node.id) || []); node.received = input; let output = zero();
if (node.type === "source") { if (state.tick % node.source.period === 0) output = polar(node.source.strength, node.source.phase); }
else if (node.type === "bond") output = zero();
else output = operationOutput(node, input);
node.live = node.type === "bond" ? input : output; outputs.set(node.id, output); updateNode(node);
}
for (const node of state.nodes.values()) {
const outgoing = [...state.edges.values()].filter(edge => edge.from === node.id), share = outgoing.length ? scale(outputs.get(node.id) || zero(), .92 / outgoing.length) : zero();
for (const edge of outgoing) { edge.signal = share; if (!next.has(edge.to)) next.set(edge.to, []); next.get(edge.to).push({ ...share }); updateEdge(edge); }
}
state.mailbox = next;
const control = state.nodes.get("bond").received;
worldStep(control); drawArena(); $("#tick").textContent = `tick ${state.tick}`;
if (state.tick % 20 === 0) log("trajectory_snapshot", { position: { x: round(state.world.x), y: round(state.world.y) }, velocity: { x: round(state.world.vx), y: round(state.world.vy) }, incoming: vecLog(control), stars: [state.world.starA, state.world.starB], dock_stability: state.world.dockTicks, nodes: state.nodes.size, links: state.edges.size });
}
function updateNode(node) {
const v = node.live, power = magnitude(v), orb = node.el.querySelector(".orb");
orb.style.setProperty("--angle", `${-phase(v)}deg`); orb.style.setProperty("--flux", fluxColor(v));
node.el.querySelector(".numbers b").textContent = power < .01 ? "quiet" : `${power.toFixed(2)} · ${phaseName(v)}`;
node.el.querySelector(".numbers span").textContent = `x ${v.x.toFixed(2)} · y ${v.y.toFixed(2)}`;
}
function resetWorld(reason = "manual") {
const c = CONSTELLATIONS[state.constellation], previous = state.world;
state.world = { x: c.start[0], y: c.start[1], vx: 0, vy: 0, trail: [], starA: false, starB: false, dockTicks: 0, impacts: 0 };
state.completedTick = null; updateWorldStatus(); drawArena();
log("familiar_reset", { reason, previous: previous ? { x: round(previous.x), y: round(previous.y), stars: [previous.starA, previous.starB], dock_ticks: previous.dockTicks } : null, constellation: state.constellation });
}
function worldStep(control) {
const w = state.world, c = CONSTELLATIONS[state.constellation];
w.vx += control.x * .014; w.vy -= control.y * .014;
const speed = Math.hypot(w.vx, w.vy), cap = .045;
if (speed > cap) { w.vx *= cap / speed; w.vy *= cap / speed; }
w.vx *= .88; w.vy *= .88;
const old = { x: w.x, y: w.y }; w.x += w.vx; w.y += w.vy;
let impact = false;
if (w.x < .035 || w.x > .965) { w.x = Math.max(.035, Math.min(.965, w.x)); w.vx *= -.52; impact = true; }
if (w.y < .045 || w.y > .955) { w.y = Math.max(.045, Math.min(.955, w.y)); w.vy *= -.52; impact = true; }
for (const obstacle of c.obstacles) {
const pad = .022;
if (w.x > obstacle.x - pad && w.x < obstacle.x + obstacle.w + pad && w.y > obstacle.y - pad && w.y < obstacle.y + obstacle.h + pad) {
const enteredX = old.x <= obstacle.x - pad || old.x >= obstacle.x + obstacle.w + pad;
if (enteredX) { w.x = old.x; w.vx *= -.5; } else { w.y = old.y; w.vy *= -.5; }
impact = true;
}
}
if (impact) { w.impacts++; log("familiar_impact", { x: round(w.x), y: round(w.y), speed: round(Math.hypot(w.vx, w.vy)), impacts: w.impacts }); }
w.trail.push({ x: w.x, y: w.y, color: fluxColor(control), active: magnitude(control) > .01 }); if (w.trail.length > 520) w.trail.shift();
if (!w.starA && distance(w, c.starA) < .052) { w.starA = true; log("star_woken", { star: "amber", position: { x: round(w.x), y: round(w.y) } }); toast("The amber star remembers your path."); }
if (!w.starB && distance(w, c.starB) < .052) { w.starB = true; log("star_woken", { star: "violet", position: { x: round(w.x), y: round(w.y) } }); toast("The violet star is awake."); }
const inDock = distance(w, c.dock) < .065, slow = Math.hypot(w.vx, w.vy) < .0045;
if (w.starA && w.starB && inDock && slow) w.dockTicks++; else if (!inDock || !slow) w.dockTicks = 0;
if (w.dockTicks === 14 && state.completedTick === null) {
state.completedTick = state.tick; log("constellation_completed", { constellation: state.constellation, ticks: state.tick, impacts: w.impacts, nodes: state.nodes.size, links: state.edges.size });
toast("The familiar is settled. Refine this route, try another constellation, or test an idea of your own.");
}
updateWorldStatus(control);
}
const distance = (w, point) => Math.hypot(w.x - point[0], w.y - point[1]);
function updateWorldStatus(control = zero()) {
const w = state.world;
$("#star-a-status").classList.toggle("done", w.starA); $("#star-a-status").textContent = `${w.starA ? "●" : "○"} ${w.starA ? "Amber star awake" : "Wake the amber star"}`;
$("#star-b-status").classList.toggle("done", w.starB); $("#star-b-status").textContent = `${w.starB ? "●" : "○"} ${w.starB ? "Violet star awake" : "Wake the violet star"}`;
const docked = state.completedTick !== null; $("#dock-status").classList.toggle("done", docked); $("#dock-status").textContent = `${docked ? "● Familiar settled" : "○ Settle in the cradle"}`;
$("#motion-readout").textContent = `position ${w.x.toFixed(2)}, ${w.y.toFixed(2)} · speed ${Math.hypot(w.vx, w.vy).toFixed(3)} · impacts ${w.impacts}`;
$("#flux-readout").textContent = magnitude(control) < .01 ? "incoming: quiet" : `incoming: ${magnitude(control).toFixed(2)} ${phaseName(control)}`;
$("#dock-readout").textContent = `cradle stability ${w.dockTicks} / 14`;
}
function resizeCanvas() {
const rect = canvas.getBoundingClientRect(), ratio = Math.min(2, devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(rect.width * ratio)); canvas.height = Math.max(1, Math.round(rect.height * ratio)); ctx.setTransform(ratio, 0, 0, ratio, 0, 0); drawArena();
}
function drawArena() {
const rect = canvas.getBoundingClientRect(), W = rect.width, H = rect.height; if (!state.world || !W || !H) return;
const w = state.world, c = CONSTELLATIONS[state.constellation]; ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "#080c13"; ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = "rgba(103,140,171,.1)"; ctx.lineWidth = 1;
for (let x = 20; x < W; x += 28) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
for (let y = 20; y < H; y += 28) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
for (const obstacle of c.obstacles) {
const x = obstacle.x * W, y = obstacle.y * H, width = obstacle.w * W, height = obstacle.h * H;
ctx.fillStyle = "#182333"; ctx.strokeStyle = "#40516a"; ctx.lineWidth = 2; ctx.fillRect(x, y, width, height); ctx.strokeRect(x, y, width, height);
ctx.strokeStyle = "rgba(112,234,214,.12)"; for (let d = -height; d < width; d += 12) { ctx.beginPath(); ctx.moveTo(x + Math.max(0, d), y + Math.max(0, -d)); ctx.lineTo(x + Math.min(width, d + height), y + Math.min(height, height + d)); ctx.stroke(); }
}
drawDock(c.dock, W, H, w.dockTicks);
drawStar(c.starA, W, H, "#f5bf64", w.starA, "A"); drawStar(c.starB, W, H, "#c39aff", w.starB, "B");
if (w.trail.length > 1) {
ctx.lineWidth = 2; ctx.lineCap = "round"; ctx.lineJoin = "round";
for (let i = 1; i < w.trail.length; i++) { const a = w.trail[i - 1], b = w.trail[i]; ctx.globalAlpha = .12 + .55 * i / w.trail.length; ctx.strokeStyle = b.active ? b.color : "#5d7188"; ctx.beginPath(); ctx.moveTo(a.x * W, a.y * H); ctx.lineTo(b.x * W, b.y * H); ctx.stroke(); }
ctx.globalAlpha = 1;
}
const x = w.x * W, y = w.y * H, speed = Math.hypot(w.vx, w.vy);
const glow = ctx.createRadialGradient(x, y, 2, x, y, 23); glow.addColorStop(0, "rgba(112,234,214,.8)"); glow.addColorStop(1, "rgba(112,234,214,0)"); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(x, y, 23, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#e9fff9"; ctx.strokeStyle = "#70ead6"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x, y - 10); ctx.lineTo(x + 9, y + 7); ctx.lineTo(x - 9, y + 7); ctx.closePath(); ctx.fill(); ctx.stroke();
if (speed > .0005) { ctx.strokeStyle = "rgba(112,234,214,.8)"; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + w.vx * W * 6, y + w.vy * H * 6); ctx.stroke(); }
}
function drawStar(point, W, H, color, awake, label) {
const x = point[0] * W, y = point[1] * H; ctx.save(); ctx.translate(x, y); ctx.strokeStyle = color; ctx.fillStyle = awake ? color : "#111823"; ctx.shadowColor = color; ctx.shadowBlur = awake ? 18 : 4; ctx.lineWidth = 2; ctx.beginPath();
for (let i = 0; i < 10; i++) { const r = i % 2 ? 7 : 14, a = -Math.PI / 2 + i * Math.PI / 5; const px = Math.cos(a) * r, py = Math.sin(a) * r; i ? ctx.lineTo(px, py) : ctx.moveTo(px, py); } ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.shadowBlur = 0; ctx.fillStyle = awake ? "#07100f" : color; ctx.font = "bold 8px monospace"; ctx.textAlign = "center"; ctx.fillText(label, 0, 3); ctx.restore();
}
function drawDock(point, W, H, ticks) {
const x = point[0] * W, y = point[1] * H; ctx.strokeStyle = ticks ? "#70ead6" : "#60748c"; ctx.lineWidth = 2; ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.arc(x, y, Math.min(W, H) * .065, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = `rgba(112,234,214,${.03 + ticks / 70})`; ctx.fill(); ctx.fillStyle = "#8fa0b6"; ctx.font = "9px monospace"; ctx.textAlign = "center"; ctx.fillText("CRADLE", x, y + 3);
}
function place(type) {
const rect = networkEl.getBoundingClientRect(), count = [...state.nodes.values()].filter(n => !n.fixed).length;
const x = Math.max(190, Math.min(rect.width - 330, rect.width * .4 + (count % 2) * 25)); const y = 30 + (count * 100) % Math.max(120, rect.height - 140);
selectNode(addNode(type, x, y).id);
}
function initializeUI() {
for (const [type, def] of Object.entries(OPERATIONS)) { const button = document.createElement("button"); button.innerHTML = `<em>${def.glyph}</em><strong>${def.name}</strong><span>${def.short}</span>`; button.title = def.description; button.addEventListener("click", () => place(type)); $("#palette").append(button); }
const rect = networkEl.getBoundingClientRect(), top = 32, bottom = Math.max(top, rect.height - 112), gap = Math.min(137, (bottom - top) / 2);
SOURCES.forEach((source, i) => addNode("source", 24, top + gap * i, { ...source, fixed: true, source: { phase: source.phase, strength: source.strength, period: source.period } }, false));
addNode("bond", Math.max(210, rect.width - 178), top + gap, { id: "bond", name: "Familiar bond", glyph: "△", description: "Every arrival becomes an impulse in the physical field.", fixed: true }, false);
renderWires(); resetWorld("session_start"); resizeCanvas();
}
function updateHint() { networkEl.classList.toggle("has-machine", [...state.nodes.values()].some(n => !n.fixed)); }
function clearNetwork() {
const nodeCount = [...state.nodes.values()].filter(n => !n.fixed).length, linkCount = state.edges.size;
for (const n of [...state.nodes.values()]) if (!n.fixed) removeNode(n.id);
for (const e of [...state.edges.values()]) removeEdge(e.id, false);
state.mailbox.clear(); log("network_cleared", { nodes: nodeCount, links: linkCount }); toast("Network cleared. The familiar and event log were kept.");
}
function setRunning(value) { state.running = value; $("#run").textContent = value ? "Pause" : "Run"; $("#run").classList.toggle("primary", value); clearInterval(state.timer); if (value) state.timer = setInterval(step, state.speed); }
function toast(message) { const el = $("#toast"); el.textContent = message; el.classList.add("show"); clearTimeout(state.toastTimer); state.toastTimer = setTimeout(() => el.classList.remove("show"), 3600); }
async function exportLog() {
const button = $("#export"), filename = `flux-familiar-${state.session}.jsonl`;
log("log_exported", { events_before_export: state.logs.length, completed: state.completedTick !== null, 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(); log("log_saved", { path: result.path, events: result.events }); toast(`Saved directly to ${result.path}.`);
} catch (error) {
log("log_save_failed", { error: String(error) }); const blob = new Blob([state.logs.join("\n") + "\n"], { type: "application/x-ndjson" }), anchor = document.createElement("a");
anchor.href = URL.createObjectURL(blob); anchor.download = filename; anchor.click(); setTimeout(() => URL.revokeObjectURL(anchor.href), 500); toast("Direct save failed; downloaded the JSONL instead.");
} finally { button.disabled = false; button.textContent = "Save JSONL"; }
}
$("#run").addEventListener("click", () => { setRunning(!state.running); log(state.running ? "simulation_resumed" : "simulation_paused"); });
$("#step").addEventListener("click", () => { if (state.running) setRunning(false); step(); log("simulation_stepped"); });
$("#tempo").addEventListener("input", e => { const before = state.speed; state.speed = Number(e.target.value); setRunning(state.running); log("tempo_changed", { before_ms: before, after_ms: state.speed }); });
$("#reset-familiar").addEventListener("click", () => resetWorld("manual"));
$("#clear-network").addEventListener("click", clearNetwork);
$("#export").addEventListener("click", exportLog);
$("#new-constellation").addEventListener("click", () => { state.constellation = (state.constellation + 1) % CONSTELLATIONS.length; $("#constellation-name").textContent = CONSTELLATIONS[state.constellation].name; resetWorld("new_constellation"); log("constellation_changed", { constellation: state.constellation }); });
networkEl.addEventListener("pointermove", e => { if (!state.pendingFrom) return; const rect = networkEl.getBoundingClientRect(); pendingEl.setAttribute("d", curve(portPoint(state.nodes.get(state.pendingFrom), true), { x: e.clientX - rect.left, y: e.clientY - rect.top })); });
networkEl.addEventListener("click", e => { if (e.target === networkEl || e.target.classList.contains("grid")) clearSelection(); });
window.addEventListener("keydown", e => { if (!["Delete", "Backspace"].includes(e.key) || e.target.matches("select,input")) return; if (state.selectedEdge) removeEdge(state.selectedEdge); else if (state.selectedNode) removeNode(state.selectedNode); });
new ResizeObserver(() => { renderWires(); resizeCanvas(); }).observe(canvas);
document.addEventListener("visibilitychange", () => log(document.hidden ? "page_hidden" : "page_visible"));
log("session_started", { viewport: { width: innerWidth, height: innerHeight }, constellation: 0 });
initializeUI(); $("#constellation-name").textContent = CONSTELLATIONS[0].name; setRunning(true);
})();

View file

@ -0,0 +1,95 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flux Familiar — Experiment 001</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="topbar">
<div class="title"><span>EXPERIMENT 001</span><h1>Flux Familiar</h1></div>
<div class="controls">
<button id="run" class="primary">Pause</button>
<button id="step">Step</button>
<label>Tempo <input id="tempo" type="range" min="100" max="650" value="260" step="10"></label>
<span id="tick">tick 0</span>
<button id="reset-familiar">Reset familiar</button>
<button id="clear-network">Clear network</button>
<button id="export">Save JSONL</button>
</div>
</header>
<main>
<aside>
<section class="prompt">
<h2>Teach it a route</h2>
<p>Wake both stars, then settle inside the cradle nearly motionless.</p>
<p>The bond turns every arriving flux vector into a physical impulse. The trail is its memory.</p>
</section>
<section>
<div class="heading"><h2>Operations</h2><span>click to place</span></div>
<div id="palette" class="palette"></div>
</section>
<details open>
<summary>Flux laws</summary>
<ul>
<li>Phase is literal direction in the arena.</li>
<li>Strength is impulse magnitude.</li>
<li>Every link takes one tick, keeps 92%, and divides at forks.</li>
<li>Arrivals add as vectors. Opposites cancel.</li>
<li>The familiar keeps momentum, drags, and rebounds from walls.</li>
</ul>
</details>
<section class="status-panel">
<div class="heading"><h2>Constellation</h2><span id="constellation-name">one</span></div>
<div id="star-a-status" class="status">○ Wake the amber star</div>
<div id="star-b-status" class="status">○ Wake the violet star</div>
<div id="dock-status" class="status">○ Settle in the cradle</div>
<button id="new-constellation">Change constellation</button>
<p class="fine">Changing constellation keeps your network but changes the physical problem.</p>
</section>
<section class="help">
<h2>Bench controls</h2>
<p>Drag headers. Click an output port, then an input port, to weave. Click a link or node and press <kbd>Delete</kbd>. Right-click removes a link.</p>
</section>
</aside>
<div class="workarea">
<section class="network-panel">
<div class="panel-label"><span>SIGNAL BENCH</span><b id="selection">nothing selected</b></div>
<div id="network" tabindex="0">
<div class="grid"></div>
<svg id="wire-layer">
<defs><filter id="glow"><feGaussianBlur stdDeviation="3" result="b"></feGaussianBlur><feMerge><feMergeNode in="b"></feMergeNode><feMergeNode in="SourceGraphic"></feMergeNode></feMerge></filter></defs>
<g id="wires"></g>
<path id="pending" hidden></path>
</svg>
<div id="nodes"></div>
<div class="network-hint">Connect a well through operations to the familiar bond.</div>
</div>
</section>
<section class="arena-panel">
<div class="panel-label">
<span>PHYSICAL FIELD</span>
<b><i class="solid"></i> x flux <i class="dotted"></i> y flux</b>
</div>
<canvas id="arena"></canvas>
<div class="readouts">
<span id="motion-readout">speed 0.00</span>
<span id="flux-readout">incoming: quiet</span>
<span id="dock-readout">cradle stability 0 / 14</span>
</div>
</section>
</div>
</main>
<div id="toast" role="status"></div>
<script src="app.js"></script>
</body>
</html>

View file

@ -0,0 +1,117 @@
:root {
color-scheme: dark;
--bg: #070a10;
--panel: #0e1520;
--panel2: #151e2c;
--line: #29374b;
--text: #e7edf6;
--muted: #8c9aaf;
--aqua: #70ead6;
--amber: #f5bf64;
--violet: #c39aff;
--danger: #ff7281;
--node-w: 154px;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body { overflow: hidden; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
button, select, input { font: inherit; }
button { border: 1px solid var(--line); border-radius: 7px; padding: 7px 10px; color: var(--text); background: #162131; cursor: pointer; }
button:hover:not(:disabled) { border-color: #566b84; background: #1d2b3e; }
button.primary { border-color: var(--aqua); background: var(--aqua); color: #07110f; font-weight: 800; }
button:disabled { opacity: .38; cursor: not-allowed; }
h1, h2, p { margin-top: 0; }
h1 { margin: 0; font-size: 20px; }
h2 { margin-bottom: 8px; font-size: 12px; letter-spacing: .1em; text-transform: uppercase; }
p { font-size: 12px; line-height: 1.46; }
.topbar { height: 62px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 17px; border-bottom: 1px solid var(--line); background: #0b1018; }
.title span, .panel-label span { display: block; color: var(--aqua); font-size: 9px; font-weight: 850; letter-spacing: .16em; }
.controls { display: flex; align-items: center; gap: 7px; }
.controls label { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 10px; }
.controls input { direction: rtl; width: 85px; accent-color: var(--aqua); }
#tick { min-width: 57px; color: var(--muted); font: 10px ui-monospace, monospace; }
main { height: calc(100vh - 62px); display: grid; grid-template-columns: 260px minmax(0, 1fr); }
aside { overflow-y: auto; padding: 14px; border-right: 1px solid var(--line); background: #0b1018; }
aside section, aside details { margin-bottom: 17px; }
.prompt { padding: 12px; border: 1px solid #34635d; border-radius: 9px; background: linear-gradient(145deg, #10241f, #101722); }
.prompt p:last-child { margin-bottom: 0; color: var(--muted); }
.heading { display: flex; align-items: baseline; justify-content: space-between; }
.heading span { color: var(--muted); font-size: 9px; letter-spacing: .09em; text-transform: uppercase; }
.palette { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.palette button { min-height: 55px; padding: 8px; text-align: left; }
.palette strong { display: block; font-size: 11px; }
.palette em { float: right; color: var(--aqua); font: normal 17px ui-monospace, monospace; }
.palette span { display: block; margin-top: 3px; color: var(--muted); font-size: 9px; }
details { border: 1px solid var(--line); border-radius: 7px; background: var(--panel); }
summary { padding: 9px 10px; cursor: pointer; font-size: 11px; font-weight: 750; }
details ul { margin: 0; padding: 0 12px 10px 27px; color: var(--muted); font-size: 10px; line-height: 1.4; }
details li + li { margin-top: 4px; }
.status { margin-bottom: 6px; padding: 7px 9px; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); background: var(--panel); font-size: 10px; }
.status.done { color: var(--aqua); border-color: #39736a; background: #10231f; }
.status-panel button { width: 100%; margin-top: 3px; }
.fine, .help p { margin-top: 7px; color: var(--muted); font-size: 10px; }
kbd { padding: 1px 4px; border: 1px solid #46556a; border-bottom-width: 2px; border-radius: 4px; color: var(--text); }
.workarea { min-width: 0; display: grid; grid-template-columns: minmax(460px, 54%) minmax(360px, 46%); }
.network-panel, .arena-panel { min-width: 0; display: grid; grid-template-rows: 34px minmax(0, 1fr); background: #080d15; }
.network-panel { border-right: 1px solid var(--line); }
.panel-label { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-bottom: 1px solid var(--line); background: #0d131d; }
.panel-label b { color: var(--muted); font: 9px ui-monospace, monospace; font-weight: 400; }
.solid, .dotted { display: inline-block; width: 12px; margin: 0 3px 2px 7px; border-top: 2px solid var(--aqua); }
.dotted { border-top-style: dotted; }
#network { position: relative; min-height: 0; overflow: hidden; outline: none; }
.grid { position: absolute; inset: 0; background-image: linear-gradient(rgba(102,139,168,.07) 1px, transparent 1px), linear-gradient(90deg, rgba(102,139,168,.07) 1px, transparent 1px), radial-gradient(circle, rgba(38,85,92,.12), transparent 65%); background-size: 22px 22px, 22px 22px, 100% 100%; }
#wire-layer, #nodes { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
#wire-layer { overflow: visible; }
.network-hint { position: absolute; left: 50%; top: 52%; translate: -50% -50%; width: 230px; color: #536277; text-align: center; font-size: 10px; pointer-events: none; }
#pending { fill: none; stroke: var(--aqua); stroke-width: 2; stroke-dasharray: 5 5; }
.wire-hit { fill: none; stroke: transparent; stroke-width: 15; pointer-events: stroke; cursor: pointer; }
.wire-base { fill: none; stroke: #334359; stroke-width: 2; }
.wire-flow { fill: none; stroke: var(--aqua); stroke-width: 3; stroke-dasharray: 3 11; opacity: 0; filter: url(#glow); animation: flow .55s linear infinite; }
.wire.active .wire-flow { opacity: var(--opacity); stroke: var(--color); stroke-width: var(--width); }
.wire.selected .wire-base { stroke: white; stroke-width: 4; }
@keyframes flow { to { stroke-dashoffset: -14; } }
.node { position: absolute; width: var(--node-w); min-height: 91px; border: 1px solid #34445a; border-radius: 8px; background: linear-gradient(145deg, #182332, #111824); box-shadow: 0 8px 22px rgba(0,0,0,.3); pointer-events: auto; user-select: none; }
.node.fixed { background: linear-gradient(145deg, #152932, #111923); }
.node.bond { border-color: var(--violet); background: linear-gradient(145deg, #251f34, #121824); }
.node.selected { border-color: white; box-shadow: 0 0 0 1px white, 0 8px 25px #0008; }
.node header { height: 29px; display: flex; align-items: center; gap: 6px; padding: 0 8px; border-bottom: 1px solid #2c3a4d; cursor: grab; font-size: 10px; font-weight: 750; }
.node header i { color: var(--aqua); font: normal 14px ui-monospace, monospace; }
.node header span { flex: 1; }
.delete { width: 18px; height: 18px; padding: 0; border: 0; background: transparent; color: var(--muted); }
.delete:hover { color: var(--danger); background: #3a2028 !important; }
.body { padding: 7px 9px 8px; }
.description { min-height: 23px; color: var(--muted); font-size: 8px; line-height: 1.3; }
.live { display: flex; gap: 6px; align-items: center; margin-top: 4px; }
.orb { --angle: 0deg; width: 24px; height: 24px; position: relative; border: 1px solid #405168; border-radius: 50%; background: radial-gradient(circle, color-mix(in srgb, var(--flux) 45%, transparent), transparent 65%); }
.orb::after { content: ""; position: absolute; left: 11px; top: 10px; width: 9px; height: 2px; transform-origin: 1px 1px; rotate: var(--angle); background: var(--flux); box-shadow: 0 0 5px var(--flux); }
.numbers b, .numbers span { display: block; font: 8px ui-monospace, monospace; }
.numbers span { margin-top: 2px; color: var(--muted); }
.config { display: grid; grid-template-columns: auto 1fr; gap: 3px 5px; align-items: center; margin-top: 5px; padding-top: 5px; border-top: 1px solid #293648; }
.config label { color: var(--muted); font-size: 8px; }
.config select { min-width: 0; height: 20px; border: 1px solid #34445a; border-radius: 4px; background: #0c131d; color: var(--text); font-size: 8px; }
.port { position: absolute; top: 45px; width: 14px; height: 14px; padding: 0; border: 2px solid #8999ad; border-radius: 50%; background: #101823; z-index: 3; cursor: crosshair; }
.port.input { left: -8px; }
.port.output { right: -8px; }
.port:hover, .port.pending { scale: 1.25; border-color: white; background: var(--aqua); box-shadow: 0 0 8px var(--aqua); }
.arena-panel { position: relative; grid-template-rows: 34px minmax(0, 1fr) 32px; }
#arena { width: 100%; height: 100%; display: block; background: #080c13; }
.readouts { display: flex; align-items: center; justify-content: space-between; padding: 0 10px; border-top: 1px solid var(--line); background: #0d131d; color: var(--muted); font: 9px ui-monospace, monospace; }
#toast { position: fixed; z-index: 20; left: 50%; bottom: 18px; translate: -50% 12px; max-width: 430px; padding: 10px 14px; border: 1px solid #47726b; border-radius: 8px; background: #10231f; opacity: 0; pointer-events: none; transition: .2s; font-size: 11px; box-shadow: 0 10px 30px #0009; }
#toast.show { opacity: 1; translate: -50% 0; }
@media (max-width: 980px) {
body { overflow: auto; }
.topbar { height: auto; align-items: flex-start; flex-direction: column; }
.controls { flex-wrap: wrap; }
main { height: auto; grid-template-columns: 1fr; }
aside { max-height: none; }
.workarea { height: 850px; grid-template-columns: 1fr; grid-template-rows: 480px 520px; }
.network-panel { border-right: 0; border-bottom: 1px solid var(--line); }
}

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