From 7c5f41ca0407d0ce6c36c63f0f8fe828e62241b9 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:55:34 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat(game):=20Standard=20Operating=20Psycho?= =?UTF-8?q?pathy=20=E2=80=94=20playable=20bunker-management=20sim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-apocalyptic colony sim built from the provided architecture prototype (Sheltered x Oxygen Not Included x BoomTown), restyled as an NDW-era institutional surveillance terminal (amber CRT, hard sans, motorik audio). - Conative Utility AI: affective needs -> scored directives (eat/sleep/dig/ crank/repair/flee/sabotage), no state machines - Blackboard cognitive layer (dig orders, hazards, electrical nodes) - Dual-layer renderer: physical dollhouse + Gunpoint-style Crosslink wiring - Power graph with components, batteries, relay-hold; O2/gas diffusion - Economy: farms (food), generator burns dug Oel (power), caches, digging - Erdbeben events, device damage/repair, trait-driven psychotic breaks - Headless smoke harness (validated 120-day survival) + jsdom DOM test Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- game/.gitignore | 2 + game/README.md | 97 +++++++++ game/ai.js | 452 +++++++++++++++++++++++++++++++++++++++ game/audio.js | 99 +++++++++ game/config.js | 90 ++++++++ game/index.html | 117 +++++++++++ game/main.js | 313 +++++++++++++++++++++++++++ game/package.json | 14 ++ game/render.js | 380 +++++++++++++++++++++++++++++++++ game/style.css | 284 +++++++++++++++++++++++++ game/tools/dom_test.js | 73 +++++++ game/tools/smoke.js | 181 ++++++++++++++++ game/ui.js | 208 ++++++++++++++++++ game/world.js | 467 +++++++++++++++++++++++++++++++++++++++++ 14 files changed, 2777 insertions(+) create mode 100644 game/.gitignore create mode 100644 game/README.md create mode 100644 game/ai.js create mode 100644 game/audio.js create mode 100644 game/config.js create mode 100644 game/index.html create mode 100644 game/main.js create mode 100644 game/package.json create mode 100644 game/render.js create mode 100644 game/style.css create mode 100644 game/tools/dom_test.js create mode 100644 game/tools/smoke.js create mode 100644 game/ui.js create mode 100644 game/world.js 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..9a52102 --- /dev/null +++ b/game/README.md @@ -0,0 +1,97 @@ +# 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 three subjects act on their own drives. + +--- + +## 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. | + +## 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`, `0` | Select a module to build | +| `W` | Build wall | +| `X` | Dig order (click dirt to mark) | +| `V` | Toggle Physical ↔ Crosslink | +| `E` | Toggle selected device on/off | +| `M` | Toggle audio | +| `Space` | Pause | +| `Esc` | Cancel placement | +| Click module in **Crosslink** twice | Wire / unwire two nodes (≤ 8 tiles) | + +## 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 | + +## 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..2a5ed9c --- /dev/null +++ b/game/ai.js @@ -0,0 +1,452 @@ +/* =========================================================================== + 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.', + '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; + } + } + } + + 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; + } + + logConativeState(action) { + this.lastScored = action; + } +}; diff --git a/game/audio.js b/game/audio.js new file mode 100644 index 0000000..a5bb408 --- /dev/null +++ b/game/audio.js @@ -0,0 +1,99 @@ +/* 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]; + + 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); + } + + 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); + } + + 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); + } + + 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); + } + + 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++; + } + } + + 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; + } + + /* One-shot alerts */ + 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..2c1d81d --- /dev/null +++ b/game/config.js @@ -0,0 +1,90 @@ +/* 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.' }, + 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']; +SOP.BATTERY_CAP = 2000; +SOP.FARM_TICKS = 120; // powered ticks per ration grown + +/* 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..e1ce6e8 --- /dev/null +++ b/game/index.html @@ -0,0 +1,117 @@ + + + + + Standard Operating Psychopathy — Sektor 4 + + + + + + +
+
+ +
+ +
+
+
Systemstatus
+
Stromnetz
+
+ Detailwerte im Kopfbalken. Defizit = Verbraucher dunkel. +
+
+ +
+
Bauhof
+
+
+
+ +
+
Subjekt-Inspektor
+
Subjekt im Raster anklicken, um affektive und konative Ströme zu überwachen.
+
+ +
+
Knoten-Inspektor
+
Nur im CROSSLINK-Modus.
+
+ +
+
Ereignisprotokoll
+
+
+
+
+ +
+
◤ DURCHSAGE ◢
+
+
+ + +
+
+

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.

