Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
1df7aac
planning docs
facerix Jun 7, 2026
49efaf2
campaign arc stage foundation
facerix Jun 8, 2026
42deb13
Decker archetype
facerix Jun 8, 2026
a5e7943
transitions and score target identification
facerix Jun 8, 2026
0e99137
savestate editor
facerix Jun 8, 2026
afc548a
campaign arc reveal in the hub
facerix Jun 9, 2026
e31008c
plan status fix
facerix Jun 9, 2026
3aa139e
fix hub reveal gating on save reload
facerix Jun 9, 2026
39499b1
Finish campaign arc narrative beats
facerix Jun 9, 2026
660fd02
dedicated GameOver component
facerix Jun 10, 2026
c657648
plan updates
facerix Jun 10, 2026
e4fb86c
plan tweaks for M4
facerix Jun 10, 2026
b4be1e7
P3.M3.1: Cyberspace contract flag + deployment gates
facerix Jun 10, 2026
3719fdc
P3.M3.2: jack-in point — the Meatspace door into Cyberspace
facerix Jun 10, 2026
96afb47
P3.M3 working plan: scope decisions, slice progress, S3 worklist
facerix Jun 10, 2026
76841a0
lint
facerix Jun 10, 2026
39b357e
P3.M3.3: Cyberspace layer — grid, avatar, jack-in/out core, persistence
facerix Jun 10, 2026
5f90d44
P3.M3 plan: S3 shipped — progress row, implementation notes, deviations
facerix Jun 10, 2026
3923149
P3.M3.4: data node objective — slice targets, satisfaction, NODES chip
facerix Jun 10, 2026
0489345
P3.M3 plan: S4 shipped — progress row + implementation notes
facerix Jun 10, 2026
fab3bcb
P3.M3 S5: voluntary jack-out finalized — LINK BURNED latch on the port
facerix Jun 10, 2026
2e3db6a
P3.M3 plan: S5 shipped — progress row + implementation notes
facerix Jun 10, 2026
0a46878
P3.M3.5: Probe ICE — trace flare, pack convergence, the grid bites back
facerix Jun 10, 2026
27ab16f
P3.M3 plan: S6 shipped — progress row, notes, ICE-faction follow-up flag
facerix Jun 10, 2026
dd17cb8
P3.M3.6 S7: render/input swap — the shell enters the grid
facerix Jun 11, 2026
3c693dc
P3: The Score is a cyber run — DATA_NODE_SLICE objective
facerix Jun 11, 2026
2d668bd
P3.M3 S7.5: early jack-out confirmation — the link burn warns first
facerix Jun 11, 2026
f35cf25
P3.M3 plan: S7/S7.5 shipped, S8 wrap-up — first playable slice complete
facerix Jun 11, 2026
8c56fc0
cyberspace tuning
facerix Jun 14, 2026
7504236
docs cleanup
facerix Jun 14, 2026
9d22e50
PIP meatspace window
facerix Jun 14, 2026
6293413
refactor shell code
facerix Jun 14, 2026
0f0b52c
tune heat vs score reveal
facerix Jun 14, 2026
348ad8a
tweak reveal pacing
facerix Jun 14, 2026
7915e60
fix build
facerix Jun 15, 2026
905e3df
tweak cyberspace palette
facerix Jun 15, 2026
4019bfe
whoops fix cyber tileset test
facerix Jun 15, 2026
26355a0
dual-deploy foundation
facerix Jun 15, 2026
2ec17b9
dual-deploy spawn
facerix Jun 15, 2026
07139f3
The Flip!
facerix Jun 15, 2026
6d71c96
tune flip mechanics
facerix Jun 15, 2026
150b642
fix partner death issues
facerix Jun 15, 2026
5624a38
Simstim PIP and cyber styling
facerix Jun 16, 2026
8915b28
gah prettier
facerix Jun 16, 2026
e35b48e
lint
facerix Jun 17, 2026
b60dc63
3 jack-out routes
facerix Jun 17, 2026
b336aff
the score (mvp)
facerix Jun 25, 2026
2ff0cf7
plan revision
facerix Jun 25, 2026
d172e1b
score targets m1
facerix Jun 25, 2026
6897503
new scoreable gear targets
facerix Jun 25, 2026
d7f539a
Finn shows acquired scores
facerix Jun 25, 2026
b924d08
add score targets to The Score jobs
facerix Jun 25, 2026
4c03436
persist score blueprints to history
facerix Jun 25, 2026
347cf8b
finish score blueprints milestone
facerix Jun 25, 2026
cc43e02
chronicle! and we're done with phase 3!
facerix Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
541 changes: 541 additions & 0 deletions components/ChronicleArchive.ts

Large diffs are not rendered by default.

74 changes: 66 additions & 8 deletions components/ContractSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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('<contract-select>.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('<contract-select>.setScorePrincipalId requires a principal id or null');
}
this.#scorePrincipalId = principalId;
if (this.#ready) this.#render();
}

show() {
this.setAttribute('open', '');
queueMicrotask(() => this.#focusSelected());
Expand Down Expand Up @@ -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', '');
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
113 changes: 14 additions & 99 deletions components/CrashDump.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
/**
* <crash-dump> — 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.
* <crash-dump> — 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
* `<game-over>` and never pass through this per-job screen.
*
* *** KERNEL PANIC ***
* fault: unhandled_exception_in_meatspace
Expand All @@ -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 <touch-pad> / <run-briefing>.
Expand All @@ -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);
Expand All @@ -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);
}
Expand Down Expand Up @@ -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'
Expand All @@ -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 ? '<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) <killed>"
// We pack synthetic stack frames so the panic reads like a real backtrace.
const lines = [];
if (telemetry.outcome === 'death') {
lines.push({
Expand All @@ -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: '<ok>' });
lines.push({ text: ` 0x02 world::move_entity(${archetype})`, tag: '' });
if (Number.isInteger(telemetry.kills) && Number(telemetry.kills) > 0) {
Expand Down Expand Up @@ -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('<crash-dump>.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(`<crash-dump>: unknown outcome "${outcome}"`);
}
if (campaignTerminal && outcome !== 'death') {
throw new Error('<crash-dump>: campaignTerminal is only valid with outcome "death"');
}
if (outcome === 'campaign-over' && !Array.isArray(telemetry.crewRoster)) {
throw new TypeError('<crash-dump>: campaign-over requires crewRoster array');
}
this.#telemetry = { ...telemetry };
this.setAttribute('outcome', outcome);
if (this.#ready) this.#render();
Expand All @@ -355,26 +288,16 @@ 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 {
this.#els.title.textContent = exitTitle();
this.#els.fault.textContent = exitFault();
}

const newRunLabel =
isCampaignOver || isCampaignTerminalDeath ? '[ NEW CAMPAIGN ]' : '[ RETURN TO HUB ]';
this.#els.newRunBtn.textContent = newRunLabel;

// Build trace with `<killed>` highlight.
const lines = buildTraceLines(t);
this.#els.trace.replaceChildren();
lines.forEach((line, i) => {
Expand All @@ -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<string, unknown> = {}) {
Expand Down
Loading
Loading