Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 4 additions & 9 deletions components/CombatInventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,14 @@

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';

type CombatInventoryItem = Omit<Item, 'scope' | 'cost' | 'description' | 'needsTarget'> & {
count: number;
};

/** Human-readable labels for item IDs. */
const ITEM_LABELS = {
stim: 'Stim',
'smoke-charge': 'Smoke Charge',
'breaching-charge': 'Breaching Charge',
};

/** Interactive consumables-list styling layered on top of the shared chrome. */
const CONSUMABLE_CSS = `
.rows {
Expand Down Expand Up @@ -117,7 +110,9 @@ class CombatInventory extends InventoryOverlay {
}
this.#items = [];
for (const [id, count] of counts) {
this.#items.push({ id, label: ITEM_LABELS[id as keyof typeof ITEM_LABELS] ?? id, count });
// Label from the single catalog source of truth (`items.ts`); throws on an
// unknown id rather than silently rendering the raw id.
this.#items.push({ id, label: getItemById(id).label, count });
}
this.#selectedIndex = 0;
if (this.ready) this.render();
Expand Down
147 changes: 130 additions & 17 deletions components/CrewRoster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
* rosterEl.show();
*/

import { h } from '/src/domUtils.js';
import { CreateSvg, h } from '/src/domUtils.js';
import CrewList from '/components/CrewList.js';
import {
emptySalvage,
Expand All @@ -28,7 +28,7 @@ import {
} from '/src/game/salvage.js';
import type { Crew as CrewMember } from '/src/game/Crew.js';
import { ITEM_ID, type Item } from '/src/game/items.js';
import { gearLines, statDisplays } from '/src/game/crewDisplay.js';
import { GEAR_LEADER_PATHS, gearLabels, statDisplays } from '/src/game/crewDisplay.js';

function crewMemberHeader(crewMember: CrewMember): HTMLElement {
const name = crewMember.callsign ?? crewMember.id;
Expand Down Expand Up @@ -87,9 +87,9 @@ function consumablesRow(items: Item[]): HTMLElement {
]);

const incendiary = h('div', { className: 'consumable-item' }, [
h('img', { src: '/images/incind.png', alt: 'Firebomb' }),
h('span', { className: 'consumable-count', innerText: counts.get(ITEM_ID.INCENDIARY) ?? 0 }),
h('div', { className: 'consumable-caption', innerText: 'Fire' }),
h('img', { src: '/images/molotov.png', alt: 'Molotov' }),
h('span', { className: 'consumable-count', innerText: counts.get(ITEM_ID.MOLOTOV) ?? 0 }),
h('div', { className: 'consumable-caption', innerText: 'Molotov' }),
]);

const charge = h('div', { className: 'consumable-item' }, [
Expand All @@ -111,6 +111,21 @@ function consumablesRow(items: Item[]): HTMLElement {
return consSection;
}

function gearLeaders(keys: string[]): SVGSVGElement {
const paths = keys
.map(key => {
const path = GEAR_LEADER_PATHS[key];
if (!path) throw new Error(`Missing crew-roster gear leader path for ${key}`);
return `<path class="gear-leader ${key}" d="${path}" />`;
})
.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);
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions components/FinnShop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,11 +363,11 @@ const SCOPE_LABELS: Record<string, string> = {

const ITEM_CAP_LABELS: Record<string, string> = {
[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',
};
Expand Down
6 changes: 6 additions & 0 deletions docs/kaizen.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<dialog>` 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 `<finn-shop>` 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).
Expand Down
12 changes: 12 additions & 0 deletions docs/phase-3-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added images/gear.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/molotov.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 15 additions & 10 deletions main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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`.
* ------------------------------------------------------------------------ */

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
}
}
Expand Down
Loading
Loading