+

+ 1–8 Module bauen  ·  W Wand  ·  X Grabungsauftrag  ·  ESC Abbruch
+ V Physisch ↔ Crosslink  ·  LEER Pause  ·  M Ton
+ 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. +

+

WARNUNG: Hoher Stress führt bei geeigneten Profilen zu Kurzschlüssen im wahrsten Sinne.

+
+ JEDER ATEMZUG WIRD REGISTRIERT + +
+
+
+ + +
+
+

Akte geschlossen

+
Alle Subjekte ausgefallen — Sektor 4 stillgelegt
+
+
+ DIE OBERFLÄCHE IST KEIN ORT MEHR + +
+
+
+ + + + + + + + + + diff --git a/game/main.js b/game/main.js new file mode 100644 index 0000000..f1e200e --- /dev/null +++ b/game/main.js @@ -0,0 +1,313 @@ +/* =========================================================================== + MAIN — orchestration: game loop, input, events, day cycle, game over. + =========================================================================== */ +window.SOP = window.SOP || {}; + +SOP.Main = (() => { + const world = SOP.World; + const UI = SOP.UI; + const R = SOP.Render; + + let ticks = 0; + let started = false, paused = false, gameOver = false; + 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 stats = { caches: 0, meals: 0, repairs: 0, sabotages: 0, quakes: 0 }; + + function day() { return SOP.START_DAY + Math.floor(ticks / SOP.DAY_TICKS); } + + /* ---------------- SETUP ---------------- */ + 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(() => { + started = true; + SOP.Audio.toggle(); // starts the motorik loop (user gesture) + setInterval(simTick, SOP.TICK_MS); + UI.log('DURCHSAGE: DIE SCHICHT BEGINNT.', 'ok'); + }); + + requestAnimationFrame(frame); + } + + /* ---------------- SIMULATION TICK ---------------- */ + function simTick() { + if (!started || paused || gameOver) return; + ticks++; + + world.tick(handleWorldEvent); + + for (const inm of world.inmates) { + if (inm.dead) continue; + if (ticks % 4 === 0) inm.evaluateDrives(world); // 4 ticks = 1 s + inm.tick(world, handleInmateEvent); + } + + // random seismic event + if (ticks >= nextQuakeTick) { + quake(); + nextQuakeTick = ticks + 380 + Math.floor(Math.random() * 260); + } + + // game over check + if (world.inmates.every(i => i.dead)) endGame(); + } + + 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'); + } + + 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.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 !== 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; + } + } + + 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) 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.on) SOP.Audio.alarm(); + } + + 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}
+
REPARATUREN${stats.repairs}
+
PSYCHOTISCHE SCHÜBE${stats.sabotages}
+
ERDBEBEN ÜBERSTANDEN${stats.quakes}
+
ENDSTAND AKTEGESCHLOSSEN
`, + () => location.reload()); + } + + /* ---------------- RENDER LOOP ---------------- */ + let uiFrame = 0; + 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 % 30 === 0 && !gameOver) { + UI.updateDevicePanel(world, state.selDevice, R.getView()); + } + requestAnimationFrame(frame); + } + + /* ---------------- INPUT ---------------- */ + 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.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; } + }); + + function crosslinkClick(x, y) { + const tl = world.tiles[y][x]; + const dev = tl.obj; + if (!dev) { state.linkFrom = null; state.selDevice = null; 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'; + }); + + // 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 === '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; } + } + }); + } + + function clickMenuItem(kind) { + const btn = document.querySelector(`.build-item[data-kind="${kind}"]`); + if (btn) btn.click(); + } + + 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, get state() { return state; } }; +})(); + +window.addEventListener('DOMContentLoaded', () => SOP.Main.setup()); diff --git a/game/package.json b/game/package.json new file mode 100644 index 0000000..ac3f6ed --- /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 0.0.0.0" + }, + "devDependencies": { + "jsdom": "^24.0.0" + }, + "license": "UNLICENSED" +} diff --git a/game/render.js b/game/render.js new file mode 100644 index 0000000..7f7ad35 --- /dev/null +++ b/game/render.js @@ -0,0 +1,380 @@ +/* =========================================================================== + 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; + + function setView(v) { viewMode = v; } + function getView() { return viewMode; } + function shake(dur) { shakeUntil = performance.now() + dur; } + + 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; + } + + 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 }; + } + + /* deterministic pseudo-random per tile for texture */ + function hash(x, y) { + let h = (x * 374761393 + y * 668265263) | 0; + h = (h ^ (h >> 13)) * 1274126177 | 0; + return ((h ^ (h >> 16)) >>> 0) / 4294967295; + } + + 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); + } + + function resizeIfNeeded() { + const box = document.getElementById('canvas-container'); + if (canvas.width !== box.clientWidth || canvas.height !== box.clientHeight) resize(); + } + + /* ---------------- PHYSICAL LAYER ---------------- */ + 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) drawInmate(inm, T, state.selected === inm); + + // ghost placement + if (state.ghost) drawGhost(state.ghost, T, state.world); + } + + function drawDevice(d, T) { + const px = d.x * T, py = d.y * T; + const def = SOP.OBJ[d.kind]; + 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(); + } + } + + 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); + } + } + + 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 ---------------- */ + 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) 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..b19b5a0 --- /dev/null +++ b/game/tools/smoke.js @@ -0,0 +1,181 @@ +/* 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; +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) { + 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); + } + } + // 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; + }); + } +} + +// 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..a4b9ebc --- /dev/null +++ b/game/ui.js @@ -0,0 +1,208 @@ +/* =========================================================================== + 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 += ``; + } + html += ``; + html += ``; + 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
+
Subjekt im Raster anklicken, um affektive und konative Ströme zu überwachen.
`; + 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) ---------------- */ + function updateDevicePanel(world, dev, mode) { + if (!dev) { + el.devInspector.innerHTML = `
KNOTEN-INSPEKTOR
+
${mode === 'CROSSLINK' ? 'Knoten anklicken. Zwei Knoten = Leitung.' : 'Nur im CROSSLINK-Modus.'}
`; + return; + } + const def = SOP.OBJ[dev.kind]; + el.devInspector.innerHTML = ` +
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'}
` : ''} +
LEITUNGEN${dev.links.length}
+ +
ZWEITEN KNOTEN ANKLICKEN → LEITUNG LEGEN / TRENNEN (MAX ${SOP.LINK_RANGE} FELDER).
`; + const tb = document.getElementById('btn-toggle-dev'); + if (tb) tb.addEventListener('click', () => SOP.UI.toggleDevice(dev)); + } + + 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..57ee10a --- /dev/null +++ b/game/world.js @@ -0,0 +1,467 @@ +/* =========================================================================== + 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 ---------------- */ + function makeTile(t) { + return { t, obj: null, o2: t === 'empty' ? 96 : 0, gas: 0, mark: false, lit: false, pocket: false }; + } + + 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)); + } + + 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 ---------------- */ + 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; + } + + 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; + } + + 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; + } + + function removeLink(a, b) { + a.links = a.links.filter(id => id !== b.id); + b.links = b.links.filter(id => id !== a.id); + } + + 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; + } + + /* Build / order actions used by the UI. Returns error string or null. */ + function canBuildAt(kind, x, y) { + if (!inGrid(x, y)) return 'AUSSERHALB SEKTOR'; + const tl = tiles[y][x]; + 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; + } + + 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 }; + } + + 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 }; + } + + 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; + } + + /* Complete one tile of excavation (called by AI). */ + 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 }; + + 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 ---------------- */ + 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 ---------------- */ + 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(); + } + + 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 ---------------- */ + 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; + } + + 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; + } + + /* Nearest lit tile to (x,y). */ + 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; + } + + /* ---------------- helpers / API ---------------- */ + function inGrid(x, y) { return x >= 0 && y >= 0 && x < W && y < H; } + 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, + W, H, + }; +})(); From 0e221a4b31ce1fb38c70824686b16b2ee12018ed Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:22:24 +0000 Subject: [PATCH 2/7] SOP v0.2: save/load, surface expeditions, new subjects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Save/load: versioned JSON Akte (world + inmates + scheduler state), localStorage with safe wrapper, autosave every 1200 ticks, manual SICHERN/LADEN, file export/import; world.serialize/loadFrom and InmateAI.serialize/fromData rebuild the full simulation state. - Surface expeditions: LUFTSCHLEUSE (airlock, ceiling row only, single) sends one subject topside via the new 'expedition' directive; they return after 500-800 ticks with weighted loot (Öl/Rationen/Metall/ Beton) and an 18% injury chance. Airlock panel with candidate select, away countdown and signature-cached rendering. - Colony growth: newcomers arrive at the airlock on a timer; a powered FUNKTURM (radio, 30W) halves the interval. MAX_INMATES 6. - main.js split into headless-safe step() + browser loop; UI guards for audio; smoke test gains phase 2 covering airlock placement, expedition lifecycle, arrivals, and a save->corrupt->load->continue roundtrip. - Verified: 4x 28800-tick smoke runs + DOM boot test green. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- game/README.md | 18 ++- game/ai.js | 31 +++++ game/config.js | 19 ++- game/index.html | 27 ++++- game/main.js | 288 +++++++++++++++++++++++++++++++++++++++++--- game/render.js | 27 ++++- game/tools/smoke.js | 143 +++++++++++++++++++--- game/ui.js | 77 ++++++++++-- game/world.js | 66 ++++++++++ 9 files changed, 647 insertions(+), 49 deletions(-) diff --git a/game/README.md b/game/README.md index 9a52102..2c577a3 100644 --- a/game/README.md +++ b/game/README.md @@ -6,7 +6,13 @@ aesthetic of the **Neue Deutsche Welle** — monochrome CRT amber, hard sans-ser propaganda headers, and cold motorik audio. You do not control the inmates directly. You manage the **grid** — power, air, -food, and excavation — while three subjects act on their own drives. +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. --- @@ -28,6 +34,8 @@ is uppercase German institutional copy. The bottom strip is a looping | **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 @@ -40,15 +48,19 @@ is uppercase German institutional copy. The bottom strip is a looping | Key / input | Action | | --- | --- | -| `1–8`, `0` | Select a module to build | +| `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 @@ -64,6 +76,8 @@ is uppercase German institutional copy. The bottom strip is a looping | 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 diff --git a/game/ai.js b/game/ai.js index 2a5ed9c..6063a75 100644 --- a/game/ai.js +++ b/game/ai.js @@ -239,6 +239,7 @@ SOP.InmateAI = class InmateAI { '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] || '—'; @@ -435,6 +436,12 @@ SOP.InmateAI = class InmateAI { 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; + } } } @@ -446,6 +453,30 @@ SOP.InmateAI = class InmateAI { 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/config.js b/game/config.js index 2c1d81d..c77ec94 100644 --- a/game/config.js +++ b/game/config.js @@ -39,16 +39,33 @@ SOP.OBJ = { 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']; +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; diff --git a/game/index.html b/game/index.html index e1ce6e8..68a1731 100644 --- a/game/index.html +++ b/game/index.html @@ -42,6 +42,20 @@ +
+
Aktenverwaltung
+
+ + + + +
+ +
+ Autosicherung alle 5 Minuten. Export = Datei-Download. +
+
+
Bauhof
@@ -77,13 +91,18 @@

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.

+ 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 Module bauen  ·  W Wand  ·  X Grabungsauftrag  ·  ESC Abbruch
- V Physisch ↔ Crosslink  ·  LEER Pause  ·  M Ton
+ 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. + 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.

diff --git a/game/main.js b/game/main.js index f1e200e..dfd83d8 100644 --- a/game/main.js +++ b/game/main.js @@ -1,5 +1,6 @@ /* =========================================================================== - MAIN — orchestration: game loop, input, events, day cycle, game over. + MAIN — orchestration: game loop, input, events, day cycle, save/load, + surface expeditions, and colony growth. =========================================================================== */ window.SOP = window.SOP || {}; @@ -8,8 +9,11 @@ SOP.Main = (() => { 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) @@ -18,7 +22,8 @@ SOP.Main = (() => { ghost: null, // {x, y, kind} }; let nextQuakeTick = 260; - let stats = { caches: 0, meals: 0, repairs: 0, sabotages: 0, quakes: 0 }; + let nextArrivalTick = 2200; + let stats = { caches: 0, meals: 0, repairs: 0, sabotages: 0, quakes: 0, expeditions: 0, arrivals: 0 }; function day() { return SOP.START_DAY + Math.floor(ticks / SOP.DAY_TICKS); } @@ -41,15 +46,21 @@ SOP.Main = (() => { bindInput(); UI.showIntro(() => { - started = true; - SOP.Audio.toggle(); // starts the motorik loop (user gesture) - setInterval(simTick, SOP.TICK_MS); + beginShift(); UI.log('DURCHSAGE: DIE SCHICHT BEGINNT.', 'ok'); }); requestAnimationFrame(frame); } + 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 ---------------- */ function simTick() { if (!started || paused || gameOver) return; @@ -57,18 +68,44 @@ SOP.Main = (() => { 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) continue; - if (ticks % 4 === 0) inm.evaluateDrives(world); // 4 ticks = 1 s + 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(); } @@ -90,21 +127,211 @@ SOP.Main = (() => { 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.affective.stress = Math.min(100, i.affective.stress + 8); i.affective.paranoia = Math.min(100, i.affective.paranoia + 10); } }); + 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 !== ev.inmate) i.affective.stress = Math.min(100, i.affective.stress + 12); }); + 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 ---------------- */ + 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; + } + + 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'); + } + + 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; + } + + function returnFromExpedition(inm) { + stats.expeditions++; + const air = world.devices.find(d => d.kind === 'airlock'); + const spot = air ? adjacentStand(air) : null; + if (spot) { 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 ---------------- */ + 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; + } + + 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 ---------------- */ + function storage() { try { return window.localStorage; } catch (e) { return null; } } + + 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()), + }; + } + + 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; + } + } + + 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; + Object.assign(stats, data.stats); + 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; + } + } + + 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'); } } + 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 ---------------- */ function quake() { const name = SOP.QUAKE_NAMES[Math.floor(Math.random() * SOP.QUAKE_NAMES.length)]; stats.quakes++; @@ -121,12 +348,12 @@ SOP.Main = (() => { UI.log(`DEFEKT: ${SOP.OBJ[d.kind].label} BEI ${d.x}/${d.y}.`, 'alert'); } world.inmates.forEach(i => { - if (i.dead) return; + 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.on) SOP.Audio.alarm(); + if (SOP.Audio && SOP.Audio.on) SOP.Audio.alarm(); } function endGame() { @@ -136,6 +363,8 @@ SOP.Main = (() => {
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}
@@ -156,8 +385,8 @@ SOP.Main = (() => { UI.updateSystem(world, day()); UI.updateInspector(world, state.selected); } - if (uiFrame % 30 === 0 && !gameOver) { - UI.updateDevicePanel(world, state.selDevice, R.getView()); + if (uiFrame % 10 === 0 && !gameOver) { + UI.updateDevicePanel(world, state.selDevice, R.getView(), ticks); } requestAnimationFrame(frame); } @@ -216,7 +445,7 @@ SOP.Main = (() => { } // select inmate or device - const inm = world.inmates.find(i => !i.dead && i.tx === x && i.ty === y); + 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; } @@ -227,6 +456,7 @@ SOP.Main = (() => { 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; } @@ -266,6 +496,23 @@ SOP.Main = (() => { 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; @@ -284,6 +531,7 @@ SOP.Main = (() => { } 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; @@ -307,7 +555,17 @@ SOP.Main = (() => { if (v === 'CROSSLINK') UI.wire('CROSSLINK GEÖFFNET. LEITUNGEN SICHTBAR.'); } - return { setup, day, get state() { return state; } }; + 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/render.js b/game/render.js index 7f7ad35..79627b1 100644 --- a/game/render.js +++ b/game/render.js @@ -168,7 +168,10 @@ SOP.Render = (() => { for (const d of world.devices) drawDevice(d, T); // inmates - for (const inm of world.inmates) drawInmate(inm, T, state.selected === inm); + 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); @@ -177,6 +180,26 @@ SOP.Render = (() => { 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); @@ -300,7 +323,7 @@ SOP.Render = (() => { // inmates as faint dots for (const inm of world.inmates) { - if (inm.dead) continue; + 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); diff --git a/game/tools/smoke.js b/game/tools/smoke.js index b19b5a0..268f530 100644 --- a/game/tools/smoke.js +++ b/game/tools/smoke.js @@ -19,6 +19,24 @@ 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); @@ -67,22 +85,7 @@ for (let t = 0; t < ticks; t++) { 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) { - 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); - } - } + 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]]) { @@ -162,6 +165,114 @@ for (let t = 0; t < ticks; t++) { } } +/* ================= 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); diff --git a/game/ui.js b/game/ui.js index a4b9ebc..0746db3 100644 --- a/game/ui.js +++ b/game/ui.js @@ -122,7 +122,18 @@ SOP.UI = (() => { function updateInspector(world, selected) { if (!selected || selected.dead) { el.inspector.innerHTML = `
SUBJEKT-INSPEKTOR
-
Subjekt im Raster anklicken, um affektive und konative Ströme zu überwachen.
`; +
${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; @@ -149,28 +160,76 @@ SOP.UI = (() => {
`; } - /* ---------------- DEVICE INSPECTOR (crosslink) ---------------- */ - function updateDevicePanel(world, dev, mode) { + /* ---------------- 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.' : 'Nur im CROSSLINK-Modus.'}
`; +
${mode === 'CROSSLINK' ? 'Knoten anklicken. Zwei Knoten = Leitung.' : 'Modul anklicken, um Details zu sehen.'}
`; return; } const def = SOP.OBJ[dev.kind]; - el.devInspector.innerHTML = ` -
KNOTEN-INSPEKTOR
+ 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'}
` : ''} -
LEITUNGEN${dev.links.length}
- `; + } else if (!away.length) { + html += `
Keine Subjekte verfügbar.
`; + } + html += `
+
BEUTE: ÖL, KONSERVEN, SCHROTT. RISIKO: VERLETZUNG.
`; + } else { + html += ` -
ZWEITEN KNOTEN ANKLICKEN → LEITUNG LEGEN / TRENNEN (MAX ${SOP.LINK_RANGE} FELDER).
`; +
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) { diff --git a/game/world.js b/game/world.js index 57ee10a..2c2bf94 100644 --- a/game/world.js +++ b/game/world.js @@ -147,6 +147,15 @@ SOP.World = (() => { 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'; @@ -447,6 +456,62 @@ SOP.World = (() => { return best; } + /* ---------------- SAVE / LOAD ---------------- */ + 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 })), + }; + } + + function loadFrom(data) { + tiles = data.tiles.map(row => row.map(o => { + const tl = makeTile(o.t); + tl.o2 = o.o2; tl.gas = o.gas; tl.mark = !!o.m; tl.pocket = !!o.p; + return tl; + })); + 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 ---------------- */ function inGrid(x, y) { return x >= 0 && y >= 0 && x < W && y < H; } function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); } @@ -462,6 +527,7 @@ SOP.World = (() => { generate, placeDevice, removeDevice, addLink, removeLink, linksPairs, tryBuild, tryBuildWall, canBuildAt, orderDig, completeDig, tick, evalPower, walkable, findPath, nearestLit, inGrid, + serialize, loadFrom, W, H, }; })(); From f173951689c788e6328e1c10faa9321e334f9776 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:30:00 +0100 Subject: [PATCH 3/7] Update game/main.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- game/main.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/game/main.js b/game/main.js index dfd83d8..923a402 100644 --- a/game/main.js +++ b/game/main.js @@ -194,10 +194,11 @@ SOP.Main = (() => { } function returnFromExpedition(inm) { - stats.expeditions++; const air = world.devices.find(d => d.kind === 'airlock'); const spot = air ? adjacentStand(air) : null; - if (spot) { inm.tx = spot.x; inm.ty = spot.y; inm.px = spot.x; inm.py = spot.y; } + 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; From b2734e570a17d9edf36a23d36e84f21acf0c04f6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:44:59 +0100 Subject: [PATCH 4/7] Update game/package.json Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- game/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/game/package.json b/game/package.json index ac3f6ed..3f5d248 100644 --- a/game/package.json +++ b/game/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "test": "node tools/smoke.js 9600 && node tools/dom_test.js", - "serve": "python3 -m http.server 8000 --bind 0.0.0.0" + "serve": "python3 -m http.server 8000 --bind 127.0.0.1" }, "devDependencies": { "jsdom": "^24.0.0" From 639b165be0ce1e5df23c286ce446a41baad9da72 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:45:23 +0100 Subject: [PATCH 5/7] Update game/main.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- game/main.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/game/main.js b/game/main.js index 923a402..1739c89 100644 --- a/game/main.js +++ b/game/main.js @@ -287,7 +287,10 @@ SOP.Main = (() => { ticks = data.ticks; nextQuakeTick = data.nextQuakeTick; nextArrivalTick = data.nextArrivalTick; - Object.assign(stats, data.stats); + 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(); From 5fcb736237942a268426af63106d59a0d2409e90 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:45:29 +0000 Subject: [PATCH 6/7] docs(game): Document audio, simulation, rendering, and world functions --- game/audio.js | 8 +++++++- game/main.js | 25 +++++++++++++++++++++++++ game/render.js | 14 +++++++++++++- game/world.js | 30 +++++++++++++++++++++++++++--- 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/game/audio.js b/game/audio.js index a5bb408..1636ebd 100644 --- a/game/audio.js +++ b/game/audio.js @@ -12,6 +12,7 @@ SOP.Audio = (() => { 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; @@ -22,6 +23,7 @@ SOP.Audio = (() => { 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'; @@ -33,6 +35,7 @@ SOP.Audio = (() => { 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); @@ -45,6 +48,7 @@ SOP.Audio = (() => { 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; @@ -56,6 +60,7 @@ SOP.Audio = (() => { 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; @@ -67,6 +72,7 @@ SOP.Audio = (() => { } } + /** Start or stop the sequencer; return whether playback is running. */ function toggle() { init(); if (!ctx) return false; @@ -81,7 +87,7 @@ SOP.Audio = (() => { return running; } - /* One-shot alerts */ + /** Play a one-shot alert tone only while the sequencer is running. */ function alarm() { if (!ctx || !running) return; const t = ctx.currentTime; diff --git a/game/main.js b/game/main.js index 1739c89..f8f2188 100644 --- a/game/main.js +++ b/game/main.js @@ -25,9 +25,11 @@ SOP.Main = (() => { 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(); @@ -53,6 +55,7 @@ SOP.Main = (() => { 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) @@ -62,6 +65,7 @@ SOP.Main = (() => { } /* ---------------- SIMULATION TICK ---------------- */ + /** Advance an active, unpaused shift by one tick, processing events and autosave. */ function simTick() { if (!started || paused || gameOver) return; ticks++; @@ -110,11 +114,13 @@ SOP.Main = (() => { 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': @@ -155,6 +161,7 @@ SOP.Main = (() => { } /* ---------------- 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; @@ -165,6 +172,7 @@ SOP.Main = (() => { 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'); @@ -181,6 +189,7 @@ SOP.Main = (() => { 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); @@ -193,6 +202,7 @@ SOP.Main = (() => { 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; @@ -215,6 +225,7 @@ SOP.Main = (() => { } /* ---------------- 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'); @@ -240,6 +251,7 @@ SOP.Main = (() => { 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); @@ -248,8 +260,10 @@ SOP.Main = (() => { } /* ---------------- 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, @@ -261,6 +275,7 @@ SOP.Main = (() => { }; } + /** 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; } @@ -274,6 +289,7 @@ SOP.Main = (() => { } } + /** 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; } @@ -310,6 +326,7 @@ SOP.Main = (() => { } } + /** 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' }); @@ -326,6 +343,7 @@ SOP.Main = (() => { } } + /** Read a File as text, parse its JSON, and attempt to restore the saved game. */ function importSave(file) { const r = new FileReader(); r.onload = () => { @@ -336,6 +354,7 @@ SOP.Main = (() => { } /* ---------------- 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++; @@ -360,6 +379,7 @@ SOP.Main = (() => { 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); @@ -378,6 +398,7 @@ SOP.Main = (() => { /* ---------------- RENDER LOOP ---------------- */ let uiFrame = 0; + /** Draw the current view, periodically refresh the HUD, and request the next frame. */ function frame() { R.draw({ world, @@ -396,6 +417,7 @@ SOP.Main = (() => { } /* ---------------- INPUT ---------------- */ + /** Bind canvas, toolbar, save/load, and keyboard controls to game actions. */ function bindInput() { const canvas = document.getElementById('gameCanvas'); @@ -456,6 +478,7 @@ SOP.Main = (() => { 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; @@ -546,11 +569,13 @@ SOP.Main = (() => { }); } + /** 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; diff --git a/game/render.js b/game/render.js index 79627b1..dd37027 100644 --- a/game/render.js +++ b/game/render.js @@ -26,10 +26,14 @@ SOP.Render = (() => { 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; @@ -40,19 +44,21 @@ SOP.Render = (() => { 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 }; } - /* deterministic pseudo-random per tile for texture */ + /** 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(); @@ -86,12 +92,14 @@ SOP.Render = (() => { 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; @@ -177,6 +185,7 @@ SOP.Render = (() => { 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]; @@ -245,6 +254,7 @@ SOP.Render = (() => { } } + /** 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) { @@ -279,6 +289,7 @@ SOP.Render = (() => { } } + /** 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; @@ -310,6 +321,7 @@ SOP.Render = (() => { } /* ---------------- 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; diff --git a/game/world.js b/game/world.js index 2c2bf94..63e5ebd 100644 --- a/game/world.js +++ b/game/world.js @@ -27,10 +27,12 @@ SOP.World = (() => { }; /* ---------------- 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++) { @@ -84,6 +86,7 @@ SOP.World = (() => { [[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++) @@ -91,6 +94,7 @@ SOP.World = (() => { } /* ---------------- 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, @@ -110,6 +114,7 @@ SOP.World = (() => { 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); @@ -117,6 +122,7 @@ SOP.World = (() => { 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); @@ -126,11 +132,13 @@ SOP.World = (() => { 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) { @@ -143,7 +151,7 @@ SOP.World = (() => { return pairs; } - /* Build / order actions used by the UI. Returns error string or null. */ + /** 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]; @@ -162,6 +170,7 @@ SOP.World = (() => { 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 }; @@ -172,6 +181,7 @@ SOP.World = (() => { 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]; @@ -184,6 +194,7 @@ SOP.World = (() => { 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); @@ -191,7 +202,7 @@ SOP.World = (() => { return true; } - /* Complete one tile of excavation (called by AI). */ + /** 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; @@ -217,6 +228,7 @@ SOP.World = (() => { /* ---------------- 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(); @@ -294,6 +306,7 @@ SOP.World = (() => { } /* ---------------- 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 = []; @@ -324,6 +337,7 @@ SOP.World = (() => { } /* ---------------- TICK ---------------- */ + /** Advance devices, power, breathing, diffusion, and lighting; report device events to onEvent. */ function tick(onEvent) { // devices act for (const d of devices) { @@ -389,6 +403,7 @@ SOP.World = (() => { 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) { @@ -401,6 +416,7 @@ SOP.World = (() => { } /* ---------------- 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]; @@ -410,6 +426,10 @@ SOP.World = (() => { 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 []; @@ -444,7 +464,7 @@ SOP.World = (() => { return null; } - /* Nearest lit tile to (x,y). */ + /** 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++) { @@ -457,6 +477,7 @@ SOP.World = (() => { } /* ---------------- SAVE / LOAD ---------------- */ + /** Return world save data with copied resources and links, omitting derived runtime state. */ function serialize() { return { res: Object.assign({}, res), @@ -476,6 +497,7 @@ SOP.World = (() => { }; } + /** Restore world save data, rebuild device references and the Blackboard, then evaluate power. */ function loadFrom(data) { tiles = data.tiles.map(row => row.map(o => { const tl = makeTile(o.t); @@ -513,7 +535,9 @@ SOP.World = (() => { } /* ---------------- 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 { From 099a7f0ddaea8408801c2bb69fc083e73095445c Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:45:52 +0100 Subject: [PATCH 7/7] Update game/world.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- game/world.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/game/world.js b/game/world.js index 63e5ebd..039a4bb 100644 --- a/game/world.js +++ b/game/world.js @@ -499,11 +499,17 @@ SOP.World = (() => { /** Restore world save data, rebuild device references and the Blackboard, then evaluate power. */ function loadFrom(data) { - tiles = data.tiles.map(row => row.map(o => { + 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 = o.o2; tl.gas = o.gas; tl.mark = !!o.m; tl.pocket = !!o.p; + 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 = [];