From dbe8659b12d26d942b2e6ad4b864d84f649c3129 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Thu, 16 Jul 2026 18:30:56 -0700 Subject: [PATCH 01/10] molotov/smoke fixes --- components/FinnShop.ts | 2 +- docs/kaizen.md | 34 +++ src/game/Run.ts | 111 ++++++++- src/game/Smoke.ts | 70 +++--- src/game/World.ts | 155 ++++++++++++- src/game/ai/PatrolHostile.ts | 54 ++++- src/game/combatTurnPipeline.ts | 20 +- src/game/constants.ts | 56 ++++- src/game/items.ts | 10 +- src/game/persistence.ts | 47 ++++ src/shell/shellRuntime.ts | 38 ++-- tests/unit/game/hazard.test.ts | 316 +++++++++++++++++++++++++- tests/unit/game/hazardPathing.test.ts | 181 +++++++++++++++ tests/unit/game/items.test.ts | 112 +++++---- tests/unit/game/persistence.test.ts | 144 ++++++++++++ 15 files changed, 1216 insertions(+), 134 deletions(-) create mode 100644 tests/unit/game/hazardPathing.test.ts diff --git a/components/FinnShop.ts b/components/FinnShop.ts index 208ff21..a1bf5cb 100644 --- a/components/FinnShop.ts +++ b/components/FinnShop.ts @@ -357,7 +357,7 @@ button.target-row[aria-current='true'] .cursor { `; const SCOPE_LABELS: Record = { - [ITEM_SCOPE.JOB]: 'CONSUMABLES', + [ITEM_SCOPE.JOB]: 'CONSUMABLES (Single use)', [ITEM_SCOPE.CAMPAIGN]: 'CREW GEAR (Applies to a single crew member)', }; diff --git a/docs/kaizen.md b/docs/kaizen.md index 3a93334..4ef1008 100644 --- a/docs/kaizen.md +++ b/docs/kaizen.md @@ -48,6 +48,21 @@ When an item lands, gets reclassified, or develops new context, edit it in place ## ◇ Monitored +- **Generated hazard and thrown fire are visually identical but behave differently.** Both + render as `▓`, but a map-generated pool is permanent scenery while a thrown molotov burns + out after `INCENDIARY_BURN_TURNS`. A player has no way to tell which fire will still be + there in three turns, which makes it hard to plan around either. Options: a distinct glyph + or colour ramp for burning-out fire, or an intensity ramp as the timer runs down (a natural + fit for the 3.6 "effects" work). **Revisit trigger:** first playtest where someone waits out + a permanent pool, or routes into fire that they expected to have gone out. +- **`INCENDIARY_THROW_DIST` is a fixed 3 tiles in 8 directions.** You cannot lob short, long, + or drop at your feet — the only Molotov play is "exactly 3 tiles that way". The cluster's + radius-1 spread softens this to an effective 2–4 band on-axis, but it still means a hostile + at range 1, 2, or 5+ simply cannot be targeted. Left alone in P3.6 because free targeting is + a real UX design problem (needs a cursor/reticle mode, not just a direction press) and the + fixed throw is at least legible. **Revisit trigger:** when any second thrown item wants + variable range, or the first playtest complaint about "I couldn't hit the thing next to me". + - **Debug save Import relies on `prompt()`.** The in-app browser used for local smoke tests does not support native `prompt()`, so `/debug/save.html` logs an error and cannot import a prepared save there. Normal browsers may still support it, but the debug surface should use @@ -78,6 +93,25 @@ When an item lands, gets reclassified, or develops new context, edit it in place ## ✓ Closed +- ~~**Smoke tiles survive a mid-mission save as permanent LOS blockers.**~~ Surfaced and closed + in P3.6 while fixing the Molotov. `placeSmoke` stamped `TILE.SMOKE` onto the grid and handed + the restore records to the *shell*, which held them in `shellRuntime.activeSmokeOverlays` and + cleared them on `onPlayerTurnReady`. But `grid.tiles` **is** persisted and + `activeSmokeOverlays` **was not** — and autosave fires at the player→corp `turn:ended`, which + is exactly when smoke is on the grid. Save inside your own cloud, reload, and the overlay list + came back `[]` while the SMOKE tiles remained: a permanent sight-line wall nothing would ever + clean up. Reachable in ordinary play, and silent when it happened. + **Fix:** effect lifetimes moved onto `World` as one shared registry — `applyTileEffect(x, y, + tile, turns)` / `tickTileEffects()` — that thrown fire and smoke both use, snapshotted as + `RunSnapshot.tileEffects`. Two details worth keeping: the registry **reads the tile underneath + itself** rather than taking it from the caller (a caller passing FLOOR for a cloud over the + EXIT tile would have silently deleted the exit), and re-applying to an already-affected tile + keeps the *original* restore target, so terrain can't be lost to a chain of effects. Ticked + once per round from `advanceFromPlayerTurn` at the CORP→PLAYER handoff — the only point both + the shell and the debug harness share, and necessarily *after* the corp turn, since smoke + exists to blind the drones that move next. `clearSmoke` and the shell's overlay list are gone. + Tests: `items.test.ts` (smoke lifetime, EXIT restore, fire/smoke interaction), + `persistence.test.ts` (save-mid-cloud round-trip), `hazard.test.ts` (round-boundary ordering). - ~~**Unwinnable runs.**~~ Closed in Phase 2.5 — two-part fix: (1) **Abort extraction:** player can exit via the EXIT tile with an incomplete objective; forfeits all rewards (creds, salvage, rep gain) and takes `REP.ABORT_PENALTY` (−5). (2) **Breach charge auto-grant:** `Campaign.deployCrewMember` grants 1× breaching charge when accepting a breach contract if the operative doesn't already carry one, so the objective is always completable regardless of Finn's shop access. - ~~**`advanceFromPlayerTurn` advances the turn queue on a terminal run.**~~ Closed at Phase 2 closeout — TIER-1 item #1 from the [2026-05-17 adversarial review](./2026-05-17-adversarial-review-findings.md#1). Stepping onto the exit tile transitions the run to RESULT synchronously; the pipeline then still called `queue.endTurn(world)`, refreshing corp AP and bumping the turn counter on a dead run. The `isTerminal()` guard now runs *before* `queue.endTurn` / `onCorpTurnReady`. Regression test in `combatTurnPipeline.test.ts`. diff --git a/src/game/Run.ts b/src/game/Run.ts index b74ec77..c45e76f 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -43,6 +43,8 @@ import { SALVAGE_DROP_MAX, ENEMY_ROLE, JACK_OUT_SHOCK_DAMAGE, + INCENDIARY_BURN_TURNS, + INCENDIARY_IMPACT_DAMAGE, factionForPrincipalGroups, STATUS_EFFECT, } from './constants.js'; @@ -142,7 +144,7 @@ import type { CyberAvatarSnapshot } from './cyber/CyberAvatar.js'; import type { EntryPortSnapshot } from './cyber/EntryPort.js'; import type { DataNodeSnapshot } from './cyber/DataNode.js'; import type { DeckerSnapshot } from './archetypes/Decker.js'; -import type { AlarmState } from './World.js'; +import type { AlarmState, TileEffectEntry } from './World.js'; export const RUN_STATE = Object.freeze({ BRIEFING: 'BRIEFING', @@ -310,6 +312,13 @@ export type RunSnapshot = { objectiveTimer?: ObjectiveTimerSnapshot; /** Map memory. Missing in older saves → current LOS only. */ mapMemory?: MapMemorySnapshot; + /** + * Timed tile effects — thrown fire and smoke clouds (P3.6). Missing in + * pre-3.6 saves → no timers, so any hazard or smoke on that grid stays put. + * That's exactly the behaviour those saves were played under (it's the bug + * this field exists to fix), so an old save reloads consistent with itself. + */ + tileEffects?: TileEffectEntry[]; /** Pickup unification: removed objective pickups still count as secured. */ objectiveProgress?: ObjectiveProgressSnapshot; /** P3.M5: Score-only independent extraction latch. */ @@ -978,6 +987,7 @@ export class Run { objectiveTimer: { ...this.objectiveTimer }, mapMemory: { seen: this.mapSeenKeys() }, objectiveProgress: { securedPickups: world.securedPickupIds() }, + tileEffects: world.tileEffectsSnapshot(), ...(this.extractedOperativeIds.size > 0 ? { extractedOperativeIds: [...this.extractedOperativeIds].sort(), @@ -3430,15 +3440,53 @@ function findConsumablePickupAnchor( return rng.pick(candidates); } +export type HazardClusterOptions = { + /** + * `true` when this cluster is a thrown incendiary rather than map scenery. + * + * This is not a style flag — the two callers want genuinely opposite things + * from the same geometry: + * + * - **Map generation** (`thrown: false`) stamps scenery around a cast that is + * *already placed* (`enterCombat` spawns hostiles before objectives), so it + * must not disturb anyone: every occupied tile is spared, nothing takes + * damage, and the fire is permanent — a contaminated site stays + * contaminated. + * - **A thrown molotov** (`thrown: true`) is an attack. Landing on someone is + * the entire point, so only hazard-immune props spare their tile; everyone + * else is set alight, takes `INCENDIARY_IMPACT_DAMAGE` on the spot, and the + * fire burns out after `INCENDIARY_BURN_TURNS`. + */ + thrown?: boolean; + /** Credited as `attacker` on `ENTITY_DAMAGED`. Thrown clusters only. */ + attacker?: Entity | null; +}; + +export type HazardClusterResult = { + /** How many tiles actually caught. Zero means the throw hit hard cover. */ + placed: number; + /** Entities hit by the ignition itself. Always empty for scenery. */ + casualties: { entity: Entity; damage: number; killed: boolean }[]; +}; + /** * Place a cluster of HAZARD tiles near `center`. Stomps FLOOR tiles only — - * walls, cover, exit, and tiles occupied by entities are left alone. The - * cluster is a diamond/cross shape (center + cardinal neighbours) with a - * random subset of diagonal neighbours, giving an organic 5–9 tile footprint. + * walls, cover, exit, and rubble are left alone. The cluster is a diamond/cross + * shape (center + cardinal neighbours) with a random subset of diagonal + * neighbours, giving an organic 5–9 tile footprint. + * + * Whether an *occupied* tile catches depends on `options.thrown` — see + * {@link HazardClusterOptions}. * * Exported for testing. */ -export function placeHazardCluster(world: World, center: GridPoint, rng: Rng): number { +export function placeHazardCluster( + world: World, + center: GridPoint, + rng: Rng, + options: HazardClusterOptions = {} +): HazardClusterResult { + const thrown = options.thrown === true; const candidates: GridPoint[] = [center]; // Cardinal neighbours (always included when legal) for (const [dx, dy] of [ @@ -3461,14 +3509,61 @@ export function placeHazardCluster(world: World, center: GridPoint, rng: Rng): n } } let placed = 0; + const ignited: GridPoint[] = []; for (const { x, y } of candidates) { if (!world.grid.inBounds(x, y)) continue; if (world.grid.tileAt(x, y) !== TILE.FLOOR) continue; - if (world.entityAt(x, y)) continue; - world.grid.setTile(x, y, TILE.HAZARD); + // Scenery spares anyone standing there; a thrown bomb only spares props + // that can't burn anyway (terminals, pickups, sync pads — everything that + // overrides `isHazardImmune`). Burying an objective in permanent fire + // would make it uninteractable, which no amount of tactical intent + // justifies. + const occupant = world.entityAt(x, y); + if (occupant && (!thrown || occupant.isHazardImmune())) continue; + if (thrown) { + // Registers a burn timer — thrown fire always goes out. + world.applyTileEffect(x, y, TILE.HAZARD, INCENDIARY_BURN_TURNS); + } else { + // Raw setTile: generated hazard is permanent scenery, so it deliberately + // gets no timer and never appears in the tile-effect registry. + world.grid.setTile(x, y, TILE.HAZARD); + } + ignited.push({ x, y }); placed++; } - return placed; + if (!thrown) return { placed, casualties: [] }; + return { placed, casualties: burnEntitiesOn(world, ignited, options.attacker ?? null) }; +} + +/** + * Apply the incendiary's one-off impact damage to everything standing on a + * freshly-lit tile. Mirrors `breachBlast`: route through `Entity.damage()` so + * shields apply, and emit `ENTITY_DAMAGED` so Run's death-detection listener + * resolves kills through the normal path. + */ +function burnEntitiesOn( + world: World, + ignited: readonly GridPoint[], + attacker: Entity | null +): { entity: Entity; damage: number; killed: boolean }[] { + const lit = new Set(ignited.map(p => `${p.x},${p.y}`)); + const casualties: { entity: Entity; damage: number; killed: boolean }[] = []; + for (const entity of world.entities.values()) { + if (!entity.alive) continue; + if (entity.isHazardImmune()) continue; + if (!lit.has(`${entity.x},${entity.y}`)) continue; + const damage = entity.damage(INCENDIARY_IMPACT_DAMAGE); + const killed = !entity.alive; + casualties.push({ entity, damage, killed }); + world.events?.emit(EVENT.ENTITY_DAMAGED, { + attacker, + target: entity, + damage, + killed, + source: 'incendiary', + }); + } + return casualties; } function manhattan(a: GridPoint, b: GridPoint): number { diff --git a/src/game/Smoke.ts b/src/game/Smoke.ts index d196226..a62a836 100644 --- a/src/game/Smoke.ts +++ b/src/game/Smoke.ts @@ -1,57 +1,49 @@ /** - * Smoke cloud placement and cleanup for the Smoke Charge consumable. + * Smoke cloud placement for the Smoke Charge consumable. * - * Smoke is a grid overlay — FLOOR tiles under the cloud are temporarily - * replaced with `TILE.SMOKE`, which is passable but blocks LOS. The overlay - * records each original tile so `clearSmoke` can restore the grid exactly. + * Smoke is a timed tile effect — FLOOR/EXIT tiles under the cloud are replaced + * with `TILE.SMOKE`, which is passable but blocks LOS. Lifetime is owned by + * `World.applyTileEffect`: placed on the player's turn, it blocks drone LOS + * through the following corp turn, and `World.tickTileEffects` clears it at the + * round boundary before the player acts again. * - * Smoke lasts 1 turn: placed on the player's turn, blocks drone LOS during - * the following corp turn, then clears at the start of the player's next - * turn. The shell drives the lifecycle via `onPlayerTurnReady`. + * Cleanup used to be the *shell's* job (`shellRuntime.activeSmokeOverlays`, + * cleared on `onPlayerTurnReady`), which quietly broke across saves: the grid is + * persisted, that overlay list was not, and autosave fires at the player→corp + * hand-off — exactly when smoke is on the map. Save inside your own cloud, + * reload, and the SMOKE tiles came back with no overlay left to clear them: a + * permanent sight-line wall. Lifetimes now live with the grid they mutate. * - * Pure module — no DOM, no side-effects beyond grid mutation. + * Pure module — no DOM, no side-effects beyond the world's grid. */ -import { TILE } from './constants.js'; -import type { Grid } from './Grid.js'; - -type SmokeOverlay = { x: number; y: number; originalTile: number }; +import { SMOKE_DURATION, TILE } from './constants.js'; +import type { World } from './World.js'; +import type { GridPoint } from '../types.js'; /** * Place a smoke cloud centered on (cx, cy) with the given Chebyshev radius. - * Only FLOOR and EXIT tiles are converted to SMOKE — walls and cover are - * unaffected (smoke doesn't fill solid objects). Out-of-bounds tiles are - * silently skipped. + * Only FLOOR and EXIT tiles are converted — walls and cover are unaffected + * (smoke doesn't fill solid objects), and tiles already carrying an effect + * (a burning hazard, say) are left to it. Out-of-bounds tiles are skipped. * - * @returns {Array} - * Records for each tile converted, used by `clearSmoke` to restore. + * @returns the tiles that took smoke. */ -export function placeSmoke(grid: Grid, cx: number, cy: number, radius: number): SmokeOverlay[] { - const overlays = []; +export function placeSmoke(world: World, cx: number, cy: number, radius: number): GridPoint[] { + if (!Number.isInteger(radius) || radius < 0) { + throw new RangeError(`placeSmoke requires a non-negative integer radius, got ${radius}`); + } + const placed: GridPoint[] = []; for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { const x = cx + dx; const y = cy + dy; - if (!grid.inBounds(x, y)) continue; - const t = grid.tileAt(x, y); - // Only replace passable, non-smoke tiles. - if (t === TILE.FLOOR || t === TILE.EXIT) { - overlays.push({ x, y, originalTile: t }); - grid.setTile(x, y, TILE.SMOKE); - } - } - } - return overlays; -} - -/** - * Remove smoke by restoring each overlay's original tile type. Idempotent — - * calling with an empty array is a no-op. - */ -export function clearSmoke(grid: Grid, overlays: SmokeOverlay[]) { - for (const { x, y, originalTile } of overlays) { - if (grid.inBounds(x, y) && grid.tileAt(x, y) === TILE.SMOKE) { - grid.setTile(x, y, originalTile); + if (!world.grid.inBounds(x, y)) continue; + const t = world.grid.tileAt(x, y); + if (t !== TILE.FLOOR && t !== TILE.EXIT) continue; + world.applyTileEffect(x, y, TILE.SMOKE, SMOKE_DURATION); + placed.push({ x, y }); } } + return placed; } diff --git a/src/game/World.ts b/src/game/World.ts index 332586e..baee66a 100644 --- a/src/game/World.ts +++ b/src/game/World.ts @@ -8,10 +8,11 @@ import { ConsumablePickup, CONSUMABLE_PICKUP_GLYPH } from './entities/Consumable import { BreachingCharge } from './entities/BreachingCharge.js'; import { KeyCard } from './entities/KeyCard.js'; import { totalSalvage } from './salvage.js'; +import { coordKey } from './mapConnectivity.js'; import type { Grid } from './Grid.js'; import type { Entity, LootableEntity } from './Entity.js'; import type { EventBus } from './events.js'; -import type { TileDelta } from '../types.js'; +import type { GridPoint, TileDelta } from '../types.js'; import { nudgeIfOccupied } from './placement.js'; /** @@ -38,6 +39,18 @@ export type WorldOptions = { events?: EventBus | null; }; +/** + * One timed tile effect: the tile it placed, how many rounds it has left, and + * the tile to put back when it ends. Snapshot/restore shape. + */ +export type TileEffectEntry = { + x: number; + y: number; + tile: TileId; + turnsLeft: number; + restoreTo: TileId; +}; + export const ALARM_PHASE = Object.freeze({ QUIET: 'quiet', ALERT: 'alert', @@ -75,6 +88,24 @@ export class World { mutationDeltas: TileDelta[]; #breachingChargeSeq: number; + /** + * Timed tile effects: `"x,y"` → what's on the tile, how long it lasts, and + * what to put back. Thrown fire and smoke clouds both live here; + * `tickTileEffects` ages them at the round boundary. + * + * Map-generated hazard (the "gassed clinic" contract flavor) is permanent + * scenery and is deliberately never registered — a contaminated site stays + * contaminated for the whole run. + * + * This lives on World rather than in the shell on purpose. Smoke used to keep + * its lifetime in `shellRuntime.activeSmokeOverlays`, which was *not* + * persisted while `grid.tiles` *is* — so a mid-mission autosave/reload + * stranded smoke tiles on the grid permanently, with no overlay left to clear + * them. Effect lifetimes belong with the state they mutate, and ride the run + * snapshot with it. + */ + #tileEffects: Map; + /** Tunable alarm cadence. `alarmActive` remains as the legacy alert-phase view. */ alarm: AlarmState; @@ -86,6 +117,7 @@ export class World { this.securedPickups = new Set(); this.mutationDeltas = []; this.#breachingChargeSeq = 0; + this.#tileEffects = new Map(); this.alarm = quietAlarm(); } @@ -282,6 +314,127 @@ export class World { return charge; } + /** + * Temporarily override a tile, restoring whatever was underneath when the + * timer runs out. This is the one mechanism behind every timed tile effect — + * thrown fire (`TILE.HAZARD`, `INCENDIARY_BURN_TURNS`) and smoke clouds + * (`TILE.SMOKE`, `SMOKE_DURATION`) are both just entries in this registry. + * + * The restore target is **read off the grid here**, never passed in. A caller + * forced to supply it can get it wrong, and the failure is silent map + * corruption: pass FLOOR for a cloud drifting over the EXIT tile and the exit + * quietly ceases to exist for the rest of the run. Capturing it at the moment + * of the mutation makes that unrepresentable. + * + * Re-applying to a tile that already carries an effect refreshes it in place + * and **keeps the original restore target**, so terrain can never be lost to a + * chain of effects — a second molotov on a burning tile still reverts to + * FLOOR, not to HAZARD. Last effect wins; what lies underneath is remembered + * exactly once. + * + * Application is unconditional: callers own the "what may be overridden" rules + * (`placeHazardCluster` skips walls and props, `placeSmoke` takes only + * FLOOR/EXIT). What this guarantees is that an overridden tile is *always* + * registered — no effect can be applied without also being scheduled to end. + */ + applyTileEffect(x: number, y: number, tile: TileId, turns: number): void { + if (!Number.isInteger(x) || !Number.isInteger(y)) { + throw new TypeError(`World.applyTileEffect requires integer x,y; got (${x}, ${y})`); + } + if (!this.grid.inBounds(x, y)) { + throw new RangeError(`World.applyTileEffect: (${x}, ${y}) out of grid bounds`); + } + if (!Number.isInteger(turns) || turns < 1) { + throw new RangeError(`World.applyTileEffect requires turns >= 1, got ${turns}`); + } + const key = coordKey(x, y); + const existing = this.#tileEffects.get(key); + const restoreTo = existing ? existing.restoreTo : (this.grid.tileAt(x, y) as TileId); + if (tile === restoreTo) { + throw new RangeError( + `World.applyTileEffect: (${x}, ${y}) effect tile ${tile} is already the tile underneath — ` + + `the effect would be invisible and its expiry a no-op` + ); + } + this.grid.setTile(x, y, tile); + this.#tileEffects.set(key, { tile, turnsLeft: turns, restoreTo }); + } + + /** + * Age every timed tile effect by one round and revert the spent ones. + * + * Driven once per round by `advanceFromPlayerTurn` at the CORP→PLAYER handoff + * — the true round boundary, and the one point both the shell and the debug + * harness share. Smoke placed on your turn therefore survives the corp turn it + * was thrown to blind (its entire purpose) and is gone before you act again. + * + * A tile that has since become something else (rubble from a breach, say) is + * dropped from the registry without being reverted — an expiring effect must + * never clobber a newer mutation. + * + * @returns the tiles whose effects ended this round. + */ + tickTileEffects(): GridPoint[] { + const expired: GridPoint[] = []; + // Safe to mutate while iterating: we only ever delete the current key or + // overwrite an existing one, neither of which perturbs a Map's iteration. + for (const [key, effect] of this.#tileEffects) { + const [xs, ys] = key.split(','); + const x = Number(xs); + const y = Number(ys); + if (this.grid.tileAt(x, y) !== effect.tile) { + this.#tileEffects.delete(key); + continue; + } + const remaining = effect.turnsLeft - 1; + if (remaining > 0) { + this.#tileEffects.set(key, { ...effect, turnsLeft: remaining }); + continue; + } + this.grid.setTile(x, y, effect.restoreTo); + this.#tileEffects.delete(key); + expired.push({ x, y }); + } + return expired; + } + + /** Live timed tile effects, for the run snapshot. Empty when none are active. */ + tileEffectsSnapshot(): TileEffectEntry[] { + return [...this.#tileEffects].map(([key, effect]) => { + const [xs, ys] = key.split(','); + return { + x: Number(xs), + y: Number(ys), + tile: effect.tile, + turnsLeft: effect.turnsLeft, + restoreTo: effect.restoreTo, + }; + }); + } + + /** + * Rehydrate timed tile effects from a snapshot. Entries whose tile no longer + * matches what the effect claims to have placed are dropped — the grid is the + * source of truth for what is actually on the map. + */ + restoreTileEffects(entries: readonly TileEffectEntry[]): void { + this.#tileEffects.clear(); + for (const { x, y, tile, turnsLeft, restoreTo } of entries) { + if (!Number.isInteger(x) || !Number.isInteger(y)) { + throw new TypeError( + `World.restoreTileEffects: entry requires integer x,y; got (${x}, ${y})` + ); + } + if (!Number.isInteger(turnsLeft) || turnsLeft < 1) { + throw new RangeError( + `World.restoreTileEffects: entry (${x}, ${y}) requires turnsLeft >= 1, got ${turnsLeft}` + ); + } + if (!this.grid.inBounds(x, y) || this.grid.tileAt(x, y) !== tile) continue; + this.#tileEffects.set(coordKey(x, y), { tile, turnsLeft, restoreTo }); + } + } + recordSecuredPickup(id: string): void { if (typeof id !== 'string' || id.length === 0) { throw new TypeError('World.recordSecuredPickup requires a non-empty id'); diff --git a/src/game/ai/PatrolHostile.ts b/src/game/ai/PatrolHostile.ts index d42534c..9b0807f 100644 --- a/src/game/ai/PatrolHostile.ts +++ b/src/game/ai/PatrolHostile.ts @@ -26,7 +26,7 @@ */ import { Hostile, type HostileInit } from '../Hostile.js'; -import { AP_COST, FACTION, SIGHT_RANGE, moveStepApCost } from '../constants.js'; +import { AP_COST, FACTION, SIGHT_RANGE, TILE, moveStepApCost } from '../constants.js'; import type { FactionId, TileId } from '../constants.js'; import { findPath } from '../Pathfinding.js'; import { withinRange } from '../LineOfSight.js'; @@ -324,6 +324,12 @@ export abstract class PatrolHostile extends Hostile { * Step one tile along an A* path toward `(gx, gy)`. Returns the move step on * success, or `null` when there's no path / not enough AP / the move is * illegal. `protected` so subclass engage logic can close distance. + * + * Paths twice: once with every hazard tile treated as blocked, and — only if + * that finds nothing at all — once without the restriction. So fire reads as + * a wall the AI respects, while never becoming a *real* wall: a hostile + * sealed in by flame still comes for you, it just has to walk through the + * burn to do it (and `moveStepApCost` makes sure that costs it). */ protected stepToward( world: World, @@ -331,7 +337,7 @@ export abstract class PatrolHostile extends Hostile { gy: number, kind: PatrolHostileMoveKind ): PatrolHostileMoveStep | null { - const path = findPath(world, { x: this.x, y: this.y }, { x: gx, y: gy }); + const path = this.#pathAvoidingHazard(world, gx, gy); if (!path || path.length === 0) return null; const next = path[0]; const stepCost = moveStepApCost(world.grid.tileAt(next.x, next.y) as TileId); @@ -343,4 +349,48 @@ export abstract class PatrolHostile extends Hostile { world.moveEntity(this, dx, dy); return { type: `move-${kind}`, to: { x: this.x, y: this.y } } satisfies PatrolHostileMoveStep; } + + /** + * Prefer a route that never touches fire; fall back to the direct route when + * no fire-free route exists at all. + * + * Deliberately *not* implemented by weighting A* or by making HAZARD + * impassable in `Grid.isPassable`. `findPath` is load-bearing for map + * generation and for the exit-reachability soft-lock check in `Run` — a + * player throwing a molotov must never be able to convince the game that the + * exit is unreachable. Keeping the avoidance here, at the one AI call site, + * means fire changes how hostiles *choose* to move without changing what the + * map believes is connected. + * + * The fallback is keyed on "no safe path exists", not "no safe path within + * this turn's AP" — `stepToward` paths the whole route and takes one step, so + * a hostile will happily walk the long way round over several turns rather + * than sprint through flame the moment the detour exceeds one turn's budget. + */ + #pathAvoidingHazard(world: World, gx: number, gy: number): GridPoint[] | null { + const start = { x: this.x, y: this.y }; + const goal = { x: gx, y: gy }; + const burning = hazardKeys(world); + if (burning.size > 0) { + const safe = findPath(world, start, goal, { extraBlockers: burning }); + if (safe && safe.length > 0) return safe; + } + return findPath(world, start, goal); + } +} + +/** + * Coordinate keys of every hazard tile on the grid. Recomputed per call — fire + * changes between turns (thrown, burned out), and `findPath` already scans the + * grid, so caching would buy little for the extra staleness risk. + */ +function hazardKeys(world: World): Set { + const keys = new Set(); + const { width, height } = world.grid; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (world.grid.tileAt(x, y) === TILE.HAZARD) keys.add(`${x},${y}`); + } + } + return keys; } diff --git a/src/game/combatTurnPipeline.ts b/src/game/combatTurnPipeline.ts index b7fd6ad..6682070 100644 --- a/src/game/combatTurnPipeline.ts +++ b/src/game/combatTurnPipeline.ts @@ -202,6 +202,16 @@ export function advanceFromPlayerTurn(ctx: PlayerTurnContext) { onFinish: () => { if (isTerminal()) return; queue.endTurn(world); + // Round boundary: age every timed tile effect exactly once, here, at + // the CORP→PLAYER handoff. This is the only point both the shell and + // the debug harness pass through, which is why effect lifetimes hang + // off it rather than off shell state. + // + // It has to be *after* the corp turn: smoke thrown on the player's + // turn exists to blind the drones that move next, so aging it any + // earlier (in player aftermath, say) would clear it before it ever + // did its job. + world.tickTileEffects(); onPlayerTurnReady(); }, }); @@ -371,10 +381,12 @@ export function* runPlayerAftermathSteps( if (!entity.alive) continue; if (entity.isHazardImmune()) continue; if (world.grid.tileAt(entity.x, entity.y) !== TILE.HAZARD) continue; - const damage = HAZARD_DAMAGE; - entity.hp = Math.max(0, entity.hp - damage); - const killed = entity.hp <= 0; - if (killed) entity.alive = false; + // Route through `Entity.damage()` rather than writing `hp` directly, so + // shields absorb fire like they absorb everything else. Writing hp here by + // hand made a Medic's shield the one thing in the game that stopped + // bullets but not flame. + const damage = entity.damage(HAZARD_DAMAGE); + const killed = !entity.alive; world.events?.emit(EVENT.ENTITY_DAMAGED, { attacker: null, target: entity, diff --git a/src/game/constants.ts b/src/game/constants.ts index 5a36ff4..b1fffd4 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -13,6 +13,11 @@ * grants a defender hit-modifier (applied in combat). * - EXIT: passable, transparent; same walk rules as FLOOR but painted so the * objective tile is visible. `Run` still tracks `exitTile` for transitions. + * - HAZARD: fire/contamination. Passable and transparent, but costs extra AP to + * enter and damages whoever ends a turn on it. Two sources with deliberately + * different lifetimes: map-generated pools are permanent scenery, while a + * thrown incendiary registers a burn timer on `World` and goes out (see + * `INCENDIARY_BURN_TURNS`). * - RUBBLE: passable debris left by breaching charges; costs more AP to enter. */ export const TILE = Object.freeze({ @@ -103,6 +108,7 @@ export type StatusEffectId = (typeof STATUS_EFFECT)[keyof typeof STATUS_EFFECT]; export const AP_COST = Object.freeze({ MOVE: 1, ENTER_RUBBLE: 2, + ENTER_HAZARD: 2, // picking your way through flame is slow — see moveStepApCost RANGED_ATTACK: 2, MELEE_ATTACK: 1, INTERACT: 1, @@ -372,6 +378,9 @@ export const FLANKER_BASE_AP = 3; * tile at the end of a round (resolved during player aftermath). Same damage * as a ranged shot — enough to punish loitering but survivable for a healthy * entity. + * + * This is the *standing* tick only. A thrown incendiary also deals + * `INCENDIARY_IMPACT_DAMAGE` once, on the turn it lands. */ export const HAZARD_DAMAGE = 1; @@ -381,11 +390,25 @@ export const BREACH_BLAST_DAMAGE = 2; export const BREACHING_CHARGE_GLYPH = 'ø'; /** - * AP to spend when stepping onto a tile. Rubble uses ENTER_RUBBLE; all other - * passable destinations use MOVE (including leaving rubble). + * AP to spend when stepping onto a tile. Rubble uses ENTER_RUBBLE, hazard uses + * ENTER_HAZARD; all other passable destinations use MOVE (including *leaving* + * rubble or hazard — the cost is charged on entry only). + * + * P3.6: hazard's entry cost is what gives fire teeth. With `DEFAULT_AP` of 4 + * and a 1 AP step, an entity could cross a whole 3-wide incendiary cluster in + * a single turn and never be standing on fire when the aftermath tick fires — + * i.e. take zero damage. At 2 AP per tile a forced crossing ends the turn + * inside the burn, which is the entire point of throwing it. */ export function moveStepApCost(destTile: TileId): number { - return destTile === TILE.RUBBLE ? AP_COST.ENTER_RUBBLE : AP_COST.MOVE; + switch (destTile) { + case TILE.RUBBLE: + return AP_COST.ENTER_RUBBLE; + case TILE.HAZARD: + return AP_COST.ENTER_HAZARD; + default: + return AP_COST.MOVE; + } } /** @@ -453,9 +476,34 @@ export const EMP_STUN_DURATION = 1; * `MODE.AIM` with `aimKind: 'use-item'`. The target tile is `thrower + dir * * INCENDIARY_THROW_DIST`; LOS from thrower → target must be clear (no lobbing * through walls). Hazard cluster shape and size come from `placeHazardCluster` - * (5–9 tile diamond/cross of `TILE.HAZARD`). Damage per tile: `HAZARD_DAMAGE`. + * (5–9 tile diamond/cross of `TILE.HAZARD`). Damage per standing tick: + * `HAZARD_DAMAGE`. */ export const INCENDIARY_THROW_DIST = 3; +/** + * Damage dealt once, immediately, to every non-hazard-immune entity standing + * on a tile the incendiary ignites. Distinct from `HAZARD_DAMAGE` (the + * per-turn standing tick) — this is the bomb going off, not the fire burning. + * + * Tuning: at 2, a direct hit plus a single standing tick kills a `DEFAULT_HP` + * (3) drone. That's the intended payoff for 40 salvage and an item slot — but + * only if the drone can't just walk away for free, hence `ENTER_HAZARD`. + */ +export const INCENDIARY_IMPACT_DAMAGE = 2; +/** + * Turns a thrown incendiary's fire burns before the tile reverts to FLOOR. + * + * Only *thrown* fire expires. Hazard placed by map generation (the "gassed + * clinic" contract flavor) is permanent scenery and is never registered with + * a burn timer — a contaminated site stays contaminated. + */ +export const INCENDIARY_BURN_TURNS = 3; +/** + * Rounds a smoke cloud lasts. One round means: thrown on your turn, it blinds + * drones through the corp turn that immediately follows, and is gone before you + * act again — which is exactly the window it's bought for. + */ +export const SMOKE_DURATION = 1; /** Breaching charges are placed against an adjacent tile/entity. */ export const BREACHING_CHARGE_RANGE = 1; export const TARGETING_BONUS = 0.1; diff --git a/src/game/items.ts b/src/game/items.ts index 96cebba..28ad457 100644 --- a/src/game/items.ts +++ b/src/game/items.ts @@ -19,8 +19,6 @@ import { SHOP_COST, STIM_HEAL, SMOKE_RADIUS, - INCENDIARY_THROW_DIST, - BREACHING_CHARGE_RANGE, TARGETING_BONUS, DODGE_BONUS, RANGED_DAMAGE_BONUS, @@ -108,7 +106,7 @@ export const DEFAULT_ITEMS: readonly Item[] = Object.freeze([ label: 'Stim', scope: ITEM_SCOPE.JOB, cost: SHOP_COST.STIM, - description: `Restores ${STIM_HEAL} HP to the deployed crew member. Single use.`, + description: `Restores ${STIM_HEAL} HP to the deployed crew member.`, needsTarget: true, }), Object.freeze({ @@ -116,7 +114,7 @@ export const DEFAULT_ITEMS: readonly Item[] = Object.freeze([ label: 'Smoke Charge', scope: ITEM_SCOPE.JOB, cost: SHOP_COST.SMOKE_CHARGE, - description: `Blocks LOS in radius ${SMOKE_RADIUS} for 1 turn. Single use.`, + description: `Blocks LOS in radius ${SMOKE_RADIUS} for 1 turn.`, needsTarget: true, }), Object.freeze({ @@ -124,7 +122,7 @@ export const DEFAULT_ITEMS: readonly Item[] = Object.freeze([ label: 'Molotov', scope: ITEM_SCOPE.JOB, cost: SHOP_COST.MOLOTOV, - description: `Throw ${INCENDIARY_THROW_DIST} tiles in a chosen direction; ignites a persistent hazard cluster. Single use.`, + description: `Throw one to damage enemies and deny them easy movement.`, needsTarget: true, needsAim: true, }), @@ -133,7 +131,7 @@ export const DEFAULT_ITEMS: readonly Item[] = Object.freeze([ label: 'Breaching Charge', scope: ITEM_SCOPE.JOB, cost: SHOP_COST.BREACHING_CHARGE, - description: `Destroy a wall, locked door, or reinforced objective within ${BREACHING_CHARGE_RANGE} tile. Single use.`, + description: `Destroys walls, locked doors, and reinforced objectives.`, needsTarget: true, needsAim: true, }), diff --git a/src/game/persistence.ts b/src/game/persistence.ts index cd49da1..3819a96 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -32,6 +32,7 @@ import { Rng } from '../rng.js'; import { Grid } from './Grid.js'; import { World } from './World.js'; +import type { TileEffectEntry } from './World.js'; import { TurnQueue } from './TurnQueue.js'; import { EventBus } from './events.js'; import { @@ -1263,6 +1264,7 @@ export function restore(record: unknown, options: RestoreOptions = {}) { run.world.restoreSecuredPickups( normalizeObjectiveProgress(record.objectiveProgress).securedPickups ); + run.world.restoreTileEffects(normalizeTileEffects(record.tileEffects)); run.extractedOperativeIds = new Set(extractedOperativeIds); run.world.mutationDeltas = normalizeMutationDeltas(record.mutationDeltas, grid); if (record.alarm) { @@ -2141,6 +2143,51 @@ function normalizeObjectiveProgress(progress: unknown): ObjectiveProgressSnapsho return { securedPickups: [...candidate.securedPickups] }; } +/** + * Timed tile effects — thrown fire, smoke clouds. Absent in pre-P3.6 saves → no + * timers, which leaves that save's fire and smoke permanent: the behaviour it + * was played under. + */ +function normalizeTileEffects(raw: unknown): TileEffectEntry[] { + if (raw === undefined || raw === null) return []; + if (!Array.isArray(raw)) { + throw new TypeError('restore: tileEffects must be an array'); + } + return raw.map(entry => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new TypeError('restore: tileEffects entries must be objects'); + } + const { x, y, tile, turnsLeft, restoreTo } = entry as Partial; + if (!Number.isInteger(x) || !Number.isInteger(y)) { + throw new TypeError( + `restore: tileEffects entry requires integer x,y; got (${String(x)}, ${String(y)})` + ); + } + if (!isTileId(tile)) { + throw new TypeError( + `restore: tileEffects entry (${x}, ${y}) has an unknown tile ${String(tile)}` + ); + } + if (!isTileId(restoreTo)) { + throw new TypeError( + `restore: tileEffects entry (${x}, ${y}) has an unknown restoreTo ${String(restoreTo)}` + ); + } + if (!Number.isInteger(turnsLeft) || (turnsLeft as number) < 1) { + throw new RangeError( + `restore: tileEffects entry (${x}, ${y}) requires turnsLeft >= 1, got ${String(turnsLeft)}` + ); + } + return { x, y, tile, turnsLeft, restoreTo } as TileEffectEntry; + }); +} + +const TILE_IDS: ReadonlySet = new Set(Object.values(TILE)); + +function isTileId(value: unknown): boolean { + return typeof value === 'number' && TILE_IDS.has(value); +} + function normalizeExtractedOperativeIds(raw: unknown): string[] { if (raw === undefined || raw === null) return []; if (!Array.isArray(raw)) { diff --git a/src/shell/shellRuntime.ts b/src/shell/shellRuntime.ts index 813af6f..9802707 100644 --- a/src/shell/shellRuntime.ts +++ b/src/shell/shellRuntime.ts @@ -53,7 +53,7 @@ import { AIM_KIND, MODE } from '/src/input/keymap.js'; import { applyIntent, PLAYER_ACTIONS } from '/src/input/applyIntent.js'; import { recordStatusActionLine } from '/src/statusActivityRows.js'; -import { placeSmoke, clearSmoke } from '/src/game/Smoke.js'; +import { placeSmoke } from '/src/game/Smoke.js'; import { placeHazardCluster } from '/src/game/Run.js'; import { blastCells } from '/src/game/breachBlast.js'; import { hasLineOfSight } from '/src/game/LineOfSight.js'; @@ -139,7 +139,6 @@ import type { import KeyHelp from '/components/KeyHelp.js'; -type SmokeOverlay = ReturnType[number]; type PointLike = Pick | { x: number; y: number } | null | undefined; type HelpScope = import('/src/shell/domTypes.js').HelpScope; @@ -246,12 +245,6 @@ function scheduleCombatPump(fn: () => void, ms: number): void { fn(); }, ms); } -/** - * Active smoke overlays from Smoke Charge consumables. Each entry records - * the tile position and original tile type so `clearSmoke` can restore the - * grid. Cleared at the start of the player's next turn (`onPlayerTurnReady`). - */ -let activeSmokeOverlays: SmokeOverlay[] = []; /** Hazard-glyph blast flash — cleared on a short timer after each detonation. */ let activeBreachBlastOverlayKeys = new Set(); let breachBlastOverlayTimer: ReturnType | null = null; @@ -644,7 +637,6 @@ function abortShellForFault(): void { corpToneActivityBody = null; clearBreachBlastOverlay(false); resetInputModes(); - activeSmokeOverlays = []; civilianHarmsThisJob = 0; } @@ -1126,8 +1118,10 @@ function applyUseConsumableResult( if (!Number.isInteger(cx) || !Number.isInteger(cy) || !Number.isInteger(radius)) { throw new Error('[shell] smoke consumable returned invalid placement data'); } - const overlays = placeSmoke(run.world.grid, cx, cy, radius); - activeSmokeOverlays.push(...overlays); + // Lifetime is World's — `tickTileEffects` clears the cloud at the round + // boundary, so there's no shell-side overlay list to keep in sync (and + // none to be lost across a save). + placeSmoke(run.world, cx, cy, radius); recomputeVision(); flash(`Used SMOKE CHARGE — LOS blocked in radius ${radius}. ${operator.ap} AP left.`); return; @@ -1143,12 +1137,19 @@ function applyUseConsumableResult( // AP and consumed the charge in Crew.useConsumable; the LOS pre-check // in `resolveAimedUseItem` is what protects the player from "throw // through a wall and lose your bomb." - const stamped = placeHazardCluster(run.world, { x: cx, y: cy }, run.rng); - if (stamped === 0) { + const { placed, casualties } = placeHazardCluster(run.world, { x: cx, y: cy }, run.rng, { + thrown: true, + attacker: operator, + }); + if (placed === 0) { flash(`Used MOLOTOV — landed on hard cover; no fire took. ${operator.ap} AP left.`); } else { + const caught = casualties.length; + const downed = casualties.filter(c => c.killed).length; + const hit = caught === 0 ? '' : ` ${caught} caught${downed > 0 ? `, ${downed} DOWN` : ''}.`; flash( - `Used MOLOTOV — ${stamped} tile${stamped === 1 ? '' : 's'} ignited. ${operator.ap} AP left.` + `Used MOLOTOV — ${placed} tile${placed === 1 ? '' : 's'} ignited.${hit} ` + + `${operator.ap} AP left.` ); } recomputeVision(); @@ -1918,12 +1919,9 @@ function driveCombatTurnPipeline(run: Run, options: { resumeFromCorpSlice?: bool }, onPlayerTurnReady: () => { if (degrading) return; - // Clear any smoke from last turn before the player acts. - if (activeSmokeOverlays.length > 0) { - clearSmoke(world.grid, activeSmokeOverlays); - activeSmokeOverlays = []; - } - // Stealth & vision may both have changed during the corp turn. + // Expired smoke/fire has already been cleared by `tickTileEffects` inside + // advanceFromPlayerTurn — vision recompute below picks up the sightlines + // that just reopened. Stealth may also have changed during the corp turn. recomputeVision(); paint(); }, diff --git a/tests/unit/game/hazard.test.ts b/tests/unit/game/hazard.test.ts index 0f4a70f..82c864d 100644 --- a/tests/unit/game/hazard.test.ts +++ b/tests/unit/game/hazard.test.ts @@ -10,11 +10,22 @@ import assert from 'node:assert/strict'; import { Grid } from '../../../src/game/Grid.js'; import { World } from '../../../src/game/World.js'; +import type { World as WorldType } from '../../../src/game/World.js'; import { Entity } from '../../../src/game/Entity.js'; import { EventBus, EVENT } from '../../../src/game/events.js'; -import { TILE, FACTION, HAZARD_DAMAGE } from '../../../src/game/constants.js'; +import { + TILE, + FACTION, + HAZARD_DAMAGE, + AP_COST, + DEFAULT_AP, + INCENDIARY_IMPACT_DAMAGE, + INCENDIARY_BURN_TURNS, + moveStepApCost, +} from '../../../src/game/constants.js'; import { Rng } from '../../../src/rng.js'; import { + advanceFromPlayerTurn, runPlayerAftermathSteps, formatPlayerAftermathStepLogLines, isPlayerAftermathStepLogVisible, @@ -47,6 +58,19 @@ function makeEntity(id: string, x: number, y: number, faction = FACTION.PLAYER, return new Entity({ id, x, y, faction, glyph: '@', maxHp: hp }); } +/** + * One full round of the parts that touch hazard: the player's aftermath (where + * standing damage lands), then the round-boundary effect tick (where fire ages). + * Mirrors the order `advanceFromPlayerTurn` drives them in, minus the corp turn + * between — see `advanceFromPlayerTurn` in combatTurnPipeline.test.ts for the + * full-pipeline ordering. + */ +function advanceRound(world: WorldType) { + const steps = [...runPlayerAftermathSteps(world, new Rng(1))]; + world.tickTileEffects(); + return steps; +} + // --------------------------------------------------------------------------- // Grid: passability and LOS // --------------------------------------------------------------------------- @@ -64,13 +88,48 @@ test('HAZARD tile does NOT block line of sight', () => { assert.equal(hasLineOfSight(g, 0, 0, 4, 0), true, 'LOS through HAZARD tile'); }); -test('HAZARD tile blocks movement as little as FLOOR does', () => { +test('HAZARD tile is enterable, but costs ENTER_HAZARD AP to step onto', () => { const { world } = makeHazardWorld(4, 1); world.grid.setTile(1, 0, TILE.HAZARD); const entity = makeEntity('e', 0, 0); world.addEntity(entity); const check = world.canMoveEntity(entity, 1, 0); - assert.equal(check.ok, true, 'should be able to step onto HAZARD'); + assert.equal(check.ok, true, 'fire is passable — you may choose to run through it'); + assert.equal(moveStepApCost(TILE.HAZARD), AP_COST.ENTER_HAZARD, 'entry costs extra'); + assert.ok(AP_COST.ENTER_HAZARD > AP_COST.MOVE, 'crossing fire is slower than open floor'); +}); + +test('an entity cannot step onto HAZARD without the AP to pay for it', () => { + const { world } = makeHazardWorld(4, 1); + world.grid.setTile(1, 0, TILE.HAZARD); + const entity = makeEntity('e', 0, 0); + world.addEntity(entity); + entity.ap = AP_COST.ENTER_HAZARD - 1; + const check = world.canMoveEntity(entity, 1, 0); + assert.equal(check.ok, false, 'not enough AP to enter the fire'); +}); + +test('leaving a HAZARD tile costs only MOVE — the cost is charged on entry', () => { + const { world } = makeHazardWorld(4, 1); + world.grid.setTile(0, 0, TILE.HAZARD); + const entity = makeEntity('e', 0, 0); + world.addEntity(entity); + entity.ap = AP_COST.MOVE; + const check = world.canMoveEntity(entity, 1, 0); + assert.equal(check.ok, true, 'stepping out onto FLOOR is a normal move'); +}); + +// Fire has to be slow enough that a crossing entity is still standing in it +// when the aftermath tick fires. With DEFAULT_AP=4 and a 1 AP step, a drone +// would clear a 3-wide cluster in one turn and take zero damage. +test('a full-AP entity cannot cross a 3-wide hazard cluster in one turn', () => { + const width = 3; + const crossingCost = width * AP_COST.ENTER_HAZARD; + assert.ok( + crossingCost > DEFAULT_AP, + `crossing ${width} hazard tiles costs ${crossingCost} AP, which must exceed ` + + `DEFAULT_AP (${DEFAULT_AP}) or fire deals no damage to anything walking through it` + ); }); // --------------------------------------------------------------------------- @@ -93,6 +152,22 @@ test('entity on HAZARD tile takes damage during player aftermath', () => { assert.equal(entity.alive, true); }); +// The standing tick historically wrote `entity.hp` directly instead of going +// through `Entity.damage()`, which meant a Medic's shield absorbed every +// damage source in the game except fire. +test('hazard standing tick is absorbed by shields, like every other damage source', () => { + const { world, grid } = makeHazardWorld(); + grid.setTile(3, 3, TILE.HAZARD); + const entity = makeEntity('shielded', 3, 3, FACTION.PLAYER, 3); + entity.shieldHp = HAZARD_DAMAGE; + world.addEntity(entity); + + [...runPlayerAftermathSteps(world, new Rng(1))]; + + assert.equal(entity.shieldHp, 0, 'shield takes the burn'); + assert.equal(entity.hp, 3, 'HP untouched behind the shield'); +}); + test('entity on FLOOR tile takes no hazard damage', () => { const { world } = makeHazardWorld(); const entity = makeEntity('safe', 2, 2, FACTION.PLAYER, 3); @@ -325,7 +400,7 @@ test('isPlayerAftermathStepLogVisible: hazard damage to non-player respects LOS' test('placeHazardCluster places HAZARD tiles around center', () => { const { world } = makeHazardWorld(10, 10); const center = { x: 5, y: 5 }; - const placed = placeHazardCluster(world, center, new Rng(42)); + const { placed } = placeHazardCluster(world, center, new Rng(42)); assert.ok(placed >= 5, `should place at least center + 4 cardinals, got ${placed}`); assert.ok(placed <= 9, `should place at most 9 tiles, got ${placed}`); @@ -347,7 +422,12 @@ test('placeHazardCluster does not overwrite WALL tiles', () => { assert.equal(world.grid.tileAt(5, 5), TILE.HAZARD, 'center still placed'); }); -test('placeHazardCluster does not overwrite occupied tiles', () => { +// Map generation stamps scenery around a cast that is *already placed* +// (enterCombat spawns hostiles before objective/hazard placement), so a +// generated cluster must never appear under anyone. A thrown incendiary is +// the opposite: landing on someone is the entire point. Same function, two +// callers, deliberately different rules. +test('placeHazardCluster (generated scenery) does not overwrite occupied tiles', () => { const { world } = makeHazardWorld(10, 10); const entity = makeEntity('e', 6, 5, FACTION.PLAYER, 3); world.addEntity(entity); @@ -355,12 +435,236 @@ test('placeHazardCluster does not overwrite occupied tiles', () => { placeHazardCluster(world, center, new Rng(42)); assert.equal(world.grid.tileAt(6, 5), TILE.FLOOR, 'occupied tile should stay FLOOR'); + assert.equal(entity.hp, 3, 'generated scenery deals no impact damage'); +}); + +test('placeHazardCluster (thrown) DOES ignite the tile under a hostile', () => { + const { world } = makeHazardWorld(10, 10); + const drone = makeEntity('drone-0', 5, 5, FACTION.CORP, 3); + world.addEntity(drone); + + placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42), { thrown: true }); + + assert.equal( + world.grid.tileAt(5, 5), + TILE.HAZARD, + 'a molotov landing on a drone must set that drone on fire, not ring it in flame' + ); +}); + +test('placeHazardCluster (thrown) still spares hazard-immune props', () => { + const { world } = makeHazardWorld(10, 10); + const terminal = new Terminal({ id: 'terminal-0', x: 6, y: 5, label: 'Access terminal' }); + world.addEntity(terminal); + + placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42), { thrown: true }); + + assert.equal(world.grid.tileAt(6, 5), TILE.FLOOR, 'objective prop tile stays FLOOR'); + assert.equal(terminal.alive, true); +}); + +// --------------------------------------------------------------------------- +// Thrown incendiary: impact damage +// --------------------------------------------------------------------------- + +test('thrown incendiary deals impact damage to entities on ignited tiles', () => { + const { world, bus } = makeHazardWorld(10, 10); + const drone = makeEntity('drone-0', 5, 5, FACTION.CORP, 3); + world.addEntity(drone); + + const damaged: Record[] = []; + bus.on(EVENT.ENTITY_DAMAGED, p => damaged.push(p as Record)); + + const result = placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42), { thrown: true }); + + assert.equal(drone.hp, 3 - INCENDIARY_IMPACT_DAMAGE); + assert.equal(result.casualties.length, 1); + assert.equal(result.casualties[0].entity.id, 'drone-0'); + assert.equal(result.casualties[0].damage, INCENDIARY_IMPACT_DAMAGE); + assert.equal(result.casualties[0].killed, false); + assert.equal(damaged.length, 1); + assert.equal(damaged[0].source, 'incendiary'); +}); + +test('thrown incendiary credits the thrower as attacker', () => { + const { world, bus } = makeHazardWorld(10, 10); + const thrower = makeEntity('player-0', 1, 1, FACTION.PLAYER, 3); + const drone = makeEntity('drone-0', 5, 5, FACTION.CORP, 3); + world.addEntity(thrower); + world.addEntity(drone); + + const damaged: Record[] = []; + bus.on(EVENT.ENTITY_DAMAGED, p => damaged.push(p as Record)); + + placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42), { thrown: true, attacker: thrower }); + + assert.equal(damaged.length, 1); + assert.equal((damaged[0].attacker as Entity).id, 'player-0'); +}); + +test('thrown incendiary impact respects shields (goes through Entity.damage)', () => { + const { world } = makeHazardWorld(10, 10); + const drone = makeEntity('drone-0', 5, 5, FACTION.CORP, 3); + drone.shieldHp = INCENDIARY_IMPACT_DAMAGE; + world.addEntity(drone); + + placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42), { thrown: true }); + + assert.equal(drone.shieldHp, 0, 'shield absorbs the impact'); + assert.equal(drone.hp, 3, 'HP untouched behind the shield'); +}); + +test('thrown incendiary impact kills and reports the casualty', () => { + const { world } = makeHazardWorld(10, 10); + const drone = makeEntity('drone-0', 5, 5, FACTION.CORP, INCENDIARY_IMPACT_DAMAGE); + world.addEntity(drone); + + const result = placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42), { thrown: true }); + + assert.equal(drone.alive, false); + assert.equal(result.casualties[0].killed, true); +}); + +test('thrown incendiary damages each entity at most once', () => { + const { world } = makeHazardWorld(10, 10); + const drone = makeEntity('drone-0', 5, 5, FACTION.CORP, 9); + world.addEntity(drone); + + const result = placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42), { thrown: true }); + + assert.equal(result.casualties.length, 1, 'one casualty record per entity'); + assert.equal(drone.hp, 9 - INCENDIARY_IMPACT_DAMAGE, 'not damaged once per ignited tile'); +}); + +// --------------------------------------------------------------------------- +// Thrown incendiary: burnout +// --------------------------------------------------------------------------- + +test('thrown fire reverts to FLOOR after INCENDIARY_BURN_TURNS rounds', () => { + const { world } = makeHazardWorld(10, 10); + placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42), { thrown: true }); + assert.equal(world.grid.tileAt(5, 5), TILE.HAZARD, 'ignites immediately'); + + for (let i = 0; i < INCENDIARY_BURN_TURNS - 1; i++) { + advanceRound(world); + assert.equal(world.grid.tileAt(5, 5), TILE.HAZARD, `still burning after round ${i + 1}`); + } + + advanceRound(world); + assert.equal(world.grid.tileAt(5, 5), TILE.FLOOR, 'fire burns out'); +}); + +test('thrown fire damages on every round it burns, including its last', () => { + const { world } = makeHazardWorld(10, 10); + const victim = makeEntity('victim', 5, 5, FACTION.CORP, 99); + world.addEntity(victim); + placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42), { thrown: true }); + const hpAfterImpact = victim.hp; + + let ticks = 0; + for (let i = 0; i < INCENDIARY_BURN_TURNS + 2; i++) { + ticks += advanceRound(world).filter(s => s.type === 'hazard-damage').length; + } + + assert.equal(ticks, INCENDIARY_BURN_TURNS, 'one damage tick per burning round, then nothing'); + assert.equal(victim.hp, hpAfterImpact - INCENDIARY_BURN_TURNS * HAZARD_DAMAGE); +}); + +// The reason the effect tick hangs off the round boundary rather than player +// aftermath. Fire has to still be on the map during the corp turns it's meant +// to deny — if it aged in aftermath instead, it would wink out one corp turn +// early and the last round of denial the player paid for would never happen. +test('thrown fire is still burning during every corp turn it is meant to deny', () => { + const { world } = makeHazardWorld(10, 10); + placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42), { thrown: true }); + + const litDuringCorpTurn: boolean[] = []; + const queue = { endTurn: () => {} }; + for (let round = 0; round < INCENDIARY_BURN_TURNS + 1; round++) { + advanceFromPlayerTurn({ + queue, + world, + rng: new Rng(1), + driveCorpTurn: ({ onFinish }) => { + litDuringCorpTurn.push(world.grid.tileAt(5, 5) === TILE.HAZARD); + onFinish(); + }, + }); + } + + assert.deepEqual( + litDuringCorpTurn, + [true, true, true, false], + `fire must deny ${INCENDIARY_BURN_TURNS} full corp turns, then be gone` + ); +}); + +test('generated hazard scenery is permanent — it never burns out', () => { + const { world } = makeHazardWorld(10, 10); + placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42)); + + for (let i = 0; i < INCENDIARY_BURN_TURNS + 5; i++) { + advanceRound(world); + } + + assert.equal( + world.grid.tileAt(5, 5), + TILE.HAZARD, + 'a contaminated site stays contaminated for the whole run' + ); +}); + +test('a second molotov on the same tile refreshes its burn timer', () => { + const { world } = makeHazardWorld(10, 10); + placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42), { thrown: true }); + advanceRound(world); + + // Re-ignite the (still burning) centre directly — placeHazardCluster only + // stamps FLOOR, so an overlapping throw re-registers via applyTileEffect. + world.applyTileEffect(5, 5, TILE.HAZARD, INCENDIARY_BURN_TURNS); + + for (let i = 0; i < INCENDIARY_BURN_TURNS - 1; i++) { + advanceRound(world); + } + assert.equal(world.grid.tileAt(5, 5), TILE.HAZARD, 'timer restarted from the second throw'); + + advanceRound(world); + assert.equal(world.grid.tileAt(5, 5), TILE.FLOOR); +}); + +test('a refreshed effect still reverts to the ORIGINAL tile, not the effect tile', () => { + // Re-applying must not capture HAZARD as the thing to restore — that would + // make the second molotov's fire permanent. + const { world } = makeHazardWorld(10, 10); + world.applyTileEffect(5, 5, TILE.HAZARD, 1); + world.applyTileEffect(5, 5, TILE.HAZARD, 1); + + advanceRound(world); + + assert.equal(world.grid.tileAt(5, 5), TILE.FLOOR, 'terrain underneath is remembered once'); +}); + +test('burnout does not revert a tile that has since become something else', () => { + const { world } = makeHazardWorld(10, 10); + world.applyTileEffect(5, 5, TILE.HAZARD, 1); + // A breaching charge turns the tile to rubble while the fire is still lit. + world.grid.setTile(5, 5, TILE.RUBBLE); + + advanceRound(world); + + assert.equal(world.grid.tileAt(5, 5), TILE.RUBBLE, 'burnout must not clobber a newer tile'); +}); + +test('applyTileEffect refuses an effect that matches the tile underneath', () => { + // Would be invisible and expire into a no-op — a wiring bug, not a play. + const { world } = makeHazardWorld(10, 10); + assert.throws(() => world.applyTileEffect(5, 5, TILE.FLOOR, 1), /already the tile underneath/i); }); test('placeHazardCluster handles edge-of-map center gracefully', () => { const { world } = makeHazardWorld(6, 6); const center = { x: 0, y: 0 }; - const placed = placeHazardCluster(world, center, new Rng(1)); + const { placed } = placeHazardCluster(world, center, new Rng(1)); assert.ok(placed >= 1, 'should place at least the center tile'); assert.equal(world.grid.tileAt(0, 0), TILE.HAZARD); }); diff --git a/tests/unit/game/hazardPathing.test.ts b/tests/unit/game/hazardPathing.test.ts new file mode 100644 index 0000000..e38c202 --- /dev/null +++ b/tests/unit/game/hazardPathing.test.ts @@ -0,0 +1,181 @@ +/** + * P3.6 — Hostiles route around fire. + * + * `stepToward` paths twice: once treating HAZARD tiles as blocked, and — only + * if that finds nothing at all — once without the restriction. So fire reads + * as a wall the AI respects, while never becoming a *real* wall: a hostile + * sealed in by flame still comes for you, it just has to walk through the burn + * to do it. + * + * Deliberately NOT done by weighting A* or by making HAZARD impassable in + * `Grid.isPassable`. `findPath` is load-bearing for map generation and for the + * exit-reachability check in `Run.#checkSoftLock` — a player throwing a + * molotov must never be able to make the game believe the exit is unreachable. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Grid } from '../../../src/game/Grid.js'; +import { World } from '../../../src/game/World.js'; +import { PatrolHostile, type EngageSteps } from '../../../src/game/ai/PatrolHostile.js'; +import { TILE, FACTION } from '../../../src/game/constants.js'; +import type { World as WorldType } from '../../../src/game/World.js'; +import type { Entity } from '../../../src/game/Entity.js'; +import type { Rng } from '../../../src/rng.js'; + +/** Exposes the protected `stepToward` so pathing choices can be asserted directly. */ +class ProbeHostile extends PatrolHostile { + // Never engages — these tests drive `stepToward` directly, so the state + // machine's engage branch is out of scope. + // eslint-disable-next-line require-yield + protected override *engageSteps(_w: WorldType, _r: Rng, _t: Entity): EngageSteps { + return 'break'; + } + step(world: WorldType, gx: number, gy: number) { + return this.stepToward(world, gx, gy, 'investigate'); + } +} + +function makeWorld(width: number, height: number) { + const grid = new Grid(width, height); + for (let x = 0; x < width; x++) { + for (let y = 0; y < height; y++) { + grid.setTile(x, y, TILE.FLOOR); + } + } + return new World(grid); +} + +function makeProbe(x: number, y: number) { + return new ProbeHostile({ id: 'probe', x, y, faction: FACTION.CORP, glyph: 'k', maxAp: 99 }); +} + +/** + * A dividing wall at x=2 with exactly two gaps: a NEAR one at (2,2) and a FAR + * one at (2,4). Probe starts at (0,1), goal is (4,1). + * + * x=0 1 2 3 4 + * y=0 # # # # # + * y=1 P . [#] . G + * y=2 . . near . . ← 4 steps via here + * y=3 . . [#] . . + * y=4 . . far . . ← 6 steps via here + * y=5 # # # # # + * + * The near gap is strictly shorter, so plain uniform-cost A* always takes it. + * That's what makes this map able to fail: put fire in the near gap and only + * an AI that actually avoids hazard will take the long way. (An open 1-tile + * obstacle proves nothing here — 8-connectivity lets you slide around it + * diagonally for free, so both routes tie and the test just measures A*'s + * neighbour ordering.) + */ +function makeTwoGapWorld() { + const world = makeWorld(5, 6); + for (let x = 0; x < 5; x++) { + world.grid.setTile(x, 0, TILE.WALL); + world.grid.setTile(x, 5, TILE.WALL); + } + world.grid.setTile(2, 1, TILE.WALL); + world.grid.setTile(2, 3, TILE.WALL); + return world; +} + +/** Walk the probe to the goal, returning every tile it stood on. */ +function walk(world: WorldType, probe: ProbeHostile, gx: number, gy: number) { + const visited: number[] = []; + for (let i = 0; i < 20 && !(probe.x === gx && probe.y === gy); i++) { + probe.ap = 99; + if (!probe.step(world, gx, gy)) break; + visited.push(world.grid.tileAt(probe.x, probe.y)); + } + return { visited, arrived: probe.x === gx && probe.y === gy }; +} + +test('baseline: with both gaps clear, a hostile takes the shorter near gap', () => { + // Guards the map itself. If this ever fails, the two routes are no longer + // asymmetric and the avoidance tests below stop proving anything. + const world = makeTwoGapWorld(); + const probe = makeProbe(0, 1); + world.addEntity(probe); + + const seen: string[] = []; + for (let i = 0; i < 20 && !(probe.x === 4 && probe.y === 1); i++) { + probe.ap = 99; + if (!probe.step(world, 4, 1)) break; + seen.push(`${probe.x},${probe.y}`); + } + + assert.ok(seen.includes('2,2'), 'unobstructed, the near gap is strictly shorter'); + assert.ok(!seen.includes('2,4'), 'and the far gap is not used'); +}); + +test('a hostile takes a strictly longer route to avoid walking through fire', () => { + const world = makeTwoGapWorld(); + world.grid.setTile(2, 2, TILE.HAZARD); // plug the *short* gap with fire + const probe = makeProbe(0, 1); + world.addEntity(probe); + + const seen: string[] = []; + for (let i = 0; i < 20 && !(probe.x === 4 && probe.y === 1); i++) { + probe.ap = 99; + if (!probe.step(world, 4, 1)) break; + seen.push(`${probe.x},${probe.y}`); + } + + assert.ok(!seen.includes('2,2'), 'must not walk into the fire when a route exists'); + assert.ok(seen.includes('2,4'), 'must detour through the far gap instead'); + assert.equal(probe.x, 4, 'and still reaches the goal'); + assert.equal(probe.y, 1); +}); + +test('a hostile never ends a step on fire while any fire-free route exists', () => { + const world = makeTwoGapWorld(); + world.grid.setTile(2, 2, TILE.HAZARD); + const probe = makeProbe(0, 1); + world.addEntity(probe); + + const { visited, arrived } = walk(world, probe, 4, 1); + + assert.ok(arrived, 'probe reaches the goal'); + assert.ok( + !visited.includes(TILE.HAZARD), + 'probe stood on fire at some point despite a clear detour' + ); +}); + +test('a hostile DOES walk through fire when it is the only way through', () => { + const world = makeTwoGapWorld(); + world.grid.setTile(2, 2, TILE.HAZARD); // fire in the near gap + world.grid.setTile(2, 4, TILE.WALL); // and the far gap sealed + const probe = makeProbe(0, 1); + world.addEntity(probe); + + const { visited, arrived } = walk(world, probe, 4, 1); + + assert.ok(arrived, 'with no alternative, the probe commits to the burn and still reaches you'); + assert.ok(visited.includes(TILE.HAZARD), 'it had to cross the fire to get there'); +}); + +test('fire never makes a goal unreachable — it is a deterrent, not a wall', () => { + const world = makeTwoGapWorld(); + world.grid.setTile(2, 2, TILE.HAZARD); + world.grid.setTile(2, 4, TILE.WALL); + const probe = makeProbe(0, 1); + world.addEntity(probe); + + assert.ok(probe.step(world, 4, 1), 'a fire-plugged corridor is still a route, not a dead end'); +}); + +test('a hostile that starts inside fire is not stranded by its own avoidance', () => { + // The safe-path pass blocks every hazard tile; a hostile already standing in + // a burn must not path itself into a null and freeze. + const world = makeWorld(5, 3); + for (const x of [1, 2, 3]) world.grid.setTile(x, 1, TILE.HAZARD); + const probe = makeProbe(2, 1); + world.addEntity(probe); + + const step = probe.step(world, 4, 1); + + assert.ok(step, 'probe still moves'); + assert.notEqual(probe.x, 2, 'and makes progress out of the burn'); +}); diff --git a/tests/unit/game/items.test.ts b/tests/unit/game/items.test.ts index 60334dd..5e1fd56 100644 --- a/tests/unit/game/items.test.ts +++ b/tests/unit/game/items.test.ts @@ -26,11 +26,11 @@ import { Merc } from '../../../src/game/archetypes/Merc.js'; import { Skirmisher } from '../../../src/game/ai/Skirmisher.js'; import { Rng } from '../../../src/rng.js'; import { ITEM_ID } from '../../../src/game/items.js'; -import { placeSmoke, clearSmoke } from '../../../src/game/Smoke.js'; +import { placeSmoke } from '../../../src/game/Smoke.js'; import { resolveRanged, resolveMelee } from '../../../src/game/Combat.js'; import { Razor } from '../../../src/game/archetypes/Razor.js'; import { Entity } from '../../../src/game/Entity.js'; -import { FACTION } from '../../../src/game/constants.js'; +import { FACTION, SMOKE_DURATION } from '../../../src/game/constants.js'; // --------------------------------------------------------------------------- // Crew.applyGear — Bone Lacing @@ -401,35 +401,33 @@ test('useConsumable enforces aim shape for aimed items only', () => { // Smoke placement and clearing // --------------------------------------------------------------------------- +const smokeWorld = (w = 10, h = 10) => new World(new Grid(w, h, TILE.FLOOR)); + test('placeSmoke converts FLOOR tiles to SMOKE within radius', () => { - const grid = new Grid(10, 10, TILE.FLOOR); - const overlays = placeSmoke(grid, 5, 5, 2); - assert.ok(overlays.length > 0); + const world = smokeWorld(); + const placed = placeSmoke(world, 5, 5, 2); + assert.ok(placed.length > 0); // Center should be smoke. - assert.equal(grid.tileAt(5, 5), TILE.SMOKE); + assert.equal(world.grid.tileAt(5, 5), TILE.SMOKE); // Corner of radius 2 (Chebyshev) should be smoke. - assert.equal(grid.tileAt(3, 3), TILE.SMOKE); - assert.equal(grid.tileAt(7, 7), TILE.SMOKE); - // All overlays should have FLOOR as originalTile. - for (const o of overlays) { - assert.equal(o.originalTile, TILE.FLOOR); - } + assert.equal(world.grid.tileAt(3, 3), TILE.SMOKE); + assert.equal(world.grid.tileAt(7, 7), TILE.SMOKE); }); test('placeSmoke does not convert WALLs', () => { - const grid = new Grid(10, 10, TILE.FLOOR); - grid.setTile(5, 4, TILE.WALL); - const overlays = placeSmoke(grid, 5, 5, 2); - assert.equal(grid.tileAt(5, 4), TILE.WALL); - assert.ok(!overlays.some(o => o.x === 5 && o.y === 4)); + const world = smokeWorld(); + world.grid.setTile(5, 4, TILE.WALL); + const placed = placeSmoke(world, 5, 5, 2); + assert.equal(world.grid.tileAt(5, 4), TILE.WALL); + assert.ok(!placed.some(p => p.x === 5 && p.y === 4)); }); test('placeSmoke handles edge of grid gracefully', () => { - const grid = new Grid(6, 6, TILE.FLOOR); + const world = smokeWorld(6, 6); // Place near corner — some tiles will be OOB. - const overlays = placeSmoke(grid, 0, 0, 2); - assert.ok(overlays.length > 0); - assert.equal(grid.tileAt(0, 0), TILE.SMOKE); + const placed = placeSmoke(world, 0, 0, 2); + assert.ok(placed.length > 0); + assert.equal(world.grid.tileAt(0, 0), TILE.SMOKE); }); test('SMOKE tile is passable', () => { @@ -444,32 +442,60 @@ test('SMOKE tile blocks line of sight', () => { assert.equal(grid.blocksLineOfSight(2, 2), true); }); -test('clearSmoke restores original tiles', () => { - const grid = new Grid(10, 10, TILE.FLOOR); - const overlays = placeSmoke(grid, 5, 5, 1); - clearSmoke(grid, overlays); +test('smoke clears itself after SMOKE_DURATION rounds', () => { + const world = smokeWorld(); + placeSmoke(world, 5, 5, 1); + assert.equal(world.grid.tileAt(5, 5), TILE.SMOKE); + + for (let i = 0; i < SMOKE_DURATION; i++) world.tickTileEffects(); + // All tiles should be back to FLOOR. - assert.equal(grid.tileAt(5, 5), TILE.FLOOR); - assert.equal(grid.tileAt(4, 4), TILE.FLOOR); - assert.equal(grid.tileAt(6, 6), TILE.FLOOR); + assert.equal(world.grid.tileAt(5, 5), TILE.FLOOR); + assert.equal(world.grid.tileAt(4, 4), TILE.FLOOR); + assert.equal(world.grid.tileAt(6, 6), TILE.FLOOR); +}); + +test('smoke survives the corp turn it was thrown to blind', () => { + // SMOKE_DURATION is measured in *rounds*, and the tick lands after the corp + // turn. A cloud that cleared before the drones moved would be pointless. + const world = smokeWorld(); + placeSmoke(world, 5, 5, 1); + assert.equal( + world.grid.tileAt(5, 5), + TILE.SMOKE, + 'still up between placement and the round boundary' + ); }); -test('clearSmoke preserves EXIT tiles that were under smoke', () => { - const grid = new Grid(10, 10, TILE.FLOOR); - grid.setTile(5, 5, TILE.EXIT); - const overlays = placeSmoke(grid, 5, 5, 1); +test('smoke over an EXIT tile restores the EXIT, not FLOOR', () => { + const world = smokeWorld(); + world.grid.setTile(5, 5, TILE.EXIT); + placeSmoke(world, 5, 5, 1); // EXIT should have been converted to SMOKE. - assert.equal(grid.tileAt(5, 5), TILE.SMOKE); - const exitOverlay = overlays.find(o => o.x === 5 && o.y === 5); - assert.ok(exitOverlay); - assert.equal(exitOverlay.originalTile, TILE.EXIT); - // Clear restores EXIT. - clearSmoke(grid, overlays); - assert.equal(grid.tileAt(5, 5), TILE.EXIT); + assert.equal(world.grid.tileAt(5, 5), TILE.SMOKE); + + for (let i = 0; i < SMOKE_DURATION; i++) world.tickTileEffects(); + + assert.equal(world.grid.tileAt(5, 5), TILE.EXIT, 'the exit must survive its own smoke cloud'); }); -test('clearSmoke is idempotent on empty array', () => { - const grid = new Grid(5, 5, TILE.FLOOR); - clearSmoke(grid, []); - assert.equal(grid.tileAt(2, 2), TILE.FLOOR); +test('smoke leaves a burning hazard tile to its own timer', () => { + // placeSmoke only takes FLOOR/EXIT, so a cloud thrown over fire must not + // capture HAZARD as its restore target and strand the fire permanently. + const world = smokeWorld(); + world.applyTileEffect(5, 5, TILE.HAZARD, 2); + placeSmoke(world, 5, 5, 1); + + assert.equal(world.grid.tileAt(5, 5), TILE.HAZARD, 'fire is not smothered by the cloud'); + assert.equal(world.grid.tileAt(5, 4), TILE.SMOKE, 'but the tiles around it take smoke'); + + world.tickTileEffects(); + world.tickTileEffects(); + assert.equal(world.grid.tileAt(5, 5), TILE.FLOOR, 'and the fire still burns out on schedule'); +}); + +test('tickTileEffects is a no-op when nothing is active', () => { + const world = smokeWorld(5, 5); + assert.deepEqual(world.tickTileEffects(), []); + assert.equal(world.grid.tileAt(2, 2), TILE.FLOOR); }); diff --git a/tests/unit/game/persistence.test.ts b/tests/unit/game/persistence.test.ts index c828130..d48301a 100644 --- a/tests/unit/game/persistence.test.ts +++ b/tests/unit/game/persistence.test.ts @@ -21,10 +21,12 @@ import { CONTRACT_DIFFICULTY, CRASH_HIT_PENALTY, FACTION, + INCENDIARY_BURN_TURNS, SALVAGE_TO_CRED_RATE, STATUS_EFFECT, TILE, } from '../../../src/game/constants.js'; +import { placeSmoke } from '../../../src/game/Smoke.js'; import { makeSalvage, totalSalvage } from '../../../src/game/salvage.js'; import { buildCrewMember } from '../../../src/game/archetypes/index.js'; import { Decker } from '../../../src/game/archetypes/Decker.js'; @@ -978,3 +980,145 @@ test('M6.2: restore normalizes legacy top-level crew fields into extra', () => { const { player } = restore(rec); assert.equal(player.callsign, 'Ghost'); }); + +// --------------------------------------------------------------------------- +// P3.6: timed tile effects (thrown fire, smoke) +// +// Effect lifetimes live on World specifically so they survive a save. Smoke +// used to keep its lifetime in shell state that was *not* persisted while +// grid.tiles *is* — so a mid-mission reload stranded smoke on the map forever. +// Autosave fires at the player→corp hand-off, which is exactly when smoke is +// on the grid, so this was reachable in ordinary play. These guard the fix. +// --------------------------------------------------------------------------- + +test('P3.6: thrown-fire burn timers survive a snapshot round-trip', () => { + const run = freshCombatRun(0xf1e5, 'razor'); + const tile = freeCombatTile(run); + run.world.applyTileEffect(tile.x, tile.y, TILE.HAZARD, INCENDIARY_BURN_TURNS); + + const rec = snapshot(run); + assert.deepEqual( + rec.tileEffects, + [ + { + x: tile.x, + y: tile.y, + tile: TILE.HAZARD, + turnsLeft: INCENDIARY_BURN_TURNS, + restoreTo: TILE.FLOOR, + }, + ], + 'effect timers ride the run snapshot' + ); + + const { run: restored } = restore(rec); + assert.deepEqual(snapshot(restored), rec, 'stable round-trip'); +}); + +test('P3.6: fire reloaded mid-burn still goes out on schedule', () => { + const run = freshCombatRun(0xf1e6, 'razor'); + const tile = freeCombatTile(run); + run.world.applyTileEffect(tile.x, tile.y, TILE.HAZARD, INCENDIARY_BURN_TURNS); + + const { run: restored } = restore(snapshot(run)); + assert.equal(restored.world.grid.tileAt(tile.x, tile.y), TILE.HAZARD, 'still alight on reload'); + + for (let i = 0; i < INCENDIARY_BURN_TURNS; i++) { + restored.world.tickTileEffects(); + } + + assert.equal( + restored.world.grid.tileAt(tile.x, tile.y), + TILE.FLOOR, + 'reloaded fire must still burn out — not become a permanent scar' + ); +}); + +// The regression this whole mechanism exists for. +test('P3.6: smoke saved mid-cloud does NOT become a permanent LOS wall on reload', () => { + const run = freshCombatRun(0xf1e9, 'razor'); + const tile = freeCombatTile(run); + placeSmoke(run.world, tile.x, tile.y, 0); + assert.equal(run.world.grid.tileAt(tile.x, tile.y), TILE.SMOKE, 'cloud is on the grid'); + + // Autosave fires at the player→corp hand-off — i.e. right here, with smoke up. + const { run: restored } = restore(snapshot(run)); + assert.equal(restored.world.grid.tileAt(tile.x, tile.y), TILE.SMOKE, 'cloud survives reload'); + + restored.world.tickTileEffects(); + + assert.equal( + restored.world.grid.tileAt(tile.x, tile.y), + TILE.FLOOR, + 'reloaded smoke must still clear — it used to sit there blocking LOS forever' + ); +}); + +test('P3.6: smoke over the EXIT tile restores the EXIT, not FLOOR', () => { + const run = freshCombatRun(0xf1ea, 'razor'); + const exit = run.exitTile; + assert.ok(exit, 'fixture has an exit tile'); + assert.equal(run.world.grid.tileAt(exit.x, exit.y), TILE.EXIT); + + placeSmoke(run.world, exit.x, exit.y, 0); + const { run: restored } = restore(snapshot(run)); + restored.world.tickTileEffects(); + + assert.equal( + restored.world.grid.tileAt(exit.x, exit.y), + TILE.EXIT, + 'a cloud over the exit must not quietly delete the exit' + ); +}); + +test('P3.6: a pre-3.6 save with no tileEffects field restores as permanent fire', () => { + const run = freshCombatRun(0xf1e7, 'razor'); + const tile = freeCombatTile(run); + run.world.applyTileEffect(tile.x, tile.y, TILE.HAZARD, INCENDIARY_BURN_TURNS); + + const rec = snapshot(run); + delete (rec as Record).tileEffects; // as an old save would be + + const { run: restored } = restore(rec); + for (let i = 0; i < INCENDIARY_BURN_TURNS + 3; i++) { + restored.world.tickTileEffects(); + } + + assert.equal( + restored.world.grid.tileAt(tile.x, tile.y), + TILE.HAZARD, + 'an old save keeps the behaviour it was played under' + ); +}); + +test('P3.6: restore rejects a malformed tileEffects entry rather than guessing', () => { + const run = freshCombatRun(0xf1e8, 'razor'); + const tile = freeCombatTile(run); + run.world.applyTileEffect(tile.x, tile.y, TILE.HAZARD, INCENDIARY_BURN_TURNS); + const rec = snapshot(run); + + const withEffect = (patch: Record) => { + const bad = structuredClone(rec) as Record; + bad.tileEffects = [ + { x: tile.x, y: tile.y, tile: TILE.HAZARD, turnsLeft: 1, restoreTo: TILE.FLOOR, ...patch }, + ]; + return bad; + }; + + assert.throws( + () => restore(withEffect({ turnsLeft: 0 })), + /turnsLeft/i, + 'turnsLeft must be >= 1' + ); + assert.throws(() => restore(withEffect({ x: 'nope' })), /integer x,y/i, 'coords are integers'); + assert.throws(() => restore(withEffect({ tile: 99 })), /unknown tile/i, 'tile must be a TILE id'); + assert.throws( + () => restore(withEffect({ restoreTo: 99 })), + /unknown restoreTo/i, + 'restoreTo must be a TILE id' + ); + + const notArray = structuredClone(rec) as Record; + notArray.tileEffects = 'not-an-array'; + assert.throws(() => restore(notArray), /must be an array/i); +}); From 53699b792a107baa37a4092c9058addf9bc2d4a0 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Fri, 17 Jul 2026 13:34:11 -0700 Subject: [PATCH 02/10] molotov effects and resolver fixes --- docs/kaizen.md | 33 +++ src/game/Crew.ts | 22 +- src/game/Run.ts | 22 +- src/game/constants.ts | 37 ++- src/game/incendiary.ts | 145 +++++++++++ src/render/animations.ts | 81 +++++- src/render/palette.ts | 20 ++ src/shell/domTypes.ts | 14 ++ src/shell/sceneListeners.ts | 31 +++ src/shell/shellRuntime.ts | 81 +++--- sw-core.js | 82 +++--- tests/unit/game/hazard.test.ts | 58 +++++ tests/unit/game/incendiary.test.ts | 362 +++++++++++++++++++++++++++ tests/unit/game/items.test.ts | 16 +- tests/unit/render/animations.test.ts | 88 +++++++ 15 files changed, 1000 insertions(+), 92 deletions(-) create mode 100644 src/game/incendiary.ts create mode 100644 tests/unit/game/incendiary.test.ts diff --git a/docs/kaizen.md b/docs/kaizen.md index 4ef1008..4d0e392 100644 --- a/docs/kaizen.md +++ b/docs/kaizen.md @@ -48,6 +48,20 @@ When an item lands, gets reclassified, or develops new context, edit it in place ## ◇ Monitored +- **No throw preview / reticle for the Molotov.** A thrown incendiary commits AP and the charge on + a single direction press with no indication of where the bottle will land or what it will hit. + This bites hardest since P3.6 made your own crew backstops (a crewmate on the ray eats the + bottle — intended and tested, but a body clustered in front of you now silently shortens the + throw). A trajectory/impact preview at aim time — highlight the resolved impact tile and flag a + friendly interceptor before commit — would make both the range and the friendly-fire rule + legible. `resolveIncendiaryImpact` is pure over grid + entity state and already returns the + impact point, so a preview is a render pass over its result, not new geometry. **Note:** an + earlier `IncendiaryImpact.steps` field was carried speculatively for exactly this and pulled in + P3.6 (unused, untested) — re-add it *with* its consumer and a test if the preview needs travel + distance. **Revisit trigger:** first playtest complaint about a throw that "went nowhere" or + burned a teammate, or when a second thrown item lands (shares the reticle work). Pairs naturally + with the fixed-`INCENDIARY_THROW_DIST` item below. + - **Generated hazard and thrown fire are visually identical but behave differently.** Both render as `▓`, but a map-generated pool is permanent scenery while a thrown molotov burns out after `INCENDIARY_BURN_TURNS`. A player has no way to tell which fire will still be @@ -93,6 +107,25 @@ When an item lands, gets reclassified, or develops new context, edit it in place ## ✓ Closed +- ~~**A body caught square on by a molotov takes no impact damage unless it's standing on FLOOR.**~~ + Surfaced in P3.6 while adding ray-walked throws; closed the same phase. `placeHazardCluster` + stamped `TILE.FLOOR` only, and `burnEntitiesOn` only damages entities on tiles it actually + ignited — so a drone on `RUBBLE` or in an existing `HAZARD` pool intercepted the bottle, was + reported as the impact point, and took zero `INCENDIARY_IMPACT_DAMAGE` because its own tile + refused to light: "Caught X square on. 0 caught." in one flash. **Fix:** thrown fire now takes + any ground fire can hold via a shared `thrownFireCanTake(tile)` predicate (constants.ts) — the + single source of truth for both `incendiary.ts`'s `canHoldFire` (where the ray centres) and + `placeHazardCluster`'s thrown branch (where fire stamps), so the two cannot drift into the + silent "used the charge, got nothing" bug. Whitelist is `FLOOR | RUBBLE | HAZARD`; `EXIT` stays + unburnable (extraction tile) and generated scenery (`thrown: false`) stays FLOOR-only. An + already-burning tile is left as-is rather than re-stamped (`applyTileEffect` refuses an effect + equal to the tile underneath, which would crash on permanent hazard scenery) but is still counted + so a body in the pool takes the impact. The one residual — a body parked on `EXIT` takes no + impact damage because that tile must never burn — no longer lies: the shell only prints "square + on" when the struck body is an actual casualty. Tests: `hazard.test.ts` (RUBBLE/HAZARD impact + + RUBBLE burnout-restore + EXIT spare), `incendiary.test.ts` (rubble/exit centring, re-centre on + burning tile, open-doorway catch). + - ~~**Smoke tiles survive a mid-mission save as permanent LOS blockers.**~~ Surfaced and closed in P3.6 while fixing the Molotov. `placeSmoke` stamped `TILE.SMOKE` onto the grid and handed the restore records to the *shell*, which held them in `shellRuntime.activeSmokeOverlays` and diff --git a/src/game/Crew.ts b/src/game/Crew.ts index d70385c..615154c 100644 --- a/src/game/Crew.ts +++ b/src/game/Crew.ts @@ -9,7 +9,6 @@ import { FACTION, STIM_HEAL, SMOKE_RADIUS, - INCENDIARY_THROW_DIST, BREACHING_CHARGE_RANGE, TARGETING_BONUS, TURRET_DAMAGE, @@ -572,17 +571,18 @@ export class Crew extends Entity { // is the center; radius comes from constants. return { type: 'smoke', cx: this.x, cy: this.y, radius: SMOKE_RADIUS }; case ITEM_ID.MOLOTOV: { - // Thrown: target tile is `thrower + dir * INCENDIARY_THROW_DIST`. - // LOS-clear-target validation is the shell's job (it owns the Grid / - // World refs); Crew just reports the intended center. The shell may - // refuse to stamp if LOS is blocked or the tile is out of bounds — in - // that case Crew has already paid AP and consumed the charge, which - // matches stim's "used up on commit" semantics. The shell should - // gate before calling, not after. + // Thrown along a direction, not at a point (P3.6): the bottle flies + // until a wall or a body stops it, so the impact tile depends on the + // whole ray — Grid and entity state Crew has no reference to. We + // report the aim and let the shell resolve it via + // `resolveIncendiaryImpact`; returning a `thrower + dir * DIST` centre + // here would be a guess that's wrong the moment anything is in the way. + // + // The shell must resolve the impact *before* calling this — a throw + // with nowhere to land is refused, and by the time we return, AP and + // the charge are already spent ("used up on commit", same as stim). const { dx, dy } = aim!; - const cx = this.x + dx * INCENDIARY_THROW_DIST; - const cy = this.y + dy * INCENDIARY_THROW_DIST; - return { type: 'incendiary', cx, cy }; + return { type: 'incendiary', dx, dy }; } case ITEM_ID.BREACHING_CHARGE: { const { dx, dy } = aim!; diff --git a/src/game/Run.ts b/src/game/Run.ts index c45e76f..d85a6d1 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -45,6 +45,7 @@ import { JACK_OUT_SHOCK_DAMAGE, INCENDIARY_BURN_TURNS, INCENDIARY_IMPACT_DAMAGE, + thrownFireCanTake, factionForPrincipalGroups, STATUS_EFFECT, } from './constants.js'; @@ -120,7 +121,7 @@ import { buildMap } from './procgen/mapBuild.js'; import { normalizeMapDimensions } from './procgen/mapDimensions.js'; import { findPath } from './Pathfinding.js'; import type { Contract } from './hub/Curator.js'; -import type { FactionId } from './constants.js'; +import type { FactionId, TileId } from './constants.js'; import type { GridPoint, KeyItem, TileDelta, EntitySnapshotExtra } from '../types.js'; import type { CrewSnapshot } from './Crew.js'; import type { TechSnapshot } from './archetypes/Tech.js'; @@ -3512,7 +3513,12 @@ export function placeHazardCluster( const ignited: GridPoint[] = []; for (const { x, y } of candidates) { if (!world.grid.inBounds(x, y)) continue; - if (world.grid.tileAt(x, y) !== TILE.FLOOR) continue; + const tile = world.grid.tileAt(x, y) as TileId; + // Generated scenery stamps FLOOR only; a thrown bottle takes any ground + // fire can hold (FLOOR/RUBBLE/HAZARD — the shared `thrownFireCanTake`, which + // `incendiary.ts` also centres on so the two can't drift). EXIT and the rest + // are excluded so the extraction tile can never be buried in fire. + if (thrown ? !thrownFireCanTake(tile) : tile !== TILE.FLOOR) continue; // Scenery spares anyone standing there; a thrown bomb only spares props // that can't burn anyway (terminals, pickups, sync pads — everything that // overrides `isHazardImmune`). Burying an objective in permanent fire @@ -3521,8 +3527,16 @@ export function placeHazardCluster( const occupant = world.entityAt(x, y); if (occupant && (!thrown || occupant.isHazardImmune())) continue; if (thrown) { - // Registers a burn timer — thrown fire always goes out. - world.applyTileEffect(x, y, TILE.HAZARD, INCENDIARY_BURN_TURNS); + // A tile already ablaze stays as-is: re-stamping HAZARD would throw on + // permanent hazard scenery (`applyTileEffect` refuses an effect equal to + // the tile underneath) and only redundantly refresh timed fire. We still + // push it to `ignited` below, so a body caught standing in an existing + // pool takes the impact hit rather than just the next standing tick. + if (tile !== TILE.HAZARD) { + // Registers a burn timer — thrown fire always goes out, reverting to + // whatever ground (FLOOR or RUBBLE) the registry recorded underneath. + world.applyTileEffect(x, y, TILE.HAZARD, INCENDIARY_BURN_TURNS); + } } else { // Raw setTile: generated hazard is permanent scenery, so it deliberately // gets no timer and never appears in the tile-effect registry. diff --git a/src/game/constants.ts b/src/game/constants.ts index b1fffd4..4e31acd 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -411,6 +411,31 @@ export function moveStepApCost(destTile: TileId): number { } } +/** + * Which tiles a *thrown* incendiary can take fire on (P3.6). The single source + * of truth shared by `incendiary.ts`'s `canHoldFire` (where the bottle is + * allowed to centre) and `placeHazardCluster`'s thrown branch (where fire is + * actually stamped). These two MUST agree tile-for-tile: if the ray centres on + * a tile the cluster then refuses to light, you get the silent "used the charge, + * got nothing" bug the ray-walk exists to prevent — so they call this, not two + * hand-synced predicates. + * + * FLOOR and RUBBLE are open ground fire takes; HAZARD is already burning (a + * re-throw refreshes it and, crucially, catches a body standing in the pool). + * EXIT is deliberately excluded — burning the extraction tile would make it + * uninteractable — as are WALL/COVER (not passable ground) and SMOKE (transient + * and not something fire pools on). The tile-effect registry reads the tile + * underneath before stamping, so lighting RUBBLE or HAZARD reverts to the right + * terrain on burnout; this predicate never has to worry about restoration. + * + * Generated hazard scenery (`placeHazardCluster` with `thrown: false`) does NOT + * use this — it stays FLOOR-only so map generation can't spread permanent fire + * across rubble or bury an objective. + */ +export function thrownFireCanTake(tile: TileId): boolean { + return tile === TILE.FLOOR || tile === TILE.RUBBLE || tile === TILE.HAZARD; +} + /** * Corp turret parameters. Stationary CORP-faction hostile that fires at * PLAYER entities during the corp turn. Range matches the player turret @@ -473,11 +498,13 @@ export const EMP_RADIUS = SMOKE_RADIUS; export const EMP_STUN_DURATION = 1; /** * Incendiary bomb: thrown along an aim direction (dx, dy) selected via - * `MODE.AIM` with `aimKind: 'use-item'`. The target tile is `thrower + dir * - * INCENDIARY_THROW_DIST`; LOS from thrower → target must be clear (no lobbing - * through walls). Hazard cluster shape and size come from `placeHazardCluster` - * (5–9 tile diamond/cross of `TILE.HAZARD`). Damage per standing tick: - * `HAZARD_DAMAGE`. + * `MODE.AIM` with `aimKind: 'use-item'`. This is the bottle's *maximum* carry, + * not its landing tile — `resolveIncendiaryImpact` (incendiary.ts) walks the + * ray and detonates on the first thing that stops it, so a body in the way is + * hit at 1 or 2 steps instead of being flown past. Walls stop the bottle + * (it lands short); cover is lobbed over. Hazard cluster shape and size come + * from `placeHazardCluster` (5–9 tile diamond/cross of `TILE.HAZARD`), centred + * on the impact. Damage per standing tick: `HAZARD_DAMAGE`. */ export const INCENDIARY_THROW_DIST = 3; /** diff --git a/src/game/incendiary.ts b/src/game/incendiary.ts new file mode 100644 index 0000000..03e3de5 --- /dev/null +++ b/src/game/incendiary.ts @@ -0,0 +1,145 @@ +/** + * Molotov throw geometry (P3.6). + * + * A thrown incendiary is aimed as a direction, not a point: the keymap emits a + * unit `(dx, dy)` and the bottle flies along that 8-way ray until something + * stops it. This module answers the only question that matters — *where does it + * land* — and nothing else. Stamping the fire is `placeHazardCluster`'s job + * (Run.ts); paying AP and consuming the charge is `Crew.useConsumable`'s. + * + * This replaces the original rule, which was "land at exactly + * `thrower + dir * INCENDIARY_THROW_DIST`, and refuse the throw outright if + * line-of-sight to that one tile is blocked." That rule had two problems: a + * hostile standing between you and the max-range tile was simply flown past, + * and a wall anywhere on the line turned the throw into a `USE FAILED` instead + * of a short throw. Both are now impacts. + * + * Two independent predicates drive the walk, and keeping them separate is the + * whole trick: + * + * - **Does it stop the bottle?** `World.entityAt` (which already excludes + * passable props and pickups) plus `TILE.WALL`. This gets doors right for + * free — `Door.passable = !locked`, so a locked door is a backstop and an + * open one is a hole to throw through. + * - **Does it catch the fire?** `Entity.isHazardImmune()`. A drone catches + * it; a locked door or a terminal stops it without becoming the impact + * point, because the fire has to land on ground that can burn. + * + * Cover is deliberately in neither set: a molotov is a thrown arc, so it clears + * chest-high cover the same way `hasLineOfSight` ignores it and `Combat` treats + * it as a hit-modifier rather than an occluder. But cover is not ground fire can + * take (`thrownFireCanTake`), so it is never an impact centre either — the + * bottle passes over it and, if nothing beyond catches it, the centre pulls back + * to the last tile that can hold fire. Which tiles those are (FLOOR, RUBBLE, + * HAZARD — not EXIT/SMOKE) lives in `thrownFireCanTake` so `canHoldFire` here + * and `placeHazardCluster`'s stamp cannot disagree. + */ + +import { TILE, thrownFireCanTake, type TileId } from './constants.js'; +import type { Entity } from './Entity.js'; +import type { World } from './World.js'; +import type { GridPoint } from '../types.js'; + +export type IncendiaryImpact = GridPoint & { + /** + * The body the bottle hit, when one stopped it — credited so callers can + * report "caught square on" and so the shell can flash the right tile. + * `null` when the throw landed on open ground (max range, or short of a wall). + * Never a hazard-immune prop: those stop the bottle but cannot be its centre. + */ + intercepted: Entity | null; +}; + +/** + * The tiles a thrown incendiary can be centred on. + * + * Delegates to `thrownFireCanTake` (constants.ts) — the exact predicate + * `placeHazardCluster`'s thrown branch stamps against — so an impact point is + * always somewhere fire can actually take. Sharing the one predicate is what + * stops the two from drifting into the silent "used the charge, got nothing" + * bug this rule exists to prevent. + */ +function canHoldFire(world: World, x: number, y: number): boolean { + return thrownFireCanTake(world.grid.tileAt(x, y) as TileId); +} + +/** Walls stop a thrown bottle. Cover does not — see the module note. */ +function blocksThrow(world: World, x: number, y: number): boolean { + return world.grid.tileAt(x, y) === TILE.WALL; +} + +/** + * Walk the throw ray and report where the bottle detonates. + * + * @param world the meat world — read only, never mutated here. + * @param origin the thrower's tile. Excluded from the walk: the bottle leaves + * your hand, so you are never your own backstop and never your + * own impact point. + * @param aim unit direction, one of the 8 compass steps. A non-unit or zero + * aim is a wiring bug in the caller (the keymap and + * `Crew.useConsumable` both validate first), so it throws. + * @param maxDist how many steps the bottle carries — `INCENDIARY_THROW_DIST`. + * + * @returns the impact point, or `null` when the ray never crosses a tile that + * can hold fire and nothing caught it — you are facing an adjacent wall, or + * throwing off the map edge. There is no sensible place for the fire to go, + * so the caller must refuse the throw *before* spending AP or the charge + * rather than eating the bottle for nothing. + */ +export function resolveIncendiaryImpact( + world: World, + origin: GridPoint, + aim: { dx: number; dy: number }, + maxDist: number +): IncendiaryImpact | null { + const { dx, dy } = aim; + if ( + !Number.isInteger(dx) || + !Number.isInteger(dy) || + (dx === 0 && dy === 0) || + Math.abs(dx) > 1 || + Math.abs(dy) > 1 + ) { + throw new RangeError( + `resolveIncendiaryImpact: aim (${dx}, ${dy}) must be a non-zero integer unit vector` + ); + } + if (!Number.isInteger(maxDist) || maxDist < 1) { + throw new RangeError( + `resolveIncendiaryImpact: throw distance must be an integer >= 1, got ${maxDist}` + ); + } + + // The best place found so far that fire could actually take. Seeded null, not + // `origin`: a throw with nowhere to go is refused, not smashed at your feet. + let lastClear: GridPoint | null = null; + + for (let step = 1; step <= maxDist; step++) { + const x = origin.x + dx * step; + const y = origin.y + dy * step; + // Off the map or into a wall: the bottle goes no further. Whatever clear + // ground it already passed over is where it falls. + if (!world.grid.inBounds(x, y)) break; + if (blocksThrow(world, x, y)) break; + + const occupant = world.entityAt(x, y); + if (occupant) { + // A body catches it square on — that tile is the centre even if the + // terrain underneath is rubble or already burning. + if (!occupant.isHazardImmune()) { + return { x, y, intercepted: occupant }; + } + // A hazard-immune blocker (locked door, terminal, sync pad) is a + // backstop that cannot burn. The bottle stops against it and drops onto + // the last clear ground behind it. + break; + } + + if (canHoldFire(world, x, y)) { + lastClear = { x, y }; + } + } + + if (!lastClear) return null; + return { x: lastClear.x, y: lastClear.y, intercepted: null }; +} diff --git a/src/render/animations.ts b/src/render/animations.ts index 70f8852..0c9e428 100644 --- a/src/render/animations.ts +++ b/src/render/animations.ts @@ -31,7 +31,14 @@ import type { AsciiRenderer } from './AsciiRenderer.js'; import { COMBAT_HUD_COLORS } from './combatHud.js'; -import { CRASH_FLASH_FG, HEAL_FLASH_FG, STUNNED_FG, SURGE_FLASH_FG } from './palette.js'; +import { + BURN_FLASH_FG, + CRASH_FLASH_FG, + HEAL_FLASH_FG, + INCENDIARY_IMPACT_FG, + STUNNED_FG, + SURGE_FLASH_FG, +} from './palette.js'; export const ANIMATION_DURATIONS = Object.freeze({ SHAKE: 150, @@ -76,6 +83,20 @@ export const ANIMATION_DURATIONS = Object.freeze({ * you," short enough to stay a subtle self-cue, not a screen-wide event. */ CLOAK_FLASH: 160, + /** + * Ignition burst on a thrown molotov's impact tile (P3.6). Longer than the + * muzzle flash — a bottle breaking is a heavier beat than a gunshot, and the + * burst has to register *before* the eye settles on the fire it leaves + * behind. Matched to MIND_INFLUENCE_FLASH rather than the 120ms impacts. + */ + INCENDIARY_IMPACT_FLASH: 200, + /** + * Ember burst on a body taking fire damage (P3.6) — the ignition tick and + * every standing tick after. Shorter than the impact burst: this one can + * fire several times in a round (one per burning entity), so it stays brief + * enough not to hold the input lock open across a crowded aftermath. + */ + BURN_FLASH: 140, }); export const SHAKE_CLASS = 'kp-shake'; @@ -324,3 +345,61 @@ export function runInteractSecuredFlash( color, }); } + +type RunFireFlashOptions = { + duration?: number; + timers?: typeof defaultTimers; +}; + +/** + * Ignition burst where a thrown molotov breaks (P3.6). Fires on the impact tile + * whatever landed there — a body, bare floor, or the ground short of a wall — + * so a throw always has a visible beat even when it catches nobody. + * + * `*` rather than the HAZARD tile's own `▓`: the fire cluster is stamped onto + * this cell in the same frame, so reusing its glyph would make the burst + * invisible. The star reads as the bottle shattering, then resolves into fire. + */ +export function runIncendiaryImpactFlash( + renderer: AsciiRenderer, + repaint: () => void, + worldX: number, + worldY: number, + options: RunFireFlashOptions = {} +) { + const { duration = ANIMATION_DURATIONS.INCENDIARY_IMPACT_FLASH, timers = defaultTimers } = + options; + return runMuzzleFlash(renderer, repaint, worldX, worldY, { + duration, + timers, + char: '*', + color: INCENDIARY_IMPACT_FG, + }); +} + +/** + * Ember burst on a body being eaten by fire (P3.6) — the molotov's ignition + * tick and every standing tick on a HAZARD tile after it. + * + * Paints the entity's *own* glyph tinted ember, the `MIND_INFLUENCED` / + * `RAZOR_CLOAKED` shape: this is something happening to a body, and the player + * needs to read *which* body at a glance when three drones are burning at once. + * Overpainting with a fire glyph would erase exactly that information — and the + * tile underneath is already drawn as fire anyway. + */ +export function runBurnFlash( + renderer: AsciiRenderer, + repaint: () => void, + worldX: number, + worldY: number, + glyphChar: string, + options: RunFireFlashOptions = {} +) { + const { duration = ANIMATION_DURATIONS.BURN_FLASH, timers = defaultTimers } = options; + return runMuzzleFlash(renderer, repaint, worldX, worldY, { + duration, + timers, + char: glyphChar, + color: BURN_FLASH_FG, + }); +} diff --git a/src/render/palette.ts b/src/render/palette.ts index 6033bfc..5056bc8 100644 --- a/src/render/palette.ts +++ b/src/render/palette.ts @@ -93,6 +93,26 @@ export const MIND_INFLUENCE_FG = '#a64dff'; */ export const CLOAK_FLASH_FG = '#babfb6'; +/** + * Hot ignition burst on a thrown molotov's impact tile (P3.6). Deliberately far + * brighter and yellower than the HAZARD tile's own ember red (`#d45a3a`): the + * burst is painted a frame before that fire is stamped underneath it, so a + * closer hue would read as "nothing happened." This is the bottle breaking, not + * the fire that follows. + */ +export const INCENDIARY_IMPACT_FG = '#ffb03a'; + +/** + * Ember-orange burst on an entity's own tile as fire eats it (P3.6) — both the + * ignition tick (`INCENDIARY_IMPACT_DAMAGE`) and each standing tick + * (`HAZARD_DAMAGE`). Sits between the HAZARD tile's red and the impact burst's + * gold: the same fire family, so "this body is burning" reads as continuous + * with the tile it's standing on, while still separating from it. Painted on + * the entity's own glyph, like `MIND_INFLUENCE_FG` — something happening *to* a + * body, not a place. + */ +export const BURN_FLASH_FG = '#ff7a2f'; + /** * Sentinel glyph for cells outside the world (camera near the map edge). * We render *something* rather than leaving holes so the playfield always diff --git a/src/shell/domTypes.ts b/src/shell/domTypes.ts index 05aab71..c09a2d2 100644 --- a/src/shell/domTypes.ts +++ b/src/shell/domTypes.ts @@ -174,6 +174,20 @@ export type RazorCloakedPayload = { actor?: import('../game/Entity.js').Entity; }; +/** + * `EVENT.HAZARD_DAMAGE` — a body taking its per-round standing tick on a HAZARD + * tile (P3.6 wired this to the ember burst). Carries `x`/`y` alongside the + * entity because the emitter reads them at tick time; the listener uses the + * entity's own position, which is the same tile. + */ +export type HazardDamagePayload = { + entity?: import('../game/Entity.js').Entity; + damage?: number; + killed?: boolean; + x?: number; + y?: number; +}; + export type ShellDomRefs = { stageEl: HTMLElement; pipCanvas: HTMLCanvasElement; diff --git a/src/shell/sceneListeners.ts b/src/shell/sceneListeners.ts index 0d7947c..4a4bdf6 100644 --- a/src/shell/sceneListeners.ts +++ b/src/shell/sceneListeners.ts @@ -3,6 +3,7 @@ import { EVENT, alarmPayloadTriggersRepPenalty } from '../game/events.js'; import { resolveEntityLabel } from '../game/Entity.js'; import { ANIMATION_DURATIONS, + runBurnFlash, runMuzzleFlash, triggerCrashFlash, triggerDamageFlash, @@ -19,6 +20,7 @@ import { isRun } from './sceneView.js'; import type { DoorUnlockPayload, EntityDamagedPayload, + HazardDamagePayload, MindInfluencedPayload, NoisePayload, RazorCloakedPayload, @@ -153,6 +155,17 @@ export class SceneListenerController { ); } } + // A molotov's ignition tick (P3.6). The per-round standing tick is the + // HAZARD_DAMAGE listener below; both are contact with fire, so they + // share one ember burst. Deliberately not folded into the branch above + // — that one is keyed on `damage > 0`, and a body whose shield eats the + // whole ignition is still visibly on fire. + if (source === 'incendiary' && target) { + const flashRenderer = meatInPip ? renderers.pip : renderers.main; + const repaint = meatInPip ? effects.paintPip : effects.paint; + const fired = runBurnFlash(flashRenderer, repaint, target.x, target.y, target.glyph); + if (fired) animLock.push(ANIMATION_DURATIONS.BURN_FLASH); + } if (killed && target && !forcedBodyJackOut) { this.#deps.memoriseMeatCorpse(target, (x, y) => meatVision.isVisible(x, y)); } @@ -168,6 +181,24 @@ export class SceneListenerController { const fired = runMuzzleFlash(flashRenderer, repaint, origin.x, origin.y); if (fired) animLock.push(ANIMATION_DURATIONS.MUZZLE_FLASH); }), + run.bus.on(EVENT.HAZARD_DAMAGE, payload => { + // Ember burst on each body taking a standing tick in fire (P3.6). The + // event has carried x/y since hazards shipped but nothing listened, so + // burning was invisible unless it was happening to *you* (the + // ENTITY_DAMAGED handler's shake/vignette). Now every body that burns + // says so, on its own tile. + // + // Fires once per burning entity per round, so several can land in one + // aftermath. animLock takes the longest outstanding window rather than + // summing, so a crowded fire doesn't stack into a long input freeze. + const { entity } = (payload ?? {}) as HazardDamagePayload; + if (!entity) return; + const meatInPip = isCyberView(run); + const flashRenderer = meatInPip ? renderers.pip : renderers.main; + const repaint = meatInPip ? effects.paintPip : effects.paint; + const fired = runBurnFlash(flashRenderer, repaint, entity.x, entity.y, entity.glyph); + if (fired) animLock.push(ANIMATION_DURATIONS.BURN_FLASH); + }), run.bus.on(EVENT.DOOR_UNLOCKED, payload => { const { label = 'Door' } = (payload ?? {}) as DoorUnlockPayload; effects.flash(`${label} unlocked — passage open.`); diff --git a/src/shell/shellRuntime.ts b/src/shell/shellRuntime.ts index 9802707..2d1a826 100644 --- a/src/shell/shellRuntime.ts +++ b/src/shell/shellRuntime.ts @@ -43,6 +43,7 @@ import { CrtFilter } from '/src/render/CrtFilter.js'; import { ANIMATION_DURATIONS, createAnimationLock, + runIncendiaryImpactFlash, runInteractSecuredFlash, triggerHealFlash, triggerShake, @@ -56,7 +57,7 @@ import { recordStatusActionLine } from '/src/statusActivityRows.js'; import { placeSmoke } from '/src/game/Smoke.js'; import { placeHazardCluster } from '/src/game/Run.js'; import { blastCells } from '/src/game/breachBlast.js'; -import { hasLineOfSight } from '/src/game/LineOfSight.js'; +import { resolveIncendiaryImpact } from '/src/game/incendiary.js'; import { ITEM_ID, SCOREABLE_ITEMS, getItemById } from '/src/game/items.js'; import type { CampaignSnapshot } from '/src/game/persistence.js'; import type { Contract } from '/src/game/hub/Curator.js'; @@ -1127,28 +1128,54 @@ function applyUseConsumableResult( return; } if (result.type === 'incendiary') { - const { cx, cy } = result as { cx: number; cy: number }; - if (!Number.isInteger(cx) || !Number.isInteger(cy)) { - throw new Error('[shell] incendiary consumable returned invalid placement data'); + const { dx, dy } = result as { dx: number; dy: number }; + // Crew reports the aim; the ray is resolved here because it needs World + // (P3.6). Resolving it a second time after the pre-check in + // `resolveAimedUseItem` is deliberate and cheap: the function is pure over + // grid + entity state, `useConsumable` only touches the operator's AP and + // inventory in between, and both calls run in one synchronous tick — so + // they cannot disagree. Same shape as the breaching charge's + // `canPlaceBreachingCharge` → `placeBreachingCharge` pair. + const impact = resolveIncendiaryImpact(run.world, operator, { dx, dy }, INCENDIARY_THROW_DIST); + if (!impact) { + // Unreachable: the pre-check refuses this before any AP is spent. If it + // fires, the two call sites have drifted apart and the player has just + // silently eaten a charge — crash loud rather than swallow it. + throw new Error( + `[shell] incendiary resolved to no impact after the pre-check passed (aim ${dx},${dy})` + ); } - // `placeHazardCluster` only stamps onto FLOOR tiles inside bounds — if - // the target is on/past the map edge or buried in a wall, the cluster - // simply finds zero candidates and stamps nothing. We've already paid - // AP and consumed the charge in Crew.useConsumable; the LOS pre-check - // in `resolveAimedUseItem` is what protects the player from "throw - // through a wall and lose your bomb." - const { placed, casualties } = placeHazardCluster(run.world, { x: cx, y: cy }, run.rng, { + // The impact tile is always FLOOR or a body standing on something, so the + // cluster has somewhere to take. `placed === 0` is still reachable — a body + // caught it while standing on rubble, or on the map edge with every + // neighbour a wall — so the dry-throw copy stays. + const { placed, casualties } = placeHazardCluster(run.world, impact, run.rng, { thrown: true, attacker: operator, }); + // Ignition burst on the impact tile, before the fire is drawn under it. + // Fires whatever the throw hit, so a dry throw still reads as a throw. + const burst = runIncendiaryImpactFlash(renderer, paint, impact.x, impact.y); + if (burst) animLock.push(ANIMATION_DURATIONS.INCENDIARY_IMPACT_FLASH); + const caught = casualties.length; + const downed = casualties.filter(c => c.killed).length; + const hit = caught === 0 ? '' : ` ${caught} caught${downed > 0 ? `, ${downed} DOWN` : ''}.`; if (placed === 0) { - flash(`Used MOLOTOV — landed on hard cover; no fire took. ${operator.ap} AP left.`); + flash(`Used MOLOTOV — nothing there would take. ${operator.ap} AP left.`); } else { - const caught = casualties.length; - const downed = casualties.filter(c => c.killed).length; - const hit = caught === 0 ? '' : ` ${caught} caught${downed > 0 ? `, ${downed} DOWN` : ''}.`; + // Only claim "square on" when the body it hit actually burned. A body + // caught on the one unburnable-but-standable tile (EXIT) is the impact + // point yet takes no impact damage, so it is not in `casualties` — without + // this guard the flash would read "Caught X square on. 0 caught." and lie. + const struckBody = + impact.intercepted && casualties.some(c => c.entity === impact.intercepted) + ? impact.intercepted + : null; + const short = struckBody + ? ` Caught ${resolveEntityLabel(struckBody.id, run.world.entities)} square on.` + : ''; flash( - `Used MOLOTOV — ${placed} tile${placed === 1 ? '' : 's'} ignited.${hit} ` + + `Used MOLOTOV — ${placed} tile${placed === 1 ? '' : 's'} ignited.${short}${hit} ` + `${operator.ap} AP left.` ); } @@ -1197,20 +1224,14 @@ function resolveAimedUseItem(aim: { dx: number; dy: number }, run: Run): void { } pendingAimItemId = null; if (itemId === ITEM_ID.MOLOTOV) { - // LOS-clear-target pre-check: the throw lands at - // `player + dir * INCENDIARY_THROW_DIST`. If LOS from the thrower to - // that tile is blocked (or the tile is out of bounds), refuse the - // throw *before* spending AP / consuming the bomb. - const cx = operator.x + aim.dx * INCENDIARY_THROW_DIST; - const cy = operator.y + aim.dy * INCENDIARY_THROW_DIST; - if (!run.world.grid.inBounds(cx, cy)) { - flash('USE FAILED: target is off the map.'); - paint(); - return; - } - const blockers = run.world.blockerKeys(); - if (!hasLineOfSight(run.world.grid, operator.x, operator.y, cx, cy, { blockers })) { - flash('USE FAILED: target is behind cover.'); + // P3.6: a molotov no longer refuses on geometry — it flies until something + // stops it and burns there, walls and cover included. The one case left + // with no answer is a ray that never crosses ground fire can take (facing + // an adjacent wall, or throwing off the map edge). Refuse that *before* + // `useConsumable` spends the AP and the charge; Rylee's call is that this + // stays a refusal rather than smashing the bottle at your own feet. + if (!resolveIncendiaryImpact(run.world, operator, aim, INCENDIARY_THROW_DIST)) { + flash('USE FAILED: no clear ground to throw at.'); paint(); return; } diff --git a/sw-core.js b/sw-core.js index 4ab2dd9..5eddab2 100644 --- a/sw-core.js +++ b/sw-core.js @@ -26,18 +26,21 @@ const CacheConfig = { '/about.js', '/main.css', '/manifest.json', + '/components/ChronicleArchive.js', + '/components/ClinicModal.js', + '/components/CombatInventory.js', '/components/ConfirmationModal.js', - '/components/CuratorBriefing.js', '/components/ContractSelect.js', '/components/CrashDump.js', - '/components/GameOver.js', - '/components/FaultScreen.js', + '/components/CrewInventory.js', '/components/CrewList.js', '/components/CrewRoster.js', + '/components/CuratorBriefing.js', + '/components/FaultScreen.js', '/components/FinnShop.js', - '/components/ClinicModal.js', - '/components/ChronicleArchive.js', + '/components/GameOver.js', '/components/InitialRecruit.js', + '/components/InventoryOverlay.js', '/components/KeyHelp.js', '/components/RunBriefing.js', '/components/SystemStart.js', @@ -46,41 +49,27 @@ const CacheConfig = { '/src/DataStore.js', '/src/domUtils.js', '/src/errorBoundary.js', - '/src/game/ai/Skirmisher.js', - '/src/game/ai/Guard.js', '/src/game/ai/Bruiser.js', - '/src/game/ai/Juggernaut.js', '/src/game/ai/Flanker.js', + '/src/game/ai/Guard.js', + '/src/game/ai/Juggernaut.js', '/src/game/ai/Lookout.js', - '/src/game/ai/Sniper.js', '/src/game/ai/Medic.js', '/src/game/ai/PatrolHostile.js', - '/src/game/breachBlast.js', + '/src/game/ai/Skirmisher.js', + '/src/game/ai/Sniper.js', + '/src/game/archetypeRewards.js', + '/src/game/archetypes/Adept.js', + '/src/game/archetypes/Berserk.js', + '/src/game/archetypes/Chimera.js', + '/src/game/archetypes/Decker.js', '/src/game/archetypes/index.js', '/src/game/archetypes/Merc.js', '/src/game/archetypes/Razor.js', '/src/game/archetypes/Tech.js', - '/src/game/archetypes/Decker.js', - '/src/game/archetypes/Berserk.js', - '/src/game/archetypes/Adept.js', - '/src/game/archetypes/Chimera.js', - '/src/game/empBlast.js', - '/src/game/surge.js', - '/src/game/nanoRepair.js', - '/src/game/mindInfluence.js', - '/src/game/crewStatRoll.js', - '/src/game/scoreableUnlocks.js', - '/src/game/archetypeUnlocks.js', - '/src/game/archetypeRewards.js', '/src/game/archetypeShowcase.js', - '/src/game/cyber/CyberAvatar.js', - '/src/game/cyber/cyberMapBuild.js', - '/src/game/cyber/CyberspaceLayer.js', - '/src/game/cyber/DataNode.js', - '/src/game/cyber/EntryPort.js', - '/src/game/cyber/GuardianIce.js', - '/src/game/cyber/ProbeIce.js', - '/src/game/cyber/SparkIce.js', + '/src/game/archetypeUnlocks.js', + '/src/game/breachBlast.js', '/src/game/Campaign.js', '/src/game/campaignSummary.js', '/src/game/chronicle.js', @@ -90,8 +79,20 @@ const CacheConfig = { '/src/game/corpTurnDriver.js', '/src/game/corpTurnStatusCopy.js', '/src/game/Crew.js', + '/src/game/crewDisplay.js', + '/src/game/crewStatRoll.js', + '/src/game/cyber/CyberAvatar.js', + '/src/game/cyber/cyberMapBuild.js', + '/src/game/cyber/CyberspaceLayer.js', + '/src/game/cyber/DataNode.js', + '/src/game/cyber/EntryPort.js', + '/src/game/cyber/GuardianIce.js', + '/src/game/cyber/ProbeIce.js', + '/src/game/cyber/SparkIce.js', '/src/game/describe.js', + '/src/game/empBlast.js', '/src/game/encounters.js', + '/src/game/endFlavor.js', '/src/game/enemyAliases.js', '/src/game/entities/BreachingCharge.js', '/src/game/entities/ConsumablePickup.js', @@ -113,24 +114,27 @@ const CacheConfig = { '/src/game/events.js', '/src/game/Grid.js', '/src/game/Hostile.js', - '/src/game/hub/Curator.js', - '/src/game/hub/arcSurface.js', '/src/game/hub/ArchiveTerminal.js', + '/src/game/hub/arcSurface.js', '/src/game/hub/Clinic.js', + '/src/game/hub/Curator.js', '/src/game/hub/Finn.js', '/src/game/hub/hubReveals.js', '/src/game/hub/SafeSpace.js', '/src/game/hub/Terminal.js', + '/src/game/incendiary.js', '/src/game/items.js', '/src/game/knockback.js', - '/src/game/locations.js', '/src/game/LineOfSight.js', + '/src/game/locations.js', '/src/game/mapConnectivity.js', + '/src/game/mindInfluence.js', + '/src/game/nanoRepair.js', '/src/game/objectiveProgress.js', '/src/game/Pathfinding.js', '/src/game/persistence.js', - '/src/game/playerPerception.js', '/src/game/placement.js', + '/src/game/playerPerception.js', '/src/game/procgen/bsp.js', '/src/game/procgen/mapBuild.js', '/src/game/procgen/mapDimensions.js', @@ -143,8 +147,10 @@ const CacheConfig = { '/src/game/procgen/prefabs/types.js', '/src/game/Run.js', '/src/game/salvage.js', + '/src/game/scoreableUnlocks.js', '/src/game/slide.js', '/src/game/Smoke.js', + '/src/game/surge.js', '/src/game/TurnQueue.js', '/src/game/Turret.js', '/src/game/Vision.js', @@ -152,16 +158,16 @@ const CacheConfig = { '/src/input/applyIntent.js', '/src/input/KeyboardController.js', '/src/input/keyHelp.js', - '/src/input/mapKeyRows.js', '/src/input/keymap.js', + '/src/input/mapKeyRows.js', '/src/input/touchpad.js', '/src/render/animations.js', '/src/render/AsciiRenderer.js', '/src/render/combatHud.js', '/src/render/CrtFilter.js', '/src/render/frame.js', - '/src/render/pip.js', '/src/render/palette.js', + '/src/render/pip.js', '/src/render/principalTerrainPalettes.js', '/src/rng.js', '/src/ServiceWorkerManager.js', @@ -171,9 +177,11 @@ const CacheConfig = { '/src/shell/locationHud.js', '/src/shell/sceneListeners.js', '/src/shell/sceneView.js', - '/src/shell/statusLine.js', '/src/shell/shellRuntime.js', + '/src/shell/statusLine.js', + '/src/shell/visionSync.js', '/src/statusActivityRows.js', + '/src/updateRelease.js', ]; }, diff --git a/tests/unit/game/hazard.test.ts b/tests/unit/game/hazard.test.ts index 82c864d..388960b 100644 --- a/tests/unit/game/hazard.test.ts +++ b/tests/unit/game/hazard.test.ts @@ -536,6 +536,64 @@ test('thrown incendiary damages each entity at most once', () => { assert.equal(drone.hp, 9 - INCENDIARY_IMPACT_DAMAGE, 'not damaged once per ignited tile'); }); +// P3.6 widening: "caught square on" must actually burn on any tile fire can +// take, not just FLOOR. Before this, a body on RUBBLE or in an existing fire +// pool was reported as the impact centre yet took zero impact damage, because +// its own tile refused to light — the "square on … 0 caught" lie. +test('thrown incendiary ignites RUBBLE under a body and deals impact damage', () => { + const { world } = makeHazardWorld(10, 10); + world.grid.setTile(5, 5, TILE.RUBBLE); + const drone = makeEntity('drone-0', 5, 5, FACTION.CORP, 3); + world.addEntity(drone); + + const result = placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42), { thrown: true }); + + assert.equal(world.grid.tileAt(5, 5), TILE.HAZARD, 'rubble under the body lights'); + assert.equal(drone.hp, 3 - INCENDIARY_IMPACT_DAMAGE, 'caught square on for real, not for nothing'); + assert.equal(result.casualties.length, 1); +}); + +test('thrown fire on RUBBLE reverts to RUBBLE after burnout, not FLOOR', () => { + const { world } = makeHazardWorld(10, 10); + world.grid.setTile(5, 5, TILE.RUBBLE); + placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42), { thrown: true }); + assert.equal(world.grid.tileAt(5, 5), TILE.HAZARD, 'ignites the rubble'); + + for (let i = 0; i < INCENDIARY_BURN_TURNS; i++) advanceRound(world); + + // The tile-effect registry reads the tile underneath, so widening ignition + // must not erase terrain: rubble comes back, not FLOOR. + assert.equal(world.grid.tileAt(5, 5), TILE.RUBBLE, 'terrain underneath preserved'); +}); + +test('thrown incendiary catches a body standing in an existing fire pool', () => { + const { world } = makeHazardWorld(10, 10); + world.grid.setTile(5, 5, TILE.HAZARD); + const drone = makeEntity('drone-0', 5, 5, FACTION.CORP, 3); + world.addEntity(drone); + + const result = placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42), { thrown: true }); + + assert.equal(drone.hp, 3 - INCENDIARY_IMPACT_DAMAGE, 'a body in a fire pool takes the impact hit'); + assert.equal(result.casualties.length, 1); +}); + +test('thrown incendiary spares an EXIT tile even under a body — extraction stays unburnable', () => { + const { world } = makeHazardWorld(10, 10); + world.grid.setTile(5, 5, TILE.EXIT); + const drone = makeEntity('drone-0', 5, 5, FACTION.CORP, 3); + world.addEntity(drone); + + const result = placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42), { thrown: true }); + + // Hard constraint: burning the extraction tile would make it uninteractable. + // Accepted residual — a body parked exactly on the EXIT takes no impact damage + // — which is why the shell only claims "square on" when the body is a casualty. + assert.equal(world.grid.tileAt(5, 5), TILE.EXIT, 'EXIT never burns, body or not'); + assert.equal(drone.hp, 3, 'a body on the EXIT takes no impact damage'); + assert.equal(result.casualties.length, 0); +}); + // --------------------------------------------------------------------------- // Thrown incendiary: burnout // --------------------------------------------------------------------------- diff --git a/tests/unit/game/incendiary.test.ts b/tests/unit/game/incendiary.test.ts new file mode 100644 index 0000000..3a50359 --- /dev/null +++ b/tests/unit/game/incendiary.test.ts @@ -0,0 +1,362 @@ +/** + * P3.6 — Molotov throw geometry. + * + * `resolveIncendiaryImpact` replaces the old "land at exactly `dir * 3`, refuse + * if LOS is blocked" rule. The bottle now flies until something stops it and + * detonates there, so these tests are the contract for *where* it lands. + * + * The shell (`resolveAimedUseItem`) is the only caller and has no unit coverage + * of its own — see the `shell-runtime-untestable` note. This file is what guards + * the behaviour, so it carries the edge cases the shell used to gate on. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Grid } from '../../../src/game/Grid.js'; +import { World } from '../../../src/game/World.js'; +import { Entity } from '../../../src/game/Entity.js'; +import { EventBus } from '../../../src/game/events.js'; +import { TILE, FACTION, INCENDIARY_THROW_DIST } from '../../../src/game/constants.js'; +import { resolveIncendiaryImpact } from '../../../src/game/incendiary.js'; +import { Door } from '../../../src/game/entities/Door.js'; +import { Terminal } from '../../../src/game/entities/Terminal.js'; +import { ConsumablePickup } from '../../../src/game/entities/ConsumablePickup.js'; +import { EscortNpc } from '../../../src/game/entities/EscortNpc.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeWorld(width = 12, height = 12) { + const grid = new Grid(width, height); + for (let x = 0; x < width; x++) { + for (let y = 0; y < height; y++) { + grid.setTile(x, y, TILE.FLOOR); + } + } + return new World(grid, { events: new EventBus() }); +} + +/** A plain blocking, burnable body — the drone stand-in. */ +function makeBody(id: string, x: number, y: number, faction: string = FACTION.CORP) { + return new Entity({ id, x, y, faction, glyph: 'd', maxHp: 3 }); +} + +const THROWER = { x: 5, y: 5 }; + +/** Throw from THROWER along `aim`, at the real configured range. */ +function throwFrom(world: World, dx: number, dy: number, origin = THROWER) { + return resolveIncendiaryImpact(world, origin, { dx, dy }, INCENDIARY_THROW_DIST); +} + +// --------------------------------------------------------------------------- +// Baseline: open ground +// --------------------------------------------------------------------------- + +test('over open floor the bottle flies the full throw distance', () => { + const world = makeWorld(); + const impact = throwFrom(world, 1, 0); + assert.deepEqual( + { x: impact?.x, y: impact?.y }, + { x: 5 + INCENDIARY_THROW_DIST, y: 5 }, + 'nothing in the way means it lands at max range' + ); + assert.equal(impact?.intercepted, null); +}); + +test('diagonal throws travel the same number of steps, not the same euclidean distance', () => { + const world = makeWorld(); + const impact = throwFrom(world, -1, 1); + assert.deepEqual({ x: impact?.x, y: impact?.y }, { x: 2, y: 8 }); +}); + +// --------------------------------------------------------------------------- +// Interception: the headline case +// --------------------------------------------------------------------------- + +test('a hostile 2 diagonal steps away intercepts a throw with 3 steps of range', () => { + // Rylee's case: throw SW, hostile at (dx, dy) = (-2, +2). The bottle must + // stop on them rather than sailing past to the max-range tile at (-3, +3). + const world = makeWorld(); + const drone = makeBody('drone-1', 3, 7); + world.addEntity(drone); + + const impact = throwFrom(world, -1, 1); + + assert.deepEqual({ x: impact?.x, y: impact?.y }, { x: 3, y: 7 }, 'fire radiates from the drone'); + assert.equal(impact?.intercepted, drone, 'the drone is reported as the thing that stopped it'); +}); + +test('the nearest body on the ray intercepts, not the furthest', () => { + const world = makeWorld(); + const near = makeBody('near', 6, 5); + const far = makeBody('far', 8, 5); + world.addEntity(near); + world.addEntity(far); + + const impact = throwFrom(world, 1, 0); + + assert.equal(impact?.intercepted, near); + assert.deepEqual({ x: impact?.x, y: impact?.y }, { x: 6, y: 5 }); +}); + +test('a friendly crewmate on the ray intercepts the throw — friendly fire is intended', () => { + const world = makeWorld(); + const ally = makeBody('ally', 6, 5, FACTION.PLAYER); + world.addEntity(ally); + + const impact = throwFrom(world, 1, 0); + + assert.equal(impact?.intercepted, ally, 'the bottle does not politely fly around your own crew'); +}); + +test('an EscortNpc intercepts — it is a blocker and it burns', () => { + const world = makeWorld(); + const npc = new EscortNpc({ id: 'vip', x: 6, y: 5, label: 'VIP' }); + world.addEntity(npc); + + const impact = throwFrom(world, 1, 0); + + assert.equal(impact?.intercepted, npc); +}); + +test('a body beyond max range does not intercept', () => { + const world = makeWorld(); + world.addEntity(makeBody('far', 5 + INCENDIARY_THROW_DIST + 1, 5)); + + const impact = throwFrom(world, 1, 0); + + assert.equal(impact?.intercepted, null, 'out of range is out of range'); + assert.deepEqual({ x: impact?.x, y: impact?.y }, { x: 5 + INCENDIARY_THROW_DIST, y: 5 }); +}); + +// --------------------------------------------------------------------------- +// Terrain: walls stop it, cover is lobbed over +// --------------------------------------------------------------------------- + +test('a wall stops the bottle and it lands on the last clear tile short of it', () => { + const world = makeWorld(); + world.grid.setTile(7, 5, TILE.WALL); + + const impact = throwFrom(world, 1, 0); + + assert.deepEqual( + { x: impact?.x, y: impact?.y }, + { x: 6, y: 5 }, + 'impact is never the wall tile itself' + ); + assert.equal(impact?.intercepted, null); +}); + +test('cover is lobbed over — a hostile behind it still catches the bottle', () => { + const world = makeWorld(); + world.grid.setTile(6, 5, TILE.COVER); + const drone = makeBody('drone-1', 7, 5); + world.addEntity(drone); + + const impact = throwFrom(world, 1, 0); + + assert.equal(impact?.intercepted, drone, 'chest-high cover does not stop a thrown arc'); + assert.deepEqual({ x: impact?.x, y: impact?.y }, { x: 7, y: 5 }); +}); + +test('cover is passed over but is never itself a landing tile — fire cannot take there', () => { + const world = makeWorld(); + // Cover sits on the max-range tile with nothing beyond to catch the bottle. + world.grid.setTile(8, 5, TILE.COVER); + + const impact = throwFrom(world, 1, 0); + + assert.deepEqual({ x: impact?.x, y: impact?.y }, { x: 7, y: 5 }, 'pulls back to the last floor'); +}); + +test('a wall directly behind cover still stops the throw', () => { + const world = makeWorld(); + world.grid.setTile(6, 5, TILE.COVER); + world.grid.setTile(7, 5, TILE.WALL); + + const impact = throwFrom(world, 1, 0); + + // Step 1 (6,5) is cover — flown over, but not a landing tile. So the only + // clear tile the bottle passed is... none. Nowhere to throw. + assert.equal(impact, null); +}); + +// --------------------------------------------------------------------------- +// Blocking props: stop the flight, but never catch the fire +// --------------------------------------------------------------------------- + +test('a locked door stops the bottle without catching it', () => { + const world = makeWorld(); + const door = new Door({ id: 'door-1', doorId: 'd1', x: 7, y: 5, locked: true }); + world.addEntity(door); + + const impact = throwFrom(world, 1, 0); + + assert.equal(impact?.intercepted, null, 'a hazard-immune prop never becomes the impact point'); + assert.deepEqual({ x: impact?.x, y: impact?.y }, { x: 6, y: 5 }, 'fire pools in the doorway'); +}); + +test('an unlocked door is walk-through — the bottle flies through the opening', () => { + const world = makeWorld(); + world.addEntity(new Door({ id: 'door-1', doorId: 'd1', x: 6, y: 5, locked: false })); + + const impact = throwFrom(world, 1, 0); + + assert.deepEqual({ x: impact?.x, y: impact?.y }, { x: 8, y: 5 }, 'open door does not block'); +}); + +test('a Terminal stops the bottle but does not catch it', () => { + const world = makeWorld(); + world.addEntity(new Terminal({ id: 'term-1', x: 7, y: 5, label: 'Terminal' })); + + const impact = throwFrom(world, 1, 0); + + assert.equal(impact?.intercepted, null); + assert.deepEqual({ x: impact?.x, y: impact?.y }, { x: 6, y: 5 }); +}); + +test('a walk-through consumable pickup does not stop the bottle', () => { + const world = makeWorld(); + world.addEntity( + new ConsumablePickup({ id: 'pk-1', x: 6, y: 5, consumableId: 'stim', label: 'Stim' }) + ); + + const impact = throwFrom(world, 1, 0); + + assert.deepEqual({ x: impact?.x, y: impact?.y }, { x: 8, y: 5 }, 'litter is not a backstop'); +}); + +test('a dead body does not stop the bottle', () => { + const world = makeWorld(); + const corpse = makeBody('corpse', 6, 5); + corpse.alive = false; + world.addEntity(corpse); + + const impact = throwFrom(world, 1, 0); + + assert.deepEqual({ x: impact?.x, y: impact?.y }, { x: 8, y: 5 }); +}); + +// --------------------------------------------------------------------------- +// Map edges +// --------------------------------------------------------------------------- + +test('a throw toward the map edge lands on the last in-bounds tile', () => { + const world = makeWorld(); + // From (1,1) throwing W: (0,1) is the last in-bounds tile on the ray. + const impact = throwFrom(world, -1, 0, { x: 1, y: 1 }); + + assert.deepEqual({ x: impact?.x, y: impact?.y }, { x: 0, y: 1 }); +}); + +test('a throw off the map from the edge tile itself has nowhere to land', () => { + const world = makeWorld(); + const impact = throwFrom(world, -1, 0, { x: 0, y: 4 }); + + assert.equal(impact, null, 'every tile on the ray is out of bounds'); +}); + +// --------------------------------------------------------------------------- +// The refusal case — no clear ground +// --------------------------------------------------------------------------- + +test('facing an adjacent wall there is nowhere to throw — returns null, no smash-at-your-feet', () => { + const world = makeWorld(); + world.grid.setTile(6, 5, TILE.WALL); + + const impact = throwFrom(world, 1, 0); + + assert.equal(impact, null, 'the shell refuses this before spending AP or the charge'); +}); + +test('a body adjacent to the thrower still catches it, wall or no wall behind', () => { + const world = makeWorld(); + const drone = makeBody('drone-1', 6, 5); + world.addEntity(drone); + world.grid.setTile(7, 5, TILE.WALL); + + const impact = throwFrom(world, 1, 0); + + assert.equal(impact?.intercepted, drone, 'point blank is a hit, not a refusal'); +}); + +// --------------------------------------------------------------------------- +// Non-FLOOR passable terrain +// --------------------------------------------------------------------------- + +test('rubble now holds thrown fire; exit still never does', () => { + const world = makeWorld(); + world.grid.setTile(7, 5, TILE.RUBBLE); + world.grid.setTile(8, 5, TILE.EXIT); + + const impact = throwFrom(world, 1, 0); + + // Widened whitelist (P3.6): rubble can take thrown fire, so a body caught on + // it actually burns rather than being ringed in flame. The centre lands on + // the rubble at (7,5). EXIT stays unburnable — burying the extraction tile in + // fire is never allowed — so the bottle never centres on (8,5), even in range. + assert.deepEqual({ x: impact?.x, y: impact?.y }, { x: 7, y: 5 }); +}); + +test('existing fire holds fire — a second throw can re-centre on the burning tile', () => { + const world = makeWorld(); + world.grid.setTile(8, 5, TILE.HAZARD); + + const impact = throwFrom(world, 1, 0); + + // A burning tile can take fire (it re-lights / refreshes its timer and, more + // importantly, catches a body standing in it), so the max-range HAZARD at + // (8,5) is a valid centre rather than being pulled back. + assert.deepEqual({ x: impact?.x, y: impact?.y }, { x: 8, y: 5 }); +}); + +test('a body standing in existing fire still intercepts', () => { + const world = makeWorld(); + world.grid.setTile(7, 5, TILE.HAZARD); + const drone = makeBody('drone-1', 7, 5); + world.addEntity(drone); + + const impact = throwFrom(world, 1, 0); + + assert.equal(impact?.intercepted, drone); +}); + +test('a hostile in an open doorway is caught — the open door does not shield it', () => { + const world = makeWorld(); + world.addEntity(new Door({ id: 'door-1', doorId: 'd1', x: 7, y: 5, locked: false })); + const drone = makeBody('drone-1', 7, 4); + world.addEntity(drone); + // Share the tile with the passable (walk-through) open door — the only way a + // real drone comes to stand in a doorway. `entityAt` skips the open door, so + // `relocateEntity` sees the tile as free. + world.relocateEntity(drone, 7, 5); + + const impact = throwFrom(world, 1, 0); + + assert.equal( + impact?.intercepted, + drone, + 'the open door is flown through; the body co-located with it still catches the bottle' + ); + assert.deepEqual({ x: impact?.x, y: impact?.y }, { x: 7, y: 5 }); +}); + +// --------------------------------------------------------------------------- +// Input contract +// --------------------------------------------------------------------------- + +test('a non-unit or zero aim is a wiring bug, not a recoverable miss', () => { + const world = makeWorld(); + assert.throws(() => throwFrom(world, 0, 0), /unit vector/i); + assert.throws(() => throwFrom(world, 2, 0), /unit vector/i); + assert.throws(() => throwFrom(world, 0.5, 0), /unit vector/i); +}); + +test('a non-positive throw distance is a wiring bug', () => { + const world = makeWorld(); + assert.throws( + () => resolveIncendiaryImpact(world, THROWER, { dx: 1, dy: 0 }, 0), + /throw distance/i + ); +}); diff --git a/tests/unit/game/items.test.ts b/tests/unit/game/items.test.ts index 5e1fd56..480cc5f 100644 --- a/tests/unit/game/items.test.ts +++ b/tests/unit/game/items.test.ts @@ -8,7 +8,6 @@ import { TILE, STIM_HEAL, SMOKE_RADIUS, - INCENDIARY_THROW_DIST, BREACHING_CHARGE_RANGE, TARGETING_BONUS, DODGE_BONUS, @@ -354,13 +353,22 @@ test('useConsumable(SMOKE_CHARGE) returns smoke descriptor', () => { // Crew.useConsumable — Incendiary // --------------------------------------------------------------------------- -test('useConsumable(MOLOTOV) returns thrown hazard descriptor', () => { +test('useConsumable(MOLOTOV) reports the aim, not a landing tile', () => { + // P3.6: the bottle flies until something stops it, so the impact depends on + // grid + entity state Crew has no reference to. Crew must hand back the aim + // and let the shell resolve the ray (`resolveIncendiaryImpact`) — a + // `thrower + dir * DIST` centre here would be wrong the moment a drone, a + // wall, or the map edge is in the way. See incendiary.test.ts for the ray. const crew = new Merc({ id: 'merc', x: 3, y: 3, maxAp: 4 }); crew.addConsumable(ITEM_ID.MOLOTOV); const result = crew.useConsumable(ITEM_ID.MOLOTOV, { dx: 1, dy: 0 }); assert.equal(result.type, 'incendiary'); - assert.equal(result.cx, 3 + INCENDIARY_THROW_DIST); - assert.equal(result.cy, 3); + assert.deepEqual({ dx: result.dx, dy: result.dy }, { dx: 1, dy: 0 }); + assert.equal( + 'cx' in result, + false, + 'a precomputed centre would be a guess the shell then has to ignore' + ); assert.equal(crew.inventory.consumables.length, 0); }); diff --git a/tests/unit/render/animations.test.ts b/tests/unit/render/animations.test.ts index 20b180d..4509cd7 100644 --- a/tests/unit/render/animations.test.ts +++ b/tests/unit/render/animations.test.ts @@ -8,6 +8,8 @@ import { SHAKE_CLASS, createAnimationLock, restartCssAnimation, + runBurnFlash, + runIncendiaryImpactFlash, runInteractSecuredFlash, runMuzzleFlash, triggerCrashFlash, @@ -19,8 +21,10 @@ import { triggerSurgeFlash, } from '../../../src/render/animations.js'; import { + BURN_FLASH_FG, CRASH_FLASH_FG, HEAL_FLASH_FG, + INCENDIARY_IMPACT_FG, STUNNED_FG, SURGE_FLASH_FG, } from '../../../src/render/palette.js'; @@ -360,3 +364,87 @@ test('runInteractSecuredFlash: paints the prop glyph in white and schedules repa timers.advance(ANIMATION_DURATIONS.INTERACT_SECURED_FLASH); assert.deepEqual(calls[1], ['repaint']); }); + +// --------------------------------------------------------------------------- +// P3.6 — molotov fire effects +// --------------------------------------------------------------------------- + +test('runIncendiaryImpactFlash: bursts on the impact tile and schedules repaint', () => { + const timers = makeTimers(); + const calls = []; + const renderer = { + flashCell: (wx, wy, opts) => { + calls.push(['flash', wx, wy, opts]); + return true; + }, + }; + const repaint = () => calls.push(['repaint']); + + const fired = runIncendiaryImpactFlash(renderer, repaint, 6, 3, { timers }); + assert.equal(fired, true); + assert.deepEqual(calls[0], [ + 'flash', + 6, + 3, + { + duration: ANIMATION_DURATIONS.INCENDIARY_IMPACT_FLASH, + char: '*', + color: INCENDIARY_IMPACT_FG, + }, + ]); + timers.advance(ANIMATION_DURATIONS.INCENDIARY_IMPACT_FLASH); + assert.deepEqual(calls[1], ['repaint']); +}); + +test('runIncendiaryImpactFlash: does not overpaint with the HAZARD glyph', () => { + // The fire cluster is stamped onto this same cell in the same frame, so a + // burst drawn as `▓` would be invisible — the whole point of the effect is + // that the throw reads as a distinct beat before the fire settles. + const calls = []; + const renderer = { + flashCell: (_wx, _wy, opts) => { + calls.push(opts.char); + return true; + }, + }; + runIncendiaryImpactFlash(renderer, () => {}, 0, 0, { timers: makeTimers() }); + assert.notEqual(calls[0], '▓'); +}); + +test('runBurnFlash: tints the burning body’s own glyph rather than overpainting it', () => { + // Which body is burning is the information the player needs when several are + // alight at once — a generic fire glyph would erase exactly that. + const timers = makeTimers(); + const calls = []; + const renderer = { + flashCell: (wx, wy, opts) => { + calls.push(['flash', wx, wy, opts]); + return true; + }, + }; + const repaint = () => calls.push(['repaint']); + + const fired = runBurnFlash(renderer, repaint, 4, 9, 'd', { timers }); + assert.equal(fired, true); + assert.deepEqual(calls[0], [ + 'flash', + 4, + 9, + { duration: ANIMATION_DURATIONS.BURN_FLASH, char: 'd', color: BURN_FLASH_FG }, + ]); + timers.advance(ANIMATION_DURATIONS.BURN_FLASH); + assert.deepEqual(calls[1], ['repaint']); +}); + +test('the two fire effects are visually distinct from each other', () => { + // Impact and burn are different beats — the bottle breaking vs. a body being + // eaten — and land on the same tile when a throw catches someone square on. + assert.notEqual(INCENDIARY_IMPACT_FG, BURN_FLASH_FG); +}); + +test('a burn flash is short enough not to hold the input lock across a crowded fire', () => { + // One fires per burning entity per aftermath; the lock takes the longest + // outstanding window rather than summing, but the individual beat still has + // to stay under the impact burst it follows. + assert.ok(ANIMATION_DURATIONS.BURN_FLASH < ANIMATION_DURATIONS.INCENDIARY_IMPACT_FLASH); +}); From ef68cd249db88b9e182b58142902a366c8b0c880 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Fri, 17 Jul 2026 13:37:34 -0700 Subject: [PATCH 03/10] format --- tests/unit/game/hazard.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/unit/game/hazard.test.ts b/tests/unit/game/hazard.test.ts index 388960b..8aacf48 100644 --- a/tests/unit/game/hazard.test.ts +++ b/tests/unit/game/hazard.test.ts @@ -549,7 +549,11 @@ test('thrown incendiary ignites RUBBLE under a body and deals impact damage', () const result = placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42), { thrown: true }); assert.equal(world.grid.tileAt(5, 5), TILE.HAZARD, 'rubble under the body lights'); - assert.equal(drone.hp, 3 - INCENDIARY_IMPACT_DAMAGE, 'caught square on for real, not for nothing'); + assert.equal( + drone.hp, + 3 - INCENDIARY_IMPACT_DAMAGE, + 'caught square on for real, not for nothing' + ); assert.equal(result.casualties.length, 1); }); @@ -574,7 +578,11 @@ test('thrown incendiary catches a body standing in an existing fire pool', () => const result = placeHazardCluster(world, { x: 5, y: 5 }, new Rng(42), { thrown: true }); - assert.equal(drone.hp, 3 - INCENDIARY_IMPACT_DAMAGE, 'a body in a fire pool takes the impact hit'); + assert.equal( + drone.hp, + 3 - INCENDIARY_IMPACT_DAMAGE, + 'a body in a fire pool takes the impact hit' + ); assert.equal(result.casualties.length, 1); }); From ff5597183f17d057341d4c9289818fa939bda875 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 21 Jul 2026 16:07:46 -0700 Subject: [PATCH 04/10] add audio sfx --- .prettierignore | 4 + components/SettingsModal.ts | 306 ++++++++++ debug/sound.html | 137 +++++ debug/sound.ts | 213 +++++++ docs/kaizen.md | 2 + index.html | 10 + index.ts | 2 + main.css | 2 +- scripts/copy-assets.mjs | 5 + src/audio/AudioManager.ts | 204 +++++++ src/audio/soundBoard.ts | 10 + src/audio/sounds.ts | 676 ++++++++++++++++++++++ src/game/Run.ts | 23 +- src/game/World.ts | 8 +- src/game/events.ts | 15 + src/game/lootCollection.ts | 85 +++ src/game/persistence.ts | 30 +- src/input/applyIntent.ts | 32 +- src/input/keyHelp.ts | 10 +- src/shell/domTypes.ts | 5 + src/shell/sceneListeners.ts | 113 ++++ src/shell/shellRuntime.ts | 241 ++++++-- src/vendor/tonebench/README.md | 40 ++ src/vendor/tonebench/tonebenchEngine.d.ts | 80 +++ src/vendor/tonebench/tonebenchEngine.js | 224 +++++++ sw-core.js | 5 + tests/audio/audioManager.test.ts | 271 +++++++++ tests/audio/soundDefs.test.ts | 313 ++++++++++ tests/unit/game/World.test.ts | 11 + tests/unit/game/lootCollection.test.ts | 115 ++++ tests/unit/game/persistence.test.ts | 34 ++ tests/unit/input/applyIntent.test.ts | 11 + tests/unit/input/keyHelp.test.ts | 15 +- tests/unit/serviceWorkerCore.test.ts | 5 +- tests/unit/shell/sceneListeners.test.ts | 234 +++++++- 35 files changed, 3430 insertions(+), 61 deletions(-) create mode 100644 components/SettingsModal.ts create mode 100644 debug/sound.html create mode 100644 debug/sound.ts create mode 100644 src/audio/AudioManager.ts create mode 100644 src/audio/soundBoard.ts create mode 100644 src/audio/sounds.ts create mode 100644 src/game/lootCollection.ts create mode 100644 src/vendor/tonebench/README.md create mode 100644 src/vendor/tonebench/tonebenchEngine.d.ts create mode 100644 src/vendor/tonebench/tonebenchEngine.js create mode 100644 tests/audio/audioManager.test.ts create mode 100644 tests/audio/soundDefs.test.ts create mode 100644 tests/unit/game/lootCollection.test.ts diff --git a/.prettierignore b/.prettierignore index 1b18b3d..c134f41 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,6 +7,10 @@ dist/ build/ *.min.js +# Vendored libraries — kept byte-for-byte as upstream emits them (re-vendor, +# don't reformat). Our co-located .d.ts boundaries are NOT ignored. +src/vendor/**/*.js + # Generated files *.xml diff --git a/components/SettingsModal.ts b/components/SettingsModal.ts new file mode 100644 index 0000000..b833345 --- /dev/null +++ b/components/SettingsModal.ts @@ -0,0 +1,306 @@ +/** + * — player preferences. The app's first real DataStore.prefs + * UI; audio (mute + volume) is the pathfinder consumer, and future prefs plug + * in here. + * + * Data flow is one-way: this modal writes prefs to DataStore; AudioManager is + * subscribed to DataStore's `change` event and reacts (updates the master gain). + * The modal never talks to AudioManager directly. It reads current values from + * DataStore.prefs each time it opens. + * + * Events: + * - `dismiss` — player pressed Esc / clicked the backdrop / hit CLOSE. + */ + +import { h } from '/src/domUtils.js'; +import dataStore from '/src/DataStore.js'; +import { + AUDIO_MUTED_PREF, + AUDIO_VOLUME_PREF, + DEFAULT_VOLUME, + parseAudioPrefs, +} from '/src/audio/AudioManager.js'; + +const CSS = ` +:host { + --settings-bg: rgba(7, 18, 16, 0.96); + --settings-border: var(--accent-color, #00d9a5); + --settings-text: #c5efdf; + --settings-dim: #6ae8c8; + --settings-accent: var(--accent-color, #00d9a5); + --settings-shadow: 0 0 28px rgba(0, 217, 165, 0.18), 0 12px 36px rgba(0, 0, 0, 0.5); + + display: none; + position: fixed; + inset: 0; + z-index: 50; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.55); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: var(--settings-text); +} + +:host([open]) { + display: flex; +} + +.panel { + background: var(--settings-bg); + border: 1px solid var(--settings-border); + border-radius: 6px; + padding: 1.25rem 1.5rem 1.4rem; + box-shadow: var(--settings-shadow); + min-width: min(420px, 92vw); + max-width: min(520px, 96vw); +} + +.title { + margin: 0 0 1rem; + text-align: center; + font-size: 0.95rem; + letter-spacing: 0.18em; + color: var(--settings-accent); + border-bottom: 1px dashed var(--settings-border); + padding-bottom: 0.5rem; +} + +.section-label { + margin: 0 0 0.6rem; + font-size: 0.72rem; + letter-spacing: 0.16em; + color: var(--settings-dim); +} + +.row { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.75rem 1rem; + align-items: center; + margin-bottom: 0.9rem; +} + +.row-name { + font-size: 0.82rem; + letter-spacing: 0.06em; + color: var(--settings-text); +} + +button.control, +button.close { + appearance: none; + -webkit-appearance: none; + background: rgba(0, 217, 165, 0.08); + color: var(--settings-text); + border: 1px solid rgba(0, 217, 165, 0.45); + border-radius: 4px; + padding: 0.4rem 0.6rem; + font: inherit; + font-size: 0.78rem; + letter-spacing: 0.06em; + cursor: pointer; + min-height: 32px; +} + +button.control[aria-pressed='true'] { + border-color: var(--settings-accent); + background: rgba(0, 217, 165, 0.2); + color: var(--settings-accent); +} + +button.control:hover, +button.control:focus-visible, +button.close:hover, +button.close:focus-visible { + outline: none; + border-color: var(--settings-accent); + background: rgba(0, 217, 165, 0.18); +} + +.volume-cell { + display: flex; + align-items: center; + gap: 0.6rem; +} + +input[type='range'] { + flex: 1; + accent-color: var(--settings-accent); + cursor: pointer; + min-width: 0; +} + +input[type='range']:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.volume-readout { + font-size: 0.78rem; + color: var(--settings-dim); + min-width: 3ch; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.footer { + margin-top: 1rem; + display: flex; + justify-content: center; +} + +.hint { + margin: 0.85rem 0 0; + text-align: center; + font-size: 0.7rem; + letter-spacing: 0.08em; + color: var(--settings-dim); +} +`; + +/** Volume slider granularity: 0–100 integer, mapped to the 0–1 pref. */ +const VOLUME_STEPS = 100; + +class SettingsModal extends HTMLElement { + #ready = false; + #panelEl: HTMLElement | null = null; + #soundBtn: HTMLButtonElement | null = null; + #volumeInput: HTMLInputElement | null = null; + #volumeReadout: HTMLElement | null = null; + + #muted = false; + #volume = DEFAULT_VOLUME; + + #onKeyDown: ((this: HTMLElement, ev: KeyboardEvent) => void) | null = null; + #onBackdrop: ((this: HTMLElement, ev: MouseEvent) => void) | null = null; + + connectedCallback() { + if (this.#ready) return; + this.tabIndex = -1; + const shadow = this.attachShadow({ mode: 'open' }); + const style = h('style'); + style.textContent = CSS; + shadow.appendChild(style); + + this.#soundBtn = h('button', { + type: 'button', + className: 'control', + 'aria-pressed': 'false', + }) as HTMLButtonElement; + this.#soundBtn.addEventListener('click', () => this.#toggleMuted()); + + this.#volumeInput = h('input', { + type: 'range', + min: '0', + max: String(VOLUME_STEPS), + step: '1', + 'aria-label': 'Volume', + }) as HTMLInputElement; + // `input` fires continuously as the player drags — write each step so the + // master gain tracks live via DataStore → AudioManager. + this.#volumeInput.addEventListener('input', () => this.#onVolumeInput()); + + this.#volumeReadout = h('span', { className: 'volume-readout' }); + + const closeBtn = h('button', { + type: 'button', + className: 'close', + textContent: 'CLOSE', + }) as HTMLButtonElement; + closeBtn.addEventListener('click', () => this.#emit('dismiss')); + + this.#panelEl = h('section', { className: 'panel' }, [ + h('h2', { className: 'title', textContent: '── OPTIONS ──' }), + h('p', { className: 'section-label', textContent: 'AUDIO' }), + h('div', { className: 'row' }, [ + h('span', { className: 'row-name', textContent: 'Sound' }), + this.#soundBtn, + ]), + h('div', { className: 'row' }, [ + h('span', { className: 'row-name', textContent: 'Volume' }), + h('div', { className: 'volume-cell' }, [this.#volumeInput, this.#volumeReadout]), + ]), + h('div', { className: 'footer' }, [closeBtn]), + h('p', { className: 'hint', textContent: 'Esc close' }), + ]); + shadow.appendChild(this.#panelEl); + + this.#onKeyDown = evt => { + if (!this.isOpen) return; + if (evt.key === 'Escape') { + evt.preventDefault(); + this.#emit('dismiss'); + } + }; + this.addEventListener('keydown', this.#onKeyDown); + this.#onBackdrop = evt => { + if (!evt.composedPath().includes(this.#panelEl as EventTarget)) this.#emit('dismiss'); + }; + this.addEventListener('click', this.#onBackdrop); + + this.#ready = true; + this.#render(); + } + + disconnectedCallback() { + if (this.#onKeyDown) this.removeEventListener('keydown', this.#onKeyDown); + if (this.#onBackdrop) this.removeEventListener('click', this.#onBackdrop); + } + + show() { + // Pull the latest persisted values every open — the store is the source of truth. + ({ muted: this.#muted, volume: this.#volume } = parseAudioPrefs(dataStore.prefs)); + this.setAttribute('open', ''); + this.#render(); + queueMicrotask(() => this.#soundBtn?.focus()); + } + + hide() { + this.removeAttribute('open'); + } + + get isOpen() { + return this.hasAttribute('open'); + } + + #toggleMuted() { + this.#muted = !this.#muted; + dataStore.setPref(AUDIO_MUTED_PREF, this.#muted); + this.#render(); + } + + #onVolumeInput() { + if (!this.#volumeInput) return; + const steps = Number(this.#volumeInput.value); + this.#volume = Math.min(1, Math.max(0, steps / VOLUME_STEPS)); + dataStore.setPref(AUDIO_VOLUME_PREF, this.#volume); + this.#syncVolumeReadout(); + } + + #render() { + if (!this.#ready) return; + if (this.#soundBtn) { + this.#soundBtn.setAttribute('aria-pressed', this.#muted ? 'false' : 'true'); + this.#soundBtn.textContent = this.#muted ? 'OFF' : 'ON'; + } + if (this.#volumeInput) { + this.#volumeInput.value = String(Math.round(this.#volume * VOLUME_STEPS)); + this.#volumeInput.disabled = this.#muted; + } + this.#syncVolumeReadout(); + } + + #syncVolumeReadout() { + if (this.#volumeReadout) { + this.#volumeReadout.textContent = `${Math.round(this.#volume * 100)}%`; + } + } + + #emit(eventName: string, detail: Record = {}) { + this.dispatchEvent(new CustomEvent(eventName, { detail })); + } +} + +customElements.define('settings-modal', SettingsModal); + +export default SettingsModal; diff --git a/debug/sound.html b/debug/sound.html new file mode 100644 index 0000000..fec7654 --- /dev/null +++ b/debug/sound.html @@ -0,0 +1,137 @@ + + + + Kernel Panic — Sound test + + + + + + + +
+

Kernel Panic — Sound test

+
+
+

+ Every entry in src/audio/sounds.ts, playable on demand through the real + AudioManager / vendored TONEBENCH engine — no game state required. Mute and + volume here read/write the same DataStore prefs as the in-game Options modal, + so they persist across pages. +

+
+

+
+

Sounds

+
+
+
+

Sequences & chains

+

+ Multi-hit cues that re-pitch or chain the defs above via playSequence / + playChain rather than being defs of their own. +

+
+
+
+

Params

+

The SynthParams (or sequence/chain spec) behind the last-played button.

+
(nothing played yet)
+
+
+ + + diff --git a/debug/sound.ts b/debug/sound.ts new file mode 100644 index 0000000..c24214a --- /dev/null +++ b/debug/sound.ts @@ -0,0 +1,213 @@ +/** + * Sound test harness — every def in `src/audio/sounds.ts`, playable on demand + * through the real `AudioManager` (soundBoard's production singleton, wired to + * the vendored TONEBENCH engine). No game state: this is a synth palette + * browser, not a scenario harness like index.ts/map.ts. + * + * `KERNEL_PANIC_DEFS` is sounds.ts's own single source of truth (see its + * docstring), so the sound grid is generated from `Object.keys()` rather than + * a hand-maintained list here — add a sound there and it appears here for free. + * + * Sequences/chains (`playSequence`, `playChain`) re-pitch or chain a base def + * rather than defining a new one, so they're listed separately with their + * step data rather than mixed into the flat sound grid. + */ +import { h } from '/src/domUtils.js'; +import { audioManager } from '/src/audio/soundBoard.js'; +import { + KERNEL_PANIC_DEFS, + EXTRACTION_MOTIF, + TRANSACTION_MOTIF, + type SoundName, + type SequenceStep, +} from '/src/audio/sounds.js'; +import { + AUDIO_MUTED_PREF, + AUDIO_VOLUME_PREF, + DEFAULT_VOLUME, + parseAudioPrefs, +} from '/src/audio/AudioManager.js'; +import dataStore from '/src/DataStore.js'; + +const VOLUME_STEPS = 100; + +const soundNames = Object.keys(KERNEL_PANIC_DEFS) as SoundName[]; + +/** Turret deploy's two-beat chain, matching sceneListeners.ts's TURRET_DEPLOYED handler. */ +const DEPLOY_CHAIN: readonly { name: SoundName; when: number }[] = [ + { name: 'deploy', when: 0 }, + { name: 'deployOnline', when: 0.09 }, +]; + +let muted = false; +let volume = DEFAULT_VOLUME; +let soundBtn: HTMLButtonElement; +let volumeInput: HTMLInputElement; +let volumeReadout: HTMLElement; +let statusEl: HTMLElement; +let paramsEl: HTMLElement; +let playCount = 0; + +function timestamp() { + return new Date().toLocaleTimeString(undefined, { hour12: false }); +} + +function setStatus(label: string) { + playCount += 1; + statusEl.textContent = `> [${timestamp()}] #${playCount} played: ${label}${muted ? ' (muted — no sound)' : ''}`; +} + +function setParams(value: unknown) { + paramsEl.textContent = JSON.stringify(value, null, 2); +} + +function markActive(grid: HTMLElement, btn: HTMLButtonElement) { + for (const child of Array.from(grid.children)) { + if (child instanceof HTMLButtonElement) child.setAttribute('aria-pressed', 'false'); + } + btn.setAttribute('aria-pressed', 'true'); +} + +function playSound(name: SoundName, grid: HTMLElement, btn: HTMLButtonElement) { + audioManager.resume(); + audioManager.play(name); + markActive(grid, btn); + setStatus(`"${name}"`); + setParams(KERNEL_PANIC_DEFS[name]); +} + +function playMotif( + label: string, + base: SoundName, + steps: readonly SequenceStep[], + grid: HTMLElement, + btn: HTMLButtonElement +) { + audioManager.resume(); + audioManager.playSequence(base, steps); + markActive(grid, btn); + setStatus(`"${label}" (playSequence base="${base}")`); + setParams({ base, steps }); +} + +function playChain( + label: string, + steps: readonly { name: SoundName; when: number }[], + grid: HTMLElement, + btn: HTMLButtonElement +) { + audioManager.resume(); + audioManager.playChain(steps); + markActive(grid, btn); + setStatus(`"${label}" (playChain)`); + setParams({ steps }); +} + +function buildSoundGrid() { + const grid = document.getElementById('sound-grid') as HTMLElement; + const buttons = soundNames.map(name => { + const btn = h('button', { + type: 'button', + className: 'sound-btn', + 'aria-pressed': 'false', + textContent: name, + }) as HTMLButtonElement; + btn.addEventListener('click', () => playSound(name, grid, btn)); + return btn; + }); + grid.append(...buttons); +} + +function buildSequenceGrid() { + const grid = document.getElementById('sequence-grid') as HTMLElement; + + const extractionBtn = h('button', { + type: 'button', + className: 'sound-btn', + 'aria-pressed': 'false', + textContent: 'extracted (EXTRACTION_MOTIF)', + }) as HTMLButtonElement; + extractionBtn.addEventListener('click', () => + playMotif('EXTRACTION_MOTIF', 'extracted', EXTRACTION_MOTIF, grid, extractionBtn) + ); + + const transactionBtn = h('button', { + type: 'button', + className: 'sound-btn', + 'aria-pressed': 'false', + textContent: 'transaction (TRANSACTION_MOTIF)', + }) as HTMLButtonElement; + transactionBtn.addEventListener('click', () => + playMotif('TRANSACTION_MOTIF', 'transaction', TRANSACTION_MOTIF, grid, transactionBtn) + ); + + const deployChainBtn = h('button', { + type: 'button', + className: 'sound-btn', + 'aria-pressed': 'false', + textContent: 'deploy → deployOnline (chain)', + }) as HTMLButtonElement; + deployChainBtn.addEventListener('click', () => + playChain('turret deploy chain', DEPLOY_CHAIN, grid, deployChainBtn) + ); + + grid.append(extractionBtn, transactionBtn, deployChainBtn); +} + +function renderAudioControls() { + if (soundBtn) soundBtn.setAttribute('aria-pressed', muted ? 'false' : 'true'); + if (soundBtn) soundBtn.textContent = muted ? 'OFF' : 'ON'; + if (volumeInput) { + volumeInput.value = String(Math.round(volume * VOLUME_STEPS)); + volumeInput.disabled = muted; + } + if (volumeReadout) volumeReadout.textContent = `${Math.round(volume * 100)}%`; +} + +function buildAudioControls() { + const container = document.getElementById('audio-controls') as HTMLElement; + + soundBtn = h('button', { + type: 'button', + className: 'control', + 'aria-pressed': 'false', + }) as HTMLButtonElement; + soundBtn.addEventListener('click', () => { + muted = !muted; + dataStore.setPref(AUDIO_MUTED_PREF, muted); + renderAudioControls(); + }); + + volumeInput = h('input', { + type: 'range', + min: '0', + max: String(VOLUME_STEPS), + step: '1', + 'aria-label': 'Volume', + }) as HTMLInputElement; + volumeInput.addEventListener('input', () => { + volume = Math.min(1, Math.max(0, Number(volumeInput.value) / VOLUME_STEPS)); + dataStore.setPref(AUDIO_VOLUME_PREF, volume); + renderAudioControls(); + }); + + volumeReadout = h('span', { className: 'readout' }); + + container.append( + h('label', {}, [h('span', { textContent: 'Sound' }), soundBtn]), + h('label', {}, [h('span', { textContent: 'Volume' }), volumeInput, volumeReadout]) + ); +} + +statusEl = document.getElementById('sound-status') as HTMLElement; +paramsEl = document.getElementById('sound-params') as HTMLElement; + +// Read persisted prefs once at load; the settings modal (if opened in another +// tab) isn't listened for here — this page's own controls are the source of +// truth for the rest of this session, matching its read-once-per-open pattern. +({ muted, volume } = parseAudioPrefs(dataStore.prefs)); + +buildAudioControls(); +buildSoundGrid(); +buildSequenceGrid(); +renderAudioControls(); diff --git a/docs/kaizen.md b/docs/kaizen.md index 4d0e392..c88f210 100644 --- a/docs/kaizen.md +++ b/docs/kaizen.md @@ -105,6 +105,8 @@ When an item lands, gets reclassified, or develops new context, edit it in place - **`chebyshev` / `manhattan` helpers.** `Pathfinding.chebyshev(ax, ay, bx, by)` vs `Run.chebyshev` / `Run.manhattan` (GridPoint) vs `mapBuild.manhattan` — unify on GridPoint wrappers or a small `gridMath.ts` when convenient. - **`LineOfSight` inline `` `${x},${y}` `` keys.** Could import `coordKey` from `mapConnectivity.js`; isolated, low drift risk at current scale. +- **Dual-site and escort objectives have no HUD progress chip.** Surfaced 2026-07-20 while adding a distinct `checkpoint` audio cue (`src/audio/sounds.ts`) for their interim beats — sync pad 1 of 2 touched, escort contact linked up — via `handleSecuredInteract` in `shellRuntime.ts` gating on `run.isObjectiveSatisfied()`. That closes the *audio* gap, but `objectiveProgress()` in `src/game/objectiveProgress.ts` still has no `case` for `OBJECTIVES.DUAL_SITE` or `OBJECTIVES.ESCORT_EXTRACT`, unlike `RECON`/`SWEEP`/`DATA_NODE_SLICE`/`SCORE_FINAL`, which all render a `[LABEL:current/total]` chip. Players get a sound but no on-screen `[SYNC:1/2]`-style readout. Would need progress tallies analogous to `dataNodeProgress` (count `SyncPad.synced` / `EscortNpc.activated` across the world) plumbed through `buildCombatHudSnapshot`. **Revisit trigger:** first playtest confusion about dual-site/escort state, or next time the HUD chip work is touched. + ## ✓ Closed - ~~**A body caught square on by a molotov takes no impact damage unless it's standing on FLOOR.**~~ diff --git a/index.html b/index.html index c695be0..ff92fe8 100644 --- a/index.html +++ b/index.html @@ -36,6 +36,15 @@
Kernel Panic logo

Kernel Panic

+