diff --git a/components/CombatInventory.ts b/components/CombatInventory.ts index 309f1b2..f2e5f9d 100644 --- a/components/CombatInventory.ts +++ b/components/CombatInventory.ts @@ -13,7 +13,7 @@ import { h } from '/src/domUtils.js'; import { emptySalvage, type TypedSalvage } from '/src/game/salvage.js'; -import type { Item } from '/src/game/items.js'; +import { getItemById, type Item } from '/src/game/items.js'; import type { KeyItemView } from '/src/shell/domTypes.js'; import { INVENTORY_CSS, InventoryOverlay } from '/components/InventoryOverlay.js'; @@ -21,13 +21,6 @@ type CombatInventoryItem = Omit { + const path = GEAR_LEADER_PATHS[key]; + if (!path) throw new Error(`Missing crew-roster gear leader path for ${key}`); + return ``; + }) + .join(''); + const svg = CreateSvg(paths, 250, 300, 'gear-leaders'); + svg.setAttribute('viewBox', '0 0 250 300'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('focusable', 'false'); + return svg; +} + const CSS = ` :host { --roster-bg: rgba(7, 18, 16, 0.96); @@ -121,6 +136,7 @@ const CSS = ` --roster-danger: #ff5d73; --roster-shadow: 0 0 28px rgba(0, 217, 165, 0.18), 0 12px 36px rgba(0, 0, 0, 0.5); --roster-border-secondary: #a4d1cc; + --gear-label-bg: rgba(0, 217, 165, 0.18); display: none; position: fixed; @@ -265,17 +281,112 @@ crew-list { margin-bottom: 0.15rem; } -.detail-stat { - color: var(--roster-text); - margin: 0.1rem 0; -} - .detail-none { color: var(--roster-dim); font-style: italic; margin: 0.1rem; } +.gear { + height: 300px; + margin: 0.5rem 0; + background: no-repeat center url('/images/gear.png'); + background-size: contain; + + position: relative; +} + +.gear-leaders { + position: absolute; + top: 0; + left: 50%; + width: 250px; + height: 300px; + transform: translateX(-50%); + overflow: visible; + color: var(--roster-border-secondary); + pointer-events: none; +} + +.gear-leader { + fill: none; + stroke: currentColor; + stroke-width: 1; + stroke-linecap: square; + stroke-linejoin: miter; + vector-effect: non-scaling-stroke; + opacity: 0.85; +} + +.gear-label { + color: var(--roster-text); + font-size: smaller; + margin: 0; + max-width: 115px; + text-align: center; + position: absolute; + background: var(--gear-label-bg); + border-radius: 2px; + border-bottom: 1px solid var(--roster-border-secondary); + padding: 2px; + box-sizing: border-box; + z-index: 1; + + &.maxHpBonus { + top: 0.75rem; + left: calc(50% - 120px); + width: 92px; + } + + &.hpRegen { + top: 8.25rem; + left: calc(50% + 45px); + width: 86px; + } + + &.apBonus { + bottom: 0.5rem; + left: calc(50% + 51px); + width: 64px; + } + + &.hitBonus { + top: 0; + left: calc(50% + 21px); + width: 120px; + } + + &.rangedDamageBonus { + top: 4rem; + left: calc(50% + 51px); + width: 88px; + } + + &.meleeDamageBonus { + top: 10rem; + left: calc(50% - 117px); + width: 80px; + } + + &.armorBonus { + top: 4.5rem; + left: calc(50% - 125px); + width: 72px; + } + + &.shieldRegen { + top: 14.4rem; + left: calc(50% - 125px); + width: 88px; + } + + &.dodgeBonus { + top: 11.75rem; + left: calc(50% + 43px); + width: 92px; + } +} + .consumables { display: flex; gap: 0.5rem; @@ -654,14 +765,16 @@ class CrewRoster extends HTMLElement { this.#detailEl!.appendChild(statsTable(full)); // Gear - const gLines = gearLines(full.gear); - const gearSection = h('div', { className: 'detail-section' }); - gearSection.appendChild(h('p', { className: 'detail-section-title', textContent: 'GEAR' })); - if (gLines.length === 0) { - gearSection.appendChild(h('p', { className: 'detail-none', textContent: '-None-' })); + const gLabels = gearLabels(full.gear); + const gearSection = h('div', { className: 'detail-section gear' }); + // gearSection.appendChild(h('p', { className: 'detail-section-title', textContent: 'GEAR' })); + if (Object.keys(gLabels).length === 0) { + gearSection.appendChild(h('p', { className: 'detail-none', textContent: '-No Gear-' })); } else { - for (const line of gLines) { - gearSection.appendChild(h('p', { className: 'detail-stat', textContent: line })); + const keys = Object.keys(gLabels); + gearSection.appendChild(gearLeaders(keys)); + for (const [key, label] of Object.entries(gLabels)) { + gearSection.appendChild(h('p', { className: `gear-label ${key}`, textContent: label })); } } this.#detailEl!.appendChild(gearSection); diff --git a/components/FinnShop.ts b/components/FinnShop.ts index e022cc9..208ff21 100644 --- a/components/FinnShop.ts +++ b/components/FinnShop.ts @@ -363,11 +363,11 @@ const SCOPE_LABELS: Record = { const ITEM_CAP_LABELS: Record = { [ITEM_ID.TARGETING_CHIP]: 'MAX HIT', - [ITEM_ID.BALLISTICS_COIL]: 'MAX RANGED DMG', - [ITEM_ID.REFLEX_WEAVE]: 'MAX DODGE', + [ITEM_ID.RIP_ROUNDS]: 'MAX RANGED DMG', + [ITEM_ID.GHOST_WEAVE]: 'MAX DODGE', [ITEM_ID.MONOBLADE]: 'EQUIPPED', [ITEM_ID.SUBDERMAL_PLATING]: 'EQUIPPED', - [ITEM_ID.REFLEX_BOOSTER]: 'EQUIPPED', + [ITEM_ID.ADRENAL_SPIKE]: 'EQUIPPED', [ITEM_ID.PHASE_SHIELD]: 'EQUIPPED', [ITEM_ID.REGEN_MESH]: 'EQUIPPED', }; diff --git a/docs/kaizen.md b/docs/kaizen.md index 0135ed0..3a93334 100644 --- a/docs/kaizen.md +++ b/docs/kaizen.md @@ -48,6 +48,12 @@ When an item lands, gets reclassified, or develops new context, edit it in place ## ◇ Monitored +- **Debug save Import relies on `prompt()`.** The in-app browser used for local smoke tests + does not support native `prompt()`, so `/debug/save.html` logs an error and cannot import a + prepared save there. Normal browsers may still support it, but the debug surface should use + an explicit `` with a textarea so import is inspectable, testable, and consistent + across browser hosts. Surfaced during the defense-HUD smoke test (2026-07-13); deferred + because it is unrelated to the player-facing combat HUD. - **`Crew.applyGear` and `Crew.gearAtCap` switch over the same item→channel map.** The limit-1 purchase guardrail (2026-07-10 — `Campaign.purchase` refuses gear a target has already saturated, and `` greys the same targets out) added `gearAtCap(itemId)` as the single source of truth for "would this purchase be a no-op?". It mirrors `applyGear`'s per-item switch (each gear id → its bonus field + cap): adding a new gear item now means editing *both* methods. Drift is fenced by a contract test (`Crew.test.ts` — "gearAtCap reports every limit-1 gear item saturated after one apply") that fails loudly if a new item is applied but not recognized as saturated. Consolidate opportunistically: collapse the two switches into one `GEAR_CHANNELS` table (id → `{ get, cap, step, apply }`) when next adding gear. Deferred because `applyGear`'s side effects vary (Armour Plating/Reflex Booster touch live stats + immediate benefit; regen items are pure rates), so a clean unified table is more than a mechanical extract. Low drift risk at 9 gear items; revisit when the catalog next grows. - **NeutralCivilian `#flee` bypasses `world.moveEntity`.** Flagged TIER-1 in the [2026-05-17 adversarial review](./2026-05-17-adversarial-review-findings.md#3) as a TOCTOU (two civilians flee to one tile). Triaged at Phase 2 closeout and **downgraded**: `runPlayerAftermathSteps` calls each civilian's `act()` strictly sequentially and `#flee` mutates position immediately, so a later civilian's `entityAt()` check always sees the earlier civilian's *new* tile — the double-occupancy race needs deferred/batched application that doesn't exist. The `moveEntity` bypass is intentional (documented in `NeutralCivilian.ts` — civilians have `maxAp=1` for the Entity contract but don't participate in the AP economy) and still emits `ENTITY_MOVED`. Revisit if player aftermath ever becomes batched/concurrent, or if a fleeing civilian should generate a noise event for drones. - **Adversarial review TIER 2–4 items remain open.** The [2026-05-17 review](./2026-05-17-adversarial-review-findings.md) lists state-hygiene near-bugs (dead-drone event subscriptions, `corpNoiseForTurn` module cache, Merc vault knockback bypassing `moveEntity`) and style/complexity items (`restoreEntity` length, mixed visibility conventions, detached JSDoc). None are live correctness bugs; address opportunistically when next touching those files. The TIER-1 bugs were resolved at Phase 2 closeout (see Closed). diff --git a/docs/phase-3-plan.md b/docs/phase-3-plan.md index 4773971..8f1b1b8 100644 --- a/docs/phase-3-plan.md +++ b/docs/phase-3-plan.md @@ -516,6 +516,18 @@ duplicate purchase is a harmless no-op). **Known transitional state:** between M the shop shows only `DEFAULT_ITEMS`, so the former KNOWN gear is temporarily unbuyable until the M6.3 unlocked-item merge lands. +**Defense HUD follow-up (2026-07-13):** the active Meatspace HUD now groups defenses beside +HP: Phase Shield is a live `SH ◆` / spent `SH ◇` resource, while Subdermal Plating remains a +persistent numeric `ARM 1` modifier. Unequipped channels are omitted, so absent and spent +shield states cannot be confused. Connected ranged/melee hits now carry an explicit damage +resolution (`incomingDamage`, armor absorbed, shield absorbed, real HP damage); hostile-turn +copy surfaces the layers only when armor or shield actually changes the outcome. Combat entry +runs the normal first-player-turn refresh, so an equipped Phase Shield begins charged rather +than appearing spent until round two. The HUD's screen-reader summary exposes the same states; +fully absorbed body hits retain the impact shake and replace the red damage vignette/PIP pulse +with the stopping defense's HUD color (shield takes precedence when both layers contribute). +SW cache **`0.3.2b`**. + **Follow-up (kaizen, tabled):** a **revive** path — un-flatline a crew member at the Hub for a steep Cred cost, zeroing their gear (and resetting the derived maxHp/maxAp/damageReduction to archetype base). Pure Hub economy + narrative, no combat-mechanics implications. Needs a diff --git a/images/gear.png b/images/gear.png new file mode 100644 index 0000000..3ee6499 Binary files /dev/null and b/images/gear.png differ diff --git a/images/molotov.png b/images/molotov.png new file mode 100644 index 0000000..37917f2 Binary files /dev/null and b/images/molotov.png differ diff --git a/main.css b/main.css index 84b7580..573e437 100644 --- a/main.css +++ b/main.css @@ -342,8 +342,8 @@ main { @keyframes pip-hit-pulse { 0% { - border-color: #ff7a66; - box-shadow: 0 0 16px rgba(255, 122, 102, 0.55); + border-color: var(--kp-pip-impact-color, #ff7a66); + box-shadow: 0 0 16px var(--kp-pip-impact-color, rgba(255, 122, 102, 0.55)); } 100% { border-color: rgba(0, 217, 165, 0.45); @@ -354,7 +354,7 @@ main { @media (prefers-reduced-motion: reduce) { .game-canvas-wrap > #pip-canvas.pip-hit { animation: none; - border-color: #ff7a66; + border-color: var(--kp-pip-impact-color, #ff7a66); } } @@ -441,10 +441,10 @@ main { /* --------------------------------------------------------------------------- * M0 combat feedback animations. * - * Shake translates the whole stage (canvas + any overlay); the damage - * vignette is a pseudo-element radial gradient that fades in then out. Both - * are restarted by the JS side via remove-then-re-add of the class so two - * hits in a row replay rather than coalesce. Durations are mirrored in + * Shake translates the whole stage (canvas + any overlay); damage and fully + * mitigated hits share a pseudo-element radial vignette, keyed red or to the + * defense HUD color. Effects restart via remove-then-re-add of the class so + * two hits in a row replay rather than coalesce. Durations are mirrored in * `src/render/animations.js#ANIMATION_DURATIONS`. * ------------------------------------------------------------------------ */ @@ -479,8 +479,8 @@ main { inset: 0; background: radial-gradient( circle at center, - rgba(255, 30, 30, 0) 35%, - rgba(255, 30, 30, 0.55) 100% + transparent 35%, + var(--kp-impact-flash-color, rgba(255, 30, 30, 0.55)) 100% ); opacity: 0; pointer-events: none; @@ -491,6 +491,10 @@ main { animation: kp-damage-flash 300ms ease-out; } +.game-stage.kp-mitigation-flash::after { + animation: kp-damage-flash 300ms ease-out; +} + @keyframes kp-damage-flash { 0% { opacity: 0; @@ -505,7 +509,8 @@ main { @media (prefers-reduced-motion: reduce) { .game-stage.kp-shake, - .game-stage.kp-damage-flash::after { + .game-stage.kp-damage-flash::after, + .game-stage.kp-mitigation-flash::after { animation: none; } } diff --git a/src/game/Combat.ts b/src/game/Combat.ts index 1c3941f..3676c57 100644 --- a/src/game/Combat.ts +++ b/src/game/Combat.ts @@ -21,7 +21,7 @@ * an illegal call it throws *before* mutating state. */ -import type { MeleeAttackResult, RangedAttackResult } from '../types.js'; +import type { DamageResolution, MeleeAttackResult, RangedAttackResult } from '../types.js'; import type { Entity } from './Entity.js'; import type { World } from './World.js'; import type { Rng } from '../rng.js'; @@ -175,11 +175,17 @@ export function resolveRanged( let damage = 0; let killed = false; + let damageResolution: DamageResolution | undefined; if (hit) { const intendedDamage = attackerRangedDamage(attacker, options.damage); - const mitigatedDamage = applyDamageReduction(intendedDamage, target); - const appliedDamage = target.damage(mitigatedDamage); - damage = appliedDamage === 0 ? 0 : mitigatedDamage; + damageResolution = resolveConnectedDamage(intendedDamage, target); + // Preserve the established result/event `damage` contract: post-armor + // attack damage when any HP lands (including overkill). Consumers that + // need exact HP loss or shield breakdown use `damageResolution`. + damage = + damageResolution.hpDamage === 0 + ? 0 + : damageResolution.incomingDamage - damageResolution.armorAbsorbed; killed = !target.alive; // Emit only on a connected hit. Misses still tick the noise model below // (a shot is loud regardless) — that's a separate `noise` event. @@ -187,6 +193,7 @@ export function resolveRanged( attacker, target, damage, + damageResolution, killed, source: 'ranged', }); @@ -201,7 +208,7 @@ export function resolveRanged( kind: 'ranged', }); - return { hit, roll, threshold, inCover, damage, killed }; + return { hit, roll, threshold, inCover, damage, killed, damageResolution }; } /** @@ -294,17 +301,21 @@ export function resolveMelee( const hit = !dodged; let damage = 0; let killed = false; + let damageResolution: DamageResolution | undefined; if (hit) { const intendedDamage = attackerMeleeDamage(attacker, options.damage); - const mitigatedDamage = applyDamageReduction(intendedDamage, target); - const appliedDamage = target.damage(mitigatedDamage); - damage = appliedDamage === 0 ? 0 : mitigatedDamage; + damageResolution = resolveConnectedDamage(intendedDamage, target); + damage = + damageResolution.hpDamage === 0 + ? 0 + : damageResolution.incomingDamage - damageResolution.armorAbsorbed; killed = !target.alive; } world.events?.emit(EVENT.ENTITY_DAMAGED, { attacker, target, damage, + damageResolution, killed, source: 'melee', dodged, @@ -318,7 +329,16 @@ export function resolveMelee( source: attacker, kind: 'melee', }); - return { hit, dodged, roll, dodgeThreshold, inCover, damage, killed }; + return { hit, dodged, roll, dodgeThreshold, inCover, damage, killed, damageResolution }; +} + +function resolveConnectedDamage(intendedDamage: number, target: Entity): DamageResolution { + const mitigatedDamage = applyDamageReduction(intendedDamage, target); + const armorAbsorbed = intendedDamage - mitigatedDamage; + const shieldBefore = target.shieldHp; + const hpDamage = target.damage(mitigatedDamage); + const shieldAbsorbed = shieldBefore - target.shieldHp; + return { incomingDamage: intendedDamage, armorAbsorbed, shieldAbsorbed, hpDamage }; } function applyDamageReduction(intendedDamage: number, target: Entity): number { diff --git a/src/game/Crew.ts b/src/game/Crew.ts index 02c2178..d430c01 100644 --- a/src/game/Crew.ts +++ b/src/game/Crew.ts @@ -13,6 +13,7 @@ import { BREACHING_CHARGE_RANGE, TARGETING_BONUS, TURRET_DAMAGE, + TURRET_MAX_HP, RANGED_DAMAGE_BONUS, RANGED_MAX_DAMAGE_BONUS, MELEE_DAMAGE_BONUS, @@ -49,7 +50,7 @@ export type Gear = { * - `meleeDamageBonus` — Monoblade; added in {@link Crew.meleeAttackDamage}. * - `armorBonus` — Subdermal Plating; tracks the value applied to * `damageReduction` (the live combat stat). - * - `apBonus` — Reflex Booster; tracks the value added to `maxAp`. + * - `apBonus` — Adrenal Spike; tracks the value added to `maxAp`. * - `shieldRegen` — Phase Shield; `shieldHp` re-granted each turn in * {@link Crew.refreshAp}. * - `hpRegen` — Regen Mesh; HP healed each turn in the same hook. @@ -159,7 +160,7 @@ export class Crew extends Entity { /** * Archetype base ranged damage (gear excluded). Overridden on Merc; Tech and * Razor use {@link RANGED_DAMAGE}. Outgoing shot damage is - * {@link rangedAttackDamage} (base + Ballistics Coil). + * {@link rangedAttackDamage} (base + RiP Rounds). */ get rangedDamage(): number { return RANGED_DAMAGE; @@ -184,12 +185,12 @@ export class Crew extends Entity { return 1 - this.baseDodgeChance; } - /** Cap for {@link ITEM_ID.BALLISTICS_COIL} stacks on this operator. */ + /** Cap for {@link ITEM_ID.RIP_ROUNDS} stacks on this operator. */ get maxRangedDamageBonus(): number { return RANGED_MAX_DAMAGE_BONUS; } - /** Capped Ballistics Coil bonus for this operator's outgoing ranged damage. */ + /** Capped RiP Rounds bonus for this operator's outgoing ranged damage. */ get effectiveRangedDamageBonus(): number { return Math.min(this.gear?.rangedDamageBonus ?? 0, this.maxRangedDamageBonus); } @@ -224,7 +225,7 @@ export class Crew extends Entity { return MAX_ARMOR_BONUS; } - /** Cap for the {@link ITEM_ID.REFLEX_BOOSTER} AP bonus. */ + /** Cap for the {@link ITEM_ID.ADRENAL_SPIKE} AP bonus. */ get maxApBonus(): number { return MAX_AP_BONUS; } @@ -240,13 +241,14 @@ export class Crew extends Entity { } /** - * Stats for a player turret this crew member deploys (Tech). HP mirrors the - * owner's current max (includes Armour Plating); damage uses the same coil - * bonus on the turret base (`TURRET_DAMAGE`). + * Stats for a player turret this crew member deploys (Tech). HP is the + * turret's own base (`TURRET_MAX_HP`) — a machine, so it does NOT inherit + * owner body augments like Bone Lacing. Damage still inherits the owner's + * ranged (RiP Rounds) bonus on the turret base (`TURRET_DAMAGE`). */ turretDeployProfile(): { maxHp: number; attackDamage: number } { return { - maxHp: this.maxHp, + maxHp: TURRET_MAX_HP, attackDamage: TURRET_DAMAGE + this.effectiveRangedDamageBonus, }; } @@ -314,7 +316,7 @@ export class Crew extends Entity { applyGear(itemId: string) { this.initGear(); switch (itemId) { - case ITEM_ID.ARMOUR_PLATING: + case ITEM_ID.BONE_LACING: this.gear!.maxHpBonus += 1; this.maxHp += 1; this.hp += 1; // immediate benefit — no need to heal it @@ -322,36 +324,36 @@ export class Crew extends Entity { case ITEM_ID.TARGETING_CHIP: this.gear!.hitBonus = Math.min(this.gear!.hitBonus + TARGETING_BONUS, this.maxHitBonus); break; - case ITEM_ID.REFLEX_WEAVE: + case ITEM_ID.GHOST_WEAVE: this.gear!.dodgeBonus = Math.min( (this.gear!.dodgeBonus ?? 0) + DODGE_BONUS, this.maxDodgeBonus ); break; - case ITEM_ID.BALLISTICS_COIL: + case ITEM_ID.RIP_ROUNDS: this.gear!.rangedDamageBonus = Math.min( (this.gear!.rangedDamageBonus ?? 0) + RANGED_DAMAGE_BONUS, this.maxRangedDamageBonus ); break; case ITEM_ID.MONOBLADE: - // Tracking-only bonus, read via `meleeAttackDamage`. Capped like the coil. + // Tracking-only bonus, read via `meleeAttackDamage`. Capped like RiP Rounds. this.gear!.meleeDamageBonus = Math.min( (this.gear!.meleeDamageBonus ?? 0) + MELEE_DAMAGE_BONUS, this.maxMeleeDamageBonus ); break; case ITEM_ID.SUBDERMAL_PLATING: { - // Mirror Armour Plating: `damageReduction` is the live stat, `armorBonus` + // Mirror Bone Lacing: `damageReduction` is the live stat, `armorBonus` // tracks it. Delta-apply so a capped second purchase is a no-op. const next = Math.min((this.gear!.armorBonus ?? 0) + ARMOR_BONUS, this.maxArmorBonus); this.damageReduction += next - (this.gear!.armorBonus ?? 0); this.gear!.armorBonus = next; break; } - case ITEM_ID.REFLEX_BOOSTER: { + case ITEM_ID.ADRENAL_SPIKE: { // `maxAp` is the live stat, `apBonus` tracks it. Immediate benefit: the - // extra AP is available this turn too (matches Armour Plating's +hp). + // extra AP is available this turn too (matches Bone Lacing's +hp). const next = Math.min((this.gear!.apBonus ?? 0) + AP_BONUS, this.maxApBonus); const delta = next - (this.gear!.apBonus ?? 0); this.maxAp += delta; @@ -382,8 +384,8 @@ export class Crew extends Entity { * Creds) and the shop UI (which greys out an already-equipped operator). * * Every net-new P3.M6.2 item is limit-1 (`bonus === cap`), so one purchase - * saturates it; the older stacking gear (Targeting Chip, Reflex Weave) reports - * saturated only once fully stacked to its per-archetype cap. Armour Plating is + * saturates it; the older stacking gear (Targeting Chip, Ghost Weave) reports + * saturated only once fully stacked to its per-archetype cap. Bone Lacing is * unbounded (no cap) and therefore never saturates — it stays re-purchasable. * * Throws on a non-gear id: only CAMPAIGN-scope items reach here, and a typo @@ -393,19 +395,19 @@ export class Crew extends Entity { gearAtCap(itemId: string): boolean { const g = this.gear; switch (itemId) { - case ITEM_ID.ARMOUR_PLATING: + case ITEM_ID.BONE_LACING: return false; // unbounded (+1 maxHp per purchase) — never saturates case ITEM_ID.TARGETING_CHIP: return (g?.hitBonus ?? 0) >= this.maxHitBonus; - case ITEM_ID.REFLEX_WEAVE: + case ITEM_ID.GHOST_WEAVE: return (g?.dodgeBonus ?? 0) >= this.maxDodgeBonus; - case ITEM_ID.BALLISTICS_COIL: + case ITEM_ID.RIP_ROUNDS: return (g?.rangedDamageBonus ?? 0) >= this.maxRangedDamageBonus; case ITEM_ID.MONOBLADE: return (g?.meleeDamageBonus ?? 0) >= this.maxMeleeDamageBonus; case ITEM_ID.SUBDERMAL_PLATING: return (g?.armorBonus ?? 0) >= this.maxArmorBonus; - case ITEM_ID.REFLEX_BOOSTER: + case ITEM_ID.ADRENAL_SPIKE: return (g?.apBonus ?? 0) >= this.maxApBonus; case ITEM_ID.PHASE_SHIELD: return (g?.shieldRegen ?? 0) >= this.maxShieldRegen; @@ -487,7 +489,7 @@ export class Crew extends Entity { } // Validate aim/no-aim symmetry before mutating state — a mismatched call // is a wiring bug in the shell, not a recoverable runtime condition. - const isAimed = itemId === ITEM_ID.INCENDIARY || itemId === ITEM_ID.BREACHING_CHARGE; + const isAimed = itemId === ITEM_ID.MOLOTOV || itemId === ITEM_ID.BREACHING_CHARGE; if (isAimed && !aim) { throw new Error(`useConsumable: "${itemId}" requires aim direction`); } @@ -522,7 +524,7 @@ export class Crew extends Entity { // shell can place SMOKE tiles on the grid. The crew member's position // is the center; radius comes from constants. return { type: 'smoke', cx: this.x, cy: this.y, radius: SMOKE_RADIUS }; - case ITEM_ID.INCENDIARY: { + case ITEM_ID.MOLOTOV: { // Thrown: target tile is `thrower + dir * INCENDIARY_THROW_DIST`. // LOS-clear-target validation is the shell's job (it owns the Grid / // World refs); Crew just reports the intended center. The shell may diff --git a/src/game/Run.ts b/src/game/Run.ts index f869a62..4cb4f32 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -1621,7 +1621,7 @@ export class Run { /** * Prep a crew member for the Meatspace grid — spawn position, full AP, combat - * inventory. HP persists across jobs (no reset; Armour Plating via Finn is the + * inventory. HP persists across jobs (no reset; Bone Lacing via Finn is the * only Hub-side HP recovery, stims are combat-only). Shared by the deployed * operator (`#makePlayer`) and the dual-deploy partner spawned at jack-in. */ @@ -1629,10 +1629,13 @@ export class Run { crew.x = spawn.x; crew.y = spawn.y; crew.maxAp = 4; - crew.ap = crew.maxAp; crew.alive = true; crew.stealthed = false; crew.frozen = false; + // Combat opens on the player's first turn. Use the normal turn boundary so + // AP and per-turn gear (notably Phase Shield) start in the same state they + // will have on every subsequent player activation. + crew.refreshAp(); crew.initInventory(); if (crew instanceof Tech) { crew.turretReady = true; @@ -2368,7 +2371,7 @@ export class Run { const roll = this.rng.next(); const count = roll < 0.25 ? 0 : roll < 0.75 ? 1 : 2; if (count === 0) return; - const pool = [ITEM_ID.STIM, ITEM_ID.SMOKE_CHARGE, ITEM_ID.INCENDIARY]; + const pool = [ITEM_ID.STIM, ITEM_ID.SMOKE_CHARGE, ITEM_ID.MOLOTOV]; for (let i = 0; i < count; i++) { const anchor = findConsumablePickupAnchor(this.world, this.player, this.exitTile, this.rng); if (!anchor) break; // No legal tile left — stop trying rather than throw. diff --git a/src/game/constants.ts b/src/game/constants.ts index 945a064..bee3be9 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -141,14 +141,15 @@ export const JACK_OUT_SHOCK_DAMAGE = 3; * drones but doesn't dominate the engagement. * - `TURRET_DAMAGE` — flat 2; matches the Merc's heavy sidearm (see `MERC_RANGED_DAMAGE`). * - `TURRET_SHOTS_PER_AFTERMATH` — two resolve passes per turret each player yield (2.6.5). - * Deployed turrets copy the owner's {@link Crew.maxHp} at drop time (Armour Plating applies). + * Deployed turrets always use `TURRET_MAX_HP` — they do not inherit the owner's + * {@link Crew.maxHp} or body augments like Bone Lacing. */ export const TURRET_MAX_HP = 3; export const TURRET_RANGE = 4; export const TURRET_DAMAGE = 2; /** Autofire passes per live player turret during {@link runPlayerAftermathSteps}. */ export const TURRET_SHOTS_PER_AFTERMATH = 2; -/** Per Ballistics Coil purchase — applies to owner ranged shots and deployed turrets. */ +/** Per RiP Rounds purchase — applies to owner ranged shots and deployed turrets. */ export const RANGED_DAMAGE_BONUS = 1; export const RANGED_MAX_DAMAGE_BONUS = 1; @@ -372,7 +373,7 @@ export const DODGE_BONUS = 0.1; * gear path. +1 melee damage, applied via {@link Crew.meleeAttackDamage}. * - **Subdermal Plating** (`ARMOR_BONUS`) — flat `damageReduction` (min-1 floor * in `Combat.applyDamageReduction`); the channel existed but no crew gear set it. - * - **Reflex Booster** (`AP_BONUS`) — +1 `maxAp`, the master action resource. + * - **Adrenal Spike** (`AP_BONUS`) — +1 `maxAp`, the master action resource. * Capped hard at 1: two extra AP would warp the turn economy. * - **Phase Shield** (`SHIELD_REGEN`) — re-grants `shieldHp` at the start of each * crew turn via {@link Crew.refreshAp}. Free and uncontested (unlike the @@ -416,18 +417,18 @@ export const SALVAGE_SELL_RATE = Object.freeze({ export const SHOP_COST = Object.freeze({ STIM: 20, SMOKE_CHARGE: 30, - INCENDIARY: 40, + MOLOTOV: 40, BREACHING_CHARGE: 45, - ARMOUR_PLATING: 60, + BONE_LACING: 60, TARGETING_CHIP: 80, - REFLEX_WEAVE: 80, - BALLISTICS_COIL: 80, + GHOST_WEAVE: 80, + RIP_ROUNDS: 80, // Net-new scoreable gear (P3.M6.2) — priced above baseline KNOWN gear; the - // reward is the unlock, the Cred cost is the install. Reflex Booster (+1 AP) + // reward is the unlock, the Cred cost is the install. Adrenal Spike (+1 AP) // is the priciest, matching its outsized impact on the turn economy. MONOBLADE: 90, SUBDERMAL_PLATING: 100, - REFLEX_BOOSTER: 150, + ADRENAL_SPIKE: 150, PHASE_SHIELD: 110, REGEN_MESH: 120, }); diff --git a/src/game/corpTurnStatusCopy.ts b/src/game/corpTurnStatusCopy.ts index b127918..322247b 100644 --- a/src/game/corpTurnStatusCopy.ts +++ b/src/game/corpTurnStatusCopy.ts @@ -10,7 +10,7 @@ import { Flanker } from './ai/Flanker.js'; import { Turret } from './Turret.js'; import { isConcealedFromPlayer } from './playerPerception.js'; import type { World } from './World.js'; -import type { TurnActionStep } from '../types.js'; +import type { DamageResolution, TurnActionStep } from '../types.js'; type IsVisibleFn = (x: number, y: number) => boolean; @@ -171,6 +171,7 @@ export function formatCorpTurnStep( `${actorLabel} fires at ${targetLabel} — ` + `${r.hit ? 'HIT' : 'miss'} (roll ${r.roll.toFixed(2)} vs ${r.threshold.toFixed(2)}` + `${r.inCover ? ', cover' : ''}).` + + formatDefenseResolution(r.damageResolution) + (r.killed ? ` ${targetLabel.toUpperCase()} DOWN.` : '') ); } @@ -181,6 +182,7 @@ export function formatCorpTurnStep( `${actorLabel} strikes ${targetLabel} — ` + `${r.hit ? 'HIT' : r.dodged ? 'dodged' : 'miss'} ` + `(roll ${r.roll.toFixed(2)} vs ${r.dodgeThreshold.toFixed(2)}${r.inCover ? ', cover' : ''}).` + + formatDefenseResolution(r.damageResolution) + (step.knockback ? ` ${targetLabel} is shoved to (${step.knockback.x}, ${step.knockback.y}).` : '') + @@ -194,6 +196,7 @@ export function formatCorpTurnStep( `${actorLabel} lays down suppressing fire on ${targetLabel} — ` + `${r.hit ? 'HIT' : 'miss'} (roll ${r.roll.toFixed(2)} vs ${r.threshold.toFixed(2)}` + `${r.inCover ? ', cover' : ''}).` + + formatDefenseResolution(r.damageResolution) + (r.killed ? ` ${targetLabel.toUpperCase()} DOWN.` : '') ); } @@ -249,3 +252,14 @@ export function formatCorpTurnStep( return null; } } + +function formatDefenseResolution(resolution: DamageResolution | undefined): string { + if (!resolution || (resolution.armorAbsorbed === 0 && resolution.shieldAbsorbed === 0)) { + return ''; + } + const layers = [`${resolution.incomingDamage}`]; + if (resolution.armorAbsorbed > 0) layers.push(`ARMOR -${resolution.armorAbsorbed}`); + if (resolution.shieldAbsorbed > 0) layers.push(`SHIELD -${resolution.shieldAbsorbed}`); + const outcome = resolution.hpDamage > 0 ? `HP -${resolution.hpDamage}` : 'HP SAFE'; + return ` ${layers.join(' → ')} · ${outcome}.`; +} diff --git a/src/game/crewDisplay.ts b/src/game/crewDisplay.ts index d6aa949..2100061 100644 --- a/src/game/crewDisplay.ts +++ b/src/game/crewDisplay.ts @@ -5,7 +5,7 @@ * and every {@link Crew.applyGear} channel has a single, audited place to * surface. * - * `gearLines` names the equipment (what the player bought from Finn); + * `gearLabels` names the equipment (what the player bought from Finn); * `statDisplays` maps each combat stat to its resulting display value (with any * gear contribution already folded in). The two are complementary: GEAR is the * loadout, STATS is the effect. @@ -35,6 +35,23 @@ export interface StatReadout { const pct = (n: number) => `${(n * 100).toFixed(0)}%`; +/** + * Leader paths for the crew-roster gear diagram's centered 250 x 300 coordinate + * plane. Keeping this keyed beside `gearLabels` makes a newly surfaced gear + * channel fail a test until its anatomical anchor is deliberately chosen. + */ +export const GEAR_LEADER_PATHS: Readonly> = Object.freeze({ + maxHpBonus: 'M 60 44.5 L 60 56 L 93 68', + hpRegen: 'M 170 142 L 162 142 L 138 68', + apBonus: 'M 176 260 L 169 260 L 150 245', + hitBonus: 'M 146 20 L 134 20 A 4,4 0 0 0 126 20 A 4,4 0 0 0 134 20', + rangedDamageBonus: 'M 230 64 L 230 58 L 210 46', + meleeDamageBonus: 'M 62 160 L 70 140 M 64 132 A 6,3 15 0 0 80 138', + armorBonus: 'M 72 90 L 94 90 L 124 96', + shieldRegen: 'M 88 250 L 94 250 L 125 140 A 10 15 0 1 0 125 50 A 10 15 0 0 0 125 140', + dodgeBonus: 'M 168 203 L 148 203 L 145 191.75 M 128 184 A 6,3 0 0 0 159 182', +}); + /** * Map each combat stat to its display value with gear folded in. Keys `hp`, * `ap`, `aim`, `dodge`, `ranged`, `melee`, and `armor` are always present; the @@ -59,7 +76,7 @@ export function statDisplays(stats: StatReadout): Record { const labels: Record = { hp: `${stats.hp}/${stats.maxHp}`, - // `maxAp` is the live stat — the Reflex Booster delta is already baked in. + // `maxAp` is the live stat — the Adrenal Spike delta is already baked in. ap: `${stats.maxAp}`, aim: `${pct(aim)}`, dodge: `${pct(dodge)}`, @@ -73,27 +90,29 @@ export function statDisplays(stats: StatReadout): Record { } /** - * Format the active gear bonuses on `gear` as display lines. A channel only + * Format the active gear bonuses on `gear`, keyed by the gear ID. A channel only * appears when its bonus is positive, so a fresh operator (or one that maxed a - * stat at 0) shows nothing for it. Returns `[]` for null/absent gear. + * stat at 0) shows nothing for it. Returns `{}` for null/absent gear. * - * Every branch here must mirror a case in {@link Crew.applyGear}; a purchase - * that lands a bonus with no line is the bug this module exists to prevent - * (P3.M6 gear was invisible on the roster). + * Every branch here must mirror a case in {Crew.applyGear}; a purchase + * that lands a bonus with no entry is the bug this module exists to prevent. */ -export function gearLines(gear: Gear | null | undefined): string[] { - if (!gear) return []; - const lines: string[] = []; - if (gear.maxHpBonus > 0) lines.push(`Armor Plating +${gear.maxHpBonus} HP`); - if (gear.hitBonus > 0) lines.push(`Targeting Chip +${pct(gear.hitBonus)}`); - if ((gear.dodgeBonus ?? 0) > 0) lines.push(`Reflex Weave +${pct(gear.dodgeBonus ?? 0)}`); +export function gearLabels(gear: Gear | null | undefined): Record { + const labels: Record = {}; + if (!gear) return labels; + if (gear.maxHpBonus > 0) labels.maxHpBonus = `Bone Lacing +${gear.maxHpBonus} HP`; + if (gear.hitBonus > 0) labels.hitBonus = `Targeting Chip +${pct(gear.hitBonus)} aim`; + if ((gear.dodgeBonus ?? 0) > 0) + labels.dodgeBonus = `Ghost Weave +${pct(gear.dodgeBonus ?? 0)} dodge`; if ((gear.rangedDamageBonus ?? 0) > 0) - lines.push(`Ballistics Coil +${gear.rangedDamageBonus} ranged dmg`); + labels.rangedDamageBonus = `RiP Rounds +${gear.rangedDamageBonus} damage`; if ((gear.meleeDamageBonus ?? 0) > 0) - lines.push(`Monoblade +${gear.meleeDamageBonus} melee dmg`); - if ((gear.armorBonus ?? 0) > 0) lines.push(`Subdermal Plating +${gear.armorBonus} armor`); - if ((gear.apBonus ?? 0) > 0) lines.push(`Reflex Booster +${gear.apBonus} AP`); - if ((gear.shieldRegen ?? 0) > 0) lines.push(`Phase Shield +${gear.shieldRegen} shield/turn`); - if ((gear.hpRegen ?? 0) > 0) lines.push(`Regen Mesh +${gear.hpRegen} HP/turn`); - return lines; + labels.meleeDamageBonus = `Monoblade +${gear.meleeDamageBonus} damage`; + if ((gear.armorBonus ?? 0) > 0) + labels.armorBonus = `Subdermal Plating +${gear.armorBonus} armor`; + if ((gear.apBonus ?? 0) > 0) labels.apBonus = `Adrenal Spike +${gear.apBonus} AP`; + if ((gear.shieldRegen ?? 0) > 0) + labels.shieldRegen = `Phase Shield +${gear.shieldRegen} shield/turn`; + if ((gear.hpRegen ?? 0) > 0) labels.hpRegen = `Regen Mesh +${gear.hpRegen} HP/turn`; + return labels; } diff --git a/src/game/items.ts b/src/game/items.ts index f881fb8..96cebba 100644 --- a/src/game/items.ts +++ b/src/game/items.ts @@ -70,17 +70,17 @@ export const ITEM_SCOPE = Object.freeze({ export const ITEM_ID = Object.freeze({ STIM: 'stim', SMOKE_CHARGE: 'smoke-charge', - INCENDIARY: 'incendiary', + MOLOTOV: 'incendiary', BREACHING_CHARGE: 'breaching-charge', - ARMOUR_PLATING: 'armour-plating', + BONE_LACING: 'armour-plating', TARGETING_CHIP: 'targeting-chip', - REFLEX_WEAVE: 'reflex-weave', - BALLISTICS_COIL: 'ballistics-coil', + GHOST_WEAVE: 'reflex-weave', + RIP_ROUNDS: 'ballistics-coil', // Net-new scoreable gear (P3.M6.2) — each fills a previously-untouched stat // channel (melee dmg, flat armour, AP, shield regen, HP regen). MONOBLADE: 'monoblade', SUBDERMAL_PLATING: 'subdermal-plating', - REFLEX_BOOSTER: 'reflex-booster', + ADRENAL_SPIKE: 'reflex-booster', PHASE_SHIELD: 'phase-shield', REGEN_MESH: 'regen-mesh', }); @@ -120,10 +120,10 @@ export const DEFAULT_ITEMS: readonly Item[] = Object.freeze([ needsTarget: true, }), Object.freeze({ - id: ITEM_ID.INCENDIARY, - label: 'Incendiary Bomb', + id: ITEM_ID.MOLOTOV, + label: 'Molotov', scope: ITEM_SCOPE.JOB, - cost: SHOP_COST.INCENDIARY, + cost: SHOP_COST.MOLOTOV, description: `Throw ${INCENDIARY_THROW_DIST} tiles in a chosen direction; ignites a persistent hazard cluster. Single use.`, needsTarget: true, needsAim: true, @@ -149,13 +149,13 @@ export const DEFAULT_ITEMS: readonly Item[] = Object.freeze([ */ export const SCOREABLE_ITEMS: readonly Item[] = Object.freeze([ Object.freeze({ - id: ITEM_ID.ARMOUR_PLATING, - label: 'Armour Plating', + id: ITEM_ID.BONE_LACING, + label: 'Bone Lacing', scope: ITEM_SCOPE.CAMPAIGN, - cost: SHOP_COST.ARMOUR_PLATING, - description: "+1 max HP (Tech's turrets deploy at that max HP).", + cost: SHOP_COST.BONE_LACING, + description: '+1 max HP.', needsTarget: true, - flavor: 'Corp-issue trauma plating, lifted straight off a fabrication line.', + flavor: 'Black-clinic skeletal reinforcement, laced in straight off a fabrication line.', }), Object.freeze({ id: ITEM_ID.TARGETING_CHIP, @@ -167,22 +167,23 @@ export const SCOREABLE_ITEMS: readonly Item[] = Object.freeze([ flavor: 'A smartgun targeting co-processor, pulled still-warm from a test rig.', }), Object.freeze({ - id: ITEM_ID.REFLEX_WEAVE, - label: 'Reflex Weave', + id: ITEM_ID.GHOST_WEAVE, + label: 'Ghost Weave', scope: ITEM_SCOPE.CAMPAIGN, - cost: SHOP_COST.REFLEX_WEAVE, + cost: SHOP_COST.GHOST_WEAVE, description: `+${(DODGE_BONUS * 100).toFixed(0)}% melee dodge chance.`, needsTarget: true, - flavor: "Subdermal reflex mesh, cut from an exec's private medbay.", + flavor: + "Subdermal ghosting mesh, cut from an exec's private medbay — gone before the blow lands.", }), Object.freeze({ - id: ITEM_ID.BALLISTICS_COIL, - label: 'Ballistics Coil', + id: ITEM_ID.RIP_ROUNDS, + label: 'RiP Rounds', scope: ITEM_SCOPE.CAMPAIGN, - cost: SHOP_COST.BALLISTICS_COIL, + cost: SHOP_COST.RIP_ROUNDS, description: `+${RANGED_DAMAGE_BONUS} ranged damage (Tech's turrets inherit the bonus).`, needsTarget: true, - flavor: 'An accelerator coil prised off a prototype railgun bench.', + flavor: 'Hand-loaded, overcharged rounds — factory spec be damned.', }), Object.freeze({ id: ITEM_ID.MONOBLADE, @@ -203,13 +204,13 @@ export const SCOREABLE_ITEMS: readonly Item[] = Object.freeze([ flavor: 'Military subdermal armour, woven from layered impact ceramics.', }), Object.freeze({ - id: ITEM_ID.REFLEX_BOOSTER, - label: 'Reflex Booster', + id: ITEM_ID.ADRENAL_SPIKE, + label: 'Adrenal Spike', scope: ITEM_SCOPE.CAMPAIGN, - cost: SHOP_COST.REFLEX_BOOSTER, + cost: SHOP_COST.ADRENAL_SPIKE, description: `+${AP_BONUS} max AP. One per operator.`, needsTarget: true, - flavor: 'A black-clinic adrenal booster — the world slows when it fires.', + flavor: 'A black-clinic adrenal spike — the world slows when it hits.', }), Object.freeze({ id: ITEM_ID.PHASE_SHIELD, diff --git a/src/render/AsciiRenderer.ts b/src/render/AsciiRenderer.ts index f59296d..4af2423 100644 --- a/src/render/AsciiRenderer.ts +++ b/src/render/AsciiRenderer.ts @@ -1,6 +1,8 @@ import { + COMBAT_HUD_COLORS, COMBAT_HUD_GLYPHS, formatApPips, + formatDefenseHud, formatHpSegments, formatIdentityHud, fitObjectiveHudLine, @@ -22,7 +24,7 @@ import type { Viewport, Camera, BuildFrameOptions, Frame } from './frame.js'; */ import type { World } from '../game/World.js'; import type { Entity } from '../game/Entity.js'; -import type { CombatHudSummaryInput } from './combatHud.js'; +import type { CombatHudDefenseInput, CombatHudSummaryInput } from './combatHud.js'; type NowFn = () => number; type AsciiRendererOptions = { @@ -307,11 +309,18 @@ export class AsciiRenderer { ...palette, }); const hpText = formatHpSegments(hud.hp); + const defenseText = formatDefenseHud(hud.defense); + const vitalsText = defenseText ? `${hpText} ${defenseText}` : hpText; + const vitalSegments = hpSegments(hpText, palette); + if (defenseText && hud.defense) { + vitalSegments.push({ text: ' ', color: palette.color, glowColor: palette.glowColor }); + vitalSegments.push(...defenseSegments(hud.defense, palette)); + } this.#drawHudRow({ - text: hpText, + text: vitalsText, anchor: 'top-right', row: 1, - segments: hpSegments(hpText, palette), + segments: vitalSegments, ...palette, }); const apText = formatApPips(hud.ap); @@ -502,3 +511,35 @@ function apSegments( } return segments; } + +function defenseSegments( + defense: CombatHudDefenseInput, + palette: { color: string; glowColor: string } +): HudTextSegment[] { + const segments: HudTextSegment[] = []; + if (defense.shield) { + segments.push({ text: 'SH ', color: palette.color, glowColor: palette.glowColor }); + const pips = `${COMBAT_HUD_GLYPHS.SHIELD_SPENT.repeat( + defense.shield.capacity - defense.shield.current + )}${COMBAT_HUD_GLYPHS.SHIELD_CHARGED.repeat(defense.shield.current)}`; + for (const pip of pips) { + const charged = pip === COMBAT_HUD_GLYPHS.SHIELD_CHARGED; + segments.push({ + text: pip, + color: charged ? COMBAT_HUD_COLORS.SHIELD_CHARGED : COMBAT_HUD_COLORS.SHIELD_SPENT, + glowColor: charged ? COMBAT_HUD_COLORS.SHIELD_CHARGED : undefined, + }); + } + } + if (defense.shield && defense.armor !== undefined) { + segments.push({ text: ' ', color: palette.color, glowColor: palette.glowColor }); + } + if (defense.armor !== undefined) { + segments.push({ + text: `ARM ${defense.armor}`, + color: COMBAT_HUD_COLORS.ARMOR, + glowColor: COMBAT_HUD_COLORS.ARMOR, + }); + } + return segments; +} diff --git a/src/render/animations.ts b/src/render/animations.ts index 3c3737d..ea079ec 100644 --- a/src/render/animations.ts +++ b/src/render/animations.ts @@ -30,10 +30,12 @@ */ import type { AsciiRenderer } from './AsciiRenderer.js'; +import { COMBAT_HUD_COLORS } from './combatHud.js'; export const ANIMATION_DURATIONS = Object.freeze({ SHAKE: 150, DAMAGE_FLASH: 300, + MITIGATION_FLASH: 300, // 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. @@ -46,6 +48,8 @@ export const ANIMATION_DURATIONS = Object.freeze({ export const SHAKE_CLASS = 'kp-shake'; export const DAMAGE_CLASS = 'kp-damage-flash'; +export const MITIGATION_FLASH_CLASS = 'kp-mitigation-flash'; +const IMPACT_FLASH_COLOR_PROPERTY = '--kp-impact-flash-color'; const defaultTimers = Object.freeze({ now: () => (typeof performance !== 'undefined' ? performance.now() : Date.now()), @@ -87,9 +91,33 @@ export function triggerShake(stageEl: HTMLElement, timers = defaultTimers) { } export function triggerDamageFlash(stageEl: HTMLElement, timers = defaultTimers) { + stageEl.classList.remove(MITIGATION_FLASH_CLASS); + stageEl.style.removeProperty(IMPACT_FLASH_COLOR_PROPERTY); return restartCssAnimation(stageEl, DAMAGE_CLASS, ANIMATION_DURATIONS.DAMAGE_FLASH, timers); } +export type MitigationFlashKind = 'armor' | 'shield'; + +/** + * Use the HUD's defense color for a hit that was fully stopped before HP. + * The alpha suffix keeps the vignette at the same intensity as damage red. + */ +export function triggerMitigationFlash( + stageEl: HTMLElement, + kind: MitigationFlashKind, + timers = defaultTimers +) { + const color = kind === 'shield' ? COMBAT_HUD_COLORS.SHIELD_CHARGED : COMBAT_HUD_COLORS.ARMOR; + stageEl.classList.remove(DAMAGE_CLASS); + stageEl.style.setProperty(IMPACT_FLASH_COLOR_PROPERTY, `${color}8c`); + return restartCssAnimation( + stageEl, + MITIGATION_FLASH_CLASS, + ANIMATION_DURATIONS.MITIGATION_FLASH, + timers + ); +} + /** * Create an animation-lock token. The shell pushes durations onto it from * each listener; `isLocked()` returns true while any pushed window is still diff --git a/src/render/combatHud.ts b/src/render/combatHud.ts index 7615347..fec33d6 100644 --- a/src/render/combatHud.ts +++ b/src/render/combatHud.ts @@ -7,6 +7,15 @@ export const COMBAT_HUD_GLYPHS = Object.freeze({ HP_EMPTY: '□', AP_AVAILABLE: '●', AP_SPENT: '○', + SHIELD_CHARGED: '◆', + SHIELD_SPENT: '◇', +}); + +/** Shared defense palette for HUD chrome and mitigation feedback. */ +export const COMBAT_HUD_COLORS = Object.freeze({ + SHIELD_CHARGED: '#c8b6ff', + SHIELD_SPENT: '#68757b', + ARMOR: '#d49a3a', }); export type CombatHudObjectiveInput = Readonly<{ @@ -34,6 +43,13 @@ export type CombatHudApInput = Readonly<{ maxAp: number; }>; +export type CombatHudDefenseInput = Readonly<{ + /** Persistent flat damage reduction. Omitted when the actor has no armor. */ + armor?: number; + /** Equipped shield capacity plus its current live charge. */ + shield?: Readonly<{ current: number; capacity: number }>; +}>; + export type CombatHudTurnInput = Readonly<{ currentFaction: FactionId; turnNumber: number; @@ -43,6 +59,7 @@ export type CombatHudSummaryInput = Readonly<{ objective?: CombatHudObjectiveInput | null; identity: CombatHudIdentityInput; hp: CombatHudVitalInput; + defense?: CombatHudDefenseInput; ap: CombatHudApInput; turn: CombatHudTurnInput; cyber: boolean; @@ -127,6 +144,35 @@ export function formatHpSegments(vitals: CombatHudVitalInput): string { )}`; } +export function formatDefenseHud(defense: CombatHudDefenseInput | null | undefined): string { + if (!defense) return ''; + const parts: string[] = []; + if (defense.shield) { + validateCounter( + defense.shield.current, + defense.shield.capacity, + 'shield.current', + 'shield.capacity' + ); + if (defense.shield.capacity <= 0) { + throw new RangeError(`shield.capacity must be >= 1, got ${defense.shield.capacity}`); + } + parts.push( + `SH ${rightFilledSegments( + defense.shield.current, + defense.shield.capacity, + COMBAT_HUD_GLYPHS.SHIELD_CHARGED, + COMBAT_HUD_GLYPHS.SHIELD_SPENT + )}` + ); + } + if (defense.armor !== undefined) { + assertIntegerInRange(defense.armor, 'armor', { min: 1 }); + parts.push(`ARM ${defense.armor}`); + } + return parts.join(' '); +} + export function formatApPips(vitals: CombatHudApInput): string { validateCounter(vitals.ap, vitals.maxAp, 'ap', 'maxAp'); return `AP ${rightFilledSegments( @@ -159,15 +205,33 @@ export function formatCombatHudA11ySummary(summary: CombatHudSummaryInput): stri name, archetype.toUpperCase(), `${summary.hp.hp} of ${summary.hp.maxHp} ${summary.hp.label ?? 'HP'}`, - `${summary.ap.ap} of ${summary.ap.maxAp} AP`, - turnA11yText(summary.turn), ]; + const defenseText = defenseA11yText(summary.defense); + 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'); const objectiveText = objectiveA11yText(summary.objective); if (objectiveText) parts.push(objectiveText); return parts.join(', '); } +function defenseA11yText(defense: CombatHudDefenseInput | null | undefined): string[] { + if (!defense) return []; + // Reuse the visible formatter's validation so visual and screen-reader paths + // fail on the same corrupt input. + formatDefenseHud(defense); + const parts: string[] = []; + if (defense.shield) { + parts.push( + defense.shield.current === 0 + ? 'shield spent' + : `shield ${defense.shield.current} of ${defense.shield.capacity}` + ); + } + if (defense.armor !== undefined) parts.push(`armor ${defense.armor}`); + return parts; +} + function rightFilledSegments(count: number, max: number, filled: string, empty: string): string { return `${empty.repeat(max - count)}${filled.repeat(count)}`; } diff --git a/src/shell/combatHudSnapshot.ts b/src/shell/combatHudSnapshot.ts index 92fc285..1754ef9 100644 --- a/src/shell/combatHudSnapshot.ts +++ b/src/shell/combatHudSnapshot.ts @@ -1,6 +1,6 @@ import { RUN_STATE } from '../game/Run.js'; import type { Run } from '../game/Run.js'; -import type { CombatHudSummaryInput } from '../render/combatHud.js'; +import type { CombatHudDefenseInput, CombatHudSummaryInput } from '../render/combatHud.js'; import { CyberAvatar } from '../game/cyber/CyberAvatar.js'; import { Crew } from '../game/Crew.js'; import { activeActorOf, isCyberView } from './activeView.js'; @@ -41,7 +41,7 @@ export function buildCombatHudSnapshot(scene: ShellScene | null): CombatHudSumma */ export function combatHudBodyPanes( scene: Run -): Pick { +): Pick { const actor = activeActorOf(scene); if (isCyberView(scene) && actor instanceof CyberAvatar) { return { @@ -55,6 +55,7 @@ export function combatHudBodyPanes( }; } const crew = actor instanceof Crew ? actor : scene.player!; + const defense = crewDefense(crew); return { identity: { callsign: crew.callsign, @@ -63,6 +64,17 @@ export function combatHudBodyPanes( stealthed: crew.stealthed, }, hp: { hp: crew.hp, maxHp: crew.maxHp }, + ...(defense ? { defense } : {}), ap: { ap: crew.ap, maxAp: crew.maxAp }, }; } + +function crewDefense(crew: Crew): CombatHudDefenseInput | undefined { + const armor = crew.damageReduction; + const shieldCapacity = crew.gear?.shieldRegen ?? 0; + if (armor <= 0 && shieldCapacity <= 0) return undefined; + return { + ...(armor > 0 ? { armor } : {}), + ...(shieldCapacity > 0 ? { shield: { current: crew.shieldHp, capacity: shieldCapacity } } : {}), + }; +} diff --git a/src/shell/domTypes.ts b/src/shell/domTypes.ts index b97a85d..4bdd4cf 100644 --- a/src/shell/domTypes.ts +++ b/src/shell/domTypes.ts @@ -142,6 +142,7 @@ export type EntityDamagedPayload = { damage?: number; killed?: boolean; source?: string; + damageResolution?: import('../types.js').DamageResolution; }; export type NoisePayload = { diff --git a/src/shell/sceneListeners.ts b/src/shell/sceneListeners.ts index 24fc761..c0e49d8 100644 --- a/src/shell/sceneListeners.ts +++ b/src/shell/sceneListeners.ts @@ -5,8 +5,10 @@ import { ANIMATION_DURATIONS, runMuzzleFlash, triggerDamageFlash, + triggerMitigationFlash, triggerShake, } from '../render/animations.js'; +import { COMBAT_HUD_COLORS } from '../render/combatHud.js'; import { cyberLayerOf, isCyberView } from './activeView.js'; import { isRun } from './sceneView.js'; import type { @@ -69,19 +71,29 @@ export class SceneListenerController { attacker, target, damage = 0, + damageResolution, killed, source, } = (payload ?? {}) as EntityDamagedPayload; + const hpDamage = damageResolution?.hpDamage ?? damage; + const armorAbsorbed = damageResolution?.armorAbsorbed ?? 0; + const shieldAbsorbed = damageResolution?.shieldAbsorbed ?? 0; // P3.M4.5: the meat layer is in the PIP only while the player is // *viewing* Cyberspace; once flipped back to meat it is the main canvas. const meatInPip = isCyberView(run); const bodyHit = isRun(run) && !!run.player && target === run.player; const forcedBodyJackOut = bodyHit && killed === true && !!target?.alive && run.cyberspace?.phase === 'resolved'; - if (bodyHit && damage > 0) { + const bodyImpact = bodyHit && (hpDamage > 0 || armorAbsorbed > 0 || shieldAbsorbed > 0); + if (bodyImpact) { triggerShake(dom.stageEl); - triggerDamageFlash(dom.stageEl); - animLock.push(ANIMATION_DURATIONS.DAMAGE_FLASH); + if (hpDamage > 0) { + triggerDamageFlash(dom.stageEl); + animLock.push(ANIMATION_DURATIONS.DAMAGE_FLASH); + } else { + triggerMitigationFlash(dom.stageEl, shieldAbsorbed > 0 ? 'shield' : 'armor'); + animLock.push(ANIMATION_DURATIONS.MITIGATION_FLASH); + } if (meatInPip) { const attackerLabel = attacker ? resolveEntityLabel(attacker.id, run.world!.entities) @@ -89,8 +101,23 @@ export class SceneListenerController { effects.flash( killed ? `BODY CRITICAL — ${attackerLabel} forced an emergency jack-out.` - : `BODY HIT — ${attackerLabel} struck for ${damage} (meatspace).` + : hpDamage > 0 + ? `BODY HIT — ${attackerLabel} struck for ${hpDamage} (meatspace).` + : `BODY BLOCKED — ${attackerLabel}'s hit stopped by ${ + shieldAbsorbed > 0 ? 'shield' : 'armor' + } (meatspace).` ); + const impactColor = + hpDamage > 0 + ? '' + : `${ + shieldAbsorbed > 0 ? COMBAT_HUD_COLORS.SHIELD_CHARGED : COMBAT_HUD_COLORS.ARMOR + }8c`; + if (impactColor) { + dom.pipCanvas.style.setProperty('--kp-pip-impact-color', impactColor); + } else { + dom.pipCanvas.style.removeProperty('--kp-pip-impact-color'); + } dom.pipCanvas.classList.remove('pip-hit'); void dom.pipCanvas.offsetWidth; dom.pipCanvas.classList.add('pip-hit'); diff --git a/src/shell/shellRuntime.ts b/src/shell/shellRuntime.ts index 9317cc6..44120ba 100644 --- a/src/shell/shellRuntime.ts +++ b/src/shell/shellRuntime.ts @@ -1096,10 +1096,10 @@ function applyUseConsumableResult( // through a wall and lose your bomb." const stamped = placeHazardCluster(run.world, { x: cx, y: cy }, run.rng); if (stamped === 0) { - flash(`Used INCENDIARY — bomb landed on hard cover; no fire took. ${operator.ap} AP left.`); + flash(`Used MOLOTOV — landed on hard cover; no fire took. ${operator.ap} AP left.`); } else { flash( - `Used INCENDIARY — ${stamped} tile${stamped === 1 ? '' : 's'} ignited. ${operator.ap} AP left.` + `Used MOLOTOV — ${stamped} tile${stamped === 1 ? '' : 's'} ignited. ${operator.ap} AP left.` ); } recomputeVision(); @@ -1146,7 +1146,7 @@ function resolveAimedUseItem(aim: { dx: number; dy: number }, run: Run): void { throw new Error('[shell] use-item intent received without pendingAimItemId'); } pendingAimItemId = null; - if (itemId === ITEM_ID.INCENDIARY) { + if (itemId === ITEM_ID.MOLOTOV) { // LOS-clear-target pre-check: the throw lands at // `player + dir * INCENDIARY_THROW_DIST`. If LOS from the thrower to // that tile is blocked (or the tile is out of bounds), refuse the diff --git a/src/types.ts b/src/types.ts index 3036a24..6f76a34 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,6 +58,17 @@ export type TileDelta = * Outcome of a committed ranged shot (`resolveRanged`). Shared with turn logs * and any UI that replays combat ticks. */ +export type DamageResolution = { + /** Raw damage from the attack before defenses. */ + incomingDamage: number; + /** Damage removed by flat armor. */ + armorAbsorbed: number; + /** Post-armor damage consumed by temporary shield HP. */ + shieldAbsorbed: number; + /** Damage that reached real HP. */ + hpDamage: number; +}; + export type RangedAttackResult = { hit: boolean; roll: number; @@ -65,6 +76,8 @@ export type RangedAttackResult = { inCover: boolean; damage: number; killed: boolean; + /** Present on connected hits; omitted on misses. */ + damageResolution?: DamageResolution; }; /** Outcome of a committed melee strike (`resolveMelee`). */ @@ -76,6 +89,8 @@ export type MeleeAttackResult = { inCover: boolean; damage: number; killed: boolean; + /** Present on connected hits; omitted on dodges/misses. */ + damageResolution?: DamageResolution; }; /** Movement yields from `PatrolHostile` pathing (`stepToward`). */ diff --git a/sw-core.js b/sw-core.js index 716fc5e..bb4d537 100644 --- a/sw-core.js +++ b/sw-core.js @@ -167,8 +167,9 @@ const CacheConfig = { '/icons/icon512_maskable.png', '/icons/icon512_rounded.png', '/images/back.png', + '/images/gear.png', '/images/charge.png', - '/images/incind.png', + '/images/molotov.png', '/images/smoke.png', '/images/stim.png', '/fonts/silkscreen/slkscr-webfont.woff', diff --git a/sw-dev.js b/sw-dev.js index 7f43990..8fa91ed 100644 --- a/sw-dev.js +++ b/sw-dev.js @@ -1,5 +1,5 @@ // Service Worker for Kernel Panic - Development Version -const VERSION = '0.3.2-dev'; +const VERSION = '0.3.2b-dev'; importScripts(`/sw-core.js?v=${VERSION}`); const cacheConfig = CacheConfig.create(VERSION); diff --git a/sw.js b/sw.js index 6e295ba..72d3db9 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.2'; +const VERSION = '0.3.2b'; importScripts(`/sw-core.js?v=${VERSION}`); const cacheConfig = CacheConfig.create(VERSION); diff --git a/tests/unit/game/Campaign.test.ts b/tests/unit/game/Campaign.test.ts index a10d9bc..fa42ec2 100644 --- a/tests/unit/game/Campaign.test.ts +++ b/tests/unit/game/Campaign.test.ts @@ -489,7 +489,7 @@ test('purchase deducts Creds and adds a consumable to the target crew member', ( }); test('purchase applies campaign-scoped gear bonus (armour plating)', () => { - const campaign = new Campaign({ seed: 42, credits: SHOP_COST.ARMOUR_PLATING }); + const campaign = new Campaign({ seed: 42, credits: SHOP_COST.BONE_LACING }); const member = campaign.crew[0]; const origMaxHp = member.maxHp; campaign.purchase({ itemId: 'armour-plating', targetMemberId: member.id }); @@ -506,16 +506,16 @@ test('purchase applies targeting chip gear bonus', () => { }); test('purchase applies reflex weave gear bonus', () => { - const campaign = new Campaign({ seed: 42, credits: SHOP_COST.REFLEX_WEAVE }); + const campaign = new Campaign({ seed: 42, credits: SHOP_COST.GHOST_WEAVE }); const member = campaign.crew[0]; campaign.purchase({ itemId: 'reflex-weave', targetMemberId: member.id }); assert.equal(member.gear.dodgeBonus, 0.1); }); test('purchase refuses limit-1 gear the target already has equipped, without charging', () => { - // Reflex Booster is limit-1 ("One per operator"): a second sale would silently + // Adrenal Spike is limit-1 ("One per operator"): a second sale would silently // clamp to a no-op in applyGear while still pocketing the Creds. Guard it. - const campaign = new Campaign({ seed: 42, credits: SHOP_COST.REFLEX_BOOSTER * 2 }); + const campaign = new Campaign({ seed: 42, credits: SHOP_COST.ADRENAL_SPIKE * 2 }); const member = campaign.crew[0]; campaign.purchase({ itemId: 'reflex-booster', targetMemberId: member.id }); const creditsAfterFirst = campaign.credits; @@ -543,15 +543,15 @@ test('purchase refuses every net-new limit-1 gear item once equipped', () => { } }); -test('purchase still allows re-buying unbounded Armour Plating (not limit-1)', () => { - // Armour Plating has no cap (+1 maxHp each time), so it must stay re-purchasable. - const campaign = new Campaign({ seed: 42, credits: SHOP_COST.ARMOUR_PLATING * 2 }); +test('purchase still allows re-buying unbounded Bone Lacing (not limit-1)', () => { + // Bone Lacing has no cap (+1 maxHp each time), so it must stay re-purchasable. + const campaign = new Campaign({ seed: 42, credits: SHOP_COST.BONE_LACING * 2 }); const member = campaign.crew[0]; const origMaxHp = member.maxHp; campaign.purchase({ itemId: 'armour-plating', targetMemberId: member.id }); campaign.purchase({ itemId: 'armour-plating', targetMemberId: member.id }); - assert.equal(member.maxHp, origMaxHp + 2, 'second Armour Plating stacks'); - assert.equal(campaign.credits, 0, 'both Armour Platings charged'); + assert.equal(member.maxHp, origMaxHp + 2, 'second Bone Lacing stacks'); + assert.equal(campaign.credits, 0, 'both Bone Lacings charged'); }); // meta upgrades (expanded-catalog, better-contracts) removed — Rep @@ -628,7 +628,7 @@ test('crew member HP persists across jobs — no free heal on deploy', () => { test('crew gear survives campaign snapshot/restore round-trip', () => { const campaign = new Campaign({ seed: 42, - credits: SHOP_COST.ARMOUR_PLATING + SHOP_COST.TARGETING_CHIP, + credits: SHOP_COST.BONE_LACING + SHOP_COST.TARGETING_CHIP, }); const member = campaign.crew[0]; campaign.purchase({ itemId: 'armour-plating', targetMemberId: member.id }); @@ -658,7 +658,7 @@ test('net-new scoreable gear survives campaign round-trip', () => { seed: 42, credits: SHOP_COST.SUBDERMAL_PLATING + - SHOP_COST.REFLEX_BOOSTER + + SHOP_COST.ADRENAL_SPIKE + SHOP_COST.MONOBLADE + SHOP_COST.PHASE_SHIELD + SHOP_COST.REGEN_MESH, diff --git a/tests/unit/game/Combat.test.ts b/tests/unit/game/Combat.test.ts index 7431722..becaefd 100644 --- a/tests/unit/game/Combat.test.ts +++ b/tests/unit/game/Combat.test.ts @@ -384,6 +384,28 @@ test('resolveMelee applies damageReduction after dodge with a 1-damage floor', ( assert.equal(target.hp, target.maxHp - (MELEE_DAMAGE - 1)); }); +test('resolveMelee reports armor, shield, and actual HP damage as separate layers', async () => { + const { EventBus, EVENT } = await import('../../../src/game/events.js'); + const { world, attacker, target } = makeMeleeFight(); + const bus = new EventBus(); + world.events = bus; + target.damageReduction = 1; + target.addShield(1); + const damaged = []; + bus.on(EVENT.ENTITY_DAMAGED, payload => damaged.push(payload)); + + const result = resolveMelee(world, attacker, target, new StubRng([0.99]), { damage: 3 }); + + assert.equal(result.damage, 2, 'legacy damage remains the post-armor attack value'); + assert.deepEqual(result.damageResolution, { + incomingDamage: 3, + armorAbsorbed: 1, + shieldAbsorbed: 1, + hpDamage: 1, + }); + assert.deepEqual(damaged[0].damageResolution, result.damageResolution); +}); + test('zero-armor melee damage is unchanged', () => { const { world, attacker, target } = makeMeleeFight(); diff --git a/tests/unit/game/Crew.test.ts b/tests/unit/game/Crew.test.ts index 785000b..36cdcdf 100644 --- a/tests/unit/game/Crew.test.ts +++ b/tests/unit/game/Crew.test.ts @@ -427,10 +427,10 @@ test('Crew.rangedDamage defaults to RANGED_DAMAGE; Merc overrides', () => { test('Crew.gearAtCap reports every limit-1 gear item saturated after one apply', () => { const limit1 = [ - ITEM_ID.BALLISTICS_COIL, + ITEM_ID.RIP_ROUNDS, ITEM_ID.MONOBLADE, ITEM_ID.SUBDERMAL_PLATING, - ITEM_ID.REFLEX_BOOSTER, + ITEM_ID.ADRENAL_SPIKE, ITEM_ID.PHASE_SHIELD, ITEM_ID.REGEN_MESH, ]; @@ -442,12 +442,12 @@ test('Crew.gearAtCap reports every limit-1 gear item saturated after one apply', } }); -test('Crew.gearAtCap: unbounded Armour Plating never saturates', () => { +test('Crew.gearAtCap: unbounded Bone Lacing never saturates', () => { const m = new Merc({ id: 'm', x: 0, y: 0 }); - assert.equal(m.gearAtCap(ITEM_ID.ARMOUR_PLATING), false); - m.applyGear(ITEM_ID.ARMOUR_PLATING); - m.applyGear(ITEM_ID.ARMOUR_PLATING); - assert.equal(m.gearAtCap(ITEM_ID.ARMOUR_PLATING), false, 'still re-purchasable after stacking'); + assert.equal(m.gearAtCap(ITEM_ID.BONE_LACING), false); + m.applyGear(ITEM_ID.BONE_LACING); + m.applyGear(ITEM_ID.BONE_LACING); + assert.equal(m.gearAtCap(ITEM_ID.BONE_LACING), false, 'still re-purchasable after stacking'); }); test('Crew.gearAtCap: stacking gear saturates only at its per-archetype cap', () => { @@ -466,10 +466,10 @@ test('Crew.gearAtCap throws on a non-gear id (no silent false)', () => { assert.throws(() => m.gearAtCap('stim'), /unknown gear item/i); }); -test('Crew.rangedAttackDamage is archetype base plus capped Ballistics Coil', () => { +test('Crew.rangedAttackDamage is archetype base plus capped RiP Rounds', () => { const m = new Merc({ id: 'm', x: 0, y: 0 }); assert.equal(m.rangedAttackDamage(), MERC_RANGED_DAMAGE); - m.applyGear(ITEM_ID.BALLISTICS_COIL); + m.applyGear(ITEM_ID.RIP_ROUNDS); assert.equal(m.rangedAttackDamage(), MERC_RANGED_DAMAGE + RANGED_DAMAGE_BONUS); }); @@ -544,13 +544,13 @@ test('Crew.maxDodgeBonus is 1 − baseDodgeChance', () => { test('applyGear caps dodgeBonus so base + bonus cannot exceed 1.0 (Razor)', () => { const r = new Razor({ id: 'r', x: 0, y: 0 }); - for (let i = 0; i < 7; i++) r.applyGear(ITEM_ID.REFLEX_WEAVE); + for (let i = 0; i < 7; i++) r.applyGear(ITEM_ID.GHOST_WEAVE); assert.equal(r.gear!.dodgeBonus, r.maxDodgeBonus); assert.ok(r.baseDodgeChance + r.gear!.dodgeBonus <= 1); }); -test('applyGear(REFLEX_WEAVE) sets dodgeBonus', () => { +test('applyGear(GHOST_WEAVE) sets dodgeBonus', () => { const m = new Merc({ id: 'm', x: 0, y: 0 }); - m.applyGear(ITEM_ID.REFLEX_WEAVE); + m.applyGear(ITEM_ID.GHOST_WEAVE); assert.equal(m.gear!.dodgeBonus, DODGE_BONUS); }); diff --git a/tests/unit/game/Run.test.ts b/tests/unit/game/Run.test.ts index cf371f4..9ca7fde 100644 --- a/tests/unit/game/Run.test.ts +++ b/tests/unit/game/Run.test.ts @@ -22,6 +22,7 @@ import { buildCrewMember } from '../../../src/game/archetypes/index.js'; 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'; const fakeContract = (overrides = {}) => ({ seed: 12345, @@ -94,6 +95,17 @@ test('legal transition chain: BRIEFING → COMBAT → RESULT', () => { assert.equal(run.state, RUN_STATE.RESULT); }); +test('enterCombat starts the first player turn with an equipped phase shield charged', () => { + const crewMember = makeCrew('razor'); + crewMember.applyGear(ITEM_ID.PHASE_SHIELD); + const run = new Run({ crewMember, seed: 42 }); + run.enterBriefing(fakeContract()); + + run.enterCombat(); + + assert.equal(run.player!.shieldHp, 1); +}); + test('enterCombat uses persisted contract map dimensions', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 42 }); run.enterBriefing(fakeContract({ mapWidth: 28, mapHeight: 18, threatCount: 2 })); diff --git a/tests/unit/game/Tech.test.ts b/tests/unit/game/Tech.test.ts index 69aff6c..13345a3 100644 --- a/tests/unit/game/Tech.test.ts +++ b/tests/unit/game/Tech.test.ts @@ -22,6 +22,7 @@ import { AP_COST, SALVAGE_PER_IMPROVISED_TURRET, TURRET_DAMAGE, + TURRET_MAX_HP, RANGED_DAMAGE_BONUS, } from '../../../src/game/constants.js'; import { ITEM_ID } from '../../../src/game/items.js'; @@ -127,17 +128,20 @@ test('Tech.deployTurret places a Turret on the target tile and debits AP', () => assert.equal(world.entityAt(4, 3), turret); }); -test('Tech.deployTurret sets turret maxHp to owner maxHp (Armour Plating)', () => { +test('Tech.deployTurret does not inherit owner Bone Lacing HP', () => { const { world, tech } = makeWorld(); - tech.applyGear(ITEM_ID.ARMOUR_PLATING); + tech.applyGear(ITEM_ID.BONE_LACING); + assert.ok(tech.maxHp > TURRET_MAX_HP, 'Bone Lacing must raise owner maxHp above turret base'); const turret = tech.deployTurret(world, 1, 0); - assert.equal(turret.maxHp, tech.maxHp); - assert.equal(turret.hp, tech.maxHp); + // Turret is a machine: it deploys at its own base, ignoring the owner's body augment. + assert.equal(turret.maxHp, TURRET_MAX_HP); + assert.equal(turret.hp, TURRET_MAX_HP); + assert.ok(turret.maxHp < tech.maxHp, 'turret must not inherit the owner HP bonus'); }); -test('Tech.deployTurret applies Ballistics Coil to attackDamage', () => { +test('Tech.deployTurret applies RiP Rounds to attackDamage', () => { const { world, tech } = makeWorld(); - tech.applyGear(ITEM_ID.BALLISTICS_COIL); + tech.applyGear(ITEM_ID.RIP_ROUNDS); const turret = tech.deployTurret(world, 1, 0); assert.equal(turret.attackDamage, TURRET_DAMAGE + RANGED_DAMAGE_BONUS); }); diff --git a/tests/unit/game/catalogSplit.test.ts b/tests/unit/game/catalogSplit.test.ts index 25b7e4d..8f6c87c 100644 --- a/tests/unit/game/catalogSplit.test.ts +++ b/tests/unit/game/catalogSplit.test.ts @@ -71,10 +71,10 @@ test('the two catalogs are disjoint (no item is both default and scoreable)', () test('scoreable pool has at least 5 net-new items beyond the original KNOWN gear', () => { // The original rep-gated KNOWN gear that became scoreable. const ORIGINAL_KNOWN = new Set([ - ITEM_ID.ARMOUR_PLATING, + ITEM_ID.BONE_LACING, ITEM_ID.TARGETING_CHIP, - ITEM_ID.REFLEX_WEAVE, - ITEM_ID.BALLISTICS_COIL, + ITEM_ID.GHOST_WEAVE, + ITEM_ID.RIP_ROUNDS, ]); const netNew = SCOREABLE_ITEMS.filter(i => !ORIGINAL_KNOWN.has(i.id)); assert.ok(netNew.length >= 5, `expected >= 5 net-new scoreable items, found ${netNew.length}`); @@ -126,9 +126,9 @@ test('getShopCatalog folds in unlocked scoreable items as they accrue', () => { assert.ok(ids.includes(ITEM_ID.MONOBLADE)); assert.equal(oneUnlock.length, DEFAULT_ITEMS.length + 1); - const twoUnlocks = getShopCatalog([ITEM_ID.MONOBLADE, ITEM_ID.ARMOUR_PLATING]); + const twoUnlocks = getShopCatalog([ITEM_ID.MONOBLADE, ITEM_ID.BONE_LACING]); assert.equal(twoUnlocks.length, DEFAULT_ITEMS.length + 2); - assert.ok(twoUnlocks.some(i => i.id === ITEM_ID.ARMOUR_PLATING)); + assert.ok(twoUnlocks.some(i => i.id === ITEM_ID.BONE_LACING)); }); test('getShopCatalog never renders a locked scoreable item', () => { @@ -165,6 +165,6 @@ test('getShopCatalog stock depends only on unlocks, never on rep (no rep param)' test('getItemById resolves across both catalogs and throws on unknown', () => { assert.equal(getItemById(ITEM_ID.STIM).id, ITEM_ID.STIM); // default assert.equal(getItemById(ITEM_ID.MONOBLADE).id, ITEM_ID.MONOBLADE); // scoreable net-new - assert.equal(getItemById(ITEM_ID.ARMOUR_PLATING).id, ITEM_ID.ARMOUR_PLATING); // scoreable original + assert.equal(getItemById(ITEM_ID.BONE_LACING).id, ITEM_ID.BONE_LACING); // scoreable original assert.throws(() => getItemById('unobtanium'), /unknown item/i); }); diff --git a/tests/unit/game/corpTurnStatusCopy.test.ts b/tests/unit/game/corpTurnStatusCopy.test.ts index 35726d6..a3e8ff9 100644 --- a/tests/unit/game/corpTurnStatusCopy.test.ts +++ b/tests/unit/game/corpTurnStatusCopy.test.ts @@ -217,6 +217,34 @@ test('formatCorpTurnStep narrates melee knockback when present', () => { assert.match(line, /Patch is shoved to \(4, 2\)/); }); +test('formatCorpTurnStep surfaces armor and shield mitigation on an incoming hit', () => { + const line = formatCorpTurnStep( + '[Corp]Bruiser', + { + type: 'melee', + target: 'crew-merc', + result: { + hit: true, + dodged: false, + roll: 0.99, + dodgeThreshold: 0.2, + inCover: false, + damage: 0, + killed: false, + damageResolution: { + incomingDamage: 2, + armorAbsorbed: 1, + shieldAbsorbed: 1, + hpDamage: 0, + }, + }, + }, + id => (id === 'crew-merc' ? 'Patch' : id) + ); + + assert.match(line, /2 → ARMOR -1 → SHIELD -1 · HP SAFE/); +}); + test('isCorpTurnStepLogVisibleToPlayer: a lookout mark on the player is felt even when unseen', () => { const { world } = makeDroneWorld(); const step = { type: 'spot' as const, target: 'p1' }; diff --git a/tests/unit/game/crewDisplay.test.ts b/tests/unit/game/crewDisplay.test.ts index 59fd1d3..821b8f8 100644 --- a/tests/unit/game/crewDisplay.test.ts +++ b/tests/unit/game/crewDisplay.test.ts @@ -1,42 +1,43 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { gearLines, statDisplays } from '../../../src/game/crewDisplay.js'; +import { GEAR_LEADER_PATHS, gearLabels, statDisplays } from '../../../src/game/crewDisplay.js'; import { Merc } from '../../../src/game/archetypes/Merc.js'; import { Razor } from '../../../src/game/archetypes/Razor.js'; import { ITEM_ID } from '../../../src/game/items.js'; // --------------------------------------------------------------------------- -// gearLines — roster gear readout. Every applyGear channel must surface a line -// so an operative's loadout is visible on the crew roster (P3.M6 gear bug). +// gearLabels — roster gear readout. Every applyGear channel must surface an +// entry so an operative's loadout is visible on the crew roster. // --------------------------------------------------------------------------- -test('gearLines returns [] for null/empty gear', () => { - assert.deepEqual(gearLines(null), []); +test('gearLabels returns {} for null/empty gear', () => { + assert.deepEqual(gearLabels(null), {}); assert.deepEqual( - gearLines({ maxHpBonus: 0, hitBonus: 0, dodgeBonus: 0, rangedDamageBonus: 0 }), - [] + gearLabels({ maxHpBonus: 0, hitBonus: 0, dodgeBonus: 0, rangedDamageBonus: 0 }), + {} ); }); -test('gearLines surfaces the four pre-M6 channels', () => { - const lines = gearLines({ +test('gearLabels surfaces the four pre-M6 channels', () => { + const labels = gearLabels({ maxHpBonus: 2, hitBonus: 0.1, dodgeBonus: 0.1, rangedDamageBonus: 1, }); + const { maxHpBonus: hp, hitBonus: hit, dodgeBonus: dodge, rangedDamageBonus: shot } = labels; assert.ok( - lines.some(l => l.includes('Armor Plating') && l.includes('+2 HP')), - `expected Armor Plating line, got ${JSON.stringify(lines)}` + hp.includes('Bone Lacing') && hp.includes('+2 HP'), + `expected Bone Lacing line, got ${JSON.stringify(labels)}` ); - assert.ok(lines.some(l => l.includes('Targeting Chip') && l.includes('10%'))); - assert.ok(lines.some(l => l.includes('Reflex Weave') && l.includes('10%'))); - assert.ok(lines.some(l => l.includes('Ballistics Coil') && l.includes('+1'))); + assert.ok(shot.includes('RiP Rounds') && shot.includes('+1')); + assert.ok(dodge.includes('Ghost Weave') && dodge.includes('10%')); + assert.ok(hit.includes('Targeting Chip') && hit.includes('10%')); }); -test('gearLines surfaces the five P3.M6.2 channels', () => { - const lines = gearLines({ +test('gearLabels surfaces the five P3.M6.2 channels', () => { + const labels = gearLabels({ maxHpBonus: 0, hitBonus: 0, dodgeBonus: 0, @@ -47,36 +48,61 @@ test('gearLines surfaces the five P3.M6.2 channels', () => { shieldRegen: 1, hpRegen: 1, }); - assert.ok( - lines.some(l => l.includes('Monoblade')), - `missing Monoblade in ${JSON.stringify(lines)}` - ); - assert.ok(lines.some(l => l.includes('Subdermal Plating'))); - assert.ok(lines.some(l => l.includes('Reflex Booster'))); - assert.ok(lines.some(l => l.includes('Phase Shield'))); - assert.ok(lines.some(l => l.includes('Regen Mesh'))); + const { + meleeDamageBonus: mb, + armorBonus: sp, + apBonus: rb, + shieldRegen: ps, + hpRegen: rm, + } = labels; + assert.ok(mb.includes('Monoblade'), `missing Monoblade in ${JSON.stringify(labels)}`); + assert.ok(sp.includes('Subdermal Plating')); + assert.ok(rb.includes('Adrenal Spike')); + assert.ok(ps.includes('Phase Shield')); + assert.ok(rm.includes('Regen Mesh')); }); test('every applyGear item yields at least one roster line', () => { // Walk the live applyGear path so the readout can never silently drop a // channel a purchase can produce. for (const itemId of [ - ITEM_ID.ARMOUR_PLATING, + ITEM_ID.BONE_LACING, ITEM_ID.TARGETING_CHIP, - ITEM_ID.REFLEX_WEAVE, - ITEM_ID.BALLISTICS_COIL, + ITEM_ID.GHOST_WEAVE, + ITEM_ID.RIP_ROUNDS, ITEM_ID.MONOBLADE, ITEM_ID.SUBDERMAL_PLATING, - ITEM_ID.REFLEX_BOOSTER, + ITEM_ID.ADRENAL_SPIKE, ITEM_ID.PHASE_SHIELD, ITEM_ID.REGEN_MESH, ]) { const crew = new Merc({ id: 'merc', x: 0, y: 0 }); crew.applyGear(itemId); - assert.ok(gearLines(crew.gear).length >= 1, `applyGear(${itemId}) produced no roster line`); + assert.ok( + Object.keys(gearLabels(crew.gear)).length >= 1, + `applyGear(${itemId}) produced no roster line` + ); } }); +test('every roster gear label has a silhouette leader path', () => { + const labels = gearLabels({ + maxHpBonus: 1, + hitBonus: 0.1, + dodgeBonus: 0.1, + rangedDamageBonus: 1, + meleeDamageBonus: 1, + armorBonus: 1, + apBonus: 1, + shieldRegen: 1, + hpRegen: 1, + }); + + // Path syntax/shape is a visual concern, not a unit-test one — just require + // key parity so a newly surfaced gear channel can't ship without an anchor. + assert.deepEqual(Object.keys(GEAR_LEADER_PATHS).sort(), Object.keys(labels).sort()); +}); + // --------------------------------------------------------------------------- // statDisplays — roster STATS block. Maps each combat stat to its display // value with gear folded into the value (no inline annotation). Core keys @@ -112,10 +138,10 @@ test('statDisplays folds each gear bonus into its stat value, counted exactly on const crew = new Merc({ id: 'merc', x: 0, y: 0 }); crew.applyGear(ITEM_ID.TARGETING_CHIP); - crew.applyGear(ITEM_ID.REFLEX_WEAVE); - crew.applyGear(ITEM_ID.BALLISTICS_COIL); + crew.applyGear(ITEM_ID.GHOST_WEAVE); + crew.applyGear(ITEM_ID.RIP_ROUNDS); crew.applyGear(ITEM_ID.MONOBLADE); - crew.applyGear(ITEM_ID.REFLEX_BOOSTER); + crew.applyGear(ITEM_ID.ADRENAL_SPIKE); crew.applyGear(ITEM_ID.SUBDERMAL_PLATING); const labels = statDisplays(crew); diff --git a/tests/unit/game/hub/Finn.test.ts b/tests/unit/game/hub/Finn.test.ts index a1edb43..2542293 100644 --- a/tests/unit/game/hub/Finn.test.ts +++ b/tests/unit/game/hub/Finn.test.ts @@ -42,7 +42,7 @@ test('Finn.catalog returns the default consumable stock, no rep gate (P3.M6.2)', assert.equal(items.length, 4); assert.ok(ids.includes(ITEM_ID.STIM)); assert.ok(ids.includes(ITEM_ID.SMOKE_CHARGE)); - assert.ok(ids.includes(ITEM_ID.INCENDIARY)); + assert.ok(ids.includes(ITEM_ID.MOLOTOV)); assert.ok(ids.includes(ITEM_ID.BREACHING_CHARGE)); }); @@ -50,19 +50,19 @@ test('Finn.catalog never surfaces a locked scoreable item', () => { const f = new Finn(); const ids = f.catalog().map(i => i.id); // The former KNOWN-tier gear is now scoreable — locked until a Score unlock. - assert.ok(!ids.includes(ITEM_ID.ARMOUR_PLATING)); + assert.ok(!ids.includes(ITEM_ID.BONE_LACING)); assert.ok(!ids.includes(ITEM_ID.TARGETING_CHIP)); - assert.ok(!ids.includes(ITEM_ID.REFLEX_WEAVE)); - assert.ok(!ids.includes(ITEM_ID.BALLISTICS_COIL)); + assert.ok(!ids.includes(ITEM_ID.GHOST_WEAVE)); + assert.ok(!ids.includes(ITEM_ID.RIP_ROUNDS)); }); test('Finn.catalog folds in unlocked scoreable blueprints from the meta-store', () => { const f = new Finn(); - const ids = f.catalog([ITEM_ID.ARMOUR_PLATING, ITEM_ID.MONOBLADE]).map(i => i.id); + const ids = f.catalog([ITEM_ID.BONE_LACING, ITEM_ID.MONOBLADE]).map(i => i.id); // Default stock still present. assert.ok(ids.includes(ITEM_ID.STIM)); // Unlocked scoreable now stocked. - assert.ok(ids.includes(ITEM_ID.ARMOUR_PLATING)); + assert.ok(ids.includes(ITEM_ID.BONE_LACING)); assert.ok(ids.includes(ITEM_ID.MONOBLADE)); // Still-locked scoreable stays hidden. assert.ok(!ids.includes(ITEM_ID.TARGETING_CHIP)); @@ -85,7 +85,7 @@ test('Finn.catalog surfaces no locked scoreable across any meta-store state', () [ITEM_ID.MONOBLADE], allIds, ['ghost-blueprint', 'retired-mk1'], - [ITEM_ID.MONOBLADE, ITEM_ID.MONOBLADE, ITEM_ID.ARMOUR_PLATING], + [ITEM_ID.MONOBLADE, ITEM_ID.MONOBLADE, ITEM_ID.BONE_LACING], ]; for (const unlocked of metaStates) { const unlockedSet = new Set(unlocked); @@ -158,9 +158,9 @@ test('catalog item costs are priced in Creds', () => { assert.equal(getItemById(ITEM_ID.STIM).cost, 20); assert.equal(getItemById(ITEM_ID.SMOKE_CHARGE).cost, 30); assert.equal(getItemById(ITEM_ID.BREACHING_CHARGE).cost, SHOP_COST.BREACHING_CHARGE); - assert.equal(getItemById(ITEM_ID.ARMOUR_PLATING).cost, 60); + assert.equal(getItemById(ITEM_ID.BONE_LACING).cost, 60); assert.equal(getItemById(ITEM_ID.TARGETING_CHIP).cost, 80); - assert.equal(getItemById(ITEM_ID.REFLEX_WEAVE).cost, 80); + assert.equal(getItemById(ITEM_ID.GHOST_WEAVE).cost, 80); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/game/items.test.ts b/tests/unit/game/items.test.ts index 3879002..60334dd 100644 --- a/tests/unit/game/items.test.ts +++ b/tests/unit/game/items.test.ts @@ -33,23 +33,23 @@ import { Entity } from '../../../src/game/Entity.js'; import { FACTION } from '../../../src/game/constants.js'; // --------------------------------------------------------------------------- -// Crew.applyGear — Armour Plating +// Crew.applyGear — Bone Lacing // --------------------------------------------------------------------------- -test('applyGear(ARMOUR_PLATING) increases maxHp and hp by 1', () => { +test('applyGear(BONE_LACING) increases maxHp and hp by 1', () => { const crew = new Merc({ id: 'merc', x: 0, y: 0 }); const origMaxHp = crew.maxHp; const origHp = crew.hp; - crew.applyGear(ITEM_ID.ARMOUR_PLATING); + crew.applyGear(ITEM_ID.BONE_LACING); assert.equal(crew.maxHp, origMaxHp + 1); assert.equal(crew.hp, origHp + 1); assert.equal(crew.gear.maxHpBonus, 1); }); -test('applyGear(ARMOUR_PLATING) stacks', () => { +test('applyGear(BONE_LACING) stacks', () => { const crew = new Merc({ id: 'merc', x: 0, y: 0 }); - crew.applyGear(ITEM_ID.ARMOUR_PLATING); - crew.applyGear(ITEM_ID.ARMOUR_PLATING); + crew.applyGear(ITEM_ID.BONE_LACING); + crew.applyGear(ITEM_ID.BONE_LACING); assert.equal(crew.gear.maxHpBonus, 2); assert.equal(crew.maxHp, DEFAULT_HP + 2); }); @@ -70,18 +70,18 @@ test('applyGear throws on unknown item', () => { }); // --------------------------------------------------------------------------- -// Crew.applyGear — Reflex Weave +// Crew.applyGear — Ghost Weave // --------------------------------------------------------------------------- -test('applyGear(REFLEX_WEAVE) sets dodgeBonus', () => { +test('applyGear(GHOST_WEAVE) sets dodgeBonus', () => { const crew = new Merc({ id: 'merc', x: 0, y: 0 }); - crew.applyGear(ITEM_ID.REFLEX_WEAVE); + crew.applyGear(ITEM_ID.GHOST_WEAVE); assert.equal(crew.gear.dodgeBonus, DODGE_BONUS); }); -test('applyGear(BALLISTICS_COIL) sets rangedDamageBonus', () => { +test('applyGear(RIP_ROUNDS) sets rangedDamageBonus', () => { const crew = new Merc({ id: 'merc', x: 0, y: 0 }); - crew.applyGear(ITEM_ID.BALLISTICS_COIL); + crew.applyGear(ITEM_ID.RIP_ROUNDS); assert.equal(crew.gear.rangedDamageBonus, RANGED_DAMAGE_BONUS); }); @@ -95,7 +95,7 @@ test('applyGear(MONOBLADE) raises meleeAttackDamage by the bonus, capped', () => razor.applyGear(ITEM_ID.MONOBLADE); assert.equal(razor.gear.meleeDamageBonus, MELEE_DAMAGE_BONUS); assert.equal(razor.meleeAttackDamage(), base + MELEE_DAMAGE_BONUS); - // Capped: a second install is a harmless no-op (mirrors Ballistics Coil). + // Capped: a second install is a harmless no-op (mirrors RiP Rounds). razor.applyGear(ITEM_ID.MONOBLADE); assert.equal(razor.gear.meleeDamageBonus, razor.maxMeleeDamageBonus); assert.equal(razor.meleeAttackDamage(), base + razor.maxMeleeDamageBonus); @@ -148,15 +148,15 @@ test('subdermal plating mitigates incoming melee damage with a min-1 floor', () assert.equal(target.hp, hpBefore - 1); }); -test('applyGear(REFLEX_BOOSTER) grants +AP immediately, capped at one', () => { +test('applyGear(ADRENAL_SPIKE) grants +AP immediately, capped at one', () => { const merc = new Merc({ id: 'merc', x: 0, y: 0, maxAp: DEFAULT_AP }); const apBefore = merc.ap; - merc.applyGear(ITEM_ID.REFLEX_BOOSTER); + merc.applyGear(ITEM_ID.ADRENAL_SPIKE); assert.equal(merc.maxAp, DEFAULT_AP + AP_BONUS); assert.equal(merc.ap, apBefore + AP_BONUS, 'extra AP is usable the same turn'); assert.equal(merc.gear.apBonus, AP_BONUS); // One per operator — a second install is a no-op, no runaway AP. - merc.applyGear(ITEM_ID.REFLEX_BOOSTER); + merc.applyGear(ITEM_ID.ADRENAL_SPIKE); assert.equal(merc.maxAp, DEFAULT_AP + merc.maxApBonus); assert.equal(merc.gear.apBonus, merc.maxApBonus); }); @@ -242,7 +242,7 @@ test('resolveRanged incorporates gear rangedDamageBonus into damage', () => { const bus = new EventBus(); const world = new World(grid, { events: bus }); const attacker = new Merc({ id: 'merc', x: 2, y: 2, maxAp: 4 }); - attacker.applyGear(ITEM_ID.BALLISTICS_COIL); + attacker.applyGear(ITEM_ID.RIP_ROUNDS); const target = new Skirmisher({ id: 'drone', x: 5, y: 2 }); world.addEntity(attacker); world.addEntity(target); @@ -267,7 +267,7 @@ test('resolveMelee incorporates gear dodgeBonus into threshold', () => { glyph: 'd', }); const target = new Razor({ id: 'razor', x: 3, y: 2, callsign: 'Cipher' }); - target.applyGear(ITEM_ID.REFLEX_WEAVE); + target.applyGear(ITEM_ID.GHOST_WEAVE); world.addEntity(attacker); world.addEntity(target); const rng = new Rng(42); @@ -354,10 +354,10 @@ test('useConsumable(SMOKE_CHARGE) returns smoke descriptor', () => { // Crew.useConsumable — Incendiary // --------------------------------------------------------------------------- -test('useConsumable(INCENDIARY) returns thrown hazard descriptor', () => { +test('useConsumable(MOLOTOV) returns thrown hazard descriptor', () => { const crew = new Merc({ id: 'merc', x: 3, y: 3, maxAp: 4 }); - crew.addConsumable(ITEM_ID.INCENDIARY); - const result = crew.useConsumable(ITEM_ID.INCENDIARY, { dx: 1, dy: 0 }); + crew.addConsumable(ITEM_ID.MOLOTOV); + const result = crew.useConsumable(ITEM_ID.MOLOTOV, { dx: 1, dy: 0 }); assert.equal(result.type, 'incendiary'); assert.equal(result.cx, 3 + INCENDIARY_THROW_DIST); assert.equal(result.cy, 3); @@ -376,10 +376,10 @@ test('useConsumable(BREACHING_CHARGE) returns adjacent breach descriptor', () => test('useConsumable enforces aim shape for aimed items only', () => { const crew = new Merc({ id: 'merc', x: 3, y: 3, maxAp: 4 }); - crew.addConsumable(ITEM_ID.INCENDIARY); - assert.throws(() => crew.useConsumable(ITEM_ID.INCENDIARY), /requires aim/i); - assert.throws(() => crew.useConsumable(ITEM_ID.INCENDIARY, { dx: 0, dy: 0 }), /invalid aim/i); - assert.throws(() => crew.useConsumable(ITEM_ID.INCENDIARY, { dx: 2, dy: 0 }), /invalid aim/i); + crew.addConsumable(ITEM_ID.MOLOTOV); + assert.throws(() => crew.useConsumable(ITEM_ID.MOLOTOV), /requires aim/i); + assert.throws(() => crew.useConsumable(ITEM_ID.MOLOTOV, { dx: 0, dy: 0 }), /invalid aim/i); + assert.throws(() => crew.useConsumable(ITEM_ID.MOLOTOV, { dx: 2, dy: 0 }), /invalid aim/i); const breachCrew = new Merc({ id: 'breach-merc', x: 3, y: 3, maxAp: 4 }); breachCrew.addConsumable(ITEM_ID.BREACHING_CHARGE); diff --git a/tests/unit/input/applyIntent.test.ts b/tests/unit/input/applyIntent.test.ts index 5f5c4aa..1c3c086 100644 --- a/tests/unit/input/applyIntent.test.ts +++ b/tests/unit/input/applyIntent.test.ts @@ -417,14 +417,14 @@ test('slide onto a consumable pickup collects it', () => { id: 'consumable-pickup-0', x: 2, y: 4, - consumableId: ITEM_ID.INCENDIARY, + consumableId: ITEM_ID.MOLOTOV, label: 'Incendiary', }) ); applyIntent({ type: 'special', dx: 0, dy: 1 }, ctx); assert.equal(player.x, 2); assert.equal(player.y, 4); - assert.equal(player.inventory?.consumables[0]?.id, ITEM_ID.INCENDIARY); + assert.equal(player.inventory?.consumables[0]?.id, ITEM_ID.MOLOTOV); assert.equal(world.entities.has('consumable-pickup-0'), false); assert.ok(log.some(l => l.includes('picks up Incendiary'))); }); diff --git a/tests/unit/render/AsciiRenderer.test.ts b/tests/unit/render/AsciiRenderer.test.ts index ce4d553..42c8c26 100644 --- a/tests/unit/render/AsciiRenderer.test.ts +++ b/tests/unit/render/AsciiRenderer.test.ts @@ -241,6 +241,7 @@ test('draw() paints structured combat HUD rows in the planned canvas corners', ( objective: { title: 'Sentinel window', done: false, turnsRemaining: 4 }, identity: { callsign: 'Patch', archetype: 'tech', stealthed: true }, hp: { hp: 2, maxHp: 3 }, + defense: { armor: 1, shield: { current: 1, capacity: 1 } }, ap: { ap: 2, maxAp: 4 }, turn: { currentFaction: FACTION.PLAYER, turnNumber: 12 }, }, @@ -250,6 +251,8 @@ test('draw() paints structured combat HUD rows in the planned canvas corners', ( const objective = textOps.find(c => c.char === 'OBJ Sentinel window [TODO] [TURN:4]'); const identity = textOps.find(c => c.char === 'Patch [TECH] [CLOAKED]'); const hpPrefix = textOps.find(c => c.char === 'HP '); + const shield = textOps.find(c => c.char === '◆'); + const armor = textOps.find(c => c.char === 'ARM 1'); const turn = textOps.find(c => c.char === 'TURN 12'); assert.equal(objective?.px, 6, 'objective row sits left'); @@ -258,6 +261,8 @@ test('draw() paints structured combat HUD rows in the planned canvas corners', ( assert.equal(identity?.py, 5, 'identity row is the top-right first row'); assert.equal(identity?.textAlign, 'right'); assert.equal(hpPrefix?.py, 30, 'HP row shares top-right row 1'); + assert.equal(shield?.py, 30, 'charged shield shares the HP row'); + assert.equal(armor?.py, 30, 'persistent armor shares the HP row'); assert.equal(turn?.px, 30, 'turn row sits bottom-left clear of the canvas frame corner'); assert.equal(turn?.py, canvas.height - 18); }); @@ -274,6 +279,7 @@ test('draw() paints combat HUD HP and AP glyphs with per-state colors', () => { objective: { title: 'Sentinel window', done: false }, identity: { callsign: 'Patch', archetype: 'tech', stealthed: false }, hp: { hp: 1, maxHp: 3 }, + defense: { armor: 1, shield: { current: 0, capacity: 1 } }, ap: { ap: 2, maxAp: 4 }, turn: { currentFaction: FACTION.CORP, turnNumber: 12 }, }, @@ -290,6 +296,11 @@ test('draw() paints combat HUD HP and AP glyphs with per-state colors', () => { ] ); + const spentShield = textOps.find(c => c.char === '◇' && c.py === 30); + const armor = textOps.find(c => c.char === 'ARM 1' && c.py === 30); + assert.ok(spentShield, 'spent shield remains visible as an empty diamond'); + assert.ok(armor, 'armor remains a numeric modifier rather than a pip'); + const apGlyphs = textOps.filter(c => (c.char === '○' || c.char === '●') && c.py === 55); assert.deepEqual( apGlyphs.map(c => [c.char, c.fillStyle]), diff --git a/tests/unit/render/animations.test.ts b/tests/unit/render/animations.test.ts index 8d5b639..bb58443 100644 --- a/tests/unit/render/animations.test.ts +++ b/tests/unit/render/animations.test.ts @@ -4,12 +4,14 @@ import assert from 'node:assert/strict'; import { ANIMATION_DURATIONS, DAMAGE_CLASS, + MITIGATION_FLASH_CLASS, SHAKE_CLASS, createAnimationLock, restartCssAnimation, runInteractSecuredFlash, runMuzzleFlash, triggerDamageFlash, + triggerMitigationFlash, triggerShake, } from '../../../src/render/animations.js'; @@ -21,6 +23,7 @@ import { */ function makeElement() { const classes = new Set(); + const properties = new Map(); let offsetWidthReads = 0; return { classList: { @@ -29,6 +32,11 @@ function makeElement() { contains: cls => classes.has(cls), toString: () => Array.from(classes).join(' '), }, + style: { + setProperty: (name, value) => properties.set(name, value), + removeProperty: name => properties.delete(name), + getPropertyValue: name => properties.get(name) ?? '', + }, get offsetWidth() { offsetWidthReads += 1; return 100; @@ -129,6 +137,18 @@ test('triggerShake / triggerDamageFlash apply the shared class constants', () => assert.equal(el.classList.contains(DAMAGE_CLASS), false); }); +test('triggerMitigationFlash keys the vignette color to the defense that stopped the hit', () => { + const el = makeElement(); + const timers = makeTimers(); + + triggerMitigationFlash(el, 'shield', timers); + assert.equal(el.classList.contains(MITIGATION_FLASH_CLASS), true); + assert.equal(el.style.getPropertyValue('--kp-impact-flash-color'), '#c8b6ff8c'); + + triggerMitigationFlash(el, 'armor', timers); + assert.equal(el.style.getPropertyValue('--kp-impact-flash-color'), '#d49a3a8c'); +}); + 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 87a24e0..36a0e8d 100644 --- a/tests/unit/render/combatHud.test.ts +++ b/tests/unit/render/combatHud.test.ts @@ -6,6 +6,7 @@ import { fitObjectiveHudLine, formatApPips, formatCombatHudA11ySummary, + formatDefenseHud, formatHpSegments, formatIdentityHud, formatObjectiveHud, @@ -96,6 +97,19 @@ test('formatHpSegments right-fills live HP segments', () => { assert.equal(formatHpSegments({ hp: 0, maxHp: 3 }), 'HP □□□'); }); +test('formatDefenseHud distinguishes persistent armor from charged and spent shield', () => { + assert.equal(formatDefenseHud(undefined), ''); + assert.equal(formatDefenseHud({ armor: 1 }), 'ARM 1'); + assert.equal(formatDefenseHud({ shield: { current: 1, capacity: 1 } }), 'SH ◆'); + assert.equal(formatDefenseHud({ shield: { current: 0, capacity: 1 } }), 'SH ◇'); + assert.equal(formatDefenseHud({ armor: 1, shield: { current: 1, capacity: 1 } }), 'SH ◆ ARM 1'); +}); + +test('formatDefenseHud rejects corrupt defense values', () => { + assert.throws(() => formatDefenseHud({ armor: -1 }), /armor/); + assert.throws(() => formatDefenseHud({ shield: { current: 2, capacity: 1 } }), /shield.current/); +}); + test('formatApPips right-fills available AP pips', () => { assert.equal(formatApPips({ ap: 4, maxAp: 4 }), 'AP ●●●●'); assert.equal(formatApPips({ ap: 2, maxAp: 4 }), 'AP ○○●●'); @@ -127,9 +141,10 @@ test('formatCombatHudA11ySummary preserves moved HUD facts in readable text', () objective: { title: 'Sentinel window', done: false, turnsRemaining: 4 }, identity: { callsign: 'Patch', archetype: 'tech', stealthed: false }, hp: { hp: 2, maxHp: 3 }, + defense: { armor: 1, shield: { current: 0, capacity: 1 } }, ap: { ap: 2, maxAp: 4 }, turn: { currentFaction: FACTION.PLAYER, turnNumber: 12 }, }), - 'Patch, TECH, 2 of 3 HP, 2 of 4 AP, turn 12, objective Sentinel window TODO, 4 turns remaining' + 'Patch, TECH, 2 of 3 HP, shield spent, armor 1, 2 of 4 AP, turn 12, objective Sentinel window TODO, 4 turns remaining' ); }); diff --git a/tests/unit/shell/combatHudSnapshot.test.ts b/tests/unit/shell/combatHudSnapshot.test.ts new file mode 100644 index 0000000..16e4716 --- /dev/null +++ b/tests/unit/shell/combatHudSnapshot.test.ts @@ -0,0 +1,45 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Merc } from '../../../src/game/archetypes/Merc.js'; +import { ITEM_ID } from '../../../src/game/items.js'; +import type { Run } from '../../../src/game/Run.js'; +import { combatHudBodyPanes } from '../../../src/shell/combatHudSnapshot.js'; + +test('combatHudBodyPanes exposes equipped armor and live shield state for Meatspace crew', () => { + const crew = new Merc({ id: 'crew-merc', x: 0, y: 0, callsign: 'Vega' }); + crew.applyGear(ITEM_ID.SUBDERMAL_PLATING); + crew.applyGear(ITEM_ID.PHASE_SHIELD); + crew.refreshAp(); + const scene = { + player: crew, + meatActor: crew, + activeLayer: 'meat', + archetype: 'merc', + } as unknown as Run; + + const panes = combatHudBodyPanes(scene); + + assert.deepEqual(panes.defense, { + armor: 1, + shield: { current: 1, capacity: 1 }, + }); + + crew.damage(1); + assert.deepEqual(combatHudBodyPanes(scene).defense, { + armor: 1, + shield: { current: 0, capacity: 1 }, + }); +}); + +test('combatHudBodyPanes omits defense readout when no defensive gear is equipped', () => { + const crew = new Merc({ id: 'crew-merc', x: 0, y: 0 }); + const scene = { + player: crew, + meatActor: crew, + activeLayer: 'meat', + archetype: 'merc', + } as unknown as Run; + + assert.equal(combatHudBodyPanes(scene).defense, undefined); +}); diff --git a/tests/unit/shell/sceneListeners.test.ts b/tests/unit/shell/sceneListeners.test.ts index cd70a30..0a7995b 100644 --- a/tests/unit/shell/sceneListeners.test.ts +++ b/tests/unit/shell/sceneListeners.test.ts @@ -77,6 +77,7 @@ test('P3.M4.5: meat body damage flashes the PIP only while viewing Cyberspace', const flashes: string[] = []; let pipPaints = 0; const fakeClassList = { remove: () => {}, add: () => {}, toggle: () => {} }; + const fakeStyle = { setProperty: () => {}, removeProperty: () => {} }; const controller = new SceneListenerController({ getScene: () => scene, getCampaign: () => null, @@ -84,8 +85,16 @@ test('P3.M4.5: meat body damage flashes the PIP only while viewing Cyberspace', getCyberVision: () => new VisionField(), resetCyberVision: () => new VisionField(), dom: { - stageEl: { classList: fakeClassList, offsetWidth: 0 } as unknown as HTMLElement, - pipCanvas: { classList: fakeClassList, offsetWidth: 0 } as unknown as HTMLCanvasElement, + stageEl: { + classList: fakeClassList, + style: fakeStyle, + offsetWidth: 0, + } as unknown as HTMLElement, + pipCanvas: { + classList: fakeClassList, + style: fakeStyle, + offsetWidth: 0, + } as unknown as HTMLCanvasElement, }, // No flashCell ⇒ runMuzzleFlash is a no-op, so we exercise only the damage block. renderers: { main: {} as never, pip: {} as never }, @@ -121,6 +130,111 @@ test('P3.M4.5: meat body damage flashes the PIP only while viewing Cyberspace', assert.equal(flashes.length, 1); }); +test('fully mitigated body hits still shake and flash with the stopping defense color', () => { + const bus = new EventBus(); + const body = { id: 'body', x: 3, y: 4, hp: 4, maxHp: 8, alive: true }; + const scene = { + bus, + world: { entities: new Map() }, + player: body, + archetype: 'decker', + cyberspace: { + phase: 'active', + layer: { avatar: { id: 'avatar' }, bus: new EventBus(), mapSeenKeys: () => [] }, + }, + activeLayer: 'cyber', + state: 'combat', + } as unknown as ShellScene; + + const classes = new Set(); + const properties = new Map(); + const pipClasses = new Set(); + const pipProperties = new Map(); + const flashes: string[] = []; + let pipPaints = 0; + 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; + const controller = new SceneListenerController({ + getScene: () => scene, + getCampaign: () => null, + getMeatVision: () => new VisionField(), + getCyberVision: () => new VisionField(), + resetCyberVision: () => new VisionField(), + dom: { + stageEl: stage, + pipCanvas: { + classList: { + add: (name: string) => pipClasses.add(name), + remove: (name: string) => pipClasses.delete(name), + }, + style: { + setProperty: (name: string, value: string) => pipProperties.set(name, value), + removeProperty: (name: string) => pipProperties.delete(name), + }, + offsetWidth: 0, + } as unknown as HTMLCanvasElement, + }, + renderers: { main: {} as never, pip: {} as never }, + animLock: { push: () => {} }, + effects: { + flash: (line: string) => flashes.push(line), + paint: () => {}, + paintPip: () => { + pipPaints++; + }, + recomputeVision: () => {}, + }, + onCivilianHarmReset: () => {}, + onCivilianHarmed: () => {}, + onRepAdjust: () => {}, + onAlarmTransition: () => {}, + onObjectiveTimerExpired: () => {}, + memoriseMeatCorpse: () => {}, + memoriseCyberCorpse: () => {}, + }); + controller.rewire(); + + bus.emit(EVENT.ENTITY_DAMAGED, { + target: body, + damage: 0, + damageResolution: { + incomingDamage: 2, + armorAbsorbed: 1, + shieldAbsorbed: 1, + hpDamage: 0, + }, + }); + assert.equal(classes.has('kp-shake'), true); + assert.equal(classes.has('kp-mitigation-flash'), true); + assert.equal(properties.get('--kp-impact-flash-color'), '#c8b6ff8c'); + assert.equal(pipClasses.has('pip-hit'), true); + assert.equal(pipProperties.get('--kp-pip-impact-color'), '#c8b6ff8c'); + assert.equal(pipPaints, 1); + assert.match(flashes[0] ?? '', /^BODY BLOCKED/); + + bus.emit(EVENT.ENTITY_DAMAGED, { + target: body, + damage: 0, + damageResolution: { + incomingDamage: 1, + armorAbsorbed: 1, + shieldAbsorbed: 0, + hpDamage: 0, + }, + }); + assert.equal(properties.get('--kp-impact-flash-color'), '#d49a3a8c'); + assert.equal(pipProperties.get('--kp-pip-impact-color'), '#d49a3a8c'); +}); + 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 }; @@ -136,6 +250,7 @@ test('P3.M4.6: forced jack-out body repair is not memorised as a meat corpse', ( let memorised = 0; const fakeClassList = { remove: () => {}, add: () => {}, toggle: () => {} }; + const fakeStyle = { setProperty: () => {}, removeProperty: () => {} }; const controller = new SceneListenerController({ getScene: () => scene, getCampaign: () => null, @@ -143,8 +258,16 @@ test('P3.M4.6: forced jack-out body repair is not memorised as a meat corpse', ( getCyberVision: () => new VisionField(), resetCyberVision: () => new VisionField(), dom: { - stageEl: { classList: fakeClassList, offsetWidth: 0 } as unknown as HTMLElement, - pipCanvas: { classList: fakeClassList, offsetWidth: 0 } as unknown as HTMLCanvasElement, + stageEl: { + classList: fakeClassList, + style: fakeStyle, + offsetWidth: 0, + } as unknown as HTMLElement, + pipCanvas: { + classList: fakeClassList, + style: fakeStyle, + offsetWidth: 0, + } as unknown as HTMLCanvasElement, }, renderers: { main: {} as never, pip: {} as never }, animLock: { push: () => {} },