diff --git a/game/ai.js b/game/ai.js index 6063a75..75a0103 100644 --- a/game/ai.js +++ b/game/ai.js @@ -6,6 +6,7 @@ window.SOP = window.SOP || {}; SOP.InmateAI = class InmateAI { + /** Create an idle inmate at tile coordinates (x, y) with randomised initial needs. */ constructor(name, trait, x, y) { this.name = name; this.trait = trait; @@ -36,6 +37,10 @@ SOP.InmateAI = class InmateAI { } /* ---------------- UTILITY EVALUATOR ---------------- */ + /** + * Choose the highest-scoring drive for a living inmate. Reset progress and movement + * when the action changes or has no goal; otherwise retain the current goal. + */ evaluateDrives(world) { if (this.dead) return; const A = this.affective; @@ -153,7 +158,7 @@ SOP.InmateAI = class InmateAI { this.logConativeState(best); } - /* Is this device on the same wired component as any dispenser? */ + /** Return whether dev is connected to any dispenser, including the dispenser itself. */ onDispenserGrid(world, dev) { const starts = world.devices.filter(d => d.kind === 'dispenser'); if (!starts.length) return false; @@ -171,6 +176,7 @@ SOP.InmateAI = class InmateAI { return seen.has(dev.id); } + /** Return a random sabotage goal for a wired device other than a gas vent, or null. */ pickSabotageTarget(world) { const linked = world.devices.filter(d => d.links.length > 0 && d.kind !== 'gas_vent'); if (!linked.length) return null; @@ -178,6 +184,7 @@ SOP.InmateAI = class InmateAI { return { kind: 'device', dev: d, act: 'sabotage' }; } + /** Return a walkable wander goal, or null if none is found in 20 random attempts. */ randomWanderGoal(world) { for (let tries = 0; tries < 20; tries++) { const x = Math.floor(Math.random() * world.W); @@ -187,6 +194,10 @@ SOP.InmateAI = class InmateAI { return null; } + /** + * Return the nearest device matching pred by Manhattan distance, or null. + * Reachability is not checked; errors thrown by pred propagate to the caller. + */ nearestDevice(world, pred) { let best = null, bd = Infinity; for (const d of world.devices) { @@ -197,6 +208,10 @@ SOP.InmateAI = class InmateAI { return best; } + /** + * Return the nearest dirt dig mark with a walkable neighbour, preferring unclaimed + * marks, or null if none qualifies. Distance is Manhattan; no path is checked. + */ nearestDigMark(world) { const taken = new Set(); for (const o of world.inmates) { @@ -216,6 +231,10 @@ SOP.InmateAI = class InmateAI { return best || bestTaken; } + /** + * Return the nearest empty tile with gas at most 10 and oxygen at least 30 + * on their 0–100 scales, or null. Use Manhattan distance without checking a path. + */ nearestClearAir(world) { let best = null, bd = Infinity; for (let y = 0; y < world.H; y++) for (let x = 0; x < world.W; x++) { @@ -227,6 +246,7 @@ SOP.InmateAI = class InmateAI { return best; } + /** Return the display thought for an action name, falling back to an em dash. */ thoughtFor(action) { const T = { 'ESSEN FASSEN': 'Ausgabe. Jetzt.', @@ -245,11 +265,16 @@ SOP.InmateAI = class InmateAI { return T[action] || '—'; } + /** Release all devices in world operated by this inmate. */ releaseCrank(world) { for (const d of world.devices) if (d.operatedBy === this.id) d.operatedBy = null; } /* ---------------- PER-TICK EXECUTION ---------------- */ + /** + * Advance needs, health, movement and actions by one simulation tick; ignore dead inmates. + * Deliver action events synchronously to onEvent; callback errors propagate. + */ tick(world, onEvent) { if (this.dead) return; // dynamo ownership sanity: a cranker who switched goals frees the cranks @@ -309,6 +334,10 @@ SOP.InmateAI = class InmateAI { if (this.sayCooldown > 0) this.sayCooldown--; } + /** + * Return whether tile (x, y) is close enough to execute the current goal. + * Flee and hide tile goals require an exact match; other goals allow one orthogonal step. + */ isGoalTile(x, y) { const g = this.goal; if (!g) return false; @@ -319,6 +348,10 @@ SOP.InmateAI = class InmateAI { return false; } + /** + * Replace the route to the current goal or an adjacent standing tile. + * Abandon the goal if no standing tile or path is available. + */ repath(world) { const g = this.goal; if (!g) { this.path = []; return; } @@ -339,6 +372,10 @@ SOP.InmateAI = class InmateAI { if (!p) this.goal = null; // unreachable — re-evaluate next round } + /** + * Return the first walkable neighbour in east, west, south, north order, or null. + * Inmate occupancy and reachability from the current position are not checked. + */ 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 }; @@ -346,6 +383,11 @@ SOP.InmateAI = class InmateAI { return null; } + /** + * Advance the current action when within reach, otherwise request a route if needed. + * May change needs, resources, terrain or devices and clear the goal. + * Deliver action events synchronously to onEvent; callback errors propagate. + */ execute(world, onEvent) { const g = this.goal; if (!g) return; @@ -445,6 +487,10 @@ SOP.InmateAI = class InmateAI { } } + /** + * Mark this inmate dead with the display cause, end sleep and release operated devices + * in SOP.World. + */ die(cause) { this.dead = true; this.deadCause = cause; @@ -454,6 +500,10 @@ SOP.InmateAI = class InmateAI { } /* ---------------- SAVE / LOAD ---------------- */ + /** + * Return save data with rounded health and copied needs and expedition state. + * Omit the current goal, route, sleep state and action progress. + */ serialize() { return { id: this.id, name: this.name, trait: this.trait, @@ -465,6 +515,7 @@ SOP.InmateAI = class InmateAI { }; } + /** Restore an inmate from save data with idle action state; reuse d.away when present. */ static fromData(d) { const i = new SOP.InmateAI(d.name, d.trait, d.tx, d.ty); i.id = d.id || d.name; @@ -477,6 +528,7 @@ SOP.InmateAI = class InmateAI { return i; } + /** Retain the scored action by reference as lastScored. */ logConativeState(action) { this.lastScored = action; } diff --git a/game/audio.js b/game/audio.js index 1636ebd..82824ad 100644 --- a/game/audio.js +++ b/game/audio.js @@ -72,7 +72,11 @@ SOP.Audio = (() => { } } - /** Start or stop the sequencer; return whether playback is running. */ + /** + * Start or stop scheduling notes; return whether the sequencer is running. + * Return false if Web Audio is unavailable; audio initialisation errors propagate. + * Stopping leaves already scheduled notes to finish. + */ function toggle() { init(); if (!ctx) return false; diff --git a/game/main.js b/game/main.js index f8f2188..a43cf14 100644 --- a/game/main.js +++ b/game/main.js @@ -275,7 +275,10 @@ SOP.Main = (() => { }; } - /** Save the current snapshot to localStorage and return success; silent suppresses logs. */ + /** + * Save the current snapshot to localStorage and return success; silent suppresses logs. + * Return false when storage is unavailable or serialisation or writing fails. + */ function saveGame(silent) { const st = storage(); if (!st) { if (!silent) UI.log('STORAGE BLOCKIERT. NUTZE EXPORT.', 'alert'); return false; } @@ -289,7 +292,10 @@ SOP.Main = (() => { } } - /** Restore a version-1 snapshot and resume play; return false on format or load errors. */ + /** + * Restore a version-1 snapshot and resume play; return true on success or false on format + * or load errors. Failed loads do not roll back changes already applied to the game. + */ function loadGame(data) { try { if (!data || data.v !== 1) { UI.log('AKTE UNLESBAR (FALSCHES FORMAT).', 'alert'); return false; } @@ -343,7 +349,11 @@ SOP.Main = (() => { } } - /** Read a File as text, parse its JSON, and attempt to restore the saved game. */ + /** + * Read a File as text asynchronously and attempt to restore its JSON snapshot. + * JSON parse errors are caught and load errors become loadGame's false result. + * Synchronous FileReader errors propagate; asynchronous read errors have no handler. + */ function importSave(file) { const r = new FileReader(); r.onload = () => { diff --git a/game/ui.js b/game/ui.js index 0746db3..6edad4d 100644 --- a/game/ui.js +++ b/game/ui.js @@ -26,6 +26,10 @@ SOP.UI = (() => { let logLines = []; /* ---------------- EVENT LOG ---------------- */ + /** + * Prepend a day-stamped message to the display, retaining at most 70 entries. + * msg is inserted as HTML; cls selects the l-* CSS class and defaults to ok. + */ function log(msg, cls) { const day = SOP.Main ? SOP.Main.day() : 0; logLines.push({ msg, cls: cls || 'ok', day }); @@ -35,14 +39,20 @@ SOP.UI = (() => { ).reverse().join(''); } + /** Display an alert message and sound the alarm when audio is on. */ function alert(msg) { log(msg, 'alert'); if (SOP.Audio.on) SOP.Audio.alarm(); } + /** Display a message with the wiring event style. */ function wire(msg) { log(msg, 'wire'); } /* ---------------- TICKER ---------------- */ + /** + * Replace ticker text with the standard lines, optionally prefixed by extra. + * An extra announcement highlights the ticker and schedules a reset after six seconds. + */ function setTicker(extra) { const lines = extra ? [extra, ...SOP.TICKER_LINES] : SOP.TICKER_LINES; el.ticker.textContent = lines.join(' /// ') + ' /// '; @@ -53,6 +63,7 @@ SOP.UI = (() => { } /* ---------------- BUILD MENU ---------------- */ + /** Populate build controls and hints; call onSelect(kind) when an item is clicked. */ function initBuildMenu(onSelect) { let html = ''; for (const kind of SOP.BUILDABLE) { @@ -80,17 +91,23 @@ SOP.UI = (() => { el.buildHint.textContent = 'MODUL WÄHLEN, DANN IM HOHLRAUM PLATZIEREN. ESC BRICHT AB.'; } + /** Return a German cost label from resource quantities, or GRATIS for an empty cost. */ function costText(cost) { const parts = []; for (const k in cost) parts.push(cost[k] + ' ' + k.toUpperCase()); return parts.length ? 'KOSTET ' + parts.join(', ') : 'GRATIS'; } + /** Remove selection styling from build buttons. */ function clearBuildSelection() { el.buildMenu.querySelectorAll('.build-item').forEach(x => x.classList.remove('selected')); } /* ---------------- SYSTEM PANEL ---------------- */ + /** + * Refresh the HUD with the day, power, resources, living inmate count and atmosphere + * averaged over empty tiles. + */ function updateSystem(world, day) { el.sysDay.textContent = day; const p = world.power; @@ -119,6 +136,10 @@ SOP.UI = (() => { /* ---------------- SUBJECT INSPECTOR ---------------- */ const NEED_LABEL = { stress: 'STRESS', hunger: 'HUNGER', exhaustion: 'ERSCHÖPFUNG', paranoia: 'ARGWOHN', o2: 'O₂-MANGEL' }; + /** + * Show the selected inmate’s needs and action, or an empty, dead or away state. + * world is unused. + */ function updateInspector(world, selected) { if (!selected || selected.dead) { el.inspector.innerHTML = `
SUBJEKT-INSPEKTOR
@@ -163,6 +184,11 @@ SOP.UI = (() => { /* ---------------- DEVICE INSPECTOR (crosslink / airlock) ---------------- */ let devSig = ''; + /** + * Refresh device details and controls when tracked display state changes. + * mode selects the empty-panel hint; ticksNow is elapsed simulation ticks + * used for expedition estimates and defaults to zero when omitted. + */ function updateDevicePanel(world, dev, mode, ticksNow) { const awaySig = world.inmates.filter(i => !i.dead && i.away) .map(i => i.name + ':' + i.away.returnTick).join(',') @@ -232,6 +258,7 @@ SOP.UI = (() => { }); } + /** Toggle a device’s enabled state, releasing its operator when disabling a dynamo. */ function toggleDevice(dev) { dev.enabled = dev.enabled === false ? true : false; if (dev.kind === 'dynamo' && dev.enabled === false) dev.operatedBy = null; @@ -239,6 +266,7 @@ SOP.UI = (() => { } /* ---------------- OVERLAYS ---------------- */ + /** Show the intro and bind its start button to hide it and call onStart. */ function showIntro(onStart) { el.overlayIntro.classList.add('show'); $('btn-start').addEventListener('click', () => { @@ -249,6 +277,7 @@ SOP.UI = (() => { }); } + /** Show stats as HTML in the game-over overlay; bind restart to hide it and call onRestart. */ function showGameOver(stats, onRestart) { el.overStats.innerHTML = stats; el.overlayOver.classList.add('show'); diff --git a/game/world.js b/game/world.js index 039a4bb..e4d2a47 100644 --- a/game/world.js +++ b/game/world.js @@ -17,10 +17,13 @@ SOP.World = (() => { digMarks: [], // {x, y} tiles ordered for excavation hazards: [], // {x, y, kind:'gas'} electricalNodes: [], // mirrors devices + /** Append the device reference to the electrical-node list. */ registerNode(dev) { this.electricalNodes.push(dev); }, + /** Add an excavation order unless these tile coordinates are already listed. */ postDigMark(x, y) { if (!this.digMarks.some(m => m.x === x && m.y === y)) this.digMarks.push({ x, y }); }, + /** Remove excavation orders at these tile coordinates. */ removeDigMark(x, y) { this.digMarks = this.digMarks.filter(m => !(m.x === x && m.y === y)); }, @@ -170,7 +173,10 @@ SOP.World = (() => { return null; } - /** Validate placement and resources, spend the cost, and return {ok, err?}. */ + /** + * Validate placement and resources, spend the cost, and return {ok, err?}. + * kind must name an SOP.OBJ entry; a missing definition throws a TypeError if placement is valid. + */ function tryBuild(kind, x, y) { const err = canBuildAt(kind, x, y); if (err) return { ok: false, err }; @@ -337,7 +343,10 @@ SOP.World = (() => { } /* ---------------- TICK ---------------- */ - /** Advance devices, power, breathing, diffusion, and lighting; report device events to onEvent. */ + /** + * Advance devices, power, breathing, diffusion, and lighting by one simulation tick. + * Report burn and harvest events synchronously to onEvent; callback errors propagate. + */ function tick(onEvent) { // devices act for (const d of devices) { @@ -416,7 +425,10 @@ SOP.World = (() => { } /* ---------------- PATHFINDING ---------------- */ - /** Return whether a tile is empty and unoccupied, except for lamps and sensors. */ + /** + * Return whether an in-grid tile is empty terrain with no device except a lamp or sensor. + * Inmates do not block movement; doors block it even when open. + */ function walkable(x, y) { if (!inGrid(x, y)) return false; const tl = tiles[y][x]; @@ -427,8 +439,9 @@ SOP.World = (() => { } /** - * Return a BFS path excluding the start, [] if already there, or null if unreachable. - * The target may be occupied; intermediate tiles must be walkable. + * Return a shortest orthogonal path as [x, y] tile pairs, excluding the start. + * Return [] if already at an in-grid target, or null if it is outside the grid or unreachable. + * The target need not be walkable, regardless of terrain or devices; intermediate tiles must be. */ function findPath(sx, sy, tx, ty) { if (!inGrid(tx, ty)) return null; @@ -497,7 +510,13 @@ SOP.World = (() => { }; } - /** Restore world save data, rebuild device references and the Blackboard, then evaluate power. */ + /** + * Restore world save data, rebuild device references and the Blackboard, then evaluate power + * and lighting. Power evaluation advances battery and relay state; inmates are unchanged. + * Throw Error('invalid world save') if grid dimensions, the devices array or resources object + * fail validation. + * Errors from malformed entries or power evaluation propagate; failures may leave partial changes. + */ function loadFrom(data) { if (!data || !Array.isArray(data.tiles) || data.tiles.length !== H || data.tiles.some(r => !Array.isArray(r) || r.length !== W) ||