Initial Commit
This commit is contained in:
commit
35f3810632
90 changed files with 29267 additions and 0 deletions
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
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue