From bc529e9201904debb49ea07d59d9a37ca620ba9b Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Sun, 28 Jun 2026 15:58:03 -0700 Subject: [PATCH 01/17] streamline act 3 casing gate --- docs/phase-3-plan.md | 4 +- src/game/Campaign.ts | 62 +++++-- src/game/hub/arcSurface.ts | 8 + tests/unit/game/Campaign.test.ts | 204 +++++++++++++----------- tests/unit/game/campaignSummary.test.ts | 2 + tests/unit/game/hub/arcSurface.test.ts | 40 ++++- tests/unit/game/hub/hubReveals.test.ts | 26 +++ tests/unit/game/scoreFinal.test.ts | 6 + 8 files changed, 236 insertions(+), 116 deletions(-) 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/src/game/Campaign.ts b/src/game/Campaign.ts index 1eaa29b..8d7d13c 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -76,11 +76,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 +98,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; @@ -1688,9 +1711,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 +1738,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, + }; } /** diff --git a/src/game/hub/arcSurface.ts b/src/game/hub/arcSurface.ts index 7d29762..e181ba3 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)]; } diff --git a/tests/unit/game/Campaign.test.ts b/tests/unit/game/Campaign.test.ts index de04a31..106f0c5 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, @@ -825,114 +826,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 +1095,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 +1136,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 +1260,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'); @@ -1353,6 +1361,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'); @@ -1414,6 +1424,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 +1459,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 +1494,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/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/hub/arcSurface.test.ts b/tests/unit/game/hub/arcSurface.test.ts index cd4f416..a32791f 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,42 @@ 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( () => 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/scoreFinal.test.ts b/tests/unit/game/scoreFinal.test.ts index 2a0da27..de94ea7 100644 --- a/tests/unit/game/scoreFinal.test.ts +++ b/tests/unit/game/scoreFinal.test.ts @@ -232,6 +232,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 +261,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 +348,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; From dd3058c6e9fb2a195e7eca52693143d54e8d1685 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Sun, 28 Jun 2026 16:25:49 -0700 Subject: [PATCH 02/17] surface new gear & bonuses --- components/CrewRoster.ts | 81 ++++---------- src/game/crewDisplay.ts | 96 ++++++++++++++++ tests/unit/game/crewDisplay.test.ts | 163 ++++++++++++++++++++++++++++ 3 files changed, 281 insertions(+), 59 deletions(-) create mode 100644 src/game/crewDisplay.ts create mode 100644 tests/unit/game/crewDisplay.test.ts diff --git a/components/CrewRoster.ts b/components/CrewRoster.ts index b3408bb..d675897 100644 --- a/components/CrewRoster.ts +++ b/components/CrewRoster.ts @@ -26,8 +26,9 @@ import { totalSalvage, type TypedSalvage, } from '/src/game/salvage.js'; -import type { Crew as CrewMember, Gear } from '/src/game/Crew.js'; +import type { Crew as CrewMember } from '/src/game/Crew.js'; import type { Item } from '/src/game/items.js'; +import { gearLines, statLines } from '/src/game/crewDisplay.js'; /** Human-readable labels for item IDs (mirrors ItemInventory). */ const ITEM_LABELS = { @@ -36,19 +37,6 @@ const ITEM_LABELS = { '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 []; @@ -551,32 +539,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(this.#buildStatsSection(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) { @@ -605,6 +572,20 @@ class CrewRoster extends HTMLElement { this.#detailEl!.appendChild(consSection); } + /** + * Build the STATS section for a crew member or recruit. Delegates the + * stat→line mapping to {@link statLines} (pure, tested) so both detail panes + * render an identical, complete combat readout. + */ + #buildStatsSection(member: CrewMember): HTMLElement { + const section = h('div', { className: 'detail-section' }); + section.appendChild(h('p', { className: 'detail-section-title', textContent: 'STATS' })); + for (const line of statLines(member)) { + section.appendChild(h('p', { className: 'detail-stat', textContent: line })); + } + return section; + } + // ─── Recruitment ───────────────────────────────────────────────────── #renderRecruits() { @@ -681,27 +662,9 @@ class CrewRoster extends HTMLElement { }), ]) ); - // 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); + // 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(this.#buildStatsSection(recruit)); } /** diff --git a/src/game/crewDisplay.ts b/src/game/crewDisplay.ts new file mode 100644 index 0000000..efb3404 --- /dev/null +++ b/src/game/crewDisplay.ts @@ -0,0 +1,96 @@ +/** + * 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); + * `statLines` shows the resulting combat numbers with each gear contribution + * annotated inline. 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 `statLines` + * 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)}%`; + +/** + * Format a crew member's combat stats as display lines. The seven core stats + * (HP, AP, AIM, DODGE, RANGED, MELEE, ARMOUR) always appear so the pane reads + * like a character sheet; the two per-turn regen effects only appear when a + * piece of gear grants them (a `+0/turn` row would be noise). Gear-boosted + * stats carry an inline `(+N)` so the bonus is legible without cross-checking + * the GEAR block. + */ +export function statLines(stats: StatReadout): string[] { + const hitBonus = stats.gear?.hitBonus ?? 0; + const dodgeBonus = stats.gear?.dodgeBonus ?? 0; + const apBonus = stats.gear?.apBonus ?? 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 lines = [ + `HP ${stats.hp}/${stats.maxHp}`, + `AP ${stats.maxAp}${apBonus > 0 ? ` (+${apBonus})` : ''}`, + `AIM ${pct(aim)}${hitBonus > 0 ? ` (+${pct(hitBonus)})` : ''}`, + `DODGE ${pct(dodge)}${dodgeBonus > 0 ? ` (+${pct(dodgeBonus)})` : ''}`, + `RANGED ${stats.rangedDamage + rangedBonus} dmg${rangedBonus > 0 ? ` (+${rangedBonus})` : ''}`, + `MELEE ${stats.meleeDamage + meleeBonus} dmg${meleeBonus > 0 ? ` (+${meleeBonus})` : ''}`, + `ARMOUR ${stats.damageReduction}`, + ]; + if (shieldRegen > 0) lines.push(`SHIELD +${shieldRegen}/turn`); + if (hpRegen > 0) lines.push(`REGEN +${hpRegen} HP/turn`); + return lines; +} + +/** + * 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(`Armour 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} armour`); + 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/tests/unit/game/crewDisplay.test.ts b/tests/unit/game/crewDisplay.test.ts new file mode 100644 index 0000000..95f4277 --- /dev/null +++ b/tests/unit/game/crewDisplay.test.ts @@ -0,0 +1,163 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { gearLines, statLines } 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('Armour Plating') && l.includes('+2 HP')), + `expected Armour 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`); + } +}); + +// --------------------------------------------------------------------------- +// statLines — roster STATS block. Core combat stats always show; gear bonuses +// surface inline; per-turn regen only when active. +// --------------------------------------------------------------------------- + +test('statLines always shows the seven core stats, no regen rows by default', () => { + const crew = new Merc({ id: 'merc', x: 0, y: 0 }); + const lines = statLines(crew); + for (const label of ['HP', 'AP', 'AIM', 'DODGE', 'RANGED', 'MELEE', 'ARMOUR']) { + assert.ok( + lines.some(l => l.startsWith(label + ' ') || l.startsWith(label + ' ')), + `missing ${label} stat in ${JSON.stringify(lines)}` + ); + } + assert.ok(!lines.some(l => l.startsWith('SHIELD')), 'no shield regen row without gear'); + assert.ok(!lines.some(l => l.startsWith('REGEN')), 'no hp regen row without gear'); + // A bare operator shows no bonus annotations. + assert.ok( + !lines.some(l => l.includes('(+')), + `unexpected bonus on bare crew: ${JSON.stringify(lines)}` + ); +}); + +test('statLines annotates each gear-boosted stat inline', () => { + 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 lines = statLines(crew); + assert.ok( + lines.some(l => l.startsWith('AIM') && l.includes('(+')), + 'AIM bonus' + ); + assert.ok( + lines.some(l => l.startsWith('DODGE') && l.includes('(+')), + 'DODGE bonus' + ); + assert.ok( + lines.some(l => l.startsWith('RANGED') && l.includes('(+')), + 'RANGED bonus' + ); + assert.ok( + lines.some(l => l.startsWith('MELEE') && l.includes('(+')), + 'MELEE bonus' + ); + assert.ok( + lines.some(l => l.startsWith('AP') && l.includes('(+')), + 'AP bonus' + ); + // Subdermal Plating raises ARMOUR off its 0 base. + assert.ok( + lines.some(l => l.startsWith('ARMOUR') && /ARMOUR\s+1/.test(l)), + 'ARMOUR value' + ); +}); + +test('statLines shows regen rows 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 lines = statLines(crew); + assert.ok( + lines.some(l => l.startsWith('SHIELD') && l.includes('/turn')), + 'shield regen row' + ); + assert.ok( + lines.some(l => l.startsWith('REGEN') && l.includes('HP/turn')), + 'hp regen row' + ); +}); + +test('statLines reflects archetype melee/ranged differences without gear', () => { + // Razor's heavier blade should read on the MELEE line; 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 }); + const razorMelee = statLines(razor).find(l => l.startsWith('MELEE')); + const mercMelee = statLines(merc).find(l => l.startsWith('MELEE')); + assert.ok(razorMelee && mercMelee); + assert.notEqual(razorMelee, mercMelee); +}); From 1472429149dd9e21ccfc1a7367ce506b3c8dd487 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Sun, 28 Jun 2026 17:10:54 -0700 Subject: [PATCH 03/17] make cyber jobs more visible --- components/ContractSelect.ts | 16 ++++++- components/TouchPad.ts | 72 +++++++++++++++++++++++++++++++ src/input/touchpad.ts | 5 ++- src/shell/domTypes.ts | 1 + src/shell/shellRuntime.ts | 5 +++ tests/unit/input/touchpad.test.ts | 15 +++++++ 6 files changed, 112 insertions(+), 2 deletions(-) diff --git a/components/ContractSelect.ts b/components/ContractSelect.ts index db097e4..52ff91c 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); @@ -307,6 +315,12 @@ 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( 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/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..39faead 100644 --- a/src/shell/domTypes.ts +++ b/src/shell/domTypes.ts @@ -106,6 +106,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..28341a6 100644 --- a/src/shell/shellRuntime.ts +++ b/src/shell/shellRuntime.ts @@ -2267,6 +2267,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; 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 }); From 2d0f2e823447ca51733e56bb3fed3f52312c6521 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Sun, 28 Jun 2026 17:11:23 -0700 Subject: [PATCH 04/17] bump version --- sw-dev.js | 2 +- sw.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sw-dev.js b/sw-dev.js index bcaf02a..f777c18 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.1-dev'; importScripts(`/sw-core.js?v=${VERSION}`); const cacheConfig = CacheConfig.create(VERSION); diff --git a/sw.js b/sw.js index 3488502..8d60b93 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.1'; importScripts(`/sw-core.js?v=${VERSION}`); const cacheConfig = CacheConfig.create(VERSION); From a1985df9e6abfc1f28c88d9db61480309137c289 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Sun, 28 Jun 2026 17:12:08 -0700 Subject: [PATCH 05/17] prettier --- tests/unit/game/hub/arcSurface.test.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/unit/game/hub/arcSurface.test.ts b/tests/unit/game/hub/arcSurface.test.ts index a32791f..0dae1db 100644 --- a/tests/unit/game/hub/arcSurface.test.ts +++ b/tests/unit/game/hub/arcSurface.test.ts @@ -103,12 +103,7 @@ test('formatHubArcStatusLines surfaces casing progress, counting visited org sit 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), - ], + siteRoster: [scoreSite(), orgSite('case-1', 6), orgSite('case-2', 7), orgSite('case-3', 0)], crew: [], hubReveals: {}, }; From 6f66683cb4bf84ac21cd655924a40bd9d5ec1780 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Sun, 28 Jun 2026 21:59:36 -0700 Subject: [PATCH 06/17] fix bad mapgen bug on scores --- src/game/Campaign.ts | 36 +++++++++- src/game/Run.ts | 80 ++++++++++++++++++++++- src/game/persistence.ts | 7 ++ tests/unit/game/Campaign.test.ts | 13 +++- tests/unit/game/scoreFinal.test.ts | 101 +++++++++++++++++++++++++++-- 5 files changed, 225 insertions(+), 12 deletions(-) diff --git a/src/game/Campaign.ts b/src/game/Campaign.ts index 8d7d13c..79709e3 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -676,7 +676,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; } @@ -697,6 +697,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. @@ -1814,7 +1815,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'); } @@ -1831,8 +1841,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 { diff --git a/src/game/Run.ts b/src/game/Run.ts index 5effa3b..e21fd78 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'; @@ -387,6 +387,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 @@ -494,6 +499,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 +519,7 @@ export class Run { seed, onPersist, onResult, + onCombatEntered, onAbortRequested, onJackOutRequested, onJackInPresent, @@ -537,6 +545,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 +604,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 +894,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`. */ @@ -2062,7 +2079,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', @@ -3125,6 +3148,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/persistence.ts b/src/game/persistence.ts index 58baef0..da4cce0 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -892,6 +892,7 @@ const ENTITY_RESTORE: Partial> = Object. type RestoreOptions = { onPersist?: (record: RunSnapshot) => void; onResult?: (result: RunResult) => void; + onCombatEntered?: () => void; }; type RestoreCampaignOptions = { @@ -1144,6 +1145,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 +1497,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 +1931,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); diff --git a/tests/unit/game/Campaign.test.ts b/tests/unit/game/Campaign.test.ts index 106f0c5..ccc8d01 100644 --- a/tests/unit/game/Campaign.test.ts +++ b/tests/unit/game/Campaign.test.ts @@ -1343,7 +1343,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, @@ -1386,9 +1386,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', () => { diff --git a/tests/unit/game/scoreFinal.test.ts b/tests/unit/game/scoreFinal.test.ts index de94ea7..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); From 66bb08c08948a5b62aa852eac0ad3d281718050c Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Sun, 28 Jun 2026 22:36:54 -0700 Subject: [PATCH 07/17] fix dual-deploy consumable usage --- src/shell/shellRuntime.ts | 50 ++++-- .../game/cyber/consumableDualDeploy.test.ts | 142 ++++++++++++++++++ 2 files changed, 176 insertions(+), 16 deletions(-) create mode 100644 tests/unit/game/cyber/consumableDualDeploy.test.ts diff --git a/src/shell/shellRuntime.ts b/src/shell/shellRuntime.ts index 28341a6..070beaa 100644 --- a/src/shell/shellRuntime.ts +++ b/src/shell/shellRuntime.ts @@ -947,10 +947,16 @@ function presentItemInventory() { return; } const run = campaign.activeRun; - if (!run || !run.player || !run.player.inventory) return; + 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; itemInventoryEl.setContents({ - salvage: run.player.inventory.salvage, - consumables: run.player.inventory.consumables, + salvage: operator.inventory.salvage, + consumables: operator.inventory.consumables, keyItems: [...campaign.keyItems, ...run.keyItems], }); itemInventoryEl.show(); @@ -974,6 +980,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 +999,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. @@ -1002,7 +1013,7 @@ function onUseItem(evt: Event) { return; } try { - const result = run.player.useConsumable(itemId); + const result = operator.useConsumable(itemId); applyUseConsumableResult(result, run); } catch (err) { flash(`USE FAILED: ${errorMessage(err)}`); @@ -1023,10 +1034,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 +1052,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 +1068,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 +1083,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 +1106,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 +1123,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 +1154,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)}`); 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'); +}); From fee1fd7051e22a68fb69579c98d8af601dd0d451 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Sun, 28 Jun 2026 22:58:56 -0700 Subject: [PATCH 08/17] "game over" copy variety --- components/GameOver.ts | 51 +++------ src/game/endFlavor.ts | 177 ++++++++++++++++++++++++++++++ tests/unit/game/endFlavor.test.ts | 121 ++++++++++++++++++++ 3 files changed, 314 insertions(+), 35 deletions(-) create mode 100644 src/game/endFlavor.ts create mode 100644 tests/unit/game/endFlavor.test.ts 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/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/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)); +}); From a8ddf6ef7f6311b4de49d612c544310ea856b833 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Sun, 28 Jun 2026 23:00:39 -0700 Subject: [PATCH 09/17] bump sw version --- sw-dev.js | 2 +- sw.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sw-dev.js b/sw-dev.js index f777c18..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.1-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 8d60b93..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.1'; +const VERSION = '0.3.2'; importScripts(`/sw-core.js?v=${VERSION}`); const cacheConfig = CacheConfig.create(VERSION); From bd2aecf2955395715b29e145e07eb450568c40dc Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 30 Jun 2026 17:37:13 -0700 Subject: [PATCH 10/17] keycard persistence fixes --- components/ContractSelect.ts | 31 +++++- components/ItemInventory.ts | 13 ++- src/game/Campaign.ts | 36 +++++- src/game/Run.ts | 36 ++++-- src/game/hub/arcSurface.ts | 11 +- src/game/locations.ts | 15 +++ src/game/persistence.ts | 11 +- src/shell/domTypes.ts | 11 +- src/shell/shellRuntime.ts | 38 +++++-- tests/unit/game/hub/arcSurface.test.ts | 34 ++++++ tests/unit/game/keycard.test.ts | 146 ++++++++++++++++++++++++- tests/unit/game/locations.test.ts | 13 +++ 12 files changed, 353 insertions(+), 42 deletions(-) diff --git a/components/ContractSelect.ts b/components/ContractSelect.ts index 52ff91c..fe71143 100644 --- a/components/ContractSelect.ts +++ b/components/ContractSelect.ts @@ -175,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; @@ -213,6 +218,7 @@ class ContractSelect extends HTMLElement { #contracts: Contract[] = []; #scoreTargetSiteId: string | null = null; #scorePrincipalId: string | null = null; + #heldKeycardSiteIds: ReadonlySet = new Set(); #selectedIndex = 0; #ready = false; #listEl: HTMLElement | null = null; @@ -279,6 +285,14 @@ class ContractSelect extends HTMLElement { if (this.#ready) this.#render(); } + setHeldKeycardSiteIds(siteIds: string[]) { + if (!Array.isArray(siteIds)) { + throw new TypeError('.setHeldKeycardSiteIds requires an array'); + } + this.#heldKeycardSiteIds = new Set(siteIds); + if (this.#ready) this.#render(); + } + show() { this.setAttribute('open', ''); queueMicrotask(() => this.#focusSelected()); @@ -326,7 +340,12 @@ class ContractSelect extends HTMLElement { h( 'div', { className: 'location' }, - locationLine(contract, this.#scoreTargetSiteId, this.#scorePrincipalId) + locationLine( + contract, + this.#scoreTargetSiteId, + this.#scorePrincipalId, + this.#heldKeycardSiteIds + ) ), h('div', { className: 'meta', textContent: rewardCopy(contract) }), ]), @@ -418,13 +437,19 @@ function jobTitleCopy(contract: Contract): string { function locationLine( contract: Contract, scoreTargetSiteId: string | null, - scorePrincipalId: string | null + scorePrincipalId: string | null, + heldKeycardSiteIds: 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, + heldKeycardSiteIds + )) { const className = badge.variant === 'revisit' ? 'known' : `known ${badge.variant}`; nodes.push(h('span', { className, textContent: badge.text })); } diff --git a/components/ItemInventory.ts b/components/ItemInventory.ts index 910fc67..bf5deb3 100644 --- a/components/ItemInventory.ts +++ b/components/ItemInventory.ts @@ -31,7 +31,7 @@ import { } 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'; +import type { KeyItemView } from '/src/shell/domTypes.js'; type ItemInventoryItem = Omit & { count: number; @@ -252,7 +252,7 @@ const SALVAGE_LABELS: Record = { class ItemInventory extends HTMLElement { #items: ItemInventoryItem[] = []; #salvage: TypedSalvage = emptySalvage(); - #keyItems: KeyItem[] = []; + #keyItems: KeyItemView[] = []; #ready = false; #bodyEl: HTMLElement | null = null; #titleEl: HTMLElement | null = null; @@ -308,11 +308,11 @@ class ItemInventory extends HTMLElement { setContents({ salvage = emptySalvage(), consumables = [] as Item[], - keyItems = [] as KeyItem[], + keyItems = [] as KeyItemView[], }: { salvage?: TypedSalvage; consumables?: Item[]; - keyItems?: KeyItem[]; + keyItems?: KeyItemView[]; } = {}) { this.#salvage = salvage; this.#keyItems = keyItems; @@ -399,7 +399,10 @@ class ItemInventory extends HTMLElement { 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 }) + h('span', { + className: 'key-label', + textContent: ki.locationName ?? ki.label, + }) ); keyRows.appendChild(row); } diff --git a/src/game/Campaign.ts b/src/game/Campaign.ts index 79709e3..e57214f 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -50,6 +50,7 @@ import { mergeSiteDeltas as mergeDeltas, mergeSiteSeenKeys as mergeSeen, normalizeLocationSite, + siteIdForContract, } from './locations.js'; import { resolveMapDimensions } from './procgen/mapDimensions.js'; import { @@ -776,6 +777,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); @@ -1309,6 +1314,25 @@ export class Campaign { this.#persist(); } + /** + * Promote a survived run's carried keycards into the persistent campaign + * inventory. Only site-stamped cards survive (run-scoped cards without a + * siteId belong to nowhere on the roster); already-held cards are skipped + * so a re-collected revisit card is idempotent rather than a crash. + */ + #promoteRunKeyItems(run: Run): void { + for (const item of run.keyItems) { + if (!item.siteId) continue; + if (this.keyItems.some(k => k.id === item.id)) continue; + this.addKeyItem({ + id: item.id, + label: item.label, + doorId: item.doorId, + siteId: item.siteId, + }); + } + } + /** * Check whether the campaign inventory holds a key item that unlocks the * given door id. Returns the matching `KeyItem` or `null`. @@ -1363,7 +1387,12 @@ export class Campaign { ); return; } - this.siteRoster.splice(evictIdx, 1); + const [evicted] = this.siteRoster.splice(evictIdx, 1); + // A site leaving campaign memory takes its keycards with it — a card for + // a location we can no longer revisit is dead weight in the inventory. + if (evicted) { + this.keyItems = this.keyItems.filter(k => k.siteId !== evicted.id); + } } this.siteRoster.push(normalized); this.#persist(); @@ -1400,7 +1429,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); } /** @@ -1426,8 +1455,7 @@ export class Campaign { * skip respawning pickup keycards on revisit (player re-opens via interact). */ priorKeyItemsForContract(contract: Contract): KeyItem[] { - const siteId = contract.context.locationSiteId; - if (!siteId) return []; + const siteId = siteIdForContract(contract); return this.keyItems.filter(k => k.siteId === siteId).map(k => ({ ...k })); } diff --git a/src/game/Run.ts b/src/game/Run.ts index e21fd78..b8a4cb6 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -88,7 +88,7 @@ import { DataNode } from './cyber/DataNode.js'; import { ProbeIce } from './cyber/ProbeIce.js'; import { SparkIce } from './cyber/SparkIce.js'; import { GuardianIce } from './cyber/GuardianIce.js'; -import { applyMutationDeltas } from './locations.js'; +import { applyMutationDeltas, siteIdForContract } from './locations.js'; import { BreachingCharge } from './entities/BreachingCharge.js'; import { ITEM_ID, getItemById, SCOREABLE_ITEMS } from './items.js'; import { resetCorpTurnStatusCache } from './corpTurnStatusCopy.js'; @@ -366,6 +366,7 @@ type KeyItemSnapshot = { id: string; label: string; doorId: string; + siteId?: string; }; export type JackOutRequest = { @@ -972,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.siteId ? { siteId: k.siteId } : {}), + })), 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 @@ -1469,7 +1475,12 @@ export class Run { if (this.keyItems.some(k => k.id === item.id)) { throw new Error(`Run.addKeyItem: duplicate key item "${item.id}"`); } - this.keyItems.push({ id: item.id, label: item.label, doorId: item.doorId }); + this.keyItems.push({ + id: item.id, + label: item.label, + doorId: item.doorId, + ...(item.siteId ? { siteId: item.siteId } : {}), + }); } mapSeenKeys(): string[] { @@ -1988,10 +1999,10 @@ 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); + const revisitSiteId = siteIdForContract(this.contract); + const priorKey = 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). if (!priorKey) { @@ -2026,10 +2037,11 @@ 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 roster's site id (explicit on a + // revisit, else derived from the seed) so collecting it promotes + // the card to campaign-scoped on extraction and a future revisit + // re-opens the door via the held card instead of respawning one. + const keycardSiteId = siteIdForContract(this.contract); this.world.addEntity( new KeyCard({ id: `keycard-${linkedDoorId}`, @@ -2037,7 +2049,7 @@ export class Run { y: keycardAnchor.y, doorId: linkedDoorId, label: 'Access keycard', - ...(keycardSiteId ? { siteId: keycardSiteId } : {}), + siteId: keycardSiteId, }) ); } diff --git a/src/game/hub/arcSurface.ts b/src/game/hub/arcSurface.ts index e181ba3..4ef5da1 100644 --- a/src/game/hub/arcSurface.ts +++ b/src/game/hub/arcSurface.ts @@ -11,6 +11,7 @@ import { import type { CampaignArcStage, LocationSite } from '../../types.js'; import type { Decker } from '../archetypes/Decker.js'; import type { Crew } from '../Crew.js'; +import { siteIdForContract } from '../locations.js'; type ArcSurfaceCampaign = Pick & Partial< @@ -165,7 +166,7 @@ export function isScorePrincipalContract( * `known` styling). */ export interface ContractLocationBadge { - variant: 'score-site' | 'casing' | 'revisit'; + variant: 'score-site' | 'casing' | 'revisit' | 'keycard'; text: string; } @@ -187,7 +188,8 @@ export interface ContractLocationBadge { export function contractLocationBadges( contract: Contract, scoreTargetSiteId: string | null | undefined, - scorePrincipalId: string | null | undefined + scorePrincipalId: string | null | undefined, + heldKeycardSiteIds: ReadonlySet = new Set() ): ContractLocationBadge[] { const badges: ContractLocationBadge[] = []; const scoreSite = isScoreSiteContract(contract, scoreTargetSiteId); @@ -199,6 +201,11 @@ export function contractLocationBadges( if (contract.context.locationSiteId && !scoreSite) { badges.push({ variant: 'revisit', text: '// known site' }); } + // A held keycard means we already extracted from this exact site — surface it + // so the player knows the locked door is already openable on this redeploy. + if (!scoreSite && heldKeycardSiteIds.has(siteIdForContract(contract))) { + 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 da4cce0..a7e6de3 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -2120,7 +2120,16 @@ 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.siteId !== undefined) { + if (typeof item.siteId !== 'string' || item.siteId.length === 0) { + throw new TypeError( + `restore: run keyItems[${i}].siteId must be a non-empty string when set` + ); + } + result.siteId = item.siteId; + } + return result; }); } diff --git a/src/shell/domTypes.ts b/src/shell/domTypes.ts index 39faead..de7722f 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; + setHeldKeycardSiteIds(siteIds: string[]): void; }; export type CrashDumpElement = ModalElement & { @@ -92,7 +101,7 @@ export type ItemInventoryElement = ModalElement & { setContents(contents: { salvage?: TypedSalvage; consumables?: NonNullable['consumables']; - keyItems?: KeyItem[]; + keyItems?: KeyItemView[]; }): void; setItems(consumables: NonNullable['consumables']): void; }; diff --git a/src/shell/shellRuntime.ts b/src/shell/shellRuntime.ts index 070beaa..cdb34cb 100644 --- a/src/shell/shellRuntime.ts +++ b/src/shell/shellRuntime.ts @@ -941,7 +941,7 @@ function presentItemInventory() { itemInventoryEl.setContents({ salvage: campaign.salvage, consumables: [], - keyItems: campaign.keyItems, + keyItems: keyItemsWithLocation(campaign.keyItems), }); itemInventoryEl.show(); return; @@ -962,6 +962,23 @@ function presentItemInventory() { itemInventoryEl.show(); } +/** + * Enrich key items with their roster location name (`${principal} ${site}`) + * for the inventory tag. Resolved live from the campaign roster — keycards are + * evicted alongside their site, so a held card always has a roster entry, + * except legacy untokenized sites which simply render without a tag. + */ +function keyItemsWithLocation(items: KeyItem[]): KeyItemView[] { + return items.map(item => { + const site = item.siteId ? campaign?.findRosterSite(item.siteId) : null; + if (!site?.principal) return { ...item }; + const locationName = site.site + ? `${site.principal.label} ${site.site.label}` + : site.principal.label; + return { ...item, locationName }; + }); +} + /** * Stashed item id for thrown-consumable aim flow. Set when the * inventory overlay confirmed an aimed consumable (incendiary) and we @@ -1609,16 +1626,15 @@ export function handleIntent(intent: Intent): void { }, keyItems: [...(campaign?.keyItems ?? []), ...(run as Run).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 + // site-stamped ones into the persistent inventory on live extraction. + (run as Run).addKeyItem({ + id: kc.id, + label: kc.label, + doorId: kc.doorId, + ...(kc.siteId ? { siteId: kc.siteId } : {}), + }); }, onSecuredInteract: handleSecuredInteract, onPlayerAction: (actionName: string) => { diff --git a/tests/unit/game/hub/arcSurface.test.ts b/tests/unit/game/hub/arcSurface.test.ts index 0dae1db..358b88a 100644 --- a/tests/unit/game/hub/arcSurface.test.ts +++ b/tests/unit/game/hub/arcSurface.test.ts @@ -18,6 +18,7 @@ import { scoreTargetDisplayName, scoreTargetSiteId, } from '../../../../src/game/hub/arcSurface.js'; +import { siteIdForContract } from '../../../../src/game/locations.js'; import { Decker } from '../../../../src/game/archetypes/Decker.js'; import { Merc } from '../../../../src/game/archetypes/Merc.js'; import { CLOCK_ACT2_DEADLINE_JOBS, CLOCK_ACT2_GRACE_JOBS } from '../../../../src/game/Campaign.js'; @@ -322,6 +323,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 site id is in the held set', () => { + const contract = badgeFixture('case-site'); // revisit, non-principal + const held = new Set([siteIdForContract(contract)]); + 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 derived seed id', () => { + const contract = badgeFixture(); // no locationSiteId → derived from seed + const held = new Set([siteIdForContract(contract)]); + 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 site', () => { + const contract = badgeFixture('case-site'); + const held = new Set(['some-other-site']); + 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([siteIdForContract(contract)]); + 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/keycard.test.ts b/tests/unit/game/keycard.test.ts index 2121f68..9c790f9 100644 --- a/tests/unit/game/keycard.test.ts +++ b/tests/unit/game/keycard.test.ts @@ -8,8 +8,10 @@ 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 { generateSiteId, siteIdForContract } from '../../../src/game/locations.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'; @@ -34,7 +36,7 @@ import { OBJECTIVES } 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 } from '../../../src/types.js'; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -977,3 +979,141 @@ test('bump into locked door without keycard keeps door locked', () => { assert.equal(door.locked, true, 'door should remain locked'); }); + +// ─── Keycard site-id stamping (P3.1 balance) ───────────────────────────────── + +test('spawned keycard is stamped with the contract-derived site id (no explicit locationSiteId)', () => { + 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'); + // The fresh contract carries no locationSiteId — the keycard should still be + // stamped with the seed-derived roster id (not left run-scoped/null). + assert.equal(contract!.context.locationSiteId, undefined); + const keycard = [...run!.world!.entities.values()].find(e => e instanceof KeyCard) as KeyCard; + assert.ok(keycard); + assert.equal(keycard.siteId, siteIdForContract(contract!)); + assert.equal(keycard.siteId, generateSiteId(contract!.seed as number)); +}); + +// ─── Run-scoped keycards carry siteId ──────────────────────────────────────── + +test('Run.addKeyItem preserves siteId', () => { + const run = new Run({ crewMember: makeCrew(), seed: 99 }); + run.addKeyItem({ id: 'kc-1', label: 'Site card', doorId: 'door-0', siteId: 'site-7' }); + assert.equal(run.keyItems[0]!.siteId, 'site-7'); +}); + +test('Run.keyItems snapshot round-trip preserves siteId', () => { + const run = new Run({ crewMember: makeCrew(), seed: 42 }); + run.enterBriefing(fakeContract()); + run.enterCombat(); + run.addKeyItem({ id: 'kc-site', label: 'Site card', doorId: 'door-0', siteId: 'site-9' }); + const rec = snapshot(run); + assert.equal(rec.keyItems![0]!.siteId, 'site-9'); + const { run: restored } = restore(rec); + assert.equal(restored.keyItems[0]!.siteId, 'site-9'); +}); + +// ─── 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 site-stamped run keycard into the campaign inventory on live extraction', () => { + const campaign = makeCampaign(); + const run = deployWithRun(campaign); + run.addKeyItem({ + id: 'kc-promote', + label: 'Site card', + doorId: 'door-0', + siteId: 'site-promote', + }); + 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]!.siteId, 'site-promote'); +}); + +test('onJobEnd promotes carried keycards even on an aborted (incomplete) extraction', () => { + const campaign = makeCampaign(); + const run = deployWithRun(campaign); + run.addKeyItem({ id: 'kc-abort', label: 'Site card', doorId: 'door-0', siteId: 'site-abort' }); + 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: 'Site card', doorId: 'door-0', siteId: 'site-dead' }); + campaign.onJobEnd({ outcome: OUTCOME.DEATH }); + assert.deepEqual(campaign.keyItems, []); +}); + +test('onJobEnd does not promote run-scoped keycards lacking a siteId', () => { + 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: 'Site card', doorId: 'door-0', siteId: 'site-dup' }); + const run = deployWithRun(campaign); + run.addKeyItem({ id: 'kc-dup', label: 'Site card', doorId: 'door-0', siteId: 'site-dup' }); + campaign.onJobEnd({ outcome: OUTCOME.EXIT, salvage: emptySalvage() }); + assert.equal(campaign.keyItems.length, 1, 'no duplicate, no crash'); +}); + +// ─── Eviction drops the evicted site's keycards ────────────────────────────── + +function makeSite(id: string, lastVisitedJob: number): LocationSite { + return { + id, + seed: id, + mapWidth: 24, + mapHeight: 16, + label: `// site ${id}`, + tier: 'roster', + scoreTarget: false, + mutationDeltas: [], + seenKeys: [], + lastVisitedJob, + }; +} + +test('addSiteToRoster eviction removes keycards belonging to the evicted site', () => { + 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)); + } + campaign.addKeyItem({ id: 'kc-old', label: 'Old card', doorId: 'door-0', siteId: 'site-0' }); + campaign.addKeyItem({ id: 'kc-keep', label: 'Kept card', doorId: 'door-1', siteId: 'site-1' }); + + // One more site evicts the oldest (site-0). + campaign.addSiteToRoster(makeSite('site-new', SITE_ROSTER_CAP + 1)); + + assert.equal(campaign.findRosterSite('site-0'), null, 'oldest site evicted'); + assert.equal(campaign.keyItems.length, 1, 'evicted-site keycard dropped'); + assert.equal(campaign.keyItems[0]!.id, 'kc-keep', 'surviving-site keycard retained'); +}); diff --git a/tests/unit/game/locations.test.ts b/tests/unit/game/locations.test.ts index 363b1a5..3556666 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', () => { From 89cca8d6e5d5ad96e32a6ab65bcc7d1772e7bc3a Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 30 Jun 2026 18:16:24 -0700 Subject: [PATCH 11/17] more keycard fixes & cleanup --- components/CombatInventory.ts | 217 ++++ components/CrewInventory.ts | 48 + components/CrewRoster.ts | 2 +- components/InventoryOverlay.ts | 325 ++++++ components/ItemInventory.ts | 503 -------- index.html | 3 +- index.ts | 6 +- src/game/Campaign.ts | 13 +- src/game/Run.ts | 40 +- src/game/entities/KeyCard.ts | 26 + src/game/persistence.ts | 6 +- src/input/keymap.ts | 6 +- src/shell/domTypes.ts | 9 +- src/shell/keycard-debug.json | 1941 +++++++++++++++++++++++++++++++ src/shell/shellRuntime.ts | 70 +- tests/unit/game/keycard.test.ts | 204 +++- 16 files changed, 2848 insertions(+), 571 deletions(-) create mode 100644 components/CombatInventory.ts create mode 100644 components/CrewInventory.ts create mode 100644 components/InventoryOverlay.ts delete mode 100644 components/ItemInventory.ts create mode 100644 src/shell/keycard-debug.json 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/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 d675897..fec80c1 100644 --- a/components/CrewRoster.ts +++ b/components/CrewRoster.ts @@ -30,7 +30,7 @@ import type { Crew as CrewMember } from '/src/game/Crew.js'; import type { Item } from '/src/game/items.js'; import { gearLines, statLines } from '/src/game/crewDisplay.js'; -/** Human-readable labels for item IDs (mirrors ItemInventory). */ +/** Human-readable labels for item IDs (mirrors CombatInventory). */ const ITEM_LABELS = { stim: 'Stim', 'smoke-charge': 'Smoke Charge', 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 bf5deb3..0000000 --- a/components/ItemInventory.ts +++ /dev/null @@ -1,503 +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 { KeyItemView } from '/src/shell/domTypes.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: KeyItemView[] = []; - #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 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(); - } - - /** - * 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.locationName ?? 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/index.html b/index.html index e57ea0d..75660e6 100644 --- a/index.html +++ b/index.html @@ -85,7 +85,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/src/game/Campaign.ts b/src/game/Campaign.ts index e57214f..cee416c 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -53,6 +53,7 @@ import { siteIdForContract, } from './locations.js'; import { resolveMapDimensions } from './procgen/mapDimensions.js'; +import { migrateLegacyKeycardId } from './entities/KeyCard.js'; import { normalizeCampaignChronicle, normalizePendingChronicleRun, @@ -1333,14 +1334,6 @@ export class Campaign { } } - /** - * Check whether the campaign inventory holds a key item that unlocks the - * given door id. Returns the matching `KeyItem` or `null`. - */ - keyItemForDoor(doorId: string): KeyItem | null { - return this.keyItems.find(k => k.doorId === doorId) ?? null; - } - // ─── Location memory / site roster (P2.5.M7.2) ─────────────────────────── /** Look up a remembered site by its `LocationSite.id`. */ @@ -2000,7 +1993,9 @@ function normalizeKeyItems(raw: unknown): KeyItem[] { } result.siteId = item.siteId; } - return result; + // Legacy saves stamped every site's card with the colliding bare id + // `keycard-`; re-key to the site-unique form on restore (P3.1). + return migrateLegacyKeycardId(result); }); } diff --git a/src/game/Run.ts b/src/game/Run.ts index b8a4cb6..87865b3 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -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'; @@ -1472,17 +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 site-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: item.id, - label: item.label, - doorId: item.doorId, - ...(item.siteId ? { siteId: item.siteId } : {}), + id: canonical.id, + label: canonical.label, + doorId: canonical.doorId, + ...(canonical.siteId ? { siteId: canonical.siteId } : {}), }); } + /** + * The keycards effective at this run's site: run-scoped pickups plus any + * campaign-held cards stamped for the *current* site. Cards from other sites + * are excluded so the combat inventory and the door-unlock context stay + * scoped to 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 foreign card open this door (P3.1 fix). Deduped by id. + */ + effectiveKeyItems(campaignKeyItems: readonly KeyItem[]): KeyItem[] { + const siteId = this.contract ? siteIdForContract(this.contract) : null; + const siteScoped = siteId ? campaignKeyItems.filter(k => k.siteId === siteId) : []; + const seen = new Set(); + const result: KeyItem[] = []; + for (const k of [...siteScoped, ...this.keyItems]) { + if (seen.has(k.id)) continue; + seen.add(k.id); + result.push({ ...k }); + } + return result; + } + mapSeenKeys(): string[] { return [...this.mapSeen].sort(compareCoordKeys); } @@ -2044,7 +2068,7 @@ export class Run { const keycardSiteId = siteIdForContract(this.contract); this.world.addEntity( new KeyCard({ - id: `keycard-${linkedDoorId}`, + id: keycardIdFor(linkedDoorId, keycardSiteId), x: keycardAnchor.x, y: keycardAnchor.y, doorId: linkedDoorId, diff --git a/src/game/entities/KeyCard.ts b/src/game/entities/KeyCard.ts index bd9f1af..6609aa0 100644 --- a/src/game/entities/KeyCard.ts +++ b/src/game/entities/KeyCard.ts @@ -43,6 +43,32 @@ export type KeyCardSnapshot = { siteId: string | null; }; +/** + * Canonical keycard id. The site id is baked in so two sites that share the + * stable objective `doorId` (`door-0` on every generated map) still get + * distinct ids — the P3.1 fix for the "inventory shows two keycards" collision. + * Run-scoped cards (no siteId) 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, siteId?: string | null): string { + return siteId ? `keycard-${doorId}-${siteId}` : `keycard-${doorId}`; +} + +/** + * Migrate a legacy save's bare `keycard-` id to the site-unique form. + * Only the exact legacy shape *with* a siteId is rewritten — post-fix ids, + * run-scoped ids (no siteId), and custom test ids are left untouched, so the + * transform is idempotent and safe to run on every restore. + */ +export function migrateLegacyKeycardId( + item: T +): T { + if (item.siteId && item.id === `keycard-${item.doorId}`) { + return { ...item, id: keycardIdFor(item.doorId, item.siteId) }; + } + return item; +} + export class KeyCard extends Entity { doorId: string; label: string; diff --git a/src/game/persistence.ts b/src/game/persistence.ts index a7e6de3..89af67f 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'; @@ -2129,7 +2129,9 @@ function normalizeRunKeyItems(raw: unknown): KeyItem[] { } result.siteId = item.siteId; } - return result; + // Re-key legacy colliding `keycard-` ids to the site-unique form + // (P3.1); symmetric with the campaign inventory migration. + return migrateLegacyKeycardId(result); }); } 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/shell/domTypes.ts b/src/shell/domTypes.ts index de7722f..d799663 100644 --- a/src/shell/domTypes.ts +++ b/src/shell/domTypes.ts @@ -97,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?: 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 & { diff --git a/src/shell/keycard-debug.json b/src/shell/keycard-debug.json new file mode 100644 index 0000000..4615aa3 --- /dev/null +++ b/src/shell/keycard-debug.json @@ -0,0 +1,1941 @@ +{ + "prefs": {}, + "runs": [], + "campaign": { + "id": "campaign-1ad3f9f8-mr19pywo", + "type": "campaign", + "state": "COMBAT", + "seed": 450099704, + "rng": { "seed": 450099704, "state": 1051630174 }, + "crew": [ + { + "archetype": "razor", + "id": "crew-init-1", + "callsign": "Wren", + "flatlined": true, + "hp": 0, + "maxHp": 3, + "ap": 0, + "maxAp": 4, + "damageReduction": 0, + "alive": false, + "inventory": { + "salvage": { "scrap": 0, "chips": 0, "bio": 0, "data": 0 }, + "consumables": [ + { + "id": "stim", + "label": "", + "scope": "", + "cost": 0, + "description": "", + "needsTarget": false + }, + { + "id": "stim", + "label": "", + "scope": "", + "cost": 0, + "description": "", + "needsTarget": false + }, + { + "id": "stim", + "label": "", + "scope": "", + "cost": 0, + "description": "", + "needsTarget": false + }, + { + "id": "stim", + "label": "", + "scope": "", + "cost": 0, + "description": "", + "needsTarget": false + } + ] + }, + "gear": null + }, + { + "archetype": "tech", + "id": "crew-init-2", + "callsign": "Vector", + "flatlined": true, + "hp": 0, + "maxHp": 3, + "ap": 0, + "maxAp": 4, + "damageReduction": 0, + "alive": false, + "inventory": { + "salvage": { "scrap": 0, "chips": 0, "bio": 0, "data": 0 }, + "consumables": [ + { + "id": "stim", + "label": "", + "scope": "", + "cost": 0, + "description": "", + "needsTarget": false + }, + { + "id": "stim", + "label": "", + "scope": "", + "cost": 0, + "description": "", + "needsTarget": false + }, + { + "id": "stim", + "label": "", + "scope": "", + "cost": 0, + "description": "", + "needsTarget": false + }, + { + "id": "stim", + "label": "", + "scope": "", + "cost": 0, + "description": "", + "needsTarget": false + }, + { + "id": "incendiary", + "label": "", + "scope": "", + "cost": 0, + "description": "", + "needsTarget": false + } + ] + }, + "gear": null + }, + { + "archetype": "razor", + "id": "recruit-0-2042", + "callsign": "Smoke", + "flatlined": true, + "hp": 0, + "maxHp": 3, + "ap": 0, + "maxAp": 4, + "damageReduction": 0, + "alive": false, + "inventory": { + "salvage": { "scrap": 0, "chips": 0, "bio": 0, "data": 0 }, + "consumables": [] + }, + "gear": { + "maxHpBonus": 0, + "hitBonus": 0, + "dodgeBonus": 0, + "rangedDamageBonus": 0, + "meleeDamageBonus": 1, + "armorBonus": 0, + "apBonus": 0, + "shieldRegen": 0, + "hpRegen": 0 + } + }, + { + "archetype": "decker", + "id": "crew-decker-64ac", + "callsign": "Bytesize", + "flatlined": false, + "hp": 3, + "maxHp": 3, + "ap": 1, + "maxAp": 4, + "damageReduction": 0, + "alive": true, + "inventory": { + "salvage": { "scrap": 0, "chips": 0, "bio": 0, "data": 0 }, + "consumables": [ + { + "id": "smoke-charge", + "label": "", + "scope": "", + "cost": 0, + "description": "", + "needsTarget": false + }, + { + "id": "stim", + "label": "", + "scope": "", + "cost": 0, + "description": "", + "needsTarget": false + }, + { + "id": "stim", + "label": "", + "scope": "", + "cost": 0, + "description": "", + "needsTarget": false + } + ] + }, + "gear": null, + "cyber": { "ram": 8, "intrusion": 2, "iceResistance": 1 } + }, + { + "archetype": "tech", + "id": "recruit-0-38264", + "callsign": "Static", + "flatlined": false, + "hp": 3, + "maxHp": 3, + "ap": 4, + "maxAp": 4, + "damageReduction": 0, + "alive": true, + "inventory": { + "salvage": { "scrap": 0, "chips": 0, "bio": 0, "data": 0 }, + "consumables": [ + { + "id": "stim", + "label": "", + "scope": "", + "cost": 0, + "description": "", + "needsTarget": false + } + ] + }, + "gear": null + }, + { + "archetype": "razor", + "id": "recruit-1-13534", + "callsign": "Mantis", + "flatlined": true, + "hp": 0, + "maxHp": 3, + "ap": 0, + "maxAp": 4, + "damageReduction": 0, + "alive": false, + "inventory": { + "salvage": { "scrap": 0, "chips": 0, "bio": 0, "data": 0 }, + "consumables": [ + { + "id": "stim", + "label": "", + "scope": "", + "cost": 0, + "description": "", + "needsTarget": false + }, + { + "id": "incendiary", + "label": "", + "scope": "", + "cost": 0, + "description": "", + "needsTarget": false + } + ] + }, + "gear": { + "maxHpBonus": 0, + "hitBonus": 0, + "dodgeBonus": 0, + "rangedDamageBonus": 0, + "meleeDamageBonus": 1, + "armorBonus": 0, + "apBonus": 0, + "shieldRegen": 0, + "hpRegen": 0 + } + }, + { + "archetype": "razor", + "id": "recruit-0-62247", + "callsign": "Sable", + "flatlined": false, + "hp": 3, + "maxHp": 3, + "ap": 4, + "maxAp": 4, + "damageReduction": 0, + "alive": true, + "inventory": { + "salvage": { "scrap": 3, "chips": 0, "bio": 0, "data": 0 }, + "consumables": [] + }, + "gear": null + } + ], + "salvage": { "scrap": 0, "chips": 0, "bio": 0, "data": 0 }, + "credits": 28, + "rep": 96, + "meta": {}, + "arc": { + "arcStage": "act-2", + "deckerRecruited": true, + "scoreRevealed": true, + "clockStarted": true, + "scoreAttempted": false, + "scoreCompleted": false + }, + "deployedMemberId": "recruit-0-62247", + "deployedPartnerId": null, + "activeRun": { + "id": "run-3f5bfc78-mr1d803s", + "type": "run", + "state": "COMBAT", + "crewMemberId": "recruit-0-62247", + "partnerMemberId": null, + "archetype": "razor", + "seed": 1062993016, + "rng": { "seed": 1062993016, "state": 2830617632 }, + "contract": { + "seed": 1062993016, + "mapWidth": 28, + "mapHeight": 18, + "objective": { + "kind": "handoff", + "title": "Make cryo convoy manifest handoff", + "briefing": "Locate the Northstar Civic contact near auction floor, complete the cryo convoy manifest transfer, then extract.", + "params": { + "target": "cryo-manifest", + "contact": "indie journalist", + "requiresUnlock": true + } + }, + "difficulty": "elevated", + "threatCount": 3, + "label": "// Northstar Civic auction floor - cryo convoy manifest drop", + "context": { + "recipeId": "handoff-transfer", + "principal": { + "id": "northstar-civic", + "label": "Northstar Civic", + "groups": ["corp", "civic", "infrastructure"] + }, + "site": { + "id": "auction-floor", + "label": "auction floor", + "groups": ["street", "finance"] + }, + "asset": { + "id": "cryo-manifest", + "label": "cryo convoy manifest", + "groups": ["handoff", "logistics"] + }, + "action": { "id": "drop", "label": "drop", "groups": ["handoff"] }, + "tags": [ + "meatspace", + "contact", + "social", + "objective:handoff", + "principal:northstar-civic", + "principal:corp", + "principal:civic", + "principal:infrastructure", + "site:auction-floor", + "site:street", + "site:finance", + "asset:cryo-manifest", + "asset:handoff", + "asset:logistics", + "action:drop", + "action:handoff" + ], + "arcStage": "act-2" + }, + "reward": { "credits": 74, "repDelta": 7 } + }, + "telemetry": { + "archetype": "razor", + "seed": 1062993016, + "turn": 8, + "kills": 1, + "lastDamageSource": null, + "lastAttacker": null, + "hpAtDeath": null, + "cause": null, + "outcome": null + }, + "snapshot": { + "id": "run-3f5bfc78-mr1d803s", + "type": "run", + "state": "COMBAT", + "archetype": "razor", + "seed": 1062993016, + "turnNumber": 8, + "currentFaction": "player", + "rng": { "seed": 1062993016, "state": 2830617632 }, + "contract": { + "seed": 1062993016, + "mapWidth": 28, + "mapHeight": 18, + "objective": { + "kind": "handoff", + "title": "Make cryo convoy manifest handoff", + "briefing": "Locate the Northstar Civic contact near auction floor, complete the cryo convoy manifest transfer, then extract.", + "params": { + "target": "cryo-manifest", + "contact": "indie journalist", + "requiresUnlock": true + } + }, + "difficulty": "elevated", + "threatCount": 3, + "label": "// Northstar Civic auction floor - cryo convoy manifest drop", + "context": { + "recipeId": "handoff-transfer", + "principal": { + "id": "northstar-civic", + "label": "Northstar Civic", + "groups": ["corp", "civic", "infrastructure"] + }, + "site": { + "id": "auction-floor", + "label": "auction floor", + "groups": ["street", "finance"] + }, + "asset": { + "id": "cryo-manifest", + "label": "cryo convoy manifest", + "groups": ["handoff", "logistics"] + }, + "action": { "id": "drop", "label": "drop", "groups": ["handoff"] }, + "tags": [ + "meatspace", + "contact", + "social", + "objective:handoff", + "principal:northstar-civic", + "principal:corp", + "principal:civic", + "principal:infrastructure", + "site:auction-floor", + "site:street", + "site:finance", + "asset:cryo-manifest", + "asset:handoff", + "asset:logistics", + "action:drop", + "action:handoff" + ], + "arcStage": "act-2" + }, + "reward": { "credits": 74, "repDelta": 7 } + }, + "exitTile": { "x": 22, "y": 15 }, + "grid": { + "w": 28, + "h": 18, + "tiles": [ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 2, 0, 2, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 0, 2, + 0, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, + 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 0, 2, 0, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 2, 0, 0, 2, 0, 0, 0, 0, 0, 0, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 0, 2, 0, 0, 2, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 + ] + }, + "entities": [ + { + "archetype": "razor", + "id": "recruit-0-62247", + "x": 7, + "y": 6, + "faction": "player", + "glyph": "@", + "hp": 3, + "maxHp": 3, + "damageReduction": 0, + "shieldHp": 0, + "ap": 4, + "maxAp": 4, + "alive": true, + "stealthed": false, + "extra": { + "callsign": "Sable", + "flatlined": false, + "inventory": { + "salvage": { "scrap": 3, "chips": 0, "bio": 0, "data": 0 }, + "consumables": [] + }, + "gear": null + } + }, + { + "archetype": "door", + "id": "door-entity-0", + "x": 13, + "y": 6, + "faction": "neutral", + "glyph": "▪", + "hp": 1, + "maxHp": 1, + "damageReduction": 0, + "shieldHp": 0, + "ap": 0, + "maxAp": 0, + "alive": true, + "stealthed": false, + "extra": { "doorId": "door-0", "locked": true } + }, + { + "archetype": "contact", + "id": "contact-0", + "x": 19, + "y": 5, + "faction": "neutral", + "glyph": "&", + "hp": 1, + "maxHp": 1, + "damageReduction": 0, + "shieldHp": 0, + "ap": 0, + "maxAp": 0, + "alive": true, + "stealthed": false, + "extra": { "label": "Indie journalist", "handoffComplete": false, "armed": true } + }, + { + "archetype": "drone", + "id": "drone-0", + "x": 15, + "y": 7, + "faction": "corp", + "glyph": "k", + "hp": 3, + "maxHp": 3, + "damageReduction": 0, + "shieldHp": 0, + "ap": 0, + "maxAp": 3, + "alive": true, + "stealthed": false, + "displayName": "Patrol Officer", + "principalTag": "N*", + "extra": { + "state": "patrol", + "lastKnownTarget": null, + "patrolWaypoints": [ + { "x": 15, "y": 5 }, + { "x": 15, "y": 7 } + ], + "patrolIndex": 1 + } + }, + { + "archetype": "guard", + "id": "guard-2", + "x": 18, + "y": 6, + "faction": "corp", + "glyph": "g", + "hp": 3, + "maxHp": 3, + "damageReduction": 0, + "shieldHp": 0, + "ap": 0, + "maxAp": 3, + "alive": true, + "stealthed": false, + "displayName": "Civic Guard", + "principalTag": "N*", + "extra": { + "state": "patrol", + "lastKnownTarget": null, + "patrolWaypoints": [ + { "x": 19, "y": 4 }, + { "x": 19, "y": 7 } + ], + "patrolIndex": 0 + } + }, + { + "archetype": "sniper", + "id": "sniper-0", + "x": 10, + "y": 4, + "faction": "corp", + "glyph": "s", + "hp": 3, + "maxHp": 3, + "damageReduction": 0, + "shieldHp": 0, + "ap": 0, + "maxAp": 4, + "alive": true, + "stealthed": false, + "displayName": "Marksman", + "principalTag": "N*", + "extra": { + "state": "patrol", + "lastKnownTarget": null, + "patrolWaypoints": [ + { "x": 10, "y": 4 }, + { "x": 11, "y": 4 } + ], + "patrolIndex": 0, + "aimTargetId": null + } + }, + { + "archetype": "corp-civilian", + "id": "corp-civ-0", + "x": 15, + "y": 5, + "faction": "corp", + "glyph": "c", + "hp": 1, + "maxHp": 1, + "damageReduction": 0, + "shieldHp": 0, + "ap": 1, + "maxAp": 1, + "alive": true, + "stealthed": false + }, + { + "archetype": "consumable-pickup", + "id": "consumable-pickup-0", + "x": 14, + "y": 6, + "faction": "neutral", + "glyph": "*", + "hp": 1, + "maxHp": 1, + "damageReduction": 0, + "shieldHp": 0, + "ap": 0, + "maxAp": 0, + "alive": true, + "stealthed": false, + "extra": { "consumableId": "smoke-charge", "label": "Smoke Charge" } + } + ], + "telemetry": { + "archetype": "razor", + "seed": 1062993016, + "turn": 8, + "kills": 1, + "lastDamageSource": null, + "lastAttacker": null, + "hpAtDeath": null, + "cause": null, + "outcome": null + }, + "alarm": { + "phase": "quiet", + "level": 0, + "holdTurnsRemaining": 0, + "cooldownTurnsRemaining": 0, + "triggers": 0 + }, + "alarmActive": false, + "objectiveTimer": { + "completedWithinLimit": false, + "expired": false, + "completedTurn": null, + "expiredTurn": null, + "expiryAnnounced": false + }, + "mapMemory": { + "seen": [ + "13,3", + "12,4", + "2,5", + "3,5", + "4,5", + "5,5", + "6,5", + "7,5", + "8,5", + "9,5", + "10,5", + "11,5", + "12,5", + "13,5", + "2,6", + "3,6", + "4,6", + "5,6", + "6,6", + "7,6", + "8,6", + "9,6", + "10,6", + "11,6", + "12,6", + "13,6", + "2,7", + "3,7", + "4,7", + "5,7", + "6,7", + "7,7", + "8,7", + "12,7", + "13,7", + "2,8", + "3,8", + "4,8", + "5,8", + "6,8", + "7,8", + "8,8", + "2,9", + "3,9", + "4,9", + "5,9", + "6,9", + "7,9", + "8,9", + "2,10", + "3,10", + "4,10", + "5,10", + "6,10", + "7,10", + "8,10", + "9,10", + "10,10", + "11,10", + "12,10", + "1,11", + "2,11", + "3,11", + "4,11", + "5,11", + "6,11", + "7,11", + "8,11", + "9,11", + "10,11", + "11,11", + "12,11", + "0,12", + "1,12", + "2,12", + "3,12", + "4,12", + "5,12", + "6,12", + "7,12", + "8,12", + "9,12", + "10,12", + "11,12", + "12,12", + "0,13", + "1,13", + "2,13", + "3,13", + "4,13", + "5,13", + "6,13", + "7,13", + "8,13", + "9,13", + "10,13", + "11,13", + "12,13", + "13,13", + "0,14", + "1,14", + "2,14", + "3,14", + "4,14", + "5,14", + "6,14", + "7,14", + "8,14", + "9,14", + "10,14", + "11,14", + "12,14", + "13,14", + "0,15", + "1,15", + "2,15", + "3,15", + "4,15", + "5,15", + "6,15", + "7,15", + "8,15", + "9,15", + "10,15", + "11,15", + "12,15", + "13,15" + ] + }, + "objectiveProgress": { "securedPickups": [] }, + "keyItems": [ + { + "id": "keycard-door-0", + "label": "Access keycard", + "doorId": "door-0", + "siteId": "1062993016" + } + ], + "mutationDeltas": [] + } + }, + "availableRecruits": [ + { + "archetype": "razor", + "id": "recruit-1-45923", + "callsign": "Mercury", + "flatlined": false, + "hp": 3, + "maxHp": 3, + "ap": 4, + "maxAp": 4, + "damageReduction": 0, + "alive": true, + "inventory": null, + "gear": null + } + ], + "recruitedThisVisit": true, + "pendingRecruitReward": false, + "rewardRecruitIds": [], + "healedThisVisit": [], + "hubReveals": { + "terminalRecruitmentExplained": true, + "finnIntroduced": true, + "terminalExplained": true, + "clinicIntroduced": true, + "scoreBriefingPresented": true, + "clockBriefingPresented": true + }, + "completedJobs": 5, + "clockJobsTaken": 4, + "keyItems": [ + { + "id": "keycard-door-0", + "label": "Access keycard", + "doorId": "door-0", + "siteId": "210241126" + } + ], + "siteRoster": [ + { + "id": "359644170", + "seed": "359644170", + "mapWidth": 28, + "mapHeight": 16, + "label": "// Redline Union Sublevel 3 - Gassed relay nodes blind", + "tier": "roster", + "scoreTarget": false, + "mutationDeltas": [], + "seenKeys": [ + "0,1", + "0,2", + "0,3", + "0,4", + "0,8", + "0,9", + "0,10", + "0,11", + "0,12", + "0,13", + "1,1", + "1,2", + "1,3", + "1,4", + "1,5", + "1,8", + "1,9", + "1,10", + "1,11", + "1,12", + "1,13", + "2,1", + "2,2", + "2,3", + "2,4", + "2,5", + "2,8", + "2,9", + "2,10", + "2,11", + "2,12", + "2,13", + "3,1", + "3,2", + "3,3", + "3,4", + "3,5", + "3,6", + "3,7", + "3,8", + "3,9", + "3,10", + "3,11", + "3,12", + "3,13", + "4,1", + "4,2", + "4,3", + "4,4", + "4,5", + "4,6", + "4,7", + "4,8", + "4,9", + "4,10", + "4,11", + "4,12", + "4,13", + "5,1", + "5,2", + "5,3", + "5,4", + "5,5", + "5,6", + "5,7", + "5,8", + "5,9", + "5,10", + "5,11", + "5,12", + "5,13", + "6,1", + "6,2", + "6,3", + "6,4", + "6,5", + "6,6", + "6,7", + "6,8", + "6,9", + "6,10", + "6,11", + "6,12", + "6,13", + "7,1", + "7,2", + "7,3", + "7,4", + "7,5", + "7,6", + "7,7", + "7,8", + "7,9", + "7,10", + "7,11", + "7,12", + "7,13", + "8,3", + "8,4", + "8,5", + "8,6", + "8,7", + "8,8", + "8,9", + "8,10", + "8,11", + "8,12", + "8,13", + "9,1", + "9,2", + "9,3", + "9,4", + "9,5", + "9,6", + "9,7", + "9,8", + "9,9", + "9,10", + "9,11", + "9,12", + "9,13", + "10,1", + "10,2", + "10,3", + "10,4", + "10,5", + "10,6", + "10,7", + "10,8", + "10,9", + "10,10", + "10,11", + "10,12", + "10,13", + "11,1", + "11,2", + "11,3", + "11,4", + "11,5", + "11,6", + "11,7", + "11,8", + "11,9", + "11,10", + "11,11", + "11,12", + "11,13", + "12,1", + "12,2", + "12,3", + "12,4", + "12,5", + "12,6", + "12,7", + "12,8", + "12,9", + "12,10", + "12,11", + "12,12", + "12,13", + "13,1", + "13,2", + "13,3", + "13,4", + "13,5", + "13,6", + "13,7", + "13,8", + "13,9", + "13,10", + "13,11", + "13,12", + "13,13", + "14,1", + "14,2", + "14,3", + "14,4", + "14,5", + "14,6", + "14,7", + "14,8", + "14,9", + "14,10", + "14,11", + "14,12", + "14,13", + "15,1", + "15,2", + "15,3", + "15,4", + "15,5", + "15,6", + "15,7", + "15,8", + "15,9", + "15,10", + "15,11", + "15,12", + "15,13", + "16,1", + "16,2", + "16,3", + "16,4", + "16,5", + "16,6", + "16,7", + "16,8", + "16,9", + "16,10", + "16,11", + "16,12", + "16,13", + "17,1", + "17,2", + "17,3", + "17,4", + "17,5", + "17,6", + "17,7", + "17,8", + "17,9", + "17,10", + "17,11", + "17,12", + "17,13", + "18,3", + "18,4", + "18,5", + "19,3", + "19,4", + "19,5", + "20,3", + "20,4", + "20,5", + "20,6", + "20,7", + "21,3", + "21,4", + "21,5", + "21,6", + "21,7", + "21,8", + "21,9", + "21,10", + "21,11", + "21,12", + "21,13", + "21,14", + "22,3", + "22,4", + "22,5", + "22,6", + "22,7", + "22,8", + "22,9", + "22,10", + "22,11", + "22,12", + "22,13", + "22,14", + "23,3", + "23,4", + "23,5", + "23,6", + "23,7", + "23,8", + "23,9", + "23,10", + "23,11", + "23,12", + "23,13", + "23,14", + "24,3", + "24,4", + "24,5", + "24,6", + "24,7", + "24,8", + "24,9", + "24,10", + "24,11", + "24,12", + "24,13", + "24,14", + "25,3", + "25,4", + "25,5", + "25,6", + "25,7", + "25,8", + "25,9", + "25,10", + "25,11", + "25,12", + "25,13", + "25,14", + "26,3", + "26,4", + "26,5", + "26,6", + "26,7", + "26,8", + "26,9", + "26,10", + "26,11", + "26,12", + "26,13", + "26,14", + "27,3", + "27,4", + "27,5", + "27,6", + "27,7", + "27,8", + "27,9", + "27,10", + "27,11", + "27,12", + "27,13", + "27,14" + ], + "lastVisitedJob": 2, + "principal": { + "id": "redline-union", + "label": "Redline Union", + "groups": ["rival", "logistics"] + }, + "site": { + "id": "sublevel-3", + "label": "Sublevel 3", + "groups": ["infrastructure", "hidden"] + } + }, + { + "id": "210241126", + "seed": "210241126", + "mapWidth": 28, + "mapHeight": 18, + "label": "// Vuong Holdings skybridge - shipment override", + "tier": "roster", + "scoreTarget": false, + "mutationDeltas": [], + "seenKeys": [ + "0,1", + "0,2", + "0,3", + "0,4", + "0,5", + "0,6", + "0,7", + "0,12", + "0,13", + "0,14", + "1,1", + "1,2", + "1,3", + "1,4", + "1,5", + "1,6", + "1,7", + "1,12", + "1,13", + "1,14", + "2,1", + "2,2", + "2,3", + "2,4", + "2,5", + "2,6", + "2,7", + "2,11", + "2,12", + "2,13", + "2,14", + "3,1", + "3,2", + "3,3", + "3,4", + "3,5", + "3,6", + "3,7", + "3,8", + "3,9", + "3,10", + "3,11", + "3,12", + "3,13", + "3,14", + "4,1", + "4,2", + "4,3", + "4,4", + "4,5", + "4,6", + "4,7", + "4,8", + "4,9", + "4,10", + "4,11", + "4,12", + "4,13", + "4,15", + "5,1", + "5,2", + "5,3", + "5,4", + "5,5", + "5,6", + "5,7", + "5,8", + "5,9", + "5,10", + "5,11", + "5,12", + "5,13", + "5,14", + "5,15", + "5,16", + "6,1", + "6,2", + "6,3", + "6,4", + "6,5", + "6,6", + "6,7", + "6,8", + "6,9", + "6,10", + "6,11", + "7,4", + "7,5", + "7,6", + "7,7", + "7,8", + "7,9", + "7,10", + "7,11", + "8,4", + "8,5", + "8,6", + "8,7", + "8,8", + "8,9", + "8,10", + "8,11", + "9,1", + "9,2", + "9,3", + "9,4", + "9,5", + "9,6", + "9,7", + "9,8", + "9,9", + "9,10", + "9,11", + "9,12", + "9,13", + "9,14", + "9,15", + "9,16", + "10,1", + "10,2", + "10,3", + "10,4", + "10,5", + "10,6", + "10,7", + "10,8", + "10,9", + "10,10", + "10,11", + "10,12", + "10,13", + "10,14", + "10,15", + "10,16", + "11,1", + "11,2", + "11,3", + "11,4", + "11,5", + "11,6", + "11,7", + "11,8", + "11,9", + "11,10", + "11,11", + "11,12", + "11,13", + "11,14", + "11,15", + "11,16", + "12,1", + "12,2", + "12,3", + "12,4", + "12,5", + "12,6", + "12,7", + "12,8", + "12,9", + "12,10", + "12,11", + "12,12", + "12,13", + "12,14", + "12,15", + "12,16", + "13,1", + "13,2", + "13,3", + "13,4", + "13,5", + "13,6", + "13,7", + "13,8", + "13,9", + "13,10", + "13,11", + "13,12", + "13,13", + "13,14", + "13,15", + "13,16", + "14,1", + "14,2", + "14,3", + "14,4", + "14,5", + "14,6", + "14,7", + "14,8", + "14,9", + "14,10", + "14,11", + "14,12", + "14,13", + "14,14", + "14,15", + "14,16", + "15,0", + "15,1", + "15,2", + "15,3", + "15,4", + "15,5", + "15,6", + "15,7", + "15,8", + "15,9", + "15,10", + "15,11", + "15,12", + "15,13", + "15,14", + "15,15", + "15,16", + "16,0", + "16,1", + "16,2", + "16,3", + "16,4", + "16,5", + "16,6", + "16,7", + "16,8", + "16,9", + "16,10", + "16,11", + "16,12", + "16,13", + "16,14", + "17,0", + "17,1", + "17,2", + "17,3", + "17,4", + "17,5", + "17,6", + "17,7", + "17,8", + "17,9", + "17,10", + "17,11", + "17,12", + "17,13", + "17,14", + "18,0", + "18,1", + "18,2", + "18,3", + "18,4", + "18,5", + "18,6", + "18,7", + "18,8", + "18,9", + "18,10", + "18,11", + "18,12", + "18,13", + "18,14", + "19,0", + "19,1", + "19,2", + "19,3", + "19,4", + "19,5", + "19,6", + "19,7", + "19,8", + "19,9", + "19,10", + "19,11", + "19,12", + "19,13", + "19,14", + "20,0", + "20,1", + "20,2", + "20,3", + "20,4", + "20,5", + "20,6", + "20,7", + "20,8", + "20,9", + "20,10", + "20,11", + "20,12", + "20,13", + "20,14", + "21,0", + "21,1", + "21,2", + "21,3", + "21,4", + "21,5", + "21,6", + "21,7", + "21,8", + "21,9", + "21,10", + "21,11", + "21,12", + "21,13", + "21,14", + "22,0", + "22,1", + "22,2", + "22,3", + "22,4", + "22,5", + "22,6", + "22,7", + "22,8", + "22,9", + "22,10", + "22,11", + "22,12", + "22,13", + "22,14", + "23,0", + "23,1", + "23,2", + "23,3", + "23,4", + "23,5", + "23,6", + "23,7", + "23,8", + "23,9", + "23,10", + "23,11", + "23,12", + "23,13", + "24,0", + "24,1", + "24,2", + "24,3", + "24,4", + "24,5", + "24,6", + "24,7", + "24,8", + "24,9", + "24,10", + "24,11", + "24,12", + "25,0", + "25,1", + "25,2", + "25,3", + "25,4", + "25,5", + "25,6", + "25,7", + "26,0", + "26,1", + "26,2", + "26,3", + "26,4", + "26,5", + "26,6", + "26,7" + ], + "lastVisitedJob": 4, + "principal": { + "id": "vuong-holdings", + "label": "Vuong Holdings", + "groups": ["corp", "data"] + }, + "site": { + "id": "skybridge", + "label": "skybridge", + "groups": ["infrastructure", "security"] + } + }, + { + "id": "score-1363972140", + "seed": "1363972140", + "mapWidth": 32, + "mapHeight": 20, + "label": "// Northstar Civic contractor annex - Score target", + "tier": "score", + "scoreTarget": true, + "mutationDeltas": [], + "seenKeys": [], + "lastVisitedJob": 0, + "principal": { + "id": "northstar-civic", + "label": "Northstar Civic", + "groups": ["corp", "civic", "infrastructure"] + }, + "site": { + "id": "contractor-annex", + "label": "contractor annex", + "groups": ["corp", "finance"] + } + }, + { + "id": "306547716", + "seed": "306547716", + "mapWidth": 28, + "mapHeight": 18, + "label": "// Northstar Civic Pier 7 - Flooded relay nodes sweep", + "tier": "roster", + "scoreTarget": false, + "mutationDeltas": [], + "seenKeys": [], + "lastVisitedJob": 5, + "principal": { + "id": "northstar-civic", + "label": "Northstar Civic", + "groups": ["corp", "civic", "infrastructure"] + }, + "site": { "id": "pier-7", "label": "Pier 7", "groups": ["infrastructure", "street"] } + }, + { + "id": "570181429", + "seed": "570181429", + "mapWidth": 28, + "mapHeight": 18, + "label": "// Northstar Civic Sublevel 3 - Blacked-out Redline courier exfil", + "tier": "roster", + "scoreTarget": false, + "mutationDeltas": [], + "seenKeys": [], + "lastVisitedJob": 5, + "principal": { + "id": "northstar-civic", + "label": "Northstar Civic", + "groups": ["corp", "civic", "infrastructure"] + }, + "site": { + "id": "sublevel-3", + "label": "Sublevel 3", + "groups": ["infrastructure", "hidden"] + } + }, + { + "id": "1062993016", + "seed": "1062993016", + "mapWidth": 28, + "mapHeight": 18, + "label": "// Northstar Civic auction floor - cryo convoy manifest drop", + "tier": "roster", + "scoreTarget": false, + "mutationDeltas": [], + "seenKeys": [], + "lastVisitedJob": 5, + "principal": { + "id": "northstar-civic", + "label": "Northstar Civic", + "groups": ["corp", "civic", "infrastructure"] + }, + "site": { "id": "auction-floor", "label": "auction floor", "groups": ["street", "finance"] } + } + ], + "chronicle": [ + { + "id": "chronicle-0", + "sequence": 0, + "kind": "milestone", + "stage": "act-1", + "title": "CREW ASSEMBLED", + "summary": "The first operators are in place: Wren, Vector.", + "detailLines": ["Starter crew 2 operators", "Street-level contracts are now live."] + }, + { + "id": "chronicle-1", + "sequence": 1, + "kind": "job", + "stage": "act-1", + "title": "STREET JOB", + "summary": "// Spinning Fox clinic - Blacked-out floodgate bulkhead demolition kept the crew fed and moving through the street-level grind.", + "detailLines": [ + "Objective: Breach floodgate bulkhead", + "Outcome: objective complete", + "Rep +14 | Credits +39", + "Completed jobs 0 -> 1" + ] + }, + { + "id": "chronicle-2", + "sequence": 2, + "kind": "job", + "stage": "act-1", + "title": "STREET JOB", + "summary": "// Null Saints Block 9 - site layout map went bad and the deployed operator never came home.", + "detailLines": [ + "Objective: Map site layout", + "Outcome: operator flatlined", + "Rep +0 | Credits +0", + "Completed jobs 1 -> 1", + "Losses: Wren" + ] + }, + { + "id": "chronicle-3", + "sequence": 3, + "kind": "job", + "stage": "act-1", + "title": "STREET JOB", + "summary": "// District Water Board harbor - Blacked-out floodgate bulkhead demolition kept the crew fed and moving through the street-level grind.", + "detailLines": [ + "Objective: Breach floodgate bulkhead", + "Outcome: objective complete", + "Rep +14 | Credits +35", + "Completed jobs 1 -> 2" + ] + }, + { + "id": "chronicle-4", + "sequence": 4, + "kind": "job", + "stage": "act-1", + "title": "STREET JOB", + "summary": "// Redline Union Sublevel 3 - Gassed relay nodes blind kept the crew fed and moving through the street-level grind.", + "detailLines": [ + "Objective: Sweep relay nodes", + "Outcome: objective complete", + "Rep +14 | Credits +27", + "Completed jobs 2 -> 3" + ] + }, + { + "id": "chronicle-5", + "sequence": 5, + "kind": "milestone", + "stage": "act-1", + "title": "RECRUITED — Smoke", + "summary": "Smoke joined the crew as Razor.", + "detailLines": ["Crew size 2 living / 3 total"] + }, + { + "id": "chronicle-6", + "sequence": 6, + "kind": "job", + "stage": "act-1", + "title": "STREET JOB", + "summary": "// Vuong Holdings skybridge - shipment override kept the crew fed and moving through the street-level grind.", + "detailLines": [ + "Objective: Disable shipment", + "Outcome: objective complete", + "Rep +17 | Credits +43", + "Completed jobs 3 -> 4" + ] + }, + { + "id": "chronicle-7", + "sequence": 7, + "kind": "milestone", + "stage": "act-2", + "title": "STAGE 2 — SCORE REVEALED", + "summary": "The Curator named Northstar Civic contractor annex and assigned Bytesize to open it.", + "detailLines": [ + "Score target: Northstar Civic contractor annex", + "Decker assigned: Bytesize" + ] + }, + { + "id": "chronicle-8", + "sequence": 8, + "kind": "job", + "stage": "act-2", + "title": "RUN LOGGED", + "summary": "// Vuong Holdings skybridge - identity spool override was won, but the crew paid for it in blood.", + "detailLines": [ + "Objective: Spike identity spool", + "Outcome: objective complete", + "Rep +17 | Credits +70", + "Completed jobs 4 -> 5", + "Losses: Vector", + "Score target: Northstar Civic contractor annex" + ] + }, + { + "id": "chronicle-9", + "sequence": 9, + "kind": "milestone", + "stage": "act-2", + "title": "RECRUITED — Static", + "summary": "Static joined the crew as Tech.", + "detailLines": ["Crew size 3 living / 5 total"] + }, + { + "id": "chronicle-10", + "sequence": 10, + "kind": "job", + "stage": "act-2", + "title": "CASING — NORTHSTAR CIVIC", + "summary": "// Northstar Civic Pier 7 - Flooded relay nodes sweep went bad and the deployed operator never came home.", + "detailLines": [ + "Objective: Sweep relay nodes", + "Outcome: operator flatlined", + "Rep +0 | Credits +0", + "Completed jobs 5 -> 5", + "Losses: Smoke", + "Casing principal: Northstar Civic", + "Score target: Northstar Civic contractor annex" + ] + }, + { + "id": "chronicle-11", + "sequence": 11, + "kind": "milestone", + "stage": "act-2", + "title": "RECRUITED — Mantis", + "summary": "Mantis joined the crew as Razor.", + "detailLines": ["Crew size 3 living / 6 total"] + }, + { + "id": "chronicle-12", + "sequence": 12, + "kind": "job", + "stage": "act-2", + "title": "CASING — NORTHSTAR CIVIC", + "summary": "// Northstar Civic Sublevel 3 - Blacked-out Redline courier exfil went bad and the deployed operator never came home.", + "detailLines": [ + "Objective: Extract Redline courier", + "Outcome: operator flatlined", + "Rep +0 | Credits +0", + "Completed jobs 5 -> 5", + "Losses: Mantis", + "Casing principal: Northstar Civic", + "Score target: Northstar Civic contractor annex" + ] + }, + { + "id": "chronicle-13", + "sequence": 13, + "kind": "milestone", + "stage": "act-2", + "title": "CLOCK IS LIVE", + "summary": "The operational window is now burning down. Every deploy adds heat.", + "detailLines": ["Clock heat 0", "Jobs left 5"] + }, + { + "id": "chronicle-14", + "sequence": 14, + "kind": "milestone", + "stage": "act-2", + "title": "RECRUITED — Sable", + "summary": "Sable joined the crew as Razor.", + "detailLines": ["Crew size 3 living / 7 total"] + } + ], + "pendingChronicleRun": { + "sequence": 15, + "stage": "act-2", + "contractLabel": "// Northstar Civic auction floor - cryo convoy manifest drop", + "objectiveTitle": "Make cryo convoy manifest handoff", + "scoreTargetName": "Northstar Civic contractor annex", + "principalLabel": "Northstar Civic", + "isScore": false, + "isCasing": true, + "repBefore": 96, + "creditsBefore": 28, + "completedJobsBefore": 5, + "flatlinedCrewIdsBefore": ["crew-init-1", "crew-init-2", "recruit-0-2042", "recruit-1-13534"] + } + }, + "campaignHistory": [ + { + "campaignId": "campaign-1a8f79e9-mr171qzt", + "completedAt": "2026-06-30T23:18:25.250Z", + "result": "loss", + "endReason": "decker-flatlined-score", + "seed": 445610473, + "completedJobs": 6, + "rep": 95, + "credits": 42, + "crewRoster": [ + { "callsign": "Wren", "archetype": "Razor", "flatlined": true }, + { "callsign": "Carver", "archetype": "Merc", "flatlined": false }, + { "callsign": "Volt", "archetype": "Tech", "flatlined": false }, + { "callsign": "Jack", "archetype": "Decker", "flatlined": true }, + { "callsign": "Phreak", "archetype": "Decker", "flatlined": true } + ] + }, + { + "campaignId": "campaign-14228140-mqzev48w", + "completedAt": "2026-06-30T20:57:32.482Z", + "result": "loss", + "endReason": "decker-flatlined-score", + "seed": 337805632, + "completedJobs": 8, + "rep": 100, + "credits": 111, + "crewRoster": [ + { "callsign": "Wren", "archetype": "Razor", "flatlined": false }, + { "callsign": "Cinder", "archetype": "Merc", "flatlined": false }, + { "callsign": "AcidBurn", "archetype": "Decker", "flatlined": true }, + { "callsign": "Is0bel", "archetype": "Decker", "flatlined": true } + ] + }, + { + "campaignId": "campaign-ebe56e-mqu2y4j2", + "completedAt": "2026-06-29T05:36:29.714Z", + "result": "win", + "endReason": "score-complete", + "seed": 15459694, + "completedJobs": 9, + "rep": 100, + "credits": 5004, + "crewRoster": [ + { "callsign": "Cipher", "archetype": "Razor", "flatlined": true }, + { "callsign": "Wraith", "archetype": "Merc", "flatlined": false }, + { "callsign": "Nyx", "archetype": "Tech", "flatlined": true }, + { "callsign": "Tr1nity", "archetype": "Decker", "flatlined": true }, + { "callsign": "Beacon", "archetype": "Tech", "flatlined": true }, + { "callsign": "Wren", "archetype": "Razor", "flatlined": false }, + { "callsign": "Volt", "archetype": "Tech", "flatlined": false }, + { "callsign": "Z0ne", "archetype": "Decker", "flatlined": false } + ], + "scoreReward": { + "id": "monoblade", + "label": "Monoblade", + "flavor": "A monomolecular blade schematic — an edge that never dulls." + } + }, + { + "campaignId": "campaign-ade9a52c-mq71snxo", + "completedAt": "2026-06-25T22:34:40.433Z", + "result": "win", + "endReason": "score-complete", + "seed": 2917770540, + "completedJobs": 14, + "rep": 100, + "credits": 5011, + "crewRoster": [ + { "callsign": "Wraith", "archetype": "Merc", "flatlined": true }, + { "callsign": "Wire", "archetype": "Tech", "flatlined": false }, + { "callsign": "Veil", "archetype": "Razor", "flatlined": true }, + { "callsign": "Drift", "archetype": "Merc", "flatlined": false }, + { "callsign": "Blitz", "archetype": "Decker", "flatlined": false }, + { "callsign": "Saint", "archetype": "Razor", "flatlined": false } + ], + "scoreReward": { + "id": "phase-shield", + "label": "Phase Shield Prototype", + "flavor": "A phase-shield emitter that bleeds incoming kinetic force into a flicker field." + } + } + ], + "unlockedScoreableItems": ["phase-shield", "monoblade"] +} diff --git a/src/shell/shellRuntime.ts b/src/shell/shellRuntime.ts index cdb34cb..c9f80ac 100644 --- a/src/shell/shellRuntime.ts +++ b/src/shell/shellRuntime.ts @@ -80,7 +80,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 +120,10 @@ import type { GameOverElement, InitialRecruitElement, InputState, - ItemInventoryElement, + CombatInventoryElement, + CrewInventoryElement, KeyHelpElement, + KeyItemView, RunBriefingElement, SystemStartElement, TouchPadElement, @@ -197,7 +199,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 +392,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 +447,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 +589,8 @@ function hideBlockingShellModals(): void { crewRosterEl?.hide(); finnShopEl?.hide(); clinicModalEl?.hide(); - itemInventoryEl?.hide(); + combatInventoryEl?.hide(); + crewInventoryEl?.hide(); chronicleArchiveEl?.hide(); } @@ -765,6 +771,11 @@ function presentBriefing(contract: Contract) { function presentContractSelect(contracts: Contract[]) { contractSelectEl.setScoreTargetSiteId(campaign ? scoreTargetSiteId(campaign) : null); contractSelectEl.setScorePrincipalId(campaign ? scorePrincipalId(campaign) : null); + contractSelectEl.setHeldKeycardSiteIds( + campaign + ? campaign.keyItems.map(k => k.siteId).filter((id): id is string => typeof id === 'string') + : [] + ); contractSelectEl.setContracts(contracts); contractSelectEl.show(); } @@ -926,24 +937,19 @@ 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: keyItemsWithLocation(campaign.keyItems), }); - itemInventoryEl.show(); + crewInventoryEl.show(); return; } const run = campaign.activeRun; @@ -954,12 +960,16 @@ function presentItemInventory() { // gated to Meatspace upstream, so `activeActor` is always a Crew here. const operator = activeActorOf(run) as Crew | null; if (!operator || !operator.inventory) return; - itemInventoryEl.setContents({ + combatInventoryEl.setContents({ salvage: operator.inventory.salvage, consumables: operator.inventory.consumables, - keyItems: [...campaign.keyItems, ...run.keyItems], + // 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), }); - itemInventoryEl.show(); + combatInventoryEl.show(); } /** @@ -1024,7 +1034,7 @@ 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; @@ -1036,7 +1046,7 @@ function onUseItem(evt: Event) { flash(`USE FAILED: ${errorMessage(err)}`); return; } - itemInventoryEl.hide(); + combatInventoryEl.hide(); paint(); concludeOperatorTurn(); } @@ -1393,7 +1403,8 @@ function performQuitCampaign(): void { crewRosterEl.hide(); finnShopEl.hide(); clinicModalEl.hide(); - itemInventoryEl.hide(); + combatInventoryEl.hide(); + crewInventoryEl.hide(); pendingJobResult = null; dataStore.deleteCampaign(); @@ -1624,7 +1635,7 @@ export function handleIntent(intent: Intent): void { onCorpseSalvaged: entity => { activeVisionField(run).forgetCorpse(entity); }, - keyItems: [...(campaign?.keyItems ?? []), ...(run as Run).keyItems], + keyItems: (run as Run).effectiveKeyItems(campaign?.keyItems ?? []), onKeycardCollected: kc => { // Picked-up keycards are run-scoped during the run — still usable for // in-run door unlock via ctx.keyItems. Campaign.onJobEnd promotes the @@ -1655,7 +1666,7 @@ export function handleIntent(intent: Intent): void { return; } } - presentItemInventory(); + presentInventory(); break; case PLAYER_ACTIONS.INTERACT: handleInteract(); @@ -2655,7 +2666,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/tests/unit/game/keycard.test.ts b/tests/unit/game/keycard.test.ts index 9c790f9..3188bb5 100644 --- a/tests/unit/game/keycard.test.ts +++ b/tests/unit/game/keycard.test.ts @@ -15,7 +15,7 @@ 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, @@ -215,16 +215,6 @@ 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', () => { const c = makeCampaign(); c.addKeyItem({ id: 'kc-1', label: 'Card A', doorId: 'door-0', siteId: 'site-42' }); @@ -1101,6 +1091,198 @@ function makeSite(id: string, lastVisitedJob: number): LocationSite { }; } +// ─── P3.1: site-unique keycard ids (collision fix) ─────────────────────────── + +test('keycardIdFor: encodes the site id so distinct sites get distinct ids', () => { + assert.equal(keycardIdFor('door-0', 'siteA'), 'keycard-door-0-siteA'); + assert.equal(keycardIdFor('door-0', 'siteB'), 'keycard-door-0-siteB'); + assert.notEqual(keycardIdFor('door-0', 'siteA'), keycardIdFor('door-0', 'siteB')); +}); + +test('keycardIdFor: run-scoped card (no site 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 site-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); + const siteId = siteIdForContract(contract!); + assert.equal(keycard.id, keycardIdFor('door-0', siteId)); + assert.notEqual(keycard.id, 'keycard-door-0', 'legacy bare id would collide across sites'); +}); + +// ─── P3.1: legacy id migration on restore ──────────────────────────────────── + +test('migrateLegacyKeycardId: re-stamps a legacy bare id with its site id', () => { + const migrated = migrateLegacyKeycardId({ + id: 'keycard-door-0', + label: 'Access keycard', + doorId: 'door-0', + siteId: 'site-42', + }); + assert.equal(migrated.id, 'keycard-door-0-site-42'); +}); + +test('migrateLegacyKeycardId: idempotent on an already-migrated id', () => { + const item = { + id: 'keycard-door-0-site-42', + label: 'Access keycard', + doorId: 'door-0', + siteId: 'site-42', + }; + assert.equal(migrateLegacyKeycardId(item).id, 'keycard-door-0-site-42'); +}); + +test('migrateLegacyKeycardId: leaves run-scoped (no siteId) and custom ids untouched', () => { + assert.equal( + migrateLegacyKeycardId({ id: 'keycard-door-0', label: 'x', doorId: 'door-0' }).id, + 'keycard-door-0', + 'no siteId → nothing to disambiguate' + ); + assert.equal( + migrateLegacyKeycardId({ id: 'kc-1', label: 'x', doorId: 'door-0', siteId: 's' }).id, + 'kc-1', + 'non-legacy custom id is preserved' + ); +}); + +test('Campaign restore migrates a legacy bare keycard id to a site-unique id', () => { + const c = makeCampaign(); + const snap = snapshotCampaign(c); + (snap as Record).keyItems = [ + { id: 'keycard-door-0', label: 'Access keycard', doorId: 'door-0', siteId: '210241126' }, + ]; + const restored = restoreCampaign(snap); + assert.equal(restored.keyItems[0]!.id, 'keycard-door-0-210241126'); + assert.equal(restored.keyItems[0]!.siteId, '210241126'); +}); + +test('Run restore migrates a legacy bare keycard id to a site-unique id', () => { + 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', label: 'Access keycard', doorId: 'door-0', siteId: '1062993016' }, + ]; + const { run: restored } = restore(rec); + assert.equal(restored.keyItems[0]!.id, 'keycard-door-0-1062993016'); +}); + +test('migration repro: two colliding legacy cards survive as distinct cards', () => { + // The reported bug: a held Vuong card and a freshly-picked auction card both + // serialized as `keycard-door-0`. After migration they are distinct ids so + // promotion no longer silently drops one. + const campaign = makeCampaign(); + const snap = snapshotCampaign(campaign); + (snap as Record).keyItems = [ + { id: 'keycard-door-0', label: 'Access keycard', doorId: 'door-0', siteId: '210241126' }, + ]; + const restored = restoreCampaign(snap); + // Simulate the run-carried auction card promoting on extraction. + const runCardId = keycardIdFor('door-0', '1062993016'); + assert.notEqual(runCardId, restored.keyItems[0]!.id, 'ids no longer collide'); + restored.addKeyItem({ + id: runCardId, + label: 'Access keycard', + doorId: 'door-0', + siteId: '1062993016', + }); + assert.equal(restored.keyItems.length, 2, 'both site cards retained'); +}); + +// ─── P3.1: Run.effectiveKeyItems scopes to the current site ─────────────────── + +test('Run.effectiveKeyItems: includes only campaign cards for the current site', () => { + 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 siteId = siteIdForContract(contract!); + const campaignKeyItems: KeyItem[] = [ + { id: keycardIdFor('door-0', siteId), label: 'This site', doorId: 'door-0', siteId }, + { + id: keycardIdFor('door-0', 'other-site'), + label: 'Other site', + doorId: 'door-0', + siteId: 'other-site', + }, + ]; + const effective = run!.effectiveKeyItems(campaignKeyItems); + assert.ok( + effective.some(k => k.siteId === siteId), + 'current-site card is included' + ); + assert.ok( + !effective.some(k => k.siteId === 'other-site'), + 'other-site card is excluded (was the phantom second keycard)' + ); +}); + +test('Run.effectiveKeyItems: always includes run-scoped pickups and dedups by id', () => { + const run = new Run({ crewMember: makeCrew(), seed: 42 }); + run.enterBriefing(keycardDoorContract(42)); + const siteId = siteIdForContract(run.contract!); + const sharedId = keycardIdFor('door-0', siteId); + run.addKeyItem({ id: sharedId, label: 'Picked up', doorId: 'door-0', siteId }); + // Campaign also lists the same card (shouldn't double-render). + const effective = run.effectiveKeyItems([ + { id: sharedId, label: 'Held', doorId: 'door-0', siteId }, + ]); + 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 siteId. + run.addKeyItem({ + id: 'keycard-door-0', + label: 'Access keycard', + doorId: 'door-0', + siteId: '1062993016', + }); + assert.equal(run.keyItems[0]!.id, 'keycard-door-0-1062993016'); +}); + +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', siteId: 'site-x' }, + ]); + assert.deepEqual( + effective.map(k => k.id), + ['kc-run'] + ); +}); + test('addSiteToRoster eviction removes keycards belonging to the evicted site', () => { const campaign = makeCampaign(); // Fill the roster to capacity; site-0 is the oldest (lowest lastVisitedJob). From fb973bf540ccfb66985344958335adb221b2521f Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Mon, 6 Jul 2026 19:23:12 -0700 Subject: [PATCH 12/17] fix hub inventory crash --- src/shell/keycard-debug.json | 1941 ---------------------------------- src/shell/shellRuntime.ts | 9 +- 2 files changed, 8 insertions(+), 1942 deletions(-) delete mode 100644 src/shell/keycard-debug.json diff --git a/src/shell/keycard-debug.json b/src/shell/keycard-debug.json deleted file mode 100644 index 4615aa3..0000000 --- a/src/shell/keycard-debug.json +++ /dev/null @@ -1,1941 +0,0 @@ -{ - "prefs": {}, - "runs": [], - "campaign": { - "id": "campaign-1ad3f9f8-mr19pywo", - "type": "campaign", - "state": "COMBAT", - "seed": 450099704, - "rng": { "seed": 450099704, "state": 1051630174 }, - "crew": [ - { - "archetype": "razor", - "id": "crew-init-1", - "callsign": "Wren", - "flatlined": true, - "hp": 0, - "maxHp": 3, - "ap": 0, - "maxAp": 4, - "damageReduction": 0, - "alive": false, - "inventory": { - "salvage": { "scrap": 0, "chips": 0, "bio": 0, "data": 0 }, - "consumables": [ - { - "id": "stim", - "label": "", - "scope": "", - "cost": 0, - "description": "", - "needsTarget": false - }, - { - "id": "stim", - "label": "", - "scope": "", - "cost": 0, - "description": "", - "needsTarget": false - }, - { - "id": "stim", - "label": "", - "scope": "", - "cost": 0, - "description": "", - "needsTarget": false - }, - { - "id": "stim", - "label": "", - "scope": "", - "cost": 0, - "description": "", - "needsTarget": false - } - ] - }, - "gear": null - }, - { - "archetype": "tech", - "id": "crew-init-2", - "callsign": "Vector", - "flatlined": true, - "hp": 0, - "maxHp": 3, - "ap": 0, - "maxAp": 4, - "damageReduction": 0, - "alive": false, - "inventory": { - "salvage": { "scrap": 0, "chips": 0, "bio": 0, "data": 0 }, - "consumables": [ - { - "id": "stim", - "label": "", - "scope": "", - "cost": 0, - "description": "", - "needsTarget": false - }, - { - "id": "stim", - "label": "", - "scope": "", - "cost": 0, - "description": "", - "needsTarget": false - }, - { - "id": "stim", - "label": "", - "scope": "", - "cost": 0, - "description": "", - "needsTarget": false - }, - { - "id": "stim", - "label": "", - "scope": "", - "cost": 0, - "description": "", - "needsTarget": false - }, - { - "id": "incendiary", - "label": "", - "scope": "", - "cost": 0, - "description": "", - "needsTarget": false - } - ] - }, - "gear": null - }, - { - "archetype": "razor", - "id": "recruit-0-2042", - "callsign": "Smoke", - "flatlined": true, - "hp": 0, - "maxHp": 3, - "ap": 0, - "maxAp": 4, - "damageReduction": 0, - "alive": false, - "inventory": { - "salvage": { "scrap": 0, "chips": 0, "bio": 0, "data": 0 }, - "consumables": [] - }, - "gear": { - "maxHpBonus": 0, - "hitBonus": 0, - "dodgeBonus": 0, - "rangedDamageBonus": 0, - "meleeDamageBonus": 1, - "armorBonus": 0, - "apBonus": 0, - "shieldRegen": 0, - "hpRegen": 0 - } - }, - { - "archetype": "decker", - "id": "crew-decker-64ac", - "callsign": "Bytesize", - "flatlined": false, - "hp": 3, - "maxHp": 3, - "ap": 1, - "maxAp": 4, - "damageReduction": 0, - "alive": true, - "inventory": { - "salvage": { "scrap": 0, "chips": 0, "bio": 0, "data": 0 }, - "consumables": [ - { - "id": "smoke-charge", - "label": "", - "scope": "", - "cost": 0, - "description": "", - "needsTarget": false - }, - { - "id": "stim", - "label": "", - "scope": "", - "cost": 0, - "description": "", - "needsTarget": false - }, - { - "id": "stim", - "label": "", - "scope": "", - "cost": 0, - "description": "", - "needsTarget": false - } - ] - }, - "gear": null, - "cyber": { "ram": 8, "intrusion": 2, "iceResistance": 1 } - }, - { - "archetype": "tech", - "id": "recruit-0-38264", - "callsign": "Static", - "flatlined": false, - "hp": 3, - "maxHp": 3, - "ap": 4, - "maxAp": 4, - "damageReduction": 0, - "alive": true, - "inventory": { - "salvage": { "scrap": 0, "chips": 0, "bio": 0, "data": 0 }, - "consumables": [ - { - "id": "stim", - "label": "", - "scope": "", - "cost": 0, - "description": "", - "needsTarget": false - } - ] - }, - "gear": null - }, - { - "archetype": "razor", - "id": "recruit-1-13534", - "callsign": "Mantis", - "flatlined": true, - "hp": 0, - "maxHp": 3, - "ap": 0, - "maxAp": 4, - "damageReduction": 0, - "alive": false, - "inventory": { - "salvage": { "scrap": 0, "chips": 0, "bio": 0, "data": 0 }, - "consumables": [ - { - "id": "stim", - "label": "", - "scope": "", - "cost": 0, - "description": "", - "needsTarget": false - }, - { - "id": "incendiary", - "label": "", - "scope": "", - "cost": 0, - "description": "", - "needsTarget": false - } - ] - }, - "gear": { - "maxHpBonus": 0, - "hitBonus": 0, - "dodgeBonus": 0, - "rangedDamageBonus": 0, - "meleeDamageBonus": 1, - "armorBonus": 0, - "apBonus": 0, - "shieldRegen": 0, - "hpRegen": 0 - } - }, - { - "archetype": "razor", - "id": "recruit-0-62247", - "callsign": "Sable", - "flatlined": false, - "hp": 3, - "maxHp": 3, - "ap": 4, - "maxAp": 4, - "damageReduction": 0, - "alive": true, - "inventory": { - "salvage": { "scrap": 3, "chips": 0, "bio": 0, "data": 0 }, - "consumables": [] - }, - "gear": null - } - ], - "salvage": { "scrap": 0, "chips": 0, "bio": 0, "data": 0 }, - "credits": 28, - "rep": 96, - "meta": {}, - "arc": { - "arcStage": "act-2", - "deckerRecruited": true, - "scoreRevealed": true, - "clockStarted": true, - "scoreAttempted": false, - "scoreCompleted": false - }, - "deployedMemberId": "recruit-0-62247", - "deployedPartnerId": null, - "activeRun": { - "id": "run-3f5bfc78-mr1d803s", - "type": "run", - "state": "COMBAT", - "crewMemberId": "recruit-0-62247", - "partnerMemberId": null, - "archetype": "razor", - "seed": 1062993016, - "rng": { "seed": 1062993016, "state": 2830617632 }, - "contract": { - "seed": 1062993016, - "mapWidth": 28, - "mapHeight": 18, - "objective": { - "kind": "handoff", - "title": "Make cryo convoy manifest handoff", - "briefing": "Locate the Northstar Civic contact near auction floor, complete the cryo convoy manifest transfer, then extract.", - "params": { - "target": "cryo-manifest", - "contact": "indie journalist", - "requiresUnlock": true - } - }, - "difficulty": "elevated", - "threatCount": 3, - "label": "// Northstar Civic auction floor - cryo convoy manifest drop", - "context": { - "recipeId": "handoff-transfer", - "principal": { - "id": "northstar-civic", - "label": "Northstar Civic", - "groups": ["corp", "civic", "infrastructure"] - }, - "site": { - "id": "auction-floor", - "label": "auction floor", - "groups": ["street", "finance"] - }, - "asset": { - "id": "cryo-manifest", - "label": "cryo convoy manifest", - "groups": ["handoff", "logistics"] - }, - "action": { "id": "drop", "label": "drop", "groups": ["handoff"] }, - "tags": [ - "meatspace", - "contact", - "social", - "objective:handoff", - "principal:northstar-civic", - "principal:corp", - "principal:civic", - "principal:infrastructure", - "site:auction-floor", - "site:street", - "site:finance", - "asset:cryo-manifest", - "asset:handoff", - "asset:logistics", - "action:drop", - "action:handoff" - ], - "arcStage": "act-2" - }, - "reward": { "credits": 74, "repDelta": 7 } - }, - "telemetry": { - "archetype": "razor", - "seed": 1062993016, - "turn": 8, - "kills": 1, - "lastDamageSource": null, - "lastAttacker": null, - "hpAtDeath": null, - "cause": null, - "outcome": null - }, - "snapshot": { - "id": "run-3f5bfc78-mr1d803s", - "type": "run", - "state": "COMBAT", - "archetype": "razor", - "seed": 1062993016, - "turnNumber": 8, - "currentFaction": "player", - "rng": { "seed": 1062993016, "state": 2830617632 }, - "contract": { - "seed": 1062993016, - "mapWidth": 28, - "mapHeight": 18, - "objective": { - "kind": "handoff", - "title": "Make cryo convoy manifest handoff", - "briefing": "Locate the Northstar Civic contact near auction floor, complete the cryo convoy manifest transfer, then extract.", - "params": { - "target": "cryo-manifest", - "contact": "indie journalist", - "requiresUnlock": true - } - }, - "difficulty": "elevated", - "threatCount": 3, - "label": "// Northstar Civic auction floor - cryo convoy manifest drop", - "context": { - "recipeId": "handoff-transfer", - "principal": { - "id": "northstar-civic", - "label": "Northstar Civic", - "groups": ["corp", "civic", "infrastructure"] - }, - "site": { - "id": "auction-floor", - "label": "auction floor", - "groups": ["street", "finance"] - }, - "asset": { - "id": "cryo-manifest", - "label": "cryo convoy manifest", - "groups": ["handoff", "logistics"] - }, - "action": { "id": "drop", "label": "drop", "groups": ["handoff"] }, - "tags": [ - "meatspace", - "contact", - "social", - "objective:handoff", - "principal:northstar-civic", - "principal:corp", - "principal:civic", - "principal:infrastructure", - "site:auction-floor", - "site:street", - "site:finance", - "asset:cryo-manifest", - "asset:handoff", - "asset:logistics", - "action:drop", - "action:handoff" - ], - "arcStage": "act-2" - }, - "reward": { "credits": 74, "repDelta": 7 } - }, - "exitTile": { "x": 22, "y": 15 }, - "grid": { - "w": 28, - "h": 18, - "tiles": [ - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 2, 0, 2, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 0, 2, - 0, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, - 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 0, 2, 0, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, - 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 2, 0, 0, 2, 0, 0, 0, 0, 0, 0, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, - 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 0, 2, 0, 0, 2, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 - ] - }, - "entities": [ - { - "archetype": "razor", - "id": "recruit-0-62247", - "x": 7, - "y": 6, - "faction": "player", - "glyph": "@", - "hp": 3, - "maxHp": 3, - "damageReduction": 0, - "shieldHp": 0, - "ap": 4, - "maxAp": 4, - "alive": true, - "stealthed": false, - "extra": { - "callsign": "Sable", - "flatlined": false, - "inventory": { - "salvage": { "scrap": 3, "chips": 0, "bio": 0, "data": 0 }, - "consumables": [] - }, - "gear": null - } - }, - { - "archetype": "door", - "id": "door-entity-0", - "x": 13, - "y": 6, - "faction": "neutral", - "glyph": "▪", - "hp": 1, - "maxHp": 1, - "damageReduction": 0, - "shieldHp": 0, - "ap": 0, - "maxAp": 0, - "alive": true, - "stealthed": false, - "extra": { "doorId": "door-0", "locked": true } - }, - { - "archetype": "contact", - "id": "contact-0", - "x": 19, - "y": 5, - "faction": "neutral", - "glyph": "&", - "hp": 1, - "maxHp": 1, - "damageReduction": 0, - "shieldHp": 0, - "ap": 0, - "maxAp": 0, - "alive": true, - "stealthed": false, - "extra": { "label": "Indie journalist", "handoffComplete": false, "armed": true } - }, - { - "archetype": "drone", - "id": "drone-0", - "x": 15, - "y": 7, - "faction": "corp", - "glyph": "k", - "hp": 3, - "maxHp": 3, - "damageReduction": 0, - "shieldHp": 0, - "ap": 0, - "maxAp": 3, - "alive": true, - "stealthed": false, - "displayName": "Patrol Officer", - "principalTag": "N*", - "extra": { - "state": "patrol", - "lastKnownTarget": null, - "patrolWaypoints": [ - { "x": 15, "y": 5 }, - { "x": 15, "y": 7 } - ], - "patrolIndex": 1 - } - }, - { - "archetype": "guard", - "id": "guard-2", - "x": 18, - "y": 6, - "faction": "corp", - "glyph": "g", - "hp": 3, - "maxHp": 3, - "damageReduction": 0, - "shieldHp": 0, - "ap": 0, - "maxAp": 3, - "alive": true, - "stealthed": false, - "displayName": "Civic Guard", - "principalTag": "N*", - "extra": { - "state": "patrol", - "lastKnownTarget": null, - "patrolWaypoints": [ - { "x": 19, "y": 4 }, - { "x": 19, "y": 7 } - ], - "patrolIndex": 0 - } - }, - { - "archetype": "sniper", - "id": "sniper-0", - "x": 10, - "y": 4, - "faction": "corp", - "glyph": "s", - "hp": 3, - "maxHp": 3, - "damageReduction": 0, - "shieldHp": 0, - "ap": 0, - "maxAp": 4, - "alive": true, - "stealthed": false, - "displayName": "Marksman", - "principalTag": "N*", - "extra": { - "state": "patrol", - "lastKnownTarget": null, - "patrolWaypoints": [ - { "x": 10, "y": 4 }, - { "x": 11, "y": 4 } - ], - "patrolIndex": 0, - "aimTargetId": null - } - }, - { - "archetype": "corp-civilian", - "id": "corp-civ-0", - "x": 15, - "y": 5, - "faction": "corp", - "glyph": "c", - "hp": 1, - "maxHp": 1, - "damageReduction": 0, - "shieldHp": 0, - "ap": 1, - "maxAp": 1, - "alive": true, - "stealthed": false - }, - { - "archetype": "consumable-pickup", - "id": "consumable-pickup-0", - "x": 14, - "y": 6, - "faction": "neutral", - "glyph": "*", - "hp": 1, - "maxHp": 1, - "damageReduction": 0, - "shieldHp": 0, - "ap": 0, - "maxAp": 0, - "alive": true, - "stealthed": false, - "extra": { "consumableId": "smoke-charge", "label": "Smoke Charge" } - } - ], - "telemetry": { - "archetype": "razor", - "seed": 1062993016, - "turn": 8, - "kills": 1, - "lastDamageSource": null, - "lastAttacker": null, - "hpAtDeath": null, - "cause": null, - "outcome": null - }, - "alarm": { - "phase": "quiet", - "level": 0, - "holdTurnsRemaining": 0, - "cooldownTurnsRemaining": 0, - "triggers": 0 - }, - "alarmActive": false, - "objectiveTimer": { - "completedWithinLimit": false, - "expired": false, - "completedTurn": null, - "expiredTurn": null, - "expiryAnnounced": false - }, - "mapMemory": { - "seen": [ - "13,3", - "12,4", - "2,5", - "3,5", - "4,5", - "5,5", - "6,5", - "7,5", - "8,5", - "9,5", - "10,5", - "11,5", - "12,5", - "13,5", - "2,6", - "3,6", - "4,6", - "5,6", - "6,6", - "7,6", - "8,6", - "9,6", - "10,6", - "11,6", - "12,6", - "13,6", - "2,7", - "3,7", - "4,7", - "5,7", - "6,7", - "7,7", - "8,7", - "12,7", - "13,7", - "2,8", - "3,8", - "4,8", - "5,8", - "6,8", - "7,8", - "8,8", - "2,9", - "3,9", - "4,9", - "5,9", - "6,9", - "7,9", - "8,9", - "2,10", - "3,10", - "4,10", - "5,10", - "6,10", - "7,10", - "8,10", - "9,10", - "10,10", - "11,10", - "12,10", - "1,11", - "2,11", - "3,11", - "4,11", - "5,11", - "6,11", - "7,11", - "8,11", - "9,11", - "10,11", - "11,11", - "12,11", - "0,12", - "1,12", - "2,12", - "3,12", - "4,12", - "5,12", - "6,12", - "7,12", - "8,12", - "9,12", - "10,12", - "11,12", - "12,12", - "0,13", - "1,13", - "2,13", - "3,13", - "4,13", - "5,13", - "6,13", - "7,13", - "8,13", - "9,13", - "10,13", - "11,13", - "12,13", - "13,13", - "0,14", - "1,14", - "2,14", - "3,14", - "4,14", - "5,14", - "6,14", - "7,14", - "8,14", - "9,14", - "10,14", - "11,14", - "12,14", - "13,14", - "0,15", - "1,15", - "2,15", - "3,15", - "4,15", - "5,15", - "6,15", - "7,15", - "8,15", - "9,15", - "10,15", - "11,15", - "12,15", - "13,15" - ] - }, - "objectiveProgress": { "securedPickups": [] }, - "keyItems": [ - { - "id": "keycard-door-0", - "label": "Access keycard", - "doorId": "door-0", - "siteId": "1062993016" - } - ], - "mutationDeltas": [] - } - }, - "availableRecruits": [ - { - "archetype": "razor", - "id": "recruit-1-45923", - "callsign": "Mercury", - "flatlined": false, - "hp": 3, - "maxHp": 3, - "ap": 4, - "maxAp": 4, - "damageReduction": 0, - "alive": true, - "inventory": null, - "gear": null - } - ], - "recruitedThisVisit": true, - "pendingRecruitReward": false, - "rewardRecruitIds": [], - "healedThisVisit": [], - "hubReveals": { - "terminalRecruitmentExplained": true, - "finnIntroduced": true, - "terminalExplained": true, - "clinicIntroduced": true, - "scoreBriefingPresented": true, - "clockBriefingPresented": true - }, - "completedJobs": 5, - "clockJobsTaken": 4, - "keyItems": [ - { - "id": "keycard-door-0", - "label": "Access keycard", - "doorId": "door-0", - "siteId": "210241126" - } - ], - "siteRoster": [ - { - "id": "359644170", - "seed": "359644170", - "mapWidth": 28, - "mapHeight": 16, - "label": "// Redline Union Sublevel 3 - Gassed relay nodes blind", - "tier": "roster", - "scoreTarget": false, - "mutationDeltas": [], - "seenKeys": [ - "0,1", - "0,2", - "0,3", - "0,4", - "0,8", - "0,9", - "0,10", - "0,11", - "0,12", - "0,13", - "1,1", - "1,2", - "1,3", - "1,4", - "1,5", - "1,8", - "1,9", - "1,10", - "1,11", - "1,12", - "1,13", - "2,1", - "2,2", - "2,3", - "2,4", - "2,5", - "2,8", - "2,9", - "2,10", - "2,11", - "2,12", - "2,13", - "3,1", - "3,2", - "3,3", - "3,4", - "3,5", - "3,6", - "3,7", - "3,8", - "3,9", - "3,10", - "3,11", - "3,12", - "3,13", - "4,1", - "4,2", - "4,3", - "4,4", - "4,5", - "4,6", - "4,7", - "4,8", - "4,9", - "4,10", - "4,11", - "4,12", - "4,13", - "5,1", - "5,2", - "5,3", - "5,4", - "5,5", - "5,6", - "5,7", - "5,8", - "5,9", - "5,10", - "5,11", - "5,12", - "5,13", - "6,1", - "6,2", - "6,3", - "6,4", - "6,5", - "6,6", - "6,7", - "6,8", - "6,9", - "6,10", - "6,11", - "6,12", - "6,13", - "7,1", - "7,2", - "7,3", - "7,4", - "7,5", - "7,6", - "7,7", - "7,8", - "7,9", - "7,10", - "7,11", - "7,12", - "7,13", - "8,3", - "8,4", - "8,5", - "8,6", - "8,7", - "8,8", - "8,9", - "8,10", - "8,11", - "8,12", - "8,13", - "9,1", - "9,2", - "9,3", - "9,4", - "9,5", - "9,6", - "9,7", - "9,8", - "9,9", - "9,10", - "9,11", - "9,12", - "9,13", - "10,1", - "10,2", - "10,3", - "10,4", - "10,5", - "10,6", - "10,7", - "10,8", - "10,9", - "10,10", - "10,11", - "10,12", - "10,13", - "11,1", - "11,2", - "11,3", - "11,4", - "11,5", - "11,6", - "11,7", - "11,8", - "11,9", - "11,10", - "11,11", - "11,12", - "11,13", - "12,1", - "12,2", - "12,3", - "12,4", - "12,5", - "12,6", - "12,7", - "12,8", - "12,9", - "12,10", - "12,11", - "12,12", - "12,13", - "13,1", - "13,2", - "13,3", - "13,4", - "13,5", - "13,6", - "13,7", - "13,8", - "13,9", - "13,10", - "13,11", - "13,12", - "13,13", - "14,1", - "14,2", - "14,3", - "14,4", - "14,5", - "14,6", - "14,7", - "14,8", - "14,9", - "14,10", - "14,11", - "14,12", - "14,13", - "15,1", - "15,2", - "15,3", - "15,4", - "15,5", - "15,6", - "15,7", - "15,8", - "15,9", - "15,10", - "15,11", - "15,12", - "15,13", - "16,1", - "16,2", - "16,3", - "16,4", - "16,5", - "16,6", - "16,7", - "16,8", - "16,9", - "16,10", - "16,11", - "16,12", - "16,13", - "17,1", - "17,2", - "17,3", - "17,4", - "17,5", - "17,6", - "17,7", - "17,8", - "17,9", - "17,10", - "17,11", - "17,12", - "17,13", - "18,3", - "18,4", - "18,5", - "19,3", - "19,4", - "19,5", - "20,3", - "20,4", - "20,5", - "20,6", - "20,7", - "21,3", - "21,4", - "21,5", - "21,6", - "21,7", - "21,8", - "21,9", - "21,10", - "21,11", - "21,12", - "21,13", - "21,14", - "22,3", - "22,4", - "22,5", - "22,6", - "22,7", - "22,8", - "22,9", - "22,10", - "22,11", - "22,12", - "22,13", - "22,14", - "23,3", - "23,4", - "23,5", - "23,6", - "23,7", - "23,8", - "23,9", - "23,10", - "23,11", - "23,12", - "23,13", - "23,14", - "24,3", - "24,4", - "24,5", - "24,6", - "24,7", - "24,8", - "24,9", - "24,10", - "24,11", - "24,12", - "24,13", - "24,14", - "25,3", - "25,4", - "25,5", - "25,6", - "25,7", - "25,8", - "25,9", - "25,10", - "25,11", - "25,12", - "25,13", - "25,14", - "26,3", - "26,4", - "26,5", - "26,6", - "26,7", - "26,8", - "26,9", - "26,10", - "26,11", - "26,12", - "26,13", - "26,14", - "27,3", - "27,4", - "27,5", - "27,6", - "27,7", - "27,8", - "27,9", - "27,10", - "27,11", - "27,12", - "27,13", - "27,14" - ], - "lastVisitedJob": 2, - "principal": { - "id": "redline-union", - "label": "Redline Union", - "groups": ["rival", "logistics"] - }, - "site": { - "id": "sublevel-3", - "label": "Sublevel 3", - "groups": ["infrastructure", "hidden"] - } - }, - { - "id": "210241126", - "seed": "210241126", - "mapWidth": 28, - "mapHeight": 18, - "label": "// Vuong Holdings skybridge - shipment override", - "tier": "roster", - "scoreTarget": false, - "mutationDeltas": [], - "seenKeys": [ - "0,1", - "0,2", - "0,3", - "0,4", - "0,5", - "0,6", - "0,7", - "0,12", - "0,13", - "0,14", - "1,1", - "1,2", - "1,3", - "1,4", - "1,5", - "1,6", - "1,7", - "1,12", - "1,13", - "1,14", - "2,1", - "2,2", - "2,3", - "2,4", - "2,5", - "2,6", - "2,7", - "2,11", - "2,12", - "2,13", - "2,14", - "3,1", - "3,2", - "3,3", - "3,4", - "3,5", - "3,6", - "3,7", - "3,8", - "3,9", - "3,10", - "3,11", - "3,12", - "3,13", - "3,14", - "4,1", - "4,2", - "4,3", - "4,4", - "4,5", - "4,6", - "4,7", - "4,8", - "4,9", - "4,10", - "4,11", - "4,12", - "4,13", - "4,15", - "5,1", - "5,2", - "5,3", - "5,4", - "5,5", - "5,6", - "5,7", - "5,8", - "5,9", - "5,10", - "5,11", - "5,12", - "5,13", - "5,14", - "5,15", - "5,16", - "6,1", - "6,2", - "6,3", - "6,4", - "6,5", - "6,6", - "6,7", - "6,8", - "6,9", - "6,10", - "6,11", - "7,4", - "7,5", - "7,6", - "7,7", - "7,8", - "7,9", - "7,10", - "7,11", - "8,4", - "8,5", - "8,6", - "8,7", - "8,8", - "8,9", - "8,10", - "8,11", - "9,1", - "9,2", - "9,3", - "9,4", - "9,5", - "9,6", - "9,7", - "9,8", - "9,9", - "9,10", - "9,11", - "9,12", - "9,13", - "9,14", - "9,15", - "9,16", - "10,1", - "10,2", - "10,3", - "10,4", - "10,5", - "10,6", - "10,7", - "10,8", - "10,9", - "10,10", - "10,11", - "10,12", - "10,13", - "10,14", - "10,15", - "10,16", - "11,1", - "11,2", - "11,3", - "11,4", - "11,5", - "11,6", - "11,7", - "11,8", - "11,9", - "11,10", - "11,11", - "11,12", - "11,13", - "11,14", - "11,15", - "11,16", - "12,1", - "12,2", - "12,3", - "12,4", - "12,5", - "12,6", - "12,7", - "12,8", - "12,9", - "12,10", - "12,11", - "12,12", - "12,13", - "12,14", - "12,15", - "12,16", - "13,1", - "13,2", - "13,3", - "13,4", - "13,5", - "13,6", - "13,7", - "13,8", - "13,9", - "13,10", - "13,11", - "13,12", - "13,13", - "13,14", - "13,15", - "13,16", - "14,1", - "14,2", - "14,3", - "14,4", - "14,5", - "14,6", - "14,7", - "14,8", - "14,9", - "14,10", - "14,11", - "14,12", - "14,13", - "14,14", - "14,15", - "14,16", - "15,0", - "15,1", - "15,2", - "15,3", - "15,4", - "15,5", - "15,6", - "15,7", - "15,8", - "15,9", - "15,10", - "15,11", - "15,12", - "15,13", - "15,14", - "15,15", - "15,16", - "16,0", - "16,1", - "16,2", - "16,3", - "16,4", - "16,5", - "16,6", - "16,7", - "16,8", - "16,9", - "16,10", - "16,11", - "16,12", - "16,13", - "16,14", - "17,0", - "17,1", - "17,2", - "17,3", - "17,4", - "17,5", - "17,6", - "17,7", - "17,8", - "17,9", - "17,10", - "17,11", - "17,12", - "17,13", - "17,14", - "18,0", - "18,1", - "18,2", - "18,3", - "18,4", - "18,5", - "18,6", - "18,7", - "18,8", - "18,9", - "18,10", - "18,11", - "18,12", - "18,13", - "18,14", - "19,0", - "19,1", - "19,2", - "19,3", - "19,4", - "19,5", - "19,6", - "19,7", - "19,8", - "19,9", - "19,10", - "19,11", - "19,12", - "19,13", - "19,14", - "20,0", - "20,1", - "20,2", - "20,3", - "20,4", - "20,5", - "20,6", - "20,7", - "20,8", - "20,9", - "20,10", - "20,11", - "20,12", - "20,13", - "20,14", - "21,0", - "21,1", - "21,2", - "21,3", - "21,4", - "21,5", - "21,6", - "21,7", - "21,8", - "21,9", - "21,10", - "21,11", - "21,12", - "21,13", - "21,14", - "22,0", - "22,1", - "22,2", - "22,3", - "22,4", - "22,5", - "22,6", - "22,7", - "22,8", - "22,9", - "22,10", - "22,11", - "22,12", - "22,13", - "22,14", - "23,0", - "23,1", - "23,2", - "23,3", - "23,4", - "23,5", - "23,6", - "23,7", - "23,8", - "23,9", - "23,10", - "23,11", - "23,12", - "23,13", - "24,0", - "24,1", - "24,2", - "24,3", - "24,4", - "24,5", - "24,6", - "24,7", - "24,8", - "24,9", - "24,10", - "24,11", - "24,12", - "25,0", - "25,1", - "25,2", - "25,3", - "25,4", - "25,5", - "25,6", - "25,7", - "26,0", - "26,1", - "26,2", - "26,3", - "26,4", - "26,5", - "26,6", - "26,7" - ], - "lastVisitedJob": 4, - "principal": { - "id": "vuong-holdings", - "label": "Vuong Holdings", - "groups": ["corp", "data"] - }, - "site": { - "id": "skybridge", - "label": "skybridge", - "groups": ["infrastructure", "security"] - } - }, - { - "id": "score-1363972140", - "seed": "1363972140", - "mapWidth": 32, - "mapHeight": 20, - "label": "// Northstar Civic contractor annex - Score target", - "tier": "score", - "scoreTarget": true, - "mutationDeltas": [], - "seenKeys": [], - "lastVisitedJob": 0, - "principal": { - "id": "northstar-civic", - "label": "Northstar Civic", - "groups": ["corp", "civic", "infrastructure"] - }, - "site": { - "id": "contractor-annex", - "label": "contractor annex", - "groups": ["corp", "finance"] - } - }, - { - "id": "306547716", - "seed": "306547716", - "mapWidth": 28, - "mapHeight": 18, - "label": "// Northstar Civic Pier 7 - Flooded relay nodes sweep", - "tier": "roster", - "scoreTarget": false, - "mutationDeltas": [], - "seenKeys": [], - "lastVisitedJob": 5, - "principal": { - "id": "northstar-civic", - "label": "Northstar Civic", - "groups": ["corp", "civic", "infrastructure"] - }, - "site": { "id": "pier-7", "label": "Pier 7", "groups": ["infrastructure", "street"] } - }, - { - "id": "570181429", - "seed": "570181429", - "mapWidth": 28, - "mapHeight": 18, - "label": "// Northstar Civic Sublevel 3 - Blacked-out Redline courier exfil", - "tier": "roster", - "scoreTarget": false, - "mutationDeltas": [], - "seenKeys": [], - "lastVisitedJob": 5, - "principal": { - "id": "northstar-civic", - "label": "Northstar Civic", - "groups": ["corp", "civic", "infrastructure"] - }, - "site": { - "id": "sublevel-3", - "label": "Sublevel 3", - "groups": ["infrastructure", "hidden"] - } - }, - { - "id": "1062993016", - "seed": "1062993016", - "mapWidth": 28, - "mapHeight": 18, - "label": "// Northstar Civic auction floor - cryo convoy manifest drop", - "tier": "roster", - "scoreTarget": false, - "mutationDeltas": [], - "seenKeys": [], - "lastVisitedJob": 5, - "principal": { - "id": "northstar-civic", - "label": "Northstar Civic", - "groups": ["corp", "civic", "infrastructure"] - }, - "site": { "id": "auction-floor", "label": "auction floor", "groups": ["street", "finance"] } - } - ], - "chronicle": [ - { - "id": "chronicle-0", - "sequence": 0, - "kind": "milestone", - "stage": "act-1", - "title": "CREW ASSEMBLED", - "summary": "The first operators are in place: Wren, Vector.", - "detailLines": ["Starter crew 2 operators", "Street-level contracts are now live."] - }, - { - "id": "chronicle-1", - "sequence": 1, - "kind": "job", - "stage": "act-1", - "title": "STREET JOB", - "summary": "// Spinning Fox clinic - Blacked-out floodgate bulkhead demolition kept the crew fed and moving through the street-level grind.", - "detailLines": [ - "Objective: Breach floodgate bulkhead", - "Outcome: objective complete", - "Rep +14 | Credits +39", - "Completed jobs 0 -> 1" - ] - }, - { - "id": "chronicle-2", - "sequence": 2, - "kind": "job", - "stage": "act-1", - "title": "STREET JOB", - "summary": "// Null Saints Block 9 - site layout map went bad and the deployed operator never came home.", - "detailLines": [ - "Objective: Map site layout", - "Outcome: operator flatlined", - "Rep +0 | Credits +0", - "Completed jobs 1 -> 1", - "Losses: Wren" - ] - }, - { - "id": "chronicle-3", - "sequence": 3, - "kind": "job", - "stage": "act-1", - "title": "STREET JOB", - "summary": "// District Water Board harbor - Blacked-out floodgate bulkhead demolition kept the crew fed and moving through the street-level grind.", - "detailLines": [ - "Objective: Breach floodgate bulkhead", - "Outcome: objective complete", - "Rep +14 | Credits +35", - "Completed jobs 1 -> 2" - ] - }, - { - "id": "chronicle-4", - "sequence": 4, - "kind": "job", - "stage": "act-1", - "title": "STREET JOB", - "summary": "// Redline Union Sublevel 3 - Gassed relay nodes blind kept the crew fed and moving through the street-level grind.", - "detailLines": [ - "Objective: Sweep relay nodes", - "Outcome: objective complete", - "Rep +14 | Credits +27", - "Completed jobs 2 -> 3" - ] - }, - { - "id": "chronicle-5", - "sequence": 5, - "kind": "milestone", - "stage": "act-1", - "title": "RECRUITED — Smoke", - "summary": "Smoke joined the crew as Razor.", - "detailLines": ["Crew size 2 living / 3 total"] - }, - { - "id": "chronicle-6", - "sequence": 6, - "kind": "job", - "stage": "act-1", - "title": "STREET JOB", - "summary": "// Vuong Holdings skybridge - shipment override kept the crew fed and moving through the street-level grind.", - "detailLines": [ - "Objective: Disable shipment", - "Outcome: objective complete", - "Rep +17 | Credits +43", - "Completed jobs 3 -> 4" - ] - }, - { - "id": "chronicle-7", - "sequence": 7, - "kind": "milestone", - "stage": "act-2", - "title": "STAGE 2 — SCORE REVEALED", - "summary": "The Curator named Northstar Civic contractor annex and assigned Bytesize to open it.", - "detailLines": [ - "Score target: Northstar Civic contractor annex", - "Decker assigned: Bytesize" - ] - }, - { - "id": "chronicle-8", - "sequence": 8, - "kind": "job", - "stage": "act-2", - "title": "RUN LOGGED", - "summary": "// Vuong Holdings skybridge - identity spool override was won, but the crew paid for it in blood.", - "detailLines": [ - "Objective: Spike identity spool", - "Outcome: objective complete", - "Rep +17 | Credits +70", - "Completed jobs 4 -> 5", - "Losses: Vector", - "Score target: Northstar Civic contractor annex" - ] - }, - { - "id": "chronicle-9", - "sequence": 9, - "kind": "milestone", - "stage": "act-2", - "title": "RECRUITED — Static", - "summary": "Static joined the crew as Tech.", - "detailLines": ["Crew size 3 living / 5 total"] - }, - { - "id": "chronicle-10", - "sequence": 10, - "kind": "job", - "stage": "act-2", - "title": "CASING — NORTHSTAR CIVIC", - "summary": "// Northstar Civic Pier 7 - Flooded relay nodes sweep went bad and the deployed operator never came home.", - "detailLines": [ - "Objective: Sweep relay nodes", - "Outcome: operator flatlined", - "Rep +0 | Credits +0", - "Completed jobs 5 -> 5", - "Losses: Smoke", - "Casing principal: Northstar Civic", - "Score target: Northstar Civic contractor annex" - ] - }, - { - "id": "chronicle-11", - "sequence": 11, - "kind": "milestone", - "stage": "act-2", - "title": "RECRUITED — Mantis", - "summary": "Mantis joined the crew as Razor.", - "detailLines": ["Crew size 3 living / 6 total"] - }, - { - "id": "chronicle-12", - "sequence": 12, - "kind": "job", - "stage": "act-2", - "title": "CASING — NORTHSTAR CIVIC", - "summary": "// Northstar Civic Sublevel 3 - Blacked-out Redline courier exfil went bad and the deployed operator never came home.", - "detailLines": [ - "Objective: Extract Redline courier", - "Outcome: operator flatlined", - "Rep +0 | Credits +0", - "Completed jobs 5 -> 5", - "Losses: Mantis", - "Casing principal: Northstar Civic", - "Score target: Northstar Civic contractor annex" - ] - }, - { - "id": "chronicle-13", - "sequence": 13, - "kind": "milestone", - "stage": "act-2", - "title": "CLOCK IS LIVE", - "summary": "The operational window is now burning down. Every deploy adds heat.", - "detailLines": ["Clock heat 0", "Jobs left 5"] - }, - { - "id": "chronicle-14", - "sequence": 14, - "kind": "milestone", - "stage": "act-2", - "title": "RECRUITED — Sable", - "summary": "Sable joined the crew as Razor.", - "detailLines": ["Crew size 3 living / 7 total"] - } - ], - "pendingChronicleRun": { - "sequence": 15, - "stage": "act-2", - "contractLabel": "// Northstar Civic auction floor - cryo convoy manifest drop", - "objectiveTitle": "Make cryo convoy manifest handoff", - "scoreTargetName": "Northstar Civic contractor annex", - "principalLabel": "Northstar Civic", - "isScore": false, - "isCasing": true, - "repBefore": 96, - "creditsBefore": 28, - "completedJobsBefore": 5, - "flatlinedCrewIdsBefore": ["crew-init-1", "crew-init-2", "recruit-0-2042", "recruit-1-13534"] - } - }, - "campaignHistory": [ - { - "campaignId": "campaign-1a8f79e9-mr171qzt", - "completedAt": "2026-06-30T23:18:25.250Z", - "result": "loss", - "endReason": "decker-flatlined-score", - "seed": 445610473, - "completedJobs": 6, - "rep": 95, - "credits": 42, - "crewRoster": [ - { "callsign": "Wren", "archetype": "Razor", "flatlined": true }, - { "callsign": "Carver", "archetype": "Merc", "flatlined": false }, - { "callsign": "Volt", "archetype": "Tech", "flatlined": false }, - { "callsign": "Jack", "archetype": "Decker", "flatlined": true }, - { "callsign": "Phreak", "archetype": "Decker", "flatlined": true } - ] - }, - { - "campaignId": "campaign-14228140-mqzev48w", - "completedAt": "2026-06-30T20:57:32.482Z", - "result": "loss", - "endReason": "decker-flatlined-score", - "seed": 337805632, - "completedJobs": 8, - "rep": 100, - "credits": 111, - "crewRoster": [ - { "callsign": "Wren", "archetype": "Razor", "flatlined": false }, - { "callsign": "Cinder", "archetype": "Merc", "flatlined": false }, - { "callsign": "AcidBurn", "archetype": "Decker", "flatlined": true }, - { "callsign": "Is0bel", "archetype": "Decker", "flatlined": true } - ] - }, - { - "campaignId": "campaign-ebe56e-mqu2y4j2", - "completedAt": "2026-06-29T05:36:29.714Z", - "result": "win", - "endReason": "score-complete", - "seed": 15459694, - "completedJobs": 9, - "rep": 100, - "credits": 5004, - "crewRoster": [ - { "callsign": "Cipher", "archetype": "Razor", "flatlined": true }, - { "callsign": "Wraith", "archetype": "Merc", "flatlined": false }, - { "callsign": "Nyx", "archetype": "Tech", "flatlined": true }, - { "callsign": "Tr1nity", "archetype": "Decker", "flatlined": true }, - { "callsign": "Beacon", "archetype": "Tech", "flatlined": true }, - { "callsign": "Wren", "archetype": "Razor", "flatlined": false }, - { "callsign": "Volt", "archetype": "Tech", "flatlined": false }, - { "callsign": "Z0ne", "archetype": "Decker", "flatlined": false } - ], - "scoreReward": { - "id": "monoblade", - "label": "Monoblade", - "flavor": "A monomolecular blade schematic — an edge that never dulls." - } - }, - { - "campaignId": "campaign-ade9a52c-mq71snxo", - "completedAt": "2026-06-25T22:34:40.433Z", - "result": "win", - "endReason": "score-complete", - "seed": 2917770540, - "completedJobs": 14, - "rep": 100, - "credits": 5011, - "crewRoster": [ - { "callsign": "Wraith", "archetype": "Merc", "flatlined": true }, - { "callsign": "Wire", "archetype": "Tech", "flatlined": false }, - { "callsign": "Veil", "archetype": "Razor", "flatlined": true }, - { "callsign": "Drift", "archetype": "Merc", "flatlined": false }, - { "callsign": "Blitz", "archetype": "Decker", "flatlined": false }, - { "callsign": "Saint", "archetype": "Razor", "flatlined": false } - ], - "scoreReward": { - "id": "phase-shield", - "label": "Phase Shield Prototype", - "flavor": "A phase-shield emitter that bleeds incoming kinetic force into a flicker field." - } - } - ], - "unlockedScoreableItems": ["phase-shield", "monoblade"] -} diff --git a/src/shell/shellRuntime.ts b/src/shell/shellRuntime.ts index c9f80ac..8a856bc 100644 --- a/src/shell/shellRuntime.ts +++ b/src/shell/shellRuntime.ts @@ -1618,6 +1618,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'], @@ -1635,7 +1642,7 @@ export function handleIntent(intent: Intent): void { onCorpseSalvaged: entity => { activeVisionField(run).forgetCorpse(entity); }, - keyItems: (run as Run).effectiveKeyItems(campaign?.keyItems ?? []), + keyItems, onKeycardCollected: kc => { // Picked-up keycards are run-scoped during the run — still usable for // in-run door unlock via ctx.keyItems. Campaign.onJobEnd promotes the From 8963ec42e75c8148859d60c3bcec4d232f4db660 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 7 Jul 2026 13:22:03 -0700 Subject: [PATCH 13/17] format --- tests/unit/game/keycard.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/game/keycard.test.ts b/tests/unit/game/keycard.test.ts index 3188bb5..df10448 100644 --- a/tests/unit/game/keycard.test.ts +++ b/tests/unit/game/keycard.test.ts @@ -15,7 +15,11 @@ 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, keycardIdFor, migrateLegacyKeycardId } 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, From 9bf9d5550c5099f5e7226a31ee442cf99021378d Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 7 Jul 2026 14:17:43 -0700 Subject: [PATCH 14/17] scope keycards to principals, not sites --- README.md | 2 +- components/ContractSelect.ts | 16 +- package.json | 2 +- src/game/Campaign.ts | 102 ++++-- src/game/Run.ts | 60 ++-- src/game/entities/KeyCard.ts | 70 ++-- src/game/hub/Curator.ts | 10 + src/game/hub/arcSurface.ts | 11 +- src/game/persistence.ts | 40 ++- src/input/applyIntent.ts | 10 +- src/shell/domTypes.ts | 2 +- src/shell/shellRuntime.ts | 27 +- src/types.ts | 14 +- sw-dev.js | 2 +- sw.js | 2 +- tests/unit/game/hub/arcSurface.test.ts | 19 +- tests/unit/game/keycard.test.ts | 461 ++++++++++++++----------- tests/unit/game/locations.test.ts | 26 +- 18 files changed, 508 insertions(+), 368 deletions(-) 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/ContractSelect.ts b/components/ContractSelect.ts index fe71143..422cdac 100644 --- a/components/ContractSelect.ts +++ b/components/ContractSelect.ts @@ -218,7 +218,7 @@ class ContractSelect extends HTMLElement { #contracts: Contract[] = []; #scoreTargetSiteId: string | null = null; #scorePrincipalId: string | null = null; - #heldKeycardSiteIds: ReadonlySet = new Set(); + #heldKeycardPrincipalIds: ReadonlySet = new Set(); #selectedIndex = 0; #ready = false; #listEl: HTMLElement | null = null; @@ -285,11 +285,11 @@ class ContractSelect extends HTMLElement { if (this.#ready) this.#render(); } - setHeldKeycardSiteIds(siteIds: string[]) { - if (!Array.isArray(siteIds)) { - throw new TypeError('.setHeldKeycardSiteIds requires an array'); + setHeldKeycardPrincipalIds(principalIds: string[]) { + if (!Array.isArray(principalIds)) { + throw new TypeError('.setHeldKeycardPrincipalIds requires an array'); } - this.#heldKeycardSiteIds = new Set(siteIds); + this.#heldKeycardPrincipalIds = new Set(principalIds); if (this.#ready) this.#render(); } @@ -344,7 +344,7 @@ class ContractSelect extends HTMLElement { contract, this.#scoreTargetSiteId, this.#scorePrincipalId, - this.#heldKeycardSiteIds + this.#heldKeycardPrincipalIds ) ), h('div', { className: 'meta', textContent: rewardCopy(contract) }), @@ -438,7 +438,7 @@ function locationLine( contract: Contract, scoreTargetSiteId: string | null, scorePrincipalId: string | null, - heldKeycardSiteIds: ReadonlySet + heldKeycardPrincipalIds: ReadonlySet ): HTMLElement[] { const { principal, site, siteState } = contract.context; const place = site ? `${principal.label} ${site.label}` : principal.label; @@ -448,7 +448,7 @@ function locationLine( contract, scoreTargetSiteId, scorePrincipalId, - heldKeycardSiteIds + heldKeycardPrincipalIds )) { const className = badge.variant === 'revisit' ? 'known' : `known ${badge.variant}`; nodes.push(h('span', { className, textContent: badge.text })); diff --git a/package.json b/package.json index 1c7e6b3..df7f9ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kernel-panic", - "version": "0.2.9-balance", + "version": "0.3.1-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 cee416c..5bafae4 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -53,7 +53,7 @@ import { siteIdForContract, } from './locations.js'; import { resolveMapDimensions } from './procgen/mapDimensions.js'; -import { migrateLegacyKeycardId } from './entities/KeyCard.js'; +import { keycardIdFor, migrateLegacyKeycardId } from './entities/KeyCard.js'; import { normalizeCampaignChronicle, normalizePendingChronicleRun, @@ -457,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; @@ -1317,19 +1319,19 @@ export class Campaign { /** * Promote a survived run's carried keycards into the persistent campaign - * inventory. Only site-stamped cards survive (run-scoped cards without a - * siteId belong to nowhere on the roster); already-held cards are skipped - * so a re-collected revisit card is idempotent rather than a crash. + * 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. */ #promoteRunKeyItems(run: Run): void { for (const item of run.keyItems) { - if (!item.siteId) continue; + 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, - siteId: item.siteId, + principalId: item.principalId, }); } } @@ -1380,12 +1382,12 @@ export class Campaign { ); return; } - const [evicted] = this.siteRoster.splice(evictIdx, 1); - // A site leaving campaign memory takes its keycards with it — a card for - // a location we can no longer revisit is dead weight in the inventory. - if (evicted) { - this.keyItems = this.keyItems.filter(k => k.siteId !== evicted.id); - } + 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(); @@ -1444,12 +1446,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 = siteIdForContract(contract); - 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. */ @@ -1967,13 +1971,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`); } @@ -1986,17 +2000,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, + }); } - // Legacy saves stamped every site's card with the colliding bare id - // `keycard-`; re-key to the site-unique form on restore (P3.1). - return migrateLegacyKeycardId(result); + + if (!result.some(k => k.id === normalized.id)) result.push(normalized); }); + return result; } /** diff --git a/src/game/Run.ts b/src/game/Run.ts index 87865b3..f869a62 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -88,7 +88,7 @@ import { DataNode } from './cyber/DataNode.js'; import { ProbeIce } from './cyber/ProbeIce.js'; import { SparkIce } from './cyber/SparkIce.js'; import { GuardianIce } from './cyber/GuardianIce.js'; -import { applyMutationDeltas, siteIdForContract } from './locations.js'; +import { applyMutationDeltas } from './locations.js'; import { BreachingCharge } from './entities/BreachingCharge.js'; import { ITEM_ID, getItemById, SCOREABLE_ITEMS } from './items.js'; import { resetCorpTurnStatusCache } from './corpTurnStatusCopy.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,7 +366,7 @@ type KeyItemSnapshot = { id: string; label: string; doorId: string; - siteId?: string; + principalId?: string; }; export type JackOutRequest = { @@ -488,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; @@ -977,7 +977,7 @@ export class Run { id: k.id, label: k.label, doorId: k.doorId, - ...(k.siteId ? { siteId: k.siteId } : {}), + ...(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 @@ -1456,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') { @@ -1473,7 +1473,7 @@ export class Run { throw new TypeError('Run.addKeyItem: item.doorId must be a non-empty string'); } // Canonicalize before the dedup check so a bare legacy id picked up off a - // pre-P3.1 map lands as the site-unique id, matching restore-time migration. + // 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}"`); @@ -1482,24 +1482,26 @@ export class Run { id: canonical.id, label: canonical.label, doorId: canonical.doorId, - ...(canonical.siteId ? { siteId: canonical.siteId } : {}), + ...(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* site. Cards from other sites - * are excluded so the combat inventory and the door-unlock context stay - * scoped to 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 foreign card open this door (P3.1 fix). Deduped by id. + * 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 siteId = this.contract ? siteIdForContract(this.contract) : null; - const siteScoped = siteId ? campaignKeyItems.filter(k => k.siteId === siteId) : []; + 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 [...siteScoped, ...this.keyItems]) { + for (const k of [...scoped, ...this.keyItems]) { if (seen.has(k.id)) continue; seen.add(k.id); result.push({ ...k }); @@ -2023,12 +2025,12 @@ export class Run { this.contract.objective.kind !== OBJECTIVES.TERMINAL_SLICE && this.contract.objective.kind !== OBJECTIVES.SCORE_FINAL ) { - const revisitSiteId = siteIdForContract(this.contract); + const revisitPrincipalId = this.contract.context.principal?.id ?? null; const priorKey = this.priorKeyItems.find( - k => k.doorId === linkedDoorId && k.siteId === revisitSiteId + k => k.doorId === linkedDoorId && k.principalId === revisitPrincipalId ); - // Held site keycard from a prior visit → skip spawn; door stays locked - // until interact (P2.5.M7.2). + // 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); @@ -2061,19 +2063,19 @@ export class Run { this.rng, linkedDoorId ); - // Stamp the keycard with the roster's site id (explicit on a - // revisit, else derived from the seed) so collecting it promotes - // the card to campaign-scoped on extraction and a future revisit - // re-opens the door via the held card instead of respawning one. - const keycardSiteId = siteIdForContract(this.contract); + // 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: keycardIdFor(linkedDoorId, keycardSiteId), + id: keycardIdFor(linkedDoorId, keycardPrincipalId), x: keycardAnchor.x, y: keycardAnchor.y, doorId: linkedDoorId, label: 'Access keycard', - siteId: keycardSiteId, + principalId: keycardPrincipalId, }) ); } @@ -2788,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 => { diff --git a/src/game/entities/KeyCard.ts b/src/game/entities/KeyCard.ts index 6609aa0..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,42 +31,48 @@ 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 site id is baked in so two sites that share the - * stable objective `doorId` (`door-0` on every generated map) still get - * distinct ids — the P3.1 fix for the "inventory shows two keycards" collision. - * Run-scoped cards (no siteId) keep the bare `keycard-` form; they are - * discarded at run end so there is only ever one live, hence no collision. + * 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, siteId?: string | null): string { - return siteId ? `keycard-${doorId}-${siteId}` : `keycard-${doorId}`; +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 site-unique form. - * Only the exact legacy shape *with* a siteId is rewritten — post-fix ids, - * run-scoped ids (no siteId), and custom test ids are left untouched, so the - * transform is idempotent and safe to run on every restore. + * 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( - item: T -): T { - if (item.siteId && item.id === `keycard-${item.doorId}`) { - return { ...item, id: keycardIdFor(item.doorId, item.siteId) }; +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; } @@ -72,18 +80,18 @@ export function migrateLegacyKeycardId 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 4ef5da1..a2eff9f 100644 --- a/src/game/hub/arcSurface.ts +++ b/src/game/hub/arcSurface.ts @@ -11,7 +11,6 @@ import { import type { CampaignArcStage, LocationSite } from '../../types.js'; import type { Decker } from '../archetypes/Decker.js'; import type { Crew } from '../Crew.js'; -import { siteIdForContract } from '../locations.js'; type ArcSurfaceCampaign = Pick & Partial< @@ -189,7 +188,7 @@ export function contractLocationBadges( contract: Contract, scoreTargetSiteId: string | null | undefined, scorePrincipalId: string | null | undefined, - heldKeycardSiteIds: ReadonlySet = new Set() + heldKeycardPrincipalIds: ReadonlySet = new Set() ): ContractLocationBadge[] { const badges: ContractLocationBadge[] = []; const scoreSite = isScoreSiteContract(contract, scoreTargetSiteId); @@ -201,9 +200,11 @@ export function contractLocationBadges( if (contract.context.locationSiteId && !scoreSite) { badges.push({ variant: 'revisit', text: '// known site' }); } - // A held keycard means we already extracted from this exact site — surface it - // so the player knows the locked door is already openable on this redeploy. - if (!scoreSite && heldKeycardSiteIds.has(siteIdForContract(contract))) { + // 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/persistence.ts b/src/game/persistence.ts index 89af67f..87ed803 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -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) { @@ -995,7 +999,7 @@ export type KeyItemSnapshot = { id: string; label: string; doorId: string; - siteId?: string; + principalId?: string; }; /** Serializable shape of `Campaign.hubReveals`. */ @@ -2107,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`); } @@ -2121,16 +2125,24 @@ function normalizeRunKeyItems(raw: unknown): KeyItem[] { throw new TypeError(`restore: run 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) { - if (typeof item.siteId !== 'string' || item.siteId.length === 0) { + if (item.principalId !== undefined) { + if (typeof item.principalId !== 'string' || item.principalId.length === 0) { throw new TypeError( - `restore: run keyItems[${i}].siteId must be a non-empty string when set` + `restore: run keyItems[${i}].principalId must be a non-empty string when set` ); } - result.siteId = item.siteId; + 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` + ); } - // Re-key legacy colliding `keycard-` ids to the site-unique form - // (P3.1); symmetric with the campaign inventory migration. + // 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/shell/domTypes.ts b/src/shell/domTypes.ts index d799663..b97a85d 100644 --- a/src/shell/domTypes.ts +++ b/src/shell/domTypes.ts @@ -35,7 +35,7 @@ export type ContractSelectElement = ModalElement & { setContracts(contracts: Contract[]): void; setScoreTargetSiteId(siteId: string | null): void; setScorePrincipalId(principalId: string | null): void; - setHeldKeycardSiteIds(siteIds: string[]): void; + setHeldKeycardPrincipalIds(principalIds: string[]): void; }; export type CrashDumpElement = ModalElement & { diff --git a/src/shell/shellRuntime.ts b/src/shell/shellRuntime.ts index 8a856bc..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, @@ -771,9 +772,11 @@ function presentBriefing(contract: Contract) { function presentContractSelect(contracts: Contract[]) { contractSelectEl.setScoreTargetSiteId(campaign ? scoreTargetSiteId(campaign) : null); contractSelectEl.setScorePrincipalId(campaign ? scorePrincipalId(campaign) : null); - contractSelectEl.setHeldKeycardSiteIds( + contractSelectEl.setHeldKeycardPrincipalIds( campaign - ? campaign.keyItems.map(k => k.siteId).filter((id): id is string => typeof id === 'string') + ? campaign.keyItems + .map(k => k.principalId) + .filter((id): id is string => typeof id === 'string') : [] ); contractSelectEl.setContracts(contracts); @@ -973,18 +976,16 @@ function presentInventory() { } /** - * Enrich key items with their roster location name (`${principal} ${site}`) - * for the inventory tag. Resolved live from the campaign roster — keycards are - * evicted alongside their site, so a held card always has a roster entry, - * except legacy untokenized sites which simply render without a tag. + * 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 site = item.siteId ? campaign?.findRosterSite(item.siteId) : null; - if (!site?.principal) return { ...item }; - const locationName = site.site - ? `${site.principal.label} ${site.site.label}` - : site.principal.label; + const locationName = item.principalId ? principalLabelFor(item.principalId) : null; + if (!locationName) return { ...item }; return { ...item, locationName }; }); } @@ -1646,12 +1647,12 @@ export function handleIntent(intent: Intent): void { onKeycardCollected: kc => { // Picked-up keycards are run-scoped during the run — still usable for // in-run door unlock via ctx.keyItems. Campaign.onJobEnd promotes the - // site-stamped ones into the persistent inventory on live extraction. + // principal-stamped ones into the persistent inventory on live extraction. (run as Run).addKeyItem({ id: kc.id, label: kc.label, doorId: kc.doorId, - ...(kc.siteId ? { siteId: kc.siteId } : {}), + ...(kc.principalId ? { principalId: kc.principalId } : {}), }); }, onSecuredInteract: handleSecuredInteract, 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-dev.js b/sw-dev.js index 7f43990..f777c18 100644 --- a/sw-dev.js +++ b/sw-dev.js @@ -1,5 +1,5 @@ // Service Worker for Kernel Panic - Development Version -const VERSION = '0.3.2-dev'; +const VERSION = '0.3.1-dev'; importScripts(`/sw-core.js?v=${VERSION}`); const cacheConfig = CacheConfig.create(VERSION); diff --git a/sw.js b/sw.js index 6e295ba..8d60b93 100644 --- a/sw.js +++ b/sw.js @@ -1,6 +1,6 @@ // Service Worker for Kernel Panic - Production Version // Import shared caching core with cache-busting query parameter -const VERSION = '0.3.2'; +const VERSION = '0.3.1'; importScripts(`/sw-core.js?v=${VERSION}`); const cacheConfig = CacheConfig.create(VERSION); diff --git a/tests/unit/game/hub/arcSurface.test.ts b/tests/unit/game/hub/arcSurface.test.ts index 358b88a..56c2d53 100644 --- a/tests/unit/game/hub/arcSurface.test.ts +++ b/tests/unit/game/hub/arcSurface.test.ts @@ -18,7 +18,6 @@ import { scoreTargetDisplayName, scoreTargetSiteId, } from '../../../../src/game/hub/arcSurface.js'; -import { siteIdForContract } from '../../../../src/game/locations.js'; import { Decker } from '../../../../src/game/archetypes/Decker.js'; import { Merc } from '../../../../src/game/archetypes/Merc.js'; import { CLOCK_ACT2_DEADLINE_JOBS, CLOCK_ACT2_GRACE_JOBS } from '../../../../src/game/Campaign.js'; @@ -323,26 +322,26 @@ 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 site id is in the held set', () => { - const contract = badgeFixture('case-site'); // revisit, non-principal - const held = new Set([siteIdForContract(contract)]); +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 derived seed id', () => { - const contract = badgeFixture(); // no locationSiteId → derived from seed - const held = new Set([siteIdForContract(contract)]); +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 site', () => { +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-site']); + const held = new Set(['some-other-principal']); assert.deepEqual(contractLocationBadges(contract, 'score-site', 'other-principal', held), [ { variant: 'revisit', text: '// known site' }, ]); @@ -350,7 +349,7 @@ test('contractLocationBadges omits the keycard badge when no card is held for th test('contractLocationBadges never shows the keycard badge on the Score target', () => { const contract = badgeFixture('score-site'); - const held = new Set([siteIdForContract(contract)]); + const held = new Set([contract.context.principal.id]); assert.deepEqual(contractLocationBadges(contract, 'score-site', 'matsuda', held), [ { variant: 'score-site', text: 'SCORE SITE' }, ]); diff --git a/tests/unit/game/keycard.test.ts b/tests/unit/game/keycard.test.ts index df10448..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'; @@ -10,7 +12,6 @@ import { World } from '../../../src/game/World.js'; import { Entity } from '../../../src/game/Entity.js'; import { Campaign, SITE_ROSTER_CAP } from '../../../src/game/Campaign.js'; import { OUTCOME, Run } from '../../../src/game/Run.js'; -import { generateSiteId, siteIdForContract } from '../../../src/game/locations.js'; import { emptySalvage } from '../../../src/game/salvage.js'; import { Door } from '../../../src/game/entities/Door.js'; import { Terminal } from '../../../src/game/entities/Terminal.js'; @@ -36,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, LocationSite } from '../../../src/types.js'; +import type { KeyItem, LocationSite, LocationToken } from '../../../src/types.js'; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -79,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, @@ -93,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, @@ -115,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', () => { @@ -126,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/ ); }); @@ -219,10 +259,10 @@ test('Campaign.addKeyItem: throws on missing fields', () => { assert.throws(() => c.addKeyItem({ id: 'kc-1', label: 'test', doorId: '' }), /non-empty/); }); -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 ─────────────────────────────────────────── @@ -230,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', () => { @@ -315,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; @@ -326,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 }, { @@ -345,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; @@ -360,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 }, { @@ -385,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(); @@ -530,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]); @@ -539,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( @@ -554,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!, }); }, } @@ -573,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', @@ -614,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', @@ -680,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' ); }); @@ -792,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 }, @@ -974,9 +1005,9 @@ test('bump into locked door without keycard keeps door locked', () => { assert.equal(door.locked, true, 'door should remain locked'); }); -// ─── Keycard site-id stamping (P3.1 balance) ───────────────────────────────── +// ─── Keycard principal stamping (P3.1-balance) ─────────────────────────────── -test('spawned keycard is stamped with the contract-derived site id (no explicit locationSiteId)', () => { +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++) { @@ -992,32 +1023,29 @@ test('spawned keycard is stamped with the contract-derived site id (no explicit } } assert.ok(run, 'need a keycard-door layout'); - // The fresh contract carries no locationSiteId — the keycard should still be - // stamped with the seed-derived roster id (not left run-scoped/null). - assert.equal(contract!.context.locationSiteId, undefined); const keycard = [...run!.world!.entities.values()].find(e => e instanceof KeyCard) as KeyCard; assert.ok(keycard); - assert.equal(keycard.siteId, siteIdForContract(contract!)); - assert.equal(keycard.siteId, generateSiteId(contract!.seed as number)); + assert.equal(keycard.principalId, contract!.context.principal.id); + assert.equal(keycard.principalId, 'matsuda'); }); -// ─── Run-scoped keycards carry siteId ──────────────────────────────────────── +// ─── Run-scoped keycards carry principalId ─────────────────────────────────── -test('Run.addKeyItem preserves siteId', () => { +test('Run.addKeyItem preserves principalId', () => { const run = new Run({ crewMember: makeCrew(), seed: 99 }); - run.addKeyItem({ id: 'kc-1', label: 'Site card', doorId: 'door-0', siteId: 'site-7' }); - assert.equal(run.keyItems[0]!.siteId, 'site-7'); + 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 siteId', () => { +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-site', label: 'Site card', doorId: 'door-0', siteId: 'site-9' }); + run.addKeyItem({ id: 'kc-owner', label: 'Owner card', doorId: 'door-0', principalId: 'yutani' }); const rec = snapshot(run); - assert.equal(rec.keyItems![0]!.siteId, 'site-9'); + assert.equal(rec.keyItems![0]!.principalId, 'yutani'); const { run: restored } = restore(rec); - assert.equal(restored.keyItems[0]!.siteId, 'site-9'); + assert.equal(restored.keyItems[0]!.principalId, 'yutani'); }); // ─── Run-end promotion into campaign inventory ─────────────────────────────── @@ -1029,25 +1057,25 @@ function deployWithRun(campaign: Campaign, contract = fakeContract()) { return run; } -test('onJobEnd promotes a site-stamped run keycard into the campaign inventory on live extraction', () => { +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: 'Site card', + label: 'Owner card', doorId: 'door-0', - siteId: 'site-promote', + 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]!.siteId, 'site-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: 'Site card', doorId: 'door-0', siteId: 'site-abort' }); + 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'); @@ -1056,12 +1084,12 @@ test('onJobEnd promotes carried keycards even on an aborted (incomplete) extract test('onJobEnd does NOT promote keycards when the operator flatlines', () => { const campaign = makeCampaign(); const run = deployWithRun(campaign); - run.addKeyItem({ id: 'kc-dead', label: 'Site card', doorId: 'door-0', siteId: 'site-dead' }); + 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 siteId', () => { +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' }); @@ -1071,44 +1099,32 @@ test('onJobEnd does not promote run-scoped keycards lacking a siteId', () => { test('onJobEnd promotion is idempotent against an already-held campaign keycard', () => { const campaign = makeCampaign(); - campaign.addKeyItem({ id: 'kc-dup', label: 'Site card', doorId: 'door-0', siteId: 'site-dup' }); + campaign.addKeyItem({ + id: 'kc-dup', + label: 'Owner card', + doorId: 'door-0', + principalId: 'matsuda', + }); const run = deployWithRun(campaign); - run.addKeyItem({ id: 'kc-dup', label: 'Site card', doorId: 'door-0', siteId: 'site-dup' }); + 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'); }); -// ─── Eviction drops the evicted site's keycards ────────────────────────────── - -function makeSite(id: string, lastVisitedJob: number): LocationSite { - return { - id, - seed: id, - mapWidth: 24, - mapHeight: 16, - label: `// site ${id}`, - tier: 'roster', - scoreTarget: false, - mutationDeltas: [], - seenKeys: [], - lastVisitedJob, - }; -} - -// ─── P3.1: site-unique keycard ids (collision fix) ─────────────────────────── +// ─── P3.1-balance: principal-unique keycard ids ────────────────────────────── -test('keycardIdFor: encodes the site id so distinct sites get distinct ids', () => { - assert.equal(keycardIdFor('door-0', 'siteA'), 'keycard-door-0-siteA'); - assert.equal(keycardIdFor('door-0', 'siteB'), 'keycard-door-0-siteB'); - assert.notEqual(keycardIdFor('door-0', 'siteA'), keycardIdFor('door-0', 'siteB')); +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 site id) keeps the bare id', () => { +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 site-unique (not the bare 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++) { @@ -1126,160 +1142,187 @@ test('spawned keycard id is site-unique (not the bare keycard-door-0)', () => { assert.ok(run, 'need a keycard-door layout'); const keycard = [...run!.world!.entities.values()].find(e => e instanceof KeyCard) as KeyCard; assert.ok(keycard); - const siteId = siteIdForContract(contract!); - assert.equal(keycard.id, keycardIdFor('door-0', siteId)); - assert.notEqual(keycard.id, 'keycard-door-0', 'legacy bare id would collide across sites'); + 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'); }); -// ─── P3.1: legacy id migration on restore ──────────────────────────────────── +// ─── Legacy id migration on restore ────────────────────────────────────────── -test('migrateLegacyKeycardId: re-stamps a legacy bare id with its site id', () => { +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', - siteId: 'site-42', + principalId: 'matsuda', }); - assert.equal(migrated.id, 'keycard-door-0-site-42'); + assert.equal(migrated.id, 'keycard-door-0-matsuda'); }); test('migrateLegacyKeycardId: idempotent on an already-migrated id', () => { const item = { - id: 'keycard-door-0-site-42', + id: 'keycard-door-0-matsuda', label: 'Access keycard', doorId: 'door-0', - siteId: 'site-42', + principalId: 'matsuda', }; - assert.equal(migrateLegacyKeycardId(item).id, 'keycard-door-0-site-42'); + assert.equal(migrateLegacyKeycardId(item).id, 'keycard-door-0-matsuda'); }); -test('migrateLegacyKeycardId: leaves run-scoped (no siteId) and custom ids untouched', () => { +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 siteId → nothing to disambiguate' + 'no principalId → nothing to disambiguate' ); assert.equal( - migrateLegacyKeycardId({ id: 'kc-1', label: 'x', doorId: 'door-0', siteId: 's' }).id, + migrateLegacyKeycardId({ id: 'kc-1', label: 'x', doorId: 'door-0', principalId: 'p' }).id, 'kc-1', 'non-legacy custom id is preserved' ); }); -test('Campaign restore migrates a legacy bare keycard id to a site-unique id', () => { +// ─── 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', label: 'Access keycard', doorId: 'door-0', siteId: '210241126' }, + { id: 'keycard-door-0-4242', label: 'Access keycard', doorId: 'door-0', siteId: '4242' }, ]; const restored = restoreCampaign(snap); - assert.equal(restored.keyItems[0]!.id, 'keycard-door-0-210241126'); - assert.equal(restored.keyItems[0]!.siteId, '210241126'); + 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('Run restore migrates a legacy bare keycard id to a site-unique id', () => { - 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', label: 'Access keycard', doorId: 'door-0', siteId: '1062993016' }, +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')), ]; - const { run: restored } = restore(rec); - assert.equal(restored.keyItems[0]!.id, 'keycard-door-0-1062993016'); + (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('migration repro: two colliding legacy cards survive as distinct cards', () => { - // The reported bug: a held Vuong card and a freshly-picked auction card both - // serialized as `keycard-door-0`. After migration they are distinct ids so - // promotion no longer silently drops one. - const campaign = makeCampaign(); - const snap = snapshotCampaign(campaign); +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', label: 'Access keycard', doorId: 'door-0', siteId: '210241126' }, + { id: 'keycard-door-0-ghost', label: 'Access keycard', doorId: 'door-0', siteId: 'ghost' }, ]; const restored = restoreCampaign(snap); - // Simulate the run-carried auction card promoting on extraction. - const runCardId = keycardIdFor('door-0', '1062993016'); - assert.notEqual(runCardId, restored.keyItems[0]!.id, 'ids no longer collide'); - restored.addKeyItem({ - id: runCardId, - label: 'Access keycard', - doorId: 'door-0', - siteId: '1062993016', - }); - assert.equal(restored.keyItems.length, 2, 'both site cards retained'); + assert.deepEqual(restored.keyItems, [], 'unresolvable legacy card dropped'); }); -// ─── P3.1: Run.effectiveKeyItems scopes to the current site ─────────────────── +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.effectiveKeyItems: includes only campaign cards for the current site', () => { - 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 siteId = siteIdForContract(contract!); - const campaignKeyItems: KeyItem[] = [ - { id: keycardIdFor('door-0', siteId), label: 'This site', doorId: 'door-0', siteId }, +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: keycardIdFor('door-0', 'other-site'), - label: 'Other site', + id: 'keycard-door-0-1062993016', + label: 'Access keycard', doorId: 'door-0', - siteId: 'other-site', + siteId: '1062993016', }, ]; - const effective = run!.effectiveKeyItems(campaignKeyItems); + 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.siteId === siteId), - 'current-site card is included' + 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.siteId === 'other-site'), - 'other-site card is excluded (was the phantom second keycard)' + !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(keycardDoorContract(42)); - const siteId = siteIdForContract(run.contract!); - const sharedId = keycardIdFor('door-0', siteId); - run.addKeyItem({ id: sharedId, label: 'Picked up', doorId: 'door-0', siteId }); + 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', siteId }, + { 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 siteId. + // Simulates onKeycardCollected feeding a legacy entity id + its principalId. run.addKeyItem({ id: 'keycard-door-0', label: 'Access keycard', doorId: 'door-0', - siteId: '1062993016', + principalId: 'matsuda', }); - assert.equal(run.keyItems[0]!.id, 'keycard-door-0-1062993016'); + 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', siteId: 'site-x' }, + { id: 'kc-camp', label: 'Camp card', doorId: 'door-0', principalId: 'matsuda' }, ]); assert.deepEqual( effective.map(k => k.id), @@ -1287,19 +1330,25 @@ test('Run.effectiveKeyItems: no contract → only run-scoped pickups', () => { ); }); -test('addSiteToRoster eviction removes keycards belonging to the evicted site', () => { +// ─── 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)); + campaign.addSiteToRoster(makeSite(`site-${i}`, i, TOKEN('matsuda', 'Matsuda'))); } - campaign.addKeyItem({ id: 'kc-old', label: 'Old card', doorId: 'door-0', siteId: 'site-0' }); - campaign.addKeyItem({ id: 'kc-keep', label: 'Kept card', doorId: 'door-1', siteId: 'site-1' }); + campaign.addKeyItem({ + id: keycardIdFor('door-0', 'matsuda'), + label: 'Matsuda card', + doorId: 'door-0', + principalId: 'matsuda', + }); - // One more site evicts the oldest (site-0). - campaign.addSiteToRoster(makeSite('site-new', SITE_ROSTER_CAP + 1)); + // 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, 'evicted-site keycard dropped'); - assert.equal(campaign.keyItems[0]!.id, 'kc-keep', 'surviving-site keycard retained'); + 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 3556666..5da5d18 100644 --- a/tests/unit/game/locations.test.ts +++ b/tests/unit/game/locations.test.ts @@ -728,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, { @@ -747,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', @@ -768,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' From 89e4972a42a7fe6b360580b69349cddf9bed5382 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Fri, 10 Jul 2026 18:27:23 -0700 Subject: [PATCH 15/17] guard against autoscaling --- index.html | 5 ++++- main.css | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 75660e6..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" /> - + 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 { From fcedfd2ce78f8391adc3d5e9a39c60ca2bb1a9b6 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Fri, 10 Jul 2026 18:28:42 -0700 Subject: [PATCH 16/17] improve crew roster --- components/CrewRoster.ts | 225 +++++++++++++++++++--------- images/charge.png | Bin 0 -> 37858 bytes images/incind.png | Bin 0 -> 36024 bytes images/smoke.png | Bin 0 -> 46856 bytes images/stim.png | Bin 0 -> 35519 bytes package.json | 2 +- src/game/crewDisplay.ts | 57 +++---- sw-core.js | 4 + sw-dev.js | 2 +- sw.js | 2 +- tests/unit/game/crewDisplay.test.ts | 118 ++++++++------- tests/unit/shell/statusLine.test.ts | 1 + 12 files changed, 249 insertions(+), 162 deletions(-) create mode 100644 images/charge.png create mode 100644 images/incind.png create mode 100644 images/smoke.png create mode 100644 images/stim.png diff --git a/components/CrewRoster.ts b/components/CrewRoster.ts index fec80c1..0b02b8a 100644 --- a/components/CrewRoster.ts +++ b/components/CrewRoster.ts @@ -27,29 +27,88 @@ import { type TypedSalvage, } from '/src/game/salvage.js'; import type { Crew as CrewMember } from '/src/game/Crew.js'; -import type { Item } from '/src/game/items.js'; -import { gearLines, statLines } from '/src/game/crewDisplay.js'; - -/** Human-readable labels for item IDs (mirrors CombatInventory). */ -const ITEM_LABELS = { - stim: 'Stim', - 'smoke-charge': 'Smoke Charge', - 'breaching-charge': 'Breaching Charge', -}; - -/** Aggregate consumables into "Label x2" lines. */ -function consumableLines(consumables: Item[]) { - if (!consumables || consumables.length === 0) return []; +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 = ` @@ -61,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; @@ -142,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 { @@ -163,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 { @@ -190,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; @@ -343,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', { @@ -363,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); @@ -522,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( @@ -540,7 +651,7 @@ class CrewRoster extends HTMLElement { } // Stats — full combat readout with gear bonuses annotated inline. - this.#detailEl!.appendChild(this.#buildStatsSection(full)); + this.#detailEl!.appendChild(statsTable(full)); // Gear const gLines = gearLines(full.gear); @@ -557,33 +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); - } - - /** - * Build the STATS section for a crew member or recruit. Delegates the - * stat→line mapping to {@link statLines} (pure, tested) so both detail panes - * render an identical, complete combat readout. - */ - #buildStatsSection(member: CrewMember): HTMLElement { - const section = h('div', { className: 'detail-section' }); - section.appendChild(h('p', { className: 'detail-section-title', textContent: 'STATS' })); - for (const line of statLines(member)) { - section.appendChild(h('p', { className: 'detail-stat', textContent: line })); - } - return section; + this.#detailEl!.appendChild(consumablesRow(consumables)); } // ─── Recruitment ───────────────────────────────────────────────────── @@ -653,18 +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()}]`, - }), - ]) - ); + 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(this.#buildStatsSection(recruit)); + this.#detailEl!.appendChild(statsTable(recruit)); } /** diff --git a/images/charge.png b/images/charge.png new file mode 100644 index 0000000000000000000000000000000000000000..09e3fb139421a1b9ed8e374a165aaa160960597d GIT binary patch literal 37858 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 Xr%ty2gGUUj00000NkvXXu0mjff@B-Q literal 0 HcmV?d00001 diff --git a/package.json b/package.json index df7f9ae..660fbb6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kernel-panic", - "version": "0.3.1-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/crewDisplay.ts b/src/game/crewDisplay.ts index efb3404..d6aa949 100644 --- a/src/game/crewDisplay.ts +++ b/src/game/crewDisplay.ts @@ -6,9 +6,9 @@ * surface. * * `gearLines` names the equipment (what the player bought from Finn); - * `statLines` shows the resulting combat numbers with each gear contribution - * annotated inline. The two are complementary: GEAR is the loadout, STATS is - * the effect. + * `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'; @@ -16,8 +16,8 @@ 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 `statLines` - * formats them verbatim rather than re-deriving the caps. + * 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; @@ -36,17 +36,19 @@ export interface StatReadout { const pct = (n: number) => `${(n * 100).toFixed(0)}%`; /** - * Format a crew member's combat stats as display lines. The seven core stats - * (HP, AP, AIM, DODGE, RANGED, MELEE, ARMOUR) always appear so the pane reads - * like a character sheet; the two per-turn regen effects only appear when a - * piece of gear grants them (a `+0/turn` row would be noise). Gear-boosted - * stats carry an inline `(+N)` so the bonus is legible without cross-checking - * the GEAR block. + * 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 statLines(stats: StatReadout): string[] { +export function statDisplays(stats: StatReadout): Record { const hitBonus = stats.gear?.hitBonus ?? 0; const dodgeBonus = stats.gear?.dodgeBonus ?? 0; - const apBonus = stats.gear?.apBonus ?? 0; const rangedBonus = stats.effectiveRangedDamageBonus ?? 0; const meleeBonus = stats.effectiveMeleeDamageBonus ?? 0; const shieldRegen = stats.gear?.shieldRegen ?? 0; @@ -55,18 +57,19 @@ export function statLines(stats: StatReadout): string[] { const aim = Math.min(stats.baseHitChance + hitBonus, 1); const dodge = Math.min(stats.baseDodgeChance + dodgeBonus, 1); - const lines = [ - `HP ${stats.hp}/${stats.maxHp}`, - `AP ${stats.maxAp}${apBonus > 0 ? ` (+${apBonus})` : ''}`, - `AIM ${pct(aim)}${hitBonus > 0 ? ` (+${pct(hitBonus)})` : ''}`, - `DODGE ${pct(dodge)}${dodgeBonus > 0 ? ` (+${pct(dodgeBonus)})` : ''}`, - `RANGED ${stats.rangedDamage + rangedBonus} dmg${rangedBonus > 0 ? ` (+${rangedBonus})` : ''}`, - `MELEE ${stats.meleeDamage + meleeBonus} dmg${meleeBonus > 0 ? ` (+${meleeBonus})` : ''}`, - `ARMOUR ${stats.damageReduction}`, - ]; - if (shieldRegen > 0) lines.push(`SHIELD +${shieldRegen}/turn`); - if (hpRegen > 0) lines.push(`REGEN +${hpRegen} HP/turn`); - return lines; + 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; } /** @@ -81,14 +84,14 @@ export function statLines(stats: StatReadout): string[] { export function gearLines(gear: Gear | null | undefined): string[] { if (!gear) return []; const lines: string[] = []; - if (gear.maxHpBonus > 0) lines.push(`Armour Plating +${gear.maxHpBonus} HP`); + 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} armour`); + 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`); 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 f777c18..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.1-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 8d60b93..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.1'; +const VERSION = '0.3.2'; importScripts(`/sw-core.js?v=${VERSION}`); const cacheConfig = CacheConfig.create(VERSION); diff --git a/tests/unit/game/crewDisplay.test.ts b/tests/unit/game/crewDisplay.test.ts index 95f4277..59fd1d3 100644 --- a/tests/unit/game/crewDisplay.test.ts +++ b/tests/unit/game/crewDisplay.test.ts @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { gearLines, statLines } from '../../../src/game/crewDisplay.js'; +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'; @@ -27,8 +27,8 @@ test('gearLines surfaces the four pre-M6 channels', () => { rangedDamageBonus: 1, }); assert.ok( - lines.some(l => l.includes('Armour Plating') && l.includes('+2 HP')), - `expected Armour Plating line, got ${JSON.stringify(lines)}` + 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%'))); @@ -78,29 +78,38 @@ test('every applyGear item yields at least one roster line', () => { }); // --------------------------------------------------------------------------- -// statLines — roster STATS block. Core combat stats always show; gear bonuses -// surface inline; per-turn regen only when active. +// 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. // --------------------------------------------------------------------------- -test('statLines always shows the seven core stats, no regen rows by default', () => { +/** 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 lines = statLines(crew); - for (const label of ['HP', 'AP', 'AIM', 'DODGE', 'RANGED', 'MELEE', 'ARMOUR']) { - assert.ok( - lines.some(l => l.startsWith(label + ' ') || l.startsWith(label + ' ')), - `missing ${label} stat in ${JSON.stringify(lines)}` - ); + 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)}`); } - assert.ok(!lines.some(l => l.startsWith('SHIELD')), 'no shield regen row without gear'); - assert.ok(!lines.some(l => l.startsWith('REGEN')), 'no hp regen row without gear'); - // A bare operator shows no bonus annotations. - assert.ok( - !lines.some(l => l.includes('(+')), - `unexpected bonus on bare crew: ${JSON.stringify(lines)}` - ); }); -test('statLines annotates each gear-boosted stat inline', () => { +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); @@ -108,56 +117,49 @@ test('statLines annotates each gear-boosted stat inline', () => { crew.applyGear(ITEM_ID.MONOBLADE); crew.applyGear(ITEM_ID.REFLEX_BOOSTER); crew.applyGear(ITEM_ID.SUBDERMAL_PLATING); - const lines = statLines(crew); - assert.ok( - lines.some(l => l.startsWith('AIM') && l.includes('(+')), - 'AIM bonus' - ); - assert.ok( - lines.some(l => l.startsWith('DODGE') && l.includes('(+')), - 'DODGE bonus' - ); - assert.ok( - lines.some(l => l.startsWith('RANGED') && l.includes('(+')), - 'RANGED bonus' - ); - assert.ok( - lines.some(l => l.startsWith('MELEE') && l.includes('(+')), - 'MELEE bonus' - ); - assert.ok( - lines.some(l => l.startsWith('AP') && l.includes('(+')), - 'AP bonus' - ); - // Subdermal Plating raises ARMOUR off its 0 base. - assert.ok( - lines.some(l => l.startsWith('ARMOUR') && /ARMOUR\s+1/.test(l)), - 'ARMOUR value' - ); + 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('statLines shows regen rows only when phase shield / regen mesh equipped', () => { +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 lines = statLines(crew); + 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( - lines.some(l => l.startsWith('SHIELD') && l.includes('/turn')), - 'shield regen row' + labels.shield.startsWith('+') && !labels.shield.startsWith('++'), + `shield sign: ${labels.shield}` ); assert.ok( - lines.some(l => l.startsWith('REGEN') && l.includes('HP/turn')), - 'hp regen row' + labels.regen.startsWith('+') && !labels.regen.startsWith('++'), + `regen sign: ${labels.regen}` ); }); -test('statLines reflects archetype melee/ranged differences without gear', () => { - // Razor's heavier blade should read on the MELEE line; a fresh recruit-style +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 }); - const razorMelee = statLines(razor).find(l => l.startsWith('MELEE')); - const mercMelee = statLines(merc).find(l => l.startsWith('MELEE')); - assert.ok(razorMelee && mercMelee); - assert.notEqual(razorMelee, mercMelee); + assert.notEqual(statDisplays(razor).melee, statDisplays(merc).melee); }); 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: [], From 9e716527220cb0f485ee76d5bb05185553fcabdc Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Fri, 10 Jul 2026 18:46:03 -0700 Subject: [PATCH 17/17] prevent useless gear purchases --- components/FinnShop.ts | 38 ++++++++++++----------------- docs/kaizen.md | 1 + src/game/Campaign.ts | 8 ++++++ src/game/Crew.ts | 42 ++++++++++++++++++++++++++++++++ tests/unit/game/Campaign.test.ts | 42 ++++++++++++++++++++++++++++++++ tests/unit/game/Crew.test.ts | 41 +++++++++++++++++++++++++++++++ 6 files changed, 149 insertions(+), 23 deletions(-) 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/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/src/game/Campaign.ts b/src/game/Campaign.ts index 5bafae4..b24efb4 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -1223,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; 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/tests/unit/game/Campaign.test.ts b/tests/unit/game/Campaign.test.ts index ccc8d01..a10d9bc 100644 --- a/tests/unit/game/Campaign.test.ts +++ b/tests/unit/game/Campaign.test.ts @@ -512,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. 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);