Initial Commit
This commit is contained in:
commit
35f3810632
90 changed files with 29267 additions and 0 deletions
13
experiments/005_sanctuary_wake/README.md
Normal file
13
experiments/005_sanctuary_wake/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Experiment 005 — Sanctuary Wake
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./experiments/005_sanctuary_wake/run.sh
|
||||
```
|
||||
|
||||
Then open <http://127.0.0.1:8000>.
|
||||
|
||||
Play until the shift ends or until you genuinely want to stop. Use **End shift** if you want to stop early, then **Save JSONL**. The log is written directly into the repository-level `JSONL/` directory.
|
||||
|
||||
Do not read `hypothesis.md` before playing; it contains the private experiment rationale.
|
||||
72
experiments/005_sanctuary_wake/hypothesis.md
Normal file
72
experiments/005_sanctuary_wake/hypothesis.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Experiment 005 Hypothesis — Private Until After Play
|
||||
|
||||
## Reason for the Pivot
|
||||
|
||||
Experiment 004 produced a real self-directed question but no satisfaction. Better room scaffolding would improve predictive play, yet would not address the larger repeated failure: abstract answers and completed predicates have not been valuable by themselves.
|
||||
|
||||
Experiment 002 remains the strongest behavioral signal. Pressure sustained activity and its coupled thermal action briefly caused contextual tactics, but multiplicative upgrades collapsed the run into one wall-blast answer. Experiment 005 removes growth while retaining direct action, pressure, coupled consequences, and recoverable failure.
|
||||
|
||||
## Question
|
||||
|
||||
Does one simple physical tool become enjoyable when it supports improvised rescues, accidents, and weaponization in a changing field—without construction, hidden rules, or stat progression?
|
||||
|
||||
## Core System
|
||||
|
||||
The player directly pilots a small keeper around a sanctuary. Its field has two fully disclosed modes:
|
||||
|
||||
- pull every loose body in range toward the keeper;
|
||||
- push every loose body in range away from the keeper.
|
||||
|
||||
The field does not distinguish rescue pods, raiders, or wreckage. Position and timing determine whether the same input helps or harms.
|
||||
|
||||
- Pods are saved on touching the sanctuary.
|
||||
- Raiders capture pods and damage the sanctuary.
|
||||
- Fast wreckage destroys raiders but also damages the sanctuary.
|
||||
- Saved pods repair sanctuary integrity.
|
||||
- Field charge and player impacts prevent continuous maximal use.
|
||||
- A sanctuary breach vents the field and resets integrity rather than ending the run.
|
||||
|
||||
All laws are stated. There is no discovery test and no permanent build. Randomized spawns define the changing problem while the player always chooses exact actions.
|
||||
|
||||
## Competing Interpretations
|
||||
|
||||
1. Recoverable chaos plus coupled tools creates enjoyable improvisation.
|
||||
2. Pressure creates input persistence but not enjoyment, repeating Experiment 002.
|
||||
3. Rescue/defense supplies a valued consequence missing from prior abstractions.
|
||||
4. Shepherding bodies with radial forces feels like another awkward steering task.
|
||||
5. The system has one dominant positional answer, such as camping at the sanctuary and repelling.
|
||||
6. The absence of power progression removes the main reason Experiment 002 lasted.
|
||||
7. Three minutes is endured because it is a timer, not because the activity is wanted.
|
||||
|
||||
## Strong Evidence
|
||||
|
||||
- a deliberate save after an accident changes the situation;
|
||||
- using wreckage against a raider without an explicit prompt;
|
||||
- relocating because pull/push would affect mixed nearby bodies;
|
||||
- changing priorities based on pod, raider, debris, and sanctuary state;
|
||||
- continuing into optional overtime;
|
||||
- a player-described moment whose outcome was surprising, understandable, and useful.
|
||||
|
||||
## Ambiguous Evidence
|
||||
|
||||
- finishing the timer;
|
||||
- high rescue count;
|
||||
- rapid input;
|
||||
- survival without breaches;
|
||||
- overtime entered merely to check whether anything changes.
|
||||
|
||||
## Failure Signals
|
||||
|
||||
- stationary field spam solves most states;
|
||||
- activity feels like object babysitting or imprecise mouse-less herding;
|
||||
- consequences are unreadable during clutter;
|
||||
- the player waits out or dutifully finishes the timer;
|
||||
- no event creates a memorable decision or recovery.
|
||||
|
||||
## Feedback to Request
|
||||
|
||||
1. When did they first want to stop, and did the shift timer make them continue past that point?
|
||||
2. Was there one situation where they intentionally used or avoided affecting multiple object types at once?
|
||||
3. Did any mistake create an interesting recovery, or did errors only add cleanup/frustration?
|
||||
|
||||
Do not ask whether they “like recoverable chaos”; ask about concrete moments.
|
||||
613
experiments/005_sanctuary_wake/prototype/app.js
vendored
Normal file
613
experiments/005_sanctuary_wake/prototype/app.js
vendored
Normal file
|
|
@ -0,0 +1,613 @@
|
|||
(() => {
|
||||
"use strict";
|
||||
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const canvas = $("#field");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const SHIFT_DURATION = 180;
|
||||
const DOCK = { x: .5, y: .5, r: .073 };
|
||||
const FIELD_RANGE = .235;
|
||||
const KEY_VECTOR = {
|
||||
KeyW: [0, -1], ArrowUp: [0, -1], KeyS: [0, 1], ArrowDown: [0, 1],
|
||||
KeyA: [-1, 0], ArrowLeft: [-1, 0], KeyD: [1, 0], ArrowRight: [1, 0]
|
||||
};
|
||||
const STAGES = [
|
||||
{ at: 0, name: "Quiet Wake" },
|
||||
{ at: 25, name: "Scavengers" },
|
||||
{ at: 75, name: "Debris Front" },
|
||||
{ at: 130, name: "Convergence" }
|
||||
];
|
||||
|
||||
const state = {
|
||||
session: crypto.randomUUID ? crypto.randomUUID() : `session-${Date.now()}`,
|
||||
started: Date.now(), logs: [], active: false, ended: false, overtime: false,
|
||||
shiftTime: 0, lastFrame: performance.now(), stage: 0, snapshotClock: 0,
|
||||
sessionSeed: 0, random: null, objectId: 0, objects: [], particles: [], stars: [],
|
||||
keys: new Set(), fieldMode: null, fieldStartedAt: 0, fieldSource: null,
|
||||
nextPod: 3, nextRaider: 20, nextDebris: 6, bannerTimer: null, toastTimer: null,
|
||||
report: [], integrity: 100, rescued: 0, lost: 0, breaches: 0, broken: 0,
|
||||
player: { x: .5, y: .72, vx: 0, vy: 0, energy: 100, hitCooldown: 0 },
|
||||
render: { size: 1, ox: 0, oy: 0, width: 1, height: 1 }
|
||||
};
|
||||
|
||||
function hashSeed(text) {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < text.length; i++) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 16777619); }
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function mulberry32(seed) {
|
||||
return () => {
|
||||
seed |= 0; seed = seed + 0x6D2B79F5 | 0;
|
||||
let value = Math.imul(seed ^ seed >>> 15, 1 | seed);
|
||||
value = value + Math.imul(value ^ value >>> 7, 61 | value) ^ value;
|
||||
return ((value ^ value >>> 14) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
state.sessionSeed = hashSeed(state.session);
|
||||
state.random = mulberry32(state.sessionSeed);
|
||||
for (let i = 0; i < 90; i++) state.stars.push([state.random(), state.random(), .15 + state.random() * .55]);
|
||||
|
||||
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
|
||||
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const round = value => Math.round(value * 1000) / 1000;
|
||||
const objectCounts = () => state.objects.reduce((counts, object) => {
|
||||
counts[object.kind] = (counts[object.kind] || 0) + 1; return counts;
|
||||
}, { pod: 0, raider: 0, debris: 0 });
|
||||
|
||||
function log(type, data = {}) {
|
||||
const event = {
|
||||
schema: 1, experiment: "005_sanctuary_wake", prototype_revision: 1,
|
||||
session_id: state.session, elapsed_ms: Date.now() - state.started,
|
||||
shift_seconds: round(state.shiftTime), stage: state.overtime ? "overtime" : STAGES[state.stage].name,
|
||||
type, ...data
|
||||
};
|
||||
state.logs.push(JSON.stringify(event));
|
||||
try { localStorage.setItem("sanctuary-wake-last-jsonl", state.logs.join("\n") + "\n"); } catch (_) {}
|
||||
}
|
||||
|
||||
function addReport(text) {
|
||||
state.report.unshift(text);
|
||||
state.report = state.report.slice(0, 4);
|
||||
$("#field-report").innerHTML = state.report.map((line, index) => `<div class="${index === 0 ? "latest" : ""}">${line}</div>`).join("");
|
||||
}
|
||||
|
||||
function begin() {
|
||||
if (state.active || state.ended) return;
|
||||
$("#start-overlay").classList.add("hidden");
|
||||
state.active = true;
|
||||
state.lastFrame = performance.now();
|
||||
spawnInitial();
|
||||
addReport("Recovery shift started. The wake is quiet—for now.");
|
||||
log("shift_started", { seed: state.sessionSeed, viewport: [window.innerWidth, window.innerHeight], initial_objects: objectCounts() });
|
||||
canvas.focus({ preventScroll: true });
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function spawnInitial() {
|
||||
spawn("pod", { x: .12, y: .27, vx: .018, vy: .008 });
|
||||
spawn("pod", { x: .87, y: .34, vx: -.016, vy: .006 });
|
||||
spawn("debris", { x: .16, y: .74, vx: .067, vy: -.02 });
|
||||
spawn("debris", { x: .84, y: .77, vx: -.058, vy: -.026 });
|
||||
}
|
||||
|
||||
function randomEdge(kind) {
|
||||
const edge = Math.floor(state.random() * 4);
|
||||
const margin = .035, along = .1 + state.random() * .8;
|
||||
let x, y, nx, ny;
|
||||
if (edge === 0) { x = along; y = margin; nx = 0; ny = 1; }
|
||||
else if (edge === 1) { x = 1 - margin; y = along; nx = -1; ny = 0; }
|
||||
else if (edge === 2) { x = along; y = 1 - margin; nx = 0; ny = -1; }
|
||||
else { x = margin; y = along; nx = 1; ny = 0; }
|
||||
const spread = (state.random() - .5) * .055;
|
||||
const speed = kind === "debris" ? .055 + state.random() * .075 : .01 + state.random() * .012;
|
||||
return { x, y, vx: nx * speed + (ny !== 0 ? spread : 0), vy: ny * speed + (nx !== 0 ? spread : 0) };
|
||||
}
|
||||
|
||||
function spawn(kind, values = randomEdge(kind)) {
|
||||
const definitions = {
|
||||
pod: { radius: .016, mass: .72 }, raider: { radius: .019, mass: 1.05 }, debris: { radius: .021, mass: 2.15 }
|
||||
};
|
||||
const object = {
|
||||
id: ++state.objectId, kind, radius: definitions[kind].radius, mass: definitions[kind].mass,
|
||||
x: values.x, y: values.y, vx: values.vx, vy: values.vy, age: 0, dockCooldown: 0,
|
||||
fieldInfluence: 0, removed: false
|
||||
};
|
||||
state.objects.push(object);
|
||||
log("object_spawned", { id: object.id, kind, position: [round(object.x), round(object.y)], velocity: [round(object.vx), round(object.vy)], counts: objectCounts() });
|
||||
return object;
|
||||
}
|
||||
|
||||
function updateSpawns() {
|
||||
const stage = state.overtime ? 4 : state.stage;
|
||||
if (state.shiftTime >= state.nextPod) {
|
||||
spawn("pod"); state.nextPod += Math.max(5.2, 8.4 - stage * .65) + state.random() * 2;
|
||||
}
|
||||
if (stage > 0 && state.shiftTime >= state.nextRaider) {
|
||||
spawn("raider"); state.nextRaider += Math.max(3.4, 7.4 - stage * .9) + state.random() * 1.4;
|
||||
}
|
||||
if (state.shiftTime >= state.nextDebris) {
|
||||
spawn("debris");
|
||||
const base = stage === 2 ? 4.7 : stage >= 3 ? 5.5 : 8.5;
|
||||
state.nextDebris += base + state.random() * 2.2;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStage() {
|
||||
if (state.overtime) return;
|
||||
let next = 0;
|
||||
for (let i = 0; i < STAGES.length; i++) if (state.shiftTime >= STAGES[i].at) next = i;
|
||||
if (next === state.stage) return;
|
||||
state.stage = next;
|
||||
const stage = STAGES[next];
|
||||
showBanner(stage.name);
|
||||
addReport(`Conditions changed: ${stage.name}.`);
|
||||
log("stage_changed", { stage_index: next, stage_name: stage.name, counts: objectCounts(), integrity: round(state.integrity) });
|
||||
}
|
||||
|
||||
function updatePlayer(dt) {
|
||||
let dx = 0, dy = 0;
|
||||
for (const code of state.keys) {
|
||||
const vector = KEY_VECTOR[code]; if (!vector) continue;
|
||||
dx += vector[0]; dy += vector[1];
|
||||
}
|
||||
const magnitude = Math.hypot(dx, dy) || 1;
|
||||
if (dx || dy) { state.player.vx += dx / magnitude * .82 * dt; state.player.vy += dy / magnitude * .82 * dt; }
|
||||
const drag = Math.pow(.055, dt);
|
||||
state.player.vx *= drag; state.player.vy *= drag;
|
||||
const speed = Math.hypot(state.player.vx, state.player.vy), max = .29;
|
||||
if (speed > max) { state.player.vx *= max / speed; state.player.vy *= max / speed; }
|
||||
state.player.x = clamp(state.player.x + state.player.vx * dt, .035, .965);
|
||||
state.player.y = clamp(state.player.y + state.player.vy * dt, .035, .965);
|
||||
if (state.player.x <= .035 || state.player.x >= .965) state.player.vx *= -.25;
|
||||
if (state.player.y <= .035 || state.player.y >= .965) state.player.vy *= -.25;
|
||||
state.player.hitCooldown = Math.max(0, state.player.hitCooldown - dt);
|
||||
|
||||
if (state.fieldMode && state.player.energy > 0) {
|
||||
state.player.energy = Math.max(0, state.player.energy - 25 * dt);
|
||||
applyField(dt);
|
||||
if (state.player.energy === 0) stopField("depleted");
|
||||
} else {
|
||||
state.player.energy = Math.min(100, state.player.energy + 18 * dt);
|
||||
}
|
||||
}
|
||||
|
||||
function applyField(dt) {
|
||||
const sign = state.fieldMode === "pull" ? 1 : -1;
|
||||
for (const object of state.objects) {
|
||||
const dx = state.player.x - object.x, dy = state.player.y - object.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length <= .001 || length > FIELD_RANGE) { object.fieldInfluence = 0; continue; }
|
||||
const falloff = 1 - length / FIELD_RANGE;
|
||||
const acceleration = sign * (.46 / object.mass) * (.22 + falloff * .78);
|
||||
object.vx += dx / length * acceleration * dt;
|
||||
object.vy += dy / length * acceleration * dt;
|
||||
object.fieldInfluence = sign * falloff;
|
||||
}
|
||||
}
|
||||
|
||||
function updateObjects(dt) {
|
||||
for (const object of state.objects) {
|
||||
object.age += dt; object.dockCooldown = Math.max(0, object.dockCooldown - dt);
|
||||
if (!state.fieldMode) object.fieldInfluence = 0;
|
||||
if (object.kind === "raider") {
|
||||
const pods = state.objects.filter(candidate => candidate.kind === "pod" && !candidate.removed);
|
||||
const target = pods.length ? pods.reduce((best, candidate) => distance(object, candidate) < distance(object, best) ? candidate : best) : DOCK;
|
||||
steerToward(object, target, .045, dt);
|
||||
} else if (object.kind === "pod") {
|
||||
steerToward(object, DOCK, .0065, dt);
|
||||
}
|
||||
const drag = Math.pow(object.kind === "debris" ? .996 : .982, dt * 60);
|
||||
object.vx *= drag; object.vy *= drag;
|
||||
const speed = Math.hypot(object.vx, object.vy), max = object.kind === "debris" ? .34 : .28;
|
||||
if (speed > max) { object.vx *= max / speed; object.vy *= max / speed; }
|
||||
object.x += object.vx * dt; object.y += object.vy * dt;
|
||||
bounceBoundary(object);
|
||||
}
|
||||
resolveObjectCollisions();
|
||||
resolvePlayerCollisions();
|
||||
resolveDockContacts();
|
||||
state.objects = state.objects.filter(object => !object.removed);
|
||||
}
|
||||
|
||||
function steerToward(object, target, acceleration, dt) {
|
||||
const dx = target.x - object.x, dy = target.y - object.y, length = Math.hypot(dx, dy) || 1;
|
||||
object.vx += dx / length * acceleration * dt;
|
||||
object.vy += dy / length * acceleration * dt;
|
||||
}
|
||||
|
||||
function bounceBoundary(object) {
|
||||
const low = object.radius, high = 1 - object.radius;
|
||||
if (object.x < low) { object.x = low; object.vx = Math.abs(object.vx) * .78; }
|
||||
if (object.x > high) { object.x = high; object.vx = -Math.abs(object.vx) * .78; }
|
||||
if (object.y < low) { object.y = low; object.vy = Math.abs(object.vy) * .78; }
|
||||
if (object.y > high) { object.y = high; object.vy = -Math.abs(object.vy) * .78; }
|
||||
}
|
||||
|
||||
function resolveObjectCollisions() {
|
||||
for (let i = 0; i < state.objects.length; i++) {
|
||||
const a = state.objects[i]; if (a.removed) continue;
|
||||
for (let j = i + 1; j < state.objects.length; j++) {
|
||||
const b = state.objects[j]; if (b.removed) continue;
|
||||
const dx = b.x - a.x, dy = b.y - a.y, length = Math.hypot(dx, dy), overlap = a.radius + b.radius - length;
|
||||
if (overlap <= 0) continue;
|
||||
const nx = length > .0001 ? dx / length : 1, ny = length > .0001 ? dy / length : 0;
|
||||
const relative = Math.abs((b.vx - a.vx) * nx + (b.vy - a.vy) * ny);
|
||||
const raider = a.kind === "raider" ? a : b.kind === "raider" ? b : null;
|
||||
const debris = a.kind === "debris" ? a : b.kind === "debris" ? b : null;
|
||||
if (raider && debris && relative >= .105) {
|
||||
raider.removed = true; state.broken++;
|
||||
burst(raider.x, raider.y, "#f07370", 12);
|
||||
addReport("Fast wreckage broke a raider.");
|
||||
log("raider_broken", { raider_id: raider.id, debris_id: debris.id, relative_speed: round(relative), position: [round(raider.x), round(raider.y)], field_mode: state.fieldMode });
|
||||
continue;
|
||||
}
|
||||
const pod = a.kind === "pod" ? a : b.kind === "pod" ? b : null;
|
||||
if (raider && pod) {
|
||||
pod.removed = true; state.lost++;
|
||||
burst(pod.x, pod.y, "#7bdcf4", 9);
|
||||
addReport("A raider captured an escape pod.");
|
||||
log("pod_lost", { pod_id: pod.id, reason: "captured", raider_id: raider.id, position: [round(pod.x), round(pod.y)] });
|
||||
continue;
|
||||
}
|
||||
separateAndBounce(a, b, nx, ny, overlap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function separateAndBounce(a, b, nx, ny, overlap) {
|
||||
const totalMass = a.mass + b.mass;
|
||||
a.x -= nx * overlap * b.mass / totalMass; a.y -= ny * overlap * b.mass / totalMass;
|
||||
b.x += nx * overlap * a.mass / totalMass; b.y += ny * overlap * a.mass / totalMass;
|
||||
const relative = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
|
||||
if (relative >= 0) return;
|
||||
const impulse = -(1.55) * relative / (1 / a.mass + 1 / b.mass);
|
||||
a.vx -= impulse * nx / a.mass; a.vy -= impulse * ny / a.mass;
|
||||
b.vx += impulse * nx / b.mass; b.vy += impulse * ny / b.mass;
|
||||
}
|
||||
|
||||
function resolvePlayerCollisions() {
|
||||
for (const object of state.objects) {
|
||||
if (object.removed) continue;
|
||||
const dx = object.x - state.player.x, dy = object.y - state.player.y, length = Math.hypot(dx, dy), minimum = object.radius + .021;
|
||||
if (length >= minimum) continue;
|
||||
const nx = length > .0001 ? dx / length : 1, ny = length > .0001 ? dy / length : 0;
|
||||
object.x = state.player.x + nx * minimum; object.y = state.player.y + ny * minimum;
|
||||
const push = .11 + Math.hypot(state.player.vx, state.player.vy) * .35;
|
||||
object.vx += nx * push; object.vy += ny * push;
|
||||
state.player.vx -= nx * .035; state.player.vy -= ny * .035;
|
||||
if ((object.kind === "raider" || object.kind === "debris") && state.player.hitCooldown === 0) {
|
||||
state.player.energy = Math.max(0, state.player.energy - 16);
|
||||
state.player.hitCooldown = .55;
|
||||
burst(state.player.x, state.player.y, "#efb45a", 6);
|
||||
log("keeper_impact", { object_id: object.id, object_kind: object.kind, energy_after: round(state.player.energy), position: [round(state.player.x), round(state.player.y)] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDockContacts() {
|
||||
for (const object of state.objects) {
|
||||
if (object.removed) continue;
|
||||
const dx = object.x - DOCK.x, dy = object.y - DOCK.y, length = Math.hypot(dx, dy);
|
||||
if (length > DOCK.r + object.radius) continue;
|
||||
if (object.kind === "pod") {
|
||||
object.removed = true; state.rescued++; state.integrity = Math.min(100, state.integrity + 6);
|
||||
burst(object.x, object.y, "#7bdcf4", 13);
|
||||
addReport("Escape pod secured. Sanctuary integrity restored.");
|
||||
log("pod_rescued", { pod_id: object.id, rescued: state.rescued, integrity_after: round(state.integrity), velocity: [round(object.vx), round(object.vy)] });
|
||||
} else if (object.kind === "raider") {
|
||||
object.removed = true; damageDock(14, "raider", object);
|
||||
} else {
|
||||
const nx = length > .0001 ? dx / length : 1, ny = length > .0001 ? dy / length : 0;
|
||||
object.x = DOCK.x + nx * (DOCK.r + object.radius + .002);
|
||||
const radial = object.vx * nx + object.vy * ny;
|
||||
if (radial < 0) { object.vx -= 1.75 * radial * nx; object.vy -= 1.75 * radial * ny; }
|
||||
if (object.dockCooldown === 0 && Math.abs(radial) > .065) {
|
||||
object.dockCooldown = .8;
|
||||
damageDock(clamp(Math.abs(radial) * 72, 5, 18), "wreckage", object);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function damageDock(amount, cause, object) {
|
||||
state.integrity -= amount;
|
||||
burst(DOCK.x, DOCK.y, cause === "raider" ? "#f07370" : "#efb45a", 15);
|
||||
addReport(`${cause === "raider" ? "Raider" : "Wreckage"} struck the sanctuary: −${Math.round(amount)} integrity.`);
|
||||
log("sanctuary_hit", { cause, object_id: object.id, damage: round(amount), integrity_after: round(Math.max(0, state.integrity)), velocity: [round(object.vx), round(object.vy)] });
|
||||
if (state.integrity <= 0) breach();
|
||||
}
|
||||
|
||||
function breach() {
|
||||
state.breaches++; state.integrity = 45;
|
||||
for (const object of state.objects) {
|
||||
const dx = object.x - DOCK.x, dy = object.y - DOCK.y, length = Math.hypot(dx, dy) || 1;
|
||||
object.vx += dx / length * .24; object.vy += dy / length * .24;
|
||||
}
|
||||
burst(DOCK.x, DOCK.y, "#f07370", 35);
|
||||
showBanner("SANCTUARY BREACH · EMERGENCY VENT");
|
||||
addReport("Breach contained. Emergency vent cleared the center; recovery continues.");
|
||||
log("sanctuary_breach", { breaches: state.breaches, reset_integrity: state.integrity, counts: objectCounts() });
|
||||
}
|
||||
|
||||
function burst(x, y, color, count) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = state.random() * Math.PI * 2, speed = .035 + state.random() * .16;
|
||||
state.particles.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: .35 + state.random() * .55, maxLife: .9, color });
|
||||
}
|
||||
}
|
||||
|
||||
function updateParticles(dt) {
|
||||
for (const particle of state.particles) {
|
||||
particle.x += particle.vx * dt; particle.y += particle.vy * dt;
|
||||
particle.vx *= Math.pow(.2, dt); particle.vy *= Math.pow(.2, dt); particle.life -= dt;
|
||||
}
|
||||
state.particles = state.particles.filter(particle => particle.life > 0);
|
||||
}
|
||||
|
||||
function startField(mode, source) {
|
||||
if (!state.active || state.player.energy <= 0 || (mode !== "pull" && mode !== "push")) return;
|
||||
if (state.fieldMode === mode) return;
|
||||
if (state.fieldMode) stopField("mode_changed");
|
||||
state.fieldMode = mode; state.fieldStartedAt = state.shiftTime; state.fieldSource = source;
|
||||
$("#pull-button").classList.toggle("active", mode === "pull");
|
||||
$("#push-button").classList.toggle("active", mode === "push");
|
||||
log("field_started", { mode, source, energy: round(state.player.energy), position: [round(state.player.x), round(state.player.y)], counts: objectCounts() });
|
||||
}
|
||||
|
||||
function stopField(reason) {
|
||||
if (!state.fieldMode) return;
|
||||
log("field_ended", {
|
||||
mode: state.fieldMode, source: state.fieldSource, reason,
|
||||
duration_seconds: round(state.shiftTime - state.fieldStartedAt), energy: round(state.player.energy),
|
||||
position: [round(state.player.x), round(state.player.y)], counts: objectCounts()
|
||||
});
|
||||
state.fieldMode = null; state.fieldSource = null;
|
||||
$("#pull-button").classList.remove("active"); $("#push-button").classList.remove("active");
|
||||
}
|
||||
|
||||
function endShift(early) {
|
||||
if (state.ended || (!state.active && !state.overtime)) return;
|
||||
stopField("shift_ended"); state.active = false; state.ended = true;
|
||||
log("shift_ended", { early, rescued: state.rescued, lost: state.lost, breaches: state.breaches, broken: state.broken, integrity: round(state.integrity), counts: objectCounts() });
|
||||
$("#summary-title").textContent = early ? "Shift ended early" : "Measured shift complete";
|
||||
$("#summary-stats").innerHTML = [
|
||||
["RESCUED", state.rescued], ["LOST", state.lost], ["BREACHES", state.breaches], ["RAIDERS BROKEN", state.broken]
|
||||
].map(([label, value]) => `<div><span>${label}</span><b>${value}</b></div>`).join("");
|
||||
$("#summary-copy").textContent = early ? "You ended the measured shift. Continue only if you want to keep interacting." : "The measured shift is over. Continuing grants no upgrade or new tool.";
|
||||
$("#summary-overlay").classList.remove("hidden");
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function startOvertime() {
|
||||
if (!state.ended) return;
|
||||
state.ended = false; state.overtime = true; state.active = true;
|
||||
$("#summary-overlay").classList.add("hidden");
|
||||
state.nextPod = state.shiftTime + 2; state.nextRaider = state.shiftTime + 1.5; state.nextDebris = state.shiftTime + 3;
|
||||
addReport("Overtime started. No new tools or upgrades are coming.");
|
||||
showBanner("OVERTIME");
|
||||
log("overtime_started", { stats: { rescued: state.rescued, lost: state.lost, breaches: state.breaches, broken: state.broken }, counts: objectCounts() });
|
||||
canvas.focus({ preventScroll: true }); updateUI();
|
||||
}
|
||||
|
||||
async function saveLog() {
|
||||
log("save_requested", { event_count_before_save: state.logs.length });
|
||||
const filename = `sanctuary-wake-${state.session}.jsonl`;
|
||||
const payload = state.logs.join("\n") + "\n";
|
||||
try {
|
||||
const response = await fetch("/api/playtest-log", {
|
||||
method: "POST", headers: { "Content-Type": "application/x-ndjson", "X-Playtest-Filename": filename }, body: payload
|
||||
});
|
||||
if (!response.ok) throw new Error(`server returned ${response.status}`);
|
||||
const result = await response.json();
|
||||
log("log_saved", { path: result.path, saved_events: result.events });
|
||||
showToast(`Saved ${result.events} events to ${result.path}`);
|
||||
} catch (error) {
|
||||
const blob = new Blob([payload], { type: "application/x-ndjson" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob); link.download = filename; link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
||||
log("save_fallback_download", { message: String(error) });
|
||||
showToast("Server save unavailable; downloaded the JSONL instead.");
|
||||
}
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
if (!state.active) return;
|
||||
state.shiftTime += dt;
|
||||
updateStage(); updateSpawns(); updatePlayer(dt); updateObjects(dt); updateParticles(dt);
|
||||
state.snapshotClock += dt;
|
||||
if (state.snapshotClock >= 2) {
|
||||
state.snapshotClock -= 2;
|
||||
log("field_snapshot", {
|
||||
player: [round(state.player.x), round(state.player.y)], velocity: [round(state.player.vx), round(state.player.vy)],
|
||||
energy: round(state.player.energy), field_mode: state.fieldMode, integrity: round(state.integrity),
|
||||
rescued: state.rescued, lost: state.lost, breaches: state.breaches, broken: state.broken, counts: objectCounts()
|
||||
});
|
||||
}
|
||||
if (!state.overtime && state.shiftTime >= SHIFT_DURATION) endShift(false);
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
const remaining = Math.max(0, SHIFT_DURATION - state.shiftTime);
|
||||
const remainingSeconds = Math.ceil(remaining);
|
||||
$("#time-left").textContent = state.overtime ? "OVERTIME" : `${Math.floor(remainingSeconds / 60)}:${String(remainingSeconds % 60).padStart(2, "0")}`;
|
||||
$("#charge").textContent = `${Math.round(state.player.energy)}%`;
|
||||
$("#rescued").textContent = state.rescued; $("#lost").textContent = state.lost;
|
||||
$("#breaches").textContent = state.breaches; $("#broken").textContent = state.broken;
|
||||
$("#integrity-value").textContent = `${Math.round(state.integrity)}%`;
|
||||
$("#integrity-bar").style.transform = `scaleX(${clamp(state.integrity / 100, 0, 1)})`;
|
||||
$("#stage-name").textContent = state.overtime ? "Overtime" : STAGES[state.stage].name;
|
||||
$("#end-shift").disabled = !state.active;
|
||||
}
|
||||
|
||||
function showBanner(message) {
|
||||
const banner = $("#banner"); banner.textContent = message; banner.classList.add("show");
|
||||
clearTimeout(state.bannerTimer); state.bannerTimer = setTimeout(() => banner.classList.remove("show"), 1500);
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const toast = $("#toast"); toast.textContent = message; toast.classList.add("show");
|
||||
clearTimeout(state.toastTimer); state.toastTimer = setTimeout(() => toast.classList.remove("show"), 2800);
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const rect = canvas.getBoundingClientRect(), dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.max(1, Math.round(rect.width * dpr)); canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const size = Math.min(rect.width, rect.height) * .91;
|
||||
state.render = { size, ox: (rect.width - size) / 2, oy: (rect.height - size) / 2, width: rect.width, height: rect.height };
|
||||
}
|
||||
|
||||
function screen(point) {
|
||||
return { x: state.render.ox + point.x * state.render.size, y: state.render.oy + point.y * state.render.size };
|
||||
}
|
||||
|
||||
function draw(time) {
|
||||
const { width, height, size, ox, oy } = state.render;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(ox, oy, size, size); ctx.clip();
|
||||
ctx.fillStyle = "#071016"; ctx.fillRect(ox, oy, size, size);
|
||||
for (const [x, y, alpha] of state.stars) {
|
||||
ctx.fillStyle = `rgba(151, 190, 199, ${alpha})`; ctx.fillRect(ox + x * size, oy + y * size, 1.2, 1.2);
|
||||
}
|
||||
drawGrid(); drawDock(time); drawField();
|
||||
for (const object of state.objects) drawObject(object);
|
||||
for (const particle of state.particles) drawParticle(particle);
|
||||
drawPlayer();
|
||||
ctx.restore();
|
||||
ctx.strokeStyle = "#38505a"; ctx.lineWidth = 1; ctx.strokeRect(ox + .5, oy + .5, size - 1, size - 1);
|
||||
}
|
||||
|
||||
function drawGrid() {
|
||||
const { size, ox, oy } = state.render;
|
||||
ctx.strokeStyle = "rgba(87, 126, 137, .09)"; ctx.lineWidth = 1;
|
||||
for (let i = 1; i < 10; i++) {
|
||||
ctx.beginPath(); ctx.moveTo(ox + i * size / 10, oy); ctx.lineTo(ox + i * size / 10, oy + size); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(ox, oy + i * size / 10); ctx.lineTo(ox + size, oy + i * size / 10); ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawDock(time) {
|
||||
const center = screen(DOCK), radius = DOCK.r * state.render.size;
|
||||
ctx.save(); ctx.translate(center.x, center.y);
|
||||
ctx.fillStyle = "rgba(65, 133, 115, .16)"; ctx.strokeStyle = state.integrity < 30 ? "#f07370" : "#6ee4bd";
|
||||
ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
ctx.strokeStyle = "rgba(110, 228, 189, .35)"; ctx.lineWidth = 1.5; ctx.setLineDash([5, 7]);
|
||||
ctx.rotate(time / 3200); ctx.beginPath(); ctx.arc(0, 0, radius * .72, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
|
||||
for (let i = 0; i < Math.min(12, state.rescued); i++) {
|
||||
const angle = i / 12 * Math.PI * 2; ctx.fillStyle = "#7bdcf4";
|
||||
ctx.beginPath(); ctx.arc(Math.cos(angle) * radius * .48, Math.sin(angle) * radius * .48, 2.3, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
ctx.fillStyle = "#d7f4e9"; ctx.font = `800 ${Math.max(8, radius * .2)}px ui-monospace, monospace`;
|
||||
ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("SANCTUARY", 0, 1);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawField() {
|
||||
if (!state.fieldMode) return;
|
||||
const player = screen(state.player), radius = FIELD_RANGE * state.render.size;
|
||||
const color = state.fieldMode === "pull" ? "123,220,244" : "239,180,90";
|
||||
const gradient = ctx.createRadialGradient(player.x, player.y, 0, player.x, player.y, radius);
|
||||
gradient.addColorStop(0, `rgba(${color}, .13)`); gradient.addColorStop(.65, `rgba(${color}, .06)`); gradient.addColorStop(1, `rgba(${color}, 0)`);
|
||||
ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(player.x, player.y, radius, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = `rgba(${color}, .5)`; ctx.lineWidth = 1.5; ctx.setLineDash([7, 7]);
|
||||
ctx.beginPath(); ctx.arc(player.x, player.y, radius, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
|
||||
for (const object of state.objects) {
|
||||
if (!object.fieldInfluence) continue;
|
||||
const point = screen(object); ctx.strokeStyle = `rgba(${color}, ${.15 + Math.abs(object.fieldInfluence) * .45})`;
|
||||
ctx.beginPath(); ctx.moveTo(player.x, player.y); ctx.lineTo(point.x, point.y); ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawObject(object) {
|
||||
const point = screen(object), radius = object.radius * state.render.size;
|
||||
ctx.save(); ctx.translate(point.x, point.y);
|
||||
if (object.kind === "pod") {
|
||||
ctx.shadowColor = "#7bdcf4"; ctx.shadowBlur = 10; ctx.fillStyle = "#173844"; ctx.strokeStyle = "#7bdcf4"; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = "#d9f8ff"; ctx.beginPath(); ctx.arc(0, 0, radius * .3, 0, Math.PI * 2); ctx.fill();
|
||||
} else if (object.kind === "raider") {
|
||||
ctx.rotate(Math.atan2(object.vy, object.vx) + Math.PI / 4); ctx.shadowColor = "#f07370"; ctx.shadowBlur = 10;
|
||||
ctx.fillStyle = "#401e24"; ctx.strokeStyle = "#f07370"; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.rect(-radius * .72, -radius * .72, radius * 1.44, radius * 1.44); ctx.fill(); ctx.stroke();
|
||||
ctx.strokeStyle = "#ffb0a7"; ctx.beginPath(); ctx.moveTo(-radius * .45, 0); ctx.lineTo(radius * .45, 0); ctx.stroke();
|
||||
} else {
|
||||
ctx.rotate(object.id * 1.71 + object.age * .15); ctx.fillStyle = "#443621"; ctx.strokeStyle = "#efb45a"; ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = i / 6 * Math.PI * 2, uneven = i % 2 ? .78 : 1;
|
||||
const x = Math.cos(angle) * radius * uneven, y = Math.sin(angle) * radius * uneven;
|
||||
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
||||
}
|
||||
ctx.closePath(); ctx.fill(); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPlayer() {
|
||||
const point = screen(state.player), radius = .022 * state.render.size;
|
||||
ctx.save(); ctx.translate(point.x, point.y); ctx.rotate(Math.atan2(state.player.vy, state.player.vx) + Math.PI / 2);
|
||||
ctx.shadowColor = state.player.hitCooldown ? "#f07370" : "#6ee4bd"; ctx.shadowBlur = 14;
|
||||
ctx.fillStyle = "#173b33"; ctx.strokeStyle = state.player.hitCooldown ? "#f07370" : "#6ee4bd"; ctx.lineWidth = 2.5;
|
||||
ctx.beginPath(); ctx.moveTo(0, -radius); ctx.lineTo(radius * .8, radius * .85); ctx.lineTo(0, radius * .56); ctx.lineTo(-radius * .8, radius * .85); ctx.closePath(); ctx.fill(); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawParticle(particle) {
|
||||
const point = screen(particle); ctx.globalAlpha = clamp(particle.life / particle.maxLife, 0, 1);
|
||||
ctx.fillStyle = particle.color; ctx.beginPath(); ctx.arc(point.x, point.y, 2.2, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function frame(now) {
|
||||
const dt = Math.min(.035, Math.max(0, (now - state.lastFrame) / 1000)); state.lastFrame = now;
|
||||
update(dt); draw(now); requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", event => {
|
||||
if (!KEY_VECTOR[event.code]) return;
|
||||
event.preventDefault();
|
||||
if (!state.keys.has(event.code)) {
|
||||
state.keys.add(event.code);
|
||||
if (state.active) log("movement_key_down", { key: event.code, active_keys: [...state.keys] });
|
||||
}
|
||||
});
|
||||
document.addEventListener("keyup", event => {
|
||||
if (!KEY_VECTOR[event.code]) return;
|
||||
event.preventDefault(); state.keys.delete(event.code);
|
||||
if (state.active) log("movement_key_up", { key: event.code, active_keys: [...state.keys] });
|
||||
});
|
||||
canvas.addEventListener("pointerdown", event => {
|
||||
event.preventDefault();
|
||||
try { canvas.setPointerCapture?.(event.pointerId); } catch (_) {}
|
||||
canvas.focus({ preventScroll: true });
|
||||
if (event.button === 0) startField("pull", "canvas");
|
||||
if (event.button === 2) startField("push", "canvas");
|
||||
});
|
||||
window.addEventListener("pointerup", event => {
|
||||
if ((event.button === 0 && state.fieldMode === "pull") || (event.button === 2 && state.fieldMode === "push")) stopField("pointer_up");
|
||||
});
|
||||
canvas.addEventListener("contextmenu", event => event.preventDefault());
|
||||
document.addEventListener("contextmenu", event => event.preventDefault());
|
||||
document.addEventListener("selectstart", event => event.preventDefault());
|
||||
|
||||
for (const [selector, mode] of [["#pull-button", "pull"], ["#push-button", "push"]]) {
|
||||
const button = $(selector);
|
||||
button.addEventListener("pointerdown", event => { event.preventDefault(); startField(mode, "button"); });
|
||||
button.addEventListener("pointerup", () => { if (state.fieldMode === mode) stopField("pointer_up"); });
|
||||
button.addEventListener("pointercancel", () => { if (state.fieldMode === mode) stopField("pointer_cancel"); });
|
||||
}
|
||||
|
||||
$("#start-overlay").addEventListener("pointerdown", begin);
|
||||
$("#end-shift").addEventListener("click", () => endShift(true));
|
||||
$("#save").addEventListener("click", saveLog); $("#save-summary").addEventListener("click", saveLog);
|
||||
$("#overtime").addEventListener("click", startOvertime);
|
||||
window.addEventListener("blur", () => { state.keys.clear(); stopField("window_blur"); });
|
||||
document.addEventListener("visibilitychange", () => log("visibility_changed", { visibility: document.visibilityState, active: state.active, stats: { rescued: state.rescued, lost: state.lost, breaches: state.breaches } }));
|
||||
window.addEventListener("beforeunload", () => log("session_unload", { active: state.active, stats: { rescued: state.rescued, lost: state.lost, breaches: state.breaches, broken: state.broken } }));
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
resize(); updateUI(); requestAnimationFrame(frame);
|
||||
})();
|
||||
97
experiments/005_sanctuary_wake/prototype/index.html
Normal file
97
experiments/005_sanctuary_wake/prototype/index.html
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sanctuary Wake — Experiment 005</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="title"><span>EXPERIMENT 005</span><h1>Sanctuary Wake</h1></div>
|
||||
<div class="shift-stage"><span id="stage-kicker">SHIFT STAGE</span><b id="stage-name">Quiet Wake</b></div>
|
||||
<div class="top-actions">
|
||||
<button id="end-shift" type="button">End shift</button>
|
||||
<button id="save" type="button">Save JSONL</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<aside>
|
||||
<section class="brief">
|
||||
<span>THREE-MINUTE RECOVERY SHIFT</span>
|
||||
<h2>Keep the sanctuary working.</h2>
|
||||
<p>Bring escape pods into the sanctuary. Keep raiders and fast wreckage away. A breach is costly, but the shift continues.</p>
|
||||
</section>
|
||||
|
||||
<section class="controls">
|
||||
<h3>Keeper controls</h3>
|
||||
<p><kbd>WASD</kbd><span>Move the keeper.</span></p>
|
||||
<p><kbd>LEFT HOLD</kbd><span>Pull every loose body in range.</span></p>
|
||||
<p><kbd>RIGHT HOLD</kbd><span>Push every loose body in range.</span></p>
|
||||
<div class="field-buttons">
|
||||
<button id="pull-button" type="button"><i>↘</i><span><b>PULL</b><small>toward keeper</small></span></button>
|
||||
<button id="push-button" type="button"><i>↗</i><span><b>PUSH</b><small>away from keeper</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="laws">
|
||||
<h3>Field consequences</h3>
|
||||
<div class="law pod"><i>●</i><p><b>ESCAPE POD</b><span>Sanctuary contact rescues it and repairs integrity.</span></p></div>
|
||||
<div class="law raider"><i>◆</i><p><b>RAIDER</b><span>Captures pods. Sanctuary impact damages integrity.</span></p></div>
|
||||
<div class="law debris"><i>⬟</i><p><b>WRECKAGE</b><span>A fast hit destroys a raider. It can also damage the sanctuary.</span></p></div>
|
||||
<div class="law neutral"><i>◎</i><p><b>ONE FIELD</b><span>Pull and push affect all three kinds. Mass changes acceleration.</span></p></div>
|
||||
</section>
|
||||
|
||||
<section class="metrics">
|
||||
<div><span>SHIFT LEFT</span><b id="time-left">3:00</b></div>
|
||||
<div><span>FIELD CHARGE</span><b id="charge">100%</b></div>
|
||||
<div><span>RESCUED</span><b id="rescued">0</b></div>
|
||||
<div><span>LOST</span><b id="lost">0</b></div>
|
||||
<div><span>BREACHES</span><b id="breaches">0</b></div>
|
||||
<div><span>RAIDERS BROKEN</span><b id="broken">0</b></div>
|
||||
</section>
|
||||
|
||||
<section class="integrity">
|
||||
<div><span>SANCTUARY INTEGRITY</span><b id="integrity-value">100%</b></div>
|
||||
<div class="bar"><i id="integrity-bar"></i></div>
|
||||
</section>
|
||||
|
||||
<section class="notes">
|
||||
<h3>Field report</h3>
|
||||
<div id="field-report">Awaiting shift start.</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="playfield">
|
||||
<canvas id="field" tabindex="0" aria-label="Sanctuary recovery field"></canvas>
|
||||
<div id="banner" class="banner" aria-live="polite"></div>
|
||||
<div class="canvas-help">Move with WASD · hold left mouse to pull · hold right mouse to push</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="start-overlay" class="overlay">
|
||||
<div>
|
||||
<span>THE FIELD DOES NOT CHOOSE FOR YOU</span>
|
||||
<b>Pull and push affect pods, raiders, and wreckage together.</b>
|
||||
<p>The shift lasts three minutes. Breaches vent the field instead of ending the run. Click to begin.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="summary-overlay" class="overlay hidden">
|
||||
<div>
|
||||
<span>SHIFT REPORT</span>
|
||||
<b id="summary-title">Shift complete</b>
|
||||
<div id="summary-stats" class="summary-stats"></div>
|
||||
<p id="summary-copy">The measured shift is over.</p>
|
||||
<div class="summary-actions">
|
||||
<button id="save-summary" type="button">Save JSONL</button>
|
||||
<button id="overtime" type="button">Continue without a timer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" role="status"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
116
experiments/005_sanctuary_wake/prototype/style.css
Normal file
116
experiments/005_sanctuary_wake/prototype/style.css
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #05090d;
|
||||
--panel: #0b141a;
|
||||
--panel2: #111d24;
|
||||
--line: #293b45;
|
||||
--text: #e8f1ef;
|
||||
--muted: #879b9f;
|
||||
--mint: #6ee4bd;
|
||||
--cyan: #7bdcf4;
|
||||
--red: #f07370;
|
||||
--amber: #efb45a;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; margin: 0; }
|
||||
body { overflow: hidden; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, sans-serif; user-select: none; }
|
||||
button { border: 1px solid var(--line); border-radius: 7px; background: #13212a; color: var(--text); font: inherit; cursor: pointer; touch-action: none; }
|
||||
button:hover { border-color: #56717a; background: #1a2b35; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin: 0; font-size: 20px; }
|
||||
h2 { margin: 5px 0 0; font-size: 18px; }
|
||||
h3 { margin: 0 0 9px; color: #a9bbb9; font-size: 9px; letter-spacing: .14em; text-transform: uppercase; }
|
||||
|
||||
header { height: 64px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 14px; padding: 8px 14px; border-bottom: 1px solid var(--line); background: #081015; }
|
||||
.title > span, .brief > span, .overlay span { display: block; color: var(--mint); font-size: 8px; font-weight: 850; letter-spacing: .18em; }
|
||||
.shift-stage { min-width: 170px; text-align: center; }
|
||||
.shift-stage span, .shift-stage b { display: block; }
|
||||
.shift-stage span { color: var(--muted); font-size: 7px; letter-spacing: .15em; }
|
||||
.shift-stage b { margin-top: 3px; color: var(--amber); font-size: 12px; }
|
||||
.top-actions { display: flex; justify-content: flex-end; gap: 7px; }
|
||||
.top-actions button { padding: 8px 11px; }
|
||||
|
||||
main { height: calc(100vh - 64px); min-height: 0; display: grid; grid-template-columns: clamp(285px, 22vw, 345px) minmax(0, 1fr); overflow: hidden; }
|
||||
aside { min-height: 0; overflow-y: auto; scrollbar-gutter: stable; padding: 12px 14px; border-right: 1px solid var(--line); background: #081015; }
|
||||
aside section { margin-bottom: 10px; }
|
||||
.brief { padding: 11px; border: 1px solid #355a4e; border-radius: 9px; background: linear-gradient(145deg, #10241e, #101920); }
|
||||
.brief p { margin: 8px 0 0; color: #b8c7c4; font-size: 10px; line-height: 1.45; }
|
||||
.controls { padding: 9px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); }
|
||||
.controls > p { display: grid; grid-template-columns: 76px 1fr; align-items: center; gap: 7px; margin: 6px 0; color: var(--muted); font-size: 8px; }
|
||||
kbd { padding: 3px 5px; border: 1px solid #53666c; border-bottom-width: 2px; border-radius: 4px; background: #17252d; color: var(--text); font: 8px ui-monospace, monospace; text-align: center; }
|
||||
.field-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 7px; }
|
||||
.field-buttons button { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 6px; }
|
||||
.field-buttons button.active { border-color: var(--cyan); background: #12313a; box-shadow: inset 0 0 18px #65d8f422; }
|
||||
.field-buttons i { color: var(--cyan); font-size: 17px; font-style: normal; }
|
||||
.field-buttons b, .field-buttons small { display: block; }
|
||||
.field-buttons b { font-size: 9px; }
|
||||
.field-buttons small { margin-top: 2px; color: var(--muted); font-size: 7px; }
|
||||
|
||||
.laws { display: grid; gap: 4px; }
|
||||
.laws h3 { margin-bottom: 2px; }
|
||||
.law { display: grid; grid-template-columns: 27px 1fr; align-items: center; gap: 7px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
|
||||
.law i { font-style: normal; font-size: 17px; text-align: center; }
|
||||
.law p { margin: 0; }
|
||||
.law b, .law span { display: block; }
|
||||
.law b { font-size: 8px; letter-spacing: .08em; }
|
||||
.law span { margin-top: 2px; color: var(--muted); font-size: 7px; line-height: 1.35; }
|
||||
.law.pod i { color: var(--cyan); }
|
||||
.law.raider i { color: var(--red); }
|
||||
.law.debris i { color: var(--amber); }
|
||||
.law.neutral i { color: var(--mint); }
|
||||
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
||||
.metrics div { min-width: 0; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
|
||||
.metrics span, .metrics b { display: block; }
|
||||
.metrics span { overflow: hidden; color: var(--muted); font-size: 6px; letter-spacing: .08em; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.metrics b { margin-top: 4px; color: #d8e4e1; font: 10px ui-monospace, monospace; }
|
||||
.integrity { padding: 8px 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel); }
|
||||
.integrity > div:first-child { display: flex; justify-content: space-between; align-items: center; }
|
||||
.integrity span { color: var(--muted); font-size: 7px; letter-spacing: .1em; }
|
||||
.integrity b { font: 10px ui-monospace, monospace; }
|
||||
.bar { height: 7px; margin-top: 8px; overflow: hidden; border-radius: 7px; background: #1b272d; }
|
||||
.bar i { display: block; width: 100%; height: 100%; background: linear-gradient(90deg, var(--red), var(--amber), var(--mint)); transform-origin: left; }
|
||||
.notes { min-height: 55px; padding: 7px 9px; border-left: 2px solid #415a61; color: #a6b8b6; font-size: 8px; line-height: 1.4; }
|
||||
.notes h3 { margin-bottom: 6px; }
|
||||
|
||||
.playfield { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: radial-gradient(circle at 50% 48%, #11212a, #05090c 73%); }
|
||||
#field { display: block; width: 100%; height: 100%; outline: none; touch-action: none; }
|
||||
.canvas-help { position: absolute; z-index: 3; left: 50%; bottom: 13px; translate: -50%; padding: 7px 11px; border-radius: 6px; background: #071015dc; color: #809399; font-size: 9px; pointer-events: none; }
|
||||
.banner { position: absolute; z-index: 5; left: 50%; top: 20px; translate: -50% -8px; min-width: 250px; padding: 10px 16px; border: 1px solid #4a7569; border-radius: 8px; background: #10231edc; color: var(--mint); opacity: 0; text-align: center; pointer-events: none; transition: .2s; font-size: 11px; font-weight: 750; }
|
||||
.banner.show { opacity: 1; translate: -50% 0; }
|
||||
|
||||
.overlay { position: fixed; z-index: 20; inset: 64px 0 0 clamp(285px, 22vw, 345px); display: grid; place-items: center; background: #030709d9; }
|
||||
.overlay.hidden { display: none; }
|
||||
.overlay > div { width: min(590px, 87%); padding: 23px 27px; border: 1px solid #426e62; border-radius: 10px; background: #101b20f3; text-align: center; }
|
||||
.overlay b, .overlay p { display: block; }
|
||||
.overlay > div > b { margin-top: 7px; font-size: 15px; }
|
||||
.overlay p { margin: 9px 0 0; color: var(--muted); font-size: 10px; line-height: 1.45; }
|
||||
.summary-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 15px; }
|
||||
.summary-stats div { padding: 9px; border: 1px solid var(--line); border-radius: 6px; background: #0a1318; }
|
||||
.summary-stats span, .summary-stats b { display: block; }
|
||||
.summary-stats span { color: var(--muted); font-size: 7px; }
|
||||
.summary-stats b { margin-top: 4px; color: var(--mint); font: 13px ui-monospace, monospace; }
|
||||
.summary-actions { display: flex; justify-content: center; gap: 8px; margin-top: 15px; }
|
||||
.summary-actions button { padding: 9px 13px; }
|
||||
#toast { position: fixed; z-index: 30; left: calc(50% + 150px); bottom: 20px; translate: -50% 12px; max-width: 500px; padding: 10px 14px; border: 1px solid #4c7368; border-radius: 8px; background: #10231e; box-shadow: 0 10px 30px #000b; opacity: 0; pointer-events: none; transition: .2s; font-size: 10px; }
|
||||
#toast.show { opacity: 1; translate: -50% 0; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
header { grid-template-columns: auto 1fr auto; padding-inline: 8px; }
|
||||
.title h1 { font-size: 15px; }
|
||||
.title > span { font-size: 6px; }
|
||||
.top-actions button { padding: 7px; font-size: 9px; }
|
||||
main { grid-template-columns: 270px minmax(0, 1fr); }
|
||||
.overlay { left: 270px; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body { overflow: auto; }
|
||||
header { height: auto; grid-template-columns: 1fr; }
|
||||
.shift-stage { text-align: left; }
|
||||
.top-actions { justify-content: flex-start; }
|
||||
main { height: auto; grid-template-columns: 1fr; overflow: visible; }
|
||||
.playfield { height: min(80vh, 650px); }
|
||||
.overlay { display: none; }
|
||||
#toast { left: 50%; }
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# Experiment 005 Preliminary Analysis — Session 69cdaf46
|
||||
|
||||
Status: telemetry and player report analyzed.
|
||||
|
||||
Source: `JSONL/sanctuary-wake-69cdaf46-11e4-498f-abe0-ee791a81fad4.jsonl`
|
||||
|
||||
## Session Summary
|
||||
|
||||
- 1,019 saved events over 245 seconds of wall time.
|
||||
- The measured shift ran to its automatic end at 180.0 simulation seconds.
|
||||
- The player saved about three seconds after the summary appeared and did not enter overtime.
|
||||
- Final state: 6 pods rescued, 18 lost, 17 raiders broken, no breaches, 41% sanctuary integrity.
|
||||
- 289 movement key-down events and 289 matching releases indicate continuous active piloting rather than waiting out the timer.
|
||||
- 89 field sessions: 60 pull and 29 push, totaling 59.4 active seconds.
|
||||
- 53/89 field sessions ended through charge depletion rather than release.
|
||||
- 43 keeper impacts occurred.
|
||||
|
||||
## Time Course
|
||||
|
||||
- First keeper/wreckage impact: 3.0 s.
|
||||
- First rescue: 28.8 s.
|
||||
- First pod loss: 32.8 s.
|
||||
- First raider destruction: two simultaneous breaks at 47.8 s.
|
||||
- Sanctuary remained at 100% until 150 s, then took six impacts during the final 30 seconds and ended at 41%.
|
||||
- No emergency breach occurred, so the intended recoverable-failure event was never directly tested.
|
||||
|
||||
The player used pull and push in every 30-second interval, but pull increasingly dominated. Field time stayed near 9–11 seconds per interval even as session count increased; depletion endings rose from none in the first 30 seconds to 13/16 in the final interval. A plausible interpretation is that later use became repeated short attempts constrained by an often-empty charge meter rather than deliberation about when to stop. The player report must distinguish intentional rationing, confusing responsiveness, and simple input frustration.
|
||||
|
||||
Seventeen wreckage/raider kills prove the advertised collision interaction occurred. Five happened while a field was actively logged; the remainder happened under residual momentum or without immediate field input. Telemetry cannot determine which, if any, were deliberately set up by the player.
|
||||
|
||||
## Competing Explanations
|
||||
|
||||
1. The core activity—radially shepherding multiple autonomous bodies—was intrinsically unpleasant or illegible.
|
||||
2. Pull/push created outcomes, but clutter and shared influence prevented intentional multi-object decisions.
|
||||
3. The charge meter interrupted actions without creating a worthwhile resource decision.
|
||||
4. Rescue stakes were too abstract to create care; losses became noise rather than emotionally meaningful failures.
|
||||
5. The player was actively solving conventional pressure but continued only because a three-minute timer defined completion.
|
||||
6. Interesting recoveries never emerged: pods disappeared on contact and sanctuary damage arrived late, leaving little reversible intermediate failure.
|
||||
7. Some wreckage kills were intentional and locally satisfying, but the surrounding activity overwhelmed that positive moment.
|
||||
|
||||
## Player Report
|
||||
|
||||
The player wanted to stop almost immediately. They understood the instructions, but the activity did not seem interesting. Later wreckage accumulation made the situation feel impossible.
|
||||
|
||||
A few wreckage kills were deliberate. Once the field filled, however, nearly any intervention risked pushing pods into raiders or wreckage into the sanctuary. Attacking a raider felt inseparable from harming the player's own objective. Charge was an annoyance that made an already impossible situation harder, not a meaningful resource decision.
|
||||
|
||||
Controls were fine and the chaos was readable. The player did not care what happened. The decisive complaint was not perceptual overload but **unmanageable state**: consequences were visible, yet the available actions no longer offered credible improvement.
|
||||
|
||||
## Final Interpretation
|
||||
|
||||
Experiment 005 did not create recoverable chaos. It created accumulating contamination. Pods were removed instantly when caught, wreckage persisted, and the indiscriminate radial field increasingly coupled every local intervention to collateral damage. A recoverable failure should generate a new tractable problem; here failures reduced the future action space.
|
||||
|
||||
This distinction matters. Reducing spawn rates or increasing charge would make the activity easier, but would not address the player's immediate lack of interest or indifference to rescue outcomes. Controls, feedback, and collision readability are specifically exonerated as primary causes.
|
||||
|
||||
The experiment weakens the idea that pressure, systemic coupling, or a nominal protect/rescue objective can supply motivation. Coupling is useful only while the player can isolate leverage within it. “Everything affects everything” becomes paralysis rather than emergence when tools cannot select, sequence, or bound consequences.
|
||||
|
||||
The next probe should not be another custodial field-management game. A higher-information contrast is **assertive, selective agency**: direct movement and targeted verbs in a compact combat or traversal activity where the player creates outcomes rather than babysitting an accumulating state. Keep failure local and resettable. Do not add upgrades until the base actions demonstrate value.
|
||||
3
experiments/005_sanctuary_wake/results/README.md
Normal file
3
experiments/005_sanctuary_wake/results/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Experiment 005 Results
|
||||
|
||||
No playtest has been analyzed yet. JSONL telemetry saves to the repository-level `JSONL/` directory.
|
||||
10
experiments/005_sanctuary_wake/run.sh
Executable file
10
experiments/005_sanctuary_wake/run.sh
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
EXPERIMENT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$EXPERIMENT_DIR/../.." && pwd)"
|
||||
|
||||
exec python3 "$REPO_ROOT/tools/playtest_server.py" \
|
||||
--directory "$EXPERIMENT_DIR/prototype" \
|
||||
--log-directory "$REPO_ROOT/JSONL" \
|
||||
--port 8000
|
||||
Loading…
Add table
Add a link
Reference in a new issue