From 5edf693b28d8427fe497b7ef119d7f5cb53f7fca Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Mon, 13 Jul 2026 14:05:50 -0700 Subject: [PATCH 1/9] generic status-effect channel --- docs/phase-3.5-plan.md | 4 +- src/game/Entity.ts | 90 ++++++++++++++++++++++++---- src/game/archetypes/Razor.ts | 17 +----- src/game/constants.ts | 19 ++++++ src/input/applyIntent.ts | 12 ++++ tests/unit/game/Entity.test.ts | 77 +++++++++++++++++++++++- tests/unit/game/Razor.test.ts | 24 +++++++- tests/unit/game/TurnQueue.test.ts | 18 +++++- tests/unit/input/applyIntent.test.ts | 24 ++++++++ 9 files changed, 256 insertions(+), 29 deletions(-) diff --git a/docs/phase-3.5-plan.md b/docs/phase-3.5-plan.md index 8e16c1c..cabba4e 100644 --- a/docs/phase-3.5-plan.md +++ b/docs/phase-3.5-plan.md @@ -36,7 +36,7 @@ M3 + M4 + M5 ──> M6 (stat-roll β†’ archetype derivation, needs all 6 profile | Milestone | Status | |---|---| -| P3.5.M1 β€” Generic status-effect subsystem | πŸ”² Not started | +| P3.5.M1 β€” Generic status-effect subsystem | βœ… Complete | | P3.5.M2 β€” Decker perk swap: Override β†’ EMP AOE stun | πŸ”² Not started | | P3.5.M3 β€” Berserk archetype (surge/crash) | πŸ”² Not started | | P3.5.M4 β€” Adept archetype (Influence, renamed from Override) | πŸ”² Not started | @@ -97,6 +97,8 @@ if (player.ap === 0 && intent.type !== 'end-turn' && intent.type !== 'cancel') { **Persistence:** none needed. `CampaignCrewSnapshot` (`persistence.ts:907-931`) only saves at the Hub between runs; every effect in scope is combat-run-scoped and will have long since ticked to zero by save time. (If a future phase adds mid-combat save/resume, `effects` would need to serialize into the run snapshot then.) +**Implementation note (as-built):** `stealthed` turned out to be read/written in ~8 files beyond `Entity`/`Razor` (Combat stealth-break, `Run` snapshot/reset, `persistence` save/restore, HUD snapshot+render). Rather than fan the migration across all of them, `Entity.stealthed` became a getter/setter alias backed by `STATUS_EFFECT.STEALTH` on the effects Map β€” one source of truth, zero changes to those call sites or their tests. `Razor.slide()` still assigns `this.stealthed = true` (now routed through the setter), and Razor's `refreshAp` override was deleted as planned. + **Critical files:** `src/game/Entity.ts`, `src/game/archetypes/Razor.ts`, `src/input/applyIntent.ts`, `src/game/TurnQueue.ts`. **Tests:** `tests/unit/game/Entity.test.ts` (apply/has/clear/duration-1-clears-next-refresh/overwrite-not-stack/invalid-duration throws), `tests/unit/game/Razor.test.ts` (stealth via `hasEffect`, slideβ†’waitβ†’slide re-cloak regression), `tests/unit/game/TurnQueue.test.ts` (synthetic stunned entity: 0 AP on the stunned refresh, full AP the one after), `tests/unit/input/applyIntent.test.ts` (player at 0 AP with no legal action sends an intent, no throw, turn concludes). diff --git a/src/game/Entity.ts b/src/game/Entity.ts index dc900b4..efe785a 100644 --- a/src/game/Entity.ts +++ b/src/game/Entity.ts @@ -1,4 +1,4 @@ -import { DEFAULT_AP, DEFAULT_HP, FACTION, type FactionId } from './constants.js'; +import { DEFAULT_AP, DEFAULT_HP, FACTION, STATUS_EFFECT, type FactionId } from './constants.js'; import type { TurnActionStep, TurnActionSteps } from '../types.js'; import type { Rng } from '../rng.js'; import type { World } from './World.js'; @@ -72,7 +72,15 @@ export class Entity { shieldHp: number; damageReduction: number; alive: boolean; - stealthed: boolean; + /** + * P3.5.M1: generic timed status-effect channel. Maps an effect id + * (`STATUS_EFFECT.*`) to the number of this entity's own `refreshAp()` + * firings the effect has left. Manipulated only through + * `applyEffect`/`clearEffect`/`hasEffect`; decremented by `tickEffects` + * inside `refreshAp`. Not persisted β€” every effect in scope is + * combat-run-scoped and has ticked to zero long before a Hub save. + */ + effects: Map; passable: boolean; anchored: boolean; /** @@ -131,14 +139,7 @@ export class Entity { this.shieldHp = 0; this.damageReduction = damageReduction; this.alive = true; - /** - * Stealth flag. The Razor's `slide` perk sets this true; it clears on the - * archetype's next AP refresh (so it lasts through the corp turn but no - * further). Generic so future cyberware (cloak, ghost-protocol) can flip - * the same field without touching observer code. Skirmisher uses - * `isSpottableBy` to honour it. - */ - this.stealthed = false; + this.effects = new Map(); this.passable = passable; this.anchored = anchored; this.frozen = false; @@ -146,6 +147,70 @@ export class Entity { this.principalTag = principalTag; } + /** + * Stealth cloak, backed by the generic effect channel (P3.5.M1). Reads/writes + * `STATUS_EFFECT.STEALTH` so the Razor's `slide`, the combat stealth-break, + * vision (`isSpottableBy`), the HUD snapshot, and save/restore all share one + * source of truth without any of them knowing about the effects Map. Setting + * `true` arms a duration of 1 (clears on this entity's next refresh); setting + * `false` clears it immediately. + */ + get stealthed(): boolean { + return this.hasEffect(STATUS_EFFECT.STEALTH); + } + + set stealthed(value: boolean) { + if (value) { + this.applyEffect(STATUS_EFFECT.STEALTH, 1); + } else { + this.clearEffect(STATUS_EFFECT.STEALTH); + } + } + + hasEffect(id: string): boolean { + return this.effects.has(id); + } + + /** Turns of this entity's own refreshes the effect has left; 0 if absent. */ + effectTurnsRemaining(id: string): number { + return this.effects.get(id) ?? 0; + } + + /** + * Arm (or re-arm) a timed effect. Duration counts in this entity's own + * `refreshAp()` firings. No stacking: reapplying overwrites the remaining + * duration, mirroring the existing "second Slide re-arms stealth" behavior. + * Crashes on a non-positive-integer duration β€” that is a caller bug, not a + * value to silently clamp. + */ + applyEffect(id: string, duration: number): void { + if (!Number.isInteger(duration) || duration <= 0) { + throw new RangeError(`effect duration must be a positive integer, got ${duration}`); + } + this.effects.set(id, duration); + } + + clearEffect(id: string): void { + this.effects.delete(id); + } + + /** + * Decrement every active effect by one and drop any that reach zero. Called + * once per `refreshAp` (this entity's own turn cadence). Protected: subclass + * refresh overrides get it for free through `super.refreshAp()` and never + * call it directly. + */ + protected tickEffects(): void { + for (const [id, turns] of this.effects) { + const remaining = turns - 1; + if (remaining <= 0) { + this.effects.delete(id); + } else { + this.effects.set(id, remaining); + } + } + } + canAfford(cost: number): boolean { return this.ap >= cost; } @@ -185,8 +250,11 @@ export class Entity { } refreshAp(): void { - this.ap = this.maxAp; + // Check the stun *before* ticking so a duration of 1 covers this upcoming + // refresh (the 0-AP turn *is* the stun), not the one that just passed. + this.ap = this.hasEffect(STATUS_EFFECT.STUN) ? 0 : this.maxAp; this.shieldHp = 0; + this.tickEffects(); } /** diff --git a/src/game/archetypes/Razor.ts b/src/game/archetypes/Razor.ts index cd2b136..9a0bf8a 100644 --- a/src/game/archetypes/Razor.ts +++ b/src/game/archetypes/Razor.ts @@ -38,9 +38,9 @@ export const CALLSIGNS = Object.freeze([ * post-slide state β€” but no NOISE event, so a sentry doesn't latch onto the * tiles she passed through. That asymmetry is the whole point of the perk. * - * Stealth lifecycle: - * slide() β†’ this.stealthed = true - * refreshAp() β†’ this.stealthed = false (turn rotation clears it) + * Stealth lifecycle (P3.5.M1: now on the generic effect channel): + * slide() β†’ this.stealthed = true (arms STATUS_EFFECT.STEALTH, duration 1) + * refreshAp() β†’ base Entity.tickEffects() clears it one refresh later * * `refreshAp` runs on the incoming-faction's entities at `TurnQueue.endTurn`, * so stealth holds through the corp turn that immediately follows a Slide and @@ -91,15 +91,4 @@ export class Razor extends Crew { slideTwoTiles(world, this, dx, dy); this.stealthed = true; } - - /** - * Override AP refresh to also clear the slide stealth flag. Called by - * `TurnQueue.endTurn` on the incoming faction's entities, so stealth set - * on the player's turn N persists through the corp turn between N and N+1 - * and clears as turn N+1 begins. - */ - override refreshAp() { - super.refreshAp(); - this.stealthed = false; - } } diff --git a/src/game/constants.ts b/src/game/constants.ts index bee3be9..3dae066 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -74,6 +74,25 @@ export function factionForPrincipalGroups(groups: readonly string[]): FactionId return groups.includes('rival') ? FACTION.RIVAL : FACTION.CORP; } +/** + * Status-effect ids for the generic duration-effect channel (`Entity.effects`, + * P3.5.M1). Duration counts in "how many times the owning entity's own + * `refreshAp()` fires" β€” a duration of 1 clears by that entity's next refresh, + * the same semantics Razor's stealth cloak always had. No stacking; reapplying + * overwrites. New effects are added here as their milestones land. + * + * - `STEALTH` β€” Razor's slide cloak (migrated onto this channel in M1). + * - `STUN` β€” EMP neural-shock: the entity takes 0 AP on its stunned refresh + * (M2). Gated in `Entity.refreshAp` *before* the tick, so a duration of 1 + * covers the upcoming refresh, not the one that just passed. + */ +export const STATUS_EFFECT = Object.freeze({ + STEALTH: 'stealth', + STUN: 'stun', +}); + +export type StatusEffectId = (typeof STATUS_EFFECT)[keyof typeof STATUS_EFFECT]; + /** * Action Point costs from the V1 blueprint. Centralised so tuning is one edit. */ diff --git a/src/input/applyIntent.ts b/src/input/applyIntent.ts index b9e0ac2..cd56e0e 100644 --- a/src/input/applyIntent.ts +++ b/src/input/applyIntent.ts @@ -207,6 +207,18 @@ export function applyIntent(intent: Intent, ctx: ApplyIntentContext) { return; } + // P3.5.M1: a stunned (EMP'd) player-faction entity starts its turn at 0 AP. + // Every action intent would immediately trip `Entity.spendAp`'s overspend + // crash, since the AP-exhaustion gate normally ends the turn *before* the + // player is handed an intent at exactly 0. Conclude the turn here instead β€” + // same `concludeTurn ?? advanceTurn` fallback the exhaustion gate uses. + // `end-turn` (Wait) and `cancel` already no-op cleanly at 0 AP. + if (player.ap === 0 && intent.type !== 'end-turn' && intent.type !== 'cancel') { + log(`> ${entityLabel(player)} is STUNNED β€” no AP this turn.`); + (ctx.concludeTurn ?? advanceTurn)(); + return; + } + switch (intent.type) { case 'move': return doMove(intent, ctx); diff --git a/tests/unit/game/Entity.test.ts b/tests/unit/game/Entity.test.ts index 2aa796c..0e3f8fc 100644 --- a/tests/unit/game/Entity.test.ts +++ b/tests/unit/game/Entity.test.ts @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { Entity } from '../../../src/game/Entity.js'; -import { FACTION, DEFAULT_AP } from '../../../src/game/constants.js'; +import { FACTION, DEFAULT_AP, STATUS_EFFECT } from '../../../src/game/constants.js'; const baseProps = () => ({ id: 'e1', @@ -179,6 +179,81 @@ test('Entity.heal and addShield reject corrupt inputs loudly', () => { assert.throws(() => e.addShield(1), /already dead/); }); +// --------------------------------------------------------------------------- +// P3.5.M1 β€” generic status-effect channel +// --------------------------------------------------------------------------- + +test('Entity has no effects by default', () => { + const e = new Entity(baseProps()); + assert.equal(e.hasEffect(STATUS_EFFECT.STUN), false); + assert.equal(e.effectTurnsRemaining(STATUS_EFFECT.STUN), 0); +}); + +test('Entity.applyEffect arms an effect with its duration', () => { + const e = new Entity(baseProps()); + e.applyEffect(STATUS_EFFECT.STUN, 2); + assert.equal(e.hasEffect(STATUS_EFFECT.STUN), true); + assert.equal(e.effectTurnsRemaining(STATUS_EFFECT.STUN), 2); +}); + +test('Entity.clearEffect removes an effect immediately', () => { + const e = new Entity(baseProps()); + e.applyEffect(STATUS_EFFECT.STUN, 2); + e.clearEffect(STATUS_EFFECT.STUN); + assert.equal(e.hasEffect(STATUS_EFFECT.STUN), false); +}); + +test('Entity.applyEffect overwrites, it does not stack', () => { + const e = new Entity(baseProps()); + e.applyEffect(STATUS_EFFECT.STEALTH, 1); + e.applyEffect(STATUS_EFFECT.STEALTH, 3); + assert.equal(e.effectTurnsRemaining(STATUS_EFFECT.STEALTH), 3, 'reapply overwrites remaining duration'); +}); + +test('Entity.applyEffect rejects a non-positive-integer duration (data-corruption guard)', () => { + const e = new Entity(baseProps()); + assert.throws(() => e.applyEffect(STATUS_EFFECT.STUN, 0), RangeError); + assert.throws(() => e.applyEffect(STATUS_EFFECT.STUN, -1), RangeError); + assert.throws(() => e.applyEffect(STATUS_EFFECT.STUN, 1.5), RangeError); +}); + +test('Entity effect of duration 1 clears on the very next refresh', () => { + const e = new Entity(baseProps()); + e.applyEffect(STATUS_EFFECT.STEALTH, 1); + assert.equal(e.hasEffect(STATUS_EFFECT.STEALTH), true, 'active before the refresh'); + e.refreshAp(); + assert.equal(e.hasEffect(STATUS_EFFECT.STEALTH), false, 'ticked to 0 and dropped'); +}); + +test('Entity effect of duration 2 survives one refresh, clears on the second', () => { + const e = new Entity(baseProps()); + e.applyEffect(STATUS_EFFECT.STEALTH, 2); + e.refreshAp(); + assert.equal(e.effectTurnsRemaining(STATUS_EFFECT.STEALTH), 1, 'one refresh consumed'); + e.refreshAp(); + assert.equal(e.hasEffect(STATUS_EFFECT.STEALTH), false, 'cleared on the second refresh'); +}); + +test('Entity.refreshAp yields 0 AP on a stunned refresh, full AP the one after', () => { + const e = new Entity({ ...baseProps(), maxAp: 4 }); + e.applyEffect(STATUS_EFFECT.STUN, 1); + e.refreshAp(); + assert.equal(e.ap, 0, 'stun consumes this activation (0 AP)'); + assert.equal(e.hasEffect(STATUS_EFFECT.STUN), false, 'stun ticked away'); + e.refreshAp(); + assert.equal(e.ap, 4, 'the refresh after the stun is unaffected'); +}); + +test('Entity.stealthed is backed by the STEALTH effect channel', () => { + const e = new Entity(baseProps()); + assert.equal(e.stealthed, false); + e.stealthed = true; + assert.equal(e.hasEffect(STATUS_EFFECT.STEALTH), true, 'setter arms the effect'); + assert.equal(e.stealthed, true, 'getter reads the effect'); + e.stealthed = false; + assert.equal(e.hasEffect(STATUS_EFFECT.STEALTH), false, 'setter clears the effect'); +}); + test('Entity.stealthed defaults to false', () => { const e = new Entity(baseProps()); assert.equal(e.stealthed, false); diff --git a/tests/unit/game/Razor.test.ts b/tests/unit/game/Razor.test.ts index aa2d846..dde79c6 100644 --- a/tests/unit/game/Razor.test.ts +++ b/tests/unit/game/Razor.test.ts @@ -5,7 +5,7 @@ import { Razor } from '../../../src/game/archetypes/Razor.js'; import { Entity } from '../../../src/game/Entity.js'; import { Grid } from '../../../src/game/Grid.js'; import { World } from '../../../src/game/World.js'; -import { TILE, FACTION, AP_COST } from '../../../src/game/constants.js'; +import { TILE, FACTION, AP_COST, STATUS_EFFECT } from '../../../src/game/constants.js'; import { EventBus, EVENT } from '../../../src/game/events.js'; const makeWorld = ({ grid, razorAt = [3, 3], extraEntities = [], bus = null } = {}) => { @@ -149,3 +149,25 @@ test('Razor.refreshAp clears the stealthed flag (turn rotation drops cloak)', () // And refresh actually refreshes AP, so subclass didn't break super(). assert.equal(razor.ap, razor.maxAp); }); + +test('Razor.slide arms the generic STEALTH effect (P3.5.M1 channel)', () => { + const { world, razor } = makeWorld(); + assert.equal(razor.hasEffect(STATUS_EFFECT.STEALTH), false); + razor.slide(world, 1, 0); + assert.equal(razor.hasEffect(STATUS_EFFECT.STEALTH), true, 'slide sets STEALTH via the effect channel'); + assert.equal(razor.effectTurnsRemaining(STATUS_EFFECT.STEALTH), 1, 'one own-refresh of cloak'); +}); + +test('Razor slide -> wait -> slide re-cloaks (second slide re-arms stealth)', () => { + const g = new Grid(12, 12); + const { world, razor } = makeWorld({ grid: g, razorAt: [2, 2] }); + razor.slide(world, 1, 0); // lands (4,2), cloaked + assert.equal(razor.stealthed, true); + // The refresh that would have dropped the cloak also re-opens the AP budget, + // then a second slide re-arms stealth for another turn. + razor.refreshAp(); + assert.equal(razor.stealthed, false, 'cloak dropped at refresh'); + razor.slide(world, 1, 0); // lands (6,2) + assert.equal(razor.stealthed, true, 'second slide re-cloaks'); + assert.equal(razor.effectTurnsRemaining(STATUS_EFFECT.STEALTH), 1); +}); diff --git a/tests/unit/game/TurnQueue.test.ts b/tests/unit/game/TurnQueue.test.ts index 2119c77..4335622 100644 --- a/tests/unit/game/TurnQueue.test.ts +++ b/tests/unit/game/TurnQueue.test.ts @@ -5,7 +5,7 @@ import { Grid } from '../../../src/game/Grid.js'; import { Entity } from '../../../src/game/Entity.js'; import { World } from '../../../src/game/World.js'; import { TurnQueue } from '../../../src/game/TurnQueue.js'; -import { FACTION } from '../../../src/game/constants.js'; +import { FACTION, STATUS_EFFECT } from '../../../src/game/constants.js'; test('TurnQueue requires a non-empty faction order', () => { assert.throws(() => new TurnQueue([]), TypeError); @@ -69,6 +69,22 @@ test('TurnQueue.turnNumber increments after a full round', () => { assert.equal(q.turnNumber, 2); }); +test('TurnQueue.endTurn refreshes a stunned entity to 0 AP, then full AP next round', () => { + const w = new World(new Grid(3, 3)); + const drone = new Entity({ id: 'd', x: 2, y: 2, faction: FACTION.CORP, glyph: 'd', maxAp: 3 }); + w.addEntity(drone); + drone.applyEffect(STATUS_EFFECT.STUN, 1); + const q = new TurnQueue([FACTION.PLAYER, FACTION.CORP]); + // PLAYER -> CORP: the stunned drone refreshes into its own turn at 0 AP. + q.endTurn(w); + assert.equal(drone.ap, 0, 'stunned drone gets 0 AP on the stunned refresh'); + assert.equal(drone.hasEffect(STATUS_EFFECT.STUN), false, 'stun ticked away that same refresh'); + // CORP -> PLAYER -> CORP again: the refresh after the stun is unaffected. + q.endTurn(w); // -> PLAYER + q.endTurn(w); // -> CORP + assert.equal(drone.ap, 3, 'full AP the activation after the stun'); +}); + test('TurnQueue.endTurn emits turn:ended with previous/next/turn when bus attached', async () => { const { EventBus, EVENT } = await import('../../../src/game/events.js'); const bus = new EventBus(); diff --git a/tests/unit/input/applyIntent.test.ts b/tests/unit/input/applyIntent.test.ts index 1c3c086..b657377 100644 --- a/tests/unit/input/applyIntent.test.ts +++ b/tests/unit/input/applyIntent.test.ts @@ -450,6 +450,30 @@ test('special intent routes to Slide on a Razor (moves 2 tiles, engages stealth) assert.equal(player.stealthed, true); }); +test('a stunned (0-AP) player concludes its turn instead of crashing on spendAp', () => { + const { ctx, log, calls, player } = buildCtx(); + // Simulate an EMP'd body: refreshed into its turn at 0 AP. + player.ap = 0; + const advanceBefore = calls.advanceTurn; + // A move intent would otherwise trip Entity.spendAp's overspend crash. + assert.doesNotThrow(() => applyIntent({ type: 'move', dx: 1, dy: 0 }, ctx)); + assert.equal(player.x, 2, 'no move committed while stunned'); + assert.equal(calls.advanceTurn, advanceBefore + 1, 'turn concluded via advanceTurn fallback'); + assert.ok( + log.some(line => line.includes('STUNNED')), + 'legibility line names the stun' + ); +}); + +test('a stunned player can still cancel without ending the turn', () => { + const { ctx, calls, player } = buildCtx(); + player.ap = 0; + const advanceBefore = calls.advanceTurn; + applyIntent({ type: 'cancel' }, ctx); + assert.equal(calls.advanceTurn, advanceBefore, 'cancel does not conclude the turn'); + assert.equal(calls.resetInputModes, 1, 'cancel still clears aim modes'); +}); + test('special intent routes CyberAvatar Override against Probe ICE', () => { const grid = new Grid(8, 5); const bus = new EventBus(); From a8ed68d3a85dc0a2b31d7c821280ee29a220bcdd Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Mon, 13 Jul 2026 15:15:40 -0700 Subject: [PATCH 2/9] Decker EMP for meatspace --- components/TouchPad.ts | 32 ++++- docs/phase-3.5-plan.md | 9 +- src/game/archetypes/Decker.ts | 31 ++-- src/game/archetypes/index.ts | 31 +++- src/game/constants.ts | 17 ++- src/game/empBlast.ts | 71 +++++++++ src/game/events.ts | 2 + src/input/KeyboardController.ts | 29 +++- src/input/applyIntent.ts | 40 +++++- src/input/keymap.ts | 31 +++- src/input/touchpad.ts | 7 +- src/render/animations.ts | 15 ++ src/render/combatHud.ts | 9 +- src/render/frame.ts | 9 +- src/render/palette.ts | 8 ++ src/shell/combatHudSnapshot.ts | 3 + src/shell/domTypes.ts | 3 +- src/shell/sceneListeners.ts | 7 + src/shell/shellRuntime.ts | 17 +++ tests/unit/game/Decker.test.ts | 198 +++++--------------------- tests/unit/game/archetypes.test.ts | 27 +++- tests/unit/game/droneOverride.test.ts | 187 ++++++++++++++++++++++++ tests/unit/game/empBlast.test.ts | 133 +++++++++++++++++ tests/unit/input/applyIntent.test.ts | 38 +++++ tests/unit/input/keymap.test.ts | 16 ++- tests/unit/input/touchpad.test.ts | 9 +- tests/unit/render/animations.test.ts | 13 ++ tests/unit/render/combatHud.test.ts | 17 +++ tests/unit/render/frame.test.ts | 12 ++ 29 files changed, 812 insertions(+), 209 deletions(-) create mode 100644 src/game/empBlast.ts create mode 100644 tests/unit/game/droneOverride.test.ts create mode 100644 tests/unit/game/empBlast.test.ts diff --git a/components/TouchPad.ts b/components/TouchPad.ts index 45ae8ec..2d0d885 100644 --- a/components/TouchPad.ts +++ b/components/TouchPad.ts @@ -34,7 +34,7 @@ import { h } from '/src/domUtils.js'; import { AIM_KIND, MODE, aimKindLabel } from '/src/input/keymap.js'; import { dispatchTouchAction, TOUCHPAD_DIRECTIONS } from '/src/input/touchpad.js'; -import type { AimKind, Mode } from '/src/input/keymap.js'; +import type { AimKind, Mode, PerkAim } from '/src/input/keymap.js'; const FORCE_SHOW_PARAM = 'touch'; const FORCE_SHOW_VALUE = 'force'; @@ -308,6 +308,14 @@ class TouchPad extends HTMLElement { */ #isBlocked: IsBlockedPredicate = () => false; + /** + * Resolve the live archetype's perk-aim so the SPECIAL button fires a + * self-centered perk (Decker EMP, future self-buffs) immediately instead of + * entering aim mode β€” mirrors `KeyboardController.getSpecialAim`. Defaults to + * `'directional'` so tests and non-combat callers need not wire it. + */ + #getSpecialAim: () => PerkAim = () => 'directional'; + static get observedAttributes() { return ['force-show']; } @@ -399,6 +407,21 @@ class TouchPad extends HTMLElement { this.#isBlocked = predicate; } + /** + * Install (or replace) the perk-aim resolver. Pass `null` to reset to the + * default `'directional'`. Validated so a typo'd assignment crashes loudly. + */ + setSpecialAim(resolver: (() => PerkAim) | null): void { + if (resolver === null || resolver === undefined) { + this.#getSpecialAim = () => 'directional'; + return; + } + if (typeof resolver !== 'function') { + throw new TypeError('.setSpecialAim: expected a function or null'); + } + this.#getSpecialAim = resolver; + } + #shouldForceShow() { if (this.hasAttribute('force-show')) return true; try { @@ -566,7 +589,12 @@ class TouchPad extends HTMLElement { #dispatchButtonPress(buttonId: string) { const previousMode = this.#mode; const previousAimKind = this.#aimKind; - const { intent, nextMode, aimKind } = dispatchTouchAction(buttonId, this.#mode, this.#aimKind); + const { intent, nextMode, aimKind } = dispatchTouchAction( + buttonId, + this.#mode, + this.#aimKind, + this.#getSpecialAim() + ); if (nextMode !== previousMode || aimKind !== previousAimKind) { this.#mode = nextMode; diff --git a/docs/phase-3.5-plan.md b/docs/phase-3.5-plan.md index cabba4e..e79713f 100644 --- a/docs/phase-3.5-plan.md +++ b/docs/phase-3.5-plan.md @@ -37,7 +37,7 @@ M3 + M4 + M5 ──> M6 (stat-roll β†’ archetype derivation, needs all 6 profile | Milestone | Status | |---|---| | P3.5.M1 β€” Generic status-effect subsystem | βœ… Complete | -| P3.5.M2 β€” Decker perk swap: Override β†’ EMP AOE stun | πŸ”² Not started | +| P3.5.M2 β€” Decker perk swap: Override β†’ EMP AOE stun | βœ… Complete | | P3.5.M3 β€” Berserk archetype (surge/crash) | πŸ”² Not started | | P3.5.M4 β€” Adept archetype (Influence, renamed from Override) | πŸ”² Not started | | P3.5.M5 β€” Chimera archetype (scrap-to-HP sustain) | πŸ”² Not started | @@ -146,6 +146,13 @@ export const EMP_STUN_DURATION = 1; // one skipped activation per hosti **`archetypes/index.ts`:** update the Decker's `perkName`/`perkLabel` copy to describe EMP. +**Implementation notes (as-built):** +- **No self-stun (revised after review).** The blast now exempts the firing Decker (`entity === decker` skip). Self-stun read as a pure negative-play footgun. EMP costs 2 AP and stuns everyone *else* in radius; the caster is shielded from their own discharge. +- **EMP (Meatspace) + Override (Cyberspace) split β€” already true.** The CyberAvatar (the jacked-in Decker's form) always kept its own `canOverride`/`overrideDrone` for the cyber grid. So the Decker fires EMP in Meatspace and Override on the cyber grid with no extra code β€” `doSpecial` gained a `canEmp` branch and *kept* the `canOverride` branch (now reaching only the CyberAvatar); `OverrideActor` retyped to `CyberAvatar`. +- **Directionless perks (new infra).** `keymap.PerkAim` (`'directional' | 'self'`) threads through `dispatch` β†’ `KeyboardController.getSpecialAim` / `TouchPad.setSpecialAim`, resolved from the live archetype via `perkAimForArchetype` (new, in `archetypes/index.ts`) and `ARCHETYPES[*].perkAim`. A `'self'` perk fires the `special` intent immediately (no aim step); EMP is the first, and **Berserk / Chimera self-buffs will reuse it in M3/M5**. The CyberAvatar (Override) resolves to `'directional'`. +- **Stun visuals (new).** (a) Stunned entities render **electric cyan** (`STUNNED_FG`), overriding faction hue, in `frame.glyphForEntityCell`. (b) `detonateEmp` emits `EVENT.EMP_DETONATED`; `sceneListeners` fires `triggerEmpFlash` β€” a cyan full-screen discharge pulse (reuses the parametrized colored-vignette primitive). (c) `formatIdentityHud` appends `[STUNNED]` (parallels `[CLOAKED]`); it triggers when you flip to a partner caught in your own EMP. +- **Override module coverage relocated, not dropped.** The Override *module* tests (previously reachable only through `Decker.test.ts`) moved to a standalone `tests/unit/game/droneOverride.test.ts` exercising the pure functions against a generic PLAYER operator. M4 renames that file to `mindInfluence.test.ts`. + **Critical files:** `src/game/empBlast.ts` (new), `src/game/archetypes/Decker.ts`, `src/input/applyIntent.ts`, `src/game/constants.ts`. **Tests:** `tests/unit/game/empBlast.test.ts` (legality, radius geometry β€” mirror `breach.test.ts`'s `isInBlast` cases, mixed-faction stun, AP debited once, dead entities skipped), `tests/unit/game/Decker.test.ts` (Override assertions replaced with EMP assertions β€” Override's own test coverage moves wholesale to M4, not duplicated here), `tests/unit/input/applyIntent.test.ts` (`doSpecial` β†’ EMP for a Decker; a same-faction crew member caught in radius ends up at 0 AP next refresh). diff --git a/src/game/archetypes/Decker.ts b/src/game/archetypes/Decker.ts index 80dde4c..38aaf09 100644 --- a/src/game/archetypes/Decker.ts +++ b/src/game/archetypes/Decker.ts @@ -1,14 +1,12 @@ import { Crew } from '../Crew.js'; -import { canOverride, overrideDrone } from '../droneOverride.js'; +import { canEmp, detonateEmp } from '../empBlast.js'; import { DECKER_BASE_ICE_RESISTANCE, DECKER_BASE_INTRUSION, DECKER_BASE_RAM, } from '../constants.js'; import type { CrewInit, CrewSnapshot } from '../Crew.js'; -import type { Entity } from '../Entity.js'; import type { World } from '../World.js'; -import type { Rng } from '../../rng.js'; /** * Curated callsign pool for the Decker archetype. See `Merc.js` CALLSIGNS for @@ -35,10 +33,11 @@ export const CALLSIGNS = Object.freeze([ * (Merc 0.8, Tech 0.75, Razor 0.7). Their real edge is digital β€” and, on the * physical grid, the signature **Drone Override Hack**. * - * Phase-3 perk: **Override**. Reaches across a clean LOS lane to hijack a corp - * drone's allegiance for a few turns (`droneOverride.ts`). It is a *targeted* - * perk β€” the intent layer resolves a drone along the aim ray, then calls - * `overrideDrone`. + * Meatspace perk (P3.5.M2): **EMP**. A self-centered neural-shock blast that + * stuns everyone alive in radius β€” friend, foe, and the Decker themselves β€” for + * one activation (`empBlast.ts`). No aim ray, no target: the intent layer calls + * `detonateEmp` directly. (This replaced the old Drone Override Hack, which + * moved to the Adept archetype as "Influence" in M4.) * * Cyberspace attributes (P3.M3.3) are named stats with real effects: `ram` * is the avatar HP pool, `intrusionStrength` the slice progress per data-node @@ -105,20 +104,20 @@ export class Decker extends Crew { } /** - * Pre-flight legality check for overriding `target`. Returns `{ ok }` or + * Pre-flight legality check for detonating an EMP. Returns `{ ok }` or * `{ ok: false, reason }`, mirroring the other archetype perks. Delegates to - * the shared `droneOverride` module so the rules live in one place. + * the shared `empBlast` module so the rules live in one place. */ - canOverride(world: World, target: Entity | null) { - return canOverride(world, this, target); + canEmp() { + return canEmp(this); } /** - * Commit an override against `target`. Throws on illegal pre-conditions - * (no AP burned); on a legal attempt, debits AP and either flips the drone - * or trips the alarm depending on the roll. Returns the {@link OverrideResult}. + * Detonate the EMP. Throws on illegal pre-conditions (no AP burned); on a + * legal attempt, debits AP once and stuns every alive entity in radius + * (including this Decker). Returns the stunned entities. */ - overrideDrone(world: World, target: Entity, rng: Rng) { - return overrideDrone(world, this, target, rng); + detonateEmp(world: World) { + return detonateEmp(world, this); } } diff --git a/src/game/archetypes/index.ts b/src/game/archetypes/index.ts index 4a75600..b3609b9 100644 --- a/src/game/archetypes/index.ts +++ b/src/game/archetypes/index.ts @@ -64,6 +64,7 @@ export const ARCHETYPES = Object.freeze({ perks: Object.freeze(['vault']), perkName: 'BREAK', perkLabel: 'Mercs can BREAK: hop cover / knock enemies back', + perkAim: 'directional', }), razor: Object.freeze({ id: 'razor', @@ -72,6 +73,7 @@ export const ARCHETYPES = Object.freeze({ perks: Object.freeze(['slide']), perkName: 'SLIDE', perkLabel: 'Razors can SLIDE: dash 2 tiles and go silent for a turn', + perkAim: 'directional', }), tech: Object.freeze({ id: 'tech', @@ -80,19 +82,40 @@ export const ARCHETYPES = Object.freeze({ perks: Object.freeze(['deploy']), perkName: 'DEPLOY', perkLabel: 'Techs can DEPLOY: place a turret that will fire on enemies', + perkAim: 'directional', }), decker: Object.freeze({ id: 'decker', name: 'DECKER', - blurb: 'Console cowboy. Hijacks corp drones; jacks into Cyberspace.', - perks: Object.freeze(['override']), - perkName: 'OVERRIDE', - perkLabel: 'Deckers can OVERRIDE: hijack a corp drone to fight for you', + blurb: 'Console cowboy. Fries a room with an EMP; jacks into Cyberspace.', + perks: Object.freeze(['emp']), + perkName: 'EMP', + perkLabel: 'Deckers can EMP: stun everyone around you for a turn', + // Self-centered blast β€” the perk key fires it immediately, no aim step. + perkAim: 'self', }), }); export type ArchetypeInfo = (typeof ARCHETYPES)[keyof typeof ARCHETYPES]; +/** Per-archetype perk aim requirement β€” see `keymap.PerkAim`. */ +export type PerkAim = 'directional' | 'self'; + +/** + * Resolve how an archetype's `special` perk aims. Accepts either the lowercase + * registry id (`'decker'`) or the class-cased `Crew.archetype` (`'Decker'`). + * Throws on an unknown archetype β€” a crew member always has a registered + * archetype, so an unknown one is a wiring bug, not a value to paper over. + */ +export function perkAimForArchetype(archetype: string): PerkAim { + const key = archetype.toLowerCase(); + const info = ARCHETYPES[key as keyof typeof ARCHETYPES]; + if (!info) { + throw new Error(`perkAimForArchetype: unknown archetype "${archetype}"`); + } + return info.perkAim as PerkAim; +} + const BUILDERS = Object.freeze({ merc: Merc, razor: Razor, diff --git a/src/game/constants.ts b/src/game/constants.ts index 3dae066..ffb69f2 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -106,7 +106,8 @@ export const AP_COST = Object.freeze({ VAULT: 2, // Merc β€” hop a cover tile while firing SLIDE: 2, // Razor β€” 2-tile reposition with stealth bonus DEPLOY: 2, // Tech β€” place a turret on an adjacent tile - OVERRIDE: 2, // Decker β€” hijack a corp drone's allegiance + OVERRIDE: 2, // CyberAvatar β€” flip ICE allegiance on the cyber grid (M4 renames -> INFLUENCE for the Adept) + EMP: 2, // Decker β€” self-centered AOE neural-shock stun (P3.5.M2) }); /** @@ -367,6 +368,20 @@ export const SALVAGE_PER_IMPROVISED_TURRET = 2; export const STIM_HEAL = 2; export const SMOKE_RADIUS = 2; export const SMOKE_DURATION_TURNS = 1; + +/** + * Decker EMP blast parameters (P3.5.M2). The Decker's Meatspace signature: a + * self-centered neural-shock/EMP that stuns *everyone* alive in radius β€” friend + * and foe alike, excepting only the Decker themselves. (matches the deliberately + * blurred organic/mechanical enemy theming). A stunned entity takes 0 AP on its + * next refresh (see `STATUS_EFFECT.STUN`). + * + * - `EMP_RADIUS` β€” Chebyshev reach, matched to {@link SMOKE_RADIUS} (2): a + * "clears a room" footprint. + * - `EMP_STUN_DURATION` β€” one skipped activation per caught entity. + */ +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 * diff --git a/src/game/empBlast.ts b/src/game/empBlast.ts new file mode 100644 index 0000000..b6ccdb7 --- /dev/null +++ b/src/game/empBlast.ts @@ -0,0 +1,71 @@ +/** + * EMP Neural-Shock (P3.5.M2) β€” the Decker's signature Meatspace ability, + * replacing the old Drone Override Hack (which moves to the Adept as + * "Influence" in M4). + * + * A self-centered blast: no aim ray, no target picker. It stuns everyone else + * alive within {@link EMP_RADIUS} β€” friend and foe alike, no faction filter and + * no organic/mechanical branching (a uniform stun, matching the game's + * deliberately blurred enemy theming) β€” but **not the Decker who fired it** + * (the caster is shielded from their own discharge; self-stun read as a pure + * negative-play footgun). A stunned entity takes 0 AP on its next refresh (see + * `STATUS_EFFECT.STUN` / `Entity.refreshAp`). + * + * Pure verbs, mirroring `breachBlast.ts` (blast hits everyone, no faction + * check) + `Smoke.ts` (self-centered radius loop). The archetype class stays a + * thin delegator; the intent layer a thin caller. + */ + +import { AP_COST, EMP_RADIUS, EMP_STUN_DURATION, STATUS_EFFECT } from './constants.js'; +import { chebyshev } from './Pathfinding.js'; +import { EVENT } from './events.js'; +import type { Entity } from './Entity.js'; +import type { World } from './World.js'; + +/** Pre-flight legality verdict, mirroring the other archetype perks. */ +export type EmpCheck = { ok: true } | { ok: false; reason: 'dead' | 'insufficient-ap' }; + +/** + * Pure legality check for detonating an EMP. Never mutates. A self-centered + * blast has no target, so the only gates are "the Decker is alive" and "can + * afford the AP". + */ +export function canEmp(decker: Entity): EmpCheck { + if (!decker.alive) return { ok: false, reason: 'dead' }; + if (!decker.canAfford(AP_COST.EMP)) return { ok: false, reason: 'insufficient-ap' }; + return { ok: true }; +} + +/** Whether `(x, y)` is within the EMP blast centered on `(cx, cy)`. */ +export function isInEmpBlast(cx: number, cy: number, x: number, y: number): boolean { + return chebyshev(cx, cy, x, y) <= EMP_RADIUS; +} + +/** + * Detonate the EMP. Throws on an illegal attempt *before* mutating any state + * (no AP burned). On a legal attempt: debits `AP_COST.EMP` exactly once, then + * stuns every *other* alive entity in radius (the firing Decker is exempt). + * Returns the entities that were stunned. + */ +export function detonateEmp(world: World, decker: Entity): { stunned: Entity[] } { + const check = canEmp(decker); + if (!check.ok) { + throw new Error(`Illegal EMP for ${decker.id}: ${check.reason}`); + } + decker.spendAp(AP_COST.EMP); + const stunned: Entity[] = []; + for (const entity of world.entities.values()) { + if (entity === decker) continue; // the caster is shielded from their own discharge + if (!entity.alive) continue; + if (!isInEmpBlast(decker.x, decker.y, entity.x, entity.y)) continue; + entity.applyEffect(STATUS_EFFECT.STUN, EMP_STUN_DURATION); + stunned.push(entity); + } + // Presentation hook (P3.5.M2): the shell listens for this to paint the cyan + // discharge flash. Gameplay is already resolved above β€” this carries no state. + world.events?.emit(EVENT.EMP_DETONATED, { + origin: { x: decker.x, y: decker.y }, + stunned: stunned.length, + }); + return { stunned }; +} diff --git a/src/game/events.ts b/src/game/events.ts index f503d84..2f83d50 100644 --- a/src/game/events.ts +++ b/src/game/events.ts @@ -39,6 +39,8 @@ export const EVENT = Object.freeze({ JACK_IN: 'cyber:jack-in', /** P3.M3: the Decker left the grid (voluntary or forced). */ JACK_OUT: 'cyber:jack-out', + /** P3.5.M2: a Decker detonated an EMP. Payload `{ origin: {x,y}, stunned }`. */ + EMP_DETONATED: 'emp:detonated', }); const KNOWN_TYPES = new Set(Object.values(EVENT)); diff --git a/src/input/KeyboardController.ts b/src/input/KeyboardController.ts index 11aba42..9caa0ac 100644 --- a/src/input/KeyboardController.ts +++ b/src/input/KeyboardController.ts @@ -1,6 +1,6 @@ import { dispatch, MODE } from './keymap.js'; import type { Intent } from './applyIntent.js'; -import type { AimKind, Mode } from './keymap.js'; +import type { AimKind, Mode, PerkAim } from './keymap.js'; /** * DOM-side input wrapper. Listens for keydown on a target element (defaults @@ -24,28 +24,46 @@ type KeyboardControllerInit = { onIntent: (intent: Intent) => void; onModeChange: (mode: Mode) => void; isBlocked?: () => boolean; + /** + * Resolve the *current* archetype's perk-aim so the `x` key fires a + * self-centered perk (EMP, self-buffs) immediately instead of entering aim + * mode. Called per keypress β€” the active archetype can change (simstim flip, + * partner swap). Defaults to `'directional'` (the historical behavior). + */ + getSpecialAim?: () => PerkAim; }; export class KeyboardController { target: Document; onIntent: (intent: Intent) => void; onModeChange: (mode: Mode) => void; isBlocked: () => boolean; + getSpecialAim: () => PerkAim; mode: Mode; /** Active when `mode === MODE.AIM`; selects fire / special / use-item. */ aimKind: AimKind | null; attached: boolean; - constructor({ target = document, onIntent, onModeChange, isBlocked }: KeyboardControllerInit) { + constructor({ + target = document, + onIntent, + onModeChange, + isBlocked, + getSpecialAim, + }: KeyboardControllerInit) { if (typeof onIntent !== 'function') { throw new TypeError('KeyboardController requires an onIntent callback'); } if (isBlocked !== undefined && typeof isBlocked !== 'function') { throw new TypeError('KeyboardController: isBlocked must be a function when supplied'); } + if (getSpecialAim !== undefined && typeof getSpecialAim !== 'function') { + throw new TypeError('KeyboardController: getSpecialAim must be a function when supplied'); + } this.target = target; this.onIntent = onIntent; this.onModeChange = onModeChange ?? (() => {}); this.isBlocked = isBlocked ?? (() => false); + this.getSpecialAim = getSpecialAim ?? (() => 'directional'); this.mode = MODE.IDLE; this.aimKind = null; this.attached = false; @@ -71,7 +89,12 @@ export class KeyboardController { if (this.isBlocked()) return; const previousMode = this.mode; const previousAimKind = this.aimKind; - const { intent, nextMode, aimKind } = dispatch(evt.key, this.mode, this.aimKind); + const { intent, nextMode, aimKind } = dispatch( + evt.key, + this.mode, + this.aimKind, + this.getSpecialAim() + ); if (intent || nextMode !== previousMode || aimKind !== previousAimKind) evt.preventDefault(); if (nextMode !== previousMode || aimKind !== previousAimKind) { this.mode = nextMode; diff --git a/src/input/applyIntent.ts b/src/input/applyIntent.ts index cd56e0e..e6922d8 100644 --- a/src/input/applyIntent.ts +++ b/src/input/applyIntent.ts @@ -382,9 +382,11 @@ function collectTileLoot(ctx: ApplyIntentContext) { /** * Archetype dispatcher for the unified `special` intent. Picks the perk verb * by capability check on the live player: - * - `canDeploy` β†’ Tech's Deploy Turret - * - `canVault` β†’ Merc's Vault - * - `canSlide` β†’ Razor's Slide + * - `canDeploy` β†’ Tech's Deploy Turret + * - `canVault` β†’ Merc's Vault + * - `canSlide` β†’ Razor's Slide + * - `canEmp` β†’ Decker's EMP neural-shock (self-centered AOE stun) + * - `canOverride` β†’ CyberAvatar's ICE override (cyber grid only) * * Capability sniffing (vs. a class `instanceof` check) keeps this module free * of the archetype-class imports β€” applyIntent stays a thin glue layer. A @@ -411,20 +413,46 @@ function doSpecial(intent: Intent, ctx: ApplyIntentContext) { if (typeof (player as Razor).canSlide === 'function') { return doSlide(intent, ctx); } - if (typeof (player as Decker).canOverride === 'function') { + if (typeof (player as Decker).canEmp === 'function') { + return doEmp(ctx); + } + // Only the CyberAvatar still exposes canOverride (P3.5.M2 moved the Decker's + // Meatspace perk to EMP; M4 repoints this picker at the Adept's Influence). + if (typeof (player as CyberAvatar).canOverride === 'function') { return doOverride(intent, ctx); } log('> SPECIAL: this archetype has no perk action.'); } +/** + * Detonate the Decker's self-centered EMP: no aim, stuns everyone alive in + * radius (friend, foe, and the Decker). Reports the stun count for legibility. + */ +function doEmp(ctx: ApplyIntentContext) { + const { world, player, log } = ctx; + const decker = player as Decker; + const playerLabel = entityLabel(player); + const check = decker.canEmp(); + if (!check.ok) { + log(`> ${playerLabel} EMP DENIED: ${check.reason}`); + return; + } + const { stunned } = decker.detonateEmp(world); + log( + `> ${playerLabel} detonates an EMP β€” ${stunned.length} caught in the blast ` + + `(${player.ap} AP left).` + ); + gateOnApExhausted(ctx); +} + /** * Acquire a drone in the Decker's aimed eight-way sector and attempt the * hijack. Range, LOS, and perception match the resolver and combat targeting; * a failed roll trips the alarm, while an empty sector yields a legible deny. */ type OverrideActor = ApplyIntentContext['player'] & { - canOverride(world: World, target: Entity | null): ReturnType; - overrideDrone(world: World, target: Entity, rng: Rng): ReturnType; + canOverride(world: World, target: Entity | null): ReturnType; + overrideDrone(world: World, target: Entity, rng: Rng): ReturnType; }; function doOverride(intent: Intent, ctx: ApplyIntentContext) { diff --git a/src/input/keymap.ts b/src/input/keymap.ts index 9365acd..33cf469 100644 --- a/src/input/keymap.ts +++ b/src/input/keymap.ts @@ -56,6 +56,17 @@ export const AIM_KIND = Object.freeze({ export type AimKind = (typeof AIM_KIND)[keyof typeof AIM_KIND]; +/** + * How the active archetype's `special` perk resolves its target: + * - `'directional'` β€” needs a direction; the perk key enters `MODE.AIM` + * (Merc Vault, Razor Slide, Tech Deploy, CyberAvatar Override). + * - `'self'` β€” self-centered, no direction; the perk key fires immediately + * (Decker EMP, and future Berserk / Chimera self-buffs). + * Supplied per-press by the input owner (which knows the live archetype); the + * keymap itself stays archetype-agnostic and just honours the flag. + */ +export type PerkAim = 'directional' | 'self'; + export type DispatchResult = { intent: Intent | null; nextMode: Mode; @@ -90,7 +101,7 @@ const stayAim = (aimKind: AimKind): DispatchResult => ({ const stayLook = (): DispatchResult => ({ intent: null, nextMode: MODE.LOOK, aimKind: null }); -function dispatchIdle(key: string): DispatchResult { +function dispatchIdle(key: string, specialAim: PerkAim): DispatchResult { const dir = directionFor(key); if (dir) { return { @@ -133,8 +144,13 @@ function dispatchIdle(key: string): DispatchResult { return stayAim(AIM_KIND.FIRE); case 'x': // Unified archetype perk. The intent layer dispatches by class β€” - // Merc β†’ vault, Razor β†’ slide, Tech β†’ deploy. `x` is unused elsewhere - // and avoids the WASD collision that would block `d` for deploy. + // Merc β†’ vault, Razor β†’ slide, Tech β†’ deploy, Decker β†’ EMP. `x` is + // unused elsewhere and avoids the WASD collision that would block `d` + // for deploy. A `'self'` perk (EMP, and future self-buffs) needs no + // direction, so it fires immediately instead of entering aim mode. + if (specialAim === 'self') { + return { intent: { type: 'special', dx: 0, dy: 0 }, nextMode: MODE.IDLE, aimKind: null }; + } return stayAim(AIM_KIND.SPECIAL); default: return idle(); @@ -194,10 +210,15 @@ export function aimKindLabel(aimKind: AimKind): string { } } -export function dispatch(key: string, mode: Mode, aimKind: AimKind | null = null): DispatchResult { +export function dispatch( + key: string, + mode: Mode, + aimKind: AimKind | null = null, + specialAim: PerkAim = 'directional' +): DispatchResult { switch (mode) { case MODE.IDLE: - return dispatchIdle(key); + return dispatchIdle(key, specialAim); case MODE.AIM: if (!aimKind) { throw new Error('keymap: MODE.AIM requires an aimKind'); diff --git a/src/input/touchpad.ts b/src/input/touchpad.ts index 2465c30..c9893b6 100644 --- a/src/input/touchpad.ts +++ b/src/input/touchpad.ts @@ -20,7 +20,7 @@ */ import { dispatch } from './keymap.js'; -import type { AimKind, DispatchResult, Mode } from './keymap.js'; +import type { AimKind, DispatchResult, Mode, PerkAim } from './keymap.js'; const DIRECTION_KEYS = Object.freeze({ N: 'ArrowUp', @@ -77,7 +77,8 @@ export function syntheticKeyFor(buttonId: string): string { export function dispatchTouchAction( buttonId: string, mode: Mode, - aimKind: AimKind | null = null + aimKind: AimKind | null = null, + specialAim: PerkAim = 'directional' ): DispatchResult { - return dispatch(syntheticKeyFor(buttonId), mode, aimKind); + return dispatch(syntheticKeyFor(buttonId), mode, aimKind, specialAim); } diff --git a/src/render/animations.ts b/src/render/animations.ts index ea079ec..0c2b7ab 100644 --- a/src/render/animations.ts +++ b/src/render/animations.ts @@ -31,11 +31,14 @@ import type { AsciiRenderer } from './AsciiRenderer.js'; import { COMBAT_HUD_COLORS } from './combatHud.js'; +import { STUNNED_FG } from './palette.js'; export const ANIMATION_DURATIONS = Object.freeze({ SHAKE: 150, DAMAGE_FLASH: 300, MITIGATION_FLASH: 300, + /** Cyan discharge pulse when a Decker detonates an EMP (P3.5.M2). */ + EMP_FLASH: 220, // Original plan suggested "~80ms" but at 60fps that's ~5 frames β€” perceptually // borderline, especially with the shooter's own glyph sitting underneath. // 120ms (~7 frames) is still snappy and reads clearly as a burst. @@ -96,6 +99,18 @@ export function triggerDamageFlash(stageEl: HTMLElement, timers = defaultTimers) return restartCssAnimation(stageEl, DAMAGE_CLASS, ANIMATION_DURATIONS.DAMAGE_FLASH, timers); } +/** + * Cyan full-screen discharge pulse for a Decker EMP (P3.5.M2). Reuses the + * parametrized colored-vignette primitive (the same class + color property the + * mitigation flash drives) tinted electric cyan β€” the same hue a stunned glyph + * takes, so the blast and its aftermath read as one effect. + */ +export function triggerEmpFlash(stageEl: HTMLElement, timers = defaultTimers) { + stageEl.classList.remove(DAMAGE_CLASS); + stageEl.style.setProperty(IMPACT_FLASH_COLOR_PROPERTY, `${STUNNED_FG}8c`); + return restartCssAnimation(stageEl, MITIGATION_FLASH_CLASS, ANIMATION_DURATIONS.EMP_FLASH, timers); +} + export type MitigationFlashKind = 'armor' | 'shield'; /** diff --git a/src/render/combatHud.ts b/src/render/combatHud.ts index fec33d6..0fc2813 100644 --- a/src/render/combatHud.ts +++ b/src/render/combatHud.ts @@ -29,6 +29,8 @@ export type CombatHudIdentityInput = Readonly<{ callsign?: string | null; archetype: string; stealthed: boolean; + /** P3.5.M2: the controlled actor is EMP-stunned (0 AP next refresh). */ + stunned?: boolean; }>; export type CombatHudVitalInput = Readonly<{ @@ -130,8 +132,10 @@ export function fitObjectiveHudLine( export function formatIdentityHud(identity: CombatHudIdentityInput): string { const archetype = requireNonEmptyString(identity.archetype, 'identity.archetype').toUpperCase(); const callsign = identity.callsign?.trim(); - const base = callsign ? `${callsign} [${archetype}]` : archetype; - return identity.stealthed ? `${base} [CLOAKED]` : base; + let label = callsign ? `${callsign} [${archetype}]` : archetype; + if (identity.stealthed) label += ' [CLOAKED]'; + if (identity.stunned) label += ' [STUNNED]'; + return label; } export function formatHpSegments(vitals: CombatHudVitalInput): string { @@ -210,6 +214,7 @@ export function formatCombatHudA11ySummary(summary: CombatHudSummaryInput): stri if (defenseText) parts.push(...defenseText); parts.push(`${summary.ap.ap} of ${summary.ap.maxAp} AP`, turnA11yText(summary.turn)); if (summary.identity.stealthed) parts.push('cloaked'); + if (summary.identity.stunned) parts.push('stunned'); const objectiveText = objectiveA11yText(summary.objective); if (objectiveText) parts.push(objectiveText); return parts.join(', '); diff --git a/src/render/frame.ts b/src/render/frame.ts index dc8224e..4bc87e5 100644 --- a/src/render/frame.ts +++ b/src/render/frame.ts @@ -1,4 +1,4 @@ -import { TILE } from '../game/constants.js'; +import { TILE, STATUS_EFFECT } from '../game/constants.js'; import { glyphForTile, glyphForEntity, @@ -10,6 +10,7 @@ import { CORPSE_GLYPH_CHAR, MEMORY_DIM, INTERACTABLE_SECURED_FG, + STUNNED_FG, } from './palette.js'; import { Interactable } from '../game/entities/Interactable.js'; import type { World } from '../game/World.js'; @@ -233,6 +234,12 @@ function glyphForEntityCell(entity: Entity): Glyph { if (entity instanceof Interactable && entity.secured) { return { char: entity.glyph, fg: INTERACTABLE_SECURED_FG }; } + // A stunned (EMP'd) entity glows electric cyan, overriding its faction hue β€” + // it reads as "short-circuited, skipping its turn". Wins over faction colour + // but not over the corpse/secured states resolved above. + if (entity.hasEffect(STATUS_EFFECT.STUN)) { + return { char: entity.glyph, fg: STUNNED_FG }; + } return glyphForEntity(entity); } diff --git a/src/render/palette.ts b/src/render/palette.ts index df30b34..7d9febc 100644 --- a/src/render/palette.ts +++ b/src/render/palette.ts @@ -40,6 +40,14 @@ const FACTION_FG = { /** NEUTRAL interactables after the player has triggered them (slice, sync, handoff). */ export const INTERACTABLE_SECURED_FG = FACTION_FG[FACTION.PLAYER]; +/** + * Electric cyan for a stunned (EMP'd) entity glyph (P3.5.M2). Overrides the + * faction hue so a shocked hostile reads as "short-circuited, skipping its + * turn" at a glance. Shared with the EMP screen-flash tint so the discharge and + * its aftermath are the same colour. + */ +export const STUNNED_FG = '#8be9ff'; + /** * 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/combatHudSnapshot.ts b/src/shell/combatHudSnapshot.ts index 1754ef9..ab5d4dd 100644 --- a/src/shell/combatHudSnapshot.ts +++ b/src/shell/combatHudSnapshot.ts @@ -1,4 +1,5 @@ import { RUN_STATE } from '../game/Run.js'; +import { STATUS_EFFECT } from '../game/constants.js'; import type { Run } from '../game/Run.js'; import type { CombatHudDefenseInput, CombatHudSummaryInput } from '../render/combatHud.js'; import { CyberAvatar } from '../game/cyber/CyberAvatar.js'; @@ -49,6 +50,7 @@ export function combatHudBodyPanes( callsign: actor.callsign, archetype: 'Avatar', stealthed: actor.stealthed, + stunned: actor.hasEffect(STATUS_EFFECT.STUN), }, hp: { hp: actor.hp, maxHp: actor.maxHp, label: 'RAM' }, ap: { ap: actor.ap, maxAp: actor.maxAp }, @@ -62,6 +64,7 @@ export function combatHudBodyPanes( // The active meat crew may be the partner β€” show its own archetype. archetype: crew === scene.player ? scene.archetype : crew.archetype, stealthed: crew.stealthed, + stunned: crew.hasEffect(STATUS_EFFECT.STUN), }, hp: { hp: crew.hp, maxHp: crew.maxHp }, ...(defense ? { defense } : {}), diff --git a/src/shell/domTypes.ts b/src/shell/domTypes.ts index affd65d..e17e771 100644 --- a/src/shell/domTypes.ts +++ b/src/shell/domTypes.ts @@ -6,7 +6,7 @@ import type { Item } from '../game/items.js'; import type { CampaignSummary } from '../game/campaignSummary.js'; import type { TypedSalvage } from '../game/salvage.js'; import type { KeyItem, Telemetry } from '../types.js'; -import type { AimKind, Mode } from '../input/keymap.js'; +import type { AimKind, Mode, PerkAim } from '../input/keymap.js'; import type { UpdateAvailableDetail, UpdateRestartRequiredDetail, @@ -124,6 +124,7 @@ export type TouchPadElement = HTMLElement & { aimKind: AimKind | null; setMode(mode: Mode, aimKind?: AimKind | null): void; setBlocked(predicate: (() => boolean) | null): void; + setSpecialAim(resolver: (() => PerkAim) | null): void; setFlipAvailable(available: boolean): void; }; diff --git a/src/shell/sceneListeners.ts b/src/shell/sceneListeners.ts index c0e49d8..3360064 100644 --- a/src/shell/sceneListeners.ts +++ b/src/shell/sceneListeners.ts @@ -5,6 +5,7 @@ import { ANIMATION_DURATIONS, runMuzzleFlash, triggerDamageFlash, + triggerEmpFlash, triggerMitigationFlash, triggerShake, } from '../render/animations.js'; @@ -148,6 +149,12 @@ export class SceneListenerController { run.bus.on(EVENT.DOOR_UNLOCKED, payload => { const { label = 'Door' } = (payload ?? {}) as DoorUnlockPayload; effects.flash(`${label} unlocked β€” passage open.`); + }), + run.bus.on(EVENT.EMP_DETONATED, () => { + // Cyan discharge pulse on the shared stage. EMP is a Meatspace-only + // perk; the flash reads whether or not the meat view is in the PIP. + triggerEmpFlash(dom.stageEl); + animLock.push(ANIMATION_DURATIONS.EMP_FLASH); }) ); } diff --git a/src/shell/shellRuntime.ts b/src/shell/shellRuntime.ts index 483362b..a0c7a60 100644 --- a/src/shell/shellRuntime.ts +++ b/src/shell/shellRuntime.ts @@ -97,6 +97,7 @@ import { pickActiveVisionField, } from '/src/shell/activeView.js'; import { buildCombatHudSnapshot } from '/src/shell/combatHudSnapshot.js'; +import { perkAimForArchetype, type PerkAim } from '/src/game/archetypes/index.js'; import { buildHubHudRows, currentLocationLabel } from '/src/shell/locationHud.js'; import { SceneListenerController } from '/src/shell/sceneListeners.js'; import { isRun, resolveSceneView, type ShellScene } from '/src/shell/sceneView.js'; @@ -423,6 +424,7 @@ export async function boot() { paint(); }, isBlocked: () => animLock.isLocked() || isAnyBlockingModalOpen(), + getSpecialAim: currentSpecialAim, }); keyboard.attach(); @@ -505,6 +507,7 @@ export async function boot() { paint(); }); touchPadEl.setBlocked(() => animLock.isLocked() || isAnyBlockingModalOpen()); + touchPadEl.setSpecialAim(currentSpecialAim); logHeaderEl.addEventListener('click', () => { logEl.classList.toggle('collapsed'); @@ -1332,6 +1335,20 @@ function currentScene(): ShellScene | null { return campaign.activeRun ?? campaign; } +/** + * Resolve the live archetype's perk-aim for the keymap so `x` fires a + * self-centered perk (Decker EMP, future self-buffs) immediately. The active + * actor changes with simstim flips and partner swaps, so this reads it fresh + * each press. Only a Crew carries an `archetype` string; the CyberAvatar (its + * Override perk is aimed) and any non-combat scene fall back to `'directional'`. + */ +function currentSpecialAim(): PerkAim { + const scene = currentScene(); + if (!scene || !isRun(scene)) return 'directional'; + const archetype = (activeActorOf(scene) as { archetype?: unknown } | null)?.archetype; + return typeof archetype === 'string' ? perkAimForArchetype(archetype) : 'directional'; +} + /** * Wire the confirmation callbacks a Run raises mid-combat. Callbacks do not * persist, so this runs at deploy AND on campaign resume β€” a restored run diff --git a/tests/unit/game/Decker.test.ts b/tests/unit/game/Decker.test.ts index 1ab565d..d7072c8 100644 --- a/tests/unit/game/Decker.test.ts +++ b/tests/unit/game/Decker.test.ts @@ -1,13 +1,11 @@ /** - * Decker archetype tests (P3.M2) β€” Drone Override Hack legality matrix, - * commit semantics, and the per-turn stepping/revert loop. + * Decker archetype tests (P3.5.M2). * - * The perk mirrors the Tech/Razor/Merc contract: `canOverride` is a pure - * legality check returning `{ ok, reason }`; `overrideDrone` commits the spend - * (AP + faction flip, or AP + alarm on a failed roll) and throws β€” without - * mutating state β€” on any illegal precondition. The override then plays out - * through `stepOverriddenDrones`, which fights the drone on the player's side - * and reverts it when the countdown lapses. + * The Decker's Meatspace signature is now **EMP** β€” a self-centered AOE stun. + * The old Drone Override Hack moved off the Decker in M2 (its module coverage + * lives in `droneOverride.test.ts`, and it becomes the Adept's Influence perk + * in M4). Here we cover the class basics and the thin `canEmp`/`detonateEmp` + * delegators to `empBlast.ts`. */ import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -17,12 +15,9 @@ import { Crew } from '../../../src/game/Crew.js'; import { Grid } from '../../../src/game/Grid.js'; import { World } from '../../../src/game/World.js'; import { Entity } from '../../../src/game/Entity.js'; -import { Skirmisher } from '../../../src/game/ai/Skirmisher.js'; -import { applyOverride, stepOverriddenDrones } from '../../../src/game/droneOverride.js'; -import { TILE, FACTION, AP_COST, OVERRIDE_DURATION } from '../../../src/game/constants.js'; -import { Rng } from '../../../src/rng.js'; +import { FACTION, AP_COST, EMP_RADIUS, STATUS_EFFECT } from '../../../src/game/constants.js'; -function makeWorld({ deckerAt = [1, 1], grid, extraEntities = [] } = {}) { +function makeWorld({ deckerAt = [5, 5], grid, extraEntities = [] } = {}) { const g = grid ?? new Grid(12, 12); const w = new World(g); const decker = new Decker({ id: 'decker', x: deckerAt[0], y: deckerAt[1] }); @@ -31,14 +26,7 @@ function makeWorld({ deckerAt = [1, 1], grid, extraEntities = [] } = {}) { return { world: w, decker }; } -function makeDrone(id, x, y, faction = FACTION.CORP) { - return new Skirmisher({ id, x, y, faction }); -} - -// Deterministic single-roll stubs for the commit step (overrideDrone only -// consumes one rng.next()). 0.1 < success chance β†’ hijack; 0.9 β†’ failure. -const winRng = { next: () => 0.1 }; -const loseRng = { next: () => 0.9 }; +const corp = (id, x, y) => new Entity({ id, x, y, faction: FACTION.CORP, glyph: 'd' }); // --- class basics ---------------------------------------------------------- @@ -51,152 +39,44 @@ test('Decker extends Crew and is a PLAYER-faction operator with the @ glyph', () assert.equal(d.baseHitChance, 0.7); }); -// --- canOverride legality matrix ------------------------------------------- - -test('canOverride accepts a live in-range LOS corp drone', () => { - const drone = makeDrone('k', 3, 1); - const { world, decker } = makeWorld({ extraEntities: [drone] }); - assert.equal(decker.canOverride(world, drone).ok, true); -}); - -test('canOverride rejects a null / non-Hostile target as not-overridable', () => { - const prop = new Entity({ id: 'prop', x: 2, y: 1, faction: FACTION.CORP, glyph: '#' }); - const { world, decker } = makeWorld({ extraEntities: [prop] }); - assert.equal(decker.canOverride(world, null).reason, 'not-overridable'); - assert.equal(decker.canOverride(world, prop).reason, 'not-overridable'); -}); - -test('canOverride rejects when the Decker is dead', () => { - const drone = makeDrone('k', 3, 1); - const { world, decker } = makeWorld({ extraEntities: [drone] }); - decker.alive = false; - assert.equal(decker.canOverride(world, drone).reason, 'dead'); -}); - -test('canOverride rejects when AP < OVERRIDE cost', () => { - const drone = makeDrone('k', 3, 1); - const { world, decker } = makeWorld({ extraEntities: [drone] }); - decker.spendAp(decker.ap - (AP_COST.OVERRIDE - 1)); - assert.equal(decker.canOverride(world, drone).reason, 'insufficient-ap'); -}); - -test('canOverride rejects a dead target drone', () => { - const drone = makeDrone('k', 3, 1); - const { world, decker } = makeWorld({ extraEntities: [drone] }); - drone.alive = false; - assert.equal(decker.canOverride(world, drone).reason, 'dead-target'); -}); - -test('canOverride rejects a drone that is already overridden', () => { - const drone = makeDrone('k', 3, 1); - const { world, decker } = makeWorld({ extraEntities: [drone] }); - applyOverride(drone, FACTION.PLAYER); - assert.equal(decker.canOverride(world, drone).reason, 'already-overridden'); -}); - -test('canOverride rejects a same-faction (already player-aligned) drone', () => { - const drone = makeDrone('k', 3, 1, FACTION.PLAYER); - const { world, decker } = makeWorld({ extraEntities: [drone] }); - assert.equal(decker.canOverride(world, drone).reason, 'friendly'); -}); - -test('canOverride rejects an out-of-range drone', () => { - const drone = makeDrone('k', 9, 1); // distance 8 > OVERRIDE_RANGE (5) - const { world, decker } = makeWorld({ extraEntities: [drone] }); - assert.equal(decker.canOverride(world, drone).reason, 'out-of-range'); -}); - -test('canOverride rejects a drone behind a wall (no LOS)', () => { - const g = new Grid(12, 12); - g.setTile(2, 1, TILE.WALL); - const drone = makeDrone('k', 3, 1); - const { world, decker } = makeWorld({ grid: g, extraEntities: [drone] }); - assert.equal(decker.canOverride(world, drone).reason, 'no-los'); +test('Decker retains its cyber-deck capability flag and cyber stats', () => { + const d = new Decker({ id: 'd', x: 0, y: 0 }); + assert.equal(d.canJackIn, true); + assert.ok(d.ram > 0); + assert.ok(d.intrusionStrength > 0); }); -// --- overrideDrone commit semantics ---------------------------------------- +// --- EMP delegators -------------------------------------------------------- -test('overrideDrone success flips the drone to PLAYER for the full duration', () => { - const drone = makeDrone('k', 3, 1); - const { world, decker } = makeWorld({ extraEntities: [drone] }); - const apBefore = decker.ap; - const result = decker.overrideDrone(world, drone, winRng); - assert.equal(result.success, true); - assert.equal(result.alarm, false); - assert.equal(drone.faction, FACTION.PLAYER); - assert.equal(drone.factionBeforeOverride, FACTION.CORP); - assert.equal(drone.overrideTurnsRemaining, OVERRIDE_DURATION); - assert.equal(drone.isOverridden, true); - assert.equal(decker.ap, apBefore - AP_COST.OVERRIDE); +test('Decker.canEmp accepts a live Decker with enough AP', () => { + const { decker } = makeWorld(); + assert.equal(decker.canEmp().ok, true); }); -test('overrideDrone failure burns AP, trips the alarm, leaves faction intact', () => { - const drone = makeDrone('k', 3, 1); - const { world, decker } = makeWorld({ extraEntities: [drone] }); - const apBefore = decker.ap; - assert.equal(world.alarmActive, false); - const result = decker.overrideDrone(world, drone, loseRng); - assert.equal(result.success, false); - assert.equal(result.alarm, true); - assert.equal(world.alarmActive, true); - assert.equal(drone.faction, FACTION.CORP); - assert.equal(drone.isOverridden, false); - assert.equal(decker.ap, apBefore - AP_COST.OVERRIDE); +test('Decker.canEmp rejects when AP < EMP cost', () => { + const { decker } = makeWorld(); + decker.spendAp(decker.ap - (AP_COST.EMP - 1)); + assert.equal(decker.canEmp().reason, 'insufficient-ap'); }); -test('overrideDrone throws on an illegal attempt without burning AP', () => { - const drone = makeDrone('k', 9, 1); // out of range - const { world, decker } = makeWorld({ extraEntities: [drone] }); +test('Decker.detonateEmp stuns in-radius foes but not itself; debits AP once', () => { + const foe = corp('foe', 5 + EMP_RADIUS, 5); // edge of radius + const far = corp('far', 5 + EMP_RADIUS + 1, 5); // just outside + const { world, decker } = makeWorld({ extraEntities: [foe, far] }); const apBefore = decker.ap; - assert.throws(() => decker.overrideDrone(world, drone, winRng), /Illegal override/); - assert.equal(decker.ap, apBefore, 'AP not debited on illegal override'); - assert.equal(drone.faction, FACTION.CORP); -}); - -// --- golden path: hijacked drone fights corp, then reverts ----------------- - -test('an overridden drone attacks corp allies for N turns then reverts', () => { - // Open arena: decker, hijacked drone, and a corp victim in a clean LOS line. - const drone = makeDrone('hijacked', 4, 4); - const victim = makeDrone('victim', 6, 4); // corp; the drone's new enemy - const { world, decker } = makeWorld({ deckerAt: [2, 4], extraEntities: [drone, victim] }); - - const result = decker.overrideDrone(world, drone, winRng); - assert.equal(result.success, true); - - const rng = new Rng(7); - const victimHpStart = victim.hp; - let sawEngageAction = false; - - // Drive the override across its whole lifetime. Each pass is one player turn: - // the drone acts, then its countdown ticks. - let expired = false; - for (let turn = 0; turn < OVERRIDE_DURATION; turn++) { - assert.equal(drone.faction, FACTION.PLAYER, `drone should be player-aligned on turn ${turn}`); - drone.refreshAp(); // mimic the AP refresh PLAYER entities get each round - for (const { entity, action } of stepOverriddenDrones(world, rng)) { - assert.equal(entity.id, drone.id); - if (action.type === 'fire' || String(action.type).startsWith('move')) { - sawEngageAction = true; - } - if (action.type === 'override-expired') expired = true; - } - } - - assert.ok(sawEngageAction, 'hijacked drone should engage (fire/move) against corp'); - assert.ok(expired, 'override should emit an override-expired action when it lapses'); - assert.equal(drone.isOverridden, false, 'override countdown should be spent'); - assert.equal(drone.faction, FACTION.CORP, 'drone reverts to its original faction'); - assert.equal(drone.factionBeforeOverride, null, 'override bookkeeping cleared on revert'); - assert.ok(victim.hp <= victimHpStart, 'corp victim took fire from the hijacked drone'); + const { stunned } = decker.detonateEmp(world); + assert.ok(!stunned.includes(decker), 'caster is exempt from its own EMP'); + assert.ok(stunned.includes(foe), 'in-radius foe stunned'); + assert.ok(!stunned.includes(far), 'out-of-radius foe untouched'); + assert.equal(foe.hasEffect(STATUS_EFFECT.STUN), true); + assert.equal(far.hasEffect(STATUS_EFFECT.STUN), false); + assert.equal(decker.hasEffect(STATUS_EFFECT.STUN), false); + assert.equal(decker.ap, apBefore - AP_COST.EMP); }); -test('overrideDrone is a no-op on the corp turn list β€” driver skips player faction', () => { - // Sanity: once flipped, the drone is PLAYER faction, so the corp-turn driver - // (which filters on corpFaction) will never step it. We assert the faction - // contract that guarantees this rather than re-running the whole driver. - const drone = makeDrone('k', 3, 1); - const { world, decker } = makeWorld({ extraEntities: [drone] }); - decker.overrideDrone(world, drone, winRng); - assert.notEqual(drone.faction, FACTION.CORP); +test('Decker.detonateEmp throws on an illegal attempt without burning AP', () => { + const { world, decker } = makeWorld(); + decker.spendAp(decker.ap); + assert.throws(() => decker.detonateEmp(world), /Illegal EMP/); + assert.equal(decker.ap, 0); }); diff --git a/tests/unit/game/archetypes.test.ts b/tests/unit/game/archetypes.test.ts index d5e14d7..9bb447d 100644 --- a/tests/unit/game/archetypes.test.ts +++ b/tests/unit/game/archetypes.test.ts @@ -19,6 +19,7 @@ import { RECRUIT_ARCHETYPE_POOL, buildCrewMember, isArchetypeId, + perkAimForArchetype, pickCallsign, } from '../../../src/game/archetypes/index.js'; import { Decker } from '../../../src/game/archetypes/Decker.js'; @@ -149,14 +150,34 @@ test('buildCrewMember rejects a missing or invalid rng', () => { // --- Decker (P3.M2): registered, buildable, but not a starter/recruit pick --- -test('Decker is registered in ARCHETYPES with its OVERRIDE perk metadata', () => { +test('Decker is registered in ARCHETYPES with its EMP perk metadata', () => { const a = ARCHETYPES.decker; assert.ok(a, 'missing decker archetype'); assert.equal(a.id, 'decker'); assert.equal(a.name, 'DECKER'); - assert.deepEqual(a.perks, ['override']); - assert.equal(a.perkName, 'OVERRIDE'); + assert.deepEqual(a.perks, ['emp']); + assert.equal(a.perkName, 'EMP'); assert.ok(a.perkLabel.length > 0); + assert.equal(a.perkAim, 'self', 'EMP is self-centered β€” no aim step'); +}); + +test('perkAim metadata: only self-centered perks are tagged "self"', () => { + assert.equal(ARCHETYPES.merc.perkAim, 'directional'); + assert.equal(ARCHETYPES.razor.perkAim, 'directional'); + assert.equal(ARCHETYPES.tech.perkAim, 'directional'); + assert.equal(ARCHETYPES.decker.perkAim, 'self'); +}); + +test('perkAimForArchetype resolves lowercase id and class-cased Crew.archetype', () => { + assert.equal(perkAimForArchetype('decker'), 'self'); + assert.equal(perkAimForArchetype('Decker'), 'self', 'accepts class-cased archetype'); + assert.equal(perkAimForArchetype('merc'), 'directional'); + assert.equal(perkAimForArchetype('Razor'), 'directional'); +}); + +test('perkAimForArchetype throws on an unknown archetype (no silent fallback)', () => { + assert.throws(() => perkAimForArchetype('Avatar'), /unknown archetype/); + assert.throws(() => perkAimForArchetype('nope'), /unknown archetype/); }); test('Decker is excluded from the starter selector and the random recruit pool', () => { diff --git a/tests/unit/game/droneOverride.test.ts b/tests/unit/game/droneOverride.test.ts new file mode 100644 index 0000000..6b93dbb --- /dev/null +++ b/tests/unit/game/droneOverride.test.ts @@ -0,0 +1,187 @@ +/** + * Drone Override module tests (P3.5.M2 relocation). + * + * This coverage used to live in `Decker.test.ts`, exercised through the Decker's + * `canOverride`/`overrideDrone` delegators. M2 removed those delegators (the + * Decker's Meatspace perk is now EMP), so this file tests the `droneOverride` + * module functions *directly* against a generic PLAYER-faction operator β€” the + * mechanic is faction-agnostic and never depended on the Decker specifically. + * M4 renames this module to `mindInfluence.ts` (the Adept's Influence perk) and + * this file moves with it. + */ +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 { Skirmisher } from '../../../src/game/ai/Skirmisher.js'; +import { + canOverride, + overrideDrone, + applyOverride, + stepOverriddenDrones, +} from '../../../src/game/droneOverride.js'; +import { TILE, FACTION, AP_COST, OVERRIDE_DURATION } from '../../../src/game/constants.js'; +import { Rng } from '../../../src/rng.js'; + +function makeWorld({ operatorAt = [1, 1], grid, extraEntities = [] } = {}) { + const g = grid ?? new Grid(12, 12); + const w = new World(g); + // A generic PLAYER-faction operator β€” the module only needs alive/canAfford/ + // x,y/faction/spendAp, none of which are Decker-specific. + const operator = new Entity({ + id: 'operator', + x: operatorAt[0], + y: operatorAt[1], + faction: FACTION.PLAYER, + glyph: '@', + }); + w.addEntity(operator); + for (const e of extraEntities) w.addEntity(e); + return { world: w, operator }; +} + +function makeDrone(id, x, y, faction = FACTION.CORP) { + return new Skirmisher({ id, x, y, faction }); +} + +// Deterministic single-roll stubs: 0.1 < success chance β†’ hijack; 0.9 β†’ fail. +const winRng = { next: () => 0.1 }; +const loseRng = { next: () => 0.9 }; + +// --- canOverride legality matrix ------------------------------------------- + +test('canOverride accepts a live in-range LOS corp drone', () => { + const drone = makeDrone('k', 3, 1); + const { world, operator } = makeWorld({ extraEntities: [drone] }); + assert.equal(canOverride(world, operator, drone).ok, true); +}); + +test('canOverride rejects a null / non-Hostile target as not-overridable', () => { + const prop = new Entity({ id: 'prop', x: 2, y: 1, faction: FACTION.CORP, glyph: '#' }); + const { world, operator } = makeWorld({ extraEntities: [prop] }); + assert.equal(canOverride(world, operator, null).reason, 'not-overridable'); + assert.equal(canOverride(world, operator, prop).reason, 'not-overridable'); +}); + +test('canOverride rejects when the operator is dead', () => { + const drone = makeDrone('k', 3, 1); + const { world, operator } = makeWorld({ extraEntities: [drone] }); + operator.alive = false; + assert.equal(canOverride(world, operator, drone).reason, 'dead'); +}); + +test('canOverride rejects when AP < OVERRIDE cost', () => { + const drone = makeDrone('k', 3, 1); + const { world, operator } = makeWorld({ extraEntities: [drone] }); + operator.spendAp(operator.ap - (AP_COST.OVERRIDE - 1)); + assert.equal(canOverride(world, operator, drone).reason, 'insufficient-ap'); +}); + +test('canOverride rejects a dead target drone', () => { + const drone = makeDrone('k', 3, 1); + const { world, operator } = makeWorld({ extraEntities: [drone] }); + drone.alive = false; + assert.equal(canOverride(world, operator, drone).reason, 'dead-target'); +}); + +test('canOverride rejects a drone that is already overridden', () => { + const drone = makeDrone('k', 3, 1); + const { world, operator } = makeWorld({ extraEntities: [drone] }); + applyOverride(drone, FACTION.PLAYER); + assert.equal(canOverride(world, operator, drone).reason, 'already-overridden'); +}); + +test('canOverride rejects a same-faction (already player-aligned) drone', () => { + const drone = makeDrone('k', 3, 1, FACTION.PLAYER); + const { world, operator } = makeWorld({ extraEntities: [drone] }); + assert.equal(canOverride(world, operator, drone).reason, 'friendly'); +}); + +test('canOverride rejects an out-of-range drone', () => { + const drone = makeDrone('k', 9, 1); // distance 8 > OVERRIDE_RANGE (5) + const { world, operator } = makeWorld({ extraEntities: [drone] }); + assert.equal(canOverride(world, operator, drone).reason, 'out-of-range'); +}); + +test('canOverride rejects a drone behind a wall (no LOS)', () => { + const g = new Grid(12, 12); + g.setTile(2, 1, TILE.WALL); + const drone = makeDrone('k', 3, 1); + const { world, operator } = makeWorld({ grid: g, extraEntities: [drone] }); + assert.equal(canOverride(world, operator, drone).reason, 'no-los'); +}); + +// --- overrideDrone commit semantics ---------------------------------------- + +test('overrideDrone success flips the drone to PLAYER for the full duration', () => { + const drone = makeDrone('k', 3, 1); + const { world, operator } = makeWorld({ extraEntities: [drone] }); + const apBefore = operator.ap; + const result = overrideDrone(world, operator, drone, winRng); + assert.equal(result.success, true); + assert.equal(result.alarm, false); + assert.equal(drone.faction, FACTION.PLAYER); + assert.equal(drone.factionBeforeOverride, FACTION.CORP); + assert.equal(drone.overrideTurnsRemaining, OVERRIDE_DURATION); + assert.equal(drone.isOverridden, true); + assert.equal(operator.ap, apBefore - AP_COST.OVERRIDE); +}); + +test('overrideDrone failure burns AP, trips the alarm, leaves faction intact', () => { + const drone = makeDrone('k', 3, 1); + const { world, operator } = makeWorld({ extraEntities: [drone] }); + const apBefore = operator.ap; + assert.equal(world.alarmActive, false); + const result = overrideDrone(world, operator, drone, loseRng); + assert.equal(result.success, false); + assert.equal(result.alarm, true); + assert.equal(world.alarmActive, true); + assert.equal(drone.faction, FACTION.CORP); + assert.equal(drone.isOverridden, false); + assert.equal(operator.ap, apBefore - AP_COST.OVERRIDE); +}); + +test('overrideDrone throws on an illegal attempt without burning AP', () => { + const drone = makeDrone('k', 9, 1); // out of range + const { world, operator } = makeWorld({ extraEntities: [drone] }); + const apBefore = operator.ap; + assert.throws(() => overrideDrone(world, operator, drone, winRng), /Illegal override/); + assert.equal(operator.ap, apBefore, 'AP not debited on illegal override'); + assert.equal(drone.faction, FACTION.CORP); +}); + +// --- golden path: hijacked drone fights corp, then reverts ----------------- + +test('an overridden drone attacks corp allies for N turns then reverts', () => { + const drone = makeDrone('hijacked', 4, 4); + const victim = makeDrone('victim', 6, 4); // corp; the drone's new enemy + const { world, operator } = makeWorld({ operatorAt: [2, 4], extraEntities: [drone, victim] }); + + const result = overrideDrone(world, operator, drone, winRng); + assert.equal(result.success, true); + + const rng = new Rng(7); + const victimHpStart = victim.hp; + let sawEngageAction = false; + let expired = false; + for (let turn = 0; turn < OVERRIDE_DURATION; turn++) { + assert.equal(drone.faction, FACTION.PLAYER, `drone should be player-aligned on turn ${turn}`); + drone.refreshAp(); + for (const { entity, action } of stepOverriddenDrones(world, rng)) { + assert.equal(entity.id, drone.id); + if (action.type === 'fire' || String(action.type).startsWith('move')) { + sawEngageAction = true; + } + if (action.type === 'override-expired') expired = true; + } + } + + assert.ok(sawEngageAction, 'hijacked drone should engage (fire/move) against corp'); + assert.ok(expired, 'override should emit an override-expired action when it lapses'); + assert.equal(drone.isOverridden, false, 'override countdown should be spent'); + assert.equal(drone.faction, FACTION.CORP, 'drone reverts to its original faction'); + assert.equal(drone.factionBeforeOverride, null, 'override bookkeeping cleared on revert'); + assert.ok(victim.hp <= victimHpStart, 'corp victim took fire from the hijacked drone'); +}); diff --git a/tests/unit/game/empBlast.test.ts b/tests/unit/game/empBlast.test.ts new file mode 100644 index 0000000..0b7655d --- /dev/null +++ b/tests/unit/game/empBlast.test.ts @@ -0,0 +1,133 @@ +/** + * EMP Neural-Shock tests (P3.5.M2) β€” the Decker's self-centered AOE stun. + * + * Mirrors `breach.test.ts`'s blast-geometry style: `isInEmpBlast` is a pure + * Chebyshev check; `detonateEmp` stuns everyone alive in radius with no faction + * filter, debits AP exactly once, and skips corpses. + */ +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 { Decker } from '../../../src/game/archetypes/Decker.js'; +import { canEmp, isInEmpBlast, detonateEmp } from '../../../src/game/empBlast.js'; +import { EventBus, EVENT } from '../../../src/game/events.js'; +import { FACTION, AP_COST, EMP_RADIUS, STATUS_EFFECT } from '../../../src/game/constants.js'; + +function makeWorld({ deckerAt = [5, 5], grid, extraEntities = [], bus = null } = {}) { + const g = grid ?? new Grid(12, 12); + const w = new World(g, bus ? { events: bus } : {}); + const decker = new Decker({ id: 'decker', x: deckerAt[0], y: deckerAt[1] }); + w.addEntity(decker); + for (const e of extraEntities) w.addEntity(e); + return { world: w, decker }; +} + +const enemy = (id, x, y) => new Entity({ id, x, y, faction: FACTION.CORP, glyph: 'd' }); +const ally = (id, x, y) => new Entity({ id, x, y, faction: FACTION.PLAYER, glyph: 'c' }); + +// --- isInEmpBlast geometry -------------------------------------------------- + +test('isInEmpBlast covers the Chebyshev disc of EMP_RADIUS around the center', () => { + const cx = 5; + const cy = 5; + assert.equal(isInEmpBlast(cx, cy, cx, cy), true, 'center is in the blast'); + assert.equal(isInEmpBlast(cx, cy, cx + EMP_RADIUS, cy + EMP_RADIUS), true, 'far corner in radius'); + assert.equal(isInEmpBlast(cx, cy, cx + EMP_RADIUS + 1, cy), false, 'one tile past radius is out'); + assert.equal(isInEmpBlast(cx, cy, cx, cy - (EMP_RADIUS + 1)), false, 'one tile past radius (up) is out'); +}); + +// --- canEmp legality -------------------------------------------------------- + +test('canEmp accepts a live Decker with enough AP', () => { + const { decker } = makeWorld(); + assert.equal(canEmp(decker).ok, true); +}); + +test('canEmp rejects a dead Decker', () => { + const { decker } = makeWorld(); + decker.alive = false; + assert.equal(canEmp(decker).reason, 'dead'); +}); + +test('canEmp rejects when AP < EMP cost', () => { + const { decker } = makeWorld(); + decker.spendAp(decker.ap - (AP_COST.EMP - 1)); + assert.equal(canEmp(decker).reason, 'insufficient-ap'); +}); + +// --- detonateEmp semantics -------------------------------------------------- + +test('detonateEmp stuns every OTHER alive entity in radius regardless of faction', () => { + const nearCorp = enemy('corp', 6, 5); // dist 1 + const nearAlly = ally('ally', 5, 4); // dist 1 + const { world, decker } = makeWorld({ extraEntities: [nearCorp, nearAlly] }); + const { stunned } = detonateEmp(world, decker); + assert.ok(stunned.includes(nearCorp), 'corp foe stunned'); + assert.ok(stunned.includes(nearAlly), 'friendly crew stunned too (no faction filter)'); + assert.equal(nearCorp.hasEffect(STATUS_EFFECT.STUN), true); + assert.equal(nearAlly.hasEffect(STATUS_EFFECT.STUN), true); +}); + +test('detonateEmp does NOT stun the Decker who fired it (no self-footgun)', () => { + const nearCorp = enemy('corp', 6, 5); + const { world, decker } = makeWorld({ extraEntities: [nearCorp] }); + const { stunned } = detonateEmp(world, decker); + assert.ok(!stunned.includes(decker), 'caster exempt from its own discharge'); + assert.equal(decker.hasEffect(STATUS_EFFECT.STUN), false); +}); + +test('detonateEmp leaves entities outside the radius untouched', () => { + const far = enemy('far', 5 + EMP_RADIUS + 1, 5); + const { world, decker } = makeWorld({ extraEntities: [far] }); + const { stunned } = detonateEmp(world, decker); + assert.equal(far.hasEffect(STATUS_EFFECT.STUN), false, 'out-of-radius foe not stunned'); + assert.ok(!stunned.includes(far)); +}); + +test('detonateEmp skips corpses (dead entities take no stun)', () => { + const dead = enemy('dead', 6, 5); + dead.alive = false; + const { world, decker } = makeWorld({ extraEntities: [dead] }); + const { stunned } = detonateEmp(world, decker); + assert.ok(!stunned.includes(dead)); + assert.equal(dead.hasEffect(STATUS_EFFECT.STUN), false); +}); + +test('detonateEmp debits AP exactly once', () => { + const { world, decker } = makeWorld({ extraEntities: [enemy('c', 6, 5), enemy('c2', 4, 5)] }); + const apBefore = decker.ap; + detonateEmp(world, decker); + assert.equal(decker.ap, apBefore - AP_COST.EMP, 'one EMP cost debited, not per-target'); +}); + +test('detonateEmp throws on an illegal attempt without burning AP', () => { + const { world, decker } = makeWorld(); + decker.spendAp(decker.ap); // 0 AP + const apBefore = decker.ap; + assert.throws(() => detonateEmp(world, decker), /Illegal EMP/); + assert.equal(decker.ap, apBefore, 'no AP debited on an illegal EMP'); +}); + +test('detonateEmp emits EMP_DETONATED with origin and stun count for the shell flash', () => { + const bus = new EventBus(); + const events = []; + bus.on(EVENT.EMP_DETONATED, payload => events.push(payload)); + const { world, decker } = makeWorld({ bus, extraEntities: [enemy('a', 6, 5), enemy('b', 4, 5)] }); + detonateEmp(world, decker); + assert.equal(events.length, 1, 'exactly one detonation event'); + assert.deepEqual(events[0].origin, { x: 5, y: 5 }, 'origin is the Decker tile'); + assert.equal(events[0].stunned, 2, 'reports how many were caught'); +}); + +test('a stunned entity takes 0 AP on its next refresh, full AP after', () => { + const foe = enemy('foe', 6, 5); + const { world, decker } = makeWorld({ extraEntities: [foe] }); + detonateEmp(world, decker); + foe.refreshAp(); + assert.equal(foe.ap, 0, 'stun consumes the next activation'); + foe.refreshAp(); + assert.equal(foe.ap, foe.maxAp, 'the activation after the stun is normal'); +}); diff --git a/tests/unit/input/applyIntent.test.ts b/tests/unit/input/applyIntent.test.ts index b657377..8ed6ebf 100644 --- a/tests/unit/input/applyIntent.test.ts +++ b/tests/unit/input/applyIntent.test.ts @@ -16,6 +16,7 @@ import { makeSalvage, totalSalvage } from '../../../src/game/salvage.js'; import { Merc } from '../../../src/game/archetypes/Merc.js'; import { Razor } from '../../../src/game/archetypes/Razor.js'; import { Tech } from '../../../src/game/archetypes/Tech.js'; +import { Decker } from '../../../src/game/archetypes/Decker.js'; import { CyberAvatar } from '../../../src/game/cyber/CyberAvatar.js'; import { ProbeIce } from '../../../src/game/cyber/ProbeIce.js'; import { Turret } from '../../../src/game/Turret.js'; @@ -474,6 +475,43 @@ test('a stunned player can still cancel without ending the turn', () => { assert.equal(calls.resetInputModes, 1, 'cancel still clears aim modes'); }); +test('special intent routes to EMP on a Decker and stuns a same-faction ally in radius', () => { + const grid = new Grid(10, 6); + const bus = new EventBus(); + const world = new World(grid, { events: bus }); + const decker = new Decker({ id: 'decker', x: 4, y: 2, maxAp: 4 }); + const ally = new Merc({ id: 'ally', x: 5, y: 2, maxAp: 4 }); // adjacent, PLAYER faction + const corp = new Skirmisher({ id: 'c1', x: 3, y: 2, maxAp: 3 }); // adjacent, CORP faction + world.addEntity(decker); + world.addEntity(ally); + world.addEntity(corp); + corp.bindToBus(bus); + + const queue = new TurnQueue([FACTION.PLAYER, FACTION.CORP]); + const log = []; + const ctx = { + world, + player: decker, + queue, + rng: new Rng(1), + log: line => log.push(line), + advanceTurn: () => queue.endTurn(world), + resetInputModes: () => {}, + onPlayerAction: () => {}, + }; + + const apBefore = decker.ap; + applyIntent({ type: 'special', dx: 0, dy: 0 }, ctx); + + assert.equal(decker.ap, apBefore - AP_COST.EMP, 'EMP debited once'); + // The ally is same-faction but the blast ignores faction β€” it gets stunned. + ally.refreshAp(); + assert.equal(ally.ap, 0, 'same-faction ally caught in the EMP is at 0 AP next refresh'); + corp.refreshAp(); + assert.equal(corp.ap, 0, 'corp unit in radius is stunned too'); + assert.ok(log.some(line => line.includes('EMP'))); +}); + test('special intent routes CyberAvatar Override against Probe ICE', () => { const grid = new Grid(8, 5); const bus = new EventBus(); diff --git a/tests/unit/input/keymap.test.ts b/tests/unit/input/keymap.test.ts index 2a4b947..822ed10 100644 --- a/tests/unit/input/keymap.test.ts +++ b/tests/unit/input/keymap.test.ts @@ -48,13 +48,27 @@ test('IDLE + Space emits interact and stays IDLE', () => { assert.equal(r.nextMode, MODE.IDLE); }); -test('IDLE + x enters AIM (special) with no intent yet', () => { +test('IDLE + x enters AIM (special) with no intent yet (directional perk, default)', () => { const r = dispatch('x', MODE.IDLE); assert.equal(r.intent, null, 'aiming alone produces no intent'); assert.equal(r.nextMode, MODE.AIM); assert.equal(r.aimKind, AIM_KIND.SPECIAL); }); +test('IDLE + x with a self-targeted perk fires special immediately (no aim step)', () => { + const r = dispatch('x', MODE.IDLE, null, 'self'); + assert.deepEqual(r.intent, { type: 'special', dx: 0, dy: 0 }, 'self perk fires with a zero aim'); + assert.equal(r.nextMode, MODE.IDLE, 'no aim mode entered'); + assert.equal(r.aimKind, null); +}); + +test('IDLE + x with an explicit directional perk still enters AIM', () => { + const r = dispatch('x', MODE.IDLE, null, 'directional'); + assert.equal(r.intent, null); + assert.equal(r.nextMode, MODE.AIM); + assert.equal(r.aimKind, AIM_KIND.SPECIAL); +}); + test('IDLE + Escape emits cancel and stays IDLE', () => { const r = dispatch('Escape', MODE.IDLE); assert.deepEqual(r.intent, { type: 'cancel' }); diff --git a/tests/unit/input/touchpad.test.ts b/tests/unit/input/touchpad.test.ts index 6d6605b..a1d1480 100644 --- a/tests/unit/input/touchpad.test.ts +++ b/tests/unit/input/touchpad.test.ts @@ -125,13 +125,20 @@ test('syntheticKeyFor rejects legacy melee button id (removed from pad)', () => assert.throws(() => syntheticKeyFor('melee'), /unknown button/i); }); -test('IDLE + special button enters AIM (special)', () => { +test('IDLE + special button enters AIM (special) for a directional perk', () => { const r = dispatchTouchAction('special', MODE.IDLE); assert.equal(r.intent, null); assert.equal(r.nextMode, MODE.AIM); assert.equal(r.aimKind, AIM_KIND.SPECIAL); }); +test('IDLE + special button fires immediately for a self-targeted perk', () => { + const r = dispatchTouchAction('special', MODE.IDLE, null, 'self'); + assert.deepEqual(r.intent, { type: 'special', dx: 0, dy: 0 }); + assert.equal(r.nextMode, MODE.IDLE); + assert.equal(r.aimKind, null); +}); + test('IDLE + look button enters LOOK mode', () => { const r = dispatchTouchAction('look', MODE.IDLE); assert.equal(r.intent, null); diff --git a/tests/unit/render/animations.test.ts b/tests/unit/render/animations.test.ts index bb58443..e939ee5 100644 --- a/tests/unit/render/animations.test.ts +++ b/tests/unit/render/animations.test.ts @@ -11,9 +11,11 @@ import { runInteractSecuredFlash, runMuzzleFlash, triggerDamageFlash, + triggerEmpFlash, triggerMitigationFlash, triggerShake, } from '../../../src/render/animations.js'; +import { STUNNED_FG } from '../../../src/render/palette.js'; /** * Minimal DOM-element stub β€” enough surface for restartCssAnimation to @@ -149,6 +151,17 @@ test('triggerMitigationFlash keys the vignette color to the defense that stopped assert.equal(el.style.getPropertyValue('--kp-impact-flash-color'), '#d49a3a8c'); }); +test('triggerEmpFlash drives a cyan vignette pulse on the shared flash class', () => { + const el = makeElement(); + const timers = makeTimers(); + triggerEmpFlash(el, timers); + assert.equal(el.classList.contains(MITIGATION_FLASH_CLASS), true, 'reuses the colored-flash class'); + assert.equal(el.classList.contains(DAMAGE_CLASS), false, 'not the red damage flash'); + assert.equal(el.style.getPropertyValue('--kp-impact-flash-color'), `${STUNNED_FG}8c`); + timers.advance(ANIMATION_DURATIONS.EMP_FLASH + 1); + assert.equal(el.classList.contains(MITIGATION_FLASH_CLASS), false, 'clears after its duration'); +}); + test('createAnimationLock: isLocked is false before any push', () => { const timers = makeTimers(); const lock = createAnimationLock(timers); diff --git a/tests/unit/render/combatHud.test.ts b/tests/unit/render/combatHud.test.ts index 36a0e8d..99badf0 100644 --- a/tests/unit/render/combatHud.test.ts +++ b/tests/unit/render/combatHud.test.ts @@ -90,6 +90,23 @@ test('formatIdentityHud falls back to archetype when callsign is missing', () => assert.equal(formatIdentityHud({ archetype: 'tech', stealthed: true }), 'TECH [CLOAKED]'); }); +test('formatIdentityHud appends [STUNNED] when the controlled actor is EMP-stunned', () => { + assert.equal( + formatIdentityHud({ callsign: 'Patch', archetype: 'tech', stealthed: false, stunned: true }), + 'Patch [TECH] [STUNNED]' + ); + // Cloak + stun can co-occur (a cloaked partner caught in an EMP) β€” show both. + assert.equal( + formatIdentityHud({ callsign: 'Ghost', archetype: 'razor', stealthed: true, stunned: true }), + 'Ghost [RAZOR] [CLOAKED] [STUNNED]' + ); + // Absent/false stunned changes nothing. + assert.equal( + formatIdentityHud({ callsign: 'Patch', archetype: 'tech', stealthed: false }), + 'Patch [TECH]' + ); +}); + test('formatHpSegments right-fills live HP segments', () => { assert.equal(formatHpSegments({ hp: 3, maxHp: 3 }), 'HP β– β– β– '); assert.equal(formatHpSegments({ hp: 2, maxHp: 3 }), 'HP β–‘β– β– '); diff --git a/tests/unit/render/frame.test.ts b/tests/unit/render/frame.test.ts index 89ad9c7..ddfaeb9 100644 --- a/tests/unit/render/frame.test.ts +++ b/tests/unit/render/frame.test.ts @@ -17,7 +17,9 @@ import { glyphForTile, glyphForEntity, INTERACTABLE_SECURED_FG, + STUNNED_FG, } from '../../../src/render/palette.js'; +import { STATUS_EFFECT } from '../../../src/game/constants.js'; import { ConsumablePickup } from '../../../src/game/entities/ConsumablePickup.js'; import { Terminal } from '../../../src/game/entities/Terminal.js'; import { SyncPad } from '../../../src/game/entities/SyncPad.js'; @@ -99,6 +101,16 @@ test('buildFrame renders dead entities as a dimmed corpse glyph (faction-coloure assert.equal(cell.fg, dimColor('#ff4d6d', CORPSE_DIM)); }); +test('buildFrame renders a stunned entity in electric cyan, overriding faction hue', () => { + const { world, drone } = fixture(); + drone.applyEffect(STATUS_EFFECT.STUN, 1); + const frame = buildFrame(world, { x: 0, y: 0, width: 6, height: 4 }); + const cell = cellAt(frame, 4, 2); + assert.equal(cell.char, 'd', 'live glyph kept β€” not a corpse'); + assert.equal(cell.fg, STUNNED_FG, 'stunned overrides the CORP faction colour'); + assert.notEqual(cell.fg, glyphForEntity(drone).fg, 'differs from the un-stunned faction hue'); +}); + test('buildFrame: a live entity standing on a corpse tile renders the live entity', () => { // Construct two drones on the same tile by killing one and walking the other // onto its square β€” the live silhouette must win the cell. From 25b21f0821df8a8288928a6c9e05327f6d9d5c76 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Mon, 13 Jul 2026 15:35:36 -0700 Subject: [PATCH 3/9] Berserk archetype --- components/KeyHelp.ts | 6 +- docs/phase-3.5-plan.md | 11 +- src/game/Campaign.ts | 3 +- src/game/Run.ts | 13 ++- src/game/archetypes/Berserk.ts | 83 +++++++++++++++ src/game/archetypes/index.ts | 23 ++++- src/game/constants.ts | 13 +++ src/game/persistence.ts | 45 +++++++- src/game/surge.ts | 28 +++++ src/input/applyIntent.ts | 20 ++++ src/render/combatHud.ts | 5 + src/shell/combatHudSnapshot.ts | 11 +- sw-core.js | 4 + sw-dev.js | 4 +- sw-release.js | 8 +- sw.js | 2 +- tests/unit/game/Berserk.test.ts | 114 +++++++++++++++++++++ tests/unit/game/Campaign.test.ts | 35 ++++--- tests/unit/game/Run.test.ts | 9 ++ tests/unit/game/archetypes.test.ts | 21 +++- tests/unit/game/persistence.test.ts | 45 ++++++++ tests/unit/input/applyIntent.test.ts | 16 ++- tests/unit/render/combatHud.test.ts | 23 +++++ tests/unit/shell/combatHudSnapshot.test.ts | 28 +++++ 24 files changed, 533 insertions(+), 37 deletions(-) create mode 100644 src/game/archetypes/Berserk.ts create mode 100644 src/game/surge.ts create mode 100644 tests/unit/game/Berserk.test.ts diff --git a/components/KeyHelp.ts b/components/KeyHelp.ts index 58e3b9f..014be02 100644 --- a/components/KeyHelp.ts +++ b/components/KeyHelp.ts @@ -499,7 +499,11 @@ class KeyHelp extends HTMLElement { }); const perkHint = this.#archetypeInfo - ? `${this.#archetypeInfo.perkLabel} β€” activate it, then pick a direction.` + ? `${this.#archetypeInfo.perkLabel} β€” ${ + this.#archetypeInfo.perkAim === 'self' + ? 'activate it immediately.' + : 'activate it, then pick a direction.' + }` : ''; const archetypeInfo = h('p', { textContent: `Every player archetype has a special ability that can be used to move, attack, or both. ${perkHint}`, diff --git a/docs/phase-3.5-plan.md b/docs/phase-3.5-plan.md index e79713f..27c4e75 100644 --- a/docs/phase-3.5-plan.md +++ b/docs/phase-3.5-plan.md @@ -38,7 +38,7 @@ M3 + M4 + M5 ──> M6 (stat-roll β†’ archetype derivation, needs all 6 profile |---|---| | P3.5.M1 β€” Generic status-effect subsystem | βœ… Complete | | P3.5.M2 β€” Decker perk swap: Override β†’ EMP AOE stun | βœ… Complete | -| P3.5.M3 β€” Berserk archetype (surge/crash) | πŸ”² Not started | +| P3.5.M3 β€” Berserk archetype (surge/crash) | βœ… Complete | | P3.5.M4 β€” Adept archetype (Influence, renamed from Override) | πŸ”² Not started | | P3.5.M5 β€” Chimera archetype (scrap-to-HP sustain) | πŸ”² Not started | | P3.5.M6 β€” Inverted crew generation (roll stats, derive archetype) | πŸ”² Not started | @@ -212,6 +212,15 @@ Berserk is recruit-pool only β€” moot as a distinct decision once M6 lands, sinc **Tests:** `tests/unit/game/Berserk.test.ts` (new β€” mirrors `Razor.test.ts`: legality/AP debit, damage+AP bonus while surging, crash auto-applies on surge expiry, hit-chance penalty applied/restored, base stats), `tests/unit/game/persistence.test.ts`, `tests/unit/game/Run.test.ts`, `tests/unit/input/applyIntent.test.ts` (each extended per the wiring-surface list). +**Implementation notes (as-built):** +- `Berserk` and the pure `surge.ts` legality/commit module shipped with the proposed tuning and callsign pool. Surge is self-targeted, costs 2 AP, adds +1 ranged/melee damage while active, and grants +1 AP on an active Surge refresh. It cannot be re-armed during Surge or Crash, so the mandatory payback cannot be postponed indefinitely. +- Surge expiry immediately arms a two-refresh Crash. Crash applies -1 AP and -0.1 base hit chance while active; accuracy is derived directly from `STATUS_EFFECT.CRASH` rather than tracked in a second mutable flag, so expiry and restore cannot over-/under-correct the stat. +- **Run persistence correction:** the earlier phase-level assumption that timed effects never meet persistence was false for mid-combat autosaves. `RunEntitySnapshot.effects` now stores validated non-stealth timed effects (legacy `stealthed` stays byte-compatible), so Surge/Crash cannot be cleared by reload. Restore accepts Berserk's legitimate `maxAp + SURGE_AP_BONUS` snapshot only while Surge is present and fails loud on unknown/reserved/malformed effects. +- Full archetype fan-out landed: registry/factory/callsigns/perk metadata, recruit pool, Run classification/telemetry/snapshot, campaign/run restore, capability-based input dispatch, and self-targeting keyboard/touch behavior. The interim weighted pool is now `2 Merc / 2 Razor / 1 Tech / 1 Berserk`; its tests include Berserk in the denominator instead of silently ignoring a new class. M6 still replaces this pool with stat-first derivation. +- Player-facing state is explicit: combat HUD appends `[SURGING]` / `[CRASH]`, the log announces Surge/denials, and Key Help no longer tells self-targeted perks to pick a direction. Offline precache includes Berserk/Surge plus the previously omitted Decker/EMP modules; service-worker/release version is `0.3.4b`. +- **Smoketest correction (`0.3.4b`):** the combat HUD now treats active Surge as a real `maxAp + 1` five-pip capacity, fixing the tier-1 `ap must be <= 4, got 5` paint fault and preserving the spent fifth pip. Berserk's refresh override also respects the base stun gate, so overlapping Stun cannot leak one AP back into a deliberately zero-AP activation. +- Verification: focused Berserk/wiring/persistence tests, `npm run format`, `npm run lint`, and full `npm test` pass. Browser smoke at `http://localhost:8099/` resumed combat without console warnings/errors and installed the new service worker successfully. + --- ## P3.5.M4 β€” Adept archetype: Influence (Override, renamed and rehomed) diff --git a/src/game/Campaign.ts b/src/game/Campaign.ts index b24efb4..2fd2ff5 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -1129,7 +1129,8 @@ export class Campaign { /** * Generate the starter candidate pool for a fresh campaign. Returns * `RECRUIT.INITIAL_CANDIDATES` (3) candidates with weighted archetype - * distribution (40/40/20). Stores them on `initialCandidates` for + * distribution (2 Merc / 2 Razor / 1 Tech / 1 Berserk). Stores them on + * `initialCandidates` for * `recruitInitial()` to consume. Does NOT require Rep gate β€” this is * the campaign-start exception. */ diff --git a/src/game/Run.ts b/src/game/Run.ts index 4cb4f32..b0f02ee 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -44,6 +44,7 @@ import { ENEMY_ROLE, JACK_OUT_SHOCK_DAMAGE, factionForPrincipalGroups, + STATUS_EFFECT, } from './constants.js'; import { coordKey, explorationReachableKeys, hasAdjacentPassableTile } from './mapConnectivity.js'; import { isValidBlockingPlacement, checkPlacementIntegrity } from './placement.js'; @@ -56,6 +57,7 @@ import { Merc } from './archetypes/Merc.js'; import { Razor } from './archetypes/Razor.js'; import { Tech } from './archetypes/Tech.js'; import { Decker } from './archetypes/Decker.js'; +import { Berserk } from './archetypes/Berserk.js'; import { Turret } from './Turret.js'; import { Skirmisher } from './ai/Skirmisher.js'; import { Guard } from './ai/Guard.js'; @@ -155,7 +157,7 @@ const KNOWN_OUTCOMES = new Set(Object.values(OUTCOME)); export type RunState = (typeof RUN_STATE)[keyof typeof RUN_STATE]; export type Outcome = (typeof OUTCOME)[keyof typeof OUTCOME]; -export type CrewArchetypeId = 'merc' | 'razor' | 'tech' | 'decker'; +export type CrewArchetypeId = 'merc' | 'razor' | 'tech' | 'decker' | 'berserk'; export type EntityArchetypeId = | CrewArchetypeId | 'turret' @@ -268,6 +270,8 @@ export type RunEntitySnapshot = { maxAp: number; alive: boolean; stealthed: boolean; + /** Active non-stealth timed effects. Omitted when empty and on legacy saves. */ + effects?: Record; /** Phase 2.9 principal theming β€” omitted for un-aliased entities (player, props). */ displayName?: string; principalTag?: string; @@ -2689,6 +2693,7 @@ const SNAPSHOT_EXTRACTORS: Partial Enti { merc: e => crewSnapshotExtra(e as Crew) as unknown as EntitySnapshotExtra, razor: e => crewSnapshotExtra(e as Crew) as unknown as EntitySnapshotExtra, + berserk: e => crewSnapshotExtra(e as Crew) as unknown as EntitySnapshotExtra, decker: e => { const d = e as Decker; return { @@ -2874,6 +2879,10 @@ function snapshotEntity(entity: Entity): RunEntitySnapshot { // keep a byte-stable snapshot and pre-2.9 saves stay unaffected. if (entity.displayName !== undefined) base.displayName = entity.displayName; if (entity.principalTag !== undefined) base.principalTag = entity.principalTag; + const effects = Object.fromEntries( + [...entity.effects].filter(([id]) => id !== STATUS_EFFECT.STEALTH) + ); + if (Object.keys(effects).length > 0) base.effects = effects; const extract = SNAPSHOT_EXTRACTORS[archetype]; if (extract) base.extra = extract(entity); return base; @@ -2884,6 +2893,7 @@ function archetypeOf(entity: Entity): EntityArchetypeId { if (entity instanceof Razor) return 'razor'; if (entity instanceof Tech) return 'tech'; if (entity instanceof Decker) return 'decker'; + if (entity instanceof Berserk) return 'berserk'; if (entity instanceof Turret) return 'turret'; if (entity instanceof Bruiser) return 'bruiser'; if (entity instanceof Juggernaut) return 'juggernaut'; @@ -3461,6 +3471,7 @@ function archetypeOfCrew(entity: Entity): CrewArchetypeId { if (entity instanceof Razor) return 'razor'; if (entity instanceof Tech) return 'tech'; if (entity instanceof Decker) return 'decker'; + if (entity instanceof Berserk) return 'berserk'; throw new Error( `archetypeOfCrew: cannot classify crew member ${(entity as Entity | undefined)?.id}` ); diff --git a/src/game/archetypes/Berserk.ts b/src/game/archetypes/Berserk.ts new file mode 100644 index 0000000..bba6e5b --- /dev/null +++ b/src/game/archetypes/Berserk.ts @@ -0,0 +1,83 @@ +import { Crew } from '../Crew.js'; +import { + CRASH_AP_PENALTY, + CRASH_DURATION, + CRASH_HIT_PENALTY, + STATUS_EFFECT, + SURGE_AP_BONUS, + SURGE_DAMAGE_BONUS, +} from '../constants.js'; +import { canSurge, doSurge } from '../surge.js'; +import type { CrewInit } from '../Crew.js'; + +export const CALLSIGNS = Object.freeze([ + 'Fury', + 'Havoc', + 'Grit', + 'Maul', + 'Wrath', + 'Torque', + 'Riptide', + 'Ember', + 'Thresh', + 'Brawn', + 'Ronin', + 'Ashwalker', +]); + +/** Berserk β€” volatile assault archetype. Surge always resolves into Crash. */ +export class Berserk extends Crew { + override archetype = 'Berserk'; + + override get baseHitChance(): number { + return 0.75 - (this.hasEffect(STATUS_EFFECT.CRASH) ? CRASH_HIT_PENALTY : 0); + } + + override get baseDodgeChance(): number { + return 0.2; + } + + constructor(props: CrewInit) { + super({ ...props, glyph: '@' }); + } + + canSurge() { + return canSurge(this); + } + + surge(): void { + doSurge(this); + } + + override rangedAttackDamage(): number { + return ( + super.rangedAttackDamage() + (this.hasEffect(STATUS_EFFECT.SURGE) ? SURGE_DAMAGE_BONUS : 0) + ); + } + + override meleeAttackDamage(): number { + return ( + super.meleeAttackDamage() + (this.hasEffect(STATUS_EFFECT.SURGE) ? SURGE_DAMAGE_BONUS : 0) + ); + } + + override refreshAp(): void { + const wasSurging = this.hasEffect(STATUS_EFFECT.SURGE); + const wasStunned = this.hasEffect(STATUS_EFFECT.STUN); + super.refreshAp(); + + if (wasSurging && !this.hasEffect(STATUS_EFFECT.SURGE)) { + this.applyEffect(STATUS_EFFECT.CRASH, CRASH_DURATION); + } + + // Entity.refreshAp owns the stun invariant: a stunned activation has 0 AP. + // Surge/Crash modifiers must never reopen that deliberately closed budget. + if (wasStunned) return; + + if (this.hasEffect(STATUS_EFFECT.SURGE)) { + this.ap += SURGE_AP_BONUS; + } else if (this.hasEffect(STATUS_EFFECT.CRASH)) { + this.ap = Math.max(0, this.ap - CRASH_AP_PENALTY); + } + } +} diff --git a/src/game/archetypes/index.ts b/src/game/archetypes/index.ts index b3609b9..8a2f690 100644 --- a/src/game/archetypes/index.ts +++ b/src/game/archetypes/index.ts @@ -23,11 +23,12 @@ import { Merc, CALLSIGNS as MERC_CALLSIGNS } from './Merc.js'; import { Razor, CALLSIGNS as RAZOR_CALLSIGNS } from './Razor.js'; import { Tech, CALLSIGNS as TECH_CALLSIGNS } from './Tech.js'; import { Decker, CALLSIGNS as DECKER_CALLSIGNS } from './Decker.js'; +import { Berserk, CALLSIGNS as BERSERK_CALLSIGNS } from './Berserk.js'; import type { Rng } from '../../rng.js'; import type { FactionId } from '../constants.js'; import type { CrewInit } from '../Crew.js'; -export type Archetype = Merc | Razor | Tech | Decker; +export type Archetype = Merc | Razor | Tech | Decker | Berserk; /** * Display order is also the starter crew order in `Campaign.buildCrew`. @@ -47,7 +48,14 @@ export const ARCHETYPE_IDS = Object.freeze(['merc', 'razor', 'tech']); * The Decker is **not** in this pool β€” normal random recruitment must never * roll one; it joins only through the Act-2 narrative beat (P3.M2 / P3.M1). */ -export const RECRUIT_ARCHETYPE_POOL = Object.freeze(['merc', 'merc', 'razor', 'razor', 'tech']); +export const RECRUIT_ARCHETYPE_POOL = Object.freeze([ + 'merc', + 'merc', + 'razor', + 'razor', + 'tech', + 'berserk', +]); /** * All three archetypes share a single perk key (`x`) β€” the keymap collapses @@ -94,6 +102,15 @@ export const ARCHETYPES = Object.freeze({ // Self-centered blast β€” the perk key fires it immediately, no aim step. perkAim: 'self', }), + berserk: Object.freeze({ + id: 'berserk', + name: 'BERSERK', + blurb: 'Volatile assault. Surge hard, then endure the crash.', + perks: Object.freeze(['surge']), + perkName: 'SURGE', + perkLabel: 'Berserks can SURGE: gain damage and AP before crashing', + perkAim: 'self', + }), }); export type ArchetypeInfo = (typeof ARCHETYPES)[keyof typeof ARCHETYPES]; @@ -121,6 +138,7 @@ const BUILDERS = Object.freeze({ razor: Razor, tech: Tech, decker: Decker, + berserk: Berserk, }); /** @@ -134,6 +152,7 @@ export const CALLSIGNS_BY_ARCHETYPE = Object.freeze({ razor: RAZOR_CALLSIGNS, tech: TECH_CALLSIGNS, decker: DECKER_CALLSIGNS, + berserk: BERSERK_CALLSIGNS, }); export function isArchetypeId(value: string) { diff --git a/src/game/constants.ts b/src/game/constants.ts index ffb69f2..e8a66a0 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -85,10 +85,14 @@ export function factionForPrincipalGroups(groups: readonly string[]): FactionId * - `STUN` β€” EMP neural-shock: the entity takes 0 AP on its stunned refresh * (M2). Gated in `Entity.refreshAp` *before* the tick, so a duration of 1 * covers the upcoming refresh, not the one that just passed. + * - `SURGE` / `CRASH` β€” Berserk's chained self-buff and mandatory payback + * window (M3). */ export const STATUS_EFFECT = Object.freeze({ STEALTH: 'stealth', STUN: 'stun', + SURGE: 'surge', + CRASH: 'crash', }); export type StatusEffectId = (typeof STATUS_EFFECT)[keyof typeof STATUS_EFFECT]; @@ -108,8 +112,17 @@ export const AP_COST = Object.freeze({ DEPLOY: 2, // Tech β€” place a turret on an adjacent tile OVERRIDE: 2, // CyberAvatar β€” flip ICE allegiance on the cyber grid (M4 renames -> INFLUENCE for the Adept) EMP: 2, // Decker β€” self-centered AOE neural-shock stun (P3.5.M2) + SURGE: 2, // Berserk β€” self-buff that always chains into CRASH (P3.5.M3) }); +/** Berserk Surge/Crash tuning (P3.5.M3). */ +export const SURGE_DURATION = 2; +export const SURGE_DAMAGE_BONUS = 1; +export const SURGE_AP_BONUS = 1; +export const CRASH_DURATION = 2; +export const CRASH_AP_PENALTY = 1; +export const CRASH_HIT_PENALTY = 0.1; + /** * Decker drone-override parameters (P3.M2). The Decker's signature Meatspace * ability flips a corp drone to the player's side for a few turns by reusing diff --git a/src/game/persistence.ts b/src/game/persistence.ts index 87ed803..f937465 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -39,6 +39,8 @@ import { DOOR_OPEN_GLYPH, FACTION, SALVAGE_TO_CRED_RATE, + STATUS_EFFECT, + SURGE_AP_BONUS, TILE, } from './constants.js'; import { migrateSalvage, type TypedSalvage } from './salvage.js'; @@ -48,6 +50,7 @@ import { Merc } from './archetypes/Merc.js'; import { Razor } from './archetypes/Razor.js'; import { Tech } from './archetypes/Tech.js'; import { Decker } from './archetypes/Decker.js'; +import { Berserk } from './archetypes/Berserk.js'; import { Turret } from './Turret.js'; import { Skirmisher, type SkirmisherProps } from './ai/Skirmisher.js'; import { Guard, type GuardProps } from './ai/Guard.js'; @@ -213,6 +216,7 @@ const ARCHETYPE_FACTORY: Record new Razor(props as CrewInit), tech: (props: RestoreEntityProps) => new Tech(props as CrewInit), decker: (props: RestoreEntityProps) => new Decker(props as DeckerInit), + berserk: (props: RestoreEntityProps) => new Berserk(props as CrewInit), turret: (props: RestoreEntityProps) => new Turret(props as TurretInit), drone: (props: RestoreEntityProps) => new Skirmisher(props as SkirmisherProps), guard: (props: RestoreEntityProps) => new Guard(props as GuardProps), @@ -257,6 +261,7 @@ const ARCHETYPE_FACTORY: Record(Object.values(STATUS_EFFECT)); const KNOWN_RUN_STATES = new Set(Object.values(RUN_STATE)); const KNOWN_PATROL_STATES = new Set(Object.values(PATROL_STATE)); const PATROL_ARCHETYPE_SET = new Set(PATROL_ARCHETYPE_IDS); @@ -265,6 +270,26 @@ function isPatrolArchetype(archetype: EntityArchetypeId): archetype is PatrolArc return PATROL_ARCHETYPE_SET.has(archetype); } +function readActiveEffects(rec: RunEntitySnapshot): Map { + if (rec.effects === undefined) return new Map(); + if (!rec.effects || typeof rec.effects !== 'object' || Array.isArray(rec.effects)) { + throw new TypeError(`restore: entity ${rec.id} effects must be an object`); + } + const effects = new Map(); + for (const [id, duration] of Object.entries(rec.effects)) { + if (!KNOWN_STATUS_EFFECTS.has(id) || id === STATUS_EFFECT.STEALTH) { + throw new Error(`restore: entity ${rec.id} has unknown or reserved effect "${id}"`); + } + if (!Number.isInteger(duration) || duration <= 0) { + throw new RangeError( + `restore: entity ${rec.id} effect "${id}" duration must be a positive integer` + ); + } + effects.set(id, duration); + } + return effects; +} + // --------------------------------------------------------------------------- // P2.7.M6.2 β€” Data-Mapper entity restore registry. // @@ -1592,6 +1617,8 @@ function restoreEntity(rec: RunEntitySnapshot, grid: Grid): Entity { throw new Error(`restore: entity ${rec.id} has unknown faction "${rec.faction}"`); } + const activeEffects = readActiveEffects(rec); + const extra = normalizeEntityExtra(rec); const entry = ENTITY_RESTORE[rec.archetype]; @@ -1637,11 +1664,16 @@ function restoreEntity(rec: RunEntitySnapshot, grid: Grid): Entity { entity.alive = rec.alive ?? rec.hp > 0; entity.shieldHp = rec.shieldHp ?? 0; if (Number.isInteger(rec.ap)) { - if (rec.ap < 0 || rec.ap > entity.maxAp) { - throw new RangeError(`restore: entity ${rec.id} ap=${rec.ap} out of [0, ${entity.maxAp}]`); + const maxAp = + rec.archetype === 'berserk' && activeEffects.has(STATUS_EFFECT.SURGE) + ? entity.maxAp + SURGE_AP_BONUS + : entity.maxAp; + if (rec.ap < 0 || rec.ap > maxAp) { + throw new RangeError(`restore: entity ${rec.id} ap=${rec.ap} out of [0, ${maxAp}]`); } entity.ap = rec.ap; } + for (const [id, duration] of activeEffects) entity.applyEffect(id, duration); entity.stealthed = !!rec.stealthed; if (rec.faction) entity.faction = rec.faction; if (rec.glyph) entity.glyph = rec.glyph; @@ -1717,7 +1749,13 @@ function validateRecord(record: unknown): asserts record is RunSnapshot { } } -const KNOWN_ARCHETYPES_SET = new Set(['merc', 'razor', 'tech', 'decker']); +const KNOWN_ARCHETYPES_SET = new Set([ + 'merc', + 'razor', + 'tech', + 'decker', + 'berserk', +]); /** Clamp gear bonuses to archetype caps after restore. */ function repairGearForCrew(member: Crew) { @@ -2329,6 +2367,7 @@ function archetypeOfCrew(member: Crew): CrewArchetypeId { if (member instanceof Razor) return 'razor'; if (member instanceof Tech) return 'tech'; if (member instanceof Decker) return 'decker'; + if (member instanceof Berserk) return 'berserk'; throw new Error(`snapshotCampaign: cannot classify crew member ${member?.id}`); } diff --git a/src/game/surge.ts b/src/game/surge.ts new file mode 100644 index 0000000..95e404c --- /dev/null +++ b/src/game/surge.ts @@ -0,0 +1,28 @@ +import { AP_COST, STATUS_EFFECT, SURGE_DURATION } from './constants.js'; +import type { Entity } from './Entity.js'; + +export type SurgeCheck = + | { ok: true } + | { + ok: false; + reason: 'dead' | 'insufficient-ap' | 'already-surging' | 'crashing'; + }; + +/** Pure legality check for Berserk's self-targeted Surge. */ +export function canSurge(entity: Entity): SurgeCheck { + if (!entity.alive) return { ok: false, reason: 'dead' }; + if (!entity.canAfford(AP_COST.SURGE)) return { ok: false, reason: 'insufficient-ap' }; + if (entity.hasEffect(STATUS_EFFECT.SURGE)) return { ok: false, reason: 'already-surging' }; + if (entity.hasEffect(STATUS_EFFECT.CRASH)) return { ok: false, reason: 'crashing' }; + return { ok: true }; +} + +/** Commit Surge after validating every precondition; illegal calls do not mutate. */ +export function doSurge(entity: Entity): void { + const check = canSurge(entity); + if (!check.ok) { + throw new Error(`Illegal surge for ${entity.id}: ${check.reason}`); + } + entity.spendAp(AP_COST.SURGE); + entity.applyEffect(STATUS_EFFECT.SURGE, SURGE_DURATION); +} diff --git a/src/input/applyIntent.ts b/src/input/applyIntent.ts index e6922d8..58ef97e 100644 --- a/src/input/applyIntent.ts +++ b/src/input/applyIntent.ts @@ -70,6 +70,7 @@ import type { Tech } from '../game/archetypes/Tech.js'; import type { Merc } from '../game/archetypes/Merc.js'; import type { Razor } from '../game/archetypes/Razor.js'; import type { Decker } from '../game/archetypes/Decker.js'; +import type { Berserk } from '../game/archetypes/Berserk.js'; export type Intent = { type: string; @@ -386,6 +387,7 @@ function collectTileLoot(ctx: ApplyIntentContext) { * - `canVault` β†’ Merc's Vault * - `canSlide` β†’ Razor's Slide * - `canEmp` β†’ Decker's EMP neural-shock (self-centered AOE stun) + * - `canSurge` β†’ Berserk's Surge self-buff * - `canOverride` β†’ CyberAvatar's ICE override (cyber grid only) * * Capability sniffing (vs. a class `instanceof` check) keeps this module free @@ -416,6 +418,9 @@ function doSpecial(intent: Intent, ctx: ApplyIntentContext) { if (typeof (player as Decker).canEmp === 'function') { return doEmp(ctx); } + if (typeof (player as Berserk).canSurge === 'function') { + return doSurge(ctx); + } // Only the CyberAvatar still exposes canOverride (P3.5.M2 moved the Decker's // Meatspace perk to EMP; M4 repoints this picker at the Adept's Influence). if (typeof (player as CyberAvatar).canOverride === 'function') { @@ -424,6 +429,21 @@ function doSpecial(intent: Intent, ctx: ApplyIntentContext) { log('> SPECIAL: this archetype has no perk action.'); } +/** Trigger the Berserk's self-targeted Surge. */ +function doSurge(ctx: ApplyIntentContext) { + const { player, log } = ctx; + const berserk = player as Berserk; + const playerLabel = entityLabel(player); + const check = berserk.canSurge(); + if (!check.ok) { + log(`> ${playerLabel} SURGE DENIED: ${check.reason}`); + return; + } + berserk.surge(); + log(`> ${playerLabel} SURGES β€” power spikes before the crash (${player.ap} AP left).`); + gateOnApExhausted(ctx); +} + /** * Detonate the Decker's self-centered EMP: no aim, stuns everyone alive in * radius (friend, foe, and the Decker). Reports the stun count for legibility. diff --git a/src/render/combatHud.ts b/src/render/combatHud.ts index 0fc2813..feec1de 100644 --- a/src/render/combatHud.ts +++ b/src/render/combatHud.ts @@ -31,6 +31,9 @@ export type CombatHudIdentityInput = Readonly<{ stealthed: boolean; /** P3.5.M2: the controlled actor is EMP-stunned (0 AP next refresh). */ stunned?: boolean; + /** P3.5.M3: Berserk's active power window / mandatory payback window. */ + surging?: boolean; + crashing?: boolean; }>; export type CombatHudVitalInput = Readonly<{ @@ -135,6 +138,8 @@ export function formatIdentityHud(identity: CombatHudIdentityInput): string { let label = callsign ? `${callsign} [${archetype}]` : archetype; if (identity.stealthed) label += ' [CLOAKED]'; if (identity.stunned) label += ' [STUNNED]'; + if (identity.surging) label += ' [SURGING]'; + if (identity.crashing) label += ' [CRASH]'; return label; } diff --git a/src/shell/combatHudSnapshot.ts b/src/shell/combatHudSnapshot.ts index ab5d4dd..a501150 100644 --- a/src/shell/combatHudSnapshot.ts +++ b/src/shell/combatHudSnapshot.ts @@ -1,5 +1,5 @@ import { RUN_STATE } from '../game/Run.js'; -import { STATUS_EFFECT } from '../game/constants.js'; +import { STATUS_EFFECT, SURGE_AP_BONUS } from '../game/constants.js'; import type { Run } from '../game/Run.js'; import type { CombatHudDefenseInput, CombatHudSummaryInput } from '../render/combatHud.js'; import { CyberAvatar } from '../game/cyber/CyberAvatar.js'; @@ -51,6 +51,8 @@ export function combatHudBodyPanes( archetype: 'Avatar', stealthed: actor.stealthed, stunned: actor.hasEffect(STATUS_EFFECT.STUN), + surging: actor.hasEffect(STATUS_EFFECT.SURGE), + crashing: actor.hasEffect(STATUS_EFFECT.CRASH), }, hp: { hp: actor.hp, maxHp: actor.maxHp, label: 'RAM' }, ap: { ap: actor.ap, maxAp: actor.maxAp }, @@ -65,10 +67,15 @@ export function combatHudBodyPanes( archetype: crew === scene.player ? scene.archetype : crew.archetype, stealthed: crew.stealthed, stunned: crew.hasEffect(STATUS_EFFECT.STUN), + surging: crew.hasEffect(STATUS_EFFECT.SURGE), + crashing: crew.hasEffect(STATUS_EFFECT.CRASH), }, hp: { hp: crew.hp, maxHp: crew.maxHp }, ...(defense ? { defense } : {}), - ap: { ap: crew.ap, maxAp: crew.maxAp }, + ap: { + ap: crew.ap, + maxAp: crew.maxAp + (crew.hasEffect(STATUS_EFFECT.SURGE) ? SURGE_AP_BONUS : 0), + }, }; } diff --git a/sw-core.js b/sw-core.js index 81e3ee5..338f9ff 100644 --- a/sw-core.js +++ b/sw-core.js @@ -60,6 +60,10 @@ const CacheConfig = { '/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/empBlast.js', + '/src/game/surge.js', '/src/game/Campaign.js', '/src/game/campaignSummary.js', '/src/game/chronicle.js', diff --git a/sw-dev.js b/sw-dev.js index c2a3cab..711e8ff 100644 --- a/sw-dev.js +++ b/sw-dev.js @@ -1,6 +1,6 @@ // Service Worker for Kernel Panic - Development Version -const VERSION = '0.3.4-dev'; -importScripts('/sw-release.js?v=0.3.3b'); +const VERSION = '0.3.4b-dev'; +importScripts('/sw-release.js?v=0.3.4b'); importScripts(`/sw-core.js?v=${VERSION}`); if (!self.KernelPanicRelease) { diff --git a/sw-release.js b/sw-release.js index cf474b6..356a724 100644 --- a/sw-release.js +++ b/sw-release.js @@ -2,11 +2,11 @@ // Increment shellEpoch only when the new app shell cannot safely coexist with // the previous worker (for example, removed or renamed runtime modules). self.KernelPanicRelease = Object.freeze({ - version: '0.3.3b', + version: '0.3.4b', shellEpoch: 2, - title: 'Update controls online', + title: 'Berserk archetype online', highlights: Object.freeze([ - 'Release notes now accompany app updates.', - 'Breaking updates now require a safe restart.', + 'Recruit Berserk operators and trigger their Surge perk.', + 'Surge boosts damage and AP before an unavoidable Crash.', ]), }); diff --git a/sw.js b/sw.js index 65e864f..a86f1c2 100644 --- a/sw.js +++ b/sw.js @@ -1,6 +1,6 @@ // Service Worker for Kernel Panic - Production Version // Import shared caching core with cache-busting query parameter -const VERSION = '0.3.4'; +const VERSION = '0.3.4b'; importScripts(`/sw-release.js?v=${VERSION}`); importScripts(`/sw-core.js?v=${VERSION}`); diff --git a/tests/unit/game/Berserk.test.ts b/tests/unit/game/Berserk.test.ts new file mode 100644 index 0000000..556f175 --- /dev/null +++ b/tests/unit/game/Berserk.test.ts @@ -0,0 +1,114 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Berserk } from '../../../src/game/archetypes/Berserk.js'; +import { Entity } from '../../../src/game/Entity.js'; +import { + AP_COST, + CRASH_HIT_PENALTY, + STATUS_EFFECT, + SURGE_AP_BONUS, + SURGE_DAMAGE_BONUS, +} from '../../../src/game/constants.js'; + +function makeBerserk() { + return new Berserk({ id: 'berserk', x: 2, y: 2, maxAp: 4 }); +} + +test('Berserk inherits Entity, uses baseline stats, and starts without effects', () => { + const berserk = makeBerserk(); + assert.ok(berserk instanceof Entity); + assert.equal(berserk.baseHitChance, 0.75); + assert.equal(berserk.baseDodgeChance, 0.2); + assert.equal(berserk.hasEffect(STATUS_EFFECT.SURGE), false); + assert.equal(berserk.hasEffect(STATUS_EFFECT.CRASH), false); +}); + +test('Berserk.surge debits AP once and arms SURGE', () => { + const berserk = makeBerserk(); + const apBefore = berserk.ap; + berserk.surge(); + assert.equal(berserk.ap, apBefore - AP_COST.SURGE); + assert.equal(berserk.hasEffect(STATUS_EFFECT.SURGE), true); +}); + +test('Berserk.canSurge rejects dead, low-AP, already-surging, and crashing states', () => { + const dead = makeBerserk(); + dead.damage(dead.maxHp); + assert.equal(dead.canSurge().reason, 'dead'); + + const poor = makeBerserk(); + poor.ap = AP_COST.SURGE - 1; + assert.equal(poor.canSurge().reason, 'insufficient-ap'); + + const active = makeBerserk(); + active.surge(); + assert.equal(active.canSurge().reason, 'already-surging'); + + active.refreshAp(); + active.refreshAp(); + assert.equal(active.hasEffect(STATUS_EFFECT.CRASH), true); + assert.equal(active.canSurge().reason, 'crashing'); +}); + +test('SURGE adds ranged and melee damage without hiding gear damage', () => { + const berserk = makeBerserk(); + const rangedBefore = berserk.rangedAttackDamage(); + const meleeBefore = berserk.meleeAttackDamage(); + berserk.surge(); + assert.equal(berserk.rangedAttackDamage(), rangedBefore + SURGE_DAMAGE_BONUS); + assert.equal(berserk.meleeAttackDamage(), meleeBefore + SURGE_DAMAGE_BONUS); +}); + +test('SURGE grants bonus AP on an active refresh', () => { + const berserk = makeBerserk(); + berserk.surge(); + berserk.refreshAp(); + assert.equal(berserk.ap, berserk.maxAp + SURGE_AP_BONUS); + assert.equal(berserk.hasEffect(STATUS_EFFECT.SURGE), true); +}); + +test('STUN keeps a surging Berserk at 0 AP instead of leaking the Surge bonus', () => { + const berserk = makeBerserk(); + berserk.surge(); + berserk.applyEffect(STATUS_EFFECT.STUN, 1); + berserk.refreshAp(); + assert.equal(berserk.ap, 0); + assert.equal(berserk.hasEffect(STATUS_EFFECT.SURGE), true); +}); + +test('SURGE expiry automatically applies CRASH and its AP/accuracy penalties', () => { + const berserk = makeBerserk(); + const baseHitChance = berserk.baseHitChance; + berserk.surge(); + berserk.refreshAp(); + berserk.refreshAp(); + + assert.equal(berserk.hasEffect(STATUS_EFFECT.SURGE), false); + assert.equal(berserk.hasEffect(STATUS_EFFECT.CRASH), true); + assert.equal(berserk.ap, berserk.maxAp - 1); + assert.equal(berserk.baseHitChance, baseHitChance - CRASH_HIT_PENALTY); +}); + +test('CRASH penalty persists for its duration and restores hit chance exactly once', () => { + const berserk = makeBerserk(); + const baseHitChance = berserk.baseHitChance; + berserk.surge(); + berserk.refreshAp(); + berserk.refreshAp(); + berserk.refreshAp(); + assert.equal(berserk.baseHitChance, baseHitChance - CRASH_HIT_PENALTY); + assert.equal(berserk.ap, berserk.maxAp - 1); + + berserk.refreshAp(); + assert.equal(berserk.hasEffect(STATUS_EFFECT.CRASH), false); + assert.equal(berserk.baseHitChance, baseHitChance); + assert.equal(berserk.ap, berserk.maxAp); + + berserk.refreshAp(); + assert.equal( + berserk.baseHitChance, + baseHitChance, + 'later refreshes do not over-restore accuracy' + ); +}); diff --git a/tests/unit/game/Campaign.test.ts b/tests/unit/game/Campaign.test.ts index fa42ec2..9a1d23e 100644 --- a/tests/unit/game/Campaign.test.ts +++ b/tests/unit/game/Campaign.test.ts @@ -1586,8 +1586,8 @@ test('generateRecruits returns empty when Rep < threshold', () => { assert.equal(campaign.availableRecruits.length, 0); }); -test('generateRecruits archetype weights approximate 40/40/20 over many seeds', () => { - const counts: Record = { Merc: 0, Razor: 0, Tech: 0 }; +test('generateRecruits follows the 2/2/1/1 archetype pool over many seeds', () => { + const counts: Record = { Merc: 0, Razor: 0, Tech: 0, Berserk: 0 }; const total = 1000; for (let i = 0; i < total; i++) { const campaign = new Campaign({ seed: i, rep: 80 }); @@ -1595,14 +1595,16 @@ test('generateRecruits archetype weights approximate 40/40/20 over many seeds', counts[recruit.constructor.name]++; } } - const sum = counts.Merc + counts.Razor + counts.Tech; - // Merc and Razor should each be ~40%, Tech ~20%. Allow Β±8% tolerance. - assert.ok(counts.Merc / sum > 0.32, `Merc ${((counts.Merc / sum) * 100).toFixed(1)}% < 32%`); - assert.ok(counts.Merc / sum < 0.48, `Merc ${((counts.Merc / sum) * 100).toFixed(1)}% > 48%`); - assert.ok(counts.Razor / sum > 0.32, `Razor ${((counts.Razor / sum) * 100).toFixed(1)}% < 32%`); - assert.ok(counts.Razor / sum < 0.48, `Razor ${((counts.Razor / sum) * 100).toFixed(1)}% > 48%`); - assert.ok(counts.Tech / sum > 0.12, `Tech ${((counts.Tech / sum) * 100).toFixed(1)}% < 12%`); - assert.ok(counts.Tech / sum < 0.28, `Tech ${((counts.Tech / sum) * 100).toFixed(1)}% > 28%`); + const sum = counts.Merc + counts.Razor + counts.Tech + counts.Berserk; + // Merc/Razor ~33%; Tech/Berserk ~17%. Allow Β±8% tolerance. + for (const id of ['Merc', 'Razor']) { + const ratio = counts[id] / sum; + assert.ok(ratio > 0.25 && ratio < 0.41, `${id} ${(ratio * 100).toFixed(1)}% out of range`); + } + for (const id of ['Tech', 'Berserk']) { + const ratio = counts[id] / sum; + assert.ok(ratio > 0.09 && ratio < 0.25, `${id} ${(ratio * 100).toFixed(1)}% out of range`); + } }); test('generateRecruits deduplicates callsigns against flatlined crew members', () => { @@ -1760,7 +1762,7 @@ test('generateInitialCandidates returns RECRUIT.INITIAL_CANDIDATES (3) candidate }); test('generateInitialCandidates uses weighted archetype pool', () => { - const counts: Record = { Merc: 0, Razor: 0, Tech: 0 }; + const counts: Record = { Merc: 0, Razor: 0, Tech: 0, Berserk: 0 }; const total = 500; for (let i = 0; i < total; i++) { const campaign = new Campaign({ seed: i, crew: [] }); @@ -1769,12 +1771,11 @@ test('generateInitialCandidates uses weighted archetype pool', () => { counts[c.constructor.name]++; } } - const sum = counts.Merc + counts.Razor + counts.Tech; - // Merc and Razor should each be ~40%, Tech ~20%. Allow Β±10% tolerance. - assert.ok(counts.Merc / sum > 0.3, `Merc ${((counts.Merc / sum) * 100).toFixed(1)}% < 30%`); - assert.ok(counts.Merc / sum < 0.5, `Merc ${((counts.Merc / sum) * 100).toFixed(1)}% > 50%`); - assert.ok(counts.Tech / sum > 0.1, `Tech ${((counts.Tech / sum) * 100).toFixed(1)}% < 10%`); - assert.ok(counts.Tech / sum < 0.3, `Tech ${((counts.Tech / sum) * 100).toFixed(1)}% > 30%`); + const sum = counts.Merc + counts.Razor + counts.Tech + counts.Berserk; + assert.ok(counts.Berserk > 0, 'Berserk must be reachable in the starter candidate pool'); + assert.ok(counts.Merc / sum > 0.23 && counts.Merc / sum < 0.43); + assert.ok(counts.Tech / sum > 0.07 && counts.Tech / sum < 0.27); + assert.ok(counts.Berserk / sum > 0.07 && counts.Berserk / sum < 0.27); }); test('recruitInitial validates exactly RECRUIT.INITIAL_PICKS (2) IDs', () => { diff --git a/tests/unit/game/Run.test.ts b/tests/unit/game/Run.test.ts index 9ca7fde..da6edd1 100644 --- a/tests/unit/game/Run.test.ts +++ b/tests/unit/game/Run.test.ts @@ -23,6 +23,7 @@ import { findPath } from '../../../src/game/Pathfinding.js'; import { Rng } from '../../../src/rng.js'; import { testContractContext } from './contractTestUtils.js'; import { ITEM_ID } from '../../../src/game/items.js'; +import { Berserk } from '../../../src/game/archetypes/Berserk.js'; const fakeContract = (overrides = {}) => ({ seed: 12345, @@ -84,6 +85,14 @@ test('Run starts with state=null and a deployed crew member', () => { assert.equal(run.archetype, 'razor'); }); +test('Run classifies a Berserk and seeds matching telemetry', () => { + const crewMember = makeCrew('berserk'); + const run = new Run({ crewMember, seed: 43 }); + assert.ok(crewMember instanceof Berserk); + assert.equal(run.archetype, 'berserk'); + assert.equal(run.telemetry.archetype, 'berserk'); +}); + test('legal transition chain: BRIEFING β†’ COMBAT β†’ RESULT', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 42 }); run.enterBriefing(fakeContract()); diff --git a/tests/unit/game/archetypes.test.ts b/tests/unit/game/archetypes.test.ts index 9bb447d..f7518d7 100644 --- a/tests/unit/game/archetypes.test.ts +++ b/tests/unit/game/archetypes.test.ts @@ -23,6 +23,7 @@ import { pickCallsign, } from '../../../src/game/archetypes/index.js'; import { Decker } from '../../../src/game/archetypes/Decker.js'; +import { Berserk, CALLSIGNS as BERSERK_CALLSIGNS } from '../../../src/game/archetypes/Berserk.js'; import { FACTION } from '../../../src/game/constants.js'; import { Merc, CALLSIGNS as MERC_CALLSIGNS } from '../../../src/game/archetypes/Merc.js'; import { CALLSIGNS as RAZOR_CALLSIGNS } from '../../../src/game/archetypes/Razor.js'; @@ -49,7 +50,7 @@ test('Merc perk is vault; Razor perk is slide; Tech perk is deploy', () => { assert.deepEqual(ARCHETYPES.tech.perks, ['deploy']); }); -test('ARCHETYPE_IDS lists every registered id, in display order', () => { +test('ARCHETYPE_IDS preserves the legacy starter-selector display order', () => { // Display order matters for the character-select modal (default focus is // the first entry on first load). Merc β†’ Razor β†’ Tech, simplest kit first. assert.deepEqual(ARCHETYPE_IDS, ['merc', 'razor', 'tech']); @@ -72,6 +73,7 @@ test('each archetype exports a CALLSIGNS list of 10–15 unique entries', () => test('CALLSIGNS_BY_ARCHETYPE mirrors the per-archetype module exports', () => { assert.deepEqual(CALLSIGNS_BY_ARCHETYPE.merc, MERC_CALLSIGNS); assert.deepEqual(CALLSIGNS_BY_ARCHETYPE.razor, RAZOR_CALLSIGNS); + assert.deepEqual(CALLSIGNS_BY_ARCHETYPE.berserk, BERSERK_CALLSIGNS); }); test('pickCallsign returns a name from the archetype pool', () => { @@ -166,6 +168,7 @@ test('perkAim metadata: only self-centered perks are tagged "self"', () => { assert.equal(ARCHETYPES.razor.perkAim, 'directional'); assert.equal(ARCHETYPES.tech.perkAim, 'directional'); assert.equal(ARCHETYPES.decker.perkAim, 'self'); + assert.equal(ARCHETYPES.berserk.perkAim, 'self'); }); test('perkAimForArchetype resolves lowercase id and class-cased Crew.archetype', () => { @@ -173,6 +176,8 @@ test('perkAimForArchetype resolves lowercase id and class-cased Crew.archetype', assert.equal(perkAimForArchetype('Decker'), 'self', 'accepts class-cased archetype'); assert.equal(perkAimForArchetype('merc'), 'directional'); assert.equal(perkAimForArchetype('Razor'), 'directional'); + assert.equal(perkAimForArchetype('berserk'), 'self'); + assert.equal(perkAimForArchetype('Berserk'), 'self'); }); test('perkAimForArchetype throws on an unknown archetype (no silent fallback)', () => { @@ -197,6 +202,20 @@ test('buildCrewMember can still construct a Decker by id (recruitment path)', () assert.ok(CALLSIGNS_BY_ARCHETYPE.decker.includes(d.callsign)); }); +test('Berserk is registered, recruitable, and self-targeted', () => { + const a = ARCHETYPES.berserk; + assert.deepEqual(a.perks, ['surge']); + assert.equal(a.perkName, 'SURGE'); + assert.equal(a.perkAim, 'self'); + assert.ok(!ARCHETYPE_IDS.includes('berserk')); + assert.ok(RECRUIT_ARCHETYPE_POOL.includes('berserk')); + + const berserk = buildCrewMember('berserk', { x: 1, y: 2 }, new Rng(10)); + assert.ok(berserk instanceof Berserk); + assert.equal(isArchetypeId('berserk'), true); + assert.ok(BERSERK_CALLSIGNS.includes(berserk.callsign)); +}); + test('isArchetypeId is a string-set membership check', () => { assert.equal(isArchetypeId('merc'), true); assert.equal(isArchetypeId('razor'), true); diff --git a/tests/unit/game/persistence.test.ts b/tests/unit/game/persistence.test.ts index 416b0b0..7b24561 100644 --- a/tests/unit/game/persistence.test.ts +++ b/tests/unit/game/persistence.test.ts @@ -21,11 +21,13 @@ import { CONTRACT_DIFFICULTY, FACTION, SALVAGE_TO_CRED_RATE, + STATUS_EFFECT, TILE, } from '../../../src/game/constants.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'; +import { Berserk } from '../../../src/game/archetypes/Berserk.js'; import { PatrolHostile } from '../../../src/game/ai/PatrolHostile.js'; import { applyOverride } from '../../../src/game/droneOverride.js'; import { Rng } from '../../../src/rng.js'; @@ -118,6 +120,49 @@ test('snapshot/restore round-trips a Decker deploy (P3.M2)', () => { assert.deepEqual(snapshot(restoredRun), rec); }); +test('snapshot/restore round-trips a Berserk deploy (P3.5.M3)', () => { + const run = freshCombatRun(0xb3e5e4, 'berserk'); + assert.ok(run.player instanceof Berserk, 'fixture should deploy a Berserk'); + const rec = snapshot(run); + const { run: restoredRun } = restore(rec); + assert.ok(restoredRun.player instanceof Berserk, 'Berserk should restore as a Berserk'); + assert.equal(restoredRun.player.callsign, run.player.callsign); + assert.deepEqual(snapshot(restoredRun), rec); +}); + +test('snapshot/restore preserves an active Berserk Surge, including bonus AP', () => { + const run = freshCombatRun(0xb3e5e5, 'berserk'); + const berserk = run.player as Berserk; + berserk.surge(); + berserk.refreshAp(); + assert.equal(berserk.ap, berserk.maxAp + 1); + + const rec = snapshot(run); + const { run: restoredRun } = restore(rec); + const restored = restoredRun.player as Berserk; + assert.ok(restored instanceof Berserk); + assert.equal(restored.hasEffect(STATUS_EFFECT.SURGE), true); + assert.equal(restored.ap, restored.maxAp + 1); + assert.deepEqual(snapshot(restoredRun), rec); +}); + +test('snapshot/restore preserves Berserk Crash and its derived hit penalty', () => { + const run = freshCombatRun(0xb3e5e6, 'berserk'); + const berserk = run.player as Berserk; + berserk.surge(); + berserk.refreshAp(); + berserk.refreshAp(); + assert.equal(berserk.hasEffect(STATUS_EFFECT.CRASH), true); + + const rec = snapshot(run); + const { run: restoredRun } = restore(rec); + const restored = restoredRun.player as Berserk; + assert.ok(restored instanceof Berserk); + assert.equal(restored.hasEffect(STATUS_EFFECT.CRASH), true); + assert.equal(restored.baseHitChance, 0.65); + assert.deepEqual(snapshot(restoredRun), rec); +}); + test('snapshot/restore preserves a live drone-override (P3.M2)', () => { const run = freshCombatRun(0xbadbeef, 'decker'); const drone = [...run.world.entities.values()].find(e => e instanceof PatrolHostile); diff --git a/tests/unit/input/applyIntent.test.ts b/tests/unit/input/applyIntent.test.ts index 8ed6ebf..810c342 100644 --- a/tests/unit/input/applyIntent.test.ts +++ b/tests/unit/input/applyIntent.test.ts @@ -11,12 +11,14 @@ import { AP_COST, MELEE_DAMAGE, SALVAGE_PER_IMPROVISED_TURRET, + STATUS_EFFECT, } from '../../../src/game/constants.js'; import { makeSalvage, totalSalvage } from '../../../src/game/salvage.js'; import { Merc } from '../../../src/game/archetypes/Merc.js'; import { Razor } from '../../../src/game/archetypes/Razor.js'; import { Tech } from '../../../src/game/archetypes/Tech.js'; import { Decker } from '../../../src/game/archetypes/Decker.js'; +import { Berserk } from '../../../src/game/archetypes/Berserk.js'; import { CyberAvatar } from '../../../src/game/cyber/CyberAvatar.js'; import { ProbeIce } from '../../../src/game/cyber/ProbeIce.js'; import { Turret } from '../../../src/game/Turret.js'; @@ -51,7 +53,9 @@ function buildCtx({ archetype = 'merc', placeDrone = true } = {}) { ? new Merc({ id: 'merc', x: 2, y: 2, maxAp: 4 }) : archetype === 'tech' ? new Tech({ id: 'tech', x: 2, y: 2, maxAp: 4 }) - : new Razor({ id: 'razor', x: 2, y: 2, maxAp: 4 }); + : archetype === 'berserk' + ? new Berserk({ id: 'berserk', x: 2, y: 2, maxAp: 4 }) + : new Razor({ id: 'razor', x: 2, y: 2, maxAp: 4 }); world.addEntity(player); let drone = null; @@ -512,6 +516,16 @@ test('special intent routes to EMP on a Decker and stuns a same-faction ally in assert.ok(log.some(line => line.includes('EMP'))); }); +test('special intent routes to Surge on a Berserk without entering directional movement', () => { + const { ctx, log, player } = buildCtx({ archetype: 'berserk', placeDrone: false }); + const positionBefore = { x: player.x, y: player.y }; + applyIntent({ type: 'special', dx: 0, dy: 0 }, ctx); + assert.deepEqual({ x: player.x, y: player.y }, positionBefore); + assert.equal(player.hasEffect(STATUS_EFFECT.SURGE), true); + assert.equal(player.ap, player.maxAp - AP_COST.SURGE); + assert.ok(log.some(line => line.includes('SURGES'))); +}); + test('special intent routes CyberAvatar Override against Probe ICE', () => { const grid = new Grid(8, 5); const bus = new EventBus(); diff --git a/tests/unit/render/combatHud.test.ts b/tests/unit/render/combatHud.test.ts index 99badf0..b66da03 100644 --- a/tests/unit/render/combatHud.test.ts +++ b/tests/unit/render/combatHud.test.ts @@ -107,6 +107,27 @@ test('formatIdentityHud appends [STUNNED] when the controlled actor is EMP-stunn ); }); +test('formatIdentityHud surfaces Berserk Surge and Crash states', () => { + assert.equal( + formatIdentityHud({ + callsign: 'Fury', + archetype: 'berserk', + stealthed: false, + surging: true, + }), + 'Fury [BERSERK] [SURGING]' + ); + assert.equal( + formatIdentityHud({ + callsign: 'Fury', + archetype: 'berserk', + stealthed: false, + crashing: true, + }), + 'Fury [BERSERK] [CRASH]' + ); +}); + test('formatHpSegments right-fills live HP segments', () => { assert.equal(formatHpSegments({ hp: 3, maxHp: 3 }), 'HP β– β– β– '); assert.equal(formatHpSegments({ hp: 2, maxHp: 3 }), 'HP β–‘β– β– '); @@ -131,6 +152,8 @@ test('formatApPips right-fills available AP pips', () => { assert.equal(formatApPips({ ap: 4, maxAp: 4 }), 'AP ●●●●'); assert.equal(formatApPips({ ap: 2, maxAp: 4 }), 'AP ○○●●'); assert.equal(formatApPips({ ap: 0, maxAp: 4 }), 'AP β—‹β—‹β—‹β—‹'); + assert.equal(formatApPips({ ap: 5, maxAp: 5 }), 'AP ●●●●●'); + assert.equal(formatApPips({ ap: 4, maxAp: 5 }), 'AP ○●●●●'); }); test('vital formatters reject invalid counts instead of hiding impossible state', () => { diff --git a/tests/unit/shell/combatHudSnapshot.test.ts b/tests/unit/shell/combatHudSnapshot.test.ts index 16e4716..3ae72c6 100644 --- a/tests/unit/shell/combatHudSnapshot.test.ts +++ b/tests/unit/shell/combatHudSnapshot.test.ts @@ -2,6 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { Merc } from '../../../src/game/archetypes/Merc.js'; +import { Berserk } from '../../../src/game/archetypes/Berserk.js'; import { ITEM_ID } from '../../../src/game/items.js'; import type { Run } from '../../../src/game/Run.js'; import { combatHudBodyPanes } from '../../../src/shell/combatHudSnapshot.js'; @@ -43,3 +44,30 @@ test('combatHudBodyPanes omits defense readout when no defensive gear is equippe assert.equal(combatHudBodyPanes(scene).defense, undefined); }); + +test('combatHudBodyPanes exposes the Berserk Surge and Crash windows', () => { + const crew = new Berserk({ id: 'crew-berserk', x: 0, y: 0, callsign: 'Fury' }); + const scene = { + player: crew, + meatActor: crew, + activeLayer: 'meat', + archetype: 'berserk', + } as unknown as Run; + + crew.surge(); + const surgePanes = combatHudBodyPanes(scene); + assert.equal(surgePanes.identity.surging, true); + assert.equal(surgePanes.identity.crashing, false); + assert.deepEqual(surgePanes.ap, { ap: 2, maxAp: 5 }, 'HUD reserves the Surge bonus pip'); + + crew.refreshAp(); + assert.deepEqual( + combatHudBodyPanes(scene).ap, + { ap: 5, maxAp: 5 }, + 'the exact 5/4 smoketest crash is represented as a valid five-pip counter' + ); + crew.refreshAp(); + assert.equal(combatHudBodyPanes(scene).identity.surging, false); + assert.equal(combatHudBodyPanes(scene).identity.crashing, true); + assert.deepEqual(combatHudBodyPanes(scene).ap, { ap: 3, maxAp: 4 }); +}); From 3960c4d42e7499bc452f91dac584a72f838432ca Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Mon, 13 Jul 2026 18:13:48 -0700 Subject: [PATCH 4/9] Berserk tuning --- docs/phase-3.5-plan.md | 59 ++++++++++------ src/game/Combat.ts | 6 +- src/game/Entity.ts | 11 +++ src/game/TurnQueue.ts | 11 +++ src/game/archetypes/Berserk.ts | 16 ++++- src/game/constants.ts | 19 ++++-- src/game/events.ts | 10 +++ src/input/applyIntent.ts | 5 +- src/render/animations.ts | 45 ++++++++++++- src/render/palette.ts | 9 +++ src/shell/combatHudSnapshot.ts | 3 +- src/shell/sceneListeners.ts | 13 ++++ tests/unit/game/Berserk.test.ts | 78 +++++++++++++++++++--- tests/unit/game/Entity.test.ts | 6 +- tests/unit/game/Razor.test.ts | 6 +- tests/unit/game/TurnQueue.test.ts | 30 +++++++++ tests/unit/game/empBlast.test.ts | 12 +++- tests/unit/game/persistence.test.ts | 22 +++++- tests/unit/input/applyIntent.test.ts | 8 ++- tests/unit/render/animations.test.ts | 41 +++++++++++- tests/unit/shell/combatHudSnapshot.test.ts | 6 +- 21 files changed, 361 insertions(+), 55 deletions(-) diff --git a/docs/phase-3.5-plan.md b/docs/phase-3.5-plan.md index 27c4e75..e011c6a 100644 --- a/docs/phase-3.5-plan.md +++ b/docs/phase-3.5-plan.md @@ -197,7 +197,8 @@ export const CRASH_AP_PENALTY = 1; // symmetric with SURGE_AP_BONUS β€” n export const CRASH_HIT_PENALTY = 0.1; // mirrors the existing 0.1-per-tier TARGETING_BONUS/DODGE_BONUS step // AP_COST.SURGE: 2, matches the "every perk costs 2 AP" convention ``` -**Base stats: `baseHitChance = 0.75`, `baseDodgeChance = 0.20`** β€” generic baseline like Tech; differentiation lives entirely in the surge/crash swing. **Armor note for M6:** Berserk's centroid collides exactly with Tech's on the hit/dodge plane β€” M6 resolves this via the armor axis (`armor === 0 β†’ Tech`, `armor > 0 β†’ Berserk`). +> **Retuned in the M3 enrichment pass (see as-built notes):** playtest showed the "roughly even" Crash was too soft to make Surge a real gamble. Shipped values are `CRASH_DURATION = 3`, `CRASH_AP_PENALTY = 2`, `CRASH_HIT_PENALTY = 0.2`, and a new `SURGE_ARMOR_BONUS = 1` β€” the payback now outlasts and outweighs the spike it pays for. +**Base stats: `baseHitChance = 0.78`, `baseDodgeChance = 0.36`** β€” the fast, high-melee frenzy identity (respecced from an initial `0.75/0.20` generic-baseline placeholder once M6 gave Berserk its high-dodge anchor); the surge/crash swing layers on top. **~~Armor note for M6:~~ (Superseded by M6.)** An earlier draft had Berserk share Tech's exact `(0.75, 0.20)` centroid and resolved the collision via the armor axis (`armor === 0 β†’ Tech`, `armor > 0 β†’ Berserk`). That made Berserk only ~1% rollable (gated behind the rare armor roll), so M6 instead gives Berserk its own high-dodge classification anchor `(0.78, 0.36)` and drops armor as a classifier entirely. Berserk's default base stats above were respecced to match that anchor exactly (`0.78/0.36`). **Wiring surface** (every place a `CrewArchetypeId` fans out β€” this exact list is reused verbatim for M4 and M5, so it's spelled out once here): - `src/game/archetypes/index.ts` β€” `BUILDERS`, `ARCHETYPES`, `CALLSIGNS_BY_ARCHETYPE`, `ARCHETYPE_IDS`. @@ -214,13 +215,19 @@ Berserk is recruit-pool only β€” moot as a distinct decision once M6 lands, sinc **Implementation notes (as-built):** - `Berserk` and the pure `surge.ts` legality/commit module shipped with the proposed tuning and callsign pool. Surge is self-targeted, costs 2 AP, adds +1 ranged/melee damage while active, and grants +1 AP on an active Surge refresh. It cannot be re-armed during Surge or Crash, so the mandatory payback cannot be postponed indefinitely. -- Surge expiry immediately arms a two-refresh Crash. Crash applies -1 AP and -0.1 base hit chance while active; accuracy is derived directly from `STATUS_EFFECT.CRASH` rather than tracked in a second mutable flag, so expiry and restore cannot over-/under-correct the stat. +- Surge expiry immediately arms a Crash. Accuracy is derived directly from `STATUS_EFFECT.CRASH` rather than tracked in a second mutable flag, so expiry and restore cannot over-/under-correct the stat. *(Crash duration and penalties were retuned in the enrichment pass below β€” originally a two-refresh, -1 AP / -0.1 hit window.)* - **Run persistence correction:** the earlier phase-level assumption that timed effects never meet persistence was false for mid-combat autosaves. `RunEntitySnapshot.effects` now stores validated non-stealth timed effects (legacy `stealthed` stays byte-compatible), so Surge/Crash cannot be cleared by reload. Restore accepts Berserk's legitimate `maxAp + SURGE_AP_BONUS` snapshot only while Surge is present and fails loud on unknown/reserved/malformed effects. - Full archetype fan-out landed: registry/factory/callsigns/perk metadata, recruit pool, Run classification/telemetry/snapshot, campaign/run restore, capability-based input dispatch, and self-targeting keyboard/touch behavior. The interim weighted pool is now `2 Merc / 2 Razor / 1 Tech / 1 Berserk`; its tests include Berserk in the denominator instead of silently ignoring a new class. M6 still replaces this pool with stat-first derivation. - Player-facing state is explicit: combat HUD appends `[SURGING]` / `[CRASH]`, the log announces Surge/denials, and Key Help no longer tells self-targeted perks to pick a direction. Offline precache includes Berserk/Surge plus the previously omitted Decker/EMP modules; service-worker/release version is `0.3.4b`. - **Smoketest correction (`0.3.4b`):** the combat HUD now treats active Surge as a real `maxAp + 1` five-pip capacity, fixing the tier-1 `ap must be <= 4, got 5` paint fault and preserving the spent fifth pip. Berserk's refresh override also respects the base stun gate, so overlapping Stun cannot leak one AP back into a deliberately zero-AP activation. - Verification: focused Berserk/wiring/persistence tests, `npm run format`, `npm run lint`, and full `npm test` pass. Browser smoke at `http://localhost:8099/` resumed combat without console warnings/errors and installed the new service worker successfully. +**M3 enrichment pass (playtest feedback, as-built):** three follow-ups from playing the Berserk β€” the surge/crash swing read as too weak and too invisible. +- **+1 armor while surging (`SURGE_ARMOR_BONUS = 1`).** Deliberately *not* modelled by mutating `damageReduction` the way gear (Subdermal Plating) does: `run.player` **is** the campaign crew object, and `damageReduction` is a persisted stat, so a stored buff would leak permanently if a run ends mid-Surge (e.g. surge, then step onto the exit the same turn β€” the crew returns to the Hub carrying `base+1` with no effect left to remove it). Instead the buff is **computed, never stored**: a new `Entity.effectiveDamageReduction` getter defaults to `damageReduction`; `Berserk` overrides it to add the bonus while `hasEffect('surge')`. `Combat.applyDamageReduction` and the HUD defense pane read `effectiveDamageReduction`; every snapshot keeps reading the pristine `damageReduction`. Structurally impossible to leak β€” the persistence round-trip test asserts the record stores base armor mid-Surge and re-derives the buff from the restored effect. +- **Harsher, longer Crash.** `CRASH_DURATION` 2β†’3, `CRASH_AP_PENALTY` 1β†’2, `CRASH_HIT_PENALTY` 0.1β†’0.2. Crash tests are duration-driven (loop over `CRASH_DURATION`) so a future retune can't rot them. +- **Renderer pulses beyond the HUD tag.** New presentation-only bus events `BERSERK_SURGED` (emitted from `applyIntent.doSurge`, where `world` is in scope) and `BERSERK_CRASHED` (emitted from `TurnQueue.endTurn` on the exact surgeβ†’crash refresh edge, exactly once). `triggerSurgeFlash` (blaze orange) / `triggerCrashFlash` (ashen violet-grey) reuse the same colored-vignette primitive the Decker EMP flash drives, wired in `sceneListeners` beside the EMP listener. Palette tints live in `palette.ts` (`SURGE_FLASH_FG` / `CRASH_FLASH_FG`). +- Verification: full `npm test` (2012 pass), `npm run lint`, `npm run format`. Visual pulse confirmation is a live-playtest observation (no node-testable surface); the flash triggers and both event emissions are unit-covered. + --- ## P3.5.M4 β€” Adept archetype: Influence (Override, renamed and rehomed) @@ -266,7 +273,7 @@ Doc comments reflavor from "hijack a corp drone's allegiance" to "psychically do Fiction is deliberately unresolved: Chimera is a sustain operative built around a semi-sentient nanite swarm, or an awakened AI in an android chassis β€” the game (and possibly the character) never confirms which. Flavor/log text should preserve that ambiguity rather than pick a side. -**New `src/game/archetypes/Chimera.ts`:** thin `Crew` subclass, archetype `'Chimera'`. **Base stats: `baseHitChance = 0.75`, `baseDodgeChance = 0.25`** β€” deliberately its own point on the hit/dodge plane (not shared with Tech/Berserk's `(0.75, 0.20)`), so M6's centroid table doesn't need a third collision resolved by armor; Tech/Berserk stay a clean 2-way armor tie-break. +**New `src/game/archetypes/Chimera.ts`:** thin `Crew` subclass, archetype `'Chimera'`. **Base stats: `baseHitChance = 0.75`, `baseDodgeChance = 0.25`** β€” its own identity point on the hit/dodge plane, distinct from Tech/Berserk's `(0.75, 0.20)`. (These are the *default* stats / old-save fallback; M6 classifies rolled crew against a separate tuned anchor table β€” Chimera's classification anchor is `(0.79, 0.20)` β€” and no longer uses any armor tie-break. See M6.) **New pure module `src/game/nanoRepair.ts`** (mirrors Tech's `improviseTurret` shape exactly β€” resource-gated, repeatable, no per-job cap): ```ts @@ -308,34 +315,42 @@ Repeatable every turn as long as the shared scrap pool allows β€” same resource- - **No weighted pool, no starter-variety guarantee** β€” every non-Decker crew member (campaign-start trio *and* mid-campaign recruits) goes through the same roll-then-derive pipeline. `RECRUIT_ARCHETYPE_POOL`'s hand-weighted array is retired outright. Duplicates are allowed at campaign start (e.g. two Merc-flavored operatives is a legal, if unlucky, roll). **Mechanical refactor: `baseHitChance`/`baseDodgeChance` getter β†’ field.** Convert each archetype's `override get baseHitChance(): number { return X; }` into a constructor-settable instance field: -- Extract each literal into a named constant in `constants.ts` (`MERC_DEFAULT_HIT_CHANCE = 0.8`, `RAZOR_DEFAULT_HIT_CHANCE = 0.7` / `RAZOR_DEFAULT_DODGE_CHANCE = 0.35`, `TECH_DEFAULT_HIT_CHANCE = 0.75`, `DECKER_DEFAULT_HIT_CHANCE = 0.7`, `BERSERK_DEFAULT_HIT_CHANCE = 0.75`, `ADEPT_DEFAULT_HIT_CHANCE = 0.7`, `CHIMERA_DEFAULT_HIT_CHANCE = 0.75` / `CHIMERA_DEFAULT_DODGE_CHANCE = 0.25`). +- Extract each literal into a named constant in `constants.ts` (`MERC_DEFAULT_HIT_CHANCE = 0.8`, `RAZOR_DEFAULT_HIT_CHANCE = 0.7` / `RAZOR_DEFAULT_DODGE_CHANCE = 0.35`, `TECH_DEFAULT_HIT_CHANCE = 0.75`, `DECKER_DEFAULT_HIT_CHANCE = 0.7`, `BERSERK_DEFAULT_HIT_CHANCE = 0.78` / `BERSERK_DEFAULT_DODGE_CHANCE = 0.36`, `ADEPT_DEFAULT_HIT_CHANCE = 0.7`, `CHIMERA_DEFAULT_HIT_CHANCE = 0.75` / `CHIMERA_DEFAULT_DODGE_CHANCE = 0.25`). - `CrewInit` gains optional `baseHitChance?`/`baseDodgeChance?`; each constructor does `this.baseHitChance = baseHitChance ?? _DEFAULT_HIT_CHANCE;`, deleting the `override get` block. - **Explicit regression requirement:** `new Merc({...})` with no override must still yield `0.8`, etc. β€” keeps `tests/unit/game/Crew.test.ts:404-497`'s existing assertions passing *unmodified*, proving the refactor alone changes nothing. - `damageReduction`/HP/AP need no change β€” already constructor-settable instance fields. -**Derivation rule β€” nearest-centroid** (`src/game/crewStatRoll.ts`, new pure module). Six centroids, reusing each archetype's current fixed `(hitChance, dodgeChance)`: +**Derivation rule β€” nearest-anchor** (`src/game/crewStatRoll.ts`, new pure module). Six classification anchors on the `(hitChance, dodgeChance)` plane, tuned for an even partition (`CREW_STAT_ANCHORS`). **Armor plays no role in classification** β€” it's rolled purely as a combat stat (`damageReduction`). Agility is the primary spread axis: the "fast" pair (Berserk, Razor) owns the high-dodge region, Merc sits mid-dodge, and the "slow" trio (Chimera, Tech, Adept) fills the low-dodge band separated along the hit axis: -| Archetype | hitChance | dodgeChance | armor tie-break | +| Archetype | anchor hit | anchor dodge | fiction | |---|---|---|---| -| Merc | 0.80 | 0.20 | β€” | -| Razor | 0.70 | 0.35 | β€” | -| Adept | 0.70 | 0.20 | β€” | -| Tech | 0.75 | 0.20 | `armor === 0` | -| Berserk | 0.75 | 0.20 | `armor > 0` | -| Chimera | 0.75 | 0.25 | β€” | +| Merc | 0.83 | 0.27 | ace shot, some mobility | +| Berserk | 0.78 | 0.36 | fast, high-melee frenzy | +| Razor | 0.68 | 0.36 | evasive melee/stealth | +| Chimera | 0.79 | 0.20 | accurate, slow sustain | +| Tech | 0.73 | 0.19 | slow generalist | +| Adept | 0.67 | 0.20 | slow, weak shot (bring for Influence) | + +Classification is squared Euclidean distance from the rolled `(hitChance, dodgeChance)` to each anchor; minimum wins; break any exact boundary tie by fixed priority `merc > razor > adept > tech > berserk > chimera` for determinism. -Only Tech/Berserk collide on the hit/dodge plane (by design β€” see M3); resolved by the armor axis. Otherwise: squared Euclidean distance from the rolled tuple to each centroid, pick the minimum; break any remaining exact tie by fixed priority `merc > razor > adept > tech > berserk > chimera` for determinism. +**Resulting spawn distribution** (verified by the exhaustive sweep below, uniform roll over the widened ranges): Merc ~15%, Razor ~21%, Adept ~14%, Tech ~13%, Berserk ~21%, Chimera ~16% β€” every archetype lands in 13–21%. This replaces an earlier "anchors = each archetype's default stats + armor tie-break for Berserk" design that computed out to **Razor ~35% / Berserk ~1%** β€” Berserk was near-unrollable because it shared Tech's exact centroid and only won on the rare (15%) armor roll, contradicting the phase's "all seven archetypes reachable via the roll" completion criterion. + +**Why the anchors are a *separate table* from the archetype default stats (old-save safety):** the discarded design doubled the classification centroids as the archetype default base stats. But those defaults are also the pre-P3.5 old-save fallback (`DEFAULT_*_BY_ARCHETYPE`, below) β€” retuning them to fix the distribution would silently restore legacy saved crew (Merc/Razor/Tech) to stats they never had (violates completion criterion #4). So `CREW_STAT_ANCHORS` is its own table tuned only for the partition, while the fallback default constants for the three legacy archetypes stay **frozen** at their shipped values (`Merc 0.80/0.20`, `Razor 0.70/0.35`, `Tech 0.75/0.20`). The three P3.5-new archetypes (Berserk/Adept/Chimera) have no pre-P3.5 saves, so their defaults are free β€” but the anchor table above is what `deriveArchetype` reads regardless. The derived crew member always gets its *rolled* stats, not the anchor. ```ts export function rollCrewStats(rng: Rng): { hitChance: number; dodgeChance: number; armor: number } -export function deriveArchetype(stats): CrewArchetypeId +// ^ hitChance/dodgeChance rolled as uniform floats then rounded to 0.01 inside this fn; +// deriveArchetype receives the already-rounded tuple. +export function deriveArchetype(stats): CrewArchetypeId // reads hitChance/dodgeChance only; armor is not a classifier ``` -Roll ranges, anchored to today's observed spread so day-one balance doesn't quietly widen: -- `hitChance`: uniform pick from `{0.70, 0.75, 0.80}`. -- `dodgeChance`: uniform pick from `{0.20, 0.25, 0.30, 0.35}`. -- `armor`: `rng.chance(0.15) β†’ 1, else 0` β€” conservative, since armor is a wholly new variance axis with no prior balance data. +Roll ranges β€” continuous and **deliberately wider than today's discrete spread** (P3.5 refinement: the old `{0.70,0.75,0.80}` / `{0.20,0.25,0.30,0.35}` buckets clustered crew onto a handful of identical stat lines; continuous rolls over a widened range give every operative a distinct feel). Roll a uniform float, then **round to 0.01** so the HUD reads clean whole-percents and the derivation domain stays finite/enumerable: +- `hitChance`: uniform in `[0.65, 0.85]`, rounded to 0.01 β†’ 21 discrete values. +- `dodgeChance`: uniform in `[0.15, 0.40]`, rounded to 0.01 β†’ 26 discrete values. +- `armor`: `rng.chance(0.15) β†’ 1, else 0` β€” unchanged; conservative, since armor is a wholly new variance axis with no prior balance data. + +**These ranges overrun the anchor hull on purpose** (anchor hit spans 0.67–0.83, dodge 0.19–0.36). Rolls in the outer margins β€” e.g. hit 0.85 / dodge 0.15 β†’ Chimera, hit 0.85 / dodge 0.40 β†’ Berserk, hit 0.65 / dodge 0.40 β†’ Razor, hit 0.65 / dodge 0.15 β†’ Adept β€” have no anchor of their own and saturate to the nearest corner archetype, so the widened tails read as "an unusually sharp/evasive operative of an existing archetype," not a new class. **Balance caveat:** this widening is a deliberate difficulty change β€” genuine stat extremes (a 0.65-hit or 0.40-dodge crew member) now occur that the old buckets never produced. Needs a fresh playtest eyeball, not just green tests. -**Test requirement, exhaustive by design:** the roll domain is small and finite (`3 Γ— 4 Γ— 2 = 24` combinations) β€” table-test `deriveArchetype` against **all 24 discrete tuples**, not just the six exact centroids, to prove there's no accidental collision or dead zone anywhere in the grid. +**Test requirement, exhaustive by design:** classification reads only `(hitChance, dodgeChance)`, and rounding to 0.01 keeps that domain finite (`21 Γ— 26 = 546` tuples) β€” sweep `deriveArchetype` across **every hit/dodge tuple on the rounded grid** and assert each resolves to a registered `CrewArchetypeId` (no dead zones, no throw) *and* that all six archetypes appear (no starved anchor). Layer targeted assertions on top of the sweep: (a) each of the six anchors maps to its own archetype; (b) Voronoi-boundary points equidistant between two anchors resolve deterministically via the fixed priority tie-break; (c) the four widened corners saturate as tabled above (0.85/0.15β†’Chimera, 0.85/0.40β†’Berserk, 0.65/0.40β†’Razor, 0.65/0.15β†’Adept); (d) the measured distribution over the full grid stays within the ~13–21% spread above β€” a guard so a future anchor edit can't silently re-skew back toward the discarded 35/1. **RNG determinism:** every stat roll goes through `rng.fork('crew-stats')` (per `rng.ts:106-121`'s documented "add a mechanic without perturbing other rolls" use case), not the raw campaign `this.rng`. New wrapper, additive (doesn't touch `buildCrewMember`'s existing archetype-first signature, still used by `#assignDecker`/tests): ```ts @@ -363,7 +378,7 @@ Restore: `baseHitChance: rec.baseHitChance ?? DEFAULT_HIT_CHANCE_BY_ARCHETYPE[re **Critical files:** `src/game/Crew.ts`, `src/game/crewStatRoll.ts` (new), `src/game/Campaign.ts`, `src/game/persistence.ts`. -**Tests:** `tests/unit/game/Crew.test.ts` (extend, don't break, existing getter-value assertions; add constructor-override cases for all six archetypes), `tests/unit/game/crewStatRoll.test.ts` (new β€” the exhaustive 24-point table above, plus tie-break determinism), `tests/unit/game/Campaign.test.ts` (`buildCrew`/`generateRecruits` produce rolled stats; `rng.fork('crew-stats')` doesn't perturb existing callsign/combat rolls), `tests/unit/game/persistence.test.ts` (`CampaignCrewSnapshot` round-trip with/without the new optional fields; legacy-save defaults to old fixed constant). +**Tests:** `tests/unit/game/Crew.test.ts` (extend, don't break, existing getter-value assertions; add constructor-override cases for all six archetypes), `tests/unit/game/crewStatRoll.test.ts` (new β€” the exhaustive 546-tuple hit/dodge grid sweep above, the anchor/boundary/corner-saturation + distribution-spread assertions, tie-break determinism, and a property check that `rollCrewStats` only ever emits values on the 0.01 grid within `[0.65,0.85]`/`[0.15,0.40]`), `tests/unit/game/Campaign.test.ts` (`buildCrew`/`generateRecruits` produce rolled stats; `rng.fork('crew-stats')` doesn't perturb existing callsign/combat rolls), `tests/unit/game/persistence.test.ts` (`CampaignCrewSnapshot` round-trip with/without the new optional fields; legacy-save defaults to old fixed constant). --- @@ -377,8 +392,8 @@ Per milestone: `npm test` (typecheck + build tests + `node --test`) must pass, i - **M1:** drive a combat run where a Razor slides, confirm stealth clears on schedule (regression); construct a synthetic stunned entity and confirm it takes 0 AP that turn via the actual `TurnQueue.endTurn` path, not just the unit-level `refreshAp` call. - **M2:** play a Decker to EMP with a teammate adjacent β€” confirm the ally is stunned too (0 AP next refresh) and a corp unit in radius is stunned. -- **M3:** play a Berserk through a full surgeβ†’crash cycle β€” confirm damage/AP bonus during surge, confirm crash auto-applies on expiry with the accuracy penalty visible in the HUD hit-chance display, confirm crash itself expires cleanly back to baseline. +- **M3:** play a Berserk through a full surgeβ†’crash cycle β€” confirm damage/AP bonus **and +1 armor** during surge (the armor pane shows in the HUD), confirm the **surge and crash screen pulses** fire, confirm crash auto-applies on expiry with the accuracy penalty visible in the HUD hit-chance display, confirm crash itself expires cleanly back to baseline after its (now longer) window. - **M4:** play an Adept, confirm Influence behaves identically to the old Override (aim-sector targeting, success roll, alarm on failure, countdown-and-revert) end to end; confirm `mindInfluence.test.ts` covers the mechanic independent of any archetype wiring. - **M5:** play a Chimera, kill a hostile, collect its scrap drop, convert it to HP β€” confirm repeatable across turns as long as scrap lasts and HP clamps at max. -- **M6:** start several fresh campaigns (different seeds) and confirm crew stats vary run-to-run but land only on the discrete roll values, and archetypes span all six non-Decker options across enough campaigns; save mid-campaign, reload, confirm rolled stats round-trip; load a pre-P3.5 save fixture (or a save snapshot lacking `baseHitChance`) and confirm it restores to the old fixed per-archetype constant rather than crashing or silently rerolling. +- **M6:** start several fresh campaigns (different seeds) and confirm crew stats vary run-to-run, land on the 0.01 grid within the widened `[0.65,0.85]`/`[0.15,0.40]` ranges (no clustering onto a few repeated values like the old discrete buckets), and archetypes span all six non-Decker options across enough campaigns; save mid-campaign, reload, confirm rolled stats round-trip; load a pre-P3.5 save fixture (or a save snapshot lacking `baseHitChance`) and confirm it restores to the old fixed per-archetype constant rather than crashing or silently rerolling. - Full regression: `npm test` at the end of the phase, plus a manual playthrough covering all seven archetypes (Merc/Razor/Tech/Decker/Berserk/Adept/Chimera) in one run to catch any wiring gaps in the fan-out surfaces (`Run.ts`, `persistence.ts`, `applyIntent.ts`). diff --git a/src/game/Combat.ts b/src/game/Combat.ts index 3676c57..14ff6c0 100644 --- a/src/game/Combat.ts +++ b/src/game/Combat.ts @@ -346,9 +346,11 @@ function applyDamageReduction(intendedDamage: number, target: Entity): number { throw new RangeError(`damage must be a non-negative integer, got ${intendedDamage}`); } if (intendedDamage === 0) return 0; - const armor = target.damageReduction; + // Live combat armor: base plus any active timed buff (Berserk Surge). Equals + // `damageReduction` for every other entity. + const armor = target.effectiveDamageReduction; if (!Number.isInteger(armor) || armor < 0) { - throw new RangeError(`target ${target.id} has invalid damageReduction=${armor}`); + throw new RangeError(`target ${target.id} has invalid effectiveDamageReduction=${armor}`); } return Math.max(1, intendedDamage - armor); } diff --git a/src/game/Entity.ts b/src/game/Entity.ts index efe785a..e77b588 100644 --- a/src/game/Entity.ts +++ b/src/game/Entity.ts @@ -167,6 +167,17 @@ export class Entity { } } + /** + * The armor value combat actually mitigates with β€” base `damageReduction` + * plus any live timed buff a subclass layers on (the Berserk's Surge armor). + * For a plain entity it equals `damageReduction`. Kept separate from the + * stored field so a transient buff is computed on read and never persisted: + * snapshots read `damageReduction` (pristine base), combat reads this. + */ + get effectiveDamageReduction(): number { + return this.damageReduction; + } + hasEffect(id: string): boolean { return this.effects.has(id); } diff --git a/src/game/TurnQueue.ts b/src/game/TurnQueue.ts index bd074d4..fa74628 100644 --- a/src/game/TurnQueue.ts +++ b/src/game/TurnQueue.ts @@ -1,3 +1,4 @@ +import { STATUS_EFFECT } from './constants.js'; import { EVENT } from './events.js'; import type { FactionId } from './constants.js'; import type { World } from './World.js'; @@ -41,7 +42,17 @@ export class TurnQueue { const incoming = this.currentFaction; for (const e of world.entities.values()) { if (e.alive && e.faction === incoming) { + // The refresh boundary is where timed effects tick β€” a Berserk's Surge + // expires into Crash here. Capture the rising edge so the shell can pulse + // the comedown (presentation only; the effect swap is owned by refreshAp). + const wasCrashing = e.hasEffect(STATUS_EFFECT.CRASH); e.refreshAp(); + if (!wasCrashing && e.hasEffect(STATUS_EFFECT.CRASH)) { + world.events?.emit(EVENT.BERSERK_CRASHED, { + origin: { x: e.x, y: e.y }, + entityId: e.id, + }); + } } } if (advancedRound) { diff --git a/src/game/archetypes/Berserk.ts b/src/game/archetypes/Berserk.ts index bba6e5b..e682d55 100644 --- a/src/game/archetypes/Berserk.ts +++ b/src/game/archetypes/Berserk.ts @@ -5,6 +5,7 @@ import { CRASH_HIT_PENALTY, STATUS_EFFECT, SURGE_AP_BONUS, + SURGE_ARMOR_BONUS, SURGE_DAMAGE_BONUS, } from '../constants.js'; import { canSurge, doSurge } from '../surge.js'; @@ -30,11 +31,22 @@ export class Berserk extends Crew { override archetype = 'Berserk'; override get baseHitChance(): number { - return 0.75 - (this.hasEffect(STATUS_EFFECT.CRASH) ? CRASH_HIT_PENALTY : 0); + return 0.78 - (this.hasEffect(STATUS_EFFECT.CRASH) ? CRASH_HIT_PENALTY : 0); } override get baseDodgeChance(): number { - return 0.2; + return 0.36; + } + + /** + * Live combat armor: base `damageReduction` plus Surge's flat bonus while it + * is active. Computed on read, never stored β€” `damageReduction` stays the + * pristine persisted base, so the transient buff cannot leak into a save even + * if a run ends mid-Surge (`run.player` *is* the campaign crew object). Combat + * mitigation and the HUD read this; snapshots keep reading `damageReduction`. + */ + override get effectiveDamageReduction(): number { + return this.damageReduction + (this.hasEffect(STATUS_EFFECT.SURGE) ? SURGE_ARMOR_BONUS : 0); } constructor(props: CrewInit) { diff --git a/src/game/constants.ts b/src/game/constants.ts index e8a66a0..b32c036 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -115,13 +115,24 @@ export const AP_COST = Object.freeze({ SURGE: 2, // Berserk β€” self-buff that always chains into CRASH (P3.5.M3) }); -/** Berserk Surge/Crash tuning (P3.5.M3). */ +/** + * Berserk Surge/Crash tuning (P3.5.M3). Surge is a short, potent spike that + * always chains into a deliberately heavier Crash β€” the payback outweighs the + * high so the ability reads as a genuine gamble, not free value. + * + * - `SURGE_ARMOR_BONUS` β€” flat `damageReduction` while SURGE is active. The + * frenzy shrugs off hits mid-spike; the bonus is computed on Berserk's + * `damageReduction` getter (never stored), so it can't leak into a save. + * - Crash is longer (3 refreshes) and bites harder (βˆ’2 AP, βˆ’0.2 hit) than the + * Surge it pays for β€” playtest-tuned so the comedown actually stings. + */ export const SURGE_DURATION = 2; export const SURGE_DAMAGE_BONUS = 1; export const SURGE_AP_BONUS = 1; -export const CRASH_DURATION = 2; -export const CRASH_AP_PENALTY = 1; -export const CRASH_HIT_PENALTY = 0.1; +export const SURGE_ARMOR_BONUS = 1; +export const CRASH_DURATION = 3; +export const CRASH_AP_PENALTY = 2; +export const CRASH_HIT_PENALTY = 0.2; /** * Decker drone-override parameters (P3.M2). The Decker's signature Meatspace diff --git a/src/game/events.ts b/src/game/events.ts index 2f83d50..203f23f 100644 --- a/src/game/events.ts +++ b/src/game/events.ts @@ -41,6 +41,16 @@ export const EVENT = Object.freeze({ JACK_OUT: 'cyber:jack-out', /** P3.5.M2: a Decker detonated an EMP. Payload `{ origin: {x,y}, stunned }`. */ EMP_DETONATED: 'emp:detonated', + /** + * P3.5.M3: a Berserk armed Surge. Presentation-only hook (gameplay already + * resolved by the caller). Payload `{ origin: {x,y} }`. + */ + BERSERK_SURGED: 'berserk:surged', + /** + * P3.5.M3: a Berserk's Surge expired into Crash. Emitted at the refresh + * boundary where the transition happens. Payload `{ origin: {x,y}, entityId }`. + */ + BERSERK_CRASHED: 'berserk:crashed', }); const KNOWN_TYPES = new Set(Object.values(EVENT)); diff --git a/src/input/applyIntent.ts b/src/input/applyIntent.ts index 58ef97e..cc09fbf 100644 --- a/src/input/applyIntent.ts +++ b/src/input/applyIntent.ts @@ -431,7 +431,7 @@ function doSpecial(intent: Intent, ctx: ApplyIntentContext) { /** Trigger the Berserk's self-targeted Surge. */ function doSurge(ctx: ApplyIntentContext) { - const { player, log } = ctx; + const { world, player, log } = ctx; const berserk = player as Berserk; const playerLabel = entityLabel(player); const check = berserk.canSurge(); @@ -441,6 +441,9 @@ function doSurge(ctx: ApplyIntentContext) { } berserk.surge(); log(`> ${playerLabel} SURGES β€” power spikes before the crash (${player.ap} AP left).`); + // Presentation hook (P3.5.M3): the shell listens for this to pulse the surge + // spike. Gameplay is already committed above β€” this carries no state. + world.events?.emit(EVENT.BERSERK_SURGED, { origin: { x: player.x, y: player.y } }); gateOnApExhausted(ctx); } diff --git a/src/render/animations.ts b/src/render/animations.ts index 0c2b7ab..682c891 100644 --- a/src/render/animations.ts +++ b/src/render/animations.ts @@ -31,7 +31,7 @@ import type { AsciiRenderer } from './AsciiRenderer.js'; import { COMBAT_HUD_COLORS } from './combatHud.js'; -import { STUNNED_FG } from './palette.js'; +import { CRASH_FLASH_FG, STUNNED_FG, SURGE_FLASH_FG } from './palette.js'; export const ANIMATION_DURATIONS = Object.freeze({ SHAKE: 150, @@ -39,6 +39,10 @@ export const ANIMATION_DURATIONS = Object.freeze({ MITIGATION_FLASH: 300, /** Cyan discharge pulse when a Decker detonates an EMP (P3.5.M2). */ EMP_FLASH: 220, + /** Blaze-orange spike when a Berserk arms Surge (P3.5.M3). */ + SURGE_FLASH: 220, + /** Ashen comedown pulse when a Berserk's Surge expires into Crash (P3.5.M3). */ + CRASH_FLASH: 260, // Original plan suggested "~80ms" but at 60fps that's ~5 frames β€” perceptually // borderline, especially with the shooter's own glyph sitting underneath. // 120ms (~7 frames) is still snappy and reads clearly as a burst. @@ -108,7 +112,44 @@ export function triggerDamageFlash(stageEl: HTMLElement, timers = defaultTimers) export function triggerEmpFlash(stageEl: HTMLElement, timers = defaultTimers) { stageEl.classList.remove(DAMAGE_CLASS); stageEl.style.setProperty(IMPACT_FLASH_COLOR_PROPERTY, `${STUNNED_FG}8c`); - return restartCssAnimation(stageEl, MITIGATION_FLASH_CLASS, ANIMATION_DURATIONS.EMP_FLASH, timers); + return restartCssAnimation( + stageEl, + MITIGATION_FLASH_CLASS, + ANIMATION_DURATIONS.EMP_FLASH, + timers + ); +} + +/** + * Blaze-orange discharge pulse when a Berserk arms Surge (P3.5.M3). Reuses the + * same parametrized colored-vignette primitive the EMP and mitigation flashes + * drive β€” the surge spike and its later Crash comedown share this one screen + * effect, tinted differently, so the ability reads as a single arc. + */ +export function triggerSurgeFlash(stageEl: HTMLElement, timers = defaultTimers) { + stageEl.classList.remove(DAMAGE_CLASS); + stageEl.style.setProperty(IMPACT_FLASH_COLOR_PROPERTY, `${SURGE_FLASH_FG}8c`); + return restartCssAnimation( + stageEl, + MITIGATION_FLASH_CLASS, + ANIMATION_DURATIONS.SURGE_FLASH, + timers + ); +} + +/** + * Ashen violet-grey pulse when a Berserk's Surge expires into Crash (P3.5.M3). + * The comedown twin of {@link triggerSurgeFlash} on the shared vignette class. + */ +export function triggerCrashFlash(stageEl: HTMLElement, timers = defaultTimers) { + stageEl.classList.remove(DAMAGE_CLASS); + stageEl.style.setProperty(IMPACT_FLASH_COLOR_PROPERTY, `${CRASH_FLASH_FG}8c`); + return restartCssAnimation( + stageEl, + MITIGATION_FLASH_CLASS, + ANIMATION_DURATIONS.CRASH_FLASH, + timers + ); } export type MitigationFlashKind = 'armor' | 'shield'; diff --git a/src/render/palette.ts b/src/render/palette.ts index 7d9febc..e95d939 100644 --- a/src/render/palette.ts +++ b/src/render/palette.ts @@ -48,6 +48,15 @@ export const INTERACTABLE_SECURED_FG = FACTION_FG[FACTION.PLAYER]; */ export const STUNNED_FG = '#8be9ff'; +/** + * Berserk Surge/Crash screen-pulse tints (P3.5.M3). Reused by the shared + * colored-vignette flash so the two halves of the ability read as one arc: + * - `SURGE_FLASH_FG` β€” blaze orange for the power spike (rage, heat). + * - `CRASH_FLASH_FG` β€” ashen violet-grey for the comedown (drained, burnt out). + */ +export const SURGE_FLASH_FG = '#ff6a1a'; +export const CRASH_FLASH_FG = '#6c6a8a'; + /** * 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/combatHudSnapshot.ts b/src/shell/combatHudSnapshot.ts index a501150..9a4b4b0 100644 --- a/src/shell/combatHudSnapshot.ts +++ b/src/shell/combatHudSnapshot.ts @@ -80,7 +80,8 @@ export function combatHudBodyPanes( } function crewDefense(crew: Crew): CombatHudDefenseInput | undefined { - const armor = crew.damageReduction; + // Live combat armor, so a surging Berserk's +armor shows in the HUD pane. + const armor = crew.effectiveDamageReduction; const shieldCapacity = crew.gear?.shieldRegen ?? 0; if (armor <= 0 && shieldCapacity <= 0) return undefined; return { diff --git a/src/shell/sceneListeners.ts b/src/shell/sceneListeners.ts index 3360064..00f6a8a 100644 --- a/src/shell/sceneListeners.ts +++ b/src/shell/sceneListeners.ts @@ -4,10 +4,12 @@ import { resolveEntityLabel } from '../game/Entity.js'; import { ANIMATION_DURATIONS, runMuzzleFlash, + triggerCrashFlash, triggerDamageFlash, triggerEmpFlash, triggerMitigationFlash, triggerShake, + triggerSurgeFlash, } from '../render/animations.js'; import { COMBAT_HUD_COLORS } from '../render/combatHud.js'; import { cyberLayerOf, isCyberView } from './activeView.js'; @@ -155,6 +157,17 @@ export class SceneListenerController { // perk; the flash reads whether or not the meat view is in the PIP. triggerEmpFlash(dom.stageEl); animLock.push(ANIMATION_DURATIONS.EMP_FLASH); + }), + run.bus.on(EVENT.BERSERK_SURGED, () => { + // Blaze-orange spike as Surge arms β€” a beat of feedback beyond the HUD + // status tag. Meatspace-only perk, so the shared stage flash suffices. + triggerSurgeFlash(dom.stageEl); + animLock.push(ANIMATION_DURATIONS.SURGE_FLASH); + }), + run.bus.on(EVENT.BERSERK_CRASHED, () => { + // Ashen comedown pulse the instant Surge expires into Crash. + triggerCrashFlash(dom.stageEl); + animLock.push(ANIMATION_DURATIONS.CRASH_FLASH); }) ); } diff --git a/tests/unit/game/Berserk.test.ts b/tests/unit/game/Berserk.test.ts index 556f175..6892dbf 100644 --- a/tests/unit/game/Berserk.test.ts +++ b/tests/unit/game/Berserk.test.ts @@ -5,9 +5,12 @@ import { Berserk } from '../../../src/game/archetypes/Berserk.js'; import { Entity } from '../../../src/game/Entity.js'; import { AP_COST, + CRASH_AP_PENALTY, + CRASH_DURATION, CRASH_HIT_PENALTY, STATUS_EFFECT, SURGE_AP_BONUS, + SURGE_ARMOR_BONUS, SURGE_DAMAGE_BONUS, } from '../../../src/game/constants.js'; @@ -18,8 +21,8 @@ function makeBerserk() { test('Berserk inherits Entity, uses baseline stats, and starts without effects', () => { const berserk = makeBerserk(); assert.ok(berserk instanceof Entity); - assert.equal(berserk.baseHitChance, 0.75); - assert.equal(berserk.baseDodgeChance, 0.2); + assert.equal(berserk.baseHitChance, 0.78); + assert.equal(berserk.baseDodgeChance, 0.36); assert.equal(berserk.hasEffect(STATUS_EFFECT.SURGE), false); assert.equal(berserk.hasEffect(STATUS_EFFECT.CRASH), false); }); @@ -77,6 +80,53 @@ test('STUN keeps a surging Berserk at 0 AP instead of leaking the Surge bonus', assert.equal(berserk.hasEffect(STATUS_EFFECT.SURGE), true); }); +test('SURGE grants bonus combat armor for its whole duration and drops it on expiry', () => { + const berserk = makeBerserk(); + const baseArmor = berserk.damageReduction; + berserk.surge(); + // The stored base is never touched β€” only the computed combat value spikes. + assert.equal(berserk.damageReduction, baseArmor, 'stored base armor unchanged by surge'); + assert.equal( + berserk.effectiveDamageReduction, + baseArmor + SURGE_ARMOR_BONUS, + 'combat armor spikes on surge' + ); + + // Armor holds while SURGE is active (i.e. through the enemy turn where it matters). + berserk.refreshAp(); + assert.equal(berserk.hasEffect(STATUS_EFFECT.SURGE), true); + assert.equal( + berserk.effectiveDamageReduction, + baseArmor + SURGE_ARMOR_BONUS, + 'armor holds mid-surge' + ); + + // SURGE expiry into CRASH drops the armor back to base β€” Crash grants no armor. + berserk.refreshAp(); + assert.equal(berserk.hasEffect(STATUS_EFFECT.CRASH), true); + assert.equal( + berserk.effectiveDamageReduction, + baseArmor, + 'combat armor returns to base after surge' + ); +}); + +test('SURGE armor stacks on top of gear/base armor without mutating the stored base', () => { + const berserk = new Berserk({ id: 'berserk', x: 2, y: 2, maxAp: 4, damageReduction: 2 }); + assert.equal(berserk.effectiveDamageReduction, 2, 'no buff before surge'); + berserk.surge(); + assert.equal(berserk.damageReduction, 2, 'stored base armor never mutated'); + assert.equal(berserk.effectiveDamageReduction, 2 + SURGE_ARMOR_BONUS); + berserk.refreshAp(); + berserk.refreshAp(); + assert.equal(berserk.hasEffect(STATUS_EFFECT.CRASH), true); + assert.equal( + berserk.effectiveDamageReduction, + 2, + 'combat armor back to base after the surge buff clears' + ); +}); + test('SURGE expiry automatically applies CRASH and its AP/accuracy penalties', () => { const berserk = makeBerserk(); const baseHitChance = berserk.baseHitChance; @@ -86,22 +136,30 @@ test('SURGE expiry automatically applies CRASH and its AP/accuracy penalties', ( assert.equal(berserk.hasEffect(STATUS_EFFECT.SURGE), false); assert.equal(berserk.hasEffect(STATUS_EFFECT.CRASH), true); - assert.equal(berserk.ap, berserk.maxAp - 1); + assert.equal(berserk.ap, berserk.maxAp - CRASH_AP_PENALTY); assert.equal(berserk.baseHitChance, baseHitChance - CRASH_HIT_PENALTY); }); -test('CRASH penalty persists for its duration and restores hit chance exactly once', () => { +test('CRASH penalty persists for its full duration and restores hit chance exactly once', () => { const berserk = makeBerserk(); const baseHitChance = berserk.baseHitChance; berserk.surge(); + // Two refreshes drive SURGE (duration 2) to expiry and arm CRASH. berserk.refreshAp(); berserk.refreshAp(); - berserk.refreshAp(); - assert.equal(berserk.baseHitChance, baseHitChance - CRASH_HIT_PENALTY); - assert.equal(berserk.ap, berserk.maxAp - 1); - - berserk.refreshAp(); - assert.equal(berserk.hasEffect(STATUS_EFFECT.CRASH), false); + assert.equal(berserk.hasEffect(STATUS_EFFECT.CRASH), true, 'crash armed on surge expiry'); + + // CRASH is active for CRASH_DURATION player turns, penalising AP and accuracy + // the whole way; each refresh here advances one crash turn. + for (let turn = 0; turn < CRASH_DURATION; turn++) { + assert.equal(berserk.hasEffect(STATUS_EFFECT.CRASH), true, `crash active on turn ${turn}`); + assert.equal(berserk.baseHitChance, baseHitChance - CRASH_HIT_PENALTY); + assert.equal(berserk.ap, berserk.maxAp - CRASH_AP_PENALTY); + berserk.refreshAp(); + } + + // The refresh after the last crash turn clears it exactly once. + assert.equal(berserk.hasEffect(STATUS_EFFECT.CRASH), false, 'crash cleared after its duration'); assert.equal(berserk.baseHitChance, baseHitChance); assert.equal(berserk.ap, berserk.maxAp); diff --git a/tests/unit/game/Entity.test.ts b/tests/unit/game/Entity.test.ts index 0e3f8fc..87781d8 100644 --- a/tests/unit/game/Entity.test.ts +++ b/tests/unit/game/Entity.test.ts @@ -207,7 +207,11 @@ test('Entity.applyEffect overwrites, it does not stack', () => { const e = new Entity(baseProps()); e.applyEffect(STATUS_EFFECT.STEALTH, 1); e.applyEffect(STATUS_EFFECT.STEALTH, 3); - assert.equal(e.effectTurnsRemaining(STATUS_EFFECT.STEALTH), 3, 'reapply overwrites remaining duration'); + assert.equal( + e.effectTurnsRemaining(STATUS_EFFECT.STEALTH), + 3, + 'reapply overwrites remaining duration' + ); }); test('Entity.applyEffect rejects a non-positive-integer duration (data-corruption guard)', () => { diff --git a/tests/unit/game/Razor.test.ts b/tests/unit/game/Razor.test.ts index dde79c6..389358d 100644 --- a/tests/unit/game/Razor.test.ts +++ b/tests/unit/game/Razor.test.ts @@ -154,7 +154,11 @@ test('Razor.slide arms the generic STEALTH effect (P3.5.M1 channel)', () => { const { world, razor } = makeWorld(); assert.equal(razor.hasEffect(STATUS_EFFECT.STEALTH), false); razor.slide(world, 1, 0); - assert.equal(razor.hasEffect(STATUS_EFFECT.STEALTH), true, 'slide sets STEALTH via the effect channel'); + assert.equal( + razor.hasEffect(STATUS_EFFECT.STEALTH), + true, + 'slide sets STEALTH via the effect channel' + ); assert.equal(razor.effectTurnsRemaining(STATUS_EFFECT.STEALTH), 1, 'one own-refresh of cloak'); }); diff --git a/tests/unit/game/TurnQueue.test.ts b/tests/unit/game/TurnQueue.test.ts index 4335622..8efa4e1 100644 --- a/tests/unit/game/TurnQueue.test.ts +++ b/tests/unit/game/TurnQueue.test.ts @@ -5,6 +5,8 @@ import { Grid } from '../../../src/game/Grid.js'; import { Entity } from '../../../src/game/Entity.js'; import { World } from '../../../src/game/World.js'; import { TurnQueue } from '../../../src/game/TurnQueue.js'; +import { Berserk } from '../../../src/game/archetypes/Berserk.js'; +import { EventBus, EVENT } from '../../../src/game/events.js'; import { FACTION, STATUS_EFFECT } from '../../../src/game/constants.js'; test('TurnQueue requires a non-empty faction order', () => { @@ -85,6 +87,34 @@ test('TurnQueue.endTurn refreshes a stunned entity to 0 AP, then full AP next ro assert.equal(drone.ap, 3, 'full AP the activation after the stun'); }); +test('TurnQueue.endTurn emits berserk:crashed once, on the surgeβ†’crash refresh boundary', () => { + const bus = new EventBus(); + const w = new World(new Grid(3, 3), { events: bus }); + const berserk = new Berserk({ id: 'b', x: 1, y: 1, maxAp: 4 }); + berserk.faction = FACTION.PLAYER; + w.addEntity(berserk); + berserk.surge(); + + const crashes = []; + bus.on(EVENT.BERSERK_CRASHED, payload => crashes.push(payload)); + + const q = new TurnQueue([FACTION.PLAYER, FACTION.CORP]); + // Two player refreshes (each a full round) drive SURGE (duration 2) to expiry. + q.endTurn(w); // PLAYER -> CORP + q.endTurn(w); // CORP -> PLAYER: surge tick, still surging β€” no crash yet + assert.deepEqual(crashes, [], 'no crash while surge is still active'); + q.endTurn(w); // PLAYER -> CORP + q.endTurn(w); // CORP -> PLAYER: surge expires into crash β€” event fires here + + assert.equal(berserk.hasEffect(STATUS_EFFECT.CRASH), true); + assert.deepEqual(crashes, [{ origin: { x: 1, y: 1 }, entityId: 'b' }]); + + // Crash persisting across further refreshes does not re-emit. + q.endTurn(w); + q.endTurn(w); + assert.equal(crashes.length, 1, 'crash edge fires exactly once'); +}); + test('TurnQueue.endTurn emits turn:ended with previous/next/turn when bus attached', async () => { const { EventBus, EVENT } = await import('../../../src/game/events.js'); const bus = new EventBus(); diff --git a/tests/unit/game/empBlast.test.ts b/tests/unit/game/empBlast.test.ts index 0b7655d..4cf22e4 100644 --- a/tests/unit/game/empBlast.test.ts +++ b/tests/unit/game/empBlast.test.ts @@ -34,9 +34,17 @@ test('isInEmpBlast covers the Chebyshev disc of EMP_RADIUS around the center', ( const cx = 5; const cy = 5; assert.equal(isInEmpBlast(cx, cy, cx, cy), true, 'center is in the blast'); - assert.equal(isInEmpBlast(cx, cy, cx + EMP_RADIUS, cy + EMP_RADIUS), true, 'far corner in radius'); + assert.equal( + isInEmpBlast(cx, cy, cx + EMP_RADIUS, cy + EMP_RADIUS), + true, + 'far corner in radius' + ); assert.equal(isInEmpBlast(cx, cy, cx + EMP_RADIUS + 1, cy), false, 'one tile past radius is out'); - assert.equal(isInEmpBlast(cx, cy, cx, cy - (EMP_RADIUS + 1)), false, 'one tile past radius (up) is out'); + assert.equal( + isInEmpBlast(cx, cy, cx, cy - (EMP_RADIUS + 1)), + false, + 'one tile past radius (up) is out' + ); }); // --- canEmp legality -------------------------------------------------------- diff --git a/tests/unit/game/persistence.test.ts b/tests/unit/game/persistence.test.ts index 7b24561..482594c 100644 --- a/tests/unit/game/persistence.test.ts +++ b/tests/unit/game/persistence.test.ts @@ -19,6 +19,7 @@ import { } from '../../../src/game/persistence.js'; import { CONTRACT_DIFFICULTY, + CRASH_HIT_PENALTY, FACTION, SALVAGE_TO_CRED_RATE, STATUS_EFFECT, @@ -130,20 +131,37 @@ test('snapshot/restore round-trips a Berserk deploy (P3.5.M3)', () => { assert.deepEqual(snapshot(restoredRun), rec); }); -test('snapshot/restore preserves an active Berserk Surge, including bonus AP', () => { +test('snapshot/restore preserves an active Berserk Surge, including bonus AP and armor', () => { const run = freshCombatRun(0xb3e5e5, 'berserk'); const berserk = run.player as Berserk; + const baseArmor = berserk.damageReduction; berserk.surge(); berserk.refreshAp(); assert.equal(berserk.ap, berserk.maxAp + 1); + assert.equal(berserk.effectiveDamageReduction, baseArmor + 1, 'surge armor live before save'); const rec = snapshot(run); + // The record stores the pristine base armor β€” the +armor is a computed combat + // value derived from the SURGE effect, never a stored stat, so it can't leak. + assert.equal(rec.entities.find(e => e.id === berserk.id)?.damageReduction, baseArmor); + const { run: restoredRun } = restore(rec); const restored = restoredRun.player as Berserk; assert.ok(restored instanceof Berserk); assert.equal(restored.hasEffect(STATUS_EFFECT.SURGE), true); assert.equal(restored.ap, restored.maxAp + 1); + assert.equal(restored.damageReduction, baseArmor, 'stored base armor round-trips clean'); + assert.equal( + restored.effectiveDamageReduction, + baseArmor + 1, + 'surge armor re-derived from effect' + ); assert.deepEqual(snapshot(restoredRun), rec); + + // Drive Surge to expiry on the restored Berserk β€” combat armor falls to base. + restored.refreshAp(); + assert.equal(restored.hasEffect(STATUS_EFFECT.CRASH), true); + assert.equal(restored.effectiveDamageReduction, baseArmor, 'combat armor drops on surge expiry'); }); test('snapshot/restore preserves Berserk Crash and its derived hit penalty', () => { @@ -159,7 +177,7 @@ test('snapshot/restore preserves Berserk Crash and its derived hit penalty', () const restored = restoredRun.player as Berserk; assert.ok(restored instanceof Berserk); assert.equal(restored.hasEffect(STATUS_EFFECT.CRASH), true); - assert.equal(restored.baseHitChance, 0.65); + assert.equal(restored.baseHitChance, 0.78 - CRASH_HIT_PENALTY); assert.deepEqual(snapshot(restoredRun), rec); }); diff --git a/tests/unit/input/applyIntent.test.ts b/tests/unit/input/applyIntent.test.ts index 810c342..a3a7279 100644 --- a/tests/unit/input/applyIntent.test.ts +++ b/tests/unit/input/applyIntent.test.ts @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import { Grid } from '../../../src/game/Grid.js'; import { World } from '../../../src/game/World.js'; import { TurnQueue } from '../../../src/game/TurnQueue.js'; -import { EventBus } from '../../../src/game/events.js'; +import { EventBus, EVENT } from '../../../src/game/events.js'; import { TILE, FACTION, @@ -517,13 +517,17 @@ test('special intent routes to EMP on a Decker and stuns a same-faction ally in }); test('special intent routes to Surge on a Berserk without entering directional movement', () => { - const { ctx, log, player } = buildCtx({ archetype: 'berserk', placeDrone: false }); + const { ctx, log, player, world } = buildCtx({ archetype: 'berserk', placeDrone: false }); const positionBefore = { x: player.x, y: player.y }; + const surges = []; + world.events.on(EVENT.BERSERK_SURGED, payload => surges.push(payload)); applyIntent({ type: 'special', dx: 0, dy: 0 }, ctx); assert.deepEqual({ x: player.x, y: player.y }, positionBefore); assert.equal(player.hasEffect(STATUS_EFFECT.SURGE), true); assert.equal(player.ap, player.maxAp - AP_COST.SURGE); assert.ok(log.some(line => line.includes('SURGES'))); + // Presentation hook fires for the shell's surge pulse. + assert.deepEqual(surges, [{ origin: { x: player.x, y: player.y } }]); }); test('special intent routes CyberAvatar Override against Probe ICE', () => { diff --git a/tests/unit/render/animations.test.ts b/tests/unit/render/animations.test.ts index e939ee5..238ddc4 100644 --- a/tests/unit/render/animations.test.ts +++ b/tests/unit/render/animations.test.ts @@ -10,12 +10,14 @@ import { restartCssAnimation, runInteractSecuredFlash, runMuzzleFlash, + triggerCrashFlash, triggerDamageFlash, triggerEmpFlash, triggerMitigationFlash, triggerShake, + triggerSurgeFlash, } from '../../../src/render/animations.js'; -import { STUNNED_FG } from '../../../src/render/palette.js'; +import { CRASH_FLASH_FG, STUNNED_FG, SURGE_FLASH_FG } from '../../../src/render/palette.js'; /** * Minimal DOM-element stub β€” enough surface for restartCssAnimation to @@ -155,13 +157,48 @@ test('triggerEmpFlash drives a cyan vignette pulse on the shared flash class', ( const el = makeElement(); const timers = makeTimers(); triggerEmpFlash(el, timers); - assert.equal(el.classList.contains(MITIGATION_FLASH_CLASS), true, 'reuses the colored-flash class'); + assert.equal( + el.classList.contains(MITIGATION_FLASH_CLASS), + true, + 'reuses the colored-flash class' + ); assert.equal(el.classList.contains(DAMAGE_CLASS), false, 'not the red damage flash'); assert.equal(el.style.getPropertyValue('--kp-impact-flash-color'), `${STUNNED_FG}8c`); timers.advance(ANIMATION_DURATIONS.EMP_FLASH + 1); assert.equal(el.classList.contains(MITIGATION_FLASH_CLASS), false, 'clears after its duration'); }); +test('triggerSurgeFlash drives a blaze-orange vignette pulse on the shared flash class', () => { + const el = makeElement(); + const timers = makeTimers(); + triggerSurgeFlash(el, timers); + assert.equal( + el.classList.contains(MITIGATION_FLASH_CLASS), + true, + 'reuses the colored-flash class' + ); + assert.equal(el.classList.contains(DAMAGE_CLASS), false, 'not the red damage flash'); + assert.equal(el.style.getPropertyValue('--kp-impact-flash-color'), `${SURGE_FLASH_FG}8c`); + timers.advance(ANIMATION_DURATIONS.SURGE_FLASH + 1); + assert.equal(el.classList.contains(MITIGATION_FLASH_CLASS), false, 'clears after its duration'); +}); + +test('triggerCrashFlash drives an ashen vignette pulse distinct from the surge tint', () => { + const el = makeElement(); + const timers = makeTimers(); + triggerCrashFlash(el, timers); + assert.equal( + el.classList.contains(MITIGATION_FLASH_CLASS), + true, + 'reuses the colored-flash class' + ); + assert.equal(el.classList.contains(DAMAGE_CLASS), false, 'not the red damage flash'); + assert.equal(el.style.getPropertyValue('--kp-impact-flash-color'), `${CRASH_FLASH_FG}8c`); + assert.notEqual(CRASH_FLASH_FG, SURGE_FLASH_FG, 'crash and surge read as different beats'); + timers.advance(ANIMATION_DURATIONS.CRASH_FLASH + 1); + assert.equal(el.classList.contains(MITIGATION_FLASH_CLASS), false, 'clears after its duration'); +}); + test('createAnimationLock: isLocked is false before any push', () => { const timers = makeTimers(); const lock = createAnimationLock(timers); diff --git a/tests/unit/shell/combatHudSnapshot.test.ts b/tests/unit/shell/combatHudSnapshot.test.ts index 3ae72c6..f3726d1 100644 --- a/tests/unit/shell/combatHudSnapshot.test.ts +++ b/tests/unit/shell/combatHudSnapshot.test.ts @@ -69,5 +69,9 @@ test('combatHudBodyPanes exposes the Berserk Surge and Crash windows', () => { crew.refreshAp(); assert.equal(combatHudBodyPanes(scene).identity.surging, false); assert.equal(combatHudBodyPanes(scene).identity.crashing, true); - assert.deepEqual(combatHudBodyPanes(scene).ap, { ap: 3, maxAp: 4 }); + assert.deepEqual( + combatHudBodyPanes(scene).ap, + { ap: 2, maxAp: 4 }, + 'Crash docks CRASH_AP_PENALTY (2) from the 4-AP budget' + ); }); From f3921fcb0d54a7c218f2e2e044c65c76ed92b081 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Mon, 13 Jul 2026 18:58:16 -0700 Subject: [PATCH 5/9] new milestone: add new archetypes to score rewards pool --- docs/phase-3.5-plan.md | 93 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 6 deletions(-) diff --git a/docs/phase-3.5-plan.md b/docs/phase-3.5-plan.md index e011c6a..32cb854 100644 --- a/docs/phase-3.5-plan.md +++ b/docs/phase-3.5-plan.md @@ -14,8 +14,9 @@ The Decker's **Override** perk (hijack a corp drone's allegiance) reads oddly no 4. **Adept** β€” a new archetype that inherits Override's exact mechanic wholesale, reflavored as **Influence**: psychically dominating a hostile's will for a few turns instead of hacking a drone's firmware. Same targeting, same risk shape, same countdown-and-revert lifecycle β€” renamed and re-fictionalized, with a new archetype shell around it. 5. **Chimera** β€” a new sustain archetype (deliberately ambiguous fiction: nobody in-world knows for certain whether this is a human running a semi-sentient nanite swarm or an awakened AI in an android chassis; flavor text never resolves it) whose perk converts scrap into HP, mirroring Tech's improvised-turret resource-gate shape. 6. **Inverted crew generation** β€” roll core stats first (hit chance, dodge chance, armor), derive the archetype from the resulting profile, instead of picking an archetype and getting fixed stats. No weighted archetype pool β€” pure RNG at both campaign start and mid-campaign recruiting. The old "one of each starter kit" guarantee is dropped; duplicates are allowed. Decker is unaffected β€” still a forced, narrative-only mid-campaign recruit, never rolled. +7. **Archetype unlocks via Score rewards (M7, added 2026-07-13)** β€” Berserk/Adept/Chimera start locked for every meta-crew and join the existing `SCOREABLE_ITEMS` meta-progression pool (P3.M6 "Stolen Blueprints"): a clean Score win draws one reward, item or archetype, from whatever's still unacquired. A locked archetype's anchor is simply absent from M6's nearest-anchor derivation table, so rolls that would've landed there saturate to the nearest unlocked neighbor β€” same mechanism M6 already uses for rolls that overrun the anchor hull, no new logic required. -End state: **seven playable archetypes** (Merc, Razor, Tech, Decker, Berserk, Adept, Chimera), a differentiated Decker perk, one shared effect-duration mechanism the roster can keep building on (e.g. a future control/support archetype), and less deterministic crew stats without breaking campaign-save compatibility. +End state: **seven playable archetypes** (Merc, Razor, Tech, Decker, Berserk, Adept, Chimera) β€” Merc/Razor/Tech reachable via the roll from turn one, Decker via forced narrative recruit, and Berserk/Adept/Chimera progressively unlocked via Score rewards across a save's campaign history β€” a differentiated Decker perk, one shared effect-duration mechanism the roster can keep building on (e.g. a future control/support archetype), and less deterministic crew stats without breaking campaign-save compatibility. ## Dependency graph @@ -30,6 +31,8 @@ M4 (Adept: Influence) ── independent of M1; sequenced after M2 M5 (Chimera: scrapβ†’HP) ── fully independent (no duration effect at all) M3 + M4 + M5 ──> M6 (stat-roll β†’ archetype derivation, needs all 6 profiles) + +M6 ──> M7 (archetype unlocks via Score rewards β€” needs M6's anchor table to gate) ``` ## Current status @@ -42,13 +45,15 @@ M3 + M4 + M5 ──> M6 (stat-roll β†’ archetype derivation, needs all 6 profile | P3.5.M4 β€” Adept archetype (Influence, renamed from Override) | πŸ”² Not started | | P3.5.M5 β€” Chimera archetype (scrap-to-HP sustain) | πŸ”² Not started | | P3.5.M6 β€” Inverted crew generation (roll stats, derive archetype) | πŸ”² Not started | +| P3.5.M7 β€” Archetype unlocks via Score rewards | πŸ”² Not started | **Phase 3.5** is complete when: 1. Every milestone in the table above is βœ…. -2. All seven archetypes (Merc, Razor, Tech, Decker, Berserk, Adept, Chimera) are playable end to end in a single campaign, including mixed-archetype recruiting. -3. `npm test` passes with the new/updated coverage listed per milestone below. -4. A pre-P3.5 save loads without error and without silently regenerating stats it never had (legacy defaults kick in instead). +2. `npm test` passes with the new/updated coverage listed per milestone below. +3. A pre-P3.5 save loads without error and without silently regenerating stats it never had (legacy defaults kick in instead). + +> **Note on "all seven archetypes in one campaign" (dropped as a phase-level gate, 2026-07-13):** M7 makes Berserk/Adept/Chimera Score-unlocked, and `THE SCORE` ends the campaign it's completed in β€” so no fresh save can ever be *mid-campaign, recruiting,* and *fully unlocked* at the same time. Each archetype (including the three gated ones) is instead validated end-to-end during its own milestone's playtest pass β€” see Verification below β€” and mixed-archetype recruiting is covered by a test fixture that pre-seeds `unlockedArchetypes`, not a blank-slate single-campaign requirement. --- @@ -343,6 +348,7 @@ export function rollCrewStats(rng: Rng): { hitChance: number; dodgeChance: numbe // deriveArchetype receives the already-rounded tuple. export function deriveArchetype(stats): CrewArchetypeId // reads hitChance/dodgeChance only; armor is not a classifier ``` +> **Amended by M7:** `deriveArchetype` gains an optional third `anchors: readonly CrewStatAnchor[] = CREW_STAT_ANCHORS` parameter so M7 can pass a lock-filtered subset without M6 needing any awareness of the unlock system. Implement the parameter now (even though nothing supplies a non-default value until M7 lands) so M6's own signature doesn't need a follow-up edit. Roll ranges β€” continuous and **deliberately wider than today's discrete spread** (P3.5 refinement: the old `{0.70,0.75,0.80}` / `{0.20,0.25,0.30,0.35}` buckets clustered crew onto a handful of identical stat lines; continuous rolls over a widened range give every operative a distinct feel). Roll a uniform float, then **round to 0.01** so the HUD reads clean whole-percents and the derivation domain stays finite/enumerable: - `hitChance`: uniform in `[0.65, 0.85]`, rounded to 0.01 β†’ 21 discrete values. - `dodgeChance`: uniform in `[0.15, 0.40]`, rounded to 0.01 β†’ 26 discrete values. @@ -382,6 +388,80 @@ Restore: `baseHitChance: rec.baseHitChance ?? DEFAULT_HIT_CHANCE_BY_ARCHETYPE[re --- +## P3.5.M7 β€” Archetype unlocks via Score rewards + +**Depends on M6** (needs `CREW_STAT_ANCHORS` to gate). **Also depends on the already-shipped P3.M6 "Stolen Blueprints"** meta-progression system (`phase-3-plan.md`) β€” this milestone extends that system rather than building a new one. + +**Design decisions locked in by discussion (2026-07-13):** Berserk/Adept/Chimera join `SCOREABLE_ITEMS` in a single **merged draw pool** β€” a completed Score nets *either* a new item *or* a new archetype, drawn uniformly from whatever's still unacquired (not a separate/additive reward track, not a fixed unlock order). This means early campaigns (12 candidates: 9 items + 3 archetypes) have roughly a 25% chance per clean Score of drawing an archetype instead of gear, rising as items deplete first. All three new archetypes start **locked** for every meta-crew, including saves that already unlocked every item under the pre-M7 system β€” `unlockedArchetypes` is a wholly new, independently-empty store key; nothing grandfathers in from item-unlock history. + +**New module `src/game/archetypeUnlocks.ts`** (mirrors `scoreableUnlocks.ts` exactly β€” same validation contract, same "absent β†’ `[]`, malformed β†’ throw, idempotent archive" shape): +```ts +export function normalizeUnlockedArchetypes(value: unknown): string[] +export function archiveUnlockedArchetype(list: readonly string[], id: string): { list: string[]; added: boolean } +``` + +**New descriptor table `src/game/archetypeRewards.ts`** (sibling to `items.ts`'s `SCOREABLE_ITEMS`, not folded into it β€” an archetype reward has no `cost`/`scope`/`needsTarget`, it isn't a shop purchase): +```ts +export type ArchetypeReward = { id: CrewArchetypeId; label: string; flavor: string }; +export const SCOREABLE_ARCHETYPES: readonly ArchetypeReward[] = Object.freeze([ + { id: 'berserk', label: 'Combat-Stim Rig', flavor: '' }, + { id: 'adept', label: 'Psychic Interface Cradle', flavor: '' }, + { id: 'chimera', label: 'Nanite Culture Sample', flavor: '' }, +]); +export const SCOREABLE_ARCHETYPE_IDS: ReadonlySet = Object.freeze(new Set(SCOREABLE_ARCHETYPES.map(r => r.id))); +``` +Flavor lines are placeholders for the as-built pass β€” should read as "what got reverse-engineered," matching the `SCOREABLE_ITEMS` convention (e.g. Monoblade's "a monomolecular blade schematic"), not as a description of the archetype's kit. + +**`DataStore.ts`:** add `unlockedArchetypes: string[]` following the exact `unlockedScoreableItems` pattern at every site that field touches (`KPData`, private field + default, `save`/`restore`, `get unlockedArchetypes()`, `archiveUnlockedArchetype(id)` β†’ emits a change event). Same file, same shape, new key β€” no shared plumbing beyond copy-paste-adapt. + +**`Campaign.ts` β€” merged payload draw.** `pickScorePayload` (`Campaign.ts:140-146`) becomes payload-kind-aware: +```ts +export type ScorePayload = + | { kind: 'item'; item: Item } + | { kind: 'archetype'; reward: ArchetypeReward }; + +function pickScorePayload( + seed: number, + acquiredItemIds: readonly string[], + acquiredArchetypeIds: readonly string[] +): ScorePayload | null { + const items = SCOREABLE_ITEMS.filter(i => !acquiredItemIds.includes(i.id)) + .map((item): ScorePayload => ({ kind: 'item', item })); + const archetypes = SCOREABLE_ARCHETYPES.filter(r => !acquiredArchetypeIds.includes(r.id)) + .map((reward): ScorePayload => ({ kind: 'archetype', reward })); + const pool = [...items, ...archetypes]; + if (pool.length === 0) return null; // exhausted β€” both catalogs fully acquired + const rng = new Rng(((seed >>> 0) ^ SCORE_PAYLOAD_SALT) >>> 0); + return pool[Math.floor(rng.next() * pool.length)] ?? null; +} +``` +Pool exhaustion (β†’ `ABSTRACT_SCORE_TARGETS` fallback, `Campaign.ts:148-` ) now requires **both** catalogs fully acquired, not just items. + +`buildScoreContract` (`Campaign.ts:899`) gains a second parameter `unlockedArchetypeIds: readonly string[] = []` alongside the existing `unlockedScoreableIds`, threaded into `pickScorePayload`. Briefing/objective copy composition needs an archetype-flavored branch (frame the heist around reverse-engineering an operative-class technology, not a specific weapon/implant) alongside the existing item-flavored copy. + +**Settlement (`Campaign.ts:847-860`):** the `completedScoreRun` branch currently does `this.meta.scoreUnlockedItemId = payloadId`. Rework to read the drawn `ScorePayload`'s kind and set exactly one of `this.meta.scoreUnlockedItemId` / `this.meta.scoreUnlockedArchetypeId` (never both β€” one Score, one reward). New getter `scoreUnlockedArchetypeId` mirrors `scoreUnlockedItemId` (`Campaign.ts:872-875`): validates against `SCOREABLE_ARCHETYPE_IDS`, returns `null` for stale/foreign/absent. + +**`shellRuntime.ts` (mirror every `unlockedScoreableItems`/`archiveScoreableItem` site β€” `shellRuntime.ts:764,784,820,897,1271-1272`):** each `dataStore.unlockedScoreableItems` read that feeds `buildScoreContract` also reads `dataStore.unlockedArchetypes` and passes it through; the settlement block (`shellRuntime.ts:1271-1272`) grows a parallel `if (unlockedArchetypeId) dataStore.archiveUnlockedArchetype(unlockedArchetypeId)`. + +**`campaignSummary.ts` (`:49,88`):** `scoreUnlockedItemId?: string | null` gains a sibling `scoreUnlockedArchetypeId?: string | null`; `resolveScoreReward` grows an archetype-reward branch for the chronicle/history view. + +**Gating the derivation table (the M6 tie-in).** `crewStatRoll.ts`'s `deriveArchetype` gains an optional anchor-subset parameter rather than M6 needing any awareness of locks: +```ts +export function deriveArchetype( + stats: { hitChance: number; dodgeChance: number }, + anchors: readonly CrewStatAnchor[] = CREW_STAT_ANCHORS +): CrewArchetypeId +``` +M7 supplies a filtered table β€” `CREW_STAT_ANCHORS.filter(a => !SCOREABLE_ARCHETYPE_IDS.has(a.archetype) || unlockedArchetypes.has(a.archetype))` β€” so a locked archetype's anchor is simply absent from the nearest-anchor search and every roll that would've landed there saturates to its nearest *unlocked* neighbor, exactly the same mechanism M6 already uses for rolls that overrun the anchor hull (documented corner-saturation behavior, M6). No new derivation logic β€” a locked Berserk/Adept/Chimera is structurally identical to "outside the widened roll range." + +**Threading unlock state into crew generation.** Because a completed Score both grants exactly one reward *and* ends the campaign in the same step, `unlockedArchetypeIds` **cannot change mid-campaign** β€” unlike `unlockedScoreableIds` (read live from `DataStore` at each of several call sites via `shellRuntime.ts`), it's safe and simpler to capture once as **Campaign construction-time state** rather than threading it as a parameter through every `buildCrew`/`generateRecruits`/`generateInitialCandidates` call site (some of which are called from inside `Campaign` itself, not just from `shellRuntime` β€” `Campaign.ts:451,608,629`). Proposed: `Campaign`'s constructor/restore path accepts `unlockedArchetypeIds: readonly string[]` (from `dataStore.unlockedArchetypes` at Campaign creation, same lifecycle moment `rng` is set), stores it as a readonly instance field, and `buildCrew`/`generateRecruits`/`generateInitialCandidates` read that field when calling `buildCrewMemberFromRoll`. **Flag for implementer confirmation:** this assumes Campaign construction is the only place unlock state needs to enter β€” verify no code path calls these three generation methods before Campaign is fully constructed from a fresh `DataStore` read. + +**Critical files:** `src/game/archetypeUnlocks.ts` (new), `src/game/archetypeRewards.ts` (new), `src/game/crewStatRoll.ts` (amend M6's `deriveArchetype` signature), `src/game/Campaign.ts`, `src/DataStore.ts`, `src/shell/shellRuntime.ts`, `src/game/campaignSummary.ts`. + +**Tests:** `tests/unit/game/archetypeUnlocks.test.ts` (new, mirrors `scoreableUnlocks.test.ts`), extend `tests/unit/game/crewStatRoll.test.ts` (locked-anchor sweep: with only `{merc, razor, tech}` unlocked, every one of the 546 tuples still resolves to a registered *unlocked* archetype, no dead zones, no throw; each locked archetype's own anchor point resolves to a different, unlocked archetype), extend `tests/unit/game/Campaign.test.ts` (`pickScorePayload` draws from the merged pool; exhaustion requires both catalogs empty; settlement sets exactly one of the two meta fields, never both), extend `tests/unit/game/persistence.test.ts`/`DataStore.test.ts` (`unlockedArchetypes` round-trip, legacy-absent β†’ `[]`, malformed β†’ throw, idempotent archive), extend `tests/unit/game/campaignSummary.test.ts` (archetype-reward chronicle line). + +--- + ## Out of scope, parked - Multi-floor maps, faction rep/NPC social, neural backups β€” existing Phase 4 deferrals per [phase-3-plan.md](phase-3-plan.md), unaffected by any of this. @@ -395,5 +475,6 @@ Per milestone: `npm test` (typecheck + build tests + `node --test`) must pass, i - **M3:** play a Berserk through a full surgeβ†’crash cycle β€” confirm damage/AP bonus **and +1 armor** during surge (the armor pane shows in the HUD), confirm the **surge and crash screen pulses** fire, confirm crash auto-applies on expiry with the accuracy penalty visible in the HUD hit-chance display, confirm crash itself expires cleanly back to baseline after its (now longer) window. - **M4:** play an Adept, confirm Influence behaves identically to the old Override (aim-sector targeting, success roll, alarm on failure, countdown-and-revert) end to end; confirm `mindInfluence.test.ts` covers the mechanic independent of any archetype wiring. - **M5:** play a Chimera, kill a hostile, collect its scrap drop, convert it to HP β€” confirm repeatable across turns as long as scrap lasts and HP clamps at max. -- **M6:** start several fresh campaigns (different seeds) and confirm crew stats vary run-to-run, land on the 0.01 grid within the widened `[0.65,0.85]`/`[0.15,0.40]` ranges (no clustering onto a few repeated values like the old discrete buckets), and archetypes span all six non-Decker options across enough campaigns; save mid-campaign, reload, confirm rolled stats round-trip; load a pre-P3.5 save fixture (or a save snapshot lacking `baseHitChance`) and confirm it restores to the old fixed per-archetype constant rather than crashing or silently rerolling. -- Full regression: `npm test` at the end of the phase, plus a manual playthrough covering all seven archetypes (Merc/Razor/Tech/Decker/Berserk/Adept/Chimera) in one run to catch any wiring gaps in the fan-out surfaces (`Run.ts`, `persistence.ts`, `applyIntent.ts`). +- **M6:** start several fresh campaigns (different seeds) and confirm crew stats vary run-to-run, land on the 0.01 grid within the widened `[0.65,0.85]`/`[0.15,0.40]` ranges (no clustering onto a few repeated values like the old discrete buckets), and archetypes span all six non-Decker options across enough campaigns; save mid-campaign, reload, confirm rolled stats round-trip; load a pre-P3.5 save fixture (or a save snapshot lacking `baseHitChance`) and confirm it restores to the old fixed per-archetype constant rather than crashing or silently rerolling. **Run this check against a fixture with all three M7 archetypes pre-unlocked** (M7 will otherwise have shrunk the live anchor table to `{merc, razor, tech}` by the time M7 ships) so M6's own "all six reachable" claim stays independently verifiable. +- **M7:** play a fresh meta-crew (empty `unlockedArchetypes`) through a full campaign to a clean Score win and confirm the reward is drawn from the merged pool (item or archetype, never both, matches what settlement recorded); confirm a won archetype reward persists in `DataStore` across a new campaign and that a subsequent crew roll can now land the newly-unlocked archetype (a fixture forcing a roll onto its exact anchor point is the deterministic way to prove this, rather than replaying rolls until one lands); confirm a **locked** archetype's anchor point saturates to its documented nearest unlocked neighbor instead of dead-zoning or throwing; confirm a `score-partial` outcome writes nothing to either meta-store key. +- Full regression: `npm test` at the end of the phase, plus a manual playthrough covering all seven archetypes (Merc/Razor/Tech/Decker/Berserk/Adept/Chimera) in one run **using a save fixture with `unlockedArchetypes` pre-seeded to all three** (per the dropped single-campaign gate above, a truly blank-slate save can't reach this state) to catch any wiring gaps in the fan-out surfaces (`Run.ts`, `persistence.ts`, `applyIntent.ts`). From 418ff93bae72bf16cfac301840eea9d51f9a8205 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 14 Jul 2026 12:55:21 -0700 Subject: [PATCH 6/9] Adept + Influence --- docs/phase-3.5-plan.md | 10 +- src/game/Campaign.ts | 4 +- src/game/Hostile.ts | 16 +- src/game/Run.ts | 6 +- src/game/archetypes/Adept.ts | 74 +++++++ src/game/archetypes/index.ts | 27 ++- src/game/combatTurnPipeline.ts | 15 +- src/game/constants.ts | 29 +-- src/game/cyber/CyberAvatar.ts | 14 +- src/game/droneOverride.ts | 178 ---------------- src/game/mindInfluence.ts | 198 ++++++++++++++++++ src/game/persistence.ts | 16 +- src/input/applyIntent.ts | 103 ++++++--- tests/unit/game/Adept.test.ts | 101 +++++++++ tests/unit/game/Campaign.test.ts | 16 +- tests/unit/game/Decker.test.ts | 6 +- tests/unit/game/Run.test.ts | 9 + tests/unit/game/archetypes.test.ts | 19 ++ tests/unit/game/cyber/ProbeIce.test.ts | 2 +- .../unit/game/cyber/cyberPersistence.test.ts | 2 +- ...Override.test.ts => mindInfluence.test.ts} | 115 +++++----- tests/unit/game/persistence.test.ts | 13 +- tests/unit/input/applyIntent.test.ts | 48 ++++- 23 files changed, 700 insertions(+), 321 deletions(-) create mode 100644 src/game/archetypes/Adept.ts delete mode 100644 src/game/droneOverride.ts create mode 100644 src/game/mindInfluence.ts create mode 100644 tests/unit/game/Adept.test.ts rename tests/unit/game/{droneOverride.test.ts => mindInfluence.test.ts} (51%) diff --git a/docs/phase-3.5-plan.md b/docs/phase-3.5-plan.md index 32cb854..b94eff1 100644 --- a/docs/phase-3.5-plan.md +++ b/docs/phase-3.5-plan.md @@ -42,7 +42,7 @@ M6 ──> M7 (archetype unlocks via Score rewards β€” needs M6's anchor table t | P3.5.M1 β€” Generic status-effect subsystem | βœ… Complete | | P3.5.M2 β€” Decker perk swap: Override β†’ EMP AOE stun | βœ… Complete | | P3.5.M3 β€” Berserk archetype (surge/crash) | βœ… Complete | -| P3.5.M4 β€” Adept archetype (Influence, renamed from Override) | πŸ”² Not started | +| P3.5.M4 β€” Adept archetype (Influence, renamed from Override) | βœ… Complete | | P3.5.M5 β€” Chimera archetype (scrap-to-HP sustain) | πŸ”² Not started | | P3.5.M6 β€” Inverted crew generation (roll stats, derive archetype) | πŸ”² Not started | | P3.5.M7 β€” Archetype unlocks via Score rewards | πŸ”² Not started | @@ -270,6 +270,14 @@ Doc comments reflavor from "hijack a corp drone's allegiance" to "psychically do **Tests:** rename the Override-specific coverage currently embedded in `Decker.test.ts` into a new standalone `tests/unit/game/mindInfluence.test.ts`, exercising the renamed pure functions directly against a generic `Hostile` fixture β€” this is the module's only coverage today (only reachable via `Decker.test.ts`), so it must move deliberately, not get silently dropped when `Decker.ts` stops calling it (M2) or renamed out from under it (M4). Add `tests/unit/game/Adept.test.ts` (mirrors `Decker.test.ts`'s old Override-legality assertions, now via `canInfluence`/`influenceTarget`). Extend `applyIntent.test.ts`, `Run.test.ts`, `persistence.test.ts` per the wiring-surface list. +**Implementation notes (as-built):** +- **`doOverride`/`doInfluence` split (deviation from the literal rename table).** The plan's rename table lists `doOverride (applyIntent.ts) β†’ doInfluence`, but by the time M4 landed `doOverride` was *already* the CyberAvatar-only cyber-grid dispatch handler (M2 left it that way β€” "OverrideActor retyped to CyberAvatar"). Renaming it outright would have repointed the CyberAvatar's own `canOverride`/`overrideDrone` capability check at a function carrying Adept-flavored log copy, which contradicts M2's explicit "Override stays the cyber grid's own fiction" decision. Landed instead: `doOverride` is untouched (still serves only the CyberAvatar, still logs "OVERRIDES"/"OVERRIDE DENIED"/"OVERRIDE FAILED"), and a new sibling `doInfluence` was added for the Adept's `canInfluence` branch with its own copy ("DOMINATES"/"INFLUENCE DENIED"/"INFLUENCE FAILED"). Both share the renamed `pickInfluenceTarget` (was `pickOverrideTarget`) and unchanged `isInAimSector` β€” the picker itself has zero archetype-specific behavior, exactly as the plan intended. +- **Hostile bookkeeping field names, deny-reason strings, and `applyOverride`/`revertOverride` function names are unchanged**, per M1's explicit "do not migrate" carve-out for `overrideTurnsRemaining`/`factionBeforeOverride`. Since those fields (and the `isOverridden` getter) are shared bookkeeping between the CyberAvatar's cyber-grid Override *and* the Adept's Meatspace Influence, the `InfluenceDenyReason` strings (`'not-overridable'`, `'already-overridden'`) and the `Illegal override for …` throw message were left as-is rather than partially reflavored β€” renaming only the deny strings while the backing field stays `isOverridden` would have been more inconsistent, not less. +- **`AP_COST.INFLUENCE` is a single renamed constant**, not a new Adept-only cost β€” the CyberAvatar's cyber-grid Override now costs `AP_COST.INFLUENCE` too (same numeric value, `2`, as `AP_COST.OVERRIDE` before the rename). No behavior change, only the identifier. +- `combatTurnPipeline.ts`'s `stepOverriddenDrones` β†’ `stepInfluencedHostiles` rename (and its `OverriddenDroneAction` β†’ `InfluencedHostileAction` type) was carried through as planned; the pipeline's own local step type (`OverriddenDroneAftermathStep`) and its aftermath log copy ("shakes off the override…") were deliberately left alone β€” that copy already covered both fictions (cyber ICE and the old Decker override) before this milestone and isn't part of the module being renamed. +- Full archetype fan-out landed: registry/factory/callsigns/perk metadata (`perkAim: 'directional'`, matching the old aim-sector picker), recruit pool (interim weighted pool is now `2 Merc / 2 Razor / 1 Tech / 1 Berserk / 1 Adept`), Run classification/telemetry/snapshot, campaign/run restore, and capability-based input dispatch. +- Verification: `npm test` (2025 pass, 0 fail), `npm run lint`, `npm run format` all clean. A stale compiled `dist/tests/unit/game/droneOverride.test.js` (and `dist/src/game/droneOverride.js`) left over from before the source rename had to be removed by hand β€” `tsc`'s incremental test build doesn't delete outputs for deleted sources. + --- ## P3.5.M5 β€” Chimera archetype: scrap-to-HP sustain diff --git a/src/game/Campaign.ts b/src/game/Campaign.ts index 2fd2ff5..af61156 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -1129,8 +1129,8 @@ export class Campaign { /** * Generate the starter candidate pool for a fresh campaign. Returns * `RECRUIT.INITIAL_CANDIDATES` (3) candidates with weighted archetype - * distribution (2 Merc / 2 Razor / 1 Tech / 1 Berserk). Stores them on - * `initialCandidates` for + * distribution (2 Merc / 2 Razor / 1 Tech / 1 Berserk / 1 Adept). Stores + * them on `initialCandidates` for * `recruitInitial()` to consume. Does NOT require Rep gate β€” this is * the campaign-start exception. */ diff --git a/src/game/Hostile.ts b/src/game/Hostile.ts index 44abda8..9546f10 100644 --- a/src/game/Hostile.ts +++ b/src/game/Hostile.ts @@ -21,12 +21,16 @@ export abstract class Hostile extends Entity { sightRange: number; /** - * Decker drone-override state (P3.M2). While `overrideTurnsRemaining > 0` - * this hostile has been hijacked: its `faction` is temporarily the - * overrider's (PLAYER) and `factionBeforeOverride` records the allegiance to - * restore when the override lapses. Both default to the not-overridden state - * so every existing hostile is unaffected. The countdown is ticked once per - * player turn by `stepOverriddenDrones` (see `droneOverride.ts`). + * Mind-influence/override state (P3.M2; renamed/rehomed P3.5.M4). While + * `overrideTurnsRemaining > 0` this hostile has been dominated: its + * `faction` is temporarily the operator's (PLAYER) and + * `factionBeforeOverride` records the allegiance to restore when the + * influence lapses. Both default to the not-overridden state so every + * existing hostile is unaffected. Field names are deliberately unchanged by + * the M4 rename β€” they're shared bookkeeping for both the Adept's Meatspace + * Influence and the CyberAvatar's cyber-grid Override against ICE. The + * countdown is ticked once per player turn by `stepInfluencedHostiles` (see + * `mindInfluence.ts`). */ overrideTurnsRemaining: number; factionBeforeOverride: FactionId | null; diff --git a/src/game/Run.ts b/src/game/Run.ts index b0f02ee..0ba6e03 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -58,6 +58,7 @@ import { Razor } from './archetypes/Razor.js'; import { Tech } from './archetypes/Tech.js'; import { Decker } from './archetypes/Decker.js'; import { Berserk } from './archetypes/Berserk.js'; +import { Adept } from './archetypes/Adept.js'; import { Turret } from './Turret.js'; import { Skirmisher } from './ai/Skirmisher.js'; import { Guard } from './ai/Guard.js'; @@ -157,7 +158,7 @@ const KNOWN_OUTCOMES = new Set(Object.values(OUTCOME)); export type RunState = (typeof RUN_STATE)[keyof typeof RUN_STATE]; export type Outcome = (typeof OUTCOME)[keyof typeof OUTCOME]; -export type CrewArchetypeId = 'merc' | 'razor' | 'tech' | 'decker' | 'berserk'; +export type CrewArchetypeId = 'merc' | 'razor' | 'tech' | 'decker' | 'berserk' | 'adept'; export type EntityArchetypeId = | CrewArchetypeId | 'turret' @@ -2694,6 +2695,7 @@ const SNAPSHOT_EXTRACTORS: Partial Enti merc: e => crewSnapshotExtra(e as Crew) as unknown as EntitySnapshotExtra, razor: e => crewSnapshotExtra(e as Crew) as unknown as EntitySnapshotExtra, berserk: e => crewSnapshotExtra(e as Crew) as unknown as EntitySnapshotExtra, + adept: e => crewSnapshotExtra(e as Crew) as unknown as EntitySnapshotExtra, decker: e => { const d = e as Decker; return { @@ -2894,6 +2896,7 @@ function archetypeOf(entity: Entity): EntityArchetypeId { if (entity instanceof Tech) return 'tech'; if (entity instanceof Decker) return 'decker'; if (entity instanceof Berserk) return 'berserk'; + if (entity instanceof Adept) return 'adept'; if (entity instanceof Turret) return 'turret'; if (entity instanceof Bruiser) return 'bruiser'; if (entity instanceof Juggernaut) return 'juggernaut'; @@ -3472,6 +3475,7 @@ function archetypeOfCrew(entity: Entity): CrewArchetypeId { if (entity instanceof Tech) return 'tech'; if (entity instanceof Decker) return 'decker'; if (entity instanceof Berserk) return 'berserk'; + if (entity instanceof Adept) return 'adept'; throw new Error( `archetypeOfCrew: cannot classify crew member ${(entity as Entity | undefined)?.id}` ); diff --git a/src/game/archetypes/Adept.ts b/src/game/archetypes/Adept.ts new file mode 100644 index 0000000..fdb4479 --- /dev/null +++ b/src/game/archetypes/Adept.ts @@ -0,0 +1,74 @@ +import { Crew } from '../Crew.js'; +import { canInfluence, influenceTarget } from '../mindInfluence.js'; +import type { CrewInit } from '../Crew.js'; +import type { World } from '../World.js'; +import type { Entity } from '../Entity.js'; +import type { Rng } from '../../rng.js'; + +/** + * Curated callsign pool for the Adept archetype. See `Merc.ts` CALLSIGNS for + * the design rationale. Adept names lean mentalist/psychic to match the + * "dominates a hostile's will" fantasy β€” checked against every other pool for + * collisions. + */ +export const CALLSIGNS = Object.freeze([ + 'Oracle', + 'Mirage', + 'Sibyl', + 'Whisper', + 'Halo', + 'Delphi', + 'Thrall', + 'Mendel', + 'Wisp', + 'Seer', + 'Aura', + 'Puppet', +]); + +/** + * Adept β€” mind-control specialist (P3.5.M4). Inherits the old Decker "Drone + * Override Hack" mechanic wholesale, unchanged in behavior β€” same aim-sector + * target picker, same range/duration/success-chance/alarm-on-fail risk shape, + * same countdown-and-revert lifecycle (`mindInfluence.ts`, deliberately *not* + * migrated onto the generic status-effect channel β€” see P3.5.M1's "do not + * migrate" list). Only the name and fictional framing change: **Influence** + * is psychic domination of a hostile's will, not a firmware hack. + * + * A deliberately weaker combatant than the other archetypes β€” you bring an + * Adept for Influence, not for their aim. + */ +export class Adept extends Crew { + override archetype = 'Adept'; + + override get baseHitChance(): number { + return 0.7; + } + + override get baseDodgeChance(): number { + return 0.2; + } + + constructor(props: CrewInit) { + super({ ...props, glyph: '@' }); + } + + /** + * Pre-flight legality check for a psychic domination attempt. Returns + * `{ ok }` or `{ ok: false, reason }`, mirroring the other archetype perks. + * Delegates to the shared `mindInfluence` module so the rules live in one + * place (also used by the CyberAvatar's cyber-grid Override). + */ + canInfluence(world: World, target: Entity | null) { + return canInfluence(world, this, target); + } + + /** + * Attempt to dominate `target`'s will. Throws on illegal pre-conditions (no + * AP burned); on a legal attempt, debits AP once and rolls the success + * chance β€” a failure still costs AP and may trip the alarm. + */ + influenceTarget(world: World, target: Entity, rng: Rng) { + return influenceTarget(world, this, target, rng); + } +} diff --git a/src/game/archetypes/index.ts b/src/game/archetypes/index.ts index 8a2f690..e39d41e 100644 --- a/src/game/archetypes/index.ts +++ b/src/game/archetypes/index.ts @@ -24,11 +24,12 @@ import { Razor, CALLSIGNS as RAZOR_CALLSIGNS } from './Razor.js'; import { Tech, CALLSIGNS as TECH_CALLSIGNS } from './Tech.js'; import { Decker, CALLSIGNS as DECKER_CALLSIGNS } from './Decker.js'; import { Berserk, CALLSIGNS as BERSERK_CALLSIGNS } from './Berserk.js'; +import { Adept, CALLSIGNS as ADEPT_CALLSIGNS } from './Adept.js'; import type { Rng } from '../../rng.js'; import type { FactionId } from '../constants.js'; import type { CrewInit } from '../Crew.js'; -export type Archetype = Merc | Razor | Tech | Decker | Berserk; +export type Archetype = Merc | Razor | Tech | Decker | Berserk | Adept; /** * Display order is also the starter crew order in `Campaign.buildCrew`. @@ -43,10 +44,13 @@ export type Archetype = Merc | Razor | Tech | Decker | Berserk; export const ARCHETYPE_IDS = Object.freeze(['merc', 'razor', 'tech']); /** - * Weighted archetype pool for recruitment. 40% Merc, 40% Razor, 20% Tech. - * Expressed as a flat array so `rng.pick()` gives the correct distribution. - * The Decker is **not** in this pool β€” normal random recruitment must never - * roll one; it joins only through the Act-2 narrative beat (P3.M2 / P3.M1). + * Weighted archetype pool for recruitment: 2 Merc / 2 Razor / 1 Tech / 1 + * Berserk / 1 Adept. Expressed as a flat array so `rng.pick()` gives the + * correct distribution. This is an interim hand-weighted pool β€” P3.5.M6 + * retires it entirely in favor of rolling core stats first and deriving the + * archetype from the result. The Decker is **not** in this pool β€” normal + * random recruitment must never roll one; it joins only through the Act-2 + * narrative beat (P3.M2 / P3.M1). */ export const RECRUIT_ARCHETYPE_POOL = Object.freeze([ 'merc', @@ -55,6 +59,7 @@ export const RECRUIT_ARCHETYPE_POOL = Object.freeze([ 'razor', 'tech', 'berserk', + 'adept', ]); /** @@ -111,6 +116,16 @@ export const ARCHETYPES = Object.freeze({ perkLabel: 'Berserks can SURGE: gain damage and AP before crashing', perkAim: 'self', }), + adept: Object.freeze({ + id: 'adept', + name: 'ADEPT', + blurb: 'Weak shot, strong will. Bends a hostile mind to your side.', + perks: Object.freeze(['influence']), + perkName: 'INFLUENCE', + perkLabel: 'Adepts can INFLUENCE: dominate a hostile mind for a few turns', + // Aim-sector target picker, same shape the old drone Override always used. + perkAim: 'directional', + }), }); export type ArchetypeInfo = (typeof ARCHETYPES)[keyof typeof ARCHETYPES]; @@ -139,6 +154,7 @@ const BUILDERS = Object.freeze({ tech: Tech, decker: Decker, berserk: Berserk, + adept: Adept, }); /** @@ -153,6 +169,7 @@ export const CALLSIGNS_BY_ARCHETYPE = Object.freeze({ tech: TECH_CALLSIGNS, decker: DECKER_CALLSIGNS, berserk: BERSERK_CALLSIGNS, + adept: ADEPT_CALLSIGNS, }); export function isArchetypeId(value: string) { diff --git a/src/game/combatTurnPipeline.ts b/src/game/combatTurnPipeline.ts index 581c025..b7fd6ad 100644 --- a/src/game/combatTurnPipeline.ts +++ b/src/game/combatTurnPipeline.ts @@ -7,7 +7,7 @@ import { EVENT } from './events.js'; import { chebyshev, findPath } from './Pathfinding.js'; import { detonateBreachingCharge } from './breachBlast.js'; import { BreachingCharge } from './entities/BreachingCharge.js'; -import { stepOverriddenDrones, type OverriddenDroneAction } from './droneOverride.js'; +import { stepInfluencedHostiles, type InfluencedHostileAction } from './mindInfluence.js'; import type { Hostile } from './Hostile.js'; import type { BlastCasualty } from './breachBlast.js'; import type { Entity } from './Entity.js'; @@ -70,7 +70,7 @@ export type BreachDetonateAftermathStep = { export type OverriddenDroneAftermathStep = { type: 'overridden-drone'; entity: Hostile; - action: OverriddenDroneAction; + action: InfluencedHostileAction; }; export type PlayerAftermathStep = @@ -332,10 +332,13 @@ export function* runPlayerAftermathSteps( }; } } - // Phase 1b: overridden drones act on the player's side, then their hijack - // countdown ticks and they revert when it lapses (P3.M2). They are - // player-aligned automated combatants β€” same aftermath slot as turrets. - for (const { entity, action } of stepOverriddenDrones(world, rng)) { + // Phase 1b: influenced/overridden hostiles act on the player's side, then + // their domination countdown ticks and they revert when it lapses. Shared + // by the Adept's Meatspace Influence (P3.5.M4) and the CyberAvatar's + // cyber-grid Override against ICE β€” both flip `faction` through the same + // `mindInfluence.ts` bookkeeping. They are player-aligned automated + // combatants β€” same aftermath slot as turrets. + for (const { entity, action } of stepInfluencedHostiles(world, rng)) { yield { type: 'overridden-drone', entity, action }; } diff --git a/src/game/constants.ts b/src/game/constants.ts index b32c036..a841c61 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -110,7 +110,7 @@ export const AP_COST = Object.freeze({ VAULT: 2, // Merc β€” hop a cover tile while firing SLIDE: 2, // Razor β€” 2-tile reposition with stealth bonus DEPLOY: 2, // Tech β€” place a turret on an adjacent tile - OVERRIDE: 2, // CyberAvatar β€” flip ICE allegiance on the cyber grid (M4 renames -> INFLUENCE for the Adept) + INFLUENCE: 2, // Adept (Meatspace) + CyberAvatar (cyber grid) β€” flip a hostile's/ICE's allegiance (renamed from OVERRIDE, P3.5.M4) EMP: 2, // Decker β€” self-centered AOE neural-shock stun (P3.5.M2) SURGE: 2, // Berserk β€” self-buff that always chains into CRASH (P3.5.M3) }); @@ -135,21 +135,24 @@ export const CRASH_AP_PENALTY = 2; export const CRASH_HIT_PENALTY = 0.2; /** - * Decker drone-override parameters (P3.M2). The Decker's signature Meatspace - * ability flips a corp drone to the player's side for a few turns by reusing - * the existing drone AI with a faction swap (the AI targets by faction, so a - * flipped drone fights its former allies for free). + * Mind Influence parameters (renamed from Decker drone-override, P3.M2 β†’ + * P3.5.M4). The Adept's signature Meatspace ability psychically dominates a + * hostile's will for a few turns by reusing the existing hostile AI with a + * faction swap (the AI targets by faction, so a dominated hostile fights its + * former allies for free). The CyberAvatar's cyber-grid Override against ICE + * shares this exact same machinery (`mindInfluence.ts`) β€” only the fiction + * differs between the two consumers, not the numbers. * - * - `OVERRIDE_RANGE` β€” reach for the intrusion, matched to baseline SIGHT so - * the Decker must have a clean LOS lane like a ranged shot. - * - `OVERRIDE_DURATION` β€” turns the drone stays player-aligned before its - * firmware reasserts control and it reverts to its original faction. - * - `OVERRIDE_SUCCESS_CHANCE` β€” probability the intrusion takes. A failed + * - `INFLUENCE_RANGE` β€” reach for the intrusion, matched to baseline SIGHT + * so the operator must have a clean LOS lane like a ranged shot. + * - `INFLUENCE_DURATION` β€” turns the target stays player-aligned before its + * own will reasserts control and it reverts to its original faction. + * - `INFLUENCE_SUCCESS_CHANCE` β€” probability the intrusion takes. A failed * attempt still burns AP and trips the facility alarm. */ -export const OVERRIDE_RANGE = 5; -export const OVERRIDE_DURATION = 3; -export const OVERRIDE_SUCCESS_CHANCE = 0.6; +export const INFLUENCE_RANGE = 5; +export const INFLUENCE_DURATION = 3; +export const INFLUENCE_SUCCESS_CHANCE = 0.6; /** * Decker cyber stats (P3.M3.3). Named stats with real effects from day one diff --git a/src/game/cyber/CyberAvatar.ts b/src/game/cyber/CyberAvatar.ts index 07e7514..3ee2298 100644 --- a/src/game/cyber/CyberAvatar.ts +++ b/src/game/cyber/CyberAvatar.ts @@ -17,7 +17,7 @@ * also carries `intrusionStrength`, so a flag (not the stat) marks the avatar. */ import { Entity } from '../Entity.js'; -import { canOverride, overrideDrone } from '../droneOverride.js'; +import { canInfluence, influenceTarget } from '../mindInfluence.js'; import { CYBER_AVATAR_HIT_CHANCE, CYBER_AVATAR_MAX_AP, FACTION } from '../constants.js'; import type { World } from '../World.js'; import type { Rng } from '../../rng.js'; @@ -65,14 +65,20 @@ export class CyberAvatar extends Entity { return this.damageReduction; } - /** Decker Override translated into the digital layer: ICE is hostile code. */ + /** + * Override translated into the digital layer: ICE is hostile code. Delegates + * to the same `mindInfluence.ts` machinery the Adept's Meatspace Influence + * perk uses (P3.5.M4 rename) β€” the avatar keeps its own `canOverride`/ + * `overrideDrone` method names since "Override" is the cyber-grid's own + * fiction, unchanged by the Adept's reflavor. + */ canOverride(world: World, target: Entity | null) { - return canOverride(world, this, target); + return canInfluence(world, this, target); } /** Attempt to flip ICE to the avatar's faction for the normal override duration. */ overrideDrone(world: World, target: Entity, rng: Rng) { - return overrideDrone(world, this, target, rng); + return influenceTarget(world, this, target, rng); } constructor({ diff --git a/src/game/droneOverride.ts b/src/game/droneOverride.ts deleted file mode 100644 index c0a7a79..0000000 --- a/src/game/droneOverride.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Drone Override Hack (P3.M2) β€” the Decker's signature Meatspace ability. - * - * The Decker reaches out across a clean line of sight and hijacks a corp - * drone's allegiance. On success the drone's `faction` flips to the Decker's - * (PLAYER) for {@link OVERRIDE_DURATION} turns; because the existing hostile AI - * acquires targets purely by faction difference (`Hostile.isHostileTo`), a - * flipped drone immediately fights its former allies with *zero* new AI code. - * When the countdown lapses, the drone's firmware reasserts control and it - * reverts to whatever faction it held before. - * - * State lives on the hostile itself (`overrideTurnsRemaining`, - * `factionBeforeOverride`) so it round-trips through the patrol snapshot. This - * module owns the *verbs*: the legality check, the commit (with the success - * roll + alarm-on-failure), and the per-turn stepping/revert that the combat - * aftermath drives. Keeping it here mirrors `slide.ts` β€” the archetype class - * stays a thin delegator and the pipeline stays a thin caller. - */ - -import { Hostile } from './Hostile.js'; -import { hasLineOfSight, withinRange } from './LineOfSight.js'; -import { - AP_COST, - OVERRIDE_DURATION, - OVERRIDE_RANGE, - OVERRIDE_SUCCESS_CHANCE, - type FactionId, -} from './constants.js'; -import type { Entity } from './Entity.js'; -import type { World } from './World.js'; -import type { Rng } from '../rng.js'; -import type { TurnActionStep } from '../types.js'; - -/** Pre-flight legality verdict, mirroring the Tech/Razor/Merc perk shape. */ -export type OverrideCheck = { ok: true } | { ok: false; reason: OverrideDenyReason }; - -export type OverrideDenyReason = - | 'dead' - | 'insufficient-ap' - | 'not-overridable' - | 'dead-target' - | 'already-overridden' - | 'friendly' - | 'out-of-range' - | 'no-los'; - -/** Result of a committed override attempt. */ -export type OverrideResult = { - ok: true; - /** Whether the intrusion took. On false the alarm was raised. */ - success: boolean; - /** The success roll, surfaced for log copy / determinism tests. */ - roll: number; - alarm: boolean; -}; - -/** - * Pure legality check. Returns `{ ok }` or `{ ok: false, reason }`; never - * mutates. `decker` is the overriding crew member (any PLAYER-faction - * operator); `target` is the entity it is aiming at. - * - * Only a live, in-range, line-of-sight `Hostile` of a *different* faction that - * is not already overridden can be hijacked. A non-Hostile target (terminal, - * civilian, turret) is `not-overridable` rather than a thrown error β€” the - * picker may legitimately land on one and we want a legible deny, not a crash. - */ -export function canOverride(world: World, decker: Entity, target: Entity | null): OverrideCheck { - if (!decker.alive) return { ok: false, reason: 'dead' }; - if (!decker.canAfford(AP_COST.OVERRIDE)) return { ok: false, reason: 'insufficient-ap' }; - if (!(target instanceof Hostile)) return { ok: false, reason: 'not-overridable' }; - if (!target.alive) return { ok: false, reason: 'dead-target' }; - if (target.isOverridden) return { ok: false, reason: 'already-overridden' }; - if (target.faction === decker.faction) return { ok: false, reason: 'friendly' }; - if (!withinRange(decker.x, decker.y, target.x, target.y, OVERRIDE_RANGE)) { - return { ok: false, reason: 'out-of-range' }; - } - if ( - !hasLineOfSight(world.grid, decker.x, decker.y, target.x, target.y, { - blockers: world.blockerKeys(), - }) - ) { - return { ok: false, reason: 'no-los' }; - } - return { ok: true }; -} - -/** - * Commit an override attempt. Throws on illegal pre-conditions *before* - * mutating any state (no AP burned). On a legal attempt: debits - * `AP_COST.OVERRIDE`, rolls against {@link OVERRIDE_SUCCESS_CHANCE}, and either - * flips the drone (success) or trips the facility alarm (failure). A failed - * hack still costs AP β€” the intrusion was noisy whether or not it landed. - */ -export function overrideDrone( - world: World, - decker: Entity, - target: Entity, - rng: Rng -): OverrideResult { - const check = canOverride(world, decker, target); - if (!check.ok) { - throw new Error(`Illegal override for ${decker.id}: ${check.reason}`); - } - decker.spendAp(AP_COST.OVERRIDE); - const roll = rng.next(); - const success = roll < OVERRIDE_SUCCESS_CHANCE; - if (success) { - applyOverride(target as Hostile, decker.faction); - return { ok: true, success: true, roll, alarm: false }; - } - // A botched intrusion pings the building's security net. - const raised = world.raiseAlarm({ source: decker, repPenalty: false }); - return { ok: true, success: false, roll, alarm: raised }; -} - -/** Flip a hostile to `overriderFaction` for the full override duration. */ -export function applyOverride(target: Hostile, overriderFaction: FactionId): void { - target.factionBeforeOverride = target.faction; - target.faction = overriderFaction; - target.overrideTurnsRemaining = OVERRIDE_DURATION; -} - -/** - * Restore a hostile's original allegiance. Throws if there is no recorded - * pre-override faction β€” reverting an entity we never overrode is corrupt - * bookkeeping, not a recoverable situation. - */ -export function revertOverride(target: Hostile): void { - if (target.factionBeforeOverride === null) { - throw new Error(`revertOverride: ${target.id} has no factionBeforeOverride to restore`); - } - target.faction = target.factionBeforeOverride; - target.factionBeforeOverride = null; - target.overrideTurnsRemaining = 0; -} - -/** One action emitted by an overridden drone during the player aftermath. */ -export type OverriddenDroneAction = TurnActionStep | { type: 'override-expired' }; - -/** - * Step every currently-overridden drone through one turn on the player's side, - * then tick its countdown and revert it when the override lapses. Yields one - * entry per committed action so the aftermath pipeline can pace + log it, plus - * a synthetic `override-expired` action when a drone snaps back to corp. - * - * Dead overridden drones are skipped (their corpse needs no turn and no - * revert). The live entity list is snapshotted up front so a drone that kills - * a corp unit mid-turn can't perturb the iteration. - */ -export function* stepOverriddenDrones( - world: World, - rng: Rng -): Generator<{ entity: Hostile; action: OverriddenDroneAction }, void, undefined> { - const overridden: Hostile[] = []; - for (const e of world.entities.values()) { - if (e instanceof Hostile && e.isOverridden && e.alive) overridden.push(e); - } - for (const drone of overridden) { - if (!drone.alive) continue; // may have died earlier in this aftermath pass - // Corp drones are PatrolHostiles with a step generator; fall back to the - // synchronous `takeTurn` for any future hostile that lacks one. - if (typeof drone.takeTurnSteps === 'function') { - for (const step of drone.takeTurnSteps(world, rng)) { - yield { entity: drone, action: step }; - } - } else if (typeof drone.takeTurn === 'function') { - const steps = drone.takeTurn(world, rng); - if (Array.isArray(steps)) { - for (const step of steps) yield { entity: drone, action: step }; - } - } - drone.overrideTurnsRemaining -= 1; - if (drone.overrideTurnsRemaining <= 0) { - revertOverride(drone); - yield { entity: drone, action: { type: 'override-expired' } }; - } - } -} diff --git a/src/game/mindInfluence.ts b/src/game/mindInfluence.ts new file mode 100644 index 0000000..15555d8 --- /dev/null +++ b/src/game/mindInfluence.ts @@ -0,0 +1,198 @@ +/** + * Mind Influence (P3.5.M4, renamed and rehomed from `droneOverride.ts`) β€” the + * Adept's signature Meatspace ability, and the pure machinery the CyberAvatar's + * cyber-grid Override also delegates to (`CyberAvatar.ts` keeps its own + * `canOverride`/`overrideDrone` method names β€” ICE hijack stays "Override" on + * the digital layer; see that file's doc comment). + * + * The Adept reaches out across a clean line of sight and psychically dominates + * a hostile's will. On success the target's `faction` flips to the Adept's + * (PLAYER) for {@link INFLUENCE_DURATION} turns; because the existing hostile + * AI acquires targets purely by faction difference (`Hostile.isHostileTo`), a + * dominated hostile immediately fights its former allies with *zero* new AI + * code. When the countdown lapses, the target's own will reasserts itself and + * it reverts to whatever faction it held before. + * + * This mechanic used to be the Decker's "Drone Override Hack" (P3.M2); M2 + * moved the Decker's Meatspace perk to EMP (`empBlast.ts`) and this module + * kept serving only the CyberAvatar's cyber-grid Override in the interim. M4 + * renames the module wholesale and gives the mechanic a permanent Meatspace + * owner (the Adept) β€” behavior is unchanged, only the name and fictional + * framing. + * + * State lives on the target itself (`overrideTurnsRemaining`, + * `factionBeforeOverride`) so it round-trips through the patrol snapshot β€” + * those field names are deliberately *not* renamed (P3.5.M1's "do not + * migrate" list): they're shared bookkeeping for both the cyber-grid Override + * and the Adept's Influence, and renaming them buys nothing. This module owns + * the *verbs*: the legality check, the commit (with the success roll + alarm- + * on-failure), and the per-turn stepping/revert that the combat aftermath + * drives. Keeping it here mirrors `slide.ts` β€” the archetype class stays a + * thin delegator and the pipeline stays a thin caller. + */ + +import { Hostile } from './Hostile.js'; +import { hasLineOfSight, withinRange } from './LineOfSight.js'; +import { + AP_COST, + INFLUENCE_DURATION, + INFLUENCE_RANGE, + INFLUENCE_SUCCESS_CHANCE, + type FactionId, +} from './constants.js'; +import type { Entity } from './Entity.js'; +import type { World } from './World.js'; +import type { Rng } from '../rng.js'; +import type { TurnActionStep } from '../types.js'; + +/** Pre-flight legality verdict, mirroring the Tech/Razor/Merc perk shape. */ +export type InfluenceCheck = { ok: true } | { ok: false; reason: InfluenceDenyReason }; + +export type InfluenceDenyReason = + | 'dead' + | 'insufficient-ap' + | 'not-overridable' + | 'dead-target' + | 'already-overridden' + | 'friendly' + | 'out-of-range' + | 'no-los'; + +/** Result of a committed influence attempt. */ +export type InfluenceResult = { + ok: true; + /** Whether the domination took. On false the alarm was raised. */ + success: boolean; + /** The success roll, surfaced for log copy / determinism tests. */ + roll: number; + alarm: boolean; +}; + +/** + * Pure legality check. Returns `{ ok }` or `{ ok: false, reason }`; never + * mutates. `operator` is the influencing crew member (any PLAYER-faction + * operator); `target` is the entity it is aiming at. + * + * Only a live, in-range, line-of-sight `Hostile` of a *different* faction that + * is not already dominated can be influenced. A non-Hostile target (terminal, + * civilian, turret) is `not-overridable` rather than a thrown error β€” the + * picker may legitimately land on one and we want a legible deny, not a crash. + */ +export function canInfluence( + world: World, + operator: Entity, + target: Entity | null +): InfluenceCheck { + if (!operator.alive) return { ok: false, reason: 'dead' }; + if (!operator.canAfford(AP_COST.INFLUENCE)) return { ok: false, reason: 'insufficient-ap' }; + if (!(target instanceof Hostile)) return { ok: false, reason: 'not-overridable' }; + if (!target.alive) return { ok: false, reason: 'dead-target' }; + if (target.isOverridden) return { ok: false, reason: 'already-overridden' }; + if (target.faction === operator.faction) return { ok: false, reason: 'friendly' }; + if (!withinRange(operator.x, operator.y, target.x, target.y, INFLUENCE_RANGE)) { + return { ok: false, reason: 'out-of-range' }; + } + if ( + !hasLineOfSight(world.grid, operator.x, operator.y, target.x, target.y, { + blockers: world.blockerKeys(), + }) + ) { + return { ok: false, reason: 'no-los' }; + } + return { ok: true }; +} + +/** + * Commit an influence attempt. Throws on illegal pre-conditions *before* + * mutating any state (no AP burned). On a legal attempt: debits + * `AP_COST.INFLUENCE`, rolls against {@link INFLUENCE_SUCCESS_CHANCE}, and + * either dominates the target (success) or trips the facility alarm + * (failure). A failed attempt still costs AP β€” the intrusion was noisy + * whether or not it landed. + */ +export function influenceTarget( + world: World, + operator: Entity, + target: Entity, + rng: Rng +): InfluenceResult { + const check = canInfluence(world, operator, target); + if (!check.ok) { + throw new Error(`Illegal override for ${operator.id}: ${check.reason}`); + } + operator.spendAp(AP_COST.INFLUENCE); + const roll = rng.next(); + const success = roll < INFLUENCE_SUCCESS_CHANCE; + if (success) { + applyOverride(target as Hostile, operator.faction); + return { ok: true, success: true, roll, alarm: false }; + } + // A botched intrusion pings the building's security net. + const raised = world.raiseAlarm({ source: operator, repPenalty: false }); + return { ok: true, success: false, roll, alarm: raised }; +} + +/** Flip a hostile to `overriderFaction` for the full influence duration. */ +export function applyOverride(target: Hostile, overriderFaction: FactionId): void { + target.factionBeforeOverride = target.faction; + target.faction = overriderFaction; + target.overrideTurnsRemaining = INFLUENCE_DURATION; +} + +/** + * Restore a hostile's original allegiance. Throws if there is no recorded + * pre-override faction β€” reverting an entity we never dominated is corrupt + * bookkeeping, not a recoverable situation. + */ +export function revertOverride(target: Hostile): void { + if (target.factionBeforeOverride === null) { + throw new Error(`revertOverride: ${target.id} has no factionBeforeOverride to restore`); + } + target.faction = target.factionBeforeOverride; + target.factionBeforeOverride = null; + target.overrideTurnsRemaining = 0; +} + +/** One action emitted by an influenced hostile during the player aftermath. */ +export type InfluencedHostileAction = TurnActionStep | { type: 'override-expired' }; + +/** + * Step every currently-dominated hostile through one turn on the player's + * side, then tick its countdown and revert it when the influence lapses. + * Yields one entry per committed action so the aftermath pipeline can pace + + * log it, plus a synthetic `override-expired` action when a target snaps back + * to its original faction. + * + * Dead dominated hostiles are skipped (their corpse needs no turn and no + * revert). The live entity list is snapshotted up front so a hostile that + * kills another unit mid-turn can't perturb the iteration. + */ +export function* stepInfluencedHostiles( + world: World, + rng: Rng +): Generator<{ entity: Hostile; action: InfluencedHostileAction }, void, undefined> { + const overridden: Hostile[] = []; + for (const e of world.entities.values()) { + if (e instanceof Hostile && e.isOverridden && e.alive) overridden.push(e); + } + for (const target of overridden) { + if (!target.alive) continue; // may have died earlier in this aftermath pass + // Corp drones/ICE are PatrolHostiles with a step generator; fall back to + // the synchronous `takeTurn` for any future hostile that lacks one. + if (typeof target.takeTurnSteps === 'function') { + for (const step of target.takeTurnSteps(world, rng)) { + yield { entity: target, action: step }; + } + } else if (typeof target.takeTurn === 'function') { + const steps = target.takeTurn(world, rng); + if (Array.isArray(steps)) { + for (const step of steps) yield { entity: target, action: step }; + } + } + target.overrideTurnsRemaining -= 1; + if (target.overrideTurnsRemaining <= 0) { + revertOverride(target); + yield { entity: target, action: { type: 'override-expired' } }; + } + } +} diff --git a/src/game/persistence.ts b/src/game/persistence.ts index f937465..d437f74 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -51,6 +51,7 @@ import { Razor } from './archetypes/Razor.js'; import { Tech } from './archetypes/Tech.js'; import { Decker } from './archetypes/Decker.js'; import { Berserk } from './archetypes/Berserk.js'; +import { Adept } from './archetypes/Adept.js'; import { Turret } from './Turret.js'; import { Skirmisher, type SkirmisherProps } from './ai/Skirmisher.js'; import { Guard, type GuardProps } from './ai/Guard.js'; @@ -217,6 +218,7 @@ const ARCHETYPE_FACTORY: Record new Tech(props as CrewInit), decker: (props: RestoreEntityProps) => new Decker(props as DeckerInit), berserk: (props: RestoreEntityProps) => new Berserk(props as CrewInit), + adept: (props: RestoreEntityProps) => new Adept(props as CrewInit), turret: (props: RestoreEntityProps) => new Turret(props as TurretInit), drone: (props: RestoreEntityProps) => new Skirmisher(props as SkirmisherProps), guard: (props: RestoreEntityProps) => new Guard(props as GuardProps), @@ -633,12 +635,12 @@ function restorePatrolState( } /** - * Re-apply Decker drone-override bookkeeping (P3.M2). The two fields travel as - * a pair: a live hijack has a positive countdown *and* a recorded prior - * faction. Either one present without the other β€” or a countdown that isn't a - * positive integer, or a prior faction that isn't a known faction β€” is corrupt - * mid-override state and throws, rather than silently restoring a drone that - * can never revert. + * Re-apply mind-influence/override bookkeeping (P3.M2; renamed P3.5.M4 β€” see + * `mindInfluence.ts`). The two fields travel as a pair: a live domination has + * a positive countdown *and* a recorded prior faction. Either one present + * without the other β€” or a countdown that isn't a positive integer, or a + * prior faction that isn't a known faction β€” is corrupt mid-override state + * and throws, rather than silently restoring a hostile that can never revert. */ function restoreOverrideState( entity: PatrolHostile, @@ -1755,6 +1757,7 @@ const KNOWN_ARCHETYPES_SET = new Set([ 'tech', 'decker', 'berserk', + 'adept', ]); /** Clamp gear bonuses to archetype caps after restore. */ @@ -2368,6 +2371,7 @@ function archetypeOfCrew(member: Crew): CrewArchetypeId { if (member instanceof Tech) return 'tech'; if (member instanceof Decker) return 'decker'; if (member instanceof Berserk) return 'berserk'; + if (member instanceof Adept) return 'adept'; throw new Error(`snapshotCampaign: cannot classify crew member ${member?.id}`); } diff --git a/src/input/applyIntent.ts b/src/input/applyIntent.ts index cc09fbf..0c1e271 100644 --- a/src/input/applyIntent.ts +++ b/src/input/applyIntent.ts @@ -18,13 +18,15 @@ * automation can commit a melee strike without synthesizing a walk intent. * * The archetype-specific perks (Merc's Vault, Razor's Slide, Tech's Deploy - * Turret, Decker's Override) collapse into a single `special` intent at the - * keymap layer. The `doSpecial` dispatcher below routes it to the right verb - * based on which methods the active player class exposes β€” `canVault` β†’ vault, - * `canSlide` β†’ slide, `canDeploy` β†’ deploy, `canOverride` β†’ override (the - * Decker resolves the nearest visible drone in the aimed eight-way sector). - * This keeps the input surface symmetric across archetypes (one key, one touch - * button) and stays out of the player's way: + * Turret, Decker's EMP, Berserk's Surge, Adept's Influence, the CyberAvatar's + * cyber-grid Override) collapse into a single `special` intent at the keymap + * layer. The `doSpecial` dispatcher below routes it to the right verb based on + * which methods the active player class exposes β€” `canVault` β†’ vault, + * `canSlide` β†’ slide, `canDeploy` β†’ deploy, `canEmp` β†’ EMP, `canSurge` β†’ + * surge, `canInfluence` β†’ influence (the Adept resolves the nearest visible + * hostile in the aimed eight-way sector), `canOverride` β†’ override (same + * picker, cyber grid only). This keeps the input surface symmetric across + * archetypes (one key, one touch button) and stays out of the player's way: * the keymap doesn't need to know which class is in play, and the intent * dispatcher doesn't need an explicit archetype switch. * @@ -46,7 +48,7 @@ import { SIGHT_RANGE, VAULT_DAMAGE, NOISE_RADIUS, - OVERRIDE_RANGE, + INFLUENCE_RANGE, } from '../game/constants.js'; import { totalSalvage, formatSalvageCompact } from '../game/salvage.js'; import { canFireRanged, resolveRanged, canMelee, resolveMelee } from '../game/Combat.js'; @@ -71,6 +73,7 @@ import type { Merc } from '../game/archetypes/Merc.js'; import type { Razor } from '../game/archetypes/Razor.js'; import type { Decker } from '../game/archetypes/Decker.js'; import type { Berserk } from '../game/archetypes/Berserk.js'; +import type { Adept } from '../game/archetypes/Adept.js'; export type Intent = { type: string; @@ -383,12 +386,13 @@ function collectTileLoot(ctx: ApplyIntentContext) { /** * Archetype dispatcher for the unified `special` intent. Picks the perk verb * by capability check on the live player: - * - `canDeploy` β†’ Tech's Deploy Turret - * - `canVault` β†’ Merc's Vault - * - `canSlide` β†’ Razor's Slide - * - `canEmp` β†’ Decker's EMP neural-shock (self-centered AOE stun) - * - `canSurge` β†’ Berserk's Surge self-buff - * - `canOverride` β†’ CyberAvatar's ICE override (cyber grid only) + * - `canDeploy` β†’ Tech's Deploy Turret + * - `canVault` β†’ Merc's Vault + * - `canSlide` β†’ Razor's Slide + * - `canEmp` β†’ Decker's EMP neural-shock (self-centered AOE stun) + * - `canSurge` β†’ Berserk's Surge self-buff + * - `canInfluence` β†’ Adept's Influence (Meatspace mind control) + * - `canOverride` β†’ CyberAvatar's ICE override (cyber grid only) * * Capability sniffing (vs. a class `instanceof` check) keeps this module free * of the archetype-class imports β€” applyIntent stays a thin glue layer. A @@ -421,8 +425,13 @@ function doSpecial(intent: Intent, ctx: ApplyIntentContext) { if (typeof (player as Berserk).canSurge === 'function') { return doSurge(ctx); } + if (typeof (player as Adept).canInfluence === 'function') { + return doInfluence(intent, ctx); + } // Only the CyberAvatar still exposes canOverride (P3.5.M2 moved the Decker's - // Meatspace perk to EMP; M4 repoints this picker at the Adept's Influence). + // Meatspace perk to EMP; P3.5.M4 gave the Adept the renamed Influence perk + // β€” the CyberAvatar keeps its own "Override" name/fiction for the cyber + // grid, delegating to the same underlying mindInfluence.ts machinery). if (typeof (player as CyberAvatar).canOverride === 'function') { return doOverride(intent, ctx); } @@ -469,9 +478,13 @@ function doEmp(ctx: ApplyIntentContext) { } /** - * Acquire a drone in the Decker's aimed eight-way sector and attempt the - * hijack. Range, LOS, and perception match the resolver and combat targeting; - * a failed roll trips the alarm, while an empty sector yields a legible deny. + * Acquire a hostile in the CyberAvatar's aimed eight-way sector and attempt + * the ICE hijack. Range, LOS, and perception match the resolver and combat + * targeting; a failed roll trips the alarm, while an empty sector yields a + * legible deny. Shares `pickInfluenceTarget`/`isInAimSector` with the Adept's + * `doInfluence` below β€” same picker, same underlying `mindInfluence.ts` + * mechanic, different fiction (ICE vs. a hostile mind) and different method + * names on the acting class. */ type OverrideActor = ApplyIntentContext['player'] & { canOverride(world: World, target: Entity | null): ReturnType; @@ -480,15 +493,15 @@ type OverrideActor = ApplyIntentContext['player'] & { function doOverride(intent: Intent, ctx: ApplyIntentContext) { const { world, player, log } = ctx; - const decker = player as OverrideActor; + const avatar = player as OverrideActor; const playerLabel = entityLabel(player); - const target = pickOverrideTarget(ctx, intent.dx!, intent.dy!); - const check = decker.canOverride(world, target); + const target = pickInfluenceTarget(ctx, intent.dx!, intent.dy!); + const check = avatar.canOverride(world, target); if (!check.ok) { log(`> ${playerLabel} OVERRIDE DENIED: ${check.reason}`); return; } - const result = decker.overrideDrone(world, target!, ctx.rng); + const result = avatar.overrideDrone(world, target!, ctx.rng); const targetLabel = entityLabel(target!); if (result.success) { log(`> ${playerLabel} OVERRIDES ${targetLabel} β€” it fights for you! (${player.ap} AP left).`); @@ -502,12 +515,50 @@ function doOverride(intent: Intent, ctx: ApplyIntentContext) { } /** - * Nearest live hostile in the aimed eight-way sector within `OVERRIDE_RANGE` + * Acquire a hostile in the Adept's aimed eight-way sector and attempt to + * dominate its will. Same picker/range/LOS/perception as the CyberAvatar's + * `doOverride` above β€” a failed roll trips the alarm, an empty sector yields + * a legible deny. + */ +type InfluenceActor = ApplyIntentContext['player'] & { + canInfluence(world: World, target: Entity | null): ReturnType; + influenceTarget(world: World, target: Entity, rng: Rng): ReturnType; +}; + +function doInfluence(intent: Intent, ctx: ApplyIntentContext) { + const { world, player, log } = ctx; + const adept = player as InfluenceActor; + const playerLabel = entityLabel(player); + const target = pickInfluenceTarget(ctx, intent.dx!, intent.dy!); + const check = adept.canInfluence(world, target); + if (!check.ok) { + log(`> ${playerLabel} INFLUENCE DENIED: ${check.reason}`); + return; + } + const result = adept.influenceTarget(world, target!, ctx.rng); + const targetLabel = entityLabel(target!); + if (result.success) { + log( + `> ${playerLabel} DOMINATES ${targetLabel}'s will β€” it fights for you! (${player.ap} AP left).` + ); + } else { + log( + `> ${playerLabel} INFLUENCE FAILED on ${targetLabel}` + + `${result.alarm ? ' β€” ALARM TRIPPED' : ''} (${player.ap} AP left).` + ); + } + gateOnApExhausted(ctx); +} + +/** + * Nearest live hostile in the aimed eight-way sector within `INFLUENCE_RANGE` * and LOS. The 22.5-degree half-angle partitions arbitrary target offsets * across the eight directions supplied by keyboard and touch controls, so a - * moving Probe does not need to be perfectly collinear with the avatar. + * moving target does not need to be perfectly collinear with the operator. + * Shared by the CyberAvatar's cyber-grid Override and the Adept's Influence β€” + * the picker itself has no archetype-specific behavior. */ -function pickOverrideTarget(ctx: ApplyIntentContext, dx: number, dy: number) { +function pickInfluenceTarget(ctx: ApplyIntentContext, dx: number, dy: number) { const { world, player } = ctx; const blockers = world.blockerKeys(); let best: Hostile | null = null; @@ -519,7 +570,7 @@ function pickOverrideTarget(ctx: ApplyIntentContext, dx: number, dy: number) { const offsetX = entity.x - player.x; const offsetY = entity.y - player.y; if (!isInAimSector(dx, dy, offsetX, offsetY)) continue; - if (!withinRange(player.x, player.y, entity.x, entity.y, OVERRIDE_RANGE)) continue; + if (!withinRange(player.x, player.y, entity.x, entity.y, INFLUENCE_RANGE)) continue; if ( !hasLineOfSight(world.grid, player.x, player.y, entity.x, entity.y, { blockers, diff --git a/tests/unit/game/Adept.test.ts b/tests/unit/game/Adept.test.ts new file mode 100644 index 0000000..238871d --- /dev/null +++ b/tests/unit/game/Adept.test.ts @@ -0,0 +1,101 @@ +/** + * Adept archetype tests (P3.5.M4). + * + * The Adept inherits the old Decker "Drone Override Hack" mechanic wholesale + * β€” reflavored as **Influence**, psychic domination of a hostile's will. The + * mechanic's own pure-function coverage lives in `mindInfluence.test.ts`; + * here we cover the class basics (base stats deliberately weaker than the + * combat archetypes) and the thin `canInfluence`/`influenceTarget` + * delegators, mirroring the shape `Decker.test.ts` used for Override before + * P3.5.M2 moved it off the Decker. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Adept } from '../../../src/game/archetypes/Adept.js'; +import { Crew } from '../../../src/game/Crew.js'; +import { Grid } from '../../../src/game/Grid.js'; +import { World } from '../../../src/game/World.js'; +import { Skirmisher } from '../../../src/game/ai/Skirmisher.js'; +import { FACTION, AP_COST, INFLUENCE_DURATION } from '../../../src/game/constants.js'; + +function makeWorld({ adeptAt = [1, 1], grid, extraEntities = [] } = {}) { + const g = grid ?? new Grid(12, 12); + const w = new World(g); + const adept = new Adept({ id: 'adept', x: adeptAt[0], y: adeptAt[1] }); + w.addEntity(adept); + for (const e of extraEntities) w.addEntity(e); + return { world: w, adept }; +} + +const makeDrone = (id, x, y, faction = FACTION.CORP) => new Skirmisher({ id, x, y, faction }); + +// Deterministic single-roll stubs: 0.1 < success chance β†’ dominate; 0.9 β†’ fail. +const winRng = { next: () => 0.1 }; +const loseRng = { next: () => 0.9 }; + +// --- class basics ------------------------------------------------------------ + +test('Adept extends Crew and is a PLAYER-faction operator with the @ glyph', () => { + const a = new Adept({ id: 'a', x: 0, y: 0 }); + assert.ok(a instanceof Crew, 'Adept must extend Crew'); + assert.equal(a.faction, FACTION.PLAYER); + assert.equal(a.glyph, '@'); + assert.equal(a.archetype, 'Adept'); +}); + +test('Adept base stats are deliberately weaker than the combat archetypes', () => { + const a = new Adept({ id: 'a', x: 0, y: 0 }); + assert.equal(a.baseHitChance, 0.7); + assert.equal(a.baseDodgeChance, 0.2); +}); + +// --- Influence delegators ---------------------------------------------------- + +test('Adept.canInfluence accepts a live in-range LOS corp hostile', () => { + const drone = makeDrone('k', 3, 1); + const { world, adept } = makeWorld({ extraEntities: [drone] }); + assert.equal(adept.canInfluence(world, drone).ok, true); +}); + +test('Adept.canInfluence rejects when AP < INFLUENCE cost', () => { + const drone = makeDrone('k', 3, 1); + const { world, adept } = makeWorld({ extraEntities: [drone] }); + adept.spendAp(adept.ap - (AP_COST.INFLUENCE - 1)); + assert.equal(adept.canInfluence(world, drone).reason, 'insufficient-ap'); +}); + +test('Adept.canInfluence rejects a non-Hostile target', () => { + const { world, adept } = makeWorld(); + assert.equal(adept.canInfluence(world, null).reason, 'not-overridable'); +}); + +test('Adept.influenceTarget success dominates the target for the full duration', () => { + const drone = makeDrone('k', 3, 1); + const { world, adept } = makeWorld({ extraEntities: [drone] }); + const apBefore = adept.ap; + const result = adept.influenceTarget(world, drone, winRng); + assert.equal(result.success, true); + assert.equal(drone.faction, FACTION.PLAYER); + assert.equal(drone.overrideTurnsRemaining, INFLUENCE_DURATION); + assert.equal(adept.ap, apBefore - AP_COST.INFLUENCE); +}); + +test('Adept.influenceTarget failure burns AP, trips the alarm, leaves faction intact', () => { + const drone = makeDrone('k', 3, 1); + const { world, adept } = makeWorld({ extraEntities: [drone] }); + const apBefore = adept.ap; + const result = adept.influenceTarget(world, drone, loseRng); + assert.equal(result.success, false); + assert.equal(result.alarm, true); + assert.equal(drone.faction, FACTION.CORP); + assert.equal(adept.ap, apBefore - AP_COST.INFLUENCE); +}); + +test('Adept.influenceTarget throws on an illegal attempt without burning AP', () => { + const drone = makeDrone('k', 9, 1); // out of range + const { world, adept } = makeWorld({ extraEntities: [drone] }); + const apBefore = adept.ap; + assert.throws(() => adept.influenceTarget(world, drone, winRng), /Illegal override/); + assert.equal(adept.ap, apBefore, 'AP not debited on illegal influence'); +}); diff --git a/tests/unit/game/Campaign.test.ts b/tests/unit/game/Campaign.test.ts index 9a1d23e..7f392b5 100644 --- a/tests/unit/game/Campaign.test.ts +++ b/tests/unit/game/Campaign.test.ts @@ -1586,8 +1586,8 @@ test('generateRecruits returns empty when Rep < threshold', () => { assert.equal(campaign.availableRecruits.length, 0); }); -test('generateRecruits follows the 2/2/1/1 archetype pool over many seeds', () => { - const counts: Record = { Merc: 0, Razor: 0, Tech: 0, Berserk: 0 }; +test('generateRecruits follows the 2/2/1/1/1 archetype pool over many seeds', () => { + const counts: Record = { Merc: 0, Razor: 0, Tech: 0, Berserk: 0, Adept: 0 }; const total = 1000; for (let i = 0; i < total; i++) { const campaign = new Campaign({ seed: i, rep: 80 }); @@ -1595,13 +1595,13 @@ test('generateRecruits follows the 2/2/1/1 archetype pool over many seeds', () = counts[recruit.constructor.name]++; } } - const sum = counts.Merc + counts.Razor + counts.Tech + counts.Berserk; - // Merc/Razor ~33%; Tech/Berserk ~17%. Allow Β±8% tolerance. + const sum = counts.Merc + counts.Razor + counts.Tech + counts.Berserk + counts.Adept; + // Merc/Razor ~29%; Tech/Berserk/Adept ~14%. Allow generous tolerance. for (const id of ['Merc', 'Razor']) { const ratio = counts[id] / sum; assert.ok(ratio > 0.25 && ratio < 0.41, `${id} ${(ratio * 100).toFixed(1)}% out of range`); } - for (const id of ['Tech', 'Berserk']) { + for (const id of ['Tech', 'Berserk', 'Adept']) { const ratio = counts[id] / sum; assert.ok(ratio > 0.09 && ratio < 0.25, `${id} ${(ratio * 100).toFixed(1)}% out of range`); } @@ -1762,7 +1762,7 @@ test('generateInitialCandidates returns RECRUIT.INITIAL_CANDIDATES (3) candidate }); test('generateInitialCandidates uses weighted archetype pool', () => { - const counts: Record = { Merc: 0, Razor: 0, Tech: 0, Berserk: 0 }; + const counts: Record = { Merc: 0, Razor: 0, Tech: 0, Berserk: 0, Adept: 0 }; const total = 500; for (let i = 0; i < total; i++) { const campaign = new Campaign({ seed: i, crew: [] }); @@ -1771,11 +1771,13 @@ test('generateInitialCandidates uses weighted archetype pool', () => { counts[c.constructor.name]++; } } - const sum = counts.Merc + counts.Razor + counts.Tech + counts.Berserk; + const sum = counts.Merc + counts.Razor + counts.Tech + counts.Berserk + counts.Adept; assert.ok(counts.Berserk > 0, 'Berserk must be reachable in the starter candidate pool'); + assert.ok(counts.Adept > 0, 'Adept must be reachable in the starter candidate pool'); assert.ok(counts.Merc / sum > 0.23 && counts.Merc / sum < 0.43); assert.ok(counts.Tech / sum > 0.07 && counts.Tech / sum < 0.27); assert.ok(counts.Berserk / sum > 0.07 && counts.Berserk / sum < 0.27); + assert.ok(counts.Adept / sum > 0.07 && counts.Adept / sum < 0.27); }); test('recruitInitial validates exactly RECRUIT.INITIAL_PICKS (2) IDs', () => { diff --git a/tests/unit/game/Decker.test.ts b/tests/unit/game/Decker.test.ts index d7072c8..22f41a0 100644 --- a/tests/unit/game/Decker.test.ts +++ b/tests/unit/game/Decker.test.ts @@ -3,9 +3,9 @@ * * The Decker's Meatspace signature is now **EMP** β€” a self-centered AOE stun. * The old Drone Override Hack moved off the Decker in M2 (its module coverage - * lives in `droneOverride.test.ts`, and it becomes the Adept's Influence perk - * in M4). Here we cover the class basics and the thin `canEmp`/`detonateEmp` - * delegators to `empBlast.ts`. + * lives in `mindInfluence.test.ts`, and it became the Adept's Influence perk + * in M4 β€” see `Adept.test.ts`). Here we cover the class basics and the thin + * `canEmp`/`detonateEmp` delegators to `empBlast.ts`. */ import { test } from 'node:test'; import assert from 'node:assert/strict'; diff --git a/tests/unit/game/Run.test.ts b/tests/unit/game/Run.test.ts index da6edd1..af62c34 100644 --- a/tests/unit/game/Run.test.ts +++ b/tests/unit/game/Run.test.ts @@ -24,6 +24,7 @@ import { Rng } from '../../../src/rng.js'; import { testContractContext } from './contractTestUtils.js'; import { ITEM_ID } from '../../../src/game/items.js'; import { Berserk } from '../../../src/game/archetypes/Berserk.js'; +import { Adept } from '../../../src/game/archetypes/Adept.js'; const fakeContract = (overrides = {}) => ({ seed: 12345, @@ -93,6 +94,14 @@ test('Run classifies a Berserk and seeds matching telemetry', () => { assert.equal(run.telemetry.archetype, 'berserk'); }); +test('Run classifies an Adept and seeds matching telemetry', () => { + const crewMember = makeCrew('adept'); + const run = new Run({ crewMember, seed: 44 }); + assert.ok(crewMember instanceof Adept); + assert.equal(run.archetype, 'adept'); + assert.equal(run.telemetry.archetype, 'adept'); +}); + test('legal transition chain: BRIEFING β†’ COMBAT β†’ RESULT', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 42 }); run.enterBriefing(fakeContract()); diff --git a/tests/unit/game/archetypes.test.ts b/tests/unit/game/archetypes.test.ts index f7518d7..882432a 100644 --- a/tests/unit/game/archetypes.test.ts +++ b/tests/unit/game/archetypes.test.ts @@ -24,6 +24,7 @@ import { } from '../../../src/game/archetypes/index.js'; import { Decker } from '../../../src/game/archetypes/Decker.js'; import { Berserk, CALLSIGNS as BERSERK_CALLSIGNS } from '../../../src/game/archetypes/Berserk.js'; +import { Adept, CALLSIGNS as ADEPT_CALLSIGNS } from '../../../src/game/archetypes/Adept.js'; import { FACTION } from '../../../src/game/constants.js'; import { Merc, CALLSIGNS as MERC_CALLSIGNS } from '../../../src/game/archetypes/Merc.js'; import { CALLSIGNS as RAZOR_CALLSIGNS } from '../../../src/game/archetypes/Razor.js'; @@ -74,6 +75,7 @@ test('CALLSIGNS_BY_ARCHETYPE mirrors the per-archetype module exports', () => { assert.deepEqual(CALLSIGNS_BY_ARCHETYPE.merc, MERC_CALLSIGNS); assert.deepEqual(CALLSIGNS_BY_ARCHETYPE.razor, RAZOR_CALLSIGNS); assert.deepEqual(CALLSIGNS_BY_ARCHETYPE.berserk, BERSERK_CALLSIGNS); + assert.deepEqual(CALLSIGNS_BY_ARCHETYPE.adept, ADEPT_CALLSIGNS); }); test('pickCallsign returns a name from the archetype pool', () => { @@ -169,6 +171,7 @@ test('perkAim metadata: only self-centered perks are tagged "self"', () => { assert.equal(ARCHETYPES.tech.perkAim, 'directional'); assert.equal(ARCHETYPES.decker.perkAim, 'self'); assert.equal(ARCHETYPES.berserk.perkAim, 'self'); + assert.equal(ARCHETYPES.adept.perkAim, 'directional'); }); test('perkAimForArchetype resolves lowercase id and class-cased Crew.archetype', () => { @@ -178,6 +181,8 @@ test('perkAimForArchetype resolves lowercase id and class-cased Crew.archetype', assert.equal(perkAimForArchetype('Razor'), 'directional'); assert.equal(perkAimForArchetype('berserk'), 'self'); assert.equal(perkAimForArchetype('Berserk'), 'self'); + assert.equal(perkAimForArchetype('adept'), 'directional'); + assert.equal(perkAimForArchetype('Adept'), 'directional'); }); test('perkAimForArchetype throws on an unknown archetype (no silent fallback)', () => { @@ -216,6 +221,20 @@ test('Berserk is registered, recruitable, and self-targeted', () => { assert.ok(BERSERK_CALLSIGNS.includes(berserk.callsign)); }); +test('Adept is registered, recruitable, and directionally aimed', () => { + const a = ARCHETYPES.adept; + assert.deepEqual(a.perks, ['influence']); + assert.equal(a.perkName, 'INFLUENCE'); + assert.equal(a.perkAim, 'directional'); + assert.ok(!ARCHETYPE_IDS.includes('adept')); + assert.ok(RECRUIT_ARCHETYPE_POOL.includes('adept')); + + const adept = buildCrewMember('adept', { x: 1, y: 2 }, new Rng(10)); + assert.ok(adept instanceof Adept); + assert.equal(isArchetypeId('adept'), true); + assert.ok(ADEPT_CALLSIGNS.includes(adept.callsign)); +}); + test('isArchetypeId is a string-set membership check', () => { assert.equal(isArchetypeId('merc'), true); assert.equal(isArchetypeId('razor'), true); diff --git a/tests/unit/game/cyber/ProbeIce.test.ts b/tests/unit/game/cyber/ProbeIce.test.ts index d3b7c21..bfba6e6 100644 --- a/tests/unit/game/cyber/ProbeIce.test.ts +++ b/tests/unit/game/cyber/ProbeIce.test.ts @@ -17,7 +17,7 @@ import { PATROL_STATE } from '../../../../src/game/ai/PatrolHostile.js'; import { JackInPoint } from '../../../../src/game/entities/JackInPoint.js'; import { resolveMelee } from '../../../../src/game/Combat.js'; import { runPlayerAftermathSteps } from '../../../../src/game/combatTurnPipeline.js'; -import { applyOverride } from '../../../../src/game/droneOverride.js'; +import { applyOverride } from '../../../../src/game/mindInfluence.js'; import { EVENT } from '../../../../src/game/events.js'; import { snapshot, restore } from '../../../../src/game/persistence.js'; import { buildCrewMember } from '../../../../src/game/archetypes/index.js'; diff --git a/tests/unit/game/cyber/cyberPersistence.test.ts b/tests/unit/game/cyber/cyberPersistence.test.ts index 11ff58c..9931183 100644 --- a/tests/unit/game/cyber/cyberPersistence.test.ts +++ b/tests/unit/game/cyber/cyberPersistence.test.ts @@ -16,7 +16,7 @@ import { EntryPort } from '../../../../src/game/cyber/EntryPort.js'; import { JackInPoint } from '../../../../src/game/entities/JackInPoint.js'; import { Decker } from '../../../../src/game/archetypes/Decker.js'; import { ProbeIce } from '../../../../src/game/cyber/ProbeIce.js'; -import { applyOverride } from '../../../../src/game/droneOverride.js'; +import { applyOverride } from '../../../../src/game/mindInfluence.js'; import { Campaign } from '../../../../src/game/Campaign.js'; import { EVENT } from '../../../../src/game/events.js'; import { diff --git a/tests/unit/game/droneOverride.test.ts b/tests/unit/game/mindInfluence.test.ts similarity index 51% rename from tests/unit/game/droneOverride.test.ts rename to tests/unit/game/mindInfluence.test.ts index 6b93dbb..fb48915 100644 --- a/tests/unit/game/droneOverride.test.ts +++ b/tests/unit/game/mindInfluence.test.ts @@ -1,13 +1,14 @@ /** - * Drone Override module tests (P3.5.M2 relocation). + * Mind Influence module tests (P3.5.M4 rename from `droneOverride.test.ts`). * - * This coverage used to live in `Decker.test.ts`, exercised through the Decker's - * `canOverride`/`overrideDrone` delegators. M2 removed those delegators (the - * Decker's Meatspace perk is now EMP), so this file tests the `droneOverride` - * module functions *directly* against a generic PLAYER-faction operator β€” the - * mechanic is faction-agnostic and never depended on the Decker specifically. - * M4 renames this module to `mindInfluence.ts` (the Adept's Influence perk) and - * this file moves with it. + * This coverage used to live in `Decker.test.ts`, then moved standalone in + * P3.5.M2 once the Decker's Meatspace perk became EMP (the module kept + * serving only the CyberAvatar's cyber-grid Override in the interim). M4 + * renames the module to `mindInfluence.ts` and gives it a permanent Meatspace + * owner (the Adept's Influence perk) β€” this file moves and renames with it, + * still testing the pure functions *directly* against a generic PLAYER- + * faction operator: the mechanic is faction-agnostic and never depended on + * any specific archetype. */ import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -17,19 +18,19 @@ import { World } from '../../../src/game/World.js'; import { Entity } from '../../../src/game/Entity.js'; import { Skirmisher } from '../../../src/game/ai/Skirmisher.js'; import { - canOverride, - overrideDrone, + canInfluence, + influenceTarget, applyOverride, - stepOverriddenDrones, -} from '../../../src/game/droneOverride.js'; -import { TILE, FACTION, AP_COST, OVERRIDE_DURATION } from '../../../src/game/constants.js'; + stepInfluencedHostiles, +} from '../../../src/game/mindInfluence.js'; +import { TILE, FACTION, AP_COST, INFLUENCE_DURATION } from '../../../src/game/constants.js'; import { Rng } from '../../../src/rng.js'; function makeWorld({ operatorAt = [1, 1], grid, extraEntities = [] } = {}) { const g = grid ?? new Grid(12, 12); const w = new World(g); // A generic PLAYER-faction operator β€” the module only needs alive/canAfford/ - // x,y/faction/spendAp, none of which are Decker-specific. + // x,y/faction/spendAp, none of which are archetype-specific. const operator = new Entity({ id: 'operator', x: operatorAt[0], @@ -46,130 +47,130 @@ function makeDrone(id, x, y, faction = FACTION.CORP) { return new Skirmisher({ id, x, y, faction }); } -// Deterministic single-roll stubs: 0.1 < success chance β†’ hijack; 0.9 β†’ fail. +// Deterministic single-roll stubs: 0.1 < success chance β†’ dominate; 0.9 β†’ fail. const winRng = { next: () => 0.1 }; const loseRng = { next: () => 0.9 }; -// --- canOverride legality matrix ------------------------------------------- +// --- canInfluence legality matrix ------------------------------------------- -test('canOverride accepts a live in-range LOS corp drone', () => { +test('canInfluence accepts a live in-range LOS corp hostile', () => { const drone = makeDrone('k', 3, 1); const { world, operator } = makeWorld({ extraEntities: [drone] }); - assert.equal(canOverride(world, operator, drone).ok, true); + assert.equal(canInfluence(world, operator, drone).ok, true); }); -test('canOverride rejects a null / non-Hostile target as not-overridable', () => { +test('canInfluence rejects a null / non-Hostile target as not-overridable', () => { const prop = new Entity({ id: 'prop', x: 2, y: 1, faction: FACTION.CORP, glyph: '#' }); const { world, operator } = makeWorld({ extraEntities: [prop] }); - assert.equal(canOverride(world, operator, null).reason, 'not-overridable'); - assert.equal(canOverride(world, operator, prop).reason, 'not-overridable'); + assert.equal(canInfluence(world, operator, null).reason, 'not-overridable'); + assert.equal(canInfluence(world, operator, prop).reason, 'not-overridable'); }); -test('canOverride rejects when the operator is dead', () => { +test('canInfluence rejects when the operator is dead', () => { const drone = makeDrone('k', 3, 1); const { world, operator } = makeWorld({ extraEntities: [drone] }); operator.alive = false; - assert.equal(canOverride(world, operator, drone).reason, 'dead'); + assert.equal(canInfluence(world, operator, drone).reason, 'dead'); }); -test('canOverride rejects when AP < OVERRIDE cost', () => { +test('canInfluence rejects when AP < INFLUENCE cost', () => { const drone = makeDrone('k', 3, 1); const { world, operator } = makeWorld({ extraEntities: [drone] }); - operator.spendAp(operator.ap - (AP_COST.OVERRIDE - 1)); - assert.equal(canOverride(world, operator, drone).reason, 'insufficient-ap'); + operator.spendAp(operator.ap - (AP_COST.INFLUENCE - 1)); + assert.equal(canInfluence(world, operator, drone).reason, 'insufficient-ap'); }); -test('canOverride rejects a dead target drone', () => { +test('canInfluence rejects a dead target', () => { const drone = makeDrone('k', 3, 1); const { world, operator } = makeWorld({ extraEntities: [drone] }); drone.alive = false; - assert.equal(canOverride(world, operator, drone).reason, 'dead-target'); + assert.equal(canInfluence(world, operator, drone).reason, 'dead-target'); }); -test('canOverride rejects a drone that is already overridden', () => { +test('canInfluence rejects a target that is already dominated', () => { const drone = makeDrone('k', 3, 1); const { world, operator } = makeWorld({ extraEntities: [drone] }); applyOverride(drone, FACTION.PLAYER); - assert.equal(canOverride(world, operator, drone).reason, 'already-overridden'); + assert.equal(canInfluence(world, operator, drone).reason, 'already-overridden'); }); -test('canOverride rejects a same-faction (already player-aligned) drone', () => { +test('canInfluence rejects a same-faction (already player-aligned) target', () => { const drone = makeDrone('k', 3, 1, FACTION.PLAYER); const { world, operator } = makeWorld({ extraEntities: [drone] }); - assert.equal(canOverride(world, operator, drone).reason, 'friendly'); + assert.equal(canInfluence(world, operator, drone).reason, 'friendly'); }); -test('canOverride rejects an out-of-range drone', () => { - const drone = makeDrone('k', 9, 1); // distance 8 > OVERRIDE_RANGE (5) +test('canInfluence rejects an out-of-range target', () => { + const drone = makeDrone('k', 9, 1); // distance 8 > INFLUENCE_RANGE (5) const { world, operator } = makeWorld({ extraEntities: [drone] }); - assert.equal(canOverride(world, operator, drone).reason, 'out-of-range'); + assert.equal(canInfluence(world, operator, drone).reason, 'out-of-range'); }); -test('canOverride rejects a drone behind a wall (no LOS)', () => { +test('canInfluence rejects a target behind a wall (no LOS)', () => { const g = new Grid(12, 12); g.setTile(2, 1, TILE.WALL); const drone = makeDrone('k', 3, 1); const { world, operator } = makeWorld({ grid: g, extraEntities: [drone] }); - assert.equal(canOverride(world, operator, drone).reason, 'no-los'); + assert.equal(canInfluence(world, operator, drone).reason, 'no-los'); }); -// --- overrideDrone commit semantics ---------------------------------------- +// --- influenceTarget commit semantics --------------------------------------- -test('overrideDrone success flips the drone to PLAYER for the full duration', () => { +test('influenceTarget success flips the target to PLAYER for the full duration', () => { const drone = makeDrone('k', 3, 1); const { world, operator } = makeWorld({ extraEntities: [drone] }); const apBefore = operator.ap; - const result = overrideDrone(world, operator, drone, winRng); + const result = influenceTarget(world, operator, drone, winRng); assert.equal(result.success, true); assert.equal(result.alarm, false); assert.equal(drone.faction, FACTION.PLAYER); assert.equal(drone.factionBeforeOverride, FACTION.CORP); - assert.equal(drone.overrideTurnsRemaining, OVERRIDE_DURATION); + assert.equal(drone.overrideTurnsRemaining, INFLUENCE_DURATION); assert.equal(drone.isOverridden, true); - assert.equal(operator.ap, apBefore - AP_COST.OVERRIDE); + assert.equal(operator.ap, apBefore - AP_COST.INFLUENCE); }); -test('overrideDrone failure burns AP, trips the alarm, leaves faction intact', () => { +test('influenceTarget failure burns AP, trips the alarm, leaves faction intact', () => { const drone = makeDrone('k', 3, 1); const { world, operator } = makeWorld({ extraEntities: [drone] }); const apBefore = operator.ap; assert.equal(world.alarmActive, false); - const result = overrideDrone(world, operator, drone, loseRng); + const result = influenceTarget(world, operator, drone, loseRng); assert.equal(result.success, false); assert.equal(result.alarm, true); assert.equal(world.alarmActive, true); assert.equal(drone.faction, FACTION.CORP); assert.equal(drone.isOverridden, false); - assert.equal(operator.ap, apBefore - AP_COST.OVERRIDE); + assert.equal(operator.ap, apBefore - AP_COST.INFLUENCE); }); -test('overrideDrone throws on an illegal attempt without burning AP', () => { +test('influenceTarget throws on an illegal attempt without burning AP', () => { const drone = makeDrone('k', 9, 1); // out of range const { world, operator } = makeWorld({ extraEntities: [drone] }); const apBefore = operator.ap; - assert.throws(() => overrideDrone(world, operator, drone, winRng), /Illegal override/); - assert.equal(operator.ap, apBefore, 'AP not debited on illegal override'); + assert.throws(() => influenceTarget(world, operator, drone, winRng), /Illegal override/); + assert.equal(operator.ap, apBefore, 'AP not debited on illegal influence'); assert.equal(drone.faction, FACTION.CORP); }); -// --- golden path: hijacked drone fights corp, then reverts ----------------- +// --- golden path: dominated hostile fights corp, then reverts --------------- -test('an overridden drone attacks corp allies for N turns then reverts', () => { +test('a dominated hostile attacks corp allies for N turns then reverts', () => { const drone = makeDrone('hijacked', 4, 4); const victim = makeDrone('victim', 6, 4); // corp; the drone's new enemy const { world, operator } = makeWorld({ operatorAt: [2, 4], extraEntities: [drone, victim] }); - const result = overrideDrone(world, operator, drone, winRng); + const result = influenceTarget(world, operator, drone, winRng); assert.equal(result.success, true); const rng = new Rng(7); const victimHpStart = victim.hp; let sawEngageAction = false; let expired = false; - for (let turn = 0; turn < OVERRIDE_DURATION; turn++) { + for (let turn = 0; turn < INFLUENCE_DURATION; turn++) { assert.equal(drone.faction, FACTION.PLAYER, `drone should be player-aligned on turn ${turn}`); drone.refreshAp(); - for (const { entity, action } of stepOverriddenDrones(world, rng)) { + for (const { entity, action } of stepInfluencedHostiles(world, rng)) { assert.equal(entity.id, drone.id); if (action.type === 'fire' || String(action.type).startsWith('move')) { sawEngageAction = true; @@ -178,10 +179,10 @@ test('an overridden drone attacks corp allies for N turns then reverts', () => { } } - assert.ok(sawEngageAction, 'hijacked drone should engage (fire/move) against corp'); - assert.ok(expired, 'override should emit an override-expired action when it lapses'); - assert.equal(drone.isOverridden, false, 'override countdown should be spent'); + assert.ok(sawEngageAction, 'dominated hostile should engage (fire/move) against corp'); + assert.ok(expired, 'influence should emit an override-expired action when it lapses'); + assert.equal(drone.isOverridden, false, 'influence countdown should be spent'); assert.equal(drone.faction, FACTION.CORP, 'drone reverts to its original faction'); assert.equal(drone.factionBeforeOverride, null, 'override bookkeeping cleared on revert'); - assert.ok(victim.hp <= victimHpStart, 'corp victim took fire from the hijacked drone'); + assert.ok(victim.hp <= victimHpStart, 'corp victim took fire from the dominated hostile'); }); diff --git a/tests/unit/game/persistence.test.ts b/tests/unit/game/persistence.test.ts index 482594c..22858ec 100644 --- a/tests/unit/game/persistence.test.ts +++ b/tests/unit/game/persistence.test.ts @@ -29,8 +29,9 @@ import { makeSalvage, totalSalvage } from '../../../src/game/salvage.js'; import { buildCrewMember } from '../../../src/game/archetypes/index.js'; import { Decker } from '../../../src/game/archetypes/Decker.js'; import { Berserk } from '../../../src/game/archetypes/Berserk.js'; +import { Adept } from '../../../src/game/archetypes/Adept.js'; import { PatrolHostile } from '../../../src/game/ai/PatrolHostile.js'; -import { applyOverride } from '../../../src/game/droneOverride.js'; +import { applyOverride } from '../../../src/game/mindInfluence.js'; import { Rng } from '../../../src/rng.js'; import { testContractContext } from './contractTestUtils.js'; @@ -131,6 +132,16 @@ test('snapshot/restore round-trips a Berserk deploy (P3.5.M3)', () => { assert.deepEqual(snapshot(restoredRun), rec); }); +test('snapshot/restore round-trips an Adept deploy (P3.5.M4)', () => { + const run = freshCombatRun(0xadeb70, 'adept'); + assert.ok(run.player instanceof Adept, 'fixture should deploy an Adept'); + const rec = snapshot(run); + const { run: restoredRun } = restore(rec); + assert.ok(restoredRun.player instanceof Adept, 'Adept should restore as an Adept'); + assert.equal(restoredRun.player.callsign, run.player.callsign); + assert.deepEqual(snapshot(restoredRun), rec); +}); + test('snapshot/restore preserves an active Berserk Surge, including bonus AP and armor', () => { const run = freshCombatRun(0xb3e5e5, 'berserk'); const berserk = run.player as Berserk; diff --git a/tests/unit/input/applyIntent.test.ts b/tests/unit/input/applyIntent.test.ts index a3a7279..84f62ba 100644 --- a/tests/unit/input/applyIntent.test.ts +++ b/tests/unit/input/applyIntent.test.ts @@ -19,6 +19,7 @@ import { Razor } from '../../../src/game/archetypes/Razor.js'; import { Tech } from '../../../src/game/archetypes/Tech.js'; import { Decker } from '../../../src/game/archetypes/Decker.js'; import { Berserk } from '../../../src/game/archetypes/Berserk.js'; +import { Adept } from '../../../src/game/archetypes/Adept.js'; import { CyberAvatar } from '../../../src/game/cyber/CyberAvatar.js'; import { ProbeIce } from '../../../src/game/cyber/ProbeIce.js'; import { Turret } from '../../../src/game/Turret.js'; @@ -55,7 +56,9 @@ function buildCtx({ archetype = 'merc', placeDrone = true } = {}) { ? new Tech({ id: 'tech', x: 2, y: 2, maxAp: 4 }) : archetype === 'berserk' ? new Berserk({ id: 'berserk', x: 2, y: 2, maxAp: 4 }) - : new Razor({ id: 'razor', x: 2, y: 2, maxAp: 4 }); + : archetype === 'adept' + ? new Adept({ id: 'adept', x: 2, y: 2, maxAp: 4 }) + : new Razor({ id: 'razor', x: 2, y: 2, maxAp: 4 }); world.addEntity(player); let drone = null; @@ -561,7 +564,7 @@ test('special intent routes CyberAvatar Override against Probe ICE', () => { applyIntent({ type: 'special', dx: 1, dy: 0 }, ctx); assert.equal(probe.faction, FACTION.PLAYER); - assert.equal(avatar.ap, avatar.maxAp - AP_COST.OVERRIDE); + assert.equal(avatar.ap, avatar.maxAp - AP_COST.INFLUENCE); assert.ok(log.some(line => line.includes('OVERRIDES Probe'))); }); @@ -596,10 +599,49 @@ test('CyberAvatar Override acquires an off-axis Probe in the aimed direction', ( applyIntent({ type: 'special', dx: 1, dy: 0 }, ctx); assert.equal(probe.faction, FACTION.PLAYER); - assert.equal(avatar.ap, avatar.maxAp - AP_COST.OVERRIDE); + assert.equal(avatar.ap, avatar.maxAp - AP_COST.INFLUENCE); assert.ok(log.some(line => line.includes('OVERRIDES Probe'))); }); +test('special intent routes to Influence on an Adept and dominates the aimed hostile', () => { + const grid = new Grid(10, 6); + const bus = new EventBus(); + const world = new World(grid, { events: bus }); + const adept = new Adept({ id: 'adept', x: 2, y: 2, maxAp: 4 }); + const drone = new Skirmisher({ id: 'd1', x: 5, y: 2, maxAp: 3 }); + world.addEntity(adept); + world.addEntity(drone); + drone.bindToBus(bus); + const log = []; + const ctx = { + world, + player: adept, + queue: new TurnQueue([FACTION.PLAYER, FACTION.CORP]), + rng: { next: () => 0 }, // deterministic success + log: line => log.push(line), + advanceTurn: () => {}, + resetInputModes: () => {}, + onPlayerAction: () => {}, + }; + const apBefore = adept.ap; + + applyIntent({ type: 'special', dx: 1, dy: 0 }, ctx); + + assert.equal(drone.faction, FACTION.PLAYER); + assert.equal(adept.ap, apBefore - AP_COST.INFLUENCE); + assert.ok(log.some(line => line.includes('DOMINATES'))); +}); + +test('special intent on an Adept with no legal target logs a denial without spending AP', () => { + const { ctx, log, player } = buildCtx({ archetype: 'adept', placeDrone: false }); + const apBefore = player.ap; + + applyIntent({ type: 'special', dx: 1, dy: 0 }, ctx); + + assert.equal(player.ap, apBefore, 'no AP spent on an empty sector'); + assert.ok(log.some(line => line.includes('INFLUENCE DENIED'))); +}); + test('end-turn drains AP to 0, logs wait, and invokes advanceTurn once', () => { const { ctx, player, calls, log } = buildCtx(); applyIntent({ type: 'end-turn' }, ctx); From d87fb2c200090a2a3a360a82dcc3d0710094fe0f Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 14 Jul 2026 19:52:19 -0700 Subject: [PATCH 7/9] Chimera, and a visual effect pass on all perks --- docs/phase-3.5-plan.md | 11 +- src/game/Run.ts | 13 +- src/game/archetypes/Chimera.ts | 69 ++++++ src/game/archetypes/index.ts | 28 ++- src/game/constants.ts | 16 ++ src/game/events.ts | 23 ++ src/game/nanoRepair.ts | 60 +++++ src/game/persistence.ts | 4 + src/input/applyIntent.ts | 69 +++++- src/input/keymap.ts | 2 +- src/render/animations.ts | 45 +++- src/render/frame.ts | 22 +- src/render/palette.ts | 36 +++ src/shell/domTypes.ts | 10 + src/shell/sceneListeners.ts | 82 ++++++- src/shell/shellRuntime.ts | 6 + sw-core.js | 2 + sw-dev.js | 6 +- sw-release.js | 8 +- sw.js | 2 +- tests/unit/game/Chimera.test.ts | 114 +++++++++ tests/unit/game/Run.test.ts | 9 + tests/unit/game/archetypes.test.ts | 19 ++ tests/unit/game/persistence.test.ts | 11 + tests/unit/input/applyIntent.test.ts | 93 ++++++- tests/unit/render/animations.test.ts | 25 +- tests/unit/render/frame.test.ts | 42 ++++ tests/unit/shell/sceneListeners.test.ts | 313 ++++++++++++++++++++++++ 28 files changed, 1103 insertions(+), 37 deletions(-) create mode 100644 src/game/archetypes/Chimera.ts create mode 100644 src/game/nanoRepair.ts create mode 100644 tests/unit/game/Chimera.test.ts diff --git a/docs/phase-3.5-plan.md b/docs/phase-3.5-plan.md index b94eff1..2e00f0a 100644 --- a/docs/phase-3.5-plan.md +++ b/docs/phase-3.5-plan.md @@ -43,7 +43,7 @@ M6 ──> M7 (archetype unlocks via Score rewards β€” needs M6's anchor table t | P3.5.M2 β€” Decker perk swap: Override β†’ EMP AOE stun | βœ… Complete | | P3.5.M3 β€” Berserk archetype (surge/crash) | βœ… Complete | | P3.5.M4 β€” Adept archetype (Influence, renamed from Override) | βœ… Complete | -| P3.5.M5 β€” Chimera archetype (scrap-to-HP sustain) | πŸ”² Not started | +| P3.5.M5 β€” Chimera archetype (scrap-to-HP sustain) | βœ… Complete | | P3.5.M6 β€” Inverted crew generation (roll stats, derive archetype) | πŸ”² Not started | | P3.5.M7 β€” Archetype unlocks via Score rewards | πŸ”² Not started | @@ -318,6 +318,15 @@ Repeatable every turn as long as the shared scrap pool allows β€” same resource- **Tests:** `tests/unit/game/Chimera.test.ts` (new β€” mirrors `Tech.test.ts`'s improvised-turret cases: legality with/without sufficient scrap, AP + scrap debited, HP clamps at maxHp, repeatable across turns), extend `applyIntent.test.ts`/`Run.test.ts`/`persistence.test.ts` per the wiring-surface list. +**Implementation notes (as-built):** +- Shipped with the proposed tuning verbatim: `NANITE_HEAL_AMOUNT = 1`, `SALVAGE_PER_NANITE_HEAL = SALVAGE_PER_IMPROVISED_TURRET`, `AP_COST.NANITE_HEAL = 2`, base stats `(0.75, 0.25)`. +- **No "already at full HP" gate**, deliberately β€” mirrors the existing `ITEM_ID.STIM` precedent in `Crew.useConsumable` (`Math.min(STIM_HEAL, maxHp - hp)`), which also lets a player burn a heal at full health rather than crash or silently no-op. Wasting your own scrap is a player mistake, not a state the engine needs to police. `canConvertScrap`'s `NaniteHealCheck` reasons are `'dead' | 'insufficient-ap' | 'no-inventory' | 'insufficient-salvage'` β€” no `'already-full'` branch. +- **`perkAim: 'self'`** β€” Nanite Repair has no target or direction, same self-fire shape as Decker's EMP and Berserk's Surge; `doSpecial` gained a `canConvertScrap` branch dispatching to a new `doConvertScrap`, appended after Adept's `canInfluence` check and before the CyberAvatar's `canOverride` fallback. +- Full archetype fan-out landed per the M3 wiring-surface list: registry/factory/callsigns/perk metadata (`archetypes/index.ts`), recruit pool (interim weighted pool is now `2 Merc / 2 Razor / 1 Tech / 1 Berserk / 1 Adept / 1 Chimera`), `Run.ts` classification/telemetry/snapshot (`crewSnapshotExtra` reused as-is β€” Chimera carries no extra per-job state beyond the shared `inventory`/`gear` slice), `persistence.ts` factory/restore/classification, and capability-based input dispatch. +- Log copy stays deliberately ambiguous ("converts scrap into tissue") rather than confirming nanite swarm vs. android self-repair, per the archetype's unresolved fiction. +- Offline precache (`sw-core.js`) gained `Chimera.js` and `nanoRepair.js`; release metadata (`sw.js`/`sw-dev.js`/`sw-release.js`) bumped `0.3.4b β†’ 0.3.5`. **Noted gap, not fixed here:** `sw-core.js` never gained `Adept.js`/`mindInfluence.js` (or anything under `src/game/cyber/`) when M4 shipped β€” a pre-existing precache omission from before this milestone, out of scope for M5 but worth a follow-up pass. +- Verification: full `npm test` (2040 pass, 0 fail), `npm run lint`, `npm run format` all clean. + --- ## P3.5.M6 β€” Inverted crew generation: roll stats, derive archetype diff --git a/src/game/Run.ts b/src/game/Run.ts index 0ba6e03..b74ec77 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -59,6 +59,7 @@ import { Tech } from './archetypes/Tech.js'; import { Decker } from './archetypes/Decker.js'; import { Berserk } from './archetypes/Berserk.js'; import { Adept } from './archetypes/Adept.js'; +import { Chimera } from './archetypes/Chimera.js'; import { Turret } from './Turret.js'; import { Skirmisher } from './ai/Skirmisher.js'; import { Guard } from './ai/Guard.js'; @@ -158,7 +159,14 @@ const KNOWN_OUTCOMES = new Set(Object.values(OUTCOME)); export type RunState = (typeof RUN_STATE)[keyof typeof RUN_STATE]; export type Outcome = (typeof OUTCOME)[keyof typeof OUTCOME]; -export type CrewArchetypeId = 'merc' | 'razor' | 'tech' | 'decker' | 'berserk' | 'adept'; +export type CrewArchetypeId = + | 'merc' + | 'razor' + | 'tech' + | 'decker' + | 'berserk' + | 'adept' + | 'chimera'; export type EntityArchetypeId = | CrewArchetypeId | 'turret' @@ -2696,6 +2704,7 @@ const SNAPSHOT_EXTRACTORS: Partial Enti razor: e => crewSnapshotExtra(e as Crew) as unknown as EntitySnapshotExtra, berserk: e => crewSnapshotExtra(e as Crew) as unknown as EntitySnapshotExtra, adept: e => crewSnapshotExtra(e as Crew) as unknown as EntitySnapshotExtra, + chimera: e => crewSnapshotExtra(e as Crew) as unknown as EntitySnapshotExtra, decker: e => { const d = e as Decker; return { @@ -2897,6 +2906,7 @@ function archetypeOf(entity: Entity): EntityArchetypeId { if (entity instanceof Decker) return 'decker'; if (entity instanceof Berserk) return 'berserk'; if (entity instanceof Adept) return 'adept'; + if (entity instanceof Chimera) return 'chimera'; if (entity instanceof Turret) return 'turret'; if (entity instanceof Bruiser) return 'bruiser'; if (entity instanceof Juggernaut) return 'juggernaut'; @@ -3476,6 +3486,7 @@ function archetypeOfCrew(entity: Entity): CrewArchetypeId { if (entity instanceof Decker) return 'decker'; if (entity instanceof Berserk) return 'berserk'; if (entity instanceof Adept) return 'adept'; + if (entity instanceof Chimera) return 'chimera'; throw new Error( `archetypeOfCrew: cannot classify crew member ${(entity as Entity | undefined)?.id}` ); diff --git a/src/game/archetypes/Chimera.ts b/src/game/archetypes/Chimera.ts new file mode 100644 index 0000000..ec9ff79 --- /dev/null +++ b/src/game/archetypes/Chimera.ts @@ -0,0 +1,69 @@ +import { Crew } from '../Crew.js'; +import { canConvertScrap, convertScrapToHp } from '../nanoRepair.js'; +import type { CrewInit } from '../Crew.js'; +import type { NaniteHealCheck } from '../nanoRepair.js'; + +/** + * Curated callsign pool for the Chimera archetype. See `Merc.ts` CALLSIGNS + * for the design rationale. Deliberately unplaceable, human-or-machine names β€” + * matching the archetype's unresolved fiction β€” checked against every other + * pool for collisions. + */ +export const CALLSIGNS = Object.freeze([ + 'Vessel', + 'Husk', + 'Chrysalis', + 'Relic', + 'Doll', + 'Ghost', + 'Null', + 'Sigil', + 'Mesh', + 'Splice', + 'Cocoon', + 'Effigy', +]); + +/** + * Chimera β€” sustain operative (P3.5.M5). Perk: **Nanite Repair**, converting + * scrap salvage into HP on demand (`nanoRepair.ts`), mirroring Tech's + * improvised-turret resource-gate shape (repeatable every turn as long as the + * shared scrap pool allows, no per-job cap). + * + * Fiction is deliberately unresolved: nobody in-world knows for certain + * whether this is a human running a semi-sentient nanite swarm or an awakened + * AI in an android chassis. Flavor and log text should never resolve it + * either way. + */ +export class Chimera extends Crew { + override archetype = 'Chimera'; + + override get baseHitChance(): number { + return 0.75; + } + + override get baseDodgeChance(): number { + return 0.25; + } + + constructor(props: CrewInit) { + super({ ...props, glyph: '@' }); + } + + /** + * Pre-flight legality check for converting scrap into HP. Returns + * `{ ok, reason }`, mirroring the other archetype perks. Delegates to the + * shared `nanoRepair` module so the rules live in one place. + */ + canConvertScrap(): NaniteHealCheck { + return canConvertScrap(this); + } + + /** + * Convert `SALVAGE_PER_NANITE_HEAL` scrap into `NANITE_HEAL_AMOUNT` HP. + * Throws on illegal pre-conditions (no AP burned, no scrap spent). + */ + convertScrapToHp(): number { + return convertScrapToHp(this); + } +} diff --git a/src/game/archetypes/index.ts b/src/game/archetypes/index.ts index e39d41e..636cfc2 100644 --- a/src/game/archetypes/index.ts +++ b/src/game/archetypes/index.ts @@ -25,11 +25,12 @@ import { Tech, CALLSIGNS as TECH_CALLSIGNS } from './Tech.js'; import { Decker, CALLSIGNS as DECKER_CALLSIGNS } from './Decker.js'; import { Berserk, CALLSIGNS as BERSERK_CALLSIGNS } from './Berserk.js'; import { Adept, CALLSIGNS as ADEPT_CALLSIGNS } from './Adept.js'; +import { Chimera, CALLSIGNS as CHIMERA_CALLSIGNS } from './Chimera.js'; import type { Rng } from '../../rng.js'; import type { FactionId } from '../constants.js'; import type { CrewInit } from '../Crew.js'; -export type Archetype = Merc | Razor | Tech | Decker | Berserk | Adept; +export type Archetype = Merc | Razor | Tech | Decker | Berserk | Adept | Chimera; /** * Display order is also the starter crew order in `Campaign.buildCrew`. @@ -45,12 +46,12 @@ export const ARCHETYPE_IDS = Object.freeze(['merc', 'razor', 'tech']); /** * Weighted archetype pool for recruitment: 2 Merc / 2 Razor / 1 Tech / 1 - * Berserk / 1 Adept. Expressed as a flat array so `rng.pick()` gives the - * correct distribution. This is an interim hand-weighted pool β€” P3.5.M6 - * retires it entirely in favor of rolling core stats first and deriving the - * archetype from the result. The Decker is **not** in this pool β€” normal - * random recruitment must never roll one; it joins only through the Act-2 - * narrative beat (P3.M2 / P3.M1). + * Berserk / 1 Adept / 1 Chimera. Expressed as a flat array so `rng.pick()` + * gives the correct distribution. This is an interim hand-weighted pool β€” + * P3.5.M6 retires it entirely in favor of rolling core stats first and + * deriving the archetype from the result. The Decker is **not** in this + * pool β€” normal random recruitment must never roll one; it joins only + * through the Act-2 narrative beat (P3.M2 / P3.M1). */ export const RECRUIT_ARCHETYPE_POOL = Object.freeze([ 'merc', @@ -60,6 +61,7 @@ export const RECRUIT_ARCHETYPE_POOL = Object.freeze([ 'tech', 'berserk', 'adept', + 'chimera', ]); /** @@ -126,6 +128,16 @@ export const ARCHETYPES = Object.freeze({ // Aim-sector target picker, same shape the old drone Override always used. perkAim: 'directional', }), + chimera: Object.freeze({ + id: 'chimera', + name: 'CHIMERA', + blurb: 'Human, machine, or both β€” nobody is sure. Turns scrap into scar tissue.', + perks: Object.freeze(['nanite-repair']), + perkName: 'NANITE REPAIR', + perkLabel: 'Chimeras can NANITE REPAIR: convert scrap into HP', + // Self-targeted like EMP/Surge β€” no direction to pick. + perkAim: 'self', + }), }); export type ArchetypeInfo = (typeof ARCHETYPES)[keyof typeof ARCHETYPES]; @@ -155,6 +167,7 @@ const BUILDERS = Object.freeze({ decker: Decker, berserk: Berserk, adept: Adept, + chimera: Chimera, }); /** @@ -170,6 +183,7 @@ export const CALLSIGNS_BY_ARCHETYPE = Object.freeze({ decker: DECKER_CALLSIGNS, berserk: BERSERK_CALLSIGNS, adept: ADEPT_CALLSIGNS, + chimera: CHIMERA_CALLSIGNS, }); export function isArchetypeId(value: string) { diff --git a/src/game/constants.ts b/src/game/constants.ts index a841c61..8832b79 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -113,6 +113,7 @@ export const AP_COST = Object.freeze({ INFLUENCE: 2, // Adept (Meatspace) + CyberAvatar (cyber grid) β€” flip a hostile's/ICE's allegiance (renamed from OVERRIDE, P3.5.M4) EMP: 2, // Decker β€” self-centered AOE neural-shock stun (P3.5.M2) SURGE: 2, // Berserk β€” self-buff that always chains into CRASH (P3.5.M3) + NANITE_HEAL: 2, // Chimera β€” convert scrap salvage into HP (P3.5.M5) }); /** @@ -387,6 +388,21 @@ export const SALVAGE_DROP_MIN = 1; export const SALVAGE_DROP_MAX = 3; export const SALVAGE_PER_IMPROVISED_TURRET = 2; +/** + * Chimera nanite-repair parameters (P3.5.M5). The Chimera's signature sustain + * perk converts scrap salvage into HP β€” same resource-gate shape as Tech's + * improvised turret (repeatable every turn, no per-job cap), reusing that + * exact scrap price rather than inventing a new number. + * + * - `NANITE_HEAL_AMOUNT` β€” flat HP restored per activation, clamped at + * maxHp by `Entity.heal`. + * - `SALVAGE_PER_NANITE_HEAL` β€” scrap cost, matched to + * {@link SALVAGE_PER_IMPROVISED_TURRET} so both scrap-gated perks compete + * for the same pool at the same price. + */ +export const NANITE_HEAL_AMOUNT = 1; +export const SALVAGE_PER_NANITE_HEAL = SALVAGE_PER_IMPROVISED_TURRET; + /** * Finn's shop β€” item tuning constants. Job-scoped consumables are lost on * job end; campaign-scoped gear persists until campaign wipe; meta upgrades diff --git a/src/game/events.ts b/src/game/events.ts index 203f23f..ce121e4 100644 --- a/src/game/events.ts +++ b/src/game/events.ts @@ -51,6 +51,29 @@ export const EVENT = Object.freeze({ * boundary where the transition happens. Payload `{ origin: {x,y}, entityId }`. */ BERSERK_CRASHED: 'berserk:crashed', + /** + * P3.5.M5: a Chimera converted scrap into HP via Nanite Repair. + * Presentation-only hook (gameplay already resolved by the caller), mirrors + * `BERSERK_SURGED`. Payload `{ origin: {x,y}, healed }`. + */ + NANITE_HEALED: 'nanite:healed', + /** + * P3.5.M5: a mind-influence roll resolved β€” the Adept's Influence + * (Meatspace) or the CyberAvatar's Override (cyber grid), both backed by + * `mindInfluence.ts`. Presentation-only hook, fired whether the roll + * succeeded or not so the shell can pulse the target's own tile either way + * (log copy carries the success/fail nuance, same as every other perk + * hook). Payload `{ actor, target, success }`. + */ + MIND_INFLUENCED: 'mind:influenced', + /** + * P3.5.M5: a Razor slid and cloaked. Presentation-only hook (gameplay + * already resolved by the caller), mirrors `MIND_INFLUENCED`'s shape but + * self-targeted like `BERSERK_SURGED` β€” a single-cell pulse centered on + * the Razor's own landing tile rather than a screen-wide wash. Payload + * `{ actor }`. + */ + RAZOR_CLOAKED: 'razor:cloaked', }); const KNOWN_TYPES = new Set(Object.values(EVENT)); diff --git a/src/game/nanoRepair.ts b/src/game/nanoRepair.ts new file mode 100644 index 0000000..7f58532 --- /dev/null +++ b/src/game/nanoRepair.ts @@ -0,0 +1,60 @@ +/** + * Nanite Repair (P3.5.M5) β€” the Chimera's signature sustain perk: convert + * scrap salvage into HP. Mirrors `Tech.improviseTurret`'s resource-gate shape + * exactly β€” repeatable every turn as long as the shared scrap pool allows, no + * per-job cap, no cooldown. + * + * Fiction is deliberately unresolved (see `archetypes/Chimera.ts`): whether + * this is a nanite swarm stitching flesh back together or an android chassis + * re-fabricating its own plating is never confirmed, in dialogue or in code + * comments. Log/flavor copy at every call site should preserve that + * ambiguity rather than pick a side. + * + * Pure verbs, mirroring `surge.ts`/`empBlast.ts`: the archetype class stays a + * thin delegator, the intent layer a thin caller. + */ + +import { AP_COST, NANITE_HEAL_AMOUNT, SALVAGE_PER_NANITE_HEAL } from './constants.js'; +import type { Crew } from './Crew.js'; + +/** Pre-flight legality verdict, mirroring the other archetype perks. */ +export type NaniteHealCheck = + | { ok: true } + | { + ok: false; + reason: 'dead' | 'insufficient-ap' | 'no-inventory' | 'insufficient-salvage'; + }; + +/** + * Pure legality check for converting scrap into HP. Never mutates. No + * "already at full HP" gate β€” matches the existing STIM item precedent + * (`Crew.useConsumable`'s `ITEM_ID.STIM` case), which also lets a player burn + * a heal at full health rather than crash or silently no-op; wasting your own + * resource is a player mistake, not a state we need to police. + */ +export function canConvertScrap(chimera: Crew): NaniteHealCheck { + if (!chimera.alive) return { ok: false, reason: 'dead' }; + if (!chimera.canAfford(AP_COST.NANITE_HEAL)) return { ok: false, reason: 'insufficient-ap' }; + if (!chimera.inventory) return { ok: false, reason: 'no-inventory' }; + if (chimera.inventory.salvage.scrap < SALVAGE_PER_NANITE_HEAL) { + return { ok: false, reason: 'insufficient-salvage' }; + } + return { ok: true }; +} + +/** + * Commit a scrap-to-HP conversion. Throws on any illegal pre-condition (no AP + * burned, no scrap spent). On a legal attempt: debits `AP_COST.NANITE_HEAL` + * AP and `SALVAGE_PER_NANITE_HEAL` scrap, heals the Chimera by + * `NANITE_HEAL_AMOUNT` (clamped at maxHp by `Entity.heal`), and returns the + * HP actually restored. + */ +export function convertScrapToHp(chimera: Crew): number { + const check = canConvertScrap(chimera); + if (!check.ok) { + throw new Error(`Illegal nanite conversion for ${chimera.id}: ${check.reason}`); + } + chimera.spendAp(AP_COST.NANITE_HEAL); + chimera.inventory!.salvage.scrap -= SALVAGE_PER_NANITE_HEAL; + return chimera.heal(NANITE_HEAL_AMOUNT); +} diff --git a/src/game/persistence.ts b/src/game/persistence.ts index d437f74..be1b1b9 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -52,6 +52,7 @@ import { Tech } from './archetypes/Tech.js'; import { Decker } from './archetypes/Decker.js'; import { Berserk } from './archetypes/Berserk.js'; import { Adept } from './archetypes/Adept.js'; +import { Chimera } from './archetypes/Chimera.js'; import { Turret } from './Turret.js'; import { Skirmisher, type SkirmisherProps } from './ai/Skirmisher.js'; import { Guard, type GuardProps } from './ai/Guard.js'; @@ -219,6 +220,7 @@ const ARCHETYPE_FACTORY: Record new Decker(props as DeckerInit), berserk: (props: RestoreEntityProps) => new Berserk(props as CrewInit), adept: (props: RestoreEntityProps) => new Adept(props as CrewInit), + chimera: (props: RestoreEntityProps) => new Chimera(props as CrewInit), turret: (props: RestoreEntityProps) => new Turret(props as TurretInit), drone: (props: RestoreEntityProps) => new Skirmisher(props as SkirmisherProps), guard: (props: RestoreEntityProps) => new Guard(props as GuardProps), @@ -1758,6 +1760,7 @@ const KNOWN_ARCHETYPES_SET = new Set([ 'decker', 'berserk', 'adept', + 'chimera', ]); /** Clamp gear bonuses to archetype caps after restore. */ @@ -2372,6 +2375,7 @@ function archetypeOfCrew(member: Crew): CrewArchetypeId { if (member instanceof Decker) return 'decker'; if (member instanceof Berserk) return 'berserk'; if (member instanceof Adept) return 'adept'; + if (member instanceof Chimera) return 'chimera'; throw new Error(`snapshotCampaign: cannot classify crew member ${member?.id}`); } diff --git a/src/input/applyIntent.ts b/src/input/applyIntent.ts index 0c1e271..6971b40 100644 --- a/src/input/applyIntent.ts +++ b/src/input/applyIntent.ts @@ -18,17 +18,18 @@ * automation can commit a melee strike without synthesizing a walk intent. * * The archetype-specific perks (Merc's Vault, Razor's Slide, Tech's Deploy - * Turret, Decker's EMP, Berserk's Surge, Adept's Influence, the CyberAvatar's - * cyber-grid Override) collapse into a single `special` intent at the keymap - * layer. The `doSpecial` dispatcher below routes it to the right verb based on - * which methods the active player class exposes β€” `canVault` β†’ vault, - * `canSlide` β†’ slide, `canDeploy` β†’ deploy, `canEmp` β†’ EMP, `canSurge` β†’ - * surge, `canInfluence` β†’ influence (the Adept resolves the nearest visible - * hostile in the aimed eight-way sector), `canOverride` β†’ override (same - * picker, cyber grid only). This keeps the input surface symmetric across - * archetypes (one key, one touch button) and stays out of the player's way: - * the keymap doesn't need to know which class is in play, and the intent - * dispatcher doesn't need an explicit archetype switch. + * Turret, Decker's EMP, Berserk's Surge, Adept's Influence, Chimera's Nanite + * Repair, the CyberAvatar's cyber-grid Override) collapse into a single + * `special` intent at the keymap layer. The `doSpecial` dispatcher below + * routes it to the right verb based on which methods the active player class + * exposes β€” `canVault` β†’ vault, `canSlide` β†’ slide, `canDeploy` β†’ deploy, + * `canEmp` β†’ EMP, `canSurge` β†’ surge, `canInfluence` β†’ influence (the Adept + * resolves the nearest visible hostile in the aimed eight-way sector), + * `canConvertScrap` β†’ Nanite Repair, `canOverride` β†’ override (same picker, + * cyber grid only). This keeps the input surface symmetric across archetypes + * (one key, one touch button) and stays out of the player's way: the keymap + * doesn't need to know which class is in play, and the intent dispatcher + * doesn't need an explicit archetype switch. * * The function is pure-ish: it mutates `ctx.world` / `ctx.player` / * `ctx.queue` and emits log lines via `ctx.log`, but doesn't touch the DOM. @@ -74,6 +75,7 @@ import type { Razor } from '../game/archetypes/Razor.js'; import type { Decker } from '../game/archetypes/Decker.js'; import type { Berserk } from '../game/archetypes/Berserk.js'; import type { Adept } from '../game/archetypes/Adept.js'; +import type { Chimera } from '../game/archetypes/Chimera.js'; export type Intent = { type: string; @@ -392,6 +394,7 @@ function collectTileLoot(ctx: ApplyIntentContext) { * - `canEmp` β†’ Decker's EMP neural-shock (self-centered AOE stun) * - `canSurge` β†’ Berserk's Surge self-buff * - `canInfluence` β†’ Adept's Influence (Meatspace mind control) + * - `canConvertScrap` β†’ Chimera's Nanite Repair (scrap-to-HP sustain) * - `canOverride` β†’ CyberAvatar's ICE override (cyber grid only) * * Capability sniffing (vs. a class `instanceof` check) keeps this module free @@ -428,6 +431,9 @@ function doSpecial(intent: Intent, ctx: ApplyIntentContext) { if (typeof (player as Adept).canInfluence === 'function') { return doInfluence(intent, ctx); } + if (typeof (player as Chimera).canConvertScrap === 'function') { + return doConvertScrap(ctx); + } // Only the CyberAvatar still exposes canOverride (P3.5.M2 moved the Decker's // Meatspace perk to EMP; P3.5.M4 gave the Adept the renamed Influence perk // β€” the CyberAvatar keeps its own "Override" name/fiction for the cyber @@ -456,6 +462,35 @@ function doSurge(ctx: ApplyIntentContext) { gateOnApExhausted(ctx); } +/** + * Trigger the Chimera's self-targeted Nanite Repair: convert scrap salvage + * into HP. Log copy stays deliberately ambiguous about the mechanism (nanite + * swarm vs. android self-repair) per the archetype's unresolved fiction. + */ +function doConvertScrap(ctx: ApplyIntentContext) { + const { world, player, log } = ctx; + const chimera = player as Chimera; + const playerLabel = entityLabel(player); + const check = chimera.canConvertScrap(); + if (!check.ok) { + log(`> ${playerLabel} NANITE REPAIR DENIED: ${check.reason}`); + return; + } + const healed = chimera.convertScrapToHp(); + log( + `> ${playerLabel} converts scrap into tissue β€” +${healed} HP ` + + `(${chimera.inventory!.salvage.scrap} scrap left, ${player.ap} AP left).` + ); + // Presentation hook (P3.5.M5): the shell listens for this to pulse the + // nanite-heal flash. Gameplay is already committed above β€” this carries + // no state. Mirrors BERSERK_SURGED's shape exactly. + world.events?.emit(EVENT.NANITE_HEALED, { + origin: { x: player.x, y: player.y }, + healed, + }); + gateOnApExhausted(ctx); +} + /** * Detonate the Decker's self-centered EMP: no aim, stuns everyone alive in * radius (friend, foe, and the Decker). Reports the stun count for legibility. @@ -511,6 +546,10 @@ function doOverride(intent: Intent, ctx: ApplyIntentContext) { `${result.alarm ? ' β€” ALARM TRIPPED' : ''} (${player.ap} AP left).` ); } + // Presentation hook (P3.5.M5): the shell listens for this to pulse the + // target's tile whether the roll landed or not. Gameplay is already + // committed above β€” this carries no state. + world.events?.emit(EVENT.MIND_INFLUENCED, { actor: player, target, success: result.success }); gateOnApExhausted(ctx); } @@ -547,6 +586,10 @@ function doInfluence(intent: Intent, ctx: ApplyIntentContext) { `${result.alarm ? ' β€” ALARM TRIPPED' : ''} (${player.ap} AP left).` ); } + // Presentation hook (P3.5.M5): the shell listens for this to pulse the + // target's tile whether the roll landed or not. Gameplay is already + // committed above β€” this carries no state. + world.events?.emit(EVENT.MIND_INFLUENCED, { actor: player, target, success: result.success }); gateOnApExhausted(ctx); } @@ -706,6 +749,10 @@ function doSlide(intent: Intent, ctx: ApplyIntentContext) { `> ${playerLabel} slid to (${player.x}, ${player.y}) β€” CLOAKED until next turn (` + `${player.ap} AP left).` ); + // Presentation hook (P3.5.M5): the shell listens for this to pulse the + // Razor's own landing tile. Gameplay is already committed above β€” this + // carries no state. + world.events?.emit(EVENT.RAZOR_CLOAKED, { actor: player }); collectTileLoot(ctx); gateOnApExhausted(ctx); } diff --git a/src/input/keymap.ts b/src/input/keymap.ts index 33cf469..7d584c6 100644 --- a/src/input/keymap.ts +++ b/src/input/keymap.ts @@ -61,7 +61,7 @@ export type AimKind = (typeof AIM_KIND)[keyof typeof AIM_KIND]; * - `'directional'` β€” needs a direction; the perk key enters `MODE.AIM` * (Merc Vault, Razor Slide, Tech Deploy, CyberAvatar Override). * - `'self'` β€” self-centered, no direction; the perk key fires immediately - * (Decker EMP, and future Berserk / Chimera self-buffs). + * (Decker EMP, Berserk Surge, Chimera Nanite Repair). * Supplied per-press by the input owner (which knows the live archetype); the * keymap itself stays archetype-agnostic and just honours the flag. */ diff --git a/src/render/animations.ts b/src/render/animations.ts index 682c891..70f8852 100644 --- a/src/render/animations.ts +++ b/src/render/animations.ts @@ -31,7 +31,7 @@ import type { AsciiRenderer } from './AsciiRenderer.js'; import { COMBAT_HUD_COLORS } from './combatHud.js'; -import { CRASH_FLASH_FG, STUNNED_FG, SURGE_FLASH_FG } from './palette.js'; +import { CRASH_FLASH_FG, HEAL_FLASH_FG, STUNNED_FG, SURGE_FLASH_FG } from './palette.js'; export const ANIMATION_DURATIONS = Object.freeze({ SHAKE: 150, @@ -43,6 +43,11 @@ export const ANIMATION_DURATIONS = Object.freeze({ SURGE_FLASH: 220, /** Ashen comedown pulse when a Berserk's Surge expires into Crash (P3.5.M3). */ CRASH_FLASH: 260, + /** + * Green pulse for any HP-restoring action β€” a Chimera converting scrap + * into HP (P3.5.M5) or a crew member using a STIM (P3.5.M5). + */ + HEAL_FLASH: 220, // Original plan suggested "~80ms" but at 60fps that's ~5 frames β€” perceptually // borderline, especially with the shooter's own glyph sitting underneath. // 120ms (~7 frames) is still snappy and reads clearly as a burst. @@ -51,6 +56,26 @@ export const ANIMATION_DURATIONS = Object.freeze({ INTERACT_SECURED_FLASH: 150, /** Hazard-glyph breaching-charge blast overlay (presentation only). */ BREACH_BLAST_OVERLAY: 100, + /** + * Gold single-cell burst on the occupant's tile when a Merc's Vault lands a + * body-check (P3.5.M5). Same pacing as the muzzle flash β€” a snappy impact + * beat, not a lingering one. + */ + VAULT_IMPACT_FLASH: 120, + /** + * Violet single-cell burst on the target's tile when a mind-influence roll + * resolves β€” Adept Influence or CyberAvatar Override (P3.5.M5). Held + * slightly longer than a muzzle flash: a domination attempt is a bigger + * beat than a gunshot, win or lose. + */ + MIND_INFLUENCE_FLASH: 200, + /** + * Pale mint single-cell burst on a Razor's own landing tile as Slide + * engages the cloak (P3.5.M5). Between the muzzle flash and the + * mind-influence beat β€” long enough to read as "something changed about + * you," short enough to stay a subtle self-cue, not a screen-wide event. + */ + CLOAK_FLASH: 160, }); export const SHAKE_CLASS = 'kp-shake'; @@ -152,6 +177,24 @@ export function triggerCrashFlash(stageEl: HTMLElement, timers = defaultTimers) ); } +/** + * Green discharge pulse for any HP-restoring action β€” the Chimera's Nanite + * Repair (P3.5.M5) and the STIM consumable both drive this. Reuses the same + * parametrized colored-vignette primitive as the EMP/Surge/Crash flashes, + * tinted for "HP restored" generically β€” a beat of feedback beyond the + * HP-tick itself, same shape as `triggerSurgeFlash`. + */ +export function triggerHealFlash(stageEl: HTMLElement, timers = defaultTimers) { + stageEl.classList.remove(DAMAGE_CLASS); + stageEl.style.setProperty(IMPACT_FLASH_COLOR_PROPERTY, `${HEAL_FLASH_FG}8c`); + return restartCssAnimation( + stageEl, + MITIGATION_FLASH_CLASS, + ANIMATION_DURATIONS.HEAL_FLASH, + timers + ); +} + export type MitigationFlashKind = 'armor' | 'shield'; /** diff --git a/src/render/frame.ts b/src/render/frame.ts index 4bc87e5..a8e1b81 100644 --- a/src/render/frame.ts +++ b/src/render/frame.ts @@ -11,6 +11,9 @@ import { MEMORY_DIM, INTERACTABLE_SECURED_FG, STUNNED_FG, + SURGE_FLASH_FG, + CRASH_FLASH_FG, + CLOAK_FLASH_FG, } from './palette.js'; import { Interactable } from '../game/entities/Interactable.js'; import type { World } from '../game/World.js'; @@ -236,10 +239,27 @@ function glyphForEntityCell(entity: Entity): Glyph { } // A stunned (EMP'd) entity glows electric cyan, overriding its faction hue β€” // it reads as "short-circuited, skipping its turn". Wins over faction colour - // but not over the corpse/secured states resolved above. + // but not over the corpse/secured states resolved above, and over the + // altered-state tints below: losing your turn is the most urgent thing to + // read off a glyph, so it wins any co-occurrence. if (entity.hasEffect(STATUS_EFFECT.STUN)) { return { char: entity.glyph, fg: STUNNED_FG }; } + // P3.5.M5: persistent tints for the two archetype-only altered states that + // otherwise only announced themselves via a HUD tag / transient flash β€” + // same colours as each ability's screen/cell flash so the sustained state + // and its trigger beat read as one language. SURGE/CRASH are mutually + // exclusive (Surge expires *into* Crash, see `TurnQueue.endTurn`), so + // their order below is arbitrary. + if (entity.hasEffect(STATUS_EFFECT.SURGE)) { + return { char: entity.glyph, fg: SURGE_FLASH_FG }; + } + if (entity.hasEffect(STATUS_EFFECT.CRASH)) { + return { char: entity.glyph, fg: CRASH_FLASH_FG }; + } + if (entity.stealthed) { + return { char: entity.glyph, fg: CLOAK_FLASH_FG }; + } return glyphForEntity(entity); } diff --git a/src/render/palette.ts b/src/render/palette.ts index e95d939..6033bfc 100644 --- a/src/render/palette.ts +++ b/src/render/palette.ts @@ -57,6 +57,42 @@ export const STUNNED_FG = '#8be9ff'; export const SURGE_FLASH_FG = '#ff6a1a'; export const CRASH_FLASH_FG = '#6c6a8a'; +/** + * Green screen-pulse tint for any HP-restoring action β€” the Chimera's Nanite + * Repair (P3.5.M5) and the STIM consumable both drive this. Distinct from the + * mint player faction colour (`#00d9a5`) so a heal pulse doesn't read as + * "just the player's own colour" β€” brighter and greener. Deliberately + * mechanism-neutral (not "nanite-tinted") since STIM's fiction is a chemical + * injector, not nanites β€” the colour reads as "HP restored," not "how." + */ +export const HEAL_FLASH_FG = '#5cff8f'; + +/** + * Warm gold-amber single-cell burst for a Merc's Vault body-check landing on + * an occupant (P3.5.M5). Distinct from the muzzle flash's yellow (`#ffff66`) + * and from `ARMOR`/rival amber so a kinetic slam reads differently from + * gunfire landing on the same tile. + */ +export const VAULT_IMPACT_FG = '#ffd166'; + +/** + * Violet single-cell burst for a resolved mind-influence roll β€” the Adept's + * Influence and the CyberAvatar's Override (P3.5.M5), both backed by + * `mindInfluence.ts`. Fires on the target's own tile regardless of success + * or failure; distinct from the pale lavender used for shields/neutral + * faction so "psychic domination" doesn't read as "defense mitigated." + */ +export const MIND_INFLUENCE_FG = '#a64dff'; + +/** + * Pale icy mint single-cell burst on a Razor's own tile as Slide engages the + * cloak (P3.5.M5). Deliberately a washed-out relative of the mint player + * faction colour (`#00d9a5`) rather than a foreign hue β€” this is the + * player's own signature going translucent, not an outside force acting on + * them (contrast `VAULT_IMPACT_FG`/`MIND_INFLUENCE_FG`, which are external). + */ +export const CLOAK_FLASH_FG = '#babfb6'; + /** * 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 e17e771..05aab71 100644 --- a/src/shell/domTypes.ts +++ b/src/shell/domTypes.ts @@ -164,6 +164,16 @@ export type DoorUnlockPayload = { label?: string; }; +export type MindInfluencedPayload = { + actor?: import('../game/Entity.js').Entity; + target?: import('../game/Entity.js').Entity; + success?: boolean; +}; + +export type RazorCloakedPayload = { + actor?: import('../game/Entity.js').Entity; +}; + export type ShellDomRefs = { stageEl: HTMLElement; pipCanvas: HTMLCanvasElement; diff --git a/src/shell/sceneListeners.ts b/src/shell/sceneListeners.ts index 00f6a8a..0d7947c 100644 --- a/src/shell/sceneListeners.ts +++ b/src/shell/sceneListeners.ts @@ -7,17 +7,21 @@ import { triggerCrashFlash, triggerDamageFlash, triggerEmpFlash, + triggerHealFlash, triggerMitigationFlash, triggerShake, triggerSurgeFlash, } from '../render/animations.js'; import { COMBAT_HUD_COLORS } from '../render/combatHud.js'; +import { CLOAK_FLASH_FG, MIND_INFLUENCE_FG, VAULT_IMPACT_FG } from '../render/palette.js'; import { cyberLayerOf, isCyberView } from './activeView.js'; import { isRun } from './sceneView.js'; import type { DoorUnlockPayload, EntityDamagedPayload, + MindInfluencedPayload, NoisePayload, + RazorCloakedPayload, SceneListenerDeps, } from './domTypes.js'; @@ -127,11 +131,27 @@ export class SceneListenerController { effects.paintPip(); } } - if (source === 'melee' && target && damage > 0) { + if ((source === 'melee' || source === 'vault') && target && damage > 0) { const flashRenderer = meatInPip ? renderers.pip : renderers.main; const repaint = meatInPip ? effects.paintPip : effects.paint; - const fired = runMuzzleFlash(flashRenderer, repaint, target.x, target.y); - if (fired) animLock.push(ANIMATION_DURATIONS.MUZZLE_FLASH); + // Vault's body-check gets its own gold "kinetic slam" burst instead + // of the yellow gunfire-shaped muzzle flash β€” same tile, different + // beat (P3.5.M5). + const fired = + source === 'vault' + ? runMuzzleFlash(flashRenderer, repaint, target.x, target.y, { + duration: ANIMATION_DURATIONS.VAULT_IMPACT_FLASH, + char: '!', + color: VAULT_IMPACT_FG, + }) + : runMuzzleFlash(flashRenderer, repaint, target.x, target.y); + if (fired) { + animLock.push( + source === 'vault' + ? ANIMATION_DURATIONS.VAULT_IMPACT_FLASH + : ANIMATION_DURATIONS.MUZZLE_FLASH + ); + } } if (killed && target && !forcedBodyJackOut) { this.#deps.memoriseMeatCorpse(target, (x, y) => meatVision.isVisible(x, y)); @@ -168,6 +188,46 @@ export class SceneListenerController { // Ashen comedown pulse the instant Surge expires into Crash. triggerCrashFlash(dom.stageEl); animLock.push(ANIMATION_DURATIONS.CRASH_FLASH); + }), + run.bus.on(EVENT.NANITE_HEALED, () => { + // Green heal pulse as a Chimera converts scrap into HP. Meatspace-only + // perk, so the shared stage flash suffices (same shape as Surge/Crash). + // Shared with the STIM consumable's direct trigger in shellRuntime. + triggerHealFlash(dom.stageEl); + animLock.push(ANIMATION_DURATIONS.HEAL_FLASH); + }), + run.bus.on(EVENT.MIND_INFLUENCED, payload => { + // Violet burst on the dominated (or resisting) hostile's own tile β€” + // the Adept's Influence. Fires on both outcomes; log copy carries the + // success/fail nuance (P3.5.M5). CyberAvatar's Override wires the + // same event on the cyber bus below. + const { target } = (payload ?? {}) as MindInfluencedPayload; + if (!target) return; + const meatInPip = isCyberView(run); + const flashRenderer = meatInPip ? renderers.pip : renderers.main; + const repaint = meatInPip ? effects.paintPip : effects.paint; + const fired = runMuzzleFlash(flashRenderer, repaint, target.x, target.y, { + duration: ANIMATION_DURATIONS.MIND_INFLUENCE_FLASH, + char: target.glyph, + color: MIND_INFLUENCE_FG, + }); + if (fired) animLock.push(ANIMATION_DURATIONS.MIND_INFLUENCE_FLASH); + }), + run.bus.on(EVENT.RAZOR_CLOAKED, payload => { + // Pale mint burst on the Razor's own landing tile as Slide engages + // the cloak β€” subtler and self-centered, not a screen-wide wash + // (P3.5.M5). Meatspace-only perk. + const { actor } = (payload ?? {}) as RazorCloakedPayload; + if (!actor) return; + const meatInPip = isCyberView(run); + const flashRenderer = meatInPip ? renderers.pip : renderers.main; + const repaint = meatInPip ? effects.paintPip : effects.paint; + const fired = runMuzzleFlash(flashRenderer, repaint, actor.x, actor.y, { + duration: ANIMATION_DURATIONS.CLOAK_FLASH, + char: actor.glyph, + color: CLOAK_FLASH_FG, + }); + if (fired) animLock.push(ANIMATION_DURATIONS.CLOAK_FLASH); }) ); } @@ -225,6 +285,22 @@ export class SceneListenerController { const repaint = cyberInPip ? effects.paintPip : effects.paint; const fired = runMuzzleFlash(flashRenderer, repaint, origin.x, origin.y); if (fired) animLock.push(ANIMATION_DURATIONS.MUZZLE_FLASH); + }), + layer.bus.on(EVENT.MIND_INFLUENCED, payload => { + // Same violet burst as the Adept's Influence (above), on the cyber + // grid for the CyberAvatar's Override β€” same underlying roll, same + // fires-on-both-outcomes shape (P3.5.M5). + const { target } = (payload ?? {}) as MindInfluencedPayload; + if (!target) return; + const cyberInPip = !isCyberView(run); + const flashRenderer = cyberInPip ? renderers.pip : renderers.main; + const repaint = cyberInPip ? effects.paintPip : effects.paint; + const fired = runMuzzleFlash(flashRenderer, repaint, target.x, target.y, { + duration: ANIMATION_DURATIONS.MIND_INFLUENCE_FLASH, + char: target.glyph, + color: MIND_INFLUENCE_FG, + }); + if (fired) animLock.push(ANIMATION_DURATIONS.MIND_INFLUENCE_FLASH); }) ); } diff --git a/src/shell/shellRuntime.ts b/src/shell/shellRuntime.ts index a0c7a60..7208bdd 100644 --- a/src/shell/shellRuntime.ts +++ b/src/shell/shellRuntime.ts @@ -44,6 +44,7 @@ import { ANIMATION_DURATIONS, createAnimationLock, runInteractSecuredFlash, + triggerHealFlash, triggerShake, } from '/src/render/animations.js'; import { Interactable } from '/src/game/entities/Interactable.js'; @@ -1098,6 +1099,11 @@ function applyUseConsumableResult( flash( `Used STIM β€” healed ${healed} HP (now ${operator.hp}/${operator.maxHp}). ${operator.ap} AP left.` ); + // Same green heal pulse as the Chimera's Nanite Repair β€” shared "HP + // restored" beat, direct trigger since useConsumable doesn't route + // through the bus (see pulseSecuredInteractable for the same shape). + triggerHealFlash(stageEl); + animLock.push(ANIMATION_DURATIONS.HEAL_FLASH); return; } if (result.type === 'smoke') { diff --git a/sw-core.js b/sw-core.js index 338f9ff..12fcab9 100644 --- a/sw-core.js +++ b/sw-core.js @@ -62,8 +62,10 @@ const CacheConfig = { '/src/game/archetypes/Tech.js', '/src/game/archetypes/Decker.js', '/src/game/archetypes/Berserk.js', + '/src/game/archetypes/Chimera.js', '/src/game/empBlast.js', '/src/game/surge.js', + '/src/game/nanoRepair.js', '/src/game/Campaign.js', '/src/game/campaignSummary.js', '/src/game/chronicle.js', diff --git a/sw-dev.js b/sw-dev.js index 711e8ff..5a3979f 100644 --- a/sw-dev.js +++ b/sw-dev.js @@ -1,7 +1,7 @@ // Service Worker for Kernel Panic - Development Version -const VERSION = '0.3.4b-dev'; -importScripts('/sw-release.js?v=0.3.4b'); -importScripts(`/sw-core.js?v=${VERSION}`); +const VERSION = '0.3.5b'; +importScripts(`/sw-release.js?v=${VERSION}`); +importScripts(`/sw-core.js?v=${VERSION}-dev`); if (!self.KernelPanicRelease) { throw new Error('[KernelPanic] Development release metadata is missing'); diff --git a/sw-release.js b/sw-release.js index 356a724..2933048 100644 --- a/sw-release.js +++ b/sw-release.js @@ -2,11 +2,11 @@ // Increment shellEpoch only when the new app shell cannot safely coexist with // the previous worker (for example, removed or renamed runtime modules). self.KernelPanicRelease = Object.freeze({ - version: '0.3.4b', + version: '0.3.5', shellEpoch: 2, - title: 'Berserk archetype online', + title: 'Chimera archetype online', highlights: Object.freeze([ - 'Recruit Berserk operators and trigger their Surge perk.', - 'Surge boosts damage and AP before an unavoidable Crash.', + 'Recruit Chimera operators and trigger their Nanite Repair perk.', + 'Nanite Repair converts scrap salvage into HP, repeatable while it lasts.', ]), }); diff --git a/sw.js b/sw.js index a86f1c2..f705612 100644 --- a/sw.js +++ b/sw.js @@ -1,6 +1,6 @@ // Service Worker for Kernel Panic - Production Version // Import shared caching core with cache-busting query parameter -const VERSION = '0.3.4b'; +const VERSION = '0.3.5b'; importScripts(`/sw-release.js?v=${VERSION}`); importScripts(`/sw-core.js?v=${VERSION}`); diff --git a/tests/unit/game/Chimera.test.ts b/tests/unit/game/Chimera.test.ts new file mode 100644 index 0000000..9fbf49b --- /dev/null +++ b/tests/unit/game/Chimera.test.ts @@ -0,0 +1,114 @@ +/** + * Chimera archetype tests β€” Nanite Repair legality matrix and commit + * semantics. + * + * The contract mirrors Tech's improvised-turret tests: `canConvertScrap` is a + * pure legality check returning `{ ok, reason }`; `convertScrapToHp` commits + * the spend (AP + scrap) on success and throws β€” without mutating state β€” on + * any illegal precondition. No world/grid involvement: Nanite Repair is + * self-targeted, so there's no tile to validate. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Chimera } from '../../../src/game/archetypes/Chimera.js'; +import { Crew } from '../../../src/game/Crew.js'; +import { + AP_COST, + NANITE_HEAL_AMOUNT, + SALVAGE_PER_NANITE_HEAL, +} from '../../../src/game/constants.js'; +import { makeSalvage, totalSalvage } from '../../../src/game/salvage.js'; + +function makeChimera({ salvage = SALVAGE_PER_NANITE_HEAL } = {}) { + const chimera = new Chimera({ id: 'chimera', x: 0, y: 0 }); + chimera.initInventory(); + chimera.inventory.salvage = makeSalvage({ scrap: salvage }); + return chimera; +} + +test('Chimera inherits from Crew and uses its own baseline stats', () => { + const chimera = makeChimera(); + assert.ok(chimera instanceof Crew, 'Chimera must extend Crew'); + assert.equal(chimera.baseHitChance, 0.75); + assert.equal(chimera.baseDodgeChance, 0.25); +}); + +test('Chimera.canConvertScrap rejects when dead', () => { + const chimera = makeChimera(); + chimera.damage(chimera.maxHp); + assert.equal(chimera.canConvertScrap().reason, 'dead'); +}); + +test('Chimera.canConvertScrap rejects when AP < NANITE_HEAL cost', () => { + const chimera = makeChimera(); + chimera.spendAp(chimera.ap - (AP_COST.NANITE_HEAL - 1)); + assert.equal(chimera.canConvertScrap().reason, 'insufficient-ap'); +}); + +test('Chimera.canConvertScrap rejects when inventory is not initialised', () => { + const chimera = new Chimera({ id: 'chimera', x: 0, y: 0 }); // no initInventory() + assert.equal(chimera.canConvertScrap().reason, 'no-inventory'); +}); + +test('Chimera.canConvertScrap rejects when scrap < cost', () => { + const chimera = makeChimera({ salvage: SALVAGE_PER_NANITE_HEAL - 1 }); + assert.equal(chimera.canConvertScrap().reason, 'insufficient-salvage'); +}); + +test('Chimera.canConvertScrap accepts with sufficient AP and scrap', () => { + const chimera = makeChimera(); + assert.equal(chimera.canConvertScrap().ok, true); +}); + +test('Chimera.convertScrapToHp debits AP + scrap and heals, clamped at maxHp', () => { + const chimera = makeChimera({ salvage: 10 }); + chimera.damage(2); + const apBefore = chimera.ap; + const scrapBefore = chimera.inventory.salvage.scrap; + const hpBefore = chimera.hp; + const healed = chimera.convertScrapToHp(); + assert.equal(chimera.ap, apBefore - AP_COST.NANITE_HEAL, 'AP debited once'); + assert.equal( + chimera.inventory.salvage.scrap, + scrapBefore - SALVAGE_PER_NANITE_HEAL, + 'scrap deducted by nanite-heal cost' + ); + assert.equal(healed, Math.min(NANITE_HEAL_AMOUNT, chimera.maxHp - hpBefore)); + assert.equal(chimera.hp, Math.min(chimera.maxHp, hpBefore + NANITE_HEAL_AMOUNT)); +}); + +test('Chimera.convertScrapToHp clamps at maxHp and still spends the resources', () => { + const chimera = makeChimera({ salvage: 10 }); + assert.equal(chimera.hp, chimera.maxHp, 'starts at full HP'); + const apBefore = chimera.ap; + const scrapBefore = chimera.inventory.salvage.scrap; + const healed = chimera.convertScrapToHp(); + assert.equal(healed, 0, 'no HP actually restored at full health'); + assert.equal(chimera.hp, chimera.maxHp); + assert.equal(chimera.ap, apBefore - AP_COST.NANITE_HEAL, 'AP still spent'); + assert.equal( + chimera.inventory.salvage.scrap, + scrapBefore - SALVAGE_PER_NANITE_HEAL, + 'scrap still spent' + ); +}); + +test('Chimera.convertScrapToHp throws on illegal preconditions without mutating state', () => { + const chimera = makeChimera({ salvage: 0 }); + const apBefore = chimera.ap; + assert.throws(() => chimera.convertScrapToHp(), /Illegal nanite conversion/); + assert.equal(chimera.ap, apBefore, 'AP not debited on illegal conversion'); + assert.equal(totalSalvage(chimera.inventory.salvage), 0, 'salvage wallet untouched'); +}); + +test('Chimera.convertScrapToHp is repeatable across turns as long as scrap lasts', () => { + const chimera = makeChimera({ salvage: SALVAGE_PER_NANITE_HEAL * 2 }); + chimera.damage(chimera.maxHp - 1); // leave 1 HP so repeated heals have room to matter + chimera.convertScrapToHp(); + chimera.refreshAp(); + const secondHeal = chimera.convertScrapToHp(); + assert.ok(secondHeal >= 0); + assert.equal(chimera.inventory.salvage.scrap, 0, 'both activations spent scrap'); + assert.equal(chimera.canConvertScrap().reason, 'insufficient-salvage', 'scrap now exhausted'); +}); diff --git a/tests/unit/game/Run.test.ts b/tests/unit/game/Run.test.ts index af62c34..18e8783 100644 --- a/tests/unit/game/Run.test.ts +++ b/tests/unit/game/Run.test.ts @@ -25,6 +25,7 @@ import { testContractContext } from './contractTestUtils.js'; import { ITEM_ID } from '../../../src/game/items.js'; import { Berserk } from '../../../src/game/archetypes/Berserk.js'; import { Adept } from '../../../src/game/archetypes/Adept.js'; +import { Chimera } from '../../../src/game/archetypes/Chimera.js'; const fakeContract = (overrides = {}) => ({ seed: 12345, @@ -102,6 +103,14 @@ test('Run classifies an Adept and seeds matching telemetry', () => { assert.equal(run.telemetry.archetype, 'adept'); }); +test('Run classifies a Chimera and seeds matching telemetry', () => { + const crewMember = makeCrew('chimera'); + const run = new Run({ crewMember, seed: 45 }); + assert.ok(crewMember instanceof Chimera); + assert.equal(run.archetype, 'chimera'); + assert.equal(run.telemetry.archetype, 'chimera'); +}); + test('legal transition chain: BRIEFING β†’ COMBAT β†’ RESULT', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 42 }); run.enterBriefing(fakeContract()); diff --git a/tests/unit/game/archetypes.test.ts b/tests/unit/game/archetypes.test.ts index 882432a..6746b97 100644 --- a/tests/unit/game/archetypes.test.ts +++ b/tests/unit/game/archetypes.test.ts @@ -25,6 +25,7 @@ import { import { Decker } from '../../../src/game/archetypes/Decker.js'; import { Berserk, CALLSIGNS as BERSERK_CALLSIGNS } from '../../../src/game/archetypes/Berserk.js'; import { Adept, CALLSIGNS as ADEPT_CALLSIGNS } from '../../../src/game/archetypes/Adept.js'; +import { Chimera, CALLSIGNS as CHIMERA_CALLSIGNS } from '../../../src/game/archetypes/Chimera.js'; import { FACTION } from '../../../src/game/constants.js'; import { Merc, CALLSIGNS as MERC_CALLSIGNS } from '../../../src/game/archetypes/Merc.js'; import { CALLSIGNS as RAZOR_CALLSIGNS } from '../../../src/game/archetypes/Razor.js'; @@ -76,6 +77,7 @@ test('CALLSIGNS_BY_ARCHETYPE mirrors the per-archetype module exports', () => { assert.deepEqual(CALLSIGNS_BY_ARCHETYPE.razor, RAZOR_CALLSIGNS); assert.deepEqual(CALLSIGNS_BY_ARCHETYPE.berserk, BERSERK_CALLSIGNS); assert.deepEqual(CALLSIGNS_BY_ARCHETYPE.adept, ADEPT_CALLSIGNS); + assert.deepEqual(CALLSIGNS_BY_ARCHETYPE.chimera, CHIMERA_CALLSIGNS); }); test('pickCallsign returns a name from the archetype pool', () => { @@ -172,6 +174,7 @@ test('perkAim metadata: only self-centered perks are tagged "self"', () => { assert.equal(ARCHETYPES.decker.perkAim, 'self'); assert.equal(ARCHETYPES.berserk.perkAim, 'self'); assert.equal(ARCHETYPES.adept.perkAim, 'directional'); + assert.equal(ARCHETYPES.chimera.perkAim, 'self'); }); test('perkAimForArchetype resolves lowercase id and class-cased Crew.archetype', () => { @@ -183,6 +186,8 @@ test('perkAimForArchetype resolves lowercase id and class-cased Crew.archetype', assert.equal(perkAimForArchetype('Berserk'), 'self'); assert.equal(perkAimForArchetype('adept'), 'directional'); assert.equal(perkAimForArchetype('Adept'), 'directional'); + assert.equal(perkAimForArchetype('chimera'), 'self'); + assert.equal(perkAimForArchetype('Chimera'), 'self'); }); test('perkAimForArchetype throws on an unknown archetype (no silent fallback)', () => { @@ -235,6 +240,20 @@ test('Adept is registered, recruitable, and directionally aimed', () => { assert.ok(ADEPT_CALLSIGNS.includes(adept.callsign)); }); +test('Chimera is registered, recruitable, and self-targeted', () => { + const a = ARCHETYPES.chimera; + assert.deepEqual(a.perks, ['nanite-repair']); + assert.equal(a.perkName, 'NANITE REPAIR'); + assert.equal(a.perkAim, 'self'); + assert.ok(!ARCHETYPE_IDS.includes('chimera')); + assert.ok(RECRUIT_ARCHETYPE_POOL.includes('chimera')); + + const chimera = buildCrewMember('chimera', { x: 1, y: 2 }, new Rng(10)); + assert.ok(chimera instanceof Chimera); + assert.equal(isArchetypeId('chimera'), true); + assert.ok(CHIMERA_CALLSIGNS.includes(chimera.callsign)); +}); + test('isArchetypeId is a string-set membership check', () => { assert.equal(isArchetypeId('merc'), true); assert.equal(isArchetypeId('razor'), true); diff --git a/tests/unit/game/persistence.test.ts b/tests/unit/game/persistence.test.ts index 22858ec..711f9e0 100644 --- a/tests/unit/game/persistence.test.ts +++ b/tests/unit/game/persistence.test.ts @@ -30,6 +30,7 @@ import { buildCrewMember } from '../../../src/game/archetypes/index.js'; import { Decker } from '../../../src/game/archetypes/Decker.js'; import { Berserk } from '../../../src/game/archetypes/Berserk.js'; import { Adept } from '../../../src/game/archetypes/Adept.js'; +import { Chimera } from '../../../src/game/archetypes/Chimera.js'; import { PatrolHostile } from '../../../src/game/ai/PatrolHostile.js'; import { applyOverride } from '../../../src/game/mindInfluence.js'; import { Rng } from '../../../src/rng.js'; @@ -142,6 +143,16 @@ test('snapshot/restore round-trips an Adept deploy (P3.5.M4)', () => { assert.deepEqual(snapshot(restoredRun), rec); }); +test('snapshot/restore round-trips a Chimera deploy (P3.5.M5)', () => { + const run = freshCombatRun(0xc4171e4, 'chimera'); + assert.ok(run.player instanceof Chimera, 'fixture should deploy a Chimera'); + const rec = snapshot(run); + const { run: restoredRun } = restore(rec); + assert.ok(restoredRun.player instanceof Chimera, 'Chimera should restore as a Chimera'); + assert.equal(restoredRun.player.callsign, run.player.callsign); + assert.deepEqual(snapshot(restoredRun), rec); +}); + test('snapshot/restore preserves an active Berserk Surge, including bonus AP and armor', () => { const run = freshCombatRun(0xb3e5e5, 'berserk'); const berserk = run.player as Berserk; diff --git a/tests/unit/input/applyIntent.test.ts b/tests/unit/input/applyIntent.test.ts index 84f62ba..e2a9738 100644 --- a/tests/unit/input/applyIntent.test.ts +++ b/tests/unit/input/applyIntent.test.ts @@ -11,6 +11,8 @@ import { AP_COST, MELEE_DAMAGE, SALVAGE_PER_IMPROVISED_TURRET, + SALVAGE_PER_NANITE_HEAL, + NANITE_HEAL_AMOUNT, STATUS_EFFECT, } from '../../../src/game/constants.js'; import { makeSalvage, totalSalvage } from '../../../src/game/salvage.js'; @@ -20,6 +22,7 @@ import { Tech } from '../../../src/game/archetypes/Tech.js'; import { Decker } from '../../../src/game/archetypes/Decker.js'; import { Berserk } from '../../../src/game/archetypes/Berserk.js'; import { Adept } from '../../../src/game/archetypes/Adept.js'; +import { Chimera } from '../../../src/game/archetypes/Chimera.js'; import { CyberAvatar } from '../../../src/game/cyber/CyberAvatar.js'; import { ProbeIce } from '../../../src/game/cyber/ProbeIce.js'; import { Turret } from '../../../src/game/Turret.js'; @@ -58,7 +61,9 @@ function buildCtx({ archetype = 'merc', placeDrone = true } = {}) { ? new Berserk({ id: 'berserk', x: 2, y: 2, maxAp: 4 }) : archetype === 'adept' ? new Adept({ id: 'adept', x: 2, y: 2, maxAp: 4 }) - : new Razor({ id: 'razor', x: 2, y: 2, maxAp: 4 }); + : archetype === 'chimera' + ? new Chimera({ id: 'chimera', x: 2, y: 2, maxAp: 4 }) + : new Razor({ id: 'razor', x: 2, y: 2, maxAp: 4 }); world.addEntity(player); let drone = null; @@ -449,13 +454,17 @@ test('special intent routes to Deploy on a Tech and places a Turret adjacent', ( }); test('special intent routes to Slide on a Razor (moves 2 tiles, engages stealth)', () => { - const { ctx, player } = buildCtx({ archetype: 'razor' }); + const { ctx, player, world } = buildCtx({ archetype: 'razor' }); + const cloaks = []; + world.events.on(EVENT.RAZOR_CLOAKED, payload => cloaks.push(payload)); // Player at (2,2). Special dy=1 wants to land at (2,4) β€” but (3,2) is cover // so dy=1 (down) avoids it: step (2,3), land (2,4). Both should be FLOOR. applyIntent({ type: 'special', dx: 0, dy: 1 }, ctx); assert.equal(player.x, 2); assert.equal(player.y, 4); assert.equal(player.stealthed, true); + // Presentation hook fires for the shell's cloak pulse, at the landing tile. + assert.deepEqual(cloaks, [{ actor: player }]); }); test('a stunned (0-AP) player concludes its turn instead of crashing on spendAp', () => { @@ -561,11 +570,16 @@ test('special intent routes CyberAvatar Override against Probe ICE', () => { onPlayerAction: () => {}, }; + const influenced = []; + world.events.on(EVENT.MIND_INFLUENCED, payload => influenced.push(payload)); + applyIntent({ type: 'special', dx: 1, dy: 0 }, ctx); assert.equal(probe.faction, FACTION.PLAYER); assert.equal(avatar.ap, avatar.maxAp - AP_COST.INFLUENCE); assert.ok(log.some(line => line.includes('OVERRIDES Probe'))); + // Presentation hook fires on the cyber-grid bus for the shell's violet pulse. + assert.deepEqual(influenced, [{ actor: avatar, target: probe, success: true }]); }); test('CyberAvatar Override acquires an off-axis Probe in the aimed direction', () => { @@ -624,12 +638,46 @@ test('special intent routes to Influence on an Adept and dominates the aimed hos onPlayerAction: () => {}, }; const apBefore = adept.ap; + const influenced = []; + world.events.on(EVENT.MIND_INFLUENCED, payload => influenced.push(payload)); applyIntent({ type: 'special', dx: 1, dy: 0 }, ctx); assert.equal(drone.faction, FACTION.PLAYER); assert.equal(adept.ap, apBefore - AP_COST.INFLUENCE); assert.ok(log.some(line => line.includes('DOMINATES'))); + // Presentation hook fires on the Meatspace bus for the shell's violet pulse. + assert.deepEqual(influenced, [{ actor: adept, target: drone, success: true }]); +}); + +test('a failed Influence roll still pulses the target tile β€” log copy carries the fail, not the flash', () => { + const grid = new Grid(10, 6); + const bus = new EventBus(); + const world = new World(grid, { events: bus }); + const adept = new Adept({ id: 'adept', x: 2, y: 2, maxAp: 4 }); + const drone = new Skirmisher({ id: 'd1', x: 5, y: 2, maxAp: 3 }); + world.addEntity(adept); + world.addEntity(drone); + drone.bindToBus(bus); + const log = []; + const ctx = { + world, + player: adept, + queue: new TurnQueue([FACTION.PLAYER, FACTION.CORP]), + rng: { next: () => 0.99 }, // deterministic failure (>= INFLUENCE_SUCCESS_CHANCE) + log: line => log.push(line), + advanceTurn: () => {}, + resetInputModes: () => {}, + onPlayerAction: () => {}, + }; + const influenced = []; + world.events.on(EVENT.MIND_INFLUENCED, payload => influenced.push(payload)); + + applyIntent({ type: 'special', dx: 1, dy: 0 }, ctx); + + assert.notEqual(drone.faction, FACTION.PLAYER, 'domination did not take'); + assert.ok(log.some(line => line.includes('INFLUENCE FAILED'))); + assert.deepEqual(influenced, [{ actor: adept, target: drone, success: false }]); }); test('special intent on an Adept with no legal target logs a denial without spending AP', () => { @@ -771,6 +819,8 @@ test('vault body-check deals VAULT_DAMAGE and knocks hostile back', () => { const drone = new Skirmisher({ id: 'd1', x: 4, y: 2, maxAp: 3 }); world.addEntity(drone); const hpBefore = drone.hp; + const damaged = []; + world.events.on(EVENT.ENTITY_DAMAGED, payload => damaged.push(payload)); applyIntent({ type: 'special', dx: 1, dy: 0 }, ctx); assert.equal(player.x, 4, 'Merc lands where the hostile was'); assert.equal(drone.x, 5, 'hostile knocked back 1 tile east'); @@ -779,6 +829,11 @@ test('vault body-check deals VAULT_DAMAGE and knocks hostile back', () => { log.some(l => l.includes('SLAMMED')), 'log mentions the slam' ); + // Presentation hook: sceneListeners keys its gold "impact" flash off this + // source label, distinct from a melee strike (P3.5.M5). + assert.equal(damaged.length, 1); + assert.equal(damaged[0].source, 'vault'); + assert.equal(damaged[0].target, drone); }); test('vault body-check does not debit extra AP beyond the vault cost', () => { @@ -870,3 +925,37 @@ test('special on a Tech with no turret and no salvage logs a denial', () => { 'should log a denial when no turret and no salvage' ); }); + +// --- M5: Nanite Repair dispatch via special intent -------------------------- + +test('special intent routes to Nanite Repair on a Chimera without entering directional movement', () => { + const { ctx, log, player, world } = buildCtx({ archetype: 'chimera', placeDrone: false }); + player.initInventory(); + player.inventory.salvage = makeSalvage({ scrap: SALVAGE_PER_NANITE_HEAL }); + player.damage(1); + const positionBefore = { x: player.x, y: player.y }; + const apBefore = player.ap; + const scrapBefore = player.inventory.salvage.scrap; + const hpBefore = player.hp; + const heals = []; + world.events.on(EVENT.NANITE_HEALED, payload => heals.push(payload)); + applyIntent({ type: 'special', dx: 0, dy: 0 }, ctx); + assert.deepEqual({ x: player.x, y: player.y }, positionBefore, 'self-targeted, no movement'); + assert.equal(player.ap, apBefore - AP_COST.NANITE_HEAL); + assert.equal(player.inventory.salvage.scrap, scrapBefore - SALVAGE_PER_NANITE_HEAL); + assert.equal(player.hp, hpBefore + NANITE_HEAL_AMOUNT); + assert.ok(log.some(line => line.includes('scrap into tissue'))); + // Presentation hook fires for the shell's nanite-heal pulse. + assert.deepEqual(heals, [{ origin: { x: player.x, y: player.y }, healed: NANITE_HEAL_AMOUNT }]); +}); + +test('special on a Chimera with no scrap logs a denial', () => { + const { ctx, player, log } = buildCtx({ archetype: 'chimera', placeDrone: false }); + player.initInventory(); + // Default emptySalvage wallet β€” no scrap, can't convert. + applyIntent({ type: 'special', dx: 0, dy: 0 }, ctx); + assert.ok( + log.some(l => l.includes('NANITE REPAIR DENIED')), + 'should log a denial when no scrap is available' + ); +}); diff --git a/tests/unit/render/animations.test.ts b/tests/unit/render/animations.test.ts index 238ddc4..20b180d 100644 --- a/tests/unit/render/animations.test.ts +++ b/tests/unit/render/animations.test.ts @@ -13,11 +13,17 @@ import { triggerCrashFlash, triggerDamageFlash, triggerEmpFlash, + triggerHealFlash, triggerMitigationFlash, triggerShake, triggerSurgeFlash, } from '../../../src/render/animations.js'; -import { CRASH_FLASH_FG, STUNNED_FG, SURGE_FLASH_FG } from '../../../src/render/palette.js'; +import { + CRASH_FLASH_FG, + HEAL_FLASH_FG, + STUNNED_FG, + SURGE_FLASH_FG, +} from '../../../src/render/palette.js'; /** * Minimal DOM-element stub β€” enough surface for restartCssAnimation to @@ -199,6 +205,23 @@ test('triggerCrashFlash drives an ashen vignette pulse distinct from the surge t assert.equal(el.classList.contains(MITIGATION_FLASH_CLASS), false, 'clears after its duration'); }); +test('triggerHealFlash drives a green vignette pulse distinct from surge/crash, shared by Nanite Repair and STIM', () => { + const el = makeElement(); + const timers = makeTimers(); + triggerHealFlash(el, timers); + assert.equal( + el.classList.contains(MITIGATION_FLASH_CLASS), + true, + 'reuses the colored-flash class' + ); + assert.equal(el.classList.contains(DAMAGE_CLASS), false, 'not the red damage flash'); + assert.equal(el.style.getPropertyValue('--kp-impact-flash-color'), `${HEAL_FLASH_FG}8c`); + assert.notEqual(HEAL_FLASH_FG, SURGE_FLASH_FG, 'heal reads as a different beat than surge'); + assert.notEqual(HEAL_FLASH_FG, CRASH_FLASH_FG, 'heal reads as a different beat than crash'); + timers.advance(ANIMATION_DURATIONS.HEAL_FLASH + 1); + assert.equal(el.classList.contains(MITIGATION_FLASH_CLASS), false, 'clears after its duration'); +}); + test('createAnimationLock: isLocked is false before any push', () => { const timers = makeTimers(); const lock = createAnimationLock(timers); diff --git a/tests/unit/render/frame.test.ts b/tests/unit/render/frame.test.ts index ddfaeb9..cc1ff19 100644 --- a/tests/unit/render/frame.test.ts +++ b/tests/unit/render/frame.test.ts @@ -18,6 +18,9 @@ import { glyphForEntity, INTERACTABLE_SECURED_FG, STUNNED_FG, + SURGE_FLASH_FG, + CRASH_FLASH_FG, + CLOAK_FLASH_FG, } from '../../../src/render/palette.js'; import { STATUS_EFFECT } from '../../../src/game/constants.js'; import { ConsumablePickup } from '../../../src/game/entities/ConsumablePickup.js'; @@ -111,6 +114,45 @@ test('buildFrame renders a stunned entity in electric cyan, overriding faction h assert.notEqual(cell.fg, glyphForEntity(drone).fg, 'differs from the un-stunned faction hue'); }); +test('buildFrame renders a surged entity in blaze orange, overriding faction hue', () => { + const { world, player } = fixture(); + player.applyEffect(STATUS_EFFECT.SURGE, 1); + const frame = buildFrame(world, { x: 0, y: 0, width: 6, height: 4 }); + const cell = cellAt(frame, 1, 1); + assert.equal(cell.char, '@', 'live glyph kept'); + assert.equal(cell.fg, SURGE_FLASH_FG, 'sustained Surge tint matches the arming flash colour'); +}); + +test('buildFrame renders a crashed entity in ashen violet-grey, distinct from surge', () => { + const { world, player } = fixture(); + player.applyEffect(STATUS_EFFECT.CRASH, 1); + const frame = buildFrame(world, { x: 0, y: 0, width: 6, height: 4 }); + const cell = cellAt(frame, 1, 1); + assert.equal(cell.fg, CRASH_FLASH_FG); + assert.notEqual(cell.fg, SURGE_FLASH_FG, 'crash reads differently from surge'); +}); + +test('buildFrame renders a stealthed entity in the cloak tint, overriding faction hue', () => { + const { world, player } = fixture(); + player.stealthed = true; + const frame = buildFrame(world, { x: 0, y: 0, width: 6, height: 4 }); + const cell = cellAt(frame, 1, 1); + assert.equal(cell.char, '@'); + assert.equal(cell.fg, CLOAK_FLASH_FG); + assert.notEqual(cell.fg, glyphForEntity(player).fg, 'differs from the un-cloaked faction hue'); +}); + +test('buildFrame: a stunned entity wins over a simultaneous altered-state tint', () => { + // Not reachable through any single archetype today, but locks the render + // priority contract: losing your turn is the most urgent thing to read. + const { world, player } = fixture(); + player.applyEffect(STATUS_EFFECT.SURGE, 1); + player.applyEffect(STATUS_EFFECT.STUN, 1); + const frame = buildFrame(world, { x: 0, y: 0, width: 6, height: 4 }); + const cell = cellAt(frame, 1, 1); + assert.equal(cell.fg, STUNNED_FG, 'stun wins over the Surge tint'); +}); + test('buildFrame: a live entity standing on a corpse tile renders the live entity', () => { // Construct two drones on the same tile by killing one and walking the other // onto its square β€” the live silhouette must win the cell. diff --git a/tests/unit/shell/sceneListeners.test.ts b/tests/unit/shell/sceneListeners.test.ts index 0a7995b..6a29307 100644 --- a/tests/unit/shell/sceneListeners.test.ts +++ b/tests/unit/shell/sceneListeners.test.ts @@ -3,6 +3,13 @@ import assert from 'node:assert/strict'; import { EventBus, EVENT } from '../../../src/game/events.js'; import { VisionField } from '../../../src/game/Vision.js'; +import { ANIMATION_DURATIONS } from '../../../src/render/animations.js'; +import { + CLOAK_FLASH_FG, + HEAL_FLASH_FG, + MIND_INFLUENCE_FG, + VAULT_IMPACT_FG, +} from '../../../src/render/palette.js'; import { SceneListenerController } from '../../../src/shell/sceneListeners.js'; import type { ShellScene } from '../../../src/shell/sceneView.js'; @@ -235,6 +242,312 @@ test('fully mitigated body hits still shake and flash with the stopping defense assert.equal(pipProperties.get('--kp-pip-impact-color'), '#d49a3a8c'); }); +test('P3.5.M5: Nanite Repair pulses the shared stage flash', () => { + const bus = new EventBus(); + const scene: ShellScene = { + bus, + world: null, + player: null, + state: 'combat', + } as unknown as ShellScene; + + const classes = new Set(); + const properties = new Map(); + const stage = { + classList: { + add: (name: string) => classes.add(name), + remove: (name: string) => classes.delete(name), + }, + style: { + setProperty: (name: string, value: string) => properties.set(name, value), + removeProperty: (name: string) => properties.delete(name), + }, + offsetWidth: 0, + } as unknown as HTMLElement; + + let pushedDuration = 0; + const controller = new SceneListenerController({ + getScene: () => scene, + getCampaign: () => null, + getMeatVision: () => new VisionField(), + getCyberVision: () => new VisionField(), + resetCyberVision: () => new VisionField(), + dom: { stageEl: stage, pipCanvas: {} as HTMLCanvasElement }, + renderers: { + main: { draw: () => {} } as never, + pip: { draw: () => {} } as never, + }, + animLock: { + push: (ms: number) => { + pushedDuration = ms; + }, + }, + effects: { + flash: () => {}, + paint: () => {}, + paintPip: () => {}, + recomputeVision: () => {}, + }, + onCivilianHarmReset: () => {}, + onCivilianHarmed: () => {}, + onRepAdjust: () => {}, + onAlarmTransition: () => {}, + onObjectiveTimerExpired: () => {}, + memoriseMeatCorpse: () => {}, + memoriseCyberCorpse: () => {}, + }); + controller.rewire(); + + bus.emit(EVENT.NANITE_HEALED, { origin: { x: 2, y: 2 }, healed: 1 }); + + assert.equal(classes.has('kp-mitigation-flash'), true); + assert.equal(properties.get('--kp-impact-flash-color'), `${HEAL_FLASH_FG}8c`); + assert.equal(pushedDuration, ANIMATION_DURATIONS.HEAL_FLASH); +}); + +test('P3.5.M5: Vault body-check pulses a gold impact burst on the occupant tile, distinct from a melee muzzle flash', () => { + const bus = new EventBus(); + const scene: ShellScene = { + bus, + world: null, + player: null, + state: 'combat', + } as unknown as ShellScene; + + const flashCalls: { x: number; y: number; opts: Record }[] = []; + const controller = new SceneListenerController({ + getScene: () => scene, + getCampaign: () => null, + getMeatVision: () => new VisionField(), + getCyberVision: () => new VisionField(), + resetCyberVision: () => new VisionField(), + dom: { stageEl: {} as HTMLElement, pipCanvas: {} as HTMLCanvasElement }, + renderers: { + main: { + flashCell: (x: number, y: number, opts: Record) => { + flashCalls.push({ x, y, opts }); + return true; + }, + } as never, + pip: {} as never, + }, + animLock: { push: () => {} }, + effects: { + flash: () => {}, + paint: () => {}, + paintPip: () => {}, + recomputeVision: () => {}, + }, + onCivilianHarmReset: () => {}, + onCivilianHarmed: () => {}, + onRepAdjust: () => {}, + onAlarmTransition: () => {}, + onObjectiveTimerExpired: () => {}, + memoriseMeatCorpse: () => {}, + memoriseCyberCorpse: () => {}, + }); + controller.rewire(); + + bus.emit(EVENT.ENTITY_DAMAGED, { + target: { id: 'drone', x: 5, y: 2 }, + damage: 2, + source: 'vault', + }); + + assert.equal(flashCalls.length, 1, 'vault slam fires exactly one cell flash'); + assert.equal(flashCalls[0].x, 5); + assert.equal(flashCalls[0].y, 2); + assert.equal( + flashCalls[0].opts.char, + '!', + 'vault gets its own impact glyph, not the bullet spark' + ); + assert.equal(flashCalls[0].opts.color, VAULT_IMPACT_FG); + assert.equal(flashCalls[0].opts.duration, ANIMATION_DURATIONS.VAULT_IMPACT_FLASH); +}); + +test('P3.5.M5: Mind Influence pulses a violet burst on the target tile in Meatspace (Adept)', () => { + const bus = new EventBus(); + const scene: ShellScene = { + bus, + world: null, + player: null, + state: 'combat', + } as unknown as ShellScene; + + const flashCalls: { x: number; y: number; opts: Record }[] = []; + const controller = new SceneListenerController({ + getScene: () => scene, + getCampaign: () => null, + getMeatVision: () => new VisionField(), + getCyberVision: () => new VisionField(), + resetCyberVision: () => new VisionField(), + dom: { stageEl: {} as HTMLElement, pipCanvas: {} as HTMLCanvasElement }, + renderers: { + main: { + flashCell: (x: number, y: number, opts: Record) => { + flashCalls.push({ x, y, opts }); + return true; + }, + } as never, + pip: {} as never, + }, + animLock: { push: () => {} }, + effects: { + flash: () => {}, + paint: () => {}, + paintPip: () => {}, + recomputeVision: () => {}, + }, + onCivilianHarmReset: () => {}, + onCivilianHarmed: () => {}, + onRepAdjust: () => {}, + onAlarmTransition: () => {}, + onObjectiveTimerExpired: () => {}, + memoriseMeatCorpse: () => {}, + memoriseCyberCorpse: () => {}, + }); + controller.rewire(); + + // A failed roll gets the same pulse as a success β€” log copy carries the outcome. + bus.emit(EVENT.MIND_INFLUENCED, { + actor: { id: 'adept' }, + target: { id: 'drone', x: 6, y: 3, glyph: 'd' }, + success: false, + }); + + assert.equal(flashCalls.length, 1); + assert.equal(flashCalls[0].x, 6); + assert.equal(flashCalls[0].y, 3); + assert.equal(flashCalls[0].opts.char, 'd', "overpaints the target's own glyph"); + assert.equal(flashCalls[0].opts.color, MIND_INFLUENCE_FG); + assert.equal(flashCalls[0].opts.duration, ANIMATION_DURATIONS.MIND_INFLUENCE_FLASH); +}); + +test('P3.5.M5: Razor Slide pulses a pale mint burst on its own landing tile, subtler than a hostile-targeted flash', () => { + const bus = new EventBus(); + const scene: ShellScene = { + bus, + world: null, + player: null, + state: 'combat', + } as unknown as ShellScene; + + const flashCalls: { x: number; y: number; opts: Record }[] = []; + const controller = new SceneListenerController({ + getScene: () => scene, + getCampaign: () => null, + getMeatVision: () => new VisionField(), + getCyberVision: () => new VisionField(), + resetCyberVision: () => new VisionField(), + dom: { stageEl: {} as HTMLElement, pipCanvas: {} as HTMLCanvasElement }, + renderers: { + main: { + flashCell: (x: number, y: number, opts: Record) => { + flashCalls.push({ x, y, opts }); + return true; + }, + } as never, + pip: {} as never, + }, + animLock: { push: () => {} }, + effects: { + flash: () => {}, + paint: () => {}, + paintPip: () => {}, + recomputeVision: () => {}, + }, + onCivilianHarmReset: () => {}, + onCivilianHarmed: () => {}, + onRepAdjust: () => {}, + onAlarmTransition: () => {}, + onObjectiveTimerExpired: () => {}, + memoriseMeatCorpse: () => {}, + memoriseCyberCorpse: () => {}, + }); + controller.rewire(); + + bus.emit(EVENT.RAZOR_CLOAKED, { + actor: { id: 'razor', x: 2, y: 4, glyph: '@' }, + }); + + assert.equal(flashCalls.length, 1); + assert.equal(flashCalls[0].x, 2); + assert.equal(flashCalls[0].y, 4); + assert.equal(flashCalls[0].opts.char, '@', "overpaints the Razor's own glyph, not a foreign one"); + assert.equal(flashCalls[0].opts.color, CLOAK_FLASH_FG); + assert.notEqual( + CLOAK_FLASH_FG, + MIND_INFLUENCE_FG, + 'self-cloak reads differently from a hostile-targeted pulse' + ); + assert.equal(flashCalls[0].opts.duration, ANIMATION_DURATIONS.CLOAK_FLASH); +}); + +test('P3.5.M5: Mind Influence pulses on the cyber grid for the CyberAvatar Override', () => { + const bus = new EventBus(); + const cyberBus = new EventBus(); + const scene = { + bus, + world: { entities: new Map() }, + player: { id: 'body', x: 3, y: 4, hp: 4, maxHp: 8, alive: true }, + archetype: 'decker', + cyberspace: { + phase: 'active', + layer: { avatar: { id: 'avatar' }, bus: cyberBus, mapSeenKeys: () => [] }, + }, + activeLayer: 'cyber', + state: 'combat', + } as unknown as ShellScene; + + const flashCalls: { x: number; y: number; opts: Record }[] = []; + const controller = new SceneListenerController({ + getScene: () => scene, + getCampaign: () => null, + getMeatVision: () => new VisionField(), + getCyberVision: () => new VisionField(), + resetCyberVision: () => new VisionField(), + dom: { stageEl: {} as HTMLElement, pipCanvas: {} as HTMLCanvasElement }, + renderers: { + // Viewing cyber β‡’ the cyber grid is on the main canvas. + main: { + flashCell: (x: number, y: number, opts: Record) => { + flashCalls.push({ x, y, opts }); + return true; + }, + } as never, + pip: {} as never, + }, + animLock: { push: () => {} }, + effects: { + flash: () => {}, + paint: () => {}, + paintPip: () => {}, + recomputeVision: () => {}, + }, + onCivilianHarmReset: () => {}, + onCivilianHarmed: () => {}, + onRepAdjust: () => {}, + onAlarmTransition: () => {}, + onObjectiveTimerExpired: () => {}, + memoriseMeatCorpse: () => {}, + memoriseCyberCorpse: () => {}, + }); + controller.rewire(); + + cyberBus.emit(EVENT.MIND_INFLUENCED, { + actor: { id: 'avatar' }, + target: { id: 'probe-ice-0', x: 4, y: 3, glyph: 'i' }, + success: true, + }); + + assert.equal(flashCalls.length, 1); + assert.equal(flashCalls[0].x, 4); + assert.equal(flashCalls[0].y, 3); + assert.equal(flashCalls[0].opts.char, 'i'); + assert.equal(flashCalls[0].opts.color, MIND_INFLUENCE_FG); +}); + test('P3.M4.6: forced jack-out body repair is not memorised as a meat corpse', () => { const bus = new EventBus(); const body = { id: 'body', x: 3, y: 4, hp: 1, maxHp: 8, alive: true }; From d2179b49d4e9963fcecbf9a767fe72cc1974693b Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 14 Jul 2026 22:23:02 -0700 Subject: [PATCH 8/9] Generative crew stats, new archetypes as score rewards --- docs/phase-3.5-plan.md | 43 ++- src/DataStore.ts | 104 +++++- src/game/Campaign.ts | 338 +++++++++++++++---- src/game/Crew.ts | 59 +++- src/game/archetypeRewards.ts | 49 +++ src/game/archetypeShowcase.ts | 29 ++ src/game/archetypeUnlocks.ts | 64 ++++ src/game/archetypes/Adept.ts | 16 +- src/game/archetypes/Berserk.ts | 21 +- src/game/archetypes/Chimera.ts | 16 +- src/game/archetypes/Decker.ts | 7 +- src/game/archetypes/Merc.ts | 13 +- src/game/archetypes/Razor.ts | 20 +- src/game/archetypes/Tech.ts | 12 +- src/game/archetypes/index.ts | 45 +-- src/game/campaignSummary.ts | 43 ++- src/game/constants.ts | 47 ++- src/game/crewStatRoll.ts | 264 +++++++++++++++ src/game/persistence.ts | 27 ++ src/shell/shellRuntime.ts | 25 +- sw-core.js | 16 + sw-dev.js | 2 +- sw-release.js | 8 +- sw.js | 2 +- tests/unit/DataStore.test.ts | 143 ++++++++ tests/unit/game/Campaign.test.ts | 382 ++++++++++++++++++++-- tests/unit/game/Crew.test.ts | 59 ++++ tests/unit/game/archetypeShowcase.test.ts | 26 ++ tests/unit/game/archetypeUnlocks.test.ts | 63 ++++ tests/unit/game/archetypes.test.ts | 14 +- tests/unit/game/campaignSummary.test.ts | 43 ++- tests/unit/game/crewStatRoll.test.ts | 371 +++++++++++++++++++++ tests/unit/game/persistence.test.ts | 61 ++++ tests/unit/game/scoreFinal.test.ts | 59 +++- 34 files changed, 2268 insertions(+), 223 deletions(-) create mode 100644 src/game/archetypeRewards.ts create mode 100644 src/game/archetypeShowcase.ts create mode 100644 src/game/archetypeUnlocks.ts create mode 100644 src/game/crewStatRoll.ts create mode 100644 tests/unit/game/archetypeShowcase.test.ts create mode 100644 tests/unit/game/archetypeUnlocks.test.ts create mode 100644 tests/unit/game/crewStatRoll.test.ts diff --git a/docs/phase-3.5-plan.md b/docs/phase-3.5-plan.md index 2e00f0a..b103ac0 100644 --- a/docs/phase-3.5-plan.md +++ b/docs/phase-3.5-plan.md @@ -44,8 +44,8 @@ M6 ──> M7 (archetype unlocks via Score rewards β€” needs M6's anchor table t | P3.5.M3 β€” Berserk archetype (surge/crash) | βœ… Complete | | P3.5.M4 β€” Adept archetype (Influence, renamed from Override) | βœ… Complete | | P3.5.M5 β€” Chimera archetype (scrap-to-HP sustain) | βœ… Complete | -| P3.5.M6 β€” Inverted crew generation (roll stats, derive archetype) | πŸ”² Not started | -| P3.5.M7 β€” Archetype unlocks via Score rewards | πŸ”² Not started | +| P3.5.M6 β€” Inverted crew generation (roll stats, derive archetype) | βœ… Complete | +| P3.5.M7 β€” Archetype unlocks via Score rewards | βœ… Complete | **Phase 3.5** is complete when: @@ -403,6 +403,15 @@ Restore: `baseHitChance: rec.baseHitChance ?? DEFAULT_HIT_CHANCE_BY_ARCHETYPE[re **Tests:** `tests/unit/game/Crew.test.ts` (extend, don't break, existing getter-value assertions; add constructor-override cases for all six archetypes), `tests/unit/game/crewStatRoll.test.ts` (new β€” the exhaustive 546-tuple hit/dodge grid sweep above, the anchor/boundary/corner-saturation + distribution-spread assertions, tie-break determinism, and a property check that `rollCrewStats` only ever emits values on the 0.01 grid within `[0.65,0.85]`/`[0.15,0.40]`), `tests/unit/game/Campaign.test.ts` (`buildCrew`/`generateRecruits` produce rolled stats; `rng.fork('crew-stats')` doesn't perturb existing callsign/combat rolls), `tests/unit/game/persistence.test.ts` (`CampaignCrewSnapshot` round-trip with/without the new optional fields; legacy-save defaults to old fixed constant). +**Implementation notes (as-built):** +- Shipped with the proposed anchor table, tie-break order, and roll ranges verbatim β€” the exhaustive 546-tuple sweep and the 13–21%-ish distribution guard both passed on the first real run against the code, confirming the plan's hand-computed partition. +- **`baseHitChance`/`baseDodgeChance` stayed getters, not raw fields** (a refinement of the "getter β†’ field" framing): `Crew` now holds them in private `#baseHitChance`/`#baseDodgeChance` fields set from `CrewInit`, with `get baseHitChance()`/`get baseDodgeChance()` reading them straight through. This was necessary, not cosmetic β€” Berserk's Crash penalty is a *live* modifier layered on top of a constructor-set pristine value, and TS/JS won't let a subclass define `get baseHitChance()` over a plain inherited data property (assigning to an accessor-shadowed field throws). Berserk's getter now reads `super.baseHitChance` and subtracts the Crash penalty, exactly reproducing its pre-M6 behavior; every other archetype simply doesn't override the getter at all and passes its own default through `super()`. +- **New `Crew.pristineBaseHitChance` getter (not in the original plan).** Discovered while wiring persistence: `snapshotCrewMember` was about to serialize `member.baseHitChance` β€” for a Berserk mid-Crash, that's the *live*, penalty-adjusted value, and baking a transient -0.2 into a `CampaignCrewSnapshot` would have permanently corrupted the restored baseline on any save taken between Crash triggering and its natural expiry. Added a `pristineBaseHitChance` getter (unaffected by Berserk's override) and pointed the snapshot at that instead β€” mirrors the existing `damageReduction` (pristine, persisted) vs `effectiveDamageReduction` (live, combat/HUD-facing) split from the M3 enrichment pass. Covered by a dedicated persistence test (Crash active at snapshot time β†’ restored member's live `baseHitChance` reads the pristine value, no baked-in penalty). +- **`RECRUIT_ARCHETYPE_POOL` removed outright** (not deprecated in place) β€” `archetypes/index.ts` no longer exports it. `Campaign.buildCrew`'s `STARTER_ARCHETYPES` fixed triple was replaced by a `STARTER_CREW_COUNT = 3` loop calling `buildCrewMemberFromRoll` per slot; starter crew ids changed from `crew-` (collision-prone once duplicates are legal) to `crew-`. +- Three pre-existing tests asserted the retired behavior directly (`buildCrew`'s fixed `[Merc, Razor, Tech]` triple, and the two weighted-pool distribution tests for `generateRecruits`/`generateInitialCandidates`) and were rewritten to assert structure (three unique callsigns, every member a registered non-Decker archetype, rolled stats in range) and the new roll-derived distribution instead. +- `buildCrewMember`'s `BuildCrewMemberOptions`/`BuildCrewMemberSpawn` types were exported (previously module-private) and `BuildCrewMemberOptions` gained `baseHitChance?`/`baseDodgeChance?` so `crewStatRoll.ts` could thread rolled stats through the existing factory without duplicating its callsign-pick/validation logic. +- Verification: full `npm test` (2075 pass, 0 fail), `npm run lint`, `npm run format` all clean. + --- ## P3.5.M7 β€” Archetype unlocks via Score rewards @@ -477,6 +486,36 @@ M7 supplies a filtered table β€” `CREW_STAT_ANCHORS.filter(a => !SCOREABLE_ARCHE **Tests:** `tests/unit/game/archetypeUnlocks.test.ts` (new, mirrors `scoreableUnlocks.test.ts`), extend `tests/unit/game/crewStatRoll.test.ts` (locked-anchor sweep: with only `{merc, razor, tech}` unlocked, every one of the 546 tuples still resolves to a registered *unlocked* archetype, no dead zones, no throw; each locked archetype's own anchor point resolves to a different, unlocked archetype), extend `tests/unit/game/Campaign.test.ts` (`pickScorePayload` draws from the merged pool; exhaustion requires both catalogs empty; settlement sets exactly one of the two meta fields, never both), extend `tests/unit/game/persistence.test.ts`/`DataStore.test.ts` (`unlockedArchetypes` round-trip, legacy-absent β†’ `[]`, malformed β†’ throw, idempotent archive), extend `tests/unit/game/campaignSummary.test.ts` (archetype-reward chronicle line). +**Implementation notes (as-built):** +- **Gating polarity: "omitted β†’ ungated" at the engine level, not "locked."** `CampaignOptions.unlockedArchetypeIds` is `unknown`, validated, and stored as `readonly string[] | null` β€” `null` (the option genuinely omitted) means `#crewStatAnchors()` returns the full unfiltered `CREW_STAT_ANCHORS`, matching every pre-M7 test's assumption that a bare `new Campaign({ seed })` reaches all six archetypes. A real array (including `[]`) gates. This was a deliberate resolution of a tension the plan's own M6 Verification note flagged ("M7 will otherwise have shrunk the live anchor table to `{merc, razor, tech}`"): defaulting the *shell's* production behavior to locked (every real construction site explicitly threads `dataStore.unlockedArchetypes`) while keeping the *engine's* bare default permissive, so none of M6's already-shipped "all six reachable" tests needed rewriting. `buildCrew` picked up the same amendment shape M6 already gave `deriveArchetype`: an optional `anchors: readonly CrewStatAnchor[] = CREW_STAT_ANCHORS` parameter. +- **`Campaign.#crewStatAnchors()`** is the single gating chokepoint `buildCrew` (via the constructor), `generateRecruits`, and `generateInitialCandidates` all read from β€” computed via `CREW_STAT_ANCHORS.filter(a => !SCOREABLE_ARCHETYPE_IDS.has(a.archetype) || unlocked.includes(a.archetype))`, exactly the formula the plan specified. +- **Real construction sites updated in `shellRuntime.ts`:** `startFreshCampaign` and the live-resume branch of `resumeCampaign` (`restoreCampaign(record, { unlockedArchetypeIds: dataStore.unlockedArchetypes, ... })`) both thread live meta-store state. The validation-only round-trip in `persistValidatedCampaignForRestart` (constructs and immediately discards a throwaway `Campaign` to confirm a snapshot deserializes) was deliberately left ungated β€” nothing gating-dependent is ever called on that instance. +- **`ScorePayload` discriminated union** (`{ kind: 'item'; item: Item } | { kind: 'archetype'; reward: ArchetypeReward }`) shipped exactly as drafted; `scorePayloadArchetypeId` was added as `scorePayloadItemId`'s sibling, reading `objective.params.scoreArchetypeId`. `Campaign.#scoreRewardSummary` (the campaign's own internal chronicle-line helper, distinct from `campaignSummary.ts`) also gained an archetype branch β€” not explicitly called out in the plan's file list, but it reads the same contract params and would have silently gone blank on an archetype win otherwise. +- **`campaignSummary.ts`'s `resolveScoreReward` takes both ids** (`itemId`, `archetypeId`) rather than being duplicated into a second function β€” `CampaignSummaryScoreReward` stays a single non-discriminated `{id, label, flavor}` shape shared by both reward kinds (the win screen/Chronicle render them identically), matching the plan's "no new type" implication. +- **Pre-existing test fallout, not a design change:** `scoreFinal.test.ts`'s item-exclusivity tests (P3.M6.4/M6.5) predate the merged pool and hardcode seedβ†’item mappings; every `buildScoreContract(...)` call there now also passes `ALL_ARCHETYPES_ACQUIRED = ['berserk','adept','chimera']` as the second argument so the pool stays item-only and every existing assertion keeps its original meaning. `campaignSummary.test.ts`'s one end-to-end win-summary test broadened its assertion to accept either reward catalog (it was never pinned to a specific draw). +- **Offline precache (`sw-core.js`) gap closed, broader than just this milestone's new files.** Beyond the three new M6/M7 modules (`crewStatRoll.js`, `archetypeUnlocks.js`, `archetypeRewards.js`), the already-flagged pre-existing gap (`Adept.js`/`mindInfluence.js` and all of `src/game/cyber/` missing since before P3.5.M5 β€” see auto-memory `sw-precache-adept-gap`) was fixed in the same pass, plus `scoreableUnlocks.js` (P3.M6, directly adjacent to the new `archetypeUnlocks.js`) and `entities/JackInPoint.js` (the Cyberspace entry point β€” none of the cyber/ files work offline without it). **Deliberately left alone as a separate, unrelated gap:** `CombatInventory.js`/`CrewInventory.js`/`InventoryOverlay.js`/`visionSync.js` (pre-existing omissions from the 3.3-inventory phase, out of scope here). +- **Found and fixed a live production break, unrelated to this milestone's design but discovered while touching `sw-core.js`.** The prior commit on this branch (`d87fb2c`, "Chimera...") bumped `sw-release.js`'s `version` to `'0.3.5'` but left `sw.js`/`sw-dev.js`'s `VERSION` const at `'0.3.5b'` β€” `sw.js` has a strict `self.KernelPanicRelease.version !== VERSION` throw on install, so the production service worker as last committed would have thrown on load. Fixed by aligning all three to `'0.3.6'` (no `'b'` suffix) as part of this milestone's version bump; `shellEpoch` stayed at `2` (no runtime module was removed/renamed, only added β€” same posture M3/M5 took). +- Verification: full `npm test` (2109 pass, 0 fail), `npm run lint`, `npm run format` all clean. + +--- + +## P3.5.M7.1 β€” Showcase slot for newly-unlocked archetypes (follow-up, added 2026-07-14) + +**User request:** "the first new campaign after unlocking a new archetype, we should make sure that the first of the three open crew candidate slots is reserved for an instance of that archetype." A clean Score win unlocking Berserk/Adept/Chimera is otherwise no guarantee the player ever actually sees it β€” the ordinary roll-then-derive pipeline (M6) only gives it the same ~13–21% shot as everything else. This closes that gap: a win is immediately followed by a guaranteed chance to try what was just unlocked. + +**Design:** +- **`DataStore.pendingArchetypeShowcase: string | null`** (new module `src/game/archetypeShowcase.ts`, `normalizePendingArchetypeShowcase` β€” mirrors the `scoreableUnlocks.ts`/`archetypeUnlocks.ts` validation shape but scalar, not a list: at most one showcase can ever be pending, since the only way to unlock an archetype β€” winning a Score β€” always ends the campaign in the same step, so there's always a "next campaign start" between any two unlocks in normal play). `DataStore.archiveUnlockedArchetype(id)` arms it atomically alongside the unlock itself on a genuinely new add (a duplicate/no-op archive leaves an already-pending showcase untouched β€” re-unlocking something already unlocked can't happen, but re-*archiving* the same id idempotently must not re-arm a showcase the player already saw). A new `DataStore.clearPendingArchetypeShowcase()` clears it. +- **`Campaign` gains `showcaseArchetypeId`** (constructor option + readonly instance field, same construction-time-capture lifecycle as `unlockedArchetypeIds`), accepting `undefined`, `null`, or a non-empty string (both `undefined` and `null` mean "no showcase" β€” `DataStore`'s getter naturally yields `null`, not `undefined`, so both had to be accepted or every real call site would need an `?? undefined` dance). +- **`generateInitialCandidates()`** reserves slot 0 via a new private `#buildShowcaseCandidate()`: if `showcaseArchetypeId` is set *and* still reachable under the campaign's own gating (`#crewStatAnchors()` β€” defensive; should always hold in the live shell since the DataStore write is atomic, but a stale/hand-edited save must not be able to crash campaign-start recruitment or leak a locked archetype into the pool over a cosmetic nicety), it fills slot 0 and the remaining two slots roll normally. `buildCrew` (the fixed always-3-member path, not reachable from the live shell β€” `startFreshCampaign` always passes `crew: []` and drives the actual 3-candidate/pick-2 flow through `generateInitialCandidates`/`recruitInitial`) was deliberately left untouched β€” the user's ask was specifically about "the three open crew candidate slots," which is that pool. +- **New `crewStatRoll.ts` export `buildCrewMemberFromRollForArchetype`**: rejection-sampling roll β€” repeatedly `rollCrewStats` on a forked substream until `deriveArchetype` classifies to the requested archetype, then builds via the normal `buildCrewMember` path. Chosen over just pinning the archetype's exact anchor point so the showcased operator still has natural roll variance like every other candidate, rather than standing out as a fixed stat line every time. Bounded by a generous `maxAttempts` (2000) β€” a crash-over-hang backstop, not a tuned budget; expected attempts are in the single digits given M6's ~13–21%-per-archetype partition. Throws if the archetype has no anchor in the supplied table at all (an unconditional contract violation, not something to silently ignore) or if attempts are exhausted (astronomically unlikely). +- **Consumption timing:** `startFreshCampaign()` *peeks* `dataStore.pendingArchetypeShowcase` (doesn't consume) when constructing the new `Campaign`. The flag is only cleared in `onInitialRecruited()`, once `campaign.recruitInitial(memberIds)` actually commits β€” regardless of which two of the three candidates were picked, the showcase's job (guaranteeing the player *saw* the option) is done at that point. This ordering matters: the pre-recruitment phase of a fresh campaign is never persisted to `DataStore` (`Campaign.crew.length === 0` β€” no world/hub built yet), so a browser reload or an abandoned attempt at the initial-recruit screen would otherwise permanently burn the showcase before the player ever laid eyes on it, since `startFreshCampaign()` reruns from scratch on next load. + +**Critical files:** `src/game/archetypeShowcase.ts` (new), `src/DataStore.ts`, `src/game/crewStatRoll.ts`, `src/game/Campaign.ts`, `src/shell/shellRuntime.ts`. + +**Tests:** `tests/unit/game/archetypeShowcase.test.ts` (new), extend `tests/unit/DataStore.test.ts` (absent β†’ `null`; atomic arm on new unlock; untouched on a duplicate archive; idempotent clear; corrupt-value throw), extend `tests/unit/game/crewStatRoll.test.ts` (`buildCrewMemberFromRollForArchetype` always yields the forced archetype with varying stats across seeds, works for all six non-Decker archetypes, throws when the archetype has no anchor in the supplied table, applies rolled armor, doesn't desync from `buildCrewMemberFromRoll`'s fork-then-build determinism shape), extend `tests/unit/game/Campaign.test.ts` (slot 0 reserved across seeds; remaining slots still vary; unique callsigns/ids preserved; ordinary no-showcase path unchanged; defensive no-op when the showcased archetype isn't actually gated-unlocked; constructor validation for malformed/`null`/omitted `showcaseArchetypeId`). + +Verification: full `npm test` (2131 pass, 0 fail), `npm run lint`, `npm run format` all clean. `shellRuntime.ts`'s wiring itself has no dedicated unit tests (consistent with existing project practice β€” see auto-memory `shell-runtime-untestable`); the model-level contract it drives (`Campaign.showcaseArchetypeId` β†’ `generateInitialCandidates` β†’ `DataStore.clearPendingArchetypeShowcase`) is what's covered above. + --- ## Out of scope, parked diff --git a/src/DataStore.ts b/src/DataStore.ts index c750a71..60d052d 100644 --- a/src/DataStore.ts +++ b/src/DataStore.ts @@ -7,6 +7,8 @@ import { type CampaignSummary, } from './game/campaignSummary.js'; import { archiveScoreableItem, normalizeUnlockedScoreableItems } from './game/scoreableUnlocks.js'; +import { archiveUnlockedArchetype, normalizeUnlockedArchetypes } from './game/archetypeUnlocks.js'; +import { normalizePendingArchetypeShowcase } from './game/archetypeShowcase.js'; const STORAGE_KEY = 'kp:data'; let instance: DataStore | null = null; @@ -25,6 +27,15 @@ type KPData = { campaign: Campaign | null; campaignHistory: CampaignSummary[]; unlockedScoreableItems: string[]; + /** P3.5.M7: cross-campaign archetype unlocks (Berserk/Adept/Chimera). */ + unlockedArchetypes: string[]; + /** + * Showcase-slot follow-up (2026-07-14): the archetype id, if any, the next + * campaign start should reserve crew candidate slot 0 for. Set atomically + * alongside `unlockedArchetypes` by `archiveUnlockedArchetype`; cleared by + * `clearPendingArchetypeShowcase` once a campaign-start recruitment commits. + */ + pendingArchetypeShowcase: string | null; }; type KPDataObject = | string @@ -42,6 +53,8 @@ class DataStore extends EventTarget { #campaign: KPData['campaign'] = null; #campaignHistory: KPData['campaignHistory'] = []; #unlockedScoreableItems: KPData['unlockedScoreableItems'] = []; + #unlockedArchetypes: KPData['unlockedArchetypes'] = []; + #pendingArchetypeShowcase: KPData['pendingArchetypeShowcase'] = null; constructor() { if (instance) { @@ -72,6 +85,8 @@ class DataStore extends EventTarget { campaign: null, campaignHistory: [], unlockedScoreableItems: [], + unlockedArchetypes: [], + pendingArchetypeShowcase: null, }) ); } catch (storageError) { @@ -83,6 +98,8 @@ class DataStore extends EventTarget { campaign: null, campaignHistory: [], unlockedScoreableItems: [], + unlockedArchetypes: [], + pendingArchetypeShowcase: null, }; } // The meta-progression store crashes loudly on structural corruption β€” a @@ -95,6 +112,8 @@ class DataStore extends EventTarget { campaign: (data.campaign as KPData['campaign']) ?? null, campaignHistory, unlockedScoreableItems: normalizeUnlockedScoreableItems(data.unlockedScoreableItems), + unlockedArchetypes: normalizeUnlockedArchetypes(data.unlockedArchetypes), + pendingArchetypeShowcase: normalizePendingArchetypeShowcase(data.pendingArchetypeShowcase), }; } @@ -107,27 +126,47 @@ class DataStore extends EventTarget { campaign: null, campaignHistory: [], unlockedScoreableItems: [], + unlockedArchetypes: [], + pendingArchetypeShowcase: null, }); window.localStorage.setItem(STORAGE_KEY, savedDataJson); } - const { prefs, runs, campaign, campaignHistory, unlockedScoreableItems } = - this.#loadDataFromJson(savedDataJson); + const { + prefs, + runs, + campaign, + campaignHistory, + unlockedScoreableItems, + unlockedArchetypes, + pendingArchetypeShowcase, + } = this.#loadDataFromJson(savedDataJson); this.#prefs = prefs; this.#runs = runs; this.#campaign = campaign; this.#campaignHistory = campaignHistory; this.#unlockedScoreableItems = unlockedScoreableItems; + this.#unlockedArchetypes = unlockedArchetypes; + this.#pendingArchetypeShowcase = pendingArchetypeShowcase; this.#emitChangeEvent('init', '*'); } import(jsonData: string): void { - const { prefs, runs, campaign, campaignHistory, unlockedScoreableItems } = - this.#loadDataFromJson(jsonData); + const { + prefs, + runs, + campaign, + campaignHistory, + unlockedScoreableItems, + unlockedArchetypes, + pendingArchetypeShowcase, + } = this.#loadDataFromJson(jsonData); this.#prefs = prefs; this.#runs = runs; this.#campaign = campaign; this.#campaignHistory = campaignHistory; this.#unlockedScoreableItems = unlockedScoreableItems; + this.#unlockedArchetypes = unlockedArchetypes; + this.#pendingArchetypeShowcase = pendingArchetypeShowcase; this.#emitChangeEvent('import', '*'); } @@ -140,6 +179,8 @@ class DataStore extends EventTarget { campaign: this.#campaign, campaignHistory: this.#campaignHistory, unlockedScoreableItems: this.#unlockedScoreableItems, + unlockedArchetypes: this.#unlockedArchetypes, + pendingArchetypeShowcase: this.#pendingArchetypeShowcase, }) ); } @@ -201,6 +242,61 @@ class DataStore extends EventTarget { return { added }; } + /** + * Cross-campaign meta-progression store (P3.5.M7): archetype IDs + * (Berserk/Adept/Chimera) unlocked via clean Score heists, in acquisition + * order. Wholly independent of `unlockedScoreableItems` β€” a save that + * unlocked every item under the pre-M7 system starts with an empty + * archetype list. Returns a defensive copy. + */ + get unlockedArchetypes(): string[] { + return [...this.#unlockedArchetypes]; + } + + /** + * Record an archetype as unlocked. Idempotent: re-archiving an + * already-unlocked id is a no-op (no event, no save). Returns whether the + * store changed. Written only on `score-complete` when the drawn payload + * is an archetype reward. + * + * A genuinely new unlock also arms `pendingArchetypeShowcase` with the same + * id, atomically β€” the next campaign start reserves crew candidate slot 0 + * for it (see `archetypeShowcase.ts`). A duplicate/no-op archive leaves any + * already-pending showcase untouched. + */ + archiveUnlockedArchetype(id: string): { added: boolean } { + const { list, added } = archiveUnlockedArchetype(this.#unlockedArchetypes, id); + if (added) { + this.#unlockedArchetypes = list; + this.#pendingArchetypeShowcase = id; + this.#emitChangeEvent('add', 'unlockedArchetypes', [...list]); + this.#saveData(); + } + return { added }; + } + + /** + * The archetype id, if any, the next campaign start should showcase in + * crew candidate slot 0 (see `archetypeShowcase.ts`). `null` when nothing + * is pending. + */ + get pendingArchetypeShowcase(): string | null { + return this.#pendingArchetypeShowcase; + } + + /** + * Clear the pending showcase. Idempotent (no event, no save if already + * `null`). Called once a campaign-start recruitment actually commits + * (`Campaign.recruitInitial`) β€” regardless of which candidate was picked, + * the showcase's job (guaranteeing the player *saw* the option) is done. + */ + clearPendingArchetypeShowcase(): void { + if (this.#pendingArchetypeShowcase === null) return; + this.#pendingArchetypeShowcase = null; + this.#emitChangeEvent('delete', 'pendingArchetypeShowcase'); + this.#saveData(); + } + archiveCampaign(summary: CampaignSummary): CampaignSummary { const archived = archiveCampaignSummary(this.#campaignHistory, summary); this.#campaignHistory = archived.history; diff --git a/src/game/Campaign.ts b/src/game/Campaign.ts index af61156..d99dda5 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -20,7 +20,18 @@ import { type SalvageType, type TypedSalvage, } from './salvage.js'; -import { buildCrewMember, RECRUIT_ARCHETYPE_POOL } from './archetypes/index.js'; +import { buildCrewMember } from './archetypes/index.js'; +import { + buildCrewMemberFromRoll, + buildCrewMemberFromRollForArchetype, + CREW_STAT_ANCHORS, + type CrewStatAnchor, +} from './crewStatRoll.js'; +import { + SCOREABLE_ARCHETYPES, + SCOREABLE_ARCHETYPE_IDS, + type ArchetypeReward, +} from './archetypeRewards.js'; import { CONTRACT_LEXICON, Curator, contractRequiresCyberspace } from './hub/Curator.js'; import { OBJECTIVES } from './hub/Curator.js'; import { Terminal } from './hub/Terminal.js'; @@ -44,7 +55,7 @@ import { SCOREABLE_ITEM_IDS, type Item, } from './items.js'; -import { OUTCOME, Run } from './Run.js'; +import { OUTCOME, Run, type CrewArchetypeId } from './Run.js'; import { generateSiteId, mergeSiteDeltas as mergeDeltas, @@ -130,16 +141,40 @@ const SYNTHETIC_SCORE_TARGET_DIFFICULTY = CONTRACT_DIFFICULTY.CRITICAL; const SCORE_PAYLOAD_SALT = 0x5c07e; /** - * Deterministically pick the unacquired scoreable blueprint this Score targets. - * Draws from {@link SCOREABLE_ITEMS} minus the meta-crew's already-stolen ids; - * a retired/forward-version id in `acquiredIds` simply isn't in the pool, so it - * is never rolled. Returns `null` when the pool is exhausted (every blueprint - * stolen) β€” at which point the Score falls back to an abstract credit payload - * via {@link pickAbstractScorePayload} (P3.M6.5). + * P3.5.M7: a completed Score draws exactly one reward from a single merged + * pool β€” an item blueprint or an archetype, never both. `kind` discriminates + * which catalog the draw landed in; the settlement path (`onJobEnd`) writes + * to exactly one of `meta.scoreUnlockedItemId` / `meta.scoreUnlockedArchetypeId` + * accordingly. */ -function pickScorePayload(seed: number, acquiredIds: readonly string[]): Item | null { - const acquired = new Set(acquiredIds); - const pool = SCOREABLE_ITEMS.filter(item => !acquired.has(item.id)); +export type ScorePayload = + | { kind: 'item'; item: Item } + | { kind: 'archetype'; reward: ArchetypeReward }; + +/** + * Deterministically pick the unacquired reward this Score targets. Draws from + * a pool merging {@link SCOREABLE_ITEMS} and {@link SCOREABLE_ARCHETYPES}, + * each minus the meta-crew's already-acquired ids; a retired/forward-version + * id in either `acquired*Ids` simply isn't in the pool, so it is never + * rolled. Returns `null` only when *both* catalogs are exhausted β€” at which + * point the Score falls back to an abstract credit payload via + * {@link pickAbstractScorePayload} (P3.M6.5). (P3.5.M7: item-only exhaustion + * previously triggered this fallback; now both catalogs must be empty.) + */ +function pickScorePayload( + seed: number, + acquiredItemIds: readonly string[], + acquiredArchetypeIds: readonly string[] +): ScorePayload | null { + const acquiredItems = new Set(acquiredItemIds); + const acquiredArchetypes = new Set(acquiredArchetypeIds); + const items: ScorePayload[] = SCOREABLE_ITEMS.filter(item => !acquiredItems.has(item.id)).map( + item => ({ kind: 'item', item }) + ); + const archetypes: ScorePayload[] = SCOREABLE_ARCHETYPES.filter( + reward => !acquiredArchetypes.has(reward.id) + ).map(reward => ({ kind: 'archetype', reward })); + const pool = [...items, ...archetypes]; if (pool.length === 0) return null; const rng = new Rng(((seed >>> 0) ^ SCORE_PAYLOAD_SALT) >>> 0); return pool[Math.floor(rng.next() * pool.length)] ?? null; @@ -203,19 +238,33 @@ function pickAbstractScorePayload(seed: number): AbstractScoreTarget { return target; } -/** Read the embedded heist payload id from a completed Score contract (or `null`). */ +/** Read the embedded heist item payload id from a completed Score contract (or `null`). */ function scorePayloadItemId(contract: Contract | null): string | null { const raw = contract?.objective?.params?.scoreItemId; return typeof raw === 'string' && SCOREABLE_ITEM_IDS.has(raw) ? raw : null; } +/** P3.5.M7: read the embedded archetype-reward payload id from a completed Score contract (or `null`). */ +function scorePayloadArchetypeId(contract: Contract | null): string | null { + const raw = contract?.objective?.params?.scoreArchetypeId; + return typeof raw === 'string' && SCOREABLE_ARCHETYPE_IDS.has(raw as CrewArchetypeId) + ? raw + : null; +} + export const CAMPAIGN_STATE = Object.freeze({ HUB: 'HUB', COMBAT: 'COMBAT', ENDED: 'ENDED', }); -const STARTER_ARCHETYPES = Object.freeze(['merc', 'razor', 'tech']); +/** + * P3.5.M6: number of crew members `buildCrew` generates for a fresh + * campaign. No longer a fixed `[merc, razor, tech]` triple β€” each slot now + * rolls stats and derives its own archetype (`crewStatRoll.ts`), so + * duplicates are a legal, if unlucky, outcome. + */ +const STARTER_CREW_COUNT = 3; export type CampaignState = (typeof CAMPAIGN_STATE)[keyof typeof CAMPAIGN_STATE]; // `expandedCatalog` and `betterContracts` were removed in P2.5.M5.1 β€” Rep @@ -264,6 +313,25 @@ export type CampaignOptions = { pendingChronicleRun?: unknown; onPersist?: unknown; onResult?: unknown; + /** + * P3.5.M7: archetype ids the meta-crew has unlocked via Score wins + * (`DataStore.unlockedArchetypes`), captured once at construction β€” a + * completed Score both grants exactly one reward *and* ends the campaign + * in the same step, so this can never change mid-campaign. Omitted (vs. + * an explicit array) is a distinct, permissive "ungated" state used by + * bare engine/test construction β€” see `Campaign.#crewStatAnchors`. + */ + unlockedArchetypeIds?: unknown; + /** + * Showcase-slot follow-up to P3.5.M7 (2026-07-14): an archetype id, if + * any, `generateInitialCandidates` should reserve crew candidate slot 0 + * for β€” the first campaign start after that archetype's Score unlock. + * Captured once at construction, same lifecycle moment as + * `unlockedArchetypeIds`. `undefined`/omitted or `null` β†’ no showcase + * (both accepted β€” `DataStore.pendingArchetypeShowcase` naturally yields + * `null`, not `undefined`, when nothing is pending). + */ + showcaseArchetypeId?: unknown; }; type CampaignLike = { @@ -318,19 +386,32 @@ export function willEndCampaignAfterResult( ); } -export function buildCrew(rng: Rng): Crew[] { +/** + * Build the starter crew for a fresh campaign. P3.5.M6: every slot rolls + * core stats and derives its archetype from the result + * (`buildCrewMemberFromRoll`) β€” no fixed one-of-each guarantee, no weighted + * pool. Duplicate archetypes are a legal roll outcome. + */ +export function buildCrew( + rng: Rng, + anchors: readonly CrewStatAnchor[] = CREW_STAT_ANCHORS +): Crew[] { if (!rng || typeof rng.pick !== 'function') { throw new TypeError('buildCrew requires an Rng'); } const usedCallsigns = new Set(); - return STARTER_ARCHETYPES.map(archetypeId => { - const member = buildCrewMember(archetypeId, { x: 0, y: 0 }, rng, { - id: `crew-${archetypeId}`, - excludeCallsigns: usedCallsigns, - }); + const crew: Crew[] = []; + for (let i = 0; i < STARTER_CREW_COUNT; i++) { + const member = buildCrewMemberFromRoll( + { x: 0, y: 0 }, + rng, + { id: `crew-${i}`, excludeCallsigns: usedCallsigns }, + anchors + ); if (member.callsign) usedCallsigns.add(member.callsign); - return member; - }); + crew.push(member); + } + return crew; } export class Campaign { @@ -380,6 +461,20 @@ export class Campaign { /** Set by the latest `enterHub` when a reveal message fired; shell reads and clears. */ lastHubReveal: HubRevealMessage | null; exitTile: GridPoint | null; + /** + * P3.5.M7: archetype ids unlocked via Score wins, captured once at + * construction (see `CampaignOptions.unlockedArchetypeIds`). `null` = + * ungated (bare engine/test construction reaches all six non-Decker + * archetypes, preserving pre-M7 behavior); a real array (possibly empty) + * gates `buildCrew`/`generateRecruits`/`generateInitialCandidates` via + * `#crewStatAnchors()`. + */ + readonly unlockedArchetypeIds: readonly string[] | null; + /** + * Showcase-slot follow-up to P3.5.M7 (2026-07-14). See + * `CampaignOptions.showcaseArchetypeId`. `null` = no showcase. + */ + readonly showcaseArchetypeId: string | null; constructor({ id, @@ -399,6 +494,8 @@ export class Campaign { pendingChronicleRun, onPersist, onResult, + unlockedArchetypeIds, + showcaseArchetypeId, }: CampaignOptions = {}) { if (typeof seed !== 'number' || !Number.isFinite(seed)) { throw new TypeError(`Campaign requires a finite numeric seed, got ${seed}`); @@ -406,6 +503,24 @@ export class Campaign { if (crew !== undefined && !Array.isArray(crew)) { throw new TypeError('Campaign: crew must be an array when supplied'); } + if ( + showcaseArchetypeId !== undefined && + showcaseArchetypeId !== null && + (typeof showcaseArchetypeId !== 'string' || showcaseArchetypeId.length === 0) + ) { + throw new TypeError( + `Campaign: showcaseArchetypeId must be a non-empty string, null, or undefined, got ${showcaseArchetypeId}` + ); + } + if ( + unlockedArchetypeIds !== undefined && + (!Array.isArray(unlockedArchetypeIds) || + !unlockedArchetypeIds.every(id => typeof id === 'string')) + ) { + throw new TypeError( + 'Campaign: unlockedArchetypeIds must be an array of strings when supplied' + ); + } // Salvage is a TypedSalvage wallet (pre-P2.5.M4.2 saves stored a number). // `migrateSalvage` accepts either a legacy non-negative integer (bucketed // into scrap) or a valid TypedSalvage. Anything else crashes. @@ -448,7 +563,13 @@ export class Campaign { this.id = id ?? makeCampaignId(seed); this.seed = seed >>> 0; this.rng = new Rng(this.seed); - this.crew = (crew as Crew[] | undefined) ?? buildCrew(this.rng); + this.unlockedArchetypeIds = + unlockedArchetypeIds === undefined ? null : (unlockedArchetypeIds as string[]); + this.showcaseArchetypeId = + showcaseArchetypeId === undefined || showcaseArchetypeId === null + ? null + : (showcaseArchetypeId as string); + this.crew = (crew as Crew[] | undefined) ?? buildCrew(this.rng, this.#crewStatAnchors()); this.salvage = salvageWallet; this.credits = credits; this.rep = rep; @@ -847,12 +968,15 @@ export class Campaign { if (completedScoreRun) { this.arc.scoreCompleted = true; this.arc.arcStage = 'score'; - // P3.M6.4: record the stolen blueprint so settlement writes it to the - // cross-campaign meta-store. Persisted on `meta` (alongside `scorePartial`) - // so a refresh after completion still surfaces the unlock to the shell β€” - // the actual `DataStore.archiveScoreableItem` write is idempotent. - const payloadId = scorePayloadItemId(activeContract); - if (payloadId) this.meta.scoreUnlockedItemId = payloadId; + // P3.M6.4 / P3.5.M7: record exactly one of the drawn reward's two kinds + // so settlement writes it to the cross-campaign meta-store. Persisted on + // `meta` (alongside `scorePartial`) so a refresh after completion still + // surfaces the unlock to the shell β€” the actual `DataStore.archive*` + // write is idempotent. Never both β€” one Score, one reward. + const payloadItemId = scorePayloadItemId(activeContract); + const payloadArchetypeId = scorePayloadArchetypeId(activeContract); + if (payloadItemId) this.meta.scoreUnlockedItemId = payloadItemId; + else if (payloadArchetypeId) this.meta.scoreUnlockedArchetypeId = payloadArchetypeId; this.state = CAMPAIGN_STATE.ENDED; this.#tearDownHubWorld(); this.#persist(); @@ -865,15 +989,30 @@ export class Campaign { /** * The scoreable blueprint id stolen by a completed Score, or `null` if the - * Score isn't complete / drew an abstract payload (P3.M6.4). Read by the shell - * on terminal settlement to write the cross-campaign meta-store. Returns a - * known scoreable id only β€” a stale/foreign meta value is treated as absent. + * Score isn't complete / drew an archetype reward or an abstract payload + * (P3.M6.4). Read by the shell on terminal settlement to write the + * cross-campaign meta-store. Returns a known scoreable id only β€” a + * stale/foreign meta value is treated as absent. */ get scoreUnlockedItemId(): string | null { const raw = this.meta.scoreUnlockedItemId; return typeof raw === 'string' && SCOREABLE_ITEM_IDS.has(raw) ? raw : null; } + /** + * P3.5.M7: the archetype id unlocked by a completed Score, or `null` if the + * Score isn't complete / drew an item or an abstract payload. Mirrors + * {@link scoreUnlockedItemId} β€” read by the shell on terminal settlement to + * write `DataStore.archiveUnlockedArchetype`. Returns a known scoreable + * archetype id only. + */ + get scoreUnlockedArchetypeId(): string | null { + const raw = this.meta.scoreUnlockedArchetypeId; + return typeof raw === 'string' && SCOREABLE_ARCHETYPE_IDS.has(raw as CrewArchetypeId) + ? raw + : null; + } + canAttemptScore(): boolean { if (this.state !== CAMPAIGN_STATE.HUB) return false; if (this.arc.arcStage !== 'act-3') return false; @@ -885,18 +1024,26 @@ export class Campaign { /** * Build the climactic Score contract. P3.M6.4: the heist targets a specific - * unacquired scoreable blueprint (drawn deterministically from the seed minus - * the meta-crew's already-stolen ids), and the briefing frames the site around - * that prototype. The chosen id rides along in `objective.params.scoreItemId` - * so the completion path can unlock it. An exhausted pool (every blueprint - * stolen) draws an abstract credit payload instead (P3.M6.5): the briefing is - * framed from {@link ABSTRACT_SCORE_TARGETS} but carries no `scoreItemId`, so a - * clean completion writes nothing to the meta-store. + * unacquired reward (drawn deterministically from the seed minus the + * meta-crew's already-acquired ids), and the briefing frames the site around + * it. P3.5.M7: the draw is a single merged pool of item blueprints and + * archetype rewards β€” the chosen payload rides along in + * `objective.params.scoreItemId` (item) or `objective.params.scoreArchetypeId` + * (archetype), never both, so the completion path can unlock the right one. + * An exhausted pool (*both* catalogs fully acquired) draws an abstract + * credit payload instead (P3.M6.5): the briefing is framed from + * {@link ABSTRACT_SCORE_TARGETS} but carries neither param, so a clean + * completion writes nothing to the meta-store. * * @param unlockedScoreableIds β€” acquired blueprint ids from the meta-store * (`DataStore.unlockedScoreableItems`); these are excluded from the draw. + * @param unlockedArchetypeIds β€” acquired archetype-reward ids from the + * meta-store (`DataStore.unlockedArchetypes`); these are excluded too. */ - buildScoreContract(unlockedScoreableIds: readonly string[] = []): Contract { + buildScoreContract( + unlockedScoreableIds: readonly string[] = [], + unlockedArchetypeIds: readonly string[] = [] + ): Contract { if (!this.canAttemptScore()) { throw new Error('Campaign.buildScoreContract: Score is not available'); } @@ -920,13 +1067,17 @@ export class Campaign { .replace(/^\/\//, '') .replace(/\s+-\s+Score target$/i, '') .trim(); - const payload = pickScorePayload(seed, unlockedScoreableIds); + const payload = pickScorePayload(seed, unlockedScoreableIds, unlockedArchetypeIds); const abstract = payload ? null : pickAbstractScorePayload(seed); - const briefing = payload - ? `${payload.flavor} Their prototype is held at ${siteLabel}. Breach the facility, ` + - `secure the ${payload.label}, and extract with the crew alive.` - : `${abstract!.flavor} The ${abstract!.label} sits in ${siteLabel}. Breach the facility, ` + - `clean it out, and extract with the crew alive.`; + const briefing = + payload?.kind === 'item' + ? `${payload.item.flavor} Their prototype is held at ${siteLabel}. Breach the facility, ` + + `secure the ${payload.item.label}, and extract with the crew alive.` + : payload?.kind === 'archetype' + ? `${payload.reward.flavor} The tech is held at ${siteLabel}. Breach the facility, ` + + `secure the ${payload.reward.label}, and extract with the crew alive.` + : `${abstract!.flavor} The ${abstract!.label} sits in ${siteLabel}. Breach the facility, ` + + `clean it out, and extract with the crew alive.`; return { seed, mapWidth: target.mapWidth, @@ -939,7 +1090,8 @@ export class Campaign { requiresCyberspace: true, count: 1, doorId, - ...(payload ? { scoreItemId: payload.id } : {}), + ...(payload?.kind === 'item' ? { scoreItemId: payload.item.id } : {}), + ...(payload?.kind === 'archetype' ? { scoreArchetypeId: payload.reward.id } : {}), }, }, difficulty: CONTRACT_DIFFICULTY.CRITICAL, @@ -1039,6 +1191,25 @@ export class Campaign { // ─── Recruitment ────────────────────────────────────────────────────────── + /** + * P3.5.M7: the anchor table `buildCrew`/`generateRecruits`/ + * `generateInitialCandidates` roll against. `unlockedArchetypeIds === null` + * (bare engine/test construction, no live meta-store threaded in) is + * ungated β€” the full six-archetype `CREW_STAT_ANCHORS`. Otherwise, a + * locked archetype's anchor is simply absent from the search: every roll + * that would've landed there saturates to its nearest *unlocked* + * neighbor, the same mechanism `deriveArchetype` already uses for rolls + * overrunning the anchor hull (M6) β€” no new derivation logic here. + */ + #crewStatAnchors(): readonly CrewStatAnchor[] { + if (this.unlockedArchetypeIds === null) return CREW_STAT_ANCHORS; + const unlocked = this.unlockedArchetypeIds; + return CREW_STAT_ANCHORS.filter( + anchor => + !SCOREABLE_ARCHETYPE_IDS.has(anchor.archetype) || unlocked.includes(anchor.archetype) + ); + } + /** * Collect every callsign ever used by any crew member (living or flatlined) * and any current recruit candidate. Prevents callsign recycling within a @@ -1076,12 +1247,15 @@ export class Campaign { : this.rng.intRange(RECRUIT.POOL_MIN, RECRUIT.POOL_MAX + 1); const usedCallsigns = this.allUsedCallsigns(); const recruits: Crew[] = []; + const anchors = this.#crewStatAnchors(); for (let i = 0; i < count; i++) { - const archetypeId = this.rng.pick(RECRUIT_ARCHETYPE_POOL as unknown as string[]); - const recruit = buildCrewMember(archetypeId, { x: 0, y: 0 }, this.rng, { - id: `recruit-${i}-${this.rng.intRange(0, 0xffff)}`, - excludeCallsigns: usedCallsigns, - }); + const idSuffix = this.rng.intRange(0, 0xffff); + const recruit = buildCrewMemberFromRoll( + { x: 0, y: 0 }, + this.rng, + { id: `recruit-${i}-${idSuffix}`, excludeCallsigns: usedCallsigns }, + anchors + ); if (recruit.callsign) usedCallsigns.add(recruit.callsign); if (rewardRecruit) this.rewardRecruitIds.add(recruit.id); recruits.push(recruit); @@ -1128,21 +1302,35 @@ export class Campaign { /** * Generate the starter candidate pool for a fresh campaign. Returns - * `RECRUIT.INITIAL_CANDIDATES` (3) candidates with weighted archetype - * distribution (2 Merc / 2 Razor / 1 Tech / 1 Berserk / 1 Adept). Stores - * them on `initialCandidates` for + * `RECRUIT.INITIAL_CANDIDATES` (3) candidates, each rolled and derived + * independently (P3.5.M6: `buildCrewMemberFromRoll`) β€” no weighted pool, + * duplicates allowed. Stores them on `initialCandidates` for * `recruitInitial()` to consume. Does NOT require Rep gate β€” this is * the campaign-start exception. + * + * Showcase-slot follow-up (2026-07-14): when `showcaseArchetypeId` is set + * and still reachable under the current gating, candidate slot 0 is + * reserved for that archetype (`#buildShowcaseCandidate`) β€” the remaining + * slots roll normally. This guarantees the first campaign start after an + * archetype's Score unlock actually offers it, rather than leaving it to + * the ordinary roll odds. */ generateInitialCandidates(): Crew[] { const usedCallsigns = this.allUsedCallsigns(); + const anchors = this.#crewStatAnchors(); const candidates: Crew[] = []; - for (let i = 0; i < RECRUIT.INITIAL_CANDIDATES; i++) { - const archetypeId = this.rng.pick(RECRUIT_ARCHETYPE_POOL as unknown as string[]); - const candidate = buildCrewMember(archetypeId, { x: 0, y: 0 }, this.rng, { - id: `crew-init-${i}`, - excludeCallsigns: usedCallsigns, - }); + const showcase = this.#buildShowcaseCandidate(usedCallsigns, anchors); + if (showcase) { + candidates.push(showcase); + if (showcase.callsign) usedCallsigns.add(showcase.callsign); + } + for (let i = candidates.length; i < RECRUIT.INITIAL_CANDIDATES; i++) { + const candidate = buildCrewMemberFromRoll( + { x: 0, y: 0 }, + this.rng, + { id: `crew-init-${i}`, excludeCallsigns: usedCallsigns }, + anchors + ); if (candidate.callsign) usedCallsigns.add(candidate.callsign); candidates.push(candidate); } @@ -1150,6 +1338,31 @@ export class Campaign { return candidates; } + /** + * Build the showcase candidate (slot 0) for `generateInitialCandidates`, + * or `null` when there's nothing to showcase. Defensive: returns `null` + * (rather than throwing) if `showcaseArchetypeId` isn't actually reachable + * under `anchors` β€” should never happen in the live shell, since + * `DataStore.archiveUnlockedArchetype` arms the showcase atomically with + * the unlock itself, but a stale/hand-edited save shouldn't be able to + * crash campaign-start recruitment over a cosmetic nicety. + */ + #buildShowcaseCandidate( + excludeCallsigns: Set, + anchors: readonly CrewStatAnchor[] + ): Crew | null { + const archetypeId = this.showcaseArchetypeId; + if (archetypeId === null) return null; + if (!anchors.some(anchor => anchor.archetype === archetypeId)) return null; + return buildCrewMemberFromRollForArchetype( + { x: 0, y: 0 }, + this.rng, + archetypeId as CrewArchetypeId, + anchors, + { id: 'crew-init-0', excludeCallsigns } + ); + } + /** * Commit the player's initial crew picks. Exactly `RECRUIT.INITIAL_PICKS` * (2) IDs from `initialCandidates` must be provided. Moves selected @@ -1686,6 +1899,11 @@ export class Campaign { const payload = SCOREABLE_ITEMS.find(item => item.id === payloadId); if (payload) return payload.label; } + const archetypeId = scorePayloadArchetypeId(contract); + if (archetypeId) { + const reward = SCOREABLE_ARCHETYPES.find(entry => entry.id === archetypeId); + if (reward) return reward.label; + } return null; } diff --git a/src/game/Crew.ts b/src/game/Crew.ts index d430c01..d70385c 100644 --- a/src/game/Crew.ts +++ b/src/game/Crew.ts @@ -129,6 +129,14 @@ export interface CrewInit extends Omit { flatlined?: boolean; inventory?: Inventory | null; gear?: Gear | null; + /** + * P3.5.M6: rolled (or archetype-default) base ranged hit probability. + * Each archetype constructor supplies its own default when this is + * omitted β€” see `_DEFAULT_HIT_CHANCE` in `constants.ts`. + */ + baseHitChance?: number; + /** P3.5.M6: rolled (or archetype-default) base melee dodge probability. */ + baseDodgeChance?: number; } export class Crew extends Entity { @@ -139,22 +147,49 @@ export class Crew extends Entity { // not a valid archetype; Crew is essentially an abstract base class for all player-controlled entities archetype: string = 'CrewMember'; + /** + * P3.5.M6: backing field for {@link baseHitChance}, settable at + * construction (`CrewInit.baseHitChance`) so `crewStatRoll.ts` can hand a + * rolled value straight to the constructor instead of every archetype + * hard-coding a fixed getter. Each archetype subclass supplies its own + * default via its `super()` call β€” see `_DEFAULT_HIT_CHANCE`. + * Falls back to `BASE_HIT_CHANCE` for a bare `Crew` (tests only; no + * archetype ships without its own default). + */ + #baseHitChance: number; + + /** P3.5.M6: backing field for {@link baseDodgeChance}. See {@link #baseHitChance}. */ + #baseDodgeChance: number; + /** * Base ranged hit probability for this crew member, before gear bonuses. - * Overridden per archetype: Merc 0.8, Tech 0.75, Razor 0.7. Falls back to - * `BASE_HIT_CHANCE` (the universal drone/turret default) so a bare `Crew` - * in tests behaves sensibly. + * Read from the constructor-settable {@link #baseHitChance} field. Berserk + * overrides this getter (not the field) to layer a live Crash penalty on + * top of the stored pristine value β€” see `Berserk.baseHitChance`. */ get baseHitChance(): number { - return BASE_HIT_CHANCE; + return this.#baseHitChance; + } + + /** + * P3.5.M6: the pristine constructor-set value, unaffected by any live + * modifier layered on top of {@link baseHitChance} (e.g. Berserk's Crash + * penalty). `persistence.ts` snapshots *this*, not the live getter β€” so a + * `CampaignCrewSnapshot` taken mid-Crash can't permanently bake a + * transient penalty into the restored baseline. Mirrors how + * `damageReduction` (pristine, persisted) and `effectiveDamageReduction` + * (live, combat/HUD-facing) already split for Berserk's Surge armor bonus. + */ + get pristineBaseHitChance(): number { + return this.#baseHitChance; } /** * Base melee dodge probability for this crew member (before cover bonus). - * Overridden on Razor; other archetypes use {@link DODGE_CHANCE}. + * Read from the constructor-settable {@link #baseDodgeChance} field. */ get baseDodgeChance(): number { - return DODGE_CHANCE; + return this.#baseDodgeChance; } /** @@ -258,6 +293,8 @@ export class Crew extends Entity { flatlined = false, inventory = null, gear = null, + baseHitChance = BASE_HIT_CHANCE, + baseDodgeChance = DODGE_CHANCE, ...rest }: CrewInit) { super({ @@ -270,8 +307,18 @@ export class Crew extends Entity { if (typeof flatlined !== 'boolean') { throw new TypeError(`Crew flatlined must be a boolean, got ${typeof flatlined}`); } + if (typeof baseHitChance !== 'number' || baseHitChance < 0 || baseHitChance > 1) { + throw new RangeError(`Crew baseHitChance must be a number in [0, 1], got ${baseHitChance}`); + } + if (typeof baseDodgeChance !== 'number' || baseDodgeChance < 0 || baseDodgeChance > 1) { + throw new RangeError( + `Crew baseDodgeChance must be a number in [0, 1], got ${baseDodgeChance}` + ); + } this.callsign = callsign; this.flatlined = flatlined; + this.#baseHitChance = baseHitChance; + this.#baseDodgeChance = baseDodgeChance; /** * Inventory β€” `{ salvage: number, consumables: Item[] }` once * `initInventory()` has been called (at job deploy time in `Run`). diff --git a/src/game/archetypeRewards.ts b/src/game/archetypeRewards.ts new file mode 100644 index 0000000..1989ac6 --- /dev/null +++ b/src/game/archetypeRewards.ts @@ -0,0 +1,49 @@ +/** + * Score-unlockable archetype reward catalog (P3.5.M7). + * + * Sibling to `items.ts`'s `SCOREABLE_ITEMS` β€” but an archetype reward has no + * `cost`/`scope`/`needsTarget`, since it isn't a shop purchase: winning it + * unlocks the archetype for every future stat roll (`crewStatRoll.ts`'s + * `deriveArchetype`), campaign-wide, forever. `Campaign.ts`'s `pickScorePayload` + * draws from a pool merging this catalog with `SCOREABLE_ITEMS` β€” a clean Score + * nets either a new item or a new archetype, never both. + * + * `flavor` reads as "what got reverse-engineered" β€” matching the + * `SCOREABLE_ITEMS` convention (e.g. Monoblade's "a monomolecular blade + * schematic") β€” not as a description of the archetype's kit. Chimera's stays + * deliberately ambiguous per that archetype's unresolved human-or-machine + * fiction (see `archetypes/Chimera.ts`). + */ +import type { CrewArchetypeId } from './Run.js'; + +export type ArchetypeReward = { + id: CrewArchetypeId; + label: string; + flavor: string; +}; + +export const SCOREABLE_ARCHETYPES: readonly ArchetypeReward[] = Object.freeze([ + Object.freeze({ + id: 'berserk', + label: 'Combat-Stim Rig', + flavor: + 'A black-clinic surge-stim rig, seized mid-titration β€” the crash it demands is baked into the chemistry.', + }), + Object.freeze({ + id: 'adept', + label: 'Psychic Interface Cradle', + flavor: + 'A cortical interface cradle for directed will-projection, still humming with someone else’s trained discipline.', + }), + Object.freeze({ + id: 'chimera', + label: 'Nanite Culture Sample', + flavor: + 'A sealed nanite culture sample β€” self-sustaining, self-replicating, and utterly silent on whether it was ever meant for a human host.', + }), +]); + +/** Frozen id set for the archetype-reward pool β€” O(1) membership checks. */ +export const SCOREABLE_ARCHETYPE_IDS: ReadonlySet = Object.freeze( + new Set(SCOREABLE_ARCHETYPES.map(reward => reward.id)) +) as ReadonlySet; diff --git a/src/game/archetypeShowcase.ts b/src/game/archetypeShowcase.ts new file mode 100644 index 0000000..8178d83 --- /dev/null +++ b/src/game/archetypeShowcase.ts @@ -0,0 +1,29 @@ +/** + * "Showcase slot" follow-up to P3.5.M7 (added 2026-07-14). + * + * The first new campaign started after an archetype unlock reserves crew + * candidate slot 0 for an instance of that archetype (`Campaign. + * generateInitialCandidates`), so a clean Score win is immediately + * followed by a guaranteed chance to try what was just unlocked β€” not a + * "maybe, eventually" the ordinary roll-then-derive pipeline would give. + * + * `pendingArchetypeShowcase` is a single nullable id (not a list, unlike + * `unlockedArchetypes`) β€” at most one showcase can ever be pending at a + * time, since the only way to unlock an archetype (winning a Score) always + * ends the campaign in the same step, so there is always a "next campaign + * start" between any two unlocks in normal play. + */ + +/** + * Validate and normalize a persisted `pendingArchetypeShowcase` value. + * + * - `undefined` or `null` (key absent, or explicitly cleared) β†’ `null`. + * - Anything other than a non-empty string β†’ throw. + */ +export function normalizePendingArchetypeShowcase(value: unknown): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== 'string' || value.length === 0) { + throw new TypeError('pendingArchetypeShowcase must be a non-empty string or null'); + } + return value; +} diff --git a/src/game/archetypeUnlocks.ts b/src/game/archetypeUnlocks.ts new file mode 100644 index 0000000..3c5f0a5 --- /dev/null +++ b/src/game/archetypeUnlocks.ts @@ -0,0 +1,64 @@ +/** + * Meta-progression store helpers for archetype unlocks (P3.5.M7). + * + * `unlockedArchetypes` is a cross-campaign record of which of the three + * P3.5-gated archetypes (Berserk, Adept, Chimera) the meta-crew has + * unlocked via a clean Score win. It is an ordered list of archetype ids + * (acquisition order, newest-last) persisted by `DataStore`, wholly + * independent of `unlockedScoreableItems` β€” nothing grandfathers in from + * item-unlock history (design decision locked 2026-07-13; see + * `docs/phase-3.5-plan.md` Β§ P3.5.M7). + * + * Mirrors `scoreableUnlocks.ts` exactly: same validation contract, same + * "absent β†’ [], malformed β†’ throw, idempotent archive" shape. Per the + * global directive β€” silent fallbacks corrupt data β€” a structurally + * invalid store throws rather than resetting to an empty list. + */ + +/** + * Validate and normalize a persisted `unlockedArchetypes` value. + * + * - `undefined` (key absent β€” every pre-M7 save) β†’ `[]`. + * - Not an array β†’ throw. + * - Any element that is not a non-empty string β†’ throw. + * - De-duplicates, preserving first-seen (acquisition) order. + * + * Returns a fresh array β€” never the caller-owned reference. + */ +export function normalizeUnlockedArchetypes(value: unknown): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) { + throw new TypeError('unlockedArchetypes must be an array'); + } + const seen = new Set(); + const archetypes: string[] = []; + for (const candidate of value) { + if (typeof candidate !== 'string' || candidate.length === 0) { + throw new TypeError('unlockedArchetypes entries must be non-empty strings'); + } + if (seen.has(candidate)) continue; + seen.add(candidate); + archetypes.push(candidate); + } + return archetypes; +} + +/** + * Append a newly-unlocked archetype id to the unlock list. Idempotent: + * archiving an id already present is a no-op (`added: false`) β€” an + * archetype unlocks once. The input list is validated; the id must be a + * non-empty string. Returns a fresh list and whether anything changed. + */ +export function archiveUnlockedArchetype( + list: readonly string[], + id: string +): { list: string[]; added: boolean } { + const normalized = normalizeUnlockedArchetypes(list); + if (typeof id !== 'string' || id.length === 0) { + throw new TypeError('archiveUnlockedArchetype: id must be a non-empty string'); + } + if (normalized.includes(id)) { + return { list: normalized, added: false }; + } + return { list: [...normalized, id], added: true }; +} diff --git a/src/game/archetypes/Adept.ts b/src/game/archetypes/Adept.ts index fdb4479..39097fa 100644 --- a/src/game/archetypes/Adept.ts +++ b/src/game/archetypes/Adept.ts @@ -1,5 +1,6 @@ import { Crew } from '../Crew.js'; import { canInfluence, influenceTarget } from '../mindInfluence.js'; +import { ADEPT_DEFAULT_HIT_CHANCE, ADEPT_DEFAULT_DODGE_CHANCE } from '../constants.js'; import type { CrewInit } from '../Crew.js'; import type { World } from '../World.js'; import type { Entity } from '../Entity.js'; @@ -41,16 +42,13 @@ export const CALLSIGNS = Object.freeze([ export class Adept extends Crew { override archetype = 'Adept'; - override get baseHitChance(): number { - return 0.7; - } - - override get baseDodgeChance(): number { - return 0.2; - } - constructor(props: CrewInit) { - super({ ...props, glyph: '@' }); + super({ + baseHitChance: ADEPT_DEFAULT_HIT_CHANCE, + baseDodgeChance: ADEPT_DEFAULT_DODGE_CHANCE, + ...props, + glyph: '@', + }); } /** diff --git a/src/game/archetypes/Berserk.ts b/src/game/archetypes/Berserk.ts index e682d55..46ffa3a 100644 --- a/src/game/archetypes/Berserk.ts +++ b/src/game/archetypes/Berserk.ts @@ -7,6 +7,8 @@ import { SURGE_AP_BONUS, SURGE_ARMOR_BONUS, SURGE_DAMAGE_BONUS, + BERSERK_DEFAULT_HIT_CHANCE, + BERSERK_DEFAULT_DODGE_CHANCE, } from '../constants.js'; import { canSurge, doSurge } from '../surge.js'; import type { CrewInit } from '../Crew.js'; @@ -30,12 +32,14 @@ export const CALLSIGNS = Object.freeze([ export class Berserk extends Crew { override archetype = 'Berserk'; + /** + * P3.5.M6: `super.baseHitChance` reads the constructor-settable pristine + * value (rolled or `BERSERK_DEFAULT_HIT_CHANCE`) β€” this getter layers the + * live Crash penalty on top without a second mutable field, so expiry and + * restore can never over-/under-correct the stat (see the M3 as-built note). + */ override get baseHitChance(): number { - return 0.78 - (this.hasEffect(STATUS_EFFECT.CRASH) ? CRASH_HIT_PENALTY : 0); - } - - override get baseDodgeChance(): number { - return 0.36; + return super.baseHitChance - (this.hasEffect(STATUS_EFFECT.CRASH) ? CRASH_HIT_PENALTY : 0); } /** @@ -50,7 +54,12 @@ export class Berserk extends Crew { } constructor(props: CrewInit) { - super({ ...props, glyph: '@' }); + super({ + baseHitChance: BERSERK_DEFAULT_HIT_CHANCE, + baseDodgeChance: BERSERK_DEFAULT_DODGE_CHANCE, + ...props, + glyph: '@', + }); } canSurge() { diff --git a/src/game/archetypes/Chimera.ts b/src/game/archetypes/Chimera.ts index ec9ff79..d18bcf6 100644 --- a/src/game/archetypes/Chimera.ts +++ b/src/game/archetypes/Chimera.ts @@ -1,5 +1,6 @@ import { Crew } from '../Crew.js'; import { canConvertScrap, convertScrapToHp } from '../nanoRepair.js'; +import { CHIMERA_DEFAULT_HIT_CHANCE, CHIMERA_DEFAULT_DODGE_CHANCE } from '../constants.js'; import type { CrewInit } from '../Crew.js'; import type { NaniteHealCheck } from '../nanoRepair.js'; @@ -38,16 +39,13 @@ export const CALLSIGNS = Object.freeze([ export class Chimera extends Crew { override archetype = 'Chimera'; - override get baseHitChance(): number { - return 0.75; - } - - override get baseDodgeChance(): number { - return 0.25; - } - constructor(props: CrewInit) { - super({ ...props, glyph: '@' }); + super({ + baseHitChance: CHIMERA_DEFAULT_HIT_CHANCE, + baseDodgeChance: CHIMERA_DEFAULT_DODGE_CHANCE, + ...props, + glyph: '@', + }); } /** diff --git a/src/game/archetypes/Decker.ts b/src/game/archetypes/Decker.ts index 38aaf09..4f83560 100644 --- a/src/game/archetypes/Decker.ts +++ b/src/game/archetypes/Decker.ts @@ -4,6 +4,7 @@ import { DECKER_BASE_ICE_RESISTANCE, DECKER_BASE_INTRUSION, DECKER_BASE_RAM, + DECKER_DEFAULT_HIT_CHANCE, } from '../constants.js'; import type { CrewInit, CrewSnapshot } from '../Crew.js'; import type { World } from '../World.js'; @@ -74,17 +75,13 @@ export class Decker extends Crew { /** Avatar `damageReduction` against ICE (P3.M3.3). */ iceResistance: number; - override get baseHitChance(): number { - return 0.7; - } - constructor({ ram = DECKER_BASE_RAM, intrusionStrength = DECKER_BASE_INTRUSION, iceResistance = DECKER_BASE_ICE_RESISTANCE, ...props }: DeckerInit) { - super({ ...props, glyph: '@' }); + super({ baseHitChance: DECKER_DEFAULT_HIT_CHANCE, ...props, glyph: '@' }); if (!Number.isInteger(ram) || ram <= 0) { throw new RangeError(`Decker ram must be a positive integer, got ${ram}`); } diff --git a/src/game/archetypes/Merc.ts b/src/game/archetypes/Merc.ts index 88f59d6..0149385 100644 --- a/src/game/archetypes/Merc.ts +++ b/src/game/archetypes/Merc.ts @@ -1,5 +1,11 @@ import { Crew } from '../Crew.js'; -import { TILE, AP_COST, FACTION, MERC_RANGED_DAMAGE } from '../constants.js'; +import { + TILE, + AP_COST, + FACTION, + MERC_RANGED_DAMAGE, + MERC_DEFAULT_HIT_CHANCE, +} from '../constants.js'; import { canKnockbackByOffset } from '../knockback.js'; import type { CrewInit } from '../Crew.js'; import type { Entity } from '../Entity.js'; @@ -59,16 +65,13 @@ export const CALLSIGNS = Object.freeze([ */ export class Merc extends Crew { override archetype = 'Merc'; - override get baseHitChance(): number { - return 0.8; - } override get rangedDamage(): number { return MERC_RANGED_DAMAGE; } constructor(props: CrewInit) { - super({ ...props, glyph: '@' }); + super({ baseHitChance: MERC_DEFAULT_HIT_CHANCE, ...props, glyph: '@' }); } canVault(world: World, dx: number, dy: number): VaultCheck { diff --git a/src/game/archetypes/Razor.ts b/src/game/archetypes/Razor.ts index 9a0bf8a..4dfa97b 100644 --- a/src/game/archetypes/Razor.ts +++ b/src/game/archetypes/Razor.ts @@ -1,5 +1,9 @@ import { Crew } from '../Crew.js'; -import { HEAVY_MELEE_DAMAGE } from '../constants.js'; +import { + HEAVY_MELEE_DAMAGE, + RAZOR_DEFAULT_HIT_CHANCE, + RAZOR_DEFAULT_DODGE_CHANCE, +} from '../constants.js'; import { canSlideTwoTiles, slideTwoTiles } from '../slide.js'; import type { CrewInit } from '../Crew.js'; import type { World } from '../World.js'; @@ -53,20 +57,18 @@ export const CALLSIGNS = Object.freeze([ */ export class Razor extends Crew { override archetype = 'Razor'; - override get baseHitChance(): number { - return 0.7; - } - - override get baseDodgeChance(): number { - return 0.35; - } override get meleeDamage(): number { return HEAVY_MELEE_DAMAGE; } constructor(props: CrewInit) { - super({ ...props, glyph: '@' }); + super({ + baseHitChance: RAZOR_DEFAULT_HIT_CHANCE, + baseDodgeChance: RAZOR_DEFAULT_DODGE_CHANCE, + ...props, + glyph: '@', + }); } /** diff --git a/src/game/archetypes/Tech.ts b/src/game/archetypes/Tech.ts index b6dcf21..a6818dd 100644 --- a/src/game/archetypes/Tech.ts +++ b/src/game/archetypes/Tech.ts @@ -1,5 +1,10 @@ import { Crew } from '../Crew.js'; -import { FACTION, AP_COST, SALVAGE_PER_IMPROVISED_TURRET } from '../constants.js'; +import { + FACTION, + AP_COST, + SALVAGE_PER_IMPROVISED_TURRET, + TECH_DEFAULT_HIT_CHANCE, +} from '../constants.js'; import { Turret } from '../Turret.js'; import type { CrewInit, CrewSnapshot } from '../Crew.js'; import type { World } from '../World.js'; @@ -46,15 +51,12 @@ export const CALLSIGNS = Object.freeze([ */ export class Tech extends Crew { override archetype = 'Tech'; - override get baseHitChance(): number { - return 0.75; - } turretReady: boolean; private _improvisedTurretCount: number; constructor(props: CrewInit) { - super({ ...props, glyph: '@' }); + super({ baseHitChance: TECH_DEFAULT_HIT_CHANCE, ...props, glyph: '@' }); this.turretReady = true; /** Counter for unique improvised turret ids within a job. */ this._improvisedTurretCount = 0; diff --git a/src/game/archetypes/index.ts b/src/game/archetypes/index.ts index 636cfc2..2fbe72c 100644 --- a/src/game/archetypes/index.ts +++ b/src/game/archetypes/index.ts @@ -33,37 +33,21 @@ import type { CrewInit } from '../Crew.js'; export type Archetype = Merc | Razor | Tech | Decker | Berserk | Adept | Chimera; /** - * Display order is also the starter crew order in `Campaign.buildCrew`. - * Merc first so new players hit the simpler ranged archetype on first load; - * Tech last since its gadget loop is the most involved kit to learn. + * Legacy starter-selector display order (pre-P3.5.M6, when archetype was a + * direct pick rather than a rolled-and-derived outcome). Still governs any + * UI that lists the "core three" β€” e.g. character-select β€” but no longer + * drives `Campaign.buildCrew`, which now rolls stats for every starter slot + * and derives the archetype from the result (`crewStatRoll.ts`); duplicates + * are a legal roll outcome there. * * The **Decker is deliberately absent** (P3.M2): it is a mid-campaign narrative - * recruit, never a starter pick or selector option. Its metadata still lives in - * `ARCHETYPES`/`BUILDERS` so `buildCrewMember('decker', …)` and snapshot - * round-trips work β€” it just isn't offered through the normal selection paths. + * recruit, never a starter pick or selector option, and β€” per M6 β€” `deriveArchetype` + * never resolves to it either. Its metadata still lives in `ARCHETYPES`/`BUILDERS` + * so `buildCrewMember('decker', …)` and snapshot round-trips work β€” it just isn't + * offered through the normal selection paths. */ export const ARCHETYPE_IDS = Object.freeze(['merc', 'razor', 'tech']); -/** - * Weighted archetype pool for recruitment: 2 Merc / 2 Razor / 1 Tech / 1 - * Berserk / 1 Adept / 1 Chimera. Expressed as a flat array so `rng.pick()` - * gives the correct distribution. This is an interim hand-weighted pool β€” - * P3.5.M6 retires it entirely in favor of rolling core stats first and - * deriving the archetype from the result. The Decker is **not** in this - * pool β€” normal random recruitment must never roll one; it joins only - * through the Act-2 narrative beat (P3.M2 / P3.M1). - */ -export const RECRUIT_ARCHETYPE_POOL = Object.freeze([ - 'merc', - 'merc', - 'razor', - 'razor', - 'tech', - 'berserk', - 'adept', - 'chimera', -]); - /** * All three archetypes share a single perk key (`x`) β€” the keymap collapses * vault/slide/deploy into one `MODE.AIM` / `aimKind: 'special'` flow that dispatches by @@ -224,14 +208,17 @@ export function pickCallsign(archetypeId: string, rng: Rng, excludeCallsigns = n * Set so callers (`Campaign.buildCrew`, `Campaign.generateRecruits`) can dedupe * against campaign history. */ -type BuildCrewMemberOptions = { +export type BuildCrewMemberOptions = { excludeCallsigns?: Set; id?: string; maxAp?: number; maxHp?: number; faction?: FactionId; + /** P3.5.M6: rolled base stats, threaded straight to the archetype constructor. */ + baseHitChance?: number; + baseDodgeChance?: number; }; -type BuildCrewMemberSpawn = { +export type BuildCrewMemberSpawn = { x: number; y: number; maxAp?: number; @@ -269,5 +256,7 @@ export function buildCrewMember( }; if (spawn.maxAp !== undefined) props.maxAp = spawn.maxAp; if (spawn.maxHp !== undefined) props.maxHp = spawn.maxHp; + if (options.baseHitChance !== undefined) props.baseHitChance = options.baseHitChance; + if (options.baseDodgeChance !== undefined) props.baseDodgeChance = options.baseDodgeChance; return new Ctor(props); } diff --git a/src/game/campaignSummary.ts b/src/game/campaignSummary.ts index 4ed8d2d..6997f89 100644 --- a/src/game/campaignSummary.ts +++ b/src/game/campaignSummary.ts @@ -1,5 +1,6 @@ import type { CampaignEndReason } from '../types.js'; import { SCOREABLE_ITEMS } from './items.js'; +import { SCOREABLE_ARCHETYPES } from './archetypeRewards.js'; export const CAMPAIGN_HISTORY_CAP = 50; @@ -12,10 +13,12 @@ export type CampaignSummaryCrew = { }; /** - * Blueprint stolen by a winning Score (P3.M6.4), captured self-contained - * (id + display copy) so the record reads correctly forever even if the catalog - * changes. Absent on losses, partials, and abstract (exhausted-pool) Scores. - * Surfaced on the win screen now and the Chronicle in M7. + * Reward won by a winning Score (P3.M6.4; P3.5.M7 extends the draw to + * archetypes too), captured self-contained (id + display copy) so the record + * reads correctly forever even if the catalog changes. Absent on losses, + * partials, and abstract (exhausted-pool) Scores. Item and archetype rewards + * share this shape β€” there's no `kind` discriminator, since the win + * screen/Chronicle render both identically (id/label/flavor). */ export type CampaignSummaryScoreReward = { id: string; @@ -47,6 +50,8 @@ type EndedCampaignLike = { credits: number; /** The stolen blueprint id (P3.M6.4); resolved to display copy in the summary. */ scoreUnlockedItemId?: string | null; + /** The unlocked archetype id (P3.5.M7); resolved to display copy in the summary. */ + scoreUnlockedArchetypeId?: string | null; crew: Array<{ id: string; callsign: string | null; @@ -55,12 +60,25 @@ type EndedCampaignLike = { }>; }; -/** Resolve a stolen-blueprint id to a self-contained summary reward (or undefined). */ -function resolveScoreReward(id: string | null | undefined): CampaignSummaryScoreReward | undefined { - if (typeof id !== 'string') return undefined; - const item = SCOREABLE_ITEMS.find(entry => entry.id === id); - if (!item) return undefined; - return { id: item.id, label: item.label, flavor: item.flavor ?? '' }; +/** + * Resolve a completed Score's drawn reward to a self-contained summary + * record (or undefined). At most one of `itemId`/`archetypeId` is ever + * non-null β€” `Campaign.onJobEnd` writes exactly one of the two meta fields + * per Score (P3.5.M7). + */ +function resolveScoreReward( + itemId: string | null | undefined, + archetypeId: string | null | undefined +): CampaignSummaryScoreReward | undefined { + if (typeof itemId === 'string') { + const item = SCOREABLE_ITEMS.find(entry => entry.id === itemId); + if (item) return { id: item.id, label: item.label, flavor: item.flavor ?? '' }; + } + if (typeof archetypeId === 'string') { + const reward = SCOREABLE_ARCHETYPES.find(entry => entry.id === archetypeId); + if (reward) return { id: reward.id, label: reward.label, flavor: reward.flavor }; + } + return undefined; } const END_REASONS: readonly CampaignEndReason[] = [ @@ -85,7 +103,10 @@ export function buildCampaignSummary( throw new Error('buildCampaignSummary requires a campaign end reason'); } - const scoreReward = resolveScoreReward(campaign.scoreUnlockedItemId); + const scoreReward = resolveScoreReward( + campaign.scoreUnlockedItemId, + campaign.scoreUnlockedArchetypeId + ); return validateCampaignSummary({ campaignId: campaign.id, completedAt, diff --git a/src/game/constants.ts b/src/game/constants.ts index 8832b79..5a36ff4 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -239,6 +239,29 @@ export const MELEE_DAMAGE = 2; /** Razor blade and elite corp strike β€” overrides {@link MELEE_DAMAGE}. */ export const HEAVY_MELEE_DAMAGE = 3; +/** + * Archetype base hit/dodge defaults (P3.5.M6). Used two ways: (1) each + * archetype constructor's un-rolled default when no `baseHitChance`/ + * `baseDodgeChance` override is supplied, and (2) `crewStatRoll.ts`'s + * `DEFAULT_*_BY_ARCHETYPE` old-save fallback for `CampaignCrewSnapshot` + * records written before P3.5.M6. These are a *separate* table from + * `CREW_STAT_ANCHORS` (`crewStatRoll.ts`) β€” the anchors are tuned only for + * an even roll-classification partition, while Merc/Razor/Tech here stay + * frozen at their pre-P3.5 shipped values so an old save never silently + * regenerates stats it never had. + */ +export const MERC_DEFAULT_HIT_CHANCE = 0.8; +export const RAZOR_DEFAULT_HIT_CHANCE = 0.7; +export const RAZOR_DEFAULT_DODGE_CHANCE = 0.35; +export const TECH_DEFAULT_HIT_CHANCE = 0.75; +export const DECKER_DEFAULT_HIT_CHANCE = 0.7; +export const BERSERK_DEFAULT_HIT_CHANCE = 0.78; +export const BERSERK_DEFAULT_DODGE_CHANCE = 0.36; +export const ADEPT_DEFAULT_HIT_CHANCE = 0.7; +export const ADEPT_DEFAULT_DODGE_CHANCE = 0.2; +export const CHIMERA_DEFAULT_HIT_CHANCE = 0.75; +export const CHIMERA_DEFAULT_DODGE_CHANCE = 0.25; + /** * Vault (Merc perk). Breach-and-clear slam in two modes: * - **Hop:** vault over cover; body-check a hostile on the landing tile. @@ -670,8 +693,11 @@ export const REP = Object.freeze({ }); /** - * Recruitment parameters. Controls candidate pool size, campaign-start picks, - * and archetype weight distribution. + * Recruitment parameters. Controls candidate pool size and campaign-start + * picks. Archetype is no longer chosen by weighted pool (P3.5.M6 retired + * `RECRUIT_ARCHETYPE_POOL`) β€” see `crewStatRoll.ts` for the roll-then-derive + * pipeline every non-Decker crew member (starter trio and mid-campaign + * recruits alike) now goes through. */ export const RECRUIT = Object.freeze({ /** Mid-campaign: minimum recruits offered per hub visit (when Rep gate met). */ @@ -684,6 +710,23 @@ export const RECRUIT = Object.freeze({ INITIAL_PICKS: 2, }); +/** + * P3.5.M6: crew stat roll ranges β€” continuous and deliberately wider than the + * old fixed per-archetype stats, so every rolled operative reads as distinct + * rather than clustering onto a handful of identical stat lines. Rolled + * floats are rounded to 0.01 (`crewStatRoll.ts`) so the derivation domain + * stays finite/enumerable and the HUD reads clean whole percents. The ranges + * deliberately overrun `CREW_STAT_ANCHORS`' hull (hit 0.67–0.83, dodge + * 0.19–0.36) β€” outer-margin rolls saturate to the nearest corner archetype + * rather than dead-zoning (see `deriveArchetype`). + */ +export const CREW_HIT_CHANCE_ROLL_MIN = 0.65; +export const CREW_HIT_CHANCE_ROLL_MAX = 0.85; +export const CREW_DODGE_CHANCE_ROLL_MIN = 0.15; +export const CREW_DODGE_CHANCE_ROLL_MAX = 0.4; +/** Conservative β€” armor is a wholly new variance axis with no prior balance data. */ +export const CREW_ARMOR_ROLL_CHANCE = 0.15; + /** * Rep tier definitions β€” each tier carries a label, a lower bound, and a * difficulty pool that the Curator uses when rolling contracts. Ordered from diff --git a/src/game/crewStatRoll.ts b/src/game/crewStatRoll.ts new file mode 100644 index 0000000..1befd9d --- /dev/null +++ b/src/game/crewStatRoll.ts @@ -0,0 +1,264 @@ +/** + * P3.5.M6: roll-then-derive crew stat generation. + * + * Core stats (hit chance, dodge chance, armor) are rolled first; the + * archetype is derived from the resulting (hitChance, dodgeChance) point via + * nearest-anchor classification. Replaces the old "pick an archetype, get + * fixed stats" flow (`RECRUIT_ARCHETYPE_POOL`, retired this milestone). See + * `docs/phase-3.5-plan.md` Β§ P3.5.M6 for the full design rationale β€” in + * particular *why* `CREW_STAT_ANCHORS` is a separate table from each + * archetype's own default base stats (old-save safety: retuning the + * defaults to fix the classification partition would silently restore + * legacy Merc/Razor/Tech saves to stats they never had). + * + * The Decker is deliberately absent from `CREW_STAT_ANCHORS` β€” it stays a + * forced, narrative-only mid-campaign recruit, never rolled. + */ +import { Rng } from '../rng.js'; +import { + buildCrewMember, + type BuildCrewMemberOptions, + type BuildCrewMemberSpawn, +} from './archetypes/index.js'; +import type { Crew } from './Crew.js'; +import type { CrewArchetypeId } from './Run.js'; +import { + CREW_HIT_CHANCE_ROLL_MIN, + CREW_HIT_CHANCE_ROLL_MAX, + CREW_DODGE_CHANCE_ROLL_MIN, + CREW_DODGE_CHANCE_ROLL_MAX, + CREW_ARMOR_ROLL_CHANCE, + DODGE_CHANCE, + MERC_DEFAULT_HIT_CHANCE, + RAZOR_DEFAULT_HIT_CHANCE, + RAZOR_DEFAULT_DODGE_CHANCE, + TECH_DEFAULT_HIT_CHANCE, + DECKER_DEFAULT_HIT_CHANCE, + BERSERK_DEFAULT_HIT_CHANCE, + BERSERK_DEFAULT_DODGE_CHANCE, + ADEPT_DEFAULT_HIT_CHANCE, + ADEPT_DEFAULT_DODGE_CHANCE, + CHIMERA_DEFAULT_HIT_CHANCE, + CHIMERA_DEFAULT_DODGE_CHANCE, +} from './constants.js'; + +export type CrewStatAnchor = { + archetype: CrewArchetypeId; + hitChance: number; + dodgeChance: number; +}; + +/** + * Nearest-anchor classification table (P3.5.M6). Tuned for an even six-way + * partition of the roll domain β€” this is NOT the same table as each + * archetype's default base stats (`DEFAULT_HIT_CHANCE_BY_ARCHETYPE` below). + * Armor plays no role in classification; agility (dodge) is the primary + * spread axis β€” the "fast" pair (Berserk, Razor) owns the high-dodge + * region, Merc sits mid-dodge, and the "slow" trio (Chimera, Tech, Adept) + * fills the low-dodge band separated along the hit axis. + */ +export const CREW_STAT_ANCHORS: readonly CrewStatAnchor[] = Object.freeze([ + { archetype: 'merc', hitChance: 0.83, dodgeChance: 0.27 }, + { archetype: 'berserk', hitChance: 0.78, dodgeChance: 0.36 }, + { archetype: 'razor', hitChance: 0.68, dodgeChance: 0.36 }, + { archetype: 'chimera', hitChance: 0.79, dodgeChance: 0.2 }, + { archetype: 'tech', hitChance: 0.73, dodgeChance: 0.19 }, + { archetype: 'adept', hitChance: 0.67, dodgeChance: 0.2 }, +]); + +/** + * Fixed tie-break priority for exact anchor-distance ties. Deterministic, + * not gameplay-significant β€” Voronoi-boundary points are measure-zero on a + * continuous roll, but the 0.01 rounding grid lands on them often enough + * that `deriveArchetype` needs a documented, stable rule. + */ +const TIE_BREAK_ORDER: readonly CrewArchetypeId[] = [ + 'merc', + 'razor', + 'adept', + 'tech', + 'berserk', + 'chimera', +]; + +/** + * Old-save fallback + each archetype's own un-rolled constructor default. + * Frozen to the pre-P3.5 shipped values for Merc/Razor/Tech/Decker β€” see + * `constants.ts`'s doc comment on `MERC_DEFAULT_HIT_CHANCE` et al. β€” and + * free for the three P3.5-new archetypes (no pre-P3.5 saves exist for them). + * `restoreCrewMember` (`persistence.ts`) reads these when a + * `CampaignCrewSnapshot` predates P3.5.M6 and carries no rolled stats. + */ +export const DEFAULT_HIT_CHANCE_BY_ARCHETYPE: Record = Object.freeze({ + merc: MERC_DEFAULT_HIT_CHANCE, + razor: RAZOR_DEFAULT_HIT_CHANCE, + tech: TECH_DEFAULT_HIT_CHANCE, + decker: DECKER_DEFAULT_HIT_CHANCE, + berserk: BERSERK_DEFAULT_HIT_CHANCE, + adept: ADEPT_DEFAULT_HIT_CHANCE, + chimera: CHIMERA_DEFAULT_HIT_CHANCE, +}); + +export const DEFAULT_DODGE_CHANCE_BY_ARCHETYPE: Record = Object.freeze({ + merc: DODGE_CHANCE, + razor: RAZOR_DEFAULT_DODGE_CHANCE, + tech: DODGE_CHANCE, + decker: DODGE_CHANCE, + berserk: BERSERK_DEFAULT_DODGE_CHANCE, + adept: ADEPT_DEFAULT_DODGE_CHANCE, + chimera: CHIMERA_DEFAULT_DODGE_CHANCE, +}); + +function roundToHundredth(value: number): number { + return Math.round(value * 100) / 100; +} + +export type RolledCrewStats = { + hitChance: number; + dodgeChance: number; + armor: number; +}; + +/** + * Roll core combat stats for a fresh crew member. Continuous uniform rolls, + * rounded to 0.01 so the HUD reads clean percents and `deriveArchetype`'s + * classification domain stays finite and enumerable (21 Γ— 26 = 546 tuples). + * These ranges deliberately overrun `CREW_STAT_ANCHORS`' hull (hit + * 0.67–0.83, dodge 0.19–0.36) β€” see the `constants.ts` doc comment on + * `CREW_HIT_CHANCE_ROLL_MIN` for why that's intentional. + */ +export function rollCrewStats(rng: Rng): RolledCrewStats { + if (!rng || typeof rng.next !== 'function' || typeof rng.chance !== 'function') { + throw new TypeError('rollCrewStats requires an Rng'); + } + const hitChance = roundToHundredth( + CREW_HIT_CHANCE_ROLL_MIN + rng.next() * (CREW_HIT_CHANCE_ROLL_MAX - CREW_HIT_CHANCE_ROLL_MIN) + ); + const dodgeChance = roundToHundredth( + CREW_DODGE_CHANCE_ROLL_MIN + + rng.next() * (CREW_DODGE_CHANCE_ROLL_MAX - CREW_DODGE_CHANCE_ROLL_MIN) + ); + const armor = rng.chance(CREW_ARMOR_ROLL_CHANCE) ? 1 : 0; + return { hitChance, dodgeChance, armor }; +} + +/** + * Classify a rolled (hitChance, dodgeChance) point to the nearest archetype + * anchor by squared Euclidean distance; minimum wins. `anchors` defaults to + * the full six-archetype table; P3.5.M7 passes a lock-filtered subset so a + * locked archetype's anchor is simply absent from the search β€” every roll + * that would've landed there saturates to its nearest *unlocked* neighbor, + * the same mechanism that already handles rolls overrunning the anchor + * hull. Throws on an empty anchor list β€” never silently returns an + * unclassifiable result. + */ +export function deriveArchetype( + stats: { hitChance: number; dodgeChance: number }, + anchors: readonly CrewStatAnchor[] = CREW_STAT_ANCHORS +): CrewArchetypeId { + if (!anchors || anchors.length === 0) { + throw new Error('deriveArchetype: anchors list is empty'); + } + let bestArchetype: CrewArchetypeId | null = null; + let bestDist = Infinity; + let bestPriority = Infinity; + for (const anchor of anchors) { + const dh = stats.hitChance - anchor.hitChance; + const dd = stats.dodgeChance - anchor.dodgeChance; + const dist = dh * dh + dd * dd; + const priority = TIE_BREAK_ORDER.indexOf(anchor.archetype); + if (dist < bestDist || (dist === bestDist && priority < bestPriority)) { + bestDist = dist; + bestArchetype = anchor.archetype; + bestPriority = priority; + } + } + if (bestArchetype === null) { + // Unreachable given the length guard above; keeps the return type honest. + throw new Error('deriveArchetype: no archetype resolved'); + } + return bestArchetype; +} + +/** + * Roll stats, derive the archetype, and construct the crew member in one + * call. Stat rolling goes through `rng.fork('crew-stats')` β€” a substream + * derived from (but not consuming) the caller's `rng` β€” so adding this roll + * doesn't perturb any other roll sequence (callsign pick, combat, …) that + * already reads from `rng` (`rng.ts`'s documented "add a mechanic without + * perturbing other rolls" fork use case). Armor is applied post-construction + * via the already-settable `damageReduction` field β€” it's rolled but is NOT + * a `deriveArchetype` classifier. + */ +export function buildCrewMemberFromRoll( + spawn: BuildCrewMemberSpawn, + rng: Rng, + options: BuildCrewMemberOptions = {}, + anchors: readonly CrewStatAnchor[] = CREW_STAT_ANCHORS +): Crew { + const statsRng = rng.fork('crew-stats'); + const stats = rollCrewStats(statsRng); + const archetypeId = deriveArchetype(stats, anchors); + const member = buildCrewMember(archetypeId, spawn, rng, { + ...options, + baseHitChance: stats.hitChance, + baseDodgeChance: stats.dodgeChance, + }); + member.damageReduction += stats.armor; + return member; +} + +/** Generous crash-over-hang backstop for {@link buildCrewMemberFromRollForArchetype}'s rejection loop β€” not a tuned budget. */ +const FORCED_ARCHETYPE_ROLL_MAX_ATTEMPTS = 2000; + +/** + * Showcase-slot follow-up to P3.5.M7 (added 2026-07-14): roll stats by + * rejection sampling until they classify to `archetypeId` under `anchors`, + * then construct the crew member β€” used to guarantee a specific archetype + * (the first campaign-start candidate pool after that archetype's Score + * unlock reserves slot 0 for it) while still keeping natural roll variance, + * rather than pinning the archetype's exact anchor point every time. + * + * `deriveArchetype`'s classification domain is finite (546 rounded grid + * tuples) and every registered archetype covers a double-digit percentage + * of it (M6's partition guarantee β€” see `crewStatRoll.test.ts`), so this + * converges in a handful of attempts in practice. Throws if `archetypeId` + * has no anchor in the supplied table (e.g. it's actually locked β€” callers + * must check gating themselves; this function does not silently ignore a + * request it cannot satisfy) or if `maxAttempts` is exhausted. + */ +export function buildCrewMemberFromRollForArchetype( + spawn: BuildCrewMemberSpawn, + rng: Rng, + archetypeId: CrewArchetypeId, + anchors: readonly CrewStatAnchor[] = CREW_STAT_ANCHORS, + options: BuildCrewMemberOptions = {}, + maxAttempts: number = FORCED_ARCHETYPE_ROLL_MAX_ATTEMPTS +): Crew { + if (!anchors.some(anchor => anchor.archetype === archetypeId)) { + throw new Error( + `buildCrewMemberFromRollForArchetype: "${archetypeId}" has no anchor in the supplied table` + ); + } + const statsRng = rng.fork('crew-stats'); + let stats: RolledCrewStats | null = null; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const candidate = rollCrewStats(statsRng); + if (deriveArchetype(candidate, anchors) === archetypeId) { + stats = candidate; + break; + } + } + if (!stats) { + throw new Error( + `buildCrewMemberFromRollForArchetype: could not roll "${archetypeId}" in ${maxAttempts} attempts` + ); + } + const member = buildCrewMember(archetypeId, spawn, rng, { + ...options, + baseHitChance: stats.hitChance, + baseDodgeChance: stats.dodgeChance, + }); + member.damageReduction += stats.armor; + return member; +} diff --git a/src/game/persistence.ts b/src/game/persistence.ts index be1b1b9..cd49da1 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -53,6 +53,10 @@ import { Decker } from './archetypes/Decker.js'; import { Berserk } from './archetypes/Berserk.js'; import { Adept } from './archetypes/Adept.js'; import { Chimera } from './archetypes/Chimera.js'; +import { + DEFAULT_HIT_CHANCE_BY_ARCHETYPE, + DEFAULT_DODGE_CHANCE_BY_ARCHETYPE, +} from './crewStatRoll.js'; import { Turret } from './Turret.js'; import { Skirmisher, type SkirmisherProps } from './ai/Skirmisher.js'; import { Guard, type GuardProps } from './ai/Guard.js'; @@ -931,6 +935,12 @@ type RestoreOptions = { type RestoreCampaignOptions = { onPersist?: (campaign: Campaign) => void; onResult?: (result: RunResult) => void; + /** + * P3.5.M7: live archetype-unlock state from `DataStore.unlockedArchetypes` + * at restore time. Omitted β†’ ungated (matches pre-M7 test/API behavior); + * the shell always supplies this from the meta-store on a real load. + */ + unlockedArchetypeIds?: readonly string[]; }; type CampaignCrewSnapshot = { @@ -948,6 +958,13 @@ type CampaignCrewSnapshot = { * only tracks it for cap-clamping. Absent on pre-M6.2 saves β†’ restores to 0. */ damageReduction?: number; + /** + * P3.5.M6: rolled base stats. Absent on pre-P3.5 saves β€” restore to that + * archetype's historical fixed value (`DEFAULT_HIT_CHANCE_BY_ARCHETYPE`) + * instead of silently regenerating a new roll. + */ + baseHitChance?: number; + baseDodgeChance?: number; alive: boolean; inventory: Inventory | null; gear: Gear | null; @@ -1502,6 +1519,7 @@ export function restoreCampaign(record: unknown, options: RestoreCampaignOptions pendingChronicleRun: record.pendingChronicleRun, onPersist: options.onPersist, onResult: options.onResult, + unlockedArchetypeIds: options.unlockedArchetypeIds, }); campaign.rng = new Rng(record.rng.seed); campaign.rng.setState(record.rng.state); @@ -1815,6 +1833,11 @@ function snapshotCrewMember(member: Crew): CampaignCrewSnapshot { ap: member.ap, maxAp: member.maxAp, damageReduction: member.damageReduction, + // P3.5.M6: pristine, not the live `baseHitChance` getter β€” Berserk's + // Crash penalty is transient and must never get baked into a save + // (mirrors how `damageReduction` here is pristine, not `effectiveDamageReduction`). + baseHitChance: member.pristineBaseHitChance, + baseDodgeChance: member.baseDodgeChance, alive: !!member.alive, inventory: member.inventory, gear: member.gear, @@ -1901,6 +1924,10 @@ function restoreCrewMember(rec: CampaignCrewSnapshot): Crew { maxHp: rec.maxHp, maxAp: rec.maxAp, damageReduction: rec.damageReduction ?? 0, + // P3.5.M6: absent on pre-P3.5 saves β€” fall back to that archetype's + // historical fixed value rather than silently regenerating a new roll. + baseHitChance: rec.baseHitChance ?? DEFAULT_HIT_CHANCE_BY_ARCHETYPE[rec.archetype], + baseDodgeChance: rec.baseDodgeChance ?? DEFAULT_DODGE_CHANCE_BY_ARCHETYPE[rec.archetype], // P3.M3.3: Decker cyber stats (validated; throws on a non-decker record). ...readCampaignCrewCyber(rec), }); diff --git a/src/shell/shellRuntime.ts b/src/shell/shellRuntime.ts index 7208bdd..813af6f 100644 --- a/src/shell/shellRuntime.ts +++ b/src/shell/shellRuntime.ts @@ -569,6 +569,12 @@ function startFreshCampaign() { crew: [], onPersist: handlePersist, onResult: handleResult, + unlockedArchetypeIds: dataStore.unlockedArchetypes, + // Showcase-slot follow-up (2026-07-14): peek, don't consume yet β€” the + // pending flag is only cleared once campaign-start recruitment actually + // commits (onInitialRecruited), so an abandoned/reloaded attempt at this + // screen doesn't burn the showcase before the player ever sees it. + showcaseArchetypeId: dataStore.pendingArchetypeShowcase, }); pendingJobResult = null; @@ -595,6 +601,11 @@ function onInitialRecruited(evt: Event) { if (!campaign) return; const { memberIds } = (evt as CustomEvent<{ memberIds: string[] }>).detail; campaign.recruitInitial(memberIds); + // Showcase-slot follow-up (2026-07-14): the reserved candidate's job was to + // be *offered*, not necessarily picked β€” clear the pending flag the moment + // campaign-start recruitment actually commits, regardless of which two + // candidates were chosen, so it doesn't linger for a future campaign. + dataStore.clearPendingArchetypeShowcase(); initialRecruitEl.hide(); // Now the crew is set β€” enter the hub for the first time (builds world, persists). campaign.enterHub(); @@ -782,7 +793,9 @@ function onCrewRecruit(evt: Event) { campaign.canAttemptScore() && !currentJobOptions.some(contract => contract.context.recipeId === 'score-final') ) { - currentJobOptions.push(campaign.buildScoreContract(dataStore.unlockedScoreableItems)); + currentJobOptions.push( + campaign.buildScoreContract(dataStore.unlockedScoreableItems, dataStore.unlockedArchetypes) + ); } // Refresh the roster to reflect the new crew + hide recruit section. presentCrewRoster(); @@ -818,7 +831,9 @@ function generateCurrentJobOptions(): Contract[] { } const contracts = campaign.curator.generateContracts(campaign.rng, campaign); if (campaign.canAttemptScore()) { - contracts.push(campaign.buildScoreContract(dataStore.unlockedScoreableItems)); + contracts.push( + campaign.buildScoreContract(dataStore.unlockedScoreableItems, dataStore.unlockedArchetypes) + ); } return contracts; } @@ -1271,11 +1286,14 @@ function restartWithValidatedSave(): void { } function presentEndedCampaignOverlay(c: Campaign): void { - // P3.M6.4: commit a stolen blueprint to the cross-campaign meta-store before + // P3.M6.4 / P3.5.M7: commit exactly one drawn reward β€” a stolen blueprint or + // an unlocked archetype, never both β€” to the cross-campaign meta-store before // archiving the summary. Idempotent (duplicate id β†’ no-op), so it's safe on // both live Score completion and a restored already-ended save. const unlockedItemId = c.scoreUnlockedItemId; if (unlockedItemId) dataStore.archiveScoreableItem(unlockedItemId); + const unlockedArchetypeId = c.scoreUnlockedArchetypeId; + if (unlockedArchetypeId) dataStore.archiveUnlockedArchetype(unlockedArchetypeId); // The summary captures the stolen blueprint (P3.M6.4) for the win screen and // the M7 Chronicle; `` reads it straight off the summary. const summary = dataStore.archiveCampaign(buildCampaignSummary(c, new Date().toISOString())); @@ -1411,6 +1429,7 @@ function resumeCampaign(record: CampaignSnapshot | unknown) { campaign = restoreCampaign(record, { onPersist: () => handlePersist(), onResult: handleResult, + unlockedArchetypeIds: dataStore.unlockedArchetypes, }); if (campaign.activeRun) { wireRunConfirmations(campaign.activeRun); diff --git a/sw-core.js b/sw-core.js index 12fcab9..4ab2dd9 100644 --- a/sw-core.js +++ b/sw-core.js @@ -62,10 +62,25 @@ const CacheConfig = { '/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/Campaign.js', '/src/game/campaignSummary.js', '/src/game/chronicle.js', @@ -87,6 +102,7 @@ const CacheConfig = { '/src/game/entities/Door.js', '/src/game/entities/EscortNpc.js', '/src/game/entities/Interactable.js', + '/src/game/entities/JackInPoint.js', '/src/game/entities/KeyCard.js', '/src/game/entities/NeutralCivilian.js', '/src/game/entities/Pickup.js', diff --git a/sw-dev.js b/sw-dev.js index 5a3979f..7e17288 100644 --- a/sw-dev.js +++ b/sw-dev.js @@ -1,5 +1,5 @@ // Service Worker for Kernel Panic - Development Version -const VERSION = '0.3.5b'; +const VERSION = '0.3.5c'; importScripts(`/sw-release.js?v=${VERSION}`); importScripts(`/sw-core.js?v=${VERSION}-dev`); diff --git a/sw-release.js b/sw-release.js index 2933048..837e3eb 100644 --- a/sw-release.js +++ b/sw-release.js @@ -2,11 +2,11 @@ // Increment shellEpoch only when the new app shell cannot safely coexist with // the previous worker (for example, removed or renamed runtime modules). self.KernelPanicRelease = Object.freeze({ - version: '0.3.5', + version: '0.3.5c', shellEpoch: 2, - title: 'Chimera archetype online', + title: 'Rolled crew stats & archetype unlocks', highlights: Object.freeze([ - 'Recruit Chimera operators and trigger their Nanite Repair perk.', - 'Nanite Repair converts scrap salvage into HP, repeatable while it lasts.', + 'Crew stats are now rolled, not picked β€” every operator has a distinct hit/dodge profile.', + 'Berserk, Adept, and Chimera start locked; win a clean Score to unlock one for future crews.', ]), }); diff --git a/sw.js b/sw.js index f705612..070712e 100644 --- a/sw.js +++ b/sw.js @@ -1,6 +1,6 @@ // Service Worker for Kernel Panic - Production Version // Import shared caching core with cache-busting query parameter -const VERSION = '0.3.5b'; +const VERSION = '0.3.5c'; importScripts(`/sw-release.js?v=${VERSION}`); importScripts(`/sw-core.js?v=${VERSION}`); diff --git a/tests/unit/DataStore.test.ts b/tests/unit/DataStore.test.ts index 56b5147..769f3fa 100644 --- a/tests/unit/DataStore.test.ts +++ b/tests/unit/DataStore.test.ts @@ -120,3 +120,146 @@ test('DataStore throws on a structurally corrupt scoreable store rather than res const { default: dataStore } = await import('../../src/DataStore.js'); await assert.rejects(() => dataStore.init(), TypeError); }); + +// --- P3.5.M7: unlockedArchetypes ------------------------------------------ + +test('DataStore normalizes an absent archetype store to an empty list', async () => { + installStorage({ prefs: {}, runs: [], campaign: null }); + const { default: dataStore } = await import('../../src/DataStore.js'); + await dataStore.init(); + assert.deepEqual(dataStore.unlockedArchetypes, []); +}); + +test('DataStore starts unlockedArchetypes empty even when unlockedScoreableItems has history', async () => { + // Design decision locked 2026-07-13: nothing grandfathers in from item-unlock + // history β€” unlockedArchetypes is a wholly independent store key. + installStorage({ + prefs: {}, + runs: [], + campaign: null, + unlockedScoreableItems: ['proto-exoframe', 'mil-spec-optics', 'ballistics-coil'], + }); + const { default: dataStore } = await import('../../src/DataStore.js'); + await dataStore.init(); + assert.deepEqual(dataStore.unlockedArchetypes, []); + assert.equal(dataStore.unlockedScoreableItems.length, 3); +}); + +test('DataStore archives unlocked archetypes idempotently and persists them', async () => { + const localStorage = installStorage({ prefs: {}, runs: [], campaign: null }); + const { default: dataStore } = await import('../../src/DataStore.js'); + await dataStore.init(); + + let events = 0; + const onChange = (evt: Event) => { + if ((evt as CustomEvent).detail.key === 'unlockedArchetypes') events++; + }; + dataStore.addEventListener('change', onChange); + + const first = dataStore.archiveUnlockedArchetype('berserk'); + assert.equal(first.added, true); + const second = dataStore.archiveUnlockedArchetype('adept'); + assert.equal(second.added, true); + assert.deepEqual(dataStore.unlockedArchetypes, ['berserk', 'adept']); + assert.equal(events, 2, 'each new unlock emits one change event'); + + // Duplicate archival is a silent no-op: no event, no growth, no save churn. + const duplicate = dataStore.archiveUnlockedArchetype('berserk'); + assert.equal(duplicate.added, false); + assert.deepEqual(dataStore.unlockedArchetypes, ['berserk', 'adept']); + assert.equal(events, 2, 'a duplicate archival emits no change event'); + dataStore.removeEventListener('change', onChange); + + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as { + unlockedArchetypes?: string[]; + }; + assert.deepEqual(stored.unlockedArchetypes, ['berserk', 'adept']); + + // The getter hands back a defensive copy β€” callers can't mutate the store. + const snapshot = dataStore.unlockedArchetypes; + snapshot.push('tampered'); + assert.deepEqual(dataStore.unlockedArchetypes, ['berserk', 'adept']); +}); + +test('DataStore throws on a structurally corrupt archetype store rather than resetting', async () => { + installStorage({ prefs: {}, runs: [], campaign: null, unlockedArchetypes: [1] }); + const { default: dataStore } = await import('../../src/DataStore.js'); + await assert.rejects(() => dataStore.init(), TypeError); +}); + +// --- showcase-slot follow-up (2026-07-14): pendingArchetypeShowcase ------- + +test('DataStore normalizes an absent pendingArchetypeShowcase to null', async () => { + installStorage({ prefs: {}, runs: [], campaign: null }); + const { default: dataStore } = await import('../../src/DataStore.js'); + await dataStore.init(); + assert.equal(dataStore.pendingArchetypeShowcase, null); +}); + +test('archiveUnlockedArchetype arms pendingArchetypeShowcase atomically with a genuinely new unlock', async () => { + const localStorage = installStorage({ prefs: {}, runs: [], campaign: null }); + const { default: dataStore } = await import('../../src/DataStore.js'); + await dataStore.init(); + + const first = dataStore.archiveUnlockedArchetype('berserk'); + assert.equal(first.added, true); + assert.equal(dataStore.pendingArchetypeShowcase, 'berserk'); + + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as { + pendingArchetypeShowcase?: string | null; + }; + assert.equal(stored.pendingArchetypeShowcase, 'berserk'); + + // A second, later unlock re-arms the showcase to the newer id. + dataStore.archiveUnlockedArchetype('adept'); + assert.equal(dataStore.pendingArchetypeShowcase, 'adept'); +}); + +test('a duplicate archiveUnlockedArchetype call leaves an already-pending showcase untouched', async () => { + installStorage({ prefs: {}, runs: [], campaign: null }); + const { default: dataStore } = await import('../../src/DataStore.js'); + await dataStore.init(); + + dataStore.archiveUnlockedArchetype('berserk'); + dataStore.clearPendingArchetypeShowcase(); + assert.equal(dataStore.pendingArchetypeShowcase, null); + + // Re-archiving the same (already-unlocked) id is a no-op β€” must NOT re-arm + // a showcase the player already saw and moved past. + const duplicate = dataStore.archiveUnlockedArchetype('berserk'); + assert.equal(duplicate.added, false); + assert.equal(dataStore.pendingArchetypeShowcase, null); +}); + +test('clearPendingArchetypeShowcase is idempotent and persists the clear', async () => { + const localStorage = installStorage({ prefs: {}, runs: [], campaign: null }); + const { default: dataStore } = await import('../../src/DataStore.js'); + await dataStore.init(); + dataStore.archiveUnlockedArchetype('berserk'); + + let events = 0; + const onChange = (evt: Event) => { + if ((evt as CustomEvent).detail.key === 'pendingArchetypeShowcase') events++; + }; + dataStore.addEventListener('change', onChange); + + dataStore.clearPendingArchetypeShowcase(); + assert.equal(dataStore.pendingArchetypeShowcase, null); + assert.equal(events, 1); + + // Clearing an already-clear store is a silent no-op. + dataStore.clearPendingArchetypeShowcase(); + assert.equal(events, 1, 'no event on a redundant clear'); + dataStore.removeEventListener('change', onChange); + + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as { + pendingArchetypeShowcase?: string | null; + }; + assert.equal(stored.pendingArchetypeShowcase, null); +}); + +test('DataStore throws on a structurally corrupt pendingArchetypeShowcase rather than resetting', async () => { + installStorage({ prefs: {}, runs: [], campaign: null, pendingArchetypeShowcase: 7 }); + const { default: dataStore } = await import('../../src/DataStore.js'); + await assert.rejects(() => dataStore.init(), TypeError); +}); diff --git a/tests/unit/game/Campaign.test.ts b/tests/unit/game/Campaign.test.ts index 7f392b5..782bfb4 100644 --- a/tests/unit/game/Campaign.test.ts +++ b/tests/unit/game/Campaign.test.ts @@ -27,6 +27,11 @@ import { import { emptySalvage, makeSalvage, totalSalvage } from '../../../src/game/salvage.js'; import { testContractContext } from './contractTestUtils.js'; import { buildCrewMember } from '../../../src/game/archetypes/index.js'; +import { SCOREABLE_ITEMS } from '../../../src/game/items.js'; +import { + SCOREABLE_ARCHETYPES, + SCOREABLE_ARCHETYPE_IDS, +} from '../../../src/game/archetypeRewards.js'; import type { LocationSite } from '../../../src/types.js'; const fakeContract = (overrides = {}) => ({ @@ -60,13 +65,20 @@ function validSite(overrides: Partial = {}): LocationSite { }; } -test('buildCrew creates one named member per starter archetype with unique callsigns', () => { +test('buildCrew creates three named crew members with unique callsigns', () => { + // P3.5.M6: no fixed [Merc, Razor, Tech] triple β€” each slot rolls its own + // stats and derives its own archetype (crewStatRoll.ts), so duplicates + // are a legal outcome. Assert structure, not a specific archetype list. const crew = buildCrew(new Rng(0xc0ffee)); assert.equal(crew.length, 3); - assert.deepEqual( - crew.map(member => member.constructor.name), - ['Merc', 'Razor', 'Tech'] - ); + const registered = new Set(['Merc', 'Razor', 'Tech', 'Berserk', 'Adept', 'Chimera']); + for (const member of crew) { + assert.ok( + registered.has(member.constructor.name), + `unexpected starter archetype "${member.constructor.name}" (Decker must never roll)` + ); + assert.ok(member.callsign, 'every starter crew member gets a callsign'); + } assert.equal(new Set(crew.map(member => member.callsign)).size, 3); assert.deepEqual( crew.map(member => member.flatlined), @@ -74,6 +86,27 @@ test('buildCrew creates one named member per starter archetype with unique calls ); }); +test('buildCrew: every starter slot carries its own rolled base stats', () => { + const crew = buildCrew(new Rng(7)); + for (const member of crew) { + assert.ok(member.baseHitChance >= 0.65 && member.baseHitChance <= 0.85); + assert.ok(member.baseDodgeChance >= 0.15 && member.baseDodgeChance <= 0.4); + } +}); + +test('buildCrew is deterministic from a fixed seed (rng.fork does not desync the stream)', () => { + const describe = (crew: ReturnType) => + crew.map(m => ({ + archetype: m.constructor.name, + callsign: m.callsign, + baseHitChance: m.baseHitChance, + baseDodgeChance: m.baseDodgeChance, + })); + const a = buildCrew(new Rng(555)); + const b = buildCrew(new Rng(555)); + assert.deepEqual(describe(a), describe(b)); +}); + test('Campaign starts in HUB with crew, salvage, credits, rep, and meta state', () => { const campaign = new Campaign({ seed: 42 }); assert.equal(campaign.state, CAMPAIGN_STATE.HUB); @@ -1586,24 +1619,33 @@ test('generateRecruits returns empty when Rep < threshold', () => { assert.equal(campaign.availableRecruits.length, 0); }); -test('generateRecruits follows the 2/2/1/1/1 archetype pool over many seeds', () => { - const counts: Record = { Merc: 0, Razor: 0, Tech: 0, Berserk: 0, Adept: 0 }; +test('generateRecruits archetypes come from the roll-then-derive pipeline, never Decker', () => { + // P3.5.M6: RECRUIT_ARCHETYPE_POOL is retired. Every recruit's archetype is + // derived from a stat roll (crewStatRoll.ts) rather than picked from a + // weighted pool β€” every registered non-Decker archetype should be + // reachable, each landing roughly in the plan's documented 13–21% spread + // (generous tolerance here to avoid sample-size flakiness; the exact + // partition math is asserted exhaustively in crewStatRoll.test.ts). + const counts: Record = { + Merc: 0, + Razor: 0, + Tech: 0, + Berserk: 0, + Adept: 0, + Chimera: 0, + }; const total = 1000; for (let i = 0; i < total; i++) { const campaign = new Campaign({ seed: i, rep: 80 }); for (const recruit of campaign.availableRecruits) { + assert.notEqual(recruit.constructor.name, 'Decker', 'Decker must never be a random recruit'); counts[recruit.constructor.name]++; } } - const sum = counts.Merc + counts.Razor + counts.Tech + counts.Berserk + counts.Adept; - // Merc/Razor ~29%; Tech/Berserk/Adept ~14%. Allow generous tolerance. - for (const id of ['Merc', 'Razor']) { + const sum = Object.values(counts).reduce((a, b) => a + b, 0); + for (const id of Object.keys(counts)) { const ratio = counts[id] / sum; - assert.ok(ratio > 0.25 && ratio < 0.41, `${id} ${(ratio * 100).toFixed(1)}% out of range`); - } - for (const id of ['Tech', 'Berserk', 'Adept']) { - const ratio = counts[id] / sum; - assert.ok(ratio > 0.09 && ratio < 0.25, `${id} ${(ratio * 100).toFixed(1)}% out of range`); + assert.ok(ratio > 0.08 && ratio < 0.28, `${id} ${(ratio * 100).toFixed(1)}% out of range`); } }); @@ -1761,23 +1803,31 @@ test('generateInitialCandidates returns RECRUIT.INITIAL_CANDIDATES (3) candidate assert.equal(new Set(ids).size, 3); }); -test('generateInitialCandidates uses weighted archetype pool', () => { - const counts: Record = { Merc: 0, Razor: 0, Tech: 0, Berserk: 0, Adept: 0 }; +test('generateInitialCandidates archetypes come from the roll-then-derive pipeline', () => { + // P3.5.M6: same retired-pool rationale as the generateRecruits test above. + const counts: Record = { + Merc: 0, + Razor: 0, + Tech: 0, + Berserk: 0, + Adept: 0, + Chimera: 0, + }; const total = 500; for (let i = 0; i < total; i++) { const campaign = new Campaign({ seed: i, crew: [] }); const candidates = campaign.generateInitialCandidates(); for (const c of candidates) { + assert.notEqual(c.constructor.name, 'Decker', 'Decker must never be an initial candidate'); counts[c.constructor.name]++; } } - const sum = counts.Merc + counts.Razor + counts.Tech + counts.Berserk + counts.Adept; - assert.ok(counts.Berserk > 0, 'Berserk must be reachable in the starter candidate pool'); - assert.ok(counts.Adept > 0, 'Adept must be reachable in the starter candidate pool'); - assert.ok(counts.Merc / sum > 0.23 && counts.Merc / sum < 0.43); - assert.ok(counts.Tech / sum > 0.07 && counts.Tech / sum < 0.27); - assert.ok(counts.Berserk / sum > 0.07 && counts.Berserk / sum < 0.27); - assert.ok(counts.Adept / sum > 0.07 && counts.Adept / sum < 0.27); + const sum = Object.values(counts).reduce((a, b) => a + b, 0); + for (const id of Object.keys(counts)) { + assert.ok(counts[id] > 0, `${id} must be reachable in the starter candidate pool`); + const ratio = counts[id] / sum; + assert.ok(ratio > 0.07 && ratio < 0.28, `${id} ${(ratio * 100).toFixed(1)}% out of range`); + } }); test('recruitInitial validates exactly RECRUIT.INITIAL_PICKS (2) IDs', () => { @@ -1866,3 +1916,287 @@ test('recruitInitial does not require Rep gate', () => { campaign.recruitInitial(ids); assert.equal(campaign.crew.length, 2); }); + +// ─── P3.5.M7: archetype unlocks via Score rewards ────────────────────────── + +const M7_SCORE_PRINCIPAL = { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }; + +/** + * A Score-ready campaign (Act 3, living Decker + partner, designated target). + * `pickScorePayload` is seeded from the Score *target site's* seed, not the + * campaign's own seed β€” vary both together so a seed sweep actually produces + * different draws (a fixed site seed would draw the same payload every time). + */ +function m7ScoreReadyCampaign( + overrides: { seed?: number; unlockedArchetypeIds?: string[] } = {} +): Campaign { + const seed = overrides.seed ?? 42; + const campaign = new Campaign({ + seed, + rep: 65, + completedJobs: 9, + unlockedArchetypeIds: overrides.unlockedArchetypeIds, + siteRoster: [ + validSite({ + id: 'score', + seed: String(seed), + tier: 'score', + scoreTarget: true, + lastVisitedJob: 5, + principal: M7_SCORE_PRINCIPAL, + }), + validSite({ + id: 'case-1', + seed: `${seed}01`, + lastVisitedJob: 6, + principal: M7_SCORE_PRINCIPAL, + }), + validSite({ + id: 'case-2', + seed: `${seed}02`, + lastVisitedJob: 7, + principal: M7_SCORE_PRINCIPAL, + }), + validSite({ + id: 'case-3', + seed: `${seed}03`, + lastVisitedJob: 8, + principal: M7_SCORE_PRINCIPAL, + }), + validSite({ + id: 'case-4', + seed: `${seed}04`, + lastVisitedJob: 9, + principal: M7_SCORE_PRINCIPAL, + }), + ], + }); + assert.ok(campaign.canAttemptScore(), 'fixture should be Score-ready'); + return campaign; +} + +test('pickScorePayload draws from the merged item+archetype pool over enough seeds', () => { + let itemDraws = 0; + let archetypeDraws = 0; + for (let seed = 0; seed < 60; seed++) { + const contract = m7ScoreReadyCampaign({ seed }).buildScoreContract([], []); + if (contract.objective.params?.scoreItemId) itemDraws++; + if (contract.objective.params?.scoreArchetypeId) archetypeDraws++; + } + assert.ok(itemDraws > 0, 'merged pool must still be able to draw items'); + assert.ok(archetypeDraws > 0, 'merged pool must be able to draw archetypes too'); +}); + +test('a drawn contract carries at most one of scoreItemId/scoreArchetypeId, never both', () => { + for (let seed = 0; seed < 60; seed++) { + const contract = m7ScoreReadyCampaign({ seed }).buildScoreContract([], []); + const hasItem = contract.objective.params?.scoreItemId !== undefined; + const hasArchetype = contract.objective.params?.scoreArchetypeId !== undefined; + assert.ok(!(hasItem && hasArchetype), `seed ${seed}: contract carries both payload kinds`); + } +}); + +test('pool exhaustion (abstract fallback) requires both item and archetype catalogs acquired', () => { + const campaign = m7ScoreReadyCampaign(); + const allItems = SCOREABLE_ITEMS.map(i => i.id); + const allArchetypes = SCOREABLE_ARCHETYPES.map(r => r.id); + + // Items exhausted, archetypes NOT β€” draw must still be an archetype, not abstract. + const itemsGone = campaign.buildScoreContract(allItems, []); + assert.equal(itemsGone.objective.params?.scoreItemId, undefined); + assert.equal(typeof itemsGone.objective.params?.scoreArchetypeId, 'string'); + + // Archetypes exhausted, items NOT β€” draw must still be an item, not abstract. + const archetypesGone = campaign.buildScoreContract([], allArchetypes); + assert.equal(archetypesGone.objective.params?.scoreArchetypeId, undefined); + assert.equal(typeof archetypesGone.objective.params?.scoreItemId, 'string'); + + // Both exhausted β†’ abstract fallback, neither param present. + const bothGone = campaign.buildScoreContract(allItems, allArchetypes); + assert.equal(bothGone.objective.params?.scoreItemId, undefined); + assert.equal(bothGone.objective.params?.scoreArchetypeId, undefined); +}); + +test('settlement records the drawn archetype reward when items are exhausted, never both meta fields', () => { + const campaign = m7ScoreReadyCampaign(); + const decker = campaign.crew.find(m => m.archetype === 'Decker')!; + const partner = campaign.crew.find(m => m.archetype !== 'Decker')!; + const allItems = SCOREABLE_ITEMS.map(i => i.id); + const contract = campaign.buildScoreContract(allItems, []); + const expectedArchetypeId = contract.objective.params!.scoreArchetypeId as string; + campaign.deployCrewMember(decker.id, contract, partner.id).enterCombat(); + + campaign.onJobEnd({ outcome: OUTCOME.EXIT, completed: true }); + assert.equal(campaign.endReason, 'score-complete'); + assert.equal(campaign.scoreUnlockedArchetypeId, expectedArchetypeId); + assert.equal(campaign.scoreUnlockedItemId, null, 'never both'); +}); + +test('a score-partial outcome writes nothing to either meta-store key', () => { + const campaign = m7ScoreReadyCampaign(); + const decker = campaign.crew.find(m => m.archetype === 'Decker')!; + const partner = campaign.crew.find(m => m.archetype !== 'Decker')!; + const allItems = SCOREABLE_ITEMS.map(i => i.id); + campaign + .deployCrewMember(decker.id, campaign.buildScoreContract(allItems, []), partner.id) + .enterCombat(); + + campaign.onJobEnd({ outcome: OUTCOME.EXIT, completed: false }); + assert.equal(campaign.endReason, 'score-partial'); + assert.equal(campaign.scoreUnlockedItemId, null); + assert.equal(campaign.scoreUnlockedArchetypeId, null); +}); + +test('SCOREABLE_ARCHETYPE_IDS matches the SCOREABLE_ARCHETYPES catalog', () => { + assert.deepEqual(new Set(SCOREABLE_ARCHETYPE_IDS), new Set(SCOREABLE_ARCHETYPES.map(r => r.id))); +}); + +// ─── P3.5.M7: gating crew generation via Campaign construction-time unlocks ─ + +test('unlockedArchetypeIds: [] gates the starter trio to merc/razor/tech only', () => { + for (let seed = 0; seed < 40; seed++) { + const campaign = new Campaign({ seed, unlockedArchetypeIds: [] }); + for (const member of campaign.crew) { + assert.ok( + ['Merc', 'Razor', 'Tech'].includes(member.constructor.name), + `seed ${seed}: unexpected "${member.constructor.name}" with archetypes locked` + ); + } + } +}); + +test('unlockedArchetypeIds with all three gated archetypes reaches all six starter archetypes', () => { + const seen = new Set(); + for (let seed = 0; seed < 150; seed++) { + const campaign = new Campaign({ seed, unlockedArchetypeIds: ['berserk', 'adept', 'chimera'] }); + for (const member of campaign.crew) seen.add(member.constructor.name); + } + assert.deepEqual(seen, new Set(['Merc', 'Razor', 'Tech', 'Berserk', 'Adept', 'Chimera'])); +}); + +test('a partial unlock (only Berserk) reaches exactly four starter archetypes across enough campaigns', () => { + const seen = new Set(); + for (let seed = 0; seed < 150; seed++) { + const campaign = new Campaign({ seed, unlockedArchetypeIds: ['berserk'] }); + for (const member of campaign.crew) seen.add(member.constructor.name); + } + assert.deepEqual(seen, new Set(['Merc', 'Razor', 'Tech', 'Berserk'])); +}); + +test('omitting unlockedArchetypeIds stays ungated (bare engine/test construction reaches all six)', () => { + const seen = new Set(); + for (let seed = 0; seed < 150; seed++) { + const campaign = new Campaign({ seed }); + for (const member of campaign.crew) seen.add(member.constructor.name); + } + assert.deepEqual(seen, new Set(['Merc', 'Razor', 'Tech', 'Berserk', 'Adept', 'Chimera'])); +}); + +test('generateInitialCandidates respects the same gating (never rolls a locked archetype)', () => { + const campaign = new Campaign({ seed: 1, crew: [], unlockedArchetypeIds: [] }); + for (let seed = 0; seed < 100; seed++) { + campaign.rng = new Rng(seed); + for (const candidate of campaign.generateInitialCandidates()) { + assert.ok(['Merc', 'Razor', 'Tech'].includes(candidate.constructor.name)); + } + } +}); + +test('generateRecruits respects the same gating (never rolls a locked archetype)', () => { + const campaign = new Campaign({ seed: 1, rep: 80, unlockedArchetypeIds: [] }); + for (let seed = 0; seed < 100; seed++) { + campaign.rng = new Rng(seed); + for (const recruit of campaign.generateRecruits()) { + assert.ok( + !['Berserk', 'Adept', 'Chimera'].includes(recruit.constructor.name), + `seed ${seed}: rolled locked archetype "${recruit.constructor.name}"` + ); + } + } +}); + +// ─── Showcase-slot follow-up (2026-07-14): reserve candidate slot 0 ──────── + +test('showcaseArchetypeId reserves initialCandidates[0] for that archetype', () => { + for (let seed = 0; seed < 30; seed++) { + const campaign = new Campaign({ + seed, + crew: [], + unlockedArchetypeIds: ['berserk', 'adept', 'chimera'], + showcaseArchetypeId: 'berserk', + }); + const candidates = campaign.generateInitialCandidates(); + assert.equal(candidates.length, 3); + assert.equal(candidates[0].constructor.name, 'Berserk'); + assert.equal(candidates[0].id, 'crew-init-0'); + } +}); + +test('showcaseArchetypeId still yields 3 unique candidates with unique callsigns', () => { + const campaign = new Campaign({ + seed: 5, + crew: [], + unlockedArchetypeIds: ['berserk'], + showcaseArchetypeId: 'berserk', + }); + const candidates = campaign.generateInitialCandidates(); + assert.equal(candidates.length, 3); + assert.equal(new Set(candidates.map(c => c.callsign)).size, 3); + assert.equal(new Set(candidates.map(c => c.id)).size, 3); +}); + +test('the other two showcase-run slots still roll normally (not forced to any particular archetype)', () => { + const seen = new Set(); + for (let seed = 0; seed < 60; seed++) { + const campaign = new Campaign({ + seed, + crew: [], + unlockedArchetypeIds: ['berserk', 'adept', 'chimera'], + showcaseArchetypeId: 'berserk', + }); + const candidates = campaign.generateInitialCandidates(); + for (const candidate of candidates.slice(1)) seen.add(candidate.constructor.name); + } + // Slots 1–2 should still range over more than just Berserk across enough seeds. + assert.ok( + seen.size > 1, + `expected varied archetypes in the non-showcase slots, got ${[...seen]}` + ); +}); + +test('no showcaseArchetypeId (the ordinary case) behaves exactly as before β€” no forced slot', () => { + const campaign = new Campaign({ seed: 9, crew: [] }); + const candidates = campaign.generateInitialCandidates(); + assert.equal(candidates.length, 3); + // Not asserting a specific archetype β€” just that construction succeeds + // and nothing about the ordinary path changed shape. + assert.equal(campaign.showcaseArchetypeId, null); +}); + +test('showcaseArchetypeId defensively no-ops when the id is not actually reachable under gating', () => { + // Simulates a stale/hand-edited save: showcase points at an archetype that + // (per unlockedArchetypeIds) isn't actually unlocked. Must not throw or + // leak a locked archetype into the candidate pool β€” just skip the reservation. + const campaign = new Campaign({ + seed: 3, + crew: [], + unlockedArchetypeIds: [], // berserk NOT unlocked, contradicting the showcase below + showcaseArchetypeId: 'berserk', + }); + const candidates = campaign.generateInitialCandidates(); + assert.equal(candidates.length, 3); + for (const candidate of candidates) { + assert.ok(['Merc', 'Razor', 'Tech'].includes(candidate.constructor.name)); + } +}); + +test('Campaign rejects a malformed showcaseArchetypeId (empty string)', () => { + assert.throws(() => new Campaign({ seed: 1, showcaseArchetypeId: '' }), /showcaseArchetypeId/); +}); + +test('Campaign accepts an explicit null showcaseArchetypeId (DataStore.pendingArchetypeShowcase shape)', () => { + const campaign = new Campaign({ seed: 1, crew: [], showcaseArchetypeId: null }); + assert.equal(campaign.showcaseArchetypeId, null); + const candidates = campaign.generateInitialCandidates(); + assert.equal(candidates.length, 3); +}); diff --git a/tests/unit/game/Crew.test.ts b/tests/unit/game/Crew.test.ts index 36cdcdf..9825453 100644 --- a/tests/unit/game/Crew.test.ts +++ b/tests/unit/game/Crew.test.ts @@ -16,6 +16,10 @@ import { Entity } from '../../../src/game/Entity.js'; import { Merc } from '../../../src/game/archetypes/Merc.js'; import { Razor } from '../../../src/game/archetypes/Razor.js'; import { Tech } from '../../../src/game/archetypes/Tech.js'; +import { Decker } from '../../../src/game/archetypes/Decker.js'; +import { Berserk } from '../../../src/game/archetypes/Berserk.js'; +import { Adept } from '../../../src/game/archetypes/Adept.js'; +import { Chimera } from '../../../src/game/archetypes/Chimera.js'; import { Grid } from '../../../src/game/Grid.js'; import { World } from '../../../src/game/World.js'; import { @@ -497,6 +501,61 @@ test('Merc.baseDodgeChance uses the crew default', () => { assert.equal(m.baseDodgeChance, DODGE_CHANCE); }); +// --- P3.5.M6: constructor-settable baseHitChance/baseDodgeChance --------- + +test('every archetype falls back to its own default when no override is given', () => { + const cases: Array<{ ctor: new (props: never) => Crew; hit: number; dodge: number }> = [ + { ctor: Merc, hit: 0.8, dodge: DODGE_CHANCE }, + { ctor: Razor, hit: 0.7, dodge: 0.35 }, + { ctor: Tech, hit: 0.75, dodge: DODGE_CHANCE }, + { ctor: Decker, hit: 0.7, dodge: DODGE_CHANCE }, + { ctor: Berserk, hit: 0.78, dodge: 0.36 }, + { ctor: Adept, hit: 0.7, dodge: 0.2 }, + { ctor: Chimera, hit: 0.75, dodge: 0.25 }, + ]; + for (const { ctor, hit, dodge } of cases) { + const member = new ctor({ id: 'x', x: 0, y: 0 } as never); + assert.equal(member.baseHitChance, hit, `${ctor.name} default baseHitChance`); + assert.equal(member.baseDodgeChance, dodge, `${ctor.name} default baseDodgeChance`); + } +}); + +test('every archetype constructor accepts a rolled baseHitChance/baseDodgeChance override', () => { + const ctors: Array Crew> = [ + Merc, + Razor, + Tech, + Decker, + Berserk, + Adept, + Chimera, + ]; + for (const Ctor of ctors) { + const member = new Ctor({ + id: 'x', + x: 0, + y: 0, + baseHitChance: 0.71, + baseDodgeChance: 0.29, + } as never); + assert.equal(member.baseHitChance, 0.71, `${Ctor.name} rolled baseHitChance`); + assert.equal(member.baseDodgeChance, 0.29, `${Ctor.name} rolled baseDodgeChance`); + } +}); + +test('Crew constructor rejects an out-of-range baseHitChance/baseDodgeChance', () => { + assert.throws(() => new Crew({ ...baseProps, baseHitChance: 1.1 }), /baseHitChance/); + assert.throws(() => new Crew({ ...baseProps, baseHitChance: -0.1 }), /baseHitChance/); + assert.throws(() => new Crew({ ...baseProps, baseDodgeChance: 1.1 }), /baseDodgeChance/); + assert.throws(() => new Crew({ ...baseProps, baseDodgeChance: -0.1 }), /baseDodgeChance/); +}); + +test("Berserk's dynamic baseHitChance getter reads the rolled pristine value (not a fixed 0.78)", () => { + const berserk = new Berserk({ id: 'b', x: 0, y: 0, baseHitChance: 0.81 }); + assert.equal(berserk.baseHitChance, 0.81); + assert.equal(berserk.pristineBaseHitChance, 0.81); +}); + test('Crew.maxHitBonus is 1 βˆ’ baseHitChance', () => { const m = new Merc({ id: 'm', x: 0, y: 0 }); assert.equal(m.maxHitBonus, 1 - 0.8); // 0.2 diff --git a/tests/unit/game/archetypeShowcase.test.ts b/tests/unit/game/archetypeShowcase.test.ts new file mode 100644 index 0000000..c53571c --- /dev/null +++ b/tests/unit/game/archetypeShowcase.test.ts @@ -0,0 +1,26 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { normalizePendingArchetypeShowcase } from '../../../src/game/archetypeShowcase.js'; + +test('normalizePendingArchetypeShowcase treats an absent store as null', () => { + assert.equal(normalizePendingArchetypeShowcase(undefined), null); +}); + +test('normalizePendingArchetypeShowcase passes through an explicit null', () => { + assert.equal(normalizePendingArchetypeShowcase(null), null); +}); + +test('normalizePendingArchetypeShowcase passes through a valid id', () => { + assert.equal(normalizePendingArchetypeShowcase('berserk'), 'berserk'); +}); + +test('normalizePendingArchetypeShowcase throws on a non-string, non-null value', () => { + assert.throws(() => normalizePendingArchetypeShowcase(1), TypeError); + assert.throws(() => normalizePendingArchetypeShowcase({}), TypeError); + assert.throws(() => normalizePendingArchetypeShowcase(['berserk']), TypeError); +}); + +test('normalizePendingArchetypeShowcase throws on an empty string', () => { + assert.throws(() => normalizePendingArchetypeShowcase(''), TypeError); +}); diff --git a/tests/unit/game/archetypeUnlocks.test.ts b/tests/unit/game/archetypeUnlocks.test.ts new file mode 100644 index 0000000..f727fe5 --- /dev/null +++ b/tests/unit/game/archetypeUnlocks.test.ts @@ -0,0 +1,63 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + archiveUnlockedArchetype, + normalizeUnlockedArchetypes, +} from '../../../src/game/archetypeUnlocks.js'; + +test('normalizeUnlockedArchetypes treats an absent store as empty', () => { + assert.deepEqual(normalizeUnlockedArchetypes(undefined), []); +}); + +test('normalizeUnlockedArchetypes passes through a valid id list, fresh copy', () => { + const input = ['berserk', 'adept']; + const result = normalizeUnlockedArchetypes(input); + assert.deepEqual(result, ['berserk', 'adept']); + assert.notEqual(result, input, 'returns a fresh array, not the caller-owned reference'); +}); + +test('normalizeUnlockedArchetypes de-dupes preserving acquisition order', () => { + const result = normalizeUnlockedArchetypes(['berserk', 'adept', 'berserk', 'chimera', 'adept']); + assert.deepEqual(result, ['berserk', 'adept', 'chimera']); +}); + +test('normalizeUnlockedArchetypes throws on a non-array store', () => { + assert.throws(() => normalizeUnlockedArchetypes('berserk'), TypeError); + assert.throws(() => normalizeUnlockedArchetypes({ 0: 'berserk' }), TypeError); +}); + +test('normalizeUnlockedArchetypes throws on a non-string or empty element', () => { + assert.throws(() => normalizeUnlockedArchetypes(['berserk', 1]), TypeError); + assert.throws(() => normalizeUnlockedArchetypes(['berserk', '']), TypeError); + assert.throws(() => normalizeUnlockedArchetypes(['berserk', null]), TypeError); +}); + +test('archiveUnlockedArchetype appends a new id to the end (acquisition order)', () => { + const { list, added } = archiveUnlockedArchetype(['berserk'], 'adept'); + assert.equal(added, true); + assert.deepEqual(list, ['berserk', 'adept']); +}); + +test('archiveUnlockedArchetype is a no-op for a duplicate id', () => { + const start = ['berserk', 'adept']; + const { list, added } = archiveUnlockedArchetype(start, 'berserk'); + assert.equal(added, false); + assert.deepEqual(list, ['berserk', 'adept']); +}); + +test('archiveUnlockedArchetype validates the input list', () => { + assert.throws(() => archiveUnlockedArchetype('berserk' as unknown as string[], 'x'), TypeError); + assert.throws(() => archiveUnlockedArchetype(['ok', ''], 'x'), TypeError); +}); + +test('archiveUnlockedArchetype rejects an empty or non-string id', () => { + assert.throws(() => archiveUnlockedArchetype(['berserk'], ''), TypeError); + assert.throws(() => archiveUnlockedArchetype(['berserk'], 7 as unknown as string), TypeError); +}); + +test('nothing grandfathers in β€” an independently-empty store even with unrelated history', () => { + // Design decision locked 2026-07-13: unlockedArchetypes starts empty for + // every meta-crew regardless of unlockedScoreableItems history. + assert.deepEqual(normalizeUnlockedArchetypes(undefined), []); +}); diff --git a/tests/unit/game/archetypes.test.ts b/tests/unit/game/archetypes.test.ts index 6746b97..7df1a77 100644 --- a/tests/unit/game/archetypes.test.ts +++ b/tests/unit/game/archetypes.test.ts @@ -16,7 +16,6 @@ import { ARCHETYPES, ARCHETYPE_IDS, CALLSIGNS_BY_ARCHETYPE, - RECRUIT_ARCHETYPE_POOL, buildCrewMember, isArchetypeId, perkAimForArchetype, @@ -195,14 +194,12 @@ test('perkAimForArchetype throws on an unknown archetype (no silent fallback)', assert.throws(() => perkAimForArchetype('nope'), /unknown archetype/); }); -test('Decker is excluded from the starter selector and the random recruit pool', () => { +test('Decker is excluded from the starter selector', () => { // The Decker joins only via the Act-2 narrative beat β€” never as a starter - // pick or a random recruit roll (P3.M2 design constraint). + // pick or a random recruit roll (P3.M2 design constraint). P3.5.M6: + // `deriveArchetype` never resolves to 'decker' β€” see crewStatRoll.test.ts β€” + // so exclusion from the roll pipeline is proven there, not here. assert.ok(!ARCHETYPE_IDS.includes('decker'), 'Decker must not be a selectable starter'); - assert.ok( - !RECRUIT_ARCHETYPE_POOL.includes('decker'), - 'Decker must not be in the random recruit pool' - ); }); test('buildCrewMember can still construct a Decker by id (recruitment path)', () => { @@ -218,7 +215,6 @@ test('Berserk is registered, recruitable, and self-targeted', () => { assert.equal(a.perkName, 'SURGE'); assert.equal(a.perkAim, 'self'); assert.ok(!ARCHETYPE_IDS.includes('berserk')); - assert.ok(RECRUIT_ARCHETYPE_POOL.includes('berserk')); const berserk = buildCrewMember('berserk', { x: 1, y: 2 }, new Rng(10)); assert.ok(berserk instanceof Berserk); @@ -232,7 +228,6 @@ test('Adept is registered, recruitable, and directionally aimed', () => { assert.equal(a.perkName, 'INFLUENCE'); assert.equal(a.perkAim, 'directional'); assert.ok(!ARCHETYPE_IDS.includes('adept')); - assert.ok(RECRUIT_ARCHETYPE_POOL.includes('adept')); const adept = buildCrewMember('adept', { x: 1, y: 2 }, new Rng(10)); assert.ok(adept instanceof Adept); @@ -246,7 +241,6 @@ test('Chimera is registered, recruitable, and self-targeted', () => { assert.equal(a.perkName, 'NANITE REPAIR'); assert.equal(a.perkAim, 'self'); assert.ok(!ARCHETYPE_IDS.includes('chimera')); - assert.ok(RECRUIT_ARCHETYPE_POOL.includes('chimera')); const chimera = buildCrewMember('chimera', { x: 1, y: 2 }, new Rng(10)); assert.ok(chimera instanceof Chimera); diff --git a/tests/unit/game/campaignSummary.test.ts b/tests/unit/game/campaignSummary.test.ts index 0829af1..1614cb5 100644 --- a/tests/unit/game/campaignSummary.test.ts +++ b/tests/unit/game/campaignSummary.test.ts @@ -14,6 +14,10 @@ import { import { OUTCOME } from '../../../src/game/Run.js'; import { makeSalvage } from '../../../src/game/salvage.js'; import { SCOREABLE_ITEM_IDS } from '../../../src/game/items.js'; +import { + SCOREABLE_ARCHETYPE_IDS, + SCOREABLE_ARCHETYPES, +} from '../../../src/game/archetypeRewards.js'; import type { LocationSite } from '../../../src/types.js'; const COMPLETED_AT = '2026-06-14T19:30:00.000Z'; @@ -89,13 +93,46 @@ test('Score completion summary uses post-settlement jobs, Rep, and Credits', () assert.equal(record.credits, 125 + SCORE_CREDITS_REWARD); assert.equal('salvage' in record, false); assert.ok(record.crewRoster.some(member => member.archetype === 'Decker')); - // P3.M6.4: a winning Score captures the stolen blueprint, self-contained. - assert.ok(record.scoreReward, 'win summary carries the stolen blueprint'); - assert.ok(SCOREABLE_ITEM_IDS.has(record.scoreReward.id)); + // P3.M6.4 / P3.5.M7: a winning Score captures the drawn reward β€” item + // blueprint or archetype β€” self-contained. `buildScoreContract()` here is + // called ungated (no unlockedArchetypeIds), so either pool is a legal draw. + assert.ok(record.scoreReward, 'win summary carries the stolen reward'); + assert.ok( + SCOREABLE_ITEM_IDS.has(record.scoreReward.id) || + SCOREABLE_ARCHETYPE_IDS.has(record.scoreReward.id as never) + ); assert.ok(record.scoreReward.label.length > 0); assert.equal(typeof record.scoreReward.flavor, 'string'); }); +test('a win summary captures an archetype reward when the Score drew one instead of an item (P3.5.M7)', () => { + const campaign = new Campaign({ id: 'campaign-archetype-win', seed: 9 }); + campaign.state = CAMPAIGN_STATE.ENDED; + Object.defineProperty(campaign, 'endReason', { value: 'score-complete' }); + campaign.meta.scoreUnlockedArchetypeId = 'berserk'; + + const record = buildCampaignSummary(campaign, COMPLETED_AT); + const berserkReward = SCOREABLE_ARCHETYPES.find(r => r.id === 'berserk')!; + assert.deepEqual(record.scoreReward, { + id: 'berserk', + label: berserkReward.label, + flavor: berserkReward.flavor, + }); +}); + +test('resolveScoreReward prefers the item id when (implausibly) both meta fields are set', () => { + // Campaign.onJobEnd never sets both β€” this pins resolveScoreReward's own + // resolution order as a defensive-in-depth guarantee, not a reachable state. + const campaign = new Campaign({ id: 'campaign-both-set', seed: 9 }); + campaign.state = CAMPAIGN_STATE.ENDED; + Object.defineProperty(campaign, 'endReason', { value: 'score-complete' }); + campaign.meta.scoreUnlockedItemId = 'monoblade'; + campaign.meta.scoreUnlockedArchetypeId = 'berserk'; + + const record = buildCampaignSummary(campaign, COMPLETED_AT); + assert.equal(record.scoreReward!.id, 'monoblade'); +}); + test('loss summaries preserve each terminal reason and final roster state', () => { for (const endReason of ['crew-wipe', 'clock-expired', 'decker-flatlined-score'] as const) { const campaign = new Campaign({ id: `campaign-${endReason}`, seed: 9 }); diff --git a/tests/unit/game/crewStatRoll.test.ts b/tests/unit/game/crewStatRoll.test.ts new file mode 100644 index 0000000..1c3a1ea --- /dev/null +++ b/tests/unit/game/crewStatRoll.test.ts @@ -0,0 +1,371 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + CREW_STAT_ANCHORS, + DEFAULT_HIT_CHANCE_BY_ARCHETYPE, + DEFAULT_DODGE_CHANCE_BY_ARCHETYPE, + rollCrewStats, + deriveArchetype, + buildCrewMemberFromRoll, + buildCrewMemberFromRollForArchetype, + type CrewStatAnchor, +} from '../../../src/game/crewStatRoll.js'; +import { + CREW_HIT_CHANCE_ROLL_MIN, + CREW_HIT_CHANCE_ROLL_MAX, + CREW_DODGE_CHANCE_ROLL_MIN, + CREW_DODGE_CHANCE_ROLL_MAX, +} from '../../../src/game/constants.js'; +import { Rng } from '../../../src/rng.js'; +import { SCOREABLE_ARCHETYPE_IDS } from '../../../src/game/archetypeRewards.js'; + +const NON_DECKER_ARCHETYPES = ['merc', 'razor', 'tech', 'berserk', 'adept', 'chimera'] as const; + +// The rounded roll grid: 21 hit values Γ— 26 dodge values = 546 tuples. +function* gridTuples() { + for (let hi = 0; hi <= 20; hi++) { + const hitChance = (65 + hi) / 100; + for (let di = 0; di <= 25; di++) { + const dodgeChance = (15 + di) / 100; + yield { hitChance, dodgeChance }; + } + } +} + +test('CREW_STAT_ANCHORS registers exactly the six non-Decker archetypes, Decker absent', () => { + const ids = CREW_STAT_ANCHORS.map(a => a.archetype); + assert.deepEqual(new Set(ids), new Set(NON_DECKER_ARCHETYPES)); + assert.ok(!ids.includes('decker' as never), 'Decker must never be a rollable anchor'); +}); + +test('deriveArchetype: exhaustive 546-tuple grid sweep resolves every tuple, no dead zones, no throw', () => { + const seen = new Set(); + let count = 0; + for (const stats of gridTuples()) { + count++; + const archetype = deriveArchetype(stats); + assert.ok( + NON_DECKER_ARCHETYPES.includes(archetype as (typeof NON_DECKER_ARCHETYPES)[number]), + `tuple (${stats.hitChance}, ${stats.dodgeChance}) resolved to unregistered "${archetype}"` + ); + seen.add(archetype); + } + assert.equal(count, 546, 'grid must be exactly 21 Γ— 26 = 546 tuples'); + assert.deepEqual( + seen, + new Set(NON_DECKER_ARCHETYPES), + 'every archetype must be reachable β€” no starved anchor' + ); +}); + +test('deriveArchetype: each anchor maps to its own archetype', () => { + for (const anchor of CREW_STAT_ANCHORS) { + assert.equal( + deriveArchetype({ hitChance: anchor.hitChance, dodgeChance: anchor.dodgeChance }), + anchor.archetype + ); + } +}); + +test('deriveArchetype: the four widened-range corners saturate to their nearest archetype', () => { + const cases: Array<{ hitChance: number; dodgeChance: number; expect: string }> = [ + { hitChance: 0.85, dodgeChance: 0.15, expect: 'chimera' }, + { hitChance: 0.85, dodgeChance: 0.4, expect: 'berserk' }, + { hitChance: 0.65, dodgeChance: 0.4, expect: 'razor' }, + { hitChance: 0.65, dodgeChance: 0.15, expect: 'adept' }, + ]; + for (const { hitChance, dodgeChance, expect } of cases) { + assert.equal( + deriveArchetype({ hitChance, dodgeChance }), + expect, + `corner (${hitChance}, ${dodgeChance})` + ); + } +}); + +test('deriveArchetype: exact-distance ties resolve via the fixed priority order (merc > razor > adept > tech > berserk > chimera)', () => { + const razor: CrewStatAnchor = { archetype: 'razor', hitChance: 0.6, dodgeChance: 0.5 }; + const chimera: CrewStatAnchor = { archetype: 'chimera', hitChance: 0.4, dodgeChance: 0.5 }; + // Equidistant from both synthetic anchors β€” razor outranks chimera. + assert.equal(deriveArchetype({ hitChance: 0.5, dodgeChance: 0.5 }, [chimera, razor]), 'razor'); + assert.equal(deriveArchetype({ hitChance: 0.5, dodgeChance: 0.5 }, [razor, chimera]), 'razor'); + + const merc: CrewStatAnchor = { archetype: 'merc', hitChance: 0.6, dodgeChance: 0.5 }; + const berserk: CrewStatAnchor = { archetype: 'berserk', hitChance: 0.4, dodgeChance: 0.5 }; + // Merc outranks everything. + assert.equal( + deriveArchetype({ hitChance: 0.5, dodgeChance: 0.5 }, [berserk, merc, razor]), + 'merc' + ); +}); + +test('deriveArchetype: throws on an empty anchor list rather than returning an unclassifiable result', () => { + assert.throws(() => deriveArchetype({ hitChance: 0.7, dodgeChance: 0.2 }, []), /anchors/i); +}); + +test('deriveArchetype: measured distribution over the full grid stays in the documented 13–21%-ish spread (guards against re-skew toward the discarded 35/1 design)', () => { + const counts: Record = Object.fromEntries( + NON_DECKER_ARCHETYPES.map(id => [id, 0]) + ); + for (const stats of gridTuples()) { + counts[deriveArchetype(stats)]++; + } + const total = 546; + for (const id of NON_DECKER_ARCHETYPES) { + const ratio = counts[id] / total; + assert.ok( + ratio > 0.1 && ratio < 0.26, + `${id}: ${(ratio * 100).toFixed(1)}% of the grid, outside the healthy partition band` + ); + } +}); + +test('rollCrewStats: every roll lands on the 0.01 grid within the documented ranges', () => { + const rng = new Rng(1234); + for (let i = 0; i < 2000; i++) { + const { hitChance, dodgeChance, armor } = rollCrewStats(rng); + assert.ok(hitChance >= CREW_HIT_CHANCE_ROLL_MIN && hitChance <= CREW_HIT_CHANCE_ROLL_MAX); + assert.ok( + dodgeChance >= CREW_DODGE_CHANCE_ROLL_MIN && dodgeChance <= CREW_DODGE_CHANCE_ROLL_MAX + ); + assert.equal(Math.round(hitChance * 100) / 100, hitChance, `${hitChance} not on 0.01 grid`); + assert.equal( + Math.round(dodgeChance * 100) / 100, + dodgeChance, + `${dodgeChance} not on 0.01 grid` + ); + assert.ok(armor === 0 || armor === 1); + } +}); + +test('rollCrewStats: armor rolls roughly at the documented 15% rate over a large sample', () => { + const rng = new Rng(99); + let armored = 0; + const total = 5000; + for (let i = 0; i < total; i++) { + if (rollCrewStats(rng).armor === 1) armored++; + } + const ratio = armored / total; + assert.ok(ratio > 0.1 && ratio < 0.2, `armor rate ${(ratio * 100).toFixed(1)}% out of range`); +}); + +test('rollCrewStats throws without a valid Rng', () => { + assert.throws(() => rollCrewStats(null as never)); + assert.throws(() => rollCrewStats({} as never)); +}); + +test('DEFAULT_HIT_CHANCE_BY_ARCHETYPE / DEFAULT_DODGE_CHANCE_BY_ARCHETYPE cover all seven archetypes including Decker', () => { + const allSeven = [...NON_DECKER_ARCHETYPES, 'decker']; + for (const id of allSeven) { + assert.equal(typeof DEFAULT_HIT_CHANCE_BY_ARCHETYPE[id as never], 'number'); + assert.equal(typeof DEFAULT_DODGE_CHANCE_BY_ARCHETYPE[id as never], 'number'); + } + // Frozen pre-P3.5 values β€” old-save safety (see crewStatRoll.ts doc comment). + assert.equal(DEFAULT_HIT_CHANCE_BY_ARCHETYPE.merc, 0.8); + assert.equal(DEFAULT_HIT_CHANCE_BY_ARCHETYPE.razor, 0.7); + assert.equal(DEFAULT_DODGE_CHANCE_BY_ARCHETYPE.razor, 0.35); + assert.equal(DEFAULT_HIT_CHANCE_BY_ARCHETYPE.tech, 0.75); +}); + +// --- buildCrewMemberFromRoll ---------------------------------------------- + +test('buildCrewMemberFromRoll constructs a crew member whose archetype matches deriveArchetype(rollCrewStats(fork))', () => { + const rng = new Rng(42); + const forked = rng.fork('crew-stats'); + const expectedStats = rollCrewStats(forked); + const expectedArchetype = deriveArchetype(expectedStats); + + // rng itself is untouched by the fork above (fork doesn't consume from the + // parent stream) β€” buildCrewMemberFromRoll will derive the exact same + // stats from the same rng state. + const member = buildCrewMemberFromRoll({ x: 0, y: 0 }, rng, { id: 'roll-1' }); + assert.equal(member.constructor.name.toLowerCase(), expectedArchetype); + assert.equal(member.baseHitChance, expectedStats.hitChance); + assert.equal(member.baseDodgeChance, expectedStats.dodgeChance); +}); + +test('buildCrewMemberFromRoll applies rolled armor onto damageReduction', () => { + // Sweep seeds until we find one that rolls armor, to exercise the branch β€” + // deterministic given the fixed seed sequence. + for (let seed = 0; seed < 200; seed++) { + const rng = new Rng(seed); + const forked = rng.fork('crew-stats'); + const stats = rollCrewStats(forked); + if (stats.armor === 0) continue; + const member = buildCrewMemberFromRoll({ x: 0, y: 0 }, new Rng(seed), { id: `roll-${seed}` }); + assert.equal(member.damageReduction, stats.armor); + return; + } + assert.fail('no seed in [0, 200) rolled armor β€” widen the sweep'); +}); + +test('buildCrewMemberFromRoll never derives a Decker', () => { + for (let seed = 0; seed < 300; seed++) { + const member = buildCrewMemberFromRoll({ x: 0, y: 0 }, new Rng(seed), { id: `roll-${seed}` }); + assert.notEqual(member.constructor.name, 'Decker'); + } +}); + +test('buildCrewMemberFromRoll respects a caller-supplied anchor subset (P3.5.M7 gating hook)', () => { + const mercOnly: CrewStatAnchor[] = [{ archetype: 'merc', hitChance: 0.83, dodgeChance: 0.27 }]; + for (let seed = 0; seed < 25; seed++) { + const member = buildCrewMemberFromRoll( + { x: 0, y: 0 }, + new Rng(seed), + { id: `roll-${seed}` }, + mercOnly + ); + assert.equal(member.constructor.name, 'Merc'); + } +}); + +// --- P3.5.M7: locked-archetype anchor gating ------------------------------ +// +// Real gating lives in `Campaign.#crewStatAnchors()`, which filters +// `CREW_STAT_ANCHORS` down to `!SCOREABLE_ARCHETYPE_IDS.has(id) || +// unlocked.includes(id)` before calling `deriveArchetype`/ +// `buildCrewMemberFromRoll`. These tests exercise that exact filter shape +// directly against `deriveArchetype`, independent of Campaign. + +/** Anchors with only {merc, razor, tech} unlocked β€” every M7-gated archetype absent. */ +const STARTER_ONLY_ANCHORS: CrewStatAnchor[] = CREW_STAT_ANCHORS.filter( + anchor => !SCOREABLE_ARCHETYPE_IDS.has(anchor.archetype) +); + +test('SCOREABLE_ARCHETYPE_IDS names exactly the three M7-gated archetypes', () => { + assert.deepEqual(new Set(SCOREABLE_ARCHETYPE_IDS), new Set(['berserk', 'adept', 'chimera'])); +}); + +test('STARTER_ONLY_ANCHORS fixture retains exactly merc/razor/tech', () => { + assert.deepEqual( + new Set(STARTER_ONLY_ANCHORS.map(a => a.archetype)), + new Set(['merc', 'razor', 'tech']) + ); +}); + +test('deriveArchetype with only {merc, razor, tech} unlocked: every one of the 546 tuples resolves to a registered unlocked archetype, no dead zones, no throw', () => { + const seen = new Set(); + let count = 0; + for (const stats of gridTuples()) { + count++; + const archetype = deriveArchetype(stats, STARTER_ONLY_ANCHORS); + assert.ok( + archetype === 'merc' || archetype === 'razor' || archetype === 'tech', + `tuple (${stats.hitChance}, ${stats.dodgeChance}) resolved to locked/unknown "${archetype}"` + ); + seen.add(archetype); + } + assert.equal(count, 546); + assert.deepEqual(seen, new Set(['merc', 'razor', 'tech'])); +}); + +test('each locked archetype anchor point saturates to a different, unlocked neighbor rather than dead-zoning or throwing', () => { + for (const anchor of CREW_STAT_ANCHORS) { + if (!SCOREABLE_ARCHETYPE_IDS.has(anchor.archetype)) continue; // only test the three gated ones + const resolved = deriveArchetype( + { hitChance: anchor.hitChance, dodgeChance: anchor.dodgeChance }, + STARTER_ONLY_ANCHORS + ); + assert.notEqual( + resolved, + anchor.archetype, + `${anchor.archetype}'s own anchor must saturate away` + ); + assert.ok(['merc', 'razor', 'tech'].includes(resolved)); + } +}); + +test('deriveArchetype throws with an anchor table filtered down to nothing (all six gated)', () => { + const allGated = CREW_STAT_ANCHORS.filter(a => !NON_DECKER_ARCHETYPES.includes(a.archetype)); + assert.deepEqual(allGated, []); + assert.throws( + () => deriveArchetype({ hitChance: 0.75, dodgeChance: 0.25 }, allGated), + /anchors/i + ); +}); + +test('a partially-unlocked table (e.g. only Berserk unlocked among the gated three) reaches four archetypes total', () => { + const withBerserkUnlocked = CREW_STAT_ANCHORS.filter( + anchor => !SCOREABLE_ARCHETYPE_IDS.has(anchor.archetype) || anchor.archetype === 'berserk' + ); + const seen = new Set(); + for (const stats of gridTuples()) { + seen.add(deriveArchetype(stats, withBerserkUnlocked)); + } + assert.deepEqual(seen, new Set(['merc', 'razor', 'tech', 'berserk'])); +}); + +// --- showcase-slot follow-up (2026-07-14): buildCrewMemberFromRollForArchetype --- + +test('buildCrewMemberFromRollForArchetype always constructs the forced archetype, with varying stats', () => { + const hitChances = new Set(); + const dodgeChances = new Set(); + for (let seed = 0; seed < 40; seed++) { + const member = buildCrewMemberFromRollForArchetype( + { x: 0, y: 0 }, + new Rng(seed), + 'berserk', + CREW_STAT_ANCHORS, + { id: `showcase-${seed}` } + ); + assert.equal(member.constructor.name, 'Berserk'); + assert.ok(member.baseHitChance >= 0.65 && member.baseHitChance <= 0.85); + assert.ok(member.baseDodgeChance >= 0.15 && member.baseDodgeChance <= 0.4); + hitChances.add(member.baseHitChance); + dodgeChances.add(member.baseDodgeChance); + } + // Natural variance, not always pinned to the anchor point. + assert.ok(hitChances.size > 1, 'hit chance should vary across seeds'); + assert.ok(dodgeChances.size > 1, 'dodge chance should vary across seeds'); +}); + +test('buildCrewMemberFromRollForArchetype works for every non-Decker archetype', () => { + for (const archetype of ['merc', 'razor', 'tech', 'berserk', 'adept', 'chimera'] as const) { + const member = buildCrewMemberFromRollForArchetype( + { x: 0, y: 0 }, + new Rng(1), + archetype, + CREW_STAT_ANCHORS, + { id: `showcase-${archetype}` } + ); + assert.equal(member.archetype.toLowerCase(), archetype); + } +}); + +test('buildCrewMemberFromRollForArchetype throws when the archetype has no anchor in the supplied table (locked)', () => { + const withoutBerserk = CREW_STAT_ANCHORS.filter(a => a.archetype !== 'berserk'); + assert.throws( + () => + buildCrewMemberFromRollForArchetype({ x: 0, y: 0 }, new Rng(1), 'berserk', withoutBerserk), + /no anchor/i + ); +}); + +test('buildCrewMemberFromRollForArchetype applies rolled armor onto damageReduction', () => { + let sawArmor = false; + for (let seed = 0; seed < 200 && !sawArmor; seed++) { + const member = buildCrewMemberFromRollForArchetype( + { x: 0, y: 0 }, + new Rng(seed), + 'merc', + CREW_STAT_ANCHORS, + { id: `showcase-armor-${seed}` } + ); + if (member.damageReduction > 0) { + assert.equal(member.damageReduction, 1); + sawArmor = true; + } + } + assert.ok(sawArmor, 'no seed in [0, 200) rolled armor for the showcase build β€” widen the sweep'); +}); + +test('buildCrewMemberFromRollForArchetype does not perturb the caller rng beyond the callsign pick (matches buildCrewMemberFromRoll)', () => { + // Same fork-then-build shape as buildCrewMemberFromRoll β€” two independently + // constructed rngs from the same seed must produce identical results. + const a = buildCrewMemberFromRollForArchetype({ x: 0, y: 0 }, new Rng(77), 'chimera'); + const b = buildCrewMemberFromRollForArchetype({ x: 0, y: 0 }, new Rng(77), 'chimera'); + assert.equal(a.callsign, b.callsign); + assert.equal(a.baseHitChance, b.baseHitChance); + assert.equal(a.baseDodgeChance, b.baseDodgeChance); +}); diff --git a/tests/unit/game/persistence.test.ts b/tests/unit/game/persistence.test.ts index 711f9e0..c828130 100644 --- a/tests/unit/game/persistence.test.ts +++ b/tests/unit/game/persistence.test.ts @@ -31,6 +31,10 @@ import { Decker } from '../../../src/game/archetypes/Decker.js'; import { Berserk } from '../../../src/game/archetypes/Berserk.js'; import { Adept } from '../../../src/game/archetypes/Adept.js'; import { Chimera } from '../../../src/game/archetypes/Chimera.js'; +import { + DEFAULT_HIT_CHANCE_BY_ARCHETYPE, + DEFAULT_DODGE_CHANCE_BY_ARCHETYPE, +} from '../../../src/game/crewStatRoll.js'; import { PatrolHostile } from '../../../src/game/ai/PatrolHostile.js'; import { applyOverride } from '../../../src/game/mindInfluence.js'; import { Rng } from '../../../src/rng.js'; @@ -736,6 +740,63 @@ test('restoreCampaign preserves valid hitBonus below cap', () => { assert.equal(restored.crew[0].gear!.hitBonus, 0.1); }); +// --- P3.5.M6: rolled base stats round-trip ------------------------------- + +test('CampaignCrewSnapshot round-trips rolled baseHitChance/baseDodgeChance', () => { + const campaign = new Campaign({ seed: 0xc0ffee }); + const rec = snapshotCampaign(campaign); + for (const crewRec of rec.crew) { + assert.equal(typeof crewRec.baseHitChance, 'number'); + assert.equal(typeof crewRec.baseDodgeChance, 'number'); + } + const restored = restoreCampaign(rec); + for (const member of campaign.crew) { + const restoredMember = restored.crew.find(m => m.id === member.id)!; + assert.equal(restoredMember.baseHitChance, member.baseHitChance); + assert.equal(restoredMember.baseDodgeChance, member.baseDodgeChance); + } +}); + +test('restoreCampaign falls back to the archetype default when baseHitChance/baseDodgeChance are absent (pre-P3.5 save)', () => { + const campaign = new Campaign({ seed: 0xfeed }); + const rec = snapshotCampaign(campaign); + for (const crewRec of rec.crew) { + delete (crewRec as Record).baseHitChance; + delete (crewRec as Record).baseDodgeChance; + } + const restored = restoreCampaign(rec); + for (const crewRec of rec.crew) { + const member = restored.crew.find(m => m.id === crewRec.id)!; + assert.equal(member.baseHitChance, DEFAULT_HIT_CHANCE_BY_ARCHETYPE[crewRec.archetype]); + assert.equal(member.baseDodgeChance, DEFAULT_DODGE_CHANCE_BY_ARCHETYPE[crewRec.archetype]); + } +}); + +test('CampaignCrewSnapshot persists the pristine baseHitChance, not a live Berserk Crash penalty', () => { + const berserk = buildCrewMember('berserk', { x: 0, y: 0 }, new Rng(3), { + id: 'crew-berserk', + }) as Berserk; + berserk.applyEffect(STATUS_EFFECT.CRASH, 2); + const pristine = berserk.pristineBaseHitChance; + assert.ok(berserk.baseHitChance < pristine, 'precondition: Crash penalty is live'); + + const campaign = new Campaign({ seed: 1, crew: [berserk] }); + const rec = snapshotCampaign(campaign); + const crewRec = rec.crew.find(c => c.id === 'crew-berserk')!; + assert.equal( + crewRec.baseHitChance, + pristine, + 'snapshot stores the pristine roll, not the live value' + ); + + const restored = restoreCampaign(rec); + const restoredMember = restored.crew.find(m => m.id === 'crew-berserk')!; + // Restored fresh β€” no Crash effect carried over via CampaignCrewSnapshot + // (combat-run-scoped effects are out of scope for the Hub-level snapshot; + // see P3.5.M1) β€” so the live getter now reads the pristine value back. + assert.equal(restoredMember.baseHitChance, pristine); +}); + test('restore normalizes over-capped hitBonus in run entity gear', () => { const run = freshCombatRun(0xc0de, 'merc'); run.player.initGear(); diff --git a/tests/unit/game/scoreFinal.test.ts b/tests/unit/game/scoreFinal.test.ts index c709909..fdb24c7 100644 --- a/tests/unit/game/scoreFinal.test.ts +++ b/tests/unit/game/scoreFinal.test.ts @@ -33,6 +33,15 @@ import type { LocationSite } from '../../../src/types.js'; const SCORE_DOOR_ID = 'score-door-0'; +/** + * P3.5.M7: `buildScoreContract` now draws from a merged item+archetype pool. + * This file's tests predate that merge and assert deterministic seedβ†’item + * mappings β€” passing every archetype as already-"acquired" keeps the pool + * item-only, preserving those assertions untouched. Archetype-specific draw + * behavior is covered separately (Campaign.test.ts, Β§ P3.5.M7). + */ +const ALL_ARCHETYPES_ACQUIRED = ['berserk', 'adept', 'chimera']; + function scoreContract(seed = 100, mapWidth = 28, mapHeight = 18): Contract { return { seed, @@ -362,7 +371,7 @@ function scoreReadyCampaign() { test('buildScoreContract embeds an unacquired scoreable blueprint id + frames briefing', () => { const campaign = scoreReadyCampaign(); - const contract = campaign.buildScoreContract([]); + const contract = campaign.buildScoreContract([], ALL_ARCHETYPES_ACQUIRED); const itemId = contract.objective.params!.scoreItemId as string; assert.ok(SCOREABLE_ITEM_IDS.has(itemId), `payload "${itemId}" must be a known scoreable`); const item = SCOREABLE_ITEMS.find(i => i.id === itemId)!; @@ -372,16 +381,18 @@ test('buildScoreContract embeds an unacquired scoreable blueprint id + frames br test('buildScoreContract payload selection is deterministic for a campaign', () => { const campaign = scoreReadyCampaign(); - const a = campaign.buildScoreContract([]).objective.params!.scoreItemId; - const b = campaign.buildScoreContract([]).objective.params!.scoreItemId; + const a = campaign.buildScoreContract([], ALL_ARCHETYPES_ACQUIRED).objective.params!.scoreItemId; + const b = campaign.buildScoreContract([], ALL_ARCHETYPES_ACQUIRED).objective.params!.scoreItemId; assert.equal(a, b, 'same seed + same acquired set β†’ same payload'); }); test('buildScoreContract excludes already-acquired blueprints', () => { const campaign = scoreReadyCampaign(); - const firstPick = campaign.buildScoreContract([]).objective.params!.scoreItemId as string; + const firstPick = campaign.buildScoreContract([], ALL_ARCHETYPES_ACQUIRED).objective.params! + .scoreItemId as string; // Acquire the would-be pick β€” the Score must now target a different blueprint. - const nextPick = campaign.buildScoreContract([firstPick]).objective.params!.scoreItemId as string; + const nextPick = campaign.buildScoreContract([firstPick], ALL_ARCHETYPES_ACQUIRED).objective + .params!.scoreItemId as string; assert.notEqual(nextPick, firstPick); assert.ok(SCOREABLE_ITEM_IDS.has(nextPick)); }); @@ -391,15 +402,18 @@ test('buildScoreContract targets the sole remaining blueprint when all others ar const allIds = SCOREABLE_ITEMS.map(i => i.id); const remaining = allIds[3]!; // arbitrary survivor const acquired = allIds.filter(id => id !== remaining); - const pick = campaign.buildScoreContract(acquired).objective.params!.scoreItemId; + const pick = campaign.buildScoreContract(acquired, ALL_ARCHETYPES_ACQUIRED).objective.params! + .scoreItemId; assert.equal(pick, remaining); }); test('buildScoreContract never rolls a retired/foreign id passed as acquired', () => { const campaign = scoreReadyCampaign(); // A ghost id isn't in the pool, so it can't shrink it β€” selection is unchanged. - const baseline = campaign.buildScoreContract([]).objective.params!.scoreItemId; - const withGhost = campaign.buildScoreContract(['ghost-blueprint']).objective.params!.scoreItemId; + const baseline = campaign.buildScoreContract([], ALL_ARCHETYPES_ACQUIRED).objective.params! + .scoreItemId; + const withGhost = campaign.buildScoreContract(['ghost-blueprint'], ALL_ARCHETYPES_ACQUIRED) + .objective.params!.scoreItemId; assert.equal(withGhost, baseline); assert.ok(SCOREABLE_ITEM_IDS.has(withGhost as string)); }); @@ -411,7 +425,7 @@ test('buildScoreContract never rolls a retired/foreign id passed as acquired', ( test('buildScoreContract on an exhausted pool drops the blueprint id and frames an abstract target', () => { const campaign = scoreReadyCampaign(); const allAcquired = SCOREABLE_ITEMS.map(i => i.id); - const contract = campaign.buildScoreContract(allAcquired); + const contract = campaign.buildScoreContract(allAcquired, ALL_ARCHETYPES_ACQUIRED); // No blueprint id β€” a clean completion must not write the meta-store. assert.equal(contract.objective.params!.scoreItemId, undefined); // Briefing is framed from exactly one abstract category. @@ -422,8 +436,8 @@ test('buildScoreContract on an exhausted pool drops the blueprint id and frames test('abstract Score category selection is deterministic for a campaign', () => { const campaign = scoreReadyCampaign(); const allAcquired = SCOREABLE_ITEMS.map(i => i.id); - const a = campaign.buildScoreContract(allAcquired).objective.briefing; - const b = campaign.buildScoreContract(allAcquired).objective.briefing; + const a = campaign.buildScoreContract(allAcquired, ALL_ARCHETYPES_ACQUIRED).objective.briefing; + const b = campaign.buildScoreContract(allAcquired, ALL_ARCHETYPES_ACQUIRED).objective.briefing; assert.equal(a, b, 'same seed + exhausted pool β†’ same abstract briefing'); }); @@ -443,7 +457,10 @@ test('different Score seeds can draw different abstract categories', () => { scoreSite({ id: 'case-4', tier: 'roster', scoreTarget: false, seed: `${seed}04` }), ], }); - const briefing = campaign.buildScoreContract(SCOREABLE_ITEMS.map(i => i.id)).objective.briefing; + const briefing = campaign.buildScoreContract( + SCOREABLE_ITEMS.map(i => i.id), + ALL_ARCHETYPES_ACQUIRED + ).objective.briefing; for (const t of ABSTRACT_SCORE_TARGETS) { if (briefing.includes(t.label)) labels.add(t.id); } @@ -458,7 +475,11 @@ test('a clean abstract Score settles the arc but writes no meta-store unlock', ( const partner = campaign.crew.find(member => member.archetype !== 'Decker')!; const beforeCredits = campaign.credits; campaign - .deployCrewMember(decker.id, campaign.buildScoreContract(allAcquired), partner.id) + .deployCrewMember( + decker.id, + campaign.buildScoreContract(allAcquired, ALL_ARCHETYPES_ACQUIRED), + partner.id + ) .enterCombat(); campaign.onJobEnd({ outcome: OUTCOME.EXIT, completed: true }); @@ -474,7 +495,7 @@ test('clean Score completion records the stolen blueprint for the meta-store', ( const campaign = scoreReadyCampaign(); const decker = campaign.crew.find(member => member.archetype === 'Decker')!; const partner = campaign.crew.find(member => member.archetype !== 'Decker')!; - const contract = campaign.buildScoreContract([]); + const contract = campaign.buildScoreContract([], ALL_ARCHETYPES_ACQUIRED); const expectedId = contract.objective.params!.scoreItemId as string; campaign.deployCrewMember(decker.id, contract, partner.id).enterCombat(); @@ -487,7 +508,13 @@ test('partial Score records no blueprint unlock (prototype not secured)', () => const campaign = scoreReadyCampaign(); const decker = campaign.crew.find(member => member.archetype === 'Decker')!; const partner = campaign.crew.find(member => member.archetype !== 'Decker')!; - campaign.deployCrewMember(decker.id, campaign.buildScoreContract([]), partner.id).enterCombat(); + campaign + .deployCrewMember( + decker.id, + campaign.buildScoreContract([], ALL_ARCHETYPES_ACQUIRED), + partner.id + ) + .enterCombat(); campaign.onJobEnd({ outcome: OUTCOME.EXIT, completed: false }); assert.equal(campaign.endReason, 'score-partial'); @@ -531,7 +558,7 @@ test('recorded Score unlock survives campaign snapshot/restore (resume can still const campaign = scoreReadyCampaign(); const decker = campaign.crew.find(member => member.archetype === 'Decker')!; const partner = campaign.crew.find(member => member.archetype !== 'Decker')!; - const contract = campaign.buildScoreContract([]); + const contract = campaign.buildScoreContract([], ALL_ARCHETYPES_ACQUIRED); const expectedId = contract.objective.params!.scoreItemId as string; campaign.deployCrewMember(decker.id, contract, partner.id).enterCombat(); campaign.onJobEnd({ outcome: OUTCOME.EXIT, completed: true }); From ad9bdac4ce90791a3cc39d2655b7c31bb816977a Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 14 Jul 2026 22:23:11 -0700 Subject: [PATCH 9/9] bump release --- package.json | 2 +- sw-dev.js | 2 +- sw-release.js | 4 ++-- sw.js | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 660fbb6..e3535dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kernel-panic", - "version": "0.3.2-balance", + "version": "0.3.5", "description": "Turn-based cyberpunk roguelike PWA (ASCII-plus terminal aesthetic)", "main": "index.html", "type": "module", diff --git a/sw-dev.js b/sw-dev.js index 7e17288..c389e3d 100644 --- a/sw-dev.js +++ b/sw-dev.js @@ -1,5 +1,5 @@ // Service Worker for Kernel Panic - Development Version -const VERSION = '0.3.5c'; +const VERSION = '0.3.5'; importScripts(`/sw-release.js?v=${VERSION}`); importScripts(`/sw-core.js?v=${VERSION}-dev`); diff --git a/sw-release.js b/sw-release.js index 837e3eb..48dd7a2 100644 --- a/sw-release.js +++ b/sw-release.js @@ -2,11 +2,11 @@ // Increment shellEpoch only when the new app shell cannot safely coexist with // the previous worker (for example, removed or renamed runtime modules). self.KernelPanicRelease = Object.freeze({ - version: '0.3.5c', + version: '0.3.5', shellEpoch: 2, title: 'Rolled crew stats & archetype unlocks', highlights: Object.freeze([ 'Crew stats are now rolled, not picked β€” every operator has a distinct hit/dodge profile.', - 'Berserk, Adept, and Chimera start locked; win a clean Score to unlock one for future crews.', + '3 new crew classes: Berserk, Adept, and Chimera; unlock one with a clean Score.', ]), }); diff --git a/sw.js b/sw.js index 070712e..61e8e18 100644 --- a/sw.js +++ b/sw.js @@ -1,6 +1,6 @@ // Service Worker for Kernel Panic - Production Version // Import shared caching core with cache-busting query parameter -const VERSION = '0.3.5c'; +const VERSION = '0.3.5'; importScripts(`/sw-release.js?v=${VERSION}`); importScripts(`/sw-core.js?v=${VERSION}`);