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