diff --git a/components/ChronicleArchive.ts b/components/ChronicleArchive.ts new file mode 100644 index 0000000..162db15 --- /dev/null +++ b/components/ChronicleArchive.ts @@ -0,0 +1,541 @@ +import { h } from '/src/domUtils.js'; +import type { CampaignChronicleEntry } from '/src/game/chronicle.js'; +import type { CampaignSummary } from '/src/game/campaignSummary.js'; + +type ChronicleData = { + activeChronicle?: { + statusLines?: readonly string[]; + entries: readonly CampaignChronicleEntry[]; + } | null; + history: readonly CampaignSummary[]; + acquisitions: { unlocked: number; total: number }; +}; + +type ChronicleTab = 'chronicle' | 'history'; + +type ChronicleMetaRefs = { + body: HTMLElement; + meta: HTMLElement; + panel: HTMLElement; + status: HTMLElement; + tabs: HTMLElement; +}; + +const CSS = ` +:host { + --chronicle-bg: rgba(7, 18, 16, 0.96); + --chronicle-border: var(--accent-color, #00d9a5); + --chronicle-text: #c5efdf; + --chronicle-dim: #6ae8c8; + --tab-active: rgba(0, 217, 165, 0.18); + --chronicle-accent: var(--accent-color, #00d9a5); + --chronicle-gold: #ffd166; + --chronicle-danger: #ff6a78; + --chronicle-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: 65; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.62); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: var(--chronicle-text); +} + +:host([open]) { + display: flex; +} + +.panel { + width: min(880px, 96vw); + max-height: min(760px, 92vh); + display: flex; + flex-direction: column; + background: linear-gradient(180deg, rgba(7, 18, 16, 0.98), rgba(2, 8, 7, 0.98)); + border: 1px solid var(--chronicle-border); + border-radius: 6px; + box-shadow: var(--chronicle-shadow); + overflow: hidden; +} + +.head { + padding: 1rem 1.25rem 0.9rem; + border-bottom: 1px dashed rgba(0, 217, 165, 0.3); +} + +.title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.title { + margin: 0; + color: var(--chronicle-accent); + letter-spacing: 0.18em; + font-size: 0.98rem; +} + +.meta { + margin-top: 0.7rem; + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.pill { + border: 1px solid rgba(106, 232, 200, 0.25); + border-radius: 999px; + padding: 0.25rem 0.6rem; + color: var(--chronicle-gold); + font-size: 0.82rem; + letter-spacing: 0.08em; +} + +.status { + margin-top: 0.7rem; + display: flex; + flex-direction: column; + gap: 0.2rem; + color: var(--chronicle-dim); + font-size: 0.84rem; +} + +.tabs { + display: flex; + gap: 0.45rem; + padding: 0.85rem 1.25rem 0; +} + +.tab { + appearance: none; + -webkit-appearance: none; + background: transparent; + color: var(--chronicle-dim); + border: 1px solid rgba(106, 232, 200, 0.35); + border-radius: 4px; + min-height: 40px; + padding: 0.45rem 0.75rem; + font: inherit; + cursor: pointer; +} + +button.tab[aria-selected='true'] { + background: var(--tab-active); + color: var(--chronicle-accent); + font-weight: 700; +} + +.body { + flex: 1; + overflow: auto; + padding: 1rem 1.25rem 1.2rem; +} + +.empty { + margin: 0; + color: var(--chronicle-dim); + font-style: italic; +} + +.entry { + border: 1px solid rgba(106, 232, 200, 0.18); + border-radius: 6px; + padding: 0.85rem 0.9rem; + background: rgba(0, 0, 0, 0.18); +} + +.entry + .entry { + margin-top: 0.75rem; +} + +.entry-head { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: baseline; + margin-bottom: 0.4rem; +} + +.entry-title { + color: var(--chronicle-accent); + font-size: 0.9rem; + letter-spacing: 0.12em; +} + +.entry-tag { + color: var(--chronicle-gold); + font-size: 0.76rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.entry-summary { + margin: 0; + line-height: 1.5; +} + +.detail-list { + margin: 0.55rem 0 0; + padding-left: 1rem; + color: var(--chronicle-dim); + font-size: 0.84rem; +} + +.detail-list li { + margin: 0.12rem 0; +} + +.history-grid { + display: grid; + gap: 0.75rem; +} + +.history-result { + color: var(--chronicle-gold); + font-size: 0.78rem; + letter-spacing: 0.12em; +} + +.history-result.loss { + color: var(--chronicle-danger); +} + +.history-result.partial { + color: #ffb347; +} + +@media (max-width: 720px) { + .panel { + width: 100vw; + max-height: 100vh; + border-radius: 0; + } + + .entry-head, + .title-row { + flex-direction: column; + align-items: flex-start; + } +} +`; + +const TAB_HISTORY = 'history'; +const TAB_CHRONICLE = 'chronicle'; +type TabId = typeof TAB_HISTORY | typeof TAB_CHRONICLE; + +const STAGE_LABELS = Object.freeze({ + 'act-1': 'Stage 1', + 'act-2': 'Stage 2', + 'act-3': 'Stage 3', + score: 'The Score', +}); + +const EMPTY_DATA: ChronicleData = Object.freeze({ + activeChronicle: null, + history: Object.freeze([]), + acquisitions: Object.freeze({ unlocked: 0, total: 0 }), +}); + +class ChronicleArchive extends HTMLElement { + #ready = false; + #data: ChronicleData = EMPTY_DATA; + #activeTab: ChronicleTab = 'history'; + #els: ChronicleMetaRefs | null = null; + #onBackdrop: ((evt: PointerEvent) => void) | null = null; + #onKeyDown: ((evt: KeyboardEvent) => 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); + + const meta = h('div', { className: 'meta' }); + const status = h('div', { className: 'status' }); + const tabs = h('div', { className: 'tabs' }); + const body = h('div', { className: 'body' }); + + const panel = h('section', { className: 'panel' }, [ + h('div', { className: 'head' }, [ + h('div', { className: 'title-row' }, [ + h('h2', { className: 'title', textContent: '── LEDGER ──' }), + ]), + meta, + status, + ]), + tabs, + body, + ]); + + shadow.appendChild(panel); + this.#els = { body, meta, panel, status, tabs }; + this.#onKeyDown = evt => this.#handleKey(evt); + this.#onBackdrop = evt => this.#handleBackdrop(evt); + this.addEventListener('keydown', this.#onKeyDown); + this.addEventListener('click', this.#onBackdrop); + this.#ready = true; + this.#render(); + } + + disconnectedCallback() { + if (this.#onKeyDown) this.removeEventListener('keydown', this.#onKeyDown); + if (this.#onBackdrop) this.removeEventListener('click', this.#onBackdrop); + } + + setData(data: ChronicleData) { + this.#data = normalizeChronicleData(data); + if (!this.#data.activeChronicle) { + this.#activeTab = 'history'; + } + this.#render(); + } + + show() { + this.setAttribute('open', ''); + queueMicrotask(() => this.focus()); + } + + hide() { + this.removeAttribute('open'); + } + + get isOpen() { + return this.hasAttribute('open'); + } + + #render() { + if (!this.#ready || !this.#els) return; + this.#els.meta.replaceChildren(...this.#buildMetaPills()); + this.#els.status.replaceChildren(...this.#buildStatusLines()); + this.#els.tabs.replaceChildren(...this.#buildTabs()); + this.#els.body.replaceChildren( + this.#activeTab === 'chronicle' ? this.#buildChronicle() : this.#buildHistory() + ); + } + + #buildMetaPills(): HTMLElement[] { + const pills = [ + h('span', { + className: 'pill', + textContent: `ACQUISITIONS: ${this.#data.acquisitions.unlocked} / ${this.#data.acquisitions.total}`, + }), + h('span', { + className: 'pill', + textContent: `ARCHIVE: ${this.#data.history.length} campaigns`, + }), + ]; + if (this.#data.activeChronicle) { + pills.push( + h('span', { + className: 'pill', + textContent: `LIVE LOG: ${this.#data.activeChronicle.entries.length} entries`, + }) + ); + } + return pills; + } + + #buildStatusLines(): HTMLElement[] { + return (this.#data.activeChronicle?.statusLines ?? []).map(line => + h('div', { textContent: line }) + ); + } + + #buildTabs(): HTMLElement[] { + const tabs: Array<{ id: ChronicleTab; label: string }> = [ + { id: 'chronicle', label: 'Active Chronicle' }, + { id: 'history', label: 'History' }, + ]; + return tabs.map(tab => { + const button = h('button', { + type: 'button', + className: 'tab', + textContent: tab.label, + }); + button.setAttribute('aria-selected', String(this.#activeTab === tab.id)); + button.setAttribute('role', 'tab'); + button.addEventListener('click', () => { + this.#activeTab = tab.id; + this.#render(); + }); + return button; + }); + } + + #buildChronicle(): HTMLElement { + const chronicle = this.#data.activeChronicle; + if (!chronicle || chronicle.entries.length === 0) { + return h('p', { + className: 'empty', + textContent: + 'No campaign entries yet. The log will fill as the crew takes jobs and the arc unfolds.', + }); + } + return h( + 'div', + {}, + chronicle.entries.map(entry => + h('article', { className: 'entry' }, [ + h('div', { className: 'entry-head' }, [ + h('div', { className: 'entry-title', textContent: entry.title }), + h('div', { + className: 'entry-tag', + textContent: `${STAGE_LABELS[entry.stage]} • ${entry.kind}`, + }), + ]), + h('p', { className: 'entry-summary', textContent: entry.summary }), + h( + 'ul', + { className: 'detail-list' }, + entry.detailLines.map(line => h('li', { textContent: line })) + ), + ]) + ) + ); + } + + #buildHistory(): HTMLElement { + if (this.#data.history.length === 0) { + return h('p', { + className: 'empty', + textContent: 'No archived campaigns yet. Finish a campaign and it will appear here.', + }); + } + return h( + 'div', + { className: 'history-grid' }, + this.#data.history.map(summary => { + const resultClass = + summary.result === 'loss' + ? 'history-result loss' + : summary.result === 'partial' + ? 'history-result partial' + : 'history-result'; + const rewardLine = summary.scoreReward + ? `Blueprint stolen: ${summary.scoreReward.label}` + : summary.result === 'win' + ? 'Blueprint stolen: abstract credit cache' + : `End reason: ${summary.endReason}`; + return h('article', { className: 'entry' }, [ + h('div', { className: 'entry-head' }, [ + h('div', { + className: 'entry-title', + textContent: `${summary.completedJobs} jobs • ${summary.credits} Cr • REP ${summary.rep}`, + }), + h('div', { + className: resultClass, + textContent: summary.result.toUpperCase(), + }), + ]), + h('p', { + className: 'entry-summary', + textContent: `${formatDate(summary.completedAt)} — ${rewardLine}.`, + }), + h('ul', { className: 'detail-list' }, [ + h('li', { textContent: `Campaign seed ${summary.seed}` }), + h('li', { + textContent: `Crew at end: ${summary.crewRoster + .map( + member => + `${member.callsign} (${member.archetype}${member.flatlined ? ', flatlined' : ''})` + ) + .join(', ')}`, + }), + ]), + ]); + }) + ); + } + + #handleBackdrop(evt: PointerEvent) { + if (!this.isOpen || !this.#els) return; + if (evt.composedPath().includes(this.#els.panel)) return; + this.dispatchEvent(new CustomEvent('dismiss', { bubbles: true, composed: true })); + } + + #switchTab(tab: TabId) { + if (tab === this.#activeTab) return; + this.#activeTab = tab; + this.#render(); + } + + #handleKey(evt: KeyboardEvent) { + if (!this.isOpen) return; + evt.stopPropagation(); + + switch (evt.key) { + case 'Escape': + evt.preventDefault(); + this.dispatchEvent(new CustomEvent('dismiss', { bubbles: true, composed: true })); + break; + + case 'ArrowLeft': + case 'a': + evt.preventDefault(); + this.#switchTab(TAB_CHRONICLE); + break; + + case 'ArrowRight': + case 'd': + evt.preventDefault(); + this.#switchTab(TAB_HISTORY); + break; + + case 'Tab': + evt.preventDefault(); + this.#switchTab(this.#activeTab === TAB_HISTORY ? TAB_CHRONICLE : TAB_HISTORY); + return; + } + } +} + +function normalizeChronicleData(data: ChronicleData): ChronicleData { + if (!data || typeof data !== 'object') { + throw new TypeError('.setData requires a data object'); + } + if (!Array.isArray(data.history)) { + throw new TypeError('.setData requires history[]'); + } + if ( + !data.acquisitions || + !Number.isInteger(data.acquisitions.unlocked) || + !Number.isInteger(data.acquisitions.total) + ) { + throw new TypeError('.setData requires acquisitions counts'); + } + if (data.activeChronicle && !Array.isArray(data.activeChronicle.entries)) { + throw new TypeError('.setData activeChronicle.entries must be an array'); + } + return { + activeChronicle: data.activeChronicle + ? { + entries: [...data.activeChronicle.entries], + statusLines: [...(data.activeChronicle.statusLines ?? [])], + } + : null, + history: [...data.history], + acquisitions: { ...data.acquisitions }, + }; +} + +function formatDate(value: string): string { + const parsed = new Date(value); + if (!Number.isFinite(parsed.getTime())) return value; + return parsed.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +customElements.define('chronicle-archive', ChronicleArchive); + +export default ChronicleArchive; diff --git a/components/ContractSelect.ts b/components/ContractSelect.ts index 2eddd21..db097e4 100644 --- a/components/ContractSelect.ts +++ b/components/ContractSelect.ts @@ -6,6 +6,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 type { Contract } from '/src/game/hub/Curator.js'; @@ -139,6 +140,11 @@ const CSS = ` background: var(--board-danger); } +.badge.score { + background: #f8f7ff; + color: #020403; +} + .known { color: var(--board-dim); border: 1px solid rgba(106, 232, 200, 0.45); @@ -149,6 +155,18 @@ const CSS = ` white-space: nowrap; } +.known.score-site { + color: #020403; + background: var(--board-warn); + border-color: var(--board-warn); +} + +.known.casing { + color: #020403; + background: #9be7ff; + border-color: #9be7ff; +} + .meta { color: var(--board-dim); font-size: 0.84rem; @@ -185,6 +203,8 @@ const CSS = ` class ContractSelect extends HTMLElement { #contracts: Contract[] = []; + #scoreTargetSiteId: string | null = null; + #scorePrincipalId: string | null = null; #selectedIndex = 0; #ready = false; #listEl: HTMLElement | null = null; @@ -235,6 +255,22 @@ class ContractSelect extends HTMLElement { if (this.#ready) this.#render(); } + setScoreTargetSiteId(siteId: string | null) { + if (siteId !== null && (typeof siteId !== 'string' || siteId.length === 0)) { + throw new TypeError('.setScoreTargetSiteId requires a site id or null'); + } + this.#scoreTargetSiteId = siteId; + if (this.#ready) this.#render(); + } + + setScorePrincipalId(principalId: string | null) { + if (principalId !== null && (typeof principalId !== 'string' || principalId.length === 0)) { + throw new TypeError('.setScorePrincipalId requires a principal id or null'); + } + this.#scorePrincipalId = principalId; + if (this.#ready) this.#render(); + } + show() { this.setAttribute('open', ''); queueMicrotask(() => this.#focusSelected()); @@ -268,15 +304,22 @@ class ContractSelect extends HTMLElement { h('div', { className: 'primary' }, [ h('div', { className: 'name' }, [ h('span', { - className: `badge ${contract.difficulty}`, + className: `badge ${isScoreContract(contract) ? 'score' : contract.difficulty}`, textContent: difficultyLabel(contract), }), h('span', { className: 'target', textContent: jobTitleCopy(contract) }), ]), - h('div', { className: 'location' }, locationLine(contract)), + h( + 'div', + { className: 'location' }, + locationLine(contract, this.#scoreTargetSiteId, this.#scorePrincipalId) + ), h('div', { className: 'meta', textContent: rewardCopy(contract) }), ]), - h('div', { className: 'take', textContent: 'TAKE THE JOB' }), + h('div', { + className: 'take', + textContent: isScoreContract(contract) ? 'TAKE THE SCORE' : 'TAKE THE JOB', + }), ] ) as HTMLButtonElement; if (index === this.#selectedIndex) button.setAttribute('selected', ''); @@ -337,28 +380,39 @@ class ContractSelect extends HTMLElement { } function difficultyLabel(contract: Contract): string { + if (isScoreContract(contract)) return 'SCORE'; return DIFFICULTY_LABEL[contract.difficulty] ?? contract.difficulty.toUpperCase(); } function rewardCopy(contract: Contract): string { + const creditsFormatted = contract.reward.credits.toLocaleString(); + if (isScoreContract(contract)) { + return `${encounterHostileCount(contract)} hostiles · Cr +${creditsFormatted} · campaign on the line`; + } const recruit = contract.reward.recruit ? ' · recruit lead' : ''; - return `${encounterHostileCount(contract)} hostiles · Cr +${contract.reward.credits} · REP +${contract.reward.repDelta}${recruit}`; + return `${encounterHostileCount(contract)} hostiles · Cr +${creditsFormatted} · REP +${contract.reward.repDelta}${recruit}`; } function jobTitleCopy(contract: Contract): string { + if (isScoreContract(contract)) return '// The Score'; const turnLimit = contract.objective.params?.turnLimit; const window = Number.isInteger(turnLimit) && Number(turnLimit) > 0 ? ` · WINDOW ${turnLimit} turns` : ''; return `// ${contract.objective.title}${window}`; } -function locationLine(contract: Contract): HTMLElement[] { - const { principal, site, siteState, locationSiteId } = contract.context; +function locationLine( + contract: Contract, + scoreTargetSiteId: string | null, + scorePrincipalId: string | null +): 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}` })]; - if (locationSiteId) { - nodes.push(h('span', { className: 'known', textContent: '// known site' })); + for (const badge of contractLocationBadges(contract, scoreTargetSiteId, scorePrincipalId)) { + const className = badge.variant === 'revisit' ? 'known' : `known ${badge.variant}`; + nodes.push(h('span', { className, textContent: badge.text })); } return nodes; } @@ -371,6 +425,10 @@ function cloneContract(contract: Contract): Contract { }; } +function isScoreContract(contract: Contract): boolean { + return contract.context.tags.includes('score') && contract.context.recipeId === 'score-final'; +} + customElements.define('contract-select', ContractSelect); export default ContractSelect; diff --git a/components/CrashDump.ts b/components/CrashDump.ts index a3f7d75..8c8cf41 100644 --- a/components/CrashDump.ts +++ b/components/CrashDump.ts @@ -1,10 +1,8 @@ /** - * — DOM overlay shown while `Run.state === RESULT`, when - * `Campaign.state === ENDED` (restore), or for the terminal job death that - * wipes the crew. Renders the run's telemetry as a faux kernel-panic stack - * trace (on DEATH) or a clean "JACK OUT" debrief (on EXIT). Campaign wipe uses - * `outcome: 'campaign-over'` or `campaignTerminal: true` on a death payload. - * Same component, different copy — keeps the shell's overlay count down. + * — per-job debrief overlay shown while `Run.state === RESULT`. + * Renders run telemetry as a faux kernel-panic stack trace (DEATH setback) or a + * clean "JACK OUT" debrief (EXIT). All terminal campaign outcomes use sibling + * `` and never pass through this per-job screen. * * *** KERNEL PANIC *** * fault: unhandled_exception_in_meatspace @@ -19,8 +17,8 @@ * * Usage: * const dump = document.querySelector('crash-dump'); - * dump.addEventListener('new-run', () => { dataStore.deleteCampaign(); startFreshCampaign(); }); - * dump.setTelemetry({ outcome, archetype, turn, kills, cause, seed, hpAtDeath }); + * dump.addEventListener('new-run', () => onJobEndContinue()); + * dump.setTelemetry({ outcome, archetype, turn, kills, cause, seed }); * dump.show(); * * Visual verification via the shell — same rule as / . @@ -29,8 +27,6 @@ import { h } from '/src/domUtils.js'; import type { Telemetry } from '/src/types.js'; -type CrewMemberStub = { callsign: string; archetype: string; flatlined: boolean }; - const CSS = ` :host { --crash-bg: rgba(7, 12, 11, 0.97); @@ -52,8 +48,7 @@ const CSS = ` justify-content: center; } -:host([outcome="death"]), -:host([outcome="campaign-over"]) { +:host([outcome="death"]) { --crash-accent: var(--crash-warn); --crash-border: var(--crash-warn); } @@ -164,10 +159,6 @@ function exitTitle() { return '*** JACK OUT ***'; } -function campaignOverTitle() { - return '*** CAMPAIGN TERMINATED ***'; -} - function deathFault() { return ['fault: unhandled_exception_in_meatspace', 'addr: 0x00000@meatspace', 'trace:'].join( '\n' @@ -178,46 +169,12 @@ function exitFault() { return ['status: exfil_successful', 'addr: 0x00000@meatspace', 'trace:'].join('\n'); } -function campaignOverFault() { - return [ - 'fault: collective_operator_pool_exhausted', - 'addr: 0x00000@campaign_layer', - 'trace:', - ].join('\n'); -} - -function campaignTerminalFault() { - return [ - 'notice: final_operator_channel_lost — no surviving crew', - 'fault: unhandled_exception_in_meatspace', - 'addr: 0x00000@meatspace', - 'trace:', - ].join('\n'); -} - -function buildCampaignOverTraceLines(crewRoster: CrewMemberStub[]) { - return crewRoster.map((op, i) => { - const idx = (i + 1).toString(16).padStart(2, '0'); - const tag = op.flatlined ? '' : ''; - return { - text: ` 0x${idx} roster::${op.callsign} (${op.archetype})`, - tag, - }; - }); -} - function buildTraceLines(telemetry: Telemetry) { - if (telemetry.outcome === 'campaign-over') { - return buildCampaignOverTraceLines(telemetry.crewRoster ?? []); - } - const archetype = telemetry.archetype ?? 'entity'; const lastSource = telemetry.lastDamageSource ?? 'unknown'; const attacker = telemetry.lastAttacker ?? 'unknown'; const killed = telemetry.outcome === 'death'; - // Format: " 0xNN module::function(arg) " - // We pack synthetic stack frames so the panic reads like a real backtrace. const lines = []; if (telemetry.outcome === 'death') { lines.push({ @@ -233,7 +190,6 @@ function buildTraceLines(telemetry: Telemetry) { tag: '', }); } else { - // EXIT: walk the player off the exit tile. Friendlier trace. lines.push({ text: ` 0x01 ${archetype}::reach_exit()`, tag: '' }); lines.push({ text: ` 0x02 world::move_entity(${archetype})`, tag: '' }); if (Number.isInteger(telemetry.kills) && Number(telemetry.kills) > 0) { @@ -301,37 +257,14 @@ class CrashDump extends HTMLElement { if (this.#telemetry) this.#render(); } - /** - * @param {{ - * outcome: 'death' | 'exit' | 'campaign-over', - * campaignTerminal?: boolean, - * crewRoster?: { callsign: string, archetype: string, flatlined: boolean }[], - * salvage?: number, - * archetype?: string, - * turn?: number, - * kills?: number, - * cause?: string, - * seed?: number, - * hpAtDeath?: number | null, - * hpAtDamage?: number | null, - * lastDamageSource?: string | null, - * lastAttacker?: string | null, - * }} telemetry - */ setTelemetry(telemetry: Telemetry) { if (!telemetry || typeof telemetry !== 'object') { throw new TypeError('.setTelemetry requires a telemetry object'); } - const { outcome, campaignTerminal } = telemetry; - if (outcome !== 'death' && outcome !== 'exit' && outcome !== 'campaign-over') { + const { outcome } = telemetry; + if (outcome !== 'death' && outcome !== 'exit') { throw new Error(`: unknown outcome "${outcome}"`); } - if (campaignTerminal && outcome !== 'death') { - throw new Error(': campaignTerminal is only valid with outcome "death"'); - } - if (outcome === 'campaign-over' && !Array.isArray(telemetry.crewRoster)) { - throw new TypeError(': campaign-over requires crewRoster array'); - } this.#telemetry = { ...telemetry }; this.setAttribute('outcome', outcome); if (this.#ready) this.#render(); @@ -355,14 +288,9 @@ class CrashDump extends HTMLElement { #render() { if (!this.#els || !this.#telemetry) return; const t = this.#telemetry; - const isCampaignOver = t.outcome === 'campaign-over'; const isDeath = t.outcome === 'death'; - const isCampaignTerminalDeath = isDeath && t.campaignTerminal; - if (isCampaignOver || isCampaignTerminalDeath) { - this.#els.title.textContent = campaignOverTitle(); - this.#els.fault.textContent = isCampaignOver ? campaignOverFault() : campaignTerminalFault(); - } else if (isDeath) { + if (isDeath) { this.#els.title.textContent = deathTitle(); this.#els.fault.textContent = deathFault(); } else { @@ -370,11 +298,6 @@ class CrashDump extends HTMLElement { this.#els.fault.textContent = exitFault(); } - const newRunLabel = - isCampaignOver || isCampaignTerminalDeath ? '[ NEW CAMPAIGN ]' : '[ RETURN TO HUB ]'; - this.#els.newRunBtn.textContent = newRunLabel; - - // Build trace with `` highlight. const lines = buildTraceLines(t); this.#els.trace.replaceChildren(); lines.forEach((line, i) => { @@ -393,18 +316,10 @@ class CrashDump extends HTMLElement { }); this.#els.seedDd.textContent = hexSeed(t.seed ?? 0); - if (isCampaignOver) { - this.#els.turnDd.textContent = '—'; - this.#els.killsDd.textContent = '—'; - this.#els.causeDd.textContent = - Number.isInteger(t.salvage) && Number(t.salvage) >= 0 - ? `pool salvage ${t.salvage} (lost with run)` - : 'no-surviving-crew'; - } else { - this.#els.turnDd.textContent = Number.isInteger(t.turn) ? String(t.turn) : '?'; - this.#els.killsDd.textContent = Number.isInteger(t.kills) ? String(t.kills) : '0'; - this.#els.causeDd.textContent = t.cause ?? (isDeath ? 'unknown' : 'exit-reached'); - } + this.#els.turnDd.textContent = Number.isInteger(t.turn) ? String(t.turn) : '?'; + this.#els.killsDd.textContent = Number.isInteger(t.kills) ? String(t.kills) : '0'; + this.#els.causeDd.textContent = t.cause ?? (isDeath ? 'unknown' : 'exit-reached'); + this.#els.newRunBtn.textContent = '[ RETURN TO HUB ]'; } #emit(eventName: string, detail: Record = {}) { diff --git a/components/CrewList.ts b/components/CrewList.ts index 2124c59..61a8c48 100644 --- a/components/CrewList.ts +++ b/components/CrewList.ts @@ -19,8 +19,17 @@ type CrewSummary = { hp: number; maxHp: number; flatlined: boolean; + /** P3.M3.1: non-null when a contract gate disables this row (e.g. 'NEEDS DECKER'). */ + gatedReason: string | null; }; +/** + * P3.M3.1: optional per-row deployment gate. Return a short uppercase tag + * (e.g. 'NEEDS DECKER') to disable the row with that flag, or null to leave + * the row deployable. + */ +export type CrewRowGate = (member: CrewMember) => string | null; + const CSS = ` :host { display: block; @@ -127,11 +136,15 @@ class CrewList extends HTMLElement { /** * @param {Array} crew — array of crew member objects (or snapshots). + * @param rowGate — optional P3.M3.1 deployment gate; see {@link CrewRowGate}. */ - setCrew(crew: CrewMember[]) { + setCrew(crew: CrewMember[], rowGate: CrewRowGate | null = null) { if (!Array.isArray(crew)) { throw new TypeError('.setCrew requires an array'); } + if (rowGate !== null && typeof rowGate !== 'function') { + throw new TypeError('.setCrew rowGate must be a function when set'); + } this.#crew = crew.map(member => ({ id: member.id, callsign: member.callsign ?? member.id, @@ -139,10 +152,11 @@ class CrewList extends HTMLElement { hp: member.hp, maxHp: member.maxHp, flatlined: !!member.flatlined, + gatedReason: rowGate ? rowGate(member) : null, })); this.#selectedIndex = Math.max( 0, - this.#crew.findIndex(member => !member.flatlined) + this.#crew.findIndex(member => isSelectable(member)) ); if (this.#ready) this.#render(); // Emit initial selection so the parent can populate the detail pane. @@ -175,9 +189,9 @@ class CrewList extends HTMLElement { for (let i = 0; i < this.#crew.length; i++) { const member = this.#crew[i]; - // Only flatlined members are disabled; living rows stay focusable for - // keyboard navigation and click-to-select. - const disabled = member.flatlined; + // Flatlined members and contract-gated rows (P3.M3.1) are disabled; + // living deployable rows stay focusable for keyboard nav and click. + const disabled = !isSelectable(member); const row = h('button', { type: 'button', className: 'row', @@ -191,7 +205,7 @@ class CrewList extends HTMLElement { h('span', { className: 'name', textContent: member.callsign }), h('span', { className: 'flag', - textContent: member.flatlined ? 'FLATLINED' : '', + textContent: member.flatlined ? 'FLATLINED' : (member.gatedReason ?? ''), }), h('span', { className: 'meta', @@ -223,7 +237,7 @@ class CrewList extends HTMLElement { let next = this.#selectedIndex; for (let i = 0; i < this.#crew.length; i++) { next = (next + delta + this.#crew.length) % this.#crew.length; - if (!this.#crew[next].flatlined) break; + if (isSelectable(this.#crew[next])) break; } this.#selectedIndex = next; this.#syncCurrent(); @@ -233,7 +247,7 @@ class CrewList extends HTMLElement { #onRowClick(index: number) { const member = this.#crew[index]; - if (!member || member.flatlined) return; + if (!member || !isSelectable(member)) return; // Update selection to clicked row. this.#selectedIndex = index; this.#syncCurrent(); @@ -254,6 +268,10 @@ class CrewList extends HTMLElement { } } +function isSelectable(member: CrewSummary): boolean { + return !member.flatlined && member.gatedReason === null; +} + customElements.define('crew-list', CrewList); export default CrewList; diff --git a/components/CrewRoster.ts b/components/CrewRoster.ts index 1dd7337..b3408bb 100644 --- a/components/CrewRoster.ts +++ b/components/CrewRoster.ts @@ -11,6 +11,7 @@ * Usage: * rosterEl.setCrew(campaign.crew, { * salvage: campaign.salvage, + * campaignStatus: ['ACT 2: CASING | SCORE: Matsuda server farm', 'CLOCK: HEAT 1 / 4 JOBS LEFT'], * availableRecruits: campaign.availableRecruits, * recruitedThisVisit: campaign.recruitedThisVisit, * }); @@ -116,6 +117,23 @@ const CSS = ` letter-spacing: 0.08em; } +.campaign-status { + margin: -0.35rem 0 0.75rem; + display: flex; + flex-direction: column; + gap: 0.15rem; + text-align: center; + color: #ffd166; + font-size: 0.82rem; + letter-spacing: 0.08em; +} + +.campaign-status__line { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .body { display: grid; grid-template-columns: 1fr 1fr; @@ -308,6 +326,7 @@ class CrewRoster extends HTMLElement { #detailEl: HTMLElement | null = null; #titleEl: HTMLElement | null = null; #balanceEl: HTMLElement | null = null; + #campaignStatusEl: HTMLElement | null = null; #hintEl: HTMLElement | null = null; #panelEl: HTMLElement | null = null; #recruitSectionEl: HTMLElement | null = null; @@ -329,6 +348,7 @@ class CrewRoster extends HTMLElement { this.#titleEl = h('h2', { className: 'title', textContent: '── CREW ROSTER ──' }); this.#balanceEl = h('p', { className: 'balance' }); + this.#campaignStatusEl = h('div', { className: 'campaign-status' }); this.#listEl = document.createElement('crew-list') as CrewList; this.#listEl.addEventListener('select', evt => @@ -359,6 +379,7 @@ class CrewRoster extends HTMLElement { this.#panelEl = h('section', { className: 'panel' }, [ this.#titleEl, this.#balanceEl, + this.#campaignStatusEl, body, this.#recruitSectionEl, this.#hintEl, @@ -389,10 +410,12 @@ class CrewRoster extends HTMLElement { crew: CrewMember[], { salvage = emptySalvage(), + campaignStatus = '', availableRecruits = [] as CrewMember[], recruitedThisVisit = false, }: { salvage?: TypedSalvage; + campaignStatus?: string | readonly string[]; availableRecruits?: CrewMember[]; recruitedThisVisit?: boolean; } = {} @@ -407,6 +430,7 @@ class CrewRoster extends HTMLElement { // Show typed breakdown + total const total = totalSalvage(this.#salvage); this.#balanceEl!.textContent = `SALVAGE ${total} · ${formatSalvageCompact(this.#salvage)}`; + this.#renderCampaignStatus(campaignStatus); this.#recruitFocused = false; this.#selectedRecruitIndex = -1; // Crew list handles its own rendering; selection triggers detail update. @@ -414,6 +438,17 @@ class CrewRoster extends HTMLElement { this.#renderRecruits(); } + #renderCampaignStatus(status: string | readonly string[]) { + const lines = typeof status === 'string' ? status.split('\n') : status; + const normalized = lines.map(line => line.trim()).filter(Boolean); + this.#campaignStatusEl!.replaceChildren( + ...normalized.map(line => + h('span', { className: 'campaign-status__line', textContent: line }) + ) + ); + this.#campaignStatusEl!.style.display = normalized.length > 0 ? '' : 'none'; + } + show() { this.setAttribute('open', ''); queueMicrotask(() => { diff --git a/components/FaultScreen.ts b/components/FaultScreen.ts index 0930fec..586ceaf 100644 --- a/components/FaultScreen.ts +++ b/components/FaultScreen.ts @@ -2,8 +2,8 @@ * — the top-level error boundary's user-facing surface. * * This is deliberately **non-diegetic**, and that distinction matters. The - * sibling `` is in-fiction: a faux "KERNEL PANIC" stack trace shown - * when your operator *dies* — a normal, authored game outcome. Routing a real + * sibling `` / `` are in-fiction: per-job debrief and + * terminal campaign loss — normal, authored game outcomes. Routing a real * software bug through that screen would disguise the bug as an in-universe * death (the player shrugs and hits "new run"), which is exactly the silent * failure the project's error doctrine forbids (see `AGENTS.md` → "Error diff --git a/components/GameOver.ts b/components/GameOver.ts new file mode 100644 index 0000000..4b666fd --- /dev/null +++ b/components/GameOver.ts @@ -0,0 +1,427 @@ +/** + * — the single terminal campaign overlay. + * + * Both outcomes consume the Chronicle's validated `CampaignSummary`: wins use + * a cyan/green SCORE COMPLETE treatment, while losses retain the red GAME OVER + * presentation. Per-job death and exfil debriefs remain in ``. + * + * Usage: + * const screen = document.querySelector('game-over'); + * screen.addEventListener('new-run', () => finishEndedCampaign()); + * screen.setSummary(summary); + * screen.show(); + */ + +import { h } from '/src/domUtils.js'; +import { validateCampaignSummary, type CampaignSummary } from '/src/game/campaignSummary.js'; + +const CSS = ` +:host { + --go-accent: #ff3355; + --go-bg: rgba(4, 2, 3, 0.98); + --go-text: #f0d4da; + --go-dim: #c96b7d; + --go-roster-border: rgba(255, 51, 85, 0.28); + --go-shadow: 0 0 48px rgba(255, 51, 85, 0.35), 0 16px 48px rgba(0, 0, 0, 0.75); + + display: none; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: var(--go-text); + background: + radial-gradient(ellipse at center, rgba(255, 51, 85, 0.12) 0%, transparent 65%), + rgba(0, 0, 0, 0.88); + animation: game-over-backdrop 2.4s ease-in-out infinite alternate; +} + +:host([result='win']) { + --go-accent: #00e5b0; + --go-bg: rgba(2, 10, 9, 0.98); + --go-text: #d8fff5; + --go-dim: #72d9c0; + --go-roster-border: rgba(0, 229, 176, 0.3); + --go-shadow: 0 0 54px rgba(0, 229, 176, 0.28), 0 16px 48px rgba(0, 0, 0, 0.72); + background: + radial-gradient(ellipse at center, rgba(0, 229, 176, 0.14) 0%, transparent 68%), + rgba(0, 5, 4, 0.88); + animation: score-complete-backdrop 5s ease-in-out infinite alternate; +} + +:host([open]) { + display: flex; + align-items: center; + justify-content: center; +} + +@keyframes game-over-backdrop { + from { background-color: rgba(0, 0, 0, 0.86); } + to { background-color: rgba(12, 2, 4, 0.92); } +} + +@keyframes score-complete-backdrop { + from { background-color: rgba(0, 4, 3, 0.86); } + to { background-color: rgba(1, 14, 11, 0.92); } +} + +.panel { + background: var(--go-bg); + border: 2px solid var(--go-accent); + border-radius: 6px; + padding: 1.25rem 1.5rem 1.5rem; + box-shadow: var(--go-shadow); + min-width: min(520px, 94vw); + max-width: min(680px, 96vw); + text-align: center; +} + +.banner { + margin: 0 0 0.35rem; + font-size: clamp(1.55rem, 6vw, 2.5rem); + letter-spacing: 0.22em; + color: var(--go-accent); + text-shadow: 0 0 18px color-mix(in srgb, var(--go-accent) 45%, transparent); + animation: game-over-pulse 1.8s ease-in-out infinite; +} + +:host([result='win']) .banner { + animation-duration: 3.6s; +} + +@keyframes game-over-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.82; } +} + +.rule { + margin: 0 auto 1.25rem; + width: min(320px, 76%); + border: none; + border-top: 2px solid var(--go-accent); + opacity: 0.7; +} + +.reason { + margin: 0 0 0.75rem; + font-size: 1.05rem; + color: var(--go-text); +} + +.detail { + margin: 0 0 1.25rem; + font-size: 0.9rem; + color: var(--go-dim); + line-height: 1.45; +} + +.reward { + display: none; + margin: 0 0 1.25rem; + padding: 0.6rem 0.85rem; + background: rgba(0, 0, 0, 0.4); + border: 1px solid var(--go-roster-border); + border-radius: 4px; + text-align: center; +} + +.reward .reward-kicker { + display: block; + font-size: 0.7rem; + letter-spacing: 0.18em; + color: var(--go-dim); +} + +.reward .reward-name { + display: block; + margin-top: 0.2rem; + font-size: 1.1rem; + letter-spacing: 0.06em; + color: var(--go-accent); + text-shadow: 0 0 12px color-mix(in srgb, var(--go-accent) 40%, transparent); +} + +.reward .reward-flavor { + display: block; + margin-top: 0.35rem; + font-size: 0.82rem; + font-style: italic; + color: var(--go-text); + line-height: 1.4; +} + +.roster { + margin: 0 0 1.25rem; + padding: 0.65rem 0.85rem; + background: rgba(0, 0, 0, 0.45); + border: 1px solid var(--go-roster-border); + border-radius: 4px; + font-size: 0.85rem; + text-align: left; +} + +.roster dt { + margin: 0; + color: var(--go-dim); + letter-spacing: 0.1em; + font-size: 0.75rem; +} + +.roster dd { + margin: 0.35rem 0 0; + display: grid; + gap: 0.2rem; +} + +.roster-line { + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.roster-line .status { + color: var(--go-accent); + letter-spacing: 0.08em; +} + +.roster-line .status.flatlined { + color: #ff6680; +} + +.meta { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 1.25rem; + row-gap: 0.25rem; + font-size: 0.85rem; + margin: 0 0 1.25rem; + text-align: left; +} + +.meta dt { + color: var(--go-dim); + letter-spacing: 0.08em; +} + +.meta dd { + margin: 0; + word-break: break-word; +} + +.actions { + display: flex; + justify-content: center; +} + +button.new-campaign { + appearance: none; + -webkit-appearance: none; + background: transparent; + color: var(--go-accent); + border: 2px solid var(--go-accent); + padding: 0.65em 2.25em; + border-radius: 4px; + font-family: inherit; + font-size: 1.05rem; + letter-spacing: 0.2em; + cursor: pointer; + min-height: 44px; + transition: background 0.15s ease, color 0.15s ease; +} + +button.new-campaign:hover, +button.new-campaign:focus-visible { + background: var(--go-accent); + color: #020403; + outline: none; +} + +button.new-campaign:active { transform: scale(0.98); } +`; + +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; + #els: { + banner: HTMLElement; + reason: HTMLElement; + detail: HTMLElement; + reward: HTMLElement; + rewardName: HTMLElement; + rewardFlavor: HTMLElement; + rosterList: HTMLElement; + jobsDd: HTMLElement; + repDd: HTMLElement; + creditsDd: HTMLElement; + seedDd: HTMLElement; + causeDd: HTMLElement; + } | null = null; + + connectedCallback() { + if (this.#ready) return; + const shadow = this.attachShadow({ mode: 'open' }); + const style = h('style'); + style.textContent = CSS; + shadow.appendChild(style); + + const banner = h('h1', { className: 'banner' }); + const reason = h('p', { className: 'reason' }); + const detail = h('p', { className: 'detail' }); + 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 rosterList = h('dd'); + const jobsDd = h('dd', { id: 'jobs' }); + const repDd = h('dd', { id: 'rep' }); + const creditsDd = h('dd', { id: 'credits' }); + const seedDd = h('dd', { id: 'seed' }); + const causeDd = h('dd', { id: 'cause' }); + const roster = h('dl', { className: 'roster' }, [ + h('dt', { textContent: 'FINAL ROSTER' }), + rosterList, + ]); + + const newCampaignBtn = h('button', { + type: 'button', + className: 'new-campaign', + textContent: '[ NEW CAMPAIGN ]', + }) as HTMLButtonElement; + newCampaignBtn.addEventListener('click', () => this.#emit('new-run')); + + const panel = h('section', { className: 'panel' }, [ + banner, + h('hr', { className: 'rule' }), + reason, + detail, + reward, + roster, + h('dl', { className: 'meta' }, [ + h('dt', { textContent: 'jobs' }), + jobsDd, + h('dt', { textContent: 'rep' }), + repDd, + h('dt', { textContent: 'credits' }), + creditsDd, + h('dt', { textContent: 'seed' }), + seedDd, + h('dt', { textContent: 'cause' }), + causeDd, + ]), + h('div', { className: 'actions' }, [newCampaignBtn]), + ]); + shadow.appendChild(panel); + this.#els = { + banner, + reason, + detail, + reward, + rewardName, + rewardFlavor, + rosterList, + jobsDd, + repDd, + creditsDd, + seedDd, + causeDd, + }; + this.#ready = true; + if (this.#summary) this.#render(); + } + + setSummary(summary: CampaignSummary) { + this.#summary = validateCampaignSummary(summary); + this.setAttribute('result', this.#summary.result); + if (this.#ready) this.#render(); + } + + show() { + this.setAttribute('open', ''); + queueMicrotask(() => { + (this.shadowRoot?.querySelector('button.new-campaign') as HTMLButtonElement)?.focus(); + }); + } + + hide() { + this.removeAttribute('open'); + } + + get isOpen() { + return this.hasAttribute('open'); + } + + #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); + // Score prize — present on a win that stole a specific blueprint. + const reward = summary.result === 'win' ? (summary.scoreReward ?? null) : null; + if (reward) { + this.#els.rewardName.textContent = reward.label; + this.#els.rewardFlavor.textContent = reward.flavor; + this.#els.reward.style.display = 'block'; + } else { + this.#els.reward.style.display = 'none'; + } + this.#els.jobsDd.textContent = String(summary.completedJobs); + this.#els.repDd.textContent = String(summary.rep); + this.#els.creditsDd.textContent = String(summary.credits); + this.#els.seedDd.textContent = hexSeed(summary.seed); + this.#els.causeDd.textContent = summary.endReason; + this.#els.rosterList.replaceChildren( + ...summary.crewRoster.map(operator => + h('div', { className: 'roster-line' }, [ + h('span', { textContent: `${operator.callsign} (${operator.archetype})` }), + h('span', { + className: `status${operator.flatlined ? ' flatlined' : ''}`, + textContent: operator.flatlined ? 'FLATLINED' : 'SURVIVED', + }), + ]) + ) + ); + } + + #emit(eventName: string) { + this.dispatchEvent( + new CustomEvent(eventName, { + detail: { summary: this.#summary ? validateCampaignSummary(this.#summary) : null }, + }) + ); + } +} + +customElements.define('game-over', GameOver); + +export default GameOver; diff --git a/components/KeyHelp.ts b/components/KeyHelp.ts index 91a3225..58e3b9f 100644 --- a/components/KeyHelp.ts +++ b/components/KeyHelp.ts @@ -486,7 +486,7 @@ class KeyHelp extends HTMLElement { const hubExtra = h('p', { textContent: - 'In the hub, walk up to the Curator or the crew terminal (‡ glyph) and use Interact to hear rumors, pick contracts, or open the crew roster.', + 'In the hub, walk up to the Curator, the crew terminal (‡ glyph), or the archive ledger (L glyph) and use Interact to hear rumors, open the crew roster, or review the Chronicle and campaign archive.', }); const combatExtra = h('p', { diff --git a/components/RunBriefing.ts b/components/RunBriefing.ts index 261aa0c..249f4e4 100644 --- a/components/RunBriefing.ts +++ b/components/RunBriefing.ts @@ -20,9 +20,10 @@ import { h } from '/src/domUtils.js'; import CrewList from '/components/CrewList.js'; import { encounterHostileCount } from '/src/game/encounters.js'; -import { cloneObjective } from '/src/game/hub/Curator.js'; +import { cloneObjective, contractRequiresCyberspace } from '/src/game/hub/Curator.js'; import type { Crew as CrewMember } from '/src/game/Crew.js'; import type { Contract } from '/src/game/hub/Curator.js'; +import type { CrewRowGate } from '/components/CrewList.js'; type BriefingCells = { target: HTMLElement; @@ -195,6 +196,9 @@ function threatCopy(count: number) { } function rewardCopy(contract: Partial) { + if (contract.context?.tags.includes('score') && contract.context.recipeId === 'score-final') { + return contract.reward ? `Cr +${contract.reward.credits} / Campaign finale` : 'Campaign finale'; + } const reward = contract.reward; if (!reward) return '?'; const recruit = reward.recruit ? ' + recruit lead' : ''; @@ -203,6 +207,7 @@ function rewardCopy(contract: Partial) { class RunBriefing extends HTMLElement { #contract: Contract | null = null; + #crew: CrewMember[] = []; #selectedMember: CrewMember | null = null; #ready = false; #cells: BriefingCells | null = null; @@ -251,7 +256,7 @@ class RunBriefing extends HTMLElement { this.#listEl?.addEventListener('select', evt => { this.#selectedMember = (evt as CustomEvent<{ member: CrewMember }>).detail.member; if (this.#jackInBtn) { - this.#jackInBtn.disabled = !this.#selectedMember || this.#selectedMember.flatlined; + this.#jackInBtn.disabled = !this.#deployable(this.#selectedMember); } }); @@ -306,6 +311,9 @@ class RunBriefing extends HTMLElement { } this.#contract = cloneContract(contract); if (this.#ready) this.#renderContract(); + // The deployment gate depends on the contract (P3.M3.1) — re-render any + // already-set crew so row gating cannot go stale on contract swap. + if (this.#crew.length > 0) this.#syncCrewList(); } /** @@ -316,9 +324,46 @@ class RunBriefing extends HTMLElement { if (!Array.isArray(crew)) { throw new TypeError('.setCrew requires an array'); } + this.#crew = crew; + this.#syncCrewList(); + } + + /** + * P3.M4.1: a Cyberspace dual-deploy auto-includes the Decker (the jack-in + * operator) and asks the player to pick the *meat partner* who rides along. + * The Decker row renders locked (CYBER OP); living non-Deckers are the + * selectable partners. + * + * Solo fallback (P3.M3 behaviour): if no living non-Decker is available, the + * dual-deploy is impossible — only the Decker can deploy, so other rows show + * NEEDS DECKER and the run goes in solo. + */ + #cyberPartnerMode(): boolean { + return ( + !!this.#contract && + contractRequiresCyberspace(this.#contract) && + this.#crew.some(member => member.archetype !== 'Decker' && !member.flatlined) + ); + } + + #rowGate(): CrewRowGate | null { + if (!this.#contract || !contractRequiresCyberspace(this.#contract)) return null; + if (this.#cyberPartnerMode()) { + return member => (member.archetype === 'Decker' ? 'CYBER OP' : null); + } + return member => (member.archetype === 'Decker' ? null : 'NEEDS DECKER'); + } + + #deployable(member: CrewMember | null): boolean { + if (!member || member.flatlined) return false; + const gate = this.#rowGate(); + return gate ? gate(member) === null : true; + } + + #syncCrewList() { this.#selectedMember = null; if (this.#jackInBtn) this.#jackInBtn.disabled = true; - this.#listEl?.setCrew(crew); + this.#listEl?.setCrew(this.#crew, this.#rowGate()); } show() { @@ -379,10 +424,22 @@ class RunBriefing extends HTMLElement { } #commit() { - if (!this.#contract || !this.#selectedMember || this.#selectedMember.flatlined) return; + const selected = this.#selectedMember; + if (!this.#contract || !selected || !this.#deployable(selected)) return; + // P3.M4.1: in dual-deploy mode the selected row is the meat partner and the + // Decker auto-attaches as the jack-in operator; otherwise the selected row + // deploys solo (non-cyber, or cyber with no partner available). + let memberId = selected.id; + let partnerId: string | null = null; + if (this.#cyberPartnerMode()) { + const decker = this.#crew.find(member => member.archetype === 'Decker' && !member.flatlined); + if (!decker) return; // cyber board invariant: a living Decker exists + memberId = decker.id; + partnerId = selected.id; + } this.dispatchEvent( new CustomEvent('deploy', { - detail: { memberId: this.#selectedMember.id, contract: cloneContract(this.#contract) }, + detail: { memberId, partnerId, contract: cloneContract(this.#contract) }, }) ); } diff --git a/components/TouchPad.ts b/components/TouchPad.ts index 36d6faf..f82d6dd 100644 --- a/components/TouchPad.ts +++ b/components/TouchPad.ts @@ -75,6 +75,7 @@ const ACTION_BUTTONS = Object.freeze([ // verb actually resolves, so the player still sees their kit's flavour. { id: 'special', label: 'SPECIAL', shortcut: 'x' }, { id: 'interact', label: 'INTERACT', shortcut: '␣' }, + { id: 'jack-out', label: 'JACK OUT', shortcut: 'j' }, { id: 'look', label: 'LOOK', shortcut: 'l' }, { id: 'inventory', label: 'ITEMS', shortcut: 'i' }, { id: 'end-turn', label: 'WAIT', shortcut: '.' }, diff --git a/debug/save.html b/debug/save.html new file mode 100644 index 0000000..1b15892 --- /dev/null +++ b/debug/save.html @@ -0,0 +1,225 @@ + + + + Kernel Panic — savegame editor + + + + + + + +
+

Kernel Panic — savegame editor

+
+
+
+
+
+
+
+ + + diff --git a/debug/save.ts b/debug/save.ts new file mode 100644 index 0000000..d83d662 --- /dev/null +++ b/debug/save.ts @@ -0,0 +1,443 @@ +/** + * Savegame editor — debug tool for inspecting and modifying the `kp:data` + * localStorage payload without hand-editing raw JSON. + * + * Two views: + * 1. **Quick-edit panel** — labeled fields for the most common campaign + * values (crew HP, salvage, credits, rep, arc stage). + * 2. **Tree editor** — full recursive render of every key/value. + * Scalars are editable inline; objects/arrays collapse. + * + * All edits are staged in memory. Nothing touches localStorage until you + * press "Save". A "Revert" button re-reads from storage. "Export" and + * "Import" round-trip the full JSON blob for clipboard / file backup. + */ +import { h } from '/src/domUtils.js'; + +const STORAGE_KEY = 'kp:data'; +/* eslint-disable-next-line no-unused-vars */ +const CREW_ARCHETYPES = ['merc', 'razor', 'tech', 'decker'] as const; +const ARC_STAGES = ['act-1', 'act-2', 'act-3', 'score'] as const; +const CAMPAIGN_STATES = ['HUB', 'COMBAT', 'ENDED'] as const; + +// --------------------------------------------------------------------------- +// Types (mirrors DataStore's KPData but kept local so we stay import-free +// from the game runtime — this page must work even if the save is corrupt). +// --------------------------------------------------------------------------- +type KPData = { + prefs: Record; + runs: unknown[]; + campaign: Record | null; +}; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- +let data: KPData = { prefs: {}, runs: [], campaign: null }; +let dirty = false; + +function loadFromStorage(): KPData { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return { prefs: {}, runs: [], campaign: null }; + try { + const parsed = JSON.parse(raw); + return { + prefs: parsed.prefs ?? {}, + runs: parsed.runs ?? [], + campaign: parsed.campaign ?? null, + }; + } catch { + return { prefs: {}, runs: [], campaign: null }; + } +} + +function saveToStorage(): void { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + dirty = false; + toast('Saved to localStorage'); + render(); +} + +function revert(): void { + data = loadFromStorage(); + dirty = false; + render(); + toast('Reverted from localStorage'); +} + +// --------------------------------------------------------------------------- +// Toast +// --------------------------------------------------------------------------- +function toast(msg: string): void { + const el = document.getElementById('toast')!; + el.textContent = msg; + el.classList.add('visible'); + setTimeout(() => el.classList.remove('visible'), 1800); +} + +// --------------------------------------------------------------------------- +// Deep accessor helpers +// --------------------------------------------------------------------------- + +/** Walk a dotted path like "campaign.crew.0.hp" into the data tree. */ +/* eslint-disable-next-line no-unused-vars */ +function getByPath(obj: unknown, path: string): unknown { + const parts = path.split('.'); + let cur: unknown = obj; + for (const p of parts) { + if (cur == null || typeof cur !== 'object') return undefined; + cur = (cur as Record)[p]; + } + return cur; +} + +function setByPath(obj: unknown, path: string, value: unknown): void { + const parts = path.split('.'); + let cur: unknown = obj; + for (let i = 0; i < parts.length - 1; i++) { + if (cur == null || typeof cur !== 'object') return; + cur = (cur as Record)[parts[i]]; + } + if (cur != null && typeof cur === 'object') { + (cur as Record)[parts[parts.length - 1]] = value; + } +} + +// --------------------------------------------------------------------------- +// Quick-edit panel +// --------------------------------------------------------------------------- +function renderQuickEdit(): void { + const root = document.getElementById('quick-edit')!; + root.innerHTML = ''; + const c = data.campaign; + if (!c) { + root.appendChild( + h('div', { className: 'empty-state' }, [document.createTextNode('No active campaign.')]) + ); + return; + } + + const panel = h('div', { className: 'quick-edit' }, [ + h('h2', null, [document.createTextNode('Quick edit — campaign')]), + ]); + + const grid = h('div', { className: 'qe-grid' }); + + // --- Economy --- + grid.appendChild(qeNumberField('Credits', c, 'credits', 0)); + grid.appendChild(qeNumberField('Rep', c, 'rep', 0, 0, 100)); + + // Typed salvage wallet + const salvage = c.salvage; + if (salvage && typeof salvage === 'object' && !Array.isArray(salvage)) { + const sw = salvage as Record; + for (const bucket of ['scrap', 'tech', 'bioware', 'synth']) { + if (bucket in sw) { + grid.appendChild(qeNumberField(`Salvage: ${bucket}`, sw, bucket, 0)); + } + } + } else if (typeof salvage === 'number') { + grid.appendChild(qeNumberField('Salvage (legacy)', c, 'salvage', 0)); + } + + // --- Campaign state --- + grid.appendChild(qeSelectField('State', c, 'state', CAMPAIGN_STATES)); + + // --- Arc --- + const arc = c.arc as Record | undefined; + if (arc) { + grid.appendChild(qeSelectField('Arc stage', arc, 'stage', ARC_STAGES)); + grid.appendChild(qeNumberField('Completed jobs', c, 'completedJobs', 0)); + } + + // --- Crew quick rows --- + const crew = c.crew; + if (Array.isArray(crew)) { + for (let i = 0; i < crew.length; i++) { + const m = crew[i] as Record; + const label = (m.callsign as string) || (m.id as string) || `crew-${i}`; + grid.appendChild( + h('span', { className: 'callsign', style: 'grid-column: 1 / -1; margin-top: 0.4rem' }, [ + document.createTextNode(`▸ ${label} (${m.archetype})`), + ]) + ); + grid.appendChild(qeNumberField('HP', m, 'hp', 0, 0, m.maxHp as number)); + grid.appendChild(qeNumberField('Max HP', m, 'maxHp', 1)); + grid.appendChild(qeNumberField('AP', m, 'ap', 0, 0, m.maxAp as number)); + grid.appendChild(qeNumberField('Max AP', m, 'maxAp', 1)); + grid.appendChild(qeBoolField('Alive', m, 'alive')); + grid.appendChild(qeBoolField('Flatlined', m, 'flatlined')); + } + } + + panel.appendChild(grid); + root.appendChild(panel); +} + +function qeNumberField( + label: string, + obj: Record, + key: string, + fallback: number, + min?: number, + max?: number +): HTMLElement { + const cur = typeof obj[key] === 'number' ? (obj[key] as number) : fallback; + const input = h('input', { + type: 'number', + value: String(cur), + ...(min !== undefined ? { min: String(min) } : {}), + ...(max !== undefined ? { max: String(max) } : {}), + }) as HTMLInputElement; + input.addEventListener('change', () => { + const v = Number(input.value); + if (!Number.isFinite(v)) return; + obj[key] = Number.isInteger(cur) ? Math.round(v) : v; + markDirty(); + }); + return h('label', null, [document.createTextNode(label), input]); +} + +function qeBoolField(label: string, obj: Record, key: string): HTMLElement { + const cur = !!obj[key]; + const input = h('input', { type: 'checkbox', checked: cur }) as HTMLInputElement; + input.addEventListener('change', () => { + obj[key] = input.checked; + markDirty(); + }); + return h('label', null, [document.createTextNode(label), input]); +} + +function qeSelectField( + label: string, + obj: Record, + key: string, + options: readonly string[] +): HTMLElement { + const cur = String(obj[key] ?? ''); + const select = h( + 'select', + null, + options.map(o => { + const opt = h('option', { value: o }, [document.createTextNode(o)]) as HTMLOptionElement; + if (o === cur) opt.selected = true; + return opt; + }) + ) as HTMLSelectElement; + select.addEventListener('change', () => { + obj[key] = select.value; + markDirty(); + }); + return h('label', null, [document.createTextNode(label), select]); +} + +function markDirty(): void { + dirty = true; + renderToolbar(); +} + +// --------------------------------------------------------------------------- +// Tree editor +// --------------------------------------------------------------------------- + +function renderTree(): void { + const root = document.getElementById('tree-root')!; + root.innerHTML = ''; + const container = h('div', { className: 'tree' }); + container.appendChild(buildNode('kp:data', data, 'data', true)); + root.appendChild(container); +} + +function buildNode(key: string, value: unknown, path: string, startOpen = false): HTMLElement { + if (value === null || value === undefined) { + return leafNode(key, value, path); + } + if (Array.isArray(value)) { + return arrayNode(key, value, path, startOpen); + } + if (typeof value === 'object') { + return objectNode(key, value as Record, path, startOpen); + } + return leafNode(key, value, path); +} + +function objectNode( + key: string, + obj: Record, + path: string, + startOpen: boolean +): HTMLElement { + const keys = Object.keys(obj); + const details = h('details', startOpen ? { open: true } : {}) as HTMLDetailsElement; + const summary = h('summary', null, [ + h('span', { className: 'key' }, [document.createTextNode(key)]), + document.createTextNode(': '), + h('span', { className: 'bracket' }, [document.createTextNode('{')]), + h('span', { className: 'count' }, [document.createTextNode(` ${keys.length} keys `)]), + h('span', { className: 'bracket' }, [document.createTextNode('}')]), + ]); + details.appendChild(summary); + for (const k of keys) { + details.appendChild(buildNode(k, obj[k], `${path}.${k}`)); + } + return details; +} + +function arrayNode(key: string, arr: unknown[], path: string, startOpen: boolean): HTMLElement { + const details = h('details', startOpen ? { open: true } : {}) as HTMLDetailsElement; + const summary = h('summary', null, [ + h('span', { className: 'key' }, [document.createTextNode(key)]), + document.createTextNode(': '), + h('span', { className: 'bracket' }, [document.createTextNode('[')]), + h('span', { className: 'count' }, [document.createTextNode(` ${arr.length} items `)]), + h('span', { className: 'bracket' }, [document.createTextNode(']')]), + ]); + details.appendChild(summary); + for (let i = 0; i < arr.length; i++) { + details.appendChild(buildNode(String(i), arr[i], `${path}.${i}`)); + } + return details; +} + +function leafNode(key: string, value: unknown, path: string): HTMLElement { + const row = h('div', { className: 'leaf' }); + row.appendChild(h('span', { className: 'key' }, [document.createTextNode(`${key}: `)])); + + if (value === null || value === undefined) { + row.appendChild(h('span', { className: 'val-null' }, [document.createTextNode('null')])); + return row; + } + + if (typeof value === 'boolean') { + const select = h('select', { className: 'inline-edit' }, [ + h('option', { value: 'true', selected: value === true }, [ + document.createTextNode('true'), + ]) as HTMLOptionElement, + h('option', { value: 'false', selected: value === false }, [ + document.createTextNode('false'), + ]) as HTMLOptionElement, + ]) as HTMLSelectElement; + select.classList.add('val-bool'); + select.addEventListener('change', () => { + setByPath(data, path.replace(/^data\./, ''), select.value === 'true'); + select.classList.add('dirty'); + markDirty(); + }); + row.appendChild(select); + return row; + } + + if (typeof value === 'number') { + const input = h('input', { + className: 'inline-edit val-num', + type: 'number', + value: String(value), + style: `width: ${Math.max(4, String(value).length + 2)}ch`, + }) as HTMLInputElement; + input.addEventListener('change', () => { + const v = Number(input.value); + if (!Number.isFinite(v)) return; + setByPath(data, path.replace(/^data\./, ''), v); + input.classList.add('dirty'); + markDirty(); + }); + row.appendChild(input); + return row; + } + + // string + const str = String(value); + const input = h('input', { + className: 'inline-edit val-str', + type: 'text', + value: str, + style: `width: ${Math.max(4, str.length + 2)}ch`, + }) as HTMLInputElement; + input.addEventListener('change', () => { + setByPath(data, path.replace(/^data\./, ''), input.value); + input.classList.add('dirty'); + markDirty(); + }); + row.appendChild(input); + return row; +} + +// --------------------------------------------------------------------------- +// Toolbar +// --------------------------------------------------------------------------- + +function renderToolbar(): void { + const bar = document.getElementById('toolbar')!; + bar.innerHTML = ''; + + const saveBtn = h('button', null, [document.createTextNode(dirty ? '● Save' : 'Save')]); + saveBtn.addEventListener('click', saveToStorage); + + const revertBtn = h('button', null, [document.createTextNode('Revert')]); + revertBtn.addEventListener('click', revert); + + const exportBtn = h('button', null, [document.createTextNode('Export')]); + exportBtn.addEventListener('click', () => { + const json = JSON.stringify(data, null, 2); + navigator.clipboard.writeText(json).then( + () => toast('Copied to clipboard'), + () => { + // Fallback: download as file + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = h('a', { href: url, download: 'kp-save.json' }) as HTMLAnchorElement; + a.click(); + URL.revokeObjectURL(url); + toast('Downloaded kp-save.json'); + } + ); + }); + + const importBtn = h('button', null, [document.createTextNode('Import')]); + importBtn.addEventListener('click', () => { + const json = prompt('Paste JSON:'); + if (!json) return; + try { + const parsed = JSON.parse(json); + data = { + prefs: parsed.prefs ?? {}, + runs: parsed.runs ?? [], + campaign: parsed.campaign ?? null, + }; + dirty = true; + render(); + toast('Imported — press Save to persist'); + } catch (e) { + toast(`Import failed: ${(e as Error).message}`); + } + }); + + const nukeBtn = h('button', { className: 'danger' }, [document.createTextNode('Nuke save')]); + nukeBtn.addEventListener('click', () => { + if (!confirm('Delete ALL save data from localStorage? This cannot be undone.')) return; + window.localStorage.removeItem(STORAGE_KEY); + data = { prefs: {}, runs: [], campaign: null }; + dirty = false; + render(); + toast('Save data nuked'); + }); + + bar.append(saveBtn, revertBtn, exportBtn, importBtn, nukeBtn); +} + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- + +function render(): void { + renderToolbar(); + renderQuickEdit(); + renderTree(); +} + +// --------------------------------------------------------------------------- +// Boot +// --------------------------------------------------------------------------- + +data = loadFromStorage(); +render(); diff --git a/docs/kaizen.md b/docs/kaizen.md index 7a974a0..717ca03 100644 --- a/docs/kaizen.md +++ b/docs/kaizen.md @@ -21,8 +21,8 @@ When an item lands, gets reclassified, or develops new context, edit it in place ## ▶ Phase 3 candidates -- **Cyberspace / Matrix layer.** Jack-in mechanic, second layered grid, ICE AI (Probes, Sparks, Guardians), CCTV PIP window showing physical body status while jacked in. Originally Blueprint Phase 2; moved to Phase 3 so Phase 2 deepens Meatspace first. -- **Decker archetype.** Cyberspace specialist. Deferred alongside the Matrix layer — design the environment before designing who navigates it. +- **Cyberspace / Matrix layer.** **P3.M3 first playable slice shipped (2026-06-11):** `JackInPoint` (Ω) in Meatspace opens the layer; `CyberspaceLayer` owns its own `World`, `CyberAvatar`, data nodes, and Probe ICE; data-node-slice objective with `NODES` HUD chip; Probe ICE with trace-flare and pack convergence; render/input swap to the grid view (distinct cyan/magenta tileset, `// THE GRID //` location, RAM HUD); voluntary jack-out with LINK BURNED latch and early-exit confirmation modal. Score is always a cyber run (scope decision #5). **Still open:** Spark ICE (fast attacker), Guardian ICE (heavy node guard), CCTV PIP window showing inactive layer, dual-deploy simstim flip (P3.M4). See [phase-3-plan.md](phase-3-plan.md) P3.M3–P3.M4 for full slice notes and recorded scope decisions. +- ~~**Decker archetype.**~~ **✓ Closed — P3.M2.** Shipped 2026-06-11: recruited as a narrative beat at Act 1 → Act 2 (same `score-reveal` Hub beat as the Score target), drone override hack, `Decker.ts` archetype with Cyberspace stats (`ram`, `intrusionStrength`, `iceResistance`), normal roster ownership. Cannot be randomly recruited before Act 2; Cyberspace-required deploys fail loudly without a living Decker. Cyberspace avatar (`CyberAvatar`) and its stats are P3.M3. - **Full Rep NPC ally behaviour.** Phase 2 (M5) lays the groundwork: Rep meter, NPC taxonomy, behavior tiers. Phase 3 adds the payoff: high-rep neutrals become Human Shields or information sources, as described in the blueprint. - **Inter-hostile friction — multiple crews fighting each other on one map.** Surfaced during Phase 2.9 planning (June 2026). Phase 2.9 ships `RIVAL` as a faction *value* but deliberately keeps **one hostile faction per run** (faction derived from the contract's principal: corp/civic → `CORP`, rival groups → `RIVAL`). That's the slim, cosmetic-foundational slice — it does *not* put `CORP` and `RIVAL` on the same map. The deferred expansion is **mixed encounters with relationship structure** — a rival crew/gang on a site you're also hitting, where the play value comes from *friction*, not a recolor. The design spectrum we mapped: - **Cooperate vs player** — RIVAL behaves identically and shares your enemy; mechanically a reskin (the M1 display layer already delivers the label/colour delta). Weakest payoff; arguably not worth a faction *model*. @@ -31,13 +31,20 @@ When an item lands, gets reclassified, or develops new context, edit it in place - **Cost insight:** target acquisition is *already faction-general* — `Hostile.acquireTarget` scans for the closest entity `isHostileTo` returns true for, and that's just `faction !== this.faction` (minus NEUTRAL). So the three-way needs **no new targeting AI**; the cost is a stance/relationship helper replacing raw `!==`, plus **balance tuning** to avoid the degenerate case (enemies ignore the player and murder each other while you spectate — lever: weight aggro toward the player or by proximity). - **Why deferred:** Cyberspace (Phase 3) is the higher-impact direction for the game Rylee wants to build first. Revisit after the Matrix layer lands, or sooner if a contract design specifically calls for a contested-site fantasy. The Phase 2.9 `RIVAL` faction + Principal→faction mapping is the foundation this would build on. - **Typed salvage components.** Phase 2 uses a generic `salvage: number` counter. **Intro scope (typed categories + Finn integration) moves to [phase-2.5-plan.md](phase-2.5-plan.md) M4.** Deep multi-recipe crafting and Matrix-adjacent sinks may remain Phase 3+. -- **"What's this glyph?" / "Look" feature:** Roaming-cursor inspect mode to disambiguate `%` (rubble vs corpse) and identify map glyphs via the log feed. **Planned — Tier 2 spec in [`look-feature-plan.md`](look-feature-plan.md)** (cursor, player-turn only, minimal+ labels, floor no-op). Implement when scheduling post–2.5 QoL. +~~- **"What's this glyph?" / "Look" feature:**~~ **(→ Closed in P2.9)** ~~Roaming-cursor inspect mode to disambiguate `%` (rubble vs corpse) and identify map glyphs via the log feed. **Planned — Tier 2 spec in [`look-feature-plan.md`](look-feature-plan.md)** (cursor, player-turn only, minimal+ labels, floor no-op). Implement when scheduling post–2.5 QoL.~~ - **Objective extension backlog beyond Phase 2.5 M2.11–M2.12.** Recon and escort/extract are now planned in [phase-2.5-plan.md](phase-2.5-plan.md), but the M2.10 recipe-builder discussion surfaced additional objective shapes worth preserving: - **Plant / seed / bug:** carry something in, place it at a target, then extract. Fiction: bug a relay, plant evidence, seed malware, place a charge. - **Compound chains:** retrieve then handoff, slice then retrieve, sync then deny, etc. Likely needs a multi-step objective schema rather than more one-kind recipes. - **Clean constraints as modifiers:** no alarm, no civilian harm, no kills, under turn budget. Treat as bonus constraints / payout modifiers unless a future design proves one should be a base objective kind. - **Breach / demolition target:** place or detonate a breach charge at an authored wall or target. Mechanically adjacent to deny/destroy; Phase 2.5 M7 covers breaching foundations. - **Cyberspace / data-layer objectives:** jack in, slice data nodes, open locks, defeat or avoid ICE. Likely Phase 3 dual-layer objective work rather than plain Meatspace recipes. +- **index.ts complexity:** ~~Introduction of the Cyberspace layer pushed complexity of the core UI shell over a reasonable limit; candidates for cleanup / simplification identified:~~ **Partially closed (2026-06-14):** extracted `src/shell/` modules (`activeView`, `statusLine`, `sceneView`, `combatHudSnapshot`, `locationHud`, `visionSync`, `sceneListeners`, `shellRuntime`, `GameShell`); `index.ts` is now boot-only (~65 LOC). Remaining ◇ items: + - ~~**ShellScene = Campaign | Run union** forces isRun() casts everywhere~~ — `sceneView.ts` + `resolveSceneView` landed; casts reduced. + - ~~**statusLine()** is a ~110-line HTML string builder~~ — pure `formatStatusLine` in `statusLine.ts` with unit tests. + - ~~**Four duplicated attachVisionListener/Animation/Rep/Cyber blocks**~~ — `SceneListenerController.rewire()` + `rewireSceneListeners()`. + - ~~**Listener-order coupling on the bus** (JACK_IN/JACK_OUT)~~ — `Run.onJackInPresent` / `onJackOutPresent` shell hooks. + - **run.world/run.player direct reads remain legal** — active-view seam in `activeView.ts`; lint rule still ◇ Monitored. +- ~~**Cyberspace playtesting feedback.**~~ **Closed 2026-06-14:** Probe ICE reduced from 3 HP / 4 AP to 2 HP / 2 AP; the CyberAvatar can use Override against ICE, including allied aftermath actions, reversion, and save round-trip; a pre-Score Decker flatline creates a free replacement Decker lead in the Terminal and gates THE SCORE until recruited; a Decker flatline during THE SCORE ends the campaign with explicit Game Over copy (`decker-flatlined-score`). See the P3.M3 playtest stabilization note in [phase-3-plan.md](phase-3-plan.md). ## ◇ Monitored @@ -55,6 +62,7 @@ When an item lands, gets reclassified, or develops new context, edit it in place - **`Run.id` collision risk.** `Run.makeRunId(seed)` concatenates `seed` and `Date.now()` for a non-cryptographic id; two runs started on the same millisecond with the same seed would collide. Vanishingly unlikely in practice, but if a future automated playthrough harness ever spins many runs quickly, switch to `crypto.randomUUID()`. Logged in `Run.js`. - **No Hub healing path.** M4 made HP persistent across jobs but deliberately shipped with no Hub-side heal beyond Armour Plating's +1 hp side-effect. **→ M5.3 (Hub clinic NPC)** will ship a dedicated Doc NPC on the Hub map: heal-to-full for Creds, one heal per crew member per visit. See [phase-2.5-plan.md](phase-2.5-plan.md) M5.3. - **Dev browser reports multiple service workers.** M8 browser smoke showed `[KernelPanic] Multiple service workers detected` before accepting the waiting worker. The update flow recovered and reloaded cleanly, with no console errors after activation, so this is monitored rather than blocking. Revisit if update prompts repeat in a loop or stale bundles survive an accepted update. +- **`RunBriefing` still has one direct DOM creation call.** `components/RunBriefing.ts` creates its nested `` with `document.createElement('crew-list')` instead of the project-standard `h()` helper. Not touched during P3.M1 because it is unrelated to campaign arc behavior; clean it up next time that component is edited. - **`mapConnectivity` cleanup backlog (post–Pier 7 audit).** Chokepoint avoidance, entity-aware RECON eligible cells, and `explorationReachableKeys` as the shared flood are landed (`mapConnectivity.ts`; see [phase-2.5-plan.md](phase-2.5-plan.md) M2.11 / M6.3). Remaining items are mechanical dedup — no known correctness gap: - **Anchor finder loops in `Run.ts`.** `findInteractableAnchor`, `findDecoupledTerminalAnchor`, `findInteractableAnchorByReachability`, and `findBehindDoorFallbackAnchor` each repeat the same grid scan + filter predicates (~80 lines). Extract a shared candidate collector when next touching objective placement. - **Door-state exploration wrapper duplicated.** `anchorPreservesExplorationReachabilityForDoorAnchor` (`Run.ts`) and `preservesExplorationWithDoorState` (`mapBuild.ts`) are the same lock/unlock + chokepoint check. Consolidate when either file is next edited. diff --git a/docs/phase-2-plan.md b/docs/phase-2-plan.md index 7aa480d..32d322f 100644 --- a/docs/phase-2-plan.md +++ b/docs/phase-2-plan.md @@ -49,7 +49,7 @@ Test count at Phase 2 start: **409 passing** (end of Phase 1 / M8). - *Truly neutral* — civilians; Rep-sensitive (behavior scales with meter level). - *Corp-aligned non-combatant* — office workers, desk security; do not fight but trigger an alarm (all drones in the map enter ENGAGE) if they spot the player. - **Rep:** Campaign-level meter (0–100, starting at 50). Raised by clean contract completion; lowered by civilian/neutral kills. Gates neutral NPC behavior and crew recruitment unlocks. -- **Recruitment:** New crew members unlock when Rep reaches a threshold (suggest 65) or as a specific contract reward. Archetype and callsign generated on recruit; callsign deduplication applies. +- **Recruitment:** New crew members unlock when Rep reaches the KNOWN tier floor (`REP.RECRUIT_THRESHOLD`, 50) or as a specific contract reward. Archetype and callsign generated on recruit; callsign deduplication applies. - **Animations (M0):** Turn-blocking — input disabled for ~300ms during the longest active animation. Three effects: screen shake (CSS `@keyframes` translate on game container, ~150ms), damage reddening (CRT filter temporary red vignette, ~300ms), muzzle flash (1-frame canvas color override at shooter's tile, ~80ms). All wired to the existing event bus. No game-logic changes. - **Unified special-action key (M1):** Vault, Slide, and Deploy collapse into a single `x` → `MODE.SPECIAL_AIM` → `{ type: 'special', dx, dy }` intent at the keymap layer; `applyIntent.doSpecial` dispatches to the archetype's perk by capability sniffing (`canDeploy` → Tech, `canVault` → Merc, `canSlide` → Razor). One key, one touch-pad button, one help row — no WASD collision (the original plan's `d` key clashed with WASD-right), and adding a future archetype only requires implementing its perk method. - **Interact key rebound to Space (M3):** `i` → Space (`' '`). Roguelike players associate `i` with inventory (which M4's Finn shop will want). Space is the universal "activate" key in modern games — accessible, no directional collision (qezc diagonals, WASD, arrows all occupied). Keymap, touchpad, key-help rows, and proximity hints all updated. `i` is now unbound, reserved for inventory in M4. @@ -72,8 +72,8 @@ Test count at Phase 2 start: **409 passing** (end of Phase 1 / M8). - **Campaign-start recruitment (M6):** New campaigns start with an empty crew (`crew: []`). The shell shows ``, then presents `` — a full-screen overlay with 3 randomly-generated candidates (weighted 40/40/20 Merc/Razor/Tech). Player picks 2 of 3; the unchosen candidate is discarded. `Campaign.recruitInitial(memberIds)` commits the picks, then the shell calls `enterHub()` to build the hub world and persist. No Rep gate for initial recruitment — this is the campaign-start exception. - **`` component (M6):** New Web Component (`components/InitialRecruit.ts`). Card-based layout (3-column grid, responsive to 1-column on mobile). Cards show callsign, archetype, HP, AIM%, DODGE%, and a short blurb. Toggle selection with Enter/Space or click; ←/→ or A/D navigate. Confirm when exactly 2 are selected. Emits `recruited` CustomEvent with `{ memberIds: string[] }`. -- **Mid-campaign recruitment (M6):** `Campaign.generateRecruits()` called on every `enterHub()`. Returns 1–2 candidates (weighted archetype pool) when `rep ≥ REP.RECRUIT_THRESHOLD` (65), empty array otherwise. `` extended with an "Available Recruits" section below the crew list — recruit rows with keyboard nav (ArrowDown from last crew row transitions into recruit section). One recruit per hub visit (`recruitedThisVisit` flag, reset on `enterHub()`). `Campaign.recruit(recruitId)` validates Rep gate still holds, moves the recruit from `availableRecruits` into `crew`, persists. -- **Recruit constants (M6):** `RECRUIT.POOL_MIN = 1`, `POOL_MAX = 2`, `INITIAL_CANDIDATES = 3`, `INITIAL_PICKS = 2`. `REP.RECRUIT_THRESHOLD = 65`. `RECRUIT_ARCHETYPE_POOL = ['merc','merc','razor','razor','tech']` (flat array for `rng.pick()` weighted distribution). +- **Mid-campaign recruitment (M6):** `Campaign.generateRecruits()` called on every `enterHub()`. Returns 1–2 candidates (weighted archetype pool) when `rep ≥ REP.RECRUIT_THRESHOLD` (50 — KNOWN tier floor), empty array otherwise. `` extended with an "Available Recruits" section below the crew list — recruit rows with keyboard nav (ArrowDown from last crew row transitions into recruit section). One recruit per hub visit (`recruitedThisVisit` flag, reset on `enterHub()`). `Campaign.recruit(recruitId)` validates Rep gate still holds, moves the recruit from `availableRecruits` into `crew`, persists. +- **Recruit constants (M6):** `RECRUIT.POOL_MIN = 1`, `POOL_MAX = 2`, `INITIAL_CANDIDATES = 3`, `INITIAL_PICKS = 2`. `REP.RECRUIT_THRESHOLD = 50` (lowered from the original 65 in Phase 3 so recruitment opens at KNOWN while Act 2 waits on a higher bar). `RECRUIT_ARCHETYPE_POOL = ['merc','merc','razor','razor','tech']` (flat array for `rng.pick()` weighted distribution). - **Entity display labels (M6):** `entityLabel(entity)` and `resolveEntityLabel(id, entities)` in `Entity.ts`. Crew members display by callsign; other entities display as `[Faction]Kind` (e.g. `[Corp]Drone`, `[Neutral]Civilian`, `Turret`). All log messages in `applyIntent`, `combatTurnPipeline`, `corpTurnStatusCopy`, and `debug/index` switched from raw entity IDs to display labels. - **`CharacterSelect` removed (M6):** The `` component is deleted — campaign-start archetype selection is replaced by the recruitment flow. The debug harness retains its own archetype selection via URL params. - **Status bar two-row activity (M6):** The status bar's lower section now has two activity rows instead of a dedicated hint row + action row. When a proximity hint is active, it takes the upper slot (pushing previous action line out); otherwise both rows show rolling action logs (`prevActionLine` / `lastActionLine`). Corp turn status messages also take the upper slot ephemerally. Geometry remains constant (CSS reserved heights). @@ -143,7 +143,7 @@ The biggest architectural seam in Phase 2. `Run.js` is refactored; Hub logic mov - `Run.js` refactored: `HUB` state removed. Run now covers `BRIEFING → COMBAT → RESULT` only. Hub panel rendering moves to Campaign's `HUB` handler in `index.js`. - `index.js` (shell): mounts `Campaign` instead of `Run` directly. Campaign's `onPersist` callback writes to DataStore at campaign scope. Meta scope written separately on every Hub upgrade purchase. - **Campaign start UX:** On the start of a new campaign, a new `` overlay shows a basic terminal-styled welcome message in the Curator's voice. -- **Campaign wipe UX (shell):** When the last non-flatlined operator dies on a job, `` shows campaign-terminal copy (`willEndCampaignOnThisDeath` → `campaignTerminal` on death telemetry — **CAMPAIGN TERMINATED**, last-fight trace, `[ NEW CAMPAIGN ]`). Resuming a save in `ENDED` uses `outcome: 'campaign-over'` (roster + salvage line). Same overlay component as per-job debrief (M8). +- **Campaign wipe UX (shell, superseded by P3.M6):** Phase 2 originally routed the last-operator death through ``. The Chronicle end-summary slice now settles terminal results immediately and presents the summary-backed `` overlay; `` is restricted to recoverable job debriefs. - DataStore: new `campaign` record `{ id, crew: CrewSnapshot[], salvage, credits, vouch, meta }`. `persistence.js` gains `snapshotCampaign(campaign)` / `restoreCampaign(record)`. Corrupt campaign records throw with useful messages (same rule as job snapshots). - Hub UI: `` web component — shows all three crew members (callsign, archetype badge, HP indicator, `FLATLINED` flag). Crew member selection for next deployment. Mounts in place of the removed Hub-inside-Run panel. - `buildPlayer` removed from `src/game/archetypes/index.js`; all callers updated. @@ -229,7 +229,7 @@ Closes the **NEUTRAL faction shootable** kaizen item. ### M6 — Recruitment ✅ - `Campaign` gains `availableRecruits: Crew[]` and `recruitedThisVisit: boolean` — refreshed on each `enterHub()`. `generateRecruits()` rolls 1–2 candidates; archetype weighted via `RECRUIT_ARCHETYPE_POOL` (Merc 40%, Razor 40%, Tech 20%); callsign picked from archetype list excluding all names ever used in this campaign (living + flatlined + current recruit candidates). -- **Unlock conditions** checked in `generateRecruits`: Rep ≥ `REP.RECRUIT_THRESHOLD` (65). Returns empty array below threshold. Contract `reward.recruit` flag type-stubbed on `Contract` for M8's high-tier contract rewards. +- **Unlock conditions** checked in `generateRecruits`: Rep ≥ `REP.RECRUIT_THRESHOLD` (50). Returns empty array below threshold. Contract `reward.recruit` flag type-stubbed on `Contract` for M8's high-tier contract rewards. - `Campaign.recruit(recruitId)` — validates Rep gate still holds, `recruitedThisVisit` not set, recruit exists in pool; splices recruit from `availableRecruits` into `crew`; sets `recruitedThisVisit = true`; persists. Crew can exceed 3 members after recruitment. Throws on all illegal preconditions. - `Campaign.backfillRecruitsIfEligible()` — safety net called by the shell before opening ``. Fills an empty pool when Rep meets threshold but recruits weren't generated (edge case from restore order or Rep changes between enterHub and roster open). - Hub UI: `` extended with an "Available Recruits" section below the crew list (visible when `availableRecruits.length > 0` and `!recruitedThisVisit`). Recruit rows are keyboard-navigable — ArrowDown from the last crew row transitions into the recruit section; ArrowUp from the first recruit row returns to crew. Selected recruit shows stats in the detail pane. RECRUIT button commits via `recruit` CustomEvent. diff --git a/docs/phase-2.5-plan.md b/docs/phase-2.5-plan.md index 5479213..3e24cb7 100644 --- a/docs/phase-2.5-plan.md +++ b/docs/phase-2.5-plan.md @@ -759,8 +759,8 @@ Phase 2.5 milestones that follow (M4–M7) retain their original numbering for c - **Rep tier constants:** Formalise the existing `REP_LABEL` brackets into a tier enum with gameplay consequences. At least four tiers with defined thresholds: - **BURNED** (0–19): Only STANDARD contracts. Recruitment locked. - **UNKNOWN** (20–49): BASE_DIFFICULTY_POOL (5 STD / 3 ELV / 1 CRT). Current default. - - **KNOWN** (50–79): Shifted pool (3 STD / 4 ELV / 2 CRT). Recruitment unlocked at 65 (existing gate). - - **TRUSTED** (80–100): Top pool (2 STD / 3 ELV / 4 CRT). Phase 3 Decker recruitment + Score access gate. + - **KNOWN** (50–79): Shifted pool (3 STD / 4 ELV / 2 CRT). Terminal recruitment unlocked (KNOWN floor). + - **TRUSTED** (80–100): Top pool (2 STD / 3 ELV / 4 CRT). Phase 3 Act 2 transition (`rep >= 65`) and Score access. - **BURNED penalty pool:** When Rep < 20, the Curator rolls only STANDARD difficulty — the player is too hot to get offered real work. This is the stick; the existing clean-completion Rep bonuses are the carrot. - **Curator integration:** `generateContracts` reads `campaign.rep` (via the existing `ContractCampaign` type) to select the difficulty pool, replacing the `betterContracts` boolean check. The `BETTER_CONTRACTS_POOL` constant and `betterContracts` meta key are removed. - **Reward scaling per tier:** The per-contract credit reward floor bump that `betterContracts` provided (`+2× SALVAGE_TO_CRED_RATE`) is now tied to the TRUSTED tier instead of a purchased flag. @@ -888,7 +888,7 @@ Phase 2.5 milestones that follow (M4–M7) retain their original numbering for c - **Reveal check on Hub entry:** `Campaign.enterHub` (or a new `Campaign.checkHubReveals`) evaluates trigger conditions against campaign state and fires the **first unseen** introduction that qualifies. **One message per Hub visit** (don’t stack — the player absorbs one new thing at a time). - **Reveal definitions:** - **Finn introduction:** Trigger = player has returned from at least one run (campaign has > 0 completed jobs, or `credits > 0`, or `totalSalvage > 0`). Before this trigger, **Finn’s entity is absent from the Hub map** — `enterHub` skips spawning him. Curator message introduces Finn and explains salvage selling. After the flag is set, Finn spawns every visit. - - **Terminal / recruitment introduction:** Trigger = `campaign.rep >= REP.RECRUIT_THRESHOLD` (65) or `campaign.pendingRecruitReward`. Terminal entity is **always present** on the Hub map (it’s plausible scenery), but the Curator message is the prompt to use it. Before the flag, interacting with the Terminal could show a “systems locked” or “access denied” flavor response (or simply not open the recruit UI). + - **Terminal / recruitment introduction:** Trigger = `campaign.rep >= REP.RECRUIT_THRESHOLD` (50 — KNOWN floor) or `campaign.pendingRecruitReward`. Terminal entity is **always present** on the Hub map (it’s plausible scenery), but the Curator message is the prompt to use it. Before the flag, interacting with the Terminal could show a “systems locked” or “access denied” flavor response (or simply not open the recruit UI). - **Clinic introduction:** Trigger = any crew member has `hp < maxHp` on Hub entry (the player has experienced attrition). Curator message introduces the Doc and explains the clinic. Before the flag, the clinic NPC is absent from the Hub map (same pattern as Finn). - **Curator message delivery:** The Curator entity (or the shell’s Hub interaction handler) emits a `curator:message` event (or equivalent) with the reveal’s text. The shell displays it in the log or a brief overlay/modal — same feedback channel as existing Curator contract-board interactions. Messages are 1–3 lines of flavor text that double as system hints. - **Pattern reuse (Phase 3):** The reveal system accepts new entries without modifying the check loop. Phase 3 adds Decker recruitment (trigger: top Rep tier, new flag `deckerRecruited`). M5 documents this extension point but does **not** implement the Decker reveal. @@ -897,17 +897,17 @@ Phase 2.5 milestones that follow (M4–M7) retain their original numbering for c **Acceptance:** - Unit tests: each reveal’s trigger condition fires correctly; flags persist and prevent re-fire; only one reveal per Hub visit; Finn/Clinic absent from world when their flag is unset. -- Integration test: fresh campaign → first Hub (no Finn, no Clinic) → complete a run → return to Hub → Finn introduced → next Hub visit with damaged crew → Clinic introduced → next Hub visit with Rep ≥ 65 → Terminal explained. +- Integration test: fresh campaign → first Hub (no Finn, no Clinic) → complete a run → return to Hub → Finn introduced → next Hub visit with damaged crew → Clinic introduced → next Hub visit with Rep ≥ 50 → Terminal recruitment explained. - Campaign snapshot round-trip preserves `hubReveals`. - Pre-M5.4 saves load with `hubReveals: {}` default and don’t crash. **Implementation notes:** -- `src/game/hub/hubReveals.ts` owns reveal definitions in fixed order (Finn → Clinic → Terminal), trigger predicates, `applyFirstHubReveal`, and spawn/unlock helpers (`shouldSpawnFinn`, `shouldSpawnClinic`, `isTerminalRecruitmentUnlocked`). Clinic precedes Terminal so attrition healing is introduced before recruitment at Rep 65. +- `src/game/hub/hubReveals.ts` owns reveal definitions in fixed order (Finn → Clinic → Terminal), trigger predicates, `applyFirstHubReveal`, and spawn/unlock helpers (`shouldSpawnFinn`, `shouldSpawnClinic`, `isTerminalRecruitmentUnlocked`). Clinic precedes Terminal so attrition healing is introduced before recruitment at Rep 50 (KNOWN). - `Campaign.hubReveals` + `completedJobs` persist in `CampaignSnapshot`; `normalizeHubReveals` on load; pre-M5.4 saves default to `{}` / `0`. - `Campaign.enterHub()` applies at most one reveal (sets flag + `lastHubReveal`), then spawns Finn/Clinic only when their flags are set; Terminal always spawns. - Finn trigger: `completedJobs > 0` OR `credits > 0` OR `totalSalvage > 0`. `onJobEnd` EXIT increments `completedJobs`. -- Terminal trigger: `rep >= REP.RECRUIT_THRESHOLD` (65) OR `pendingRecruitReward`. Shell blocks roster UI until `terminalExplained` (“access denied” flash). +- Terminal trigger: `rep >= REP.RECRUIT_THRESHOLD` (50) OR `pendingRecruitReward`. Shell blocks roster UI until `terminalExplained` (“access denied” flash). - Clinic trigger: any living crew member with `hp < maxHp`. Clinic absent until `clinicIntroduced`. - `` (`components/CuratorBriefing.ts`) — SystemStart-style full-screen modal; `setBriefing({ title, lines })` for diegetic copy. Hub reveals show here (titles per reveal in `hubReveals.ts`); status-line hint deferred until `[ CONTINUE ]` / Esc / Enter. Shell `presentHubRevealIfAny()` after `enterHubAndRender` and post-job `onNewRunRequested`; interact hints list only spawned NPCs. - 13 tests in `hubReveals.test.ts`; Campaign/persistence tests updated. Full suite: 976/976 green. diff --git a/docs/phase-2.6-plan.md b/docs/phase-2.6-plan.md index 3326d7e..02495a7 100644 --- a/docs/phase-2.6-plan.md +++ b/docs/phase-2.6-plan.md @@ -128,6 +128,6 @@ This doctrine is codified in `AGENTS.md` → "Error handling — fail loud, but - **Doctrine already codified.** The three-tier policy is already written in `AGENTS.md` → "Error handling — fail loud, but recover." M2 is therefore *just the boundary*, not "doctrine + boundary." - **M2.1 audit result:** confirmed **no** global handlers exist anywhere (`window.onerror`, `unhandledrejection`, `addEventListener('error')`) and no app-level boundary component. The real entry/bootstrap is root `index.ts` (no `entries/` dir); `DataStore` is `src/DataStore.ts`. -- **Fault screen is non-diegetic, separate from `CrashDump`.** `components/CrashDump.ts` is the *in-fiction* death/exit/campaign-over modal (faux "KERNEL PANIC" stack trace). Routing a real bug through it would disguise the bug as an in-universe death — itself a silent failure. The boundary gets its own deliberately out-of-fiction ``: "Something glitched — returning to the Hub, your progress is safe," single `[ RETURN TO HUB ]`. +- **Fault screen is non-diegetic, separate from run-result UI.** `components/CrashDump.ts` is the *in-fiction* recoverable death/exit modal (faux "KERNEL PANIC" stack trace); Phase 3 terminal campaign outcomes moved to ``. Routing a real bug through either would disguise the bug as an in-universe outcome — itself a silent failure. The boundary gets its own deliberately out-of-fiction ``: "Something glitched — returning to the Hub, your progress is safe," single `[ RETURN TO HUB ]`. - **Architecture (honors no-logic-in-components):** browser-free `src/errorBoundary.ts` (installs handlers on an injected `EventTarget`, normalizes the thrown value, fires `onSignal` = console.error + no-op telemetry seam, invokes a coarse `degrade()` callback, re-entrancy guarded, returns an uninstall fn) + thin `components/FaultScreen.ts` + `index.ts` wiring. Node 22 lacks `ErrorEvent`/`PromiseRejectionEvent`, so the module duck-types the payload (`.error ?? .reason ?? event`) and stays testable under `node --test` with a plain `EventTarget`. - **Corp-slice cold resume:** autosave fires at the player→corp `turn:ended` before the animated aftermath/corp driver runs. On reload with `currentFaction: corp`, the shell calls `resumePendingCombatSliceIfNeeded()` (`advanceFromPlayerTurn` with `resumeFromCorpSlice: true`) so the save doesn't load into a stuck "CORP TURN — controls locked" state with no driver running. diff --git a/docs/phase-3-plan.md b/docs/phase-3-plan.md index d4dda67..3989d3c 100644 --- a/docs/phase-3-plan.md +++ b/docs/phase-3-plan.md @@ -13,13 +13,13 @@ The campaign is a **Neuromancer-shaped arc**: a crew of operators assembles over ### Campaign shape -| Arc | Runs | What happens | Systems | -|-----|------|-------------|---------| -| **Act 1: Street level** | 1–5 | Build rep, learn combat, recruit crew. Pure Meatspace gigs. Unconnected contracts from P2.5.M2.10 recipes. | P2.5.M2 objectives, P2.5.M5 economy, P2.5.M7 site roster begins | -| **Turning point** | ~5 | Reach top rep tier. Offered the Score (or discover it). Recruit the Decker. | Decker joins crew; Score target site designated | -| **Act 2: Casing** | 6–10 | Prep runs at/near the target site + resource building. Cyberspace available on some contracts. Learning the flip. Curator biases toward Score-adjacent contracts. | P2.5.M7 persistence (casing), Cyberspace, simstim flip | -| **The Clock starts** | ~8 | Pressure mounts — rival crew, corp heat, neural degradation. Contracts get harder; delay has cost. | Clock mechanic | -| **Act 3: The Score** | 11–13 | Final prep runs, then the big job. Dual-layer climax: Meatspace breach + Cyberspace penetration. | Everything converges | +| Stage | Runs | What happens | Systems | +|-------|------|-------------|---------| +| **Stage 1: Street level** | 1–5 | Build rep, learn combat, recruit crew. Pure Meatspace gigs. Unconnected contracts from P2.5.M2.10 recipes. | P2.5.M2 objectives, P2.5.M5 economy, P2.5.M7 site roster begins | +| **Turning point** | ~5 | Reach KNOWN rep tier. Curator reveals the Score and assigns the Decker in one beat. | Decker joins crew; Score target synthesized (always new, CRITICAL-tier) | +| **Stage 2: Casing** | 6–10 | Prep runs targeting the Score principal's org + resource building. Cyberspace available on some contracts. Learning the flip. Curator biases toward same-principal contracts. | P2.5.M7 persistence (casing), Cyberspace, simstim flip | +| **The Clock starts** | Act 2+ | After a grace period of Act 2/3 **deploys** (not completed jobs), corp heat mounts — more hostiles, tighter alarms, visible deadline pressure. | Clock mechanic + `clock-reveal` Hub beat | +| **Stage 3: Final prep** | ~9+ jobs | Casing gates satisfied; Curator `act-3-reveal` beat; prep board + player-initiated **THE SCORE**. | Everything converges toward the climax | ### The simstim flip @@ -27,7 +27,7 @@ On contracts with a Cyberspace component, the player **dual-deploys**: a Meatspa - Reuses the existing single-operator control model twice (no squad tactics required). - Tension is purely **attention allocation**: every turn spent in Cyberspace is a turn the Meatspace operator isn't moving, while corp drones keep closing in — and vice versa. -- The flip is a **free action** (or 1 AP — TBD). The PIP / CCTV window from the blueprint shows the inactive layer in miniature. +- The flip is a **free action** (resolved 2026-06-15; AP cost may be revisited after playtest). The PIP / CCTV window from the blueprint shows the inactive layer in miniature. ### The Decker @@ -41,12 +41,13 @@ A new **player archetype** recruited mid-campaign (late Act 1 / start of Act 2), | Milestone | Status | |---|---| -| P3.M1 — Campaign arc structure | 🔲 Planned | -| P3.M2 — The Decker archetype | 🔲 Planned | -| P3.M3 — Cyberspace grid + ICE | 🔲 Planned | -| P3.M4 — Simstim flip (dual-deploy) | 🔲 Planned | -| P3.M5 — The Score (climactic mission) | 🔲 Planned | -| P3.M6 — Chronicle (campaign narrative memory) | 🔲 Planned | +| P3.M1 — Campaign arc structure | ✅ Done | +| P3.M2 — The Decker archetype | ✅ Done | +| P3.M3 — Cyberspace grid + ICE | ✅ Done (full ICE roster: Probe, Spark, Guardian) | +| P3.M4 — Simstim flip (dual-deploy) | ✅ Done | +| P3.M5 — The Score (climactic mission) | ✅ Done | +| P3.M6 — Stolen Blueprints (shop rework + meta-progression) | ✅ Meta-store, catalog split, shop rework, Score target rework, abstract targets, and hub surface shipped (M6.1–M6.6) | +| P3.M7 — Chronicle (campaign narrative memory) | 🟡 End-summary foundation shipped | **Phase 3** is complete when: @@ -63,7 +64,7 @@ Phase 3 should start from the shipped Phase 2.5 surface, not rebuild it: - `LocationSite` already reserves `tier: 'score'` and `scoreTarget`; P2.5.M7 never sets them, so P3.M1 owns designation. - `Curator.generateContracts(rng, campaign)` already accepts `arcStage` and stores it into `contract.context.arcStage`; P3.M1 owns deriving the stage from campaign state and using it for real recipe weighting / Score targeting. - P2.7.M6.2 landed: entity snapshot `extra` property bags and campaign-scoped key items are available for Decker / Cyberspace state instead of expanding the old top-level snapshot union. -- Hub reveal plumbing already exists (`applyFirstHubReveal`, Finn, Clinic, Terminal), so Decker recruitment should use the same progressive reveal pattern rather than adding a parallel modal. +- Hub reveal plumbing already exists (`applyFirstHubReveal`, Finn, Clinic, Terminal). Phase 3 arc beats use deferred Curator briefings: `score-reveal` (Score + Decker), `clock-reveal` (heat + deadline), and `act-3-reveal` (THE SCORE available). Flags commit on briefing dismiss, not on queue. ## Phase 2.5 foundations (prerequisites) @@ -72,16 +73,16 @@ Phase 3 depends on specific hooks built into Phase 2.5 milestones: | 2.5 Milestone | Phase 3 hook | Notes | |---|---|---| | **P2.5.M2.10** (recipes) | Recipe context accepts **arc stage** input | Phase 3 uses this to bias contract generation toward Score-adjacent objectives in Acts 2–3 | -| **P2.5.M5** (economy/rep) | Top rep tier defined and reachable | Phase 3 gates Decker recruitment and Score access at this tier | -| **P2.5.M7** (persistence) | Location schema includes `scoreTarget` flag; site roster + mutation deltas | Phase 3 designates one roster site as the Score target; player "cases" it across visits | +| **P2.5.M5** (economy/rep) | KNOWN rep tier defined and reachable | Terminal recruitment opens at `rep >= 50` (KNOWN floor). Act 2 (Score reveal + Decker) gates at `rep >= 65` plus job count | +| **P2.5.M7** (persistence) | Location schema includes `scoreTarget` flag; site roster + mutation deltas | Phase 3 synthesizes a new CRITICAL-tier Score target; player "cases" it across visits. Roster sites for the same principal provide recon value | ### Score target identification -Score-target sites always use roster-stored dimensions (P2.7.M1.5: `mapWidth`, `mapHeight`, `seed`, mutation deltas); contract `difficulty` scales encounter composition only, not footprint. +The Score target is always a newly synthesized CRITICAL-tier site, never promoted from the roster. This ensures the map is large enough to support escalated hostile placement as heat grows. Act 2 Curator bias weights toward the same principal (same corp, different sites) so the player learns the organization before the climax. The synthesized site uses roster-stored dimensions (P2.7.M1.5: `mapWidth`, `mapHeight`, `seed`, mutation deltas from visits); contract `difficulty` scales encounter composition only, not footprint. ## Milestones — detail -### P3.M1 — Campaign arc structure 🔲 +### P3.M1 — Campaign arc structure ✅ **Depends on:** Phase 2.5 complete (P2.5.M2.10 recipe hooks, P2.5.M5 rep tiers, P2.5.M7 site roster). @@ -89,46 +90,90 @@ Score-target sites always use roster-stored dimensions (P2.7.M1.5: `mapWidth`, ` **Scope:** -- **Arc state:** Campaign save tracks current act (1/2/3), run count, and arc-specific flags (Decker recruited, Score revealed, Clock started, Score attempted). +- **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: reach top rep tier + minimum run count (e.g. 4–5 runs). Triggers Score reveal and Decker recruitment opportunity. - - Act 2 → Act 3: Decker recruited + Score target site visited at least once + Clock threshold (e.g. run 10+). Triggers "final prep" phase. - - Score available: Act 3 + player-initiated (choose to attempt the Score from the Hub). -- **Score target designation:** At Act 2 entry, choose exactly one remembered or newly seeded `LocationSite`, set `scoreTarget: true`, and promote `tier: 'score'` so P2.5.M7 eviction preserves it. If no roster site exists yet, synthesize a site identity from the Curator lexicon and add it to the roster; do not silently defer the Score reveal. + - 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. + - 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. - **Arc-aware Curator:** Current code already passes through `arcStage`; P3.M1 must make it behaviorally meaningful: - Act 1 = broad pool, unconnected gigs, no Score-target pinning. - - Act 2 = at least one board slot biased toward the Score target or its principal/site identity when available. + - Act 2 = at least one board slot biased toward the Score target's principal (same corp, different sites) so the player learns the org before the climax. - Act 3 = board mostly prep contracts at or near the Score target, plus a separate player-initiated Score action instead of a random roll. - Score = special contract build path; not part of the normal three-card job board. -- **Hub surface:** Hub status / Terminal shows current act, Clock pressure, and Score target label once revealed. Decker recruitment uses progressive Hub reveal plumbing. +- **Hub surface:** Hub status / Terminal shows current stage and Score target label once revealed. Clock HUD (`CLOCK: HEAT X / Y JOBS LEFT`) appears **only after** the player dismisses the `clock-reveal` briefing and heat has actually started — no "dormant" or countdown-to-heat lines before then. User-facing labels use "Stage" (STAGE 1, STAGE 2, etc.) — code and persistence keep `arc`/`arcStage` naming. Arc beats use progressive Hub reveal plumbing (`score-reveal`, `clock-reveal`, `act-3-reveal`); see P3.M1.4–M1.5 notes. - **Win/loss conditions:** - - **Win:** Complete the Score (extract with objective satisfied from the final mission). + - **Win:** Complete the Score (extract with objective satisfied from the final mission). Terminal campaign overlay: `SCORE COMPLETE`. - **Loss (flatline):** Entire crew wiped during any run (existing behavior, but now with arc context for the chronicle). - - **Loss (clock):** Clock expires before Score is attempted (if hard deadline chosen — see Clock mechanic below). + - **Loss (Score Decker):** If the Decker flatlines during THE SCORE, the campaign ends immediately with explicit Game Over copy. Before the Score, a flatlined Decker instead opens one free replacement lead through the Terminal; THE SCORE remains gated until a living Decker is recruited. + - **Loss (clock):** `clockJobsTaken >= CLOCK_ACT2_DEADLINE_JOBS` (8) before `scoreAttempted`. Terminal campaign overlay: `GAME OVER` with explicit window-closed copy — not a status-line footnote. Attempted Score keeps the deadline from retroactively killing the save. **The Clock mechanic:** -The Clock creates mounting pressure that discourages indefinite grinding. Options (pick one or combine at implementation): +The Clock creates mounting pressure that discourages indefinite grinding in Act 2/3. **Shipped implementation:** Act 2/3 **deploys taken** (successful or not) drive heat and the hard deadline — not global `completedJobs`, so entering Act 2 with a high job count from Stage 1 does not immediately start the Clock. + +- `CLOCK_ACT2_GRACE_JOBS = 3` — first three Act 2/3 deploys are grace (no heat, no `clockStarted`) +- `CLOCK_HEAT_WINDOW_JOBS = 5` — deploys after grace before the window closes **in Act 3** +- `CLOCK_ACT2_DEADLINE_JOBS = 8` — total Act 2/3 deploys (`grace + window`) counted against the Act 3 deadline +- `CLOCK_ACT3_MIN_JOBS_REMAINING = 3` — on Act 2 → Act 3 transition, over-budget `clockJobsTaken` is clamped so final prep always has at least this many deploys left +- `clockJobsTaken` increments on `deployCrewMember` while `arcStage` is `act-2` or `act-3` (Score deploy excluded — it sets `scoreAttempted`) +- `clockStarted` when `scoreRevealed && clockJobsTaken >= CLOCK_ACT2_GRACE_JOBS` +- `clockHeat = max(0, clockJobsTaken - CLOCK_ACT2_GRACE_JOBS)` once started +- **Act 2 casing:** heat mounts and HUD shows `CLOCK: HEAT N`, but the deadline cannot end the campaign — failed deploys must not stillborn the path to Act 3 +- **Act 3 final prep:** HUD adds `/ Y JOBS LEFT`; returning to Hub at the deadline without `scoreAttempted` sets `Campaign.state` to `ENDED` with `endReason: 'clock-expired'` + +**Hub narrative beats (priority order on `enterHub`):** `score-reveal` → `clock-reveal` → `act-3-reveal`. Each defers its `hubReveals` flag until the player dismisses the Curator briefing modal. + +Other Clock variants remain useful later, but should not block P3.M1: - **Escalating global difficulty:** Each run after a threshold (e.g. run 8), corp security tier increases globally — more drones, tougher spawns, higher alarm sensitivity. Soft pressure: you *can* keep running, but it gets harder. - **Rival crew:** A competing team is after the same Score. Abstract progress bar: each run you take, they advance. If they reach the Score first, you lose (or the Score becomes dramatically harder — they've tripped every alarm). - **Operational window:** The Score target has a time-limited vulnerability (maintenance cycle, personnel rotation, satellite blind spot). After N total runs, the window closes permanently. Hard deadline. - **Neural degradation:** The Decker's implants degrade with each jack-in. After N Cyberspace runs, they can no longer jack in — and the Score requires Cyberspace. Biological clock on the crew, not the world. -Implementation notes TBD after Clock type is chosen. Multiple types may coexist (escalating difficulty as soft pressure + operational window as hard deadline). +Neural degradation is deferred until Cyberspace is fun enough to deserve a jack-in-specific cost. Rival crew pressure is best saved for the inter-hostile friction work in kaizen unless a Score narrative beat specifically needs it. + +**Implementation slices:** + +| Slice | Status | Change | Tests | +|---|---|---|---| +| **P3.M1.1 Arc record** | ✅ Done | Add `Campaign.arc`, derive `arcStage`, persist/restore, normalize old saves to Act 1 | constructor validation, snapshot round-trip, invalid stage throws | +| **P3.M1.2 Transitions** | ✅ Done | Advance acts from `rep`, `completedJobs`, Decker flag, Score-site visit | boundary tests around job counts; rep floor (incl. TRUSTED) | +| **P3.M1.3 Score target** | ✅ Done | Always synthesize a new CRITICAL-tier Score target; preserve through eviction | exactly-one target, no eviction at roster cap, always synthesized (never promoted) | +| **P3.M1.4 Hub arc surface** | ✅ Done | Curator arc briefings; Hub / Terminal stage + Score target; `` CASING + SCORE SITE badges | deferred-commit reveals, resume briefing, casing tag | +| **P3.M1.5 Clock** | ✅ Done | Act 2/3 deploy-driven heat + deadline; `clock-reveal`; HUD gated on briefing; clock loss game-over screen | heat math, grace deploys, deadline loss, endReason | +| **P3.M1.6 Curator bias** | ✅ Done | Pass campaign-derived arc context; bias board slots by act and score target's principal | seeded boards show expected `arcStage` and same-principal frequency in Act 2+ | +| **P3.M1.7 Score entry** | ✅ Done | Hub action creates the special Score contract in Act 3 only | availability gates, deployment path, attempted flag | +| **P3.M1.8 Game Over component** | ✅ Done | Dedicated component, separate from CrashDump, to be shown when Score window closes or crew are all flatlined | + +**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.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. + +**P3.M1.4 implementation note:** Score reveal is player-visible. A one-shot `score-reveal` Hub reveal lets the Curator name the target, introduce the Decker, and teach the **CASING** job-board badge (same-principal org jobs during Act 2+). **SCORE SITE** still marks contracts whose `locationSiteId` matches the synthesized Score target. Arc briefings (`score-reveal`, `clock-reveal`, `act-3-reveal`) are priority Hub reveals: they are evaluated before lower-priority intros (Finn, clinic, terminal-recruit) so a mid-save act transition is not crowded out on the same visit. Their `hubReveals` flags commit on Curator briefing **dismiss** (`commitHubReveal` in the shell), not when `enterHub` queues the copy — so a missed modal can retry on the next Hub entry, and pre-P3 saves that qualify for Act 2 on first load under 3.0 are not silently bumped without the narrative beat. The shell resume path presents any pending `lastHubReveal` when restoring a HUB save. The Hub status row and Terminal crew roster show the current stage label (user-facing "STAGE N") plus Score target once revealed. Shared `arcSurface` helpers own the copy and invariant checks so multiple Score targets, or revealed Score state without a target, fail loud instead of rendering misleading UI. + +**P3.M1.5 implementation note:** The Clock is driven by `clockJobsTaken` (Act 2/3 deploys), not `completedJobs` — entering Act 2 with many Stage 1 extractions does not start heat immediately. Grace: `CLOCK_ACT2_GRACE_JOBS` (3) deploys; then `clockStarted` and heat accrue until `CLOCK_ACT2_DEADLINE_JOBS` (8). A `clock-reveal` Hub briefing explains heat, the operational window, and what happens when it closes (copy in `clockRevealLines`). Clock HUD text appears only after `clockBriefingPresented` **and** `clockStarted`: `CLOCK: HEAT X / Y JOBS LEFT` on the canvas HUD, Terminal roster, and status bar. No "dormant" or "N jobs to heat" lines before the player has seen the briefing. Heat raises Curator threat counts without changing difficulty tier, capped per tier. Deadline loss sets `Campaign.endReason` to `clock-expired`; terminal outcomes now bypass `` and use the summary-backed `` overlay. An attempted Score keeps the deadline from retroactively killing the save. + +**P3.M1.6 implementation note:** `Curator.generateContracts` now uses campaign-derived arc context behaviorally. Act 2 guarantees at least one fresh same-principal casing job for the Score target's organization. Act 3 guarantees a mostly same-principal prep board. The normal board avoids rolling the Score target itself so the finale stays a deliberate Hub action. + +**P3.M1.7 implementation note:** Act 3 exposes a special `THE SCORE` contract through `Campaign.buildScoreContract()`, appended to the Hub job choices only when `Campaign.canAttemptScore()` passes. The first qualifying Hub visit also fires `act-3-reveal` ("You're ready… grab THE SCORE from the board while you can") before the player sees the fourth board slot. Deploying THE SCORE marks `scoreAttempted`, moves `arcStage` to `score`, uses the persisted Score target dimensions/memory, and completing it awards the campaign-ending `1,000 Cr` payoff, marks `scoreCompleted`, and ends the campaign in a win state. **Acceptance:** - Arc state persists in campaign save; restore round-trip. - Act transitions fire at correct thresholds; tests for boundary conditions. +- Score reveal is player-visible: Curator presents the target once (including on legacy-save restore when Act 2 opens for the first time), Hub / Terminal displays the current act and target label, and contract selection marks **CASING** (same principal) and **SCORE SITE** (target location) jobs. - Curator generates arc-appropriate contracts per act (testable via seeded generation with arc context). -- At least one Clock type implemented with visible feedback (Hub status, contract briefing, or log). +- Clock: `clock-reveal` briefing, deploy-driven heat, HUD visible only post-briefing, clock loss reaches terminal game-over screen. - Exactly one Score target exists after Score reveal; it survives roster eviction and keeps its P2.5.M7 terrain memory. - Win/loss conditions reachable in golden-path test. --- -### P3.M2 — The Decker archetype 🔲 +### P3.M2 — The Decker archetype ✅ **Depends on:** P3.M1 (arc structure for recruitment gating). Can develop archetype mechanics in parallel, but recruitment integration requires arc state. @@ -139,108 +184,443 @@ Implementation notes TBD after Clock type is chosen. Multiple types may coexist - **Archetype definition:** Stats, AP costs, base loadout. Comparable to Merc/Razor/Tech in Meatspace capability but not optimized for it. - **Signature ability — Drone Override Hack:** Target a corp drone within range; spend AP to attempt override. On success, drone switches to PLAYER faction for N turns (or until destroyed). Reuses existing drone AI with faction flip. Failure may trigger alarm (P2.5.M2.1 cadence). - **Cyberspace stats:** The Decker has Cyberspace-specific attributes (e.g. RAM, intrusion strength, ICE resistance) used in P3.M3. Other archetypes cannot jack in (or can with severe penalties — TBD). -- **Recruitment flow:** Triggered at Act 1 → Act 2 transition. Uses the **progressive Hub reveal** system from P2.5.M5: Curator message introduces the Decker on Hub entry when rep threshold is met and `arc.deckerRecruited` is false. Same pattern as Finn's introduction and Terminal explanation — the Hub grows with the campaign. +- **Recruitment flow:** Same narrative beat as the Score reveal — triggered at Act 1 → Act 2 transition. The Curator assigns a named Decker (no player choice modal); the `score-reveal` Hub reveal introduces both the Score target and the Decker in one moment. Same progressive Hub reveal pattern as Finn's introduction and Terminal explanation — the Hub grows with the campaign. Future phases may differentiate Decker stats and offer a choice; for now the assignment is the narrative. - **Deployment:** The Decker is deployable as a solo operator on any contract (Meatspace only on non-Cyberspace contracts). On Cyberspace contracts, the Decker is one of the dual-deploy pair (see P3.M4). +- **Roster rule:** The Decker is a named crew member, not a temporary ability unlock. Recruitment should add them to `Campaign.crew` through the existing recruit/callsign machinery or a deliberately separate `recruitDecker()` path with the same validation guarantees. Do not let normal random recruitment roll a Decker before Act 2. +- **Jack-in authority:** Only a living Decker can start P3.M3 jack-in. If a contract has a Cyberspace requirement and no living Decker is available, deployment should fail loudly at the Hub selection layer rather than starting an unwinnable run. **Acceptance:** -- Decker archetype playable in Meatspace: move, attack, interact, deploy — comparable to other archetypes. -- Drone Override Hack: golden-path test — target drone, override succeeds, drone attacks corp allies for N turns, reverts or is destroyed. -- Recruitment: gated by arc state; not available in Act 1; golden-path test for recruitment flow. -- Snapshot: Decker state (including Cyberspace attributes) persists in campaign save; restore round-trip. -- Key help: Decker glyph and ability description. +- ✅ Decker archetype playable in Meatspace: move, attack, interact — comparable to other archetypes (`Decker` extends `Crew`, `baseHitChance` 0.7, `@` glyph). +- ✅ Drone Override Hack: golden-path test — target drone, override succeeds, drone attacks corp allies for N turns, reverts or is destroyed (`droneOverride.ts`, `Decker.test.ts`). Failed roll burns AP and trips the alarm. +- ✅ Recruitment: same beat as Score reveal (Act 1 → Act 2 transition); Curator assigns a named Decker, no choice modal. Not available in Act 1; golden-path test for recruitment flow. Decker is registered but excluded from `ARCHETYPE_IDS` and `RECRUIT_ARCHETYPE_POOL` so random recruitment can't roll one early. +- ✅ Snapshot: Decker state persists (campaign + run round-trip); live drone-override state round-trips through the patrol snapshot. Cyberspace attributes deferred to P3.M3. +- ✅ Key help: Decker glyph (`@`) and OVERRIDE ability description via shared `ARCHETYPES[id].perkLabel`. + +**P3.M2 implementation note:** The Decker's signature **Override** reuses the unified `special` perk key — the intent layer's `doSpecial` sniffs `canOverride` and resolves a drone along the aim ray (`OVERRIDE_RANGE`, LOS-gated, like fire). A successful hijack flips the drone to `FACTION.PLAYER` for `OVERRIDE_DURATION` turns; the existing hostile AI then fights corp for free (it targets by faction difference). Override state lives on `Hostile` (`overrideTurnsRemaining`, `factionBeforeOverride`) and is driven each player turn by `stepOverriddenDrones`, a new combat-aftermath phase that steps the hijacked drone and reverts it when the countdown lapses. Mid-override saves round-trip through the patrol snapshot; half-populated override state throws on restore. --- -### P3.M3 — Cyberspace grid + ICE 🔲 +### P3.M3 — Cyberspace grid + ICE ✅ + +**Status (2026-06-14):** Complete. The first playable slice (M3.1–M3.6, plus voluntary jack-out pulled forward from M4.6 and an early-jack-out confirmation) shipped end-to-end, and the **full ICE roster** — Probe (detector), Spark (fast/fragile swarm), and Guardian (heavy node guard) — now lands at every jack-in. **Depends on:** P3.M2 (Decker as the Cyberspace avatar). Can prototype grid mechanics independently. **Goal:** The second tactical layer — a **Cyberspace grid** with its own geometry, traversal rules, and hostile AI (**ICE** — Intrusion Countermeasure Electronics). Generated fresh per jack-in (not persistent across runs). +**Scope decisions (recorded):** + +1. **First playable slice only** — slices M3.1–M3.6 (contract flag → jack-in terminal → cyber layer model → data node objective → Probe ICE → render swap). **Spark/Guardian ICE deferred** to follow-up slices; the milestone stays open until they land. +2. **Minimal voluntary jack-out pulled forward** from P3.M4.6 so the layer is playable end-to-end solo before the simstim flip exists. M4.6 then only adds forced jack-out + dual-deploy cleanup. +3. **Avatar death = flatline** — ICE destroying the avatar kills the Decker through the existing DEATH/flatline paths. Genre-honest (black ICE kills), zero new death machinery. +4. **Named cyber stats ship now with real effects:** RAM = avatar HP pool, intrusion strength = slice progress per interact, ICE resistance = `damageReduction` (existing min-1 mitigation in `Combat.ts`). Persisted and validated in both crew persistence paths. +5. **The Score is always a cyber run** (2026-06-11, superseded by P3.M5): `buildScoreContract` now emits `SCORE_FINAL` with `{requiresCyberspace: true, count: 1, doorId: 'score-door-0'}`. Deploy goes through the living-Decker gate and, for the finale only, requires a living non-Decker meat partner at the model layer. + +TDD throughout; malformed persisted state throws (no silent fallbacks). + **Scope:** -- **Cyberspace grid:** Separate `Grid` / `World` instance for the digital layer. **Graph-based nodes and logic pathways** (per blueprint) — may use the same grid engine with a different tileset/topology, or a simplified node graph. Design decision at implementation; document trade-offs. -- **Cyberspace tileset / aesthetic:** Distinct from Meatspace. Nodes, data lines, firewalls, open channels. ASCII glyphs TBD but visually differentiated (color palette, glyph set, CRT effects). +- **Cyberspace grid:** Separate `Grid` / `World` instance for the digital layer. **First implementation reuses the existing square grid engine** with a distinct tileset and generation rules; reserve a graph-topology refactor only if the square grid fails the feel test. +- **Cyberspace tileset / aesthetic:** Distinct from Meatspace — FLOOR `·` deep cyan, WALL `▒` magenta; location label `// THE GRID //`; vitals pane labeled RAM. - **ICE hostiles:** Three types per blueprint: - - **Probe:** Sentry / patrol. Detects the Decker, raises alert (Cyberspace alarm analog). - - **Spark:** Fast, fragile attacker. Swarm behavior. - - **Guardian:** Heavy. Guards critical nodes. High HP, high damage, limited mobility. -- **ICE AI:** A* pathfinding (reuse Meatspace drone infrastructure with Cyberspace-specific cost maps). Alarm/alert model adapted from P2.5.M2.1 for the digital layer. -- **Cyberspace objectives:** What the Decker *does* once jacked in — slice data nodes, disable firewalls, open digital locks. Reuses `Interactable` patterns from P2.5.M2.2 adapted for Cyberspace. -- **Generation:** Procedural per jack-in. Seeded from contract + campaign RNG. Not persistent (fresh each time). Complexity scales with contract difficulty / act. -- **Jack-in trigger:** Decker interacts with a Meatspace terminal (P2.5.M2.2 `Interactable`). This spawns the Cyberspace grid and activates dual-deploy mode (P3.M4). + - **Probe:** Sentry / patrol. Detects the Decker, raises alert (Cyberspace alarm analog). ✅ Shipped. 2 HP / 2 AP / 1 dmg / **sight 7** (longest — it's the detector). + - **Spark:** Fast, fragile attacker. Swarm behavior. ✅ Shipped. 1 HP / **4 AP** / 1 dmg / sight 6 — rides the trace flare, never raises one. + - **Guardian:** Heavy. Guards critical nodes. High HP, high damage, limited mobility. ✅ Shipped. 6 HP / 2 AP / **3 dmg** / sight 5 — parks on a data node (no patrol), flares on contact. +- **ICE AI:** A* pathfinding (reuse Meatspace drone infrastructure). Alarm/alert model adapted from P2.5.M2.1 for the digital layer. +- **Cyberspace objectives:** Slice data nodes (shipped); disable firewalls, open digital locks — future. +- **Generation:** Procedural per jack-in. Seeded from contract (`new Rng(contract.seed).fork('cyberspace')`). Not persistent. Complexity scales with contract difficulty. +- **Jack-in trigger:** Decker interacts with a Meatspace `JackInPoint` (Ω glyph). Spawns the Cyberspace grid; dual-deploy flip deferred to P3.M4. + +**Architecture:** + +- **`CyberspaceLayer` owned by `Run`, single `TurnQueue`, both worlds tick.** New `src/game/cyber/CyberspaceLayer.ts` owns its own `EventBus`, `World`, `CyberAvatar`, `entryTile`, `mapSeen` — not a nested Run. +- **`CyberspaceState` union:** `{phase: 'dormant'}` | `{phase: 'active'; layer: CyberspaceLayer}` | `{phase: 'resolved'; objectiveComplete: boolean}`. `Run.cyberspace: CyberspaceState | null` — null ⇔ no cyber component. +- **Turn integration:** One existing `TurnQueue`. Meat `TURN_ENDED` listener forwards `{next}` to `layer.onTurnEnded(next)` when cyber is active. Shell corp phase chains two `corpTurnDriver` passes — meat hostiles, then ICE — consuming shared `run.rng` in fixed order. **Meatspace keeps ticking during jack-in** — Decker body stands at the port as `run.player`, targetable; body death hits existing player-death path (M4.2 vulnerability falls out for free). +- **`CyberAvatar`:** `Entity` subclass; `maxHp = decker.ram`, `damageReduction = decker.iceResistance`, `intrusionStrength`, `readonly isCyberAvatar = true` (capability sniff — Decker body also carries `intrusionStrength`). Stats on `Decker` with `DECKER_BASE_RAM/INTRUSION/ICE_RESISTANCE` constants; round-trip through both crew persistence paths. +- **Generation:** `buildCyberMap({rng, difficulty})` — rooms-as-nodes lattice, FLOOR/WALL only, connectivity validated via `explorationReachableKeys`. Distinct visuals via tileset axis in `palette.ts`, not new TILE ids. + +**Entering Cyberspace — first playable slice:** + +1. `requiresCyberspace` contract param for Act 2+ jobs, generated only when a living Decker exists. +2. Meatspace `JackInPoint` placed via `findInteractableAnchor`, deterministic per contract seed. +3. Jack-in creates active cyber layer: generated grid, `CyberAvatar`, data nodes, Probe ICE per patrol ring. +4. Repeated jack-in against linked port → deterministic `already-linked` refusal; corrupt state throws. +5. Mid-jack-in save restores both layers; absent/malformed cyber snapshot for active jack-in is tier-1 corrupt state. + +**Implementation slices:** + +| Slice | Status | Change | Tests | +|---|---|---|---| +| **P3.M3.1 Contract flag** | ✅ Done | Cyberspace-capable contract metadata and validation | generated only Act 2+, invalid flag/params throw | +| **P3.M3.2 Jack-in terminal** | ✅ Done | `JackInPoint` placement and interact flow | deterministic placement, no collision with objective props | +| **P3.M3.3 Cyber layer model** | ✅ Done | Serializable `Run.cyberspace` layer with grid/world/avatar | snapshot round-trip, active-layer invariants | +| **P3.M3.4 Data node objective** | ✅ Done | Slice data nodes and feed objective satisfaction | incomplete blocks clean extraction, complete allows it | +| **P3.M3.5 Probe ICE** | ✅ Done | ICE patrol/detect/attack loop | seeded movement, detection/alarm, damage/death | +| **P3.M3.6 Render swap** | ✅ Done | Render Cyberspace when active; dual-phase corp turn | browser smoke, dualPhaseTurn determinism | +| **P3.M3.7 Body CCTV PIP** | ✅ Done | Meatspace overlay while jacked in; body-damage feedback | pip viewport/chrome unit tests, browser smoke | +| **M4.6 pull-forward — voluntary jack-out** | ✅ Done | `JackInPoint.burned` latch; `Run.jackOut()`; early jack-out confirmation | LINK BURNED latch, defer/confirm matrix, round-trip | +| **Playtest stabilization** | ✅ Done | Probe 2 HP / 2 AP; Cyber Override against ICE; pre-Score replacement Decker; Score Decker death Game Over | action budget, override/revert + persistence, replacement/Score gates | +| **Spark ICE** | ✅ Done | Fast, fragile attacker; swarm behavior (rides the flare) | stats, listens-for-flare swarm, difficulty-scaled count, round-trip | +| **Guardian ICE** | ✅ Done | Heavy guard of critical nodes; high HP/damage, parks on the node | stats, one-per-data-node placement, heavy strike vs resistance, round-trip | +| **ProbeIce rebalance** | ✅ Done | Sight 6→7 (the detector); roster split off the data-node rings | sight, non-data-ring patrol count | + +**P3.M3.1 implementation note:** `OBJECTIVES.DATA_NODE_SLICE = 'data-node-slice'` with cross-field validation in `normalizeObjective`: kind requires `params.requiresCyberspace === true` plus positive-integer `params.count`; flag forbidden on every other kind. `contractRequiresCyberspace(contract)` exported from `Curator.ts`. Recipe `cyber-data-spike` gated by `ContractRecipe.availableWhen`: `arcStage ∈ {act-2, act-3} && hasLivingDecker`. Deploy gate in `Campaign.deployCrewMember` throws for cyber contracts unless deployed member is a living Decker. UX: `CrewList.setCrew(crew, rowGate?)` — `NEEDS DECKER` on non-Decker rows. + +**P3.M3.2 implementation note:** `JackInPoint extends Interactable`, glyph `Ω`, id `jack-in-0` (not matching `/^terminal-\d+$/`). Interact: linked → `already-linked` refusal; `actor.canJackIn !== true` → `no-cyberdeck`; success latches `linked`, emits `EVENT.JACK_IN`. `Run.cyberspace` latched in `enterBriefing` from `contractRequiresCyberspace`. Persistence: `RunSnapshot.cyberspace?`; dormant-only in S2, extended in S3. + +**P3.M3.3 implementation note:** `buildCyberMap` — 4×2 cell lattice, L-corridors, patrol rings; node count by difficulty (standard 5 / elevated 6 / critical 8); returns `portTile` (Chebyshev-1 from entry). `CyberspaceLayer.build` forks `new Rng(contractSeed).fork('cyberspace')`. Serialization lives in Run's `snapshotCyberspace`, not `layer.snapshot()`. `Run.jackIn(point)` / `jackOut()` with explicit autosave. Cyber `ENTITY_DAMAGED` listener mirrors meat player-death → flatline. Decker cyber stats: absent → defaults (legacy), half-populated → throw. + +**P3.M3.4 implementation note:** `DataNode extends Interactable`, glyph `◈`, avatar-only via `isCyberAvatar` sniff. `sliceDifficultyFor`: standard 2 / elevated 3 / critical 4. `ObjectiveState.cyber?: {sliced, required}`; `DATA_NODE_SLICE` satisfaction reads live tally while active, resolved latch after jack-out. Early jack-out latches `objectiveComplete: false` → existing abort-confirm extraction flow. Active snapshot requires exactly the contract's node count. + +**P3.M3.5 (Probe ICE) implementation note:** `ProbeIce extends PatrolHostile`, glyph `¶`. Trace flare: `engageSteps` raises cyber alarm (`repPenalty: false`) before striking; pack convergence via default `listensForAlarm()`. `'probe-ice'` in `PATROL_ARCHETYPE_IDS` for snapshot machinery. **Follow-up:** probes default `FACTION.CORP`; future rival-principal cyber recipe needs ICE faction stamping at `jackIn`. + +**Spark + Guardian ICE implementation note (2026-06-14):** The roster is now three distinct silhouettes that share the patrol state machine but split by role and map geometry, assembled in one pass in `CyberspaceLayer.build`: + +- **Guardian** (`GuardianIce`, glyph `Ψ`) spawns on **every data-node ring** (`dataNodeIndices`) — one heavy per critical node. 6 HP / 2 AP / 3 dmg (`HEAVY_MELEE_DAMAGE`) / sight 5, **no patrol waypoints** so it holds station on the prize until the avatar enters its short sight, then flares (like the Probe) and closes. ICE resistance only files its strike to 2. +- **Probe** (`ProbeIce`, glyph `¶`) patrols **every non-data ring**. Rebalanced to **sight 7** — the longest of the three — because its job is to *see* you first and trip the flare that wakes the pack; it stays the weakest in a fight (2 HP / 2 AP / 1 dmg). +- **Spark** (`SparkIce`, glyph `×`) is the **difficulty-scaled swarm** (`SPARK_COUNT`: standard 1 / elevated 2 / critical 3), seeded onto random rings. 1 HP / 4 AP / 1 dmg / sight 6 — it **rides** the Probe/Guardian flare via `listensForAlarm()` but never raises one itself, closing three tiles and biting in a single activation. + +Placement is collision-safe (`pickFreeRingTile` consumes one rng draw then scans the ring, throwing rather than stacking ICE) and remains a pure function of the contract seed. All three share the `PatrolSnapshot` `extra` block via `PATROL_ARCHETYPE_IDS` (`spark-ice`, `guardian-ice` added alongside `probe-ice`); both round-trip through the active-cyber restore path with bus re-binding. **Follow-ups:** ICE faction stamping for rival-principal recipes (unchanged from M3.5); a leashing option if Guardians chasing a lost lead across the lattice ever reads wrong; live playtest tuning of `SPARK_COUNT` / Guardian HP. + +**P3.M3.6 implementation note:** `TilesetId = 'meat' | 'cyber'` in `palette.ts`. Shell active-view seam via `run.activeWorld`/`run.activeActor` through vision, paint, look/describe, touch, statusLine. `ApplyIntentContext.player` widened to `Archetype | CyberAvatar`. Dual-phase corp turn while jacked in. + +**P3.M3.7 implementation note (2026-06-14):** Partial pull-forward of M4.5 PIP. While `cyberspace.active`, a read-only `#pip-canvas` overlay (bottom-right on `.game-stage`) paints meatspace via a second `AsciiRenderer` (`pip.ts` helpers: `pipCameraFor`, `pipChrome`, `shouldShowPip`). Meat `vision` stays live; the silent meat corp pass now refreshes the PIP each step and flashes visible corp lines. Body hits while jacked in emit `BODY HIT` status text, pulse the PIP border, and route muzzle flashes to the PIP renderer (not the cyber canvas). Playtest finding: Score flatline from silent meat damage motivated this slice. M4.5 will generalize to the *inactive* layer after simstim flip. + +**M4.6 pull-forward (jack-out) implementation note:** `JackInPoint.burned` set by `Run.jackOut()` — real latch, distinct `link-burned` refusal flavor. `burn()` on unlinked port throws (burned ⇒ linked invariant). Persistence: `extra.burned`; absent on pre-S5 records → unburned. + +**Early jack-out confirmation implementation note:** `Run.onJackOutRequested` defers incomplete jack-out to confirmation modal (LINK BURNED is irreversible). `run.confirmJackOut()` finalizes; throws on illegal states. `wireRunConfirmations(run)` extracted — called at deploy **and** campaign resume (fixes latent abort-confirm loss on mid-run reload). + +**Persistence (consolidated):** `RunSnapshot.cyberspace` with phase `dormant | active | resolved`. Restore rules in `restoreCyberspace`: `contractRequiresCyberspace` ⇔ block present (both directions); unknown phase throws; dormant carrying payload throws; active validates grid dims, exactly one avatar + one port, entities bounds-checked against cyber grid; resolved requires boolean `objectiveComplete`; decker cyber stat blocks half-populated → throw. Autosave on meat `TURN_ENDED` while jacked in; `jackIn`/`jackOut` call `onPersist` explicitly. + +**Risks / follow-ups:** + +- S7 shell breadth — `index.ts` reads `run.world`/`run.player` widely; kaizen tracks cleanup (ShellScene casts, statusLine extraction, listener rewire dedupe, listener-order coupling). +- Spark/Guardian ICE shipped; ICE faction stamping for non-corp principals remains open. **Acceptance:** - Cyberspace grid renders distinctly from Meatspace. -- All three ICE types functional: patrol, attack, guard behaviors. +- All three ICE types functional: patrol, attack, guard behaviors (Probe ✅; Spark ✅; Guardian ✅). - At least one Cyberspace objective type (data node slice) with `isObjectiveSatisfied` integration. - Cyberspace grid generated deterministically from seed; snapshot round-trip for mid-run save/restore. - Jack-in from Meatspace terminal spawns Cyberspace grid. +**P3.M3 playtest stabilization note (2026-06-14):** Probe tuned from 3 HP / 4 AP to 2 HP / 2 AP — burst pressure came from action economy, not nominal one-damage strike. `CyberAvatar` exposes Override against ICE (2 AP / 60% / 3-turn contract, cyber aftermath pass, patrol snapshot round-trip). Pre-Score Decker flatline → one free Terminal replacement lead; THE SCORE gated until living Decker. Decker flatline during THE SCORE → `decker-flatlined-score` campaign Game Over. **P3.M3.7** adds meatspace CCTV PIP so jacked-in body vulnerability is visible (silent meat corp damage was the Score death vector in first playtest). + --- -### P3.M4 — Simstim flip (dual-deploy) 🔲 +### P3.M4 — Simstim flip (dual-deploy) ✅ **Depends on:** P3.M2 (Decker), P3.M3 (Cyberspace grid). This is the integration milestone. **Goal:** The **simstim flip** — dual-deploy two operators (Meatspace + Decker in Cyberspace) with a flip mechanic that switches active control between layers. The PIP/CCTV window shows the inactive layer. +**Resolved design decisions (2026-06-15):** + +- **Partner spawns at jack-in, not at mission start.** A Cyberspace contract is selected as a dual-deploy (Decker + meat partner), but pre-jack-in the run is the existing solo-Decker mission — the partner is *reserved* at deploy and only spawns onto the meat grid (a random safe cell, behind cover, out of immediate danger) the moment the Decker jacks in. +- **Control stays in Meatspace after jack-in until the first flip.** On jack-in the freshly-spawned partner is the active operator; the player enters Cyberspace only on the first explicit flip. +- **Deploy UX:** the Decker is auto-included as the jack-in operator; the player picks the living meat partner. If no living non-Decker is available, the run falls back to a solo Decker deploy (the P3.M3 path). +- **Flip is a free action** that swaps the active operator among the live operators: pre-jack `{Decker}` (no-op), jacked `{partner(meat), avatar(cyber)}` (layer swap; Decker body frozen), post-jack-out `{Decker(meat), partner(meat)}` (meat↔meat). +- **Forced jack-out at 1 HP:** the Decker's body cannot die while jacked in — a killing/critical hit clamps the body to 1 HP and ejects the Decker (alive) back to Meatspace. Cyber-side death (ICE depleting RAM) still flatlines. This retires the silent-meat-damage Score death vector from M3.7. +- **Independent AP pools, decoupled turn-end (2026-06-15, from playtest).** Each dual-deploy operator keeps its own 4-AP pool — moving the avatar spends only avatar AP, the partner only partner AP. The mutual turn (which drives *both* hostile phases) ends only when **every controllable** operator is spent, or on an explicit Wait — **not** when whichever one you're driving hits 0. Exhausting the active operator while the other still has AP **auto-flips** control to it (free, like the manual flip) rather than ending the turn. Rationale: the partner is a separate living crew member, so two operators ⇒ two full turns of action, matched by the two hostile phases already ticking each round (meat drones *and* ICE) — the economy stays symmetric. The earlier behaviour (turn ended on the *active* operator's exhaustion, refreshing both) wasted the other's unspent AP and rewarded tedious flip-drain micro. *Sub-decision:* auto-flip over a passive "operator spent" nudge — keeps the player's hands moving; the view-snap is covered by a loud flash. *Refinement (2026-06-15, playtest):* **Wait (`.`) passes *this* operator and always hands control to the other operator when one exists** — forfeit this operator's remaining AP, then flip to the other *regardless of whether it still has AP*; the mutual turn (and the hostile phases) fires only once both operators are spent/passed (on a flip-and-end, next turn opens on the operator we flipped to). Distinct from AP-exhaustion, which auto-flips only *while* the other can still act and ends *in place* when the last operator runs dry — flipping back to an already-spent operator at exhaustion would be an unwanted view-snap, whereas Wait is an explicit pass/switch gesture so it always switches. (An earlier pass mid-implementation flipped on Wait only when the other had AP and otherwise stayed put — that two-faced Wait read as confusing in playtest.) This gives a clean split — **Tab** switches attention keeping both pools, **`.`** commits one operator to inaction and rotates to the other — matching the reach-for-`.`-when-this-operator-has-nothing-to-do intuition. Trade-off: no single-press "end the whole round"; ending while both hold AP is two passes (`.` then `.`). No dedicated hard-end binding for now; revisit if the two-tap end proves annoying. *Possible follow-up:* exhaustion could be made to round-robin too if the Wait/exhaustion end-position difference reads as inconsistent in play. + **Scope:** -- **Dual-deploy:** On contracts with a Cyberspace component, the player selects two operators: one for Meatspace, one (the Decker) for Cyberspace. Both are placed on their respective grids at mission start (Meatspace operator at spawn, Decker at the jack-in terminal's Cyberspace entry node). - - **Pre–jack-in phase:** Both operators start in Meatspace. The Meatspace operator moves and acts normally. The Decker must reach a terminal and jack in (P2.5.M2.2 interact) to activate Cyberspace. Until jack-in, this is a normal single-grid mission. - - **Post–jack-in:** Cyberspace grid spawns. Flip mechanic activates. Decker's Meatspace body remains at the terminal — vulnerable, immobile, and targetable by corp hostiles (blueprint: "your physical body is a vegetable"). +- **Dual-deploy:** On contracts with a Cyberspace component, the player selects two operators: the Decker (auto-included, the eventual Cyberspace avatar) and a meat partner. Only the Decker is placed at mission start — the partner is *reserved* (see the resolved decisions above) and spawns at jack-in. + - **Pre–jack-in phase:** The Decker starts solo in Meatspace, where they move and act normally. The player must reach a terminal and jack in (P2.5.M2.2 interact) to activate Cyberspace. Until jack-in, this is a normal single-grid mission. + - **Post–jack-in:** Cyberspace grid spawns; the reserved partner spawns into Meatspace at a safe cell and **becomes the active operator** (control stays in Meatspace until the first flip). The Decker's Meatspace body remains at the terminal — frozen, immobile, and targetable by corp hostiles (blueprint: "your physical body is a vegetable"). - **The flip:** Switch active control between Meatspace operator and Decker. Active operator receives player input (move, attack, interact). Inactive operator holds position. - - Cost: **free action** or **1 AP** (TBD — free action recommended for less friction; AP cost adds tactical weight). + - Cost: **free action** for the first implementation. AP cost can be revisited after playtesting, but the first version should make the new mental model easy to explore. - Can flip at any point during the active operator's turn (before or after spending AP). - **Turn structure:** Player turn → flip as desired → end turn → **both layers' hostile phases resolve** (corp drones move in Meatspace, ICE moves in Cyberspace). Both layers tick simultaneously. - **PIP / CCTV window:** The inactive layer renders in a small overlay (bottom right corner of the screen). Shows grid state, hostile positions, the other operator's status. Read-only — no input accepted in the PIP. The blueprint's "real-time CCTV showing your physical body's status" becomes this. -- **Vulnerability:** While the Decker is jacked in, their Meatspace body is a valid target for corp hostiles. If the body is destroyed, the Decker is killed (flatline) and Cyberspace access is lost. The Meatspace operator's implicit job is to **protect the Decker's body** — or at least keep hostiles away from the terminal. -- **Jack-out:** The Decker can voluntarily jack out (returns control to single-grid Meatspace). Or is forced out if their body takes critical damage. Jack-out despawns the Cyberspace grid (any unsatisfied Cyberspace objectives fail). -- **Contracts without Cyberspace:** Single-deploy as today. The Decker deploys solo in Meatspace (no flip, no Cyberspace grid). Their drone override hack is their primary value. +- **Vulnerability:** While the Decker is jacked in, their Meatspace body is a valid target for corp hostiles. The body **cannot die while jacked in** — a killing/critical hit clamps it to 1 HP and forces a jack-out (resolved decision); only ICE depleting RAM flatlines the Decker outright. The Meatspace partner's explicit job is to **protect the Decker's body** — or at least keep hostiles away from the terminal. +- **Jack-out:** + - Jack-out despawns the Cyberspace grid, returning control to single-grid Meatspace + - The Decker can voluntarily jack out at any time via two means: 1, moving to the cyberspace exit glyph, or 2, hitting an explicit "jack-out" key (doing it this way causes neural shock and loss of 3 HP, so should be confirmed before proceeding) + - when jacking out, any unsatisfied Cyberspace objectives fail, but the run can still be completed for the usual penalty (the exception being the Score, which MUST be completed successfully). + - The Decker is also forced out when their body is driven to 1 HP by hostile attacks in Meatspace. +- **Contracts without Cyberspace:** Single-deploy as today. The Decker deploys solo in Meatspace (no flip, no Cyberspace grid). Their drone override hack is their primary value. A pre-Score flatline opens one replacement Decker lead through the Terminal; a flatline during THE SCORE is campaign-terminal. +- **Save invariant:** A run may be single-layer, pre-jack dual-deploy, or active dual-layer. Those states must be explicit. A save with `cyberspace.active = true` but no cyber grid/avatar, or with a Decker marked jacked-in but no Meatspace body anchor, is corrupt and must throw. + +**Integration slices:** + +| Slice | Status | Change | Tests | +|---|---|---|---| +| **P3.M4.1 Dual deploy** | ✅ Done | Reserve the meat partner alongside the Decker on a Cyberspace deploy; `Run.partnerMember`, deploy gates, persistence | deploy gates, partner shape, campaign + standalone round-trip | +| **P3.M4.2 Jacked body anchor + partner spawn** | ✅ Done (model) | Decker body freezes at port; partner spawns at a safe meat cell; `activeLayer`/`meatActor`/`deckerBody` on `Run` | body frozen+targetable, movement rejected, partner placement, determinism, round-trip | +| **P3.M4.3 Flip command** | ✅ Done | Tab → free-action flip; `activeView` keys on `activeLayer`; `Run.flip()`/`canFlip()` | flip toggles layer/actor, solo+pre-jack can't flip, meat↔meat post-jack-out, keymap intent | +| **P3.M4.4 Dual hostile phase** | ✅ Done | Independent AP pools + decoupled turn-end (auto-flip on exhaustion); both hostile phases tick once each with partner+body present; partner death handled | AP-pool/turn-end model; dual-phase verify with partner+body; partner flatline + control repair + alert | +| **P3.M4.5 PIP** | ✅ Done | Inactive layer mini-render + status summary; both layers route flashes to whichever side is in the PIP | inactive-feed resolution, partner→body follow fallback, cyber/RAM chrome, body-hit-in-PIP routing, cyber vs meat palatte styling of both main & PIP canvases | +| **P3.M4.6 Jack-out** | ✅ Done | Exit-port, explicit-key, and forced (1-HP) jack-out transition back to Meatspace | cleanup, objective failure rules, neural-shock confirmation, snapshot round-trip | + +**P3.M4.4 — dual-phase verify + partner death (2026-06-15):** The M3.6 dual-phase corp turn (chained meat pass → ICE pass on the shared run rng) was only ever tested solo-Decker; re-verified with a partner + frozen body on the meat grid: the meat pass steps neither PLAYER operator, both layers tick regardless of which layer the player is viewing, one AP refresh per round across body + partner + avatar (+ ICE), determinism holds with the partner present (PLAYER faction draws no corp rng), and post-jack-out the meat corp turn runs solo (no ICE pass) with both operators present (`dualPhaseTurn.test.ts`, +5). **Partner death** (the playtest report — partner killed off-screen during the corp turn while the player was in Cyberspace, discovered only via a "no operator to flip to" deny): the meat partner flatlining is *not* run-ending (the Decker fights on), handled in `Run.#onEntityDamaged` → `#onPartnerFlatlined` — it repairs the active-operator state so the player never drives a corpse (if the dead partner was the meat actor, control returns to the Decker `player`; while still jacked in the body is frozen, so the view also force-flips to Cyberspace onto the avatar), and fires a new `onPartnerDown` shell hook that flashes an **unconditional** "⚠ OPERATOR DOWN" alert (the kill is invisible from the grid view). `Run.partnerDown` (partner fielded + not alive) drives `Campaign.onJobEnd`, which flatlines `deployedPartnerId` for good — independent of the Decker's outcome (extracted clean or died), and across a campaign round-trip. Tests: `partnerDeath.test.ts` (8). Closes the partner-death follow-up that M4.2 deferred. **Restore companion fix (playtest, dead-partner-stuck save):** `#onPartnerFlatlined` repairs control at the *moment* of death, but `restore()` still set `meatActor = gridPartner` unconditionally — so a save with a jacked-in run + dead partner (or any pre-fix save) reconstructed the stuck state (driving a corpse in Meatspace, `canFlip` false). Restore now mirrors the live repair: a dead grid partner is never the meat operator — `meatActor` falls back to the (frozen) body and the view defaults to Cyberspace onto the avatar; a living partner restores normally honoring the saved view. + +**P3.M4.4 bugfix — stranded partner after restore (2026-06-15, playtest):** On a campaign mid-run restore, flipping to the meat partner after the Decker jacked out controlled a phantom: the partner appeared "stuck in a wall off the map" and couldn't move. Contributing factors: (1) restore rebuilds grid entities as *detached copies*, separate from the canonical roster crew objects; the primary tolerates this because `run.player` stays the grid copy (operator) while `crewMember` is re-linked to the roster object (identity) — two fields. (2) The partner has only `partnerMember`, doing double duty as both operator and roster ref. (3) `restoreActiveRun` unconditionally rebound `partnerMember` to the off-grid canonical roster object, so once the partner was a live grid entity (jacked in / jacked out) the flip's `#aliveMeatAlternate` handed control to the off-grid copy (id-match in `world.entities.has` masked it), centering the camera on the roster object's `(0,0)` and denying movement. Fix: `restoreActiveRun` re-binds `partnerMember` to the canonical object **only while it is still off-grid** (a dormant reserve a later jack-in spawns); once it is a live grid entity, keep that entity — mirroring `run.player`. Regression test: `dualDeploy.test.ts` "after jack-out the campaign round-trip keeps the partner on the grid" (identity + flip). **Follow-up (broader, pre-existing):** the detached-grid-copy-vs-canonical-roster split means combat stats applied *after* a mid-run restore land on the grid copy, not the roster object — a latent crew-HP desync across save→restore→play→save for the primary too. Investigate whether job-end reconciliation already covers it; if not, restore should rehydrate the canonical crew objects *as* the grid entities so there is one object, as in live play. + +**P3.M4.4 implementation note — AP pools (2026-06-15):** Independent AP pools with a decoupled turn-end, settled from playtest (see the resolved decision above). Two new pure `Run` methods carry the model: **`endOfTurnReady()`** — the active actor is at 0 AP and there is no flip alternate with AP left (`#flipAlternate()` returns the operator `flip()` would hand control to: the other layer's operator while jacked, else the other live meat operator); and **`concludeActiveOperatorTurn(): 'continue' | 'auto-flip' | 'end'`** — `'continue'` while the active actor still has AP, `'end'` once the crew is spent (the shell drives the corp+ICE phases, which refresh every pool once), `'auto-flip'` when the active operator is spent but another still has AP (the method performs the flip; a spent-but-not-end-ready actor guarantees a live alternate with AP, so the flip is always safe). The frozen Decker body is never the active actor nor a flip alternate, so its full pool can't keep the turn alive; solo/single-deploy has no alternate and ends at 0 as before (no M3 regression). Shell: every auto-end-on-exhaustion site (the `applyIntent` `gateOnApExhausted` via new optional `ctx.concludeTurn`, plus consumable / loot / secured-interact) routes through one `concludeOperatorTurn()` helper that switches on the discriminant — `'end'` → `advanceTurn()`, `'auto-flip'` → shared `repaintAfterFlip(run, 'OPERATOR SPENT')` (factored out of `handleFlip`). **Wait** (`end-turn`) zeroes the active operator's AP then routes through a *separate* `passTurn` hook → `Run.passActiveOperatorTurn(): 'flip' | 'flip-and-end' | 'end'` (playtest refinement). Unlike exhaustion, Wait **always** flips to the other operator when one exists (`#flipAlternate` non-null), regardless of that operator's AP — `'flip'` keeps the turn open, `'flip-and-end'` also drives the hostile phases (next turn opens on the flipped-to operator), `'end'` is the solo/single-deploy case; the `?? advanceTurn` fallback keeps a hard end for harness contexts. The shell's `passOperatorTurn` repaints (`repaintAfterFlip(run, 'WAIT')`) on both flip outcomes and additionally `advanceTurn()`s on `'flip-and-end'`. Tests: `operatorTurnConclude.test.ts` (14) and an `applyIntent` `end-turn`-routes-through-`passTurn` case covers the predicate, the three-way conclude, auto-flip-not-end-not-refresh, never flipping to a dead/frozen operator, solo/single-deploy end-at-0, and post-jack-out meat↔meat. **Still open for M4.4:** verify the M3.6 dual-phase corp turn (`dualPhaseTurn.test.ts` was solo-Decker only) holds with the partner+body present — both layers tick once, no double refresh, deterministic with the partner on the field. + +**P3.M4.3 implementation note (2026-06-15):** The flip is a free action bound to **Tab** (`keymap` → `{type:'flip'}`; the shell's `handleFlip` validates and flashes a deny when there's nothing to flip to, so the keymap stays dumb). `Run.flip()`/`canFlip()` swap active control: while jacked in, between the controllable meat operator and the cyber avatar (only when the meat side is a real partner, not the frozen solo body); post-jack-out, between the two live meat operators (`#aliveMeatAlternate`). The big seam change is in **`activeView`**: `isJackedIn` ("a cyber layer exists" — body vulnerability, dual-layer turn plumbing) now splits from new **`isCyberView`** ("the player is viewing/controlling the grid" = jacked **and** `activeLayer==='cyber'`). Render, tileset, vision, HUD, gear gates, corp-step render target, breach overlay, and mood/hint all switched to `isCyberView`; `meatActorOf` centers meat fog + the meat HUD pane on the controllable operator (partner after a dual jack-in). Solo cyber runs are unchanged (jack-in sets `activeLayer='cyber'` ⇒ `isCyberView` true everywhere it was `isJackedIn`). **Verification gap:** the shell `handleFlip`/render path has no DOM test harness and the debug harness lacks Cyber/PIP support, so the end-to-end flip needs an in-campaign playtest once a dual-deploy cyber contract is reachable through the live loop. **Follow-up:** no touch-pad flip button yet (keyboard-only); M4.5 still owes the PIP showing the *inactive* layer (today it always shows meat per M3.7, so it's redundant while viewing meat). + +**P3.M4.2 implementation note (2026-06-15):** Run-model layer only — the shell/`activeView` wiring and the flip *command* land with P3.M4.3. `jackIn` now captures the Decker as the **body**, freezes it (`Entity.frozen`, enforced in `World.canMoveEntity` → `'jacked-in'`; still a live, targetable grid entity), and — when a partner was reserved — spawns the partner via `#partnerSpawnTile` (deterministic, prefers cells no live hostile can see and that sit against cover; falls back safe → any-free; throws on a full grid), hands it control (`meatActor`), and keeps `activeLayer = 'meat'`. A solo jack-in has no partner, so `meatActor` stays the Decker and `activeLayer = 'cyber'` (M3 behaviour preserved). New `Run` surface: `meatActor` (controllable meat crew), `activeLayer` (`'meat'`|`'cyber'`), `deckerBody` getter, and `activeWorld`/`activeActor` now honor `activeLayer` (via `cyberInputActive`). `player` deliberately stays the Decker/body so body-targeting feedback and the PIP keep reading it. `#finalizeJackOut` unfreezes the body, returns meat control to the Decker, and resets `activeLayer`. Persistence: the off-grid `partner` record is written only while the cyber layer is `dormant` (post-jack the partner is a live grid entity); `activeLayer` is captured while `active`; restore disambiguates the two meat PLAYER crew (Decker = body, non-Decker = partner), re-freezes the body when `active`, and re-establishes `meatActor`/`activeLayer`. **Follow-up:** partner-death flatline accounting (the partner can now die on the field but the campaign doesn't yet flatline it) — must land before M4 closes. + +**P3.M4.1 implementation note (2026-06-15):** A Cyberspace dual-deploy reserves a meat partner without spawning it. `Run.partnerMember: Crew | null` (validated non-Decker, living, distinct from the deployed operator) is set at construction; `Run.enterBriefing` forbids a partner on a non-cyber contract but does **not** require one (a solo Decker cyber run stays legal — the dual-deploy product rule is enforced by the briefing UI, not the model). `Campaign.deployCrewMember(memberId, contract, partnerId?)` gains the optional partner, records `Campaign.deployedPartnerId`, commits both operators (both clear job-scoped salvage at `onJobEnd`), and gates the partner (cyber-only, living, non-Decker, distinct, known id). `` inverts its P3.M3.1 gate: on a cyber contract the Decker row is locked as `CYBER OP` and the player selects the living meat partner (Decker auto-attaches via the emitted `partnerId`); with no eligible partner it falls back to the solo `NEEDS DECKER` gate. Persistence: the reserved partner round-trips as an off-grid entity record (`RunSnapshot.partner`, throwaway `(0,0)` cell) for standalone restore, and re-binds to the canonical crew object via `partnerMemberId` on the campaign path (BRIEFING and COMBAT/RESULT). A partner record without a Cyberspace contract throws on restore. **Follow-up:** partner-death flatline accounting lands with M4.2 (the partner isn't on the field until jack-in); a fully crew-depleted player still cannot field a partner for THE SCORE — revisit if that edge proves too punishing. + +**P3.M4.5 implementation note (2026-06-15):** The M3.7 PIP (always the meatspace body CCTV) now renders the **inactive** layer after the simstim flip — the layer the player is *not* driving. `pip.ts` is the pure seam: `pipFeedFor` resolves `'meat'` when viewing cyber and `'cyber'` when viewing meat (absent `activeLayer` defaults to the meat feed, preserving solo/legacy behaviour); `pipWorldOf`/`pipFollowTargetOf`/`pipChrome`/`shouldShowPip` all key off the feed. The meat feed follows the **living partner**, falling back to the Decker's frozen **body** once the partner flatlines (or on a solo Decker run) — chrome labels `PARTNER`/`BODY` accordingly; the cyber feed follows the avatar and reads `// THE GRID //` + `RAM` over a magenta-tinted (`.pip-cyber`) border, with cyber fog (`cyberVision`) and no principal palette. `pointer-events: none` keeps the overlay read-only. **Flash routing fix:** M3.7 routed body-hit feedback to the PIP whenever `isJackedIn`; that was only correct while viewing cyber. `sceneListeners` now routes by *which layer is in the PIP* — meat events (body/partner damage, muzzle, ranged noise) flash the PIP iff `isCyberView` (meat is the inactive feed) and the main canvas otherwise; cyber events symmetrically flash the PIP when viewing meat, including a new `RAM HIT`/`RAM WIPED` pulse so off-screen ICE damage is visible. Tests: `pip.test.ts` rewritten for the inactive-feed resolution + partner/body fallback + cyber chrome (11), and a `sceneListeners.test.ts` case locks the body-hit-routes-to-PIP-only-while-viewing-cyber predicate. **Follow-up:** still no touch-pad flip button (keyboard-only, from M4.3); the end-to-end PIP swap wants a live in-campaign playtest once a dual-deploy cyber contract is reachable (no DOM harness for the render path). + +**P3.M4.6 implementation note (2026-06-17):** Forced jack-out now lands on the same resolver as voluntary jack-out: if the frozen Decker body takes Meatspace damage while the cyber layer is active and is driven to **1 HP or below**, `Run.#onEntityDamaged` clamps the body alive at 1 HP, bypasses early-jack-out confirmation (it is not a choice), tears down the cyber layer, burns the jack-in port, returns control to the Decker in Meatspace, and latches the data-node objective at its current progress. Unsliced nodes therefore make the cyber objective permanently incomplete; already-sliced objectives remain complete. This covers both nonlethal hits to exactly 1 HP and lethal hits that `Entity.damage()` had already marked dead before the run listener repaired the body. Snapshot round-trip preserves the resolved cyber latch, burned port, living 1-HP Decker, and post-jack-out meat control. Shell corpse memory now ignores the repaired body even when the raw damage payload said `killed: true`, so the PIP/visibility cache does not record the Decker as a corpse after emergency ejection. Tests: `jackOut.test.ts` (+3 forced-jack-out cases) and `sceneListeners.test.ts` (+1 repaired-body corpse-memory guard). + +**P3.M4.6 explicit jack-out key closeout (2026-06-17):** The missing second voluntary path is now bound to **`j`** (`keymap` → `{type:'jack-out'}`; touch pad mirrors it as `JACK OUT`). Unlike routing out through the Cyberspace exit glyph, explicit jack-out always confirms when the shell is wired because it applies `JACK_OUT_SHOCK_DAMAGE = 3` HP neural shock to the Decker's body after the link actually drops. The request is valid from either side of a live dual-deploy jack-in, so the player can eject while controlling the meat partner; it burns the link, resolves/despawns Cyberspace, latches objective progress, and then applies shock. At critical HP, confirmed shock can flatline the Decker, but the save state is still resolved/burned before death settlement — no dead-but-still-jacked-in state. Key help and touch controls both advertise the action. Tests: `jackOut.test.ts` (+3 explicit-key cases), `keymap.test.ts`, `applyIntent.test.ts`, `touchpad.test.ts`, and `keyHelp.test.ts`. Closes P3.M4. **Acceptance:** - Dual-deploy: two operators on two grids, each controllable. - Flip switches active control; inactive operator holds position; both hostile phases tick. - PIP renders inactive layer (at minimum: grid + entities + operator status). -- Decker body vulnerable in Meatspace while jacked in; body death = Decker death. +- Decker body vulnerable in Meatspace while jacked in; body at 1 HP = forced jack-out, while cyber avatar death still flatlines the Decker. - Jack-out despawns Cyberspace grid cleanly. - Snapshot: both grids, both operators, flip state; restore round-trip mid-mission. - Golden-path test: deploy → jack in → flip between layers → complete objectives in both → extract. --- -### P3.M5 — The Score (climactic mission) 🔲 +### P3.M5 — The Score (climactic mission) ✅ **Depends on:** P3.M1 (arc structure), P3.M4 (simstim flip), P2.5.M7 (location persistence for the target site). **Goal:** The **climactic dual-layer mission** that the entire campaign builds toward. The Score is a contract at the designated target site, requiring both Meatspace breach and Cyberspace penetration to complete. +**Status (2026-06-20):** Complete for the M5 scope. The finale ships as one linked Cyberspace core node and one locked Meatspace route/payload pair, with independent operative extraction and terminal win/partial/loss campaign outcomes. The schema remains open for future multiple node/lock pairs, but M5 deliberately ships exactly one pair. + **Scope:** -- **Score contract:** A special contract type (or recipe) that is only available in Act 3 when the player chooses to attempt it. Not randomly rolled — player-initiated from the Hub. +- **Score contract:** A special `score-final` objective emitted by `Campaign.buildScoreContract()` only when Act 3 Score gates pass. It is not randomly rolled — player-initiated from the Hub — and it uses the persisted Score target site's dimensions, breach deltas, seen tiles, and site memory. - **Dual objectives:** The Score has objectives in **both** layers: - - **Meatspace:** Breach the target site (using P2.5.M7 pre-made breaches + new ones), reach the objective room, protect the Decker's body, extract. - - **Cyberspace:** Penetrate the target's digital defenses (ICE gauntlet), disable core security (opens physical locks/routes for the Meatspace operator), extract the target data/asset. - - Both must be satisfied for a clean completion. Partial completion (one layer only) = partial payout or narrative consequence (TBD). + - **Meatspace:** Reach the locked Score route, enter the objective room after the core unlock, secure the Score payload, protect the Decker's body, and extract both deployed operatives. + - **Cyberspace:** Jack in, slice the single Score core data node, and jack out or continue coordinating the Meatspace finish. + - Both layer objectives plus both deployed operatives extracting alive are required for clean completion. Confirmed early/one-layer extraction is terminal partial completion, not a retry path. - **Site knowledge payoff:** The target site uses P2.5.M7's persistent geometry. Every prior visit's breaches, mapped rooms, and learned patrol routes carry over. The player who cased the site thoroughly has a significant advantage. - **Escalated difficulty:** The Score is harder than any normal contract — more hostiles, tighter turn budget, more ICE, higher stakes. Failure = campaign loss (crew wipe or objective irrecoverably failed). -- **Narrative climax:** The Score's briefing, objective copy, and completion text reflect the campaign's arc. The chronicle (P3.M6) records the outcome as the campaign's defining moment. +- **Independent extraction:** Score runs persist `extractedOperativeIds`. A deployed Meatspace operative who reaches the exit after objectives are complete is marked extracted and removed from active control/targeting; the run continues until the remaining required operative also extracts. Either the meat partner or the Decker body can leave first. Early exit before full objectives uses the existing confirmation path; confirmed extraction ends the campaign as partial. +- **Terminal outcomes:** Full Score extraction sets `score-complete`, marks the campaign as a win, and awards the full `1,000 Cr` Score payoff. Confirmed partial extraction sets `score-partial`, marks the campaign result as `partial`, and awards no full Score payoff. Decker flatline during the Score remains `decker-flatlined-score`; crew wipe remains terminal loss. +- **Narrative climax:** The Score's briefing, objective copy, and completion/partial/failure text reflect the campaign's arc. The chronicle (P3.M6) records the outcome as the campaign's defining moment. + +**P3.M5 implementation note (2026-06-20):** `OBJECTIVES.SCORE_FINAL` is validated separately from normal `data-node-slice` contracts and requires `requiresCyberspace: true`, a positive `count`, and a stable linked `doorId`. `DataNode` emits `EVENT.DATA_NODE_SLICED`; Score runs listen for that event and unlock the linked Meatspace door once the core node is sliced. Score objective satisfaction is a conjunction of cyber core progress and secured payload state; extraction is a separate requirement handled by `Run.#extractScoreOperative`. Snapshot/restore persists the extracted operative ids plus off-grid extracted operative records so mid-finale saves can restore a partner-first or body-first extraction state without resurrecting the extracted crew onto the grid. `Campaign.onJobEnd` maps incomplete Score exits to `score-partial`, skips the normal abort Rep penalty, and ends the campaign without the Score reward; `buildCampaignSummary` now reports `win | partial | loss`, and `` renders distinct compromised-Score copy. + +**Acceptance:** + +- ✅ Score contract available only in Act 3, player-initiated, and gated by living Decker + living non-Decker partner. +- ✅ Dual-layer objectives: Meatspace payload + Cyberspace core both required for clean completion. +- ✅ Target site uses persistent geometry from prior visits (P2.5.M7 dimensions, breach deltas, and seen tiles present). +- ✅ Completion = campaign win with Score reward; confirmed partial = terminal partial result with no full reward; Decker flatline / crew wipe remain campaign loss. +- ✅ Golden-path and persistence tests cover full Score deployment, linked door unlock, partner-first extraction, mid-Score restore, early partial extraction, campaign partial settlement, and summary/game-over validation. + +--- + +### P3.M6 — Stolen Blueprints (shop rework + meta-progression) 🔲 + +**Depends on:** P3.M5 (Score completion path — unlock writes to meta-store on `score-complete`); P2.5.M5 (existing shop/rep system being replaced). + +**Goal:** Replace rep-gated shop access with a **meta-progression unlock system** rooted in successful Score heists. The item catalog is restructured into two explicit groups: **default items** (always available) and **scoreable items** (each a distinct Score target, unlocked permanently by stealing its blueprint). An enriched scoreable pool (8–12 items total, at least 5 net-new) means multiple campaigns have distinct heist targets before the pool exhausts; once exhausted, Scores shift to abstract RNG-driven credit payloads that keep the arc alive indefinitely. + +**Scope:** + +- **Data model change:** The current `minRepTier` property on items is retired as a shop-access mechanism. Items are reorganized into two fixed compile-time catalogs: + - **`DEFAULT_ITEMS`:** Items previously flagged `BURNED` or `UNKNOWN` `minRepTier`. Always available in Finn's shop; no condition, no gate. + - **`SCOREABLE_ITEMS`:** Items previously flagged `KNOWN` `minRepTier`, plus at least 5 net-new items added as part of this milestone. Each has a unique ID, a name, stats, and a short flavor line describing what was stolen (the prototype, the implant design, the weapons schematic). Not available in the shop until unlocked via a Score heist. + - `minRepTier` can be safely removed from item definitions, as it no longer influences shop availability after this milestone, and is not referenced elsewhere. + +- **Finn's shop rework:** Rep no longer gates shop inventory. + - `DEFAULT_ITEMS` always stocked from campaign start. + - Unlocked `SCOREABLE_ITEMS` added to stock permanently once acquired; locked scoreable items are not shown at all. The discovery of a new item appearing in Finn's shop after a Score is the reward. + - Shop variance = which scoreable items the meta-crew has acquired across all past campaigns. Rep meter decoupled from shop access (still drives arc transitions as before). + +- **Score target rework:** `buildScoreContract()` draws from the set of not-yet-acquired `SCOREABLE_ITEMS`. The Score target site is still a synthesized CRITICAL-tier facility, but briefing copy and objective text frame the site around the specific payload — the R&D lab, the secure vault, the production facility where the prototype lives. On clean `score-complete`, the item ID is written to the meta-progression store and the item becomes permanently available in Finn's shop. + +- **Abstract Score targets (pool exhausted):** When all `SCOREABLE_ITEMS` are acquired, `buildScoreContract()` shifts to abstract RNG-driven credit payloads drawn from a small fixed category set (corp payroll, exotic meta-materials, black-market data cache, prototype weapons cache, etc.). Payload category is seeded from the contract RNG so each exhausted-pool campaign gets different flavor. The full arc structure runs identically; the payout is a substantial credit sum rather than a shop unlock. No item is written to the meta-store. + +- **Meta-progression store:** New `DataStore` key `unlockedScoreableItems: string[]` (item IDs, ordered by acquisition date). Written only on `score-complete` (not `score-partial` — the prototype wasn't secured). Read at campaign init and Hub load. Idempotent archival: writing a duplicate ID is a no-op (no throw, no double-entry). Half-populated or structurally invalid store throws on restore rather than silently falling back to an empty list. + +- **Hub surface:** Finn's shop renders only purchasable items — `DEFAULT_ITEMS + unlockedScoreableItems`. An `ACQUISITIONS: N / M` counter is deferred to P3.M7, where it fits naturally in the Chronicle / history view. + +**Implementation slices:** + +| Slice | Status | Change | Tests | +|---|---|---|---| +| **P3.M6.1 Meta-store** | ✅ | `DataStore` key `unlockedScoreableItems`; read at campaign init; idempotent archival; duplicate no-op; corrupt throws | round-trip, idempotent archival, duplicate no-op, corrupt throws | +| **P3.M6.2 Item catalog split** | ✅ | Define `DEFAULT_ITEMS` and `SCOREABLE_ITEMS` catalogs; retire `minRepTier` as shop gate; add at least 5 net-new scoreable items (fully wired gear) | catalog validation, no duplicate IDs, all items have required fields, `minRepTier` not read by shop | +| **P3.M6.3 Shop rework** | ✅ | Shop stocks `DEFAULT_ITEMS + unlockedScoreableItems`; no rep gate; locked scoreable items not rendered | shop shows only default items when meta-store is empty; adds unlocked scoreable items as they accrue; rep change has no effect on stock | +| **P3.M6.4 Score target rework** | ✅ | `buildScoreContract()` draws from unacquired `SCOREABLE_ITEMS`; briefing copy reflects item; completion writes meta-store | available targets exclude acquired; retired items not rolled; store updated on complete | +| **P3.M6.5 Abstract targets** | ✅ | Exhausted-pool Score draws RNG credit payload from category set; seeded flavor; arc gates pass with empty scoreable pool | category selection determinism, arc unaffected, no meta-store write | +| **P3.M6.6 Hub surface** | ✅ | Shop renders only purchasable items; no locked placeholders | shop never renders a locked scoreable item regardless of meta-store state | + +**P3.M6.1 implementation note:** The meta-progression store lands as a new +`DataStore` key, `unlockedScoreableItems: string[]` (item IDs, acquisition order, +newest-last), backed by a pure validator module `src/game/scoreableUnlocks.ts` +(mirroring the `campaignSummary.ts` ⇄ `DataStore` relationship): +`normalizeUnlockedScoreableItems` (absent → `[]`, non-array/non-string-element throws, +de-dupes preserving order) and `archiveScoreableItem` (idempotent append, duplicate → +`{ added: false }`). `DataStore.archiveScoreableItem(id)` only emits a `change` event / +saves when the store actually changes (matching `archiveCampaign`'s `added` gate); the +getter returns a defensive copy. Per the global "crashing beats data corruption" +directive, `#loadDataFromJson` was split so an unparseable blob still resets to defaults +but a **structurally corrupt (yet parseable) scoreable store throws** out of +`init`/`import` rather than silently erasing earned blueprints. Structural validation +only — catalog membership (id ∈ `SCOREABLE_ITEMS`) is a follow-up once that catalog +exists in M6.2. The `score-complete` write call-site lands in M6.4. **Follow-up:** +`campaignHistory` keeps its older swallow-and-reset-on-corrupt posture (deliberately not +retrofitted in this slice). Tests: `scoreableUnlocks.test.ts`, plus three +`DataStore.test.ts` cases (absent → `[]`, idempotent archival + persistence + defensive +copy, corrupt store throws). + +**P3.M6.2 implementation note:** The single rep-gated `CATALOG` was split into two +frozen arrays — `DEFAULT_ITEMS` (the four always-available consumables: Stim, Smoke, +Incendiary, Breaching Charge) and `SCOREABLE_ITEMS` (the four former KNOWN-tier gear +items plus **five net-new prototypes**). `minRepTier` was removed from the `Item` type +and every descriptor; `getShopCatalog()` now takes no rep argument and returns a fresh +copy of `DEFAULT_ITEMS` only (the unlocked-scoreable merge is M6.3's seam). `getItemById` +searches both catalogs; `SCOREABLE_ITEM_IDS` (a frozen `Set`) is exported for M6.4's +pool membership checks. Each scoreable item carries a `flavor` line (the stolen-prototype +fiction) for M6.4 briefing copy. + +Rather than scale the existing four gear channels, the **five net-new items each fill a +stat channel no crew gear previously touched** (a deliberate design choice — a heist reward +should be a new capability, not a bigger number). Premium "bigger X" variants were +explicitly rejected in design review: with random unlock order and no equip limit, a +premium can arrive before its base and double-buying the base is identical, so the variant +adds nothing. +- **Monoblade** (`+1 melee dmg`) — the Razor's signature attack had zero gear support. + New `gear.meleeDamageBonus`, applied via a new `Crew.meleeAttackDamage()` that mirrors + `rangedAttackDamage()`; `Combat.attackerMeleeDamage` now prefers that method. +- **Subdermal Plating** (`+1 damageReduction`) — the flat-armour channel (min-1 floor in + `Combat.applyDamageReduction`) existed but no crew gear set it. The live `damageReduction` + stat is the source of truth; `gear.armorBonus` tracks it for cap-clamping. This stat was + **not** carried by the campaign-crew snapshot, so `snapshotCrewMember`/`restoreCrewMember` + were extended to persist it (the in-job entity snapshot already round-tripped it). +- **Reflex Booster** (`+1 maxAp`, hard-capped at 1) — the master action resource. `maxAp` + already round-trips on both save paths; `gear.apBonus` tracks. Immediate benefit (the + extra AP is usable the same turn), matching Armour Plating's `+hp`. +- **Phase Shield Prototype** (`+1 shield/turn`) — re-grants `shieldHp` at the start of each + crew turn via a new `Crew.refreshAp()` override (the same hook the Razor uses to clear + stealth; `super.refreshAp` zeroes the shield, the override tops it back up). Free and + uncontested, unlike the Medic's AP-costed `MEDIC_SHIELD_HP`, so kept to +1. +- **Regen Mesh** (`+1 HP/turn`) — heals real HP up to max each turn via the same refresh + hook; slow in-combat sustain distinct from the shield's resettable buffer. + +`refreshAp` regen is guarded (`alive` + amount `> 0`) so a flatlined body regenerates +nothing (`heal`/`addShield` would otherwise throw on a corpse). The five new `Gear` fields +are optional (`?? 0` reads) so pre-M6.2 gear snapshots restore cleanly; `repairGearForCrew` +clamps the capped bonuses. Capped items follow the Ballistics Coil pattern (bonus = cap → a +duplicate purchase is a harmless no-op). **Known transitional state:** between M6.2 and M6.3 +the shop shows only `DEFAULT_ITEMS`, so the former KNOWN gear is temporarily unbuyable until +the M6.3 unlocked-item merge lands. + +**Follow-up (kaizen, tabled):** a **revive** path — un-flatline a crew member at the Hub for +a steep Cred cost, zeroing their gear (and resetting the derived maxHp/maxAp/damageReduction +to archetype base). Pure Hub economy + narrative, no combat-mechanics implications. Needs a +new `Campaign.reviveMember()` and a delivery surface (Clinic service vs. Finn purchase — the +shop target-picker currently excludes flatlined crew). Deferred to its own slice; revisit +after M6.3/M6.4. + +**P3.M6.3 implementation note:** `getShopCatalog(unlockedScoreableIds = [])` now returns +`DEFAULT_ITEMS` plus the `SCOREABLE_ITEMS` whose ids appear in the supplied unlock list +(membership via a `Set`); rep is structurally gone (no parameter). `Finn.catalog()` is a +thin passthrough. The shell reads the meta-store **live** at shop-open time +(`presentFinnShop` → `dataStore.unlockedScoreableItems`) rather than caching it at campaign +init — a refinement of the M6.1 "read at init/Hub load" sketch. This is simpler and always +correct, and the practical difference is nil: the only in-campaign unlock path is completing +the climactic Score (M6.4), after which the player doesn't return to shop. **Lenient +membership:** an unlocked id that isn't a known scoreable item (a retired or +forward-version blueprint) is silently skipped, not thrown — the shop can't render a +nonexistent item, and hard-failing would brick saves across catalog changes. This resolves +the M6.1 "catalog membership validation" follow-up in the shop layer (the store stays +structural-only). The `buildScoreContract` acquired-set exclusion (which also needs the +unlock list) lands in M6.4; it will read the same `dataStore.unlockedScoreableItems`. + +**P3.M6.4 implementation note:** `buildScoreContract(unlockedScoreableIds = [])` now draws a +specific heist payload via `pickScorePayload(seed, acquired)` — `SCOREABLE_ITEMS` minus the +acquired ids, selected with a `Rng` seeded from the target seed XORed with a salt +(`SCORE_PAYLOAD_SALT`) so the choice is deterministic per campaign yet independent of the +map roll. Retired/foreign ids in the acquired list simply aren't in the pool, so they're +never rolled. The chosen blueprint's `flavor` + `label` frame the briefing, and its id rides +in `objective.params.scoreItemId` (a plain `ObjectiveParams` string that `cloneObjective` +preserves across a mid-run save/restore). On clean `score-complete`, `onJobEnd` reads that id +off the completing contract and records it on `meta.scoreUnlockedItemId` (alongside the +existing `meta.scorePartial`), exposed via the `Campaign.scoreUnlockedItemId` getter +(validated against `SCOREABLE_ITEM_IDS`). The shell writes it to the meta-store in +`presentEndedCampaignOverlay` — the shared terminal-settlement chokepoint for both live +completion and a restored already-ended save — right beside `archiveCampaign`, and the write +is idempotent so the double path is safe. Partial Scores record nothing (prototype not +secured). **Exhausted pool:** `pickScorePayload` returns `null`, the contract drops the +`scoreItemId` param and falls back to generic briefing with no unlock — the abstract +credit-payload Score is M6.5. + +*Messaging polish (playtest feedback):* the Score payload pickup is now labelled with the +target blueprint and carries its `flavor` (new optional `Pickup.detail`, persisted), so the +grab logs e.g. `secures Monoblade` followed by the flavor beat. The stolen blueprint is +captured **in the `CampaignSummary`** as an optional, self-contained `scoreReward` +(`{ id, label, flavor }`) — `buildCampaignSummary` resolves it from `scoreUnlockedItemId`, +and `` reads it straight off the summary on a win. Persisting it (rather than a +presentation-only setter) seeds the **M7 Chronicle**, which will surface acquisitions from +history. `validateCampaignSummary` gained a `requireKeys(required, optional)` helper so the +schema tolerates the new optional field (and future ones); the reward is validated +self-contained and round-trips through clone. Abstract / exhausted-pool Scores carry no +`scoreReward` and keep the generic "Score payload" pickup. + +**P3.M6.5 implementation note:** When `pickScorePayload` returns `null` (every +scoreable blueprint already stolen), `buildScoreContract` now draws an abstract +credit payload via `pickAbstractScorePayload(seed)` instead of the old flat +generic line. The pick is seeded from the **Score target site seed** XORed with +its own salt (`ABSTRACT_SCORE_PAYLOAD_SALT`, distinct from `SCORE_PAYLOAD_SALT`), +so the category is deterministic per campaign and independent of both the map +roll and the (empty) blueprint draw. `ABSTRACT_SCORE_TARGETS` is a frozen set of +`{ id, label, flavor }` categories (liquid reserves, bearer bonds, slush fund, +cold-wallet keys, payroll skim) — "you've stolen everything worth stealing; now +you're just taking their money." The abstract briefing is framed from the chosen +category's `flavor` + `label`. Crucially the contract carries **no +`scoreItemId`**, so a clean abstract Score settles the arc (`score-complete`, +`SCORE_CREDITS_REWARD` paid) exactly like a blueprint Score but +`scoreUnlockedItemId` stays `null` and the terminal-settlement meta-store write +is a no-op. The Score payload pickup keeps its generic "Score payload" label with +no flavor (the M6.4 Run-side fallback already handles a missing id). Arc gating +never read the scoreable pool, so "arc unaffected with an empty pool" is +structural — the new test exercises it end-to-end (build → deploy → complete) +for regression cover. + +**P3.M6.6 implementation note:** The substantive hub-surface change — stock is +`DEFAULT_ITEMS + unlocked scoreable` with locked blueprints filtered out — landed +in M6.3's `getShopCatalog`, the single filtering chokepoint. `` renders +strictly the catalog handed to `setCatalog` (grouped by scope; unaffordable rows +are disabled, never locked), and `presentFinnShop` is the only caller — it always +feeds `Finn.catalog(dataStore.unlockedScoreableItems)`. So there was **no new +production code** for M6.6; the milestone is the hub-surface regression cover the +acceptance names. The guard is a meta-store **state sweep** at the `Finn.catalog` +boundary (empty / one unlock / all unlocked / ghost ids / duplicates) asserting a +scoreable is stocked iff unlocked and never as a locked placeholder, plus a +companion check that every surfaced row is a real purchasable item (default or +unlocked scoreable). `` renders only the catalog handed to +`setCatalog`, so the boundary filter is the invariant's single seam. The deferred +`ACQUISITIONS: N / M` counter remains an M7 Chronicle concern. **M6 complete.** + +**Recorded design decisions:** + +- **Why `minRepTier` is retired as a shop gate:** Rep-gated access was mechanical — the best gear was reachable by grinding rep without doing anything interesting. Two explicit catalogs make availability rules legible in the data rather than computed from a tier comparison at runtime. +- **Why default items are fixed:** The interesting question is "which upgrades has the meta-crew earned?" not "will Finn have ammo today?" Fixed default stock removes friction and keeps meaningful variance on scoreable unlocks. +- **Why abstract payloads instead of pool reset:** Resetting would retroactively devalue past heists. Abstract payloads acknowledge mastery — "you've stolen everything worth stealing; now you're just taking their money" — while keeping the arc valid indefinitely for long-running meta-campaigns. +- **Why partial Score doesn't unlock:** Incomplete extraction means the prototype wasn't secured. Clean win only; the fiction holds. **Acceptance:** -- Score contract available only in Act 3, player-initiated. -- Dual-layer objectives: Meatspace + Cyberspace both required. -- Target site uses persistent geometry from prior visits (P2.5.M7 mutations present). -- Completion = campaign win; failure = campaign loss. -- Golden-path test: full Score run from deployment through dual-layer completion to extraction. +- Finn's shop never gates by rep; `DEFAULT_ITEMS` always present from campaign start. +- Unlocked `SCOREABLE_ITEMS` appear in shop; locked ones are not rendered. +- `buildScoreContract()` excludes acquired scoreable items; draws abstract credit payload when pool is exhausted. +- Meta-store persists across campaign boundaries; duplicate ID writes are no-ops; corrupt store throws. +- Golden-path test: complete Score with scoreable item target → next campaign shows item in Finn's shop and excludes it from Score target pool. --- -### P3.M6 — Chronicle (campaign narrative memory) 🔲 +### P3.M7 — Chronicle (campaign narrative memory) ✅ **Depends on:** P3.M1 (arc structure provides the narrative beats to chronicle). Can begin data collection earlier if arc state is available. @@ -248,11 +628,12 @@ Implementation notes TBD after Clock type is chosen. Multiple types may coexist **Scope:** -- **Active campaign chronicle:** Entries for each run (jobs taken, outcomes, objectives completed/failed, major Rep deltas, crew changes, Decker recruitment, Score prep milestones) stored **in the campaign save**. +- **Active campaign chronicle:** Entries for each run (jobs taken, outcomes, objectives completed/failed, major Rep deltas, crew changes, Decker recruitment, Score prep milestones, Score target blueprint / credits stolen when all items are unlocked) stored **in the campaign save**. - **Arc-aware entries:** Chronicle entries reflect the campaign's narrative arc — Act 1 entries read as "getting established"; Act 2 entries reference the Score target; Act 3 entries build tension toward the climax. -- **Presentation:** Surfaced from the Hub **Terminal** alongside / inside the existing **crew** view (exact IA: tab, section, or shared scroll — TBD). +- **Presentation:** Surfaced from a new Hub entry point (not the existing crew terminal: it remains focused on crew stats, inventory, and recruiting). - **Campaign end summary:** On win (Score completed) or loss (flatline / clock expired), roll up a **summary record** into a **persistent history** list (localStorage / DataStore — same durability pattern as runs/prefs). High-scores-style: scannable list with campaign stats, arc outcome, run count, crew roster at end. - **History access:** Hub waypoint or menu entry to view past campaign summaries. Viewable without an active campaign. +- **Acquisitions counter:** The Chronicle / history view surfaces an `ACQUISITIONS: N / M` counter showing how many scoreable item blueprints the meta-crew has stolen across all campaigns (read from `unlockedScoreableItems` in the meta-store). The shop itself shows only purchasable items; this is the right place to communicate meta-progression depth. **Acceptance:** @@ -262,6 +643,8 @@ Implementation notes TBD after Clock type is chosen. Multiple types may coexist - Hub can open chronicle (active campaign) and history (past campaigns) without errors. - Tests for append + round-trip + cap/trim policy if the list is bounded. +**P3.M7 implementation note:** Shipped. The Chronicle now persists active-campaign entries in the campaign save, recording crew assembly, Decker recruitment, arc transitions, and job outcomes with stage-aware copy. The Hub exposes a dedicated `LOG` entry point that opens a Chronicle/archive modal rather than reusing the crew terminal. That surface shows the live campaign log, current arc status lines, archived `CampaignSummary` history, and an `ACQUISITIONS: N / M` meta-progression counter sourced from `unlockedScoreableItems`. The end-summary foundation remains the archival backend: a validated `CampaignSummary` is still built only after campaign settlement reaches `ENDED`, stored newest-first, deduplicated by campaign id, and trimmed to 50 history rows. Chronicle state round-trips through snapshot restore, including pending run context so job entries still settle correctly after reload. + --- ## Recorded design decisions diff --git a/docs/phase-4-notes.md b/docs/phase-4-notes.md new file mode 100644 index 0000000..3941a8c --- /dev/null +++ b/docs/phase-4-notes.md @@ -0,0 +1,213 @@ +# Phase 4 notes — multi-floor maps (June 6, 2026) + +Exploratory notes from a feasibility assessment. Not a living plan — see [phase-3-plan.md](phase-3-plan.md) for official Phase 3 scope and deferrals. + +## Context + +[phase-3-plan.md](phase-3-plan.md) defers **multi-level / sublevel maps** to Phase 4: + +> Vertical map depth (multiple floors, stairs, elevators) is deferred. Labels like "Sublevel 3 cache" remain flavor. Multi-level is a natural extension once persistent locations and Cyberspace are stable — potentially Phase 4. + +**Status of that deferral condition (June 2026):** + +- **Persistent locations** — shipped (Phase 2.5 M7: `LocationSite`, `mutationDeltas`, `seenKeys`, revisit merge). +- **Cyberspace** — not yet stable (Phase 3 in flight). +- **Simulation / mapgen / UI** — still single 2D grid throughout. + +--- + +## Short answer + +Multi-floor maps are **feasible but cross-cutting** — roughly half a phase, not a weekend. + +| Scope | Rough effort | What you get | +|-------|--------------|--------------| +| **Thin MVP** | ~2–3 focused weeks | 2 floors, hand-linked prefabs, stair/elevator interactable, per-floor fog, save/load | +| **Shippable vertical maps** | ~5–8 weeks | Procgen multi-floor, connectivity validation, recon/dual-site across floors, site memory per floor, content | +| **Full vision + Phase 3 interplay** | Phase 4-sized | Above + Cyberspace/Meatspace sync rules, mixed-floor AI policy, tower contracts, balance pass | + +--- + +## What exists today + +Combat is **one flat `Grid` per run**, wired through every layer: + +``` +buildMap() → single Grid + → World { grid, entities } + → Pathfinding / LOS / Vision / Combat + → RunSnapshot + LocationSite + → frame.ts → AsciiRenderer +``` + +Key facts: + +- [`Grid`](../src/game/Grid.ts): `width × height` flat `Uint8Array`; no layer index. +- [`GridPoint`](../src/types.ts): `{ x, y }` only; coord keys are `"x,y"` everywhere ([`mapConnectivity.ts`](../src/game/mapConnectivity.ts), [`Vision.ts`](../src/game/Vision.ts), [`locations.ts`](../src/game/locations.ts)). +- [`World`](../src/game/World.ts): one `grid`, `entityAt(x,y)`, `blockerKeys()` as `"x,y"` set. +- [`Run.enterCombat`](../src/game/Run.ts): `buildMap()` once → spawn everything on that grid. +- **"Sublevel 3"** is flavor only ([`LocationSite.site`](../src/types.ts)); `DUAL_SITE` = two pads on the **same** floor. +- **Doors** are the only connectivity gate today; no stair/elevator tile or prefab anchor type. +- **Renderer**: one tactical canvas; camera centers on player `(x,y)` ([`frame.ts`](../src/render/frame.ts)). + +There is **no latent z-axis** to flip on — this is a foundational coordinate-system change. + +--- + +## What got easier since the deferral + +Real seams that reduce incremental cost (not multi-floor themselves): + +| Seam | Why it helps | +|------|--------------| +| **Site memory** (`LocationSite`: `mutationDeltas`, `seenKeys`) | Per-floor deltas/seen keys are a schema extension, not greenfield | +| **Doors + terminals** | Pattern for gated transitions; stairs/elevators fit `Interactable` | +| **`Entity.passable`** ([`Entity.ts`](../src/game/Entity.ts)) | Comment already reserves floor signs / location markers | +| **`mapConnectivity`** | Reusable per-floor; extend for "spawn → stairs → exit on floor 2" | +| **Dual-site / recon objectives** | Multi-anchor logic exists; needs floor-aware keys | +| **Hub vs combat grids** ([`SafeSpace.ts`](../src/game/hub/SafeSpace.ts)) | Precedent for "swap active grid" at scene boundaries | + +--- + +## Architectural fork (decide first) + +### Option A — Multiple grids + active floor index (recommended) + +```typescript +type FloorId = number; +World { + floors: Map; + activeFloor: FloorId; + entities: Map; +} +``` + +- **Pros:** Each floor keeps current map sizes (24×16); fog/pathfinding/LOS stay floor-local; matches "Sublevel 3" fiction. +- **Cons:** Floor transitions are explicit; cross-floor queries need `floor` on every entity/coord key. + +### Option B — One mega-grid with floor as metadata + +- **Pros:** Minimal change to `Grid` API. +- **Cons:** Breaks current size caps, camera, and "tower" feel; coord collisions; worse for persistence and revisit. + +**Recommendation:** Option A — matches how Hub already treats combat as a separate grid. + +--- + +## Systems touched (breadth) + +Roughly **~35–45 production modules** and **~45 test files** assume single-floor `(x,y)`: + +| Layer | Files / areas | Work | +|-------|---------------|------| +| **Core model** | `Grid`, `World`, `types.ts`, `Entity`, `coordKey` helpers | Add `floor`; scope occupancy, blockers, movement | +| **Simulation** | `Pathfinding`, `LineOfSight`, `Vision`, `Combat`, `slide`, `knockback`, `Smoke`, `mapConnectivity` | All queries scoped to `activeFloor` (or entity's floor) | +| **AI** | All `src/game/ai/*`, `PatrolHostile`, `corpTurnDriver` | Design decision: simulate off-floor hostiles or freeze them? | +| **Run lifecycle** | `Run.enterCombat`, `placement.ts`, objective placement, exit detection in `index.ts` | Multi-floor spawn, floor-aware exit, transition hooks | +| **Mapgen** | [`mapBuild.ts`](../src/game/procgen/mapBuild.ts), prefabs, BSP | Largest greenfield chunk: per-floor layout + vertical links | +| **Persistence** | `RunSnapshot`, `persistence.ts`, `Campaign`, `locations.ts` | `{ floor, x, y }` keys; migration for old saves | +| **Input / shell** | `applyIntent.ts`, describe/look cursor, combat HUD | New intent: use stairs/elevator; floor indicator in HUD | +| **Render** | `frame.ts`, `AsciiRenderer`, `cameraFor` | Show one floor at a time; optional floor label ("Sublevel 2") | + +[`coordKey`](../src/game/mapConnectivity.ts) appears in ~8 modules directly; ad-hoc `` `${x},${y}` `` strings appear in ~20+ more (AI blockers, frame entity index, vision seen sets). + +--- + +## Suggested milestone breakdown + +### M0 — Design locks (~2–3 days) + +Decisions that change implementation size: + +1. **Off-floor hostiles:** frozen vs full sim vs "dormant until player enters floor" +2. **Transition cost:** 1 AP interact? free? elevator requires keycard? +3. **Cross-floor LOS/noise/alarm:** none (simplest) vs muffled vs full +4. **Recon scope:** per-floor fog reset vs unified site memory +5. **Max floors per contract:** 2 for MVP vs N + +### M1 — Foundation (~1 week) + +- `FloorId` + `LocatedPoint { floor, x, y }` +- `coordKey(floor, x, y)` — single helper, replace ad-hoc strings +- `World` multi-grid + `activeFloor` + floor-scoped `entityAt` / `canMoveEntity` +- Snapshot schema v2 with save migration (default `floor: 0`) + +### M2 — Floor transitions (~3–5 days) + +- `Stairs` / `Elevator` interactable: `{ targetFloor, targetX, targetY }` +- Player transition in `applyIntent` / `World.relocateEntity` +- Reset or fork fog episode on floor change (`VisionField.resetFogState` pattern already exists) + +### M3 — Render + HUD (~3–5 days) + +- Frame builder reads `world.activeFloor` grid only +- HUD floor label; combat log copy ("descends to Sublevel 3") +- Input: interact on stair glyph when adjacent + +### M4 — Mapgen (thin vs full) + +**Thin (~1 week):** hand-authored 2-floor prefab pairs linked by fixed stair anchors; `buildMap` returns `{ floors: Grid[], links: FloorLink[] }`. + +**Full (~2–3 weeks):** BSP per floor, stair/elevator placement, `mapIsFullyConnectedFromSpawn` extended to multi-floor graph, prefab schema for vertical-link glyphs. + +### M5 — Objectives + site memory (~1 week) + +- Recon: `mapSeen` keys include floor +- Retrieve/cache on floor 2; exit on floor 1 +- `LocationSite.seenKeys` / `mutationDeltas` floor-aware merge on extract/revisit + +### M6 — AI policy + tests (~1–2 weeks) + +- Pick off-floor behavior; update patrol paths per floor +- Regression: pathfinding, LOS, recon, doors, breach restore, persistence round-trip +- Procgen connectivity sweep per floor count + +--- + +## Lower-cost alternatives (no multi-floor) + +Ways to honor "Sublevel 3" **flavor** without vertical simulation: + +- **Hidden pocket / false wall room** on one map (already supported by prefabs + doors) +- **Separate contracts** for "Sublevel 2" vs "Sublevel 3" as `LocationSite` revisits with different seeds +- **Taller 2D maps** (28×16, 30×18) for sniper verticality — already in [phase-2.7-plan.md](phase-2.7-plan.md) + +These do **not** deliver floor-switching tactics but cover some narrative gap at ~0 incremental architecture cost. + +--- + +## Sequencing + +**Why wait for Phase 3 (or do MVP in parallel):** + +- Phase 3 adds **dual-control attention** — multi-floor Meatspace increases cognitive load while Cyberspace is also demanding. +- No blueprint spec for cross-layer floor sync. +- Phase 2.9 deferred **mixed encounters** for similar complexity reasons. + +**Why you could start now:** + +- Location persistence is ready — the original deferral condition is partially met. +- "Sublevel 3 cache" and recon objectives would finally match their names. +- M1–M3 are mostly orthogonal to Cyberspace if kept Meatspace-only. + +Suggested order: **Phase 3 Cyberspace → multi-floor MVP → full vertical maps**. + +If the goal is *"Sublevel 3 reads true in combat"* soon, start with **M0 design locks + M1 foundation + hand-authored 2-floor prefab** before investing in full procgen vertical connectivity. + +--- + +## Risks + +| Risk | Severity | +|------|----------| +| Save migration / corrupt snapshots | High — tier-1 boundary territory per [AGENTS.md](../AGENTS.md) | +| Recon soft-lock across floors | Medium — `mapConnectivity` must validate full graph | +| Off-floor AI edge cases | Medium — alarm propagation, turret LOS across floors | +| Test churn | Medium — ~45 test files construct flat grids | +| Scope creep into Phase 3 | High — if Cyberspace needs "jack into floor-2 terminal" semantics | + +--- + +## Bottom line + +Multi-floor is **not blocked by missing persistence anymore**, but it **is still a foundational coordinate-system change** touching mapgen, simulation, persistence, render, and input. A disciplined **2-floor MVP** is ~**2–3 weeks**; making it feel like a first-class Kernel Panic system (procgen, objectives, revisit memory, AI policy) is ~**5–8 weeks** — comparable to a slice the size of Phase 2.5 M6–M7. diff --git a/index.html b/index.html index cae9c73..e57ea0d 100644 --- a/index.html +++ b/index.html @@ -46,13 +46,23 @@

Kernel Panic

- +
+ + +