diff --git a/game/.gitignore b/game/.gitignore
new file mode 100644
index 0000000..504afef
--- /dev/null
+++ b/game/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+package-lock.json
diff --git a/game/README.md b/game/README.md
new file mode 100644
index 0000000..2c577a3
--- /dev/null
+++ b/game/README.md
@@ -0,0 +1,111 @@
+# STANDARD OPERATING PSYCHOPATHY
+
+A post-apocalyptic bunker-management sim in the spirit of **Sheltered × Oxygen
+Not Included × BoomTown**, skinned as an institutional surveillance terminal in the
+aesthetic of the **Neue Deutsche Welle** — monochrome CRT amber, hard sans-serif
+propaganda headers, and cold motorik audio.
+
+You do not control the inmates directly. You manage the **grid** — power, air,
+food, and excavation — while your subjects act on their own drives.
+
+**v0.2 adds the surface.** Build a **Luftschleuse** (airlock) into the bunker
+ceiling to send subjects on **expeditions** for Öl, Konserven and Schrott, and
+to receive **Neuzugänge** (new subjects) — a powered **Funkturm** (radio tower)
+doubles their arrival rate. Progress is persisted with **save / load**
+(localStorage) plus a file export / import fallback.
+
+---
+
+## The fantasy
+
+> Die Oberfläche ist erledigt. Sie überwachen Sektor 4.
+
+The UI is diegetic: a rigid engineering monitor inside the bunker. Everything
+is uppercase German institutional copy. The bottom strip is a looping
+`DURCHSAGE` (announcement) ticker of NDW-flavored slogans.
+
+## Systems
+
+| System | What it does |
+| --- | --- |
+| **Conative Utility AI** | Each subject scores every possible action against its *Affective* state (Stress / Hunger / Erschöpfung / Argwohn / O₂-Mangel) and commits to the highest-scoring directive. No state machines. |
+| **Blackboard** | Shared memory where dig orders, hazards, and electrical nodes are posted. Subjects read it to pick work. |
+| **Power graph** | Devices are wired into components (Crosslink). Generation vs. draw decides whether a component is powered. Batteries buffer. |
+| **Gas / O₂ diffusion** | A Laplacian spread over the grid. Digging can rupture gas pockets. Scrubbers clean. |
+| **Ecology** | Farms grow rations from power. The generator burns **Öl** (dug up). Excavation yields caches of Beton / Metall / Rationen / Öl. |
+| **Disasters** | Erdbeben break devices and spike stress. High-stress Pyromanes sabotage wiring. |
+| **Surface** | The airlock is the only way out. An expedition sends one subject topside for a timer; they return with weighted loot and a chance of injury. New subjects arrive at the gate on their own schedule. |
+| **Persistence** | Autosave every ~5 min, manual save/load, and JSON export/import of the whole Akte. |
+
+## Traits
+
+- **Pyromane** — triggers a psychotic break (cuts a wire) at very high stress.
+- **Paranoid** — panics in darkness, needs light.
+- **Befehlsfixiert** — digs harder, ignores warnings.
+- **Schlafwandler** — sleeps anywhere.
+
+## Controls
+
+| Key / input | Action |
+| --- | --- |
+| `1–8`, `9`, `0` | Select a module to build |
+| `Q` | Luftschleuse (airlock) — place on the ceiling row |
+| `F` | Funkturm (radio tower) — doubles arrival rate |
+| `W` | Build wall |
+| `X` | Dig order (click dirt to mark) |
+| `V` | Toggle Physical ↔ Crosslink |
+| `E` | Toggle selected device on/off |
+| `F5` | Save |
+| `M` | Toggle audio |
+| `Space` | Pause |
+| `Esc` | Cancel placement |
+| Click module in **Crosslink** twice | Wire / unwire two nodes (≤ 8 tiles) |
+| Click the airlock | Open the expedition panel: pick a subject, send them topside |
+
+## Device table
+
+| Module | Watts | Role |
+| --- | --- | --- |
+| Tretbar-Dynamo | −60 | Hand-cranked power (a subject must work it) |
+| Lampe | +12 | Light radius 4, suppresses dark-stress |
+| Pritsche | 0 | Bed |
+| Rationen-Ausgabe | +5 | Serves meals (needs power + rations) |
+| Luft-Wäscher | +25 | Scrubs gas, emits O₂ |
+| Notstromaggregat | −100 | Burns 1 Öl / 35 s |
+| Schott | +10 | Door; blocks path while unpowered |
+| Bewegungssensor | +5 | Can be coupled to a Schott |
+| Akku-Block | 0 | Stores 2000 units of charge |
+| Pilzfarm | +15 | Grows 1 ration / 30 s of power |
+| Luftschleuse | 0 | Surface gate. One per sector, only on the ceiling row. Expedition + arrival point. |
+| Funkturm | +30 | Broadcasts on all bands; halves the interval between new subjects. |
+
+## Layout
+
+```
+game/
+ index.html shell + HUD
+ style.css brutalist CRT theme
+ config.js all tuning constants & data
+ world.js grid, devices, power graph, gas diffusion, Blackboard
+ ai.js Conative Utility AI (drives, pathing, execution)
+ render.js Physical + Crosslink canvas layers
+ ui.js inspector, build menu, log, overlays
+ main.js loop, input, events, day cycle
+ audio.js cold-wave sequencer (WebAudio)
+ tools/smoke.js headless simulation harness
+ tools/dom_test.js jsdom boot/integration test
+```
+
+## Tuning
+
+Every balance number lives in `config.js`. The sim has been validated with
+`tools/smoke.js` (headless) across long runs with earthquakes; the colony is
+survivable with sensible play.
+
+## NDW styling notes
+
+- Palette: phosphor amber `#ffb000` on abyssal black `#050505`; alert red
+ `#ff0033`; Crosslink cyan `#00ccff`.
+- Typography: monospace body, `Arial Black` headers with wide letter-spacing.
+- CRT scanlines + phosphor bloom overlay; hard edges, no border-radius.
+- Audio: 4-on-the-floor kick, off-beat hats, sawtooth bassline ~126 BPM.
diff --git a/game/ai.js b/game/ai.js
new file mode 100644
index 0000000..6063a75
--- /dev/null
+++ b/game/ai.js
@@ -0,0 +1,483 @@
+/* ===========================================================================
+ CONATIVE UTILITY AI — inmates score every possible action against their
+ Affective state (needs/emotions) and pick the highest-scoring directive.
+ Movement is grid path-following; interaction happens on arrival.
+ =========================================================================== */
+window.SOP = window.SOP || {};
+
+SOP.InmateAI = class InmateAI {
+ constructor(name, trait, x, y) {
+ this.name = name;
+ this.trait = trait;
+ // desynchronized metabolisms so the crew never all drop the cranks at once
+ this.hungerRate = 0.20 + Math.random() * 0.05;
+ this.affective = {
+ stress: 15 + Math.random() * 15,
+ paranoia: 5 + Math.random() * 15,
+ hunger: 30 + Math.random() * 35,
+ exhaustion: 5 + Math.random() * 20,
+ o2: 0,
+ };
+ this.hp = 100;
+ this.tx = x; this.ty = y; // tile position
+ this.px = x; this.py = y; // pixel-lerp position (float tiles)
+ this.path = [];
+ this.currentAction = { name: 'IDLE', score: 0 };
+ this.goal = null; // {kind:'device'|'tile', ...}
+ this.progress = 0;
+ this.dead = false;
+ this.deadCause = '';
+ this.sleeping = false;
+ this.nextEval = 0;
+ this.sayCooldown = 0;
+ this.thought = '—';
+ this.walkPhase = Math.random() * 6.28;
+ this.id = name;
+ }
+
+ /* ---------------- UTILITY EVALUATOR ---------------- */
+ evaluateDrives(world) {
+ if (this.dead) return;
+ const A = this.affective;
+ const cand = [];
+ const push = (name, score, goal) => { if (score > 0) cand.push({ name, score, goal }); };
+
+ // broken infrastructure survey (drives both repair and crank decisions)
+ const brokenAll = world.devices.filter(d => d.broken && d.kind !== 'gas_vent');
+ const gridBroken = brokenAll.some(d => this.onDispenserGrid(world, d));
+
+ // --- EAT ---
+ let eatScore = A.hunger > 70 ? A.hunger * 1.5 : A.hunger;
+ const dispenser = this.nearestDevice(world, d => d.kind === 'dispenser' && !d.broken && d.powered);
+ const dispenserAny = dispenser || this.nearestDevice(world, d => d.kind === 'dispenser' && !d.broken);
+ if (world.res.rationen <= 0 || !dispenser) eatScore = 0;
+ push('ESSEN FASSEN', eatScore, dispenser ? { kind: 'device', dev: dispenser, act: 'eat' } : null);
+
+ // --- SLEEP ---
+ let sleepScore = A.exhaustion > 75 ? A.exhaustion * 1.3 : A.exhaustion * 0.7;
+ const bed = this.nearestDevice(world, d => d.kind === 'bed' && !d.broken &&
+ !world.inmates.some(i => i !== this && !i.dead && i.goal && i.goal.dev === d && i.sleeping));
+ if (bed) push('SCHLAFEN', sleepScore, { kind: 'device', dev: bed, act: 'sleep' });
+ else if (A.exhaustion > 60) push('SCHLAFEN (BODEN)', sleepScore * 0.8, { kind: 'tile', x: this.tx, y: this.ty, act: 'sleep' });
+
+ // --- CRANK DYNAMO (sticky: hold the cranks until exhausted) ---
+ const p = world.power;
+ if (this.goal && this.goal.act === 'crank' && this.goal.dev &&
+ !this.goal.dev.broken && this.goal.dev.operatedBy === this.id &&
+ A.exhaustion < 85) {
+ push('KURBELN', 95, this.goal);
+ } else if (A.exhaustion < 80 || A.hunger > 85) {
+ // hunger > 85: desperate cranking — starvation overrides exhaustion
+ const dyn = this.nearestDevice(world, d => d.kind === 'dynamo' && !d.broken && d.operatedBy == null && d.compDeficit > 0);
+ if (dyn) {
+ let crankScore = 62 + Math.min(30, dyn.compDeficit * 0.4) - A.exhaustion * 0.4;
+ // a hungry inmate powers the dispenser before trying to eat —
+ // but if the food grid is broken, repairing beats cranking
+ if (dispenserAny && dispenserAny.compDeficit > 0 && !gridBroken) {
+ if (A.hunger > 85) crankScore += 120; // starving override — beats sleep
+ else if (A.hunger > 55) crankScore += 40;
+ }
+ push('KURBELN', crankScore, { kind: 'device', dev: dyn, act: 'crank' });
+ }
+ }
+
+ // --- DIG (Standard Operating Procedure) ---
+ let workScore = 72 - A.stress * 0.35 - A.exhaustion * 0.6;
+ if (this.trait === 'Befehlsfixiert') workScore += 25;
+ // Blackboard work directive: low stocks order everyone underground
+ if (world.res.oel < 100 || world.res.rationen < 60 || world.res.metall < 10) workScore += 35;
+ const mark = this.nearestDigMark(world);
+ if (mark) {
+ // dig momentum: a tile already being excavated gets finished
+ if (this.goal && this.goal.act === 'dig' && this.progress > 0) workScore += 40;
+ push('GRABEN', workScore, { kind: 'dig', x: mark.x, y: mark.y, act: 'dig' });
+ }
+
+ // --- REPAIR ---
+ // prioritize broken nodes on the food/power grid over cosmetic fixes
+ let broken = null;
+ if (brokenAll.length) {
+ const crit = brokenAll.filter(d => this.onDispenserGrid(world, d));
+ const pool = crit.length ? crit : brokenAll;
+ broken = pool.reduce((a, b) =>
+ (Math.abs(a.x - this.tx) + Math.abs(a.y - this.ty)) <= (Math.abs(b.x - this.tx) + Math.abs(b.y - this.ty)) ? a : b);
+ }
+ if (broken) {
+ let repairScore = 60 - A.exhaustion * 0.3;
+ if (this.onDispenserGrid(world, broken)) repairScore = 210; // must beat desperate cranking
+ push('REPARIEREN', repairScore, { kind: 'device', dev: broken, act: 'repair' });
+ }
+
+ // --- FLEE DARKNESS (Paranoia) — capped so hunger can still win ---
+ const here = world.tiles[this.ty][this.tx];
+ if (!here.lit && here.t === 'empty' && !this.sleeping) {
+ let flee = A.paranoia * 1.2;
+ if (this.trait === 'Paranoid') flee *= 1.6;
+ if (this.trait === 'Befehlsfixiert') flee *= 0.3;
+ flee = Math.min(flee, 115);
+ const lit = world.nearestLit(this.tx, this.ty);
+ if (lit) push('LICHT SUCHEN', flee, { kind: 'tile', x: lit.x, y: lit.y, act: 'flee' });
+ }
+
+ // --- AVOID GAS ---
+ if (here.gas > 12) {
+ const clear = this.nearestClearAir(world);
+ if (clear) push('GAS MEIDEN', 92 + here.gas * 0.1, { kind: 'tile', x: clear.x, y: clear.y, act: 'flee' });
+ }
+
+ // --- SABOTAGE / PSYCHOTIC BREAK (trait-specific conative override) ---
+ if (A.stress >= 85 && this.trait === 'Pyromane') {
+ push(`PSYCHOTISCHER SCHUB (${this.trait})`, 100, this.pickSabotageTarget(world));
+ }
+ if (A.stress >= 92 && this.trait === 'Paranoid') {
+ const lit = world.nearestLit(this.tx, this.ty);
+ if (lit) push('IN ECKE VERKRIECHEN', 96, { kind: 'tile', x: lit.x, y: lit.y, act: 'hide' });
+ }
+
+ // --- WANDER baseline ---
+ push('UMHERIRREN', 25, this.randomWanderGoal(world));
+
+ let best = cand[0] || { name: 'IDLE', score: 0, goal: null };
+ for (const c of cand) if (c.score > best.score) best = c;
+
+ // commit
+ if (best.name !== this.currentAction.name || !this.goal) {
+ this.currentAction = { name: best.name, score: best.score };
+ this.goal = best.goal;
+ this.sleeping = false;
+ this.progress = 0;
+ this.path = [];
+ this.waitTicks = 0;
+ this.thought = this.thoughtFor(best.name);
+ }
+ this.logConativeState(best);
+ }
+
+ /* Is this device on the same wired component as any dispenser? */
+ onDispenserGrid(world, dev) {
+ const starts = world.devices.filter(d => d.kind === 'dispenser');
+ if (!starts.length) return false;
+ const seen = new Set(starts.map(d => d.id));
+ const q = [...starts];
+ while (q.length) {
+ const d = q.pop();
+ for (const id of d.links) {
+ if (seen.has(id)) continue;
+ seen.add(id);
+ const o = world.devices.find(x => x.id === id);
+ if (o) q.push(o);
+ }
+ }
+ return seen.has(dev.id);
+ }
+
+ pickSabotageTarget(world) {
+ const linked = world.devices.filter(d => d.links.length > 0 && d.kind !== 'gas_vent');
+ if (!linked.length) return null;
+ const d = linked[Math.floor(Math.random() * linked.length)];
+ return { kind: 'device', dev: d, act: 'sabotage' };
+ }
+
+ randomWanderGoal(world) {
+ for (let tries = 0; tries < 20; tries++) {
+ const x = Math.floor(Math.random() * world.W);
+ const y = Math.floor(Math.random() * world.H);
+ if (world.walkable(x, y)) return { kind: 'tile', x, y, act: 'wander' };
+ }
+ return null;
+ }
+
+ nearestDevice(world, pred) {
+ let best = null, bd = Infinity;
+ for (const d of world.devices) {
+ if (!pred(d)) continue;
+ const dist = Math.abs(d.x - this.tx) + Math.abs(d.y - this.ty);
+ if (dist < bd) { bd = dist; best = d; }
+ }
+ return best;
+ }
+
+ nearestDigMark(world) {
+ const taken = new Set();
+ for (const o of world.inmates) {
+ if (o !== this && !o.dead && o.goal && o.goal.act === 'dig') taken.add(o.goal.x + ',' + o.goal.y);
+ }
+ let best = null, bd = Infinity, bestTaken = null, bdt = Infinity;
+ for (const m of world.Blackboard.digMarks) {
+ if (world.tiles[m.y][m.x].t !== 'dirt') continue;
+ // must be reachable: at least one adjacent walkable tile
+ const reach = [[1,0],[-1,0],[0,1],[0,-1]].some(([dx, dy]) => world.walkable(m.x + dx, m.y + dy));
+ if (!reach) continue;
+ const dist = Math.abs(m.x - this.tx) + Math.abs(m.y - this.ty);
+ if (taken.has(m.x + ',' + m.y)) {
+ if (dist < bdt) { bdt = dist; bestTaken = m; }
+ } else if (dist < bd) { bd = dist; best = m; }
+ }
+ return best || bestTaken;
+ }
+
+ nearestClearAir(world) {
+ let best = null, bd = Infinity;
+ for (let y = 0; y < world.H; y++) for (let x = 0; x < world.W; x++) {
+ const tl = world.tiles[y][x];
+ if (tl.t !== 'empty' || tl.gas > 10 || tl.o2 < 30) continue;
+ const d = Math.abs(x - this.tx) + Math.abs(y - this.ty);
+ if (d < bd) { bd = d; best = { x, y }; }
+ }
+ return best;
+ }
+
+ thoughtFor(action) {
+ const T = {
+ 'ESSEN FASSEN': 'Ausgabe. Jetzt.',
+ 'SCHLAFEN': 'Die Pritsche ruft.',
+ 'SCHLAFEN (BODEN)': 'Boden genügt. Boden ist sicher.',
+ 'KURBELN': 'Strom durch Muskel. SOP.',
+ 'GRABEN': 'Die Erde gibt nicht freiwillig.',
+ 'REPARIEREN': 'Es knackt. Es muss ganz.',
+ 'LICHT SUCHEN': 'Nicht im Dunkeln. Nie wieder dunkel.',
+ 'GAS MEIDEN': 'Es schmeckt nach Kupfer.',
+ 'IN ECKE VERKRIECHEN': 'Wände beobachten.',
+ 'UMHERIRREN': 'Schritte zählen hilft.',
+ 'EXPEDITION': 'Raus. Nur kurz. Nur mit Maske.',
+ 'IDLE': '—',
+ };
+ return T[action] || '—';
+ }
+
+ releaseCrank(world) {
+ for (const d of world.devices) if (d.operatedBy === this.id) d.operatedBy = null;
+ }
+
+ /* ---------------- PER-TICK EXECUTION ---------------- */
+ tick(world, onEvent) {
+ if (this.dead) return;
+ // dynamo ownership sanity: a cranker who switched goals frees the cranks
+ if (!this.goal || this.goal.act !== 'crank') this.releaseCrank(world);
+ const A = this.affective;
+ const tl = world.tiles[this.ty][this.tx];
+
+ // ---- affective drift ----
+ A.hunger = Math.min(100, A.hunger + this.hungerRate);
+ if (!this.sleeping) A.exhaustion = Math.min(100, A.exhaustion + 0.18);
+ if (!tl.lit && tl.t === 'empty' && !this.sleeping) {
+ A.stress = Math.min(100, A.stress + 0.15);
+ A.paranoia = Math.min(100, A.paranoia + (this.trait === 'Paranoid' ? 0.5 : 0.22));
+ } else {
+ A.paranoia = Math.max(0, A.paranoia - 0.35);
+ A.stress = Math.max(0, A.stress - 0.15);
+ }
+ // breathing
+ if (tl.o2 < 14 || tl.gas > 12) {
+ A.o2 = Math.min(100, A.o2 + (tl.gas > 12 ? 2.4 : 1.1));
+ } else {
+ A.o2 = Math.max(0, A.o2 - 1.4);
+ }
+ if (A.o2 >= 100) { this.hp -= 2.5; if (this.hp <= 0) return this.die('ERSTICKT'); }
+ if (A.hunger >= 100) { this.hp -= 0.8; if (this.hp <= 0) return this.die('AUSGEZEHRT'); }
+
+ // ---- sleep is its own loop (recovery happens HERE, not in execute) ----
+ if (this.sleeping) {
+ const bed = this.goal && this.goal.dev && this.goal.dev.kind === 'bed' ? this.goal.dev : null;
+ A.exhaustion = Math.max(0, A.exhaustion - (bed ? 1.5 : 0.9));
+ A.stress = Math.max(0, A.stress - 0.3);
+ if (A.exhaustion <= 3 || A.o2 > 60 || tl.gas > 12 || A.hunger >= 85) {
+ this.sleeping = false;
+ this.goal = null;
+ }
+ if (this.sayCooldown > 0) this.sayCooldown--;
+ return;
+ }
+
+ // ---- movement along path (one tile every other tick) ----
+ this.moveGate = !this.moveGate;
+ if (this.moveGate && this.path.length && !this.executingHere) {
+ const [nx, ny] = this.path[0];
+ if (world.walkable(nx, ny) || (this.path.length === 1 && this.isGoalTile(nx, ny))) {
+ this.tx = nx; this.ty = ny;
+ this.path.shift();
+ this.walkPhase += 1;
+ } else {
+ this.repath(world);
+ }
+ }
+
+ // ---- goal arrival / execution ----
+ if (this.goal) this.execute(world, onEvent);
+
+ this.executingHere = false;
+ if (this.sayCooldown > 0) this.sayCooldown--;
+ }
+
+ isGoalTile(x, y) {
+ const g = this.goal;
+ if (!g) return false;
+ // fleeing to light/gas-free air only counts ON the target tile
+ if ((g.act === 'flee' || g.act === 'hide') && g.kind === 'tile') return g.x === x && g.y === y;
+ if (g.kind === 'tile' || g.kind === 'dig') return (Math.abs(g.x - x) + Math.abs(g.y - y)) <= 1;
+ if (g.kind === 'device') return (Math.abs(g.dev.x - x) + Math.abs(g.dev.y - y)) <= 1;
+ return false;
+ }
+
+ repath(world) {
+ const g = this.goal;
+ if (!g) { this.path = []; return; }
+ let tx, ty;
+ if (g.kind === 'tile') { tx = g.x; ty = g.y; }
+ else if (g.kind === 'dig') {
+ const spot = this.adjacentStand(world, g.x, g.y);
+ if (!spot) { this.goal = null; return; }
+ tx = spot.x; ty = spot.y;
+ } else {
+ // stand adjacent to the device, not on top of it
+ const spot = this.adjacentStand(world, g.dev.x, g.dev.y);
+ if (!spot) { this.goal = null; return; }
+ tx = spot.x; ty = spot.y;
+ }
+ const p = world.findPath(this.tx, this.ty, tx, ty);
+ this.path = p || [];
+ if (!p) this.goal = null; // unreachable — re-evaluate next round
+ }
+
+ adjacentStand(world, x, y) {
+ for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
+ if (world.walkable(x + dx, y + dy)) return { x: x + dx, y: y + dy };
+ }
+ return null;
+ }
+
+ execute(world, onEvent) {
+ const g = this.goal;
+ if (!g) return;
+
+ // en route?
+ if (g.kind === 'tile' && !this.isGoalTile(this.tx, this.ty)) {
+ if (!this.path.length) this.repath(world);
+ return;
+ }
+ if (g.kind === 'device' && !this.isGoalTile(this.tx, this.ty)) {
+ if (!this.path.length) this.repath(world);
+ return;
+ }
+ if (g.kind === 'dig' && !this.isGoalTile(this.tx, this.ty)) {
+ if (!this.path.length) this.repath(world);
+ return;
+ }
+
+ this.executingHere = true;
+ const A = this.affective;
+
+ switch (g.act) {
+ case 'eat': {
+ if (world.res.rationen <= 0 || g.dev.broken) { this.goal = null; return; }
+ if (!g.dev.powered) {
+ // stand at the Ausgabe and wait for the grid (someone is cranking…)
+ this.waitTicks = (this.waitTicks || 0) + 1;
+ if (this.waitTicks > 40) { this.waitTicks = 0; this.goal = null; }
+ return;
+ }
+ this.waitTicks = 0;
+ world.res.rationen--;
+ A.hunger = Math.max(0, A.hunger - 45);
+ A.stress = Math.max(0, A.stress - 4);
+ onEvent({ type: 'meal', inmate: this });
+ this.goal = null;
+ break;
+ }
+ case 'sleep': {
+ // the sleep loop itself lives in tick(); getting here just commits
+ this.sleeping = true;
+ break;
+ }
+ case 'crank': {
+ if (!g.dev || g.dev.broken) { this.goal = null; return; }
+ if (g.dev.operatedBy != null && g.dev.operatedBy !== this.id) { this.goal = null; return; }
+ g.dev.operatedBy = this.id;
+ A.exhaustion = Math.min(100, A.exhaustion + 0.35);
+ A.hunger = Math.min(100, A.hunger + 0.10);
+ A.stress = Math.min(100, A.stress + 0.05);
+ if (A.exhaustion > 85) { g.dev.operatedBy = null; this.goal = null; }
+ break;
+ }
+ case 'dig': {
+ if (world.tiles[g.y][g.x].t !== 'dirt') { world.Blackboard.removeDigMark(g.x, g.y); this.goal = null; return; }
+ this.progress++;
+ A.stress = Math.min(100, A.stress + 0.10);
+ A.exhaustion = Math.min(100, A.exhaustion + 0.15);
+ if (this.progress >= SOP.DIG_TICKS) {
+ const ev = world.completeDig(g.x, g.y, this);
+ if (ev && ev.type === 'gas') onEvent({ type: 'gasburst', x: g.x, y: g.y });
+ if (ev && ev.type === 'cache') onEvent({ type: 'cache', inmate: this, label: ev.label, n: ev.n, res: ev.res });
+ this.progress = 0;
+ this.goal = null;
+ }
+ break;
+ }
+ case 'repair': {
+ if (!g.dev || !g.dev.broken) { this.goal = null; return; }
+ this.progress++;
+ if (this.progress >= 6) { g.dev.broken = false; this.progress = 0; onEvent({ type: 'repair', dev: g.dev, inmate: this }); this.goal = null; }
+ break;
+ }
+ case 'sabotage': {
+ if (!g.dev || g.dev.links.length === 0) { this.goal = null; A.stress = Math.max(0, A.stress - 25); return; }
+ const other = world.devices.find(d => d.id === g.dev.links[0]);
+ if (other) {
+ world.removeLink(g.dev, other);
+ onEvent({ type: 'sabotage', inmate: this, dev: g.dev });
+ }
+ A.stress = Math.max(0, A.stress - 30);
+ this.goal = null;
+ break;
+ }
+ case 'flee':
+ case 'hide':
+ case 'wander': {
+ this.goal = null; // arrived; next evaluation picks something new
+ break;
+ }
+ case 'expedition': {
+ // reached the airlock — hand control back to the Schichtleitung
+ onEvent({ type: 'board', inmate: this, dev: g.dev });
+ this.goal = null;
+ break;
+ }
+ }
+ }
+
+ die(cause) {
+ this.dead = true;
+ this.deadCause = cause;
+ this.sleeping = false;
+ // release any device I operate
+ for (const d of SOP.World.devices) if (d.operatedBy === this.id) d.operatedBy = null;
+ }
+
+ /* ---------------- SAVE / LOAD ---------------- */
+ serialize() {
+ return {
+ id: this.id, name: this.name, trait: this.trait,
+ tx: this.tx, ty: this.ty, hp: Math.round(this.hp),
+ hungerRate: this.hungerRate,
+ affective: Object.assign({}, this.affective),
+ dead: this.dead, deadCause: this.deadCause,
+ away: this.away ? Object.assign({}, this.away) : null,
+ };
+ }
+
+ static fromData(d) {
+ const i = new SOP.InmateAI(d.name, d.trait, d.tx, d.ty);
+ i.id = d.id || d.name;
+ Object.assign(i.affective, d.affective);
+ i.hp = d.hp;
+ i.hungerRate = d.hungerRate || i.hungerRate;
+ i.dead = !!d.dead;
+ i.deadCause = d.deadCause || '';
+ i.away = d.away || null;
+ return i;
+ }
+
+ logConativeState(action) {
+ this.lastScored = action;
+ }
+};
diff --git a/game/audio.js b/game/audio.js
new file mode 100644
index 0000000..1636ebd
--- /dev/null
+++ b/game/audio.js
@@ -0,0 +1,105 @@
+/* Standard Operating Psychopathy — audio.
+ Minimal cold-wave sequencer: motorik kick, dry hat, steely bass.
+ Starts only after a user gesture. Toggle with [M]. */
+window.SOP = window.SOP || {};
+
+SOP.Audio = (() => {
+ let ctx = null, master = null, running = false, nextNote = 0, step = 0;
+ const BPM = 126, LOOKAHEAD = 0.12, INTERVAL = 40;
+ let timer = null;
+
+ /* A minor, one cold riff: A1 A1 E2 A1 / C2 A1 G1 A1, straight 16ths. */
+ const BASS = [55, 55, 82.4, 55, 65.4, 55, 49, 55, 55, 55, 82.4, 55, 65.4, 98, 82.4, 55];
+ const HAT_PATTERN = [0,0,1,0, 0,0,1,0, 0,0,1,0, 0,0,1,1];
+
+ /** Create the audio context and master gain once, if Web Audio is available. */
+ function init() {
+ if (ctx) return;
+ const AC = window.AudioContext || window.webkitAudioContext;
+ if (!AC) return;
+ ctx = new AC();
+ master = ctx.createGain();
+ master.gain.value = 0.5;
+ master.connect(ctx.destination);
+ }
+
+ /** Schedule a short sine-wave kick at audio-context time t (seconds). */
+ function kick(t) {
+ const o = ctx.createOscillator(), g = ctx.createGain();
+ o.type = 'sine';
+ o.frequency.setValueAtTime(120, t);
+ o.frequency.exponentialRampToValueAtTime(40, t + 0.09);
+ g.gain.setValueAtTime(0.55, t);
+ g.gain.exponentialRampToValueAtTime(0.001, t + 0.12);
+ o.connect(g); g.connect(master);
+ o.start(t); o.stop(t + 0.13);
+ }
+
+ /** Schedule a high-pass noise hi-hat at audio-context time t (seconds). */
+ function hat(t) {
+ const len = 0.03;
+ const buf = ctx.createBuffer(1, ctx.sampleRate * len, ctx.sampleRate);
+ const d = buf.getChannelData(0);
+ for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length);
+ const s = ctx.createBufferSource(); s.buffer = buf;
+ const f = ctx.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 6000;
+ const g = ctx.createGain(); g.gain.value = 0.08;
+ s.connect(f); f.connect(g); g.connect(master);
+ s.start(t);
+ }
+
+ /** Schedule a sawtooth bass note at time t (seconds) and frequency freq (Hz). */
+ function bass(t, freq) {
+ const o = ctx.createOscillator(), g = ctx.createGain(), f = ctx.createBiquadFilter();
+ o.type = 'sawtooth'; o.frequency.value = freq;
+ f.type = 'lowpass'; f.frequency.setValueAtTime(900, t);
+ f.frequency.exponentialRampToValueAtTime(180, t + 0.14);
+ g.gain.setValueAtTime(0.16, t);
+ g.gain.exponentialRampToValueAtTime(0.001, t + 0.16);
+ o.connect(f); f.connect(g); g.connect(master);
+ o.start(t); o.stop(t + 0.18);
+ }
+
+ /** Queue sixteenth-note steps through the audio lookahead window. */
+ function schedule() {
+ while (nextNote < ctx.currentTime + LOOKAHEAD) {
+ const s16 = step % 16;
+ if (s16 % 4 === 0) kick(nextNote);
+ if (HAT_PATTERN[s16]) hat(nextNote);
+ bass(nextNote, BASS[s16]);
+ nextNote += (60 / BPM) / 4;
+ step++;
+ }
+ }
+
+ /** Start or stop the sequencer; return whether playback is running. */
+ function toggle() {
+ init();
+ if (!ctx) return false;
+ if (running) {
+ clearInterval(timer); running = false;
+ } else {
+ if (ctx.state === 'suspended') ctx.resume();
+ nextNote = ctx.currentTime + 0.05; step = 0;
+ timer = setInterval(schedule, INTERVAL);
+ running = true;
+ }
+ return running;
+ }
+
+ /** Play a one-shot alert tone only while the sequencer is running. */
+ function alarm() {
+ if (!ctx || !running) return;
+ const t = ctx.currentTime;
+ const o = ctx.createOscillator(), g = ctx.createGain();
+ o.type = 'square'; o.frequency.value = 440;
+ o.frequency.setValueAtTime(440, t);
+ o.frequency.setValueAtTime(330, t + 0.12);
+ g.gain.setValueAtTime(0.12, t);
+ g.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
+ o.connect(g); g.connect(master);
+ o.start(t); o.stop(t + 0.32);
+ }
+
+ return { toggle, alarm, get on() { return running; } };
+})();
diff --git a/game/config.js b/game/config.js
new file mode 100644
index 0000000..c77ec94
--- /dev/null
+++ b/game/config.js
@@ -0,0 +1,107 @@
+/* Standard Operating Psychopathy — configuration & static data. */
+window.SOP = window.SOP || {};
+
+SOP.TILE = 26; // pixel size of one tile (base)
+SOP.TICK_MS = 250; // simulation tick
+SOP.EVAL_MS = 1000; // utility re-evaluation interval
+SOP.DAY_TICKS = 240; // ticks per TAG
+SOP.START_DAY = 3689; // days since surface collapse
+
+SOP.GRID_W = 34;
+SOP.GRID_H = 22;
+SOP.DIG_TOP = 5; // rows 0..4 stay bedrock
+SOP.LINK_RANGE = 8; // max wire length between nodes (tiles)
+
+SOP.RES = { beton: 220, metall: 80, rationen: 110, oel: 220 };
+
+SOP.MAX_POWER = 600;
+SOP.MAX_O2 = 250;
+
+SOP.NEEDS = ['stress', 'hunger', 'exhaustion', 'paranoia', 'o2'];
+
+/* Object definitions. watts>0 = consumer, watts<0 = generator. */
+SOP.OBJ = {
+ dynamo: { label: 'TRETBAR-DYNAMO', watts: -60, icon: '◉', hotkey: '1', cost: { metall: 12 },
+ hint: 'Handkurbel. −60 W solange ein Subjekt kurbelt.' },
+ lamp: { label: 'LAMPE', watts: 12, icon: '✶', hotkey: '2', cost: { metall: 4 },
+ hint: 'Beleuchtet Radius 4. Gegen Dunkel-Panik. 12 W.' },
+ bed: { label: 'PRITSCHE', watts: 0, icon: '▭', hotkey: '3', cost: { metall: 4, beton: 2 },
+ hint: 'Schlafplatz. Baut Erschöpfung ab.' },
+ dispenser: { label: 'RATIONEN-AUSGABE', watts: 5, icon: '▦', hotkey: '4', cost: { metall: 8 },
+ hint: 'Gibt 1 RATION pro Mahlzeit aus. Braucht Strom.' },
+ scrubber: { label: 'LUFT-WÄSCHER', watts: 25, icon: '≋', hotkey: '5', cost: { metall: 10, beton: 4 },
+ hint: 'Zieht Giftgas ab, gibt O₂ aus. 25 W.' },
+ generator: { label: 'NOTSTROMAGGREGAT', watts: -100, icon: '▣', hotkey: '6', cost: { metall: 20 },
+ hint: '−100 W. Verbrennt 1 ÖL alle 35 s. Öl kommt aus dem Erdreich.' },
+ door: { label: 'SCHOTT', watts: 10, icon: '╫', hotkey: '7', cost: { metall: 8, beton: 2 },
+ hint: 'Blockiert Weg, solange stromlos. Sensor kann es koppeln.' },
+ battery: { label: 'AKKU-BLOCK', watts: 0, icon: '▮', hotkey: '9', cost: { metall: 14 },
+ hint: 'Speichert Überschuss (2000), deckt Spitzen. Gegen Flackerlicht.' },
+ farm: { label: 'PILZFARM', watts: 15, icon: '✿', hotkey: '0', cost: { beton: 6, metall: 6 },
+ hint: 'Züchtet Rationen aus der Wandfeuchte. +1 RATION pro 30 s Strom.' },
+ airlock: { label: 'LUFTSCHLEUSE', watts: 0, icon: '⌂', hotkey: 'q', cost: { metall: 15, beton: 10 },
+ hint: 'Durchbruch zur Oberfläche. Startpunkt für Expeditionen & Neuzugänge. Nur an der Decke.' },
+ radio: { label: 'FUNKTURM', watts: 30, icon: '⌁', hotkey: 'f', cost: { metall: 18 },
+ hint: 'Sendet auf allen Bändern. Verdoppelt die Rate der Neuzugänge. 30 W.' },
+ sensor: { label: 'BEWEGUNGSSENSOR', watts: 5, icon: '◎', hotkey: '8', cost: { metall: 6 },
+ hint: 'Meldet Subjekte im Radius 2. Koppelbar an Schott.' },
+ gas_vent: { label: 'GAS-SCHLOT', watts: 0, icon: '▲', hotkey: '', cost: {},
+ hint: 'Leckt Giftgas. Einmauern oder waschen.' },
+};
+SOP.WALL_COST = { beton: 4 };
+SOP.BUILDABLE = ['dynamo', 'lamp', 'bed', 'dispenser', 'scrubber', 'generator', 'door', 'sensor', 'battery', 'farm', 'airlock', 'radio'];
+SOP.BATTERY_CAP = 2000;
+SOP.FARM_TICKS = 120; // powered ticks per ration grown
+
+/* Colony growth & surface operations. */
+SOP.MAX_INMATES = 6;
+SOP.EXPEDITION_TICKS = [500, 800]; // duration range while outside
+SOP.EXPEDITION_INJURY_CHANCE = 0.18;
+SOP.ARRIVAL_BASE = [2600, 3800]; // ticks until a newcomer (no radio)
+SOP.ARRIVAL_RADIO = [1300, 1900]; // ... with a powered Funkturm
+SOP.LOOT = [
+ { res: 'oel', n: [6, 14], label: 'ÖL-FÄSSER', weight: 30 },
+ { res: 'rationen', n: [5, 10], label: 'KONSERVEN', weight: 30 },
+ { res: 'metall', n: [6, 12], label: 'SCHROTT', weight: 22 },
+ { res: 'beton', n: [8, 16], label: 'TRÜMMERSTEINE', weight: 18 },
+];
+
+/* Digging outcomes. */
+SOP.DIG_TICKS = 4;
+SOP.DIG_CACHE_CHANCE = 0.30;
+SOP.DIG_GAS_CHANCE = 0.12;
+SOP.CACHES = [
+ { res: 'rationen', n: [3, 7], label: 'RATIONEN-DEPOT', weight: 35 },
+ { res: 'oel', n: [7, 12], label: 'ÖL-FLASCH', weight: 28 },
+ { res: 'metall', n: [4, 9], label: 'METALL-CACHE', weight: 22 },
+ { res: 'beton', n: [5, 12], label: 'BETON-CACHE', weight: 18 },
+];
+
+SOP.NAMES = [
+ 'SUBJEKT-402','SUBJEKT-117','SUBJEKT-774','SUBJEKT-033','SUBJEKT-666',
+ 'SUBJEKT-291','SUBJEKT-508','SUBJEKT-815','SUBJEKT-149','SUBJEKT-904',
+];
+
+SOP.TRAITS = {
+ Pyromane: 'Leitet bei Stress Kurzschlüsse ein.',
+ Paranoid: 'Panik in Dunkelheit. Hoher Argwohn.',
+ Befehlsfixiert: 'Arbeitet stur weiter. Ignoriert Warnlampen.',
+ Schlafwandler: 'Wandert nachts. Pennt überall.',
+};
+
+SOP.QUAKE_NAMES = ['ERDBEBEN 1', 'ERDBEBEN 2', 'DER MUSSOLINI', 'GOLDENES SPIELZEUG', 'KALTES KLARWASSER', 'MAIKÄFER FLIEG'];
+
+SOP.TICKER_LINES = [
+ 'JEDER ATEMZUG WIRD REGISTRIERT',
+ 'VERLASS DEINEN SEKTOR NICHT',
+ 'DER MUSSOLINI DREHT SICH',
+ 'KALTES KLARWASSER IST KEIN SPIEL',
+ 'GOLDENES SPIELZEUG WIRD KONFISZIERT',
+ 'ARBEIT MACHT ATEM',
+ 'GEBORGENHEIT IST PFLICHT',
+ 'HALTE DEIN SCHOTT GESCHLOSSEN',
+ 'DIE OBERFLÄCHE IST KEIN ORT MEHR',
+ 'EIN NEUES DEUTSCHES WUNDER: DIE WÄSCHER',
+ 'ANGST IST ENERGIE. SPAREN SIE DAMIT',
+ 'WIR WOLLEN WOLLEN. SIE WOLLEN AUCH',
+];
diff --git a/game/index.html b/game/index.html
new file mode 100644
index 0000000..68a1731
--- /dev/null
+++ b/game/index.html
@@ -0,0 +1,136 @@
+
+
+
+
+ Standard Operating Psychopathy — Sektor 4
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Systemstatus
+
Stromnetz
+
+ Detailwerte im Kopfbalken. Defizit = Verbraucher dunkel.
+
+
+
+
+
Aktenverwaltung
+
+ Sichern [F5]
+ Laden
+ Export
+ Import
+
+
+
+ Autosicherung alle 5 Minuten. Export = Datei-Download.
+
+
+
+
+
+
+
Subjekt-Inspektor
+
Subjekt im Raster anklicken, um affektive und konative Ströme zu überwachen.
+
+
+
+
Knoten-Inspektor
+
Nur im CROSSLINK-Modus.
+
+
+
+
+
+
+
+
+
+
+
+
Standard Operating Psychopathy
+
Sektor 4 — Handbuch für Schichtleiter
+
Die Oberfläche ist erledigt. Sie überwachen einen Bunker mit drei Subjekten.
+ Die Subjekte bewerten ihre Bedürfnisse selbst und handeln danach — Ihre Aufgabe
+ ist das Netz: Strom legen, Luft waschen, Grabungsaufträge erteilen, Schotten schalten.
+ Eine Luftschleuse in der Decke öffnet den Weg nach oben: Expeditionen holen
+ Öl und Konserven, neue Subjekte finden den Sektor. Der Funkturm ruft sie schneller.
+ Ihre Akte wird gesichert — lokal und als Datei.
+
+ 1–8, 9, 0 Module bauen · W Wand · X Grabungsauftrag · ESC Abbruch
+ V Physisch ↔ Crosslink · LEER Pause · M Ton · F5 Sichern
+ Crosslink: zwei Knoten anklicken = Leitung (max. 8 Felder).
+ Sensor + Schott koppeln: Schott öffnet bei Bewegung.
+ Notstromaggregat verbrennt ÖL — Öl liegt im Erdreich. Graben hält das Netz am Leben.
+ Luftschleuse (Q) an die Decke setzen: Expeditionen zur Oberfläche (Beute!)
+ und Neuzugänge für den Sektor. Der Funkturm beschleunigt die Ankunft neuer Subjekte.
+
+
WARNUNG: Hoher Stress führt bei geeigneten Profilen zu Kurzschlüssen im wahrsten Sinne.
+
+
+
+
+
+
+
+
Akte geschlossen
+
Alle Subjekte ausgefallen — Sektor 4 stillgelegt
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/game/main.js b/game/main.js
new file mode 100644
index 0000000..f8f2188
--- /dev/null
+++ b/game/main.js
@@ -0,0 +1,600 @@
+/* ===========================================================================
+ MAIN — orchestration: game loop, input, events, day cycle, save/load,
+ surface expeditions, and colony growth.
+ =========================================================================== */
+window.SOP = window.SOP || {};
+
+SOP.Main = (() => {
+ const world = SOP.World;
+ const UI = SOP.UI;
+ const R = SOP.Render;
+
+ const SAVE_KEY = 'sop-sektor4-akte';
+
+ let ticks = 0;
+ let started = false, paused = false, gameOver = false;
+ let simInterval = null;
+ let state = {
+ selected: null, // selected inmate
+ selDevice: null, // selected device (crosslink)
+ buildKind: null, // 'wall' | object kind | 'dig' | null
+ linkFrom: null, // device id while wiring
+ ghost: null, // {x, y, kind}
+ };
+ let nextQuakeTick = 260;
+ let nextArrivalTick = 2200;
+ let stats = { caches: 0, meals: 0, repairs: 0, sabotages: 0, quakes: 0, expeditions: 0, arrivals: 0 };
+
+ /** Return the in-game calendar day derived from elapsed simulation ticks. */
+ function day() { return SOP.START_DAY + Math.floor(ticks / SOP.DAY_TICKS); }
+
+ /* ---------------- SETUP ---------------- */
+ /** Generate the starting colony, bind controls, and start rendering the intro. */
+ function setup() {
+ world.generate();
+
+ // the cast
+ const traits = ['Pyromane', 'Paranoid', 'Befehlsfixiert'];
+ const spots = [[7, 9], [10, 8], [13, 11]];
+ const pool = SOP.NAMES.filter(n => n !== 'SUBJEKT-402').sort(() => Math.random() - 0.5);
+ const inmates = spots.map((s, i) =>
+ new SOP.InmateAI(i === 0 ? 'SUBJEKT-402' : pool[i - 1], traits[i], s[0], s[1]));
+ world.inmates = inmates;
+
+ UI.initBuildMenu(kind => { state.buildKind = kind; state.linkFrom = null; });
+ UI.setTicker();
+ UI.log('SCHICHT ERÖFFNET. DREI SUBJEKTE REGISTRIERT.', 'ok');
+ UI.log('AUFTRAGSLAGE: GRABUNG LÄUFT. STROM HANDGEMACHT.', 'dim');
+
+ bindInput();
+ UI.showIntro(() => {
+ beginShift();
+ UI.log('DURCHSAGE: DIE SCHICHT BEGINNT.', 'ok');
+ });
+
+ requestAnimationFrame(frame);
+ }
+
+ /** Start the shift and toggle audio; opts.interval=false suppresses the tick timer. */
+ function beginShift(opts) {
+ started = true;
+ if (SOP.Audio && SOP.Audio.toggle) SOP.Audio.toggle(); // motorik loop (user gesture)
+ if (!opts || opts.interval !== false) {
+ if (!simInterval) simInterval = setInterval(simTick, SOP.TICK_MS);
+ }
+ }
+
+ /* ---------------- SIMULATION TICK ---------------- */
+ /** Advance an active, unpaused shift by one tick, processing events and autosave. */
+ function simTick() {
+ if (!started || paused || gameOver) return;
+ ticks++;
+
+ world.tick(handleWorldEvent);
+
+ // expedition returns
+ for (const inm of world.inmates) {
+ if (!inm.dead && inm.away && ticks >= inm.away.returnTick) returnFromExpedition(inm);
+ }
+
+ for (const inm of world.inmates) {
+ if (inm.dead || inm.away) continue;
+ // boarding failsafe: path impossible → abandon the expedition
+ if (inm.boarding && !inm.goal) {
+ inm.boarding = false;
+ UI.log('EXPEDITION ABGEBROCHEN: SCHLEUSE NICHT ERREICHBAR.', 'alert');
+ continue;
+ }
+ if (ticks % 4 === 0 && !inm.boarding) inm.evaluateDrives(world); // 4 ticks = 1 s
+ inm.tick(world, handleInmateEvent);
+ }
+
+ // newcomer at the airlock
+ if (ticks >= nextArrivalTick) {
+ const arrived = tryArrival();
+ if (arrived) {
+ scheduleNextArrival();
+ } else {
+ // stand blocked or colony full — peek again soon
+ const full = world.inmates.filter(i => !i.dead).length >= SOP.MAX_INMATES;
+ nextArrivalTick = ticks + (full ? 2400 : 120 + Math.floor(Math.random() * 180));
+ }
+ }
+
+ // random seismic event
+ if (ticks >= nextQuakeTick) {
+ quake();
+ nextQuakeTick = ticks + 380 + Math.floor(Math.random() * 260);
+ }
+
+ // autosave every ~5 minutes of sim time
+ if (ticks % 1200 === 0 && !gameOver) saveGame(true);
+
+ // game over check
+ if (world.inmates.every(i => i.dead)) endGame();
+ }
+
+ /** Log generator fuel consumption and farm harvest events from the world. */
+ function handleWorldEvent(ev) {
+ if (ev.type === 'burn') UI.log('NOTSTROMAGGREGAT VERFEUERT 1 ÖL.', 'dim');
+ if (ev.type === 'harvest') UI.log('PILZFARM: 1 RATION GEERNTET.', 'dim');
+ }
+
+ /** Apply inmate events to statistics, alerts, stress, and expedition boarding. */
+ function handleInmateEvent(ev) {
+ switch (ev.type) {
+ case 'meal':
+ stats.meals++;
+ break;
+ case 'cache':
+ stats.caches++;
+ UI.log(`${ev.inmate.name} BIRGT ${ev.label} (+${ev.n})`, 'ok');
+ break;
+ case 'gasburst':
+ UI.alert(`GAS-SCHLOT FREIGELEGT BEI ${ev.x}/${ev.y}. LUFT WASCHEN ODER MAUERN.`);
+ R.shake(400);
+ world.inmates.forEach(i => { if (!i.dead && !i.away) { i.affective.stress = Math.min(100, i.affective.stress + 8); i.affective.paranoia = Math.min(100, i.affective.paranoia + 10); } });
+ break;
+ case 'sabotage':
+ stats.sabotages++;
+ UI.alert(`${ev.inmate.name}: PSYCHOTISCHER SCHUB. LEITUNG GEKAPPT (${SOP.OBJ[ev.dev.kind].label}).`);
+ R.shake(300);
+ world.inmates.forEach(i => { if (!i.dead && !i.away && i !== ev.inmate) i.affective.stress = Math.min(100, i.affective.stress + 12); });
+ break;
+ case 'repair':
+ stats.repairs++;
+ UI.log(`${ev.inmate.name} REPARIERT ${SOP.OBJ[ev.dev.kind].label}.`, 'ok');
+ break;
+ case 'board': {
+ const inm = ev.inmate;
+ inm.boarding = false;
+ inm.goal = null;
+ inm.path = [];
+ const dur = SOP.EXPEDITION_TICKS[0] +
+ Math.floor(Math.random() * (SOP.EXPEDITION_TICKS[1] - SOP.EXPEDITION_TICKS[0]));
+ inm.away = { returnTick: ticks + dur, started: ticks };
+ inm.currentAction = { name: 'AUSSERHAUS', score: 0 };
+ UI.log(`${inm.name} IST AUSSERHAUS. RÜCKKEHR IN ~${Math.round(dur * SOP.TICK_MS / 1000)} S.`, 'wire');
+ break;
+ }
+ }
+ }
+
+ /* ---------------- EXPEDITIONS ---------------- */
+ /** Return a walkable tile beside dev, or null; optionally ignore inmate occupancy. */
+ function adjacentStand(dev, ignoreInmates) {
+ for (const [dx, dy] of [[0, 1], [1, 0], [-1, 0], [0, -1]]) {
+ const x = dev.x + dx, y = dev.y + dy;
+ if (!world.walkable(x, y)) continue;
+ if (!ignoreInmates && world.inmates.some(i => !i.dead && !i.away && i.tx === x && i.ty === y)) continue;
+ return { x, y };
+ }
+ return null;
+ }
+
+ /** Assign the named eligible inmate to board the airlock during an active shift. */
+ function startExpedition(name) {
+ if (gameOver || !started) return;
+ const air = world.devices.find(d => d.kind === 'airlock');
+ if (!air) { UI.log('KEINE LUFTSCHLEUSE VORHANDEN.', 'alert'); return; }
+ const inm = world.inmates.find(i => i.name === name && !i.dead && !i.away && !i.boarding);
+ if (!inm) return;
+ if (!adjacentStand(air)) { UI.log('SCHLEUSE NICHT ERREICHBAR — ZUGANG FREIMACHEN.', 'alert'); return; }
+ inm.sleeping = false;
+ inm.boarding = true;
+ inm.path = [];
+ inm.goal = { kind: 'device', dev: air, act: 'expedition' };
+ inm.currentAction = { name: 'EXPEDITION', score: 999 };
+ inm.thought = 'Raus. Nur kurz. Nur mit Maske.';
+ UI.log(`${inm.name}: GANG ZUR OBERFLÄCHE. MASKE SITZT.`, 'wire');
+ }
+
+ /** Return two or three weighted loot rolls with inclusive random resource quantities. */
+ function rollLoot() {
+ const totalW = SOP.LOOT.reduce((a, c) => a + c.weight, 0);
+ const n = 2 + (Math.random() < 0.4 ? 1 : 0);
+ const out = [];
+ for (let i = 0; i < n; i++) {
+ let roll = Math.random() * totalW, c = SOP.LOOT[0];
+ for (const cand of SOP.LOOT) { roll -= cand.weight; if (roll <= 0) { c = cand; break; } }
+ out.push({ res: c.res, label: c.label, n: c.n[0] + Math.floor(Math.random() * (c.n[1] - c.n[0] + 1)) });
+ }
+ return out;
+ }
+
+ /** Bring an inmate home with loot and possible injury, or defer if the gate is blocked. */
+ function returnFromExpedition(inm) {
+ const air = world.devices.find(d => d.kind === 'airlock');
+ const spot = air ? adjacentStand(air) : null;
+ if (!spot) { inm.away.returnTick = ticks + 40; return; } // gate blocked — wait outside
+ stats.expeditions++;
+ inm.tx = spot.x; inm.ty = spot.y; inm.px = spot.x; inm.py = spot.y;
+ inm.away = null;
+ const gains = rollLoot();
+ for (const g of gains) world.res[g.res] = (world.res[g.res] || 0) + g.n;
+ UI.log(`${inm.name} ZURÜCK VON DER OBERFLÄCHE: ${gains.map(g => '+' + g.n + ' ' + g.label).join(', ')}.`, 'ok');
+ if (Math.random() < SOP.EXPEDITION_INJURY_CHANCE) {
+ inm.hp = Math.max(5, inm.hp - 35);
+ inm.affective.stress = Math.min(100, inm.affective.stress + 25);
+ UI.alert(`${inm.name}: VERLETZUNG AUF DER OBERFLÄCHE. REVIER NOTWENDIG.`);
+ } else {
+ inm.affective.stress = Math.min(100, inm.affective.stress + 8);
+ }
+ inm.affective.o2 = Math.min(100, inm.affective.o2 + 12); // thin air up there
+ inm.affective.exhaustion = Math.min(100, inm.affective.exhaustion + 15);
+ }
+
+ /* ---------------- COLONY GROWTH ---------------- */
+ /** Add a newcomer when the airlock and colony capacity permit; return success. */
+ function tryArrival() {
+ if (gameOver) return false;
+ const air = world.devices.find(d => d.kind === 'airlock');
+ if (!air) return false;
+ const present = world.inmates.filter(i => !i.dead);
+ if (present.length >= SOP.MAX_INMATES) return false;
+ const spot = adjacentStand(air, true); // a brief overlap at the gate is fine
+ if (!spot) return false;
+ const used = new Set(world.inmates.map(i => i.name));
+ const pool = SOP.NAMES.filter(n => !used.has(n));
+ const name = pool.length
+ ? pool[Math.floor(Math.random() * pool.length)]
+ : 'SUBJEKT-' + Math.floor(100 + Math.random() * 900);
+ const traitKeys = Object.keys(SOP.TRAITS);
+ const trait = traitKeys[Math.floor(Math.random() * traitKeys.length)];
+ const nu = new SOP.InmateAI(name, trait, spot.x, spot.y);
+ nu.affective.hunger = 55 + Math.random() * 25; // arrived hungry
+ nu.affective.stress = 30 + Math.random() * 20; // the surface does that
+ world.inmates.push(nu);
+ stats.arrivals++;
+ UI.log(`NEUZUGANG AN DER SCHLEUSE: ${name} (${trait.toUpperCase()}). AKTE ERÖFFNET.`, 'ok');
+ UI.setTicker(`AKTUELLE DURCHSAGE: ${name} IST DEM SEKTOR 4 ZUGETEILT WORDEN`);
+ return true;
+ }
+
+ /** Choose the next arrival tick using the shorter range when a radio is powered. */
+ function scheduleNextArrival() {
+ const radio = world.devices.find(d =>
+ d.kind === 'radio' && d.powered && !d.broken && d.enabled !== false);
+ const range = radio ? SOP.ARRIVAL_RADIO : SOP.ARRIVAL_BASE;
+ nextArrivalTick = ticks + range[0] + Math.floor(Math.random() * (range[1] - range[0]));
+ }
+
+ /* ---------------- SAVE / LOAD ---------------- */
+ /** Return localStorage, or null if browser access throws. */
+ function storage() { try { return window.localStorage; } catch (e) { return null; } }
+
+ /** Return a versioned snapshot of the world, inmates, timers, and statistics. */
+ function buildSaveData() {
+ return {
+ v: 1,
+ savedAt: Date.now(),
+ ticks, nextQuakeTick, nextArrivalTick,
+ stats: Object.assign({}, stats),
+ world: world.serialize(),
+ inmates: world.inmates.map(i => i.serialize()),
+ };
+ }
+
+ /** Save the current snapshot to localStorage and return success; silent suppresses logs. */
+ function saveGame(silent) {
+ const st = storage();
+ if (!st) { if (!silent) UI.log('STORAGE BLOCKIERT. NUTZE EXPORT.', 'alert'); return false; }
+ try {
+ st.setItem(SAVE_KEY, JSON.stringify(buildSaveData()));
+ if (!silent) UI.log('AKTE GESICHERT (LOKAL).', 'dim');
+ return true;
+ } catch (e) {
+ if (!silent) UI.log('SICHERUNG FEHLGESCHLAGEN. NUTZE EXPORT.', 'alert');
+ return false;
+ }
+ }
+
+ /** Restore a version-1 snapshot and resume play; return false on format or load errors. */
+ function loadGame(data) {
+ try {
+ if (!data || data.v !== 1) { UI.log('AKTE UNLESBAR (FALSCHES FORMAT).', 'alert'); return false; }
+ world.loadFrom(data.world);
+ const inmates = data.inmates.map(d => SOP.InmateAI.fromData(d));
+ const ids = new Set(inmates.map(i => i.id));
+ for (const d of world.devices) {
+ if (d.operatedBy != null && !ids.has(d.operatedBy)) d.operatedBy = null;
+ }
+ world.inmates = inmates;
+ ticks = data.ticks;
+ nextQuakeTick = data.nextQuakeTick;
+ nextArrivalTick = data.nextArrivalTick;
+ const s = data.stats || {};
+ for (const k of Object.keys(stats)) {
+ stats[k] = Number.isFinite(s[k]) ? s[k] : 0;
+ }
+ state.selected = null; state.selDevice = null;
+ state.buildKind = null; state.linkFrom = null; state.ghost = null;
+ UI.clearBuildSelection();
+ gameOver = false;
+ document.getElementById('overlay-over').classList.remove('show');
+ document.getElementById('overlay-intro').classList.remove('show');
+ paused = false;
+ const pb = document.getElementById('btn-pause');
+ pb.classList.remove('pause-on'); pb.textContent = '❚❚ PAUSE';
+ if (!started) beginShift();
+ UI.log(`AKTE GELADEN — TAG ${day()}. DIE SCHICHT GEHT WEITER.`, 'ok');
+ return true;
+ } catch (e) {
+ if (typeof console !== 'undefined') console.error('LOAD ERROR:', e);
+ UI.log('LADEN FEHLGESCHLAGEN.', 'alert');
+ return false;
+ }
+ }
+
+ /** Download the current snapshot as JSON and log export success or failure. */
+ function exportSave() {
+ try {
+ const blob = new Blob([JSON.stringify(buildSaveData())], { type: 'application/json' });
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = 'sop-sektor4-akte.json';
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(a.href), 2000);
+ UI.log('AKTE EXPORTIERT.', 'dim');
+ } catch (e) {
+ UI.log('EXPORT FEHLGESCHLAGEN.', 'alert');
+ }
+ }
+
+ /** Read a File as text, parse its JSON, and attempt to restore the saved game. */
+ function importSave(file) {
+ const r = new FileReader();
+ r.onload = () => {
+ try { loadGame(JSON.parse(r.result)); }
+ catch (e) { UI.log('AKTE UNLESBAR.', 'alert'); }
+ };
+ r.readAsText(file);
+ }
+
+ /* ---------------- EVENTS ---------------- */
+ /** Break up to two devices and apply earthquake stress, alerts, and screen shake. */
+ function quake() {
+ const name = SOP.QUAKE_NAMES[Math.floor(Math.random() * SOP.QUAKE_NAMES.length)];
+ stats.quakes++;
+ R.shake(800);
+ UI.alert(`${name} — STRUKTURSCHADEN IM SEKTOR.`);
+ UI.setTicker(`AKUTMELDUNG: ${name} ERSCHÜTTERT SEKTOR 4`);
+ // damage 1–2 random devices
+ const fragile = world.devices.filter(d => d.kind !== 'gas_vent' && !d.broken);
+ const hits = 1 + (Math.random() < 0.4 ? 1 : 0);
+ for (let i = 0; i < hits && fragile.length; i++) {
+ const idx = Math.floor(Math.random() * fragile.length);
+ const d = fragile.splice(idx, 1)[0];
+ d.broken = true;
+ UI.log(`DEFEKT: ${SOP.OBJ[d.kind].label} BEI ${d.x}/${d.y}.`, 'alert');
+ }
+ world.inmates.forEach(i => {
+ if (i.dead || i.away) return;
+ i.affective.stress = Math.min(100, i.affective.stress + 12);
+ i.affective.paranoia = Math.min(100, i.affective.paranoia + (i.trait === 'Paranoid' ? 22 : 8));
+ i.sleeping = false;
+ });
+ if (SOP.Audio && SOP.Audio.on) SOP.Audio.alarm();
+ }
+
+ /** Stop simulation advancement and show the final statistics with a restart action. */
+ function endGame() {
+ gameOver = true;
+ const daysWorked = Math.floor(ticks / SOP.DAY_TICKS);
+ UI.showGameOver(`
+ TAGE IM BETRIEB ${daysWorked}
+ MAHLZEITEN ${stats.meals}
+ CACHES GEBORGEN ${stats.caches}
+ EXPEDITIONEN ${stats.expeditions}
+ NEUZUGÄNGE ${stats.arrivals}
+ REPARATUREN ${stats.repairs}
+ PSYCHOTISCHE SCHÜBE ${stats.sabotages}
+ ERDBEBEN ÜBERSTANDEN ${stats.quakes}
+ ENDSTAND AKTE GESCHLOSSEN
`,
+ () => location.reload());
+ }
+
+ /* ---------------- RENDER LOOP ---------------- */
+ let uiFrame = 0;
+ /** Draw the current view, periodically refresh the HUD, and request the next frame. */
+ function frame() {
+ R.draw({
+ world,
+ selected: state.selected,
+ ghost: state.ghost,
+ linkFrom: state.linkFrom,
+ });
+ if (++uiFrame % 4 === 0 && !gameOver) {
+ UI.updateSystem(world, day());
+ UI.updateInspector(world, state.selected);
+ }
+ if (uiFrame % 10 === 0 && !gameOver) {
+ UI.updateDevicePanel(world, state.selDevice, R.getView(), ticks);
+ }
+ requestAnimationFrame(frame);
+ }
+
+ /* ---------------- INPUT ---------------- */
+ /** Bind canvas, toolbar, save/load, and keyboard controls to game actions. */
+ function bindInput() {
+ const canvas = document.getElementById('gameCanvas');
+
+ canvas.addEventListener('contextmenu', e => e.preventDefault());
+
+ canvas.addEventListener('mousemove', e => {
+ const r = canvas.getBoundingClientRect();
+ const t = R.toTile(e.clientX - r.left, e.clientY - r.top);
+ if (state.buildKind && state.buildKind !== 'dig') {
+ state.ghost = { x: t.x, y: t.y, kind: state.buildKind };
+ } else {
+ state.ghost = null;
+ }
+ });
+
+ canvas.addEventListener('mousedown', e => {
+ if (!started || gameOver) return;
+ const r = canvas.getBoundingClientRect();
+ const t = R.toTile(e.clientX - r.left, e.clientY - r.top);
+ const { x, y } = t;
+ if (!world.inGrid(x, y)) return;
+
+ if (e.button === 2) { // right click cancels
+ state.buildKind = null; state.linkFrom = null;
+ UI.clearBuildSelection();
+ return;
+ }
+
+ if (R.getView() === 'CROSSLINK') { crosslinkClick(x, y); return; }
+
+ // ---- PHYSICAL MODE ----
+ if (state.buildKind === 'dig') {
+ if (world.orderDig(x, y)) UI.log(`GRABUNGSAUFTRAG ${x}/${y} ERTEILT.`, 'dim');
+ return;
+ }
+ if (state.buildKind === 'wall') {
+ const r2 = world.tryBuildWall(x, y);
+ if (!r2.ok) UI.log('BAU ABGELEHNT: ' + r2.err, 'alert');
+ else UI.log('WAND GEZOGEN.', 'dim');
+ return;
+ }
+ if (state.buildKind) {
+ const r2 = world.tryBuild(state.buildKind, x, y);
+ if (!r2.ok) UI.log('BAU ABGELEHNT: ' + r2.err, 'alert');
+ else {
+ UI.log(`${SOP.OBJ[state.buildKind].label} INSTALLIERT. VERKABELUNG IM CROSSLINK.`, 'ok');
+ state.buildKind = null;
+ UI.clearBuildSelection();
+ }
+ return;
+ }
+
+ // select inmate or device
+ const inm = world.inmates.find(i => !i.dead && !i.away && i.tx === x && i.ty === y);
+ if (inm) { state.selected = inm; state.selDevice = null; return; }
+ const tl = world.tiles[y][x];
+ if (tl.obj) { state.selDevice = tl.obj; state.selected = null; }
+ else { state.selected = null; state.selDevice = null; }
+ });
+
+ /** Select a device at tile (x, y), or toggle its wire to the previously selected node. */
+ function crosslinkClick(x, y) {
+ const tl = world.tiles[y][x];
+ const dev = tl.obj;
+ if (!dev) { state.linkFrom = null; state.selDevice = null; return; }
+ if (dev.kind === 'airlock') { UI.log('DIE SCHLEUSE IST KEIN KNOTEN.', 'dim'); return; }
+ state.selDevice = dev;
+ if (state.linkFrom == null) {
+ if (dev.kind === 'gas_vent') { UI.log('GAS-SCHLOT IST KEIN KNOTEN.', 'dim'); return; }
+ state.linkFrom = dev.id;
+ return;
+ }
+ if (state.linkFrom === dev.id) { state.linkFrom = null; return; }
+ const a = world.devices.find(d => d.id === state.linkFrom);
+ state.linkFrom = null;
+ if (!a || dev.kind === 'gas_vent') return;
+ if (a.links.includes(dev.id)) {
+ world.removeLink(a, dev);
+ UI.wire(`LEITUNG GETRENNT: ${SOP.OBJ[a.kind].label} ↔ ${SOP.OBJ[dev.kind].label}.`);
+ } else {
+ if (world.addLink(a, dev)) {
+ UI.wire(`LEITUNG GELEGT: ${SOP.OBJ[a.kind].label} ↔ ${SOP.OBJ[dev.kind].label}.`);
+ } else {
+ UI.log(`LEITUNG ZU LANG (MAX ${SOP.LINK_RANGE} FELDER).`, 'alert');
+ }
+ }
+ }
+
+ // header buttons
+ document.getElementById('btn-physical').addEventListener('click', e => setView('PHYSICAL', e.target));
+ document.getElementById('btn-crosslink').addEventListener('click', e => setView('CROSSLINK', e.target));
+
+ document.getElementById('btn-pause').addEventListener('click', () => {
+ paused = !paused;
+ const b = document.getElementById('btn-pause');
+ b.classList.toggle('pause-on', paused);
+ b.textContent = paused ? '▶ WEITER' : '❚❚ PAUSE';
+ UI.log(paused ? 'SIMULATION ANGEHALTEN.' : 'SIMULATION FORTGESETZT.', 'dim');
+ });
+
+ document.getElementById('btn-audio').addEventListener('click', () => {
+ const on = SOP.Audio.toggle();
+ document.getElementById('btn-audio').textContent = on ? 'TON AN' : 'TON AUS';
+ });
+
+ // Akte (save/load) panel
+ document.getElementById('btn-save').addEventListener('click', () => saveGame(false));
+ document.getElementById('btn-load').addEventListener('click', () => {
+ const st = storage();
+ const raw = st && st.getItem(SAVE_KEY);
+ if (!raw) { UI.log('KEIN SPEICHERSTAND GEFUNDEN.', 'alert'); return; }
+ try { loadGame(JSON.parse(raw)); } catch (e) { UI.log('SPEICHERSTAND BESCHÄDIGT.', 'alert'); }
+ });
+ document.getElementById('btn-export').addEventListener('click', exportSave);
+ document.getElementById('btn-import').addEventListener('click', () =>
+ document.getElementById('file-import').click());
+ document.getElementById('file-import').addEventListener('change', e => {
+ const f = e.target.files && e.target.files[0];
+ if (f) importSave(f);
+ e.target.value = '';
+ });
+
+ // keyboard
+ window.addEventListener('keydown', e => {
+ if (!started) return;
+ const k = e.key.toLowerCase();
+ if (k === ' ') { e.preventDefault(); document.getElementById('btn-pause').click(); return; }
+ if (k === 'v') {
+ const other = R.getView() === 'PHYSICAL' ? 'CROSSLINK' : 'PHYSICAL';
+ document.getElementById(other === 'PHYSICAL' ? 'btn-physical' : 'btn-crosslink').click();
+ return;
+ }
+ if (k === 'm') { document.getElementById('btn-audio').click(); return; }
+ if (k === 'escape') {
+ state.buildKind = null; state.linkFrom = null;
+ UI.clearBuildSelection();
+ return;
+ }
+ if (k === 'w') { clickMenuItem('wall'); return; }
+ if (k === 'x') { clickMenuItem('dig'); return; }
+ if (k === 'f5') { e.preventDefault(); saveGame(false); return; }
+ if (k === 'e') {
+ if (state.selDevice) UI.toggleDevice(state.selDevice);
+ return;
+ }
+ for (const kind of SOP.BUILDABLE) {
+ if (SOP.OBJ[kind].hotkey === e.key) { clickMenuItem(kind); return; }
+ }
+ });
+ }
+
+ /** Activate the build-menu button for kind when it exists. */
+ function clickMenuItem(kind) {
+ const btn = document.querySelector(`.build-item[data-kind="${kind}"]`);
+ if (btn) btn.click();
+ }
+
+ /** Switch the renderer view, clear pending wiring, and highlight the supplied button. */
+ function setView(v, btnEl) {
+ R.setView(v);
+ state.linkFrom = null;
+ document.querySelectorAll('#header .btn').forEach(b => b.classList.remove('active'));
+ if (btnEl) btnEl.classList.add('active');
+ if (v === 'CROSSLINK') UI.wire('CROSSLINK GEÖFFNET. LEITUNGEN SICHTBAR.');
+ }
+
+ return {
+ setup, day,
+ startExpedition,
+ step: simTick, // one sim tick (also used headless by tools/smoke.js)
+ headlessStart: () => beginShift({ interval: false }),
+ saveData: buildSaveData,
+ loadData: loadGame,
+ get state() { return state; },
+ get ticks() { return ticks; },
+ get stats() { return stats; },
+ };
+})();
+
+window.addEventListener('DOMContentLoaded', () => SOP.Main.setup());
diff --git a/game/package.json b/game/package.json
new file mode 100644
index 0000000..3f5d248
--- /dev/null
+++ b/game/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "standard-operating-psychopathy",
+ "version": "0.1.0",
+ "description": "Post-apocalyptic bunker management sim — Sheltered x ONI x BoomTown, NDW terminal aesthetic.",
+ "private": true,
+ "scripts": {
+ "test": "node tools/smoke.js 9600 && node tools/dom_test.js",
+ "serve": "python3 -m http.server 8000 --bind 127.0.0.1"
+ },
+ "devDependencies": {
+ "jsdom": "^24.0.0"
+ },
+ "license": "UNLICENSED"
+}
diff --git a/game/render.js b/game/render.js
new file mode 100644
index 0000000..dd37027
--- /dev/null
+++ b/game/render.js
@@ -0,0 +1,415 @@
+/* ===========================================================================
+ RENDERER — dual-state drawing:
+ PHYSICAL : Shelter-style dollhouse cross-section, monochrome amber CRT.
+ CROSSLINK : Gunpoint-style wiring overlay, cyan nodes & power flow.
+ NOTE: the prototype wrote ctx.fillStyle = 'var(--…)' — canvas can't
+ resolve CSS variables, so real colors are used here.
+ =========================================================================== */
+window.SOP = window.SOP || {};
+
+SOP.Render = (() => {
+ const canvas = document.getElementById('gameCanvas');
+ const ctx = canvas.getContext('2d');
+
+ const C = {
+ bg: '#0a0a0a', bgCross: '#020508',
+ dirt: '#241c12', dirtHi: '#31261a',
+ rock: '#151313',
+ wall: '#3a3a3a', wallSeam: '#222',
+ amber: '#ffb000', amberDim: '#8a5f00', amberFaint: '#3d2c00',
+ alert: '#ff0033', wire: '#00ccff', wireDim: '#0a5a70', ok: '#00ff66', gas: '#9dff00',
+ grid: '#161616',
+ };
+
+ let viewMode = 'PHYSICAL';
+ let scale = 1, offX = 0, offY = 0;
+ let shakeUntil = 0;
+ let now = 0;
+
+ /** Set the active view mode to PHYSICAL or CROSSLINK. */
+ function setView(v) { viewMode = v; }
+ /** Return the active view mode. */
+ function getView() { return viewMode; }
+ /** Set screen shake to end dur milliseconds from now. */
+ function shake(dur) { shakeUntil = performance.now() + dur; }
+
+ /** Fit the canvas to its container and recalculate the grid scale and centering offsets. */
+ function resize() {
+ const box = document.getElementById('canvas-container');
+ canvas.width = box.clientWidth;
+ canvas.height = box.clientHeight;
+ const s = Math.min(canvas.width / (SOP.GRID_W * SOP.TILE), canvas.height / (SOP.GRID_H * SOP.TILE));
+ scale = Math.max(0.4, s);
+ offX = (canvas.width - SOP.GRID_W * SOP.TILE * scale) / 2;
+ offY = (canvas.height - SOP.GRID_H * SOP.TILE * scale) / 2;
+ }
+
+ /** Convert canvas-relative pixel coordinates to tile coordinates without clamping. */
+ function toTile(mx, my) {
+ const x = Math.floor((mx - offX) / (SOP.TILE * scale));
+ const y = Math.floor((my - offY) / (SOP.TILE * scale));
+ return { x, y };
+ }
+
+ /** Return a deterministic value in [0, 1] from tile coordinates for terrain texture. */
+ function hash(x, y) {
+ let h = (x * 374761393 + y * 668265263) | 0;
+ h = (h ^ (h >> 13)) * 1274126177 | 0;
+ return ((h ^ (h >> 16)) >>> 0) / 4294967295;
+ }
+
+ /** Render state in the active view with screen shake and monitor labels. */
+ function draw(state) {
+ now = performance.now();
+ resizeIfNeeded();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.fillStyle = viewMode === 'PHYSICAL' ? C.bg : C.bgCross;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ ctx.save();
+ // earthquake shake
+ let sx = 0, sy = 0;
+ if (now < shakeUntil) {
+ const m = 5 * ((shakeUntil - now) / 700);
+ sx = (Math.random() - 0.5) * m * 2; sy = (Math.random() - 0.5) * m * 2;
+ }
+ ctx.translate(offX + sx, offY + sy);
+ ctx.scale(scale, scale);
+
+ if (viewMode === 'PHYSICAL') drawPhysical(state);
+ else drawCrosslink(state);
+
+ ctx.restore();
+
+ // overlay label
+ ctx.fillStyle = viewMode === 'PHYSICAL' ? C.amberDim : C.wire;
+ ctx.font = '12px "Courier New", monospace';
+ const label = viewMode === 'PHYSICAL'
+ ? 'PHYSISCHE EBENE — RÄUME / ERDREICH / SUBJEKTE'
+ : 'CROSSLINK AKTIV — LOGIK & LEITUNGEN // KLICK A→B: VERBINDEN';
+ ctx.fillText(label, 14, 22);
+ ctx.fillStyle = viewMode === 'PHYSICAL' ? C.amberFaint : C.wireDim;
+ ctx.fillText('SEKTOR 4 // ÜBERWACHUNGSMONITOR 07-B', 14, 38);
+ }
+
+ /** Recalculate canvas dimensions and grid layout if the container size changed. */
+ function resizeIfNeeded() {
+ const box = document.getElementById('canvas-container');
+ if (canvas.width !== box.clientWidth || canvas.height !== box.clientHeight) resize();
+ }
+
+ /* ---------------- PHYSICAL LAYER ---------------- */
+ /** Draw terrain, lighting, gas, devices, inmates, and placement previews from state. */
+ function drawPhysical(state) {
+ const T = SOP.TILE, world = state.world;
+
+ // tiles
+ for (let y = 0; y < world.H; y++) {
+ for (let x = 0; x < world.W; x++) {
+ const tl = world.tiles[y][x];
+ const px = x * T, py = y * T;
+ if (tl.t === 'bedrock') {
+ ctx.fillStyle = C.rock;
+ ctx.fillRect(px, py, T, T);
+ if (hash(x, y) > 0.7) {
+ ctx.strokeStyle = '#1e1b1b';
+ ctx.beginPath();
+ ctx.moveTo(px + 4, py + T - 5); ctx.lineTo(px + T - 5, py + 4);
+ ctx.stroke();
+ }
+ } else if (tl.t === 'dirt') {
+ ctx.fillStyle = tl.mark ? '#3a2e1c' : C.dirt;
+ ctx.fillRect(px, py, T, T);
+ // speckle
+ const n = Math.floor(hash(x, y) * 4);
+ ctx.fillStyle = C.dirtHi;
+ for (let i = 0; i < n; i++) {
+ const rx = px + 3 + hash(x + i, y) * (T - 6);
+ const ry = py + 3 + hash(x, y + i) * (T - 6);
+ ctx.fillRect(rx, ry, 2, 2);
+ }
+ if (tl.mark) {
+ ctx.strokeStyle = C.amber;
+ ctx.setLineDash([3, 3]);
+ ctx.strokeRect(px + 3.5, py + 3.5, T - 7, T - 7);
+ ctx.setLineDash([]);
+ ctx.fillStyle = C.amber;
+ ctx.font = 'bold 13px "Courier New", monospace';
+ ctx.fillText('⛏', px + T / 2 - 6, py + T / 2 + 5);
+ }
+ } else if (tl.t === 'wall') {
+ ctx.fillStyle = C.wall;
+ ctx.fillRect(px, py, T, T);
+ ctx.strokeStyle = C.wallSeam;
+ ctx.strokeRect(px + 0.5, py + 0.5, T - 1, T - 1);
+ ctx.beginPath();
+ ctx.moveTo(px, py + T / 2); ctx.lineTo(px + T, py + T / 2);
+ ctx.stroke();
+ } else { // empty
+ ctx.fillStyle = tl.lit ? '#181307' : '#0c0c0c';
+ ctx.fillRect(px, py, T, T);
+ ctx.strokeStyle = C.grid;
+ ctx.strokeRect(px + 0.5, py + 0.5, T - 1, T - 1);
+ }
+ }
+ }
+
+ // warm glow from lamps
+ for (const d of world.devices) {
+ if (d.kind !== 'lamp' || !d.powered || d.broken) continue;
+ const g = ctx.createRadialGradient(d.x * T + T / 2, d.y * T + T / 2, 4, d.x * T + T / 2, d.y * T + T / 2, T * 4.4);
+ g.addColorStop(0, 'rgba(255,176,0,0.16)');
+ g.addColorStop(1, 'rgba(255,176,0,0)');
+ ctx.fillStyle = g;
+ ctx.fillRect((d.x - 4.5) * T, (d.y - 4.5) * T, T * 10, T * 10);
+ }
+
+ // gas haze
+ for (let y = 0; y < world.H; y++) for (let x = 0; x < world.W; x++) {
+ const tl = world.tiles[y][x];
+ if (tl.t !== 'empty' || tl.gas <= 4) continue;
+ ctx.fillStyle = `rgba(157,255,0,${Math.min(0.35, tl.gas / 260)})`;
+ ctx.fillRect(x * T, y * T, T, T);
+ }
+
+ // devices
+ for (const d of world.devices) drawDevice(d, T);
+
+ // inmates
+ for (const inm of world.inmates) {
+ if (inm.away) continue; // on the surface
+ drawInmate(inm, T, state.selected === inm);
+ }
+
+ // ghost placement
+ if (state.ghost) drawGhost(state.ghost, T, state.world);
+ }
+
+ /** Draw a device and its status using T pixels per tile before canvas scaling. */
+ function drawDevice(d, T) {
+ const px = d.x * T, py = d.y * T;
+ const def = SOP.OBJ[d.kind];
+ if (d.kind === 'airlock') {
+ // surface gate: amber hatch cut into the bedrock ceiling
+ ctx.fillStyle = '#101010';
+ ctx.fillRect(px + 2, py + 2, T - 4, T - 4);
+ ctx.strokeStyle = C.amber;
+ ctx.lineWidth = 1.5;
+ ctx.strokeRect(px + 2.5, py + 2.5, T - 5, T - 5);
+ ctx.setLineDash([3, 2]);
+ ctx.strokeStyle = C.amberDim;
+ ctx.strokeRect(px + 5.5, py + 5.5, T - 11, T - 11);
+ ctx.setLineDash([]);
+ ctx.fillStyle = C.amber;
+ ctx.font = `${T * 0.42}px "Courier New", monospace`;
+ ctx.fillText(def.icon, px + T / 2 - T * 0.2, py + T / 2 + T * 0.15);
+ ctx.font = '8px "Courier New", monospace';
+ ctx.fillStyle = Math.floor(performance.now() / 700) % 2 === 0 ? C.amberDim : C.amberFaint;
+ ctx.fillText('▲ OBERFLÄCHE', px - T * 0.55, py - 4);
+ ctx.lineWidth = 1;
+ return;
+ }
+ if (d.kind === 'gas_vent') {
+ ctx.fillStyle = '#1a2408';
+ ctx.fillRect(px + 4, py + 4, T - 8, T - 8);
+ ctx.fillStyle = C.gas;
+ ctx.font = `${T * 0.55}px "Courier New", monospace`;
+ ctx.fillText(def.icon, px + T / 2 - 7, py + T / 2 + 6);
+ // wisps
+ const t = now / 600;
+ ctx.fillStyle = `rgba(157,255,0,${0.25 + 0.1 * Math.sin(t + d.id)})`;
+ ctx.beginPath();
+ ctx.arc(px + T / 2 + Math.sin(t * 1.7 + d.id) * 5, py - 6 - (t % 1) * 8, 3.5, 0, 7);
+ ctx.fill();
+ return;
+ }
+ // base plate
+ ctx.fillStyle = d.broken ? '#2a0a10' : '#191919';
+ ctx.fillRect(px + 2, py + 2, T - 4, T - 4);
+ ctx.strokeStyle = d.broken ? C.alert : (d.powered ? C.amber : C.amberFaint);
+ if (d.broken && Math.floor(now / 300) % 2 === 0) ctx.strokeStyle = '#7a0018';
+ ctx.strokeRect(px + 2.5, py + 2.5, T - 5, T - 5);
+ ctx.fillStyle = d.broken ? C.alert : (d.powered ? C.amber : C.amberDim);
+ ctx.font = `${T * 0.5}px "Courier New", monospace`;
+ ctx.fillText(def.icon, px + T / 2 - T * 0.24, py + T / 2 + T * 0.18);
+ if (d.broken) {
+ ctx.fillStyle = C.alert;
+ ctx.font = 'bold 10px "Courier New", monospace';
+ ctx.fillText('!', px + T - 10, py + 11);
+ }
+ if (d.kind === 'door') {
+ ctx.fillStyle = d.open ? 'rgba(255,176,0,0.15)' : '#2c2c2c';
+ ctx.fillRect(px + 3, py + 3, T - 6, T - 6);
+ if (!d.open) {
+ ctx.strokeStyle = '#4a4a4a';
+ ctx.beginPath(); ctx.moveTo(px + 5, py + T / 2); ctx.lineTo(px + T - 5, py + T / 2); ctx.stroke();
+ }
+ }
+ if (d.kind === 'dynamo' && d.operatedBy != null) {
+ const t = now / 120;
+ ctx.strokeStyle = C.amber;
+ ctx.beginPath();
+ ctx.arc(px + T / 2, py + T / 2, T * 0.32, t, t + 1.2);
+ ctx.stroke();
+ }
+ }
+
+ /** Draw an inmate at T pixels per tile, including posture and optional selection marks. */
+ function drawInmate(inm, T, selected) {
+ const px = inm.tx * T + T / 2, py = inm.ty * T + T / 2;
+ if (inm.dead) {
+ ctx.fillStyle = '#5a5a5a';
+ ctx.fillRect(px - 8, py + 4, 16, 5);
+ ctx.strokeStyle = '#333';
+ ctx.strokeRect(px - 8.5, py + 3.5, 17, 6);
+ return;
+ }
+ const bob = inm.sleeping ? 0 : Math.sin(inm.walkPhase * 1.4) * 1.2;
+ if (inm.sleeping) {
+ ctx.fillStyle = C.amberDim;
+ ctx.fillRect(px - 8, py + 1, 16, 6);
+ ctx.fillStyle = C.amberFaint;
+ ctx.font = '9px "Courier New", monospace';
+ ctx.fillText('z Z', px + 6, py - 4);
+ } else {
+ // body
+ ctx.fillStyle = inm.affective.stress > 80 ? C.alert : C.amber;
+ ctx.fillRect(px - 4, py - 6 + bob, 8, 12);
+ // head
+ ctx.fillRect(px - 3, py - 12 + bob, 6, 5);
+ }
+ if (selected) {
+ ctx.strokeStyle = C.wire;
+ ctx.setLineDash([2, 2]);
+ ctx.strokeRect(px - 9, py - 15, 18, 24);
+ ctx.setLineDash([]);
+ ctx.fillStyle = C.wire;
+ ctx.font = '8px "Courier New", monospace';
+ ctx.fillText(inm.name, px - 24, py - 18);
+ }
+ }
+
+ /** Draw a placement preview and lamp/sensor range using tile size T and world checks. */
+ function drawGhost(ghost, T, world) {
+ const { x, y, kind } = ghost;
+ if (!world.inGrid(x, y)) return;
+ const ok = kind === 'wall'
+ ? world.tiles[y][x].t === 'empty' && !world.tiles[y][x].obj
+ : !world.canBuildAt(kind, x, y) ? false : true;
+ const err = kind === 'wall' ? null : world.canBuildAt(kind, x, y);
+ const valid = kind === 'wall' ? ok : !err;
+ ctx.globalAlpha = 0.55;
+ ctx.fillStyle = valid ? C.amber : C.alert;
+ ctx.fillRect(x * T + 3, y * T + 3, T - 6, T - 6);
+ ctx.globalAlpha = 1;
+ if (kind === 'lamp' && valid) {
+ ctx.strokeStyle = 'rgba(255,176,0,0.35)';
+ ctx.setLineDash([4, 4]);
+ ctx.beginPath();
+ ctx.arc(x * T + T / 2, y * T + T / 2, T * 5, 0, 7);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+ if (kind === 'sensor' && valid) {
+ ctx.strokeStyle = 'rgba(0,204,255,0.4)';
+ ctx.setLineDash([4, 4]);
+ ctx.beginPath();
+ ctx.arc(x * T + T / 2, y * T + T / 2, T * 2.5, 0, 7);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+ }
+
+ /* ---------------- CROSSLINK LAYER ---------------- */
+ /** Draw the wiring view from state, including power flow, node status, and selection. */
+ function drawCrosslink(state) {
+ const T = SOP.TILE, world = state.world;
+
+ // ghost outline of the bunker
+ for (let y = 0; y < world.H; y++) for (let x = 0; x < world.W; x++) {
+ const tl = world.tiles[y][x];
+ if (tl.t !== 'empty') continue;
+ ctx.strokeStyle = '#0a1418';
+ ctx.strokeRect(x * T + 0.5, y * T + 0.5, T - 1, T - 1);
+ }
+
+ // inmates as faint dots
+ for (const inm of world.inmates) {
+ if (inm.dead || inm.away) continue;
+ ctx.fillStyle = 'rgba(0,204,255,0.35)';
+ ctx.beginPath();
+ ctx.arc(inm.tx * T + T / 2, inm.ty * T + T / 2, 4, 0, 7);
+ ctx.fill();
+ }
+
+ // links
+ for (const [a, b] of world.linksPairs()) {
+ const x1 = a.x * T + T / 2, y1 = a.y * T + T / 2;
+ const x2 = b.x * T + T / 2, y2 = b.y * T + T / 2;
+ const powered = a.powered || b.powered;
+ ctx.strokeStyle = powered ? C.wire : C.wireDim;
+ ctx.lineWidth = powered ? 2 : 1;
+ ctx.beginPath();
+ const mx = (x1 + x2) / 2, my = (y1 + y2) / 2 + 10; // cable sag
+ ctx.moveTo(x1, y1);
+ ctx.quadraticCurveTo(mx, my, x2, y2);
+ ctx.stroke();
+ // pulse dot
+ if (powered) {
+ const t = (now / 900 + a.id * 0.13) % 1;
+ const qx = (1 - t) * (1 - t) * x1 + 2 * (1 - t) * t * mx + t * t * x2;
+ const qy = (1 - t) * (1 - t) * y1 + 2 * (1 - t) * t * my + t * t * y2;
+ ctx.fillStyle = '#bdf4ff';
+ ctx.beginPath(); ctx.arc(qx, qy, 2.4, 0, 7); ctx.fill();
+ }
+ }
+
+ // nodes
+ for (const d of world.devices) {
+ const px = d.x * T + T / 2, py = d.y * T + T / 2;
+ const def = SOP.OBJ[d.kind];
+ const col = d.broken ? C.alert : (d.powered ? C.wire : C.wireDim);
+ ctx.strokeStyle = col;
+ ctx.lineWidth = 1.5;
+ ctx.strokeRect(px - 9, py - 9, 18, 18);
+ ctx.fillStyle = col;
+ ctx.font = '11px "Courier New", monospace';
+ ctx.fillText(def.icon, px - 5, py + 4);
+ // watts label
+ const w = def.watts || 0;
+ if (w !== 0) {
+ ctx.fillStyle = col;
+ ctx.font = '8px "Courier New", monospace';
+ ctx.fillText((w < 0 ? '+' : '−') + Math.abs(w) + 'W', px - 12, py + 19);
+ }
+ if (d.kind === 'sensor' && d.triggered) {
+ ctx.strokeStyle = 'rgba(0,255,102,0.7)';
+ ctx.beginPath();
+ ctx.arc(px, py, 12 + Math.sin(now / 150) * 2, 0, 7);
+ ctx.stroke();
+ }
+ if (state.linkFrom === d.id) {
+ ctx.strokeStyle = C.ok;
+ ctx.setLineDash([3, 3]);
+ ctx.strokeRect(px - 13, py - 13, 26, 26);
+ ctx.setLineDash([]);
+ }
+ if (d.kind === 'gas_vent') {
+ ctx.fillStyle = C.gas;
+ ctx.font = '8px "Courier New", monospace';
+ ctx.fillText('GAS', px - 9, py - 13);
+ }
+ if (d.kind === 'battery') {
+ const f = d.charge / SOP.BATTERY_CAP;
+ ctx.fillStyle = '#04222b';
+ ctx.fillRect(px - 9, py - 16, 18, 4);
+ ctx.fillStyle = f > 0.25 ? C.wire : C.alert;
+ ctx.fillRect(px - 9, py - 16, 18 * f, 4);
+ }
+ }
+ }
+
+ window.addEventListener('resize', resize);
+
+ return { draw, setView, getView, toTile, shake, resize };
+})();
diff --git a/game/style.css b/game/style.css
new file mode 100644
index 0000000..222512b
--- /dev/null
+++ b/game/style.css
@@ -0,0 +1,284 @@
+/* ===========================================================================
+ STANDARD OPERATING PSYCHOPATHY — SEKTOR 4
+ Look: post-apocalyptic institutional terminal × Neue Deutsche Welle.
+ Monochrome CRT phosphor, hard edges, hard sans-serif propaganda accents.
+ =========================================================================== */
+
+:root {
+ --bg-color: #050505;
+ --ui-bg: #111111;
+ --panel: #0d0d0d;
+ --border-color: #333333;
+ --border-hi: #555555;
+ --text-main: #ffb000; /* CRT Amber */
+ --text-dim: #8a5f00;
+ --text-faint: #5c4000;
+ --alert: #ff0033;
+ --wire: #00ccff; /* Crosslink phosphor */
+ --wire-dim: #005a70;
+ --ok: #00ff66;
+ --gas: #9dff00;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ height: 100%;
+ background: var(--bg-color);
+ color: var(--text-main);
+ font-family: 'Courier New', Courier, monospace;
+ overflow: hidden;
+ user-select: none;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+/* CRT scanlines + phosphor bloom. */
+body::after {
+ content: " ";
+ display: block;
+ position: fixed;
+ inset: 0;
+ background:
+ linear-gradient(rgba(18,16,16,0) 50%, rgba(0,0,0,0.25) 50%),
+ linear-gradient(90deg, rgba(255,0,0,0.05), rgba(0,255,0,0.02), rgba(0,0,255,0.05));
+ background-size: 100% 2px, 3px 100%;
+ z-index: 90;
+ pointer-events: none;
+}
+body::before {
+ content: " ";
+ display: block;
+ position: fixed;
+ inset: 0;
+ background: radial-gradient(ellipse at center, rgba(255,176,0,0.035), rgba(0,0,0,0.35) 90%);
+ z-index: 89;
+ pointer-events: none;
+}
+
+/* ---------------- HEADER ---------------- */
+#header {
+ border-bottom: 2px solid var(--border-color);
+ padding: 8px 16px 6px;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ background: var(--ui-bg);
+ gap: 12px;
+ flex-wrap: wrap;
+}
+.brand {
+ display: flex;
+ flex-direction: column;
+ line-height: 1;
+}
+.brand-title {
+ font-family: 'Arial Black', 'Helvetica Neue', Arial, sans-serif;
+ font-weight: 900;
+ font-size: 22px;
+ letter-spacing: 3px;
+ text-transform: uppercase;
+}
+.brand-sub {
+ font-size: 11px;
+ color: var(--text-dim);
+ letter-spacing: 4px;
+ text-transform: uppercase;
+ margin-top: 3px;
+}
+#header-stats {
+ display: flex;
+ gap: 14px;
+ font-size: 12px;
+ letter-spacing: 1px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+#header-stats .stat b { color: var(--text-main); font-weight: bold; }
+#header-stats .stat { color: var(--text-dim); text-transform: uppercase; }
+#header-stats .stat.crit b { color: var(--alert); animation: blink 0.8s steps(2) infinite; }
+.view-buttons { display: flex; gap: 6px; align-items: center; }
+.view-buttons .lbl { color: var(--text-dim); font-size: 11px; text-transform: uppercase; letter-spacing: 1px; margin-right: 2px; }
+
+.btn {
+ background: transparent;
+ color: var(--text-main);
+ border: 1px solid var(--text-main);
+ padding: 6px 10px;
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ border-radius: 0;
+}
+.btn:hover { background: var(--text-main); color: var(--bg-color); }
+.btn.active { background: var(--text-main); color: var(--bg-color); }
+.btn.wire { color: var(--wire); border-color: var(--wire); }
+.btn.wire:hover, .btn.wire.active { background: var(--wire); color: #001018; }
+.btn.danger { color: var(--alert); border-color: var(--alert); }
+.btn.danger:hover { background: var(--alert); color: var(--bg-color); }
+.btn:disabled { opacity: 0.35; cursor: default; }
+.btn:disabled:hover { background: transparent; color: inherit; }
+.btn.pause-on { background: var(--alert); color: var(--bg-color); border-color: var(--alert); }
+
+/* ---------------- LAYOUT ---------------- */
+#main-view { display: flex; flex: 1; min-height: 0; }
+
+#canvas-container {
+ flex: 1;
+ position: relative;
+ background: #0a0a0a;
+ border-right: 2px solid var(--border-color);
+ min-width: 0;
+}
+canvas { width: 100%; height: 100%; display: block; image-rendering: pixelated; }
+
+#inspector {
+ width: 352px;
+ background: var(--ui-bg);
+ padding: 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ font-size: 12.5px;
+ overflow-y: auto;
+ border-left: 1px solid var(--border-color);
+}
+
+.panel-section { border: 1px solid var(--border-color); padding: 10px; background: var(--panel); }
+.panel-title {
+ color: var(--text-dim);
+ margin-bottom: 8px;
+ border-bottom: 1px dashed var(--text-dim);
+ padding-bottom: 4px;
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ font-size: 11px;
+}
+
+/* Build menu grid */
+#build-menu { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
+.build-item {
+ border: 1px solid var(--border-color);
+ background: var(--ui-bg);
+ color: var(--text-main);
+ font-family: inherit;
+ font-size: 11px;
+ text-align: left;
+ padding: 6px;
+ cursor: pointer;
+ letter-spacing: 0.5px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ text-transform: uppercase;
+}
+.build-item .ico { font-size: 14px; width: 16px; text-align: center; }
+.build-item:hover { border-color: var(--text-main); background: #1a1a1a; }
+.build-item.selected { background: var(--text-main); color: var(--bg-color); border-color: var(--text-main); }
+.build-item.selected .watt { color: var(--bg-color); }
+.build-item .watt { color: var(--text-dim); margin-left: auto; font-size: 10px; }
+
+#build-hint { color: var(--text-faint); font-size: 11px; margin-top: 6px; min-height: 28px; }
+
+/* Inspector rows */
+.kv { display: flex; justify-content: space-between; gap: 8px; }
+.kv .k { color: var(--text-dim); }
+.needrow { margin-bottom: 5px; }
+.needrow .lbl { display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 2px; }
+.bar { height: 8px; border: 1px solid var(--border-color); background: #000; }
+.bar > div { height: 100%; background: var(--text-main); }
+.bar > div.warn { background: var(--alert); }
+.directive { color: var(--alert); }
+.directive-line { margin-top: 6px; border-top: 1px dashed var(--border-color); padding-top: 6px; }
+
+#inspector .empty { color: var(--text-dim); font-style: italic; }
+
+/* Event log */
+#event-log { height: 170px; overflow-y: auto; font-size: 11px; line-height: 1.5; }
+#event-log .t { color: var(--text-faint); }
+#event-log .l-alert { color: var(--alert); }
+#event-log .l-ok { color: var(--text-main); }
+#event-log .l-wire { color: var(--wire); }
+#event-log .l-dim { color: var(--text-dim); }
+
+/* ---------------- BROADCAST STRIP ---------------- */
+#broadcast {
+ border-top: 2px solid var(--border-color);
+ background: #000;
+ overflow: hidden;
+ height: 34px;
+ display: flex;
+ align-items: center;
+ position: relative;
+}
+#broadcast .tag {
+ background: var(--alert);
+ color: #000;
+ font-family: 'Arial Black', Arial, sans-serif;
+ font-weight: 900;
+ font-size: 12px;
+ letter-spacing: 2px;
+ padding: 4px 10px;
+ z-index: 2;
+ white-space: nowrap;
+}
+#ticker {
+ white-space: nowrap;
+ font-family: 'Arial Black', 'Helvetica Neue', Arial, sans-serif;
+ font-weight: 900;
+ font-size: 15px;
+ letter-spacing: 3px;
+ text-transform: uppercase;
+ color: var(--text-main);
+ padding-left: 20px;
+ animation: tickmove 40s linear infinite;
+}
+@keyframes tickmove { from { transform: translateX(60vw); } to { transform: translateX(-100%); } }
+
+/* ---------------- OVERLAYS ---------------- */
+.overlay {
+ position: fixed; inset: 0;
+ background: rgba(0,0,0,0.82);
+ z-index: 60;
+ display: none;
+ align-items: center;
+ justify-content: center;
+}
+.overlay.show { display: flex; }
+.dialog {
+ width: 640px;
+ max-width: 92vw;
+ background: var(--ui-bg);
+ border: 2px solid var(--text-main);
+ padding: 22px 26px;
+}
+.dialog h1 {
+ font-family: 'Arial Black', 'Helvetica Neue', Arial, sans-serif;
+ font-weight: 900;
+ letter-spacing: 4px;
+ text-transform: uppercase;
+ font-size: 26px;
+ margin: 0 0 4px;
+}
+.dialog .sub { color: var(--text-dim); letter-spacing: 3px; text-transform: uppercase; font-size: 11px; margin-bottom: 14px; }
+.dialog p { font-size: 13px; line-height: 1.55; }
+.dialog .keys { color: var(--text-dim); font-size: 12px; line-height: 1.7; margin: 12px 0; }
+.dialog .keys b { color: var(--text-main); font-weight: bold; }
+.dialog .foot { margin-top: 16px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
+.dialog.gameover { border-color: var(--alert); }
+.dialog.gameover h1 { color: var(--alert); }
+
+@keyframes blink { 50% { opacity: 0.25; } }
+.blink { animation: blink 0.8s steps(2) infinite; }
+
+::-webkit-scrollbar { width: 8px; height: 8px; }
+::-webkit-scrollbar-track { background: #0a0a0a; }
+::-webkit-scrollbar-thumb { background: #333; border: 1px solid #1a1a1a; }
+::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
diff --git a/game/tools/dom_test.js b/game/tools/dom_test.js
new file mode 100644
index 0000000..c3884dc
--- /dev/null
+++ b/game/tools/dom_test.js
@@ -0,0 +1,73 @@
+/* DOM-level smoke test: loads index.html through jsdom, stubs the canvas 2D
+ context, boots the real game (setup → intro click → sim ticks) and checks
+ that nothing throws and state advances. */
+const { JSDOM } = require('jsdom');
+const fs = require('fs');
+const path = require('path');
+
+const root = path.join(__dirname, '..');
+const html = fs.readFileSync(path.join(root, 'index.html'), 'utf8');
+const dom = new JSDOM(html, { runScripts: 'outside-only', pretendToBeVisual: true });
+const { window } = dom;
+
+// ---- canvas 2D stub ----
+const gradient = { addColorStop() {} };
+const ctxStub = new Proxy({}, {
+ get(t, key) {
+ if (key === 'createRadialGradient' || key === 'createLinearGradient') return () => gradient;
+ if (key === 'measureText') return () => ({ width: 10 });
+ if (typeof key === 'symbol') return undefined;
+ return () => {};
+ },
+ set() { return true; },
+});
+window.HTMLCanvasElement.prototype.getContext = function () { return ctxStub; };
+// layout stubs
+Object.defineProperty(window.HTMLElement.prototype, 'clientWidth', { get() { return 900; } });
+Object.defineProperty(window.HTMLElement.prototype, 'clientHeight', { get() { return 600; } });
+window.HTMLCanvasElement.prototype.getBoundingClientRect = () => ({ left: 0, top: 0, width: 900, height: 600 });
+
+// ---- load game scripts in order ----
+for (const f of ['config.js', 'audio.js', 'world.js', 'ai.js', 'render.js', 'ui.js', 'main.js']) {
+ const src = fs.readFileSync(path.join(root, f), 'utf8');
+ window.eval(src);
+}
+
+// ---- boot ----
+window.document.dispatchEvent(new window.Event('DOMContentLoaded', { bubbles: true }));
+
+setTimeout(() => {
+ const SOP = window.SOP;
+ const errs = [];
+ const assert = (cond, msg) => { if (!cond) errs.push(msg); };
+
+ assert(SOP.World.inmates.length === 3, 'three inmates registered');
+ assert(SOP.World.devices.length >= 9, 'starter devices placed');
+
+ // click through the intro
+ const btn = window.document.getElementById('btn-start');
+ assert(btn, 'btn-start exists');
+ btn.click();
+
+ // simulate some canvas interactions (mode toggles, build select)
+ window.document.getElementById('btn-crosslink').click();
+ window.document.getElementById('btn-physical').click();
+ const digBtn = window.document.querySelector('.build-item[data-kind="dig"]');
+ assert(digBtn, 'dig build item exists');
+ digBtn.click();
+ window.document.getElementById('btn-pause').click();
+ window.document.getElementById('btn-pause').click();
+
+ // let the sim run a while
+ setTimeout(() => {
+ const w = SOP.World;
+ assert(w.inmates.some(i => i.currentAction.name !== 'IDLE'), 'inmates acting');
+ assert(window.document.getElementById('event-log').children.length >= 2, 'log populated');
+ assert(window.document.getElementById('ticker').textContent.length > 0, 'ticker running');
+ assert(window.document.getElementById('sys-inmates').textContent === '3', 'HUD inmates');
+ console.log('day:', SOP.Main.day(), '| inmates:', w.inmates.map(i => i.name + ':' + i.currentAction.name).join(' '));
+ if (errs.length) { console.error('DOM TEST FAILED:\n' + errs.join('\n')); process.exit(1); }
+ console.log('=== DOM TEST OK ===');
+ process.exit(0);
+ }, 4000);
+}, 200);
diff --git a/game/tools/smoke.js b/game/tools/smoke.js
new file mode 100644
index 0000000..268f530
--- /dev/null
+++ b/game/tools/smoke.js
@@ -0,0 +1,292 @@
+/* Headless smoke test: runs the real simulation (world + AI) without a DOM.
+ Usage: node game/tools/smoke.js [ticks] */
+global.window = global;
+require('../config.js');
+require('../world.js');
+require('../ai.js');
+
+const world = SOP.World;
+const ticks = parseInt(process.argv[2] || '4000', 10);
+
+world.generate();
+const traits = ['Pyromane', 'Paranoid', 'Befehlsfixiert'];
+const spots = [[7, 9], [10, 8], [13, 11]];
+world.inmates = spots.map((s, i) => new SOP.InmateAI('SUBJEKT-' + (400 + i), traits[i], s[0], s[1]));
+
+const events = {};
+const count = t => { events[t] = (events[t] || 0) + 1; };
+const smokeState = {};
+
+const actHist = {};
+let nextQuakeTick = 600;
+
+/* a diligent Schichtleiter keeps 10 frontier dig marks in the Blackboard */
+function refillMarks() {
+ const marks = world.Blackboard.digMarks.length;
+ let need = 10 - marks;
+ const frontier = [];
+ for (let y = SOP.DIG_TOP; y < world.H - 1 && need > 0; y++) for (let x = 1; x < world.W - 1; x++) {
+ if (world.tiles[y][x].t !== 'dirt') continue;
+ if ([[1,0],[-1,0],[0,1],[0,-1]].some(([dx, dy]) => world.tiles[y + dy][x + dx].t === 'empty' && !world.tiles[y + dy][x + dx].obj)) {
+ frontier.push([x, y]);
+ }
+ }
+ while (need-- > 0 && frontier.length) {
+ const i = Math.floor(Math.random() * frontier.length);
+ const [x, y] = frontier.splice(i, 1)[0];
+ world.orderDig(x, y);
+ }
+}
+function quake(t) {
+ const fragile = world.devices.filter(d => d.kind !== 'gas_vent' && !d.broken);
+ const hits = 1 + (Math.random() < 0.4 ? 1 : 0);
+ for (let i = 0; i < hits && fragile.length; i++) {
+ const idx = Math.floor(Math.random() * fragile.length);
+ const d = fragile.splice(idx, 1)[0];
+ d.broken = true;
+ console.log(`[t=${t}] QUAKE breaks ${d.kind} @ (${d.x},${d.y})`);
+ }
+ world.inmates.forEach(i => {
+ if (i.dead) return;
+ i.affective.stress = Math.min(100, i.affective.stress + 12);
+ i.affective.paranoia = Math.min(100, i.affective.paranoia + (i.trait === 'Paranoid' ? 22 : 8));
+ i.sleeping = false;
+ });
+}
+for (let t = 0; t < ticks; t++) {
+ if (t >= nextQuakeTick) { quake(t); nextQuakeTick = t + 380 + Math.floor(Math.random() * 260); }
+ if (t % 1000 === 0) {
+ const stale = world.Blackboard.digMarks.filter(m => {
+ if (world.tiles[m.y][m.x].t !== 'dirt') return true;
+ return ![[1,0],[-1,0],[0,1],[0,-1]].some(([dx,dy]) => world.walkable(m.x+dx, m.y+dy));
+ }).length;
+ const emptyNow = world.tiles.flat().filter(x => x.t === 'empty').length;
+ console.log(` [dig t=${t}] marks=${world.Blackboard.digMarks.length} stale=${stale} empty=${emptyNow}`);
+ const alive = world.inmates.filter(i => !i.dead);
+ const habGas = world.tiles[9][9].gas.toFixed(1), techGas = world.tiles[10][25].gas.toFixed(1);
+ const vents = world.devices.filter(d => d.kind === 'gas_vent').map(v => `${v.x},${v.y}`).join(' ');
+ console.log(`[t=${t}] marks=${world.Blackboard.digMarks.length} alive=${alive.length} ` +
+ alive.map(i => `${i.name.split('-')[1]}:${i.currentAction.name}`).join(' ') +
+ ` habGas=${habGas} techGas=${techGas} vents=[${vents}]`);
+ }
+ world.tick(e => { count('world:' + e.type); if (e.type === 'harvest') smokeState.harvests = (smokeState.harvests || 0) + 1; });
+ for (const inm of world.inmates) {
+ if (inm.dead) continue;
+ if (t % 4 === 0) {
+ inm.evaluateDrives(world);
+ actHist[inm.currentAction.name] = (actHist[inm.currentAction.name] || 0) + 1;
+ }
+ const wasDead = inm.dead;
+ inm.tick(world, e => {
+ count(e.type);
+ // bursts are transient; no countermove needed beyond avoiding the tile
+ if (e.type === 'gasburst') console.log('[autopilot] gas burst @', t, e.x, e.y);
+ });
+ if (!wasDead && inm.dead) console.log(`[t=${t}] DEATH ${inm.name} (${inm.deadCause}) h=${inm.affective.hunger.toFixed(0)} xh=${inm.affective.exhaustion.toFixed(0)} o2=${inm.affective.o2.toFixed(0)} pos=${inm.tx},${inm.ty}`);
+ }
+ // player simulation: keep 10 frontier dig marks adjacent to open space
+ if (t % 60 === 0) refillMarks();
+ // redundancy: a second Ausgabe so one quake can't cut off all food
+ if (t === 2000 && !smokeState.disp2) {
+ for (const [x, y] of [[14, 8], [13, 8], [14, 9], [12, 10]]) {
+ if (world.tryBuild('dispenser', x, y).ok) {
+ const dp = world.devices.find(d => d.kind === 'dispenser' && d.x === x && d.y === y);
+ const l1 = world.devices.find(d => d.kind === 'lamp' && d.x === 8);
+ world.addLink(dp, l1);
+ smokeState.disp2 = true;
+ console.log('[autopilot] dispenser-2 built+wired @', t);
+ break;
+ }
+ }
+ }
+
+ // ===== autopilot build order (a competent Schichtleiter) =====
+ const linkAnchor = () => world.devices.find(d => d.kind === 'lamp' && d.x === 8); // HAB Lampe 1
+ const buildAndWire = (kind, spots, tag) => {
+ for (const [x, y] of spots) {
+ if (world.tryBuild(kind, x, y).ok) {
+ const dev = world.devices.find(d => d.kind === kind && d.x === x && d.y === y);
+ world.addLink(dev, linkAnchor());
+ console.log(`[autopilot] ${tag} @ (${x},${y}) t=${t}`);
+ return true;
+ }
+ }
+ return false;
+ };
+
+ // 1) food: farm one
+ if (!smokeState.farm1 && t > 240 && t % 25 === 0) {
+ if (buildAndWire('farm', [[9, 6], [12, 6], [14, 6], [10, 8], [9, 8], [7, 6]], 'FARM-1')) smokeState.farm1 = true;
+ }
+ // 2) baseload power: an ÖL generator inside HAB; retire the TECHNIK pair
+ if (!smokeState.habGen && t > 400 && t % 25 === 0) {
+ if (buildAndWire('generator', [[10, 6], [9, 9], [12, 8], [14, 8], [7, 8]], 'GENERATOR (HAB)')) {
+ smokeState.habGen = true;
+ for (const d of world.devices) {
+ if (d.x > 20 && (d.kind === 'generator' || d.kind === 'scrubber')) d.enabled = false;
+ }
+ console.log('[autopilot] TECHNIK grid retired (spart Öl)');
+ }
+ }
+ // 3) air: HAB washer
+ if (smokeState.habGen && !smokeState.habScr && t % 25 === 0) {
+ if (buildAndWire('scrubber', [[10, 9], [9, 10], [12, 10], [8, 10], [10, 10]], 'WÄSCHER (HAB)')) {
+ smokeState.habScr = true;
+ smokeState.wallsWanted = smokeState.wallsWanted || [[16, 9], [16, 10], [20, 9], [20, 10]];
+ }
+ }
+ // 4) food scale: farm two
+ if (smokeState.habScr && !smokeState.farm2 && t % 25 === 0) {
+ if (buildAndWire('farm', [[12, 9], [13, 8], [14, 9], [13, 6], [7, 10]], 'FARM-2')) smokeState.farm2 = true;
+ }
+ // 5) buffer battery against quake knock-outs
+ if (t > 300 && !smokeState.battery) {
+ const dyn = world.devices.find(d => d.kind === 'dynamo');
+ for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[2,0],[0,2]]) {
+ const r = world.tryBuild('battery', dyn.x + dx, dyn.y + dy);
+ if (r.ok) {
+ const bat = world.devices.find(d => d.kind === 'battery');
+ world.addLink(bat, linkAnchor());
+ smokeState.battery = true;
+ console.log('[autopilot] AKKU built+wired @', t);
+ break;
+ }
+ }
+ }
+
+ // retry corridor walls until sealed (subjects may stand in the way)
+ if (smokeState.wallsWanted && smokeState.wallsWanted.length && t % 10 === 0) {
+ smokeState.wallsWanted = smokeState.wallsWanted.filter(([x, y]) => {
+ if (world.tiles[y][x].t === 'wall') return false;
+ const r = world.tryBuildWall(x, y);
+ if (r.ok) console.log('[autopilot] wall sealed', x, y, '@', t);
+ return !r.ok;
+ });
+ }
+}
+
+/* ================= PHASE 2: FEATURE TESTS =================
+ Expeditions, arrivals and save/load — driven through the real
+ main.js orchestration with a stub DOM (no canvas needed). */
+
+const assert = (cond, msg) => { if (!cond) { console.error('ASSERT FAIL:', msg); process.exit(1); } };
+const fakeEl = () => ({
+ innerHTML: '', textContent: '', style: {}, hidden: false, value: '', files: null,
+ classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
+ addEventListener() {}, click() {}, appendChild() {}, remove() {},
+ querySelectorAll: () => [], querySelector: () => null,
+});
+global.document = {
+ getElementById: () => fakeEl(),
+ createElement: () => fakeEl(),
+ querySelector: () => null,
+ querySelectorAll: () => [],
+ addEventListener() {},
+ body: fakeEl(),
+};
+SOP.Render = { getView: () => 'PHYSICAL', shake() {}, draw() {}, toTile() {}, setView() {} };
+SOP.Audio = SOP.Audio || { on: false, toggle() { return false; }, alarm() {} };
+global.addEventListener = () => {};
+require('../ui.js');
+require('../main.js');
+
+console.log('--- PHASE 2: airlock / expedition / arrivals / save-load ---');
+
+// the Schichtleiter requisitions material for the airlock + the growing roster
+world.res.metall = Math.max(world.res.metall, 60);
+world.res.beton = Math.max(world.res.beton, 40);
+world.res.oel = Math.max(world.res.oel, 100);
+world.res.rationen = Math.max(world.res.rationen, 150);
+
+// 1) place the single airlock in the bedrock ceiling over an open tile
+let airBuilt = false;
+for (let x = 2; x < world.W - 2 && !airBuilt; x++) {
+ const r = world.tryBuild('airlock', x, SOP.DIG_TOP - 1);
+ if (r.ok) { airBuilt = true; console.log(`[phase2] LUFTSCHLEUSE @ (${x},${SOP.DIG_TOP - 1})`); }
+}
+assert(airBuilt, 'airlock could not be placed');
+assert(!world.tryBuild('airlock', 5, SOP.DIG_TOP - 1).ok, 'second airlock must be rejected');
+
+// 2) launch an expedition through main.js and wait for board + return
+SOP.Main.headlessStart();
+const expSubject = world.inmates.find(i => !i.dead && !i.away);
+assert(expSubject, 'no expedition candidate');
+SOP.Main.startExpedition(expSubject.name);
+assert(expSubject.boarding && expSubject.goal && expSubject.goal.act === 'expedition', 'startExpedition did not board');
+
+// the roster is growing — a competent Schichtleiter expands food/power/air
+const p2 = {};
+function buildAndWire2(kind, spots, tag, s) {
+ for (const [x, y] of spots) {
+ if (world.tryBuild(kind, x, y).ok) {
+ const dev = world.devices.find(d => d.kind === kind && d.x === x && d.y === y);
+ const anchor = world.devices.find(d => d.id !== dev.id && d.links.length && !d.broken);
+ if (anchor) world.addLink(dev, anchor);
+ console.log(`[phase2] ${tag} @ (${x},${y}) s=${s}`);
+ return true;
+ }
+ }
+ return false;
+}
+
+let boarded = false, returned = false;
+for (let s = 0; s < 7000; s++) {
+ if (s % 60 === 0) refillMarks(); // digging must go on (ÖL!)
+ if (s % 25 === 0) {
+ if (!p2.farm3 && buildAndWire2('farm', [[13, 10], [14, 10], [7, 10], [11, 10], [9, 8]], 'FARM-3', s)) p2.farm3 = true;
+ if (!p2.gen2 && buildAndWire2('generator', [[12, 10], [13, 6], [7, 6], [11, 8]], 'GEN-2', s)) p2.gen2 = true;
+ if (!p2.scr2 && buildAndWire2('scrubber', [[12, 8], [8, 8], [14, 6], [10, 10]], 'SCRUB-2', s)) p2.scr2 = true;
+ if (!p2.radio && buildAndWire2('radio', [[14, 8], [12, 6], [8, 6], [11, 6]], 'FUNKTURM', s)) p2.radio = true;
+ }
+ SOP.Main.step();
+ if (expSubject.away) boarded = true;
+ else if (boarded) returned = true;
+}
+assert(boarded, 'subject never boarded (away)');
+assert(returned, 'subject never returned');
+assert(SOP.Main.stats.expeditions === 1, 'expedition stat not counted');
+console.log(`[phase2] EXPEDITION OK — zurück @ t=${SOP.Main.ticks}, res=${JSON.stringify(world.res)}`);
+
+// 3) arrivals happened during those 7000 steps (first one due by tick ~2200-3800)
+// — unless the colony died before the first arrival window could open
+const survivorsNow = world.inmates.filter(i => !i.dead).length;
+assert(SOP.Main.stats.arrivals >= 1 || survivorsNow === 0,
+ 'no newcomer arrived although colony alive');
+assert(world.inmates.filter(i => !i.dead).length <= SOP.MAX_INMATES, 'colony exceeds MAX_INMATES');
+if (survivorsNow === 0) console.log('[phase2] colony died before arrivals — mechanic verified on long runs');
+console.log(`[phase2] ARRIVALS OK — n=${SOP.Main.stats.arrivals}, roster=${world.inmates.filter(i => !i.dead).map(i => i.name).join(',')}`);
+
+// 4) save → corrupt → load → continue
+const snap = SOP.Main.saveData();
+const wire = JSON.stringify(snap); // full JSON roundtrip
+world.res.metall = 0; world.res.oel = 0;
+world.tiles[8][8].o2 = 0; world.tiles[8][8].gas = 90;
+const deadBefore = world.inmates.filter(i => i.dead).length;
+assert(SOP.Main.loadData(JSON.parse(wire)), 'loadGame rejected the save');
+assert(world.res.oel === snap.world.res.oel, 'res not restored');
+assert(world.tiles[8][8].gas < 50, 'tile gas not restored');
+assert(world.inmates.length === snap.inmates.length, 'roster size changed on load');
+assert(world.devices.some(d => d.kind === 'airlock'), 'airlock lost on load');
+for (let s = 0; s < 800; s++) SOP.Main.step(); // sim continues after load
+assert(world.inmates.filter(i => i.dead).length >= deadBefore, 'sanity');
+console.log(`[phase2] SAVE/LOAD OK — weitergespielt bis t=${SOP.Main.ticks}`);
+
+console.log('=== PHASE 2 OK ===');
+
+// report
+const empty = world.tiles.flat().filter(t => t.t === 'empty').length;
+const avgO2 = world.tiles.flat().filter(t => t.t === 'empty').reduce((a, t) => a + t.o2, 0) / Math.max(1, empty);
+const avgGas = world.tiles.flat().filter(t => t.t === 'empty').reduce((a, t) => a + t.gas, 0) / Math.max(1, empty);
+
+console.log('=== SMOKE REPORT ===');
+console.log('ticks:', ticks);
+console.log('inmates:', world.inmates.map(i => `${i.name} ${i.dead ? 'DEAD(' + i.deadCause + ')' : 'alive'} hunger=${i.affective.hunger.toFixed(0)} stress=${i.affective.stress.toFixed(0)} o2need=${i.affective.o2.toFixed(0)} act=${i.currentAction.name}`).join('\n '));
+console.log('power:', JSON.stringify(world.power));
+console.log('res:', JSON.stringify(world.res));
+console.log('empty tiles:', empty, '| avg O2:', avgO2.toFixed(1), '| avg gas:', avgGas.toFixed(2));
+console.log('dig marks left:', world.Blackboard.digMarks.length);
+console.log('devices:', world.devices.length, '| links:', world.linksPairs().length);
+console.log('events:', JSON.stringify(events));
+console.log('action histogram:', JSON.stringify(actHist));
+console.log('actions seen:', [...new Set(world.inmates.map(i => i.currentAction.name))].join(', '));
+console.log('=== SMOKE OK (no exceptions) ===');
diff --git a/game/ui.js b/game/ui.js
new file mode 100644
index 0000000..0746db3
--- /dev/null
+++ b/game/ui.js
@@ -0,0 +1,267 @@
+/* ===========================================================================
+ UI — inspector panels, build menu, event log, overlays.
+ =========================================================================== */
+window.SOP = window.SOP || {};
+
+SOP.UI = (() => {
+ const $ = id => document.getElementById(id);
+
+ const el = {
+ sysPower: $('sys-power'),
+ sysO2: $('sys-o2'),
+ sysInmates: $('sys-inmates'),
+ sysRes: $('sys-res'),
+ sysDay: $('sys-day'),
+ inspector: $('ai-inspector'),
+ devInspector: $('device-inspector'),
+ buildMenu: $('build-menu'),
+ buildHint: $('build-hint'),
+ log: $('event-log'),
+ ticker: $('ticker'),
+ overlayIntro: $('overlay-intro'),
+ overlayOver: $('overlay-over'),
+ overStats: $('over-stats'),
+ };
+
+ let logLines = [];
+
+ /* ---------------- EVENT LOG ---------------- */
+ function log(msg, cls) {
+ const day = SOP.Main ? SOP.Main.day() : 0;
+ logLines.push({ msg, cls: cls || 'ok', day });
+ if (logLines.length > 70) logLines.shift();
+ el.log.innerHTML = logLines.map(l =>
+ `T${l.day} ${l.msg}
`
+ ).reverse().join('');
+ }
+
+ function alert(msg) {
+ log(msg, 'alert');
+ if (SOP.Audio.on) SOP.Audio.alarm();
+ }
+
+ function wire(msg) { log(msg, 'wire'); }
+
+ /* ---------------- TICKER ---------------- */
+ function setTicker(extra) {
+ const lines = extra ? [extra, ...SOP.TICKER_LINES] : SOP.TICKER_LINES;
+ el.ticker.textContent = lines.join(' /// ') + ' /// ';
+ if (extra) {
+ el.ticker.style.color = 'var(--alert)';
+ setTimeout(() => { el.ticker.style.color = ''; }, 6000);
+ }
+ }
+
+ /* ---------------- BUILD MENU ---------------- */
+ function initBuildMenu(onSelect) {
+ let html = '';
+ for (const kind of SOP.BUILDABLE) {
+ const d = SOP.OBJ[kind];
+ const w = d.watts ? `${d.watts < 0 ? '+' : '−'}${Math.abs(d.watts)}W` : '·';
+ html += `
+ ${d.icon} ${d.label}${w} `;
+ }
+ html += `
+ ▬ WAND−4B `;
+ html += `
+ ⛏ GRABENAUFTRAG `;
+ el.buildMenu.innerHTML = html;
+ el.buildMenu.querySelectorAll('.build-item').forEach(b => {
+ b.addEventListener('click', () => {
+ el.buildMenu.querySelectorAll('.build-item').forEach(x => x.classList.remove('selected'));
+ b.classList.add('selected');
+ onSelect(b.dataset.kind);
+ const kind = b.dataset.kind;
+ if (kind === 'dig') el.buildHint.textContent = 'AUFTRAG: ERDFELD ANKLICKEN. SUBJEKTE GRABEN SELBST.';
+ else if (kind === 'wall') el.buildHint.textContent = 'WAND: HOHLRAUM ANKLICKEN. BLOCKIERT GAS & WEGE.';
+ else el.buildHint.textContent = SOP.OBJ[kind].hint + ' — ' + costText(SOP.OBJ[kind].cost);
+ });
+ });
+ el.buildHint.textContent = 'MODUL WÄHLEN, DANN IM HOHLRAUM PLATZIEREN. ESC BRICHT AB.';
+ }
+
+ function costText(cost) {
+ const parts = [];
+ for (const k in cost) parts.push(cost[k] + ' ' + k.toUpperCase());
+ return parts.length ? 'KOSTET ' + parts.join(', ') : 'GRATIS';
+ }
+
+ function clearBuildSelection() {
+ el.buildMenu.querySelectorAll('.build-item').forEach(x => x.classList.remove('selected'));
+ }
+
+ /* ---------------- SYSTEM PANEL ---------------- */
+ function updateSystem(world, day) {
+ el.sysDay.textContent = day;
+ const p = world.power;
+ const cls = p.deficit > 0 ? 'crit' : '';
+ const powerHtml = `${Math.round(p.gen)}W/${Math.round(p.draw)}W ${p.deficit > 0 ? 'DEFIZIT ' : 'OK'}`;
+ el.sysPower.innerHTML = `${powerHtml} `;
+ const det = $('sys-power-detail');
+ if (det) det.innerHTML = `${Math.round(p.gen)} W erzeugt / ${Math.round(p.draw)} W Last · ${p.components} Netz(e) · AKKU ${Math.round(p.stored || 0)}${p.deficit > 0 ? ' · DEFIZIT ' + Math.round(p.deficit) + ' W ' : ''}`;
+ // average O2 over empty tiles
+ let o2 = 0, n = 0, gas = 0;
+ for (let y = 0; y < world.H; y++) for (let x = 0; x < world.W; x++) {
+ const t = world.tiles[y][x];
+ if (t.t === 'empty') { o2 += t.o2; gas += t.gas; n++; }
+ }
+ const avg = n ? o2 / n : 0, avgGas = n ? gas / n : 0;
+ el.sysO2.innerHTML = `Ø ${avg.toFixed(1)}% ${avgGas > 3 ? `· GAS ${avgGas.toFixed(1)}` : ''}`;
+ el.sysO2.className = avg < 40 ? 'crit' : '';
+ el.sysInmates.textContent = world.inmates.filter(i => !i.dead).length;
+ el.sysRes.innerHTML =
+ `B:${world.res.beton} ` +
+ `M:${world.res.metall} ` +
+ `R:${world.res.rationen} ` +
+ `Ö:${world.res.oel} `;
+ }
+
+ /* ---------------- SUBJECT INSPECTOR ---------------- */
+ const NEED_LABEL = { stress: 'STRESS', hunger: 'HUNGER', exhaustion: 'ERSCHÖPFUNG', paranoia: 'ARGWOHN', o2: 'O₂-MANGEL' };
+
+ function updateInspector(world, selected) {
+ if (!selected || selected.dead) {
+ el.inspector.innerHTML = `SUBJEKT-INSPEKTOR
+ ${selected && selected.dead
+ ? selected.name + ' AUSGEFALLEN (' + selected.deadCause + '). AKTE GESCHLOSSEN.'
+ : 'Subjekt im Raster anklicken, um affektive und konative Ströme zu überwachen.'}
`;
+ return;
+ }
+ if (selected.away) {
+ el.inspector.innerHTML = `
+ SUBJEKT-INSPEKTOR
+ ID ${selected.name}
+ PROFIL ${selected.trait}
+ AUSSERHAUS — OBERFLÄCHE
+
Signal schwach. Kein konativer Strom verfügbar.
`;
+ return;
+ }
+ const A = selected.affective;
+ let bars = '';
+ for (const k of SOP.NEEDS) {
+ const v = Math.max(0, Math.min(100, A[k]));
+ const warn = v > 75 ? ' warn' : '';
+ bars += `${NEED_LABEL[k]} ${v.toFixed(1)}%
+
`;
+ }
+ const act = selected.currentAction;
+ el.inspector.innerHTML = `
+ SUBJEKT-INSPEKTOR
+ ID ${selected.name}
+ PROFIL ${selected.trait}
+ VITAL ${selected.hp.toFixed(0)} / 100
+ // AFFEKTIVER ZUSTAND
+ ${bars}
+
+
// KONATIVER PROZESSOR
+
DIREKTIVE: ${act.name}
+
NUTZWERT ${act.score.toFixed(1)}
+
GEDANKE „${selected.thought}"
+
`;
+ }
+
+ /* ---------------- DEVICE INSPECTOR (crosslink / airlock) ---------------- */
+ let devSig = '';
+
+ function updateDevicePanel(world, dev, mode, ticksNow) {
+ const awaySig = world.inmates.filter(i => !i.dead && i.away)
+ .map(i => i.name + ':' + i.away.returnTick).join(',')
+ + '#' + world.inmates.filter(i => !i.dead && !i.away && !i.boarding).map(i => i.name).join(',');
+ const sig = dev
+ ? [dev.id, dev.kind, dev.powered, dev.broken, dev.enabled, dev.links.length,
+ dev.kind === 'battery' ? Math.floor((dev.charge || 0) / 50) : 0,
+ dev.kind === 'generator' ? (dev.fueled === false ? 1 : 0) : 0,
+ mode, awaySig,
+ dev.kind === 'airlock' ? Math.floor((ticksNow || 0) / 40) : 0].join('|')
+ : 'none|' + mode;
+ if (sig === devSig) return;
+ devSig = sig;
+
+ if (!dev) {
+ el.devInspector.innerHTML = `KNOTEN-INSPEKTOR
+ ${mode === 'CROSSLINK' ? 'Knoten anklicken. Zwei Knoten = Leitung.' : 'Modul anklicken, um Details zu sehen.'}
`;
+ return;
+ }
+ const def = SOP.OBJ[dev.kind];
+ let html = `
+ ${dev.kind === 'airlock' ? 'SCHLEUSEN-STEUERUNG' : 'KNOTEN-INSPEKTOR'}
+ TYP ${def.label}
+ LAST ${def.watts ? (def.watts < 0 ? '−' + Math.abs(def.watts) + ' W (ERZEUGER)' : '+' + def.watts + ' W') : 'PASSIV'}
+ STATUS ${dev.broken ? 'DEFEKT' : dev.powered ? 'AKTIV' : 'OHNE STROM'}
+ SCHALTER ${dev.enabled === false ? 'AUS' : 'EIN'}
+ ${dev.kind === 'battery' ? `LADUNG ${Math.round(dev.charge)} / ${SOP.BATTERY_CAP}
` : ''}
+ ${dev.kind === 'generator' ? `TANK ${dev.fueled === false ? 'LEER' : 'LÄUFT'}
` : ''}
+ ${dev.kind !== 'airlock' ? `LEITUNGEN ${dev.links.length}
` : ''}`;
+
+ if (dev.kind === 'airlock') {
+ const away = world.inmates.filter(i => !i.dead && i.away);
+ const candidates = world.inmates.filter(i => !i.dead && !i.away && !i.boarding);
+ html += `
+
// EXPEDITION ZUR OBERFLÄCHE
`;
+ if (away.length) {
+ for (const i of away) {
+ const eta = Math.max(0, Math.round((i.away.returnTick - (ticksNow || 0)) * SOP.TICK_MS / 1000));
+ html += `
${i.name} AUSSERHAUS · ~${eta} S
`;
+ }
+ }
+ if (candidates.length) {
+ html += `
+ ${candidates.map(i => `${i.name} (${i.trait}) `).join('')}
+
+
EXPEDITION STARTEN `;
+ } else if (!away.length) {
+ html += `
Keine Subjekte verfügbar.
`;
+ }
+ html += `
+ BEUTE: ÖL, KONSERVEN, SCHROTT. RISIKO: VERLETZUNG.
`;
+ } else {
+ html += `
+ ${dev.enabled === false ? 'EINSCHALTEN [E]' : 'ABSCHALTEN [E]'}
+ ZWEITEN KNOTEN ANKLICKEN → LEITUNG LEGEN / TRENNEN (MAX ${SOP.LINK_RANGE} FELDER).
`;
+ }
+
+ el.devInspector.innerHTML = html;
+ const tb = document.getElementById('btn-toggle-dev');
+ if (tb) tb.addEventListener('click', () => SOP.UI.toggleDevice(dev));
+ const eb = document.getElementById('btn-expedition');
+ if (eb) eb.addEventListener('click', () => {
+ const sel = document.getElementById('exp-subject');
+ if (sel && sel.value && SOP.Main) SOP.Main.startExpedition(sel.value);
+ });
+ }
+
+ function toggleDevice(dev) {
+ dev.enabled = dev.enabled === false ? true : false;
+ if (dev.kind === 'dynamo' && dev.enabled === false) dev.operatedBy = null;
+ log(`${SOP.OBJ[dev.kind].label} ${dev.enabled === false ? 'ABGESCHALTET' : 'EINGESCHALTET'}.`, 'dim');
+ }
+
+ /* ---------------- OVERLAYS ---------------- */
+ function showIntro(onStart) {
+ el.overlayIntro.classList.add('show');
+ $('btn-start').addEventListener('click', () => {
+ el.overlayIntro.classList.remove('show');
+ onStart();
+ const ab = $('btn-audio');
+ if (ab && SOP.Audio.on) ab.textContent = 'Ton aus';
+ });
+ }
+
+ function showGameOver(stats, onRestart) {
+ el.overStats.innerHTML = stats;
+ el.overlayOver.classList.add('show');
+ $('btn-restart').addEventListener('click', () => {
+ el.overlayOver.classList.remove('show');
+ onRestart();
+ });
+ }
+
+ return {
+ log, alert, wire, setTicker,
+ initBuildMenu, clearBuildSelection,
+ updateSystem, updateInspector, updateDevicePanel, toggleDevice,
+ showIntro, showGameOver,
+ };
+})();
diff --git a/game/world.js b/game/world.js
new file mode 100644
index 0000000..039a4bb
--- /dev/null
+++ b/game/world.js
@@ -0,0 +1,563 @@
+/* ===========================================================================
+ WORLD — grid, tiles, devices, power graph, gas diffusion, Blackboard.
+ =========================================================================== */
+window.SOP = window.SOP || {};
+
+SOP.World = (() => {
+ const W = SOP.GRID_W, H = SOP.GRID_H;
+ let tiles = []; // tiles[y][x]
+ let devices = []; // all placed devices (incl. wall segments? no — walls are tiles)
+ let inmates = [];
+ let nextDevId = 1;
+
+ const res = Object.assign({}, SOP.RES); // live resource counts
+
+ /* ---------------- THE BLACKBOARD (Cognitive layer) ---------------- */
+ const Blackboard = {
+ digMarks: [], // {x, y} tiles ordered for excavation
+ hazards: [], // {x, y, kind:'gas'}
+ electricalNodes: [], // mirrors devices
+ registerNode(dev) { this.electricalNodes.push(dev); },
+ postDigMark(x, y) {
+ if (!this.digMarks.some(m => m.x === x && m.y === y)) this.digMarks.push({ x, y });
+ },
+ removeDigMark(x, y) {
+ this.digMarks = this.digMarks.filter(m => !(m.x === x && m.y === y));
+ },
+ };
+
+ /* ---------------- WORLD GENERATION ---------------- */
+ /** Return a tile of terrain type t with initial atmosphere and no device. */
+ function makeTile(t) {
+ return { t, obj: null, o2: t === 'empty' ? 96 : 0, gas: 0, mark: false, lit: false, pocket: false };
+ }
+
+ /** Rebuild the terrain, starter devices, wiring, hazards, and excavation orders. */
+ function generate() {
+ tiles = [];
+ for (let y = 0; y < H; y++) {
+ const row = [];
+ for (let x = 0; x < W; x++) {
+ let t = 'dirt';
+ if (y < SOP.DIG_TOP || y === H - 1 || x === 0 || x === W - 1) t = 'bedrock';
+ row.push(makeTile(t));
+ }
+ tiles.push(row);
+ }
+
+ // HAB block
+ carve(3, 6, 15, 12);
+ // corridor
+ carve(16, 9, 20, 10);
+ // TECHNIK block
+ carve(21, 7, 29, 13);
+
+ // hidden gas pockets in the dirt (revealed by digging)
+ let pockets = 0;
+ for (let tries = 0; tries < 400 && pockets < 14; tries++) {
+ const x = 2 + Math.floor(Math.random() * (W - 4));
+ const y = SOP.DIG_TOP + 1 + Math.floor(Math.random() * (H - SOP.DIG_TOP - 3));
+ if (tiles[y][x].t === 'dirt') { tiles[y][x].pocket = true; pockets++; }
+ }
+
+ devices = [];
+ Blackboard.digMarks = [];
+ Blackboard.hazards = [];
+ Blackboard.electricalNodes = [];
+ nextDevId = 1;
+
+ // starting devices
+ const d = placeDevice('dynamo', 5, 7);
+ const l1 = placeDevice('lamp', 8, 7);
+ const l2 = placeDevice('lamp', 13, 10);
+ const b1 = placeDevice('bed', 4, 11);
+ const b2 = placeDevice('bed', 6, 11);
+ const disp = placeDevice('dispenser', 11, 7);
+ const scr = placeDevice('scrubber', 27, 11);
+ const gen = placeDevice('generator', 24, 8);
+ const vent = placeDevice('gas_vent', 28, 12);
+ Blackboard.hazards.push({ x: 28, y: 12, kind: 'gas' });
+
+ // pre-wired starter grid (the lesson)
+ addLink(d, l1); addLink(l1, disp); addLink(disp, l2);
+ addLink(gen, scr);
+
+ // standing excavation orders so the shift starts moving
+ [[14, 13], [15, 13], [16, 13], [22, 14], [23, 14]].forEach(([x, y]) => orderDig(x, y));
+ }
+
+ /** Replace tiles in the inclusive rectangle (x1, y1)..(x2, y2) with empty tiles. */
+ function carve(x1, y1, x2, y2) {
+ for (let y = y1; y <= y2; y++)
+ for (let x = x1; x <= x2; x++)
+ tiles[y][x] = makeTile('empty');
+ }
+
+ /* ---------------- DEVICES ---------------- */
+ /** Create and register a device at (x, y) without placement or resource checks. */
+ function placeDevice(kind, x, y) {
+ const dev = {
+ id: nextDevId++, kind, x, y,
+ links: [], // device ids
+ powered: false, broken: false, enabled: true, compDeficit: 0, hold: 0,
+ operatedBy: null, // inmate id (dynamo)
+ fuelTimer: 0, fueled: true, // generator: starts with one free tank
+ charge: 0, // battery
+ growth: 0, // farm
+ open: false, // door
+ triggered: false, // sensor
+ digProgress: 0, repairProgress: 0,
+ };
+ devices.push(dev);
+ tiles[y][x].obj = dev;
+ Blackboard.registerNode(dev);
+ return dev;
+ }
+
+ /** Remove a device from the device list and tile, and remove links pointing to it. */
+ function removeDevice(dev) {
+ const i = devices.indexOf(dev);
+ if (i >= 0) devices.splice(i, 1);
+ devices.forEach(d => { d.links = d.links.filter(id => id !== dev.id); });
+ if (tiles[dev.y][dev.x].obj === dev) tiles[dev.y][dev.x].obj = null;
+ }
+
+ /** Connect two distinct devices within Manhattan link range; return whether accepted. */
+ function addLink(a, b) {
+ if (!a || !b || a === b) return false;
+ const dist = Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
+ if (dist > SOP.LINK_RANGE) return false;
+ if (!a.links.includes(b.id)) a.links.push(b.id);
+ if (!b.links.includes(a.id)) b.links.push(a.id);
+ return true;
+ }
+
+ /** Remove the bidirectional wire between devices a and b. */
+ function removeLink(a, b) {
+ a.links = a.links.filter(id => id !== b.id);
+ b.links = b.links.filter(id => id !== a.id);
+ }
+
+ /** Return each wire once as a pair of devices, omitting missing endpoints. */
+ function linksPairs() {
+ const seen = new Set(), pairs = [];
+ for (const d of devices) for (const id of d.links) {
+ const key = Math.min(d.id, id) + ':' + Math.max(d.id, id);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ const other = devices.find(o => o.id === id);
+ if (other) pairs.push([d, other]);
+ }
+ return pairs;
+ }
+
+ /** Return a placement error string for kind at (x, y), or null if the site is valid. */
+ function canBuildAt(kind, x, y) {
+ if (!inGrid(x, y)) return 'AUSSERHALB SEKTOR';
+ const tl = tiles[y][x];
+ if (kind === 'airlock') {
+ if (y !== SOP.DIG_TOP - 1) return 'SCHLEUSE NUR AN DER DECKE (REIHE ' + (SOP.DIG_TOP - 1) + ')';
+ if (tl.obj) return 'BEREITS BELEGT';
+ if (devices.some(d => d.kind === 'airlock')) return 'NUR EINE SCHLEUSE PRO SEKTOR';
+ const below = inGrid(x, y + 1) ? tiles[y + 1][x] : null;
+ if (!below || below.t !== 'empty') return 'KEIN ZUGANG UNTER DER SCHLEUSE (FELD DARUNTER FREIGRABEN)';
+ if (below.obj) return 'ZUGANG BLOCKIERT';
+ return null;
+ }
+ if (tl.t !== 'empty') return 'NUR IN HOHLRÄUMEN';
+ if (tl.obj) return 'BEREITS BELEGT';
+ if (inmates.some(i => !i.dead && i.tx === x && i.ty === y)) return 'SUBJEKT IM WEG';
+ return null;
+ }
+
+ /** Validate placement and resources, spend the cost, and return {ok, err?}. */
+ function tryBuild(kind, x, y) {
+ const err = canBuildAt(kind, x, y);
+ if (err) return { ok: false, err };
+ const cost = SOP.OBJ[kind].cost || {};
+ for (const k in cost) if ((res[k] || 0) < cost[k]) return { ok: false, err: 'MATERIAL FEHLT: ' + k.toUpperCase() };
+ for (const k in cost) res[k] -= cost[k];
+ placeDevice(kind, x, y);
+ return { ok: true };
+ }
+
+ /** Spend concrete to wall off an empty, unoccupied tile; return {ok, err?}. */
+ function tryBuildWall(x, y) {
+ if (!inGrid(x, y)) return { ok: false, err: 'AUSSERHALB SEKTOR' };
+ const tl = tiles[y][x];
+ if (tl.t !== 'empty') return { ok: false, err: 'NUR IN HOHLRÄUMEN' };
+ if (tl.obj) return { ok: false, err: 'BEREITS BELEGT' };
+ if (inmates.some(i => !i.dead && i.tx === x && i.ty === y)) return { ok: false, err: 'SUBJEKT IM WEG' };
+ if ((res.beton || 0) < SOP.WALL_COST.beton) return { ok: false, err: 'BETON FEHLT' };
+ res.beton -= SOP.WALL_COST.beton;
+ tl.t = 'wall';
+ return { ok: true };
+ }
+
+ /** Mark an in-grid dirt tile for excavation; return whether the order was accepted. */
+ function orderDig(x, y) {
+ if (!inGrid(x, y) || tiles[y][x].t !== 'dirt') return false;
+ Blackboard.postDigMark(x, y);
+ tiles[y][x].mark = true;
+ return true;
+ }
+
+ /** Excavate a marked tile and return a gas/cache event or null; digger is unused. */
+ function completeDig(x, y, digger) {
+ const tl = tiles[y][x];
+ tl.t = 'empty'; tl.mark = false; tl.o2 = 60;
+ Blackboard.removeDigMark(x, y);
+ let event = null;
+ if (tl.pocket) {
+ // trapped gas pocket: one violent burst, then it dissipates
+ tl.gas = 70;
+ tl.pocket = false;
+ Blackboard.hazards.push({ x, y, kind: 'gas' });
+ event = { type: 'gas', x, y };
+ } else if (Math.random() < SOP.DIG_CACHE_CHANCE) {
+ const totalW = SOP.CACHES.reduce((a, c) => a + c.weight, 0);
+ let roll = Math.random() * totalW, c = SOP.CACHES[0];
+ for (const cand of SOP.CACHES) { roll -= cand.weight; if (roll <= 0) { c = cand; break; } }
+ const n = c.n[0] + Math.floor(Math.random() * (c.n[1] - c.n[0] + 1));
+ res[c.res] = (res[c.res] || 0) + n;
+ event = { type: 'cache', label: c.label, n, res: c.res };
+ }
+ return event;
+ }
+
+ /* ---------------- POWER EVALUATION ---------------- */
+ let powerState = { gen: 0, draw: 0, stored: 0, components: 0, deficit: 0 };
+
+ /** Recompute wired components, advance battery/relay state, and return power totals. */
+ function evalPower() {
+ // union-find over links
+ const parent = new Map();
+ devices.forEach(d => parent.set(d.id, d.id));
+ const find = (a) => { while (parent.get(a) !== a) { parent.set(a, parent.get(parent.get(a))); a = parent.get(a); } return a; };
+ for (const d of devices) for (const id of d.links) {
+ if (!parent.has(id)) continue;
+ const ra = find(d.id), rb = find(id);
+ if (ra !== rb) parent.set(ra, rb);
+ }
+ const comps = new Map();
+ for (const d of devices) {
+ const r = find(d.id);
+ if (!comps.has(r)) comps.set(r, []);
+ comps.get(r).push(d);
+ }
+
+ let gen = 0, draw = 0, stored = 0;
+ for (const [, members] of comps) {
+ let cGen = 0, cDraw = 0;
+ const batteries = [];
+ for (const d of members) {
+ if (d.kind === 'battery') {
+ if (d.enabled !== false && !d.broken) batteries.push(d);
+ continue;
+ }
+ if (d.broken || d.enabled === false) continue;
+ const w = SOP.OBJ[d.kind].watts || 0;
+ if (w < 0) {
+ if (d.kind === 'dynamo') {
+ if (d.operatedBy != null) cGen += -w;
+ } else {
+ if (d.kind === 'generator' && d.fueled === false) { /* out of rations */ }
+ else cGen += -w;
+ }
+ } else if (w > 0) {
+ cDraw += w;
+ }
+ }
+ const need = cDraw - cGen;
+ let ok;
+ if (need <= 0) {
+ ok = true;
+ let surplus = -need; // charge the pack
+ for (const b of batteries) {
+ const room = SOP.BATTERY_CAP - b.charge;
+ const c = Math.min(room, surplus);
+ b.charge += c; surplus -= c;
+ if (surplus <= 0) break;
+ }
+ } else {
+ let avail = 0; // discharge to cover the gap
+ for (const b of batteries) {
+ const c = Math.min(b.charge, need - avail);
+ b.charge -= c; avail += c;
+ if (avail >= need) break;
+ }
+ ok = avail >= need;
+ }
+ const compDeficit = Math.max(0, cDraw - cGen);
+ for (const d of members) {
+ // relay hold: consumers ride through brief generation gaps (20 ticks)
+ let effOk = ok;
+ if (effOk) d.hold = 20;
+ else if (d.hold > 0) { d.hold--; effOk = true; }
+ d.powered = effOk && !d.broken && d.enabled !== false;
+ d.compDeficit = compDeficit; // per-grid shortfall, drives the crank urge
+ }
+ for (const b of batteries) stored += b.charge;
+ gen += cGen;
+ draw += cDraw;
+ }
+ powerState = { gen, draw, stored, components: comps.size, deficit: Math.max(0, draw - gen) };
+ return powerState;
+ }
+
+ /* ---------------- GAS / O2 DIFFUSION ---------------- */
+ /** Blend oxygen and gas between adjacent empty tiles, clamping levels to 0..100. */
+ function diffuse() {
+ const k = 0.20;
+ const nO2 = [], nGas = [];
+ for (let y = 0; y < H; y++) { nO2.push(new Array(W).fill(0)); nGas.push(new Array(W).fill(0)); }
+ for (let y = 0; y < H; y++) {
+ for (let x = 0; x < W; x++) {
+ const tl = tiles[y][x];
+ if (tl.t !== 'empty') continue;
+ let o2 = tl.o2, gas = tl.gas, cnt = 0;
+ let accO = 0, accG = 0;
+ for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
+ const nx = x + dx, ny = y + dy;
+ if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
+ const nb = tiles[ny][nx];
+ if (nb.t !== 'empty') continue;
+ accO += (nb.o2 - o2); accG += (nb.gas - gas); cnt++;
+ }
+ // proportional blend toward neighborhood average
+ if (cnt > 0) { o2 = tl.o2 + k * accO / cnt; gas = tl.gas + k * accG / cnt; }
+ nO2[y][x] = o2; nGas[y][x] = gas;
+ }
+ }
+ for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
+ if (tiles[y][x].t !== 'empty') continue;
+ tiles[y][x].o2 = clamp(nO2[y][x], 0, 100);
+ tiles[y][x].gas = clamp(nGas[y][x], 0, 100);
+ }
+ }
+
+ /* ---------------- TICK ---------------- */
+ /** Advance devices, power, breathing, diffusion, and lighting; report device events to onEvent. */
+ function tick(onEvent) {
+ // devices act
+ for (const d of devices) {
+ if (d.kind === 'gas_vent' && !d.broken) {
+ tiles[d.y][d.x].gas = Math.min(100, tiles[d.y][d.x].gas + 4);
+ }
+ if (d.kind === 'scrubber' && d.powered && !d.broken && d.enabled !== false) {
+ for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
+ const x = d.x + dx, y = d.y + dy;
+ if (!inGrid(x, y) || tiles[y][x].t !== 'empty') continue;
+ tiles[y][x].gas = Math.max(0, tiles[y][x].gas - 5);
+ tiles[y][x].o2 = Math.min(100, tiles[y][x].o2 + 1.2);
+ }
+ }
+ if (d.kind === 'generator' && !d.broken && d.enabled !== false) {
+ d.fuelTimer = (d.fuelTimer || 0) + 1;
+ if (d.fuelTimer >= 140) { // burns 1 ÖL every 140 ticks (35 s)
+ if (res.oel > 0) {
+ res.oel--; d.fuelTimer = 0; d.fueled = true;
+ onEvent({ type: 'burn', dev: d });
+ } else {
+ d.fueled = false;
+ }
+ }
+ }
+ if (d.kind === 'sensor' && d.powered && !d.broken && d.enabled !== false) {
+ d.triggered = inmates.some(i => !i.dead && Math.abs(i.tx - d.x) + Math.abs(i.ty - d.y) <= 2);
+ // coupled doors open while triggered
+ for (const id of d.links) {
+ const o = devices.find(x => x.id === id);
+ if (o && o.kind === 'door') o.open = d.triggered;
+ }
+ }
+ if (d.kind === 'farm' && d.powered && !d.broken && d.enabled !== false) {
+ d.growth = (d.growth || 0) + 1;
+ if (d.growth >= SOP.FARM_TICKS) {
+ d.growth = 0;
+ res.rationen++;
+ onEvent({ type: 'harvest', dev: d });
+ }
+ }
+ if (d.kind === 'door' && d.enabled === false) { d.open = false; continue; }
+ if (d.kind === 'door' && d.powered) d.open = true;
+ else if (d.kind === 'door' && !d.powered) {
+ const sensorHolds = d.links.some(id => {
+ const s = devices.find(x => x.id === id);
+ return s && s.kind === 'sensor' && s.triggered;
+ });
+ if (!sensorHolds) d.open = false;
+ }
+ }
+
+ evalPower();
+
+ // inmates breathe their tile
+ for (const inm of inmates) {
+ if (inm.dead) continue;
+ const tl = tiles[inm.ty][inm.tx];
+ tl.o2 = Math.max(0, tl.o2 - 0.35);
+ }
+
+ diffuse();
+ updateLighting();
+ }
+
+ /** Reset tile lighting and illuminate empty tiles near active lamps. */
+ function updateLighting() {
+ for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) tiles[y][x].lit = false;
+ for (const d of devices) {
+ if (d.kind !== 'lamp' || !d.powered || d.broken || d.enabled === false) continue;
+ for (let y = d.y - 4; y <= d.y + 4; y++) for (let x = d.x - 4; x <= d.x + 4; x++) {
+ if (!inGrid(x, y) || tiles[y][x].t !== 'empty') continue;
+ if (Math.abs(x - d.x) + Math.abs(y - d.y) <= 5) tiles[y][x].lit = true;
+ }
+ }
+ }
+
+ /* ---------------- PATHFINDING ---------------- */
+ /** Return whether a tile is empty and unoccupied, except for lamps and sensors. */
+ function walkable(x, y) {
+ if (!inGrid(x, y)) return false;
+ const tl = tiles[y][x];
+ if (tl.t !== 'empty') return false;
+ if (tl.obj && tl.obj.kind === 'door' && !tl.obj.open) return false;
+ if (tl.obj && tl.obj.kind !== 'lamp' && tl.obj.kind !== 'sensor') return false;
+ return true;
+ }
+
+ /**
+ * Return a BFS path excluding the start, [] if already there, or null if unreachable.
+ * The target may be occupied; intermediate tiles must be walkable.
+ */
+ function findPath(sx, sy, tx, ty) {
+ if (!inGrid(tx, ty)) return null;
+ if (sx === tx && sy === ty) return [];
+ // target tile itself may be an object tile -> allow if adjacent goal
+ const key = (x, y) => y * W + x;
+ const prev = new Map();
+ const q = [[sx, sy]];
+ prev.set(key(sx, sy), null);
+ while (q.length) {
+ const [x, y] = q.shift();
+ if (x === tx && y === ty) {
+ const path = [];
+ let cur = [x, y];
+ while (cur) { path.unshift(cur); cur = prev.get(key(cur[0], cur[1])); }
+ path.shift();
+ return path;
+ }
+ for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
+ const nx = x + dx, ny = y + dy;
+ if (prev.has(key(nx, ny))) continue;
+ if (nx === tx && ny === ty) {
+ // allow stepping onto the goal even if occupied by an object
+ prev.set(key(nx, ny), [x, y]);
+ q.push([nx, ny]);
+ continue;
+ }
+ if (!walkable(nx, ny)) continue;
+ prev.set(key(nx, ny), [x, y]);
+ q.push([nx, ny]);
+ }
+ }
+ return null;
+ }
+
+ /** Return the nearest lit empty tile by Manhattan distance, or null if none exists. */
+ function nearestLit(x, y) {
+ let best = null, bd = Infinity;
+ for (let yy = 0; yy < H; yy++) for (let xx = 0; xx < W; xx++) {
+ const tl = tiles[yy][xx];
+ if (tl.t !== 'empty' || !tl.lit) continue;
+ const d = Math.abs(xx - x) + Math.abs(yy - y);
+ if (d < bd) { bd = d; best = { x: xx, y: yy }; }
+ }
+ return best;
+ }
+
+ /* ---------------- SAVE / LOAD ---------------- */
+ /** Return world save data with copied resources and links, omitting derived runtime state. */
+ function serialize() {
+ return {
+ res: Object.assign({}, res),
+ nextDevId,
+ tiles: tiles.map(row => row.map(tl => ({
+ t: tl.t, o2: Math.round(tl.o2), gas: Math.round(tl.gas),
+ m: tl.mark ? 1 : 0, p: tl.pocket ? 1 : 0,
+ }))),
+ devices: devices.map(d => ({
+ id: d.id, kind: d.kind, x: d.x, y: d.y,
+ links: d.links.slice(), broken: d.broken, enabled: d.enabled,
+ charge: Math.round(d.charge || 0), growth: d.growth || 0,
+ fuelTimer: d.fuelTimer || 0, fueled: d.fueled !== false,
+ open: !!d.open, operatedBy: d.operatedBy,
+ })),
+ digMarks: Blackboard.digMarks.map(m => ({ x: m.x, y: m.y })),
+ };
+ }
+
+ /** Restore world save data, rebuild device references and the Blackboard, then evaluate power. */
+ function loadFrom(data) {
+ if (!data || !Array.isArray(data.tiles) || data.tiles.length !== H ||
+ data.tiles.some(r => !Array.isArray(r) || r.length !== W) ||
+ !Array.isArray(data.devices) || !data.res || typeof data.res !== 'object') {
+ throw new Error('invalid world save');
+ }
+ const newTiles = data.tiles.map(row => row.map(o => {
+ const tl = makeTile(o.t);
+ tl.o2 = Number(o.o2) || 0; tl.gas = Number(o.gas) || 0; tl.mark = !!o.m; tl.pocket = !!o.p;
+ return tl;
+ }));
+ tiles = newTiles;
+ devices = [];
+ Blackboard.digMarks = [];
+ Blackboard.hazards = [];
+ Blackboard.electricalNodes = [];
+ nextDevId = data.nextDevId || 1;
+ for (const o of data.devices) {
+ const dev = {
+ links: [], powered: false, broken: false, enabled: true,
+ compDeficit: 0, hold: 0, operatedBy: null,
+ fuelTimer: 0, fueled: true, charge: 0, growth: 0,
+ open: false, triggered: false, digProgress: 0, repairProgress: 0,
+ };
+ Object.assign(dev, o);
+ devices.push(dev);
+ if (inGrid(dev.x, dev.y)) tiles[dev.y][dev.x].obj = dev;
+ Blackboard.registerNode(dev);
+ if (dev.kind === 'gas_vent') Blackboard.hazards.push({ x: dev.x, y: dev.y, kind: 'gas' });
+ }
+ for (const k in res) delete res[k];
+ Object.assign(res, data.res);
+ for (const m of data.digMarks || []) {
+ if (inGrid(m.x, m.y) && tiles[m.y][m.x].t === 'dirt') {
+ Blackboard.postDigMark(m.x, m.y);
+ tiles[m.y][m.x].mark = true;
+ }
+ }
+ evalPower();
+ updateLighting();
+ }
+
+ /* ---------------- helpers / API ---------------- */
+ /** Return whether (x, y) lies within the grid bounds. */
+ function inGrid(x, y) { return x >= 0 && y >= 0 && x < W && y < H; }
+ /** Limit v to the inclusive interval from a to b. */
+ function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
+
+ return {
+ get tiles() { return tiles; },
+ get devices() { return devices; },
+ get inmates() { return inmates; },
+ set inmates(v) { inmates = v; },
+ get res() { return res; },
+ get power() { return powerState; },
+ Blackboard,
+ generate, placeDevice, removeDevice, addLink, removeLink, linksPairs,
+ tryBuild, tryBuildWall, canBuildAt, orderDig, completeDig,
+ tick, evalPower, walkable, findPath, nearestLit, inGrid,
+ serialize, loadFrom,
+ W, H,
+ };
+})();