Initial Commit
This commit is contained in:
commit
35f3810632
90 changed files with 29267 additions and 0 deletions
70
experiments/000_magic_language/README.md
Normal file
70
experiments/000_magic_language/README.md
Normal 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.
|
||||
26
experiments/000_magic_language/hypothesis.md
Normal file
26
experiments/000_magic_language/hypothesis.md
Normal 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.
|
||||
|
||||
696
experiments/000_magic_language/prototype/app.js
vendored
Normal file
696
experiments/000_magic_language/prototype/app.js
vendored
Normal 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();
|
||||
})();
|
||||
101
experiments/000_magic_language/prototype/index.html
Normal file
101
experiments/000_magic_language/prototype/index.html
Normal 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>
|
||||
186
experiments/000_magic_language/prototype/style.css
Normal file
186
experiments/000_magic_language/prototype/style.css
Normal 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; }
|
||||
}
|
||||
1
experiments/000_magic_language/results/.gitkeep
Normal file
1
experiments/000_magic_language/results/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
5
experiments/000_magic_language/run.sh
Executable file
5
experiments/000_magic_language/run.sh
Executable 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
|
||||
72
experiments/001_flux_familiar/README.md
Normal file
72
experiments/001_flux_familiar/README.md
Normal 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.
|
||||
28
experiments/001_flux_familiar/hypothesis.md
Normal file
28
experiments/001_flux_familiar/hypothesis.md
Normal 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.
|
||||
|
||||
407
experiments/001_flux_familiar/prototype/app.js
vendored
Normal file
407
experiments/001_flux_familiar/prototype/app.js
vendored
Normal 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);
|
||||
})();
|
||||
95
experiments/001_flux_familiar/prototype/index.html
Normal file
95
experiments/001_flux_familiar/prototype/index.html
Normal 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>
|
||||
117
experiments/001_flux_familiar/prototype/style.css
Normal file
117
experiments/001_flux_familiar/prototype/style.css
Normal 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); }
|
||||
}
|
||||
1
experiments/001_flux_familiar/results/.gitkeep
Normal file
1
experiments/001_flux_familiar/results/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
5
experiments/001_flux_familiar/run.sh
Executable file
5
experiments/001_flux_familiar/run.sh
Executable 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
|
||||
73
experiments/002_conversion_breach/README.md
Normal file
73
experiments/002_conversion_breach/README.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Experiment 002 — Conversion Breach
|
||||
|
||||
## Question
|
||||
|
||||
Are stable magical/physical laws interesting when the player manipulates them directly during an ongoing activity, and does external pressure make understanding instrumentally valuable?
|
||||
|
||||
## Hypotheses
|
||||
|
||||
The leading hypotheses are that direct agency is preferable to autonomous programming and that optimization needs practical stakes. The competing explanation is that action merely masks another shallow interaction. See [`hypothesis.md`](hypothesis.md).
|
||||
|
||||
## Difference From Previous Experiment
|
||||
|
||||
- No graph, wiring, autonomous controller, or static route specification.
|
||||
- The player directly extracts, stores, injects, and converts energy while moving and aiming.
|
||||
- Temperature consistently affects speed, brittleness, and instability; momentum, mass, walls, and collisions determine damage.
|
||||
- Calm and breach variants use the same verbs, entities, and laws. Only threat pressure changes.
|
||||
- Periodic calibration opportunities offer chosen improvements, not random verbs or permission unlocks.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Support for the new model would look like deliberate exploitation: freezing before a wall impact, overheating a construct before driving it into a group, using one enemy as fuel or a projectile, or choosing upgrades to deepen a discovered tactic. The pressure hypothesis is supported only if breach mode makes these decisions more meaningful without eliminating experimentation.
|
||||
|
||||
## Controls
|
||||
|
||||
- All manipulation verbs exist from the start in both variants.
|
||||
- The initial field layout and construct properties are deterministic.
|
||||
- Calm and breach modes share physics; constructs only pursue and injure the player in breach mode.
|
||||
- Switching variants starts a fresh baseline so calibration upgrades cannot contaminate the comparison; resetting within one variant preserves its calibration.
|
||||
- No authored spell recipes, equipment drops, or part unlocks.
|
||||
|
||||
## How To Run
|
||||
|
||||
From this directory:
|
||||
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
|
||||
Then open <http://localhost:8000>.
|
||||
|
||||
## What To Pay Attention To
|
||||
|
||||
- Try both **Calm** and **Breach**; which one would you rather continue?
|
||||
- Did you discover or deliberately exploit an interaction?
|
||||
- Did improvement choices create a tactic you cared about, or only numbers?
|
||||
|
||||
Use **Export JSONL** when finished.
|
||||
|
||||
## Result
|
||||
|
||||
The first playtest produced the first sustained engagement in the program: about 16 logged minutes, approximately 14 in Breach, ten waves cleared, 74 defeats, tactical adaptation, and strongly directed upgrade choices.
|
||||
|
||||
The player changed from indiscriminate heat extraction to conditional cooling based on target position and remaining fuel. They voluntarily tested player ramming. They chose conversion 19 of 24 times, capacity four times, and transfer once.
|
||||
|
||||
The test also contained major contamination: an input-device conflict during the first run, viewport failure, through-wall extraction, crowded wall phasing, an unreadable resource deadlock, exponential conversion scaling, and an invalid Calm comparison. The player now has a mouse, so the first issue is historical rather than a priority for the next run. See [`results/0732452b-analysis.md`](results/0732452b-analysis.md).
|
||||
|
||||
## Interpretation
|
||||
|
||||
This is positive evidence that a coupled property/resource system with practical stakes can provoke learning and tactical revision. It is not sufficient evidence that action, pressure, or direct control is the root cause.
|
||||
|
||||
The most promising observed interaction was not simply “freeze enemies.” Extracting heat simultaneously created ammunition, altered pursuit speed, enabled brittleness, and depleted the remaining world resource. That coupling caused the player to revise when and where they extracted.
|
||||
|
||||
However, the dominant outcome was wall impact, conversion upgrades became exponentially overwhelming, and Calm did not share wave goals. The result could be driven by progression, increasing power, conventional action feedback, a safe wall exploit, or the property tradeoff. These remain competing explanations.
|
||||
|
||||
## Next Best Experiment
|
||||
|
||||
Corrective revision 2 gives Calm the same waves, populations, layouts, and calibration as Breach; only pursuit and contact damage differ. It also fixes full-screen containment and sidebar scrolling, blocks transfer and discharge through obstacles, strengthens obstacle collision resolution, clarifies brittle/unstable impact feedback, adds click-to-move and alternate pointer controls, and marks new logs with `prototype_revision: 2`.
|
||||
|
||||
Revision 2 passed JavaScript syntax checking and real-browser layout checks at 1920×1080 and 1365×768. Browser input validation confirmed click-to-move and logged displacement, both pointer transfer modes, variant switching, inspection of an unobstructed target, rejection of a wall-occluded target, and revision-tagged telemetry.
|
||||
|
||||
The revision-2 playtest lasted about 19:39: roughly 2:55 in Calm and 16:44 in Breach. Calm received a genuine attempt, while Breach reached wave 29. Input was easier with a mouse and upgrade selection broadened substantially. Nevertheless, wall-directed impulse remained the durable answer, escalating statistics eventually let the default heat supply clear enemies, and the player reported little new to say. See [`results/bb30ddfc-analysis.md`](results/bb30ddfc-analysis.md).
|
||||
|
||||
Move to an exploratory Experiment 003 rather than further tuning 002. Test a directly piloted constructed artifact under qualitatively changing physical requirements, without numerical power escalation.
|
||||
34
experiments/002_conversion_breach/hypothesis.md
Normal file
34
experiments/002_conversion_breach/hypothesis.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Experiment 002 Hypothesis
|
||||
|
||||
## Primary hypothesis
|
||||
|
||||
Direct control plus practical stakes will turn systems knowledge from implementation work into personal agency. The player will care about understanding because it produces survival, power, improvisation, or recoverable chaos.
|
||||
|
||||
## Controlled comparison
|
||||
|
||||
- **Calm:** constructs have the same properties and physics but do not pursue or damage the player.
|
||||
- **Breach:** constructs pursue, collide with, and damage the player; waves continue.
|
||||
|
||||
## Competing explanations
|
||||
|
||||
1. Direct systemic manipulation is interesting in both modes; pressure changes pacing but is not necessary.
|
||||
2. The system becomes meaningful only under pressure.
|
||||
3. Pressure interrupts experimentation; calm is more promising.
|
||||
4. Action feedback is momentarily stimulating but the laws remain shallow.
|
||||
5. The mechanics feel like ordinary combat abilities rather than magical engineering.
|
||||
|
||||
## Strong evidence
|
||||
|
||||
- A discovery produces a second voluntary test.
|
||||
- The player deliberately engineers a state before acting: brittle, unstable, massive, aligned, or grouped.
|
||||
- A chosen calibration supports a self-selected tactic.
|
||||
- The player recovers from a bad state using the system rather than merely kiting.
|
||||
- The calm/breach preference has a clear reason tied to reasoning or emotional stakes.
|
||||
|
||||
## Weak or negative evidence
|
||||
|
||||
- The player spams one verb without tracking properties.
|
||||
- Conventional movement and aiming do all the enjoyable work.
|
||||
- Temperature states are merely status effects with obvious prescribed combos.
|
||||
- Calibration is generic numerical progression without changing decisions.
|
||||
|
||||
479
experiments/002_conversion_breach/prototype/app.js
vendored
Normal file
479
experiments/002_conversion_breach/prototype/app.js
vendored
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
(() => {
|
||||
"use strict";
|
||||
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const canvas = $("#field");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const CLASS = {
|
||||
ember: { name: "Ember construct", mass: .72, heat: 92, hp: 52, color: "#ff8b59", radius: .021, chase: .16 },
|
||||
ballast: { name: "Ballast construct", mass: 2.25, heat: 52, hp: 105, color: "#d6c7a7", radius: .031, chase: .085 },
|
||||
rime: { name: "Rime construct", mass: .95, heat: 22, hp: 62, color: "#75c9ff", radius: .023, chase: .12 }
|
||||
};
|
||||
|
||||
const BASE_SPAWNS = [
|
||||
["ember", .77, .22], ["ballast", .75, .74], ["rime", .39, .55],
|
||||
["ember", .61, .40], ["rime", .85, .49], ["ballast", .31, .22]
|
||||
];
|
||||
|
||||
const OBSTACLES = [
|
||||
{ x: .47, y: .15, w: .065, h: .27 },
|
||||
{ x: .47, y: .59, w: .065, h: .27 }
|
||||
];
|
||||
|
||||
const state = {
|
||||
mode: "calm", paused: false, upgrading: false, dead: false,
|
||||
entities: [], nextEntity: 1, keys: new Set(),
|
||||
mouse: { x: .7, y: .5, inside: false, left: false, shift: false },
|
||||
thermalMode: "extract", moveTarget: null,
|
||||
player: { x: .16, y: .5, health: 100, maxHealth: 100, reservoir: 40, capacity: 100, transfer: 27, conversion: 1, moveSpeed: .31 },
|
||||
wave: 1, kills: 0, nextCalibration: 3, target: null,
|
||||
effects: [], lastFrame: performance.now(), snapshotClock: 0,
|
||||
session: crypto.randomUUID ? crypto.randomUUID() : `session-${Date.now()}`,
|
||||
started: Date.now(), logs: [], tethering: null
|
||||
};
|
||||
|
||||
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
|
||||
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const round = value => Math.round(value * 1000) / 1000;
|
||||
|
||||
function log(type, data = {}) {
|
||||
const event = { schema: 1, experiment: "002_conversion_breach", prototype_revision: 2, session_id: state.session, elapsed_ms: Date.now() - state.started, mode: state.mode, wave: state.wave, type, ...data };
|
||||
state.logs.push(JSON.stringify(event));
|
||||
try { localStorage.setItem("conversion-breach-last-jsonl", state.logs.join("\n") + "\n"); } catch (_) {}
|
||||
}
|
||||
|
||||
function spawn(kind, x, y) {
|
||||
const def = CLASS[kind];
|
||||
return {
|
||||
id: `construct-${state.nextEntity++}`, kind, x, y, vx: 0, vy: 0,
|
||||
heat: def.heat, hp: def.hp, maxHp: def.hp, mass: def.mass, radius: def.radius,
|
||||
alive: true, state: thermalState(def.heat), lastThermalState: thermalState(def.heat),
|
||||
impactCooldown: 0, contactCooldown: 0, flash: 0
|
||||
};
|
||||
}
|
||||
|
||||
function resetField(reason = "manual") {
|
||||
state.entities = BASE_SPAWNS.map(([kind, x, y], index) => {
|
||||
const spread = state.wave > 1 ? Math.min(.08, (state.wave - 1) * .012) : 0;
|
||||
return spawn(kind, clamp(x + (index % 2 ? spread : -spread), .08, .92), y);
|
||||
});
|
||||
if (state.wave >= 2) state.entities.push(spawn(state.wave % 2 ? "ember" : "rime", .91, .14));
|
||||
state.player.x = .16; state.player.y = .5;
|
||||
state.player.health = reason === "wave_cleared" ? Math.min(state.player.maxHealth, state.player.health + 20) : state.player.maxHealth;
|
||||
state.player.reservoir = reason === "wave_cleared" ? Math.min(state.player.capacity, state.player.reservoir + 20) : Math.min(state.player.capacity, 40);
|
||||
state.dead = false; state.target = null; state.effects = []; state.moveTarget = null;
|
||||
$("#pause").textContent = state.paused ? "Resume" : "Pause";
|
||||
updateHUD(); log("field_reset", { reason, constructs: state.entities.map(e => ({ id: e.id, kind: e.kind, heat: e.heat, mass: e.mass })) });
|
||||
}
|
||||
|
||||
function thermalState(heat) {
|
||||
if (heat < 15) return "brittle";
|
||||
if (heat > 105) return "unstable";
|
||||
if (heat < 35) return "cold";
|
||||
if (heat > 75) return "hot";
|
||||
return "stable";
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
if (state.mode === mode) return;
|
||||
const before = state.mode; state.mode = mode; state.wave = 1; state.kills = 0; state.nextCalibration = 3;
|
||||
state.player.capacity = 100; state.player.transfer = 27; state.player.conversion = 1;
|
||||
$("#calm-mode").classList.toggle("selected", mode === "calm");
|
||||
$("#breach-mode").classList.toggle("selected", mode === "breach");
|
||||
$("#variant-title").textContent = mode === "calm" ? "Calm field" : "Breach field";
|
||||
$("#variant-copy").textContent = mode === "calm" ? "Full waves and calibration, but constructs do not pursue or injure you." : "The same waves and calibration, with pursuit and player damage added.";
|
||||
resetField("mode_switch"); log("mode_changed", { before, after: mode, calibration_reset: true }); toast(mode === "calm" ? "Calm field: the laws remain, the threat does not." : "Breach field: same laws, active threat.");
|
||||
}
|
||||
|
||||
function canvasMetrics() {
|
||||
const rect = canvas.getBoundingClientRect(); return { rect, width: rect.width, height: rect.height, scale: Math.min(rect.width, rect.height) };
|
||||
}
|
||||
|
||||
function resizeCanvas() {
|
||||
const { width, height } = canvasMetrics(), ratio = Math.min(2, devicePixelRatio || 1);
|
||||
canvas.width = Math.max(1, Math.round(width * ratio)); canvas.height = Math.max(1, Math.round(height * ratio));
|
||||
ctx.setTransform(ratio, 0, 0, ratio, 0, 0); draw();
|
||||
}
|
||||
|
||||
function selectTarget() {
|
||||
if (!state.mouse.inside) return null;
|
||||
const { width, height } = canvasMetrics(); let best = null, bestPixels = Infinity;
|
||||
for (const entity of state.entities) {
|
||||
if (!entity.alive || dist(entity, state.player) > .36 || lineBlocked(state.player, entity)) continue;
|
||||
const pixels = Math.hypot((entity.x - state.mouse.x) * width, (entity.y - state.mouse.y) * height);
|
||||
if (pixels < bestPixels && pixels < 62) { best = entity; bestPixels = pixels; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
if (state.paused || state.upgrading || state.dead) return;
|
||||
movePlayer(dt);
|
||||
state.target = selectTarget();
|
||||
applyTether(dt);
|
||||
updateEntities(dt);
|
||||
collideEntities(dt);
|
||||
updateEffects(dt);
|
||||
state.snapshotClock += dt;
|
||||
if (state.snapshotClock >= 5) {
|
||||
state.snapshotClock = 0;
|
||||
log("field_snapshot", {
|
||||
player: { x: round(state.player.x), y: round(state.player.y), health: round(state.player.health), reservoir: round(state.player.reservoir) },
|
||||
alive: state.entities.filter(e => e.alive).map(e => ({ id: e.id, kind: e.kind, heat: round(e.heat), hp: round(e.hp), state: e.state }))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function movePlayer(dt) {
|
||||
let dx = 0, dy = 0;
|
||||
if (state.keys.has("KeyW")) dy--;
|
||||
if (state.keys.has("KeyS")) dy++;
|
||||
if (state.keys.has("KeyA")) dx--;
|
||||
if (state.keys.has("KeyD")) dx++;
|
||||
if (dx || dy) state.moveTarget = null;
|
||||
if (!dx && !dy && state.moveTarget) {
|
||||
dx = state.moveTarget.x - state.player.x; dy = state.moveTarget.y - state.player.y;
|
||||
const remaining = Math.hypot(dx, dy);
|
||||
if (remaining < .012) { state.moveTarget = null; return; }
|
||||
dx /= remaining; dy /= remaining;
|
||||
}
|
||||
if (!dx && !dy) return;
|
||||
const length = Math.hypot(dx, dy); dx /= length; dy /= length;
|
||||
const old = { x: state.player.x, y: state.player.y };
|
||||
state.player.x = clamp(state.player.x + dx * state.player.moveSpeed * dt, .025, .975);
|
||||
state.player.y = clamp(state.player.y + dy * state.player.moveSpeed * dt, .035, .965);
|
||||
if (insideObstacle(state.player, .018)) { state.player.x = old.x; state.player.y = old.y; }
|
||||
}
|
||||
|
||||
function applyTether(dt) {
|
||||
const target = state.target;
|
||||
if (!state.mouse.left || !target) {
|
||||
if (state.tethering) { log("tether_ended", state.tethering); state.tethering = null; }
|
||||
return;
|
||||
}
|
||||
const inject = state.thermalMode === "inject" || state.keys.has("ShiftLeft") || state.keys.has("ShiftRight");
|
||||
const direction = inject ? "inject" : "extract";
|
||||
if (!state.tethering || state.tethering.target_id !== target.id || state.tethering.direction !== direction) {
|
||||
if (state.tethering) log("tether_ended", state.tethering);
|
||||
state.tethering = { target_id: target.id, direction, start_heat: round(target.heat), start_reservoir: round(state.player.reservoir) };
|
||||
log("tether_started", state.tethering);
|
||||
}
|
||||
const amount = state.player.transfer * dt;
|
||||
if (inject) {
|
||||
const moved = Math.min(amount, state.player.reservoir, 130 - target.heat);
|
||||
state.player.reservoir -= moved; target.heat += moved;
|
||||
} else {
|
||||
const moved = Math.min(amount, target.heat, state.player.capacity - state.player.reservoir);
|
||||
target.heat -= moved; state.player.reservoir += moved;
|
||||
}
|
||||
target.flash = .08;
|
||||
}
|
||||
|
||||
function updateEntities(dt) {
|
||||
for (const entity of state.entities) {
|
||||
if (!entity.alive) continue;
|
||||
entity.impactCooldown = Math.max(0, entity.impactCooldown - dt); entity.contactCooldown = Math.max(0, entity.contactCooldown - dt); entity.flash = Math.max(0, entity.flash - dt);
|
||||
entity.heat += (50 - entity.heat) * .004 * dt;
|
||||
entity.state = thermalState(entity.heat);
|
||||
if (entity.state !== entity.lastThermalState) {
|
||||
log("thermal_state_changed", { target_id: entity.id, kind: entity.kind, before: entity.lastThermalState, after: entity.state, heat: round(entity.heat) });
|
||||
if (["brittle", "unstable"].includes(entity.state)) toast(`${CLASS[entity.kind].name} became ${entity.state}.`);
|
||||
entity.lastThermalState = entity.state;
|
||||
}
|
||||
if (state.mode === "breach") {
|
||||
const dx = state.player.x - entity.x, dy = state.player.y - entity.y, length = Math.max(.001, Math.hypot(dx, dy));
|
||||
const thermalMobility = entity.heat < 15 ? .16 : clamp((entity.heat + 12) / 72, .3, 1.45);
|
||||
const acceleration = CLASS[entity.kind].chase * thermalMobility / Math.sqrt(entity.mass);
|
||||
entity.vx += dx / length * acceleration * dt; entity.vy += dy / length * acceleration * dt;
|
||||
}
|
||||
const drag = Math.pow(.72, dt); entity.vx *= drag; entity.vy *= drag;
|
||||
const speed = Math.hypot(entity.vx, entity.vy), cap = entity.state === "brittle" ? .13 : .48;
|
||||
if (speed > cap) { entity.vx *= cap / speed; entity.vy *= cap / speed; }
|
||||
const impactSpeed = Math.hypot(entity.vx, entity.vy);
|
||||
entity.x += entity.vx * dt; entity.y += entity.vy * dt;
|
||||
let hit = false;
|
||||
if (entity.x < entity.radius || entity.x > 1 - entity.radius) { entity.x = clamp(entity.x, entity.radius, 1 - entity.radius); entity.vx *= -.56; hit = true; }
|
||||
if (entity.y < entity.radius || entity.y > 1 - entity.radius) { entity.y = clamp(entity.y, entity.radius, 1 - entity.radius); entity.vy *= -.56; hit = true; }
|
||||
if (resolveObstacle(entity)) hit = true;
|
||||
if (hit) applyImpact(entity, impactSpeed, "wall");
|
||||
if (state.mode === "breach" && dist(entity, state.player) < entity.radius + .022 && entity.contactCooldown <= 0) {
|
||||
const damage = entity.kind === "ballast" ? 9 : 6; state.player.health = Math.max(0, state.player.health - damage); entity.contactCooldown = .8;
|
||||
const awayX = entity.x - state.player.x, awayY = entity.y - state.player.y, len = Math.max(.001, Math.hypot(awayX, awayY)); entity.vx += awayX / len * .18 / entity.mass; entity.vy += awayY / len * .18 / entity.mass;
|
||||
log("player_damaged", { source_id: entity.id, amount: damage, health: round(state.player.health) });
|
||||
if (state.player.health <= 0) collapseField();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function insideObstacle(point, radius) {
|
||||
return OBSTACLES.some(o => point.x > o.x - radius && point.x < o.x + o.w + radius && point.y > o.y - radius && point.y < o.y + o.h + radius);
|
||||
}
|
||||
|
||||
function lineBlocked(a, b) {
|
||||
const samples = Math.max(8, Math.ceil(dist(a, b) * 80));
|
||||
for (let i = 1; i < samples; i++) {
|
||||
const t = i / samples;
|
||||
if (insideObstacle({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }, .003)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveObstacle(entity) {
|
||||
let collided = false;
|
||||
for (const o of OBSTACLES) {
|
||||
const left = o.x - entity.radius, right = o.x + o.w + entity.radius;
|
||||
const top = o.y - entity.radius, bottom = o.y + o.h + entity.radius;
|
||||
if (entity.x <= left || entity.x >= right || entity.y <= top || entity.y >= bottom) continue;
|
||||
collided = true;
|
||||
const sides = [
|
||||
{ distance: entity.x - left, side: "left" },
|
||||
{ distance: right - entity.x, side: "right" },
|
||||
{ distance: entity.y - top, side: "top" },
|
||||
{ distance: bottom - entity.y, side: "bottom" }
|
||||
].sort((a, b) => a.distance - b.distance);
|
||||
if (sides[0].side === "left") { entity.x = left - .0001; if (entity.vx > 0) entity.vx *= -.56; }
|
||||
if (sides[0].side === "right") { entity.x = right + .0001; if (entity.vx < 0) entity.vx *= -.56; }
|
||||
if (sides[0].side === "top") { entity.y = top - .0001; if (entity.vy > 0) entity.vy *= -.56; }
|
||||
if (sides[0].side === "bottom") { entity.y = bottom + .0001; if (entity.vy < 0) entity.vy *= -.56; }
|
||||
}
|
||||
return collided;
|
||||
}
|
||||
|
||||
function collideEntities(dt) {
|
||||
const alive = state.entities.filter(e => e.alive);
|
||||
for (let i = 0; i < alive.length; i++) for (let j = i + 1; j < alive.length; j++) {
|
||||
const a = alive[i], b = alive[j], dx = b.x - a.x, dy = b.y - a.y, distance = Math.max(.0001, Math.hypot(dx, dy)), min = a.radius + b.radius;
|
||||
if (distance >= min) continue;
|
||||
const nx = dx / distance, ny = dy / distance, overlap = min - distance, total = a.mass + b.mass;
|
||||
a.x -= nx * overlap * b.mass / total; a.y -= ny * overlap * b.mass / total; b.x += nx * overlap * a.mass / total; b.y += ny * overlap * a.mass / total;
|
||||
resolveObstacle(a); resolveObstacle(b);
|
||||
const relative = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
|
||||
if (relative < 0) {
|
||||
const impulse = -(1.35 * relative) / (1 / a.mass + 1 / b.mass);
|
||||
a.vx -= impulse * nx / a.mass; a.vy -= impulse * ny / a.mass; b.vx += impulse * nx / b.mass; b.vy += impulse * ny / b.mass;
|
||||
const impact = Math.abs(relative); applyImpact(a, impact * b.mass / total, b.id); applyImpact(b, impact * a.mass / total, a.id);
|
||||
}
|
||||
const heatFlow = (a.heat - b.heat) * Math.min(.25, dt * .7); a.heat -= heatFlow; b.heat += heatFlow;
|
||||
}
|
||||
}
|
||||
|
||||
function applyImpact(entity, speed, source) {
|
||||
if (!entity.alive || entity.impactCooldown > 0 || speed < .075) return;
|
||||
let damage = Math.max(0, speed - .06) * entity.mass * 115;
|
||||
if (entity.state === "brittle") damage *= 13;
|
||||
if (damage > 1) {
|
||||
entity.hp -= damage; entity.flash = .13; entity.impactCooldown = .08;
|
||||
log("construct_impact", { target_id: entity.id, source, speed: round(speed), state: entity.state, damage: round(damage), hp: round(entity.hp) });
|
||||
}
|
||||
if (entity.state === "unstable" && speed > .105) explode(entity);
|
||||
if (entity.hp <= 0) destroy(entity, source);
|
||||
}
|
||||
|
||||
function explode(entity) {
|
||||
if (!entity.alive || entity.heat < 105) return;
|
||||
entity.heat = 62; entity.hp -= 18;
|
||||
state.effects.push({ type: "ring", x: entity.x, y: entity.y, radius: 0, life: .5, maxLife: .5, color: "#ff8b59" });
|
||||
let affected = 0;
|
||||
for (const other of state.entities) {
|
||||
if (!other.alive || other === entity) continue;
|
||||
const dx = other.x - entity.x, dy = other.y - entity.y, d = Math.hypot(dx, dy);
|
||||
if (d > .22 || d < .001) continue;
|
||||
const power = (1 - d / .22); other.vx += dx / d * .32 * power / other.mass; other.vy += dy / d * .32 * power / other.mass; other.heat = Math.min(130, other.heat + 22 * power); other.hp -= 8 * power; affected++;
|
||||
if (other.hp <= 0) destroy(other, `explosion:${entity.id}`);
|
||||
}
|
||||
log("unstable_release", { source_id: entity.id, affected }); toast(`Unstable release propagated to ${affected} construct${affected === 1 ? "" : "s"}.`);
|
||||
if (entity.hp <= 0) destroy(entity, "unstable_release");
|
||||
}
|
||||
|
||||
function destroy(entity, source) {
|
||||
if (!entity.alive) return; entity.alive = false; state.kills++; state.player.reservoir = Math.min(state.player.capacity, state.player.reservoir + 6);
|
||||
state.effects.push({ type: "burst", x: entity.x, y: entity.y, radius: 0, life: .42, maxLife: .42, color: CLASS[entity.kind].color });
|
||||
log("construct_destroyed", { target_id: entity.id, kind: entity.kind, source, state: entity.state, heat: round(entity.heat), total: state.kills });
|
||||
if (state.kills >= state.nextCalibration) { state.nextCalibration += 3; openCalibration(); }
|
||||
if (!state.entities.some(e => e.alive)) {
|
||||
state.wave++; resetField("wave_cleared");
|
||||
toast(`${state.mode === "breach" ? "Breach" : "Calm"} wave ${state.wave}: calibration persists, composition changes.`);
|
||||
}
|
||||
}
|
||||
|
||||
function discharge() {
|
||||
if (state.paused || state.upgrading || state.dead || state.player.reservoir < 5) { if (state.player.reservoir < 5) toast("Not enough stored heat to convert."); return; }
|
||||
const dx = state.mouse.x - state.player.x, dy = state.mouse.y - state.player.y, length = Math.max(.001, Math.hypot(dx, dy)), nx = dx / length, ny = dy / length;
|
||||
const spend = Math.min(28, state.player.reservoir); state.player.reservoir -= spend;
|
||||
let affected = 0;
|
||||
for (const entity of state.entities) {
|
||||
if (!entity.alive || lineBlocked(state.player, entity)) continue;
|
||||
const ex = entity.x - state.player.x, ey = entity.y - state.player.y, d = Math.hypot(ex, ey); if (d > .34 || d < .001) continue;
|
||||
const alignment = (ex / d) * nx + (ey / d) * ny; if (alignment < .72) continue;
|
||||
const falloff = .45 + .55 * (1 - d / .34), impulse = .43 * (spend / 28) * state.player.conversion * falloff / entity.mass;
|
||||
entity.vx += nx * impulse; entity.vy += ny * impulse; affected++;
|
||||
}
|
||||
state.effects.push({ type: "cone", x: state.player.x, y: state.player.y, angle: Math.atan2(ny, nx), radius: 0, life: .18, maxLife: .18, color: "#f5bf64" });
|
||||
log("kinetic_discharge", { spend: round(spend), direction: { x: round(nx), y: round(ny) }, affected, conversion: round(state.player.conversion) });
|
||||
}
|
||||
|
||||
function openCalibration() {
|
||||
state.upgrading = true;
|
||||
const choices = [
|
||||
{ id: "capacity", name: "Deeper reservoir", description: "+35 maximum storage. Existing heat is retained.", apply: () => { state.player.capacity += 35; } },
|
||||
{ id: "transfer", name: "Wider conduit", description: "+30% extraction and injection rate.", apply: () => { state.player.transfer *= 1.3; } },
|
||||
{ id: "conversion", name: "Sharper conversion", description: "+28% kinetic impulse per unit of heat.", apply: () => { state.player.conversion *= 1.28; } }
|
||||
];
|
||||
const holder = $("#upgrade-choices"); holder.innerHTML = "";
|
||||
choices.forEach(choice => { const button = document.createElement("button"); button.innerHTML = `<b>${choice.name}</b><span>${choice.description}</span>`; button.addEventListener("click", () => { choice.apply(); state.upgrading = false; $("#upgrade-modal").classList.add("hidden"); log("calibration_chosen", { choice: choice.id, capacity: state.player.capacity, transfer: round(state.player.transfer), conversion: round(state.player.conversion) }); toast(`${choice.name} calibrated.`); }); holder.append(button); });
|
||||
$("#upgrade-modal").classList.remove("hidden"); log("calibration_offered", { opportunity: Math.floor(state.kills / 3) });
|
||||
}
|
||||
|
||||
function collapseField() {
|
||||
state.dead = true; log("field_collapsed", { wave: state.wave, kills: state.kills }); toast("Field integrity collapsed. Reset or compare the calm variant.");
|
||||
}
|
||||
|
||||
function updateEffects(dt) {
|
||||
for (const effect of state.effects) { effect.life -= dt; effect.radius += dt * (effect.type === "cone" ? .9 : .45); }
|
||||
state.effects = state.effects.filter(effect => effect.life > 0);
|
||||
}
|
||||
|
||||
function heatColor(heat) {
|
||||
if (heat <= 50) { const t = clamp(heat / 50, 0, 1); return `rgb(${Math.round(90 + 150 * t)},${Math.round(174 + 54 * t)},${Math.round(255 - 25 * t)})`; }
|
||||
const t = clamp((heat - 50) / 80, 0, 1); return `rgb(${Math.round(240 + 15 * t)},${Math.round(228 - 105 * t)},${Math.round(230 - 150 * t)})`;
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const { width: W, height: H, scale: S } = canvasMetrics(); if (!W || !H) return;
|
||||
ctx.clearRect(0, 0, W, H); ctx.fillStyle = "#080c12"; ctx.fillRect(0, 0, W, H);
|
||||
ctx.strokeStyle = "rgba(104,139,170,.09)"; ctx.lineWidth = 1;
|
||||
for (let x = 0; x < W; x += 28) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
|
||||
for (let y = 0; y < H; y += 28) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
|
||||
drawObstacles(W, H);
|
||||
if (state.mouse.left && state.target) drawTether(W, H);
|
||||
for (const effect of state.effects) drawEffect(effect, W, H, S);
|
||||
for (const entity of state.entities) if (entity.alive) drawEntity(entity, W, H, S);
|
||||
drawPlayer(W, H, S); updateTargetCard(); updateHUD();
|
||||
}
|
||||
|
||||
function drawObstacles(W, H) {
|
||||
for (const o of OBSTACLES) {
|
||||
ctx.fillStyle = "#172231"; ctx.strokeStyle = "#40516a"; ctx.lineWidth = 2; ctx.fillRect(o.x * W, o.y * H, o.w * W, o.h * H); ctx.strokeRect(o.x * W, o.y * H, o.w * W, o.h * H);
|
||||
ctx.strokeStyle = "rgba(113,234,215,.13)"; for (let y = o.y * H + 7; y < (o.y + o.h) * H; y += 12) { ctx.beginPath(); ctx.moveTo(o.x * W, y); ctx.lineTo((o.x + o.w) * W, y + 7); ctx.stroke(); }
|
||||
}
|
||||
}
|
||||
|
||||
function drawEntity(entity, W, H, S) {
|
||||
const x = entity.x * W, y = entity.y * H, r = entity.radius * S, color = heatColor(entity.heat);
|
||||
if (entity === state.target) { ctx.strokeStyle = "#fff"; ctx.lineWidth = 1.5; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.arc(x, y, r + 9, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); }
|
||||
ctx.save(); ctx.translate(x, y); ctx.shadowColor = color; ctx.shadowBlur = entity.state === "unstable" ? 18 : entity.state === "brittle" ? 10 : 5; ctx.fillStyle = entity.flash ? "#fff" : color; ctx.strokeStyle = entity.state === "brittle" ? "#e8f8ff" : "#15202d"; ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
if (entity.kind === "ember") ctx.arc(0, 0, r, 0, Math.PI * 2);
|
||||
else if (entity.kind === "ballast") ctx.rect(-r, -r, r * 2, r * 2);
|
||||
else { ctx.moveTo(0, -r * 1.2); ctx.lineTo(r, r); ctx.lineTo(-r, r); ctx.closePath(); }
|
||||
ctx.fill(); ctx.stroke(); ctx.shadowBlur = 0;
|
||||
if (entity.state === "brittle") { ctx.strokeStyle = "#315b78"; ctx.beginPath(); ctx.moveTo(-r * .6, -r * .5); ctx.lineTo(r * .2, .1); ctx.lineTo(-r * .15, r * .7); ctx.stroke(); }
|
||||
if (entity.state === "unstable") { ctx.strokeStyle = "#fff0b7"; ctx.beginPath(); ctx.arc(0, 0, r * .58, 0, Math.PI * 2); ctx.stroke(); }
|
||||
ctx.restore();
|
||||
ctx.fillStyle = "#1b2736"; ctx.fillRect(x - r, y - r - 9, r * 2, 3); ctx.fillStyle = color; ctx.fillRect(x - r, y - r - 9, r * 2 * clamp(entity.heat / 130, 0, 1), 3);
|
||||
ctx.fillStyle = "#1b2736"; ctx.fillRect(x - r, y + r + 6, r * 2, 3); ctx.fillStyle = "#70ead6"; ctx.fillRect(x - r, y + r + 6, r * 2 * clamp(entity.hp / entity.maxHp, 0, 1), 3);
|
||||
}
|
||||
|
||||
function drawPlayer(W, H, S) {
|
||||
const x = state.player.x * W, y = state.player.y * H, r = .019 * S;
|
||||
const dx = state.mouse.x - state.player.x, dy = state.mouse.y - state.player.y, length = Math.max(.001, Math.hypot(dx, dy));
|
||||
ctx.strokeStyle = "rgba(113,234,215,.35)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + dx / length * 42, y + dy / length * 42); ctx.stroke();
|
||||
const glow = ctx.createRadialGradient(x, y, 2, x, y, r * 2.2); glow.addColorStop(0, "rgba(113,234,215,.8)"); glow.addColorStop(1, "rgba(113,234,215,0)"); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(x, y, r * 2.2, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = state.dead ? "#ff6f7d" : "#eafffb"; ctx.strokeStyle = "#70ead6"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x + dx / length * r, y + dy / length * r); ctx.lineTo(x - dy / length * r * .72 - dx / length * r * .65, y + dx / length * r * .72 - dy / length * r * .65); ctx.lineTo(x + dy / length * r * .72 - dx / length * r * .65, y - dx / length * r * .72 - dy / length * r * .65); ctx.closePath(); ctx.fill(); ctx.stroke();
|
||||
if (state.moveTarget) {
|
||||
ctx.strokeStyle = "rgba(113,234,215,.65)"; ctx.lineWidth = 1.5; ctx.setLineDash([3, 4]); ctx.beginPath(); ctx.arc(state.moveTarget.x * W, state.moveTarget.y * H, 8, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
function drawTether(W, H) {
|
||||
const inject = state.thermalMode === "inject" || state.keys.has("ShiftLeft") || state.keys.has("ShiftRight"), target = state.target;
|
||||
ctx.strokeStyle = inject ? "#ff9a60" : "#72cbff"; ctx.lineWidth = 3; ctx.setLineDash([4, 7]); ctx.lineDashOffset = -(performance.now() / 35) % 11; ctx.beginPath(); ctx.moveTo(state.player.x * W, state.player.y * H); ctx.lineTo(target.x * W, target.y * H); ctx.stroke(); ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
function drawEffect(effect, W, H, S) {
|
||||
const alpha = clamp(effect.life / effect.maxLife, 0, 1); ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = effect.color; ctx.lineWidth = 3;
|
||||
if (effect.type === "cone") { ctx.translate(effect.x * W, effect.y * H); ctx.rotate(effect.angle); ctx.beginPath(); ctx.moveTo(0, 0); ctx.arc(0, 0, effect.radius * S, -.55, .55); ctx.closePath(); ctx.stroke(); }
|
||||
else { ctx.beginPath(); ctx.arc(effect.x * W, effect.y * H, effect.radius * S, 0, Math.PI * 2); ctx.stroke(); }
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function updateTargetCard() {
|
||||
const entity = state.target, card = $("#target-card"); card.classList.toggle("hidden", !entity); if (!entity) return;
|
||||
const stateLabel = entity.state === "brittle" ? "brittle · impact ×13" : entity.state === "unstable" ? "unstable · impact releases" : entity.state;
|
||||
$("#target-name").textContent = CLASS[entity.kind].name; $("#target-state").textContent = stateLabel; $("#target-state").style.color = heatColor(entity.heat);
|
||||
$("#target-temp").style.width = `${clamp(entity.heat / 130 * 100, 0, 100)}%`; $("#target-temp-label").textContent = `${entity.heat.toFixed(0)}°`;
|
||||
$("#target-hp").style.width = `${clamp(entity.hp / entity.maxHp * 100, 0, 100)}%`; $("#target-hp-label").textContent = `${Math.max(0, entity.hp).toFixed(0)}`;
|
||||
$("#target-detail").textContent = `mass ${entity.mass.toFixed(2)} · speed ${Math.hypot(entity.vx, entity.vy).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function updateHUD() {
|
||||
const p = state.player;
|
||||
$("#health-fill").style.width = `${clamp(p.health / p.maxHealth * 100, 0, 100)}%`; $("#health-label").textContent = `${p.health.toFixed(0)} / ${p.maxHealth}`;
|
||||
$("#reservoir-fill").style.width = `${clamp(p.reservoir / p.capacity * 100, 0, 100)}%`; $("#reservoir-label").textContent = `${p.reservoir.toFixed(0)} / ${p.capacity}`;
|
||||
$("#wave-label").textContent = `${state.mode} wave ${state.wave}`; $("#kill-label").textContent = `${state.kills} conversion${state.kills === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function frame(now) {
|
||||
const dt = Math.min(.033, Math.max(0, (now - state.lastFrame) / 1000)); state.lastFrame = now; update(dt); draw(); requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function pointerPosition(event) {
|
||||
const rect = canvas.getBoundingClientRect(); state.mouse.x = clamp((event.clientX - rect.left) / rect.width, 0, 1); state.mouse.y = clamp((event.clientY - rect.top) / rect.height, 0, 1);
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointermove", event => { pointerPosition(event); state.mouse.inside = true; $(".aim-hint").style.opacity = "0"; });
|
||||
canvas.addEventListener("pointerenter", event => { pointerPosition(event); state.mouse.inside = true; });
|
||||
canvas.addEventListener("pointerleave", () => { state.mouse.inside = false; state.mouse.left = false; });
|
||||
canvas.addEventListener("pointerdown", event => {
|
||||
event.preventDefault(); canvas.focus(); pointerPosition(event); state.mouse.inside = true; $("#start-overlay").classList.add("hidden");
|
||||
if (event.button === 0) {
|
||||
const aimed = selectTarget();
|
||||
if (aimed) { state.target = aimed; state.mouse.left = true; state.moveTarget = null; }
|
||||
else { state.mouse.left = false; state.moveTarget = { x: state.mouse.x, y: state.mouse.y }; log("move_target_set", { x: round(state.mouse.x), y: round(state.mouse.y) }); }
|
||||
}
|
||||
if (event.button === 2) discharge();
|
||||
});
|
||||
window.addEventListener("pointerup", event => { if (event.button === 0) state.mouse.left = false; });
|
||||
canvas.addEventListener("contextmenu", event => event.preventDefault());
|
||||
window.addEventListener("keydown", event => {
|
||||
if (["KeyW", "KeyA", "KeyS", "KeyD", "ShiftLeft", "ShiftRight"].includes(event.code)) { state.keys.add(event.code); event.preventDefault(); }
|
||||
if (event.code === "Space" && !event.repeat) { event.preventDefault(); discharge(); }
|
||||
});
|
||||
window.addEventListener("keyup", event => state.keys.delete(event.code));
|
||||
window.addEventListener("blur", () => { state.keys.clear(); state.mouse.left = false; });
|
||||
$("#start-overlay").addEventListener("click", () => { $("#start-overlay").classList.add("hidden"); canvas.focus(); });
|
||||
$("#calm-mode").addEventListener("click", () => setMode("calm"));
|
||||
$("#breach-mode").addEventListener("click", () => setMode("breach"));
|
||||
$("#extract-tool").addEventListener("click", () => setThermalMode("extract"));
|
||||
$("#inject-tool").addEventListener("click", () => setThermalMode("inject"));
|
||||
$("#stop-move").addEventListener("click", () => { state.moveTarget = null; log("move_target_cleared"); });
|
||||
$("#pause").addEventListener("click", () => { state.paused = !state.paused; $("#pause").textContent = state.paused ? "Resume" : "Pause"; log(state.paused ? "paused" : "resumed"); });
|
||||
$("#reset").addEventListener("click", () => resetField("manual"));
|
||||
$("#export").addEventListener("click", async () => {
|
||||
const button = $("#export"), filename = `conversion-breach-${state.session}.jsonl`;
|
||||
log("log_exported", { events_before_export: state.logs.length, 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"; }
|
||||
});
|
||||
new ResizeObserver(resizeCanvas).observe(canvas);
|
||||
let resizeLogTimer;
|
||||
window.addEventListener("resize", () => { clearTimeout(resizeLogTimer); resizeLogTimer = setTimeout(() => log("viewport_changed", { viewport: { width: innerWidth, height: innerHeight }, canvas: { width: round(canvas.getBoundingClientRect().width), height: round(canvas.getBoundingClientRect().height) } }), 250); });
|
||||
|
||||
let toastTimer;
|
||||
function toast(message) { const el = $("#toast"); el.textContent = message; el.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(() => el.classList.remove("show"), 3400); }
|
||||
|
||||
function setThermalMode(mode) {
|
||||
const before = state.thermalMode; state.thermalMode = mode;
|
||||
$("#extract-tool").classList.toggle("selected", mode === "extract"); $("#inject-tool").classList.toggle("selected", mode === "inject");
|
||||
if (before !== mode) log("thermal_mode_changed", { before, after: mode });
|
||||
}
|
||||
|
||||
log("session_started", { viewport: { width: innerWidth, height: innerHeight } }); resetField("session_start"); resizeCanvas(); requestAnimationFrame(frame);
|
||||
})();
|
||||
102
experiments/002_conversion_breach/prototype/index.html
Normal file
102
experiments/002_conversion_breach/prototype/index.html
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Conversion Breach — Experiment 002</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="title"><span>EXPERIMENT 002</span><h1>Conversion Breach</h1></div>
|
||||
<div class="mode-switch" aria-label="Experiment variant">
|
||||
<button id="calm-mode" class="selected">Calm field</button>
|
||||
<button id="breach-mode">Breach field</button>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button id="pause">Pause</button>
|
||||
<button id="reset">Reset field</button>
|
||||
<button id="export">Save JSONL</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<aside>
|
||||
<section class="prompt">
|
||||
<h2>Manipulate, don’t assemble</h2>
|
||||
<p>Constructs carry temperature, mass, momentum, and integrity. Use the same laws in a calm field and under breach pressure.</p>
|
||||
<p class="comparison"><b>Both mode buttons start complete experimental runs</b> with waves and calibration. Calm removes pursuit and player damage; Breach adds them.</p>
|
||||
</section>
|
||||
|
||||
<section class="verbs">
|
||||
<h2>Direct operations</h2>
|
||||
<div><kbd>WASD</kbd><span class="or">or</span><kbd>Click ground</kbd><p><b>Move</b><span>Position yourself and line up impacts.</span></p></div>
|
||||
<div><kbd>LMB</kbd><p><b>Use selected transfer</b><span>Choose Extract or Inject beside the field.</span></p></div>
|
||||
<div><kbd>Shift</kbd>+<kbd>LMB</kbd><p><b>Inject heat</b><span>Return stored heat to the aimed construct.</span></p></div>
|
||||
<div><kbd>RMB</kbd><span class="or">or</span><kbd>Space</kbd><p><b>Convert to momentum</b><span>Spend stored heat toward the cursor.</span></p></div>
|
||||
</section>
|
||||
|
||||
<details open>
|
||||
<summary>Observed laws</summary>
|
||||
<ul>
|
||||
<li>Cold constructs slow down. Below 15°, they become <b>brittle</b>.</li>
|
||||
<li>Above 105°, constructs become <b>unstable</b>.</li>
|
||||
<li>Wall and construct impacts convert momentum into damage.</li>
|
||||
<li>Mass resists acceleration but increases impact energy.</li>
|
||||
<li>Brittle impacts amplify damage. Unstable impacts release heat and momentum nearby.</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<section class="legend">
|
||||
<h2>Construct classes</h2>
|
||||
<div><i class="ember"></i><p><b>Ember</b><span>hot · light · quick</span></p></div>
|
||||
<div><i class="ballast"></i><p><b>Ballast</b><span>temperate · heavy</span></p></div>
|
||||
<div><i class="rime"></i><p><b>Rime</b><span>cold · light</span></p></div>
|
||||
</section>
|
||||
|
||||
<section class="variant-note">
|
||||
<h2 id="variant-title">Calm field</h2>
|
||||
<p id="variant-copy">Full waves and calibration, but constructs do not pursue or injure you.</p>
|
||||
<small>Scroll this panel for every instruction and law.</small>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="playfield">
|
||||
<div class="hud">
|
||||
<div class="meter-block"><span>INTEGRITY</span><div class="meter"><i id="health-fill"></i></div><b id="health-label">100 / 100</b></div>
|
||||
<div class="meter-block reservoir"><span>THERMAL RESERVOIR</span><div class="meter"><i id="reservoir-fill"></i></div><b id="reservoir-label">40 / 100</b></div>
|
||||
<div class="run-state"><span id="wave-label">calm field</span><b id="kill-label">0 conversions</b></div>
|
||||
</div>
|
||||
<canvas id="field" tabindex="0"></canvas>
|
||||
<div class="pointer-tools" aria-label="Pointer controls">
|
||||
<span>LEFT CLICK TRANSFER</span>
|
||||
<button id="extract-tool" class="selected">Extract</button>
|
||||
<button id="inject-tool">Inject</button>
|
||||
<button id="stop-move">Stop moving</button>
|
||||
</div>
|
||||
<div id="target-card" class="target-card hidden">
|
||||
<b id="target-name">Ember construct</b>
|
||||
<span id="target-state">stable</span>
|
||||
<div><label>temperature</label><i class="temp-track"><em id="target-temp"></em></i><strong id="target-temp-label">50°</strong></div>
|
||||
<div><label>integrity</label><i class="hp-track"><em id="target-hp"></em></i><strong id="target-hp-label">60</strong></div>
|
||||
<small id="target-detail">mass 1.0 · speed 0.00</small>
|
||||
</div>
|
||||
<div class="aim-hint">Move the cursor near a construct to inspect and tether it.</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="upgrade-modal" class="modal hidden">
|
||||
<div class="modal-card">
|
||||
<span>CALIBRATION OPPORTUNITY</span>
|
||||
<h2>Choose where the recovered pattern goes.</h2>
|
||||
<p>The opportunity is fixed; its exact use is yours.</p>
|
||||
<div id="upgrade-choices"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="start-overlay" class="start-overlay">
|
||||
<div><span>TWO COMPLETE VARIANTS</span><b>Calm and Breach both have waves, calibration, and the same physical laws.</b><p>Calm removes pursuit and contact damage. Breach adds them. Click to begin either run.</p></div>
|
||||
</div>
|
||||
<div id="toast" role="status"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
123
experiments/002_conversion_breach/prototype/style.css
Normal file
123
experiments/002_conversion_breach/prototype/style.css
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #070a0f;
|
||||
--panel: #0d141e;
|
||||
--panel2: #151f2d;
|
||||
--line: #29384b;
|
||||
--text: #e9eef6;
|
||||
--muted: #8b9aaf;
|
||||
--aqua: #71ead7;
|
||||
--hot: #ff8b59;
|
||||
--cold: #75c9ff;
|
||||
--violet: #c29aff;
|
||||
}
|
||||
|
||||
* { 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 { border: 1px solid var(--line); border-radius: 7px; padding: 8px 11px; background: #162130; color: var(--text); font: inherit; cursor: pointer; }
|
||||
button:hover { border-color: #566a82; background: #1e2c3e; }
|
||||
button.selected { border-color: var(--aqua); background: #15332e; color: var(--aqua); }
|
||||
h1, h2, p { margin-top: 0; }
|
||||
h1 { margin: 0; font-size: 20px; }
|
||||
h2 { margin-bottom: 8px; font-size: 11px; letter-spacing: .1em; text-transform: uppercase; }
|
||||
p { line-height: 1.43; }
|
||||
|
||||
header { height: 62px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 16px; padding: 7px 16px; border-bottom: 1px solid var(--line); background: #0a1018; }
|
||||
.title span, .modal-card > span, .start-overlay span { display: block; color: var(--aqua); font-size: 9px; font-weight: 850; letter-spacing: .16em; }
|
||||
.mode-switch { display: flex; gap: 6px; }
|
||||
.top-actions { display: flex; justify-content: flex-end; gap: 6px; }
|
||||
|
||||
main { height: calc(100vh - 62px); min-height: 0; overflow: hidden; display: grid; grid-template-columns: clamp(230px, 19vw, 270px) minmax(0, 1fr); }
|
||||
aside { min-height: 0; overflow-y: scroll; scrollbar-gutter: stable; scrollbar-color: #58677a #0a1018; padding: 14px; border-right: 1px solid var(--line); background: #0a1018; }
|
||||
aside section, aside details { margin-bottom: 16px; }
|
||||
.prompt { padding: 12px; border: 1px solid #34655e; border-radius: 9px; background: linear-gradient(145deg, #10241f, #101722); }
|
||||
.prompt p { margin-bottom: 0; color: #c6d1df; font-size: 11px; }
|
||||
.prompt .comparison { margin-top: 9px; padding-top: 8px; border-top: 1px solid #31524f; color: var(--aqua); font-size: 9px; }
|
||||
.verbs > div, .legend > div { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; padding: 7px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
|
||||
kbd { flex: 0 0 auto; padding: 3px 5px; border: 1px solid #506177; border-bottom-width: 2px; border-radius: 4px; background: #182231; font: 9px ui-monospace, monospace; }
|
||||
.verbs p, .legend p { margin: 0; }
|
||||
.verbs b, .verbs span, .legend b, .legend span { display: block; }
|
||||
.verbs .or { flex: 0 0 auto; color: #607087; font-size: 8px; }
|
||||
.verbs b, .legend b { font-size: 10px; }
|
||||
.verbs span, .legend span { margin-top: 2px; 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: 10px; font-weight: 750; }
|
||||
details ul { margin: 0; padding: 0 12px 10px 27px; color: var(--muted); font-size: 9px; line-height: 1.42; }
|
||||
details li + li { margin-top: 5px; }
|
||||
.legend i { width: 17px; height: 17px; border: 2px solid currentColor; rotate: 45deg; }
|
||||
.legend .ember { color: var(--hot); border-radius: 50%; }
|
||||
.legend .ballast { color: #d1c3a4; }
|
||||
.legend .rime { color: var(--cold); clip-path: polygon(50% 0,100% 100%,0 100%); }
|
||||
.variant-note { padding: 10px; border-left: 2px solid var(--aqua); background: #101821; }
|
||||
.variant-note p { margin-bottom: 0; color: var(--muted); font-size: 10px; }
|
||||
.variant-note small { display: block; margin-top: 8px; color: #718198; font-size: 8px; }
|
||||
|
||||
.playfield { position: relative; min-width: 0; min-height: 0; height: 100%; overflow: hidden; display: grid; grid-template-rows: 52px minmax(0, 1fr); }
|
||||
.hud { display: grid; grid-template-columns: minmax(190px, 1fr) minmax(220px, 1.35fr) auto; align-items: center; gap: 18px; padding: 7px 13px; border-bottom: 1px solid var(--line); background: #0d141e; }
|
||||
.meter-block { display: grid; grid-template-columns: auto minmax(70px, 1fr) auto; align-items: center; gap: 8px; }
|
||||
.meter-block > span, .run-state span { color: var(--muted); font-size: 8px; letter-spacing: .11em; }
|
||||
.meter-block > b, .run-state b { font: 9px ui-monospace, monospace; }
|
||||
.meter { overflow: hidden; height: 8px; border: 1px solid #35455a; border-radius: 6px; background: #080c12; }
|
||||
.meter i { display: block; width: 100%; height: 100%; background: linear-gradient(90deg, #eb6371, #78e5d2); transition: width .1s; }
|
||||
.reservoir .meter i { width: 40%; background: linear-gradient(90deg, #6cc8ff, #f5bc62); }
|
||||
.run-state { min-width: 120px; text-align: right; }
|
||||
.run-state span, .run-state b { display: block; }
|
||||
.run-state b { margin-top: 3px; }
|
||||
#field { display: block; min-width: 0; min-height: 0; width: 100%; height: 100%; background: #080c12; outline: none; cursor: crosshair; }
|
||||
.pointer-tools { position: absolute; z-index: 5; left: 11px; bottom: 10px; display: flex; align-items: center; gap: 5px; padding: 6px; border: 1px solid #33455a; border-radius: 8px; background: #0c131ddd; backdrop-filter: blur(4px); }
|
||||
.pointer-tools span { margin: 0 3px; color: var(--muted); font-size: 7px; letter-spacing: .1em; }
|
||||
.pointer-tools button { padding: 5px 8px; font-size: 9px; }
|
||||
.pointer-tools button.selected { border-color: var(--aqua); background: #15332e; color: var(--aqua); }
|
||||
|
||||
.target-card { position: absolute; right: 12px; top: 64px; width: 220px; padding: 10px; border: 1px solid #3b4c62; border-radius: 8px; background: rgba(12,18,27,.91); backdrop-filter: blur(5px); pointer-events: none; transition: opacity .15s; }
|
||||
.target-card.hidden { opacity: 0; }
|
||||
.target-card > b { font-size: 11px; }
|
||||
.target-card > span { float: right; color: var(--muted); font-size: 9px; text-transform: uppercase; }
|
||||
.target-card > div { display: grid; grid-template-columns: 62px 1fr 28px; align-items: center; gap: 5px; margin-top: 8px; }
|
||||
.target-card label, .target-card strong, .target-card small { color: var(--muted); font: 8px ui-monospace, monospace; }
|
||||
.target-card strong { text-align: right; }
|
||||
.target-card small { display: block; margin-top: 8px; }
|
||||
.temp-track, .hp-track { overflow: hidden; height: 6px; border-radius: 5px; background: #253246; }
|
||||
.temp-track em, .hp-track em { display: block; width: 40%; height: 100%; background: linear-gradient(90deg, var(--cold), #d9e5eb, var(--hot)); }
|
||||
.hp-track em { width: 100%; background: var(--aqua); }
|
||||
.aim-hint { position: absolute; left: 50%; bottom: 57px; translate: -50%; padding: 6px 10px; border-radius: 6px; background: #0b111bbb; color: var(--muted); font-size: 9px; pointer-events: none; transition: opacity .3s; }
|
||||
|
||||
.modal, .start-overlay { position: fixed; z-index: 20; inset: 0; display: grid; place-items: center; background: rgba(3,6,10,.76); }
|
||||
.modal.hidden, .start-overlay.hidden { display: none; }
|
||||
.modal-card { width: min(620px, 90vw); padding: 20px; border: 1px solid #3b615e; border-radius: 10px; background: #101923; box-shadow: 0 24px 70px #000c; }
|
||||
.modal-card h2 { margin: 6px 0; font-size: 17px; text-transform: none; letter-spacing: 0; }
|
||||
.modal-card p { color: var(--muted); font-size: 11px; }
|
||||
#upgrade-choices { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; }
|
||||
#upgrade-choices button { min-height: 105px; padding: 12px; text-align: left; }
|
||||
#upgrade-choices b, #upgrade-choices span { display: block; }
|
||||
#upgrade-choices b { color: var(--aqua); font-size: 11px; }
|
||||
#upgrade-choices span { margin-top: 7px; color: var(--muted); font-size: 9px; line-height: 1.4; }
|
||||
.start-overlay { position: absolute; top: 52px; }
|
||||
.start-overlay > div { padding: 18px 23px; border: 1px solid #41645f; border-radius: 9px; background: #101923ee; text-align: center; }
|
||||
.start-overlay b, .start-overlay p { display: block; }
|
||||
.start-overlay b { margin-top: 5px; font-size: 13px; }
|
||||
.start-overlay p { margin: 7px 0 0; color: var(--muted); font-size: 10px; }
|
||||
#toast { position: fixed; z-index: 30; left: 50%; bottom: 17px; translate: -50% 12px; max-width: 450px; padding: 10px 14px; border: 1px solid #4b706a; border-radius: 8px; background: #10231f; box-shadow: 0 10px 30px #000b; opacity: 0; pointer-events: none; transition: .2s; font-size: 11px; }
|
||||
#toast.show { opacity: 1; translate: -50% 0; }
|
||||
|
||||
@media (max-width: 850px) {
|
||||
header { grid-template-columns: auto 1fr auto; gap: 8px; padding-inline: 8px; }
|
||||
.title h1 { font-size: 15px; }
|
||||
.title span { font-size: 7px; }
|
||||
header button { padding: 6px 7px; font-size: 10px; }
|
||||
main { grid-template-columns: 215px minmax(0, 1fr); }
|
||||
aside { padding: 10px; }
|
||||
.hud { grid-template-columns: 1fr 1fr; gap: 6px; height: auto; }
|
||||
.run-state { display: none; }
|
||||
.pointer-tools > span { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
body { overflow: auto; }
|
||||
header { height: auto; grid-template-columns: 1fr; }
|
||||
.top-actions { justify-content: flex-start; }
|
||||
main { height: auto; overflow: visible; grid-template-columns: 1fr; }
|
||||
aside { max-height: 430px; }
|
||||
.playfield { height: 620px; }
|
||||
}
|
||||
1
experiments/002_conversion_breach/results/.gitkeep
Normal file
1
experiments/002_conversion_breach/results/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
# Playtest Analysis — Session 0732452b
|
||||
|
||||
Date: 2026-08-16
|
||||
|
||||
Source log: `JSONL/conversion-breach-0732452b-3ce4-4329-aa69-b121180c61a8.jsonl`
|
||||
|
||||
## Observed Behavior
|
||||
|
||||
- Total logged duration: 970 seconds (about 16 minutes 10 seconds).
|
||||
- Breach events span about 14 minutes 23 seconds across two breach segments.
|
||||
- The player reached wave 11 and cleared ten complete waves.
|
||||
- 74 constructs were destroyed in Breach.
|
||||
- 67 kinetic discharges affected an average of 1.66 constructs; none affected zero targets.
|
||||
- 184 tether sessions: 139 extraction and 45 injection.
|
||||
- 25 tether sessions lasted under 150 ms, consistent with at least some control interruption or accidental taps; the log cannot prove the cause.
|
||||
- 80 thermal-state transitions, including 33 entries into brittle and only two into unstable.
|
||||
- Only one unstable release occurred.
|
||||
- 69 of 74 destroys came from wall impacts. Five came from construct collisions.
|
||||
- 19 player-damage events occurred. Thirteen were in the first logged Breach wave; only six occurred across later waves. Minimum recorded health was 22.
|
||||
- Reservoir was below 5 in 42 of 150 Breach snapshots (28%). Mean snapshot reservoir was 47.5, but later capacity upgrades inflate that mean.
|
||||
- 24 calibration choices: 19 conversion, four capacity, one transfer.
|
||||
- Final calibration values were capacity 240, transfer 35.1, conversion 85.071. Multiplicative conversion scaling became extreme.
|
||||
|
||||
## Player Report
|
||||
|
||||
- Calm was perceived as a sandbox/tutorial rather than a test and was left quickly for Breach.
|
||||
- Breach remained interesting for an estimated 5–10 minutes; the log shows longer engagement.
|
||||
- Trackpad palm rejection made keyboard-plus-pointer controls poor during this run. The player subsequently obtained a mouse, so this should not be treated as a central cause in the next comparison.
|
||||
- Initial tactic: extract as much heat as possible, then blast.
|
||||
- Revised tactic: avoid leaving the final enemy brittle and depleted unless already positioned for an immediate wall blast.
|
||||
- Capacity felt unhelpful; transfer already felt fast; most upgrades went to conversion.
|
||||
- The player tried to ram constructs after perceiving possible collision damage, but could not understand or safely reproduce it.
|
||||
- Extracting through a wall felt too safe.
|
||||
- Crowded constructs appeared to phase through walls.
|
||||
- Full-screen layout hid instructions and/or part of the field; the player could not confidently scroll. A narrower window appeared to fit better.
|
||||
|
||||
## Important Model Mismatch
|
||||
|
||||
The implementation multiplies brittle collision damage by 13. Brittle targets do not take less collision damage. The reported difficulty killing a final brittle target is more plausibly a resource-state problem: extraction removed the target's heat, the player reservoir was empty, and no other enemy remained as fuel. Cooling also reduced pursuit speed, which could make arranging an unpowered collision harder.
|
||||
|
||||
Do not “correct” the player report as if their experience were invalid. The mismatch is evidence that the causal feedback did not clearly distinguish toughness from lack of available impulse.
|
||||
|
||||
Player ramming did not deal construct damage in the implementation. The player nevertheless formed and tested that hypothesis from observed collision behavior. This is positive voluntary-experiment evidence, while the inability to read the result is a feedback/affordance failure.
|
||||
|
||||
## What This Supports
|
||||
|
||||
- A coupled tradeoff produced real adaptation: extracting heat both funded attacks and changed the remaining target/resource state.
|
||||
- Practical stakes and/or wave progression provided incentive absent from Experiments 000 and 001.
|
||||
- The player chose upgrades instrumentally around the perceived bottleneck rather than distributing them evenly.
|
||||
- Knowledge and power both increased: later waves generally produced few player hits and many were cleared much faster.
|
||||
- Visible physical laws generated at least one unrequired hypothesis (ramming).
|
||||
|
||||
## What This Does Not Establish
|
||||
|
||||
- Pressure was not isolated. Calm lacked continuing waves/progression and looked like a testing room.
|
||||
- Direct control was not isolated from action feedback, wave goals, upgrades, escalating power, and enemies.
|
||||
- The property system may still be shallow. Wall blasts dominated, brittle was common, unstable behavior was nearly absent, and conversion upgrades overwhelmed other choices.
|
||||
- Later safety may reflect learning, exponential power, through-wall extraction, collision bugs, or their combination.
|
||||
- Continued play may have been driven by wave progression/power accumulation rather than curiosity about laws.
|
||||
|
||||
## Corrective Work Before Further Comparison
|
||||
|
||||
- Make Calm a complete wave/calibration variant differing only in pursuit and player damage.
|
||||
- Clearly label Calm and Breach as two full experimental variants.
|
||||
- Add pointer-only movement and thermal-mode controls; add Space as an alternate discharge input. Retain these as general accessibility/readability improvements, but do not center the next test on trackpad friction because the player now has a mouse.
|
||||
- Make the entire field and instructions fit a full-screen viewport, with unmistakable scrolling where needed and resize telemetry.
|
||||
- Block tethering through obstacles.
|
||||
- Strengthen obstacle collision resolution to prevent clustered phasing.
|
||||
- Improve feedback that separates low target heat, low player reservoir, target integrity, and actual impact damage.
|
||||
- Preserve the current playtest build's result before using corrected behavior as new evidence.
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# Playtest Analysis — Session bb30ddfc
|
||||
|
||||
Date: 2026-08-16
|
||||
|
||||
Source log: `JSONL/conversion-breach-bb30ddfc-8e4f-4e18-bb7e-22235266d9ed.jsonl`
|
||||
|
||||
Prototype revision: 2
|
||||
|
||||
## Observed Behavior
|
||||
|
||||
- Total logged duration was 1,179 seconds, about 19 minutes 39 seconds.
|
||||
- Calm lasted about 2 minutes 55 seconds. The player destroyed 12 constructs, cleared wave 1, and defeated six of the seven constructs in wave 2 before switching.
|
||||
- Breach then lasted about 16 minutes 44 seconds. The player reached wave 29, cleared 28 waves, and destroyed 195 constructs.
|
||||
- The player made 208 kinetic discharges: 25 in Calm and 183 in Breach. Four Breach discharges affected no target.
|
||||
- There were 431 tether sessions: 343 extraction and 88 injection. Injection was more common than in the first session.
|
||||
- The pointer transfer buttons themselves were toggled only once in each direction; most injection continued to use Shift.
|
||||
- There were 69 forced calibration choices. In Breach these were capacity 33, conversion 24, and transfer 8. This is substantially broader than the first session's conversion-heavy 19/24 distribution.
|
||||
- Final Breach values were capacity 1,255, transfer 220.247, and conversion 374.144. All three upgrade dimensions became extreme.
|
||||
- 176 of 207 total defeats came from wall impacts: all 12 Calm defeats and 164 of 195 Breach defeats. Construct collisions accounted for most of the remaining non-release defeats.
|
||||
- There were 16 unstable releases, all in Breach. Twelve occurred in the middle portion of the run, but they did not displace wall impact as the dominant answer.
|
||||
- Breach produced 49 player-damage events but no collapse. Damage remained intermittent into late waves.
|
||||
- Later Breach waves often took only roughly 10–25 seconds. From wave 24 onward, conversion climbed from 40.565 to 374.144 within about 70 seconds.
|
||||
|
||||
## Player Report
|
||||
|
||||
- The player could not say much beyond the first playtest and is ready for a different experiment.
|
||||
- A mouse made control easier.
|
||||
- Easier input led them to use wider conduit and reservoir upgrades more than before.
|
||||
- Eventually the default amount of supplied heat was sufficient to blast everything away.
|
||||
- Accidental Shift plus right-click could still open the browser context menu and disrupt play.
|
||||
- Clicking interface text outside the canvas selected text. The player considers these browser-medium problems rather than central game problems.
|
||||
|
||||
## Interpretation
|
||||
|
||||
The mouse had a measurable behavioral effect, so the original input complaint was not noise. Upgrade selection diversified and injection was used more often. However, this did not generate a materially new reported experience or a new strategic layer.
|
||||
|
||||
The strongest result is not merely that conversion was overtuned. The activity offered one durable answer—acquire any heat and turn it into wall-directed impulse—and every upgrade increased the throughput or reserve of that same answer. Capacity and transfer therefore broadened the numerical build without broadening the decision structure.
|
||||
|
||||
The long session should not be read as equivalent to deep interest. Wave progression, repeated calibration interruptions, rapid power accumulation, and a desire to see the system's limit can all sustain behavior after strategic curiosity has ended. The player's explicit “can't really say much more” and readiness to move on are important counterevidence to duration as a fun proxy.
|
||||
|
||||
The matched comparison gives weak evidence that threat pressure increases continuation: Calm received almost three minutes and Breach nearly seventeen. It does not show that pressure creates deeper reasoning or greater enjoyment. Familiarity with Breach, action intensity, wave escalation, or simple challenge could explain the difference.
|
||||
|
||||
The coupled heat tradeoff mattered early in Experiment 002, but numerical escalation eventually erased it. Once a small amount of heat created overwhelming impulse, target temperature, remaining fuel, mass, position, and reservoir scarcity stopped constraining the answer. This is direct evidence of strategy crystallization inside a short run.
|
||||
|
||||
## Design Consequence
|
||||
|
||||
Do not make Experiment 003 by adding enemy types, nerfing conversion, extending waves, or offering more numerical upgrades. Those changes would tune the lifetime of the same dominant answer without testing whether another activity is fun.
|
||||
|
||||
The next experiment should remove escalating numerical power and test whether the player enjoys adapting a directly controlled constructed artifact to qualitatively changing physical requirements. This preserves instrumental building and direct agency while replacing repeated extermination waves with different problems that should demand different layouts. It also probes whether construction felt tedious in Experiment 001 because it programmed an autonomous agent, or because construction itself lacked useful leverage.
|
||||
|
||||
5
experiments/002_conversion_breach/run.sh
Executable file
5
experiments/002_conversion_breach/run.sh
Executable 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
|
||||
72
experiments/003_rig_trials/README.md
Normal file
72
experiments/003_rig_trials/README.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Experiment 003 — Rig Trials
|
||||
|
||||
## Question
|
||||
|
||||
Is modifying a physical construction enjoyable when it directly changes the body the player pilots, and do qualitatively changing requirements preserve reasoning better than escalating statistics?
|
||||
|
||||
## Hypotheses
|
||||
|
||||
The leading possibility is that Experiments 000 and 001 made construction feel like low-leverage programming, while enjoyed construction games let a design become the player's practical capability. A competing explanation is that modular construction will still collapse into obvious loadout work or a universal rig. See [`hypothesis.md`](hypothesis.md).
|
||||
|
||||
## Difference From Previous Experiment
|
||||
|
||||
- The player modifies a persistent rig rather than upgrading numeric character statistics.
|
||||
- The constructed object is controlled directly; it is not an autonomous agent.
|
||||
- Three complete trials impose different physical requirements without unlocking parts.
|
||||
- Existing construction persists when switching trials and edits are cheap.
|
||||
- Physical placement affects connectivity, thruster exhaust, tool exposure, cooling, shielding, mass, and wind response.
|
||||
- There are no enemies, defeat waves, random drops, or multiplicative progression.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Support would look like revising the rig after observing a physical weakness, retaining a useful subassembly while changing the overall layout, or testing a build idea beyond the minimum trial requirement. Completion by itself is weak evidence.
|
||||
|
||||
A universal starter layout, obvious one-part substitutions, or course execution dominating build decisions would count against the intended mechanism.
|
||||
|
||||
## Controls
|
||||
|
||||
- Every part and every trial is available immediately.
|
||||
- Part behavior is deterministic and displayed.
|
||||
- The same rig and direct controls are used across all trials.
|
||||
- Trial completion grants no statistical power.
|
||||
- Switching trials preserves the build but resets only the field state.
|
||||
- After a switch, reset, or edit, the field waits until the arena is clicked so construction is not performed under accidental trial pressure.
|
||||
|
||||
## How To Run
|
||||
|
||||
From this directory:
|
||||
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
|
||||
Then open <http://localhost:8000>.
|
||||
|
||||
## What To Pay Attention To
|
||||
|
||||
- Use the starter rig or change it whenever you want.
|
||||
- Try any or all trials; switching does not erase the rig.
|
||||
- Use **Save JSONL** when finished. When launched through `run.sh`, it writes directly into the repository-level `JSONL/` directory; a browser download remains the fallback if the local save server is unavailable.
|
||||
|
||||
## Result
|
||||
|
||||
The first playtest completed all three trials in about 6:47 but was reported as “pretty boring.” Haul consumed most of the session and involved repeated tractor/cargo manipulation plus two unexpected thermal collapses. Furnace was answered by stacking five sinks around one remaining right drive, then completed in under eight seconds. Gale was completed with four cardinal drives and no ballast. See [`results/e1fa0f4d-analysis.md`](results/e1fa0f4d-analysis.md).
|
||||
|
||||
## Validation
|
||||
|
||||
- JavaScript syntax checking passes.
|
||||
- A 1672×976 Chromium screenshot—the viewport used in the preceding playtest log—kept the whole arena visible and all essential builder controls on screen.
|
||||
- Real browser input placed and removed modules, rotated placement, switched trials, piloted the rig, produced completion/failure events, and emitted revision-tagged JSONL without runtime exceptions.
|
||||
- The untouched starter rig thermally collapsed around 69% of Furnace. Adding a parallel right-facing drive completed it. Placing a drive directly behind another correctly blocked the inner exhaust instead of increasing thrust.
|
||||
- Haul rejected a misaligned tractor approach and accepted a corrected approach; the attached fragment immediately increased effective mass. The open-loop validator did not complete the return route because it cannot visually correct momentum around walls.
|
||||
- Trial fields remain stationary while the player edits. Gale begins only after the arena is clicked.
|
||||
|
||||
## Interpretation
|
||||
|
||||
Controls were not the primary problem. Haul prompted an unintended two-tractor juggling workaround only because the player missed Sinks and misread heat as a movement budget; it was still boring. Furnace reduced to one forward Drive plus five Sinks, Gale reduced to restoring the default rig, and lattice placement felt inconsequential except for fiddly Drive orientation.
|
||||
|
||||
The prototype behaved like a spatially slower equipment menu. Changing requirements produced prescribed full loadout swaps rather than meaningful recomposition. This is negative evidence for H06/H17 as implemented, not a general rejection of physical construction.
|
||||
|
||||
## Next Best Experiment
|
||||
|
||||
Pivot to a genuine legible-discovery experiment. Test whether learning and transferring a surprising stable rule has pull independent of combat power, numerical progression, or a cosmetic construction lattice.
|
||||
47
experiments/003_rig_trials/hypothesis.md
Normal file
47
experiments/003_rig_trials/hypothesis.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Experiment 003 Hypothesis
|
||||
|
||||
## Primary Comparison
|
||||
|
||||
One persistent rig is exposed to three qualitative requirements:
|
||||
|
||||
- **Haul:** tool exposure, cargo mass, and navigation;
|
||||
- **Furnace:** drive heat, ambient heat, ward orientation, and cooling layout;
|
||||
- **Gale:** changing wind, ballast anchoring, directional thrust, and maneuvering.
|
||||
|
||||
The comparison is not which trial is best. It is whether the player changes a physical construction for reasons learned by piloting it, and whether knowledge about parts transfers without one whole layout solving everything.
|
||||
|
||||
## Competing Explanations
|
||||
|
||||
1. Construction is valuable when it becomes the player's directly controlled capability.
|
||||
2. Changing requirements create useful adaptation even if any single trial is shallow.
|
||||
3. Direct piloting adds surface action but construction remains obvious loadout selection.
|
||||
4. Physical placement is too weak or too opaque, so only part counts matter.
|
||||
5. A universal rig crystallizes immediately.
|
||||
6. Cheap revision still feels like repetitive implementation rather than reasoning.
|
||||
|
||||
## Evidence Priorities
|
||||
|
||||
Strong positive evidence:
|
||||
|
||||
- rebuild after a concrete observed failure;
|
||||
- preserve a useful subassembly while changing other structure;
|
||||
- deliberately exploit exposure, adjacency, mass, or directional behavior;
|
||||
- voluntary trial or layout experiment not needed for completion;
|
||||
- desire to improve or test the rig after a pass.
|
||||
|
||||
Weak evidence:
|
||||
|
||||
- completing all trials;
|
||||
- spending a long time driving;
|
||||
- repeatedly retrying the same build;
|
||||
- placing the part named by a trial description.
|
||||
|
||||
## Confounds
|
||||
|
||||
- poor driving feel;
|
||||
- an impossible or trivial tuning threshold;
|
||||
- unclear placement/rotation controls;
|
||||
- trial geometry requiring dexterity rather than construction;
|
||||
- the starter rig being too competent;
|
||||
- checklist-like descriptions prescribing the solution.
|
||||
|
||||
609
experiments/003_rig_trials/prototype/app.js
vendored
Normal file
609
experiments/003_rig_trials/prototype/app.js
vendored
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
(() => {
|
||||
"use strict";
|
||||
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const $$ = selector => [...document.querySelectorAll(selector)];
|
||||
const canvas = $("#field");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const GRID = 5;
|
||||
const CORE = { x: 2, y: 2 };
|
||||
const BUDGET = 12;
|
||||
|
||||
const PARTS = {
|
||||
drive: { name: "Drive", glyph: "▲", cost: 2, mass: .7, color: "#f3b65d", oriented: true },
|
||||
tractor: { name: "Tractor", glyph: "◇", cost: 2, mass: .6, color: "#71bceb", oriented: true },
|
||||
sink: { name: "Sink", glyph: "❄", cost: 2, mass: 1, color: "#8bd9ff" },
|
||||
ward: { name: "Ward", glyph: "▰", cost: 2, mass: 1.4, color: "#d4a6ff", oriented: true },
|
||||
ballast: { name: "Ballast", glyph: "⬢", cost: 1, mass: 3.2, color: "#c4b69d" },
|
||||
frame: { name: "Frame", glyph: "+", cost: 1, mass: 1.1, color: "#91a7a2" }
|
||||
};
|
||||
|
||||
const DIR = {
|
||||
up: { x: 0, y: -1, arrow: "↑", key: "KeyW", angle: -Math.PI / 2 },
|
||||
right: { x: 1, y: 0, arrow: "→", key: "KeyD", angle: 0 },
|
||||
down: { x: 0, y: 1, arrow: "↓", key: "KeyS", angle: Math.PI / 2 },
|
||||
left: { x: -1, y: 0, arrow: "←", key: "KeyA", angle: Math.PI }
|
||||
};
|
||||
const FACING_ORDER = ["up", "right", "down", "left"];
|
||||
|
||||
const TRIALS = {
|
||||
haul: {
|
||||
kicker: "TRIAL 01 · RECOVERY", name: "Haul",
|
||||
objective: "Recover both loose fragments. Engage an exposed tractor with E and return each fragment to the home bay."
|
||||
},
|
||||
furnace: {
|
||||
kicker: "TRIAL 02 · THERMAL CROSSING", name: "Furnace",
|
||||
objective: "Reach the receiver on the far side. The curtain heats the core and firing drives adds heat; collapse occurs at 100°."
|
||||
},
|
||||
gale: {
|
||||
kicker: "TRIAL 03 · VARIABLE LOAD", name: "Gale",
|
||||
objective: "Stabilize the three beacons in order. The wind changes after each beacon; touching the outer boundary restarts the attempt."
|
||||
}
|
||||
};
|
||||
|
||||
const OBSTACLES = {
|
||||
haul: [
|
||||
{ x: .42, y: .08, w: .055, h: .34 }, { x: .42, y: .58, w: .055, h: .34 },
|
||||
{ x: .68, y: .31, w: .055, h: .38 }
|
||||
],
|
||||
furnace: [
|
||||
{ x: .44, y: .05, w: .07, h: .27 }, { x: .44, y: .68, w: .07, h: .27 },
|
||||
{ x: .66, y: .20, w: .07, h: .25 }, { x: .66, y: .57, w: .07, h: .23 }
|
||||
],
|
||||
gale: [
|
||||
{ x: .29, y: .37, w: .07, h: .31 }, { x: .53, y: .08, w: .07, h: .30 },
|
||||
{ x: .53, y: .64, w: .07, h: .28 }, { x: .77, y: .31, w: .06, h: .37 }
|
||||
]
|
||||
};
|
||||
|
||||
const STARTER = [
|
||||
{ x: 2, y: 3, type: "drive", facing: "up" },
|
||||
{ x: 2, y: 1, type: "drive", facing: "down" },
|
||||
{ x: 3, y: 2, type: "drive", facing: "left" },
|
||||
{ x: 1, y: 2, type: "drive", facing: "right" },
|
||||
{ x: 3, y: 1, type: "tractor", facing: "up" }
|
||||
];
|
||||
|
||||
const state = {
|
||||
trial: "haul", selectedPart: "drive", facing: "up", paused: false, engaged: false, started: Date.now(),
|
||||
session: crypto.randomUUID ? crypto.randomUUID() : `session-${Date.now()}`, logs: [],
|
||||
build: Array(GRID * GRID).fill(null), keys: new Set(), complete: new Set(), trialDone: false,
|
||||
rig: { x: .14, y: .5, vx: 0, vy: 0, heat: 25, attached: null },
|
||||
cargo: [], delivered: 0, checkpoint: 0, attempt: 0, attemptStarted: Date.now(),
|
||||
lastFrame: performance.now(), snapshotClock: 0, collisionCooldown: 0, heatBand: "normal",
|
||||
toastTimer: null, inputStarts: new Map()
|
||||
};
|
||||
|
||||
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
|
||||
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const round = value => Math.round(value * 1000) / 1000;
|
||||
const index = (x, y) => y * GRID + x;
|
||||
const inGrid = (x, y) => x >= 0 && y >= 0 && x < GRID && y < GRID;
|
||||
const partAt = (x, y) => inGrid(x, y) ? state.build[index(x, y)] : null;
|
||||
|
||||
function log(type, data = {}) {
|
||||
const event = {
|
||||
schema: 1, experiment: "003_rig_trials", prototype_revision: 1,
|
||||
session_id: state.session, elapsed_ms: Date.now() - state.started,
|
||||
trial: state.trial, attempt: state.attempt, type, ...data
|
||||
};
|
||||
state.logs.push(JSON.stringify(event));
|
||||
try { localStorage.setItem("rig-trials-last-jsonl", state.logs.join("\n") + "\n"); } catch (_) {}
|
||||
}
|
||||
|
||||
function installStarter() {
|
||||
state.build.fill(null);
|
||||
state.build[index(CORE.x, CORE.y)] = { type: "core", facing: "up" };
|
||||
STARTER.forEach(part => { state.build[index(part.x, part.y)] = { type: part.type, facing: part.facing }; });
|
||||
}
|
||||
|
||||
function buildCost() {
|
||||
return state.build.reduce((sum, part) => sum + (part && PARTS[part.type] ? PARTS[part.type].cost : 0), 0);
|
||||
}
|
||||
|
||||
function connectedCells() {
|
||||
const connected = new Set([index(CORE.x, CORE.y)]), queue = [{ ...CORE }];
|
||||
while (queue.length) {
|
||||
const cell = queue.shift();
|
||||
for (const direction of Object.values(DIR)) {
|
||||
const x = cell.x + direction.x, y = cell.y + direction.y, key = index(x, y);
|
||||
if (!inGrid(x, y) || !partAt(x, y) || connected.has(key)) continue;
|
||||
connected.add(key); queue.push({ x, y });
|
||||
}
|
||||
}
|
||||
return connected;
|
||||
}
|
||||
|
||||
function exposedSideCount(x, y) {
|
||||
return Object.values(DIR).filter(direction => !partAt(x + direction.x, y + direction.y)).length;
|
||||
}
|
||||
|
||||
function leadingFace(x, y, facing) {
|
||||
const direction = DIR[facing];
|
||||
for (let nx = x + direction.x, ny = y + direction.y; inGrid(nx, ny); nx += direction.x, ny += direction.y) {
|
||||
if (partAt(nx, ny)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function workingPart(x, y, part, connected = connectedCells()) {
|
||||
if (!part || part.type === "core" || !connected.has(index(x, y))) return part?.type === "core";
|
||||
if (part.type === "drive") {
|
||||
const direction = DIR[part.facing];
|
||||
return !partAt(x - direction.x, y - direction.y);
|
||||
}
|
||||
if (part.type === "tractor") {
|
||||
const direction = DIR[part.facing];
|
||||
return !partAt(x + direction.x, y + direction.y);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function rigStats() {
|
||||
const connected = connectedCells();
|
||||
const drives = { up: 0, right: 0, down: 0, left: 0 };
|
||||
let mass = 3, sinks = 0, ward = 0, ballast = 0, tractors = 0, invalid = 0;
|
||||
state.build.forEach((part, key) => {
|
||||
if (!part || part.type === "core") return;
|
||||
const x = key % GRID, y = Math.floor(key / GRID), working = workingPart(x, y, part, connected);
|
||||
mass += PARTS[part.type].mass;
|
||||
if (!working) invalid++;
|
||||
if (part.type === "drive" && working) drives[part.facing]++;
|
||||
if (part.type === "tractor" && working) tractors++;
|
||||
if (part.type === "ballast" && connected.has(key)) ballast++;
|
||||
if (part.type === "sink" && connected.has(key)) {
|
||||
const adjacentCore = Math.abs(x - CORE.x) + Math.abs(y - CORE.y) === 1;
|
||||
sinks += exposedSideCount(x, y) * .9 + (adjacentCore ? 3.3 : 0);
|
||||
}
|
||||
if (part.type === "ward" && connected.has(key) && part.facing === "up" && leadingFace(x, y, "up")) ward++;
|
||||
});
|
||||
return { connected, drives, mass, cooling: 1.2 + sinks, ward, ballast, tractors, invalid, cost: buildCost() };
|
||||
}
|
||||
|
||||
function buildSignature() {
|
||||
return state.build.map((part, key) => part ? `${key}:${part.type}:${part.facing || "-"}` : null).filter(Boolean);
|
||||
}
|
||||
|
||||
function renderBuilder() {
|
||||
const grid = $("#build-grid"); grid.innerHTML = "";
|
||||
const stats = rigStats();
|
||||
state.build.forEach((part, key) => {
|
||||
const x = key % GRID, y = Math.floor(key / GRID), cell = document.createElement("button");
|
||||
cell.className = "cell"; cell.dataset.x = x; cell.dataset.y = y;
|
||||
if (part) {
|
||||
cell.classList.add(part.type === "core" ? "core" : "occupied"); cell.dataset.type = part.type;
|
||||
const working = workingPart(x, y, part, stats.connected);
|
||||
if (!working && part.type !== "core") cell.classList.add("invalid");
|
||||
const definition = part.type === "core" ? { glyph: "◆", name: "Core" } : PARTS[part.type];
|
||||
cell.innerHTML = `${definition.oriented ? `<span class="facing">${DIR[part.facing].arrow}</span>` : ""}<i>${definition.glyph}</i><small>${part.type === "core" ? "CORE" : definition.cost}</small>`;
|
||||
cell.title = `${definition.name}${working ? "" : " — blocked or disconnected"}`;
|
||||
} else cell.title = `Place ${PARTS[state.selectedPart].name}`;
|
||||
cell.addEventListener("pointerdown", onBuildCell);
|
||||
cell.addEventListener("contextmenu", event => event.preventDefault());
|
||||
grid.append(cell);
|
||||
});
|
||||
$("#budget").textContent = `${stats.cost} / ${BUDGET}`;
|
||||
$("#budget").style.color = stats.cost > BUDGET ? "var(--red)" : "var(--amber)";
|
||||
const driveText = `↑${stats.drives.up} →${stats.drives.right} ↓${stats.drives.down} ←${stats.drives.left}`;
|
||||
$("#rig-metrics").innerHTML = `
|
||||
<div><span>WORKING DRIVE</span><b>${driveText}</b></div>
|
||||
<div><span>MASS</span><b>${stats.mass.toFixed(1)}</b></div>
|
||||
<div><span>COOLING</span><b>${stats.cooling.toFixed(1)}/s</b></div>
|
||||
<div><span>TRACTORS</span><b>${stats.tractors}</b></div>
|
||||
<div><span>FURNACE WARD</span><b>${Math.min(72, stats.ward * 28)}%</b></div>
|
||||
<div><span>INVALID</span><b>${stats.invalid}</b></div>`;
|
||||
updateStatus(stats);
|
||||
}
|
||||
|
||||
function onBuildCell(event) {
|
||||
event.preventDefault();
|
||||
const x = Number(event.currentTarget.dataset.x), y = Number(event.currentTarget.dataset.y), key = index(x, y);
|
||||
const existing = state.build[key];
|
||||
if (existing?.type === "core") { toast("The core is the fixed structural root."); return; }
|
||||
if (event.button === 2) {
|
||||
if (!existing) return;
|
||||
state.build[key] = null;
|
||||
log("part_removed", { x, y, part: existing.type, facing: existing.facing, build: buildSignature() });
|
||||
afterBuildEdit("part_removed"); return;
|
||||
}
|
||||
if (event.button !== 0) return;
|
||||
const selected = PARTS[state.selectedPart];
|
||||
const nextCost = buildCost() - (existing && PARTS[existing.type] ? PARTS[existing.type].cost : 0) + selected.cost;
|
||||
if (nextCost > BUDGET) { toast(`Budget ${nextCost}/${BUDGET}. Remove or replace a module first.`); log("part_rejected", { reason: "budget", x, y, part: state.selectedPart, projected_cost: nextCost }); return; }
|
||||
if (existing?.type === state.selectedPart && selected.oriented) {
|
||||
const nextFacing = FACING_ORDER[(FACING_ORDER.indexOf(existing.facing) + 1) % FACING_ORDER.length];
|
||||
state.build[key] = { ...existing, facing: nextFacing };
|
||||
log("part_rotated", { x, y, part: existing.type, before: existing.facing, after: nextFacing, build: buildSignature() });
|
||||
} else {
|
||||
state.build[key] = { type: state.selectedPart, facing: selected.oriented ? state.facing : "up" };
|
||||
log("part_placed", { x, y, part: state.selectedPart, facing: state.build[key].facing, replaced: existing?.type || null, build: buildSignature() });
|
||||
}
|
||||
afterBuildEdit("build_changed");
|
||||
}
|
||||
|
||||
function afterBuildEdit(reason) {
|
||||
renderBuilder(); resetAttempt(reason);
|
||||
}
|
||||
|
||||
function setSelectedPart(type) {
|
||||
state.selectedPart = type;
|
||||
$$(".palette button").forEach(button => button.classList.toggle("selected", button.dataset.part === type));
|
||||
log("palette_selected", { part: type, facing: state.facing });
|
||||
}
|
||||
|
||||
function setFacing(facing) {
|
||||
const before = state.facing; state.facing = facing;
|
||||
$$(".orientation-buttons button").forEach(button => button.classList.toggle("selected", button.dataset.facing === facing));
|
||||
if (before !== facing) log("placement_facing_changed", { before, after: facing });
|
||||
}
|
||||
|
||||
function rotateFacing() {
|
||||
setFacing(FACING_ORDER[(FACING_ORDER.indexOf(state.facing) + 1) % FACING_ORDER.length]);
|
||||
}
|
||||
|
||||
function setTrial(trial) {
|
||||
if (trial === state.trial) return;
|
||||
const before = state.trial; state.trial = trial; state.attempt = 0;
|
||||
$$(".trial-switch button").forEach(button => button.classList.toggle("selected", button.dataset.trial === trial));
|
||||
updateTrialBrief(); resetAttempt("trial_switch"); log("trial_changed", { before, after: trial, build_preserved: true });
|
||||
}
|
||||
|
||||
function updateTrialBrief() {
|
||||
const trial = TRIALS[state.trial];
|
||||
$("#trial-kicker").textContent = trial.kicker; $("#trial-name").textContent = trial.name; $("#trial-objective").textContent = trial.objective;
|
||||
updateHUD();
|
||||
}
|
||||
|
||||
function resetAttempt(reason = "manual") {
|
||||
releaseDriveInputs(reason); state.attempt++; state.attemptStarted = Date.now(); state.trialDone = false; state.engaged = false;
|
||||
Object.assign(state.rig, { x: state.trial === "gale" ? .12 : .14, y: state.trial === "gale" ? .78 : .5, vx: 0, vy: 0, heat: 25, attached: null });
|
||||
state.delivered = 0; state.checkpoint = 0; state.heatBand = "normal"; state.collisionCooldown = 0;
|
||||
state.cargo = state.trial === "haul" ? [
|
||||
{ id: "fragment-a", x: .82, y: .22, delivered: false, attached: false },
|
||||
{ id: "fragment-b", x: .86, y: .79, delivered: false, attached: false }
|
||||
] : [];
|
||||
log("attempt_started", { reason, build: buildSignature(), stats: serialStats(rigStats()) });
|
||||
updateHUD(); updateStatus(rigStats());
|
||||
}
|
||||
|
||||
function serialStats(stats) {
|
||||
return { cost: stats.cost, mass: round(stats.mass), drives: stats.drives, cooling: round(stats.cooling), ward: stats.ward, ballast: stats.ballast, tractors: stats.tractors, invalid: stats.invalid };
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
if (state.paused || state.trialDone || !state.engaged) return;
|
||||
const stats = rigStats();
|
||||
const cargoMass = state.rig.attached ? 5 : 0, mass = stats.mass + cargoMass;
|
||||
let activeDrives = 0;
|
||||
for (const facing of FACING_ORDER) {
|
||||
if (!state.keys.has(DIR[facing].key)) continue;
|
||||
const count = stats.drives[facing]; activeDrives += count;
|
||||
state.rig.vx += DIR[facing].x * count * 1.25 / mass * dt;
|
||||
state.rig.vy += DIR[facing].y * count * 1.25 / mass * dt;
|
||||
}
|
||||
if (state.trial === "gale") applyGale(stats, mass, dt);
|
||||
state.rig.heat += activeDrives * 3.2 * dt;
|
||||
if (state.trial === "furnace" && state.rig.x > .34 && state.rig.x < .79) {
|
||||
const protection = Math.min(.72, stats.ward * .28);
|
||||
state.rig.heat += 36 * (1 - protection) * dt;
|
||||
}
|
||||
if (state.rig.heat > 25) state.rig.heat = Math.max(25, state.rig.heat - stats.cooling * dt);
|
||||
updateHeatBand();
|
||||
if (state.rig.heat >= 100) { failAttempt("thermal_collapse", "Core reached 100°. The attempt restarted with your build intact."); return; }
|
||||
|
||||
const drag = Math.pow(state.rig.attached ? .28 : .38, dt);
|
||||
state.rig.vx *= drag; state.rig.vy *= drag;
|
||||
const speed = Math.hypot(state.rig.vx, state.rig.vy), cap = state.rig.attached ? .30 : .46;
|
||||
if (speed > cap) { state.rig.vx *= cap / speed; state.rig.vy *= cap / speed; }
|
||||
const old = { x: state.rig.x, y: state.rig.y };
|
||||
state.rig.x += state.rig.vx * dt; state.rig.y += state.rig.vy * dt;
|
||||
resolveArena(old);
|
||||
state.collisionCooldown = Math.max(0, state.collisionCooldown - dt);
|
||||
updateAttachedCargo(); checkTrialProgress();
|
||||
|
||||
state.snapshotClock += dt;
|
||||
if (state.snapshotClock >= 5) {
|
||||
state.snapshotClock = 0;
|
||||
log("field_snapshot", {
|
||||
rig: { x: round(state.rig.x), y: round(state.rig.y), vx: round(state.rig.vx), vy: round(state.rig.vy), heat: round(state.rig.heat), attached: state.rig.attached },
|
||||
progress: state.trial === "haul" ? state.delivered : state.trial === "gale" ? state.checkpoint : round(state.rig.x),
|
||||
stats: serialStats(stats)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyGale(stats, mass, dt) {
|
||||
const winds = [
|
||||
{ x: .15, y: -.72, label: "NORTHWARD" },
|
||||
{ x: -.82, y: .10, label: "WESTWARD" },
|
||||
{ x: .35, y: .72, label: "SOUTHEAST" }
|
||||
];
|
||||
const wind = winds[Math.min(state.checkpoint, winds.length - 1)], anchor = 1 + stats.ballast * 1.15;
|
||||
state.rig.vx += wind.x / (mass * anchor) * dt; state.rig.vy += wind.y / (mass * anchor) * dt;
|
||||
}
|
||||
|
||||
function rigRadius() {
|
||||
let extent = 1;
|
||||
state.build.forEach((part, key) => { if (part) extent = Math.max(extent, Math.hypot(key % GRID - CORE.x, Math.floor(key / GRID) - CORE.y)); });
|
||||
return .021 + extent * .008;
|
||||
}
|
||||
|
||||
function insideRect(point, radius, rect) {
|
||||
return point.x > rect.x - radius && point.x < rect.x + rect.w + radius && point.y > rect.y - radius && point.y < rect.y + rect.h + radius;
|
||||
}
|
||||
|
||||
function resolveArena(old) {
|
||||
const radius = rigRadius();
|
||||
let hit = false;
|
||||
for (const obstacle of OBSTACLES[state.trial]) if (insideRect(state.rig, radius, obstacle)) { hit = true; break; }
|
||||
if (hit) {
|
||||
state.rig.x = old.x; state.rig.y = old.y; state.rig.vx *= -.28; state.rig.vy *= -.28;
|
||||
if (state.collisionCooldown <= 0) { state.collisionCooldown = .35; log("obstacle_collision", { x: round(old.x), y: round(old.y), speed: round(Math.hypot(state.rig.vx, state.rig.vy)) }); }
|
||||
}
|
||||
const outside = state.rig.x < radius || state.rig.x > 1 - radius || state.rig.y < radius || state.rig.y > 1 - radius;
|
||||
if (outside && state.trial === "gale") { failAttempt("blown_out", "The gale drove the rig across the boundary. Build preserved; attempt restarted."); return; }
|
||||
state.rig.x = clamp(state.rig.x, radius, 1 - radius); state.rig.y = clamp(state.rig.y, radius, 1 - radius);
|
||||
}
|
||||
|
||||
function updateAttachedCargo() {
|
||||
if (!state.rig.attached) return;
|
||||
const cargo = state.cargo.find(item => item.id === state.rig.attached);
|
||||
if (!cargo) return;
|
||||
cargo.x = state.rig.x - .055; cargo.y = state.rig.y;
|
||||
if (state.rig.x < .22 && state.rig.y > .36 && state.rig.y < .64) {
|
||||
cargo.attached = false; cargo.delivered = true; state.rig.attached = null; state.delivered++;
|
||||
log("cargo_delivered", { cargo_id: cargo.id, delivered: state.delivered, elapsed_in_attempt_ms: Date.now() - state.attemptStarted });
|
||||
toast(`Fragment recovered: ${state.delivered} / 2.`);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTractor() {
|
||||
if (state.trial !== "haul") { toast("Tractors are needed for loose fragments in the Haul trial."); log("tractor_toggled", { result: "no_cargo_in_trial" }); return; }
|
||||
if (state.rig.attached) {
|
||||
const cargo = state.cargo.find(item => item.id === state.rig.attached);
|
||||
if (cargo) { cargo.attached = false; cargo.x = state.rig.x - .06; cargo.y = state.rig.y; }
|
||||
log("cargo_released", { cargo_id: state.rig.attached, x: round(state.rig.x), y: round(state.rig.y) }); state.rig.attached = null; toast("Fragment released."); return;
|
||||
}
|
||||
const tractors = workingTractors();
|
||||
if (!tractors.length) { toast("No exposed, connected tractor is working."); log("tractor_toggled", { result: "no_working_tractor" }); return; }
|
||||
let best = null, bestDistance = Infinity;
|
||||
for (const cargo of state.cargo) {
|
||||
if (cargo.delivered || cargo.attached) continue;
|
||||
const d = distance(state.rig, cargo); if (d >= bestDistance || d > .105) continue;
|
||||
const dx = cargo.x - state.rig.x, dy = cargo.y - state.rig.y, length = Math.max(.001, Math.hypot(dx, dy));
|
||||
const aligned = tractors.some(tractor => (dx / length) * DIR[tractor.facing].x + (dy / length) * DIR[tractor.facing].y > .35);
|
||||
if (aligned) { best = cargo; bestDistance = d; }
|
||||
}
|
||||
if (!best) { toast("No fragment is close to an exposed tractor face."); log("tractor_toggled", { result: "no_aligned_cargo" }); return; }
|
||||
best.attached = true; state.rig.attached = best.id;
|
||||
log("cargo_attached", { cargo_id: best.id, distance: round(bestDistance), rig_mass: round(rigStats().mass + 5) }); toast("Fragment attached. Its mass now changes the rig.");
|
||||
}
|
||||
|
||||
function workingTractors() {
|
||||
const stats = rigStats(), result = [];
|
||||
state.build.forEach((part, key) => {
|
||||
if (!part || part.type !== "tractor") return;
|
||||
const x = key % GRID, y = Math.floor(key / GRID);
|
||||
if (workingPart(x, y, part, stats.connected)) result.push({ x, y, facing: part.facing });
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function checkTrialProgress() {
|
||||
if (state.trial === "haul" && state.delivered >= 2) completeTrial();
|
||||
if (state.trial === "furnace" && state.rig.x > .90 && state.rig.y > .36 && state.rig.y < .64) completeTrial();
|
||||
if (state.trial === "gale") {
|
||||
const beacons = [{ x: .32, y: .22 }, { x: .62, y: .76 }, { x: .89, y: .27 }], target = beacons[state.checkpoint];
|
||||
if (target && distance(state.rig, target) < .055) {
|
||||
state.checkpoint++; log("gale_beacon_stabilized", { beacon: state.checkpoint, heat: round(state.rig.heat), stats: serialStats(rigStats()) });
|
||||
if (state.checkpoint >= beacons.length) completeTrial();
|
||||
else toast(`Beacon ${state.checkpoint}/3 stable. The wind changed.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function completeTrial() {
|
||||
if (state.trialDone) return;
|
||||
state.trialDone = true; state.complete.add(state.trial); releaseDriveInputs("trial_completed");
|
||||
log("trial_completed", { trial: state.trial, elapsed_in_attempt_ms: Date.now() - state.attemptStarted, build: buildSignature(), stats: serialStats(rigStats()), completions: [...state.complete] });
|
||||
const button = $(`.trial-switch button[data-trial="${state.trial}"]`); button.classList.add("complete");
|
||||
toast(`${TRIALS[state.trial].name} complete. No power unlocked; revise, retry, or choose another trial.`);
|
||||
updateStatus(rigStats());
|
||||
}
|
||||
|
||||
function failAttempt(reason, message) {
|
||||
log("attempt_failed", { reason, x: round(state.rig.x), y: round(state.rig.y), heat: round(state.rig.heat), build: buildSignature(), stats: serialStats(rigStats()) });
|
||||
toast(message); resetAttempt(reason);
|
||||
}
|
||||
|
||||
function updateHeatBand() {
|
||||
const next = state.rig.heat >= 85 ? "critical" : state.rig.heat >= 60 ? "high" : "normal";
|
||||
if (next !== state.heatBand) { log("heat_band_changed", { before: state.heatBand, after: next, heat: round(state.rig.heat) }); state.heatBand = next; }
|
||||
}
|
||||
|
||||
function updateStatus(stats = rigStats()) {
|
||||
if (state.trialDone) {
|
||||
$("#status-line").textContent = `${TRIALS[state.trial].name} complete`;
|
||||
$("#status-detail").textContent = "Your build did not gain power. You can alter it, retry, or switch trials."; return;
|
||||
}
|
||||
if (!state.engaged) {
|
||||
$("#status-line").textContent = "Attempt ready · click arena to launch";
|
||||
$("#status-detail").textContent = "The field waits while you inspect or edit the lattice."; return;
|
||||
}
|
||||
if (stats.invalid) {
|
||||
$("#status-line").textContent = `${stats.invalid} inactive module${stats.invalid === 1 ? "" : "s"}`;
|
||||
$("#status-detail").textContent = "A module is disconnected or its working face is blocked. Dim lattice cells are inactive."; return;
|
||||
}
|
||||
$("#status-line").textContent = state.rig.attached ? "Fragment attached · +5 mass" : "All installed modules connected";
|
||||
$("#status-detail").textContent = "Working drives are shown bright; firing drives also raises core heat.";
|
||||
}
|
||||
|
||||
function updateHUD() {
|
||||
$("#heat-readout").textContent = `${state.rig.heat.toFixed(0)}°`;
|
||||
$("#heat-readout").style.color = state.rig.heat >= 85 ? "var(--red)" : state.rig.heat >= 60 ? "var(--amber)" : "var(--text)";
|
||||
if (state.trial === "haul") {
|
||||
$("#progress-label").innerHTML = `FRAGMENTS <b>${state.delivered} / 2</b>`; $("#environment-label").innerHTML = "FIELD <b>CALM</b>";
|
||||
} else if (state.trial === "furnace") {
|
||||
$("#progress-label").innerHTML = `CROSSING <b>${Math.round(clamp((state.rig.x - .14) / .76, 0, 1) * 100)}%</b>`; $("#environment-label").innerHTML = "CURTAIN <b>36°/s</b>";
|
||||
} else {
|
||||
const labels = ["NORTHWARD", "WESTWARD", "SOUTHEAST", "STABLE"];
|
||||
$("#progress-label").innerHTML = `BEACONS <b>${state.checkpoint} / 3</b>`; $("#environment-label").innerHTML = `WIND <b>${labels[state.checkpoint]}</b>`;
|
||||
}
|
||||
updateStatus(rigStats());
|
||||
}
|
||||
|
||||
function canvasMetrics() {
|
||||
const rect = canvas.getBoundingClientRect(); return { rect, width: rect.width, height: rect.height, scale: Math.min(rect.width, rect.height) };
|
||||
}
|
||||
|
||||
function resizeCanvas() {
|
||||
const { width, height } = canvasMetrics(), ratio = Math.min(2, devicePixelRatio || 1);
|
||||
canvas.width = Math.max(1, Math.round(width * ratio)); canvas.height = Math.max(1, Math.round(height * ratio));
|
||||
ctx.setTransform(ratio, 0, 0, ratio, 0, 0); draw();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const { width: W, height: H, scale: S } = canvasMetrics(); if (!W || !H) return;
|
||||
ctx.clearRect(0, 0, W, H); ctx.fillStyle = "#071013"; ctx.fillRect(0, 0, W, H);
|
||||
drawGrid(W, H); drawTrial(W, H, S); drawObstacles(W, H); drawRig(W, H, S); updateHUD();
|
||||
}
|
||||
|
||||
function drawGrid(W, H) {
|
||||
ctx.strokeStyle = "rgba(108,149,139,.09)"; ctx.lineWidth = 1;
|
||||
for (let x = 0; x < W; x += 30) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
|
||||
for (let y = 0; y < H; y += 30) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
|
||||
}
|
||||
|
||||
function drawTrial(W, H, S) {
|
||||
if (state.trial === "haul") {
|
||||
ctx.fillStyle = "rgba(114,230,189,.09)"; ctx.strokeStyle = "#39705f"; ctx.lineWidth = 2;
|
||||
ctx.fillRect(.045 * W, .36 * H, .18 * W, .28 * H); ctx.strokeRect(.045 * W, .36 * H, .18 * W, .28 * H);
|
||||
ctx.fillStyle = "#72e6bd"; ctx.font = "700 9px ui-monospace"; ctx.fillText("HOME BAY", .065 * W, .40 * H);
|
||||
for (const cargo of state.cargo) if (!cargo.delivered) drawCargo(cargo, W, H, S);
|
||||
} else if (state.trial === "furnace") {
|
||||
const gradient = ctx.createLinearGradient(.34 * W, 0, .79 * W, 0);
|
||||
gradient.addColorStop(0, "rgba(242,111,104,.08)"); gradient.addColorStop(.5, "rgba(243,182,93,.25)"); gradient.addColorStop(1, "rgba(242,111,104,.08)");
|
||||
ctx.fillStyle = gradient; ctx.fillRect(.34 * W, 0, .45 * W, H);
|
||||
ctx.strokeStyle = "rgba(243,182,93,.36)"; ctx.setLineDash([7, 8]); ctx.strokeRect(.34 * W, 0, .45 * W, H); ctx.setLineDash([]);
|
||||
ctx.fillStyle = "rgba(113,188,235,.12)"; ctx.strokeStyle = "#5089aa"; ctx.fillRect(.90 * W, .36 * H, .075 * W, .28 * H); ctx.strokeRect(.90 * W, .36 * H, .075 * W, .28 * H);
|
||||
ctx.fillStyle = "#8bd9ff"; ctx.font = "700 9px ui-monospace"; ctx.fillText("RECEIVER", .902 * W, .40 * H);
|
||||
} else {
|
||||
const beacons = [{ x: .32, y: .22 }, { x: .62, y: .76 }, { x: .89, y: .27 }];
|
||||
beacons.forEach((beacon, i) => {
|
||||
ctx.strokeStyle = i < state.checkpoint ? "#72e6bd" : i === state.checkpoint ? "#f3b65d" : "#405054";
|
||||
ctx.lineWidth = i === state.checkpoint ? 3 : 1.5; ctx.beginPath(); ctx.arc(beacon.x * W, beacon.y * H, .045 * S, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.fillStyle = ctx.strokeStyle; ctx.font = "700 9px ui-monospace"; ctx.fillText(String(i + 1), beacon.x * W - 3, beacon.y * H + 3);
|
||||
});
|
||||
const winds = [{ x: .15, y: -.72 }, { x: -.82, y: .10 }, { x: .35, y: .72 }], wind = winds[Math.min(state.checkpoint, 2)];
|
||||
ctx.strokeStyle = "rgba(113,188,235,.24)"; ctx.fillStyle = ctx.strokeStyle; ctx.lineWidth = 2;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const x = ((i * .137 + performance.now() / 15000) % 1) * W, y = ((i * .293 + .1) % 1) * H;
|
||||
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + wind.x * 42, y + wind.y * 42); ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawCargo(cargo, W, H, S) {
|
||||
const x = cargo.x * W, y = cargo.y * H, r = .018 * S;
|
||||
ctx.save(); ctx.translate(x, y); ctx.rotate(performance.now() / 1500); ctx.fillStyle = "#80c8ef"; ctx.strokeStyle = "#d4efff"; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(0, -r); ctx.lineTo(r, 0); ctx.lineTo(0, r); ctx.lineTo(-r, 0); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.restore();
|
||||
}
|
||||
|
||||
function drawObstacles(W, H) {
|
||||
for (const obstacle of OBSTACLES[state.trial]) {
|
||||
ctx.fillStyle = "#172226"; ctx.strokeStyle = "#435659"; ctx.lineWidth = 2;
|
||||
ctx.fillRect(obstacle.x * W, obstacle.y * H, obstacle.w * W, obstacle.h * H); ctx.strokeRect(obstacle.x * W, obstacle.y * H, obstacle.w * W, obstacle.h * H);
|
||||
ctx.strokeStyle = "rgba(114,230,189,.11)";
|
||||
for (let y = obstacle.y * H + 7; y < (obstacle.y + obstacle.h) * H; y += 12) { ctx.beginPath(); ctx.moveTo(obstacle.x * W, y); ctx.lineTo((obstacle.x + obstacle.w) * W, y + 7); ctx.stroke(); }
|
||||
}
|
||||
}
|
||||
|
||||
function drawRig(W, H, S) {
|
||||
const x = state.rig.x * W, y = state.rig.y * H, cell = clamp(S * .024, 10, 18), stats = rigStats();
|
||||
const glow = ctx.createRadialGradient(x, y, 2, x, y, cell * 3.3); glow.addColorStop(0, "rgba(114,230,189,.26)"); glow.addColorStop(1, "rgba(114,230,189,0)");
|
||||
ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(x, y, cell * 3.3, 0, Math.PI * 2); ctx.fill();
|
||||
state.build.forEach((part, key) => {
|
||||
if (!part) return;
|
||||
const gx = key % GRID, gy = Math.floor(key / GRID), px = x + (gx - CORE.x) * cell, py = y + (gy - CORE.y) * cell;
|
||||
const working = workingPart(gx, gy, part, stats.connected), definition = part.type === "core" ? { color: "#72e6bd", glyph: "◆" } : PARTS[part.type];
|
||||
ctx.save(); ctx.globalAlpha = working ? 1 : .28; ctx.fillStyle = "#142126"; ctx.strokeStyle = definition.color; ctx.lineWidth = part.type === "core" ? 2.5 : 1.5;
|
||||
ctx.fillRect(px - cell * .43, py - cell * .43, cell * .86, cell * .86); ctx.strokeRect(px - cell * .43, py - cell * .43, cell * .86, cell * .86);
|
||||
ctx.fillStyle = definition.color; ctx.font = `${Math.max(8, cell * .62)}px ui-sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(definition.glyph, px, py + .5);
|
||||
if (PARTS[part.type]?.oriented) {
|
||||
const direction = DIR[part.facing]; ctx.strokeStyle = definition.color; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px + direction.x * cell * .72, py + direction.y * cell * .72); ctx.stroke();
|
||||
}
|
||||
if (part.type === "drive" && working && state.keys.has(DIR[part.facing].key)) {
|
||||
const direction = DIR[part.facing]; ctx.strokeStyle = "#fff0bd"; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(px - direction.x * cell * .45, py - direction.y * cell * .45); ctx.lineTo(px - direction.x * cell * 1.05, py - direction.y * cell * 1.05); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
});
|
||||
ctx.textAlign = "left"; ctx.textBaseline = "alphabetic";
|
||||
const heatRatio = clamp((state.rig.heat - 25) / 75, 0, 1); ctx.fillStyle = "#1a272a"; ctx.fillRect(x - cell * 2.3, y + cell * 2.85, cell * 4.6, 4); ctx.fillStyle = heatRatio > .8 ? "#f26f68" : "#f3b65d"; ctx.fillRect(x - cell * 2.3, y + cell * 2.85, cell * 4.6 * heatRatio, 4);
|
||||
}
|
||||
|
||||
function frame(now) {
|
||||
const dt = Math.min(.033, Math.max(0, (now - state.lastFrame) / 1000)); state.lastFrame = now; update(dt); draw(); requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function toast(message) {
|
||||
const element = $("#toast"); element.textContent = message; element.classList.add("show"); clearTimeout(state.toastTimer); state.toastTimer = setTimeout(() => element.classList.remove("show"), 3600);
|
||||
}
|
||||
|
||||
function releaseDriveInputs(reason) {
|
||||
for (const code of state.keys) {
|
||||
const started = state.inputStarts.get(code);
|
||||
log("drive_input_ended", { key: code, duration_ms: started ? Date.now() - started : null, reason });
|
||||
}
|
||||
state.keys.clear(); state.inputStarts.clear();
|
||||
}
|
||||
|
||||
$$(".trial-switch button").forEach(button => button.addEventListener("click", () => setTrial(button.dataset.trial)));
|
||||
$$(".palette button").forEach(button => button.addEventListener("click", () => setSelectedPart(button.dataset.part)));
|
||||
$$(".orientation-buttons button").forEach(button => button.addEventListener("click", () => setFacing(button.dataset.facing)));
|
||||
$("#reset").addEventListener("click", () => resetAttempt("manual"));
|
||||
$("#pause").addEventListener("click", () => { state.paused = !state.paused; $("#pause").textContent = state.paused ? "Resume" : "Pause"; log(state.paused ? "paused" : "resumed"); });
|
||||
$("#export").addEventListener("click", async () => {
|
||||
const button = $("#export"), filename = `rig-trials-${state.session}.jsonl`;
|
||||
log("log_exported", { events_before_export: state.logs.length, 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"; }
|
||||
});
|
||||
function engageAttempt(source) {
|
||||
if (!state.engaged) { state.engaged = true; state.attemptStarted = Date.now(); log("attempt_engaged", { source, build: buildSignature(), stats: serialStats(rigStats()) }); }
|
||||
}
|
||||
$("#start-overlay").addEventListener("click", () => { $("#start-overlay").classList.add("hidden"); canvas.focus(); engageAttempt("start_overlay"); log("start_overlay_dismissed"); });
|
||||
canvas.addEventListener("pointerdown", event => { event.preventDefault(); canvas.focus(); $("#start-overlay").classList.add("hidden"); engageAttempt("canvas"); });
|
||||
document.body.addEventListener("contextmenu", event => event.preventDefault());
|
||||
|
||||
window.addEventListener("keydown", event => {
|
||||
if (["KeyW", "KeyA", "KeyS", "KeyD"].includes(event.code)) {
|
||||
event.preventDefault();
|
||||
if (!state.keys.has(event.code)) { state.inputStarts.set(event.code, Date.now()); log("drive_input_started", { key: event.code }); }
|
||||
state.keys.add(event.code);
|
||||
}
|
||||
if (event.code === "KeyE" && !event.repeat) { event.preventDefault(); toggleTractor(); }
|
||||
if (event.code === "KeyR" && !event.repeat && !state.keys.has("KeyW") && !state.keys.has("KeyA") && !state.keys.has("KeyS") && !state.keys.has("KeyD")) { event.preventDefault(); rotateFacing(); }
|
||||
});
|
||||
window.addEventListener("keyup", event => {
|
||||
if (state.keys.delete(event.code)) {
|
||||
const started = state.inputStarts.get(event.code); state.inputStarts.delete(event.code);
|
||||
log("drive_input_ended", { key: event.code, duration_ms: started ? Date.now() - started : null });
|
||||
}
|
||||
});
|
||||
window.addEventListener("blur", () => releaseDriveInputs("window_blur"));
|
||||
new ResizeObserver(resizeCanvas).observe(canvas);
|
||||
let resizeTimer;
|
||||
window.addEventListener("resize", () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => log("viewport_changed", { viewport: { width: innerWidth, height: innerHeight }, canvas: { width: round(canvas.getBoundingClientRect().width), height: round(canvas.getBoundingClientRect().height) } }), 250); });
|
||||
|
||||
installStarter(); renderBuilder(); updateTrialBrief();
|
||||
log("session_started", { viewport: { width: innerWidth, height: innerHeight }, starter_build: buildSignature() });
|
||||
resetAttempt("session_start"); resizeCanvas(); requestAnimationFrame(frame);
|
||||
})();
|
||||
108
experiments/003_rig_trials/prototype/index.html
Normal file
108
experiments/003_rig_trials/prototype/index.html
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Rig Trials — Experiment 003</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="title"><span>EXPERIMENT 003</span><h1>Rig Trials</h1></div>
|
||||
<nav class="trial-switch" aria-label="Complete trials">
|
||||
<button data-trial="haul" class="selected"><span>01</span> Haul</button>
|
||||
<button data-trial="furnace"><span>02</span> Furnace</button>
|
||||
<button data-trial="gale"><span>03</span> Gale</button>
|
||||
</nav>
|
||||
<div class="top-actions">
|
||||
<button id="pause">Pause</button>
|
||||
<button id="reset">Reset attempt</button>
|
||||
<button id="export">Save JSONL</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<aside>
|
||||
<section class="brief">
|
||||
<span>ONE RIG · THREE COMPLETE TRIALS</span>
|
||||
<p>Change the body you directly pilot. Every part and trial is available now. Your rig persists when you switch trials; no completion grants power.</p>
|
||||
</section>
|
||||
|
||||
<section class="builder-heading">
|
||||
<div><h2>Rig lattice</h2><small>Left place/replace · right remove</small></div>
|
||||
<b id="budget">0 / 12</b>
|
||||
</section>
|
||||
<div id="build-grid" class="build-grid" aria-label="Five by five rig construction grid"></div>
|
||||
|
||||
<section class="orientation">
|
||||
<div><h2>Placement facing</h2><small>Thruster arrow = force. Tool/ward arrow = exposed face.</small></div>
|
||||
<div class="orientation-buttons">
|
||||
<button data-facing="up" class="selected" title="Face up">↑</button>
|
||||
<button data-facing="right" title="Face right">→</button>
|
||||
<button data-facing="down" title="Face down">↓</button>
|
||||
<button data-facing="left" title="Face left">←</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="palette" aria-label="Rig parts">
|
||||
<button data-part="drive" class="selected"><i>▲</i><span><b>Drive</b><small>2 budget · thrust + heat</small></span></button>
|
||||
<button data-part="tractor"><i>◇</i><span><b>Tractor</b><small>2 · exposed face grabs</small></span></button>
|
||||
<button data-part="sink"><i>❄</i><span><b>Sink</b><small>2 · exposure cools rig</small></span></button>
|
||||
<button data-part="ward"><i>▰</i><span><b>Ward</b><small>2 · leading face screens heat</small></span></button>
|
||||
<button data-part="ballast"><i>⬢</i><span><b>Ballast</b><small>1 · mass anchors against wind</small></span></button>
|
||||
<button data-part="frame"><i>+</i><span><b>Frame</b><small>1 · cheap structural reach</small></span></button>
|
||||
</section>
|
||||
|
||||
<section id="rig-metrics" class="rig-metrics"></section>
|
||||
|
||||
<details>
|
||||
<summary>Construction laws</summary>
|
||||
<ul>
|
||||
<li>Every module must connect orthogonally to the core through other modules.</li>
|
||||
<li>A drive works only when the cell behind its arrow is empty; that is its exhaust.</li>
|
||||
<li>A tractor works only when the cell in front of its arrow is empty.</li>
|
||||
<li>Sinks cool more through exposed sides and cool best beside the core.</li>
|
||||
<li>A ward screens furnace heat only when it is the leading module facing upward.</li>
|
||||
<li>Ballast adds mass and disproportionately reduces wind acceleration.</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<section class="controls">
|
||||
<h2>Pilot controls</h2>
|
||||
<p><kbd>WASD</kbd><span>Fire working drives in that world direction.</span></p>
|
||||
<p><kbd>E</kbd><span>Toggle an exposed tractor near cargo.</span></p>
|
||||
<p><kbd>R</kbd><span>Rotate placement facing.</span></p>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="playfield">
|
||||
<div class="trial-briefing">
|
||||
<div><span id="trial-kicker">TRIAL 01 · RECOVERY</span><h2 id="trial-name">Haul</h2></div>
|
||||
<p id="trial-objective">Recover both loose fragments. Engage an exposed tractor with E and return each fragment to the home bay.</p>
|
||||
<div class="readouts">
|
||||
<span>CORE HEAT <b id="heat-readout">25°</b></span>
|
||||
<span id="progress-label">FRAGMENTS <b>0 / 2</b></span>
|
||||
<span id="environment-label">FIELD <b>CALM</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="field" tabindex="0"></canvas>
|
||||
<div class="canvas-help">Click the arena, then pilot with WASD. Edit the lattice at any time; an edit restarts only the current attempt.</div>
|
||||
<div id="status-card" class="status-card">
|
||||
<span>RIG STATUS</span>
|
||||
<b id="status-line">Starter rig ready</b>
|
||||
<small id="status-detail">Working drives are shown bright; blocked or disconnected modules are dim.</small>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="start-overlay" class="start-overlay">
|
||||
<div>
|
||||
<span>YOUR BUILD IS YOUR BODY</span>
|
||||
<b>A working starter rig is already installed.</b>
|
||||
<p>Pilot it, alter it, or switch among the three complete trials whenever you want. Click to begin.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast" role="status"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
127
experiments/003_rig_trials/prototype/style.css
Normal file
127
experiments/003_rig_trials/prototype/style.css
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #070a0c;
|
||||
--panel: #0d1417;
|
||||
--panel2: #131d21;
|
||||
--line: #2b3a3f;
|
||||
--text: #e9f0ed;
|
||||
--muted: #8d9d9a;
|
||||
--mint: #72e6bd;
|
||||
--amber: #f3b65d;
|
||||
--red: #f26f68;
|
||||
--blue: #71bceb;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; margin: 0; }
|
||||
body { overflow: hidden; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, sans-serif; user-select: none; }
|
||||
button { border: 1px solid var(--line); border-radius: 7px; background: #142025; color: var(--text); font: inherit; cursor: pointer; }
|
||||
button:hover { border-color: #59716f; background: #1b2a30; }
|
||||
button.selected { border-color: var(--mint); background: #153129; color: var(--mint); }
|
||||
h1, h2, p { margin-top: 0; }
|
||||
h1 { margin: 0; font-size: 20px; }
|
||||
h2 { margin: 0; font-size: 11px; letter-spacing: .1em; text-transform: uppercase; }
|
||||
|
||||
header { height: 62px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; padding: 7px 14px; border-bottom: 1px solid var(--line); background: #0a1012; }
|
||||
.title > span, .brief > span, .start-overlay span, .status-card > span { display: block; color: var(--mint); font-size: 8px; font-weight: 850; letter-spacing: .17em; }
|
||||
.trial-switch { display: flex; gap: 6px; }
|
||||
.trial-switch button { min-width: 92px; padding: 8px 11px; }
|
||||
.trial-switch button span { margin-right: 5px; color: #687976; font: 9px ui-monospace, monospace; }
|
||||
.trial-switch button.complete::after { content: " ✓"; color: var(--mint); }
|
||||
.top-actions { display: flex; justify-content: flex-end; gap: 6px; }
|
||||
.top-actions button { padding: 8px 10px; }
|
||||
|
||||
main { height: calc(100vh - 62px); min-height: 0; overflow: hidden; display: grid; grid-template-columns: clamp(300px, 24vw, 360px) minmax(0, 1fr); }
|
||||
aside { min-height: 0; overflow-y: scroll; scrollbar-gutter: stable; padding: 13px; border-right: 1px solid var(--line); background: #0a1012; }
|
||||
aside section, aside details { margin-bottom: 13px; }
|
||||
.brief { padding: 11px; border: 1px solid #2e6252; border-radius: 8px; background: linear-gradient(145deg, #10231d, #11181c); }
|
||||
.brief p { margin: 7px 0 0; color: #bdcbc6; font-size: 10px; line-height: 1.42; }
|
||||
.builder-heading, .orientation { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }
|
||||
.builder-heading small, .orientation small { display: block; margin-top: 3px; color: var(--muted); font-size: 8px; }
|
||||
#budget { color: var(--amber); font: 11px ui-monospace, monospace; }
|
||||
|
||||
.build-grid { width: min(100%, 310px); aspect-ratio: 1; display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; margin-inline: auto; padding: 7px; border: 1px solid #34484b; border-radius: 9px; background: #081013; }
|
||||
.cell { position: relative; display: grid; place-items: center; min-width: 0; border: 1px dashed #26363a; border-radius: 5px; background: #0d171a; color: #a9bbb6; }
|
||||
.cell:hover { border-color: #5d7773; background: #152328; }
|
||||
.cell.core { border-style: solid; border-color: var(--mint); background: #19332c; color: var(--mint); }
|
||||
.cell.occupied { border-style: solid; border-color: #536765; background: #172327; }
|
||||
.cell.invalid { opacity: .35; border-color: var(--red); }
|
||||
.cell i { font-style: normal; font-size: clamp(15px, 2vw, 23px); line-height: 1; }
|
||||
.cell small { position: absolute; right: 3px; bottom: 2px; color: #758682; font: 7px ui-monospace, monospace; }
|
||||
.cell .facing { position: absolute; left: 3px; top: 1px; color: var(--amber); font-size: 10px; }
|
||||
.cell[data-type="drive"] { color: var(--amber); }
|
||||
.cell[data-type="tractor"] { color: var(--blue); }
|
||||
.cell[data-type="sink"] { color: #8bd9ff; }
|
||||
.cell[data-type="ward"] { color: #d4a6ff; }
|
||||
.cell[data-type="ballast"] { color: #c4b69d; }
|
||||
|
||||
.orientation { align-items: center; }
|
||||
.orientation-buttons { display: flex; gap: 4px; }
|
||||
.orientation-buttons button { width: 31px; height: 29px; padding: 0; }
|
||||
.palette { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
|
||||
.palette button { min-width: 0; display: grid; grid-template-columns: 24px 1fr; align-items: center; gap: 6px; padding: 7px; text-align: left; }
|
||||
.palette i { color: var(--amber); font-style: normal; font-size: 16px; text-align: center; }
|
||||
.palette b, .palette small { display: block; }
|
||||
.palette b { font-size: 9px; }
|
||||
.palette small { margin-top: 2px; overflow: hidden; color: var(--muted); font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rig-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; }
|
||||
.rig-metrics div { padding: 7px; border: 1px solid var(--line); border-radius: 5px; background: var(--panel); }
|
||||
.rig-metrics span, .rig-metrics b { display: block; }
|
||||
.rig-metrics span { color: var(--muted); font-size: 7px; letter-spacing: .08em; }
|
||||
.rig-metrics b { margin-top: 3px; color: #cfdbd7; font: 9px ui-monospace, monospace; }
|
||||
details { border: 1px solid var(--line); border-radius: 7px; background: var(--panel); }
|
||||
summary { padding: 8px 9px; cursor: pointer; font-size: 9px; font-weight: 750; }
|
||||
details ul { margin: 0; padding: 0 12px 10px 25px; color: var(--muted); font-size: 8px; line-height: 1.42; }
|
||||
details li + li { margin-top: 4px; }
|
||||
.controls p { display: grid; grid-template-columns: 52px 1fr; align-items: center; gap: 7px; margin: 6px 0 0; color: var(--muted); font-size: 8px; }
|
||||
kbd { padding: 3px 5px; border: 1px solid #526461; border-bottom-width: 2px; border-radius: 4px; background: #172226; color: var(--text); font: 8px ui-monospace, monospace; text-align: center; }
|
||||
|
||||
.playfield { position: relative; min-width: 0; min-height: 0; overflow: hidden; display: grid; grid-template-rows: 75px minmax(0, 1fr); }
|
||||
.trial-briefing { display: grid; grid-template-columns: 155px minmax(240px, 1fr) auto; align-items: center; gap: 15px; padding: 8px 13px; border-bottom: 1px solid var(--line); background: #0d1417; }
|
||||
.trial-briefing span { color: var(--muted); font-size: 7px; letter-spacing: .1em; }
|
||||
.trial-briefing h2 { margin-top: 3px; color: var(--mint); font-size: 16px; letter-spacing: 0; text-transform: none; }
|
||||
.trial-briefing p { max-width: 650px; margin: 0; color: #b5c3bf; font-size: 10px; line-height: 1.35; }
|
||||
.readouts { display: flex; gap: 12px; }
|
||||
.readouts span, .readouts b { display: block; }
|
||||
.readouts b { margin-top: 3px; color: var(--text); font: 9px ui-monospace, monospace; letter-spacing: 0; }
|
||||
#field { display: block; width: 100%; height: 100%; min-width: 0; min-height: 0; outline: none; background: #071013; }
|
||||
.canvas-help { position: absolute; z-index: 3; left: 50%; bottom: 12px; translate: -50%; padding: 6px 10px; border-radius: 6px; background: #081114d8; color: #82938f; font-size: 8px; pointer-events: none; }
|
||||
.status-card { position: absolute; z-index: 4; right: 12px; top: 87px; width: 235px; padding: 10px; border: 1px solid #3d5353; border-radius: 8px; background: #0c1518e8; backdrop-filter: blur(4px); pointer-events: none; }
|
||||
.status-card b, .status-card small { display: block; }
|
||||
.status-card b { margin-top: 4px; font-size: 10px; }
|
||||
.status-card small { margin-top: 4px; color: var(--muted); font-size: 8px; line-height: 1.35; }
|
||||
|
||||
.start-overlay { position: fixed; z-index: 20; inset: 62px 0 0 clamp(300px, 24vw, 360px); display: grid; place-items: center; background: rgba(3,7,8,.76); }
|
||||
.start-overlay.hidden { display: none; }
|
||||
.start-overlay > div { width: min(570px, 86%); padding: 20px 24px; border: 1px solid #416b5f; border-radius: 10px; background: #101a1dee; text-align: center; }
|
||||
.start-overlay b, .start-overlay p { display: block; }
|
||||
.start-overlay b { margin-top: 6px; font-size: 14px; }
|
||||
.start-overlay p { margin: 8px 0 0; color: var(--muted); font-size: 10px; line-height: 1.4; }
|
||||
#toast { position: fixed; z-index: 30; left: calc(50% + 150px); bottom: 18px; translate: -50% 12px; max-width: 480px; padding: 10px 14px; border: 1px solid #4c7368; border-radius: 8px; background: #10231e; box-shadow: 0 10px 30px #000b; opacity: 0; pointer-events: none; transition: .2s; font-size: 10px; }
|
||||
#toast.show { opacity: 1; translate: -50% 0; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
header { grid-template-columns: auto 1fr auto; gap: 7px; padding-inline: 8px; }
|
||||
.title h1 { font-size: 15px; }
|
||||
.title > span { font-size: 6px; }
|
||||
.trial-switch { justify-content: center; }
|
||||
.trial-switch button { min-width: auto; padding: 7px; font-size: 9px; }
|
||||
.trial-switch button span { display: none; }
|
||||
.top-actions button { padding: 7px; font-size: 9px; }
|
||||
main { grid-template-columns: 285px minmax(0, 1fr); }
|
||||
.start-overlay { left: 285px; }
|
||||
.trial-briefing { grid-template-columns: 110px minmax(180px, 1fr); }
|
||||
.readouts { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 650px) {
|
||||
body { overflow: auto; }
|
||||
header { height: auto; grid-template-columns: 1fr; }
|
||||
.trial-switch { justify-content: flex-start; }
|
||||
.top-actions { justify-content: flex-start; }
|
||||
main { height: auto; grid-template-columns: 1fr; overflow: visible; }
|
||||
aside { max-height: 720px; }
|
||||
.playfield { height: 650px; }
|
||||
.start-overlay { display: none; }
|
||||
#toast { left: 50%; }
|
||||
}
|
||||
4
experiments/003_rig_trials/results/README.md
Normal file
4
experiments/003_rig_trials/results/README.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Results
|
||||
|
||||
Playtest analyses for Experiment 003 belong here. Raw player exports remain in the repository-level `JSONL/` directory.
|
||||
|
||||
61
experiments/003_rig_trials/results/e1fa0f4d-analysis.md
Normal file
61
experiments/003_rig_trials/results/e1fa0f4d-analysis.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Playtest Analysis — Session e1fa0f4d
|
||||
|
||||
Date: 2026-08-16
|
||||
|
||||
Source log: `JSONL/rig-trials-e1fa0f4d-9828-431e-8158-ddf826a41770.jsonl`
|
||||
|
||||
## Observed Behavior
|
||||
|
||||
- Total logged duration was 407 seconds, about 6 minutes 47 seconds.
|
||||
- All three trials were completed in the presented order.
|
||||
- Haul occupied about 4 minutes 29 seconds. It produced 16 cargo attachments, 11 manual releases, 12 unsuccessful tractor activations, three deliveries, five obstacle collisions, and two thermal collapses.
|
||||
- Haul was completed on attempt 4 with the four starter drives and two oppositely facing tractors. The successful attempt took about 61.7 seconds.
|
||||
- Furnace occupied about 34 seconds. The first engaged attempt thermally collapsed at x≈0.676.
|
||||
- After that collapse, the player replaced the tractor, three drives, and an added tractor with sinks in quick succession. The successful Furnace rig contained one right-facing drive and five sinks, with displayed cooling 19.2/s. It completed in about 7.9 seconds.
|
||||
- Gale occupied about 85 seconds. Before the successful run, the player removed three sinks and tried/reoriented several drive placements over eleven cheap build-reset attempts.
|
||||
- The successful Gale rig had the four cardinal starter drive directions plus two sinks. It had no ballast and completed in about 32.9 seconds.
|
||||
- After completing Gale, the player removed and then restored one sink before exporting.
|
||||
- Across the whole session there were 12 placements, six removals, one in-place rotation, two budget rejections, 23 obstacle collisions, and 222 paired drive-input sessions.
|
||||
|
||||
## Player Report
|
||||
|
||||
- The overall experience was “pretty boring.”
|
||||
- Controls and tractor alignment were not awkward. The task was obvious rather than physically difficult.
|
||||
- The player initially interpreted the heat bar as a limited movement budget and did not notice Sinks.
|
||||
- Under that mistaken model, they added a second oppositely facing tractor and repeatedly juggled the two fragments back to the home bay.
|
||||
- After learning that Sinks existed, they expected Haul would become even easier.
|
||||
- Furnace was solved by replacing everything except the core and forward drive with Sinks, then driving straight through.
|
||||
- Gale was solved by restoring the default rig.
|
||||
- Placement did not feel important. Re-adding and orienting Drives was only mildly annoying.
|
||||
|
||||
## Important Confounds
|
||||
|
||||
Heat accumulation and 100° collapse applied in Haul even though the Haul briefing focused on tractor exposure, cargo mass, and recovery. The two Haul thermal failures may have felt like an unexplained cross-trial tax rather than meaningful coupled physics.
|
||||
|
||||
Haul telemetry suggests a large share of activity was execution/debugging rather than construction reasoning: repeated attach/release operations, failed alignment checks, and long direct piloting. This could mask or constitute the failure.
|
||||
|
||||
Furnace admitted an obvious extreme answer: remove almost every movement capability except the required rightward drive and fill the rest of the budget with sinks. Completion then took under eight seconds. This is qualitative part swapping, but it may be a prescribed checklist rather than fertile adaptation.
|
||||
|
||||
Gale did not require its nominally distinctive ballast part. Reconstructing four cardinal drives was sufficient. The placement sequence may reflect learning the builder's rotation semantics rather than reasoning about wind-responsive architecture.
|
||||
|
||||
## Questions Needed Before Interpretation
|
||||
|
||||
1. Did Haul feel primarily like awkward driving/tractor alignment, or was the underlying task already uninteresting even when controls behaved?
|
||||
2. Did rebuilding for Furnace and Gale involve a decision you cared about, or did each trial merely announce which parts to stack?
|
||||
3. Was there any moment when placement itself seemed likely to matter, or did the lattice feel like a slower equipment menu because counts dominated geometry?
|
||||
|
||||
## Final Interpretation
|
||||
|
||||
The answers rule out awkward piloting as the primary cause. They support the weaker-construction explanations:
|
||||
|
||||
- modules behaved mainly as scalar counters or required verbs;
|
||||
- each trial advertised an obvious dominant composition;
|
||||
- lattice geometry rarely changed capability;
|
||||
- switching requirements caused near-total loadout replacement rather than knowledge-rich recomposition;
|
||||
- orientation added interface work without producing a valued spatial decision.
|
||||
|
||||
The two-tractor solution is important counterevidence to treating voluntary experimentation as a sufficient fun signal. It was an unintended, self-devised workaround caused by a mistaken heat model, and it involved repeated execution. The player still found the experience boring. Voluntary deviation is strong evidence of agency or problem solving, but not necessarily curiosity, delight, or a fertile consequence space.
|
||||
|
||||
This experiment does not establish that physical construction is uninteresting. It establishes that construction whose geometry is largely cosmetic and whose parts are one-dimensional counters feels like a slower loadout menu. A richer craft prototype could test physical placement, but immediately building one risks polishing a favored prior after several shallow systems.
|
||||
|
||||
Experiment 004 should make an exploratory pivot to **legible discovery**. The first four prototypes either documented their laws or made the useful response obvious. None cleanly tested whether encountering a surprising, stable rule, forming a model, and transferring that knowledge to a new situation generates interest.
|
||||
5
experiments/003_rig_trials/run.sh
Executable file
5
experiments/003_rig_trials/run.sh
Executable 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
|
||||
15
experiments/004_invariant_rooms/README.md
Normal file
15
experiments/004_invariant_rooms/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Experiment 004 — Invariant Rooms
|
||||
|
||||
This experiment is awaiting its first playtest.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./experiments/004_invariant_rooms/run.sh
|
||||
```
|
||||
|
||||
Then open <http://127.0.0.1:8000>. The **Save JSONL** button writes the run directly to the repository `JSONL/` directory when the supplied server is running. If the endpoint is unavailable, it falls back to a browser download.
|
||||
|
||||
Do not read `hypothesis.md` before playing if you are the playtester. It contains the experiment's hidden purpose and interpretation criteria.
|
||||
|
||||
After playing, report the felt experience before reading the research notes. Useful behavioral detail includes where you paused, reset, formed a prediction, or kept interacting after the required rooms.
|
||||
76
experiments/004_invariant_rooms/hypothesis.md
Normal file
76
experiments/004_invariant_rooms/hypothesis.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Experiment 004 Hypothesis — Private Until After Play
|
||||
|
||||
## Why this follows Experiment 003
|
||||
|
||||
Experiment 003 removed indirect automation but still reduced to obvious counter-loadouts. Its lattice rarely changed outcomes. The player nevertheless invented a two-tractor workaround while misunderstanding the heat system and still found the experience boring. This is important: deviation and problem solving are not equivalent to curiosity or fun.
|
||||
|
||||
Experiment 004 removes construction, upgrades, combat pressure, continuous steering, and numerical optimization. It isolates whether an unfamiliar but stable law can produce a valued cycle of observation, inference, prediction, and transfer.
|
||||
|
||||
## Mechanic
|
||||
|
||||
One command affects two bodies:
|
||||
|
||||
- Self moves in the requested cardinal direction.
|
||||
- Echo moves in the exact opposite direction.
|
||||
- A wall blocks each body independently.
|
||||
- Therefore ordinary moves preserve their midpoint, while an asymmetric collision changes it. Walls can absorb one half of a command and let the player ratchet the pair into configurations impossible in open space.
|
||||
|
||||
The interface does not state this law. It makes it legible through simultaneous tweened motion, trails, collision flashes, a connecting line, a midpoint marker, and a terse record of the last displacement. The player can undo or reset instantly.
|
||||
|
||||
## Room Sequence
|
||||
|
||||
1. **First Pair:** an open horizontal arrangement. Two left commands solve it and expose opposed motion.
|
||||
2. **One Holds:** one body begins against a wall while the other must move three cells. This isolates independent blocking.
|
||||
3. **Transfer:** asymmetric internal walls require combining ordinary opposed moves with two different wall absorptions. The shortest unordered solution is `UUUUURRDR` (9 commands), verified by breadth-first search.
|
||||
4. **Open Chamber:** no sockets and no completion condition. It exists only to observe whether the player has a question or prediction they want to test after the authored sequence.
|
||||
|
||||
## Competing Interpretations
|
||||
|
||||
1. Inferring a stable unfamiliar law is intrinsically rewarding and produces a second question.
|
||||
2. The reveal is momentarily interesting but becomes a short authored puzzle with no continuing possibility space.
|
||||
3. The player enjoys spatial puzzle solving but not open experimentation.
|
||||
4. The law is learned but applying it feels like laborious state bookkeeping.
|
||||
5. The visualization is insufficient, so success comes from input search rather than a usable mental model.
|
||||
6. The tasks are too easy or too short to expose the difference between satisfaction and compliance.
|
||||
|
||||
## Evidence Priorities
|
||||
|
||||
Strong evidence for the target loop:
|
||||
|
||||
- a prediction stated or behaviorally tested before a required move;
|
||||
- deliberate use of a wall after first observing asymmetric blocking;
|
||||
- low-search transfer in the final room after exploratory earlier rooms;
|
||||
- movement in the open chamber aimed at a self-chosen configuration;
|
||||
- a concrete question about a consequence not required by sockets;
|
||||
- wanting another situation because of the law rather than merely another puzzle.
|
||||
|
||||
Ambiguous evidence:
|
||||
|
||||
- completion;
|
||||
- resets or many moves;
|
||||
- finding an unintended route;
|
||||
- spending time in a room;
|
||||
- any claim that the mechanic would or would not be fun with more content.
|
||||
|
||||
Negative evidence:
|
||||
|
||||
- immediate mechanical input search without model formation;
|
||||
- stopping as soon as sockets are filled with no desire to predict anything else;
|
||||
- describing the law as understood but exhausted;
|
||||
- finding state planning tedious even after the behavior is clear.
|
||||
|
||||
## Instrumentation
|
||||
|
||||
Log every command with before/after coordinates, requested direction, each body's displacement and blocked state, midpoint before/after, move count, undo depth, and completion state. Also log resets, undos, room entry/completion, time and move totals, open-chamber moves, visibility changes, and saves.
|
||||
|
||||
Telemetry cannot distinguish thoughtful prediction from trial-and-error by itself. Pair it with the player's report and inspect pauses, undo patterns, and whether wall interactions become more intentional over time.
|
||||
|
||||
## Questions After Play
|
||||
|
||||
Ask only after receiving the JSONL:
|
||||
|
||||
1. At what point, if any, did you feel you understood what the paired movement and walls would do before pressing a key?
|
||||
2. In the last required room, were you executing a plan, testing local guesses, or searching inputs until something worked?
|
||||
3. Once the required sequence ended, did you have any result you wanted to produce or any question you wanted answered?
|
||||
|
||||
Do not ask whether “discovering rules” is fun; that wording would encourage agreement with the hypothesis.
|
||||
467
experiments/004_invariant_rooms/prototype/app.js
vendored
Normal file
467
experiments/004_invariant_rooms/prototype/app.js
vendored
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
(() => {
|
||||
"use strict";
|
||||
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const $$ = selector => [...document.querySelectorAll(selector)];
|
||||
const canvas = $("#field");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const W = 13;
|
||||
const H = 9;
|
||||
const TWEEN_MS = 145;
|
||||
const DIRECTIONS = {
|
||||
up: { x: 0, y: -1, glyph: "↑" },
|
||||
right: { x: 1, y: 0, glyph: "→" },
|
||||
down: { x: 0, y: 1, glyph: "↓" },
|
||||
left: { x: -1, y: 0, glyph: "←" }
|
||||
};
|
||||
const KEY_DIRECTIONS = {
|
||||
KeyW: "up", ArrowUp: "up", KeyD: "right", ArrowRight: "right",
|
||||
KeyS: "down", ArrowDown: "down", KeyA: "left", ArrowLeft: "left"
|
||||
};
|
||||
|
||||
function boundaryWalls() {
|
||||
const walls = [];
|
||||
for (let x = 0; x < W; x++) walls.push([x, 0], [x, H - 1]);
|
||||
for (let y = 1; y < H - 1; y++) walls.push([0, y], [W - 1, y]);
|
||||
return walls;
|
||||
}
|
||||
|
||||
const ROOMS = [
|
||||
{
|
||||
id: "first_pair", kicker: "ROOM 01 · FIRST PAIR", name: "First Pair",
|
||||
objective: "Place both occupants on the two sockets. Either occupant may use either socket.",
|
||||
start: { self: [4, 4], echo: [8, 4] }, sockets: [[2, 4], [10, 4]], walls: boundaryWalls()
|
||||
},
|
||||
{
|
||||
id: "one_holds", kicker: "ROOM 02 · ONE HOLDS", name: "One Holds",
|
||||
objective: "Place both occupants on the sockets again.",
|
||||
start: { self: [3, 4], echo: [9, 4] }, sockets: [[3, 4], [6, 4]],
|
||||
walls: [...boundaryWalls(), [4, 4]]
|
||||
},
|
||||
{
|
||||
id: "transfer", kicker: "ROOM 03 · TRANSFER", name: "Transfer",
|
||||
objective: "Place both occupants on the sockets.",
|
||||
start: { self: [3, 6], echo: [9, 2] }, sockets: [[6, 2], [7, 4]],
|
||||
walls: [...boundaryWalls(), [4, 4], [4, 5], [4, 6], [8, 2], [8, 3], [6, 3], [6, 4], [2, 5], [9, 6]]
|
||||
},
|
||||
{
|
||||
id: "open_chamber", kicker: "OPEN CHAMBER", name: "After the Sequence",
|
||||
objective: "There is no required arrangement in this chamber.",
|
||||
start: { self: [4, 5], echo: [8, 3] }, sockets: [],
|
||||
walls: [...boundaryWalls(), [3, 2], [3, 3], [3, 4], [5, 6], [6, 6], [7, 6], [9, 3], [9, 4], [9, 5], [6, 2]]
|
||||
}
|
||||
];
|
||||
|
||||
const state = {
|
||||
session: crypto.randomUUID ? crypto.randomUUID() : `session-${Date.now()}`,
|
||||
started: Date.now(), logs: [], roomIndex: 0, roomStarted: Date.now(), roomMoves: 0,
|
||||
self: { x: ROOMS[0].start.self[0], y: ROOMS[0].start.self[1] },
|
||||
echo: { x: ROOMS[0].start.echo[0], y: ROOMS[0].start.echo[1] }, history: [], completed: new Set(),
|
||||
active: false, animating: false, animation: null, bannerTimer: null, toastTimer: null,
|
||||
measurement: null, render: { scale: 1, ox: 0, oy: 0, width: 0, height: 0 }
|
||||
};
|
||||
|
||||
const room = () => ROOMS[state.roomIndex];
|
||||
const pair = () => ({ self: [state.self.x, state.self.y], echo: [state.echo.x, state.echo.y] });
|
||||
const midpoint = value => [
|
||||
Math.round(((value.self[0] + value.echo[0]) / 2) * 10) / 10,
|
||||
Math.round(((value.self[1] + value.echo[1]) / 2) * 10) / 10
|
||||
];
|
||||
const wallSet = () => new Set(room().walls.map(([x, y]) => `${x},${y}`));
|
||||
const samePoint = (a, b) => a[0] === b[0] && a[1] === b[1];
|
||||
const formatTime = milliseconds => {
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
function log(type, data = {}) {
|
||||
const event = {
|
||||
schema: 1, experiment: "004_invariant_rooms", prototype_revision: 1,
|
||||
session_id: state.session, elapsed_ms: Date.now() - state.started,
|
||||
room: room().id, room_index: state.roomIndex, room_elapsed_ms: Date.now() - state.roomStarted,
|
||||
room_moves: state.roomMoves, type, ...data
|
||||
};
|
||||
state.logs.push(JSON.stringify(event));
|
||||
try { localStorage.setItem("invariant-rooms-last-jsonl", state.logs.join("\n") + "\n"); } catch (_) {}
|
||||
}
|
||||
|
||||
function enterRoom(index, reason) {
|
||||
state.roomIndex = index;
|
||||
const current = room();
|
||||
state.self = { x: current.start.self[0], y: current.start.self[1] };
|
||||
state.echo = { x: current.start.echo[0], y: current.start.echo[1] };
|
||||
state.roomStarted = Date.now();
|
||||
state.roomMoves = 0;
|
||||
state.history = [];
|
||||
state.animating = false;
|
||||
state.animation = null;
|
||||
state.measurement = null;
|
||||
updateRoomUI();
|
||||
log("room_entered", { reason, start: pair(), sockets: current.sockets, wall_count: current.walls.length });
|
||||
showBanner(index === ROOMS.length - 1 ? "Sequence complete · no required result" : current.name);
|
||||
canvas.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function updateRoomUI() {
|
||||
const current = room();
|
||||
$("#room-kicker").textContent = current.kicker;
|
||||
$("#room-name").textContent = current.name;
|
||||
$("#objective").textContent = current.objective;
|
||||
$("#undo").disabled = state.history.length === 0 || state.animating;
|
||||
$("#move-count").textContent = String(state.roomMoves);
|
||||
if (state.roomIndex === ROOMS.length - 1) {
|
||||
$("#sequence-note").textContent = "The measured sequence is complete. Stay only as long as you want to.";
|
||||
$("#sequence-note").classList.add("open");
|
||||
} else {
|
||||
$("#sequence-note").textContent = "Complete rooms advance automatically. Undo and reset do not consume anything.";
|
||||
$("#sequence-note").classList.remove("open");
|
||||
}
|
||||
renderDots();
|
||||
updateMeasurement();
|
||||
}
|
||||
|
||||
function renderDots() {
|
||||
const holder = $("#room-dots");
|
||||
holder.innerHTML = "";
|
||||
ROOMS.forEach((candidate, index) => {
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "room-dot";
|
||||
if (state.completed.has(candidate.id)) dot.classList.add("complete");
|
||||
if (index === state.roomIndex) dot.classList.add(index === ROOMS.length - 1 ? "open" : "current");
|
||||
dot.title = index === ROOMS.length - 1 ? "Open chamber" : `Room ${index + 1}`;
|
||||
holder.append(dot);
|
||||
});
|
||||
}
|
||||
|
||||
function updateMeasurement() {
|
||||
const measurement = state.measurement;
|
||||
if (!measurement) {
|
||||
$("#measure-command").textContent = "—";
|
||||
$("#measure-self").textContent = "—";
|
||||
$("#measure-echo").textContent = "—";
|
||||
$("#measure-center").textContent = "—";
|
||||
return;
|
||||
}
|
||||
const movementText = value => value.blocked ? "HELD" : `${value.dx > 0 ? "+" : ""}${value.dx}, ${value.dy > 0 ? "+" : ""}${value.dy}`;
|
||||
$("#measure-command").textContent = DIRECTIONS[measurement.direction].glyph;
|
||||
$("#measure-self").textContent = movementText(measurement.self);
|
||||
$("#measure-echo").textContent = movementText(measurement.echo);
|
||||
const [bx, by] = measurement.midpointBefore, [ax, ay] = measurement.midpointAfter;
|
||||
$("#measure-center").textContent = bx === ax && by === ay ? `${ax}, ${ay} · HELD` : `${bx},${by} → ${ax},${ay}`;
|
||||
}
|
||||
|
||||
function issueCommand(direction, source = "keyboard") {
|
||||
if (!state.active || state.animating || !DIRECTIONS[direction]) return;
|
||||
const vector = DIRECTIONS[direction];
|
||||
const before = pair();
|
||||
const walls = wallSet();
|
||||
const selfCandidate = [state.self.x + vector.x, state.self.y + vector.y];
|
||||
const echoCandidate = [state.echo.x - vector.x, state.echo.y - vector.y];
|
||||
const selfBlocked = walls.has(selfCandidate.join(","));
|
||||
const echoBlocked = walls.has(echoCandidate.join(","));
|
||||
const after = {
|
||||
self: selfBlocked ? [...before.self] : selfCandidate,
|
||||
echo: echoBlocked ? [...before.echo] : echoCandidate
|
||||
};
|
||||
if (samePoint(before.self, after.self) && samePoint(before.echo, after.echo)) {
|
||||
log("command_no_effect", { direction, source, before, self_blocked: true, echo_blocked: true });
|
||||
flashMeasurement(direction, before, after, selfBlocked, echoBlocked);
|
||||
return;
|
||||
}
|
||||
|
||||
state.history.push({ before, measurement: state.measurement });
|
||||
state.roomMoves++;
|
||||
const measurement = makeMeasurement(direction, before, after, selfBlocked, echoBlocked);
|
||||
state.measurement = measurement;
|
||||
state.animation = { started: performance.now(), before, after, measurement };
|
||||
state.animating = true;
|
||||
state.self = { x: after.self[0], y: after.self[1] };
|
||||
state.echo = { x: after.echo[0], y: after.echo[1] };
|
||||
updateRoomUI();
|
||||
log("command", {
|
||||
direction, source, before, after, self_blocked: selfBlocked, echo_blocked: echoBlocked,
|
||||
self_displacement: [measurement.self.dx, measurement.self.dy],
|
||||
echo_displacement: [measurement.echo.dx, measurement.echo.dy],
|
||||
midpoint_before: measurement.midpointBefore, midpoint_after: measurement.midpointAfter,
|
||||
history_depth: state.history.length, open_chamber: state.roomIndex === ROOMS.length - 1
|
||||
});
|
||||
}
|
||||
|
||||
function makeMeasurement(direction, before, after, selfBlocked, echoBlocked) {
|
||||
return {
|
||||
direction, before, after,
|
||||
self: { dx: after.self[0] - before.self[0], dy: after.self[1] - before.self[1], blocked: selfBlocked },
|
||||
echo: { dx: after.echo[0] - before.echo[0], dy: after.echo[1] - before.echo[1], blocked: echoBlocked },
|
||||
midpointBefore: midpoint(before), midpointAfter: midpoint(after)
|
||||
};
|
||||
}
|
||||
|
||||
function flashMeasurement(direction, before, after, selfBlocked, echoBlocked) {
|
||||
state.measurement = makeMeasurement(direction, before, after, selfBlocked, echoBlocked);
|
||||
updateMeasurement();
|
||||
}
|
||||
|
||||
function finishAnimation() {
|
||||
state.animating = false;
|
||||
const completed = roomComplete();
|
||||
log("command_animation_finished", { positions: pair(), completed });
|
||||
if (completed) completeRoom();
|
||||
}
|
||||
|
||||
function roomComplete() {
|
||||
const sockets = room().sockets;
|
||||
if (sockets.length !== 2) return false;
|
||||
const current = pair();
|
||||
return (samePoint(current.self, sockets[0]) && samePoint(current.echo, sockets[1])) ||
|
||||
(samePoint(current.self, sockets[1]) && samePoint(current.echo, sockets[0]));
|
||||
}
|
||||
|
||||
function completeRoom() {
|
||||
const current = room();
|
||||
if (state.completed.has(current.id)) return;
|
||||
state.completed.add(current.id);
|
||||
log("room_completed", {
|
||||
moves: state.roomMoves, duration_ms: Date.now() - state.roomStarted,
|
||||
resets: Number(sessionStorage.getItem(`invariant-resets-${state.session}-${current.id}`) || 0)
|
||||
});
|
||||
renderDots();
|
||||
showBanner("Both sockets occupied");
|
||||
setTimeout(() => {
|
||||
if (state.roomIndex < ROOMS.length - 1) enterRoom(state.roomIndex + 1, "previous_completed");
|
||||
}, 780);
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (!state.active || state.animating || state.history.length === 0) return;
|
||||
const current = pair();
|
||||
const previous = state.history.pop();
|
||||
state.self = { x: previous.before.self[0], y: previous.before.self[1] };
|
||||
state.echo = { x: previous.before.echo[0], y: previous.before.echo[1] };
|
||||
state.measurement = previous.measurement;
|
||||
log("undo", { before: current, after: pair(), history_depth: state.history.length });
|
||||
updateRoomUI();
|
||||
}
|
||||
|
||||
function resetRoom(reason = "button") {
|
||||
if (!state.active || state.animating) return;
|
||||
const before = pair();
|
||||
const current = room();
|
||||
const key = `invariant-resets-${state.session}-${current.id}`;
|
||||
const resets = Number(sessionStorage.getItem(key) || 0) + 1;
|
||||
sessionStorage.setItem(key, String(resets));
|
||||
state.self = { x: current.start.self[0], y: current.start.self[1] };
|
||||
state.echo = { x: current.start.echo[0], y: current.start.echo[1] };
|
||||
state.history = [];
|
||||
state.roomMoves = 0;
|
||||
state.measurement = null;
|
||||
log("room_reset", { reason, before, after: pair(), reset_count: resets });
|
||||
updateRoomUI();
|
||||
}
|
||||
|
||||
async function saveLog() {
|
||||
log("save_requested", { event_count_before_save: state.logs.length });
|
||||
const filename = `invariant-rooms-${state.session}.jsonl`;
|
||||
const payload = state.logs.join("\n") + "\n";
|
||||
try {
|
||||
const response = await fetch("/api/playtest-log", {
|
||||
method: "POST", headers: { "Content-Type": "application/x-ndjson", "X-Playtest-Filename": filename }, body: payload
|
||||
});
|
||||
if (!response.ok) throw new Error(`server returned ${response.status}`);
|
||||
const result = await response.json();
|
||||
log("log_saved", { path: result.path, saved_events: result.events });
|
||||
showToast(`Saved ${result.events} events to ${result.path}`);
|
||||
} catch (error) {
|
||||
const blob = new Blob([payload], { type: "application/x-ndjson" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob); link.download = filename; link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
||||
log("save_fallback_download", { message: String(error) });
|
||||
showToast("Server save unavailable; downloaded the JSONL instead.");
|
||||
}
|
||||
}
|
||||
|
||||
function showBanner(message) {
|
||||
const banner = $("#room-banner");
|
||||
banner.textContent = message;
|
||||
banner.classList.add("show");
|
||||
clearTimeout(state.bannerTimer);
|
||||
state.bannerTimer = setTimeout(() => banner.classList.remove("show"), 1350);
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const toast = $("#toast");
|
||||
toast.textContent = message;
|
||||
toast.classList.add("show");
|
||||
clearTimeout(state.toastTimer);
|
||||
state.toastTimer = setTimeout(() => toast.classList.remove("show"), 2800);
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.max(1, Math.round(rect.width * dpr));
|
||||
canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const margin = Math.max(24, Math.min(rect.width, rect.height) * .06);
|
||||
const scale = Math.min((rect.width - margin * 2) / W, (rect.height - margin * 2) / H);
|
||||
state.render = { scale, ox: (rect.width - W * scale) / 2, oy: (rect.height - H * scale) / 2, width: rect.width, height: rect.height };
|
||||
}
|
||||
|
||||
function cellCenter(x, y) {
|
||||
const { scale, ox, oy } = state.render;
|
||||
return { x: ox + (x + .5) * scale, y: oy + (y + .5) * scale };
|
||||
}
|
||||
|
||||
function draw(time) {
|
||||
const { width, height, scale, ox, oy } = state.render;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
drawGrid(scale, ox, oy);
|
||||
drawSockets();
|
||||
drawWalls(scale, ox, oy);
|
||||
|
||||
let selfPosition = [state.self.x, state.self.y], echoPosition = [state.echo.x, state.echo.y], progress = 1;
|
||||
if (state.animating && state.animation) {
|
||||
progress = Math.min(1, (time - state.animation.started) / TWEEN_MS);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
selfPosition = interpolate(state.animation.before.self, state.animation.after.self, eased);
|
||||
echoPosition = interpolate(state.animation.before.echo, state.animation.after.echo, eased);
|
||||
drawTrails(state.animation, eased);
|
||||
if (progress >= 1) finishAnimation();
|
||||
} else if (state.measurement) {
|
||||
drawTrails({ ...state.measurement, measurement: state.measurement }, 1, .22);
|
||||
}
|
||||
|
||||
drawPairLine(selfPosition, echoPosition);
|
||||
drawOccupant(selfPosition, "self", state.animating && state.animation?.measurement.self.blocked, progress);
|
||||
drawOccupant(echoPosition, "echo", state.animating && state.animation?.measurement.echo.blocked, progress);
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
function interpolate(from, to, amount) {
|
||||
return [from[0] + (to[0] - from[0]) * amount, from[1] + (to[1] - from[1]) * amount];
|
||||
}
|
||||
|
||||
function drawGrid(scale, ox, oy) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(89, 121, 130, .16)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 0; x <= W; x++) { ctx.beginPath(); ctx.moveTo(ox + x * scale, oy); ctx.lineTo(ox + x * scale, oy + H * scale); ctx.stroke(); }
|
||||
for (let y = 0; y <= H; y++) { ctx.beginPath(); ctx.moveTo(ox, oy + y * scale); ctx.lineTo(ox + W * scale, oy + y * scale); ctx.stroke(); }
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawWalls(scale, ox, oy) {
|
||||
for (const [x, y] of room().walls) {
|
||||
const inset = Math.max(2, scale * .055);
|
||||
const left = ox + x * scale + inset, top = oy + y * scale + inset, size = scale - inset * 2;
|
||||
const gradient = ctx.createLinearGradient(left, top, left + size, top + size);
|
||||
gradient.addColorStop(0, "#263843"); gradient.addColorStop(1, "#14212a");
|
||||
ctx.fillStyle = gradient; ctx.fillRect(left, top, size, size);
|
||||
ctx.strokeStyle = "#425963"; ctx.lineWidth = 1; ctx.strokeRect(left + .5, top + .5, size - 1, size - 1);
|
||||
ctx.strokeStyle = "rgba(122, 151, 160, .14)";
|
||||
for (let offset = -size; offset < size * 2; offset += Math.max(9, scale * .2)) {
|
||||
ctx.beginPath(); ctx.moveTo(left + offset, top + size); ctx.lineTo(left + offset + size, top); ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawSockets() {
|
||||
room().sockets.forEach(([x, y]) => {
|
||||
const center = cellCenter(x, y), radius = state.render.scale * .31;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "#f1b85c"; ctx.lineWidth = Math.max(2, state.render.scale * .045);
|
||||
ctx.setLineDash([state.render.scale * .1, state.render.scale * .075]);
|
||||
ctx.beginPath(); ctx.arc(center.x, center.y, radius, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.fillStyle = "rgba(241, 184, 92, .08)"; ctx.fill();
|
||||
ctx.setLineDash([]); ctx.fillStyle = "#f1b85c"; ctx.beginPath(); ctx.arc(center.x, center.y, 2.2, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
|
||||
function drawPairLine(selfPosition, echoPosition) {
|
||||
const a = cellCenter(selfPosition[0], selfPosition[1]), b = cellCenter(echoPosition[0], echoPosition[1]);
|
||||
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(181, 164, 220, .25)"; ctx.lineWidth = 1.5; ctx.setLineDash([5, 7]);
|
||||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); ctx.setLineDash([]);
|
||||
ctx.strokeStyle = "#f1b85c"; ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.moveTo(mx - 6, my); ctx.lineTo(mx + 6, my); ctx.moveTo(mx, my - 6); ctx.lineTo(mx, my + 6); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawTrails(animation, amount, alpha = .6) {
|
||||
const measurement = animation.measurement;
|
||||
for (const identity of ["self", "echo"]) {
|
||||
const from = animation.before[identity], to = animation.after[identity];
|
||||
if (samePoint(from, to)) continue;
|
||||
const a = cellCenter(from[0], from[1]), b = cellCenter(to[0], to[1]);
|
||||
ctx.save(); ctx.globalAlpha = alpha * amount;
|
||||
ctx.strokeStyle = identity === "self" ? "#69e2bb" : "#bc91ff"; ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function drawOccupant(position, identity, blocked, progress) {
|
||||
const center = cellCenter(position[0], position[1]);
|
||||
const radius = state.render.scale * .27;
|
||||
const color = identity === "self" ? "#69e2bb" : "#bc91ff";
|
||||
ctx.save();
|
||||
if (blocked && progress < 1) { ctx.shadowColor = "#ef716c"; ctx.shadowBlur = 18 * (1 - progress); }
|
||||
else { ctx.shadowColor = color; ctx.shadowBlur = 14; }
|
||||
ctx.fillStyle = identity === "self" ? "#173c34" : "#302448";
|
||||
ctx.strokeStyle = color; ctx.lineWidth = Math.max(2, state.render.scale * .045);
|
||||
ctx.beginPath();
|
||||
if (identity === "self") {
|
||||
ctx.moveTo(center.x, center.y - radius); ctx.lineTo(center.x + radius * .88, center.y + radius * .78);
|
||||
ctx.lineTo(center.x - radius * .88, center.y + radius * .78); ctx.closePath();
|
||||
} else {
|
||||
ctx.moveTo(center.x, center.y - radius); ctx.lineTo(center.x + radius, center.y);
|
||||
ctx.lineTo(center.x, center.y + radius); ctx.lineTo(center.x - radius, center.y); ctx.closePath();
|
||||
}
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.shadowBlur = 0; ctx.fillStyle = color; ctx.font = `700 ${Math.max(7, state.render.scale * .12)}px ui-monospace, monospace`;
|
||||
ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
||||
ctx.fillText(identity === "self" ? "S" : "E", center.x, center.y + (identity === "self" ? radius * .18 : 0));
|
||||
if (blocked && progress < 1) {
|
||||
ctx.strokeStyle = "#ef716c"; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(center.x, center.y, radius * (1.15 + progress * .35), 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function begin(source) {
|
||||
if (state.active) return;
|
||||
state.active = true;
|
||||
$("#start-overlay").classList.add("hidden");
|
||||
canvas.focus({ preventScroll: true });
|
||||
log("session_started", { source, viewport: [window.innerWidth, window.innerHeight], room_count: ROOMS.length });
|
||||
enterRoom(0, "session_started");
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", event => {
|
||||
if (!state.active) return;
|
||||
if (KEY_DIRECTIONS[event.code]) {
|
||||
event.preventDefault();
|
||||
if (!event.repeat) issueCommand(KEY_DIRECTIONS[event.code], "keyboard");
|
||||
} else if (event.code === "KeyZ" || event.code === "KeyU") {
|
||||
event.preventDefault(); if (!event.repeat) undo();
|
||||
} else if (event.code === "KeyR") {
|
||||
event.preventDefault(); if (!event.repeat) resetRoom("keyboard");
|
||||
}
|
||||
});
|
||||
document.addEventListener("contextmenu", event => event.preventDefault());
|
||||
document.addEventListener("selectstart", event => event.preventDefault());
|
||||
$("#start-overlay").addEventListener("pointerdown", () => begin("overlay"));
|
||||
canvas.addEventListener("pointerdown", () => { if (!state.active) begin("canvas"); else canvas.focus({ preventScroll: true }); });
|
||||
$$("[data-direction]").forEach(button => button.addEventListener("click", () => issueCommand(button.dataset.direction, "button")));
|
||||
$("#undo").addEventListener("click", undo);
|
||||
$("#reset").addEventListener("click", () => resetRoom("button"));
|
||||
$("#save").addEventListener("click", saveLog);
|
||||
document.addEventListener("visibilitychange", () => log("visibility_changed", { visibility: document.visibilityState, positions: pair() }));
|
||||
window.addEventListener("beforeunload", () => log("session_unload", { positions: pair(), completed_rooms: [...state.completed] }));
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
setInterval(() => { $("#room-time").textContent = formatTime(Date.now() - state.roomStarted); }, 250);
|
||||
resize();
|
||||
updateRoomUI();
|
||||
requestAnimationFrame(draw);
|
||||
})();
|
||||
80
experiments/004_invariant_rooms/prototype/index.html
Normal file
80
experiments/004_invariant_rooms/prototype/index.html
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Paired Rooms — Experiment 004</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="title"><span>EXPERIMENT 004</span><h1>Paired Rooms</h1></div>
|
||||
<div id="room-dots" class="room-dots" aria-label="Room progress"></div>
|
||||
<div class="top-actions">
|
||||
<button id="undo" type="button">Undo</button>
|
||||
<button id="reset" type="button">Reset room</button>
|
||||
<button id="save" type="button">Save JSONL</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<aside>
|
||||
<section class="brief">
|
||||
<span id="room-kicker">ROOM 01 · FIRST PAIR</span>
|
||||
<h2 id="room-name">First Pair</h2>
|
||||
<p id="objective">Place both occupants on the two sockets. Either occupant may use either socket.</p>
|
||||
</section>
|
||||
|
||||
<section class="identity-card">
|
||||
<div><i class="self-symbol">▲</i><span><b>SELF</b><small>Mint occupant</small></span></div>
|
||||
<div><i class="echo-symbol">◆</i><span><b>ECHO</b><small>Violet occupant</small></span></div>
|
||||
</section>
|
||||
|
||||
<section class="controls">
|
||||
<h3>Shared command</h3>
|
||||
<div class="key-grid" aria-label="Movement controls">
|
||||
<button data-direction="up" type="button">W<span>↑</span></button>
|
||||
<button data-direction="left" type="button">A<span>←</span></button>
|
||||
<button data-direction="down" type="button">S<span>↓</span></button>
|
||||
<button data-direction="right" type="button">D<span>→</span></button>
|
||||
</div>
|
||||
<p><kbd>WASD</kbd> or <kbd>arrows</kbd> issue one command.</p>
|
||||
<p><kbd>Z</kbd> undoes. <kbd>R</kbd> restores the room.</p>
|
||||
</section>
|
||||
|
||||
<section class="measurements">
|
||||
<h3>Last measurement</h3>
|
||||
<div><span>COMMAND</span><b id="measure-command">—</b></div>
|
||||
<div><span>SELF</span><b id="measure-self">—</b></div>
|
||||
<div><span>ECHO</span><b id="measure-echo">—</b></div>
|
||||
<div><span>CENTER</span><b id="measure-center">—</b></div>
|
||||
</section>
|
||||
|
||||
<section class="room-stats">
|
||||
<div><span>ROOM MOVES</span><b id="move-count">0</b></div>
|
||||
<div><span>ROOM TIME</span><b id="room-time">0:00</b></div>
|
||||
</section>
|
||||
|
||||
<section id="sequence-note" class="sequence-note">
|
||||
Complete rooms advance automatically. Undo and reset do not consume anything.
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="playfield">
|
||||
<canvas id="field" tabindex="0" aria-label="Paired room playfield"></canvas>
|
||||
<div id="room-banner" class="room-banner" aria-live="polite"></div>
|
||||
<div class="canvas-help">Click the field, then use WASD or arrow keys.</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="start-overlay" class="start-overlay">
|
||||
<div>
|
||||
<span>TWO OCCUPANTS · ONE COMMAND</span>
|
||||
<b>Place both occupants on the sockets.</b>
|
||||
<p>The command's effect is not described. Watch both occupants. Click anywhere to begin.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast" role="status"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
106
experiments/004_invariant_rooms/prototype/style.css
Normal file
106
experiments/004_invariant_rooms/prototype/style.css
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #06090d;
|
||||
--panel: #0c1319;
|
||||
--panel2: #111b23;
|
||||
--line: #293943;
|
||||
--text: #e8f1ef;
|
||||
--muted: #879a9f;
|
||||
--mint: #69e2bb;
|
||||
--violet: #bc91ff;
|
||||
--amber: #f1b85c;
|
||||
--red: #ef716c;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; margin: 0; }
|
||||
body { overflow: hidden; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, sans-serif; user-select: none; }
|
||||
button { border: 1px solid var(--line); border-radius: 7px; background: #132029; color: var(--text); font: inherit; cursor: pointer; touch-action: manipulation; }
|
||||
button:hover { border-color: #56717a; background: #192a34; }
|
||||
button:disabled { opacity: .38; cursor: default; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin: 0; font-size: 20px; }
|
||||
h2 { margin: 4px 0 0; font-size: 23px; }
|
||||
h3 { margin: 0 0 9px; color: #a9bbb9; font-size: 9px; letter-spacing: .14em; text-transform: uppercase; }
|
||||
|
||||
header { height: 64px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 15px; padding: 8px 14px; border-bottom: 1px solid var(--line); background: #091015; }
|
||||
.title > span, .brief > span, .start-overlay span { display: block; color: var(--mint); font-size: 8px; font-weight: 850; letter-spacing: .18em; }
|
||||
.room-dots { display: flex; align-items: center; gap: 8px; }
|
||||
.room-dot { width: 34px; height: 5px; border: 0; border-radius: 5px; background: #27353e; }
|
||||
.room-dot.current { background: var(--amber); box-shadow: 0 0 12px #f1b85c66; }
|
||||
.room-dot.complete { background: var(--mint); }
|
||||
.room-dot.open { background: linear-gradient(90deg, var(--violet), var(--mint)); }
|
||||
.top-actions { display: flex; justify-content: flex-end; gap: 7px; }
|
||||
.top-actions button { padding: 8px 11px; }
|
||||
|
||||
main { height: calc(100vh - 64px); min-height: 0; display: grid; grid-template-columns: clamp(260px, 21vw, 330px) minmax(0, 1fr); overflow: hidden; }
|
||||
aside { min-height: 0; overflow-y: auto; scrollbar-gutter: stable; padding: 16px; border-right: 1px solid var(--line); background: #091015; }
|
||||
aside section { margin-bottom: 15px; }
|
||||
.brief { padding: 14px; border: 1px solid #345449; border-radius: 9px; background: linear-gradient(145deg, #10231d, #101820); }
|
||||
.brief p { margin: 9px 0 0; color: #b7c6c4; font-size: 11px; line-height: 1.45; }
|
||||
.identity-card { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
|
||||
.identity-card > div { display: flex; align-items: center; gap: 9px; padding: 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel); }
|
||||
.identity-card i { width: 25px; font-style: normal; font-size: 22px; text-align: center; }
|
||||
.identity-card b, .identity-card small { display: block; }
|
||||
.identity-card b { font-size: 9px; letter-spacing: .1em; }
|
||||
.identity-card small { margin-top: 2px; color: var(--muted); font-size: 8px; }
|
||||
.self-symbol { color: var(--mint); }
|
||||
.echo-symbol { color: var(--violet); }
|
||||
|
||||
.controls { padding: 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); }
|
||||
.key-grid { width: 142px; display: grid; grid-template-columns: repeat(3, 42px); grid-template-rows: repeat(2, 42px); gap: 5px; margin: 0 auto 10px; }
|
||||
.key-grid button { padding: 0; color: #b8c9c8; font: 700 12px ui-monospace, monospace; }
|
||||
.key-grid button span { display: block; color: var(--amber); font-size: 10px; }
|
||||
.key-grid button[data-direction="up"] { grid-column: 2; }
|
||||
.key-grid button[data-direction="left"] { grid-row: 2; grid-column: 1; }
|
||||
.key-grid button[data-direction="down"] { grid-row: 2; grid-column: 2; }
|
||||
.key-grid button[data-direction="right"] { grid-row: 2; grid-column: 3; }
|
||||
.controls p { margin: 5px 0 0; color: var(--muted); font-size: 9px; text-align: center; }
|
||||
kbd { padding: 2px 5px; border: 1px solid #53666c; border-bottom-width: 2px; border-radius: 4px; background: #17242c; color: var(--text); font: 8px ui-monospace, monospace; }
|
||||
|
||||
.measurements { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.measurements h3 { grid-column: 1 / -1; margin-bottom: 2px; }
|
||||
.measurements div, .room-stats div { min-height: 50px; padding: 9px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
|
||||
.measurements span, .measurements b, .room-stats span, .room-stats b { display: block; }
|
||||
.measurements span, .room-stats span { color: var(--muted); font-size: 7px; letter-spacing: .11em; }
|
||||
.measurements b, .room-stats b { margin-top: 5px; color: #d7e3e0; font: 10px ui-monospace, monospace; }
|
||||
.room-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.sequence-note { padding: 10px; border-left: 2px solid #41545a; color: var(--muted); font-size: 9px; line-height: 1.45; }
|
||||
.sequence-note.open { border-color: var(--violet); color: #c4b4dc; }
|
||||
|
||||
.playfield { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: radial-gradient(circle at 50% 45%, #101b23, #05090c 75%); }
|
||||
#field { display: block; width: 100%; height: 100%; outline: none; touch-action: none; }
|
||||
.canvas-help { position: absolute; left: 50%; bottom: 13px; translate: -50%; padding: 7px 11px; border-radius: 6px; background: #071015dc; color: #7d9195; font-size: 9px; pointer-events: none; }
|
||||
.room-banner { position: absolute; z-index: 5; left: 50%; top: 20px; translate: -50% -8px; min-width: 230px; padding: 10px 16px; border: 1px solid #487367; border-radius: 8px; background: #10221dda; color: var(--mint); opacity: 0; text-align: center; pointer-events: none; transition: .22s; font-size: 11px; font-weight: 750; }
|
||||
.room-banner.show { opacity: 1; translate: -50% 0; }
|
||||
|
||||
.start-overlay { position: fixed; z-index: 20; inset: 64px 0 0 clamp(260px, 21vw, 330px); display: grid; place-items: center; background: #030709d9; }
|
||||
.start-overlay.hidden { display: none; }
|
||||
.start-overlay > div { width: min(560px, 86%); padding: 22px 26px; border: 1px solid #436c61; border-radius: 10px; background: #101a20f2; text-align: center; }
|
||||
.start-overlay b, .start-overlay p { display: block; }
|
||||
.start-overlay b { margin-top: 7px; font-size: 15px; }
|
||||
.start-overlay p { margin: 9px 0 0; color: var(--muted); font-size: 10px; line-height: 1.45; }
|
||||
#toast { position: fixed; z-index: 30; left: calc(50% + 145px); bottom: 20px; translate: -50% 12px; max-width: 500px; padding: 10px 14px; border: 1px solid #4c7368; border-radius: 8px; background: #10231e; box-shadow: 0 10px 30px #000b; opacity: 0; pointer-events: none; transition: .2s; font-size: 10px; }
|
||||
#toast.show { opacity: 1; translate: -50% 0; }
|
||||
|
||||
@media (max-width: 850px) {
|
||||
header { grid-template-columns: auto 1fr auto; padding-inline: 8px; }
|
||||
.title h1 { font-size: 15px; }
|
||||
.title > span { font-size: 6px; }
|
||||
.room-dot { width: 20px; }
|
||||
.top-actions button { padding: 7px; font-size: 9px; }
|
||||
main { grid-template-columns: 250px minmax(0, 1fr); }
|
||||
.start-overlay { left: 250px; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
body { overflow: auto; }
|
||||
header { height: auto; grid-template-columns: 1fr; }
|
||||
.room-dots { justify-content: flex-start; }
|
||||
.top-actions { justify-content: flex-start; }
|
||||
main { height: auto; grid-template-columns: 1fr; overflow: visible; }
|
||||
aside { max-height: none; }
|
||||
.playfield { height: min(80vh, 650px); }
|
||||
.start-overlay { display: none; }
|
||||
#toast { left: 50%; }
|
||||
}
|
||||
3
experiments/004_invariant_rooms/results/README.md
Normal file
3
experiments/004_invariant_rooms/results/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Experiment 004 Results
|
||||
|
||||
No playtest has been analyzed yet. JSONL telemetry is saved in the repository-level `JSONL/` directory; qualitative interpretation belongs here after the player reports their experience.
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# Experiment 004 Preliminary Analysis — Session a7ad2d34
|
||||
|
||||
Status: telemetry and player report analyzed.
|
||||
|
||||
Source: `JSONL/invariant-rooms-a7ad2d34-cba4-43a5-942e-1f7dc1ad0295.jsonl`
|
||||
|
||||
## Session Summary
|
||||
|
||||
- 383 saved events over 161.2 seconds.
|
||||
- All three required rooms completed.
|
||||
- No undo or reset was used anywhere.
|
||||
- 185 effective commands and four no-effect commands were logged.
|
||||
- The player remained in the explicitly non-required open chamber for 59.8 seconds and made 88 attempted / 85 effective commands there.
|
||||
|
||||
Room behavior:
|
||||
|
||||
| Room | Time | Attempted commands | Effective commands | Sequence/result |
|
||||
|---|---:|---:|---:|---|
|
||||
| First Pair | 6.9 s | 2 | 2 | `LL`, optimal |
|
||||
| One Holds | 4.4 s | 5 | 5 | `LRRRR`; one exploratory move away, then correction and three wall absorptions |
|
||||
| Transfer | 80.9 s | 94 | 93 | broad state search; completed with occupants on the two sockets in the swapped assignment |
|
||||
| Open Chamber | 59.8 s | 88 | 85 | extensive interaction despite no required result; ended with both occupants exactly overlapped at `(6,7)` |
|
||||
|
||||
## Behavioral Reading
|
||||
|
||||
The first two rooms establish quick control acquisition. In One Holds, the initial left command moved away from the solution; four rights then first restored the initial relation and subsequently used the Self-blocking wall three times. This is consistent with observing and applying independent wall blocking, although telemetry cannot reveal the player's explicit model.
|
||||
|
||||
Transfer was not a short execution of the verified nine-move route. The player explored for 94 commands across roughly 81 seconds and encountered many asymmetric blocks. The path repeatedly shifted the midpoint and returned through several configurations. This could be productive model-building, local trial-and-error, or undirected search. Absence of undo/reset means the player treated every resulting state as recoverable rather than restarting from a failed plan.
|
||||
|
||||
The open chamber is the strongest novel evidence in the project so far, but its cause must not be assumed. The player paused about 14.1 seconds before its first command, then made 88 attempts in under 46 seconds. They deliberately or accidentally ended with Self and Echo occupying the exact same cell `(6,7)`. The late command structure drove the midpoint to the lower boundary, held Echo against the boundary while moving Self right, and then moved Self back left until the pair coincided. This looks goal-directed, but only the player can confirm whether overlap was a self-chosen target, a discovered possibility, input play, or confusion about whether another task existed.
|
||||
|
||||
If overlap was deliberate, it is stronger evidence than the two-tractor workaround in Experiment 003 because the chamber explicitly stated there was no required arrangement. Even then, it would establish a self-generated spatial goal, not automatically enjoyment or a curiosity chain. The next distinction is whether the player valued making the prediction and seeing it work, merely felt compelled to finish an obvious possibility, or was already bored while doing it.
|
||||
|
||||
## Player Report
|
||||
|
||||
The player never felt able to predict the paired system. Rooms 1 and 2 were obvious without requiring thought, while the complexity increase into Transfer was too large. In Transfer they noticed locally promising states and then mostly guessed/brute-forced. On a second look they understood that the yellow center cross could have been used as a reference, but that interpretation did not occur during the required sequence.
|
||||
|
||||
The open-chamber behavior was an intentional self-generated experiment, but the inferred goal from final overlap was wrong. The player was trying to move the yellow cross as close to a wall as possible. The chamber itself made them notice the cross. Answering that question was not satisfying.
|
||||
|
||||
## Final Interpretation
|
||||
|
||||
Experiment 004 did produce a genuine unrequired question, which is behavioral evidence that a stable system can provoke self-directed manipulation. It did not produce the hypothesized reward. The object of curiosity was an abstract marker state, and reaching an extreme had no meaningful consequence or new capability.
|
||||
|
||||
The required sequence also failed as a clean transfer test. Rooms 1 and 2 could be solved from immediate geometry without constructing the midpoint model; the third then demanded planning over that latent state without an intermediate scaffold. The marker was rendered but not functionally legible. This repeats an important finding from Experiment 000: visualization is not the same as strategic legibility.
|
||||
|
||||
Do not conclude that more tutorial rooms would make this fun. Better scaffolding would reduce brute force and allow a cleaner test of predictive mastery, but the player's self-chosen cross experiment already isolated the intrinsic payoff and was not satisfying. Abstract model acquisition is not currently supported as a sufficient activity.
|
||||
|
||||
The strongest cross-experiment model is now that questions become valuable when their answers change agency inside an activity the player already cares about. Curiosity, construction, pressure, and progression have each produced behavior without reliably producing enjoyment when their consequences were terminal, abstract, or strategically universal.
|
||||
10
experiments/004_invariant_rooms/run.sh
Executable file
10
experiments/004_invariant_rooms/run.sh
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
#!/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
|
||||
13
experiments/005_sanctuary_wake/README.md
Normal file
13
experiments/005_sanctuary_wake/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Experiment 005 — Sanctuary Wake
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./experiments/005_sanctuary_wake/run.sh
|
||||
```
|
||||
|
||||
Then open <http://127.0.0.1:8000>.
|
||||
|
||||
Play until the shift ends or until you genuinely want to stop. Use **End shift** if you want to stop early, then **Save JSONL**. The log is written directly into the repository-level `JSONL/` directory.
|
||||
|
||||
Do not read `hypothesis.md` before playing; it contains the private experiment rationale.
|
||||
72
experiments/005_sanctuary_wake/hypothesis.md
Normal file
72
experiments/005_sanctuary_wake/hypothesis.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Experiment 005 Hypothesis — Private Until After Play
|
||||
|
||||
## Reason for the Pivot
|
||||
|
||||
Experiment 004 produced a real self-directed question but no satisfaction. Better room scaffolding would improve predictive play, yet would not address the larger repeated failure: abstract answers and completed predicates have not been valuable by themselves.
|
||||
|
||||
Experiment 002 remains the strongest behavioral signal. Pressure sustained activity and its coupled thermal action briefly caused contextual tactics, but multiplicative upgrades collapsed the run into one wall-blast answer. Experiment 005 removes growth while retaining direct action, pressure, coupled consequences, and recoverable failure.
|
||||
|
||||
## Question
|
||||
|
||||
Does one simple physical tool become enjoyable when it supports improvised rescues, accidents, and weaponization in a changing field—without construction, hidden rules, or stat progression?
|
||||
|
||||
## Core System
|
||||
|
||||
The player directly pilots a small keeper around a sanctuary. Its field has two fully disclosed modes:
|
||||
|
||||
- pull every loose body in range toward the keeper;
|
||||
- push every loose body in range away from the keeper.
|
||||
|
||||
The field does not distinguish rescue pods, raiders, or wreckage. Position and timing determine whether the same input helps or harms.
|
||||
|
||||
- Pods are saved on touching the sanctuary.
|
||||
- Raiders capture pods and damage the sanctuary.
|
||||
- Fast wreckage destroys raiders but also damages the sanctuary.
|
||||
- Saved pods repair sanctuary integrity.
|
||||
- Field charge and player impacts prevent continuous maximal use.
|
||||
- A sanctuary breach vents the field and resets integrity rather than ending the run.
|
||||
|
||||
All laws are stated. There is no discovery test and no permanent build. Randomized spawns define the changing problem while the player always chooses exact actions.
|
||||
|
||||
## Competing Interpretations
|
||||
|
||||
1. Recoverable chaos plus coupled tools creates enjoyable improvisation.
|
||||
2. Pressure creates input persistence but not enjoyment, repeating Experiment 002.
|
||||
3. Rescue/defense supplies a valued consequence missing from prior abstractions.
|
||||
4. Shepherding bodies with radial forces feels like another awkward steering task.
|
||||
5. The system has one dominant positional answer, such as camping at the sanctuary and repelling.
|
||||
6. The absence of power progression removes the main reason Experiment 002 lasted.
|
||||
7. Three minutes is endured because it is a timer, not because the activity is wanted.
|
||||
|
||||
## Strong Evidence
|
||||
|
||||
- a deliberate save after an accident changes the situation;
|
||||
- using wreckage against a raider without an explicit prompt;
|
||||
- relocating because pull/push would affect mixed nearby bodies;
|
||||
- changing priorities based on pod, raider, debris, and sanctuary state;
|
||||
- continuing into optional overtime;
|
||||
- a player-described moment whose outcome was surprising, understandable, and useful.
|
||||
|
||||
## Ambiguous Evidence
|
||||
|
||||
- finishing the timer;
|
||||
- high rescue count;
|
||||
- rapid input;
|
||||
- survival without breaches;
|
||||
- overtime entered merely to check whether anything changes.
|
||||
|
||||
## Failure Signals
|
||||
|
||||
- stationary field spam solves most states;
|
||||
- activity feels like object babysitting or imprecise mouse-less herding;
|
||||
- consequences are unreadable during clutter;
|
||||
- the player waits out or dutifully finishes the timer;
|
||||
- no event creates a memorable decision or recovery.
|
||||
|
||||
## Feedback to Request
|
||||
|
||||
1. When did they first want to stop, and did the shift timer make them continue past that point?
|
||||
2. Was there one situation where they intentionally used or avoided affecting multiple object types at once?
|
||||
3. Did any mistake create an interesting recovery, or did errors only add cleanup/frustration?
|
||||
|
||||
Do not ask whether they “like recoverable chaos”; ask about concrete moments.
|
||||
613
experiments/005_sanctuary_wake/prototype/app.js
vendored
Normal file
613
experiments/005_sanctuary_wake/prototype/app.js
vendored
Normal file
|
|
@ -0,0 +1,613 @@
|
|||
(() => {
|
||||
"use strict";
|
||||
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const canvas = $("#field");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const SHIFT_DURATION = 180;
|
||||
const DOCK = { x: .5, y: .5, r: .073 };
|
||||
const FIELD_RANGE = .235;
|
||||
const KEY_VECTOR = {
|
||||
KeyW: [0, -1], ArrowUp: [0, -1], KeyS: [0, 1], ArrowDown: [0, 1],
|
||||
KeyA: [-1, 0], ArrowLeft: [-1, 0], KeyD: [1, 0], ArrowRight: [1, 0]
|
||||
};
|
||||
const STAGES = [
|
||||
{ at: 0, name: "Quiet Wake" },
|
||||
{ at: 25, name: "Scavengers" },
|
||||
{ at: 75, name: "Debris Front" },
|
||||
{ at: 130, name: "Convergence" }
|
||||
];
|
||||
|
||||
const state = {
|
||||
session: crypto.randomUUID ? crypto.randomUUID() : `session-${Date.now()}`,
|
||||
started: Date.now(), logs: [], active: false, ended: false, overtime: false,
|
||||
shiftTime: 0, lastFrame: performance.now(), stage: 0, snapshotClock: 0,
|
||||
sessionSeed: 0, random: null, objectId: 0, objects: [], particles: [], stars: [],
|
||||
keys: new Set(), fieldMode: null, fieldStartedAt: 0, fieldSource: null,
|
||||
nextPod: 3, nextRaider: 20, nextDebris: 6, bannerTimer: null, toastTimer: null,
|
||||
report: [], integrity: 100, rescued: 0, lost: 0, breaches: 0, broken: 0,
|
||||
player: { x: .5, y: .72, vx: 0, vy: 0, energy: 100, hitCooldown: 0 },
|
||||
render: { size: 1, ox: 0, oy: 0, width: 1, height: 1 }
|
||||
};
|
||||
|
||||
function hashSeed(text) {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < text.length; i++) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 16777619); }
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function mulberry32(seed) {
|
||||
return () => {
|
||||
seed |= 0; seed = seed + 0x6D2B79F5 | 0;
|
||||
let value = Math.imul(seed ^ seed >>> 15, 1 | seed);
|
||||
value = value + Math.imul(value ^ value >>> 7, 61 | value) ^ value;
|
||||
return ((value ^ value >>> 14) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
state.sessionSeed = hashSeed(state.session);
|
||||
state.random = mulberry32(state.sessionSeed);
|
||||
for (let i = 0; i < 90; i++) state.stars.push([state.random(), state.random(), .15 + state.random() * .55]);
|
||||
|
||||
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
|
||||
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const round = value => Math.round(value * 1000) / 1000;
|
||||
const objectCounts = () => state.objects.reduce((counts, object) => {
|
||||
counts[object.kind] = (counts[object.kind] || 0) + 1; return counts;
|
||||
}, { pod: 0, raider: 0, debris: 0 });
|
||||
|
||||
function log(type, data = {}) {
|
||||
const event = {
|
||||
schema: 1, experiment: "005_sanctuary_wake", prototype_revision: 1,
|
||||
session_id: state.session, elapsed_ms: Date.now() - state.started,
|
||||
shift_seconds: round(state.shiftTime), stage: state.overtime ? "overtime" : STAGES[state.stage].name,
|
||||
type, ...data
|
||||
};
|
||||
state.logs.push(JSON.stringify(event));
|
||||
try { localStorage.setItem("sanctuary-wake-last-jsonl", state.logs.join("\n") + "\n"); } catch (_) {}
|
||||
}
|
||||
|
||||
function addReport(text) {
|
||||
state.report.unshift(text);
|
||||
state.report = state.report.slice(0, 4);
|
||||
$("#field-report").innerHTML = state.report.map((line, index) => `<div class="${index === 0 ? "latest" : ""}">${line}</div>`).join("");
|
||||
}
|
||||
|
||||
function begin() {
|
||||
if (state.active || state.ended) return;
|
||||
$("#start-overlay").classList.add("hidden");
|
||||
state.active = true;
|
||||
state.lastFrame = performance.now();
|
||||
spawnInitial();
|
||||
addReport("Recovery shift started. The wake is quiet—for now.");
|
||||
log("shift_started", { seed: state.sessionSeed, viewport: [window.innerWidth, window.innerHeight], initial_objects: objectCounts() });
|
||||
canvas.focus({ preventScroll: true });
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function spawnInitial() {
|
||||
spawn("pod", { x: .12, y: .27, vx: .018, vy: .008 });
|
||||
spawn("pod", { x: .87, y: .34, vx: -.016, vy: .006 });
|
||||
spawn("debris", { x: .16, y: .74, vx: .067, vy: -.02 });
|
||||
spawn("debris", { x: .84, y: .77, vx: -.058, vy: -.026 });
|
||||
}
|
||||
|
||||
function randomEdge(kind) {
|
||||
const edge = Math.floor(state.random() * 4);
|
||||
const margin = .035, along = .1 + state.random() * .8;
|
||||
let x, y, nx, ny;
|
||||
if (edge === 0) { x = along; y = margin; nx = 0; ny = 1; }
|
||||
else if (edge === 1) { x = 1 - margin; y = along; nx = -1; ny = 0; }
|
||||
else if (edge === 2) { x = along; y = 1 - margin; nx = 0; ny = -1; }
|
||||
else { x = margin; y = along; nx = 1; ny = 0; }
|
||||
const spread = (state.random() - .5) * .055;
|
||||
const speed = kind === "debris" ? .055 + state.random() * .075 : .01 + state.random() * .012;
|
||||
return { x, y, vx: nx * speed + (ny !== 0 ? spread : 0), vy: ny * speed + (nx !== 0 ? spread : 0) };
|
||||
}
|
||||
|
||||
function spawn(kind, values = randomEdge(kind)) {
|
||||
const definitions = {
|
||||
pod: { radius: .016, mass: .72 }, raider: { radius: .019, mass: 1.05 }, debris: { radius: .021, mass: 2.15 }
|
||||
};
|
||||
const object = {
|
||||
id: ++state.objectId, kind, radius: definitions[kind].radius, mass: definitions[kind].mass,
|
||||
x: values.x, y: values.y, vx: values.vx, vy: values.vy, age: 0, dockCooldown: 0,
|
||||
fieldInfluence: 0, removed: false
|
||||
};
|
||||
state.objects.push(object);
|
||||
log("object_spawned", { id: object.id, kind, position: [round(object.x), round(object.y)], velocity: [round(object.vx), round(object.vy)], counts: objectCounts() });
|
||||
return object;
|
||||
}
|
||||
|
||||
function updateSpawns() {
|
||||
const stage = state.overtime ? 4 : state.stage;
|
||||
if (state.shiftTime >= state.nextPod) {
|
||||
spawn("pod"); state.nextPod += Math.max(5.2, 8.4 - stage * .65) + state.random() * 2;
|
||||
}
|
||||
if (stage > 0 && state.shiftTime >= state.nextRaider) {
|
||||
spawn("raider"); state.nextRaider += Math.max(3.4, 7.4 - stage * .9) + state.random() * 1.4;
|
||||
}
|
||||
if (state.shiftTime >= state.nextDebris) {
|
||||
spawn("debris");
|
||||
const base = stage === 2 ? 4.7 : stage >= 3 ? 5.5 : 8.5;
|
||||
state.nextDebris += base + state.random() * 2.2;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStage() {
|
||||
if (state.overtime) return;
|
||||
let next = 0;
|
||||
for (let i = 0; i < STAGES.length; i++) if (state.shiftTime >= STAGES[i].at) next = i;
|
||||
if (next === state.stage) return;
|
||||
state.stage = next;
|
||||
const stage = STAGES[next];
|
||||
showBanner(stage.name);
|
||||
addReport(`Conditions changed: ${stage.name}.`);
|
||||
log("stage_changed", { stage_index: next, stage_name: stage.name, counts: objectCounts(), integrity: round(state.integrity) });
|
||||
}
|
||||
|
||||
function updatePlayer(dt) {
|
||||
let dx = 0, dy = 0;
|
||||
for (const code of state.keys) {
|
||||
const vector = KEY_VECTOR[code]; if (!vector) continue;
|
||||
dx += vector[0]; dy += vector[1];
|
||||
}
|
||||
const magnitude = Math.hypot(dx, dy) || 1;
|
||||
if (dx || dy) { state.player.vx += dx / magnitude * .82 * dt; state.player.vy += dy / magnitude * .82 * dt; }
|
||||
const drag = Math.pow(.055, dt);
|
||||
state.player.vx *= drag; state.player.vy *= drag;
|
||||
const speed = Math.hypot(state.player.vx, state.player.vy), max = .29;
|
||||
if (speed > max) { state.player.vx *= max / speed; state.player.vy *= max / speed; }
|
||||
state.player.x = clamp(state.player.x + state.player.vx * dt, .035, .965);
|
||||
state.player.y = clamp(state.player.y + state.player.vy * dt, .035, .965);
|
||||
if (state.player.x <= .035 || state.player.x >= .965) state.player.vx *= -.25;
|
||||
if (state.player.y <= .035 || state.player.y >= .965) state.player.vy *= -.25;
|
||||
state.player.hitCooldown = Math.max(0, state.player.hitCooldown - dt);
|
||||
|
||||
if (state.fieldMode && state.player.energy > 0) {
|
||||
state.player.energy = Math.max(0, state.player.energy - 25 * dt);
|
||||
applyField(dt);
|
||||
if (state.player.energy === 0) stopField("depleted");
|
||||
} else {
|
||||
state.player.energy = Math.min(100, state.player.energy + 18 * dt);
|
||||
}
|
||||
}
|
||||
|
||||
function applyField(dt) {
|
||||
const sign = state.fieldMode === "pull" ? 1 : -1;
|
||||
for (const object of state.objects) {
|
||||
const dx = state.player.x - object.x, dy = state.player.y - object.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length <= .001 || length > FIELD_RANGE) { object.fieldInfluence = 0; continue; }
|
||||
const falloff = 1 - length / FIELD_RANGE;
|
||||
const acceleration = sign * (.46 / object.mass) * (.22 + falloff * .78);
|
||||
object.vx += dx / length * acceleration * dt;
|
||||
object.vy += dy / length * acceleration * dt;
|
||||
object.fieldInfluence = sign * falloff;
|
||||
}
|
||||
}
|
||||
|
||||
function updateObjects(dt) {
|
||||
for (const object of state.objects) {
|
||||
object.age += dt; object.dockCooldown = Math.max(0, object.dockCooldown - dt);
|
||||
if (!state.fieldMode) object.fieldInfluence = 0;
|
||||
if (object.kind === "raider") {
|
||||
const pods = state.objects.filter(candidate => candidate.kind === "pod" && !candidate.removed);
|
||||
const target = pods.length ? pods.reduce((best, candidate) => distance(object, candidate) < distance(object, best) ? candidate : best) : DOCK;
|
||||
steerToward(object, target, .045, dt);
|
||||
} else if (object.kind === "pod") {
|
||||
steerToward(object, DOCK, .0065, dt);
|
||||
}
|
||||
const drag = Math.pow(object.kind === "debris" ? .996 : .982, dt * 60);
|
||||
object.vx *= drag; object.vy *= drag;
|
||||
const speed = Math.hypot(object.vx, object.vy), max = object.kind === "debris" ? .34 : .28;
|
||||
if (speed > max) { object.vx *= max / speed; object.vy *= max / speed; }
|
||||
object.x += object.vx * dt; object.y += object.vy * dt;
|
||||
bounceBoundary(object);
|
||||
}
|
||||
resolveObjectCollisions();
|
||||
resolvePlayerCollisions();
|
||||
resolveDockContacts();
|
||||
state.objects = state.objects.filter(object => !object.removed);
|
||||
}
|
||||
|
||||
function steerToward(object, target, acceleration, dt) {
|
||||
const dx = target.x - object.x, dy = target.y - object.y, length = Math.hypot(dx, dy) || 1;
|
||||
object.vx += dx / length * acceleration * dt;
|
||||
object.vy += dy / length * acceleration * dt;
|
||||
}
|
||||
|
||||
function bounceBoundary(object) {
|
||||
const low = object.radius, high = 1 - object.radius;
|
||||
if (object.x < low) { object.x = low; object.vx = Math.abs(object.vx) * .78; }
|
||||
if (object.x > high) { object.x = high; object.vx = -Math.abs(object.vx) * .78; }
|
||||
if (object.y < low) { object.y = low; object.vy = Math.abs(object.vy) * .78; }
|
||||
if (object.y > high) { object.y = high; object.vy = -Math.abs(object.vy) * .78; }
|
||||
}
|
||||
|
||||
function resolveObjectCollisions() {
|
||||
for (let i = 0; i < state.objects.length; i++) {
|
||||
const a = state.objects[i]; if (a.removed) continue;
|
||||
for (let j = i + 1; j < state.objects.length; j++) {
|
||||
const b = state.objects[j]; if (b.removed) continue;
|
||||
const dx = b.x - a.x, dy = b.y - a.y, length = Math.hypot(dx, dy), overlap = a.radius + b.radius - length;
|
||||
if (overlap <= 0) continue;
|
||||
const nx = length > .0001 ? dx / length : 1, ny = length > .0001 ? dy / length : 0;
|
||||
const relative = Math.abs((b.vx - a.vx) * nx + (b.vy - a.vy) * ny);
|
||||
const raider = a.kind === "raider" ? a : b.kind === "raider" ? b : null;
|
||||
const debris = a.kind === "debris" ? a : b.kind === "debris" ? b : null;
|
||||
if (raider && debris && relative >= .105) {
|
||||
raider.removed = true; state.broken++;
|
||||
burst(raider.x, raider.y, "#f07370", 12);
|
||||
addReport("Fast wreckage broke a raider.");
|
||||
log("raider_broken", { raider_id: raider.id, debris_id: debris.id, relative_speed: round(relative), position: [round(raider.x), round(raider.y)], field_mode: state.fieldMode });
|
||||
continue;
|
||||
}
|
||||
const pod = a.kind === "pod" ? a : b.kind === "pod" ? b : null;
|
||||
if (raider && pod) {
|
||||
pod.removed = true; state.lost++;
|
||||
burst(pod.x, pod.y, "#7bdcf4", 9);
|
||||
addReport("A raider captured an escape pod.");
|
||||
log("pod_lost", { pod_id: pod.id, reason: "captured", raider_id: raider.id, position: [round(pod.x), round(pod.y)] });
|
||||
continue;
|
||||
}
|
||||
separateAndBounce(a, b, nx, ny, overlap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function separateAndBounce(a, b, nx, ny, overlap) {
|
||||
const totalMass = a.mass + b.mass;
|
||||
a.x -= nx * overlap * b.mass / totalMass; a.y -= ny * overlap * b.mass / totalMass;
|
||||
b.x += nx * overlap * a.mass / totalMass; b.y += ny * overlap * a.mass / totalMass;
|
||||
const relative = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
|
||||
if (relative >= 0) return;
|
||||
const impulse = -(1.55) * relative / (1 / a.mass + 1 / b.mass);
|
||||
a.vx -= impulse * nx / a.mass; a.vy -= impulse * ny / a.mass;
|
||||
b.vx += impulse * nx / b.mass; b.vy += impulse * ny / b.mass;
|
||||
}
|
||||
|
||||
function resolvePlayerCollisions() {
|
||||
for (const object of state.objects) {
|
||||
if (object.removed) continue;
|
||||
const dx = object.x - state.player.x, dy = object.y - state.player.y, length = Math.hypot(dx, dy), minimum = object.radius + .021;
|
||||
if (length >= minimum) continue;
|
||||
const nx = length > .0001 ? dx / length : 1, ny = length > .0001 ? dy / length : 0;
|
||||
object.x = state.player.x + nx * minimum; object.y = state.player.y + ny * minimum;
|
||||
const push = .11 + Math.hypot(state.player.vx, state.player.vy) * .35;
|
||||
object.vx += nx * push; object.vy += ny * push;
|
||||
state.player.vx -= nx * .035; state.player.vy -= ny * .035;
|
||||
if ((object.kind === "raider" || object.kind === "debris") && state.player.hitCooldown === 0) {
|
||||
state.player.energy = Math.max(0, state.player.energy - 16);
|
||||
state.player.hitCooldown = .55;
|
||||
burst(state.player.x, state.player.y, "#efb45a", 6);
|
||||
log("keeper_impact", { object_id: object.id, object_kind: object.kind, energy_after: round(state.player.energy), position: [round(state.player.x), round(state.player.y)] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDockContacts() {
|
||||
for (const object of state.objects) {
|
||||
if (object.removed) continue;
|
||||
const dx = object.x - DOCK.x, dy = object.y - DOCK.y, length = Math.hypot(dx, dy);
|
||||
if (length > DOCK.r + object.radius) continue;
|
||||
if (object.kind === "pod") {
|
||||
object.removed = true; state.rescued++; state.integrity = Math.min(100, state.integrity + 6);
|
||||
burst(object.x, object.y, "#7bdcf4", 13);
|
||||
addReport("Escape pod secured. Sanctuary integrity restored.");
|
||||
log("pod_rescued", { pod_id: object.id, rescued: state.rescued, integrity_after: round(state.integrity), velocity: [round(object.vx), round(object.vy)] });
|
||||
} else if (object.kind === "raider") {
|
||||
object.removed = true; damageDock(14, "raider", object);
|
||||
} else {
|
||||
const nx = length > .0001 ? dx / length : 1, ny = length > .0001 ? dy / length : 0;
|
||||
object.x = DOCK.x + nx * (DOCK.r + object.radius + .002);
|
||||
const radial = object.vx * nx + object.vy * ny;
|
||||
if (radial < 0) { object.vx -= 1.75 * radial * nx; object.vy -= 1.75 * radial * ny; }
|
||||
if (object.dockCooldown === 0 && Math.abs(radial) > .065) {
|
||||
object.dockCooldown = .8;
|
||||
damageDock(clamp(Math.abs(radial) * 72, 5, 18), "wreckage", object);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function damageDock(amount, cause, object) {
|
||||
state.integrity -= amount;
|
||||
burst(DOCK.x, DOCK.y, cause === "raider" ? "#f07370" : "#efb45a", 15);
|
||||
addReport(`${cause === "raider" ? "Raider" : "Wreckage"} struck the sanctuary: −${Math.round(amount)} integrity.`);
|
||||
log("sanctuary_hit", { cause, object_id: object.id, damage: round(amount), integrity_after: round(Math.max(0, state.integrity)), velocity: [round(object.vx), round(object.vy)] });
|
||||
if (state.integrity <= 0) breach();
|
||||
}
|
||||
|
||||
function breach() {
|
||||
state.breaches++; state.integrity = 45;
|
||||
for (const object of state.objects) {
|
||||
const dx = object.x - DOCK.x, dy = object.y - DOCK.y, length = Math.hypot(dx, dy) || 1;
|
||||
object.vx += dx / length * .24; object.vy += dy / length * .24;
|
||||
}
|
||||
burst(DOCK.x, DOCK.y, "#f07370", 35);
|
||||
showBanner("SANCTUARY BREACH · EMERGENCY VENT");
|
||||
addReport("Breach contained. Emergency vent cleared the center; recovery continues.");
|
||||
log("sanctuary_breach", { breaches: state.breaches, reset_integrity: state.integrity, counts: objectCounts() });
|
||||
}
|
||||
|
||||
function burst(x, y, color, count) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = state.random() * Math.PI * 2, speed = .035 + state.random() * .16;
|
||||
state.particles.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: .35 + state.random() * .55, maxLife: .9, color });
|
||||
}
|
||||
}
|
||||
|
||||
function updateParticles(dt) {
|
||||
for (const particle of state.particles) {
|
||||
particle.x += particle.vx * dt; particle.y += particle.vy * dt;
|
||||
particle.vx *= Math.pow(.2, dt); particle.vy *= Math.pow(.2, dt); particle.life -= dt;
|
||||
}
|
||||
state.particles = state.particles.filter(particle => particle.life > 0);
|
||||
}
|
||||
|
||||
function startField(mode, source) {
|
||||
if (!state.active || state.player.energy <= 0 || (mode !== "pull" && mode !== "push")) return;
|
||||
if (state.fieldMode === mode) return;
|
||||
if (state.fieldMode) stopField("mode_changed");
|
||||
state.fieldMode = mode; state.fieldStartedAt = state.shiftTime; state.fieldSource = source;
|
||||
$("#pull-button").classList.toggle("active", mode === "pull");
|
||||
$("#push-button").classList.toggle("active", mode === "push");
|
||||
log("field_started", { mode, source, energy: round(state.player.energy), position: [round(state.player.x), round(state.player.y)], counts: objectCounts() });
|
||||
}
|
||||
|
||||
function stopField(reason) {
|
||||
if (!state.fieldMode) return;
|
||||
log("field_ended", {
|
||||
mode: state.fieldMode, source: state.fieldSource, reason,
|
||||
duration_seconds: round(state.shiftTime - state.fieldStartedAt), energy: round(state.player.energy),
|
||||
position: [round(state.player.x), round(state.player.y)], counts: objectCounts()
|
||||
});
|
||||
state.fieldMode = null; state.fieldSource = null;
|
||||
$("#pull-button").classList.remove("active"); $("#push-button").classList.remove("active");
|
||||
}
|
||||
|
||||
function endShift(early) {
|
||||
if (state.ended || (!state.active && !state.overtime)) return;
|
||||
stopField("shift_ended"); state.active = false; state.ended = true;
|
||||
log("shift_ended", { early, rescued: state.rescued, lost: state.lost, breaches: state.breaches, broken: state.broken, integrity: round(state.integrity), counts: objectCounts() });
|
||||
$("#summary-title").textContent = early ? "Shift ended early" : "Measured shift complete";
|
||||
$("#summary-stats").innerHTML = [
|
||||
["RESCUED", state.rescued], ["LOST", state.lost], ["BREACHES", state.breaches], ["RAIDERS BROKEN", state.broken]
|
||||
].map(([label, value]) => `<div><span>${label}</span><b>${value}</b></div>`).join("");
|
||||
$("#summary-copy").textContent = early ? "You ended the measured shift. Continue only if you want to keep interacting." : "The measured shift is over. Continuing grants no upgrade or new tool.";
|
||||
$("#summary-overlay").classList.remove("hidden");
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function startOvertime() {
|
||||
if (!state.ended) return;
|
||||
state.ended = false; state.overtime = true; state.active = true;
|
||||
$("#summary-overlay").classList.add("hidden");
|
||||
state.nextPod = state.shiftTime + 2; state.nextRaider = state.shiftTime + 1.5; state.nextDebris = state.shiftTime + 3;
|
||||
addReport("Overtime started. No new tools or upgrades are coming.");
|
||||
showBanner("OVERTIME");
|
||||
log("overtime_started", { stats: { rescued: state.rescued, lost: state.lost, breaches: state.breaches, broken: state.broken }, counts: objectCounts() });
|
||||
canvas.focus({ preventScroll: true }); updateUI();
|
||||
}
|
||||
|
||||
async function saveLog() {
|
||||
log("save_requested", { event_count_before_save: state.logs.length });
|
||||
const filename = `sanctuary-wake-${state.session}.jsonl`;
|
||||
const payload = state.logs.join("\n") + "\n";
|
||||
try {
|
||||
const response = await fetch("/api/playtest-log", {
|
||||
method: "POST", headers: { "Content-Type": "application/x-ndjson", "X-Playtest-Filename": filename }, body: payload
|
||||
});
|
||||
if (!response.ok) throw new Error(`server returned ${response.status}`);
|
||||
const result = await response.json();
|
||||
log("log_saved", { path: result.path, saved_events: result.events });
|
||||
showToast(`Saved ${result.events} events to ${result.path}`);
|
||||
} catch (error) {
|
||||
const blob = new Blob([payload], { type: "application/x-ndjson" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob); link.download = filename; link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
||||
log("save_fallback_download", { message: String(error) });
|
||||
showToast("Server save unavailable; downloaded the JSONL instead.");
|
||||
}
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
if (!state.active) return;
|
||||
state.shiftTime += dt;
|
||||
updateStage(); updateSpawns(); updatePlayer(dt); updateObjects(dt); updateParticles(dt);
|
||||
state.snapshotClock += dt;
|
||||
if (state.snapshotClock >= 2) {
|
||||
state.snapshotClock -= 2;
|
||||
log("field_snapshot", {
|
||||
player: [round(state.player.x), round(state.player.y)], velocity: [round(state.player.vx), round(state.player.vy)],
|
||||
energy: round(state.player.energy), field_mode: state.fieldMode, integrity: round(state.integrity),
|
||||
rescued: state.rescued, lost: state.lost, breaches: state.breaches, broken: state.broken, counts: objectCounts()
|
||||
});
|
||||
}
|
||||
if (!state.overtime && state.shiftTime >= SHIFT_DURATION) endShift(false);
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
const remaining = Math.max(0, SHIFT_DURATION - state.shiftTime);
|
||||
const remainingSeconds = Math.ceil(remaining);
|
||||
$("#time-left").textContent = state.overtime ? "OVERTIME" : `${Math.floor(remainingSeconds / 60)}:${String(remainingSeconds % 60).padStart(2, "0")}`;
|
||||
$("#charge").textContent = `${Math.round(state.player.energy)}%`;
|
||||
$("#rescued").textContent = state.rescued; $("#lost").textContent = state.lost;
|
||||
$("#breaches").textContent = state.breaches; $("#broken").textContent = state.broken;
|
||||
$("#integrity-value").textContent = `${Math.round(state.integrity)}%`;
|
||||
$("#integrity-bar").style.transform = `scaleX(${clamp(state.integrity / 100, 0, 1)})`;
|
||||
$("#stage-name").textContent = state.overtime ? "Overtime" : STAGES[state.stage].name;
|
||||
$("#end-shift").disabled = !state.active;
|
||||
}
|
||||
|
||||
function showBanner(message) {
|
||||
const banner = $("#banner"); banner.textContent = message; banner.classList.add("show");
|
||||
clearTimeout(state.bannerTimer); state.bannerTimer = setTimeout(() => banner.classList.remove("show"), 1500);
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const toast = $("#toast"); toast.textContent = message; toast.classList.add("show");
|
||||
clearTimeout(state.toastTimer); state.toastTimer = setTimeout(() => toast.classList.remove("show"), 2800);
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const rect = canvas.getBoundingClientRect(), dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.max(1, Math.round(rect.width * dpr)); canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const size = Math.min(rect.width, rect.height) * .91;
|
||||
state.render = { size, ox: (rect.width - size) / 2, oy: (rect.height - size) / 2, width: rect.width, height: rect.height };
|
||||
}
|
||||
|
||||
function screen(point) {
|
||||
return { x: state.render.ox + point.x * state.render.size, y: state.render.oy + point.y * state.render.size };
|
||||
}
|
||||
|
||||
function draw(time) {
|
||||
const { width, height, size, ox, oy } = state.render;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(ox, oy, size, size); ctx.clip();
|
||||
ctx.fillStyle = "#071016"; ctx.fillRect(ox, oy, size, size);
|
||||
for (const [x, y, alpha] of state.stars) {
|
||||
ctx.fillStyle = `rgba(151, 190, 199, ${alpha})`; ctx.fillRect(ox + x * size, oy + y * size, 1.2, 1.2);
|
||||
}
|
||||
drawGrid(); drawDock(time); drawField();
|
||||
for (const object of state.objects) drawObject(object);
|
||||
for (const particle of state.particles) drawParticle(particle);
|
||||
drawPlayer();
|
||||
ctx.restore();
|
||||
ctx.strokeStyle = "#38505a"; ctx.lineWidth = 1; ctx.strokeRect(ox + .5, oy + .5, size - 1, size - 1);
|
||||
}
|
||||
|
||||
function drawGrid() {
|
||||
const { size, ox, oy } = state.render;
|
||||
ctx.strokeStyle = "rgba(87, 126, 137, .09)"; ctx.lineWidth = 1;
|
||||
for (let i = 1; i < 10; i++) {
|
||||
ctx.beginPath(); ctx.moveTo(ox + i * size / 10, oy); ctx.lineTo(ox + i * size / 10, oy + size); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(ox, oy + i * size / 10); ctx.lineTo(ox + size, oy + i * size / 10); ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawDock(time) {
|
||||
const center = screen(DOCK), radius = DOCK.r * state.render.size;
|
||||
ctx.save(); ctx.translate(center.x, center.y);
|
||||
ctx.fillStyle = "rgba(65, 133, 115, .16)"; ctx.strokeStyle = state.integrity < 30 ? "#f07370" : "#6ee4bd";
|
||||
ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
ctx.strokeStyle = "rgba(110, 228, 189, .35)"; ctx.lineWidth = 1.5; ctx.setLineDash([5, 7]);
|
||||
ctx.rotate(time / 3200); ctx.beginPath(); ctx.arc(0, 0, radius * .72, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
|
||||
for (let i = 0; i < Math.min(12, state.rescued); i++) {
|
||||
const angle = i / 12 * Math.PI * 2; ctx.fillStyle = "#7bdcf4";
|
||||
ctx.beginPath(); ctx.arc(Math.cos(angle) * radius * .48, Math.sin(angle) * radius * .48, 2.3, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
ctx.fillStyle = "#d7f4e9"; ctx.font = `800 ${Math.max(8, radius * .2)}px ui-monospace, monospace`;
|
||||
ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("SANCTUARY", 0, 1);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawField() {
|
||||
if (!state.fieldMode) return;
|
||||
const player = screen(state.player), radius = FIELD_RANGE * state.render.size;
|
||||
const color = state.fieldMode === "pull" ? "123,220,244" : "239,180,90";
|
||||
const gradient = ctx.createRadialGradient(player.x, player.y, 0, player.x, player.y, radius);
|
||||
gradient.addColorStop(0, `rgba(${color}, .13)`); gradient.addColorStop(.65, `rgba(${color}, .06)`); gradient.addColorStop(1, `rgba(${color}, 0)`);
|
||||
ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(player.x, player.y, radius, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = `rgba(${color}, .5)`; ctx.lineWidth = 1.5; ctx.setLineDash([7, 7]);
|
||||
ctx.beginPath(); ctx.arc(player.x, player.y, radius, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
|
||||
for (const object of state.objects) {
|
||||
if (!object.fieldInfluence) continue;
|
||||
const point = screen(object); ctx.strokeStyle = `rgba(${color}, ${.15 + Math.abs(object.fieldInfluence) * .45})`;
|
||||
ctx.beginPath(); ctx.moveTo(player.x, player.y); ctx.lineTo(point.x, point.y); ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawObject(object) {
|
||||
const point = screen(object), radius = object.radius * state.render.size;
|
||||
ctx.save(); ctx.translate(point.x, point.y);
|
||||
if (object.kind === "pod") {
|
||||
ctx.shadowColor = "#7bdcf4"; ctx.shadowBlur = 10; ctx.fillStyle = "#173844"; ctx.strokeStyle = "#7bdcf4"; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = "#d9f8ff"; ctx.beginPath(); ctx.arc(0, 0, radius * .3, 0, Math.PI * 2); ctx.fill();
|
||||
} else if (object.kind === "raider") {
|
||||
ctx.rotate(Math.atan2(object.vy, object.vx) + Math.PI / 4); ctx.shadowColor = "#f07370"; ctx.shadowBlur = 10;
|
||||
ctx.fillStyle = "#401e24"; ctx.strokeStyle = "#f07370"; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.rect(-radius * .72, -radius * .72, radius * 1.44, radius * 1.44); ctx.fill(); ctx.stroke();
|
||||
ctx.strokeStyle = "#ffb0a7"; ctx.beginPath(); ctx.moveTo(-radius * .45, 0); ctx.lineTo(radius * .45, 0); ctx.stroke();
|
||||
} else {
|
||||
ctx.rotate(object.id * 1.71 + object.age * .15); ctx.fillStyle = "#443621"; ctx.strokeStyle = "#efb45a"; ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = i / 6 * Math.PI * 2, uneven = i % 2 ? .78 : 1;
|
||||
const x = Math.cos(angle) * radius * uneven, y = Math.sin(angle) * radius * uneven;
|
||||
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
||||
}
|
||||
ctx.closePath(); ctx.fill(); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPlayer() {
|
||||
const point = screen(state.player), radius = .022 * state.render.size;
|
||||
ctx.save(); ctx.translate(point.x, point.y); ctx.rotate(Math.atan2(state.player.vy, state.player.vx) + Math.PI / 2);
|
||||
ctx.shadowColor = state.player.hitCooldown ? "#f07370" : "#6ee4bd"; ctx.shadowBlur = 14;
|
||||
ctx.fillStyle = "#173b33"; ctx.strokeStyle = state.player.hitCooldown ? "#f07370" : "#6ee4bd"; ctx.lineWidth = 2.5;
|
||||
ctx.beginPath(); ctx.moveTo(0, -radius); ctx.lineTo(radius * .8, radius * .85); ctx.lineTo(0, radius * .56); ctx.lineTo(-radius * .8, radius * .85); ctx.closePath(); ctx.fill(); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawParticle(particle) {
|
||||
const point = screen(particle); ctx.globalAlpha = clamp(particle.life / particle.maxLife, 0, 1);
|
||||
ctx.fillStyle = particle.color; ctx.beginPath(); ctx.arc(point.x, point.y, 2.2, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function frame(now) {
|
||||
const dt = Math.min(.035, Math.max(0, (now - state.lastFrame) / 1000)); state.lastFrame = now;
|
||||
update(dt); draw(now); requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", event => {
|
||||
if (!KEY_VECTOR[event.code]) return;
|
||||
event.preventDefault();
|
||||
if (!state.keys.has(event.code)) {
|
||||
state.keys.add(event.code);
|
||||
if (state.active) log("movement_key_down", { key: event.code, active_keys: [...state.keys] });
|
||||
}
|
||||
});
|
||||
document.addEventListener("keyup", event => {
|
||||
if (!KEY_VECTOR[event.code]) return;
|
||||
event.preventDefault(); state.keys.delete(event.code);
|
||||
if (state.active) log("movement_key_up", { key: event.code, active_keys: [...state.keys] });
|
||||
});
|
||||
canvas.addEventListener("pointerdown", event => {
|
||||
event.preventDefault();
|
||||
try { canvas.setPointerCapture?.(event.pointerId); } catch (_) {}
|
||||
canvas.focus({ preventScroll: true });
|
||||
if (event.button === 0) startField("pull", "canvas");
|
||||
if (event.button === 2) startField("push", "canvas");
|
||||
});
|
||||
window.addEventListener("pointerup", event => {
|
||||
if ((event.button === 0 && state.fieldMode === "pull") || (event.button === 2 && state.fieldMode === "push")) stopField("pointer_up");
|
||||
});
|
||||
canvas.addEventListener("contextmenu", event => event.preventDefault());
|
||||
document.addEventListener("contextmenu", event => event.preventDefault());
|
||||
document.addEventListener("selectstart", event => event.preventDefault());
|
||||
|
||||
for (const [selector, mode] of [["#pull-button", "pull"], ["#push-button", "push"]]) {
|
||||
const button = $(selector);
|
||||
button.addEventListener("pointerdown", event => { event.preventDefault(); startField(mode, "button"); });
|
||||
button.addEventListener("pointerup", () => { if (state.fieldMode === mode) stopField("pointer_up"); });
|
||||
button.addEventListener("pointercancel", () => { if (state.fieldMode === mode) stopField("pointer_cancel"); });
|
||||
}
|
||||
|
||||
$("#start-overlay").addEventListener("pointerdown", begin);
|
||||
$("#end-shift").addEventListener("click", () => endShift(true));
|
||||
$("#save").addEventListener("click", saveLog); $("#save-summary").addEventListener("click", saveLog);
|
||||
$("#overtime").addEventListener("click", startOvertime);
|
||||
window.addEventListener("blur", () => { state.keys.clear(); stopField("window_blur"); });
|
||||
document.addEventListener("visibilitychange", () => log("visibility_changed", { visibility: document.visibilityState, active: state.active, stats: { rescued: state.rescued, lost: state.lost, breaches: state.breaches } }));
|
||||
window.addEventListener("beforeunload", () => log("session_unload", { active: state.active, stats: { rescued: state.rescued, lost: state.lost, breaches: state.breaches, broken: state.broken } }));
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
resize(); updateUI(); requestAnimationFrame(frame);
|
||||
})();
|
||||
97
experiments/005_sanctuary_wake/prototype/index.html
Normal file
97
experiments/005_sanctuary_wake/prototype/index.html
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sanctuary Wake — Experiment 005</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="title"><span>EXPERIMENT 005</span><h1>Sanctuary Wake</h1></div>
|
||||
<div class="shift-stage"><span id="stage-kicker">SHIFT STAGE</span><b id="stage-name">Quiet Wake</b></div>
|
||||
<div class="top-actions">
|
||||
<button id="end-shift" type="button">End shift</button>
|
||||
<button id="save" type="button">Save JSONL</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<aside>
|
||||
<section class="brief">
|
||||
<span>THREE-MINUTE RECOVERY SHIFT</span>
|
||||
<h2>Keep the sanctuary working.</h2>
|
||||
<p>Bring escape pods into the sanctuary. Keep raiders and fast wreckage away. A breach is costly, but the shift continues.</p>
|
||||
</section>
|
||||
|
||||
<section class="controls">
|
||||
<h3>Keeper controls</h3>
|
||||
<p><kbd>WASD</kbd><span>Move the keeper.</span></p>
|
||||
<p><kbd>LEFT HOLD</kbd><span>Pull every loose body in range.</span></p>
|
||||
<p><kbd>RIGHT HOLD</kbd><span>Push every loose body in range.</span></p>
|
||||
<div class="field-buttons">
|
||||
<button id="pull-button" type="button"><i>↘</i><span><b>PULL</b><small>toward keeper</small></span></button>
|
||||
<button id="push-button" type="button"><i>↗</i><span><b>PUSH</b><small>away from keeper</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="laws">
|
||||
<h3>Field consequences</h3>
|
||||
<div class="law pod"><i>●</i><p><b>ESCAPE POD</b><span>Sanctuary contact rescues it and repairs integrity.</span></p></div>
|
||||
<div class="law raider"><i>◆</i><p><b>RAIDER</b><span>Captures pods. Sanctuary impact damages integrity.</span></p></div>
|
||||
<div class="law debris"><i>⬟</i><p><b>WRECKAGE</b><span>A fast hit destroys a raider. It can also damage the sanctuary.</span></p></div>
|
||||
<div class="law neutral"><i>◎</i><p><b>ONE FIELD</b><span>Pull and push affect all three kinds. Mass changes acceleration.</span></p></div>
|
||||
</section>
|
||||
|
||||
<section class="metrics">
|
||||
<div><span>SHIFT LEFT</span><b id="time-left">3:00</b></div>
|
||||
<div><span>FIELD CHARGE</span><b id="charge">100%</b></div>
|
||||
<div><span>RESCUED</span><b id="rescued">0</b></div>
|
||||
<div><span>LOST</span><b id="lost">0</b></div>
|
||||
<div><span>BREACHES</span><b id="breaches">0</b></div>
|
||||
<div><span>RAIDERS BROKEN</span><b id="broken">0</b></div>
|
||||
</section>
|
||||
|
||||
<section class="integrity">
|
||||
<div><span>SANCTUARY INTEGRITY</span><b id="integrity-value">100%</b></div>
|
||||
<div class="bar"><i id="integrity-bar"></i></div>
|
||||
</section>
|
||||
|
||||
<section class="notes">
|
||||
<h3>Field report</h3>
|
||||
<div id="field-report">Awaiting shift start.</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="playfield">
|
||||
<canvas id="field" tabindex="0" aria-label="Sanctuary recovery field"></canvas>
|
||||
<div id="banner" class="banner" aria-live="polite"></div>
|
||||
<div class="canvas-help">Move with WASD · hold left mouse to pull · hold right mouse to push</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="start-overlay" class="overlay">
|
||||
<div>
|
||||
<span>THE FIELD DOES NOT CHOOSE FOR YOU</span>
|
||||
<b>Pull and push affect pods, raiders, and wreckage together.</b>
|
||||
<p>The shift lasts three minutes. Breaches vent the field instead of ending the run. Click to begin.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="summary-overlay" class="overlay hidden">
|
||||
<div>
|
||||
<span>SHIFT REPORT</span>
|
||||
<b id="summary-title">Shift complete</b>
|
||||
<div id="summary-stats" class="summary-stats"></div>
|
||||
<p id="summary-copy">The measured shift is over.</p>
|
||||
<div class="summary-actions">
|
||||
<button id="save-summary" type="button">Save JSONL</button>
|
||||
<button id="overtime" type="button">Continue without a timer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" role="status"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
116
experiments/005_sanctuary_wake/prototype/style.css
Normal file
116
experiments/005_sanctuary_wake/prototype/style.css
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #05090d;
|
||||
--panel: #0b141a;
|
||||
--panel2: #111d24;
|
||||
--line: #293b45;
|
||||
--text: #e8f1ef;
|
||||
--muted: #879b9f;
|
||||
--mint: #6ee4bd;
|
||||
--cyan: #7bdcf4;
|
||||
--red: #f07370;
|
||||
--amber: #efb45a;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; margin: 0; }
|
||||
body { overflow: hidden; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, sans-serif; user-select: none; }
|
||||
button { border: 1px solid var(--line); border-radius: 7px; background: #13212a; color: var(--text); font: inherit; cursor: pointer; touch-action: none; }
|
||||
button:hover { border-color: #56717a; background: #1a2b35; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin: 0; font-size: 20px; }
|
||||
h2 { margin: 5px 0 0; font-size: 18px; }
|
||||
h3 { margin: 0 0 9px; color: #a9bbb9; font-size: 9px; letter-spacing: .14em; text-transform: uppercase; }
|
||||
|
||||
header { height: 64px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 14px; padding: 8px 14px; border-bottom: 1px solid var(--line); background: #081015; }
|
||||
.title > span, .brief > span, .overlay span { display: block; color: var(--mint); font-size: 8px; font-weight: 850; letter-spacing: .18em; }
|
||||
.shift-stage { min-width: 170px; text-align: center; }
|
||||
.shift-stage span, .shift-stage b { display: block; }
|
||||
.shift-stage span { color: var(--muted); font-size: 7px; letter-spacing: .15em; }
|
||||
.shift-stage b { margin-top: 3px; color: var(--amber); font-size: 12px; }
|
||||
.top-actions { display: flex; justify-content: flex-end; gap: 7px; }
|
||||
.top-actions button { padding: 8px 11px; }
|
||||
|
||||
main { height: calc(100vh - 64px); min-height: 0; display: grid; grid-template-columns: clamp(285px, 22vw, 345px) minmax(0, 1fr); overflow: hidden; }
|
||||
aside { min-height: 0; overflow-y: auto; scrollbar-gutter: stable; padding: 12px 14px; border-right: 1px solid var(--line); background: #081015; }
|
||||
aside section { margin-bottom: 10px; }
|
||||
.brief { padding: 11px; border: 1px solid #355a4e; border-radius: 9px; background: linear-gradient(145deg, #10241e, #101920); }
|
||||
.brief p { margin: 8px 0 0; color: #b8c7c4; font-size: 10px; line-height: 1.45; }
|
||||
.controls { padding: 9px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); }
|
||||
.controls > p { display: grid; grid-template-columns: 76px 1fr; align-items: center; gap: 7px; margin: 6px 0; color: var(--muted); font-size: 8px; }
|
||||
kbd { padding: 3px 5px; border: 1px solid #53666c; border-bottom-width: 2px; border-radius: 4px; background: #17252d; color: var(--text); font: 8px ui-monospace, monospace; text-align: center; }
|
||||
.field-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 7px; }
|
||||
.field-buttons button { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 6px; }
|
||||
.field-buttons button.active { border-color: var(--cyan); background: #12313a; box-shadow: inset 0 0 18px #65d8f422; }
|
||||
.field-buttons i { color: var(--cyan); font-size: 17px; font-style: normal; }
|
||||
.field-buttons b, .field-buttons small { display: block; }
|
||||
.field-buttons b { font-size: 9px; }
|
||||
.field-buttons small { margin-top: 2px; color: var(--muted); font-size: 7px; }
|
||||
|
||||
.laws { display: grid; gap: 4px; }
|
||||
.laws h3 { margin-bottom: 2px; }
|
||||
.law { display: grid; grid-template-columns: 27px 1fr; align-items: center; gap: 7px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
|
||||
.law i { font-style: normal; font-size: 17px; text-align: center; }
|
||||
.law p { margin: 0; }
|
||||
.law b, .law span { display: block; }
|
||||
.law b { font-size: 8px; letter-spacing: .08em; }
|
||||
.law span { margin-top: 2px; color: var(--muted); font-size: 7px; line-height: 1.35; }
|
||||
.law.pod i { color: var(--cyan); }
|
||||
.law.raider i { color: var(--red); }
|
||||
.law.debris i { color: var(--amber); }
|
||||
.law.neutral i { color: var(--mint); }
|
||||
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
||||
.metrics div { min-width: 0; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
|
||||
.metrics span, .metrics b { display: block; }
|
||||
.metrics span { overflow: hidden; color: var(--muted); font-size: 6px; letter-spacing: .08em; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.metrics b { margin-top: 4px; color: #d8e4e1; font: 10px ui-monospace, monospace; }
|
||||
.integrity { padding: 8px 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel); }
|
||||
.integrity > div:first-child { display: flex; justify-content: space-between; align-items: center; }
|
||||
.integrity span { color: var(--muted); font-size: 7px; letter-spacing: .1em; }
|
||||
.integrity b { font: 10px ui-monospace, monospace; }
|
||||
.bar { height: 7px; margin-top: 8px; overflow: hidden; border-radius: 7px; background: #1b272d; }
|
||||
.bar i { display: block; width: 100%; height: 100%; background: linear-gradient(90deg, var(--red), var(--amber), var(--mint)); transform-origin: left; }
|
||||
.notes { min-height: 55px; padding: 7px 9px; border-left: 2px solid #415a61; color: #a6b8b6; font-size: 8px; line-height: 1.4; }
|
||||
.notes h3 { margin-bottom: 6px; }
|
||||
|
||||
.playfield { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: radial-gradient(circle at 50% 48%, #11212a, #05090c 73%); }
|
||||
#field { display: block; width: 100%; height: 100%; outline: none; touch-action: none; }
|
||||
.canvas-help { position: absolute; z-index: 3; left: 50%; bottom: 13px; translate: -50%; padding: 7px 11px; border-radius: 6px; background: #071015dc; color: #809399; font-size: 9px; pointer-events: none; }
|
||||
.banner { position: absolute; z-index: 5; left: 50%; top: 20px; translate: -50% -8px; min-width: 250px; padding: 10px 16px; border: 1px solid #4a7569; border-radius: 8px; background: #10231edc; color: var(--mint); opacity: 0; text-align: center; pointer-events: none; transition: .2s; font-size: 11px; font-weight: 750; }
|
||||
.banner.show { opacity: 1; translate: -50% 0; }
|
||||
|
||||
.overlay { position: fixed; z-index: 20; inset: 64px 0 0 clamp(285px, 22vw, 345px); display: grid; place-items: center; background: #030709d9; }
|
||||
.overlay.hidden { display: none; }
|
||||
.overlay > div { width: min(590px, 87%); padding: 23px 27px; border: 1px solid #426e62; border-radius: 10px; background: #101b20f3; text-align: center; }
|
||||
.overlay b, .overlay p { display: block; }
|
||||
.overlay > div > b { margin-top: 7px; font-size: 15px; }
|
||||
.overlay p { margin: 9px 0 0; color: var(--muted); font-size: 10px; line-height: 1.45; }
|
||||
.summary-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 15px; }
|
||||
.summary-stats div { padding: 9px; border: 1px solid var(--line); border-radius: 6px; background: #0a1318; }
|
||||
.summary-stats span, .summary-stats b { display: block; }
|
||||
.summary-stats span { color: var(--muted); font-size: 7px; }
|
||||
.summary-stats b { margin-top: 4px; color: var(--mint); font: 13px ui-monospace, monospace; }
|
||||
.summary-actions { display: flex; justify-content: center; gap: 8px; margin-top: 15px; }
|
||||
.summary-actions button { padding: 9px 13px; }
|
||||
#toast { position: fixed; z-index: 30; left: calc(50% + 150px); bottom: 20px; translate: -50% 12px; max-width: 500px; padding: 10px 14px; border: 1px solid #4c7368; border-radius: 8px; background: #10231e; box-shadow: 0 10px 30px #000b; opacity: 0; pointer-events: none; transition: .2s; font-size: 10px; }
|
||||
#toast.show { opacity: 1; translate: -50% 0; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
header { grid-template-columns: auto 1fr auto; padding-inline: 8px; }
|
||||
.title h1 { font-size: 15px; }
|
||||
.title > span { font-size: 6px; }
|
||||
.top-actions button { padding: 7px; font-size: 9px; }
|
||||
main { grid-template-columns: 270px minmax(0, 1fr); }
|
||||
.overlay { left: 270px; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body { overflow: auto; }
|
||||
header { height: auto; grid-template-columns: 1fr; }
|
||||
.shift-stage { text-align: left; }
|
||||
.top-actions { justify-content: flex-start; }
|
||||
main { height: auto; grid-template-columns: 1fr; overflow: visible; }
|
||||
.playfield { height: min(80vh, 650px); }
|
||||
.overlay { display: none; }
|
||||
#toast { left: 50%; }
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# Experiment 005 Preliminary Analysis — Session 69cdaf46
|
||||
|
||||
Status: telemetry and player report analyzed.
|
||||
|
||||
Source: `JSONL/sanctuary-wake-69cdaf46-11e4-498f-abe0-ee791a81fad4.jsonl`
|
||||
|
||||
## Session Summary
|
||||
|
||||
- 1,019 saved events over 245 seconds of wall time.
|
||||
- The measured shift ran to its automatic end at 180.0 simulation seconds.
|
||||
- The player saved about three seconds after the summary appeared and did not enter overtime.
|
||||
- Final state: 6 pods rescued, 18 lost, 17 raiders broken, no breaches, 41% sanctuary integrity.
|
||||
- 289 movement key-down events and 289 matching releases indicate continuous active piloting rather than waiting out the timer.
|
||||
- 89 field sessions: 60 pull and 29 push, totaling 59.4 active seconds.
|
||||
- 53/89 field sessions ended through charge depletion rather than release.
|
||||
- 43 keeper impacts occurred.
|
||||
|
||||
## Time Course
|
||||
|
||||
- First keeper/wreckage impact: 3.0 s.
|
||||
- First rescue: 28.8 s.
|
||||
- First pod loss: 32.8 s.
|
||||
- First raider destruction: two simultaneous breaks at 47.8 s.
|
||||
- Sanctuary remained at 100% until 150 s, then took six impacts during the final 30 seconds and ended at 41%.
|
||||
- No emergency breach occurred, so the intended recoverable-failure event was never directly tested.
|
||||
|
||||
The player used pull and push in every 30-second interval, but pull increasingly dominated. Field time stayed near 9–11 seconds per interval even as session count increased; depletion endings rose from none in the first 30 seconds to 13/16 in the final interval. A plausible interpretation is that later use became repeated short attempts constrained by an often-empty charge meter rather than deliberation about when to stop. The player report must distinguish intentional rationing, confusing responsiveness, and simple input frustration.
|
||||
|
||||
Seventeen wreckage/raider kills prove the advertised collision interaction occurred. Five happened while a field was actively logged; the remainder happened under residual momentum or without immediate field input. Telemetry cannot determine which, if any, were deliberately set up by the player.
|
||||
|
||||
## Competing Explanations
|
||||
|
||||
1. The core activity—radially shepherding multiple autonomous bodies—was intrinsically unpleasant or illegible.
|
||||
2. Pull/push created outcomes, but clutter and shared influence prevented intentional multi-object decisions.
|
||||
3. The charge meter interrupted actions without creating a worthwhile resource decision.
|
||||
4. Rescue stakes were too abstract to create care; losses became noise rather than emotionally meaningful failures.
|
||||
5. The player was actively solving conventional pressure but continued only because a three-minute timer defined completion.
|
||||
6. Interesting recoveries never emerged: pods disappeared on contact and sanctuary damage arrived late, leaving little reversible intermediate failure.
|
||||
7. Some wreckage kills were intentional and locally satisfying, but the surrounding activity overwhelmed that positive moment.
|
||||
|
||||
## Player Report
|
||||
|
||||
The player wanted to stop almost immediately. They understood the instructions, but the activity did not seem interesting. Later wreckage accumulation made the situation feel impossible.
|
||||
|
||||
A few wreckage kills were deliberate. Once the field filled, however, nearly any intervention risked pushing pods into raiders or wreckage into the sanctuary. Attacking a raider felt inseparable from harming the player's own objective. Charge was an annoyance that made an already impossible situation harder, not a meaningful resource decision.
|
||||
|
||||
Controls were fine and the chaos was readable. The player did not care what happened. The decisive complaint was not perceptual overload but **unmanageable state**: consequences were visible, yet the available actions no longer offered credible improvement.
|
||||
|
||||
## Final Interpretation
|
||||
|
||||
Experiment 005 did not create recoverable chaos. It created accumulating contamination. Pods were removed instantly when caught, wreckage persisted, and the indiscriminate radial field increasingly coupled every local intervention to collateral damage. A recoverable failure should generate a new tractable problem; here failures reduced the future action space.
|
||||
|
||||
This distinction matters. Reducing spawn rates or increasing charge would make the activity easier, but would not address the player's immediate lack of interest or indifference to rescue outcomes. Controls, feedback, and collision readability are specifically exonerated as primary causes.
|
||||
|
||||
The experiment weakens the idea that pressure, systemic coupling, or a nominal protect/rescue objective can supply motivation. Coupling is useful only while the player can isolate leverage within it. “Everything affects everything” becomes paralysis rather than emergence when tools cannot select, sequence, or bound consequences.
|
||||
|
||||
The next probe should not be another custodial field-management game. A higher-information contrast is **assertive, selective agency**: direct movement and targeted verbs in a compact combat or traversal activity where the player creates outcomes rather than babysitting an accumulating state. Keep failure local and resettable. Do not add upgrades until the base actions demonstrate value.
|
||||
3
experiments/005_sanctuary_wake/results/README.md
Normal file
3
experiments/005_sanctuary_wake/results/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Experiment 005 Results
|
||||
|
||||
No playtest has been analyzed yet. JSONL telemetry saves to the repository-level `JSONL/` directory.
|
||||
10
experiments/005_sanctuary_wake/run.sh
Executable file
10
experiments/005_sanctuary_wake/run.sh
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
#!/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
|
||||
11
experiments/006_breakline/README.md
Normal file
11
experiments/006_breakline/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Experiment 006 — Breakline
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./experiments/006_breakline/run.sh
|
||||
```
|
||||
|
||||
Then open <http://127.0.0.1:8000>.
|
||||
|
||||
All four arrangements are available immediately. Play whichever ones you want, stop when you want, then press **Save JSONL**. Do not read `hypothesis.md` until after reporting your experience.
|
||||
58
experiments/006_breakline/hypothesis.md
Normal file
58
experiments/006_breakline/hypothesis.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Experiment 006 Hypothesis — Private Until After Play
|
||||
|
||||
## Question
|
||||
|
||||
Are a few targeted, assertive movement/combat verbs intrinsically more valuable to this player than indirect construction, abstract manipulation, or custodial field management?
|
||||
|
||||
## Why This Experiment
|
||||
|
||||
Experiment 005 was readable and controllable at the input level, but its indiscriminate field progressively erased good actions. The player also did not care about its rescue outcome from the beginning. Rebalancing debris or charge would not test that deeper failure.
|
||||
|
||||
Breakline supplies immediate self-relevant stakes and selective verbs:
|
||||
|
||||
- a short directional strike that damages, knocks, and reflects;
|
||||
- a targeted tether that pulls light enemies but pulls the player toward anchors;
|
||||
- an offensive dash that crosses danger and damages bodies in its path.
|
||||
|
||||
Enemy projectiles, charges, and body collisions use the same momentum space. Failure restores the current arrangement rather than accumulating damage. There is no timer, resource bar, upgrade, score requirement, or unlock. Four arrangements are simultaneously available, including an optional remix.
|
||||
|
||||
## Competing Interpretations
|
||||
|
||||
1. Expressive targeted action produces enjoyable execution even without progression.
|
||||
2. Combat becomes interesting only when qualitative build choices alter it.
|
||||
3. The player enjoys commercial action games for polish, audiovisual feel, multiplayer context, or content—not these mechanical verbs in isolation.
|
||||
4. Target selection and aim feel fiddly, making the probe an interface test.
|
||||
5. One verb dominates, reducing combat to repetition as in Experiment 002.
|
||||
6. Clearing authored enemy sets creates compliance but no desire to replay or improve.
|
||||
|
||||
## Evidence Priorities
|
||||
|
||||
Strong evidence:
|
||||
|
||||
- using different verbs in response to enemy state without instruction;
|
||||
- deliberately reflecting a projectile into another enemy;
|
||||
- tethering or striking an enemy into a damaging collision;
|
||||
- using an anchor tether as movement rather than only offense;
|
||||
- voluntary replay/remix or an attempt to execute a cleaner sequence;
|
||||
- a concrete moment the player wanted to reproduce.
|
||||
|
||||
Ambiguous evidence:
|
||||
|
||||
- clearing arrangements;
|
||||
- many attacks or deaths;
|
||||
- long play caused by difficulty;
|
||||
- trying each button once.
|
||||
|
||||
Failure evidence:
|
||||
|
||||
- immediate desire to stop despite usable controls;
|
||||
- attack or dash spam as a universal answer;
|
||||
- interactions happen accidentally and do not become intentional;
|
||||
- local failure feels like repetition rather than a fresh attempt;
|
||||
- no desire to replay after learning the verbs.
|
||||
|
||||
## Feedback Questions
|
||||
|
||||
1. Which verb, if any, felt good enough to use for its own sake rather than merely because it dealt damage?
|
||||
2. Did any collision, reflection, tether movement, or recovery feel intentional and worth repeating?
|
||||
3. When did they want to stop, and was the reason execution feel, shallow enemies, lack of progression/context, or complete exhaustion of the action space?
|
||||
562
experiments/006_breakline/prototype/app.js
vendored
Normal file
562
experiments/006_breakline/prototype/app.js
vendored
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
(() => {
|
||||
"use strict";
|
||||
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const $$ = selector => [...document.querySelectorAll(selector)];
|
||||
const canvas = $("#field");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const PLAYER_RADIUS = .021;
|
||||
const KEY_VECTOR = {
|
||||
KeyW: [0, -1], ArrowUp: [0, -1], KeyS: [0, 1], ArrowDown: [0, 1],
|
||||
KeyA: [-1, 0], ArrowLeft: [-1, 0], KeyD: [1, 0], ArrowRight: [1, 0]
|
||||
};
|
||||
const ENEMY_DEF = {
|
||||
wisp: { radius: .018, mass: .7, hp: 1, color: "#ef706e" },
|
||||
gunner: { radius: .022, mass: 1, hp: 3, color: "#b58cff" },
|
||||
anchor: { radius: .031, mass: 3.8, hp: 6, color: "#efb25a" }
|
||||
};
|
||||
const ARENAS = {
|
||||
drift: {
|
||||
kicker: "ARRANGEMENT 01", name: "Drift", copy: "A loose pack. Clear it however you want.",
|
||||
enemies: [["wisp", .18, .2], ["wisp", .5, .14], ["wisp", .82, .21], ["wisp", .2, .72], ["wisp", .8, .76], ["wisp", .5, .84]]
|
||||
},
|
||||
crossfire: {
|
||||
kicker: "ARRANGEMENT 02", name: "Crossfire", copy: "Slow bolts and closing bodies share the same space.",
|
||||
enemies: [["gunner", .14, .15], ["gunner", .86, .15], ["gunner", .5, .86], ["wisp", .25, .46], ["wisp", .75, .46], ["wisp", .5, .25]]
|
||||
},
|
||||
weight: {
|
||||
kicker: "ARRANGEMENT 03", name: "Weight", copy: "Heavy anchors telegraph their charges and reverse your tether.",
|
||||
enemies: [["anchor", .18, .2], ["anchor", .82, .2], ["gunner", .18, .79], ["gunner", .82, .79], ["wisp", .34, .28], ["wisp", .66, .28], ["wisp", .32, .72], ["wisp", .68, .72]]
|
||||
},
|
||||
remix: { kicker: "UNBOUNDED ARRANGEMENT", name: "Remix", copy: "A fresh mixture each reset. No reward is attached." }
|
||||
};
|
||||
|
||||
const state = {
|
||||
session: crypto.randomUUID ? crypto.randomUUID() : `session-${Date.now()}`,
|
||||
started: Date.now(), logs: [], startedGame: false, active: false, complete: false,
|
||||
arenaId: "drift", arenaTime: 0, attempt: 0, deaths: 0, completed: new Set(),
|
||||
keys: new Set(), enemies: [], projectiles: [], particles: [], effects: [], enemyId: 0, projectileId: 0,
|
||||
pointer: { x: .5, y: .3, inside: false }, lastFrame: performance.now(), snapshotClock: 0,
|
||||
player: { x: .5, y: .54, vx: 0, vy: 0, health: 5, invulnerable: 0, lastDamage: -99, dashTime: 0, dashHits: new Set() },
|
||||
cooldown: { strike: 0, tether: 0, dash: 0 },
|
||||
actions: { strikes: 0, strikeHits: 0, reflections: 0, tethers: 0, tetherHits: 0, dashes: 0, dashHits: 0, collisions: 0, kills: 0 },
|
||||
chain: 0, maxChain: 0, lastKill: -99, remixCount: 0, random: null,
|
||||
bannerTimer: null, toastTimer: null,
|
||||
render: { size: 1, ox: 0, oy: 0, width: 1, height: 1 }
|
||||
};
|
||||
|
||||
function hashSeed(text) {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < text.length; i++) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 16777619); }
|
||||
return hash >>> 0;
|
||||
}
|
||||
function mulberry32(seed) {
|
||||
return () => { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let v = Math.imul(seed ^ seed >>> 15, 1 | seed); v = v + Math.imul(v ^ v >>> 7, 61 | v) ^ v; return ((v ^ v >>> 14) >>> 0) / 4294967296; };
|
||||
}
|
||||
state.random = mulberry32(hashSeed(state.session));
|
||||
|
||||
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
|
||||
const round = value => Math.round(value * 1000) / 1000;
|
||||
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const normalize = (x, y) => { const length = Math.hypot(x, y) || 1; return [x / length, y / length]; };
|
||||
|
||||
function log(type, data = {}) {
|
||||
const event = {
|
||||
schema: 1, experiment: "006_breakline", prototype_revision: 1,
|
||||
session_id: state.session, elapsed_ms: Date.now() - state.started,
|
||||
arena: state.arenaId, attempt: state.attempt, arena_seconds: round(state.arenaTime), type, ...data
|
||||
};
|
||||
state.logs.push(JSON.stringify(event));
|
||||
try { localStorage.setItem("breakline-last-jsonl", state.logs.join("\n") + "\n"); } catch (_) {}
|
||||
}
|
||||
|
||||
function remixEnemies() {
|
||||
const result = [];
|
||||
const counts = { wisp: 4 + Math.floor(state.random() * 3), gunner: 2 + Math.floor(state.random() * 2), anchor: 1 + Math.floor(state.random() * 2) };
|
||||
for (const [kind, count] of Object.entries(counts)) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
let x, y;
|
||||
do { x = .1 + state.random() * .8; y = .1 + state.random() * .8; } while (Math.hypot(x - .5, y - .54) < .22);
|
||||
result.push([kind, x, y]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function loadArena(reason, emit = true) {
|
||||
state.attempt++;
|
||||
state.arenaTime = 0; state.snapshotClock = 0; state.complete = false;
|
||||
state.enemies = []; state.projectiles = []; state.particles = []; state.effects = [];
|
||||
state.player = { x: .5, y: .54, vx: 0, vy: 0, health: 5, invulnerable: 0, lastDamage: -99, dashTime: 0, dashHits: new Set() };
|
||||
state.cooldown = { strike: 0, tether: 0, dash: 0 };
|
||||
state.actions = { strikes: 0, strikeHits: 0, reflections: 0, tethers: 0, tetherHits: 0, dashes: 0, dashHits: 0, collisions: 0, kills: 0 };
|
||||
state.chain = 0; state.maxChain = 0; state.lastKill = -99;
|
||||
const arena = ARENAS[state.arenaId];
|
||||
const layout = state.arenaId === "remix" ? remixEnemies() : arena.enemies;
|
||||
if (state.arenaId === "remix") state.remixCount++;
|
||||
layout.forEach(([kind, x, y]) => spawnEnemy(kind, x, y));
|
||||
state.active = state.startedGame;
|
||||
updateArenaUI();
|
||||
if (emit) log("arena_started", { reason, enemies: state.enemies.map(enemy => ({ id: enemy.id, kind: enemy.kind, position: [round(enemy.x), round(enemy.y)] })) });
|
||||
}
|
||||
|
||||
function spawnEnemy(kind, x, y) {
|
||||
const def = ENEMY_DEF[kind];
|
||||
state.enemies.push({
|
||||
id: ++state.enemyId, kind, x, y, vx: 0, vy: 0, radius: def.radius, mass: def.mass,
|
||||
hp: def.hp, maxHp: def.hp, removed: false, hitFlash: 0, collisionCooldown: 0,
|
||||
fireCooldown: .8 + state.random() * 1.2, chargeCooldown: 1.2 + state.random() * 1.5,
|
||||
windup: 0, chargeVector: [0, 0]
|
||||
});
|
||||
}
|
||||
|
||||
function begin() {
|
||||
if (state.startedGame) return;
|
||||
state.startedGame = true; state.active = true; state.lastFrame = performance.now();
|
||||
$("#start-overlay").classList.add("hidden"); canvas.focus({ preventScroll: true });
|
||||
log("session_started", { viewport: [window.innerWidth, window.innerHeight] });
|
||||
log("arena_started", { reason: "session_started", enemies: state.enemies.map(enemy => ({ id: enemy.id, kind: enemy.kind, position: [round(enemy.x), round(enemy.y)] })) });
|
||||
}
|
||||
|
||||
function switchArena(id) {
|
||||
if (!ARENAS[id] || id === state.arenaId && !state.complete) return;
|
||||
if (state.startedGame && !state.complete) log("arena_abandoned", { reason: "switched", remaining: state.enemies.length, actions: state.actions });
|
||||
state.arenaId = id; loadArena("selected", state.startedGame);
|
||||
showBanner(ARENAS[id].name);
|
||||
if (state.startedGame) canvas.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function resetArena(reason = "button") {
|
||||
if (state.startedGame && !state.complete) log("arena_abandoned", { reason, remaining: state.enemies.length, actions: state.actions });
|
||||
loadArena(reason, state.startedGame);
|
||||
if (state.startedGame) { state.active = true; canvas.focus({ preventScroll: true }); }
|
||||
}
|
||||
|
||||
function aimVector() {
|
||||
return normalize(state.pointer.x - state.player.x, state.pointer.y - state.player.y);
|
||||
}
|
||||
|
||||
function strike(source) {
|
||||
if (!state.active || state.complete || state.cooldown.strike > 0) return;
|
||||
const [ax, ay] = aimVector(); state.cooldown.strike = .31; state.actions.strikes++;
|
||||
let hits = 0, reflections = 0;
|
||||
for (const enemy of state.enemies) {
|
||||
const dx = enemy.x - state.player.x, dy = enemy.y - state.player.y, range = Math.hypot(dx, dy);
|
||||
if (range > .125 + enemy.radius || (dx * ax + dy * ay) / (range || 1) < .43) continue;
|
||||
damageEnemy(enemy, 1, "strike");
|
||||
enemy.vx += ax * .25 / enemy.mass; enemy.vy += ay * .25 / enemy.mass;
|
||||
hits++; state.actions.strikeHits++;
|
||||
}
|
||||
for (const projectile of state.projectiles) {
|
||||
if (projectile.owner !== "enemy") continue;
|
||||
const dx = projectile.x - state.player.x, dy = projectile.y - state.player.y, range = Math.hypot(dx, dy);
|
||||
if (range > .145 || (dx * ax + dy * ay) / (range || 1) < .3) continue;
|
||||
projectile.owner = "player"; projectile.vx = ax * .43; projectile.vy = ay * .43; projectile.life = 2.2;
|
||||
reflections++; state.actions.reflections++;
|
||||
}
|
||||
state.effects.push({ kind: "strike", x: state.player.x, y: state.player.y, ax, ay, life: .15, maxLife: .15 });
|
||||
log("strike_used", { source, aim: [round(ax), round(ay)], hits, reflections, position: [round(state.player.x), round(state.player.y)] });
|
||||
}
|
||||
|
||||
function tether(source) {
|
||||
if (!state.active || state.complete || state.cooldown.tether > 0) return;
|
||||
const [ax, ay] = aimVector(); state.cooldown.tether = .72; state.actions.tethers++;
|
||||
let target = null, best = Infinity;
|
||||
for (const enemy of state.enemies) {
|
||||
const dx = enemy.x - state.player.x, dy = enemy.y - state.player.y, range = Math.hypot(dx, dy);
|
||||
if (range > .43) continue;
|
||||
const projection = dx * ax + dy * ay;
|
||||
if (projection <= 0) continue;
|
||||
const perpendicular = Math.abs(dx * ay - dy * ax);
|
||||
const score = perpendicular * 4 + range * .08;
|
||||
if (perpendicular < enemy.radius + .045 && score < best) { best = score; target = enemy; }
|
||||
}
|
||||
if (!target) {
|
||||
state.effects.push({ kind: "tether_miss", x: state.player.x, y: state.player.y, ax, ay, life: .12, maxLife: .12 });
|
||||
log("tether_used", { source, hit: false, aim: [round(ax), round(ay)], position: [round(state.player.x), round(state.player.y)] });
|
||||
return;
|
||||
}
|
||||
state.actions.tetherHits++;
|
||||
const [dx, dy] = normalize(target.x - state.player.x, target.y - state.player.y);
|
||||
let effect;
|
||||
if (target.kind === "anchor") {
|
||||
state.player.vx += dx * .48; state.player.vy += dy * .48; state.player.invulnerable = Math.max(state.player.invulnerable, .12); effect = "player_pulled";
|
||||
} else {
|
||||
target.vx -= dx * .48 / target.mass; target.vy -= dy * .48 / target.mass; effect = "enemy_pulled";
|
||||
}
|
||||
state.effects.push({ kind: "tether", x: state.player.x, y: state.player.y, tx: target.x, ty: target.y, life: .18, maxLife: .18 });
|
||||
log("tether_used", { source, hit: true, target_id: target.id, target_kind: target.kind, effect, distance: round(distance(state.player, target)) });
|
||||
}
|
||||
|
||||
function dash(source) {
|
||||
if (!state.active || state.complete || state.cooldown.dash > 0) return;
|
||||
let dx = 0, dy = 0;
|
||||
for (const code of state.keys) { const v = KEY_VECTOR[code]; if (v) { dx += v[0]; dy += v[1]; } }
|
||||
if (!dx && !dy) [dx, dy] = aimVector(); else [dx, dy] = normalize(dx, dy);
|
||||
state.player.vx = dx * .72; state.player.vy = dy * .72; state.player.dashTime = .17;
|
||||
state.player.invulnerable = .22; state.player.dashHits = new Set(); state.cooldown.dash = 1.15; state.actions.dashes++;
|
||||
state.effects.push({ kind: "dash", x: state.player.x, y: state.player.y, ax: dx, ay: dy, life: .25, maxLife: .25 });
|
||||
log("dash_used", { source, direction: [round(dx), round(dy)], position: [round(state.player.x), round(state.player.y)] });
|
||||
}
|
||||
|
||||
function damageEnemy(enemy, amount, cause) {
|
||||
if (enemy.removed) return;
|
||||
enemy.hp -= amount; enemy.hitFlash = .12;
|
||||
log("enemy_damaged", { enemy_id: enemy.id, enemy_kind: enemy.kind, amount, cause, hp_after: Math.max(0, enemy.hp), position: [round(enemy.x), round(enemy.y)] });
|
||||
burst(enemy.x, enemy.y, ENEMY_DEF[enemy.kind].color, 5);
|
||||
if (enemy.hp > 0) return;
|
||||
enemy.removed = true; state.actions.kills++;
|
||||
state.chain = state.arenaTime - state.lastKill <= 2.5 ? state.chain + 1 : 1;
|
||||
state.lastKill = state.arenaTime; state.maxChain = Math.max(state.maxChain, state.chain);
|
||||
burst(enemy.x, enemy.y, ENEMY_DEF[enemy.kind].color, 14);
|
||||
log("enemy_killed", { enemy_id: enemy.id, enemy_kind: enemy.kind, cause, chain: state.chain, remaining_after: state.enemies.filter(candidate => !candidate.removed).length });
|
||||
}
|
||||
|
||||
function damagePlayer(amount, cause, source = null) {
|
||||
if (state.player.invulnerable > 0 || !state.active) return;
|
||||
state.player.health -= amount; state.player.invulnerable = .65; state.player.lastDamage = state.arenaTime;
|
||||
burst(state.player.x, state.player.y, "#ef706e", 10);
|
||||
log("player_damaged", { amount, cause, source_id: source?.id || null, health_after: Math.max(0, state.player.health), position: [round(state.player.x), round(state.player.y)] });
|
||||
if (state.player.health <= 0) defeat();
|
||||
}
|
||||
|
||||
function defeat() {
|
||||
if (!state.active) return;
|
||||
state.active = false; state.deaths++; state.keys.clear();
|
||||
log("player_defeated", { duration_seconds: round(state.arenaTime), remaining: state.enemies.filter(enemy => !enemy.removed).length, actions: state.actions, deaths: state.deaths });
|
||||
showBanner("Contact lost · restoring arrangement");
|
||||
setTimeout(() => { if (!state.active && state.startedGame) loadArena("defeat_restart", true); }, 800);
|
||||
}
|
||||
|
||||
function completeArena() {
|
||||
if (state.complete || !state.active) return;
|
||||
state.complete = true; state.completed.add(state.arenaId);
|
||||
log("arena_completed", { duration_seconds: round(state.arenaTime), actions: state.actions, max_chain: state.maxChain, deaths_before_clear: state.deaths });
|
||||
showBanner(`${ARENAS[state.arenaId].name} clear`);
|
||||
updateArenaUI();
|
||||
}
|
||||
|
||||
function updatePlayer(dt) {
|
||||
state.player.invulnerable = Math.max(0, state.player.invulnerable - dt);
|
||||
state.cooldown.strike = Math.max(0, state.cooldown.strike - dt);
|
||||
state.cooldown.tether = Math.max(0, state.cooldown.tether - dt);
|
||||
state.cooldown.dash = Math.max(0, state.cooldown.dash - dt);
|
||||
if (state.player.health < 5 && state.arenaTime - state.player.lastDamage > 4) {
|
||||
state.player.health = Math.min(5, state.player.health + dt * .45);
|
||||
}
|
||||
if (state.player.dashTime > 0) {
|
||||
state.player.dashTime = Math.max(0, state.player.dashTime - dt);
|
||||
for (const enemy of state.enemies) {
|
||||
if (enemy.removed || state.player.dashHits.has(enemy.id) || distance(state.player, enemy) > PLAYER_RADIUS + enemy.radius + .016) continue;
|
||||
state.player.dashHits.add(enemy.id); damageEnemy(enemy, 2, "dash"); state.actions.dashHits++;
|
||||
const [dx, dy] = normalize(enemy.x - state.player.x, enemy.y - state.player.y);
|
||||
enemy.vx += dx * .28 / enemy.mass; enemy.vy += dy * .28 / enemy.mass;
|
||||
}
|
||||
} else {
|
||||
let dx = 0, dy = 0;
|
||||
for (const code of state.keys) { const vector = KEY_VECTOR[code]; if (vector) { dx += vector[0]; dy += vector[1]; } }
|
||||
if (dx || dy) { [dx, dy] = normalize(dx, dy); state.player.vx += dx * .95 * dt; state.player.vy += dy * .95 * dt; }
|
||||
const drag = Math.pow(.035, dt); state.player.vx *= drag; state.player.vy *= drag;
|
||||
const speed = Math.hypot(state.player.vx, state.player.vy), max = .31;
|
||||
if (speed > max) { state.player.vx *= max / speed; state.player.vy *= max / speed; }
|
||||
}
|
||||
state.player.x += state.player.vx * dt; state.player.y += state.player.vy * dt;
|
||||
bounce(state.player, PLAYER_RADIUS, .42);
|
||||
}
|
||||
|
||||
function updateEnemies(dt) {
|
||||
for (const enemy of state.enemies) {
|
||||
if (enemy.removed) continue;
|
||||
enemy.hitFlash = Math.max(0, enemy.hitFlash - dt); enemy.collisionCooldown = Math.max(0, enemy.collisionCooldown - dt);
|
||||
const dx = state.player.x - enemy.x, dy = state.player.y - enemy.y, range = Math.hypot(dx, dy) || 1;
|
||||
if (enemy.kind === "wisp") {
|
||||
enemy.vx += dx / range * .085 * dt; enemy.vy += dy / range * .085 * dt;
|
||||
} else if (enemy.kind === "gunner") {
|
||||
const sign = range > .34 ? 1 : range < .22 ? -1 : 0;
|
||||
enemy.vx += dx / range * .04 * sign * dt; enemy.vy += dy / range * .04 * sign * dt;
|
||||
enemy.vx += -dy / range * .012 * dt; enemy.vy += dx / range * .012 * dt;
|
||||
enemy.fireCooldown -= dt;
|
||||
if (enemy.fireCooldown <= 0) { shoot(enemy, dx / range, dy / range); enemy.fireCooldown = 1.75 + state.random() * .8; }
|
||||
} else {
|
||||
if (enemy.windup > 0) {
|
||||
const before = enemy.windup; enemy.windup -= dt; enemy.vx *= Math.pow(.03, dt); enemy.vy *= Math.pow(.03, dt);
|
||||
if (before > 0 && enemy.windup <= 0) {
|
||||
enemy.vx = enemy.chargeVector[0] * .43; enemy.vy = enemy.chargeVector[1] * .43;
|
||||
log("anchor_charged", { enemy_id: enemy.id, direction: enemy.chargeVector, position: [round(enemy.x), round(enemy.y)] });
|
||||
}
|
||||
} else {
|
||||
enemy.chargeCooldown -= dt;
|
||||
if (enemy.chargeCooldown <= 0) {
|
||||
enemy.windup = .72; enemy.chargeVector = [round(dx / range), round(dy / range)]; enemy.chargeCooldown = 3.1 + state.random() * 1.1;
|
||||
log("anchor_windup", { enemy_id: enemy.id, direction: enemy.chargeVector, position: [round(enemy.x), round(enemy.y)] });
|
||||
} else { enemy.vx += dx / range * .012 * dt; enemy.vy += dy / range * .012 * dt; }
|
||||
}
|
||||
}
|
||||
const drag = Math.pow(enemy.kind === "anchor" ? .985 : .965, dt * 60); enemy.vx *= drag; enemy.vy *= drag;
|
||||
const max = enemy.kind === "anchor" ? .44 : enemy.kind === "wisp" ? .15 : .13;
|
||||
const speed = Math.hypot(enemy.vx, enemy.vy);
|
||||
if (speed > max) { enemy.vx *= max / speed; enemy.vy *= max / speed; }
|
||||
enemy.x += enemy.vx * dt; enemy.y += enemy.vy * dt; bounce(enemy, enemy.radius, .72);
|
||||
}
|
||||
resolveEnemyCollisions(); resolvePlayerEnemyContacts();
|
||||
state.enemies = state.enemies.filter(enemy => !enemy.removed);
|
||||
if (state.enemies.length === 0) completeArena();
|
||||
}
|
||||
|
||||
function shoot(enemy, dx, dy) {
|
||||
const projectile = { id: ++state.projectileId, owner: "enemy", x: enemy.x + dx * (enemy.radius + .012), y: enemy.y + dy * (enemy.radius + .012), vx: dx * .245, vy: dy * .245, radius: .008, life: 4, removed: false };
|
||||
state.projectiles.push(projectile); log("projectile_fired", { projectile_id: projectile.id, enemy_id: enemy.id, position: [round(projectile.x), round(projectile.y)], direction: [round(dx), round(dy)] });
|
||||
}
|
||||
|
||||
function updateProjectiles(dt) {
|
||||
for (const projectile of state.projectiles) {
|
||||
projectile.x += projectile.vx * dt; projectile.y += projectile.vy * dt; projectile.life -= dt;
|
||||
if (projectile.life <= 0 || projectile.x < 0 || projectile.x > 1 || projectile.y < 0 || projectile.y > 1) { projectile.removed = true; continue; }
|
||||
if (projectile.owner === "enemy" && distance(projectile, state.player) < projectile.radius + PLAYER_RADIUS) {
|
||||
projectile.removed = true; damagePlayer(1, "projectile", projectile); continue;
|
||||
}
|
||||
if (projectile.owner === "player") {
|
||||
for (const enemy of state.enemies) {
|
||||
if (enemy.removed || distance(projectile, enemy) >= projectile.radius + enemy.radius) continue;
|
||||
projectile.removed = true; damageEnemy(enemy, 2, "reflected_projectile");
|
||||
enemy.vx += projectile.vx * .5 / enemy.mass; enemy.vy += projectile.vy * .5 / enemy.mass; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.projectiles = state.projectiles.filter(projectile => !projectile.removed);
|
||||
}
|
||||
|
||||
function resolveEnemyCollisions() {
|
||||
for (let i = 0; i < state.enemies.length; i++) {
|
||||
const a = state.enemies[i]; if (a.removed) continue;
|
||||
for (let j = i + 1; j < state.enemies.length; j++) {
|
||||
const b = state.enemies[j]; if (b.removed) continue;
|
||||
const dx = b.x - a.x, dy = b.y - a.y, length = Math.hypot(dx, dy), overlap = a.radius + b.radius - length;
|
||||
if (overlap <= 0) continue;
|
||||
const nx = length > .0001 ? dx / length : 1, ny = length > .0001 ? dy / length : 0;
|
||||
const relative = Math.abs((b.vx - a.vx) * nx + (b.vy - a.vy) * ny);
|
||||
separateBounce(a, b, nx, ny, overlap);
|
||||
if (relative >= .17 && a.collisionCooldown === 0 && b.collisionCooldown === 0) {
|
||||
a.collisionCooldown = b.collisionCooldown = .35; state.actions.collisions++;
|
||||
damageEnemy(a, 2, "body_collision"); damageEnemy(b, 2, "body_collision");
|
||||
log("damaging_collision", { a_id: a.id, b_id: b.id, relative_speed: round(relative), position: [round((a.x + b.x) / 2), round((a.y + b.y) / 2)] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function separateBounce(a, b, nx, ny, overlap) {
|
||||
const total = a.mass + b.mass;
|
||||
a.x -= nx * overlap * b.mass / total; a.y -= ny * overlap * b.mass / total;
|
||||
b.x += nx * overlap * a.mass / total; b.y += ny * overlap * a.mass / total;
|
||||
const relative = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
|
||||
if (relative >= 0) return;
|
||||
const impulse = -1.55 * relative / (1 / a.mass + 1 / b.mass);
|
||||
a.vx -= impulse * nx / a.mass; a.vy -= impulse * ny / a.mass;
|
||||
b.vx += impulse * nx / b.mass; b.vy += impulse * ny / b.mass;
|
||||
}
|
||||
|
||||
function resolvePlayerEnemyContacts() {
|
||||
for (const enemy of state.enemies) {
|
||||
if (enemy.removed) continue;
|
||||
const dx = enemy.x - state.player.x, dy = enemy.y - state.player.y, length = Math.hypot(dx, dy), overlap = PLAYER_RADIUS + enemy.radius - length;
|
||||
if (overlap <= 0) continue;
|
||||
const nx = length > .0001 ? dx / length : 1, ny = length > .0001 ? dy / length : 0;
|
||||
enemy.x += nx * overlap * .7; enemy.y += ny * overlap * .7;
|
||||
state.player.x -= nx * overlap * .3; state.player.y -= ny * overlap * .3;
|
||||
const impact = Math.hypot(enemy.vx - state.player.vx, enemy.vy - state.player.vy);
|
||||
damagePlayer(enemy.kind === "anchor" && impact > .18 ? 2 : 1, enemy.kind === "anchor" ? "anchor_contact" : "enemy_contact", enemy);
|
||||
enemy.vx += nx * .08 / enemy.mass; enemy.vy += ny * .08 / enemy.mass;
|
||||
}
|
||||
}
|
||||
|
||||
function bounce(body, radius, restitution) {
|
||||
if (body.x < radius) { body.x = radius; body.vx = Math.abs(body.vx) * restitution; }
|
||||
if (body.x > 1 - radius) { body.x = 1 - radius; body.vx = -Math.abs(body.vx) * restitution; }
|
||||
if (body.y < radius) { body.y = radius; body.vy = Math.abs(body.vy) * restitution; }
|
||||
if (body.y > 1 - radius) { body.y = 1 - radius; body.vy = -Math.abs(body.vy) * restitution; }
|
||||
}
|
||||
|
||||
function burst(x, y, color, count) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = state.random() * Math.PI * 2, speed = .04 + state.random() * .14;
|
||||
state.particles.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: .25 + state.random() * .45, maxLife: .7, color });
|
||||
}
|
||||
}
|
||||
|
||||
function updateEffects(dt) {
|
||||
for (const particle of state.particles) { particle.x += particle.vx * dt; particle.y += particle.vy * dt; particle.vx *= Math.pow(.12, dt); particle.vy *= Math.pow(.12, dt); particle.life -= dt; }
|
||||
state.particles = state.particles.filter(particle => particle.life > 0);
|
||||
for (const effect of state.effects) effect.life -= dt;
|
||||
state.effects = state.effects.filter(effect => effect.life > 0);
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
if (!state.active || state.complete) { updateEffects(dt); return; }
|
||||
state.arenaTime += dt; updatePlayer(dt); updateEnemies(dt); updateProjectiles(dt); updateEffects(dt);
|
||||
state.snapshotClock += dt;
|
||||
if (state.snapshotClock >= 1.5) {
|
||||
state.snapshotClock -= 1.5;
|
||||
log("arena_snapshot", {
|
||||
player: [round(state.player.x), round(state.player.y)], health: round(state.player.health),
|
||||
cooldowns: Object.fromEntries(Object.entries(state.cooldown).map(([key, value]) => [key, round(value)])),
|
||||
remaining: state.enemies.length, enemy_counts: state.enemies.reduce((a, enemy) => (a[enemy.kind] = (a[enemy.kind] || 0) + 1, a), {}),
|
||||
projectiles: state.projectiles.length, actions: { ...state.actions }
|
||||
});
|
||||
}
|
||||
updateArenaUI();
|
||||
}
|
||||
|
||||
function updateArenaUI() {
|
||||
const arena = ARENAS[state.arenaId];
|
||||
$("#arena-kicker").textContent = arena.kicker; $("#arena-name").textContent = arena.name; $("#arena-copy").textContent = arena.copy;
|
||||
$("#health").textContent = Array.from({ length: 5 }, (_, index) => index < Math.ceil(state.player.health) ? "●" : "○").join(" ");
|
||||
$("#remaining").textContent = state.enemies.length; $("#chain").textContent = state.chain;
|
||||
$("#arena-time").textContent = `${Math.floor(state.arenaTime / 60)}:${String(Math.floor(state.arenaTime % 60)).padStart(2, "0")}`;
|
||||
const max = { strike: .31, tether: .72, dash: 1.15 };
|
||||
for (const key of Object.keys(max)) $(`#${key}-ready`).style.transform = `scaleX(${1 - clamp(state.cooldown[key] / max[key], 0, 1)})`;
|
||||
$$("[data-arena]").forEach(button => {
|
||||
button.classList.toggle("selected", button.dataset.arena === state.arenaId);
|
||||
button.classList.toggle("complete", state.completed.has(button.dataset.arena));
|
||||
});
|
||||
}
|
||||
|
||||
async function saveLog() {
|
||||
log("save_requested", { event_count_before_save: state.logs.length, completed: [...state.completed], deaths: state.deaths });
|
||||
const filename = `breakline-${state.session}.jsonl`, payload = state.logs.join("\n") + "\n";
|
||||
try {
|
||||
const response = await fetch("/api/playtest-log", { method: "POST", headers: { "Content-Type": "application/x-ndjson", "X-Playtest-Filename": filename }, body: payload });
|
||||
if (!response.ok) throw new Error(`server returned ${response.status}`);
|
||||
const result = await response.json(); log("log_saved", { path: result.path, saved_events: result.events }); showToast(`Saved ${result.events} events to ${result.path}`);
|
||||
} catch (error) {
|
||||
const blob = new Blob([payload], { type: "application/x-ndjson" }), link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob); link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
||||
log("save_fallback_download", { message: String(error) }); showToast("Server save unavailable; downloaded the JSONL instead.");
|
||||
}
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const rect = canvas.getBoundingClientRect(), dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.max(1, Math.round(rect.width * dpr)); canvas.height = Math.max(1, Math.round(rect.height * dpr)); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const size = Math.min(rect.width, rect.height) * .91;
|
||||
state.render = { size, ox: (rect.width - size) / 2, oy: (rect.height - size) / 2, width: rect.width, height: rect.height };
|
||||
}
|
||||
const screen = point => ({ x: state.render.ox + point.x * state.render.size, y: state.render.oy + point.y * state.render.size });
|
||||
|
||||
function pointerFromEvent(event) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
state.pointer.x = clamp((event.clientX - rect.left - state.render.ox) / state.render.size, 0, 1);
|
||||
state.pointer.y = clamp((event.clientY - rect.top - state.render.oy) / state.render.size, 0, 1);
|
||||
state.pointer.inside = true;
|
||||
}
|
||||
|
||||
function draw(now) {
|
||||
const { width, height, size, ox, oy } = state.render;
|
||||
ctx.clearRect(0, 0, width, height); ctx.save(); ctx.beginPath(); ctx.rect(ox, oy, size, size); ctx.clip();
|
||||
ctx.fillStyle = "#071016"; ctx.fillRect(ox, oy, size, size); drawGrid();
|
||||
for (const projectile of state.projectiles) drawProjectile(projectile);
|
||||
for (const enemy of state.enemies) drawEnemy(enemy, now);
|
||||
for (const particle of state.particles) drawParticle(particle);
|
||||
for (const effect of state.effects) drawEffect(effect);
|
||||
drawPlayer(); drawAim(); ctx.restore();
|
||||
ctx.strokeStyle = "#38505a"; ctx.lineWidth = 1; ctx.strokeRect(ox + .5, oy + .5, size - 1, size - 1);
|
||||
}
|
||||
|
||||
function drawGrid() {
|
||||
const { size, ox, oy } = state.render; ctx.strokeStyle = "rgba(87,126,137,.1)"; ctx.lineWidth = 1;
|
||||
for (let i = 1; i < 12; i++) {
|
||||
ctx.beginPath(); ctx.moveTo(ox + i * size / 12, oy); ctx.lineTo(ox + i * size / 12, oy + size); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(ox, oy + i * size / 12); ctx.lineTo(ox + size, oy + i * size / 12); ctx.stroke();
|
||||
}
|
||||
ctx.strokeStyle = "rgba(110,228,189,.08)"; ctx.beginPath(); ctx.arc(ox + size / 2, oy + size / 2, size * .22, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
|
||||
function drawPlayer() {
|
||||
const point = screen(state.player), r = PLAYER_RADIUS * state.render.size, [ax, ay] = aimVector();
|
||||
ctx.save(); ctx.translate(point.x, point.y); ctx.rotate(Math.atan2(ay, ax) + Math.PI / 2);
|
||||
ctx.shadowColor = state.player.invulnerable > 0 ? "#70d8f3" : "#6ee4bd"; ctx.shadowBlur = 14;
|
||||
ctx.fillStyle = "#173b33"; ctx.strokeStyle = state.player.invulnerable > 0 ? "#70d8f3" : "#6ee4bd"; ctx.lineWidth = 2.5;
|
||||
ctx.beginPath(); ctx.moveTo(0, -r); ctx.lineTo(r * .78, r * .8); ctx.lineTo(0, r * .5); ctx.lineTo(-r * .78, r * .8); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.restore();
|
||||
}
|
||||
|
||||
function drawEnemy(enemy, now) {
|
||||
const point = screen(enemy), r = enemy.radius * state.render.size, color = enemy.hitFlash > 0 ? "#ffffff" : ENEMY_DEF[enemy.kind].color;
|
||||
ctx.save(); ctx.translate(point.x, point.y); ctx.shadowColor = color; ctx.shadowBlur = 9; ctx.fillStyle = `${enemy.kind === "wisp" ? "#421f25" : enemy.kind === "gunner" ? "#302344" : "#44331e"}`; ctx.strokeStyle = color; ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
if (enemy.kind === "wisp") ctx.arc(0, 0, r, 0, Math.PI * 2);
|
||||
else if (enemy.kind === "gunner") { ctx.moveTo(0, -r); ctx.lineTo(r, 0); ctx.lineTo(0, r); ctx.lineTo(-r, 0); ctx.closePath(); }
|
||||
else { for (let i = 0; i < 6; i++) { const a = i / 6 * Math.PI * 2; i ? ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r) : ctx.moveTo(Math.cos(a) * r, Math.sin(a) * r); } ctx.closePath(); }
|
||||
ctx.fill(); ctx.stroke();
|
||||
if (enemy.kind === "anchor" && enemy.windup > 0) {
|
||||
const alpha = .35 + Math.sin(now / 55) * .25; ctx.strokeStyle = `rgba(239,112,110,${alpha})`; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(enemy.chargeVector[0] * state.render.size * .32, enemy.chargeVector[1] * state.render.size * .32); ctx.stroke();
|
||||
}
|
||||
if (enemy.hp < enemy.maxHp) { ctx.shadowBlur = 0; ctx.fillStyle = "#172127"; ctx.fillRect(-r, r + 6, r * 2, 3); ctx.fillStyle = color; ctx.fillRect(-r, r + 6, r * 2 * enemy.hp / enemy.maxHp, 3); }
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawProjectile(projectile) {
|
||||
const point = screen(projectile); ctx.fillStyle = projectile.owner === "player" ? "#6ee4bd" : "#ef706e"; ctx.shadowColor = ctx.fillStyle; ctx.shadowBlur = 10;
|
||||
ctx.beginPath(); ctx.arc(point.x, point.y, projectile.radius * state.render.size, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
function drawParticle(particle) {
|
||||
const point = screen(particle); ctx.globalAlpha = clamp(particle.life / particle.maxLife, 0, 1); ctx.fillStyle = particle.color;
|
||||
ctx.beginPath(); ctx.arc(point.x, point.y, 2.2, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawEffect(effect) {
|
||||
const point = screen(effect), alpha = clamp(effect.life / effect.maxLife, 0, 1); ctx.save(); ctx.globalAlpha = alpha;
|
||||
if (effect.kind === "strike") {
|
||||
ctx.strokeStyle = "#6ee4bd"; ctx.lineWidth = 5; const angle = Math.atan2(effect.ay, effect.ax);
|
||||
ctx.beginPath(); ctx.arc(point.x, point.y, state.render.size * .12, angle - 1.05, angle + 1.05); ctx.stroke();
|
||||
} else if (effect.kind === "dash") {
|
||||
ctx.strokeStyle = "#70d8f3"; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(point.x, point.y); ctx.lineTo(point.x - effect.ax * state.render.size * .1, point.y - effect.ay * state.render.size * .1); ctx.stroke();
|
||||
} else {
|
||||
const end = effect.kind === "tether" ? screen({ x: effect.tx, y: effect.ty }) : screen({ x: effect.x + effect.ax * .42, y: effect.y + effect.ay * .42 });
|
||||
ctx.strokeStyle = effect.kind === "tether" ? "#b58cff" : "#5e526f"; ctx.lineWidth = 2; ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.moveTo(point.x, point.y); ctx.lineTo(end.x, end.y); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawAim() {
|
||||
if (!state.pointer.inside) return;
|
||||
const player = screen(state.player), pointer = screen(state.pointer); ctx.strokeStyle = "rgba(210,235,230,.18)"; ctx.lineWidth = 1; ctx.setLineDash([3, 7]);
|
||||
ctx.beginPath(); ctx.moveTo(player.x, player.y); ctx.lineTo(pointer.x, pointer.y); ctx.stroke(); ctx.setLineDash([]);
|
||||
ctx.strokeStyle = "rgba(220,240,236,.55)"; ctx.beginPath(); ctx.arc(pointer.x, pointer.y, 7, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
|
||||
function showBanner(message) { const banner = $("#banner"); banner.textContent = message; banner.classList.add("show"); clearTimeout(state.bannerTimer); state.bannerTimer = setTimeout(() => banner.classList.remove("show"), 1400); }
|
||||
function showToast(message) { const toast = $("#toast"); toast.textContent = message; toast.classList.add("show"); clearTimeout(state.toastTimer); state.toastTimer = setTimeout(() => toast.classList.remove("show"), 2800); }
|
||||
|
||||
function frame(now) {
|
||||
const dt = Math.min(.035, Math.max(0, (now - state.lastFrame) / 1000)); state.lastFrame = now;
|
||||
update(dt); draw(now); requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", event => {
|
||||
if (KEY_VECTOR[event.code]) {
|
||||
event.preventDefault();
|
||||
if (!state.keys.has(event.code)) { state.keys.add(event.code); if (state.active) log("movement_key_down", { key: event.code, active_keys: [...state.keys] }); }
|
||||
} else if (event.code === "Space") { event.preventDefault(); if (!event.repeat) dash("keyboard"); }
|
||||
});
|
||||
document.addEventListener("keyup", event => {
|
||||
if (!KEY_VECTOR[event.code]) return; event.preventDefault(); state.keys.delete(event.code);
|
||||
if (state.active) log("movement_key_up", { key: event.code, active_keys: [...state.keys] });
|
||||
});
|
||||
canvas.addEventListener("pointermove", pointerFromEvent);
|
||||
canvas.addEventListener("pointerenter", event => { pointerFromEvent(event); state.pointer.inside = true; });
|
||||
canvas.addEventListener("pointerleave", () => { state.pointer.inside = false; });
|
||||
canvas.addEventListener("pointerdown", event => { event.preventDefault(); pointerFromEvent(event); canvas.focus({ preventScroll: true }); if (event.button === 0) strike("pointer"); if (event.button === 2) tether("pointer"); });
|
||||
canvas.addEventListener("contextmenu", event => event.preventDefault());
|
||||
document.addEventListener("contextmenu", event => event.preventDefault()); document.addEventListener("selectstart", event => event.preventDefault());
|
||||
$("#start-overlay").addEventListener("pointerdown", begin);
|
||||
$$("[data-arena]").forEach(button => button.addEventListener("click", () => switchArena(button.dataset.arena)));
|
||||
$("#reset").addEventListener("click", () => resetArena("button")); $("#save").addEventListener("click", saveLog);
|
||||
window.addEventListener("blur", () => state.keys.clear());
|
||||
document.addEventListener("visibilitychange", () => log("visibility_changed", { visibility: document.visibilityState, active: state.active, remaining: state.enemies.length }));
|
||||
window.addEventListener("beforeunload", () => log("session_unload", { completed: [...state.completed], deaths: state.deaths, remaining: state.enemies.length }));
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
loadArena("initial", false); resize(); updateArenaUI(); requestAnimationFrame(frame);
|
||||
})();
|
||||
88
experiments/006_breakline/prototype/index.html
Normal file
88
experiments/006_breakline/prototype/index.html
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Breakline — Experiment 006</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="title"><span>EXPERIMENT 006</span><h1>Breakline</h1></div>
|
||||
<nav id="arrangements" aria-label="Combat arrangements">
|
||||
<button data-arena="drift" class="selected"><span>01</span> Drift</button>
|
||||
<button data-arena="crossfire"><span>02</span> Crossfire</button>
|
||||
<button data-arena="weight"><span>03</span> Weight</button>
|
||||
<button data-arena="remix"><span>∞</span> Remix</button>
|
||||
</nav>
|
||||
<div class="top-actions">
|
||||
<button id="reset" type="button">Reset arrangement</button>
|
||||
<button id="save" type="button">Save JSONL</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<aside>
|
||||
<section class="brief">
|
||||
<span id="arena-kicker">ARRANGEMENT 01</span>
|
||||
<h2 id="arena-name">Drift</h2>
|
||||
<p id="arena-copy">A loose pack. Clear it however you want.</p>
|
||||
</section>
|
||||
|
||||
<section class="controls">
|
||||
<h3>Direct controls</h3>
|
||||
<p><kbd>WASD</kbd><span>Move.</span></p>
|
||||
<p><kbd>LEFT</kbd><span>Strike toward the cursor.</span></p>
|
||||
<p><kbd>RIGHT</kbd><span>Tether the aimed body.</span></p>
|
||||
<p><kbd>SPACE</kbd><span>Dash through danger.</span></p>
|
||||
</section>
|
||||
|
||||
<section class="verbs">
|
||||
<h3>Your verbs</h3>
|
||||
<div><i class="strike">◒</i><p><b>STRIKE</b><span>Knocks bodies and reflects hostile bolts toward your aim.</span></p></div>
|
||||
<div><i class="tether">⌁</i><p><b>TETHER</b><span>Pulls wisps/gunners to you. Anchors pull you to them.</span></p></div>
|
||||
<div><i class="dash">»</i><p><b>DASH</b><span>Invulnerable during the burst; damages bodies crossed.</span></p></div>
|
||||
<div><i class="impact">✦</i><p><b>IMPACT</b><span>Fast enemy-on-enemy collisions hurt both bodies.</span></p></div>
|
||||
</section>
|
||||
|
||||
<section class="enemies">
|
||||
<h3>Arrangement bodies</h3>
|
||||
<div><i class="wisp-icon">●</i><span><b>WISP</b><small>Light · closes distance</small></span></div>
|
||||
<div><i class="gunner-icon">◆</i><span><b>GUNNER</b><small>Light · fires slow bolts</small></span></div>
|
||||
<div><i class="anchor-icon">⬢</i><span><b>ANCHOR</b><small>Heavy · telegraphed charge</small></span></div>
|
||||
</section>
|
||||
|
||||
<section class="readouts">
|
||||
<div><span>HEALTH</span><b id="health">● ● ● ● ●</b></div>
|
||||
<div><span>REMAINING</span><b id="remaining">6</b></div>
|
||||
<div><span>ARRANGEMENT TIME</span><b id="arena-time">0:00</b></div>
|
||||
<div><span>CLEAN CHAIN</span><b id="chain">0</b></div>
|
||||
</section>
|
||||
|
||||
<section class="cooldowns">
|
||||
<div><span>STRIKE</span><i><b id="strike-ready"></b></i></div>
|
||||
<div><span>TETHER</span><i><b id="tether-ready"></b></i></div>
|
||||
<div><span>DASH</span><i><b id="dash-ready"></b></i></div>
|
||||
</section>
|
||||
|
||||
<section class="note">All arrangements are available now. Clearing one grants no stats or new verbs. A defeat simply restores the current arrangement.</section>
|
||||
</aside>
|
||||
|
||||
<section class="playfield">
|
||||
<canvas id="field" tabindex="0" aria-label="Breakline combat field"></canvas>
|
||||
<div id="banner" class="banner" aria-live="polite"></div>
|
||||
<div class="canvas-help">Aim with the mouse · left strike · right tether · Space dash</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="start-overlay" class="overlay">
|
||||
<div>
|
||||
<span>NOTHING UNLOCKS LATER</span>
|
||||
<b>Your entire moveset is available now.</b>
|
||||
<p>Clear, replay, switch arrangements, or stop whenever you want. Click to begin.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast" role="status"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
102
experiments/006_breakline/prototype/style.css
Normal file
102
experiments/006_breakline/prototype/style.css
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #06090e;
|
||||
--panel: #0c141b;
|
||||
--line: #293b46;
|
||||
--text: #e9f1ef;
|
||||
--muted: #87999e;
|
||||
--mint: #6ee4bd;
|
||||
--cyan: #70d8f3;
|
||||
--violet: #b58cff;
|
||||
--amber: #efb25a;
|
||||
--red: #ef706e;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; margin: 0; }
|
||||
body { overflow: hidden; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, sans-serif; user-select: none; }
|
||||
button { border: 1px solid var(--line); border-radius: 7px; background: #13212a; color: var(--text); font: inherit; cursor: pointer; }
|
||||
button:hover { border-color: #56717a; background: #1a2b35; }
|
||||
button.selected { border-color: var(--mint); background: #123028; color: var(--mint); }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin: 0; font-size: 20px; }
|
||||
h2 { margin: 4px 0 0; font-size: 21px; }
|
||||
h3 { margin: 0 0 7px; color: #a9bbb9; font-size: 8px; letter-spacing: .14em; text-transform: uppercase; }
|
||||
|
||||
header { height: 64px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; padding: 8px 14px; border-bottom: 1px solid var(--line); background: #091015; }
|
||||
.title > span, .brief > span, .overlay span { display: block; color: var(--mint); font-size: 8px; font-weight: 850; letter-spacing: .18em; }
|
||||
nav { display: flex; gap: 6px; }
|
||||
nav button { min-width: 92px; padding: 8px 10px; }
|
||||
nav button span { margin-right: 4px; color: #6e8080; font: 8px ui-monospace, monospace; }
|
||||
nav button.complete::after { content: " ✓"; color: var(--mint); }
|
||||
.top-actions { display: flex; justify-content: flex-end; gap: 7px; }
|
||||
.top-actions button { padding: 8px 10px; }
|
||||
|
||||
main { height: calc(100vh - 64px); min-height: 0; display: grid; grid-template-columns: clamp(285px, 22vw, 345px) minmax(0, 1fr); overflow: hidden; }
|
||||
aside { min-height: 0; overflow-y: auto; scrollbar-gutter: stable; padding: 12px 14px; border-right: 1px solid var(--line); background: #091015; }
|
||||
aside section { margin-bottom: 10px; }
|
||||
.brief { padding: 11px; border: 1px solid #35594e; border-radius: 9px; background: linear-gradient(145deg, #10231d, #101920); }
|
||||
.brief p { margin: 7px 0 0; color: #b8c7c4; font-size: 9px; line-height: 1.4; }
|
||||
.controls { padding: 9px 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel); }
|
||||
.controls p { display: grid; grid-template-columns: 66px 1fr; gap: 7px; align-items: center; margin: 5px 0; color: var(--muted); font-size: 8px; }
|
||||
kbd { padding: 3px 5px; border: 1px solid #53666c; border-bottom-width: 2px; border-radius: 4px; background: #17252d; color: var(--text); font: 8px ui-monospace, monospace; text-align: center; }
|
||||
.verbs { display: grid; gap: 4px; }
|
||||
.verbs h3 { margin-bottom: 2px; }
|
||||
.verbs > div { display: grid; grid-template-columns: 27px 1fr; gap: 7px; align-items: center; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
|
||||
.verbs i { font-style: normal; font-size: 17px; text-align: center; }
|
||||
.verbs p { margin: 0; }
|
||||
.verbs b, .verbs span { display: block; }
|
||||
.verbs b { font-size: 8px; letter-spacing: .08em; }
|
||||
.verbs span { margin-top: 2px; color: var(--muted); font-size: 7px; line-height: 1.3; }
|
||||
.verbs .strike { color: var(--mint); } .verbs .tether { color: var(--violet); } .verbs .dash { color: var(--cyan); } .verbs .impact { color: var(--amber); }
|
||||
.enemies { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; }
|
||||
.enemies h3 { grid-column: 1 / -1; margin-bottom: 1px; }
|
||||
.enemies > div { min-width: 0; display: flex; gap: 5px; align-items: center; padding: 6px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
|
||||
.enemies i { font-style: normal; font-size: 13px; }
|
||||
.enemies b, .enemies small { display: block; }
|
||||
.enemies b { font-size: 7px; }
|
||||
.enemies small { margin-top: 2px; overflow: hidden; color: var(--muted); font-size: 6px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wisp-icon { color: var(--red); } .gunner-icon { color: var(--violet); } .anchor-icon { color: var(--amber); }
|
||||
.readouts { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
|
||||
.readouts div { padding: 7px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
|
||||
.readouts span, .readouts b { display: block; }
|
||||
.readouts span { color: var(--muted); font-size: 6px; letter-spacing: .09em; }
|
||||
.readouts b { margin-top: 4px; color: #dce7e4; font: 9px ui-monospace, monospace; }
|
||||
#health { color: var(--mint); letter-spacing: 2px; }
|
||||
.cooldowns { display: grid; gap: 5px; }
|
||||
.cooldowns > div { display: grid; grid-template-columns: 52px 1fr; gap: 7px; align-items: center; }
|
||||
.cooldowns span { color: var(--muted); font-size: 7px; }
|
||||
.cooldowns i { height: 5px; overflow: hidden; border-radius: 5px; background: #1b282e; }
|
||||
.cooldowns b { display: block; height: 100%; width: 100%; transform-origin: left; background: var(--mint); }
|
||||
.note { padding: 8px 9px; border-left: 2px solid #415a61; color: var(--muted); font-size: 8px; line-height: 1.4; }
|
||||
|
||||
.playfield { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: radial-gradient(circle at 50% 48%, #111f29, #05090c 74%); }
|
||||
#field { display: block; width: 100%; height: 100%; outline: none; touch-action: none; cursor: crosshair; }
|
||||
.canvas-help { position: absolute; z-index: 3; left: 50%; bottom: 13px; translate: -50%; padding: 7px 11px; border-radius: 6px; background: #071015dc; color: #809399; font-size: 9px; pointer-events: none; }
|
||||
.banner { position: absolute; z-index: 5; left: 50%; top: 20px; translate: -50% -8px; min-width: 250px; padding: 10px 16px; border: 1px solid #4a7569; border-radius: 8px; background: #10231edc; color: var(--mint); opacity: 0; text-align: center; pointer-events: none; transition: .2s; font-size: 11px; font-weight: 750; }
|
||||
.banner.show { opacity: 1; translate: -50% 0; }
|
||||
.overlay { position: fixed; z-index: 20; inset: 64px 0 0 clamp(285px, 22vw, 345px); display: grid; place-items: center; background: #030709d9; }
|
||||
.overlay.hidden { display: none; }
|
||||
.overlay > div { width: min(570px, 87%); padding: 23px 27px; border: 1px solid #426e62; border-radius: 10px; background: #101b20f3; text-align: center; }
|
||||
.overlay b, .overlay p { display: block; }
|
||||
.overlay b { margin-top: 7px; font-size: 15px; }
|
||||
.overlay p { margin: 9px 0 0; color: var(--muted); font-size: 10px; line-height: 1.45; }
|
||||
#toast { position: fixed; z-index: 30; left: calc(50% + 150px); bottom: 20px; translate: -50% 12px; max-width: 500px; padding: 10px 14px; border: 1px solid #4c7368; border-radius: 8px; background: #10231e; box-shadow: 0 10px 30px #000b; opacity: 0; pointer-events: none; transition: .2s; font-size: 10px; }
|
||||
#toast.show { opacity: 1; translate: -50% 0; }
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
nav button { min-width: auto; padding: 7px; font-size: 9px; }
|
||||
nav button span { display: none; }
|
||||
main { grid-template-columns: 270px minmax(0, 1fr); }
|
||||
.overlay { left: 270px; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
body { overflow: auto; }
|
||||
header { height: auto; grid-template-columns: 1fr; }
|
||||
nav { flex-wrap: wrap; }
|
||||
.top-actions { justify-content: flex-start; }
|
||||
main { height: auto; grid-template-columns: 1fr; overflow: visible; }
|
||||
.playfield { height: min(80vh, 650px); }
|
||||
.overlay { display: none; }
|
||||
#toast { left: 50%; }
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
# Experiment 006 Preliminary Analysis — Session 360f3f3f
|
||||
|
||||
Status: complete.
|
||||
|
||||
Source: `JSONL/breakline-360f3f3f-1e5c-442d-8fdd-7f4976b17b29.jsonl`
|
||||
|
||||
## Session Summary
|
||||
|
||||
- 788 saved events over 231.8 seconds.
|
||||
- All four arrangements were cleared without a defeat.
|
||||
- The player saved after clearing Remix, then voluntarily selected and cleared Drift and Crossfire again before saving a second time.
|
||||
- Six total clears: Drift twice, Crossfire twice, Weight once, Remix once.
|
||||
- 42 total kills: 22 strike, 12 reflected projectile, and 8 damaging body collision.
|
||||
- 56 strikes produced 29 direct hit events and 34 projectile reflections.
|
||||
- Only five tethers were used; all five hit their intended target class (two wisps, one gunner, two anchors).
|
||||
- Only one dash was used, and it hit no enemy.
|
||||
- Three player-damage events and no defeat/restart.
|
||||
|
||||
## Clear Sequence
|
||||
|
||||
| Arrangement | Attempt | Time | Notable action profile |
|
||||
|---|---:|---:|---|
|
||||
| Drift | 1 | 10.9 s | 5 strikes, 1 tether, 6 strike kills |
|
||||
| Crossfire | 2 | 21.9 s | 8 reflections, 5 direct strike hits, 1 non-damaging dash |
|
||||
| Weight | 3 | 26.8 s | 7 reflections, 4 damaging collisions, anchor tether |
|
||||
| Remix | 4 | 25.7 s | all three tether target classes, 5 reflections, 4 damaging collisions |
|
||||
| Drift replay | 5 | 24.4 s | **one strike hit and killed all six wisps simultaneously**, six-kill chain |
|
||||
| Crossfire replay | 6 | 34.4 s | 14 reflections, only 2 direct strike hits; reflection-heavy clear |
|
||||
|
||||
## Preliminary Interpretation
|
||||
|
||||
This is the strongest voluntary-replay evidence in the project. The replay happened after all authored arrangements were complete and after the first save, so it was not needed for coverage or telemetry submission.
|
||||
|
||||
The replays also do not look like ordinary repetition. Drift replay clustered all six wisps and killed them with one strike. Crossfire replay sharply emphasized reflection over direct hits. These patterns strongly suggest self-imposed execution experiments, but telemetry cannot establish the player's intent or enjoyment.
|
||||
|
||||
Strike/reflection dominated the whole session. This can support two opposite readings:
|
||||
|
||||
1. Reflecting and timing a broad strike was intrinsically satisfying enough to inspire challenge variants.
|
||||
2. Strike was simply the safest universal verb, while tether and dash lacked useful leverage.
|
||||
|
||||
The contrast matters. If the player enjoyed deliberately grouping Drift and performing a reflection-heavy Crossfire clear, H20 receives the first meaningful positive evidence for an intrinsically valued base action. If those replays were diagnostic, accidental, or merely completion cleanup, the behavioral signal is weaker.
|
||||
|
||||
Eight body-collision kills occurred in Weight/Remix, but they may have resulted automatically from anchor charges. Five successful tethers prove the control worked; low reuse indicates either quick exhaustion, poor utility, or lack of appeal. The single dash use with no hit suggests dash was unnecessary, forgotten, or unsatisfying rather than merely difficult.
|
||||
|
||||
## Questions Needed
|
||||
|
||||
1. Was the second Drift run a deliberate attempt to group and kill all six wisps with one strike? What motivated it and how did success feel?
|
||||
2. Was the second Crossfire run deliberately reflection-heavy, and were any body-collision kills intentionally engineered or worth repeating?
|
||||
3. Why were tether and dash mostly abandoned, when did the player want to stop, and did strike/reflection feel good beyond being effective?
|
||||
|
||||
## Initial Player Report
|
||||
|
||||
The second Drift run was not a planned one-strike challenge. The player was testing whether all six wisps could occupy exactly one position, continuing the overlap/extreme-configuration interest seen in Experiment 004. They had missed that enemies collide with one another. When the cluster stopped compressing, they swung once and incidentally killed all six.
|
||||
|
||||
The second Crossfire run **was** a deliberate reflection experiment. The player had initially missed that reflection existed, then returned because trying it seemed interesting. They compared it to reflecting a Minecraft ghast fireball back at the ghast. This establishes intent and interest in the interaction, but not yet whether the execution was satisfying or whether they wanted more after answering the question.
|
||||
|
||||
The eight body-collision kills were not understood as such. They mean enemies striking one another at sufficient relative speed, not player ramming. The player asked whether they could run into enemies to kill them, showing the logged collisions were not deliberately engineered. Do not cite them as successful systemic play.
|
||||
|
||||
Tether was consciously abandoned after several attempts because pulling an enemy closer worsened position without a sufficient payoff. Its selectivity was not enough; the consequence still harmed the player and failed to create leverage.
|
||||
|
||||
Dash was not judged necessary. The player also reports that reactive dodge mechanics do not naturally enter their attention during action: they generally must plan to use one before entering a situation or they forget it. This is not a blanket rejection of dodging as unfun. It is evidence that a reactive defensive verb may fail to participate in play unless encounters create anticipatory use or the verb is also part of an already intended action.
|
||||
|
||||
## Revised Preliminary Interpretation
|
||||
|
||||
Experiment 006 did not broadly validate a three-verb expressive moveset. Strike was sufficient, tether was negatively valued, dash remained cognitively inactive, and collision causality was missed.
|
||||
|
||||
It did isolate the first interaction the player voluntarily replayed because the mechanic itself sounded interesting: reflecting enemy offense back at its source. The likely valuable property is not generic assertiveness. Reflection is a legible **reversal of initiative**: the enemy creates a timed opportunity, and one precise player action converts threat into offense. It has an immediate, concrete payoff and a familiar Minecraft analogy.
|
||||
|
||||
The final report rejects that narrower hypothesis as implemented. The player was ready to stop after the reflection replay. Reflection was interesting as an idea but not enjoyable in use. Aiming the mouse at a moving enemy while also timing the nearby projectile divided attention; watching that pair made other incoming projectiles easy to miss.
|
||||
|
||||
This distinguishes **mechanic recognition** from **interaction enjoyment**. The familiar “return the attack” concept motivated one diagnostic replay, but executing it did not create satisfaction or appetite for another challenge. The replay remains useful behavioral evidence of interest, not evidence of fun.
|
||||
|
||||
The report does not prove counters or reversals are categorically unsuitable. Breakline required simultaneous cursor tracking, projectile-range timing, target motion prediction, and peripheral threat monitoring, while direct striking remained easier. A counter with a fixed return direction, automatic targeting, clearer single-threat cadence, or a much larger payoff could behave differently. Those are untested alternatives, not recommended revisions: the higher-value next experiment should move above isolated action verbs and test whether qualitative chosen growth supplies the missing reason to care about otherwise serviceable action.
|
||||
|
||||
## Final Result
|
||||
|
||||
Experiment 006 failed to find an intrinsically enjoyable base verb. The player completed and voluntarily investigated two edge cases, but was ready to stop afterward. Strike was an adequate universal action; tether usually converted control into danger; dash was not recruited into attention; reflection was conceptually appealing but operationally overloaded attention. Assertive selective combat, stripped of progression and context, was insufficient.
|
||||
3
experiments/006_breakline/results/README.md
Normal file
3
experiments/006_breakline/results/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Experiment 006 Results
|
||||
|
||||
No playtest has been analyzed yet. JSONL telemetry saves to the repository-level `JSONL/` directory.
|
||||
10
experiments/006_breakline/run.sh
Executable file
10
experiments/006_breakline/run.sh
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
#!/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
|
||||
23
experiments/007_catalyst_trials/README.md
Normal file
23
experiments/007_catalyst_trials/README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Experiment 007 — Catalyst Trials
|
||||
|
||||
Two short trials use the same movement, weapon, enemies, and encounters. Their capability choices differ.
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
./experiments/007_catalyst_trials/run.sh
|
||||
```
|
||||
|
||||
Then open <http://127.0.0.1:8000>.
|
||||
|
||||
Controls:
|
||||
|
||||
- Move with `WASD` or the arrow keys.
|
||||
- Aim with the mouse.
|
||||
- Hold the left mouse button to fire.
|
||||
- Pick one catalyst after each field.
|
||||
|
||||
Both trials are available immediately. Play them in either displayed order, replay one if you want, or stop whenever you are ready. When finished, click **Save JSONL**; the server writes it directly into `JSONL/`.
|
||||
|
||||
Please play before reading [`hypothesis.md`](hypothesis.md).
|
||||
|
||||
71
experiments/007_catalyst_trials/hypothesis.md
Normal file
71
experiments/007_catalyst_trials/hypothesis.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Experiment 007 Hypothesis — Private Until After Play
|
||||
|
||||
## Question
|
||||
|
||||
Does rapid, deliberately chosen **qualitative capability growth** create authorship, anticipation, and satisfying payoff where isolated action verbs and purely numerical growth do not?
|
||||
|
||||
## Why This Experiment
|
||||
|
||||
Experiment 006 found that a mechanic can be interesting enough to investigate without being enjoyable in execution. Bare targeted combat did not earn continued play. Across the project, Experiment 002 nevertheless produced the longest sessions while the player repeatedly chose upgrades and became dramatically stronger. That result was confounded: every dimension eventually amplified the same wall-blast loop.
|
||||
|
||||
The player's prior preferences—especially Risk of Rain 2 with Artifact of Command, Crab Champions, Warframe, and Minecraft modpacks—suggest a possible higher-level source of value: choosing a capability trajectory and then seeing combinations express themselves in play.
|
||||
|
||||
Catalyst Trials provides two short runs on the same field, with the same weapon, enemies, movement, wave layouts, pacing, health rules, and number of upgrade decisions. Session order is randomized behind neutral Trial I/II labels.
|
||||
|
||||
- **Amplification condition:** choices improve damage, firing cadence, or projectile width. They change efficiency but not the causal structure of an attack.
|
||||
- **Mutation condition:** choices add forks, kill-blooms, or hit-triggered arcs. Every family can stack and their products can trigger the other selected effects, allowing visible cascades.
|
||||
|
||||
All three choices are offered at every intermission. There are no random drops and no unlock gates.
|
||||
|
||||
## Competing Interpretations
|
||||
|
||||
1. Qualitative composition creates anticipation and a desire to see the next consequence; numerical amplification does not.
|
||||
2. Any rapid growth sustains play through escalating power, regardless of whether it changes interaction structure.
|
||||
3. Upgrade selection creates short-term compliance/novelty but the underlying activity remains unwanted.
|
||||
4. Mutation effects are simply stronger, more spectacular, or easier, confounding authorship with power and feedback.
|
||||
5. The player values planning a known build, but four decisions are too few for meaningful authorship.
|
||||
6. The player values discovery of interactions, but fully described choices remove that discovery.
|
||||
|
||||
## Evidence Priorities
|
||||
|
||||
Strong evidence:
|
||||
|
||||
- deliberation between qualitative options based on an intended combination;
|
||||
- anticipation stated before a combination pays off;
|
||||
- recognizing one selected effect as causing another;
|
||||
- voluntarily replaying to assemble a different combination;
|
||||
- wanting another upgrade or another field specifically to see what the build becomes;
|
||||
- a clear contrast in stop desire between matched conditions.
|
||||
|
||||
Ambiguous evidence:
|
||||
|
||||
- completing both trials;
|
||||
- more kills, shots, or survival time;
|
||||
- selecting one family repeatedly;
|
||||
- visually larger cascades;
|
||||
- playing the second trial because the instructions imply comparison.
|
||||
|
||||
Failure evidence:
|
||||
|
||||
- choices are obvious or inconsequential;
|
||||
- both conditions feel like waiting for the same shooting task to end;
|
||||
- cascade readability collapses into noise;
|
||||
- the player is ready to stop before a build identity forms;
|
||||
- one mutation is a universal answer and eliminates consideration.
|
||||
|
||||
## Analysis Guardrails
|
||||
|
||||
- Randomized order must be reported because familiarity and fatigue remain large confounds in one session.
|
||||
- Do not treat longer clear time as greater engagement; variant balance will not be exact.
|
||||
- Separate reported interest in reading/selecting an upgrade from enjoyment of using its result.
|
||||
- Do not infer “the player likes builds” from completion or selection alone.
|
||||
- If mutation wins, ask whether the valued part was choosing, predicting interactions, audiovisual cascade, raw power, or seeing an authored plan materialize.
|
||||
|
||||
## Feedback Questions
|
||||
|
||||
Ask after inspecting telemetry:
|
||||
|
||||
1. At any choice, were they trying to make a particular interaction happen, or just taking what sounded strongest?
|
||||
2. Was there a selected effect whose result they wanted to see again or build around?
|
||||
3. In each trial, when did they first feel ready to stop, and did the next choice ever reverse that feeling?
|
||||
|
||||
577
experiments/007_catalyst_trials/prototype/app.js
vendored
Normal file
577
experiments/007_catalyst_trials/prototype/app.js
vendored
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
(() => {
|
||||
"use strict";
|
||||
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const $$ = selector => [...document.querySelectorAll(selector)];
|
||||
const canvas = $("#field");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const KEY_VECTOR = {
|
||||
KeyW: [0, -1], ArrowUp: [0, -1], KeyS: [0, 1], ArrowDown: [0, 1],
|
||||
KeyA: [-1, 0], ArrowLeft: [-1, 0], KeyD: [1, 0], ArrowRight: [1, 0]
|
||||
};
|
||||
const COLORS = { mint: "#68e0b5", cyan: "#66d8f2", violet: "#b28bf5", amber: "#efb55e", red: "#ee6f72" };
|
||||
const ENEMY_DEF = {
|
||||
mote: { radius: .018, hp: 2, speed: .066, color: COLORS.red },
|
||||
husk: { radius: .028, hp: 5, speed: .038, color: COLORS.amber }
|
||||
};
|
||||
const WAVE_COUNTS = [7, 10, 13, 17, 22];
|
||||
const UPGRADE_DEF = {
|
||||
power: { icon: "◆", name: "Power Core", short: "Direct shot damage", description: "Shots deal 70% more damage.", color: COLORS.amber },
|
||||
rate: { icon: "»", name: "Cadence Drive", short: "Firing cadence", description: "The emitter fires 35% faster.", color: COLORS.cyan },
|
||||
width: { icon: "◉", name: "Wide Aperture", short: "Projectile width", description: "Shots become 55% wider.", color: COLORS.mint },
|
||||
fork: { icon: "⋔", name: "Fork", short: "Fragments on impact", description: "Primary hits launch two sideways fragments.", color: COLORS.cyan },
|
||||
bloom: { icon: "✦", name: "Bloom", short: "Seeking sparks on kill", description: "Destroyed bodies release three seeking sparks.", color: COLORS.mint },
|
||||
arc: { icon: "ϟ", name: "Arc", short: "Periodic chained hit", description: "Every fifth projectile hit arcs into two nearby bodies.", color: COLORS.violet }
|
||||
};
|
||||
|
||||
function hashSeed(text) {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < text.length; i++) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 16777619); }
|
||||
return hash >>> 0;
|
||||
}
|
||||
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
|
||||
const round = value => Math.round(value * 1000) / 1000;
|
||||
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const normalize = (x, y) => { const length = Math.hypot(x, y) || 1; return [x / length, y / length]; };
|
||||
|
||||
const session = crypto.randomUUID ? crypto.randomUUID() : `session-${Date.now()}`;
|
||||
const mutationFirst = (hashSeed(session) & 1) === 0;
|
||||
const state = {
|
||||
session, sessionStarted: Date.now(), logs: [], order: mutationFirst ? ["mutation", "amplification"] : ["amplification", "mutation"],
|
||||
selectedSlot: 0, startedGame: false, active: false, choosing: false, complete: false,
|
||||
trialAttempt: 0, wave: 0, waveAttempt: 0, trialTime: 0, waveTime: 0, completed: new Set(),
|
||||
upgrades: { power: 0, rate: 0, width: 0, fork: 0, bloom: 0, arc: 0 }, choiceNumber: 0,
|
||||
player: { x: .5, y: .5, vx: 0, vy: 0, health: 6, invulnerable: 0, lastDamage: -99 },
|
||||
keys: new Set(), pointer: { x: .5, y: .25, inside: false, firing: false }, fireCooldown: 0,
|
||||
enemies: [], bullets: [], particles: [], effects: [], enemyId: 0, bulletId: 0,
|
||||
hitCounter: 0, actions: null, lastFrame: performance.now(), snapshotClock: 0, clearDelay: 0,
|
||||
render: { size: 1, ox: 0, oy: 0 }, bannerTimer: null, toastTimer: null
|
||||
};
|
||||
|
||||
function freshActions() {
|
||||
return { shots: 0, primary_hits: 0, fragment_hits: 0, spark_hits: 0, arcs: 0, arc_targets: 0, kills: 0, damage_taken: 0 };
|
||||
}
|
||||
state.actions = freshActions();
|
||||
|
||||
function log(type, data = {}) {
|
||||
const event = {
|
||||
schema: 1, experiment: "007_catalyst_trials", prototype_revision: 1,
|
||||
session_id: state.session, elapsed_ms: Date.now() - state.sessionStarted,
|
||||
slot: state.selectedSlot + 1, condition: state.order[state.selectedSlot], trial_attempt: state.trialAttempt,
|
||||
wave: state.wave + 1, wave_attempt: state.waveAttempt, trial_seconds: round(state.trialTime), type, ...data
|
||||
};
|
||||
state.logs.push(JSON.stringify(event));
|
||||
try { localStorage.setItem("catalyst-trials-last-jsonl", state.logs.join("\n") + "\n"); } catch (_) {}
|
||||
}
|
||||
|
||||
function buildSummary() {
|
||||
return Object.fromEntries(Object.entries(state.upgrades).filter(([, level]) => level > 0));
|
||||
}
|
||||
|
||||
function conditionKeys() {
|
||||
return state.order[state.selectedSlot] === "mutation" ? ["fork", "bloom", "arc"] : ["power", "rate", "width"];
|
||||
}
|
||||
|
||||
function weaponStats() {
|
||||
return {
|
||||
damage: Math.pow(1.7, state.upgrades.power),
|
||||
interval: .22 * Math.pow(.65, state.upgrades.rate),
|
||||
radius: .0085 * Math.pow(1.55, state.upgrades.width)
|
||||
};
|
||||
}
|
||||
|
||||
function waveLayout(index) {
|
||||
const count = WAVE_COUNTS[index];
|
||||
const result = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = (i / count) * Math.PI * 2 + index * .47;
|
||||
const ring = .35 + ((i * 7 + index * 3) % 4) * .035;
|
||||
const kind = index >= 1 && (i + index) % (index >= 3 ? 4 : 5) === 0 ? "husk" : "mote";
|
||||
result.push({ kind, x: .5 + Math.cos(angle) * ring, y: .5 + Math.sin(angle) * ring });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function spawnWave(reason) {
|
||||
state.waveAttempt++;
|
||||
state.waveTime = 0; state.snapshotClock = 0; state.clearDelay = 0;
|
||||
state.enemies = []; state.bullets = []; state.particles = []; state.effects = [];
|
||||
state.player = { x: .5, y: .5, vx: 0, vy: 0, health: 6, invulnerable: 1, lastDamage: -99 };
|
||||
state.fireCooldown = 0; state.pointer.firing = false; state.hitCounter = 0;
|
||||
for (const entry of waveLayout(state.wave)) {
|
||||
const def = ENEMY_DEF[entry.kind];
|
||||
state.enemies.push({ id: ++state.enemyId, kind: entry.kind, x: entry.x, y: entry.y, vx: 0, vy: 0, radius: def.radius, hp: def.hp, maxHp: def.hp, hitFlash: 0, contactCooldown: 0, removed: false });
|
||||
}
|
||||
state.active = state.startedGame; state.choosing = false; state.complete = false;
|
||||
$("#choice-overlay").classList.add("hidden");
|
||||
$("#complete-overlay").classList.add("hidden");
|
||||
updateUI();
|
||||
log("wave_started", { reason, enemy_count: state.enemies.length, layout: state.enemies.map(enemy => ({ id: enemy.id, kind: enemy.kind, position: [round(enemy.x), round(enemy.y)] })), build: buildSummary() });
|
||||
showBanner(`Field ${state.wave + 1}`);
|
||||
canvas.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function startTrial(slot, reason) {
|
||||
if (state.startedGame && (state.active || state.choosing) && !state.complete) log("trial_abandoned", { reason: "switched_or_restarted", wave: state.wave + 1, remaining: state.enemies.filter(enemy => !enemy.removed).length, build: buildSummary(), actions: state.actions });
|
||||
state.selectedSlot = slot; state.trialAttempt++; state.wave = 0; state.waveAttempt = 0; state.trialTime = 0;
|
||||
state.choiceNumber = 0; state.upgrades = { power: 0, rate: 0, width: 0, fork: 0, bloom: 0, arc: 0 };
|
||||
state.actions = freshActions(); state.complete = false; state.choosing = false;
|
||||
$$(".trial").forEach((button, index) => button.classList.toggle("selected", index === slot));
|
||||
log("trial_started", { reason, condition_order: state.order, condition: state.order[slot] });
|
||||
spawnWave("trial_started");
|
||||
}
|
||||
|
||||
function begin() {
|
||||
if (state.startedGame) return;
|
||||
state.startedGame = true;
|
||||
$("#start-overlay").classList.add("hidden");
|
||||
log("session_started", { viewport: [window.innerWidth, window.innerHeight], condition_order: state.order });
|
||||
startTrial(state.selectedSlot, "session_started");
|
||||
}
|
||||
|
||||
function selectSlot(slot) {
|
||||
if (!state.startedGame) {
|
||||
state.selectedSlot = slot;
|
||||
$$(".trial").forEach((button, index) => button.classList.toggle("selected", index === slot));
|
||||
$("#begin").textContent = `Begin Trial ${slot === 0 ? "I" : "II"}`;
|
||||
updateUI();
|
||||
return;
|
||||
}
|
||||
if (slot === state.selectedSlot && !state.complete) return;
|
||||
startTrial(slot, "selected");
|
||||
}
|
||||
|
||||
function fire() {
|
||||
if (!state.active || state.fireCooldown > 0) return;
|
||||
const stats = weaponStats();
|
||||
const [dx, dy] = normalize(state.pointer.x - state.player.x, state.pointer.y - state.player.y);
|
||||
state.fireCooldown = stats.interval; state.actions.shots++;
|
||||
spawnBullet(state.player.x + dx * .025, state.player.y + dy * .025, dx * .64, dy * .64, stats.damage, stats.radius, "primary", 1.45, true);
|
||||
state.effects.push({ kind: "muzzle", x: state.player.x, y: state.player.y, dx, dy, life: .08, maxLife: .08 });
|
||||
log("shot_fired", { aim: [round(dx), round(dy)], position: [round(state.player.x), round(state.player.y)], stats: { damage: round(stats.damage), interval: round(stats.interval), radius: round(stats.radius) } });
|
||||
}
|
||||
|
||||
function spawnBullet(x, y, vx, vy, damage, radius, kind, life, canFork = false, targetId = null) {
|
||||
state.bullets.push({ id: ++state.bulletId, x, y, vx, vy, damage, radius, kind, life, canFork, targetId, removed: false, hitIds: new Set() });
|
||||
}
|
||||
|
||||
function nearestEnemies(origin, count, range, excluded = new Set()) {
|
||||
return state.enemies.filter(enemy => !enemy.removed && !excluded.has(enemy.id) && distance(origin, enemy) <= range)
|
||||
.sort((a, b) => distance(origin, a) - distance(origin, b)).slice(0, count);
|
||||
}
|
||||
|
||||
function projectileHit(enemy, bullet) {
|
||||
if (enemy.removed || bullet.removed || bullet.hitIds.has(enemy.id)) return;
|
||||
bullet.hitIds.add(enemy.id); bullet.removed = true;
|
||||
if (bullet.kind === "primary") state.actions.primary_hits++;
|
||||
if (bullet.kind === "fragment") state.actions.fragment_hits++;
|
||||
if (bullet.kind === "spark") state.actions.spark_hits++;
|
||||
damageEnemy(enemy, bullet.damage, bullet.kind, bullet);
|
||||
state.hitCounter++;
|
||||
if (state.upgrades.arc > 0) {
|
||||
const threshold = Math.max(2, 6 - state.upgrades.arc);
|
||||
if (state.hitCounter % threshold === 0) triggerArc(enemy, bullet.damage * .72);
|
||||
}
|
||||
if (bullet.kind === "primary" && bullet.canFork && state.upgrades.fork > 0) triggerFork(enemy, bullet);
|
||||
}
|
||||
|
||||
function triggerFork(enemy, source) {
|
||||
const count = state.upgrades.fork * 2;
|
||||
const base = Math.atan2(source.vy, source.vx);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const side = i % 2 === 0 ? -1 : 1;
|
||||
const layer = Math.floor(i / 2);
|
||||
const angle = base + side * (.72 + layer * .34);
|
||||
spawnBullet(enemy.x, enemy.y, Math.cos(angle) * .48, Math.sin(angle) * .48, .72, .0065, "fragment", .75, false);
|
||||
}
|
||||
state.effects.push({ kind: "fork", x: enemy.x, y: enemy.y, life: .18, maxLife: .18 });
|
||||
log("mutation_triggered", { mutation: "fork", source_enemy: enemy.id, products: count });
|
||||
}
|
||||
|
||||
function triggerBloom(enemy) {
|
||||
const count = 1 + state.upgrades.bloom * 2;
|
||||
const targets = nearestEnemies(enemy, count, .65, new Set([enemy.id]));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const target = targets[i % Math.max(1, targets.length)];
|
||||
const angle = (i / count) * Math.PI * 2;
|
||||
spawnBullet(enemy.x, enemy.y, Math.cos(angle) * .12, Math.sin(angle) * .12, .82, .007, "spark", 1.5, false, target?.id || null);
|
||||
}
|
||||
state.effects.push({ kind: "bloom", x: enemy.x, y: enemy.y, life: .28, maxLife: .28 });
|
||||
log("mutation_triggered", { mutation: "bloom", source_enemy: enemy.id, products: count, target_ids: targets.map(target => target.id) });
|
||||
}
|
||||
|
||||
function triggerArc(origin, damage) {
|
||||
const count = 1 + state.upgrades.arc;
|
||||
const targets = nearestEnemies(origin, count, .28, new Set([origin.id]));
|
||||
state.actions.arcs++; state.actions.arc_targets += targets.length;
|
||||
for (const target of targets) {
|
||||
state.effects.push({ kind: "arc", x: origin.x, y: origin.y, tx: target.x, ty: target.y, life: .15, maxLife: .15 });
|
||||
damageEnemy(target, damage, "arc", null);
|
||||
}
|
||||
log("mutation_triggered", { mutation: "arc", source_enemy: origin.id, target_ids: targets.map(target => target.id), damage: round(damage) });
|
||||
}
|
||||
|
||||
function damageEnemy(enemy, amount, cause, bullet) {
|
||||
if (enemy.removed) return;
|
||||
enemy.hp -= amount; enemy.hitFlash = .1;
|
||||
if (bullet) {
|
||||
const [dx, dy] = normalize(bullet.vx, bullet.vy);
|
||||
enemy.vx += dx * .025; enemy.vy += dy * .025;
|
||||
}
|
||||
burst(enemy.x, enemy.y, cause === "arc" ? COLORS.violet : COLORS.mint, 4);
|
||||
log("enemy_damaged", { enemy_id: enemy.id, enemy_kind: enemy.kind, amount: round(amount), cause, hp_after: round(Math.max(0, enemy.hp)) });
|
||||
if (enemy.hp > 0) return;
|
||||
enemy.removed = true; state.actions.kills++;
|
||||
burst(enemy.x, enemy.y, ENEMY_DEF[enemy.kind].color, 13);
|
||||
log("enemy_killed", { enemy_id: enemy.id, enemy_kind: enemy.kind, cause, remaining_after: state.enemies.filter(candidate => !candidate.removed).length });
|
||||
if (state.upgrades.bloom > 0) triggerBloom(enemy);
|
||||
}
|
||||
|
||||
function damagePlayer(enemy) {
|
||||
if (state.player.invulnerable > 0 || !state.active) return;
|
||||
state.player.health--; state.player.invulnerable = .85; state.player.lastDamage = state.trialTime; state.actions.damage_taken++;
|
||||
const [dx, dy] = normalize(state.player.x - enemy.x, state.player.y - enemy.y);
|
||||
state.player.vx += dx * .22; state.player.vy += dy * .22;
|
||||
burst(state.player.x, state.player.y, COLORS.red, 11);
|
||||
log("player_damaged", { enemy_id: enemy.id, enemy_kind: enemy.kind, health_after: state.player.health, position: [round(state.player.x), round(state.player.y)] });
|
||||
if (state.player.health <= 0) defeat();
|
||||
}
|
||||
|
||||
function defeat() {
|
||||
state.active = false; state.pointer.firing = false; state.keys.clear();
|
||||
log("player_defeated", { wave_seconds: round(state.waveTime), remaining: state.enemies.filter(enemy => !enemy.removed).length, build: buildSummary(), actions: state.actions });
|
||||
showBanner("Field destabilized · restoring");
|
||||
setTimeout(() => { if (state.startedGame && !state.active && !state.choosing && !state.complete) spawnWave("defeat_restart"); }, 850);
|
||||
}
|
||||
|
||||
function waveCleared() {
|
||||
if (!state.active) return;
|
||||
state.active = false; state.pointer.firing = false;
|
||||
log("wave_completed", { duration_seconds: round(state.waveTime), build: buildSummary(), health: state.player.health, actions: state.actions });
|
||||
if (state.wave >= WAVE_COUNTS.length - 1) {
|
||||
completeTrial();
|
||||
return;
|
||||
}
|
||||
showChoices();
|
||||
}
|
||||
|
||||
function showChoices() {
|
||||
state.choosing = true; state.choiceNumber++;
|
||||
const keys = conditionKeys();
|
||||
const container = $("#choices");
|
||||
container.replaceChildren();
|
||||
for (const key of keys) {
|
||||
const def = UPGRADE_DEF[key];
|
||||
const button = document.createElement("button");
|
||||
button.className = "choice"; button.dataset.upgrade = key;
|
||||
const next = state.upgrades[key] + 1;
|
||||
const stackText = state.upgrades[key] ? `Current level ${state.upgrades[key]} · choose for level ${next}` : "Not selected yet";
|
||||
const scaled = key === "fork" ? `Level ${next}: ${next * 2} fragments per primary hit.`
|
||||
: key === "bloom" ? `Level ${next}: ${1 + next * 2} sparks per kill.`
|
||||
: key === "arc" ? `Level ${next}: every ${Math.max(2, 6 - next)} hits, up to ${1 + next} targets.`
|
||||
: `${def.description}`;
|
||||
button.innerHTML = `<i style="color:${def.color}">${def.icon}</i><b>${def.name}</b><small>${scaled}</small><em>${stackText}</em>`;
|
||||
button.addEventListener("click", () => chooseUpgrade(key));
|
||||
container.append(button);
|
||||
}
|
||||
$("#choice-overlay").classList.remove("hidden");
|
||||
log("choices_shown", { choice_number: state.choiceNumber, options: keys.map(key => ({ key, current_level: state.upgrades[key], next_level: state.upgrades[key] + 1 })) });
|
||||
}
|
||||
|
||||
function chooseUpgrade(key) {
|
||||
if (!state.choosing || !conditionKeys().includes(key)) return;
|
||||
state.upgrades[key]++; state.choosing = false;
|
||||
log("upgrade_chosen", { choice_number: state.choiceNumber, upgrade: key, new_level: state.upgrades[key], build: buildSummary() });
|
||||
state.wave++; state.waveAttempt = 0;
|
||||
updateUI(); spawnWave("upgrade_chosen");
|
||||
}
|
||||
|
||||
function completeTrial() {
|
||||
state.complete = true; state.active = false; state.choosing = false; state.completed.add(state.selectedSlot);
|
||||
log("trial_completed", { duration_seconds: round(state.trialTime), condition: state.order[state.selectedSlot], build: buildSummary(), actions: state.actions });
|
||||
$$(".trial")[state.selectedSlot].classList.add("complete");
|
||||
const other = 1 - state.selectedSlot;
|
||||
$("#complete-title").textContent = `Trial ${state.selectedSlot === 0 ? "I" : "II"} stabilized.`;
|
||||
$("#complete-copy").textContent = state.completed.has(other) ? "Both trials are complete. Replay, save, or stop whenever you want." : "The other trial is available now. Start it, replay this one, save, or stop.";
|
||||
$("#other-trial").textContent = `Start Trial ${other === 0 ? "I" : "II"}`;
|
||||
$("#other-trial").dataset.slot = String(other);
|
||||
$("#complete-overlay").classList.remove("hidden");
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function updatePlayer(dt) {
|
||||
state.player.invulnerable = Math.max(0, state.player.invulnerable - dt);
|
||||
state.fireCooldown = Math.max(0, state.fireCooldown - dt);
|
||||
if (state.player.health < 6 && state.trialTime - state.player.lastDamage > 3.5) {
|
||||
state.player.health = Math.min(6, state.player.health + dt * .45);
|
||||
}
|
||||
let dx = 0, dy = 0;
|
||||
for (const code of state.keys) { const vector = KEY_VECTOR[code]; if (vector) { dx += vector[0]; dy += vector[1]; } }
|
||||
if (dx || dy) {
|
||||
[dx, dy] = normalize(dx, dy);
|
||||
state.player.vx += dx * .92 * dt; state.player.vy += dy * .92 * dt;
|
||||
}
|
||||
const drag = Math.pow(.025, dt); state.player.vx *= drag; state.player.vy *= drag;
|
||||
const speed = Math.hypot(state.player.vx, state.player.vy);
|
||||
if (speed > .29) { state.player.vx *= .29 / speed; state.player.vy *= .29 / speed; }
|
||||
state.player.x = clamp(state.player.x + state.player.vx * dt, .035, .965);
|
||||
state.player.y = clamp(state.player.y + state.player.vy * dt, .035, .965);
|
||||
if (state.pointer.firing) fire();
|
||||
}
|
||||
|
||||
function updateEnemies(dt) {
|
||||
for (const enemy of state.enemies) {
|
||||
if (enemy.removed) continue;
|
||||
enemy.hitFlash = Math.max(0, enemy.hitFlash - dt); enemy.contactCooldown = Math.max(0, enemy.contactCooldown - dt);
|
||||
const [dx, dy] = normalize(state.player.x - enemy.x, state.player.y - enemy.y);
|
||||
const def = ENEMY_DEF[enemy.kind];
|
||||
enemy.vx += dx * def.speed * 4.5 * dt; enemy.vy += dy * def.speed * 4.5 * dt;
|
||||
const drag = Math.pow(.12, dt); enemy.vx *= drag; enemy.vy *= drag;
|
||||
const speed = Math.hypot(enemy.vx, enemy.vy);
|
||||
if (speed > def.speed) { enemy.vx *= def.speed / speed; enemy.vy *= def.speed / speed; }
|
||||
enemy.x = clamp(enemy.x + enemy.vx * dt, enemy.radius, 1 - enemy.radius);
|
||||
enemy.y = clamp(enemy.y + enemy.vy * dt, enemy.radius, 1 - enemy.radius);
|
||||
if (distance(enemy, state.player) < enemy.radius + .022) damagePlayer(enemy);
|
||||
}
|
||||
for (let i = 0; i < state.enemies.length; i++) {
|
||||
const a = state.enemies[i]; if (a.removed) continue;
|
||||
for (let j = i + 1; j < state.enemies.length; j++) {
|
||||
const b = state.enemies[j]; if (b.removed) continue;
|
||||
const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy) || .001, minimum = a.radius + b.radius;
|
||||
if (d >= minimum) continue;
|
||||
const overlap = (minimum - d) * .5, nx = dx / d, ny = dy / d;
|
||||
a.x -= nx * overlap; a.y -= ny * overlap; b.x += nx * overlap; b.y += ny * overlap;
|
||||
}
|
||||
}
|
||||
state.enemies = state.enemies.filter(enemy => !enemy.removed);
|
||||
}
|
||||
|
||||
function updateBullets(dt) {
|
||||
for (const bullet of state.bullets) {
|
||||
if (bullet.removed) continue;
|
||||
bullet.life -= dt;
|
||||
if (bullet.kind === "spark" && bullet.targetId) {
|
||||
let target = state.enemies.find(enemy => enemy.id === bullet.targetId && !enemy.removed);
|
||||
if (!target) {
|
||||
target = nearestEnemies(bullet, 1, .7)[0]; bullet.targetId = target?.id || null;
|
||||
}
|
||||
if (target) {
|
||||
const [tx, ty] = normalize(target.x - bullet.x, target.y - bullet.y);
|
||||
bullet.vx += tx * 1.25 * dt; bullet.vy += ty * 1.25 * dt;
|
||||
const speed = Math.hypot(bullet.vx, bullet.vy) || 1;
|
||||
bullet.vx = bullet.vx / speed * .47; bullet.vy = bullet.vy / speed * .47;
|
||||
}
|
||||
}
|
||||
bullet.x += bullet.vx * dt; bullet.y += bullet.vy * dt;
|
||||
if (bullet.life <= 0 || bullet.x < -.04 || bullet.x > 1.04 || bullet.y < -.04 || bullet.y > 1.04) { bullet.removed = true; continue; }
|
||||
for (const enemy of state.enemies) {
|
||||
if (!enemy.removed && distance(bullet, enemy) <= bullet.radius + enemy.radius) { projectileHit(enemy, bullet); break; }
|
||||
}
|
||||
}
|
||||
state.bullets = state.bullets.filter(bullet => !bullet.removed);
|
||||
}
|
||||
|
||||
function updateEffects(dt) {
|
||||
for (const particle of state.particles) { particle.life -= dt; particle.x += particle.vx * dt; particle.y += particle.vy * dt; particle.vx *= Math.pow(.12, dt); particle.vy *= Math.pow(.12, dt); }
|
||||
for (const effect of state.effects) effect.life -= dt;
|
||||
state.particles = state.particles.filter(particle => particle.life > 0);
|
||||
state.effects = state.effects.filter(effect => effect.life > 0);
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
if (!state.active) return;
|
||||
state.trialTime += dt; state.waveTime += dt; state.snapshotClock += dt;
|
||||
updatePlayer(dt); updateEnemies(dt); updateBullets(dt); updateEffects(dt);
|
||||
if (state.enemies.length === 0) {
|
||||
state.clearDelay += dt;
|
||||
if (state.clearDelay >= .65) waveCleared();
|
||||
}
|
||||
if (state.snapshotClock >= 5) {
|
||||
state.snapshotClock = 0;
|
||||
log("state_snapshot", { player: [round(state.player.x), round(state.player.y)], health: round(state.player.health), remaining: state.enemies.length, projectiles: state.bullets.length, build: buildSummary(), actions: state.actions });
|
||||
}
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function burst(x, y, color, count) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = Math.random() * Math.PI * 2, speed = .035 + Math.random() * .11;
|
||||
state.particles.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, color, life: .18 + Math.random() * .32, maxLife: .5, radius: .002 + Math.random() * .003 });
|
||||
}
|
||||
}
|
||||
|
||||
function worldToScreen(x, y) {
|
||||
return [state.render.ox + x * state.render.size, state.render.oy + y * state.render.size];
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const dpr = window.devicePixelRatio || 1, width = canvas.clientWidth, height = canvas.clientHeight;
|
||||
if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
|
||||
canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr);
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height);
|
||||
const size = Math.max(100, Math.min(width - 38, height - 38));
|
||||
state.render = { size, ox: (width - size) / 2, oy: (height - size) / 2 };
|
||||
ctx.fillStyle = "#081219"; ctx.fillRect(state.render.ox, state.render.oy, size, size);
|
||||
ctx.strokeStyle = "#182c34"; ctx.lineWidth = 1;
|
||||
for (let i = 1; i < 10; i++) {
|
||||
const p = i / 10 * size;
|
||||
ctx.beginPath(); ctx.moveTo(state.render.ox + p, state.render.oy); ctx.lineTo(state.render.ox + p, state.render.oy + size); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(state.render.ox, state.render.oy + p); ctx.lineTo(state.render.ox + size, state.render.oy + p); ctx.stroke();
|
||||
}
|
||||
ctx.strokeStyle = "#36515b"; ctx.lineWidth = 2; ctx.strokeRect(state.render.ox, state.render.oy, size, size);
|
||||
|
||||
for (const effect of state.effects) drawEffect(effect);
|
||||
for (const bullet of state.bullets) {
|
||||
const [x, y] = worldToScreen(bullet.x, bullet.y);
|
||||
ctx.fillStyle = bullet.kind === "spark" ? COLORS.mint : bullet.kind === "fragment" ? COLORS.cyan : "#f1f6dc";
|
||||
ctx.shadowColor = ctx.fillStyle; ctx.shadowBlur = bullet.kind === "primary" ? 7 : 11;
|
||||
ctx.beginPath(); ctx.arc(x, y, Math.max(2.5, bullet.radius * size), 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0;
|
||||
}
|
||||
for (const enemy of state.enemies) drawEnemy(enemy);
|
||||
for (const particle of state.particles) {
|
||||
const [x, y] = worldToScreen(particle.x, particle.y);
|
||||
ctx.globalAlpha = clamp(particle.life / particle.maxLife, 0, 1); ctx.fillStyle = particle.color;
|
||||
ctx.beginPath(); ctx.arc(x, y, particle.radius * size, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1;
|
||||
}
|
||||
drawPlayer();
|
||||
}
|
||||
|
||||
function drawEnemy(enemy) {
|
||||
const [x, y] = worldToScreen(enemy.x, enemy.y), radius = enemy.radius * state.render.size;
|
||||
ctx.save(); ctx.translate(x, y);
|
||||
ctx.fillStyle = enemy.hitFlash > 0 ? "#ffffff" : ENEMY_DEF[enemy.kind].color;
|
||||
ctx.shadowColor = ENEMY_DEF[enemy.kind].color; ctx.shadowBlur = 9;
|
||||
ctx.beginPath();
|
||||
if (enemy.kind === "husk") {
|
||||
for (let i = 0; i < 6; i++) { const a = i / 6 * Math.PI * 2; const px = Math.cos(a) * radius, py = Math.sin(a) * radius; i ? ctx.lineTo(px, py) : ctx.moveTo(px, py); }
|
||||
ctx.closePath();
|
||||
} else ctx.arc(0, 0, radius, 0, Math.PI * 2);
|
||||
ctx.fill(); ctx.shadowBlur = 0;
|
||||
ctx.strokeStyle = "#081015"; ctx.lineWidth = 2; ctx.stroke();
|
||||
if (enemy.hp < enemy.maxHp) {
|
||||
ctx.fillStyle = "#1b2b30"; ctx.fillRect(-radius, radius + 5, radius * 2, 3);
|
||||
ctx.fillStyle = COLORS.mint; ctx.fillRect(-radius, radius + 5, radius * 2 * clamp(enemy.hp / enemy.maxHp, 0, 1), 3);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPlayer() {
|
||||
const [x, y] = worldToScreen(state.player.x, state.player.y), radius = .021 * state.render.size;
|
||||
const [ax, ay] = normalize(state.pointer.x - state.player.x, state.pointer.y - state.player.y);
|
||||
ctx.save(); ctx.translate(x, y); ctx.rotate(Math.atan2(ay, ax));
|
||||
ctx.globalAlpha = state.player.invulnerable > 0 && Math.floor(state.player.invulnerable * 16) % 2 ? .35 : 1;
|
||||
ctx.fillStyle = COLORS.mint; ctx.shadowColor = COLORS.mint; ctx.shadowBlur = 12;
|
||||
ctx.beginPath(); ctx.moveTo(radius * 1.35, 0); ctx.lineTo(-radius * .8, radius * .8); ctx.lineTo(-radius * .52, 0); ctx.lineTo(-radius * .8, -radius * .8); ctx.closePath(); ctx.fill();
|
||||
ctx.shadowBlur = 0; ctx.restore();
|
||||
}
|
||||
|
||||
function drawEffect(effect) {
|
||||
const alpha = clamp(effect.life / effect.maxLife, 0, 1); ctx.save(); ctx.globalAlpha = alpha;
|
||||
if (effect.kind === "arc") {
|
||||
const [x, y] = worldToScreen(effect.x, effect.y), [tx, ty] = worldToScreen(effect.tx, effect.ty);
|
||||
ctx.strokeStyle = COLORS.violet; ctx.lineWidth = 3; ctx.shadowColor = COLORS.violet; ctx.shadowBlur = 10;
|
||||
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo((x + tx) / 2 + (Math.random() - .5) * 12, (y + ty) / 2 + (Math.random() - .5) * 12); ctx.lineTo(tx, ty); ctx.stroke();
|
||||
} else {
|
||||
const [x, y] = worldToScreen(effect.x, effect.y);
|
||||
ctx.strokeStyle = effect.kind === "bloom" ? COLORS.mint : effect.kind === "fork" ? COLORS.cyan : "#eef8de";
|
||||
ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, y, (1 - alpha) * .05 * state.render.size + 4, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
const roman = state.selectedSlot === 0 ? "I" : "II";
|
||||
$("#trial-kicker").textContent = `TRIAL ${roman} · FIELD ${state.wave + 1} OF 5`;
|
||||
$("#trial-name").textContent = state.upgrades && Object.values(state.upgrades).some(Boolean) ? "Catalyzed" : "Uncalibrated";
|
||||
$("#health").textContent = Array.from({ length: 6 }, (_, i) => i < Math.ceil(state.player.health) ? "●" : "○").join(" ");
|
||||
$("#wave").textContent = `${state.wave + 1} / 5`;
|
||||
$("#remaining").textContent = String(state.enemies.length);
|
||||
const seconds = Math.floor(state.trialTime); $("#trial-time").textContent = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
const stats = weaponStats();
|
||||
const values = { power: stats.damage, rate: .22 / stats.interval, width: stats.radius / .0085 };
|
||||
for (const key of ["power", "rate", "width"]) {
|
||||
$(`#${key}-value`).textContent = `${values[key].toFixed(1)}×`;
|
||||
$(`#${key}-bar`).style.width = `${clamp(20 + Math.log2(values[key]) * 22, 20, 100)}%`;
|
||||
}
|
||||
const list = $("#build-list"); list.replaceChildren();
|
||||
const selected = Object.entries(state.upgrades).filter(([, level]) => level > 0);
|
||||
if (!selected.length) {
|
||||
const empty = document.createElement("p"); empty.className = "empty"; empty.textContent = "No catalysts selected yet."; list.append(empty);
|
||||
} else {
|
||||
for (const [key, level] of selected) {
|
||||
const def = UPGRADE_DEF[key], item = document.createElement("div"); item.className = "build-item";
|
||||
item.innerHTML = `<i style="color:${def.color}">${def.icon}</i><span><b>${def.name}</b><small>${def.short}</small></span><em>LV ${level}</em>`;
|
||||
list.append(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showBanner(message) {
|
||||
const banner = $("#banner"); banner.textContent = message; banner.classList.add("show");
|
||||
clearTimeout(state.bannerTimer); state.bannerTimer = setTimeout(() => banner.classList.remove("show"), 1000);
|
||||
}
|
||||
|
||||
function toast(message) {
|
||||
const element = $("#toast"); element.textContent = message; element.classList.add("show");
|
||||
clearTimeout(state.toastTimer); state.toastTimer = setTimeout(() => element.classList.remove("show"), 3300);
|
||||
}
|
||||
|
||||
async function saveLog() {
|
||||
log("session_saved", { completed_slots: [...state.completed].map(slot => slot + 1), current_build: buildSummary(), actions: state.actions, event_count_before_save: state.logs.length });
|
||||
const body = state.logs.join("\n") + "\n", filename = `catalyst-trials-${state.session}.jsonl`;
|
||||
try {
|
||||
const response = await fetch("/api/playtest-log", { method: "POST", headers: { "Content-Type": "application/x-ndjson", "X-Playtest-Filename": filename }, body });
|
||||
if (!response.ok) throw new Error(`server returned ${response.status}`);
|
||||
const result = await response.json(); toast(`Saved ${result.events} events to ${result.path}`); return;
|
||||
} catch (error) {
|
||||
const blob = new Blob([body], { type: "application/x-ndjson" }), link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob); link.download = filename; link.click(); URL.revokeObjectURL(link.href);
|
||||
toast(`Server save unavailable; downloaded ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
function pointerPosition(event) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
state.pointer.x = clamp((event.clientX - rect.left - state.render.ox) / state.render.size, 0, 1);
|
||||
state.pointer.y = clamp((event.clientY - rect.top - state.render.oy) / state.render.size, 0, 1);
|
||||
state.pointer.inside = true;
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointermove", pointerPosition);
|
||||
canvas.addEventListener("pointerdown", event => {
|
||||
pointerPosition(event); if (event.button === 0) { state.pointer.firing = true; fire(); }
|
||||
event.preventDefault(); canvas.focus({ preventScroll: true });
|
||||
});
|
||||
window.addEventListener("pointerup", event => { if (event.button === 0) state.pointer.firing = false; });
|
||||
canvas.addEventListener("pointerleave", () => { state.pointer.inside = false; });
|
||||
canvas.addEventListener("contextmenu", event => event.preventDefault());
|
||||
window.addEventListener("keydown", event => {
|
||||
if (KEY_VECTOR[event.code]) { state.keys.add(event.code); event.preventDefault(); }
|
||||
});
|
||||
window.addEventListener("keyup", event => { state.keys.delete(event.code); });
|
||||
window.addEventListener("blur", () => { state.keys.clear(); state.pointer.firing = false; });
|
||||
document.addEventListener("visibilitychange", () => { if (state.startedGame) log("visibility_changed", { hidden: document.hidden }); });
|
||||
|
||||
$$(".trial").forEach((button, slot) => button.addEventListener("click", () => selectSlot(slot)));
|
||||
$("#begin").addEventListener("click", begin);
|
||||
$("#restart").addEventListener("click", () => state.startedGame ? startTrial(state.selectedSlot, "restart_button") : begin());
|
||||
$("#save").addEventListener("click", saveLog);
|
||||
$("#complete-save").addEventListener("click", saveLog);
|
||||
$("#other-trial").addEventListener("click", event => startTrial(Number(event.currentTarget.dataset.slot), "complete_other"));
|
||||
$("#replay").addEventListener("click", () => startTrial(state.selectedSlot, "complete_replay"));
|
||||
|
||||
if (new URLSearchParams(location.search).has("validation")) {
|
||||
window.__catalystDebug = { getState: () => state, startTrial, spawnWave, chooseUpgrade, damageEnemy, spawnBullet };
|
||||
}
|
||||
|
||||
function frame(now) {
|
||||
const dt = Math.min(.04, (now - state.lastFrame) / 1000 || 0); state.lastFrame = now;
|
||||
update(dt); draw(); requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
updateUI();
|
||||
log("session_initialized", { condition_order: state.order, viewport: [window.innerWidth, window.innerHeight] });
|
||||
spawnWave("initial_preview"); state.active = false;
|
||||
requestAnimationFrame(frame);
|
||||
})();
|
||||
100
experiments/007_catalyst_trials/prototype/index.html
Normal file
100
experiments/007_catalyst_trials/prototype/index.html
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Catalyst Trials — Experiment 007</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="title"><span>EXPERIMENT 007</span><h1>Catalyst Trials</h1></div>
|
||||
<nav aria-label="Trial selection">
|
||||
<button class="trial selected" data-slot="0"><span>I</span> Trial I</button>
|
||||
<button class="trial" data-slot="1"><span>II</span> Trial II</button>
|
||||
</nav>
|
||||
<div class="top-actions">
|
||||
<button id="restart" type="button">Restart trial</button>
|
||||
<button id="save" type="button">Save JSONL</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<aside>
|
||||
<section class="brief">
|
||||
<span id="trial-kicker">TRIAL I · FIELD 1 OF 5</span>
|
||||
<h2 id="trial-name">Uncalibrated</h2>
|
||||
<p>Clear each field. Between fields, choose one catalyst. Nothing is locked behind the other trial.</p>
|
||||
</section>
|
||||
|
||||
<section class="controls">
|
||||
<h3>Direct controls</h3>
|
||||
<p><kbd>WASD</kbd><span>Move.</span></p>
|
||||
<p><kbd>HOLD LEFT</kbd><span>Fire toward the cursor.</span></p>
|
||||
</section>
|
||||
|
||||
<section class="build">
|
||||
<h3>Current catalysts</h3>
|
||||
<div id="build-list" class="build-list"><p class="empty">No catalysts selected yet.</p></div>
|
||||
</section>
|
||||
|
||||
<section class="readouts">
|
||||
<div><span>INTEGRITY</span><b id="health">● ● ● ● ● ●</b></div>
|
||||
<div><span>FIELD</span><b id="wave">1 / 5</b></div>
|
||||
<div><span>REMAINING</span><b id="remaining">0</b></div>
|
||||
<div><span>TRIAL TIME</span><b id="trial-time">0:00</b></div>
|
||||
</section>
|
||||
|
||||
<section class="weapon-readout">
|
||||
<h3>Emitter</h3>
|
||||
<div><span>POWER</span><i><b id="power-bar"></b></i><em id="power-value">1.0</em></div>
|
||||
<div><span>CADENCE</span><i><b id="rate-bar"></b></i><em id="rate-value">1.0</em></div>
|
||||
<div><span>WIDTH</span><i><b id="width-bar"></b></i><em id="width-value">1.0</em></div>
|
||||
</section>
|
||||
|
||||
<section class="note">The two trials share the same five fields. Completion, speed, and kill count are not scores. You can switch, replay, or stop.</section>
|
||||
</aside>
|
||||
|
||||
<section class="playfield">
|
||||
<canvas id="field" tabindex="0" aria-label="Catalyst combat field"></canvas>
|
||||
<div id="banner" class="banner" aria-live="polite"></div>
|
||||
<div class="canvas-help">WASD move · aim with mouse · hold left to fire</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="start-overlay" class="overlay start-overlay">
|
||||
<div>
|
||||
<span>TWO SHORT TRIALS</span>
|
||||
<b>Same fields. Different catalysts.</b>
|
||||
<p>Clear five compact fields in each trial. Choose one catalyst after every field. Both trials are available now; stop whenever you want.</p>
|
||||
<button id="begin" type="button">Begin Trial I</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="choice-overlay" class="overlay choice-overlay hidden">
|
||||
<div>
|
||||
<span>FIELD STABLE</span>
|
||||
<b>Choose one catalyst</b>
|
||||
<p>Every option remains available at later choices.</p>
|
||||
<div id="choices"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="complete-overlay" class="overlay complete-overlay hidden">
|
||||
<div>
|
||||
<span>TRIAL COMPLETE</span>
|
||||
<b id="complete-title">Field sequence cleared.</b>
|
||||
<p id="complete-copy">You can start the other trial, replay this one, save now, or stop.</p>
|
||||
<div class="complete-actions">
|
||||
<button id="other-trial" type="button">Start Trial II</button>
|
||||
<button id="replay" type="button">Replay this trial</button>
|
||||
<button id="complete-save" type="button">Save JSONL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" role="status"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
105
experiments/007_catalyst_trials/prototype/style.css
Normal file
105
experiments/007_catalyst_trials/prototype/style.css
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #05090d;
|
||||
--panel: #0b141a;
|
||||
--panel2: #101e26;
|
||||
--line: #2b414b;
|
||||
--text: #edf5f3;
|
||||
--muted: #8da1a4;
|
||||
--mint: #68e0b5;
|
||||
--cyan: #66d8f2;
|
||||
--violet: #b28bf5;
|
||||
--amber: #efb55e;
|
||||
--red: #ee6f72;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; margin: 0; }
|
||||
body { overflow: hidden; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, sans-serif; user-select: none; }
|
||||
button { border: 1px solid var(--line); border-radius: 7px; background: #13232c; color: var(--text); font: inherit; cursor: pointer; }
|
||||
button:hover { border-color: #5b7d86; background: #1a303a; }
|
||||
button.selected { border-color: var(--mint); background: #123028; color: var(--mint); }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin: 0; font-size: 20px; }
|
||||
h2 { margin: 4px 0 0; font-size: 21px; }
|
||||
h3 { margin: 0 0 7px; color: #a9bcba; font-size: 8px; letter-spacing: .14em; text-transform: uppercase; }
|
||||
|
||||
header { height: 64px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; padding: 8px 14px; border-bottom: 1px solid var(--line); background: #081015; }
|
||||
.title > span, .brief > span, .overlay span { display: block; color: var(--mint); font-size: 8px; font-weight: 850; letter-spacing: .18em; }
|
||||
nav { display: flex; gap: 7px; }
|
||||
nav button { min-width: 112px; padding: 8px 12px; }
|
||||
nav button span { margin-right: 5px; color: #718488; font: 8px ui-monospace, monospace; }
|
||||
nav button.complete::after { content: " ✓"; color: var(--mint); }
|
||||
.top-actions { display: flex; justify-content: flex-end; gap: 7px; }
|
||||
.top-actions button { padding: 8px 10px; }
|
||||
|
||||
main { height: calc(100vh - 64px); min-height: 0; display: grid; grid-template-columns: clamp(285px, 22vw, 345px) minmax(0, 1fr); overflow: hidden; }
|
||||
aside { min-height: 0; overflow-y: auto; scrollbar-gutter: stable; padding: 12px 14px; border-right: 1px solid var(--line); background: #081015; }
|
||||
aside section { margin-bottom: 11px; }
|
||||
.brief { padding: 11px; border: 1px solid #356054; border-radius: 9px; background: linear-gradient(145deg, #10261f, #101a20); }
|
||||
.brief p { margin: 7px 0 0; color: #bac9c6; font-size: 9px; line-height: 1.45; }
|
||||
.controls { padding: 9px 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel); }
|
||||
.controls p { display: grid; grid-template-columns: 72px 1fr; gap: 7px; align-items: center; margin: 5px 0; color: var(--muted); font-size: 8px; }
|
||||
kbd { padding: 3px 5px; border: 1px solid #53686d; border-bottom-width: 2px; border-radius: 4px; background: #17272e; color: var(--text); font: 8px ui-monospace, monospace; text-align: center; }
|
||||
.build-list { display: grid; gap: 5px; }
|
||||
.build-list .empty { margin: 0; padding: 9px; border: 1px dashed #344a52; border-radius: 6px; color: #718589; font-size: 8px; }
|
||||
.build-item { display: grid; grid-template-columns: 25px 1fr auto; gap: 7px; align-items: center; padding: 7px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
|
||||
.build-item i { display: grid; width: 24px; height: 24px; place-items: center; border-radius: 50%; background: #172830; font-style: normal; font-size: 13px; }
|
||||
.build-item b, .build-item small { display: block; }
|
||||
.build-item b { font-size: 8px; letter-spacing: .06em; }
|
||||
.build-item small { margin-top: 2px; color: var(--muted); font-size: 7px; }
|
||||
.build-item em { color: var(--mint); font: 9px ui-monospace, monospace; font-style: normal; }
|
||||
.readouts { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
|
||||
.readouts div { padding: 7px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
|
||||
.readouts span, .readouts b { display: block; }
|
||||
.readouts span { color: var(--muted); font-size: 6px; letter-spacing: .09em; }
|
||||
.readouts b { margin-top: 4px; color: #dce8e5; font: 9px ui-monospace, monospace; }
|
||||
#health { color: var(--mint); letter-spacing: 1px; }
|
||||
.weapon-readout { display: grid; gap: 6px; }
|
||||
.weapon-readout h3 { margin-bottom: 1px; }
|
||||
.weapon-readout > div { display: grid; grid-template-columns: 55px 1fr 28px; gap: 7px; align-items: center; }
|
||||
.weapon-readout span { color: var(--muted); font-size: 7px; }
|
||||
.weapon-readout i { height: 5px; overflow: hidden; border-radius: 5px; background: #1b2a31; }
|
||||
.weapon-readout i b { display: block; width: 25%; height: 100%; background: var(--mint); transition: width .2s; }
|
||||
.weapon-readout em { color: #c7d8d5; font: 8px ui-monospace, monospace; font-style: normal; text-align: right; }
|
||||
.note { padding: 8px 9px; border-left: 2px solid #415c64; color: var(--muted); font-size: 8px; line-height: 1.45; }
|
||||
|
||||
.playfield { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: radial-gradient(circle at 50% 48%, #11232c, #04080b 76%); }
|
||||
#field { display: block; width: 100%; height: 100%; outline: none; touch-action: none; cursor: crosshair; }
|
||||
.canvas-help { position: absolute; z-index: 3; left: 50%; bottom: 13px; translate: -50%; padding: 7px 11px; border-radius: 6px; background: #071015dc; color: #809499; font-size: 9px; pointer-events: none; }
|
||||
.banner { position: absolute; z-index: 5; left: 50%; top: 20px; translate: -50% -8px; min-width: 250px; padding: 10px 16px; border: 1px solid #4a7569; border-radius: 8px; background: #10231edc; color: var(--mint); opacity: 0; text-align: center; pointer-events: none; transition: .2s; font-size: 11px; font-weight: 750; }
|
||||
.banner.show { opacity: 1; translate: -50% 0; }
|
||||
|
||||
.overlay { position: fixed; z-index: 20; inset: 64px 0 0 clamp(285px, 22vw, 345px); display: grid; place-items: center; background: #030709dc; }
|
||||
.overlay.hidden { display: none; }
|
||||
.overlay > div { width: min(700px, 90%); padding: 24px 27px; border: 1px solid #426e62; border-radius: 10px; background: #101b20f5; text-align: center; }
|
||||
.overlay b, .overlay p { display: block; }
|
||||
.overlay > div > b { margin-top: 7px; font-size: 16px; }
|
||||
.overlay > div > p { margin: 9px 0 14px; color: var(--muted); font-size: 10px; line-height: 1.45; }
|
||||
.start-overlay button, .complete-actions button { padding: 10px 15px; }
|
||||
#choices { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin-top: 16px; text-align: left; }
|
||||
.choice { min-height: 132px; padding: 13px; border-color: #3b535c; background: #0d1a20; }
|
||||
.choice:hover { border-color: var(--mint); transform: translateY(-1px); }
|
||||
.choice i { display: grid; width: 34px; height: 34px; place-items: center; margin-bottom: 10px; border-radius: 50%; background: #172b32; color: var(--mint); font-style: normal; font-size: 18px; }
|
||||
.choice b { font-size: 11px; }
|
||||
.choice small { display: block; margin-top: 7px; color: var(--muted); font-size: 8px; line-height: 1.45; }
|
||||
.choice em { display: block; margin-top: 9px; color: var(--amber); font: 7px ui-monospace, monospace; font-style: normal; text-transform: uppercase; }
|
||||
.complete-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
|
||||
#toast { position: fixed; z-index: 30; left: calc(50% + 150px); bottom: 20px; translate: -50% 12px; max-width: 500px; padding: 10px 14px; border: 1px solid #4c7368; border-radius: 8px; background: #10231e; box-shadow: 0 10px 30px #000b; opacity: 0; pointer-events: none; transition: .2s; font-size: 10px; }
|
||||
#toast.show { opacity: 1; translate: -50% 0; }
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
nav button { min-width: auto; padding: 7px; font-size: 9px; }
|
||||
main { grid-template-columns: 270px minmax(0, 1fr); }
|
||||
.overlay { left: 270px; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
body { overflow: auto; }
|
||||
header { height: auto; grid-template-columns: 1fr; }
|
||||
.top-actions { justify-content: flex-start; }
|
||||
main { height: auto; grid-template-columns: 1fr; overflow: visible; }
|
||||
.playfield { height: min(80vh, 650px); }
|
||||
.overlay { inset: 0; }
|
||||
#choices { grid-template-columns: 1fr; max-height: 65vh; overflow-y: auto; }
|
||||
#toast { left: 50%; }
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
# Experiment 007 Preliminary Analysis — Session 56ccf2a6
|
||||
|
||||
Status: complete from updated telemetry plus player report.
|
||||
|
||||
Source: `JSONL/catalyst-trials-56ccf2a6-9613-4da1-86aa-3c5d61797784 (1).jsonl` supersedes the earlier partial save with the same session ID.
|
||||
|
||||
## Session Structure
|
||||
|
||||
- 2,900 events over 1,053.8 elapsed seconds, including long out-of-game gaps between later runs.
|
||||
- The displayed mapping was Trial I = `mutation`, Trial II = `amplification`.
|
||||
- The player completed Trial I, saved, completed Trial II, saved, then voluntarily completed three more Trial I runs with different builds.
|
||||
- All 25 fields were cleared on their first attempt. There were no defeats and only two total player-damage events.
|
||||
- Each of the five runs defeated the same 69 enemies. This confirms matched coverage; it is not enjoyment evidence.
|
||||
|
||||
## Run Summary
|
||||
|
||||
| Run | Condition | Final build | Active trial time | Final action profile |
|
||||
|---|---|---|---:|---|
|
||||
| Trial I | Mutation | Fork 1, Arc 3 | 47.7 s | 153 primary shots; 96 fragment hits; 32 Arc triggers |
|
||||
| Trial II | Amplification | Rate 2, Width 1, Power 1 | 52.2 s | 288 primary shots; no secondary effects |
|
||||
| Trial I replay | Mutation | Arc 4 | 60.0 s | 187 primary shots; 36 Arc triggers; no Fork/Bloom |
|
||||
| Trial I combined replay | Mutation | Bloom 2, Fork 1, Arc 1 | 48.7 s | 58 fragment hits; 66 spark hits; 23 Arc triggers |
|
||||
| Trial I Bloom replay | Mutation | Bloom 4 | 54.0 s | 140 primary shots; 136 spark hits |
|
||||
|
||||
Kill attribution:
|
||||
|
||||
- First mutation run: 26 primary, 32 fragment, 11 Arc.
|
||||
- Amplification run: 69 primary.
|
||||
- Arc-only replay: 47 primary, 22 Arc.
|
||||
- Combined replay: 30 primary, 22 fragment, 11 spark, 6 Arc.
|
||||
- Bloom-only replay: 42 primary, 27 spark.
|
||||
|
||||
The first mutation build produced a real causal combination: Fork fragments counted toward the hit cadence that triggered Arc. It also cleared later fields quickly, but speed and effect count cannot establish whether the combination felt satisfying, strategically authored, or merely powerful.
|
||||
|
||||
## Choice Sequence and Deliberation
|
||||
|
||||
| Run | Choices | Approximate deliberation per choice |
|
||||
|---|---|---|
|
||||
| First mutation | Fork → Arc → Arc → Arc | 4.0 s, 8.8 s, 6.0 s, 1.0 s |
|
||||
| Amplification | Rate → Width → Power → Rate | 10.0 s, 3.8 s, 2.9 s, 2.5 s |
|
||||
| Mutation replay | Arc → Arc → Arc → Arc | 1.0 s, 0.9 s, 0.6 s, 0.6 s |
|
||||
|
||||
The replay pattern is unusually specific. It began about five seconds after both displayed trials had been completed and the second save had succeeded. The near-immediate repeated Arc choices suggest a preformed test of pure stacking rather than ordinary indecision or accidental continuation. This is an inference; the player's motive is not logged.
|
||||
|
||||
Bloom was never selected. Telemetry cannot distinguish an unattractive description, an apparently weak effect, deliberate focus on the other interaction, or simple exhaustion of available decisions.
|
||||
|
||||
## Preliminary Interpretation
|
||||
|
||||
Experiment 007 produced the strongest behavioral evidence so far for testing a **capability trajectory** rather than only an isolated mechanic. Unlike the optional replays in 004 and 006, the third run changed a four-decision build to isolate one upgrade family's scaling after the player had already seen a mixed interaction build and the numerical condition.
|
||||
|
||||
That distinction is promising but still insufficient. Three alternative explanations remain live:
|
||||
|
||||
1. Arc stacking created genuine anticipation or payoff and motivated another run.
|
||||
2. The player was analytically checking what maximum Arc did, without enjoying the shooting or result.
|
||||
3. The completion screen's “other trial” action or the short run length made another diagnostic pass feel cheap enough to perform despite boredom.
|
||||
|
||||
The first mutation run also cannot yet be labeled authored synergy. Fork was selected before Arc, and its fragments did feed Arc, but the player may not have predicted or noticed that relationship. Repeated Arc could mean they valued the interaction, believed Arc was simply strongest, or wanted to remove Fork as a confound.
|
||||
|
||||
The amplification sequence sampled all three dimensions before returning to Rate. Its first choice took the longest deliberation of the session, but later choices accelerated. That could reflect learning the menu, an actual tradeoff, or declining care.
|
||||
|
||||
No telemetry pattern establishes that either condition reversed stop desire. The player saved after every complete run, which is helpful data hygiene but may also indicate they viewed each run as a required experiment.
|
||||
|
||||
## Follow-up Needed
|
||||
|
||||
1. Why did the player voluntarily replay Trial I with Arc selected four times, and did the result feel rewarding or merely answer a test question?
|
||||
2. In the first Trial I run, did they intend Fork fragments to feed Arc, and why was Bloom never selected? In Trial II, were choices part of a plan or just apparent strength/coverage?
|
||||
3. When did they first want to stop in each displayed trial, and did any upgrade choice or realized effect make them want to see the next field?
|
||||
|
||||
## Initial Player Report
|
||||
|
||||
The Arc-only replay was a deliberate test of how strong Arc could become without support. It felt weak alone. The player also performed a combined run with Bloom → Fork → Arc → Bloom and observed how well all three mutation families played off one another. The combined build felt “a lot better than expected.”
|
||||
|
||||
The first mutation sequence was not a predicted Fork→Arc plan. Fork was chosen because its description sounded interesting, then seemed weak. Arc was chosen next because it was the next interesting description. Bloom was initially skipped because the player incorrectly predicted its value; after trying it, they considered it the best of the three and regretted skipping it.
|
||||
|
||||
This wrong prediction is important. The positive result did not come from merely executing a plan described by the menu. The player sampled effects from an inaccurate prior, observed cross-effect behavior that exceeded expectation, revised the ranking of the options, isolated Arc in a replay, and separately tested Bloom/the combined system. That is the first clear multi-step loop in the project of:
|
||||
|
||||
> expectation → chosen test → surprising interaction → revised model → another build test
|
||||
|
||||
Trial II felt more boring as soon as the player finished reading its choices. They described its upgrades as things that would make Trial I's effects more fun, but not as effects that played off one another to create interesting differences. Trial I was interesting enough to replay.
|
||||
|
||||
## Revised Interpretation
|
||||
|
||||
Experiment 007 gives strong evidence for H21's qualitative-composition component and against the broader idea that any chosen power growth is equivalent. The matched numerical upgrades were recognized as useful but causally independent; their descriptions were enough to predict boredom before use. The mutation upgrades initially appeared individually weak, yet their interactions created an unexpectedly better result and motivated multiple unscripted builds.
|
||||
|
||||
The likely valuable property is not simply “qualitative upgrades” or spectacle. Each mutation changes the opportunity surface of the others:
|
||||
|
||||
- Fork creates extra hits, increasing Arc frequency.
|
||||
- Fork and Arc can create kills, increasing Bloom emissions.
|
||||
- Bloom sparks add hits, feeding Arc again.
|
||||
- Arc kills can produce more Bloom sparks.
|
||||
|
||||
This is complexity through narrow causal interfaces, closely matching the original composition hypothesis. The components remain understandable alone, but their products cross the same hit/kill boundaries. A selection therefore changes both immediate output and the future value of other selections. Trial II's damage/rate/width choices changed throughput without creating new relationships.
|
||||
|
||||
The surprising reversal around Bloom is stronger evidence than merely choosing a preferred build. The menu did not make the best-feeling combination obvious, and the player learned by using it. This may be the first prototype where knowledge transferred into a new self-selected test with an answer that affected valued capability rather than an abstract marker.
|
||||
|
||||
Important limits remain:
|
||||
|
||||
- “Interesting” and “better than expected” do not yet establish that firing and movement became enjoyable in themselves.
|
||||
- The combined build may have won through spectacle or raw crowd-clearing power rather than prediction/composition.
|
||||
- The run was short and offered only three families, so strategy half-life is unknown.
|
||||
- Bloom's homing reduces aim burden and may simply have made combat easier; this is bundled with its interaction role.
|
||||
- Bloom scaling and field-clearing spectacle remain bundled; telemetry can describe the cascade but cannot say which property caused enjoyment.
|
||||
|
||||
## Continuation Report and Final Result
|
||||
|
||||
Across the continuation, the player performed combined and all-Bloom comparisons after the Arc-only run. This resolves the endpoint ambiguity: the first surprising combination generated further specific questions rather than merely concluding the session.
|
||||
|
||||
All-Bloom appeared to scale too aggressively, but the player described watching the field disappear after a few shots as “kind of fun to watch.” This is the first explicit positive enjoyment report tied to a prototype consequence, although it remains qualified and partly bundled with overtuned spectacle.
|
||||
|
||||
The player also began classifying the mutation families by enemy ecology: Bloom seemed strong against groups of small enemies, while Arc seemed strong against large enemies. For a short prototype, moving between skills and observing those different target profiles was interesting. This indicates that the value was not only a generic cascade. The player was learning a conditional capability map and using repeated builds to compare it.
|
||||
|
||||
Experiment 007 is the first successful probe in the program. It supports the following mechanism:
|
||||
|
||||
> A deliberately chosen component becomes interesting when it participates in legible causal chains, changes the value of other components, and produces a materially different capability against a recognizable problem class.
|
||||
|
||||
The positive loop contained all of the desired stages: inaccurate expectation, chosen test, surprising outcome, model revision, a new build question, repeated test, conditional knowledge about target types, and a visible power payoff.
|
||||
|
||||
The next experiment should preserve the hit/kill interface network and test its **strategy half-life**. It should introduce changing enemy ecologies and a limited composition budget so the player cannot simply take the full Fork+Arc+Bloom package every time. The critical comparison is between:
|
||||
|
||||
- adaptive composition using knowledge that transfers across fields; and
|
||||
- obvious “swarm means Bloom, brute means Arc” counter-loadout work, repeating Experiment 003.
|
||||
|
||||
Do not merely nerf Bloom. Its aggressive scaling may be part of the observed payoff. Instead, cap runaway recursion enough to keep other builds observable, retain satisfying collapse, and vary mixtures/behaviors so several causal routes remain plausible.
|
||||
4
experiments/007_catalyst_trials/results/README.md
Normal file
4
experiments/007_catalyst_trials/results/README.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Experiment 007 Results
|
||||
|
||||
Playtest interpretations and telemetry summaries belong here.
|
||||
|
||||
11
experiments/007_catalyst_trials/run.sh
Executable file
11
experiments/007_catalyst_trials/run.sh
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
#!/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
|
||||
|
||||
22
experiments/008_catalyst_ecology/README.md
Normal file
22
experiments/008_catalyst_ecology/README.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Experiment 008 — Catalyst Ecology
|
||||
|
||||
Build four-catalyst emitters for three different populations. All catalysts and expeditions are available immediately. After the four choices, each complete build continues through four additional fields.
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
./experiments/008_catalyst_ecology/run.sh
|
||||
```
|
||||
|
||||
Then open <http://127.0.0.1:8000>.
|
||||
|
||||
Controls:
|
||||
|
||||
- Move with `WASD` or the arrow keys.
|
||||
- Aim with the mouse.
|
||||
- Hold the left mouse button to fire.
|
||||
- Choose one catalyst after each of the first four fields.
|
||||
|
||||
Play expeditions in any order, replay anything you want, and stop whenever you feel ready. Click **Save JSONL** at the end.
|
||||
|
||||
Please play before reading [`hypothesis.md`](hypothesis.md).
|
||||
73
experiments/008_catalyst_ecology/hypothesis.md
Normal file
73
experiments/008_catalyst_ecology/hypothesis.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Experiment 008 Hypothesis — Private Until After Play
|
||||
|
||||
## Question
|
||||
|
||||
Can the compositional interest found in Experiment 007 survive a strict four-choice budget and changing enemy ecologies, or does it collapse into a universal package or obvious counter-loadouts?
|
||||
|
||||
## Why This Experiment
|
||||
|
||||
Experiment 007 produced the first complete curiosity chain in the project. Fork, Arc, and Bloom interacted through shared hit/kill interfaces; an inaccurate prediction led to surprising behavior, revised knowledge, and several self-directed build runs. The player also spontaneously distinguished Bloom as useful against small enemies and Arc against large enemies.
|
||||
|
||||
That success exposes the original long-term risk: strategy crystallization. The full three-family package may be universally best, all-Bloom may erase every population through runaway scaling, or visible enemy types may turn adaptation into the prescribed part swapping that made Experiment 003 boring.
|
||||
|
||||
Catalyst Ecology retains the same movement/shooting substrate and the known Fork/Bloom/Arc vocabulary. It adds three components at narrow existing interfaces:
|
||||
|
||||
- **Focus:** repeated primary hits on one body cause a rupture;
|
||||
- **Conduit:** secondary-effect hits charge a lance toward the healthiest body;
|
||||
- **Resonance:** amplifies secondary-effect damage, but has no output alone.
|
||||
|
||||
Three expeditions—Shoal, Bastion, and Brood—use small bodies, large durable bodies, and spawning mixtures respectively. Each run permits only four choices, with duplicates allowed and all six catalysts offered every time. Revision 2 retains the complete build for fields five through eight after the first run showed that a single mature field was insufficient for the system to become observable.
|
||||
|
||||
## Competing Interpretations
|
||||
|
||||
1. Conditional enemy ecology sustains build questions and makes knowledge transfer without transferring one whole answer.
|
||||
2. Ecology produces obvious “Bloom for small, Focus/Arc for large” counter-loadouts with no discovery.
|
||||
3. The original three-effect package remains dominant, so the larger option space adds false choices.
|
||||
4. All-Bloom still wins through raw exponential scaling and spectacle.
|
||||
5. New cross-interface components create multiple viable causal routes: Fork/Bloom feeds Arc/Conduit, Focus starts kills that feed Bloom, and Resonance trades a mechanism slot for stronger products.
|
||||
6. Four slots are too restrictive and feel like arbitrary prevention rather than productive composition.
|
||||
7. The successful part of 007 was rapid escalation in one repeated field, not adapting between ecologies.
|
||||
|
||||
## Evidence Priorities
|
||||
|
||||
Strong evidence:
|
||||
|
||||
- a build is chosen based on a prediction about an ecology and then revised from its observed result;
|
||||
- the same component is valued differently across expeditions for causal reasons;
|
||||
- a new interaction creates another self-selected build question;
|
||||
- replaying an expedition with a meaningfully different composition;
|
||||
- more than one plausible solution is articulated for Brood/mixed populations;
|
||||
- a powerful cascade remains legible enough that the player attributes it to selected interfaces.
|
||||
|
||||
Ambiguous evidence:
|
||||
|
||||
- clearing all expeditions;
|
||||
- fastest clear time or largest cascade;
|
||||
- taking the visually labeled small/large counter;
|
||||
- using all six components across required runs;
|
||||
- longer play caused by extra content.
|
||||
|
||||
Failure evidence:
|
||||
|
||||
- immediate obvious loadouts from the descriptions;
|
||||
- one four-choice sequence used everywhere;
|
||||
- repeated all-Bloom erasure without further questions;
|
||||
- the slot limit makes choices feel like withholding obvious synergy;
|
||||
- Brood spawning becomes clutter/attrition rather than an exploitable ecology;
|
||||
- new trigger chains are unreadable noise.
|
||||
|
||||
## Analysis Guardrails
|
||||
|
||||
- Do not nerf a spectacular build merely because it is strong; determine whether strength ends or creates reasoning.
|
||||
- Do not infer adaptation from different builds alone; ask whether the difference was predicted, sampled, or prescribed.
|
||||
- Compare stop desire and new questions, not expedition duration.
|
||||
- Focus and Conduit add some automatic targeting, so reduced aim burden remains a confound.
|
||||
- Expeditions are deliberately not numerically matched. They test qualitative population response, not performance balance.
|
||||
|
||||
## Feedback Questions
|
||||
|
||||
After telemetry inspection:
|
||||
|
||||
1. What question or expectation, if any, drove each build and replay?
|
||||
2. Did any expedition admit multiple interesting approaches, or did the populations prescribe their counters?
|
||||
3. Did a new interaction create another thing they wanted to test, and when were they ready to stop?
|
||||
83
experiments/008_catalyst_ecology/prototype/app.js
vendored
Normal file
83
experiments/008_catalyst_ecology/prototype/app.js
vendored
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
(() => {
|
||||
"use strict";
|
||||
const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
|
||||
const canvas = $("#field"), ctx = canvas.getContext("2d");
|
||||
const COLORS = { mint: "#68e0b5", cyan: "#66d8f2", violet: "#b28bf5", amber: "#efb55e", red: "#ee6f72", rose: "#f18cba" };
|
||||
const KEYS = { KeyW:[0,-1],ArrowUp:[0,-1],KeyS:[0,1],ArrowDown:[0,1],KeyA:[-1,0],ArrowLeft:[-1,0],KeyD:[1,0],ArrowRight:[1,0] };
|
||||
const ENEMIES = {
|
||||
mote:{name:"Motes",icon:"●",radius:.017,hp:2,speed:.068,color:COLORS.red},
|
||||
husk:{name:"Husks",icon:"⬢",radius:.026,hp:7,speed:.044,color:COLORS.amber},
|
||||
titan:{name:"Titans",icon:"◆",radius:.036,hp:18,speed:.029,color:COLORS.violet},
|
||||
brood:{name:"Broods",icon:"✹",radius:.032,hp:12,speed:.024,color:COLORS.rose}
|
||||
};
|
||||
const EXPEDITIONS = {
|
||||
shoal:{name:"Shoal",copy:"Small bodies arrive in increasingly dense currents.",waves:[{mote:7},{mote:12},{mote:18,husk:1},{mote:24,husk:2},{mote:32,husk:3},{mote:36,husk:4},{mote:40,husk:5,titan:1},{mote:44,husk:6,titan:2}]},
|
||||
bastion:{name:"Bastion",copy:"Durable bodies concentrate health into fewer targets.",waves:[{husk:3},{husk:4},{titan:2,husk:2,mote:4},{titan:3,husk:3,mote:5},{titan:4,husk:4,mote:6},{titan:5,husk:5,mote:8},{titan:5,husk:7,mote:12},{titan:6,husk:8,mote:16}]},
|
||||
brood:{name:"Brood",copy:"Brood bodies release fresh motes while mixed bodies close in.",waves:[{mote:7},{brood:1,mote:6},{brood:2,mote:8},{brood:3,husk:2,mote:8},{brood:3,titan:2,mote:10},{brood:4,titan:2,husk:3,mote:10},{brood:4,titan:3,husk:4,mote:12},{brood:5,titan:3,husk:5,mote:14}]}
|
||||
};
|
||||
const MODULES = {
|
||||
fork:{icon:"⋔",name:"Fork",short:"Fragments on primary hit",color:COLORS.cyan},
|
||||
bloom:{icon:"✦",name:"Bloom",short:"Seeking sparks on any kill",color:COLORS.mint},
|
||||
arc:{icon:"ϟ",name:"Arc",short:"Chain damage after projectile hits",color:COLORS.violet},
|
||||
focus:{icon:"◎",name:"Focus",short:"Rupture after repeated primary hits",color:COLORS.amber},
|
||||
conduit:{icon:"↯",name:"Conduit",short:"Secondary hits charge a heavy lance",color:COLORS.rose},
|
||||
resonance:{icon:"◈",name:"Resonance",short:"Amplifies every secondary effect",color:"#f2d47b"}
|
||||
};
|
||||
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)), round=v=>Math.round(v*1000)/1000;
|
||||
const norm=(x,y)=>{const d=Math.hypot(x,y)||1;return[x/d,y/d]}, dist=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y);
|
||||
const session=crypto.randomUUID?crypto.randomUUID():`session-${Date.now()}`;
|
||||
const state={session,startedAt:Date.now(),logs:[],started:false,active:false,choosing:false,complete:false,expedition:"shoal",attempt:0,wave:0,waveAttempt:0,runTime:0,waveTime:0,choice:0,completed:new Set(),
|
||||
upgrades:{fork:0,bloom:0,arc:0,focus:0,conduit:0,resonance:0},player:{x:.5,y:.5,vx:0,vy:0,health:6,inv:0,lastDamage:-99},keys:new Set(),pointer:{x:.75,y:.5,firing:false},fireCd:0,
|
||||
enemies:[],bullets:[],particles:[],effects:[],enemyId:0,bulletId:0,totalHitCounter:0,secondaryCounter:0,secondaryBudget:300,actions:null,lastFrame:performance.now(),snapshot:0,clearDelay:0,render:{size:1,ox:0,oy:0},bannerTimer:null,toastTimer:null};
|
||||
const freshActions=()=>({shots:0,primary_hits:0,fragment_hits:0,spark_hits:0,lance_hits:0,arc_triggers:0,focus_ruptures:0,conduit_lances:0,kills:0,spawned_motes:0,damage_taken:0}); state.actions=freshActions();
|
||||
function log(type,data={}){const e={schema:1,experiment:"008_catalyst_ecology",prototype_revision:2,session_id:state.session,elapsed_ms:Date.now()-state.startedAt,expedition:state.expedition,run_attempt:state.attempt,wave:state.wave+1,wave_attempt:state.waveAttempt,run_seconds:round(state.runTime),type,...data};state.logs.push(JSON.stringify(e));try{localStorage.setItem("catalyst-ecology-last-jsonl",state.logs.join("\n")+"\n")}catch(_){}}
|
||||
const build=()=>Object.fromEntries(Object.entries(state.upgrades).filter(([,v])=>v));
|
||||
const secondaryScale=()=>1+state.upgrades.resonance*.55;
|
||||
function populationText(spec){return Object.entries(spec).map(([k,v])=>`${v} ${ENEMIES[k].name}`).join(" · ")}
|
||||
function layout(spec){const entries=[];for(const[k,count]of Object.entries(spec)){for(let i=0;i<count;i++)entries.push(k)}return entries.map((kind,i)=>{const n=entries.length,a=i/n*Math.PI*2+state.wave*.39,ring=.34+((i*5+state.wave)%4)*.035;return{kind,x:.5+Math.cos(a)*ring,y:.5+Math.sin(a)*ring}})}
|
||||
function spawnEnemy(kind,x,y,spawned=false){const d=ENEMIES[kind];state.enemies.push({id:++state.enemyId,kind,x,y,vx:0,vy:0,radius:d.radius,hp:d.hp,maxHp:d.hp,removed:false,flash:0,contact:0,focusHits:0,spawnCd:2+Math.random()*.7,spawnsLeft:kind==="brood"?2+Math.floor(state.wave/2):0});if(spawned)state.actions.spawned_motes++}
|
||||
function spawnWave(reason){state.waveAttempt++;state.waveTime=0;state.snapshot=0;state.clearDelay=0;state.totalHitCounter=0;state.secondaryCounter=0;state.secondaryBudget=300;state.enemies=[];state.bullets=[];state.particles=[];state.effects=[];state.player={x:.5,y:.5,vx:0,vy:0,health:6,inv:1,lastDamage:-99};state.fireCd=0;state.pointer.firing=false;layout(EXPEDITIONS[state.expedition].waves[state.wave]).forEach(e=>spawnEnemy(e.kind,e.x,e.y));state.active=state.started;state.choosing=false;state.complete=false;$("#choice-overlay").classList.add("hidden");$("#complete-overlay").classList.add("hidden");updateUI();log("wave_started",{reason,population:EXPEDITIONS[state.expedition].waves[state.wave],enemy_count:state.enemies.length,build:build()});showBanner(`Field ${state.wave+1}`);canvas.focus({preventScroll:true})}
|
||||
function startRun(id,reason){if(state.started&&(state.active||state.choosing)&&!state.complete)log("run_abandoned",{reason,remaining:state.enemies.length,build:build(),actions:state.actions});state.expedition=id;state.attempt++;state.wave=0;state.waveAttempt=0;state.runTime=0;state.choice=0;state.upgrades={fork:0,bloom:0,arc:0,focus:0,conduit:0,resonance:0};state.actions=freshActions();state.complete=false;state.choosing=false;$$('.expedition').forEach(b=>b.classList.toggle("selected",b.dataset.expedition===id));log("run_started",{reason,expedition:id});spawnWave("run_started")}
|
||||
function begin(){if(state.started)return;state.started=true;$("#start-overlay").classList.add("hidden");log("session_started",{viewport:[innerWidth,innerHeight]});startRun(state.expedition,"session_started")}
|
||||
function selectExpedition(id){if(!state.started){state.expedition=id;$$('.expedition').forEach(b=>b.classList.toggle("selected",b.dataset.expedition===id));$("#begin").textContent=`Begin ${EXPEDITIONS[id].name}`;updateUI();return}if(id===state.expedition&&!state.complete)return;startRun(id,"selected")}
|
||||
function spawnBullet(x,y,vx,vy,damage,radius,kind,life,targetId=null){state.bullets.push({id:++state.bulletId,x,y,vx,vy,damage,radius,kind,life,targetId,removed:false})}
|
||||
function fire(){if(!state.active||state.fireCd>0)return;const[dx,dy]=norm(state.pointer.x-state.player.x,state.pointer.y-state.player.y);state.fireCd=.21;state.actions.shots++;spawnBullet(state.player.x+dx*.025,state.player.y+dy*.025,dx*.66,dy*.66,1,.0085,"primary",1.5);log("shot_fired",{aim:[round(dx),round(dy)],position:[round(state.player.x),round(state.player.y)]})}
|
||||
function nearest(origin,count,range,exclude=new Set()){return state.enemies.filter(e=>!e.removed&&!exclude.has(e.id)&&dist(origin,e)<=range).sort((a,b)=>dist(origin,a)-dist(origin,b)).slice(0,count)}
|
||||
function healthiest(){return state.enemies.filter(e=>!e.removed).sort((a,b)=>b.hp-a.hp)[0]}
|
||||
function spendBudget(n=1){if(state.secondaryBudget<n)return false;state.secondaryBudget-=n;return true}
|
||||
function secondaryHit(enemy,kind){if(kind==="lance")return;state.secondaryCounter++;const l=state.upgrades.conduit;if(!l)return;const threshold=Math.max(3,9-l*2);if(state.secondaryCounter%threshold!==0||!spendBudget())return;const target=healthiest();if(!target)return;state.actions.conduit_lances++;spawnBullet(state.player.x,state.player.y,0,0,(3+l*1.8)*secondaryScale(),.012,"lance",1.7,target.id);effect("conduit",state.player.x,state.player.y);log("module_triggered",{module:"conduit",target_id:target.id,threshold})}
|
||||
function triggerFork(enemy,bullet){const l=state.upgrades.fork;if(!l)return;const count=l*2,base=Math.atan2(bullet.vy,bullet.vx);let made=0;for(let i=0;i<count&&spendBudget();i++){const side=i%2?1:-1,layer=Math.floor(i/2),a=base+side*(.62+layer*.28),target=nearest(enemy,1,.55,new Set([enemy.id]))[0];spawnBullet(enemy.x,enemy.y,Math.cos(a)*.46,Math.sin(a)*.46,.65*secondaryScale(),.0065,"fragment",.9,target?.id||null);made++}effect("fork",enemy.x,enemy.y);log("module_triggered",{module:"fork",source_id:enemy.id,products:made})}
|
||||
function triggerBloom(enemy){const l=state.upgrades.bloom;if(!l)return;const count=2+l,targets=nearest(enemy,count,.8,new Set([enemy.id]));let made=0;for(let i=0;i<count&&spendBudget();i++){const a=i/count*Math.PI*2,target=targets[i%Math.max(1,targets.length)];spawnBullet(enemy.x,enemy.y,Math.cos(a)*.1,Math.sin(a)*.1,.7*secondaryScale(),.007,"spark",1.6,target?.id||null);made++}effect("bloom",enemy.x,enemy.y);log("module_triggered",{module:"bloom",source_id:enemy.id,products:made,target_ids:targets.map(t=>t.id)})}
|
||||
function triggerArc(origin){const l=state.upgrades.arc;if(!l)return;const threshold=Math.max(3,7-l);if(state.totalHitCounter%threshold!==0)return;const targets=nearest(origin,1+l,.32,new Set([origin.id]));state.actions.arc_triggers++;for(const t of targets){if(!spendBudget())break;lineEffect("arc",origin,t);damageEnemy(t,.78*secondaryScale(),"arc");secondaryHit(t,"arc")}log("module_triggered",{module:"arc",source_id:origin.id,target_ids:targets.map(t=>t.id),threshold})}
|
||||
function triggerFocus(enemy){const l=state.upgrades.focus;if(!l)return;enemy.focusHits++;const threshold=Math.max(2,6-l);if(enemy.focusHits<threshold)return;enemy.focusHits=0;if(!spendBudget())return;state.actions.focus_ruptures++;effect("focus",enemy.x,enemy.y);damageEnemy(enemy,(3+l*2)*secondaryScale(),"focus");secondaryHit(enemy,"focus");log("module_triggered",{module:"focus",source_id:enemy.id,threshold})}
|
||||
function hit(enemy,bullet){if(enemy.removed||bullet.removed)return;bullet.removed=true;if(bullet.kind==="primary")state.actions.primary_hits++;else if(bullet.kind==="fragment")state.actions.fragment_hits++;else if(bullet.kind==="spark")state.actions.spark_hits++;else if(bullet.kind==="lance")state.actions.lance_hits++;damageEnemy(enemy,bullet.damage,bullet.kind);if(bullet.kind==="primary"){triggerFocus(enemy);triggerFork(enemy,bullet)}else secondaryHit(enemy,bullet.kind);state.totalHitCounter++;triggerArc(enemy)}
|
||||
function damageEnemy(enemy,amount,cause){if(enemy.removed)return;enemy.hp-=amount;enemy.flash=.1;burst(enemy.x,enemy.y,cause==="arc"?COLORS.violet:COLORS.mint,4);log("enemy_damaged",{enemy_id:enemy.id,enemy_kind:enemy.kind,amount:round(amount),cause,hp_after:round(Math.max(0,enemy.hp))});if(enemy.hp>0)return;enemy.removed=true;state.actions.kills++;burst(enemy.x,enemy.y,ENEMIES[enemy.kind].color,12);log("enemy_killed",{enemy_id:enemy.id,enemy_kind:enemy.kind,cause,remaining_after:state.enemies.filter(e=>!e.removed).length});triggerBloom(enemy)}
|
||||
function damagePlayer(enemy){if(state.player.inv>0||!state.active)return;state.player.health--;state.player.inv=.85;state.player.lastDamage=state.runTime;state.actions.damage_taken++;const[dx,dy]=norm(state.player.x-enemy.x,state.player.y-enemy.y);state.player.vx+=dx*.22;state.player.vy+=dy*.22;burst(state.player.x,state.player.y,COLORS.red,10);log("player_damaged",{enemy_id:enemy.id,enemy_kind:enemy.kind,health_after:state.player.health});if(state.player.health<=0)defeat()}
|
||||
function defeat(){state.active=false;state.pointer.firing=false;state.keys.clear();log("player_defeated",{remaining:state.enemies.length,build:build(),actions:state.actions});showBanner("Field restoring");setTimeout(()=>{if(state.started&&!state.active&&!state.choosing&&!state.complete)spawnWave("defeat_restart")},800)}
|
||||
function clearWave(){if(!state.active)return;state.active=false;state.pointer.firing=false;log("wave_completed",{duration_seconds:round(state.waveTime),health:state.player.health,build:build(),actions:state.actions});const waves=EXPEDITIONS[state.expedition].waves;if(state.wave===waves.length-1){completeRun();return}if(state.choice<4){showChoices();return}state.wave++;state.waveAttempt=0;const expectedAttempt=state.attempt,expectedExpedition=state.expedition;log("post_build_field_advanced",{next_population:waves[state.wave],build:build()});updateUI();showBanner(`Build retained · Field ${state.wave+1}`);setTimeout(()=>{if(state.attempt===expectedAttempt&&state.expedition===expectedExpedition&&!state.active&&!state.choosing&&!state.complete)spawnWave("post_build_advance")},650)}
|
||||
function moduleDescription(key,next){if(key==="fork")return `${next*2} fragments leave every primary impact.`;if(key==="bloom")return `${2+next} seeking sparks leave every destroyed body.`;if(key==="arc")return `Every ${Math.max(3,7-next)} projectile hits chain into up to ${1+next} nearby bodies.`;if(key==="focus")return `${Math.max(2,6-next)} repeated primary hits rupture one body for ${3+next*2} damage.`;if(key==="conduit")return `Every ${Math.max(3,9-next*2)} secondary hits launch a ${Math.round((3+next*1.8)*10)/10}-damage lance at the healthiest body.`;return `All fragment, spark, Arc, Focus, and Conduit damage becomes ${(1+next*.55).toFixed(2)}×.`}
|
||||
function showChoices(){state.choosing=true;state.choice++;const nextSpec=EXPEDITIONS[state.expedition].waves[state.wave+1];$("#next-forecast").textContent=`Next field: ${populationText(nextSpec)}.`;const box=$("#choices");box.replaceChildren();for(const[key,d]of Object.entries(MODULES)){const next=state.upgrades[key]+1,b=document.createElement("button");b.className="choice";b.dataset.module=key;b.innerHTML=`<i style="color:${d.color}">${d.icon}</i><b>${d.name}</b><small>${moduleDescription(key,next)}</small><em>${state.upgrades[key]?`Level ${state.upgrades[key]} → ${next}`:"Not selected"}</em>`;b.onclick=()=>choose(key);box.append(b)}$("#choice-overlay").classList.remove("hidden");log("choices_shown",{choice_number:state.choice,next_population:nextSpec,options:Object.keys(MODULES).map(k=>({key:k,current_level:state.upgrades[k]}))})}
|
||||
function choose(key){if(!state.choosing||!MODULES[key])return;state.upgrades[key]++;state.choosing=false;log("module_chosen",{choice_number:state.choice,module:key,new_level:state.upgrades[key],build:build()});state.wave++;state.waveAttempt=0;spawnWave("module_chosen")}
|
||||
function completeRun(){state.complete=true;state.active=false;state.completed.add(state.expedition);log("run_completed",{duration_seconds:round(state.runTime),build:build(),actions:state.actions});document.querySelector(`[data-expedition="${state.expedition}"]`).classList.add("complete");const ids=Object.keys(EXPEDITIONS),next=ids[(ids.indexOf(state.expedition)+1)%ids.length];$("#complete-title").textContent=`${EXPEDITIONS[state.expedition].name} stabilized.`;$("#next-expedition").textContent=`Start ${EXPEDITIONS[next].name}`;$("#next-expedition").dataset.expedition=next;$("#complete-overlay").classList.remove("hidden");updateUI()}
|
||||
function updatePlayer(dt){state.player.inv=Math.max(0,state.player.inv-dt);state.fireCd=Math.max(0,state.fireCd-dt);if(state.player.health<6&&state.runTime-state.player.lastDamage>3.5)state.player.health=Math.min(6,state.player.health+dt*.45);let x=0,y=0;for(const k of state.keys){const v=KEYS[k];if(v){x+=v[0];y+=v[1]}}if(x||y){[x,y]=norm(x,y);state.player.vx+=x*.92*dt;state.player.vy+=y*.92*dt}const drag=Math.pow(.025,dt);state.player.vx*=drag;state.player.vy*=drag;const s=Math.hypot(state.player.vx,state.player.vy);if(s>.29){state.player.vx*=.29/s;state.player.vy*=.29/s}state.player.x=clamp(state.player.x+state.player.vx*dt,.04,.96);state.player.y=clamp(state.player.y+state.player.vy*dt,.04,.96);if(state.pointer.firing)fire()}
|
||||
function updateEnemies(dt){for(const e of state.enemies){if(e.removed)continue;e.flash=Math.max(0,e.flash-dt);e.contact=Math.max(0,e.contact-dt);if(e.kind==="brood"&&e.spawnsLeft>0){e.spawnCd-=dt;if(e.spawnCd<=0){e.spawnCd=2.35;e.spawnsLeft--;const a=Math.random()*Math.PI*2;spawnEnemy("mote",clamp(e.x+Math.cos(a)*.055,.04,.96),clamp(e.y+Math.sin(a)*.055,.04,.96),true);log("brood_spawned",{brood_id:e.id,remaining_spawns:e.spawnsLeft})}}const[dx,dy]=norm(state.player.x-e.x,state.player.y-e.y),d=ENEMIES[e.kind];e.vx+=dx*d.speed*4.5*dt;e.vy+=dy*d.speed*4.5*dt;const drag=Math.pow(.12,dt);e.vx*=drag;e.vy*=drag;const s=Math.hypot(e.vx,e.vy);if(s>d.speed){e.vx*=d.speed/s;e.vy*=d.speed/s}e.x=clamp(e.x+e.vx*dt,e.radius,1-e.radius);e.y=clamp(e.y+e.vy*dt,e.radius,1-e.radius);if(dist(e,state.player)<e.radius+.022)damagePlayer(e)}for(let i=0;i<state.enemies.length;i++){const a=state.enemies[i];if(a.removed)continue;for(let j=i+1;j<state.enemies.length;j++){const b=state.enemies[j];if(b.removed)continue;const dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||.001,min=a.radius+b.radius;if(d>=min)continue;const o=(min-d)/2,nx=dx/d,ny=dy/d;a.x-=nx*o;a.y-=ny*o;b.x+=nx*o;b.y+=ny*o}}state.enemies=state.enemies.filter(e=>!e.removed)}
|
||||
function updateBullets(dt){for(const b of state.bullets){if(b.removed)continue;b.life-=dt;if(b.targetId&&b.kind!=="primary"){let t=state.enemies.find(e=>e.id===b.targetId&&!e.removed);if(!t){t=b.kind==="lance"?healthiest():nearest(b,1,.8)[0];b.targetId=t?.id||null}if(t){const[dx,dy]=norm(t.x-b.x,t.y-b.y);b.vx+=dx*1.3*dt;b.vy+=dy*1.3*dt;const targetSpeed=b.kind==="lance"?.65:.48,s=Math.hypot(b.vx,b.vy)||1;b.vx=b.vx/s*targetSpeed;b.vy=b.vy/s*targetSpeed}}b.x+=b.vx*dt;b.y+=b.vy*dt;if(b.life<=0||b.x<-.05||b.x>1.05||b.y<-.05||b.y>1.05){b.removed=true;continue}for(const e of state.enemies)if(!e.removed&&dist(b,e)<=b.radius+e.radius){hit(e,b);break}}state.bullets=state.bullets.filter(b=>!b.removed)}
|
||||
function updateEffects(dt){for(const p of state.particles){p.life-=dt;p.x+=p.vx*dt;p.y+=p.vy*dt;p.vx*=Math.pow(.12,dt);p.vy*=Math.pow(.12,dt)}for(const e of state.effects)e.life-=dt;state.particles=state.particles.filter(p=>p.life>0);state.effects=state.effects.filter(e=>e.life>0)}
|
||||
function update(dt){if(!state.active)return;state.runTime+=dt;state.waveTime+=dt;state.snapshot+=dt;updatePlayer(dt);updateEnemies(dt);updateBullets(dt);updateEffects(dt);if(!state.enemies.length){state.clearDelay+=dt;if(state.clearDelay>.6)clearWave()}if(state.snapshot>=5){state.snapshot=0;log("state_snapshot",{player:[round(state.player.x),round(state.player.y)],health:round(state.player.health),remaining:state.enemies.length,projectiles:state.bullets.length,secondary_budget:state.secondaryBudget,build:build(),actions:state.actions})}updateUI()}
|
||||
function burst(x,y,color,count){for(let i=0;i<count;i++){const a=Math.random()*Math.PI*2,s=.03+Math.random()*.1;state.particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,color,life:.18+Math.random()*.3,maxLife:.48,radius:.002+Math.random()*.003})}}
|
||||
function effect(kind,x,y){state.effects.push({kind,x,y,life:.22,maxLife:.22})}function lineEffect(kind,a,b){state.effects.push({kind,x:a.x,y:a.y,tx:b.x,ty:b.y,life:.16,maxLife:.16})}
|
||||
const screen=(x,y)=>[state.render.ox+x*state.render.size,state.render.oy+y*state.render.size];
|
||||
function draw(){const dpr=devicePixelRatio||1,w=canvas.clientWidth,h=canvas.clientHeight;if(canvas.width!==Math.round(w*dpr)||canvas.height!==Math.round(h*dpr)){canvas.width=Math.round(w*dpr);canvas.height=Math.round(h*dpr)}ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,w,h);const size=Math.max(100,Math.min(w-38,h-38));state.render={size,ox:(w-size)/2,oy:(h-size)/2};ctx.fillStyle="#081219";ctx.fillRect(state.render.ox,state.render.oy,size,size);ctx.strokeStyle="#182c34";ctx.lineWidth=1;for(let i=1;i<10;i++){const p=i/10*size;ctx.beginPath();ctx.moveTo(state.render.ox+p,state.render.oy);ctx.lineTo(state.render.ox+p,state.render.oy+size);ctx.stroke();ctx.beginPath();ctx.moveTo(state.render.ox,state.render.oy+p);ctx.lineTo(state.render.ox+size,state.render.oy+p);ctx.stroke()}ctx.strokeStyle="#36515b";ctx.lineWidth=2;ctx.strokeRect(state.render.ox,state.render.oy,size,size);for(const e of state.effects)drawEffect(e);for(const b of state.bullets){const[x,y]=screen(b.x,b.y);ctx.fillStyle=b.kind==="primary"?"#f5f8df":b.kind==="fragment"?COLORS.cyan:b.kind==="spark"?COLORS.mint:COLORS.rose;ctx.shadowColor=ctx.fillStyle;ctx.shadowBlur=9;ctx.beginPath();ctx.arc(x,y,Math.max(2.5,b.radius*size),0,Math.PI*2);ctx.fill();ctx.shadowBlur=0}for(const e of state.enemies)drawEnemy(e);for(const p of state.particles){const[x,y]=screen(p.x,p.y);ctx.globalAlpha=clamp(p.life/p.maxLife,0,1);ctx.fillStyle=p.color;ctx.beginPath();ctx.arc(x,y,p.radius*size,0,Math.PI*2);ctx.fill();ctx.globalAlpha=1}drawPlayer()}
|
||||
function drawEnemy(e){const[x,y]=screen(e.x,e.y),r=e.radius*state.render.size;ctx.save();ctx.translate(x,y);ctx.fillStyle=e.flash?"#fff":ENEMIES[e.kind].color;ctx.shadowColor=ENEMIES[e.kind].color;ctx.shadowBlur=8;ctx.beginPath();const sides=e.kind==="mote"?0:e.kind==="husk"?6:e.kind==="titan"?4:8;if(!sides)ctx.arc(0,0,r,0,Math.PI*2);else for(let i=0;i<sides;i++){const a=i/sides*Math.PI*2,px=Math.cos(a)*r,py=Math.sin(a)*r;i?ctx.lineTo(px,py):ctx.moveTo(px,py)}ctx.closePath();ctx.fill();ctx.shadowBlur=0;ctx.strokeStyle="#081015";ctx.lineWidth=2;ctx.stroke();if(e.hp<e.maxHp){ctx.fillStyle="#1b2b30";ctx.fillRect(-r,r+5,r*2,3);ctx.fillStyle=COLORS.mint;ctx.fillRect(-r,r+5,r*2*clamp(e.hp/e.maxHp,0,1),3)}if(e.focusHits){ctx.strokeStyle=COLORS.amber;ctx.lineWidth=2;ctx.beginPath();ctx.arc(0,0,r+4,-Math.PI/2,-Math.PI/2+Math.PI*2*e.focusHits/Math.max(2,6-state.upgrades.focus));ctx.stroke()}ctx.restore()}
|
||||
function drawPlayer(){const[x,y]=screen(state.player.x,state.player.y),r=.021*state.render.size,[ax,ay]=norm(state.pointer.x-state.player.x,state.pointer.y-state.player.y);ctx.save();ctx.translate(x,y);ctx.rotate(Math.atan2(ay,ax));ctx.globalAlpha=state.player.inv&&Math.floor(state.player.inv*16)%2?.35:1;ctx.fillStyle=COLORS.mint;ctx.shadowColor=COLORS.mint;ctx.shadowBlur=12;ctx.beginPath();ctx.moveTo(r*1.35,0);ctx.lineTo(-r*.8,r*.8);ctx.lineTo(-r*.52,0);ctx.lineTo(-r*.8,-r*.8);ctx.closePath();ctx.fill();ctx.restore()}
|
||||
function drawEffect(e){const a=clamp(e.life/e.maxLife,0,1);ctx.save();ctx.globalAlpha=a;if(e.tx!==undefined){const[x,y]=screen(e.x,e.y),[tx,ty]=screen(e.tx,e.ty);ctx.strokeStyle=e.kind==="arc"?COLORS.violet:COLORS.rose;ctx.lineWidth=3;ctx.shadowColor=ctx.strokeStyle;ctx.shadowBlur=9;ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo((x+tx)/2+(Math.random()-.5)*10,(y+ty)/2+(Math.random()-.5)*10);ctx.lineTo(tx,ty);ctx.stroke()}else{const[x,y]=screen(e.x,e.y);ctx.strokeStyle=e.kind==="bloom"?COLORS.mint:e.kind==="fork"?COLORS.cyan:e.kind==="focus"?COLORS.amber:COLORS.rose;ctx.lineWidth=2;ctx.beginPath();ctx.arc(x,y,(1-a)*.05*state.render.size+4,0,Math.PI*2);ctx.stroke()}ctx.restore()}
|
||||
function updateUI(){const ids=Object.keys(EXPEDITIONS),idx=ids.indexOf(state.expedition),ex=EXPEDITIONS[state.expedition],total=ex.waves.length;$("#expedition-kicker").textContent=`EXPEDITION ${String(idx+1).padStart(2,"0")} · FIELD ${state.wave+1} OF ${total}`;$("#expedition-name").textContent=ex.name;$("#expedition-copy").textContent=ex.copy;$("#wave").textContent=`${state.wave+1} / ${total}`;$("#remaining").textContent=String(state.enemies.length);$("#health").textContent=Array.from({length:6},(_,i)=>i<Math.ceil(state.player.health)?"●":"○").join(" ");const s=Math.floor(state.runTime);$("#run-time").textContent=`${Math.floor(s/60)}:${String(s%60).padStart(2,"0")}`;const forecast=$("#forecast-list");forecast.replaceChildren();for(const[k,v]of Object.entries(ex.waves[state.wave])){const d=ENEMIES[k],el=document.createElement("div");el.className="population";el.innerHTML=`<i style="color:${d.color}">${d.icon}</i><b>${v}</b> ${d.name}`;forecast.append(el)}const box=$("#build-list");box.replaceChildren();const selected=Object.entries(state.upgrades).filter(([,v])=>v);if(!selected.length){const p=document.createElement("p");p.className="empty";p.textContent="No catalysts selected.";box.append(p)}else for(const[k,v]of selected){const d=MODULES[k],el=document.createElement("div");el.className="build-item";el.innerHTML=`<i style="color:${d.color}">${d.icon}</i><span><b>${d.name}</b><small>${d.short}</small></span><em>LV ${v}</em>`;box.append(el)}}
|
||||
function showBanner(text){const b=$("#banner");b.textContent=text;b.classList.add("show");clearTimeout(state.bannerTimer);state.bannerTimer=setTimeout(()=>b.classList.remove("show"),1000)}function toast(text){const t=$("#toast");t.textContent=text;t.classList.add("show");clearTimeout(state.toastTimer);state.toastTimer=setTimeout(()=>t.classList.remove("show"),3300)}
|
||||
async function save(){log("session_saved",{completed:[...state.completed],current_build:build(),actions:state.actions,event_count_before_save:state.logs.length});const body=state.logs.join("\n")+"\n",filename=`catalyst-ecology-${state.session}.jsonl`;try{const r=await fetch("/api/playtest-log",{method:"POST",headers:{"Content-Type":"application/x-ndjson","X-Playtest-Filename":filename},body});if(!r.ok)throw Error(r.status);const j=await r.json();toast(`Saved ${j.events} events to ${j.path}`)}catch(_){const blob=new Blob([body],{type:"application/x-ndjson"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=filename;a.click();URL.revokeObjectURL(a.href);toast(`Server unavailable; downloaded ${filename}`)}}
|
||||
function pointer(e){const r=canvas.getBoundingClientRect();state.pointer.x=clamp((e.clientX-r.left-state.render.ox)/state.render.size,0,1);state.pointer.y=clamp((e.clientY-r.top-state.render.oy)/state.render.size,0,1)}
|
||||
canvas.onpointermove=pointer;canvas.onpointerdown=e=>{pointer(e);if(e.button===0){state.pointer.firing=true;fire()}e.preventDefault();canvas.focus({preventScroll:true})};addEventListener("pointerup",e=>{if(e.button===0)state.pointer.firing=false});canvas.oncontextmenu=e=>e.preventDefault();addEventListener("keydown",e=>{if(KEYS[e.code]){state.keys.add(e.code);e.preventDefault()}});addEventListener("keyup",e=>state.keys.delete(e.code));addEventListener("blur",()=>{state.keys.clear();state.pointer.firing=false});document.addEventListener("visibilitychange",()=>{if(state.started)log("visibility_changed",{hidden:document.hidden})});
|
||||
$$('.expedition').forEach(b=>b.onclick=()=>selectExpedition(b.dataset.expedition));$("#begin").onclick=begin;$("#restart").onclick=()=>state.started?startRun(state.expedition,"restart_button"):begin();$("#save").onclick=save;$("#complete-save").onclick=save;$("#next-expedition").onclick=e=>startRun(e.currentTarget.dataset.expedition,"complete_next");$("#replay").onclick=()=>startRun(state.expedition,"complete_replay");
|
||||
if(new URLSearchParams(location.search).has("validation"))window.__ecologyDebug={getState:()=>state,startRun,spawnWave,choose,damageEnemy,spawnBullet};
|
||||
function frame(now){const dt=Math.min(.04,(now-state.lastFrame)/1000||0);state.lastFrame=now;update(dt);draw();requestAnimationFrame(frame)}
|
||||
updateUI();log("session_initialized",{viewport:[innerWidth,innerHeight]});spawnWave("initial_preview");state.active=false;requestAnimationFrame(frame);
|
||||
})();
|
||||
39
experiments/008_catalyst_ecology/prototype/index.html
Normal file
39
experiments/008_catalyst_ecology/prototype/index.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Catalyst Ecology — Experiment 008</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="title"><span>EXPERIMENT 008</span><h1>Catalyst Ecology</h1></div>
|
||||
<nav aria-label="Expedition selection">
|
||||
<button class="expedition selected" data-expedition="shoal"><span>01</span> Shoal</button>
|
||||
<button class="expedition" data-expedition="bastion"><span>02</span> Bastion</button>
|
||||
<button class="expedition" data-expedition="brood"><span>03</span> Brood</button>
|
||||
</nav>
|
||||
<div class="top-actions"><button id="restart">Restart</button><button id="save">Save JSONL</button></div>
|
||||
</header>
|
||||
<main>
|
||||
<aside>
|
||||
<section class="brief"><span id="expedition-kicker">EXPEDITION 01 · FIELD 1 OF 8</span><h2 id="expedition-name">Shoal</h2><p id="expedition-copy"></p></section>
|
||||
<section class="forecast"><h3>Current population</h3><div id="forecast-list"></div></section>
|
||||
<section class="controls"><h3>Controls</h3><p><kbd>WASD</kbd><span>Move.</span></p><p><kbd>HOLD LEFT</kbd><span>Fire toward cursor.</span></p></section>
|
||||
<section class="build"><h3>Four-choice build</h3><div id="build-list"><p class="empty">No catalysts selected.</p></div></section>
|
||||
<section class="readouts">
|
||||
<div><span>INTEGRITY</span><b id="health">● ● ● ● ● ●</b></div><div><span>FIELD</span><b id="wave">1 / 8</b></div>
|
||||
<div><span>REMAINING</span><b id="remaining">0</b></div><div><span>RUN TIME</span><b id="run-time">0:00</b></div>
|
||||
</section>
|
||||
<section class="note">All six catalysts remain available at every choice. Repeats stack. Four selections fit in one emitter.</section>
|
||||
</aside>
|
||||
<section class="playfield"><canvas id="field" tabindex="0"></canvas><div id="banner" class="banner"></div><div class="canvas-help">WASD move · aim with mouse · hold left to fire</div></section>
|
||||
</main>
|
||||
<div id="start-overlay" class="overlay"><div><span>THREE POPULATIONS</span><b>Build within four choices.</b><p>Choose during the first four fields, then keep the complete build through four more. Everything is available now. Stop whenever you want.</p><button id="begin">Begin Shoal</button></div></div>
|
||||
<div id="choice-overlay" class="overlay hidden"><div class="wide"><span>FIELD STABLE</span><b>Choose one catalyst</b><p id="next-forecast"></p><div id="choices"></div></div></div>
|
||||
<div id="complete-overlay" class="overlay hidden"><div><span>EXPEDITION COMPLETE</span><b id="complete-title"></b><p>Choose another expedition, replay this one, save, or stop.</p><div class="complete-actions"><button id="next-expedition"></button><button id="replay">Replay this expedition</button><button id="complete-save">Save JSONL</button></div></div></div>
|
||||
<div id="toast"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
experiments/008_catalyst_ecology/prototype/style.css
Normal file
1
experiments/008_catalyst_ecology/prototype/style.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,113 @@
|
|||
# Experiment 008 Preliminary Analysis — Session 0ac8e60d
|
||||
|
||||
Status: first-run telemetry and report complete; corrective revision required before interpreting H23.
|
||||
|
||||
Source: `JSONL/catalyst-ecology-0ac8e60d-a488-4d4d-a4d4-3d220981ef67.jsonl`
|
||||
|
||||
## Session Structure
|
||||
|
||||
- 2,042 events over 374.0 seconds from initialization to save.
|
||||
- Shoal, Bastion, and Brood were played in displayed order and each completed once.
|
||||
- All 15 fields cleared on their first attempt. There were no defeats and six total damage events.
|
||||
- No expedition was replayed, restarted, switched away from, or abandoned.
|
||||
- The player saved once after all three expeditions.
|
||||
|
||||
Completion is likely compliance with the requested comparison. The lack of replay is meaningful only after asking whether the option space felt exhausted, the player had already formed conclusions, or no build question justified another run.
|
||||
|
||||
## Build Sequences
|
||||
|
||||
| Expedition | Choice sequence | Final build | Active run time |
|
||||
|---|---|---|---:|
|
||||
| Shoal | Bloom → Resonance → Arc → Focus | Bloom, Resonance, Arc, Focus | 48.9 s |
|
||||
| Bastion | Conduit → Bloom → Arc → Fork | Conduit, Bloom, Arc, Fork | 78.6 s |
|
||||
| Brood | Bloom → Focus → Arc → Fork | Bloom, Focus, Arc, Fork | 87.5 s |
|
||||
|
||||
Bloom and Arc formed a common core across all three populations. The two remaining slots varied:
|
||||
|
||||
- Shoal uniquely used Resonance and omitted Fork/Conduit.
|
||||
- Bastion uniquely used Conduit and omitted Focus/Resonance.
|
||||
- Brood used Focus and Fork, omitting Conduit/Resonance.
|
||||
|
||||
Every one of the six available modules was selected somewhere, but no module was stacked. This may reflect ecology-based choice, coverage/sampling of new options, or a belief that one level of several interacting mechanisms is better than specialization.
|
||||
|
||||
## Deliberation Pattern
|
||||
|
||||
The first choice in each expedition took much longer than later choices:
|
||||
|
||||
- Shoal first choice: about 38.9 seconds.
|
||||
- Bastion first choice: about 33.5 seconds.
|
||||
- Brood first choice: about 25.5 seconds.
|
||||
- Later choices generally took 1–6 seconds.
|
||||
|
||||
The player therefore spent substantial time reading or planning when the build was empty, then selected quickly as the run developed. The decreasing first-choice times could indicate growing understanding of the six-module vocabulary, fatigue, or a progressively clearer plan.
|
||||
|
||||
Conduit was selected first in Bastion despite having no output without secondary hits. It remained inactive during field two, then began firing after Bloom was added. Sixteen Conduit lances ultimately hit and six killed targets. This is potentially strong evidence of planning around a future interface, but it could also be experimentation with an attractive description or a misunderstanding corrected by the next choice.
|
||||
|
||||
## Population and Effect Results
|
||||
|
||||
### Shoal
|
||||
|
||||
- 99 kills: 93 Motes and 6 Husks.
|
||||
- Bloom sparks caused 52 kills; Arc caused 12; Focus caused one.
|
||||
- Field time fell from 13.9 seconds at baseline to 7.1 seconds in the final field despite a much larger population.
|
||||
- Focus arrived last and ruptured only once, consistent with most targets dying too quickly to accumulate repeated hits.
|
||||
- The secondary budget never approached exhaustion (observed minimum 214/300).
|
||||
|
||||
### Bastion
|
||||
|
||||
- 40 kills: 9 Titans, 16 Husks, and 15 Motes.
|
||||
- Primary fire still caused 27 kills; Conduit lances caused six.
|
||||
- Conduit fired/hit 16 times after secondary generators became available.
|
||||
- Final fields remained around 18–20 seconds, so the build did not erase durable populations.
|
||||
- The secondary budget remained ample (observed minimum 201/300).
|
||||
|
||||
### Brood
|
||||
|
||||
- 79 kills, including 27 Motes spawned during combat.
|
||||
- Bloom produced 103 spark hits; Fork produced 46 fragment hits; Arc triggered 32 times; Focus ruptured eight times.
|
||||
- Final build used all four direct mechanisms and produced all four corresponding kill causes.
|
||||
- Field time increased with the population from 8.5 seconds to 22.1 seconds.
|
||||
- The lowest observed secondary budget was 134/300; the recursion cap did not determine the outcome.
|
||||
|
||||
## Preliminary Interpretation
|
||||
|
||||
Experiment 008 did not immediately collapse into one identical four-slot answer. The player used all six modules across three distinct builds, and the modules materially interacted. It also did not provoke the repeated build tests seen in 007.
|
||||
|
||||
The common Bloom+Arc core is the central ambiguity. It may mean:
|
||||
|
||||
1. knowledge from 007 transferred successfully as a reusable component pair while the remaining slots adapted;
|
||||
2. Bloom+Arc is already a partially crystallized universal answer, leaving only two low-stakes flex slots;
|
||||
3. the player was deliberately holding a familiar baseline while sampling the three new modules;
|
||||
4. the choices were made for novelty/coverage rather than population response.
|
||||
|
||||
The three expedition builds look plausibly ecology-aware, but telemetry cannot establish why. Shoal's Bloom+Resonance generated strong small-target cascades. Bastion's Conduit converted secondary activity into attacks on the healthiest target. Brood combined Focus for durable spawners with Fork/Bloom/Arc for their Motes. Those relationships may have been planned, noticed afterward, or entirely incidental.
|
||||
|
||||
Unlike Experiment 003's obvious counter-parts, no module names a specific enemy class. Components still operate through hit/kill interfaces. However, the population descriptions themselves may make the build split obvious enough that it becomes counter-loadout work. The player's subjective decision process is necessary to discriminate H23.
|
||||
|
||||
The four-choice budget did preserve tradeoffs: no run could include all six modules. But it may also have suppressed the power-escalation payoff that motivated 007's specialization replays. No repeats were selected, including no all-Bloom attempt, and no run created a logged follow-on test.
|
||||
|
||||
## Follow-up Needed
|
||||
|
||||
1. What expectation drove each distinctive first choice—especially Bloom for Shoal/Brood and inactive-until-supported Conduit for Bastion—and were later choices completing a plan or sampling?
|
||||
2. Did the populations create several plausible builds, obvious counters, or a universal Bloom+Arc core with two unimportant flex slots? How did the four-choice limit feel?
|
||||
3. Did any result surprise or satisfy enough to suggest another build, and why did the player stop after one run per expedition rather than replaying as in 007?
|
||||
|
||||
## Player Report
|
||||
|
||||
The distinct builds were not substantially planned. Conduit was chosen first because its dependency was misunderstood, not because the player was deliberately investing in a future secondary-hit engine. Do not cite that sequence as forward planning.
|
||||
|
||||
The player believes the populations may permit multiple approaches, but could not formulate which effects played off one another well because each expedition contained too few rounds. They described the repeated experience as beginning to see something cool emerge and then having the expedition end before anything really cool happened.
|
||||
|
||||
## Final First-Run Interpretation
|
||||
|
||||
Experiment 008 failed to measure strategy half-life because it ended at build maturity. Four selections occurred after the first four fields, leaving only field five for the complete build:
|
||||
|
||||
- Shoal's complete build existed for 7.1 seconds.
|
||||
- Bastion's complete build existed for 19.8 seconds.
|
||||
- Brood's complete build existed for 22.1 seconds.
|
||||
|
||||
The larger six-module vocabulary and changing populations required more observation than Experiment 007, but the run structure supplied no additional mature-build time. Lack of replay is therefore not good evidence that the system exhausted its questions. The player's report and telemetry agree that the prototype terminated during emergence rather than after it.
|
||||
|
||||
The proper correction is structural, not a new mechanic or balance pass: preserve the exact four choices and current populations, then add several post-build fields so the player can observe the same completed composition against increasing and mixed populations. This keeps the slot-budget test intact while allowing H23 to become measurable.
|
||||
|
||||
Do not add replacement choices yet. Swapping would bundle ecology adaptation with interface/optimization work before establishing whether a mature four-module build is interesting to watch and understand. Do not increase the choice budget; the current question specifically concerns composition under exclusion.
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
# Experiment 008 Revision 2 Preliminary Analysis — Session 7b7c7a92
|
||||
|
||||
Status: telemetry and brief initial report inspected; targeted follow-up pending.
|
||||
|
||||
Source: `JSONL/catalyst-ecology-7b7c7a92-42cb-4ab2-8a81-d1316ea972c5.jsonl`
|
||||
|
||||
## Session Structure
|
||||
|
||||
- 5,338 events over 609.4 elapsed seconds, including a roughly 196-second break between Shoal and Bastion.
|
||||
- All three eight-field expeditions completed once in displayed order.
|
||||
- All 24 fields cleared first attempt; no deaths, restarts, abandoned runs, or replays.
|
||||
- Twelve choices total and exactly three post-build transitions per expedition, confirming the corrective structure worked.
|
||||
- Seven damage events across the session. Combat difficulty was not a meaningful constraint.
|
||||
- One save occurred after all three expeditions.
|
||||
|
||||
The player reports that revision 2 made it “a bit easier to see how my choices impacted my play.” This validates the correction at the legibility/exposure level, not yet at the enjoyment or strategy-half-life level.
|
||||
|
||||
## Builds
|
||||
|
||||
| Expedition | Choice sequence | Complete build |
|
||||
|---|---|---|
|
||||
| Shoal | Bloom → Fork → Conduit → Fork | Bloom 1, Fork 2, Conduit 1 |
|
||||
| Bastion | Focus → Fork → Bloom → Fork | Focus 1, Fork 2, Bloom 1 |
|
||||
| Brood | Arc → Fork → Arc → Resonance | Arc 2, Fork 1, Resonance 1 |
|
||||
|
||||
Unlike revision 1, Bloom+Arc was not a universal core. Fork appeared everywhere, but at different levels and with different consumers. Each expedition specialized by repeating a module or using Resonance rather than sampling four distinct mechanisms.
|
||||
|
||||
The builds are causally distinct:
|
||||
|
||||
- Shoal used Fork and Bloom to create secondary hits, then Conduit converted them into 69 heavy lances. Its 237 kills were distributed across primary (46), fragments (73), sparks (70), and lances (48).
|
||||
- Bastion used repeated primaries for 22 Focus ruptures while Fork produced 658 fragment hits. Bloom supplied 151 spark hits after kills began. Focus caused 13 kills and fragments 60.
|
||||
- Brood omitted Bloom entirely. Fork generated hits for Arc; Arc was stacked, then Resonance amplified both. Arc triggered 114 times and caused 54 kills; fragments caused 83.
|
||||
|
||||
This is much stronger behavioral evidence of conditional composition than revision 1. It is still not proof of deliberate ecology reasoning because the player may have been comparing builds experimentally or avoiding repetition from the previous session.
|
||||
|
||||
## Mature-Build Exposure
|
||||
|
||||
Each complete build persisted through fields five through eight:
|
||||
|
||||
| Expedition | Mature field times | Main growth during mature fields |
|
||||
|---|---|---|
|
||||
| Shoal | 9.8, 10.1, 9.9, 11.2 s | Conduit lances rose from 18 cumulative to 69; spark hits from 111 to 349 |
|
||||
| Bastion | 16.8, 18.2, 19.5, 21.7 s | Fork hits rose from 198 to 658; Focus ruptures from 12 to 22 |
|
||||
| Brood | 10.6, 12.9, 17.1, 15.1 s | Arc triggers rose from 44 to 114; spawned Motes from 24 to 69 |
|
||||
|
||||
The extra fields supplied approximately 41 seconds of mature Shoal, 76 seconds of mature Bastion, and 56 seconds of mature Brood, versus 7–22 seconds in revision 1. The player now reports clearer choice impact, so the first-run measurement defect was successfully repaired.
|
||||
|
||||
No field exhausted the secondary budget, though Bastion approached it most closely (observed minimum 18/300). Large effect chains were therefore bounded but not usually clipped.
|
||||
|
||||
## Choice Deliberation
|
||||
|
||||
Deliberation did not simply decline with familiarity:
|
||||
|
||||
- Shoal Conduit took about 15.0 seconds.
|
||||
- Bastion's first Focus took about 11.5 seconds and final Fork stack about 14.0 seconds.
|
||||
- Brood's final Resonance took about 9.8 seconds.
|
||||
|
||||
Those pauses are consistent with real build consideration, but reading, distraction, and uncertainty remain alternate explanations. The long Conduit choice is particularly notable because the first session's Conduit selection was a misunderstanding; revision 2 placed it into a functioning secondary-hit engine.
|
||||
|
||||
## Preliminary Interpretation
|
||||
|
||||
Revision 2 establishes that the system can visibly express multiple four-choice builds across different ecologies. It weakens the immediate crystallization interpretation from revision 1: Bloom+Arc was not universal, stacking occurred, and one expedition deliberately or experimentally omitted Bloom despite its known strength.
|
||||
|
||||
It does not yet establish why the builds differed or whether the extra exposure was enjoyable. Three readings remain:
|
||||
|
||||
1. The player transferred module knowledge and selected conditional causal engines for each population.
|
||||
2. The player intentionally sampled three distinct builds to make the experiment informative, regardless of preference.
|
||||
3. More mature fields made effects readable, but the run still lacked enough decision points or surprise to motivate replay.
|
||||
|
||||
Fork may be emerging as a reusable abstraction rather than a complete answer. It appeared in all builds because extra hits feed Bloom, Arc, and Conduit, while its partner determined the resulting capability. That would match “reuse components, not answers.” Alternatively, Fork×1/2 may simply be the strongest generic throughput option and thus an early universal core.
|
||||
|
||||
No replay followed the clearer mature exposure. As always, this is ambiguous: three eight-field runs may have provided enough evidence, may have become repetitive, or may not have produced a new specific question. The corrective test now permits that question to be asked meaningfully.
|
||||
|
||||
## Follow-up Needed
|
||||
|
||||
1. Were the three specialized builds chosen in response to each population, to test different combinations, or for another reason? What was the intended role of Fork in all three?
|
||||
2. During fields five through eight, did any interaction become surprising, satisfying, or fun rather than merely easier to read? When, if at all, did the extra fields become repetition?
|
||||
3. Did the results suggest a specific alternative build worth replaying, and did the four-slot limit now feel productive or still premature/restrictive?
|
||||
|
||||
4
experiments/008_catalyst_ecology/results/README.md
Normal file
4
experiments/008_catalyst_ecology/results/README.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Experiment 008 Results
|
||||
|
||||
Playtest interpretations and telemetry summaries belong here.
|
||||
|
||||
11
experiments/008_catalyst_ecology/run.sh
Executable file
11
experiments/008_catalyst_ecology/run.sh
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
#!/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
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue