diff --git a/README.md b/README.md index 839658b..c2b554f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Turn-based cyberpunk roguelike as a Progressive Web App — tactical grid combat **Vision and roadmap:** [docs/kernel-panic-v1-blueprint.md](docs/kernel-panic-v1-blueprint.md) **Short overview:** [docs/game-overview.md](docs/game-overview.md) -**Current phase:** [docs/phase-2.9-plan.md](docs/phase-2.9-plan.md) (`v0.2.9`) +**Current phase:** Post-[docs/phase-3-plan.md](docs/phase-3-plan.md) balance patch (`v0.3.1`) ## Architecture diff --git a/components/CombatInventory.ts b/components/CombatInventory.ts new file mode 100644 index 0000000..309f1b2 --- /dev/null +++ b/components/CombatInventory.ts @@ -0,0 +1,217 @@ +/** + * — the deployed operator's live inventory overlay. + * + * Three sections: + * 1. **SALVAGE** — the operator's job-scoped pickup wallet (informational). + * 2. **KEY ITEMS** — held keycards, shown as the generic "Access keycard": + * the locked door is right in front of you, so the location is self-evident. + * 3. **CONSUMABLES** — the operator's item list, navigable with ↑/↓ and + * activated with Enter. Emits `use-item` with `{ itemId }` on confirm. + * + * The Hub's read-only campaign stash is a separate element, ``. + */ + +import { h } from '/src/domUtils.js'; +import { emptySalvage, type TypedSalvage } from '/src/game/salvage.js'; +import 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 & { + 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 { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.empty { + text-align: center; + color: var(--inv-dim); + font-size: 0.85rem; + padding: 0.5rem 0; +} + +button.row { + appearance: none; + -webkit-appearance: none; + background: transparent; + color: var(--inv-text); + border: 1px solid transparent; + border-radius: 4px; + padding: 0.5rem 0.6rem; + text-align: left; + font: inherit; + cursor: pointer; + min-height: 40px; + display: flex; + gap: 0.5rem; + align-items: center; +} + +button.row:not(:disabled):hover { + background: var(--inv-row-hover); +} + +button.row:focus-visible, +button.row[aria-current='true'] { + outline: none; + border-color: var(--inv-accent); + background: var(--inv-row-active); +} + +.cursor { + color: var(--inv-accent); + font-weight: 700; + visibility: hidden; +} + +button.row:focus-visible .cursor, +button.row[aria-current='true'] .cursor { + visibility: visible; +} + +.item-name { + color: var(--inv-accent); + font-weight: 700; + letter-spacing: 0.08em; +} + +.item-count { + color: var(--inv-dim); + font-size: 0.85rem; +} +`; + +class CombatInventory extends InventoryOverlay { + #items: CombatInventoryItem[] = []; + #buttons: HTMLButtonElement[] = []; + #selectedIndex = 0; + + setContents({ + salvage = emptySalvage(), + consumables = [] as Item[], + keyItems = [] as KeyItemView[], + }: { + salvage?: TypedSalvage; + consumables?: Item[]; + keyItems?: KeyItemView[]; + } = {}) { + this.salvage = salvage; + this.keyItems = keyItems; + // Aggregate by id so duplicates show as "Stim x2". + const counts = new Map(); + for (const c of consumables) { + counts.set(c.id, (counts.get(c.id) ?? 0) + 1); + } + this.#items = []; + for (const [id, count] of counts) { + this.#items.push({ id, label: ITEM_LABELS[id as keyof typeof ITEM_LABELS] ?? id, count }); + } + this.#selectedIndex = 0; + if (this.ready) this.render(); + } + + protected override styleText(): string { + return INVENTORY_CSS + CONSUMABLE_CSS; + } + + protected panelTitle(): string { + return '── INVENTORY ──'; + } + + protected renderBody(): void { + this.#buttons = []; + this.renderSalvageSection(); + this.renderKeycardSection('KEY ITEMS', ki => ki.label); + + this.bodyEl.appendChild(h('p', { className: 'section-label', textContent: 'CONSUMABLES' })); + if (this.#items.length === 0) { + this.bodyEl.appendChild(h('p', { className: 'empty', textContent: 'No consumables.' })); + return; + } + const itemRows = h('div', { className: 'rows' }); + for (let i = 0; i < this.#items.length; i++) { + const item = this.#items[i]; + const btn = h('button', { + type: 'button', + className: 'row', + ariaCurrent: i === this.#selectedIndex ? 'true' : 'false', + }) as HTMLButtonElement; + btn.dataset.index = String(i); + btn.addEventListener('click', () => this.#activate(i)); + btn.append( + h('span', { className: 'cursor', textContent: '>' }), + h('span', { className: 'item-name', textContent: item.label }), + h('span', { className: 'item-count', textContent: item.count > 1 ? `x${item.count}` : '' }) + ); + itemRows.appendChild(btn); + this.#buttons.push(btn); + } + this.bodyEl.appendChild(itemRows); + } + + protected hintText(): string { + // Only mention ENTER when there's something to use. Empty wallets + empty + // consumables still get an Esc hint (the salvage view is useful on its own). + if (this.#items.length > 0) return '[ ENTER use · Esc close ]'; + if (this.salvageIsEmpty && this.keyItems.length === 0) return 'Nothing carried. [ Esc close ]'; + return '[ Esc close ]'; + } + + protected override onShown(): void { + const btn = this.#buttons[this.#selectedIndex]; + if (btn) btn.focus(); + else this.focus(); + } + + protected override handleExtraKey(evt: KeyboardEvent): boolean { + if (evt.key === 'ArrowDown' || evt.key === 's') { + evt.preventDefault(); + this.#move(1); + return true; + } + if (evt.key === 'ArrowUp' || evt.key === 'w') { + evt.preventDefault(); + this.#move(-1); + return true; + } + if (evt.key === 'Enter' || evt.key === ' ') { + evt.preventDefault(); + this.#activate(this.#selectedIndex); + return true; + } + return false; + } + + #move(delta: number) { + if (this.#items.length === 0) return; + this.#selectedIndex = (this.#selectedIndex + delta + this.#items.length) % this.#items.length; + for (let i = 0; i < this.#buttons.length; i++) { + this.#buttons[i].setAttribute('aria-current', i === this.#selectedIndex ? 'true' : 'false'); + } + const btn = this.#buttons[this.#selectedIndex]; + if (btn) btn.focus(); + } + + #activate(index: number) { + const item = this.#items[index]; + if (!item) return; + this.emit('use-item', { itemId: item.id }); + } +} + +customElements.define('combat-inventory', CombatInventory); + +export default CombatInventory; diff --git a/components/ContractSelect.ts b/components/ContractSelect.ts index db097e4..422cdac 100644 --- a/components/ContractSelect.ts +++ b/components/ContractSelect.ts @@ -7,7 +7,7 @@ import { h } from '/src/domUtils.js'; import { encounterHostileCount } from '/src/game/encounters.js'; import { contractLocationBadges } from '/src/game/hub/arcSurface.js'; -import { cloneObjective } from '/src/game/hub/Curator.js'; +import { cloneObjective, contractRequiresCyberspace } from '/src/game/hub/Curator.js'; import type { Contract } from '/src/game/hub/Curator.js'; const DIFFICULTY_LABEL: Record = Object.freeze({ @@ -145,6 +145,14 @@ const CSS = ` color: #020403; } +/* Cyberspace job marker — magenta from the cyber palette (see CYBER_ACCENT in + src/render/pip.ts and the simstim FLIP fab) so a dual-deploy reads as "the + cyber layer" wherever it surfaces. */ +.badge.cyber { + background: #ff8ad8; + color: #020403; +} + .known { color: var(--board-dim); border: 1px solid rgba(106, 232, 200, 0.45); @@ -167,6 +175,11 @@ const CSS = ` border-color: #9be7ff; } +.known.keycard { + color: var(--board-accent, #6ae8c8); + border-color: rgba(106, 232, 200, 0.7); +} + .meta { color: var(--board-dim); font-size: 0.84rem; @@ -205,6 +218,7 @@ class ContractSelect extends HTMLElement { #contracts: Contract[] = []; #scoreTargetSiteId: string | null = null; #scorePrincipalId: string | null = null; + #heldKeycardPrincipalIds: ReadonlySet = new Set(); #selectedIndex = 0; #ready = false; #listEl: HTMLElement | null = null; @@ -271,6 +285,14 @@ class ContractSelect extends HTMLElement { if (this.#ready) this.#render(); } + setHeldKeycardPrincipalIds(principalIds: string[]) { + if (!Array.isArray(principalIds)) { + throw new TypeError('.setHeldKeycardPrincipalIds requires an array'); + } + this.#heldKeycardPrincipalIds = new Set(principalIds); + if (this.#ready) this.#render(); + } + show() { this.setAttribute('open', ''); queueMicrotask(() => this.#focusSelected()); @@ -307,12 +329,23 @@ class ContractSelect extends HTMLElement { className: `badge ${isScoreContract(contract) ? 'score' : contract.difficulty}`, textContent: difficultyLabel(contract), }), + // Cyber jobs send a Decker into Cyberspace (with a meat partner + // when the crew can spare one) — flag them on the board so the + // player knows a Decker is in play before they open the briefing. + ...(contractRequiresCyberspace(contract) + ? [h('span', { className: 'badge cyber', textContent: 'CYBER' })] + : []), h('span', { className: 'target', textContent: jobTitleCopy(contract) }), ]), h( 'div', { className: 'location' }, - locationLine(contract, this.#scoreTargetSiteId, this.#scorePrincipalId) + locationLine( + contract, + this.#scoreTargetSiteId, + this.#scorePrincipalId, + this.#heldKeycardPrincipalIds + ) ), h('div', { className: 'meta', textContent: rewardCopy(contract) }), ]), @@ -404,13 +437,19 @@ function jobTitleCopy(contract: Contract): string { function locationLine( contract: Contract, scoreTargetSiteId: string | null, - scorePrincipalId: string | null + scorePrincipalId: string | null, + heldKeycardPrincipalIds: ReadonlySet ): HTMLElement[] { const { principal, site, siteState } = contract.context; const place = site ? `${principal.label} ${site.label}` : principal.label; const state = siteState ? ` [${siteState.label}]` : ''; const nodes: HTMLElement[] = [h('span', { textContent: `Location: ${place}${state}` })]; - for (const badge of contractLocationBadges(contract, scoreTargetSiteId, scorePrincipalId)) { + for (const badge of contractLocationBadges( + contract, + scoreTargetSiteId, + scorePrincipalId, + heldKeycardPrincipalIds + )) { const className = badge.variant === 'revisit' ? 'known' : `known ${badge.variant}`; nodes.push(h('span', { className, textContent: badge.text })); } diff --git a/components/CrewInventory.ts b/components/CrewInventory.ts new file mode 100644 index 0000000..06d429b --- /dev/null +++ b/components/CrewInventory.ts @@ -0,0 +1,48 @@ +/** + * — the Hub's read-only campaign stash overlay. + * + * Two sections: + * 1. **SALVAGE** — the campaign-wide accumulated wallet (so the player can + * audit typed totals without opening the shop). + * 2. **STOLEN KEYCARDS** — the persistent keycard inventory, each labelled by + * the location it was lifted from (e.g. "Redline server farm"). + * + * No consumables (those are operator-held, not a shared crew pool) and nothing + * to activate — the interactive kit lives on ``. + */ + +import { emptySalvage, type TypedSalvage } from '/src/game/salvage.js'; +import type { KeyItemView } from '/src/shell/domTypes.js'; +import { InventoryOverlay } from '/components/InventoryOverlay.js'; + +class CrewInventory extends InventoryOverlay { + setContents({ + salvage = emptySalvage(), + keyItems = [] as KeyItemView[], + }: { + salvage?: TypedSalvage; + keyItems?: KeyItemView[]; + } = {}) { + this.salvage = salvage; + this.keyItems = keyItems; + if (this.ready) this.render(); + } + + protected panelTitle(): string { + return '── INVENTORY ──'; + } + + protected renderBody(): void { + this.renderSalvageSection(); + this.renderKeycardSection('STOLEN KEYCARDS', ki => ki.locationName ?? ki.label); + } + + protected hintText(): string { + if (this.salvageIsEmpty && this.keyItems.length === 0) return 'Nothing stashed. [ Esc close ]'; + return '[ Esc close ]'; + } +} + +customElements.define('crew-inventory', CrewInventory); + +export default CrewInventory; diff --git a/components/CrewRoster.ts b/components/CrewRoster.ts index b3408bb..0b02b8a 100644 --- a/components/CrewRoster.ts +++ b/components/CrewRoster.ts @@ -26,42 +26,89 @@ import { totalSalvage, type TypedSalvage, } from '/src/game/salvage.js'; -import type { Crew as CrewMember, Gear } from '/src/game/Crew.js'; -import type { Item } from '/src/game/items.js'; - -/** Human-readable labels for item IDs (mirrors ItemInventory). */ -const ITEM_LABELS = { - stim: 'Stim', - 'smoke-charge': 'Smoke Charge', - 'breaching-charge': 'Breaching Charge', -}; - -/** Human-readable labels for gear bonuses. */ -function gearLines(gear: Gear) { - if (!gear) return []; - const lines: string[] = []; - if (gear.maxHpBonus > 0) lines.push(`Armour Plating +${gear.maxHpBonus} HP`); - if (gear.hitBonus > 0) lines.push(`Targeting Chip +${(gear.hitBonus * 100).toFixed(0)}%`); - if ((gear.dodgeBonus ?? 0) > 0) - lines.push(`Reflex Weave +${((gear.dodgeBonus ?? 0) * 100).toFixed(0)}%`); - if ((gear.rangedDamageBonus ?? 0) > 0) - lines.push(`Ballistics Coil +${gear.rangedDamageBonus} ranged dmg`); - return lines; -} - -/** Aggregate consumables into "Label x2" lines. */ -function consumableLines(consumables: Item[]) { - if (!consumables || consumables.length === 0) return []; +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'; + +function crewMemberHeader(crewMember: CrewMember): HTMLElement { + const name = crewMember.callsign ?? crewMember.id; + const className = (crewMember.constructor?.name ?? crewMember.archetype ?? 'Crew').toUpperCase(); + return h('p', { className: 'detail-header' }, [ + h('span', { className: 'detail-name', textContent: name }), + h('span', { + className: 'detail-class', + textContent: ` [${className}]`, + }), + ]); +} + +function StatCell(label: string, value: string): HTMLElement { + return h('td', {}, [ + h('span', { className: 'stat-label', textContent: label }), + h('span', { className: 'stat-value', textContent: value }), + ]); +} + +function statsTable(crewMember: CrewMember): HTMLElement { + const labels = statDisplays(crewMember); + return h('div', { className: 'stats-table-container' }, [ + h('table', { className: 'detail-stats-table' }, [ + h('tr', {}, [ + StatCell('HP ', labels.hp), + StatCell('AP ', labels.ap), + StatCell('ARMOR ', labels.armor), + ]), + ]), + h('table', { className: 'detail-stats-table secondary' }, [ + h('tr', {}, [StatCell('RANGED ', labels.ranged), StatCell('AIM ', labels.aim)]), + ]), + h('table', { className: 'detail-stats-table secondary' }, [ + h('tr', {}, [StatCell('MELEE ', labels.melee), StatCell('DODGE ', labels.dodge)]), + ]), + ]); +} + +function consumablesRow(items: Item[]): HTMLElement { const counts = new Map(); - for (const c of consumables) { + for (const c of items) { counts.set(c.id, (counts.get(c.id) ?? 0) + 1); } - const lines = []; - for (const [id, count] of counts) { - const label = ITEM_LABELS[id as keyof typeof ITEM_LABELS] ?? id; - lines.push(count > 1 ? `${label} x${count}` : label); - } - return lines; + + const stims = h('div', { className: 'consumable-item' }, [ + h('img', { src: '/images/stim.png', alt: 'Stims' }), + h('span', { className: 'consumable-count', innerText: counts.get(ITEM_ID.STIM) ?? 0 }), + h('div', { className: 'consumable-caption', innerText: 'Stim' }), + ]); + + const smoke = h('div', { className: 'consumable-item' }, [ + h('img', { src: '/images/smoke.png', alt: 'Smoke' }), + h('span', { className: 'consumable-count', innerText: counts.get(ITEM_ID.SMOKE_CHARGE) ?? 0 }), + h('div', { className: 'consumable-caption', innerText: 'Smoke' }), + ]); + + 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' }), + ]); + + const charge = h('div', { className: 'consumable-item' }, [ + h('img', { src: '/images/charge.png', alt: 'Charge' }), + h('span', { + className: 'consumable-count', + innerText: counts.get(ITEM_ID.BREACHING_CHARGE) ?? 0, + }), + h('div', { className: 'consumable-caption', innerText: 'Breach' }), + ]); + + const consSection = h('div', { className: 'detail-section consumables' }, [ + stims, + smoke, + incendiary, + charge, + ]); + + return consSection; } const CSS = ` @@ -73,6 +120,7 @@ const CSS = ` --roster-accent: var(--accent-color, #00d9a5); --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; display: none; position: fixed; @@ -154,14 +202,18 @@ crew-list { .detail { border-left: 1px dashed var(--roster-border); padding-left: 1rem; - min-height: 120px; + min-height: 15rem; + height: stretch; + + display: flex; + flex-direction: column; } .detail-header { display: flex; align-items: baseline; gap: 0.15rem; - margin: 0 0 0.5rem; + margin: 0; } .detail-name { @@ -175,6 +227,28 @@ crew-list { color: var(--roster-dim); font-size: 0.88rem; letter-spacing: 0.08em; + margin-left: auto; +} + +.detail-stats-table { + border-collapse: collapse; + font-size: smaller; + width: 100%; + + td { + border: 1px solid var(--roster-border-secondary); + padding: 0.25rem; + + span.stat-label { + color: var(--roster-border-secondary); + } + } + + &.secondary { + td { + border-top-width: 0; + } + } } .detail-section { @@ -202,6 +276,35 @@ crew-list { margin: 0.1rem; } +.consumables { + display: flex; + gap: 0.5rem; + justify-content: space-between; + margin: auto 0 0; + + .consumable-item { + flex: 0 0 22%; + position: relative; + + img { + filter: hue-rotate(-35deg); /* from cyan to lime */ + width: 3.5rem; + } + + .consumable-count { + position: absolute; + right: 0.15rem; + bottom: 1.15rem; + background-color: var(--roster-bg); + } + + .consumable-caption { + font-size: smaller; + text-align: center; + } + } +} + .flatlined-label { color: var(--roster-danger); letter-spacing: 0.1em; @@ -355,10 +458,6 @@ class CrewRoster extends HTMLElement { this.#onSelect((evt as CustomEvent<{ member: CrewMember }>).detail.member) ); - this.#detailEl = h('div', { className: 'detail' }); - - const body = h('div', { className: 'body' }, [this.#listEl, this.#detailEl]); - // Recruit section (hidden until recruits are set). this.#recruitRowsEl = h('div', { className: 'recruit-rows' }); this.#recruitBtnEl = h('button', { @@ -375,13 +474,21 @@ class CrewRoster extends HTMLElement { ]); this.#recruitSectionEl.style.display = 'none'; + const listContainer = h('div', { className: 'crew-list-container' }, [ + this.#listEl, + this.#recruitSectionEl, + ]); + + this.#detailEl = h('div', { className: 'detail' }); + + const body = h('div', { className: 'body' }, [listContainer, this.#detailEl]); + this.#hintEl = h('p', { className: 'hint', textContent: '[ ↑/↓ navigate · Esc dismiss ]' }); this.#panelEl = h('section', { className: 'panel' }, [ this.#titleEl, this.#balanceEl, this.#campaignStatusEl, body, - this.#recruitSectionEl, this.#hintEl, ]); shadow.appendChild(this.#panelEl); @@ -534,15 +641,7 @@ class CrewRoster extends HTMLElement { while (this.#detailEl!.firstChild) this.#detailEl!.removeChild(this.#detailEl!.firstChild); // Name + class header - this.#detailEl!.appendChild( - h('p', { className: 'detail-header' }, [ - h('span', { className: 'detail-name', textContent: member.callsign }), - h('span', { - className: 'detail-class', - textContent: ` [${member.archetype.toUpperCase()}]`, - }), - ]) - ); + this.#detailEl!.appendChild(crewMemberHeader(full)); if (member.flatlined) { this.#detailEl!.appendChild( @@ -551,32 +650,11 @@ class CrewRoster extends HTMLElement { return; } - // Stats - const statsSection = h('div', { className: 'detail-section' }); - statsSection.appendChild(h('p', { className: 'detail-section-title', textContent: 'STATS' })); - statsSection.appendChild( - h('p', { className: 'detail-stat', textContent: `HP ${member.hp}/${member.maxHp}` }) - ); - const hitBonus = full.gear?.hitBonus ?? 0; - const actualHit = Math.min(full.baseHitChance + hitBonus, 1); - statsSection.appendChild( - h('p', { - className: 'detail-stat', - textContent: `AIM ${(actualHit * 100).toFixed(0)}%`, - }) - ); - const dodgeBonus = full.gear?.dodgeBonus ?? 0; - const actualDodge = Math.min(full.baseDodgeChance + dodgeBonus, 1); - statsSection.appendChild( - h('p', { - className: 'detail-stat', - textContent: `DODGE ${(actualDodge * 100).toFixed(0)}%`, - }) - ); - this.#detailEl!.appendChild(statsSection); + // Stats — full combat readout with gear bonuses annotated inline. + this.#detailEl!.appendChild(statsTable(full)); // Gear - const gLines = gearLines(full.gear ?? ({} as 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) { @@ -590,19 +668,7 @@ class CrewRoster extends HTMLElement { // Consumables const consumables = full.inventory?.consumables ?? ([] as Item[]); - const cLines = consumableLines(consumables); - const consSection = h('div', { className: 'detail-section' }); - consSection.appendChild( - h('p', { className: 'detail-section-title', textContent: 'CONSUMABLES' }) - ); - if (cLines.length === 0) { - consSection.appendChild(h('p', { className: 'detail-none', textContent: '-None-' })); - } else { - for (const line of cLines) { - consSection.appendChild(h('p', { className: 'detail-stat', textContent: line })); - } - } - this.#detailEl!.appendChild(consSection); + this.#detailEl!.appendChild(consumablesRow(consumables)); } // ─── Recruitment ───────────────────────────────────────────────────── @@ -672,36 +738,10 @@ class CrewRoster extends HTMLElement { #showRecruitDetail(recruit: CrewMember) { while (this.#detailEl!.firstChild) this.#detailEl!.removeChild(this.#detailEl!.firstChild); - this.#detailEl!.appendChild( - h('p', { className: 'detail-header' }, [ - h('span', { className: 'detail-name', textContent: recruit.callsign ?? recruit.id }), - h('span', { - className: 'detail-class', - textContent: ` [${(recruit.constructor?.name ?? recruit.archetype ?? 'Crew').toUpperCase()}]`, - }), - ]) - ); - // Stats — recruits are fresh, no gear/consumables. - const statsSection = h('div', { className: 'detail-section' }); - statsSection.appendChild(h('p', { className: 'detail-section-title', textContent: 'STATS' })); - statsSection.appendChild( - h('p', { className: 'detail-stat', textContent: `HP ${recruit.hp}/${recruit.maxHp}` }) - ); - const hitChance = recruit.baseHitChance ?? 0.65; - const dodgeChance = recruit.baseDodgeChance ?? 0.2; - statsSection.appendChild( - h('p', { - className: 'detail-stat', - textContent: `AIM ${(hitChance * 100).toFixed(0)}%`, - }) - ); - statsSection.appendChild( - h('p', { - className: 'detail-stat', - textContent: `DODGE ${(dodgeChance * 100).toFixed(0)}%`, - }) - ); - this.#detailEl!.appendChild(statsSection); + this.#detailEl!.appendChild(crewMemberHeader(recruit)); + // Stats — recruits are fresh (no gear), but show the full combat readout + // so the player can compare a recruit against the standing crew. + this.#detailEl!.appendChild(statsTable(recruit)); } /** diff --git a/components/FinnShop.ts b/components/FinnShop.ts index 7aafa54..e022cc9 100644 --- a/components/FinnShop.ts +++ b/components/FinnShop.ts @@ -31,9 +31,12 @@ type CrewMemberSnapshot = { hp: number; maxHp: number; flatlined: boolean; - atMaxHit: boolean; - atMaxDodge: boolean; - atMaxRangedDamage: boolean; + /** + * Ids of the campaign gear this operator has already saturated (limit-1 gear + * they own, or stacking gear at its cap). Sourced from `Crew.gearAtCap` so the + * shop greys out the same purchases `Campaign.purchase` would refuse. + */ + saturatedGearIds: string[]; }; const SALVAGE_TYPE_LABELS: Record = { @@ -362,6 +365,11 @@ 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.MONOBLADE]: 'EQUIPPED', + [ITEM_ID.SUBDERMAL_PLATING]: 'EQUIPPED', + [ITEM_ID.REFLEX_BOOSTER]: 'EQUIPPED', + [ITEM_ID.PHASE_SHIELD]: 'EQUIPPED', + [ITEM_ID.REGEN_MESH]: 'EQUIPPED', }; const SCOPE_ORDER = [ITEM_SCOPE.JOB, ITEM_SCOPE.CAMPAIGN]; @@ -439,6 +447,7 @@ class FinnShop extends HTMLElement { balances: { credits: number; salvage: TypedSalvage } ) { this.#catalog = catalog; + const gearIds = catalog.filter(item => item.scope === ITEM_SCOPE.CAMPAIGN).map(item => item.id); this.#crew = crew.map(member => ({ id: member.id, callsign: member.callsign ?? member.id, @@ -446,9 +455,7 @@ class FinnShop extends HTMLElement { hp: member.hp, maxHp: member.maxHp, flatlined: !!member.flatlined, - atMaxHit: (member.gear?.hitBonus ?? 0) >= member.maxHitBonus, - atMaxDodge: (member.gear?.dodgeBonus ?? 0) >= member.maxDodgeBonus, - atMaxRangedDamage: (member.gear?.rangedDamageBonus ?? 0) >= member.maxRangedDamageBonus, + saturatedGearIds: gearIds.filter(id => member.gearAtCap(id)), })); this.#credits = balances.credits ?? 0; this.#salvage = balances.salvage ?? emptySalvage(); @@ -624,16 +631,10 @@ class FinnShop extends HTMLElement { this.#crew.findIndex(member => !this.#isTargetDisabled(member)) ); - const isTargetingChip = item.id === ITEM_ID.TARGETING_CHIP; - const isReflexWeave = item.id === ITEM_ID.REFLEX_WEAVE; - const isBallisticsCoil = item.id === ITEM_ID.BALLISTICS_COIL; const rows = h('div', { className: 'rows' }); for (let i = 0; i < this.#crew.length; i++) { const member = this.#crew[i]; - const atCap = - (isTargetingChip && member.atMaxHit) || - (isReflexWeave && member.atMaxDodge) || - (isBallisticsCoil && member.atMaxRangedDamage); + const atCap = member.saturatedGearIds.includes(item.id); const disabled = this.#isTargetDisabled(member); const btn = h('button', { type: 'button', @@ -795,16 +796,7 @@ class FinnShop extends HTMLElement { #isTargetDisabled(member: CrewMemberSnapshot) { if (member.flatlined || !this.#pendingItem) return true; - switch (this.#pendingItem.id) { - case ITEM_ID.TARGETING_CHIP: - return member.atMaxHit; - case ITEM_ID.REFLEX_WEAVE: - return member.atMaxDodge; - case ITEM_ID.BALLISTICS_COIL: - return member.atMaxRangedDamage; - default: - return false; - } + return member.saturatedGearIds.includes(this.#pendingItem.id); } #syncCurrent() { diff --git a/components/GameOver.ts b/components/GameOver.ts index 4b666fd..431bae5 100644 --- a/components/GameOver.ts +++ b/components/GameOver.ts @@ -14,6 +14,7 @@ import { h } from '/src/domUtils.js'; import { validateCampaignSummary, type CampaignSummary } from '/src/game/campaignSummary.js'; +import { selectEndFlavor } from '/src/game/endFlavor.js'; const CSS = ` :host { @@ -240,32 +241,6 @@ function hexSeed(seed: number): string { return `0x${(seed >>> 0).toString(16).toUpperCase().padStart(8, '0')}`; } -function reasonCopy(summary: CampaignSummary): string { - if (summary.result === 'win') return 'The Score is complete.'; - if (summary.result === 'partial') return 'The Score is compromised.'; - if (summary.endReason === 'clock-expired') return 'The Score window closed.'; - if (summary.endReason === 'decker-flatlined-score') { - return 'The Decker flatlined during the Score.'; - } - return 'No surviving operators.'; -} - -function detailCopy(summary: CampaignSummary): string { - if (summary.result === 'win') { - return 'Target data secured. The crew beat the window and closed the campaign on their terms.'; - } - if (summary.result === 'partial') { - return 'Someone made it out, but the finale broke before the crew could clear the target cleanly.'; - } - if (summary.endReason === 'clock-expired') { - return 'Corp security caught up. The contract is cold and this campaign is over.'; - } - if (summary.endReason === 'decker-flatlined-score') { - return 'The intrusion channel is gone. Nobody can finish the Score.'; - } - return 'Every crew slot on the roster is flatlined. Their campaign ends here.'; -} - class GameOver extends HTMLElement { #summary: CampaignSummary | null = null; #ready = false; @@ -274,6 +249,7 @@ class GameOver extends HTMLElement { reason: HTMLElement; detail: HTMLElement; reward: HTMLElement; + rewardKicker: HTMLElement; rewardName: HTMLElement; rewardFlavor: HTMLElement; rosterList: HTMLElement; @@ -294,13 +270,13 @@ class GameOver extends HTMLElement { const banner = h('h1', { className: 'banner' }); const reason = h('p', { className: 'reason' }); const detail = h('p', { className: 'detail' }); + const rewardKicker = h('span', { + className: 'reward-kicker', + textContent: 'BLUEPRINT ACQUIRED', + }); const rewardName = h('span', { className: 'reward-name' }); const rewardFlavor = h('span', { className: 'reward-flavor' }); - const reward = h('p', { className: 'reward' }, [ - h('span', { className: 'reward-kicker', textContent: 'BLUEPRINT SECURED' }), - rewardName, - rewardFlavor, - ]); + const reward = h('p', { className: 'reward' }, [rewardKicker, rewardName, rewardFlavor]); const rosterList = h('dd'); const jobsDd = h('dd', { id: 'jobs' }); const repDd = h('dd', { id: 'rep' }); @@ -346,6 +322,7 @@ class GameOver extends HTMLElement { reason, detail, reward, + rewardKicker, rewardName, rewardFlavor, rosterList, @@ -383,12 +360,16 @@ class GameOver extends HTMLElement { #render() { if (!this.#els || !this.#summary) return; const summary = this.#summary; - this.#els.banner.textContent = summary.result === 'win' ? 'SCORE COMPLETE' : 'GAME OVER'; - this.#els.reason.textContent = reasonCopy(summary); - this.#els.detail.textContent = detailCopy(summary); + // Every end-screen line is drawn deterministically from per-seed flavor + // pools so each slot does distinct narrative work and varies run to run. + const flavor = selectEndFlavor(summary); + this.#els.banner.textContent = flavor.banner; + this.#els.reason.textContent = flavor.reason; + this.#els.detail.textContent = flavor.detail; // Score prize — present on a win that stole a specific blueprint. const reward = summary.result === 'win' ? (summary.scoreReward ?? null) : null; - if (reward) { + if (reward && flavor.rewardKicker) { + this.#els.rewardKicker.textContent = flavor.rewardKicker; this.#els.rewardName.textContent = reward.label; this.#els.rewardFlavor.textContent = reward.flavor; this.#els.reward.style.display = 'block'; diff --git a/components/InventoryOverlay.ts b/components/InventoryOverlay.ts new file mode 100644 index 0000000..817bcf0 --- /dev/null +++ b/components/InventoryOverlay.ts @@ -0,0 +1,325 @@ +/** + * base — shared chrome for the two inventory surfaces: + * + * - ``: the deployed operator's live, *interactive* kit — + * job-scoped salvage, held keycards, and a navigable consumables list. + * - ``: the Hub's *read-only* campaign stash — accumulated + * salvage and the STOLEN KEYCARDS trophy shelf. No consumables (those are + * operator-held, not a shared pool) and nothing to activate. + * + * The two diverged enough (interactivity, which sections exist) to be separate + * elements, but share the overlay chrome, the SALVAGE table, and the keycard + * list. That shared surface lives here; subclasses fill in `renderBody`, + * `panelTitle`, `hintText`, and (optionally) extra key handling. + */ + +import { h } from '/src/domUtils.js'; +import { + SALVAGE_TYPES, + emptySalvage, + totalSalvage, + type SalvageType, + type TypedSalvage, +} from '/src/game/salvage.js'; +import { KEYCARD_GLYPH } from '/src/game/constants.js'; +import type { KeyItemView } from '/src/shell/domTypes.js'; + +/** + * Human-readable labels for typed-salvage buckets. Matches the P2.5.M4.2 docs: + * Scrap = mechanical, Chips = electronics, Bio = organic, Data = informational. + */ +export const SALVAGE_LABELS: Record = { + scrap: 'Scrap', + chips: 'Chips', + bio: 'Bio', + data: 'Data', +}; + +/** Shared overlay + SALVAGE + keycard styling. Interactive-list CSS is layered on by the combat surface. */ +export const INVENTORY_CSS = ` +:host { + --inv-bg: rgba(7, 18, 16, 0.96); + --inv-border: var(--accent-color, #00d9a5); + --inv-text: #c5efdf; + --inv-dim: #6ae8c8; + --inv-accent: var(--accent-color, #00d9a5); + --inv-row-hover: rgba(0, 217, 165, 0.08); + --inv-row-active: rgba(0, 217, 165, 0.18); + --inv-shadow: 0 0 28px rgba(0, 217, 165, 0.18), 0 12px 36px rgba(0, 0, 0, 0.5); + + display: none; + position: fixed; + inset: 0; + z-index: 50; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.55); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: var(--inv-text); +} + +:host([open]) { + display: flex; +} + +.panel { + background: var(--inv-bg); + border: 1px solid var(--inv-border); + border-radius: 6px; + padding: 1.25rem 1.5rem 1.4rem; + box-shadow: var(--inv-shadow); + min-width: min(360px, 88vw); + max-width: min(480px, 96vw); +} + +.title { + margin: 0 0 0.75rem; + text-align: center; + font-size: 0.95rem; + letter-spacing: 0.18em; + color: var(--inv-accent); + border-bottom: 1px dashed var(--inv-border); + padding-bottom: 0.5rem; +} + +.section-label { + margin: 0.9rem 0 0.4rem; + font-size: 0.8rem; + letter-spacing: 0.18em; + color: var(--inv-dim); + text-transform: uppercase; +} + +.section-label:first-of-type { + margin-top: 0; +} + +.salvage-rows { + display: flex; + flex-direction: column; + gap: 0.2rem; + margin-bottom: 0.4rem; +} + +.salvage-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 0.25rem 0.6rem; + border: 1px solid transparent; + border-radius: 4px; + min-height: 1.6rem; +} + +.salvage-row.zero { + opacity: 0.45; +} + +.salvage-row .bucket-name { + color: var(--inv-text); + letter-spacing: 0.06em; +} + +.salvage-row .bucket-count { + color: var(--inv-accent); + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.salvage-row.zero .bucket-count { + color: var(--inv-dim); + font-weight: 400; +} + +.key-item-rows { + display: flex; + flex-direction: column; + gap: 0.2rem; + margin-bottom: 0.4rem; +} + +.key-item-row { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.25rem 0.6rem; + border: 1px solid transparent; + border-radius: 4px; + min-height: 1.6rem; +} + +.key-item-row .key-glyph { + color: var(--inv-accent); + font-weight: 700; +} + +.key-item-row .key-label { + color: var(--inv-text); + letter-spacing: 0.06em; +} + +.hint { + text-align: center; + font-size: 0.85rem; + color: var(--inv-dim); + letter-spacing: 0.1em; + margin: 0.9rem 0 0; +} +`; + +/** + * Shared overlay behaviour. Not registered as a custom element itself — + * `` and `` extend it. + */ +export abstract class InventoryOverlay extends HTMLElement { + protected salvage: TypedSalvage = emptySalvage(); + protected keyItems: KeyItemView[] = []; + #ready = false; + protected titleEl!: HTMLElement; + protected bodyEl!: HTMLElement; + protected hintEl!: HTMLElement; + #panelEl: HTMLElement | null = null; + #onKeyDown: ((ev: KeyboardEvent) => void) | null = null; + #onBackdrop: ((ev: PointerEvent) => void) | null = null; + + /** Full stylesheet for the shadow root. Subclasses append their own rules. */ + protected styleText(): string { + return INVENTORY_CSS; + } + + /** Centered panel title, e.g. `── INVENTORY ──`. */ + protected abstract panelTitle(): string; + + /** Append the surface-specific sections into `bodyEl` (already cleared). */ + protected abstract renderBody(): void; + + /** Footer hint copy. */ + protected abstract hintText(): string; + + /** Focus management once the overlay opens. Defaults to focusing the host. */ + protected onShown(): void { + this.focus(); + } + + /** Subclass key handling beyond Escape. Return true when the key was consumed. */ + protected handleExtraKey(_evt: KeyboardEvent): boolean { + return false; + } + + /** Whether the shadow chrome has been built (guards early setContents calls). */ + protected get ready(): boolean { + return this.#ready; + } + + connectedCallback() { + if (this.#ready) return; + this.tabIndex = -1; + const shadow = this.attachShadow({ mode: 'open' }); + const style = h('style'); + style.textContent = this.styleText(); + shadow.appendChild(style); + + this.titleEl = h('h2', { className: 'title' }); + // Single body container; `renderBody` writes each section into it so the + // layout adapts to empty / full states without juggling element refs. + this.bodyEl = h('div', { className: 'body' }); + this.hintEl = h('p', { className: 'hint' }); + this.#panelEl = h('section', { className: 'panel' }, [this.titleEl, this.bodyEl, this.hintEl]); + shadow.appendChild(this.#panelEl); + + this.#onKeyDown = this.#handleKey.bind(this); + this.addEventListener('keydown', this.#onKeyDown); + this.#onBackdrop = evt => { + if (!evt.composedPath().includes(this.#panelEl as EventTarget)) this.emit('dismiss'); + }; + this.addEventListener('click', this.#onBackdrop); + + this.#ready = true; + this.render(); + } + + protected render() { + if (!this.#ready) return; + this.titleEl.textContent = this.panelTitle(); + while (this.bodyEl.firstChild) this.bodyEl.removeChild(this.bodyEl.firstChild); + this.renderBody(); + this.hintEl.textContent = this.hintText(); + } + + /** + * SALVAGE section — always rendered so the chrome stays consistent across + * empty / full states. Zero-count rows are dimmed (not hidden) so the player + * can see *which* bucket they're missing without parsing absence. + */ + protected renderSalvageSection() { + this.bodyEl.appendChild(h('p', { className: 'section-label', textContent: 'SALVAGE' })); + const rows = h('div', { className: 'salvage-rows' }); + for (const t of SALVAGE_TYPES) { + const count = this.salvage[t]; + const row = h('div', { className: count > 0 ? 'salvage-row' : 'salvage-row zero' }); + row.append( + h('span', { className: 'bucket-name', textContent: SALVAGE_LABELS[t] }), + h('span', { className: 'bucket-count', textContent: String(count) }) + ); + rows.appendChild(row); + } + this.bodyEl.appendChild(rows); + } + + /** + * Keycard section — absence-hidden (an empty section would be noise for the + * majority of runs without locked doors). `title` and the per-row `labelOf` + * differ by surface: combat shows the generic card, the Hub shows locations. + */ + protected renderKeycardSection(title: string, labelOf: (ki: KeyItemView) => string) { + if (this.keyItems.length === 0) return; + this.bodyEl.appendChild(h('p', { className: 'section-label', textContent: title })); + const rows = h('div', { className: 'key-item-rows' }); + for (const ki of this.keyItems) { + const row = h('div', { className: 'key-item-row' }); + row.append( + h('span', { className: 'key-glyph', textContent: KEYCARD_GLYPH }), + h('span', { className: 'key-label', textContent: labelOf(ki) }) + ); + rows.appendChild(row); + } + this.bodyEl.appendChild(rows); + } + + protected get salvageIsEmpty(): boolean { + return totalSalvage(this.salvage) === 0; + } + + show() { + this.setAttribute('open', ''); + queueMicrotask(() => this.onShown()); + } + + hide() { + this.removeAttribute('open'); + } + + get isOpen() { + return this.hasAttribute('open'); + } + + disconnectedCallback() { + if (this.#onKeyDown) this.removeEventListener('keydown', this.#onKeyDown); + if (this.#onBackdrop) this.removeEventListener('click', this.#onBackdrop); + } + + #handleKey(evt: KeyboardEvent) { + if (!this.isOpen) return; + evt.stopPropagation(); + if (evt.key === 'Escape') { + evt.preventDefault(); + this.emit('dismiss'); + return; + } + this.handleExtraKey(evt); + } + + protected emit(eventName: string, detail: object = {}) { + this.dispatchEvent(new CustomEvent(eventName, { detail })); + } +} diff --git a/components/ItemInventory.ts b/components/ItemInventory.ts deleted file mode 100644 index 910fc67..0000000 --- a/components/ItemInventory.ts +++ /dev/null @@ -1,500 +0,0 @@ -/** - * — inventory overlay for salvage + consumables. - * - * Shows two sections: - * - * 1. **SALVAGE** — typed-salvage wallet rendered with full bucket names - * (Scrap / Chips / Bio / Data). Per design feedback, the compact - * `S:N C:N B:N D:N` status-bar tag was too dense — players want to read - * the bucket they own, not decode an initial. Also extended this - * overlay to be the Hub's wallet surface so the player can audit - * typed totals without opening the shop. - * 2. **CONSUMABLES** — the deployed crew member's item list (Stim, Smoke - * Charge, …). Only populated mid-combat. Hub state shows an empty - * section so the chrome stays consistent. - * - * Navigation: ↑/↓ moves the cursor across consumable rows (salvage is - * informational only). Enter activates a consumable, Esc dismisses. - * Emits `use-item` with `{ itemId }` on confirm. - * - * If both sections are empty, shows a "nothing carried" message with a - * dismiss hint. - */ - -import { h } from '/src/domUtils.js'; -import { - SALVAGE_TYPES, - emptySalvage, - totalSalvage, - type SalvageType, - type TypedSalvage, -} from '/src/game/salvage.js'; -import { KEYCARD_GLYPH } from '/src/game/constants.js'; -import type { Item } from '/src/game/items.js'; -import type { KeyItem } from '/src/types.js'; - -type ItemInventoryItem = Omit & { - count: number; -}; - -const CSS = ` -:host { - --inv-bg: rgba(7, 18, 16, 0.96); - --inv-border: var(--accent-color, #00d9a5); - --inv-text: #c5efdf; - --inv-dim: #6ae8c8; - --inv-accent: var(--accent-color, #00d9a5); - --inv-row-hover: rgba(0, 217, 165, 0.08); - --inv-row-active: rgba(0, 217, 165, 0.18); - --inv-shadow: 0 0 28px rgba(0, 217, 165, 0.18), 0 12px 36px rgba(0, 0, 0, 0.5); - - display: none; - position: fixed; - inset: 0; - z-index: 50; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.55); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - color: var(--inv-text); -} - -:host([open]) { - display: flex; -} - -.panel { - background: var(--inv-bg); - border: 1px solid var(--inv-border); - border-radius: 6px; - padding: 1.25rem 1.5rem 1.4rem; - box-shadow: var(--inv-shadow); - min-width: min(360px, 88vw); - max-width: min(480px, 96vw); -} - -.title { - margin: 0 0 0.75rem; - text-align: center; - font-size: 0.95rem; - letter-spacing: 0.18em; - color: var(--inv-accent); - border-bottom: 1px dashed var(--inv-border); - padding-bottom: 0.5rem; -} - -.section-label { - margin: 0.9rem 0 0.4rem; - font-size: 0.8rem; - letter-spacing: 0.18em; - color: var(--inv-dim); - text-transform: uppercase; -} - -.section-label:first-of-type { - margin-top: 0; -} - -.rows { - display: flex; - flex-direction: column; - gap: 0.3rem; -} - -.salvage-rows { - display: flex; - flex-direction: column; - gap: 0.2rem; - margin-bottom: 0.4rem; -} - -.salvage-row { - display: flex; - justify-content: space-between; - align-items: baseline; - padding: 0.25rem 0.6rem; - border: 1px solid transparent; - border-radius: 4px; - min-height: 1.6rem; -} - -.salvage-row.zero { - opacity: 0.45; -} - -.salvage-row .bucket-name { - color: var(--inv-text); - letter-spacing: 0.06em; -} - -.salvage-row .bucket-count { - color: var(--inv-accent); - font-weight: 700; - font-variant-numeric: tabular-nums; -} - -.salvage-row.zero .bucket-count { - color: var(--inv-dim); - font-weight: 400; -} - -.key-item-rows { - display: flex; - flex-direction: column; - gap: 0.2rem; - margin-bottom: 0.4rem; -} - -.key-item-row { - display: flex; - align-items: baseline; - gap: 0.5rem; - padding: 0.25rem 0.6rem; - border: 1px solid transparent; - border-radius: 4px; - min-height: 1.6rem; -} - -.key-item-row .key-glyph { - color: var(--inv-accent); - font-weight: 700; -} - -.key-item-row .key-label { - color: var(--inv-text); - letter-spacing: 0.06em; -} - -.empty { - text-align: center; - color: var(--inv-dim); - font-size: 0.85rem; - padding: 0.5rem 0; -} - -button.row { - appearance: none; - -webkit-appearance: none; - background: transparent; - color: var(--inv-text); - border: 1px solid transparent; - border-radius: 4px; - padding: 0.5rem 0.6rem; - text-align: left; - font: inherit; - cursor: pointer; - min-height: 40px; - display: flex; - gap: 0.5rem; - align-items: center; -} - -button.row:not(:disabled):hover { - background: var(--inv-row-hover); -} - -button.row:focus-visible, -button.row[aria-current='true'] { - outline: none; - border-color: var(--inv-accent); - background: var(--inv-row-active); -} - -.cursor { - color: var(--inv-accent); - font-weight: 700; - visibility: hidden; -} - -button.row:focus-visible .cursor, -button.row[aria-current='true'] .cursor { - visibility: visible; -} - -.item-name { - color: var(--inv-accent); - font-weight: 700; - letter-spacing: 0.08em; -} - -.item-count { - color: var(--inv-dim); - font-size: 0.85rem; -} - -.hint { - text-align: center; - font-size: 0.85rem; - color: var(--inv-dim); - letter-spacing: 0.1em; - margin: 0.9rem 0 0; -} -`; - -/** Human-readable labels for item IDs. */ -const ITEM_LABELS = { - stim: 'Stim', - 'smoke-charge': 'Smoke Charge', - 'breaching-charge': 'Breaching Charge', -}; - -/** - * Human-readable labels for typed-salvage buckets. Matches the P2.5.M4.2 docs: - * Scrap = mechanical, Chips = electronics, Bio = organic, Data = informational. - */ -const SALVAGE_LABELS: Record = { - scrap: 'Scrap', - chips: 'Chips', - bio: 'Bio', - data: 'Data', -}; - -class ItemInventory extends HTMLElement { - #items: ItemInventoryItem[] = []; - #salvage: TypedSalvage = emptySalvage(); - #keyItems: KeyItem[] = []; - #ready = false; - #bodyEl: HTMLElement | null = null; - #titleEl: HTMLElement | null = null; - #panelEl: HTMLElement | null = null; - #hintEl: HTMLElement | null = null; - #buttons: HTMLButtonElement[] = []; - #selectedIndex = 0; - #onKeyDown: ((this: HTMLElement, ev: KeyboardEvent) => void) | null = null; - #onBackdrop: ((this: HTMLElement, ev: PointerEvent) => void) | null = null; - - connectedCallback() { - if (this.#ready) return; - this.tabIndex = -1; - const shadow = this.attachShadow({ mode: 'open' }); - const style = h('style'); - style.textContent = CSS; - shadow.appendChild(style); - - this.#titleEl = h('h2', { className: 'title' }); - // Single body container; #render writes both the SALVAGE and CONSUMABLES - // sections into it so the layout adapts to empty / full / Hub-vs-combat - // states without juggling separate element refs. - this.#bodyEl = h('div', { className: 'body' }); - this.#hintEl = h('p', { className: 'hint' }); - this.#panelEl = h('section', { className: 'panel' }, [ - this.#titleEl, - this.#bodyEl, - this.#hintEl, - ]); - shadow.appendChild(this.#panelEl); - - this.#onKeyDown = this.#handleKey.bind(this); - this.addEventListener('keydown', this.#onKeyDown); - this.#onBackdrop = evt => { - if (!evt.composedPath().includes(this.#panelEl as EventTarget)) this.#emit('dismiss'); - }; - this.addEventListener('click', this.#onBackdrop); - - this.#ready = true; - this.#render(); - } - - /** - * Combined contents API — both the typed-salvage wallet and the - * consumables list. Callers always pass both; in Hub state consumables is - * typically `[]` (no deployed crew member) but salvage is the campaign - * wallet; in combat consumables is the deployed crew member's items and - * salvage is their job-scoped pickup wallet. - * - * The salvage section is informational only — there's no per-bucket action - * here yet. Cursor navigation skips the salvage rows and stays on the consumable list. - */ - setContents({ - salvage = emptySalvage(), - consumables = [] as Item[], - keyItems = [] as KeyItem[], - }: { - salvage?: TypedSalvage; - consumables?: Item[]; - keyItems?: KeyItem[]; - } = {}) { - this.#salvage = salvage; - this.#keyItems = keyItems; - // Aggregate by id so duplicates show as "Stim x2". - const counts = new Map(); - for (const c of consumables) { - counts.set(c.id, (counts.get(c.id) ?? 0) + 1); - } - this.#items = []; - for (const [id, count] of counts) { - this.#items.push({ id, label: ITEM_LABELS[id as keyof typeof ITEM_LABELS] ?? id, count }); - } - this.#selectedIndex = 0; - if (this.#ready) this.#render(); - } - - /** - * Legacy single-arg API kept for back-compat with callers that only had a - * consumables list. New callers should prefer `setContents`. - */ - setItems(consumables: Item[]) { - this.setContents({ consumables }); - } - - show() { - this.setAttribute('open', ''); - queueMicrotask(() => { - const btn = this.#buttons[this.#selectedIndex]; - if (btn) btn.focus(); - else this.focus(); - }); - } - - hide() { - this.removeAttribute('open'); - } - - get isOpen() { - return this.hasAttribute('open'); - } - - disconnectedCallback() { - if (this.#onKeyDown) this.removeEventListener('keydown', this.#onKeyDown); - if (this.#onBackdrop) this.removeEventListener('click', this.#onBackdrop); - } - - #render() { - if (!this.#ready) return; - this.#titleEl!.textContent = '── INVENTORY ──'; - - // Wipe and rebuild body. #buttons is rebuilt alongside so the keyboard - // cursor stays in sync with the DOM after every render. - while (this.#bodyEl!.firstChild) this.#bodyEl!.removeChild(this.#bodyEl!.firstChild); - this.#buttons = []; - - // ── SALVAGE section ── - // Always rendered so the chrome is consistent across Hub / combat / - // empty-wallet states. Zero-count rows are dimmed (not hidden) so the - // player can see *which* bucket they're missing without parsing absence. - this.#bodyEl!.appendChild(h('p', { className: 'section-label', textContent: 'SALVAGE' })); - const salvageRows = h('div', { className: 'salvage-rows' }); - for (const t of SALVAGE_TYPES) { - const count = this.#salvage[t]; - const row = h('div', { - className: count > 0 ? 'salvage-row' : 'salvage-row zero', - }); - row.append( - h('span', { className: 'bucket-name', textContent: SALVAGE_LABELS[t] }), - h('span', { className: 'bucket-count', textContent: String(count) }) - ); - salvageRows.appendChild(row); - } - this.#bodyEl!.appendChild(salvageRows); - - // ── KEY ITEMS section ── - // Only rendered when the player is carrying keycards. Unlike salvage - // (which always shows zero-count rows), key items are absence-hidden — - // an empty "KEY ITEMS: (none)" section would just be visual noise for - // the majority of runs that don't involve locked doors. - if (this.#keyItems.length > 0) { - this.#bodyEl!.appendChild(h('p', { className: 'section-label', textContent: 'KEY ITEMS' })); - const keyRows = h('div', { className: 'key-item-rows' }); - for (const ki of this.#keyItems) { - const row = h('div', { className: 'key-item-row' }); - row.append( - h('span', { className: 'key-glyph', textContent: KEYCARD_GLYPH }), - h('span', { className: 'key-label', textContent: ki.label }) - ); - keyRows.appendChild(row); - } - this.#bodyEl!.appendChild(keyRows); - } - - // ── CONSUMABLES section ── - this.#bodyEl!.appendChild(h('p', { className: 'section-label', textContent: 'CONSUMABLES' })); - if (this.#items.length === 0) { - this.#bodyEl!.appendChild(h('p', { className: 'empty', textContent: 'No consumables.' })); - } else { - const itemRows = h('div', { className: 'rows' }); - for (let i = 0; i < this.#items.length; i++) { - const item = this.#items[i]; - const btn = h('button', { - type: 'button', - className: 'row', - ariaCurrent: i === this.#selectedIndex ? 'true' : 'false', - }) as HTMLButtonElement; - btn.dataset.index = String(i); - btn.addEventListener('click', () => this.#activate(i)); - btn.append( - h('span', { className: 'cursor', textContent: '>' }), - h('span', { className: 'item-name', textContent: item.label }), - h('span', { - className: 'item-count', - textContent: item.count > 1 ? `x${item.count}` : '', - }) - ); - itemRows.appendChild(btn); - this.#buttons.push(btn); - } - this.#bodyEl!.appendChild(itemRows); - } - - // Hint copy: only mention ENTER when there's something to use. Empty - // wallets + empty consumables still get an Esc hint (the salvage view - // is itself useful information even with no items to activate). - const hasConsumables = this.#items.length > 0; - const walletIsEmpty = totalSalvage(this.#salvage) === 0; - const hasKeyItems = this.#keyItems.length > 0; - if (hasConsumables) { - this.#hintEl!.textContent = '[ ENTER use · Esc close ]'; - } else if (walletIsEmpty && !hasKeyItems) { - this.#hintEl!.textContent = 'Nothing carried. [ Esc close ]'; - } else { - this.#hintEl!.textContent = '[ Esc close ]'; - } - } - - #handleKey(evt: KeyboardEvent) { - if (!this.isOpen) return; - evt.stopPropagation(); - if (evt.key === 'Escape') { - evt.preventDefault(); - this.#emit('dismiss'); - return; - } - if (evt.key === 'ArrowDown' || evt.key === 's') { - evt.preventDefault(); - this.#move(1); - return; - } - if (evt.key === 'ArrowUp' || evt.key === 'w') { - evt.preventDefault(); - this.#move(-1); - return; - } - if (evt.key === 'Enter' || evt.key === ' ') { - evt.preventDefault(); - this.#activate(this.#selectedIndex); - } - } - - #move(delta: number) { - if (this.#items.length === 0) return; - this.#selectedIndex = (this.#selectedIndex + delta + this.#items.length) % this.#items.length; - for (let i = 0; i < this.#buttons.length; i++) { - this.#buttons[i].setAttribute('aria-current', i === this.#selectedIndex ? 'true' : 'false'); - } - const btn = this.#buttons[this.#selectedIndex]; - if (btn) btn.focus(); - } - - #activate(index: number) { - const item = this.#items[index]; - if (!item) return; - this.#emit('use-item', { itemId: item.id }); - } - - #emit(eventName: string, detail = {}) { - this.dispatchEvent(new CustomEvent(eventName, { detail })); - } -} - -customElements.define('item-inventory', ItemInventory); - -export default ItemInventory; diff --git a/components/TouchPad.ts b/components/TouchPad.ts index f82d6dd..45ae8ec 100644 --- a/components/TouchPad.ts +++ b/components/TouchPad.ts @@ -105,6 +105,13 @@ const CSS = ` --touchpad-btn-bg: #0a1614; --touchpad-btn-active: rgba(0, 217, 165, 0.32); + /* Cyberspace palette (mirrors CYBER_ACCENT in src/render/pip.ts) — the + simstim FLIP fab borrows it so the gesture reads as "the cyber layer". */ + --touchpad-cyber: #ff8ad8; + --touchpad-cyber-glow: #ff5cc6; + --touchpad-cyber-soft: rgba(255, 92, 198, 0.18); + --touchpad-cyber-bg: rgba(28, 8, 22, 0.82); + display: none; position: fixed; left: 0; @@ -231,6 +238,39 @@ button.dpad-cell.center { visibility: hidden; } +/* Simstim FLIP — pulled out of the action block and pinned to the right edge + near the vertical centre, so the layer-swap gesture lives apart from the + per-turn verbs. Hidden until the shell reports a flip target exists + (dual-deploy / post-jack-out), via the flip-available attribute. */ +.flip-fab { + position: fixed; + right: max(8px, env(safe-area-inset-right, 0px)); + top: 50%; + transform: translateY(-50%); + display: none; + flex-direction: column; + gap: 1px; + min-width: 58px; + min-height: 58px; + padding: 8px 6px; + pointer-events: auto; + background: var(--touchpad-cyber-bg); + border: 1px solid var(--touchpad-cyber); + border-radius: 10px; + color: var(--touchpad-cyber); + text-shadow: 0 0 6px var(--touchpad-cyber-glow); + box-shadow: 0 0 12px var(--touchpad-cyber-soft); +} + +:host([flip-available]) .flip-fab { display: flex; } + +.flip-fab .label { font-weight: 700; letter-spacing: 0.08em; } +.flip-fab .shortcut { color: var(--touchpad-cyber); opacity: 0.7; } +.flip-fab:active { + background: var(--touchpad-cyber-soft); + border-color: var(--touchpad-cyber-glow); +} + button:active { background: var(--touchpad-btn-active); border-color: var(--touchpad-accent); } button:focus { outline: none; } @@ -288,6 +328,9 @@ class TouchPad extends HTMLElement { shadow.appendChild( h('div', { className: 'shell' }, [this.#banner, metaRow, dpad, h('div'), actions]) ); + // The simstim FLIP fab lives outside `.shell` — it's pinned to the + // viewport's right edge, not laid out with the d-pad / action columns. + shadow.appendChild(this.#buildFlipFab()); this.#boundOnPointerDown = this.#onPointerDown.bind(this); this.#boundOnPointerRelease = this.#onPointerRelease.bind(this); @@ -431,6 +474,35 @@ class TouchPad extends HTMLElement { return h('div', { className: 'actions', role: 'group', ariaLabel: 'Actions' }, buttons); } + #buildFlipFab() { + const btn = h( + 'button', + { + className: 'flip-fab', + type: 'button', + ariaLabel: 'Simstim flip — switch operator', + }, + [ + h('span', { className: 'label', textContent: 'FLIP' }), + h('span', { className: 'shortcut', textContent: '⇥' }), + ] + ); + btn.dataset.button = 'flip'; + this.#buttonsById.set('flip', btn); + return btn; + } + + /** + * P3.M4.3: show or hide the simstim FLIP fab. The shell calls this from its + * paint loop with `run.canFlip()`, so the fab only appears when a second + * operator can actually take control (dual-deploy, or the two meat operators + * post-jack-out). Mirrors the keyboard `Tab` gate — never a dead button. + */ + setFlipAvailable(available: boolean): void { + if (available) this.setAttribute('flip-available', ''); + else this.removeAttribute('flip-available'); + } + #findDataButton(evt: PointerEvent) { const root = this.shadowRoot; if (!root) return null; diff --git a/docs/kaizen.md b/docs/kaizen.md index 717ca03..0135ed0 100644 --- a/docs/kaizen.md +++ b/docs/kaizen.md @@ -48,6 +48,7 @@ When an item lands, gets reclassified, or develops new context, edit it in place ## ◇ Monitored +- **`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). - **`typecheck:tests` has residual type errors (~10).** Tests intentionally use partial stubs (e.g. `{ x: 0, y: 0 }` where a full `Entity` is expected) for ergonomics. The main build (`tsconfig.json`) type-checks clean; only `tsconfig.tests.json` fails. The test pipeline works around this with `tsconfig.test-build.json` (`noCheck: true`) so `npm test` passes. Options: (a) create proper test fixtures/factories that satisfy the full type, (b) use `as unknown as Entity` casts in tests, (c) a lighter `Partial` helper type for test contexts. Revisit when adding new test files — each new partial stub compounds the error count. diff --git a/docs/phase-3-plan.md b/docs/phase-3-plan.md index 3989d3c..4773971 100644 --- a/docs/phase-3-plan.md +++ b/docs/phase-3-plan.md @@ -93,7 +93,7 @@ The Score target is always a newly synthesized CRITICAL-tier site, never promote - **Arc state:** Campaign save tracks `arcStage` (`act-1` / `act-2` / `act-3` / `score`), `completedJobs`, `clockJobsTaken` (Act 2/3 deploys that drive the Clock — incremented on deploy, not on extract), and arc-specific flags (`deckerRecruited`, `scoreRevealed`, `clockStarted`, `scoreAttempted`, `scoreCompleted`). Hub reveal flags (`scoreBriefingPresented`, `clockBriefingPresented`, `act3BriefingPresented`) persist separately. Prefer a typed `Campaign.arc` record over stuffing more opaque keys into `Campaign.meta`; legacy saves can normalize from absent `arc` into Act 1. - **Act transitions:** Define triggers for act boundaries: - Act 1 → Act 2: `rep >= 65` (proven-operator bar) + minimum successful job count (recommended: `completedJobs >= 4`). Triggers Score reveal and Decker recruitment (same beat). Terminal recruitment for Merc/Razor/Tech opens earlier at KNOWN (`rep >= 50`). - - Act 2 → Act 3: `completedJobs >= 9` + at least 4 **living** (non-flatlined) crew + at least 3 visited sites sharing the Score target's principal (including the Score target itself). The Decker gate is gone — the Decker is always assigned at Act 2 entry. The crew gate rewards keeping people alive; the principal-sites gate is the "casing" payoff — you've hit enough of the org's facilities to know how they operate. Triggers "final prep" phase and the `act-3-reveal` Hub beat on first qualifying Hub entry. + - Act 2 → Act 3: **casing alone** — at least `ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED` (4) visited sites sharing the Score target's principal, with the synthesized Score target itself **excluded** (you breach it during the heist, never on a prep run, so it is never "cased"). The Decker gate is gone — the Decker is always assigned at Act 2 entry. The earlier `completedJobs >= 9` and "4 living crew" gates were **dropped** (2026-06-28): both were arbitrary and invisible, and `completedJobs >= 4` is already required to enter casing. Casing is now the single, on-screen-visible driver — its progress shows as `CASED N/4` in the stage status line. Triggers "final prep" phase and the `act-3-reveal` Hub beat on first qualifying Hub entry. - Score available: Act 3 + player-initiated (choose **THE SCORE** from the Hub job board after the Act 3 briefing). - **Score target designation:** At Act 2 entry, always synthesize a new CRITICAL-tier `LocationSite` from Curator lexicon as the Score target. The Score is a location the player hasn't seen — Act 2 contracts bias toward the same principal so the player learns about the organization before hitting the crown jewel. Existing roster sites are never promoted; they serve as intel and casing prep. Multiple persisted score targets, or score-tier sites missing `scoreTarget`, throw during Hub-entry arc evaluation. - **Decker recruitment:** Same narrative beat as the Score reveal. The Curator assigns a named Decker — no player choice modal. The crew gains a specialist as part of the Score pitch ("here's the job, and here's the person who can open it"). Future phases may differentiate Decker stats and offer a choice; for now the assignment is the narrative. @@ -149,7 +149,7 @@ Neural degradation is deferred until Cyberspace is fun enough to deserve a jack- **P3.M1.1 implementation note:** `Campaign` now owns a typed `arc` record (`arcStage`, `deckerRecruited`, `scoreRevealed`, `clockStarted`, `scoreAttempted`, `scoreCompleted`) plus an `arcStage` getter for Curator context. New snapshots serialize the record; pre-P3 snapshots normalize to Act 1; malformed persisted arc data throws during restore instead of being silently repaired. -**P3.M1.2 implementation note:** Hub entry now runs a monotonic arc transition evaluator. Act 1 advances to Act 2 at `rep >= ARC_ACT_2_MIN_REP` (65 — proven-operator bar, above the KNOWN recruitment floor) plus `completedJobs >= 4`, sets `scoreRevealed`, and auto-assigns a Decker to the crew. Higher rep tiers qualify too: a save that overshoots the rep floor before the job gate is crossed must not stall in Act 1. Terminal recruitment (`REP.RECRUIT_THRESHOLD = 50`) was inverted relative to the original M6 design so Stage 1 crew growth precedes the Score pitch. Act 2 advances to Act 3 once `completedJobs >= 9`, at least 4 non-flatlined crew, and at least 3 visited roster sites sharing the Score target's principal (including the target itself). The crew gate counts living members only — attrition blocks advancement. Successful extractions increment `completedJobs`, abort extractions do not. M1.2 does not synthesize the Score target — that remains P3.M1.3. +**P3.M1.2 implementation note:** Hub entry now runs a monotonic arc transition evaluator. Act 1 advances to Act 2 at `rep >= ARC_ACT_2_MIN_REP` (65 — proven-operator bar, above the KNOWN recruitment floor) plus `completedJobs >= 4`, sets `scoreRevealed`, and auto-assigns a Decker to the crew. Higher rep tiers qualify too: a save that overshoots the rep floor before the job gate is crossed must not stall in Act 1. Terminal recruitment (`REP.RECRUIT_THRESHOLD = 50`) was inverted relative to the original M6 design so Stage 1 crew growth precedes the Score pitch. Act 2 advances to Act 3 on the **casing gate alone**: at least `ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED` (4) visited roster sites sharing the Score target's principal, the synthesized Score target **excluded** (`casedPrincipalSiteCount` filters `!site.scoreTarget`, so the crown jewel never counts toward its own unlock). The old `completedJobs >= 9` and "4 living crew" gates were removed (2026-06-28) as arbitrary and invisible; `Campaign.casingProgress()` exposes `{ cased, required }` for the gate and the `CASED N/4` status line, and is null before the Score target is designated. Successful extractions increment `completedJobs` (still gating Act 1 → Act 2), abort extractions do not. M1.2 does not synthesize the Score target — that remains P3.M1.3. **P3.M1.3 implementation note:** Score reveal always synthesizes a new CRITICAL-tier site from Curator lexicon `corp` principal and `corp/data/security/infrastructure/hidden` site tokens. Existing roster sites are never promoted — the Score is always a location the player hasn't visited yet. The synthesized site gets `scoreTarget: true`, `tier: 'score'`, and CRITICAL-footprint dimensions to support escalated hostile placement. Multiple persisted score targets, or score-tier sites missing `scoreTarget`, throw during Hub-entry arc evaluation. diff --git a/images/charge.png b/images/charge.png new file mode 100644 index 0000000..09e3fb1 Binary files /dev/null and b/images/charge.png differ diff --git a/images/incind.png b/images/incind.png new file mode 100644 index 0000000..730e904 Binary files /dev/null and b/images/incind.png differ diff --git a/images/smoke.png b/images/smoke.png new file mode 100644 index 0000000..d670a79 Binary files /dev/null and b/images/smoke.png differ diff --git a/images/stim.png b/images/stim.png new file mode 100644 index 0000000..0eaf5d5 Binary files /dev/null and b/images/stim.png differ diff --git a/index.html b/index.html index e57ea0d..c695be0 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,10 @@ name="description" content="Turn-based cyberpunk roguelike — tactical grid combat in a terminal-inspired UI" /> - + @@ -85,7 +88,8 @@

Log

- + + diff --git a/index.ts b/index.ts index 8eda0fc..660ce27 100644 --- a/index.ts +++ b/index.ts @@ -20,7 +20,8 @@ import '/components/CrewList.js'; import '/components/CrewRoster.js'; import '/components/FinnShop.js'; import '/components/ClinicModal.js'; -import '/components/ItemInventory.js'; +import '/components/CombatInventory.js'; +import '/components/CrewInventory.js'; import '/components/ChronicleArchive.js'; import KeyHelp from '/components/KeyHelp.js'; @@ -40,7 +41,8 @@ const allComponentsReady = Promise.all([ customElements.whenDefined('crew-roster'), customElements.whenDefined('finn-shop'), customElements.whenDefined('clinic-modal'), - customElements.whenDefined('item-inventory'), + customElements.whenDefined('combat-inventory'), + customElements.whenDefined('crew-inventory'), customElements.whenDefined('chronicle-archive'), customElements.whenDefined('key-help'), ]); diff --git a/main.css b/main.css index 326c91d..84b7580 100644 --- a/main.css +++ b/main.css @@ -30,6 +30,12 @@ html { box-sizing: border-box; } +html, +body { + /* Allows normal scrolling but blocks pinch-zoom and double-tap zoom gestures */ + touch-action: pan-x pan-y; +} + *, *:before, *:after { diff --git a/package.json b/package.json index 1c7e6b3..660fbb6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kernel-panic", - "version": "0.2.9-balance", + "version": "0.3.2-balance", "description": "Turn-based cyberpunk roguelike PWA (ASCII-plus terminal aesthetic)", "main": "index.html", "type": "module", diff --git a/src/game/Campaign.ts b/src/game/Campaign.ts index 1eaa29b..b24efb4 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -50,8 +50,10 @@ import { mergeSiteDeltas as mergeDeltas, mergeSiteSeenKeys as mergeSeen, normalizeLocationSite, + siteIdForContract, } from './locations.js'; import { resolveMapDimensions } from './procgen/mapDimensions.js'; +import { keycardIdFor, migrateLegacyKeycardId } from './entities/KeyCard.js'; import { normalizeCampaignChronicle, normalizePendingChronicleRun, @@ -76,11 +78,15 @@ export const SITE_ROSTER_CAP = 6; /** Minimum Rep to leave Act 1 — proven-operator bar (65). Recruitment opens earlier at KNOWN (50). */ export const ARC_ACT_2_MIN_REP = 65; export const ARC_ACT_2_MIN_COMPLETED_JOBS = 4; -export const ARC_ACT_3_MIN_COMPLETED_JOBS = 9; -/** Minimum *living* crew size before the Score's final-prep stage unlocks. Starter 2 + Decker = 3, so 4 requires at least one additional recruit who hasn't flatlined. */ -export const ARC_ACT_3_MIN_CREW_ALIVE = 4; -/** Visited sites sharing the Score target's principal required for Act 3. Includes the target itself. */ -export const ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED = 3; +/** + * Distinct visited sites sharing the Score target's principal required to leave + * casing (Stage 2 → Stage 3). The synthesized Score target is never visited + * before the heist, so it does *not* count — this is purely the org sites the + * player has actually cased. This is the sole casing gate (the old living-crew + * and total-completed-jobs gates were dropped as arbitrary and invisible); + * surfaced on-screen via {@link Campaign.casingProgress}. + */ +export const ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED = 4; /** Act-2/3 deploys taken before corp heat starts (successful or not). */ export const CLOCK_ACT2_GRACE_JOBS = 3; /** Deploys after grace before the Score window closes (Act 3 only). */ @@ -94,6 +100,25 @@ export const CLOCK_ACT3_MIN_JOBS_REMAINING = 3; export function clockDeadlineApplies(arcStage: CampaignArcStage): boolean { return arcStage === 'act-3'; } + +/** + * Count distinct roster sites the player has *visited* that share the Score + * org's principal. Drives the casing gate and its on-screen indicator — kept in + * one place so the gate and the `CASED N/M` display can never disagree. + */ +export function casedPrincipalSiteCount( + siteRoster: readonly LocationSite[], + scorePrincipalId: string +): number { + return siteRoster.filter( + site => + // The synthesized Score target shares the org's principal but is never a + // "cased" site — you breach it during the heist, not on a prep run. + // Exclude it structurally so the gate can't count the crown jewel toward + // its own unlock even if a fixture or future bug marks it visited. + !site.scoreTarget && site.principal?.id === scorePrincipalId && site.lastVisitedJob > 0 + ).length; +} /** Campaign-ending payday for completing THE SCORE. */ export const SCORE_CREDITS_REWARD = 5_000; const SYNTHETIC_SCORE_TARGET_DIFFICULTY = CONTRACT_DIFFICULTY.CRITICAL; @@ -432,8 +457,10 @@ export class Campaign { this.hubReveals = normalizeHubReveals(hubReveals, 'Campaign hubReveals'); this.completedJobs = (completedJobs as number) ?? 0; this.clockJobsTaken = (clockJobsTaken as number) ?? 0; - this.keyItems = normalizeKeyItems(keyItems); + // Roster before key items: legacy keycards carry only `siteId` and are + // backfilled to their owning `principalId` via the roster (P3.1-balance). this.siteRoster = normalizeSiteRoster(siteRoster); + this.keyItems = normalizeKeyItems(keyItems, this.siteRoster); this.chronicle = normalizeCampaignChronicle(chronicle); this.pendingChronicleRun = normalizePendingChronicleRun(pendingChronicleRun); this.state = CAMPAIGN_STATE.HUB; @@ -653,7 +680,7 @@ export class Campaign { } } if (isScoreContract(contract)) { - this.#beginScoreAttempt(contract); + this.#validateScoreAttempt(contract); } else if (this.arc.arcStage === 'act-2' || this.arc.arcStage === 'act-3') { this.clockJobsTaken += 1; } @@ -674,6 +701,7 @@ export class Campaign { onResult: (result: RunResult) => { this.onResult?.(result); }, + onCombatEntered: () => this.onActiveRunCombatEntered(), }); this.activeRun.enterBriefing(deployedContract); // Remember this location (or refresh its visit marker) on deploy. @@ -752,6 +780,10 @@ export class Campaign { // returning to the Hub — breach holes survive even on an aborted exit. this.#mergeRunDeltasIntoRoster(this.activeRun); this.#mergeRunSeenIntoRoster(this.activeRun); + // Keycards carried out alive are promoted to the persistent campaign + // inventory — independent of objective completion, since the operator + // physically extracted with the card. Death (handled above) drops them. + this.#promoteRunKeyItems(this.activeRun); if (completed) { this.completedJobs += 1; addSalvage(this.salvage, extracted); @@ -1191,6 +1223,14 @@ export class Campaign { throw new Error(`Campaign.purchase: meta upgrade "${itemId}" already purchased`); } } + // Refuse gear the target has already saturated (limit-1 gear it already has + // equipped, or stacking gear at its cap). `applyGear` would silently clamp + // this to a no-op, so without the guard Finn pockets the Creds for nothing. + if (item.scope === ITEM_SCOPE.CAMPAIGN && target && target.gearAtCap(itemId)) { + throw new Error( + `Campaign.purchase: ${target.callsign ?? target.id} already has "${itemId}" at capacity` + ); + } // Commit: deduct Creds first, then apply effect. this.credits -= item.cost; @@ -1286,11 +1326,22 @@ export class Campaign { } /** - * Check whether the campaign inventory holds a key item that unlocks the - * given door id. Returns the matching `KeyItem` or `null`. + * Promote a survived run's carried keycards into the persistent campaign + * inventory. Only principal-stamped cards survive (run-scoped cards without a + * principalId belong to no owner on the roster); already-held cards are + * skipped so a re-collected revisit card is idempotent rather than a crash. */ - keyItemForDoor(doorId: string): KeyItem | null { - return this.keyItems.find(k => k.doorId === doorId) ?? null; + #promoteRunKeyItems(run: Run): void { + for (const item of run.keyItems) { + if (!item.principalId) continue; + if (this.keyItems.some(k => k.id === item.id)) continue; + this.addKeyItem({ + id: item.id, + label: item.label, + doorId: item.doorId, + principalId: item.principalId, + }); + } } // ─── Location memory / site roster (P2.5.M7.2) ─────────────────────────── @@ -1340,6 +1391,11 @@ export class Campaign { return; } this.siteRoster.splice(evictIdx, 1); + // Keycards are scoped to the owning *principal*, not a single site + // (P3.1-balance): one card opens that owner's door at every site they + // control. So a site leaving the roster no longer purges keycards — the + // owner may still hold other roster sites, and the card set is naturally + // bounded by the small principal roster regardless. } this.siteRoster.push(normalized); this.#persist(); @@ -1376,7 +1432,7 @@ export class Campaign { * happens to reuse a remembered seed pick up that site's prior geometry. */ locationSiteIdForContract(contract: Contract): string { - return contract.context.locationSiteId ?? generateSiteId(contract.seed); + return siteIdForContract(contract); } /** @@ -1398,13 +1454,14 @@ export class Campaign { } /** - * Campaign key items already held for a contract's target location. Used to - * skip respawning pickup keycards on revisit (player re-opens via interact). + * Campaign key items already held for a contract's owning principal. Used to + * skip respawning pickup keycards when we already hold this owner's card + * (from any of their sites) — the player re-opens the door via interact. */ priorKeyItemsForContract(contract: Contract): KeyItem[] { - const siteId = contract.context.locationSiteId; - if (!siteId) return []; - return this.keyItems.filter(k => k.siteId === siteId).map(k => ({ ...k })); + const principalId = contract.context.principal?.id ?? null; + if (!principalId) return []; + return this.keyItems.filter(k => k.principalId === principalId).map(k => ({ ...k })); } /** Add or refresh the roster entry for a deployed contract's location. */ @@ -1688,9 +1745,9 @@ export class Campaign { this.#appendChronicleMilestone( 'act-3', 'STAGE 3 — FINAL PREP', - 'The crew has enough casing, enough survivors, and a narrow window to attempt THE SCORE.', + 'The crew has cased enough of the org and has a narrow window to attempt THE SCORE.', [ - `Living crew: ${this.crew.filter(member => !member.flatlined).length}`, + `Sites cased: ${this.casingProgress()?.cased ?? 0}`, `Clock budget remaining: ${this.scoreDeadlineJobsRemaining}`, ] ); @@ -1715,18 +1772,23 @@ export class Campaign { } #qualifiesForAct3(): boolean { - if (this.completedJobs < ARC_ACT_3_MIN_COMPLETED_JOBS) return false; - const livingCrew = this.crew.filter(m => !m.flatlined).length; - if (livingCrew < ARC_ACT_3_MIN_CREW_ALIVE) return false; + const progress = this.casingProgress(); + return progress !== null && progress.cased >= progress.required; + } + /** + * Casing progress toward final prep: distinct Score-org sites cased so far and + * the count required to leave Stage 2. Null until a Score target with a + * principal is designated (i.e. before the Score reveal). The Hub status line + * renders this as `CASED N/M`. + */ + casingProgress(): { cased: number; required: number } | null { const scoreTarget = this.siteRoster.find(site => site.scoreTarget); - if (!scoreTarget?.principal?.id) return false; - - const principalId = scoreTarget.principal.id; - const visitedPrincipalSites = this.siteRoster.filter( - site => site.principal?.id === principalId && site.lastVisitedJob > 0 - ).length; - return visitedPrincipalSites >= ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED; + if (!scoreTarget?.principal?.id) return null; + return { + cased: casedPrincipalSiteCount(this.siteRoster, scoreTarget.principal.id), + required: ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED, + }; } /** @@ -1786,7 +1848,16 @@ export class Campaign { return scoreTargets[0] ?? null; } - #beginScoreAttempt(contract: Contract): void { + /** + * Deploy-time gate for THE SCORE. Validates eligibility but does NOT commit — + * a failed Score is terminal, and the map isn't built until `enterCombat` + * (which can throw), so the irreversible `scoreAttempted`/`arcStage` mutation + * is deferred to {@link #commitScoreAttempt}, fired from the run's + * `onCombatEntered` hook once the run is actually playable. Re-validating here + * stays correct across a retry: an uncommitted prior attempt left the flags + * untouched, so a redeploy passes again. + */ + #validateScoreAttempt(contract: Contract): void { if (this.arc.arcStage !== 'act-3') { throw new Error('Campaign.deployCrewMember: Score can only be attempted from Act 3'); } @@ -1803,8 +1874,30 @@ export class Campaign { if (!target || contract.context.locationSiteId !== target.id) { throw new Error('Campaign.deployCrewMember: Score contract does not target the Score site'); } + } + + /** + * Commit the (terminal) Score attempt — idempotent. Invoked from the active + * run's `onCombatEntered` hook, i.e. only after the Score map built and every + * objective fixture placed. Until this fires, a generation failure leaves the + * campaign able to redeploy rather than stranded in `score-partial`. + */ + #commitScoreAttempt(): void { + if (this.arc.scoreAttempted) return; this.arc.scoreAttempted = true; this.arc.arcStage = 'score'; + this.#persist(); + } + + /** + * Wired to `Run.onCombatEntered` for every deployed/restored run. Commits the + * Score attempt the moment a Score run becomes playable; a no-op otherwise. + */ + onActiveRunCombatEntered(): void { + const contract = this.activeRun?.contract ?? null; + if (contract && isScoreContract(contract)) { + this.#commitScoreAttempt(); + } } #clockExpired(): boolean { @@ -1886,13 +1979,23 @@ export class Campaign { /** * Normalize key items from a snapshot (or undefined for pre-P2.5.M6.2 saves). * Validates structure. Crashes on malformed entries per project policy. + * + * P3.1-balance re-scope: keycards are keyed by their owning `principalId`, not a + * single `siteId`. New saves carry `principalId` directly. Legacy saves carry + * only `siteId`; each is backfilled to its owner by looking the site up in the + * (already-restored) `roster`. A legacy card whose owning site is no longer on + * the roster can't be resolved to an owner — it is dropped with a warning + * rather than kept as an unscoped, unmatchable card (no silent corruption). + * Backfilled cards are re-keyed to the canonical principal id and deduped, so + * two same-owner cards collapse into one. */ -function normalizeKeyItems(raw: unknown): KeyItem[] { +function normalizeKeyItems(raw: unknown, roster: readonly LocationSite[]): KeyItem[] { if (raw === undefined || raw === null) return []; if (!Array.isArray(raw)) { throw new TypeError('Campaign: keyItems must be an array when supplied'); } - return (raw as KeyItem[]).map((item, i) => { + const result: KeyItem[] = []; + (raw as Array).forEach((item, i) => { if (!item || typeof item !== 'object') { throw new TypeError(`Campaign: keyItems[${i}] must be an object`); } @@ -1905,15 +2008,55 @@ function normalizeKeyItems(raw: unknown): KeyItem[] { if (typeof item.doorId !== 'string' || item.doorId.length === 0) { throw new TypeError(`Campaign: keyItems[${i}].doorId must be a non-empty string`); } - const result: KeyItem = { id: item.id, label: item.label, doorId: item.doorId }; - if (item.siteId !== undefined) { + + let normalized: KeyItem; + if (item.principalId !== undefined) { + if (typeof item.principalId !== 'string' || item.principalId.length === 0) { + throw new TypeError( + `Campaign: keyItems[${i}].principalId must be a non-empty string when set` + ); + } + // Current-format card: keep its (canonical) id, only healing the bare + // legacy `keycard-` shape via migrateLegacyKeycardId. + normalized = migrateLegacyKeycardId({ + id: item.id, + label: item.label, + doorId: item.doorId, + principalId: item.principalId, + }); + } else if (item.siteId !== undefined) { if (typeof item.siteId !== 'string' || item.siteId.length === 0) { throw new TypeError(`Campaign: keyItems[${i}].siteId must be a non-empty string when set`); } - result.siteId = item.siteId; + const principalId = roster.find(s => s.id === item.siteId)?.principal?.id; + if (!principalId) { + console.warn( + `Campaign: dropping legacy keycard "${item.id}" — site "${item.siteId}" is not on the roster, so its owning principal cannot be resolved` + ); + return; + } + // Legacy card: the old id baked in the siteId, so re-key to the canonical + // principal-unique form (two same-owner cards then collapse to one). + normalized = { + id: keycardIdFor(item.doorId, principalId), + label: item.label, + doorId: item.doorId, + principalId, + }; + } else { + // Neither scope key: an unscoped campaign card. Preserve it verbatim — it + // is inert (matches no principal filter) but harmless, and dropping it + // would be silent data loss. Heal only the bare legacy id shape. + normalized = migrateLegacyKeycardId({ + id: item.id, + label: item.label, + doorId: item.doorId, + }); } - return result; + + if (!result.some(k => k.id === normalized.id)) result.push(normalized); }); + return result; } /** diff --git a/src/game/Crew.ts b/src/game/Crew.ts index 8482178..02c2178 100644 --- a/src/game/Crew.ts +++ b/src/game/Crew.ts @@ -374,6 +374,48 @@ export class Crew extends Entity { } } + /** + * Whether this operator has already saturated a campaign-scoped gear item — + * i.e. re-applying it would be a stat no-op (`applyGear` clamps every channel + * with `Math.min`). This is the guardrail source of truth shared by + * `Campaign.purchase` (which refuses the sale rather than silently pocketing + * 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 + * 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 + * should crash rather than silently report "not at cap" (which would let a + * broken caller waste Creds). + */ + gearAtCap(itemId: string): boolean { + const g = this.gear; + switch (itemId) { + case ITEM_ID.ARMOUR_PLATING: + 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: + return (g?.dodgeBonus ?? 0) >= this.maxDodgeBonus; + case ITEM_ID.BALLISTICS_COIL: + 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: + return (g?.apBonus ?? 0) >= this.maxApBonus; + case ITEM_ID.PHASE_SHIELD: + return (g?.shieldRegen ?? 0) >= this.maxShieldRegen; + case ITEM_ID.REGEN_MESH: + return (g?.hpRegen ?? 0) >= this.maxHpRegen; + default: + throw new Error(`Crew.gearAtCap: unknown gear item "${itemId}"`); + } + } + /** * Refresh AP at the start of this crew member's turn, then apply per-turn * gear regen (P3.M6.2). `super.refreshAp` (Entity) zeroes `shieldHp`, so the diff --git a/src/game/Run.ts b/src/game/Run.ts index 5effa3b..f869a62 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -45,7 +45,7 @@ import { JACK_OUT_SHOCK_DAMAGE, factionForPrincipalGroups, } from './constants.js'; -import { coordKey, explorationReachableKeys } from './mapConnectivity.js'; +import { coordKey, explorationReachableKeys, hasAdjacentPassableTile } from './mapConnectivity.js'; import { isValidBlockingPlacement, checkPlacementIntegrity } from './placement.js'; import { makeSalvage, type TypedSalvage } from './salvage.js'; import { Entity, type LootableEntity } from './Entity.js'; @@ -79,7 +79,7 @@ import { CorpTurret } from './entities/CorpTurret.js'; import { RelayNode } from './entities/RelayNode.js'; import { ConsumablePickup } from './entities/ConsumablePickup.js'; import { EscortNpc } from './entities/EscortNpc.js'; -import { KeyCard } from './entities/KeyCard.js'; +import { KeyCard, keycardIdFor, migrateLegacyKeycardId } from './entities/KeyCard.js'; import { JackInPoint } from './entities/JackInPoint.js'; import { CyberspaceLayer } from './cyber/CyberspaceLayer.js'; import { CyberAvatar } from './cyber/CyberAvatar.js'; @@ -303,7 +303,7 @@ export type RunSnapshot = { extractedOperativeIds?: string[]; /** P3.M5: off-grid crew records for extracted Score operatives. */ extractedOperatives?: RunEntitySnapshot[]; - /** Run-scoped key items / keycards without a siteId (P2.5.M6.2). Defaults to []. */ + /** Run-scoped key items / keycards without a principalId (P2.5.M6.2). Defaults to []. */ keyItems?: KeyItemSnapshot[]; /** Terrain/entity mutations recorded during the run (P2.5.M7.1). Defaults to []. */ mutationDeltas?: TileDelta[]; @@ -366,6 +366,7 @@ type KeyItemSnapshot = { id: string; label: string; doorId: string; + principalId?: string; }; export type JackOutRequest = { @@ -387,6 +388,11 @@ export type RunOptions = { seed?: unknown; onPersist?: unknown; onResult?: unknown; + /** Fired once at the end of a successful `enterCombat()` — after the map is + * built and placement succeeds. Lets the campaign defer irreversible, + * one-shot commits (THE SCORE's `scoreAttempted`) until the run is actually + * playable, so a generation failure can't strand the campaign. */ + onCombatEntered?: unknown; /** Called when the player reaches the exit with an incomplete objective. * The shell should show a confirmation prompt; call `run.confirmAbort()` * to finalise the abort extraction, or do nothing to let the player @@ -482,7 +488,7 @@ export class Run { telemetry: RunTelemetry; objectiveTimer: ObjectiveTimerSnapshot; mapSeen: Set; - /** Run-scoped key items / keycards without a siteId (P2.5.M6.2). Lost on run end. */ + /** Run-scoped key items / keycards without a principalId (P2.5.M6.2). Lost on run end. */ keyItems: KeyItem[]; /** P3.M5: Score-only independent extraction latch. Empty on normal runs. */ extractedOperativeIds: Set; @@ -494,6 +500,8 @@ export class Run { priorKeyItems: KeyItem[]; onPersist: ((record: RunSnapshot) => void) | null; onResult: ((result: RunResult) => void) | null; + /** Fired once after a successful `enterCombat()`; see `RunInit.onCombatEntered`. */ + onCombatEntered: (() => void) | null; onAbortRequested: (() => void) | null; onJackOutRequested: ((request: JackOutRequest) => void) | null; /** Shell presentation hook — fired synchronously after jack-in completes. */ @@ -512,6 +520,7 @@ export class Run { seed, onPersist, onResult, + onCombatEntered, onAbortRequested, onJackOutRequested, onJackInPresent, @@ -537,6 +546,9 @@ export class Run { if (onResult !== undefined && typeof onResult !== 'function') { throw new TypeError('Run: onResult must be a function'); } + if (onCombatEntered !== undefined && typeof onCombatEntered !== 'function') { + throw new TypeError('Run: onCombatEntered must be a function'); + } if (onAbortRequested !== undefined && typeof onAbortRequested !== 'function') { throw new TypeError('Run: onAbortRequested must be a function'); } @@ -593,6 +605,7 @@ export class Run { this.priorKeyItems = ((priorKeyItems as KeyItem[] | undefined) ?? []).map(k => ({ ...k })); this.onPersist = (onPersist as ((record: RunSnapshot) => void) | undefined) ?? null; this.onResult = (onResult as ((result: RunResult) => void) | undefined) ?? null; + this.onCombatEntered = (onCombatEntered as (() => void) | undefined) ?? null; this.onAbortRequested = (onAbortRequested as (() => void) | undefined) ?? null; this.onJackOutRequested = (onJackOutRequested as ((request: JackOutRequest) => void) | undefined) ?? null; @@ -882,6 +895,11 @@ export class Run { this.state = RUN_STATE.COMBAT; this.#recordCurrentPlayerVision(); this._reattachCombatListeners(); + // The run is now playable — the map built and every objective fixture placed. + // Fire AFTER state=COMBAT so the campaign can commit one-shot, irreversible + // attempt flags (THE SCORE) only once the run can actually be won. A throw + // anywhere above skips this, leaving the campaign free to redeploy. + this.onCombatEntered?.(); } /** Permitted from COMBAT only. Notifies the shell via `onResult`. */ @@ -955,7 +973,12 @@ export class Run { .map(crew => ({ ...snapshotEntity(crew), x: 0, y: 0 })), } : {}), - keyItems: this.keyItems.map(k => ({ id: k.id, label: k.label, doorId: k.doorId })), + keyItems: this.keyItems.map(k => ({ + id: k.id, + label: k.label, + doorId: k.doorId, + ...(k.principalId ? { principalId: k.principalId } : {}), + })), mutationDeltas: world.mutationDeltas.map(delta => ({ ...delta })), // P3.M4.1/M4.2: the reserved meat partner. While the cyber layer is still // dormant the partner is off-grid, so it serializes as an entity record @@ -1433,8 +1456,8 @@ export class Run { } /** - * Add a run-scoped key item (keycard with no siteId). Crashes on duplicates - * per project policy — double-collection is always a bug. + * Add a run-scoped key item (keycard). Crashes on duplicates per project + * policy — double-collection is always a bug. */ addKeyItem(item: KeyItem): void { if (!item || typeof item !== 'object') { @@ -1449,10 +1472,41 @@ export class Run { if (typeof item.doorId !== 'string' || item.doorId.length === 0) { throw new TypeError('Run.addKeyItem: item.doorId must be a non-empty string'); } - if (this.keyItems.some(k => k.id === item.id)) { - throw new Error(`Run.addKeyItem: duplicate key item "${item.id}"`); + // Canonicalize before the dedup check so a bare legacy id picked up off a + // pre-P3.1 map lands as the principal-unique id, matching restore-time migration. + const canonical = migrateLegacyKeycardId(item); + if (this.keyItems.some(k => k.id === canonical.id)) { + throw new Error(`Run.addKeyItem: duplicate key item "${canonical.id}"`); + } + this.keyItems.push({ + id: canonical.id, + label: canonical.label, + doorId: canonical.doorId, + ...(canonical.principalId ? { principalId: canonical.principalId } : {}), + }); + } + + /** + * The keycards effective at this run's site: run-scoped pickups plus any + * campaign-held cards stamped for the *current* principal (owner). Cards from + * other principals are excluded so the combat inventory and the door-unlock + * context stay scoped to the owner of the door in front of the operator — + * every generated door shares `doorId` `door-0`, so an unscoped merge both + * renders phantom duplicates and would let a rival owner's card open this door + * (P3.1 fix). A card for *this* owner opens the door at every site they + * control (P3.1-balance). Deduped by id. + */ + effectiveKeyItems(campaignKeyItems: readonly KeyItem[]): KeyItem[] { + const principalId = this.contract?.context.principal?.id ?? null; + const scoped = principalId ? campaignKeyItems.filter(k => k.principalId === principalId) : []; + const seen = new Set(); + const result: KeyItem[] = []; + for (const k of [...scoped, ...this.keyItems]) { + if (seen.has(k.id)) continue; + seen.add(k.id); + result.push({ ...k }); } - this.keyItems.push({ id: item.id, label: item.label, doorId: item.doorId }); + return result; } mapSeenKeys(): string[] { @@ -1971,12 +2025,12 @@ export class Run { this.contract.objective.kind !== OBJECTIVES.TERMINAL_SLICE && this.contract.objective.kind !== OBJECTIVES.SCORE_FINAL ) { - const revisitSiteId = this.contract.context.locationSiteId; - const priorKey = - revisitSiteId && - this.priorKeyItems.find(k => k.doorId === linkedDoorId && k.siteId === revisitSiteId); - // Held site keycard from a prior visit → skip spawn; door stays locked - // until interact (P2.5.M7.2). + const revisitPrincipalId = this.contract.context.principal?.id ?? null; + const priorKey = this.priorKeyItems.find( + k => k.doorId === linkedDoorId && k.principalId === revisitPrincipalId + ); + // Held principal keycard from a prior visit (to any of this owner's + // sites) → skip spawn; door stays locked until interact (P2.5.M7.2). if (!priorKey) { // 50/50 roll — terminal unlock vs keycard unlock (P2.5.M6.2). const unlockMethod = resolveUnlockMethod(this.contract, this.rng); @@ -2009,18 +2063,19 @@ export class Run { this.rng, linkedDoorId ); - // On a remembered-site revisit, stamp the keycard with the site id - // so collecting it promotes the card to campaign-scoped (P2.5.M6.2 - // routing) for future revisit re-opens via interact (P2.5.M7.2). - const keycardSiteId = this.contract.context.locationSiteId; + // Stamp the keycard with the site's owning principal so collecting + // it promotes the card to campaign-scoped on extraction and a future + // visit to *any* of this owner's sites re-opens the door via the + // held card instead of respawning one (P3.1-balance). + const keycardPrincipalId = this.contract.context.principal?.id; this.world.addEntity( new KeyCard({ - id: `keycard-${linkedDoorId}`, + id: keycardIdFor(linkedDoorId, keycardPrincipalId), x: keycardAnchor.x, y: keycardAnchor.y, doorId: linkedDoorId, label: 'Access keycard', - ...(keycardSiteId ? { siteId: keycardSiteId } : {}), + principalId: keycardPrincipalId, }) ); } @@ -2062,7 +2117,13 @@ export class Run { } if (this.contract.objective.kind === OBJECTIVES.SCORE_FINAL) { const doorId = scoreDoorId(this.contract); - const jackAnchor = findInteractableAnchor(this.world, this.player, this.exitTile, this.rng); + const jackAnchor = findScoreJackInAnchor( + this.world, + this.player, + this.exitTile, + this.rng, + doorId + ); this.world.addEntity( new JackInPoint({ id: 'jack-in-0', @@ -2729,7 +2790,7 @@ const SNAPSHOT_EXTRACTORS: Partial Enti return { doorId: k.doorId, label: k.label, - siteId: k.siteId ?? null, + principalId: k.principalId ?? null, } satisfies KeyCardSnapshot; }, 'jack-in-point': e => { @@ -3125,6 +3186,59 @@ function findDecoupledTerminalAnchor( return rng.pick(candidates); } +/** + * Jack-in anchor for THE SCORE — winnability-critical and self-healing. + * + * `score-door-0` is unlocked ONLY by jacking in and slicing the cyber core, so + * the jack-in MUST live on the spawn side of that locked door. A door-agnostic + * anchor can drop it *behind* the very door it unlocks, sealing the run (the + * shipped `bad-score` dead save). Because a failed Score is terminal, this never + * warns-and-ships a deadlock: + * + * 1. Prefer the door-aware decoupled anchor (spawn-side, non-chokepoint). + * 2. If that anchor isn't actually reachable with the door still locked (or the + * finder finds nothing), REPAIR: relocate to the nearest spawn-side + * reachable tile that can host a fixture. + * + * The spawn-side reachable set always contains spawn's neighbours, so the repair + * effectively cannot come up empty; only a degenerate map throws — and the Score + * commit is deferred until the map builds, so even that is retryable, not fatal. + */ +function findScoreJackInAnchor( + world: World, + player: Entity, + exitTile: GridPoint, + rng: Rng, + doorId: string +): GridPoint { + const spawn = { x: player.x, y: player.y }; + // Floor reachable from spawn with the score door still LOCKED (the default + // flood treats locked doors as walls). The jack-in must land inside this set. + const reachable = explorationReachableKeys(world, spawn); + try { + const anchor = findDecoupledTerminalAnchor(world, player, exitTile, rng, doorId); + if (reachable.has(coordKey(anchor.x, anchor.y))) return anchor; + } catch { + // Decoupled finder found nothing legal — fall through to the repair path. + } + // Self-heal: nearest spawn-side tile that can host the jack-in fixture. + // Deterministic ordering: by distance to spawn, then row, then column. + const repaired = [...reachable] + .map(key => parseCoordKey(key, 'findScoreJackInAnchor')) + .filter( + p => + !(p.x === spawn.x && p.y === spawn.y) && + !(p.x === exitTile.x && p.y === exitTile.y) && + !world.liveEntityAt(p.x, p.y) && + hasAdjacentPassableTile(world, p.x, p.y) + ) + .sort((a, b) => manhattan(a, spawn) - manhattan(b, spawn) || a.y - b.y || a.x - b.x)[0]; + if (!repaired) { + throw new Error(`Run: Score map has no spawn-side tile to host the jack-in (door ${doorId})`); + } + return repaired; +} + function findBehindDoorAnchor( world: World, player: Entity, diff --git a/src/game/crewDisplay.ts b/src/game/crewDisplay.ts new file mode 100644 index 0000000..d6aa949 --- /dev/null +++ b/src/game/crewDisplay.ts @@ -0,0 +1,99 @@ +/** + * Pure formatting for a crew member's roster readout — the STATS and GEAR + * blocks of ``'s detail pane. Kept apart from the component so the + * stat/label mapping is unit-testable without a DOM, and so every combat stat + * and every {@link Crew.applyGear} channel has a single, audited place to + * surface. + * + * `gearLines` 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. + */ + +import type { Gear } from './Crew.js'; + +/** + * Minimal structural view of a crew member's combat stats. `Crew` instances + * satisfy this via their getters/fields; plain objects can stand in for tests. + * Effective damage bonuses are pre-capped by the `Crew` getters, so + * `statDisplays` formats them verbatim rather than re-deriving the caps. + */ +export interface StatReadout { + hp: number; + maxHp: number; + maxAp: number; + baseHitChance: number; + baseDodgeChance: number; + rangedDamage: number; + meleeDamage: number; + effectiveRangedDamageBonus: number; + effectiveMeleeDamageBonus: number; + damageReduction: number; + gear: Gear | null; +} + +const pct = (n: number) => `${(n * 100).toFixed(0)}%`; + +/** + * 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 + * `shield` and `regen` keys appear only when gear grants that per-turn effect. + * + * Bonuses are counted exactly once. Some stats are *live* (the gear delta is + * already baked into the field by {@link Crew.applyGear}): `maxAp` and + * `damageReduction`. Others are *base* and must have the effective bonus added + * here: hit/dodge chance and ranged/melee damage. Mixing the two — e.g. adding + * `apBonus` onto the already-boosted `maxAp` — double-counts the gear. + */ +export function statDisplays(stats: StatReadout): Record { + const hitBonus = stats.gear?.hitBonus ?? 0; + const dodgeBonus = stats.gear?.dodgeBonus ?? 0; + const rangedBonus = stats.effectiveRangedDamageBonus ?? 0; + const meleeBonus = stats.effectiveMeleeDamageBonus ?? 0; + const shieldRegen = stats.gear?.shieldRegen ?? 0; + const hpRegen = stats.gear?.hpRegen ?? 0; + + const aim = Math.min(stats.baseHitChance + hitBonus, 1); + const dodge = Math.min(stats.baseDodgeChance + dodgeBonus, 1); + + const labels: Record = { + hp: `${stats.hp}/${stats.maxHp}`, + // `maxAp` is the live stat — the Reflex Booster delta is already baked in. + ap: `${stats.maxAp}`, + aim: `${pct(aim)}`, + dodge: `${pct(dodge)}`, + ranged: `${stats.rangedDamage + rangedBonus} dmg`, + melee: `${stats.meleeDamage + meleeBonus} dmg`, + armor: `${stats.damageReduction}`, + }; + if (shieldRegen > 0) labels.shield = `+${shieldRegen}/turn`; + if (hpRegen > 0) labels.regen = `+${hpRegen} HP/turn`; + return labels; +} + +/** + * Format the active gear bonuses on `gear` as display lines. 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. + * + * 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). + */ +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)}`); + if ((gear.rangedDamageBonus ?? 0) > 0) + lines.push(`Ballistics Coil +${gear.rangedDamageBonus} ranged dmg`); + 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; +} diff --git a/src/game/endFlavor.ts b/src/game/endFlavor.ts new file mode 100644 index 0000000..0174296 --- /dev/null +++ b/src/game/endFlavor.ts @@ -0,0 +1,177 @@ +/** + * Campaign end-screen flavor pools (P3 polish). + * + * Every outcome's overlay does distinct narrative work — verdict, cold readout, + * crew fate, (on a win) the stolen prize — instead of restating one idea. Copy + * is drawn deterministically from per-seed pools, so a given campaign always + * ends the same way but different campaigns vary; each slot draws from its own + * `salt` so the lines don't move in lockstep. + * + * Losses stay *cause-aware*: each end reason has its own banner/reason/detail + * pool, so a clock-expired loss never borrows a death-implying banner. + */ + +import type { CampaignEndReason } from '../types.js'; +import type { CampaignSummary } from './campaignSummary.js'; + +/** The verdict — pairs visually with the loss banner treatment. */ +export const WIN_BANNERS = [ + 'EXFIL CLEAN', + "WE'RE GHOSTS", + 'CLEAN BREAK', + 'PAYDAY', + 'RUN COMPLETE', +] as const; + +/** The cold system readout. */ +export const WIN_REASONS = [ + 'Payload secured. Trail cold.', + "Exfil confirmed — you're a ghost.", + 'The Score is yours.', + 'Jacked out clean. No tail.', +] as const; + +/** The crew's fate — the human line. */ +export const WIN_DETAILS = [ + "Your crew jacked out before the ICE closed. The data's already moving on the black market.", + "Nobody flatlined on the way out. The corp won't know what's missing until the quarterlies.", + "Payload fenced, trail cold, crew breathing. That's a good night in this city.", +] as const; + +/** The loot label above the stolen blueprint. */ +export const WIN_REWARD_KICKERS = [ + 'PAYLOAD DECRYPTED', + 'HOT OFF THE WIRE', + 'STOLEN BLUEPRINT', + 'FENCED INTEL', +] as const; + +/** Partial — got out, but the finale broke. */ +export const PARTIAL_BANNERS = [ + 'PARTIAL EXFIL', + 'MESSY EXIT', + 'HALF A SCORE', + 'BURNED RUN', +] as const; + +export const PARTIAL_REASONS = [ + 'The Score is compromised.', + 'You got out — but not clean.', + 'The Score cracked, then the plan did.', +] as const; + +export const PARTIAL_DETAILS = [ + 'Someone made it out, but the finale broke before the crew could clear the target cleanly.', + 'Part of the payload is yours; the rest burned with the run. The corp knows your face now.', + 'You salvaged something from the wreck, but the corp will remember this one.', +] as const; + +type FlavorPool = { + banners: readonly string[]; + reasons: readonly string[]; + details: readonly string[]; +}; + +/** + * Loss pools keyed by end reason. `crew-wipe` is the default bucket for any + * loss that isn't a closed window or a dead Decker. + */ +export const LOSS_FLAVOR = { + 'clock-expired': { + banners: ['TIME OUT', 'WINDOW CLOSED', 'GAME OVER'], + reasons: ['The Score window closed.', 'The clock beat you to it.', 'You ran out of runway.'], + details: [ + 'Corp security caught up. The contract is cold and this campaign is over.', + 'The window slammed shut and the corp locked everything down. Nothing left to hit.', + "The schedule slipped one job too many. The Score's gone cold.", + ], + }, + 'decker-flatlined-score': { + banners: ['FLATLINED', 'LINK SEVERED', 'GAME OVER'], + reasons: [ + 'The Decker flatlined during the Score.', + 'Your Decker bricked on the ICE.', + 'The Decker never jacked back out.', + ], + details: [ + 'The intrusion channel is gone. Nobody can finish the Score.', + 'Black ICE took your Decker, and the only way in died with them.', + 'No Decker, no door. The Score is sealed for good.', + ], + }, + 'crew-wipe': { + banners: ['GAME OVER', 'CREW DOWN', 'NO SURVIVORS'], + reasons: ['No surviving operators.', 'The whole crew is gone.', 'Nobody walked away.'], + details: [ + 'Every crew slot on the roster is flatlined. Their story ends here.', + 'The street took all of them. This campaign ends in the morgue.', + 'No operators left standing. The corp wins this one.', + ], + }, +} as const satisfies Record; + +/** Per-slot salts keep the lines decorrelated for a given seed. */ +const SALT = { + banner: 0, + reason: 1, + detail: 2, + rewardKicker: 3, +} as const; + +/** + * Deterministic integer mix of `seed` and `salt`. A plain `seed % length` would + * couple every slot to the same index; mixing in a salt and avalanching the bits + * decorrelates them while staying stable across reloads. + */ +function mix(seed: number, salt: number): number { + let x = (Math.trunc(seed) ^ Math.imul(salt + 1, 0x9e3779b1)) >>> 0; + x = Math.imul(x ^ (x >>> 16), 0x45d9f3b) >>> 0; + x = Math.imul(x ^ (x >>> 16), 0x45d9f3b) >>> 0; + return (x ^ (x >>> 16)) >>> 0; +} + +/** Pick a stable member of `options` for the given `seed`/`salt`. */ +export function pickFlavor(seed: number, salt: number, options: readonly T[]): T { + if (options.length === 0) { + throw new Error('pickFlavor requires a non-empty pool'); + } + return options[mix(seed, salt) % options.length]; +} + +export type EndFlavor = { + banner: string; + reason: string; + detail: string; + /** Loot kicker — present only on a win. */ + rewardKicker?: string; +}; + +/** Resolve the loss bucket for an end reason, defaulting to the crew-wipe pool. */ +function lossPool(endReason: CampaignEndReason): FlavorPool { + if (endReason === 'clock-expired' || endReason === 'decker-flatlined-score') { + return LOSS_FLAVOR[endReason]; + } + return LOSS_FLAVOR['crew-wipe']; +} + +/** Resolve every end-screen line for a campaign summary. */ +export function selectEndFlavor(summary: CampaignSummary): EndFlavor { + const { seed } = summary; + if (summary.result === 'win') { + return { + banner: pickFlavor(seed, SALT.banner, WIN_BANNERS), + reason: pickFlavor(seed, SALT.reason, WIN_REASONS), + detail: pickFlavor(seed, SALT.detail, WIN_DETAILS), + rewardKicker: pickFlavor(seed, SALT.rewardKicker, WIN_REWARD_KICKERS), + }; + } + const pool = + summary.result === 'partial' + ? { banners: PARTIAL_BANNERS, reasons: PARTIAL_REASONS, details: PARTIAL_DETAILS } + : lossPool(summary.endReason); + return { + banner: pickFlavor(seed, SALT.banner, pool.banners), + reason: pickFlavor(seed, SALT.reason, pool.reasons), + detail: pickFlavor(seed, SALT.detail, pool.details), + }; +} diff --git a/src/game/entities/KeyCard.ts b/src/game/entities/KeyCard.ts index bd9f1af..7febbe6 100644 --- a/src/game/entities/KeyCard.ts +++ b/src/game/entities/KeyCard.ts @@ -9,9 +9,11 @@ * `blockerKeys` skip it. * - NEUTRAL faction — drones do not target it. * - Holds a `doorId` — references the locked Door this card opens. - * - **Campaign-scoped** (`siteId` set): collected into - * `Campaign.keyItems` (persistent across runs, M7.2). - * - **Run-scoped** (no `siteId`): collected into `Run.keyItems` and + * - **Campaign-scoped** (`principalId` set): collected into + * `Campaign.keyItems` (persistent across runs, M7.2). Scoped to the + * owning *principal* (corp/org), so one card opens that owner's door at + * every site they control (P3.1-balance). + * - **Run-scoped** (no `principalId`): collected into `Run.keyItems` and * discarded at the end of the run. * * Glyph: `'κ'` (kappa) — distinct from other objective/pickup glyphs. @@ -29,35 +31,67 @@ export interface KeyCardInit extends Omit< /** Display label used for log lines and the snapshot. */ label: string; /** - * Optional site id. When set, the keycard is *campaign-scoped* — it persists - * in `Campaign.keyItems` across runs (P2.5.M7.2 location memory). When absent the - * keycard is *run-scoped* and lives only in `Run.keyItems`. + * Optional principal (owner) id. When set, the keycard is *campaign-scoped* — + * it persists in `Campaign.keyItems` across runs (P2.5.M7.2 location memory) + * and opens the owning principal's door at *every* site they control + * (P3.1-balance). When absent the keycard is *run-scoped* and lives only in + * `Run.keyItems`. */ - siteId?: string; + principalId?: string; } -/** P2.7.M6.2: KeyCard snapshot `extra`. Bag-hygienic — `siteId` is `null`, not absent. */ +/** P2.7.M6.2: KeyCard snapshot `extra`. Bag-hygienic — `principalId` is `null`, not absent. */ export type KeyCardSnapshot = { doorId: string; label: string; - siteId: string | null; + principalId: string | null; }; +/** + * Canonical keycard id. The principal id is baked in so two sites that share + * the stable objective `doorId` (`door-0` on every generated map) but belong to + * *different* owners still get distinct ids, while two sites of the *same* owner + * collapse to one shared card (P3.1-balance principal scoping). Run-scoped cards + * (no principalId) keep the bare `keycard-` form; they are discarded at + * run end so there is only ever one live, hence no collision. + */ +export function keycardIdFor(doorId: string, principalId?: string | null): string { + return principalId ? `keycard-${doorId}-${principalId}` : `keycard-${doorId}`; +} + +/** + * Migrate a legacy save's bare `keycard-` id to the principal-unique + * form. Only the exact legacy shape *with* a principalId is rewritten — + * post-fix ids, run-scoped ids (no principalId), and custom test ids are left + * untouched, so the transform is idempotent and safe to run on every restore. + * Note: the site→principal backfill (which resolves `principalId` from a legacy + * `siteId` via the roster) happens upstream in the normalize step; by the time + * an item reaches here it already carries its `principalId`. + */ +export function migrateLegacyKeycardId< + T extends { id: string; doorId: string; principalId?: string }, +>(item: T): T { + if (item.principalId && item.id === `keycard-${item.doorId}`) { + return { ...item, id: keycardIdFor(item.doorId, item.principalId) }; + } + return item; +} + export class KeyCard extends Entity { doorId: string; label: string; - siteId: string | null; + principalId: string | null; - constructor({ doorId, label, siteId, ...props }: KeyCardInit) { + constructor({ doorId, label, principalId, ...props }: KeyCardInit) { if (typeof doorId !== 'string' || doorId.length === 0) { throw new TypeError('KeyCard requires a non-empty doorId'); } if (typeof label !== 'string' || label.length === 0) { throw new TypeError('KeyCard requires a non-empty label'); } - if (siteId !== undefined && siteId !== null) { - if (typeof siteId !== 'string' || siteId.length === 0) { - throw new TypeError('KeyCard siteId must be a non-empty string when set'); + if (principalId !== undefined && principalId !== null) { + if (typeof principalId !== 'string' || principalId.length === 0) { + throw new TypeError('KeyCard principalId must be a non-empty string when set'); } } super({ @@ -70,7 +104,7 @@ export class KeyCard extends Entity { }); this.doorId = doorId; this.label = label; - this.siteId = siteId ?? null; + this.principalId = principalId ?? null; } override isHazardImmune(): boolean { diff --git a/src/game/hub/Curator.ts b/src/game/hub/Curator.ts index a292a8b..4e3724c 100644 --- a/src/game/hub/Curator.ts +++ b/src/game/hub/Curator.ts @@ -266,6 +266,16 @@ export const CONTRACT_LEXICON = Object.freeze({ ]), }); +/** + * Display label for a principal (owner) id, resolved from the static lexicon. + * Used by the inventory to name a principal-scoped keycard's owner even after + * every site that owner controlled has left the roster (P3.1-balance). Returns + * `null` for an unknown id. + */ +export function principalLabelFor(principalId: string): string | null { + return CONTRACT_LEXICON.principals.find(p => p.id === principalId)?.label ?? null; +} + export const CONTRACT_RECIPES: readonly ContractRecipe[] = Object.freeze([ { id: 'retrieve-asset', diff --git a/src/game/hub/arcSurface.ts b/src/game/hub/arcSurface.ts index 7d29762..a2eff9f 100644 --- a/src/game/hub/arcSurface.ts +++ b/src/game/hub/arcSurface.ts @@ -2,8 +2,10 @@ import type { Contract } from './Curator.js'; import type { Campaign } from '../Campaign.js'; import type { HubReveals } from './hubReveals.js'; import { + ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED, CLOCK_ACT2_DEADLINE_JOBS, CLOCK_ACT2_GRACE_JOBS, + casedPrincipalSiteCount, clockDeadlineApplies, } from '../Campaign.js'; import type { CampaignArcStage, LocationSite } from '../../types.js'; @@ -65,6 +67,12 @@ export function formatHubArcStatusLines(campaign: ArcSurfaceCampaign): HubArcSta throw new Error('arcSurface: score revealed without a Score target site'); } parts.push(`SCORE: ${scoreTargetDisplayName(target)}`); + // Casing is the sole gate out of Stage 2 — surface its progress so the + // player can see how many Score-org sites are left to case. + if (campaign.arc.arcStage === 'act-2' && target.principal?.id) { + const cased = casedPrincipalSiteCount(campaign.siteRoster, target.principal.id); + parts.push(`CASED ${cased}/${ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED}`); + } } return [parts.join(' | '), formatClockStatus(campaign)]; } @@ -157,7 +165,7 @@ export function isScorePrincipalContract( * `known` styling). */ export interface ContractLocationBadge { - variant: 'score-site' | 'casing' | 'revisit'; + variant: 'score-site' | 'casing' | 'revisit' | 'keycard'; text: string; } @@ -179,7 +187,8 @@ export interface ContractLocationBadge { export function contractLocationBadges( contract: Contract, scoreTargetSiteId: string | null | undefined, - scorePrincipalId: string | null | undefined + scorePrincipalId: string | null | undefined, + heldKeycardPrincipalIds: ReadonlySet = new Set() ): ContractLocationBadge[] { const badges: ContractLocationBadge[] = []; const scoreSite = isScoreSiteContract(contract, scoreTargetSiteId); @@ -191,6 +200,13 @@ export function contractLocationBadges( if (contract.context.locationSiteId && !scoreSite) { badges.push({ variant: 'revisit', text: '// known site' }); } + // A held keycard means we hold this owner's access card (P3.1-balance) — it + // opens the locked door at every site they control, so surface it whenever the + // job targets a principal we already carry a card for. + const principalId = contract.context.principal?.id; + if (!scoreSite && principalId && heldKeycardPrincipalIds.has(principalId)) { + badges.push({ variant: 'keycard', text: '// keycard held' }); + } return badges; } diff --git a/src/game/locations.ts b/src/game/locations.ts index 3e03b89..f7ddacd 100644 --- a/src/game/locations.ts +++ b/src/game/locations.ts @@ -81,6 +81,21 @@ export function generateSiteId(seed: number | string): string { throw new TypeError('generateSiteId: seed must be a finite number or non-empty string'); } +/** + * Stable roster site id for a contract's target location. Revisit contracts + * carry an explicit `locationSiteId`; everything else derives one from the map + * seed via {@link generateSiteId}, so a fresh contract that reuses a remembered + * seed resolves to the same roster site. Mirrors + * `Campaign.locationSiteIdForContract` but takes a structural param to keep + * this module import-light (no `Contract` dependency). + */ +export function siteIdForContract(contract: { + seed: number | string; + context: { locationSiteId?: string }; +}): string { + return contract.context.locationSiteId ?? generateSiteId(contract.seed); +} + /** * Replay terrain mutations onto a freshly-built grid (revisit re-entry). * Mutates `grid` only — does not touch any `World.mutationDeltas` accumulator, diff --git a/src/game/persistence.ts b/src/game/persistence.ts index 58baef0..87ed803 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -70,7 +70,7 @@ import { CorpTurret } from './entities/CorpTurret.js'; import { RelayNode } from './entities/RelayNode.js'; import { ConsumablePickup } from './entities/ConsumablePickup.js'; import { EscortNpc } from './entities/EscortNpc.js'; -import { KeyCard } from './entities/KeyCard.js'; +import { KeyCard, migrateLegacyKeycardId } from './entities/KeyCard.js'; import { JackInPoint } from './entities/JackInPoint.js'; import { CyberspaceLayer } from './cyber/CyberspaceLayer.js'; import { CyberAvatar } from './cyber/CyberAvatar.js'; @@ -552,14 +552,18 @@ function readEscortNpc(extra: EntitySnapshotExtra, id: string): EscortNpcSnapsho function readKeyCard(extra: EntitySnapshotExtra, id: string): KeyCardSnapshot { if (hasNoState(extra)) throw new TypeError(`restore: keycard entity ${id} requires keycard state`); - const k = extra as Partial; - if (k.siteId !== undefined && k.siteId !== null && typeof k.siteId !== 'string') { - throw new TypeError(`restore: keycard ${id} siteId must be a string`); - } + const k = extra as Partial & { siteId?: unknown }; + if (k.principalId !== undefined && k.principalId !== null && typeof k.principalId !== 'string') { + throw new TypeError(`restore: keycard ${id} principalId must be a string`); + } + // Legacy P3.1 grid pickups stamped a `siteId`; the P3.1-balance re-scope keys + // by principal instead. A mid-run grid card belongs to no promotable owner + // yet (it promotes only when collected + extracted), so a legacy `siteId` is + // simply dropped — the card restores as an unscoped run pickup. return { doorId: requireString(k.doorId, `restore: keycard ${id} doorId must be a non-empty string`), label: requireString(k.label, `restore: keycard ${id} label must be a non-empty string`), - siteId: (k.siteId as string | null | undefined) ?? null, + principalId: (k.principalId as string | null | undefined) ?? null, }; } @@ -878,7 +882,7 @@ const ENTITY_RESTORE: Partial> = Object. buildProps(extra, rec) { const k = readKeyCard(extra, rec.id); const props: Partial = { doorId: k.doorId, label: k.label }; - if (k.siteId) props.siteId = k.siteId; + if (k.principalId) props.principalId = k.principalId; return props; }, apply(entity, _extra, rec) { @@ -892,6 +896,7 @@ const ENTITY_RESTORE: Partial> = Object. type RestoreOptions = { onPersist?: (record: RunSnapshot) => void; onResult?: (result: RunResult) => void; + onCombatEntered?: () => void; }; type RestoreCampaignOptions = { @@ -994,7 +999,7 @@ export type KeyItemSnapshot = { id: string; label: string; doorId: string; - siteId?: string; + principalId?: string; }; /** Serializable shape of `Campaign.hubReveals`. */ @@ -1144,6 +1149,7 @@ export function restore(record: unknown, options: RestoreOptions = {}) { seed: record.seed, onPersist: options.onPersist, onResult: options.onResult, + onCombatEntered: options.onCombatEntered, }); run.rng = new Rng(record.rng.seed); run.rng.setState(record.rng.state); @@ -1495,6 +1501,10 @@ export function restoreCampaign(record: unknown, options: RestoreCampaignOptions campaign.activeRun = restoreActiveRun(record.activeRun, member, partner, { onPersist: () => options.onPersist?.(campaign), onResult: options.onResult, + // A run resumed at BRIEFING builds its map on the next enterCombat; wire the + // hook so THE SCORE's terminal commit fires then, not at the (already-past) + // deploy — matching the live deploy path. + onCombatEntered: () => campaign.onActiveRunCombatEntered(), }); // M7.2: a run resumed at BRIEFING has not yet built its map — re-derive the // prior-visit deltas from the (already-restored) roster so the upcoming @@ -1925,6 +1935,7 @@ function restoreActiveRun( seed: record.seed, onPersist: options.onPersist, onResult: options.onResult, + onCombatEntered: options.onCombatEntered, }); run.rng = new Rng(record.rng.seed); run.rng.setState(record.rng.state); @@ -2100,7 +2111,7 @@ function normalizeRunKeyItems(raw: unknown): KeyItem[] { if (!Array.isArray(raw)) { throw new TypeError('restore: run keyItems must be an array when supplied'); } - return (raw as KeyItem[]).map((item, i) => { + return (raw as Array).map((item, i) => { if (!item || typeof item !== 'object') { throw new TypeError(`restore: run keyItems[${i}] must be an object`); } @@ -2113,7 +2124,26 @@ function normalizeRunKeyItems(raw: unknown): KeyItem[] { if (typeof item.doorId !== 'string' || item.doorId.length === 0) { throw new TypeError(`restore: run keyItems[${i}].doorId must be a non-empty string`); } - return { id: item.id, label: item.label, doorId: item.doorId }; + const result: KeyItem = { id: item.id, label: item.label, doorId: item.doorId }; + if (item.principalId !== undefined) { + if (typeof item.principalId !== 'string' || item.principalId.length === 0) { + throw new TypeError( + `restore: run keyItems[${i}].principalId must be a non-empty string when set` + ); + } + result.principalId = item.principalId; + } else if (item.siteId !== undefined) { + // P3.1-balance re-scope: a legacy in-progress run held a `siteId`-scoped + // card. Run cards match the door by `doorId` regardless of scope, so it + // still opens this run's door; but without a principal it can't promote to + // the campaign on extraction — the player re-collects on the next visit. + console.warn( + `restore: run keycard "${item.id}" carried a legacy siteId; it stays run-scoped and will not persist on extraction` + ); + } + // Heal the bare legacy `keycard-` id shape (P3.1); a no-op once the + // card carries a principalId in the canonical id. + return migrateLegacyKeycardId(result); }); } diff --git a/src/input/applyIntent.ts b/src/input/applyIntent.ts index b5003ea..b9e0ac2 100644 --- a/src/input/applyIntent.ts +++ b/src/input/applyIntent.ts @@ -131,7 +131,7 @@ export type ApplyIntentContext = { id: string; doorId: string; label: string; - siteId: string | null; + principalId: string | null; }) => void; /** * Merged key-item inventory — campaign + run-scoped (P2.5.M6.2). Passed @@ -339,9 +339,9 @@ function collectTileLoot(ctx: ApplyIntentContext) { world.removeEntity(consumablePickup.id); log(`> ${entityLabel(player)} picks up ${consumablePickup.label}.`); } - // Walk-onto keycard collection (P2.5.M6.2). siteId determines scope: - // - siteId set → campaign-scoped (persists across runs) - // - no siteId → run-scoped (discarded on run end) + // Walk-onto keycard collection (P2.5.M6.2). principalId determines scope: + // - principalId set → campaign-scoped (persists across runs) + // - no principalId → run-scoped (discarded on run end) const keycard = player.alive ? world.keycardAt(player.x, player.y) : null; if (keycard) { world.removeEntity(keycard.id); @@ -349,7 +349,7 @@ function collectTileLoot(ctx: ApplyIntentContext) { id: keycard.id, doorId: keycard.doorId, label: keycard.label, - siteId: keycard.siteId, + principalId: keycard.principalId, }); log(`> ${entityLabel(player)} picks up ${keycard.label}.`); } diff --git a/src/input/keymap.ts b/src/input/keymap.ts index a07c965..9365acd 100644 --- a/src/input/keymap.ts +++ b/src/input/keymap.ts @@ -111,9 +111,9 @@ function dispatchIdle(key: string): DispatchResult { // the shell decides what `interact` means in the current Run.state. return { intent: { type: 'interact' }, nextMode: MODE.IDLE, aimKind: null }; case 'i': - // Inventory — opens the consumable inventory during combat. The shell - // presents `` and handles `use-item` events. In the Hub - // this is a no-op (Finn's shop uses Space-interact). + // Inventory — the shell presents `` during combat + // (handling `use-item` events) and the read-only `` + // stash in the Hub. return { intent: { type: 'inventory' }, nextMode: MODE.IDLE, aimKind: null }; case 'j': // Explicit jack-out — shell gates it to active Cyberspace and confirms diff --git a/src/input/touchpad.ts b/src/input/touchpad.ts index 7aacbfe..2465c30 100644 --- a/src/input/touchpad.ts +++ b/src/input/touchpad.ts @@ -7,7 +7,8 @@ * * Button identifiers: * - Directions (8): N, NE, E, SE, S, SW, W, NW - * - Actions: fire, special, interact, jack-out, end-turn, cancel + * - Actions: fire, special, interact, jack-out, look, inventory, flip, + * end-turn, cancel * - Shell (not `applyIntent`): quit-campaign → synthetic `Q` * * The `special` action covers each archetype's perk verb (Merc Vault, Razor @@ -39,6 +40,8 @@ const ACTION_KEYS = Object.freeze({ 'jack-out': 'j', inventory: 'i', look: 'l', + // P3.M4.3: simstim flip — Tab swaps active control between operators. + flip: 'Tab', 'end-turn': '.', cancel: 'Escape', }); diff --git a/src/shell/domTypes.ts b/src/shell/domTypes.ts index 4eab825..b97a85d 100644 --- a/src/shell/domTypes.ts +++ b/src/shell/domTypes.ts @@ -10,6 +10,14 @@ import type { AimKind, Mode } from '../input/keymap.js'; export type HelpScope = 'hub' | 'combat'; +/** + * A key item enriched for display with its resolved location name + * (`${principal} ${site}`). The name is derived from the campaign roster at + * render time — not persisted on the `KeyItem` — and is absent for legacy + * untokenized sites. + */ +export type KeyItemView = KeyItem & { locationName?: string }; + export type ModalElement = HTMLElement & { show(): void; hide(): void; @@ -27,6 +35,7 @@ export type ContractSelectElement = ModalElement & { setContracts(contracts: Contract[]): void; setScoreTargetSiteId(siteId: string | null): void; setScorePrincipalId(principalId: string | null): void; + setHeldKeycardPrincipalIds(principalIds: string[]): void; }; export type CrashDumpElement = ModalElement & { @@ -88,13 +97,18 @@ export type ClinicModalElement = ModalElement & { setPatients(crew: Crew[], balances: { credits: number; healedMemberIds?: string[] }): void; }; -export type ItemInventoryElement = ModalElement & { +/** `` — the deployed operator's interactive kit. */ +export type CombatInventoryElement = ModalElement & { setContents(contents: { salvage?: TypedSalvage; consumables?: NonNullable['consumables']; - keyItems?: KeyItem[]; + keyItems?: KeyItemView[]; }): void; - setItems(consumables: NonNullable['consumables']): void; +}; + +/** `` — the Hub's read-only campaign stash. */ +export type CrewInventoryElement = ModalElement & { + setContents(contents: { salvage?: TypedSalvage; keyItems?: KeyItemView[] }): void; }; export type KeyHelpElement = ModalElement & { @@ -106,6 +120,7 @@ export type TouchPadElement = HTMLElement & { aimKind: AimKind | null; setMode(mode: Mode, aimKind?: AimKind | null): void; setBlocked(predicate: (() => boolean) | null): void; + setFlipAvailable(available: boolean): void; }; export type ConfirmationModalElement = HTMLElement & { diff --git a/src/shell/shellRuntime.ts b/src/shell/shellRuntime.ts index 120110b..9317cc6 100644 --- a/src/shell/shellRuntime.ts +++ b/src/shell/shellRuntime.ts @@ -55,6 +55,7 @@ import { hasLineOfSight } from '/src/game/LineOfSight.js'; import { ITEM_ID, SCOREABLE_ITEMS, getItemById } from '/src/game/items.js'; import type { CampaignSnapshot } from '/src/game/persistence.js'; import type { Contract } from '/src/game/hub/Curator.js'; +import { principalLabelFor } from '/src/game/hub/Curator.js'; import { formatHubArcStatusLines, scorePrincipalId, @@ -80,7 +81,7 @@ import { pipWorldOf, shouldShowPip, } from '/src/render/pip.js'; -import type { TurnActionStep } from '/src/types.js'; +import type { KeyItem, TurnActionStep } from '/src/types.js'; import { installErrorBoundary, type FaultSignal } from '/src/errorBoundary.js'; import { isDevelopmentMode } from '/src/domUtils.js'; import { @@ -120,8 +121,10 @@ import type { GameOverElement, InitialRecruitElement, InputState, - ItemInventoryElement, + CombatInventoryElement, + CrewInventoryElement, KeyHelpElement, + KeyItemView, RunBriefingElement, SystemStartElement, TouchPadElement, @@ -197,7 +200,8 @@ let touchPadEl: TouchPadElement; let crewRosterEl: CrewRosterElement; let finnShopEl: FinnShopElement; let clinicModalEl: ClinicModalElement; -let itemInventoryEl: ItemInventoryElement; +let combatInventoryEl: CombatInventoryElement; +let crewInventoryEl: CrewInventoryElement; let chronicleArchiveEl: ChronicleArchiveElement; let keyHelpEl: KeyHelpElement; let logEl: HTMLElement; @@ -389,7 +393,8 @@ export async function boot() { crewRosterEl = mustGetElement('crew-roster'); finnShopEl = mustGetElement('finn-shop'); clinicModalEl = mustGetElement('clinic-modal'); - itemInventoryEl = mustGetElement('item-inventory'); + combatInventoryEl = mustGetElement('combat-inventory'); + crewInventoryEl = mustGetElement('crew-inventory'); chronicleArchiveEl = mustGetElement('chronicle-archive'); keyHelpEl = mustGetElement('key-help'); logEl = mustQuery('.game-log'); @@ -443,8 +448,9 @@ export async function boot() { clinicModalEl.addEventListener('heal', onClinicHeal); clinicModalEl.addEventListener('dismiss', onClinicDismiss); - itemInventoryEl.addEventListener('use-item', onUseItem); - itemInventoryEl.addEventListener('dismiss', () => itemInventoryEl.hide()); + combatInventoryEl.addEventListener('use-item', onUseItem); + combatInventoryEl.addEventListener('dismiss', () => combatInventoryEl.hide()); + crewInventoryEl.addEventListener('dismiss', () => crewInventoryEl.hide()); chronicleArchiveEl.addEventListener('dismiss', () => chronicleArchiveEl.hide()); keyHelpEl.addEventListener('dismiss', () => keyHelpEl.hide()); @@ -584,7 +590,8 @@ function hideBlockingShellModals(): void { crewRosterEl?.hide(); finnShopEl?.hide(); clinicModalEl?.hide(); - itemInventoryEl?.hide(); + combatInventoryEl?.hide(); + crewInventoryEl?.hide(); chronicleArchiveEl?.hide(); } @@ -765,6 +772,13 @@ function presentBriefing(contract: Contract) { function presentContractSelect(contracts: Contract[]) { contractSelectEl.setScoreTargetSiteId(campaign ? scoreTargetSiteId(campaign) : null); contractSelectEl.setScorePrincipalId(campaign ? scorePrincipalId(campaign) : null); + contractSelectEl.setHeldKeycardPrincipalIds( + campaign + ? campaign.keyItems + .map(k => k.principalId) + .filter((id): id is string => typeof id === 'string') + : [] + ); contractSelectEl.setContracts(contracts); contractSelectEl.show(); } @@ -926,34 +940,54 @@ function onFinnSellSalvage(evt: Event) { presentFinnShop(); } -function presentItemInventory() { +function presentInventory() { if (!campaign) return; - // Inventory is now available in both Hub and combat. The two states - // surface different wallets: - // - Combat: the deployed crew member's job-scoped inventory (what they've - // picked up this run + their consumables). - // - Hub: the campaign-wide accumulated salvage. No active crew member, - // so no consumables list — the player visits the shop or roster for - // per-crew loadout management. - // This keeps the overlay's mental model simple: it always shows the - // currently meaningful wallet for the state the player is standing in. + // Two distinct surfaces for the two states: + // - Hub: — the campaign-wide stash (accumulated salvage + // + stolen keycards). Read-only; no operator, so no consumables. + // - Combat: — the deployed operator's live job-scoped + // wallet, held keycards, and their navigable consumables list. if (campaign.state === CAMPAIGN_STATE.HUB) { - itemInventoryEl.setContents({ + crewInventoryEl.setContents({ salvage: campaign.salvage, - consumables: [], - keyItems: campaign.keyItems, + keyItems: keyItemsWithLocation(campaign.keyItems), }); - itemInventoryEl.show(); + crewInventoryEl.show(); return; } const run = campaign.activeRun; - if (!run || !run.player || !run.player.inventory) return; - itemInventoryEl.setContents({ - salvage: run.player.inventory.salvage, - consumables: run.player.inventory.consumables, - keyItems: [...campaign.keyItems, ...run.keyItems], + if (!run || !run.player) return; + // The active operator owns the live job-scoped wallet — the Decker before + // jack-in, the partner while jacked in, and whichever crewmate has control + // after jack-out (the simstim flip swaps `activeActor`). This overlay is + // gated to Meatspace upstream, so `activeActor` is always a Crew here. + const operator = activeActorOf(run) as Crew | null; + if (!operator || !operator.inventory) return; + combatInventoryEl.setContents({ + salvage: operator.inventory.salvage, + consumables: operator.inventory.consumables, + // Combat renders keycards as the generic "Access keycard" — the locked + // door is in front of you, so no location lookup is needed here. Scope to + // this run's site so other sites' held cards don't render as phantom + // duplicates (P3.1). + keyItems: run.effectiveKeyItems(campaign.keyItems), + }); + combatInventoryEl.show(); +} + +/** + * Enrich key items with their owning principal's name for the inventory tag. + * Keycards are scoped to a principal (owner), not a single site (P3.1-balance), + * so the tag names the owner — resolved from the static lexicon, so it renders + * even after every site that owner controlled has left the roster. Cards with an + * unknown/absent principal simply render without a tag. + */ +function keyItemsWithLocation(items: KeyItem[]): KeyItemView[] { + return items.map(item => { + const locationName = item.principalId ? principalLabelFor(item.principalId) : null; + if (!locationName) return { ...item }; + return { ...item, locationName }; }); - itemInventoryEl.show(); } /** @@ -974,6 +1008,11 @@ function onUseItem(evt: Event) { const run = campaign.activeRun; if (!run || !run.player) return; if (!run.world) throw new Error('[shell] active combat run has no world'); + // Consumables act through whichever operator currently has control — the + // Decker before jack-in, the partner while jacked in, the flipped-to crewmate + // after jack-out. Routing through `run.player` would apply the item to the + // frozen Decker body whenever the partner is the one in control. + const operator = activeActorOf(run) as Crew; const { itemId } = (evt as CustomEvent<{ itemId?: string }>).detail; if (!itemId) return; // Aimed consumables (incendiary): close the inventory overlay, switch the @@ -988,7 +1027,7 @@ function onUseItem(evt: Event) { return; } if (descriptor.needsAim) { - if (!run.player.canAfford(AP_COST.INTERACT)) { + if (!operator.canAfford(AP_COST.INTERACT)) { // Cheap pre-check: don't strand the player in aim mode if `useConsumable` // will reject the commit anyway. Crew's `canAfford(AP_COST.INTERACT)` // remains the source of truth at commit time. @@ -996,19 +1035,19 @@ function onUseItem(evt: Event) { return; } pendingAimItemId = itemId; - itemInventoryEl.hide(); + combatInventoryEl.hide(); setInputAim(AIM_KIND.USE_ITEM); flash(`AIM ${descriptor.label.toUpperCase()} — pick a direction (Esc to cancel).`); return; } try { - const result = run.player.useConsumable(itemId); + const result = operator.useConsumable(itemId); applyUseConsumableResult(result, run); } catch (err) { flash(`USE FAILED: ${errorMessage(err)}`); return; } - itemInventoryEl.hide(); + combatInventoryEl.hide(); paint(); concludeOperatorTurn(); } @@ -1023,10 +1062,13 @@ function applyUseConsumableResult( run: Run ): void { if (!run.world || !run.player) throw new Error('[shell] applyUseConsumableResult: no scene'); + // The acting operator — whoever currently has control — so HP/AP readouts + // reflect who actually used the item, not the frozen Decker body. + const operator = activeActorOf(run) as Crew; if (result.type === 'stim') { const healed = (result as { healed: number }).healed; flash( - `Used STIM — healed ${healed} HP (now ${run.player.hp}/${run.player.maxHp}). ${run.player.ap} AP left.` + `Used STIM — healed ${healed} HP (now ${operator.hp}/${operator.maxHp}). ${operator.ap} AP left.` ); return; } @@ -1038,7 +1080,7 @@ function applyUseConsumableResult( const overlays = placeSmoke(run.world.grid, cx, cy, radius); activeSmokeOverlays.push(...overlays); recomputeVision(); - flash(`Used SMOKE CHARGE — LOS blocked in radius ${radius}. ${run.player.ap} AP left.`); + flash(`Used SMOKE CHARGE — LOS blocked in radius ${radius}. ${operator.ap} AP left.`); return; } if (result.type === 'incendiary') { @@ -1054,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. ${run.player.ap} AP left.`); + flash(`Used INCENDIARY — bomb landed on hard cover; no fire took. ${operator.ap} AP left.`); } else { flash( - `Used INCENDIARY — ${stamped} tile${stamped === 1 ? '' : 's'} ignited. ${run.player.ap} AP left.` + `Used INCENDIARY — ${stamped} tile${stamped === 1 ? '' : 's'} ignited. ${operator.ap} AP left.` ); } recomputeVision(); @@ -1069,7 +1111,7 @@ function applyUseConsumableResult( throw new Error('[shell] breaching charge returned invalid target data'); } run.world.placeBreachingCharge(tx, ty); - flash(`BREACHING CHARGE planted. Detonates end of turn. ${run.player.ap} AP left.`); + flash(`BREACHING CHARGE planted. Detonates end of turn. ${operator.ap} AP left.`); recomputeVision(); return; } @@ -1092,6 +1134,10 @@ function resolveAimedUseItem(aim: { dx: number; dy: number }, run: Run): void { resetInputModes(); return; } + // The thrower is whichever operator currently has control, so throws + // originate from their tile, not the frozen Decker body's. (We've already + // bailed above if we're flipped to Cyberspace.) + const operator = activeActorOf(run) as Crew; const itemId = pendingAimItemId; if (!itemId) { // Direction press arrived without a stashed item — shouldn't be reachable @@ -1105,23 +1151,23 @@ function resolveAimedUseItem(aim: { dx: number; dy: number }, run: Run): void { // `player + dir * INCENDIARY_THROW_DIST`. If LOS from the thrower to // that tile is blocked (or the tile is out of bounds), refuse the // throw *before* spending AP / consuming the bomb. - const cx = run.player.x + aim.dx * INCENDIARY_THROW_DIST; - const cy = run.player.y + aim.dy * INCENDIARY_THROW_DIST; + const cx = operator.x + aim.dx * INCENDIARY_THROW_DIST; + const cy = operator.y + aim.dy * INCENDIARY_THROW_DIST; if (!run.world.grid.inBounds(cx, cy)) { flash('USE FAILED: target is off the map.'); paint(); return; } const blockers = run.world.blockerKeys(); - if (!hasLineOfSight(run.world.grid, run.player.x, run.player.y, cx, cy, { blockers })) { + if (!hasLineOfSight(run.world.grid, operator.x, operator.y, cx, cy, { blockers })) { flash('USE FAILED: target is behind cover.'); paint(); return; } } if (itemId === ITEM_ID.BREACHING_CHARGE) { - const tx = run.player.x + aim.dx * BREACHING_CHARGE_RANGE; - const ty = run.player.y + aim.dy * BREACHING_CHARGE_RANGE; + const tx = operator.x + aim.dx * BREACHING_CHARGE_RANGE; + const ty = operator.y + aim.dy * BREACHING_CHARGE_RANGE; const plantCheck = run.world.canPlaceBreachingCharge(tx, ty); if (!plantCheck.ok) { const msg = @@ -1136,7 +1182,7 @@ function resolveAimedUseItem(aim: { dx: number; dy: number }, run: Run): void { } } try { - const result = run.player.useConsumable(itemId, aim); + const result = operator.useConsumable(itemId, aim); applyUseConsumableResult(result, run); } catch (err) { flash(`USE FAILED: ${errorMessage(err)}`); @@ -1358,7 +1404,8 @@ function performQuitCampaign(): void { crewRosterEl.hide(); finnShopEl.hide(); clinicModalEl.hide(); - itemInventoryEl.hide(); + combatInventoryEl.hide(); + crewInventoryEl.hide(); pendingJobResult = null; dataStore.deleteCampaign(); @@ -1572,6 +1619,13 @@ export function handleIntent(intent: Intent): void { throw new Error(`[shell] state "${run.state}" has no active world/actor for intents`); } + let keyItems: KeyItem[] = []; + if (run.state === 'COMBAT') { + keyItems = (run as Run).effectiveKeyItems(campaign?.keyItems ?? []); + } else { + keyItems = run.keyItems; + } + applyIntent(intent, { world: intentWorld, player: intentActor as Parameters[1]['player'], @@ -1589,18 +1643,17 @@ export function handleIntent(intent: Intent): void { onCorpseSalvaged: entity => { activeVisionField(run).forgetCorpse(entity); }, - keyItems: [...(campaign?.keyItems ?? []), ...(run as Run).keyItems], + keyItems, onKeycardCollected: kc => { - if (kc.siteId) { - // Campaign-scoped: persists across runs. - if (campaign?.keyItems.some(k => k.id === kc.id)) { - return; - } - campaign?.addKeyItem({ id: kc.id, label: kc.label, doorId: kc.doorId, siteId: kc.siteId }); - } else { - // Run-scoped: lives only in this run, discarded on run end. - (run as Run).addKeyItem({ id: kc.id, label: kc.label, doorId: kc.doorId }); - } + // Picked-up keycards are run-scoped during the run — still usable for + // in-run door unlock via ctx.keyItems. Campaign.onJobEnd promotes the + // principal-stamped ones into the persistent inventory on live extraction. + (run as Run).addKeyItem({ + id: kc.id, + label: kc.label, + doorId: kc.doorId, + ...(kc.principalId ? { principalId: kc.principalId } : {}), + }); }, onSecuredInteract: handleSecuredInteract, onPlayerAction: (actionName: string) => { @@ -1621,7 +1674,7 @@ export function handleIntent(intent: Intent): void { return; } } - presentItemInventory(); + presentInventory(); break; case PLAYER_ACTIONS.INTERACT: handleInteract(); @@ -2267,6 +2320,11 @@ function paintPip(): void { export function paint(stateHint: InputState = activeInputState()): void { const run = currentScene(); + // P3.M4.3: surface the simstim FLIP fab only when a flip target exists right + // now — same gate as the keyboard `Tab` (dual-deploy / post-jack-out crews). + // Set before the early-returns below so non-combat / hidden-canvas paints + // always clear a stale fab. + touchPadEl.setFlipAvailable(!!run && isRun(run) && run.canFlip()); if (canvas.hidden) { setStatus(statusLine(stateHint)); return; @@ -2616,7 +2674,8 @@ function isAnyBlockingModalOpen(): boolean { if (crewRosterEl?.isOpen) return true; if (finnShopEl?.isOpen) return true; if (clinicModalEl?.isOpen) return true; - if (itemInventoryEl?.isOpen) return true; + if (combatInventoryEl?.isOpen) return true; + if (crewInventoryEl?.isOpen) return true; if (chronicleArchiveEl?.isOpen) return true; if (keyHelpEl?.isOpen) return true; if (faultEl?.isOpen) return true; diff --git a/src/types.ts b/src/types.ts index dacbbaf..3036a24 100644 --- a/src/types.ts +++ b/src/types.ts @@ -188,20 +188,22 @@ export type TurnActionSteps = Generator; /** * Key-item — keycards used to unlock doors (P2.5.M6.2). Comes in two scopes: - * - **Campaign-scoped** (`siteId` set): stored in `Campaign.keyItems`, - * survives across runs. Not consumed on use (P2.5.M7.2 revisit). - * - **Run-scoped** (no `siteId`): stored in `Run.keyItems`, discarded + * - **Campaign-scoped** (`principalId` set): stored in `Campaign.keyItems`, + * survives across runs. Not consumed on use (P2.5.M7.2 revisit). Scoped to + * the owning principal, so it opens that owner's door at every site they + * control (P3.1-balance). + * - **Run-scoped** (no `principalId`): stored in `Run.keyItems`, discarded * when the run ends. */ export type KeyItem = { - /** Unique id (e.g. `'keycard-door-0-'`). */ + /** Unique id (e.g. `'keycard-door-0-'`). */ id: string; /** Display label for UI / log. */ label: string; /** Stable `doorId` of the door this key opens. */ doorId: string; - /** Optional site id — populated by P2.5.M7.2 location memory. */ - siteId?: string; + /** Optional principal (owner) id — the campaign scope key (P3.1-balance). */ + principalId?: string; }; /** diff --git a/sw-core.js b/sw-core.js index 4f39fd6..716fc5e 100644 --- a/sw-core.js +++ b/sw-core.js @@ -167,6 +167,10 @@ const CacheConfig = { '/icons/icon512_maskable.png', '/icons/icon512_rounded.png', '/images/back.png', + '/images/charge.png', + '/images/incind.png', + '/images/smoke.png', + '/images/stim.png', '/fonts/silkscreen/slkscr-webfont.woff', '/fonts/silkscreen/slkscrb-webfont.woff', ]; diff --git a/sw-dev.js b/sw-dev.js index bcaf02a..7f43990 100644 --- a/sw-dev.js +++ b/sw-dev.js @@ -1,5 +1,5 @@ // Service Worker for Kernel Panic - Development Version -const VERSION = '0.3.0-dev'; +const VERSION = '0.3.2-dev'; importScripts(`/sw-core.js?v=${VERSION}`); const cacheConfig = CacheConfig.create(VERSION); diff --git a/sw.js b/sw.js index 3488502..6e295ba 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.0'; +const VERSION = '0.3.2'; 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 de04a31..a10d9bc 100644 --- a/tests/unit/game/Campaign.test.ts +++ b/tests/unit/game/Campaign.test.ts @@ -2,6 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED, Campaign, CAMPAIGN_STATE, CLOCK_ACT2_DEADLINE_JOBS, @@ -511,6 +512,48 @@ test('purchase applies reflex weave gear bonus', () => { 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 + // 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 member = campaign.crew[0]; + campaign.purchase({ itemId: 'reflex-booster', targetMemberId: member.id }); + const creditsAfterFirst = campaign.credits; + const maxApAfterFirst = member.maxAp; + assert.throws( + () => campaign.purchase({ itemId: 'reflex-booster', targetMemberId: member.id }), + /at capacity/i + ); + assert.equal(campaign.credits, creditsAfterFirst, 'no Creds deducted on the refused sale'); + assert.equal(member.maxAp, maxApAfterFirst, 'stat unchanged by the refused sale'); +}); + +test('purchase refuses every net-new limit-1 gear item once equipped', () => { + for (const itemId of ['monoblade', 'subdermal-plating', 'phase-shield', 'regen-mesh'] as const) { + const cost = SHOP_COST[itemId.toUpperCase().replace(/-/g, '_') as keyof typeof SHOP_COST]; + const campaign = new Campaign({ seed: 42, credits: cost * 2 }); + const member = campaign.crew[0]; + campaign.purchase({ itemId, targetMemberId: member.id }); + assert.throws( + () => campaign.purchase({ itemId, targetMemberId: member.id }), + /at capacity/i, + `${itemId} should be refused once equipped` + ); + assert.equal(campaign.credits, cost, `${itemId} refusal must not deduct Creds`); + } +}); + +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 }); + 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'); +}); + // meta upgrades (expanded-catalog, better-contracts) removed — Rep // tiers replace them. Tests for those items removed here. @@ -825,114 +868,95 @@ test('P3.M1.2: abort extraction does not count as a completed arc job', () => { assert.equal(campaign.arc.scoreRevealed, false); }); -test('P3.M1.2: Act 2 gates — each Act 3 condition checked independently', () => { - // Act 3 requires: completedJobs >= 9, 4 living crew, and 3 visited same-principal sites. - // Default test crew: buildCrew() trio + auto-Decker = 4 living. - const scorePrincipal = { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }; +const SCORE_PRINCIPAL = { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }; - // Gate: not enough jobs (crew and sites satisfied by default) - const tooFewJobs = new Campaign({ - seed: 44, - rep: 65, - completedJobs: 8, - siteRoster: [ - validSite({ - id: 'score', - seed: '100', - tier: 'score', - scoreTarget: true, - lastVisitedJob: 5, - principal: scorePrincipal, - }), - validSite({ id: 'case-1', seed: '101', lastVisitedJob: 6, principal: scorePrincipal }), - validSite({ id: 'case-2', seed: '102', lastVisitedJob: 7, principal: scorePrincipal }), - ], +/** Synthesized Score target as it exists in real play: never visited pre-heist. */ +function scoreTargetSite(): LocationSite { + return validSite({ + id: 'score', + seed: '100', + tier: 'score', + scoreTarget: true, + lastVisitedJob: 0, + principal: SCORE_PRINCIPAL, }); - assert.equal(tooFewJobs.arcStage, 'act-2', 'blocks on job count'); +} - // Gate: not enough living crew (need 4 non-flatlined). - // Start in Act 1 (low rep), flatline a member, then cross the Act 2 threshold - // so the Act 3 check sees only 3 living crew. - const attrition = new Campaign({ - seed: 45, - rep: 20, - completedJobs: 9, - siteRoster: [ - validSite({ - id: 'score', - seed: '100', - tier: 'score', - scoreTarget: true, - lastVisitedJob: 5, - principal: scorePrincipal, - }), - validSite({ id: 'case-1', seed: '101', lastVisitedJob: 6, principal: scorePrincipal }), - validSite({ id: 'case-2', seed: '102', lastVisitedJob: 7, principal: scorePrincipal }), - ], - }); - assert.equal(attrition.arcStage, 'act-1', 'starts in Act 1 with low rep'); - attrition.crew[0].flatlined = true; - attrition.rep = 65; - attrition.enterHub(); - assert.equal(attrition.crew.length, 4, 'buildCrew trio + auto-Decker'); - assert.equal(attrition.crew.filter(m => !m.flatlined).length, 3, 'only 3 living'); - assert.equal(attrition.arcStage, 'act-2', 'blocks on living crew count'); - - // Gate: not enough same-principal visited sites (2 of 3 required) - const tooCasual = new Campaign({ +/** `n` distinct visited sites belonging to the Score org (the casing payoff). */ +function casedSites(n: number): LocationSite[] { + return Array.from({ length: n }, (_, i) => + validSite({ + id: `case-${i}`, + seed: `${101 + i}`, + lastVisitedJob: 6 + i, + principal: SCORE_PRINCIPAL, + }) + ); +} + +test('P3.M1.2: casing is the sole Act 3 gate — cased-site count drives the transition', () => { + // One fewer than required stays in Stage 2, and casingProgress reflects it. + const underCased = new Campaign({ seed: 46, rep: 65, - completedJobs: 9, - siteRoster: [ - validSite({ - id: 'score', - seed: '100', - tier: 'score', - scoreTarget: true, - lastVisitedJob: 5, - principal: scorePrincipal, - }), - validSite({ id: 'case-1', seed: '101', lastVisitedJob: 6, principal: scorePrincipal }), - // Only 2 visited sites for this principal — need 3 - ], + completedJobs: 4, + siteRoster: [scoreTargetSite(), ...casedSites(ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED - 1)], + }); + assert.equal(underCased.arcStage, 'act-2', 'blocks below the casing threshold'); + assert.deepEqual(underCased.casingProgress(), { + cased: ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED - 1, + required: ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED, }); - assert.equal(tooCasual.arcStage, 'act-2', 'blocks on principal site visits'); - // Gate: score target never visited (synthesized, lastVisitedJob: 0) - const noTargetVisit = new Campaign({ - seed: 47, + // The synthesized Score target is never visited, so it never counts toward + // its own casing gate. + assert.equal( + underCased.siteRoster.find(s => s.scoreTarget)!.lastVisitedJob, + 0, + 'score target stays unvisited' + ); + + // Hitting the threshold advances regardless of total job count. + const cased = new Campaign({ + seed: 42, rep: 65, - completedJobs: 9, + completedJobs: 4, + siteRoster: [scoreTargetSite(), ...casedSites(ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED)], + }); + assert.equal(cased.arcStage, 'act-3', 'advances on cased-site count alone'); + assert.deepEqual(cased.casingProgress(), { + cased: ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED, + required: ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED, }); - const scoreTarget = noTargetVisit.siteRoster.find(s => s.scoreTarget); - assert.ok(scoreTarget); - assert.equal(scoreTarget!.lastVisitedJob, 0, 'synthesized target starts unvisited'); - assert.equal(noTargetVisit.arcStage, 'act-2', 'blocks when score target unvisited'); }); -test('P3.M1.2: Act 2 advances to Act 3 with 4 living crew and 3 visited same-principal sites', () => { - const scorePrincipal = { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }; +test('P3.M1.2: crew attrition no longer blocks Act 3 (living-crew gate removed)', () => { + // Start in Act 1 (low rep), flatline most of the crew, then cross into Act 2. + // Under the old gate this stalled at act-2; now casing alone decides. const campaign = new Campaign({ - seed: 42, - rep: 65, - completedJobs: 9, - siteRoster: [ - validSite({ - id: 'score', - seed: '100', - tier: 'score', - scoreTarget: true, - lastVisitedJob: 5, - principal: scorePrincipal, - }), - validSite({ id: 'case-1', seed: '101', lastVisitedJob: 6, principal: scorePrincipal }), - validSite({ id: 'case-2', seed: '102', lastVisitedJob: 7, principal: scorePrincipal }), - ], + seed: 45, + rep: 20, + completedJobs: 4, + siteRoster: [scoreTargetSite(), ...casedSites(ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED)], }); + assert.equal(campaign.arcStage, 'act-1', 'starts in Act 1 with low rep'); - const living = campaign.crew.filter(m => !m.flatlined).length; - assert.ok(living >= 4, `need 4 living crew, have ${living}`); - assert.equal(campaign.arcStage, 'act-3'); + campaign.crew[0].flatlined = true; + campaign.crew[1].flatlined = true; + campaign.rep = 65; + campaign.enterHub(); + + assert.ok( + campaign.crew.filter(m => !m.flatlined).length < 4, + 'fewer than the old 4-living-crew floor' + ); + assert.equal(campaign.arcStage, 'act-3', 'casing satisfied — attrition does not block'); +}); + +test('P3.M1.2: casingProgress is null before the Score target is designated', () => { + const act1 = new Campaign({ seed: 7, rep: 20, completedJobs: 0 }); + assert.equal(act1.arcStage, 'act-1'); + assert.equal(act1.casingProgress(), null); }); test('P3.M2: Decker assignment is idempotent — restored save with existing Decker does not duplicate', () => { @@ -1113,6 +1137,18 @@ test('P3.M1.5: clock loss requires Act 3 — Act 2 casing survives past the depl lastVisitedJob: 7, principal: { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }, }), + validSite({ + id: 'case-3', + seed: '103', + lastVisitedJob: 8, + principal: { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }, + }), + validSite({ + id: 'case-4', + seed: '104', + lastVisitedJob: 9, + principal: { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }, + }), ], }); assert.equal(act3AtDeadline.arcStage, 'act-3'); @@ -1142,6 +1178,8 @@ test('P3.M1.5: Act 3 entry clamps an over-budget clock to guarantee Score deploy }), validSite({ id: 'case-1', seed: '101', lastVisitedJob: 6, principal: scorePrincipal }), validSite({ id: 'case-2', seed: '102', lastVisitedJob: 7, principal: scorePrincipal }), + validSite({ id: 'case-3', seed: '103', lastVisitedJob: 8, principal: scorePrincipal }), + validSite({ id: 'case-4', seed: '104', lastVisitedJob: 9, principal: scorePrincipal }), ], }); @@ -1264,6 +1302,18 @@ test('terminal result detection bypasses debrief for Score, terminal death, and lastVisitedJob: 7, principal: { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }, }), + validSite({ + id: 'case-3', + seed: '103', + lastVisitedJob: 8, + principal: { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }, + }), + validSite({ + id: 'case-4', + seed: '104', + lastVisitedJob: 9, + principal: { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }, + }), ], }); assert.equal(clockLoss.arcStage, 'act-3'); @@ -1335,7 +1385,7 @@ test('P3.M1.5: Clock deadline does not end the campaign after the Score is attem assert.equal(campaign.clockHeat, CLOCK_ACT2_DEADLINE_JOBS - CLOCK_ACT2_GRACE_JOBS); }); -test('P3.M1.7: Score contract is gated to Act 3 and marks attempted on deployment', () => { +test('P3.M1.7: Score contract is gated to Act 3 and commits the attempt only at combat entry', () => { const scorePrincipal = { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }; const campaign = new Campaign({ seed: 42, @@ -1353,6 +1403,8 @@ test('P3.M1.7: Score contract is gated to Act 3 and marks attempted on deploymen }), validSite({ id: 'case-1', seed: '101', lastVisitedJob: 6, principal: scorePrincipal }), validSite({ id: 'case-2', seed: '102', lastVisitedJob: 7, principal: scorePrincipal }), + validSite({ id: 'case-3', seed: '103', lastVisitedJob: 8, principal: scorePrincipal }), + validSite({ id: 'case-4', seed: '104', lastVisitedJob: 9, principal: scorePrincipal }), ], }); assert.equal(campaign.arcStage, 'act-3'); @@ -1376,9 +1428,16 @@ test('P3.M1.7: Score contract is gated to Act 3 and marks attempted on deploymen assert.ok(partner, 'Act 3 campaign should include a living meat partner'); assert.throws(() => campaign.deployCrewMember(decker!.id, score), /meat partner/); const run = campaign.deployCrewMember(decker!.id, score, partner!.id); - assert.equal(campaign.arc.scoreAttempted, true); - assert.equal(campaign.arcStage, 'score'); + // Defer-commit: deploying alone must NOT consume the (terminal) Score. The map + // is built in enterCombat, which can throw; committing earlier would strand the + // campaign in score-partial on a generation failure. + assert.equal(campaign.arc.scoreAttempted, false, 'deploy alone does not commit the Score'); + assert.equal(campaign.arcStage, 'act-3'); assert.equal(run.contract?.context.locationSiteId, 'score'); + // Entering combat (map built, fixtures placed) is what commits the attempt. + run.enterCombat(); + assert.equal(campaign.arc.scoreAttempted, true, 'combat entry commits the Score'); + assert.equal(campaign.arcStage, 'score'); }); test('a flatlined pre-Score Decker creates a free Terminal replacement lead', () => { @@ -1414,6 +1473,8 @@ test('the Score remains unavailable until a living replacement Decker is recruit }), validSite({ id: 'case-1', seed: '101', lastVisitedJob: 6, principal: scorePrincipal }), validSite({ id: 'case-2', seed: '102', lastVisitedJob: 7, principal: scorePrincipal }), + validSite({ id: 'case-3', seed: '103', lastVisitedJob: 8, principal: scorePrincipal }), + validSite({ id: 'case-4', seed: '104', lastVisitedJob: 9, principal: scorePrincipal }), ], }); const decker = campaign.crew.find(member => member.archetype === 'Decker'); @@ -1447,6 +1508,8 @@ test('P3.M1.7: completed Score contract records campaign win state', () => { }), validSite({ id: 'case-1', seed: '101', lastVisitedJob: 6, principal: scorePrincipal }), validSite({ id: 'case-2', seed: '102', lastVisitedJob: 7, principal: scorePrincipal }), + validSite({ id: 'case-3', seed: '103', lastVisitedJob: 8, principal: scorePrincipal }), + validSite({ id: 'case-4', seed: '104', lastVisitedJob: 9, principal: scorePrincipal }), ], }); const decker = campaign.crew.find(m => m.archetype === 'Decker'); @@ -1480,6 +1543,8 @@ test('a Decker flatline during the Score ends the campaign explicitly', () => { }), validSite({ id: 'case-1', seed: '101', lastVisitedJob: 6, principal: scorePrincipal }), validSite({ id: 'case-2', seed: '102', lastVisitedJob: 7, principal: scorePrincipal }), + validSite({ id: 'case-3', seed: '103', lastVisitedJob: 8, principal: scorePrincipal }), + validSite({ id: 'case-4', seed: '104', lastVisitedJob: 9, principal: scorePrincipal }), ], }); const decker = campaign.crew.find(member => member.archetype === 'Decker'); diff --git a/tests/unit/game/Crew.test.ts b/tests/unit/game/Crew.test.ts index b4f1217..785000b 100644 --- a/tests/unit/game/Crew.test.ts +++ b/tests/unit/game/Crew.test.ts @@ -425,6 +425,47 @@ test('Crew.rangedDamage defaults to RANGED_DAMAGE; Merc overrides', () => { assert.equal(t.rangedDamage, RANGED_DAMAGE); }); +test('Crew.gearAtCap reports every limit-1 gear item saturated after one apply', () => { + const limit1 = [ + ITEM_ID.BALLISTICS_COIL, + ITEM_ID.MONOBLADE, + ITEM_ID.SUBDERMAL_PLATING, + ITEM_ID.REFLEX_BOOSTER, + ITEM_ID.PHASE_SHIELD, + ITEM_ID.REGEN_MESH, + ]; + for (const itemId of limit1) { + const m = new Merc({ id: 'm', x: 0, y: 0 }); + assert.equal(m.gearAtCap(itemId), false, `${itemId} not saturated before purchase`); + m.applyGear(itemId); + assert.equal(m.gearAtCap(itemId), true, `${itemId} saturated after one purchase`); + } +}); + +test('Crew.gearAtCap: unbounded Armour Plating 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'); +}); + +test('Crew.gearAtCap: stacking gear saturates only at its per-archetype cap', () => { + const m = new Merc({ id: 'm', x: 0, y: 0 }); + // Targeting Chip stacks in TARGETING_BONUS steps up to (1 − baseHitChance). + const steps = Math.round(m.maxHitBonus / TARGETING_BONUS); + for (let i = 0; i < steps; i++) { + assert.equal(m.gearAtCap(ITEM_ID.TARGETING_CHIP), false, `not capped at step ${i}`); + m.applyGear(ITEM_ID.TARGETING_CHIP); + } + assert.equal(m.gearAtCap(ITEM_ID.TARGETING_CHIP), true, 'capped once fully stacked'); +}); + +test('Crew.gearAtCap throws on a non-gear id (no silent false)', () => { + const m = new Merc({ id: 'm', x: 0, y: 0 }); + assert.throws(() => m.gearAtCap('stim'), /unknown gear item/i); +}); + test('Crew.rangedAttackDamage is archetype base plus capped Ballistics Coil', () => { const m = new Merc({ id: 'm', x: 0, y: 0 }); assert.equal(m.rangedAttackDamage(), MERC_RANGED_DAMAGE); diff --git a/tests/unit/game/campaignSummary.test.ts b/tests/unit/game/campaignSummary.test.ts index 4be5829..0829af1 100644 --- a/tests/unit/game/campaignSummary.test.ts +++ b/tests/unit/game/campaignSummary.test.ts @@ -61,6 +61,8 @@ test('Score completion summary uses post-settlement jobs, Rep, and Credits', () validSite(), validSite({ id: 'case-1', tier: 'roster', scoreTarget: false, seed: '101' }), validSite({ id: 'case-2', tier: 'roster', scoreTarget: false, seed: '102' }), + validSite({ id: 'case-3', tier: 'roster', scoreTarget: false, seed: '103' }), + validSite({ id: 'case-4', tier: 'roster', scoreTarget: false, seed: '104' }), ], }); const decker = campaign.crew.find(member => member.archetype === 'Decker'); diff --git a/tests/unit/game/crewDisplay.test.ts b/tests/unit/game/crewDisplay.test.ts new file mode 100644 index 0000000..59fd1d3 --- /dev/null +++ b/tests/unit/game/crewDisplay.test.ts @@ -0,0 +1,165 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { gearLines, 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). +// --------------------------------------------------------------------------- + +test('gearLines returns [] for null/empty gear', () => { + assert.deepEqual(gearLines(null), []); + assert.deepEqual( + gearLines({ maxHpBonus: 0, hitBonus: 0, dodgeBonus: 0, rangedDamageBonus: 0 }), + [] + ); +}); + +test('gearLines surfaces the four pre-M6 channels', () => { + const lines = gearLines({ + maxHpBonus: 2, + hitBonus: 0.1, + dodgeBonus: 0.1, + rangedDamageBonus: 1, + }); + assert.ok( + lines.some(l => l.includes('Armor Plating') && l.includes('+2 HP')), + `expected Armor Plating line, got ${JSON.stringify(lines)}` + ); + 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'))); +}); + +test('gearLines surfaces the five P3.M6.2 channels', () => { + const lines = gearLines({ + maxHpBonus: 0, + hitBonus: 0, + dodgeBonus: 0, + rangedDamageBonus: 0, + meleeDamageBonus: 1, + armorBonus: 1, + apBonus: 1, + 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'))); +}); + +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.TARGETING_CHIP, + ITEM_ID.REFLEX_WEAVE, + ITEM_ID.BALLISTICS_COIL, + ITEM_ID.MONOBLADE, + ITEM_ID.SUBDERMAL_PLATING, + ITEM_ID.REFLEX_BOOSTER, + 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`); + } +}); + +// --------------------------------------------------------------------------- +// statDisplays — roster STATS block. Maps each combat stat to its display +// value with gear folded into the value (no inline annotation). Core keys +// always present; per-turn regen keys only when active; bonuses counted once. +// --------------------------------------------------------------------------- + +/** Leading numeric value of a stat display ("3 dmg" → 3, "65%" → 65). */ +const num = (s: string) => parseInt(s, 10); + +test('statDisplays exposes the seven core stat keys, no regen keys by default', () => { + const crew = new Merc({ id: 'merc', x: 0, y: 0 }); + const labels = statDisplays(crew); + for (const key of ['hp', 'ap', 'aim', 'dodge', 'ranged', 'melee', 'armor']) { + assert.ok(key in labels, `missing ${key} in ${JSON.stringify(labels)}`); + } + assert.equal(labels.shield, undefined, 'no shield regen key without gear'); + assert.equal(labels.regen, undefined, 'no hp regen key without gear'); + // Shape: HP is current/max, AIM/DODGE are percentages, damage carries a unit. + assert.equal(labels.hp, `${crew.hp}/${crew.maxHp}`); + assert.match(labels.aim, /^\d+%$/); + assert.match(labels.dodge, /^\d+%$/); + assert.match(labels.ranged, /\bdmg$/); + assert.match(labels.melee, /\bdmg$/); + // Values are folded — a bare operator carries no inline "(+N)" annotations. + for (const [k, v] of Object.entries(labels)) { + assert.ok(!v.includes('(+'), `unexpected annotation on ${k}: ${JSON.stringify(labels)}`); + } +}); + +test('statDisplays folds each gear bonus into its stat value, counted exactly once', () => { + const bare = statDisplays(new Merc({ id: 'bare', x: 0, y: 0 })); + const bareCrew = new Merc({ id: 'ref', x: 0, y: 0 }); + + 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.MONOBLADE); + crew.applyGear(ITEM_ID.REFLEX_BOOSTER); + crew.applyGear(ITEM_ID.SUBDERMAL_PLATING); + const labels = statDisplays(crew); + + // Base-derived stats: the bonus reads higher than the bare value. + assert.ok(num(labels.aim) > num(bare.aim), 'AIM reflects targeting chip'); + assert.ok(num(labels.dodge) > num(bare.dodge), 'DODGE reflects reflex weave'); + assert.ok(num(labels.ranged) > num(bare.ranged), 'RANGED reflects ballistics coil'); + assert.ok(num(labels.melee) > num(bare.melee), 'MELEE reflects monoblade'); + + // Live stats: `maxAp` and `damageReduction` already bake the gear delta in, so + // the display must count it once — not add the tracked bonus a second time. + assert.equal(crew.maxAp, bareCrew.maxAp + 1, 'sanity: reflex booster raised maxAp by 1'); + assert.equal(labels.ap, `${crew.maxAp}`, 'AP folds the booster exactly once'); + assert.equal(crew.damageReduction, 1, 'sanity: subdermal plating raised armor to 1'); + assert.equal(labels.armor, `${crew.damageReduction}`, 'ARMOR folds subdermal plating once'); + + // Still no inline annotations once gear is on. + for (const [k, v] of Object.entries(labels)) { + assert.ok(!v.includes('(+'), `unexpected annotation on ${k}: ${JSON.stringify(labels)}`); + } +}); + +test('statDisplays exposes regen keys only when phase shield / regen mesh equipped', () => { + const crew = new Merc({ id: 'merc', x: 0, y: 0 }); + crew.applyGear(ITEM_ID.PHASE_SHIELD); + crew.applyGear(ITEM_ID.REGEN_MESH); + const labels = statDisplays(crew); + assert.ok(labels.shield?.includes('/turn'), `shield: ${labels.shield}`); + assert.ok(labels.regen?.includes('HP/turn'), `regen: ${labels.regen}`); + // The value owns its sign: a single leading '+', never doubled by the caller. + assert.ok( + labels.shield.startsWith('+') && !labels.shield.startsWith('++'), + `shield sign: ${labels.shield}` + ); + assert.ok( + labels.regen.startsWith('+') && !labels.regen.startsWith('++'), + `regen sign: ${labels.regen}` + ); +}); + +test('statDisplays reflects archetype melee/ranged differences without gear', () => { + // Razor's heavier blade should read on the MELEE value; a fresh recruit-style + // readout still works because Crew getters supply the base values. + const razor = new Razor({ id: 'razor', x: 0, y: 0 }); + const merc = new Merc({ id: 'merc', x: 0, y: 0 }); + assert.notEqual(statDisplays(razor).melee, statDisplays(merc).melee); +}); diff --git a/tests/unit/game/cyber/consumableDualDeploy.test.ts b/tests/unit/game/cyber/consumableDualDeploy.test.ts new file mode 100644 index 0000000..3d5a9f1 --- /dev/null +++ b/tests/unit/game/cyber/consumableDualDeploy.test.ts @@ -0,0 +1,142 @@ +/** + * P3.1 regression — consumable targeting on a dual-deploy. + * + * Consumables must act through whichever operator currently has control: + * the Decker before jack-in, the partner while jacked in (the body freezes at + * the port), and whichever crewmate is flipped-to after jack-out. The shell + * resolves that through `activeActorOf` / `run.activeActor`. The original bug + * routed `useConsumable` through `run.player`, so once a partner was in control + * a STIM healed the full-HP frozen Decker (reporting "healed 0 HP") while the + * wounded partner stayed hurt. + * + * The shell wiring itself (shellRuntime) is DOM-coupled and not unit-testable, + * so this pins the model-level contract it now depends on: `run.activeActor` + * is the operator in control, distinct from `run.player` (the frozen body). + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Run } from '../../../../src/game/Run.js'; +import { JackInPoint } from '../../../../src/game/entities/JackInPoint.js'; +import { buildCrewMember } from '../../../../src/game/archetypes/index.js'; +import { OBJECTIVES } from '../../../../src/game/hub/Curator.js'; +import { ITEM_ID } from '../../../../src/game/items.js'; +import { STIM_HEAL } from '../../../../src/game/constants.js'; +import { activeActorOf } from '../../../../src/shell/activeView.js'; +import { Rng } from '../../../../src/rng.js'; +import { testContractContext } from '../contractTestUtils.js'; +import type { World } from '../../../../src/game/World.js'; +import type { Entity } from '../../../../src/game/Entity.js'; +import type { Crew } from '../../../../src/game/Crew.js'; + +const cyberContract = (overrides = {}) => ({ + seed: 12345, + objective: { + kind: OBJECTIVES.DATA_NODE_SLICE, + title: 'Spike the server farm', + briefing: 'Jack in, slice the data node, then extract.', + params: { requiresCyberspace: true, count: 1 }, + }, + difficulty: 'standard', + threatCount: 1, + label: 'cyber casing job', + context: testContractContext(OBJECTIVES.DATA_NODE_SLICE), + reward: { credits: 0, repDelta: 0 }, + ...overrides, +}); + +const makeDecker = () => + buildCrewMember('decker', { x: 0, y: 0 }, new Rng(100), { id: 'crew-decker' }); +const makeMerc = () => buildCrewMember('merc', { x: 0, y: 0 }, new Rng(101), { id: 'crew-merc' }); + +function dualRun(seed = 12345) { + const run = new Run({ crewMember: makeDecker(), partnerMember: makeMerc(), seed }); + run.enterBriefing(cyberContract({ seed })); + run.enterCombat(); + return run; +} + +function jackInPoint(run: Run): JackInPoint { + const point = [...run.world!.entities.values()].find(e => e instanceof JackInPoint); + assert.ok(point, 'cyber contract placed a jack-in point'); + return point as JackInPoint; +} + +function adjacentFreeTile(world: World, target: Entity) { + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + const x = target.x + dx; + const y = target.y + dy; + if (world.grid.inBounds(x, y) && world.grid.isPassable(x, y) && !world.entityAt(x, y)) { + return { x, y }; + } + } + } + throw new Error(`no free tile adjacent to ${target.id}`); +} + +function jackIn(run: Run) { + const point = jackInPoint(run); + const spot = adjacentFreeTile(run.world!, point); + run.world!.relocateEntity(run.player!, spot.x, spot.y); + run.player!.refreshAp(); + const result = point.interact(run.world!, run.player!); + assert.equal(result.ok, true, `link failed: ${result.message}`); + assert.equal(run.cyberspace?.phase, 'active'); +} + +test('P3.1: before jack-in the active operator is the Decker', () => { + const run = dualRun(); + // No jack-in yet — the Decker is the controllable operator and the + // consumable target, exactly as the user expects on a fresh cyber run. + assert.equal(activeActorOf(run), run.player, 'pre-jack-in target is the Decker'); +}); + +test('P3.1: STIM heals the in-control partner, not the frozen Decker body', () => { + const run = dualRun(); + const body = run.player! as Crew; + jackIn(run); + + const partner = run.partnerMember! as Crew; + // Wound the partner; leave the Decker body at full HP (the original bug's + // tell: the body soaked the heal and reported "healed 0 HP"). + partner.hp = Math.max(1, partner.maxHp - STIM_HEAL); + body.hp = body.maxHp; + partner.refreshAp(); + + // The shell resolves the consumable target through `activeActorOf`. The + // inventory overlay is gated to Meatspace, so control sits with the partner. + const target = activeActorOf(run) as Crew; + assert.equal(target, partner, 'consumable target is the in-control partner'); + assert.notEqual(target, run.player, 'consumable target is NOT the frozen Decker body'); + + target.addConsumable(ITEM_ID.STIM); + const before = partner.hp; + const result = target.useConsumable(ITEM_ID.STIM); + + assert.equal(result.type, 'stim'); + assert.equal((result as { healed: number }).healed, STIM_HEAL, 'STIM healed the partner'); + assert.equal(partner.hp, before + STIM_HEAL); + assert.equal(body.hp, body.maxHp, 'the frozen Decker body was untouched'); +}); + +test('P3.1: after jack-out the consumable target follows the flip between crewmates', () => { + const run = dualRun(); + const body = run.player! as Crew; + jackIn(run); + // No onJackOutRequested hook → the jack-out resolves immediately; control + // returns to the Decker body and both crewmates share the meat grid. + run.jackOut(); + assert.equal(run.cyberspace?.phase, 'resolved'); + + const partner = run.partnerMember! as Crew; + assert.equal(activeActorOf(run), body, 'post-jack-out control returns to the Decker'); + + // The simstim flip toggles control to the partner — the consumable target + // must follow, not stay pinned to `run.player`. + assert.equal(run.canFlip(), true, 'two live meat operators can be flipped between'); + run.flip(); + assert.equal(activeActorOf(run), partner, 'after the flip the partner is the target'); + assert.notEqual(activeActorOf(run), body, 'control moved off the Decker'); +}); diff --git a/tests/unit/game/endFlavor.test.ts b/tests/unit/game/endFlavor.test.ts new file mode 100644 index 0000000..ec8419e --- /dev/null +++ b/tests/unit/game/endFlavor.test.ts @@ -0,0 +1,121 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + WIN_BANNERS, + WIN_REASONS, + WIN_DETAILS, + WIN_REWARD_KICKERS, + PARTIAL_BANNERS, + PARTIAL_REASONS, + PARTIAL_DETAILS, + LOSS_FLAVOR, + pickFlavor, + selectEndFlavor, +} from '../../../src/game/endFlavor.js'; +import type { CampaignSummary } from '../../../src/game/campaignSummary.js'; +import type { CampaignEndReason } from '../../../src/types.js'; + +const PROSE_POOLS = [ + WIN_BANNERS, + WIN_REASONS, + WIN_DETAILS, + WIN_REWARD_KICKERS, + PARTIAL_BANNERS, + PARTIAL_REASONS, + PARTIAL_DETAILS, + ...Object.values(LOSS_FLAVOR).flatMap(pool => [pool.banners, pool.reasons, pool.details]), +]; + +function summary(overrides: Partial = {}): CampaignSummary { + return { + campaignId: 'campaign-1', + completedAt: '2026-06-14T19:30:00.000Z', + result: 'win', + endReason: 'score-complete', + seed: 42, + completedJobs: 10, + rep: 67, + credits: 1_125, + crewRoster: [{ callsign: 'Phreak', archetype: 'Decker', flatlined: false }], + ...overrides, + }; +} + +test('pickFlavor is deterministic for a given seed and salt', () => { + for (const seed of [0, 1, 42, 1024, 0xdeadbeef, -7]) { + assert.equal(pickFlavor(seed, 0, WIN_BANNERS), pickFlavor(seed, 0, WIN_BANNERS)); + } +}); + +test('pickFlavor always returns an in-bounds member of the pool', () => { + for (const pool of PROSE_POOLS) { + for (let seed = -50; seed <= 50; seed += 1) { + assert.ok(pool.includes(pickFlavor(seed, 0, pool)), `seed ${seed} must land in the pool`); + } + } +}); + +test('every pool entry is reachable across the seed space', () => { + // A collapsed hash (always index 0) would fail this — the whole point of the RNG. + for (const pool of PROSE_POOLS) { + const seen = new Set(); + for (let seed = 0; seed < 5000; seed += 1) { + seen.add(pickFlavor(seed, 0, pool)); + } + assert.equal(seen.size, pool.length, 'pool should be fully reachable'); + } +}); + +test('different salts decorrelate slots so a seed is not always the same index', () => { + const pairings = new Set(); + for (let seed = 0; seed < 200; seed += 1) { + const bannerIdx = WIN_BANNERS.indexOf(pickFlavor(seed, 0, WIN_BANNERS)); + const reasonIdx = WIN_REASONS.indexOf(pickFlavor(seed, 1, WIN_REASONS)); + pairings.add(`${bannerIdx}:${reasonIdx}`); + } + assert.ok(pairings.size > Math.max(WIN_BANNERS.length, WIN_REASONS.length)); +}); + +test('pickFlavor rejects an empty pool rather than returning undefined', () => { + assert.throws(() => pickFlavor(1, 0, []), /non-empty/); +}); + +test('selectEndFlavor draws win copy from the win pools, with a loot kicker', () => { + const flavor = selectEndFlavor(summary({ result: 'win', endReason: 'score-complete' })); + assert.ok(WIN_BANNERS.includes(flavor.banner as (typeof WIN_BANNERS)[number])); + assert.ok(WIN_REASONS.includes(flavor.reason as (typeof WIN_REASONS)[number])); + assert.ok(WIN_DETAILS.includes(flavor.detail as (typeof WIN_DETAILS)[number])); + assert.ok( + WIN_REWARD_KICKERS.includes(flavor.rewardKicker as (typeof WIN_REWARD_KICKERS)[number]) + ); +}); + +test('selectEndFlavor draws partial copy from the partial pools, no loot kicker', () => { + const flavor = selectEndFlavor(summary({ result: 'partial', endReason: 'score-partial' })); + assert.ok(PARTIAL_BANNERS.includes(flavor.banner as (typeof PARTIAL_BANNERS)[number])); + assert.ok(PARTIAL_REASONS.includes(flavor.reason as (typeof PARTIAL_REASONS)[number])); + assert.ok(PARTIAL_DETAILS.includes(flavor.detail as (typeof PARTIAL_DETAILS)[number])); + assert.equal(flavor.rewardKicker, undefined); +}); + +test('selectEndFlavor keeps losses cause-aware so banners never cross pools', () => { + // A clock-expired loss must not borrow the Decker-death banner, and vice versa. + const lossReasons: CampaignEndReason[] = ['clock-expired', 'decker-flatlined-score', 'crew-wipe']; + for (const endReason of lossReasons) { + const pool = LOSS_FLAVOR[endReason]; + // Sweep seeds so we exercise every index, not just the default one. + for (let seed = 0; seed < 300; seed += 1) { + const flavor = selectEndFlavor(summary({ result: 'loss', endReason, seed })); + assert.ok(pool.banners.includes(flavor.banner), `${endReason} banner stayed in its pool`); + assert.ok(pool.reasons.includes(flavor.reason), `${endReason} reason stayed in its pool`); + assert.ok(pool.details.includes(flavor.detail), `${endReason} detail stayed in its pool`); + assert.equal(flavor.rewardKicker, undefined); + } + } +}); + +test('selectEndFlavor is deterministic for a given summary', () => { + const s = summary({ result: 'loss', endReason: 'crew-wipe', seed: 9001 }); + assert.deepEqual(selectEndFlavor(s), selectEndFlavor(s)); +}); diff --git a/tests/unit/game/hub/arcSurface.test.ts b/tests/unit/game/hub/arcSurface.test.ts index cd4f416..56c2d53 100644 --- a/tests/unit/game/hub/arcSurface.test.ts +++ b/tests/unit/game/hub/arcSurface.test.ts @@ -72,10 +72,13 @@ test('formatHubArcStatusLines omits clock until the Curator briefing is dismisse scoreDeadlineJobsRemaining: 4, }; assert.deepEqual(formatHubArcStatusLines(campaign), [ - 'STAGE 2: CASING | SCORE: Matsuda server farm', + 'STAGE 2: CASING | SCORE: Matsuda server farm | CASED 0/4', null, ]); - assert.equal(formatHubArcStatus(campaign), 'STAGE 2: CASING | SCORE: Matsuda server farm'); + assert.equal( + formatHubArcStatus(campaign), + 'STAGE 2: CASING | SCORE: Matsuda server farm | CASED 0/4' + ); }); test('formatHubArcStatusLines shows active heat only after clock briefing', () => { @@ -89,11 +92,37 @@ test('formatHubArcStatusLines shows active heat only after clock briefing', () = scoreDeadlineJobsRemaining: 3, }; assert.deepEqual(formatHubArcStatusLines(campaign), [ - 'STAGE 2: CASING | SCORE: Matsuda server farm', + 'STAGE 2: CASING | SCORE: Matsuda server farm | CASED 0/4', 'CLOCK: HEAT 2', ]); }); +test('formatHubArcStatusLines surfaces casing progress, counting visited org sites but not the target', () => { + const orgSite = (id: string, visited: number): LocationSite => + scoreSite({ id, tier: 'roster', scoreTarget: false, lastVisitedJob: visited }); + const campaign = { + // Two cased org sites + an unvisited org site + the target (never counts). + arc: arc({ arcStage: 'act-2', scoreRevealed: true }), + siteRoster: [scoreSite(), orgSite('case-1', 6), orgSite('case-2', 7), orgSite('case-3', 0)], + crew: [], + hubReveals: {}, + }; + assert.equal( + formatHubArcStatus(campaign), + 'STAGE 2: CASING | SCORE: Matsuda server farm | CASED 2/4' + ); +}); + +test('formatHubArcStatusLines omits the casing indicator outside Stage 2', () => { + const campaign = { + arc: arc({ arcStage: 'act-3', scoreRevealed: true }), + siteRoster: [scoreSite(), scoreSite({ id: 'case-1', tier: 'roster', scoreTarget: false })], + crew: [], + hubReveals: {}, + }; + assert.equal(formatHubArcStatus(campaign), 'STAGE 3: FINAL PREP | SCORE: Matsuda server farm'); +}); + test('formatHubArcStatus throws when revealed state has no Score target', () => { assert.throws( () => @@ -293,6 +322,39 @@ test('contractLocationBadges returns no badges for a fresh non-principal job', ( assert.deepEqual(contractLocationBadges(contract, 'score-site', 'other-principal'), []); }); +test('contractLocationBadges surfaces a keycard-held badge when the owning principal is in the held set', () => { + const contract = badgeFixture('case-site'); // revisit, non-score-principal + const held = new Set([contract.context.principal.id]); + assert.deepEqual(contractLocationBadges(contract, 'score-site', 'other-principal', held), [ + { variant: 'revisit', text: '// known site' }, + { variant: 'keycard', text: '// keycard held' }, + ]); +}); + +test('contractLocationBadges keycard badge matches a fresh contract by owning principal', () => { + const contract = badgeFixture(); // no locationSiteId → fresh site, same owner + const held = new Set([contract.context.principal.id]); + assert.deepEqual(contractLocationBadges(contract, 'score-site', 'other-principal', held), [ + { variant: 'keycard', text: '// keycard held' }, + ]); +}); + +test('contractLocationBadges omits the keycard badge when no card is held for the owner', () => { + const contract = badgeFixture('case-site'); + const held = new Set(['some-other-principal']); + assert.deepEqual(contractLocationBadges(contract, 'score-site', 'other-principal', held), [ + { variant: 'revisit', text: '// known site' }, + ]); +}); + +test('contractLocationBadges never shows the keycard badge on the Score target', () => { + const contract = badgeFixture('score-site'); + const held = new Set([contract.context.principal.id]); + assert.deepEqual(contractLocationBadges(contract, 'score-site', 'matsuda', held), [ + { variant: 'score-site', text: 'SCORE SITE' }, + ]); +}); + test('findDecker throws when crew has no Decker', () => { assert.throws( () => findDecker([new Merc({ id: 'merc', x: 0, y: 0, callsign: 'Wraith' })]), diff --git a/tests/unit/game/hub/hubReveals.test.ts b/tests/unit/game/hub/hubReveals.test.ts index 13c56aa..c92277a 100644 --- a/tests/unit/game/hub/hubReveals.test.ts +++ b/tests/unit/game/hub/hubReveals.test.ts @@ -243,6 +243,32 @@ test('Act 3 reveal presents THE SCORE when final prep unlocks', () => { lastVisitedJob: 7, principal: scorePrincipal, }, + { + id: 'case-3', + seed: '103', + mapWidth: 24, + mapHeight: 16, + label: '// Matsuda case site 3', + tier: 'roster', + scoreTarget: false, + mutationDeltas: [], + seenKeys: [], + lastVisitedJob: 8, + principal: scorePrincipal, + }, + { + id: 'case-4', + seed: '104', + mapWidth: 24, + mapHeight: 16, + label: '// Matsuda case site 4', + tier: 'roster', + scoreTarget: false, + mutationDeltas: [], + seenKeys: [], + lastVisitedJob: 9, + principal: scorePrincipal, + }, ], }); assert.equal(campaign.arc.arcStage, 'act-3'); diff --git a/tests/unit/game/keycard.test.ts b/tests/unit/game/keycard.test.ts index 2121f68..c26196f 100644 --- a/tests/unit/game/keycard.test.ts +++ b/tests/unit/game/keycard.test.ts @@ -1,6 +1,8 @@ /** - * M6.2: KeyCard entity, Campaign.keyItems, decoupled terminal placement, - * 50/50 unlock method roll, Door keycard interaction, and persistence. + * M6.2 + P3.1-balance: KeyCard entity, Campaign.keyItems, decoupled terminal + * placement, 50/50 unlock method roll, Door keycard interaction, persistence, + * and principal-scoping (a keycard opens its owner's door at every site they + * control). */ import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -8,12 +10,17 @@ import assert from 'node:assert/strict'; import { Grid } from '../../../src/game/Grid.js'; import { World } from '../../../src/game/World.js'; import { Entity } from '../../../src/game/Entity.js'; -import { Campaign } from '../../../src/game/Campaign.js'; -import { Run } from '../../../src/game/Run.js'; +import { Campaign, SITE_ROSTER_CAP } from '../../../src/game/Campaign.js'; +import { OUTCOME, Run } from '../../../src/game/Run.js'; +import { emptySalvage } from '../../../src/game/salvage.js'; import { Door } from '../../../src/game/entities/Door.js'; import { Terminal } from '../../../src/game/entities/Terminal.js'; import { Pickup } from '../../../src/game/entities/Pickup.js'; -import { KeyCard } from '../../../src/game/entities/KeyCard.js'; +import { + KeyCard, + keycardIdFor, + migrateLegacyKeycardId, +} from '../../../src/game/entities/KeyCard.js'; import { findPath } from '../../../src/game/Pathfinding.js'; import { snapshot, @@ -30,11 +37,11 @@ import { KEYCARD_GLYPH, AP_COST, } from '../../../src/game/constants.js'; -import { OBJECTIVES } from '../../../src/game/hub/Curator.js'; +import { OBJECTIVES, type ContractContext } from '../../../src/game/hub/Curator.js'; import { Rng } from '../../../src/rng.js'; import { TurnQueue } from '../../../src/game/TurnQueue.js'; import { testContractContext } from './contractTestUtils.js'; -import type { KeyItem } from '../../../src/types.js'; +import type { KeyItem, LocationSite, LocationToken } from '../../../src/types.js'; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -73,6 +80,8 @@ function fakeContract(overrides = {}) { }; } +// Default keycard-door contract — its context principal is `matsuda` (the +// testContractContext default). function keycardDoorContract(seed: number, overrides = {}) { return fakeContract({ seed, @@ -87,6 +96,25 @@ function keycardDoorContract(seed: number, overrides = {}) { }); } +// A keycard-door contract owned by an explicit principal — lets a test pit two +// different owners (or two sites of the same owner) against each other. +function keycardContractForPrincipal(seed: number, principalId: string) { + const context: ContractContext = testContractContext(OBJECTIVES.RETRIEVE, { + principal: { id: principalId, label: principalId, groups: ['corp'] }, + }); + return fakeContract({ + seed, + objective: { + kind: OBJECTIVES.RETRIEVE, + title: 'Secure locked cache', + briefing: 'Find the keycard and retrieve the cache.', + params: { target: 'locked-cache', doorId: 'door-0', unlockMethod: 'keycard' }, + }, + label: 'keycard test job', + context, + }); +} + function terminalDoorContract(seed: number) { return fakeContract({ seed, @@ -109,6 +137,24 @@ function makeCampaign(overrides = {}) { }); } +const TOKEN = (id: string, label = id, groups = ['corp']): LocationToken => ({ id, label, groups }); + +function makeSite(id: string, lastVisitedJob: number, principal?: LocationToken): LocationSite { + return { + id, + seed: id, + mapWidth: 24, + mapHeight: 16, + label: `// site ${id}`, + tier: 'roster', + scoreTarget: false, + mutationDeltas: [], + seenKeys: [], + lastVisitedJob, + ...(principal ? { principal } : {}), + }; +} + // ─── KeyCard entity ────────────────────────────────────────────────────────── test('KeyCard: constructs as a passable neutral entity with the keycard glyph', () => { @@ -120,24 +166,24 @@ test('KeyCard: constructs as a passable neutral entity with the keycard glyph', assert.equal(kc.label, 'Access keycard'); assert.equal(kc.maxAp, 0); assert.equal(kc.maxHp, 1); - assert.equal(kc.siteId, null, 'siteId defaults to null (run-scoped)'); + assert.equal(kc.principalId, null, 'principalId defaults to null (run-scoped)'); }); -test('KeyCard: siteId marks a campaign-scoped keycard', () => { +test('KeyCard: principalId marks a campaign-scoped keycard', () => { const kc = new KeyCard({ id: 'kc-1', x: 3, y: 1, doorId: 'door-0', - label: 'Site card', - siteId: 'site-42', + label: 'Owner card', + principalId: 'matsuda', }); - assert.equal(kc.siteId, 'site-42'); + assert.equal(kc.principalId, 'matsuda'); }); -test('KeyCard: throws on empty siteId', () => { +test('KeyCard: throws on empty principalId', () => { assert.throws( - () => new KeyCard({ id: 'kc-1', x: 3, y: 1, doorId: 'door-0', label: 'test', siteId: '' }), + () => new KeyCard({ id: 'kc-1', x: 3, y: 1, doorId: 'door-0', label: 'test', principalId: '' }), /non-empty string/ ); }); @@ -213,20 +259,10 @@ test('Campaign.addKeyItem: throws on missing fields', () => { assert.throws(() => c.addKeyItem({ id: 'kc-1', label: 'test', doorId: '' }), /non-empty/); }); -test('Campaign.keyItemForDoor: returns matching key item or null', () => { - const c = makeCampaign(); - assert.equal(c.keyItemForDoor('door-0'), null); - c.addKeyItem({ id: 'kc-1', label: 'Card A', doorId: 'door-0' }); - const found = c.keyItemForDoor('door-0'); - assert.ok(found); - assert.equal(found!.id, 'kc-1'); - assert.equal(c.keyItemForDoor('door-1'), null); -}); - -test('Campaign.keyItems: preserves optional siteId', () => { +test('Campaign.keyItems: preserves optional principalId', () => { const c = makeCampaign(); - c.addKeyItem({ id: 'kc-1', label: 'Card A', doorId: 'door-0', siteId: 'site-42' }); - assert.equal(c.keyItems[0]!.siteId, 'site-42'); + c.addKeyItem({ id: 'kc-1', label: 'Card A', doorId: 'door-0', principalId: 'matsuda' }); + assert.equal(c.keyItems[0]!.principalId, 'matsuda'); }); // ─── Campaign.keyItems persistence ─────────────────────────────────────────── @@ -234,18 +270,18 @@ test('Campaign.keyItems: preserves optional siteId', () => { test('Campaign.keyItems snapshot round-trip', () => { const c = makeCampaign(); c.addKeyItem({ id: 'kc-1', label: 'Card A', doorId: 'door-0' }); - c.addKeyItem({ id: 'kc-2', label: 'Card B', doorId: 'door-1', siteId: 'site-1' }); + c.addKeyItem({ id: 'kc-2', label: 'Card B', doorId: 'door-1', principalId: 'vuong-holdings' }); const snap = snapshotCampaign(c); assert.ok(snap.keyItems); assert.equal(snap.keyItems!.length, 2); assert.equal(snap.keyItems![0]!.doorId, 'door-0'); - assert.equal(snap.keyItems![1]!.siteId, 'site-1'); + assert.equal(snap.keyItems![1]!.principalId, 'vuong-holdings'); const restored = restoreCampaign(snap); assert.equal(restored.keyItems.length, 2); assert.equal(restored.keyItems[0]!.id, 'kc-1'); assert.equal(restored.keyItems[0]!.doorId, 'door-0'); - assert.equal(restored.keyItems[1]!.siteId, 'site-1'); + assert.equal(restored.keyItems[1]!.principalId, 'vuong-holdings'); }); test('Campaign.keyItems: pre-M6.2 saves default to empty array', () => { @@ -319,7 +355,7 @@ test('Run.keyItems: pre-M6.2 saves default to empty array', () => { // ─── onKeycardCollected scope routing ─────────────────────────────────────── -test('onKeycardCollected: run-scoped keycard (no siteId) passes siteId: null', () => { +test('onKeycardCollected: run-scoped keycard (no principalId) passes principalId: null', () => { const world = corridorWorld(); const crew = makeCrew(); crew.x = 2; @@ -330,7 +366,8 @@ test('onKeycardCollected: run-scoped keycard (no siteId) passes siteId: null', ( world.addEntity(kc); const queue = new TurnQueue([FACTION.PLAYER, FACTION.CORP]); - let collected: { id: string; doorId: string; label: string; siteId: string | null } | null = null; + let collected: { id: string; doorId: string; label: string; principalId: string | null } | null = + null; applyIntent( { type: 'move', dx: 1, dy: 0 }, { @@ -349,10 +386,10 @@ test('onKeycardCollected: run-scoped keycard (no siteId) passes siteId: null', ( ); assert.ok(collected); - assert.equal(collected!.siteId, null, 'run-scoped keycard should have siteId: null'); + assert.equal(collected!.principalId, null, 'run-scoped keycard should have principalId: null'); }); -test('onKeycardCollected: campaign-scoped keycard (with siteId) passes siteId through', () => { +test('onKeycardCollected: campaign-scoped keycard (with principalId) passes principalId through', () => { const world = corridorWorld(); const crew = makeCrew(); crew.x = 2; @@ -364,13 +401,14 @@ test('onKeycardCollected: campaign-scoped keycard (with siteId) passes siteId th x: 3, y: 1, doorId: 'door-0', - label: 'Site card', - siteId: 'site-42', + label: 'Owner card', + principalId: 'matsuda', }); world.addEntity(kc); const queue = new TurnQueue([FACTION.PLAYER, FACTION.CORP]); - let collected: { id: string; doorId: string; label: string; siteId: string | null } | null = null; + let collected: { id: string; doorId: string; label: string; principalId: string | null } | null = + null; applyIntent( { type: 'move', dx: 1, dy: 0 }, { @@ -389,10 +427,14 @@ test('onKeycardCollected: campaign-scoped keycard (with siteId) passes siteId th ); assert.ok(collected); - assert.equal(collected!.siteId, 'site-42', 'campaign-scoped keycard should pass siteId'); + assert.equal( + collected!.principalId, + 'matsuda', + 'campaign-scoped keycard should pass principalId' + ); }); -// ─── Door + keycard interaction ────────────────────────────────────────────── +// ─── Door + keycard interaction (doorId-based, scope enforced upstream) ─────── test('Door.interact with matching keycard unlocks the door', () => { const world = corridorWorld(); @@ -534,7 +576,7 @@ test('collectTileLoot: duplicate campaign keycard pickup does not throw', () => y: 1, doorId: 'door-0', label: 'Access keycard', - siteId: 'site-42', + principalId: 'matsuda', }); world.addEntity(kc); const queue = new TurnQueue([FACTION.PLAYER, FACTION.CORP]); @@ -543,7 +585,7 @@ test('collectTileLoot: duplicate campaign keycard pickup does not throw', () => id: 'keycard-door-0', label: 'Access keycard', doorId: 'door-0', - siteId: 'site-42', + principalId: 'matsuda', }); applyIntent( @@ -558,14 +600,14 @@ test('collectTileLoot: duplicate campaign keycard pickup does not throw', () => resetInputModes: () => {}, onPlayerAction: () => {}, onKeycardCollected: collected => { - if (collected.siteId && campaign.keyItems.some(k => k.id === collected.id)) { + if (collected.principalId && campaign.keyItems.some(k => k.id === collected.id)) { return; } campaign.addKeyItem({ id: collected.id, label: collected.label, doorId: collected.doorId, - siteId: collected.siteId!, + principalId: collected.principalId!, }); }, } @@ -577,22 +619,24 @@ test('collectTileLoot: duplicate campaign keycard pickup does not throw', () => // ─── KeyCard entity snapshot round-trip ────────────────────────────────────── +function firstOpenTile(run: Run): { x: number; y: number } { + for (let y = 1; y < run.world!.grid.height - 1; y++) { + for (let x = 1; x < run.world!.grid.width - 1; x++) { + if (!run.world!.grid.isPassable(x, y)) continue; + if (run.world!.liveEntityAt(x, y)) continue; + return { x, y }; + } + } + throw new Error('no open tile'); +} + test('KeyCard snapshot round-trips through Run snapshot/restore', () => { const crew = makeCrew(); const run = new Run({ crewMember: crew, seed: 42 }); run.enterBriefing(fakeContract()); run.enterCombat(); - // Find an unoccupied passable tile for the keycard. - let anchor = null; - for (let y = 1; y < run.world!.grid.height - 1 && !anchor; y++) { - for (let x = 1; x < run.world!.grid.width - 1 && !anchor; x++) { - if (!run.world!.grid.isPassable(x, y)) continue; - if (run.world!.liveEntityAt(x, y)) continue; - anchor = { x, y }; - } - } - assert.ok(anchor); + const anchor = firstOpenTile(run); run.world!.addEntity( new KeyCard({ id: 'keycard-test', @@ -618,61 +662,45 @@ test('KeyCard snapshot round-trips through Run snapshot/restore', () => { assert.equal(restoredKc.passable, true); }); -test('KeyCard entity snapshot preserves siteId through round-trip', () => { +test('KeyCard entity snapshot preserves principalId through round-trip', () => { const crew = makeCrew(); const run = new Run({ crewMember: crew, seed: 42 }); run.enterBriefing(fakeContract()); run.enterCombat(); - let anchor = null; - for (let y = 1; y < run.world!.grid.height - 1 && !anchor; y++) { - for (let x = 1; x < run.world!.grid.width - 1 && !anchor; x++) { - if (!run.world!.grid.isPassable(x, y)) continue; - if (run.world!.liveEntityAt(x, y)) continue; - anchor = { x, y }; - } - } - assert.ok(anchor); + const anchor = firstOpenTile(run); run.world!.addEntity( new KeyCard({ - id: 'keycard-site', + id: 'keycard-owner', x: anchor.x, y: anchor.y, doorId: 'door-0', - label: 'Site card', - siteId: 'site-42', + label: 'Owner card', + principalId: 'matsuda', }) ); const rec = snapshot(run); const kcRec = rec.entities.find(e => e.archetype === 'keycard'); assert.ok(kcRec); - assert.equal(kcRec!.extra?.siteId, 'site-42'); + assert.equal(kcRec!.extra?.principalId, 'matsuda'); const { world } = restore(rec); const restoredKc = [...world.entities.values()].find(e => e instanceof KeyCard) as KeyCard; assert.ok(restoredKc); - assert.equal(restoredKc.siteId, 'site-42'); + assert.equal(restoredKc.principalId, 'matsuda'); }); -test('KeyCard entity snapshot stores siteId as null when unset', () => { +test('KeyCard entity snapshot stores principalId as null when unset', () => { const crew = makeCrew(); const run = new Run({ crewMember: crew, seed: 42 }); run.enterBriefing(fakeContract()); run.enterCombat(); - let anchor = null; - for (let y = 1; y < run.world!.grid.height - 1 && !anchor; y++) { - for (let x = 1; x < run.world!.grid.width - 1 && !anchor; x++) { - if (!run.world!.grid.isPassable(x, y)) continue; - if (run.world!.liveEntityAt(x, y)) continue; - anchor = { x, y }; - } - } - assert.ok(anchor); + const anchor = firstOpenTile(run); run.world!.addEntity( new KeyCard({ - id: 'keycard-nositeId', + id: 'keycard-noprincipal', x: anchor.x, y: anchor.y, doorId: 'door-0', @@ -684,9 +712,9 @@ test('KeyCard entity snapshot stores siteId as null when unset', () => { const kcRec = rec.entities.find(e => e.archetype === 'keycard'); assert.ok(kcRec); assert.equal( - kcRec!.extra?.siteId, + kcRec!.extra?.principalId, null, - 'siteId is bag-hygienic null (not undefined) when unset' + 'principalId is bag-hygienic null (not undefined) when unset' ); }); @@ -796,7 +824,6 @@ test('decoupled terminal placement is reachable from spawn (not through locked d e => e instanceof Terminal && (e as Terminal).unlocksId === 'door-0' ); assert.ok(terminal instanceof Terminal); - // Terminal must be reachable from spawn without unlocking the door. const path = findPath( run.world!, { x: run.player!.x, y: run.player!.y }, @@ -977,3 +1004,351 @@ test('bump into locked door without keycard keeps door locked', () => { assert.equal(door.locked, true, 'door should remain locked'); }); + +// ─── Keycard principal stamping (P3.1-balance) ─────────────────────────────── + +test('spawned keycard is stamped with the contract-owning principal id', () => { + let run = null; + let contract = null; + for (let seed = 1; seed < 80 && !run; seed++) { + const candidate = new Run({ crewMember: makeCrew(), seed }); + const c = keycardDoorContract(seed); + candidate.enterBriefing(c); + try { + candidate.enterCombat(); + run = candidate; + contract = c; + } catch { + continue; + } + } + assert.ok(run, 'need a keycard-door layout'); + const keycard = [...run!.world!.entities.values()].find(e => e instanceof KeyCard) as KeyCard; + assert.ok(keycard); + assert.equal(keycard.principalId, contract!.context.principal.id); + assert.equal(keycard.principalId, 'matsuda'); +}); + +// ─── Run-scoped keycards carry principalId ─────────────────────────────────── + +test('Run.addKeyItem preserves principalId', () => { + const run = new Run({ crewMember: makeCrew(), seed: 99 }); + run.addKeyItem({ id: 'kc-1', label: 'Owner card', doorId: 'door-0', principalId: 'matsuda' }); + assert.equal(run.keyItems[0]!.principalId, 'matsuda'); +}); + +test('Run.keyItems snapshot round-trip preserves principalId', () => { + const run = new Run({ crewMember: makeCrew(), seed: 42 }); + run.enterBriefing(fakeContract()); + run.enterCombat(); + run.addKeyItem({ id: 'kc-owner', label: 'Owner card', doorId: 'door-0', principalId: 'yutani' }); + const rec = snapshot(run); + assert.equal(rec.keyItems![0]!.principalId, 'yutani'); + const { run: restored } = restore(rec); + assert.equal(restored.keyItems[0]!.principalId, 'yutani'); +}); + +// ─── Run-end promotion into campaign inventory ─────────────────────────────── + +function deployWithRun(campaign: Campaign, contract = fakeContract()) { + const member = campaign.crew[1]!; + const run = campaign.deployCrewMember(member.id, contract); + run.enterCombat(); + return run; +} + +test('onJobEnd promotes a principal-stamped run keycard into the campaign inventory on live extraction', () => { + const campaign = makeCampaign(); + const run = deployWithRun(campaign); + run.addKeyItem({ + id: 'kc-promote', + label: 'Owner card', + doorId: 'door-0', + principalId: 'matsuda', + }); + campaign.onJobEnd({ outcome: OUTCOME.EXIT, salvage: emptySalvage() }); + assert.equal(campaign.keyItems.length, 1); + assert.equal(campaign.keyItems[0]!.id, 'kc-promote'); + assert.equal(campaign.keyItems[0]!.principalId, 'matsuda'); +}); + +test('onJobEnd promotes carried keycards even on an aborted (incomplete) extraction', () => { + const campaign = makeCampaign(); + const run = deployWithRun(campaign); + run.addKeyItem({ id: 'kc-abort', label: 'Owner card', doorId: 'door-0', principalId: 'matsuda' }); + campaign.onJobEnd({ outcome: OUTCOME.EXIT, salvage: emptySalvage(), completed: false }); + assert.equal(campaign.keyItems.length, 1); + assert.equal(campaign.keyItems[0]!.id, 'kc-abort'); +}); + +test('onJobEnd does NOT promote keycards when the operator flatlines', () => { + const campaign = makeCampaign(); + const run = deployWithRun(campaign); + run.addKeyItem({ id: 'kc-dead', label: 'Owner card', doorId: 'door-0', principalId: 'matsuda' }); + campaign.onJobEnd({ outcome: OUTCOME.DEATH }); + assert.deepEqual(campaign.keyItems, []); +}); + +test('onJobEnd does not promote run-scoped keycards lacking a principalId', () => { + const campaign = makeCampaign(); + const run = deployWithRun(campaign); + run.addKeyItem({ id: 'kc-runonly', label: 'Run card', doorId: 'door-0' }); + campaign.onJobEnd({ outcome: OUTCOME.EXIT, salvage: emptySalvage() }); + assert.deepEqual(campaign.keyItems, []); +}); + +test('onJobEnd promotion is idempotent against an already-held campaign keycard', () => { + const campaign = makeCampaign(); + campaign.addKeyItem({ + id: 'kc-dup', + label: 'Owner card', + doorId: 'door-0', + principalId: 'matsuda', + }); + const run = deployWithRun(campaign); + run.addKeyItem({ id: 'kc-dup', label: 'Owner card', doorId: 'door-0', principalId: 'matsuda' }); + campaign.onJobEnd({ outcome: OUTCOME.EXIT, salvage: emptySalvage() }); + assert.equal(campaign.keyItems.length, 1, 'no duplicate, no crash'); +}); + +// ─── P3.1-balance: principal-unique keycard ids ────────────────────────────── + +test('keycardIdFor: encodes the principal id so distinct owners get distinct ids', () => { + assert.equal(keycardIdFor('door-0', 'matsuda'), 'keycard-door-0-matsuda'); + assert.equal(keycardIdFor('door-0', 'vuong-holdings'), 'keycard-door-0-vuong-holdings'); + assert.notEqual(keycardIdFor('door-0', 'matsuda'), keycardIdFor('door-0', 'vuong-holdings')); +}); + +test('keycardIdFor: run-scoped card (no principal id) keeps the bare id', () => { + assert.equal(keycardIdFor('door-0'), 'keycard-door-0'); + assert.equal(keycardIdFor('door-0', null), 'keycard-door-0'); +}); + +test('spawned keycard id is principal-unique (not the bare keycard-door-0)', () => { + let run = null; + let contract = null; + for (let seed = 1; seed < 80 && !run; seed++) { + const candidate = new Run({ crewMember: makeCrew(), seed }); + const c = keycardDoorContract(seed); + candidate.enterBriefing(c); + try { + candidate.enterCombat(); + run = candidate; + contract = c; + } catch { + continue; + } + } + assert.ok(run, 'need a keycard-door layout'); + const keycard = [...run!.world!.entities.values()].find(e => e instanceof KeyCard) as KeyCard; + assert.ok(keycard); + assert.equal(keycard.id, keycardIdFor('door-0', contract!.context.principal.id)); + assert.notEqual(keycard.id, 'keycard-door-0', 'legacy bare id would collide across owners'); +}); + +// ─── Legacy id migration on restore ────────────────────────────────────────── + +test('migrateLegacyKeycardId: re-stamps a legacy bare id with its principal id', () => { + const migrated = migrateLegacyKeycardId({ + id: 'keycard-door-0', + label: 'Access keycard', + doorId: 'door-0', + principalId: 'matsuda', + }); + assert.equal(migrated.id, 'keycard-door-0-matsuda'); +}); + +test('migrateLegacyKeycardId: idempotent on an already-migrated id', () => { + const item = { + id: 'keycard-door-0-matsuda', + label: 'Access keycard', + doorId: 'door-0', + principalId: 'matsuda', + }; + assert.equal(migrateLegacyKeycardId(item).id, 'keycard-door-0-matsuda'); +}); + +test('migrateLegacyKeycardId: leaves run-scoped (no principalId) and custom ids untouched', () => { + assert.equal( + migrateLegacyKeycardId({ id: 'keycard-door-0', label: 'x', doorId: 'door-0' }).id, + 'keycard-door-0', + 'no principalId → nothing to disambiguate' + ); + assert.equal( + migrateLegacyKeycardId({ id: 'kc-1', label: 'x', doorId: 'door-0', principalId: 'p' }).id, + 'kc-1', + 'non-legacy custom id is preserved' + ); +}); + +// ─── P3.1-balance: legacy siteId → principalId backfill on restore ──────────── + +test('Campaign restore backfills a legacy siteId keycard to its owning principal', () => { + const c = makeCampaign(); + const snap = snapshotCampaign(c); + (snap as Record).siteRoster = [makeSite('4242', 3, TOKEN('matsuda', 'Matsuda'))]; + (snap as Record).keyItems = [ + { id: 'keycard-door-0-4242', label: 'Access keycard', doorId: 'door-0', siteId: '4242' }, + ]; + const restored = restoreCampaign(snap); + assert.equal(restored.keyItems.length, 1); + assert.equal(restored.keyItems[0]!.principalId, 'matsuda', 'backfilled from the roster site'); + assert.equal( + restored.keyItems[0]!.id, + 'keycard-door-0-matsuda', + 're-keyed to the canonical principal id' + ); + assert.equal((restored.keyItems[0] as { siteId?: string }).siteId, undefined, 'siteId dropped'); +}); + +test('Campaign restore collapses two same-owner legacy siteId cards into one', () => { + const c = makeCampaign(); + const snap = snapshotCampaign(c); + (snap as Record).siteRoster = [ + makeSite('siteA', 1, TOKEN('matsuda', 'Matsuda')), + makeSite('siteB', 2, TOKEN('matsuda', 'Matsuda')), + ]; + (snap as Record).keyItems = [ + { id: 'keycard-door-0-siteA', label: 'Access keycard', doorId: 'door-0', siteId: 'siteA' }, + { id: 'keycard-door-0-siteB', label: 'Access keycard', doorId: 'door-0', siteId: 'siteB' }, + ]; + const restored = restoreCampaign(snap); + assert.equal(restored.keyItems.length, 1, 'two matsuda site cards collapse to one owner card'); + assert.equal(restored.keyItems[0]!.principalId, 'matsuda'); +}); + +test('Campaign restore drops a legacy siteId keycard whose site is not on the roster', () => { + const c = makeCampaign(); + const snap = snapshotCampaign(c); + (snap as Record).siteRoster = []; // owning site already evicted + (snap as Record).keyItems = [ + { id: 'keycard-door-0-ghost', label: 'Access keycard', doorId: 'door-0', siteId: 'ghost' }, + ]; + const restored = restoreCampaign(snap); + assert.deepEqual(restored.keyItems, [], 'unresolvable legacy card dropped'); +}); + +test('Campaign restore preserves an unscoped campaign card (no principalId, no siteId)', () => { + const c = makeCampaign(); + const snap = snapshotCampaign(c); + (snap as Record).keyItems = [ + { id: 'kc-scopeless', label: 'Access keycard', doorId: 'door-0' }, + ]; + const restored = restoreCampaign(snap); + assert.equal(restored.keyItems.length, 1, 'inert scopeless card preserved, not dropped'); + assert.equal(restored.keyItems[0]!.id, 'kc-scopeless'); +}); + +test('Run restore keeps a legacy siteId run card run-scoped (drops the stale scope)', () => { + const run = new Run({ crewMember: makeCrew(), seed: 42 }); + run.enterBriefing(fakeContract()); + run.enterCombat(); + const rec = snapshot(run); + (rec as Record).keyItems = [ + { + id: 'keycard-door-0-1062993016', + label: 'Access keycard', + doorId: 'door-0', + siteId: '1062993016', + }, + ]; + const { run: restored } = restore(rec); + assert.equal(restored.keyItems.length, 1); + assert.equal(restored.keyItems[0]!.principalId, undefined, 'no principal → run-scoped'); +}); + +// ─── P3.1-balance: Run.effectiveKeyItems scopes to the current principal ────── + +test('Run.effectiveKeyItems: a card carries across sites of the same owner', () => { + // A card earned at one matsuda site. Deploy to a *different* matsuda site + // (different seed → different site) and the held card must still apply — this + // is the principal-scoping behavior that site-scoping could not express. + const matsudaCard: KeyItem = { + id: keycardIdFor('door-0', 'matsuda'), + label: 'Matsuda access', + doorId: 'door-0', + principalId: 'matsuda', + }; + const runAtOtherMatsudaSite = new Run({ crewMember: makeCrew(), seed: 777 }); + runAtOtherMatsudaSite.enterBriefing(keycardContractForPrincipal(777, 'matsuda')); + const effective = runAtOtherMatsudaSite.effectiveKeyItems([matsudaCard]); + assert.ok( + effective.some(k => k.id === matsudaCard.id), + 'same-owner card applies at a different site' + ); +}); + +test('Run.effectiveKeyItems: excludes a card owned by a different principal', () => { + const vuongCard: KeyItem = { + id: keycardIdFor('door-0', 'vuong-holdings'), + label: 'Vuong access', + doorId: 'door-0', + principalId: 'vuong-holdings', + }; + const run = new Run({ crewMember: makeCrew(), seed: 5 }); + run.enterBriefing(keycardContractForPrincipal(5, 'matsuda')); + const effective = run.effectiveKeyItems([vuongCard]); + assert.ok( + !effective.some(k => k.id === vuongCard.id), + 'a different owner’s card is excluded (would open the wrong door otherwise)' + ); +}); + +test('Run.effectiveKeyItems: always includes run-scoped pickups and dedups by id', () => { + const run = new Run({ crewMember: makeCrew(), seed: 42 }); + run.enterBriefing(keycardContractForPrincipal(42, 'matsuda')); + const sharedId = keycardIdFor('door-0', 'matsuda'); + run.addKeyItem({ id: sharedId, label: 'Picked up', doorId: 'door-0', principalId: 'matsuda' }); + // Campaign also lists the same card (shouldn't double-render). + const effective = run.effectiveKeyItems([ + { id: sharedId, label: 'Held', doorId: 'door-0', principalId: 'matsuda' }, + ]); + assert.equal(effective.filter(k => k.id === sharedId).length, 1, 'deduped by id'); +}); + +test('Run.addKeyItem: canonicalizes a bare legacy id picked up off a pre-P3.1 map', () => { + const run = new Run({ crewMember: makeCrew(), seed: 99 }); + // Simulates onKeycardCollected feeding a legacy entity id + its principalId. + run.addKeyItem({ + id: 'keycard-door-0', + label: 'Access keycard', + doorId: 'door-0', + principalId: 'matsuda', + }); + assert.equal(run.keyItems[0]!.id, 'keycard-door-0-matsuda'); +}); + +test('Run.effectiveKeyItems: no contract → only run-scoped pickups', () => { + const run = new Run({ crewMember: makeCrew(), seed: 42 }); + run.addKeyItem({ id: 'kc-run', label: 'Run card', doorId: 'door-0' }); + const effective = run.effectiveKeyItems([ + { id: 'kc-camp', label: 'Camp card', doorId: 'door-0', principalId: 'matsuda' }, + ]); + assert.deepEqual( + effective.map(k => k.id), + ['kc-run'] + ); +}); + +// ─── P3.1-balance: eviction no longer drops keycards ───────────────────────── + +test('addSiteToRoster eviction keeps principal-scoped keycards (they outlive sites)', () => { + const campaign = makeCampaign(); + // Fill the roster to capacity; site-0 is the oldest (lowest lastVisitedJob). + for (let i = 0; i < SITE_ROSTER_CAP; i++) { + campaign.addSiteToRoster(makeSite(`site-${i}`, i, TOKEN('matsuda', 'Matsuda'))); + } + campaign.addKeyItem({ + id: keycardIdFor('door-0', 'matsuda'), + label: 'Matsuda card', + doorId: 'door-0', + principalId: 'matsuda', + }); + + // One more site evicts the oldest (site-0), which the card was "found" at. + campaign.addSiteToRoster(makeSite('site-new', SITE_ROSTER_CAP + 1, TOKEN('matsuda', 'Matsuda'))); + + assert.equal(campaign.findRosterSite('site-0'), null, 'oldest site evicted'); + assert.equal(campaign.keyItems.length, 1, 'principal keycard survives site eviction'); + assert.equal(campaign.keyItems[0]!.principalId, 'matsuda'); +}); diff --git a/tests/unit/game/locations.test.ts b/tests/unit/game/locations.test.ts index 363b1a5..5da5d18 100644 --- a/tests/unit/game/locations.test.ts +++ b/tests/unit/game/locations.test.ts @@ -9,6 +9,7 @@ import { mergeSiteSeenKeys, normalizeLocationSite, generateSiteId, + siteIdForContract, } from '../../../src/game/locations.js'; import { Campaign, SITE_ROSTER_CAP } from '../../../src/game/Campaign.js'; import { snapshotCampaign, restoreCampaign } from '../../../src/game/persistence.js'; @@ -61,6 +62,18 @@ test('generateSiteId rejects bad seeds', () => { assert.throws(() => generateSiteId(''), /finite number or non-empty string/); }); +// ─── siteIdForContract ─────────────────────────────────────────────────────── + +test('siteIdForContract uses the explicit locationSiteId when set (revisit)', () => { + const contract = { seed: 999, context: { locationSiteId: 'site-explicit' } }; + assert.equal(siteIdForContract(contract), 'site-explicit'); +}); + +test('siteIdForContract derives from the seed when no locationSiteId (fresh)', () => { + const contract = { seed: 999, context: {} }; + assert.equal(siteIdForContract(contract), generateSiteId(999)); +}); + // ─── applyMutationDeltas (case 4) ───────────────────────────────────────────── test('applyMutationDeltas: two wall breaches replay onto the grid', () => { @@ -715,7 +728,7 @@ test('Run revisit: recon counts tiles seen this run, not prior site memory alone } }); -test('KeyCard on a revisit contract is stamped with the site id (case 7)', () => { +test('KeyCard on a revisit contract is stamped with the owning principal id (case 7)', () => { const campaign = new Campaign({ seed: 1 }); campaign.addSiteToRoster(validSite({ id: '4242', seed: '4242' })); const contract = reachExitContract(4242, { @@ -734,19 +747,17 @@ test('KeyCard on a revisit contract is stamped with the site id (case 7)', () => | KeyCard | undefined; assert.ok(keycard, 'keycard placed for a keycard-unlock contract'); - assert.equal(keycard!.siteId, '4242', 'keycard carries the location site id'); + assert.equal( + keycard!.principalId, + contract.context.principal.id, + 'keycard carries the owning principal id' + ); }); test('revisit with prior site keycard skips spawn and keeps door locked (case 9)', () => { const campaign = new Campaign({ seed: 1 }); campaign.addSiteToRoster(validSite({ id: '4242', seed: '4242' })); - campaign.addKeyItem({ - id: 'keycard-door-0', - label: 'Access keycard', - doorId: 'door-0', - siteId: '4242', - }); - const contract = reachExitContract(4242, { + const revisitContract = reachExitContract(4242, { objective: { kind: OBJECTIVES.RETRIEVE, title: 'Grab cache', @@ -755,11 +766,17 @@ test('revisit with prior site keycard skips spawn and keeps door locked (case 9) }, context: testContext('4242'), }); - const run = campaign.deployCrewMember(campaign.crew[0]!.id, contract); + campaign.addKeyItem({ + id: 'keycard-door-0', + label: 'Access keycard', + doorId: 'door-0', + principalId: revisitContract.context.principal.id, + }); + const run = campaign.deployCrewMember(campaign.crew[0]!.id, revisitContract); run.enterCombat(); const keycard = [...run.world!.entities.values()].find(e => e instanceof KeyCard); - assert.equal(keycard, undefined, 'no keycard when campaign already holds this site card'); + assert.equal(keycard, undefined, 'no keycard when campaign already holds this owner’s card'); const door = [...run.world!.entities.values()].find( e => e instanceof Door && e.doorId === 'door-0' diff --git a/tests/unit/game/scoreFinal.test.ts b/tests/unit/game/scoreFinal.test.ts index 2a0da27..c709909 100644 --- a/tests/unit/game/scoreFinal.test.ts +++ b/tests/unit/game/scoreFinal.test.ts @@ -20,6 +20,7 @@ import { restoreCampaign, } from '../../../src/game/persistence.js'; import { buildCrewMember } from '../../../src/game/archetypes/index.js'; +import { explorationReachableKeys, coordKey } from '../../../src/game/mapConnectivity.js'; import { SCOREABLE_ITEMS, SCOREABLE_ITEM_IDS } from '../../../src/game/items.js'; import { CONTRACT_DIFFICULTY } from '../../../src/game/constants.js'; import { OBJECTIVES } from '../../../src/game/hub/Curator.js'; @@ -32,11 +33,11 @@ import type { LocationSite } from '../../../src/types.js'; const SCORE_DOOR_ID = 'score-door-0'; -function scoreContract(seed = 100): Contract { +function scoreContract(seed = 100, mapWidth = 28, mapHeight = 18): Contract { return { seed, - mapWidth: 28, - mapHeight: 18, + mapWidth, + mapHeight, objective: { kind: OBJECTIVES.SCORE_FINAL, title: 'The Score', @@ -79,13 +80,34 @@ function adjacentFreeTile(world: World, target: Entity) { throw new Error(`no free tile adjacent to ${target.id}`); } -function scoreRun(seed = 100): Run { +function scoreRun(seed = 100, mapWidth = 28, mapHeight = 18): Run { const run = new Run({ crewMember: makeDecker(), partnerMember: makeMerc(), seed }); - run.enterBriefing(scoreContract(seed)); + run.enterBriefing(scoreContract(seed, mapWidth, mapHeight)); run.enterCombat(); return run; } +/** + * The Score door is unlocked only by jacking in and slicing the cyber core, so + * the jack-in point MUST sit on the spawn side of the locked door. We isolate + * exactly that failure: flood the grid from spawn treating ONLY `score-door-0` + * as a wall (`respectEntityBlockers: false` so transient mobs and unrelated + * doors don't muddy the verdict). If the jack-in tile is unreachable, the run is + * sealed by the very door the jack-in is supposed to open. + */ +function jackInReachableWithDoorLocked(run: Run): boolean { + const door = scoreDoor(run); + assert.equal(door.locked, true, 'invariant only meaningful while the door is locked'); + const point = [...run.world!.entities.values()].find(e => e instanceof JackInPoint); + assert.ok(point, 'Score placed a jack-in point'); + const spawn = { x: run.player!.x, y: run.player!.y }; + const reachable = explorationReachableKeys(run.world!, spawn, { + respectEntityBlockers: false, + extraBlockers: new Set([coordKey(door.x, door.y)]), + }); + return reachable.has(coordKey(point.x, point.y)); +} + function jackIn(run: Run): CyberspaceLayer { const point = [...run.world!.entities.values()].find(e => e instanceof JackInPoint); assert.ok(point, 'Score placed a jack-in point'); @@ -159,6 +181,75 @@ test('Score run places linked Meatspace and Cyberspace objectives', () => { assert.equal([...layer.world.entities.values()].filter(e => e instanceof DataNode).length, 1); }); +test('Score jack-in point is reachable from spawn with the score door locked (regression: dead seed 1277998342)', () => { + // Reproduces a shipped soft-lock: the jack-in point — the only way to unlock + // score-door-0 — generated *behind* that door, sealing the run. 32x20 map. + const run = scoreRun(1277998342, 32, 20); + assert.ok( + jackInReachableWithDoorLocked(run), + 'jack-in must be reachable before the door it unlocks is opened' + ); +}); + +test('Score jack-in point is always spawn-side reachable across a seed sweep', () => { + for (let seed = 1; seed <= 40; seed++) { + const run = scoreRun(seed); + assert.ok( + jackInReachableWithDoorLocked(run), + `seed ${seed}: jack-in unreachable with score door locked (soft-lock)` + ); + } +}); + +test('Run.onCombatEntered fires once after a successful enterCombat, never on failure', () => { + let ok = 0; + const good = new Run({ + crewMember: makeDecker(), + partnerMember: makeMerc(), + seed: 100, + onCombatEntered: () => ok++, + }); + good.enterBriefing(scoreContract(100)); + good.enterCombat(); + assert.equal(ok, 1, 'hook fires exactly once when the run becomes playable'); + + let bad = 0; + const doomed = new Run({ + crewMember: makeDecker(), + partnerMember: makeMerc(), + seed: 100, + onCombatEntered: () => bad++, + }); + doomed.enterBriefing(scoreContract(100, 8, 8)); // valid dims, but no door-gated layout fits + assert.throws(() => doomed.enterCombat()); + assert.equal(bad, 0, 'a generation failure must not fire the combat-entered hook'); +}); + +test('a Score map that fails to generate does not consume the (terminal) attempt', () => { + const campaign = new Campaign({ + seed: 42, + rep: 65, + completedJobs: 9, + siteRoster: [ + scoreSite({ mapWidth: 8, mapHeight: 8 }), // valid but un-buildable door-gated map + scoreSite({ id: 'case-1', tier: 'roster', scoreTarget: false, seed: '101' }), + scoreSite({ id: 'case-2', tier: 'roster', scoreTarget: false, seed: '102' }), + scoreSite({ id: 'case-3', tier: 'roster', scoreTarget: false, seed: '103' }), + scoreSite({ id: 'case-4', tier: 'roster', scoreTarget: false, seed: '104' }), + ], + }); + const decker = campaign.crew.find(m => m.archetype === 'Decker')!; + const partner = campaign.crew.find(m => m.archetype !== 'Decker')!; + const run = campaign.deployCrewMember(decker.id, campaign.buildScoreContract(), partner.id); + assert.equal(campaign.arc.scoreAttempted, false, 'deploy alone never commits the Score'); + + assert.throws(() => run.enterCombat(), 'a 3x3 Score map cannot be generated'); + // The terminal flag stayed clear — the campaign can redeploy instead of being + // stranded in score-partial by a generation failure. + assert.equal(campaign.arc.scoreAttempted, false); + assert.equal(campaign.arcStage, 'act-3'); +}); + test('slicing the Score core unlocks the linked Meatspace door', () => { const run = scoreRun(); const door = scoreDoor(run); @@ -232,6 +323,8 @@ test('Campaign records partial Score as terminal without full reward', () => { scoreSite(), scoreSite({ id: 'case-1', tier: 'roster', scoreTarget: false, seed: '101' }), scoreSite({ id: 'case-2', tier: 'roster', scoreTarget: false, seed: '102' }), + scoreSite({ id: 'case-3', tier: 'roster', scoreTarget: false, seed: '103' }), + scoreSite({ id: 'case-4', tier: 'roster', scoreTarget: false, seed: '104' }), ], }); const decker = campaign.crew.find(member => member.archetype === 'Decker')!; @@ -259,6 +352,8 @@ function scoreReadyCampaign() { scoreSite(), scoreSite({ id: 'case-1', tier: 'roster', scoreTarget: false, seed: '101' }), scoreSite({ id: 'case-2', tier: 'roster', scoreTarget: false, seed: '102' }), + scoreSite({ id: 'case-3', tier: 'roster', scoreTarget: false, seed: '103' }), + scoreSite({ id: 'case-4', tier: 'roster', scoreTarget: false, seed: '104' }), ], }); assert.ok(campaign.canAttemptScore(), 'fixture should be Score-ready'); @@ -344,6 +439,8 @@ test('different Score seeds can draw different abstract categories', () => { scoreSite({ seed: String(seed) }), scoreSite({ id: 'case-1', tier: 'roster', scoreTarget: false, seed: `${seed}01` }), scoreSite({ id: 'case-2', tier: 'roster', scoreTarget: false, seed: `${seed}02` }), + scoreSite({ id: 'case-3', tier: 'roster', scoreTarget: false, seed: `${seed}03` }), + scoreSite({ id: 'case-4', tier: 'roster', scoreTarget: false, seed: `${seed}04` }), ], }); const briefing = campaign.buildScoreContract(SCOREABLE_ITEMS.map(i => i.id)).objective.briefing; diff --git a/tests/unit/input/touchpad.test.ts b/tests/unit/input/touchpad.test.ts index d9ff3c4..6d6605b 100644 --- a/tests/unit/input/touchpad.test.ts +++ b/tests/unit/input/touchpad.test.ts @@ -19,6 +19,7 @@ test('TOUCHPAD_ACTIONS includes gameplay actions (perks unified as `special`)', 'cancel', 'end-turn', 'fire', + 'flip', 'interact', 'inventory', 'jack-out', @@ -47,6 +48,7 @@ test('syntheticKeyFor resolves actions to keymap keys', () => { assert.equal(syntheticKeyFor('inventory'), 'i'); assert.equal(syntheticKeyFor('jack-out'), 'j'); assert.equal(syntheticKeyFor('look'), 'l'); + assert.equal(syntheticKeyFor('flip'), 'Tab'); }); test('TOUCHPAD_SHELL_ACTIONS lists shell-only buttons', () => { @@ -142,6 +144,19 @@ test('IDLE + jack-out button emits jack-out intent', () => { assert.equal(r.nextMode, MODE.IDLE); }); +test('IDLE + flip button emits simstim flip intent', () => { + const r = dispatchTouchAction('flip', MODE.IDLE); + assert.deepEqual(r.intent, { type: 'flip' }); + assert.equal(r.nextMode, MODE.IDLE); +}); + +test('AIM (fire) + flip button stays in AIM (no flip mid-aim)', () => { + const r = dispatchTouchAction('flip', MODE.AIM, AIM_KIND.FIRE); + assert.equal(r.intent, null); + assert.equal(r.nextMode, MODE.AIM); + assert.equal(r.aimKind, AIM_KIND.FIRE); +}); + test('LOOK + direction emits a look-move intent', () => { const r = dispatchTouchAction('N', MODE.LOOK); assert.deepEqual(r.intent, { type: 'look-move', dx: 0, dy: -1 }); diff --git a/tests/unit/shell/statusLine.test.ts b/tests/unit/shell/statusLine.test.ts index 1906926..be5e161 100644 --- a/tests/unit/shell/statusLine.test.ts +++ b/tests/unit/shell/statusLine.test.ts @@ -60,6 +60,7 @@ test('formatStatusLine renders combat a11y summary and alert tag', () => { hp: { hp: 3, maxHp: 3 }, ap: { ap: 4, maxAp: 4 }, turn: { currentFaction: FACTION.PLAYER, turnNumber: 1 }, + cyber: false, }, contextHtml: joinStatusParts([formatAlertTag({ phase: 'alert', holdTurnsRemaining: 2 })]), actionHistory: [],