+ 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.
`;
+ 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.'}