From 1df7aac8a7f7d76ad1d3f58dd00f4d9b94e59950 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Sun, 7 Jun 2026 16:42:31 -0700 Subject: [PATCH 01/55] planning docs --- docs/kaizen.md | 4 +- docs/phase-3-plan.md | 72 ++++++++++++-- docs/phase-4-notes.md | 213 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 9 deletions(-) create mode 100644 docs/phase-4-notes.md diff --git a/docs/kaizen.md b/docs/kaizen.md index 7a974a0..141efe1 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.** 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. **Planning updated:** see [phase-3-plan.md](phase-3-plan.md) P3.M3.1–P3.M3.6 for the first playable "enter Cyberspace" path (contract flag → jack-in terminal → serializable cyber layer → data node → Probe ICE → render swap), and P3.M4.1–P3.M4.6 for dual-deploy / flip integration. +- **Decker archetype.** Cyberspace specialist. Deferred alongside the Matrix layer — design the environment before designing who navigates it. **Planning updated:** see [phase-3-plan.md](phase-3-plan.md) P3.M2 for recruitment through progressive Hub reveal, normal roster ownership, drone override, and the rule that Cyberspace-required deployments fail loudly without a living Decker. - **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*. diff --git a/docs/phase-3-plan.md b/docs/phase-3-plan.md index d4dda67..6caeb6e 100644 --- a/docs/phase-3-plan.md +++ b/docs/phase-3-plan.md @@ -89,10 +89,10 @@ 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`), run count, and arc-specific flags (`deckerRecruited`, `scoreRevealed`, `clockStarted`, `scoreAttempted`, `scoreCompleted`). 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. + - Act 1 → Act 2: reach top rep tier + minimum successful job count (recommended: `completedJobs >= 4`). Triggers Score reveal and Decker recruitment opportunity. + - Act 2 → Act 3: Decker recruited + Score target site visited at least once + Clock threshold (recommended: `completedJobs >= 9`). 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. - **Arc-aware Curator:** Current code already passes through `arcStage`; P3.M1 must make it behaviorally meaningful: @@ -108,14 +108,33 @@ Score-target sites always use roster-stored dimensions (P2.7.M1.5: `mapWidth`, ` **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. **Recommended first implementation:** combine escalating global difficulty as soft pressure with an operational window as the hard campaign deadline. It is easy to communicate, test, and tune: + +- `clockStartsAtJob = 8` +- `scoreDeadlineJob = 13` +- `heat = max(0, completedJobs - clockStartsAtJob)` +- Each point of heat nudges contract threat / alarm sensitivity upward, capped by difficulty tier. +- If the player returns to Hub with `completedJobs >= scoreDeadlineJob` and `scoreAttempted` is false, campaign ends with clock loss. + +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 | Change | Tests | +|---|---|---| +| **P3.M1.1 Arc record** | 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** | Advance acts from `rep`, `completedJobs`, Decker flag, Score-site visit | boundary tests around job counts and rep tier | +| **P3.M1.3 Score target** | Promote one roster site to `tier: 'score'`; preserve through eviction | exactly-one target, no eviction at roster cap, synthetic target when roster empty | +| **P3.M1.4 Clock** | Start heat after threshold; hard loss at deadline; show status | heat math, deadline loss, no loss after Score attempt | +| **P3.M1.5 Curator bias** | Pass campaign-derived arc context; bias board slots by act and score target | seeded boards show expected `arcStage` and target-site frequency | +| **P3.M1.6 Score entry** | Hub action creates the special Score contract in Act 3 only | availability gates, deployment path, attempted flag | **Acceptance:** @@ -141,6 +160,8 @@ Implementation notes TBD after Clock type is chosen. Multiple types may coexist - **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. - **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:** @@ -160,7 +181,7 @@ Implementation notes TBD after Clock type is chosen. Multiple types may coexist **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 grid:** Separate `Grid` / `World` instance for the digital layer. **First implementation should reuse the existing square grid engine** with a distinct tileset and generation rules, then reserve a later graph-topology refactor only if the square grid fails the feel test. This keeps pathfinding, rendering, snapshots, and tests inside known machinery. - **Cyberspace tileset / aesthetic:** Distinct from Meatspace. Nodes, data lines, firewalls, open channels. ASCII glyphs TBD but visually differentiated (color palette, glyph set, CRT effects). - **ICE hostiles:** Three types per blueprint: - **Probe:** Sentry / patrol. Detects the Decker, raises alert (Cyberspace alarm analog). @@ -171,6 +192,31 @@ Implementation notes TBD after Clock type is chosen. Multiple types may coexist - **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). +**Entering Cyberspace — first playable slice:** + +The first jack-in should prove the door between layers before shipping every ICE behavior: + +1. Add a `requiresCyberspace` / `cyberspaceObjective` contract param for Act 2+ jobs, generated only when the Decker has been recruited. +2. Place a Meatspace jack-in terminal using the existing `Terminal` / interactable placement path, distinct in label from ordinary terminal-slice props. +3. When the Decker interacts with the jack-in terminal, create a `cyberspace` run layer with: + - generated grid and seed metadata, + - Decker digital avatar, + - one data node objective, + - at least one Probe ICE. +4. Latch a run state like `cyberspace.active = true`; repeated jack-in attempts against the same terminal throw or log a deterministic "already linked" message depending on whether state is corrupt or just redundant input. +5. Saving mid-jack-in restores both Meatspace and Cyberspace. Absent or malformed Cyberspace snapshot data for an active jack-in is tier-1 corrupt state and must throw to the boundary. + +**Suggested slice order:** + +| Slice | Change | Tests | +|---|---|---| +| **P3.M3.1 Contract flag** | Add Cyberspace-capable contract metadata and validation | generated only Act 2+, invalid flag/params throw | +| **P3.M3.2 Jack-in terminal** | Place a terminal that can start the digital layer | deterministic placement, no collision with objective props | +| **P3.M3.3 Cyber layer model** | Add serializable `Run.cyberspace` layer with grid/world/avatar | snapshot round-trip, active-layer invariants | +| **P3.M3.4 Data node objective** | Slice one data node and feed objective satisfaction | incomplete blocks clean extraction, complete allows it | +| **P3.M3.5 Probe ICE** | Minimal ICE patrol/detect/attack loop | seeded movement, detection/alarm, damage/death | +| **P3.M3.6 Render swap** | Render Cyberspace when active; Meatspace remains reachable for P3.M4 | browser smoke and console-clean verification | + **Acceptance:** - Cyberspace grid renders distinctly from Meatspace. @@ -193,13 +239,25 @@ Implementation notes TBD after Clock type is chosen. Multiple types may coexist - **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"). - **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. +- **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 | Change | Tests | +|---|---|---| +| **P3.M4.1 Dual deploy pre-jack** | Select Meatspace operator + Decker; both begin in Meatspace | deployment gates, placement, no Decker = no Cyberspace contract | +| **P3.M4.2 Jacked body anchor** | Decker body becomes immobile target at terminal after jack-in | body targetable, movement rejected, death flatlines Decker | +| **P3.M4.3 Flip command** | Free action swaps active input layer | input routed to active layer only, inactive holds position | +| **P3.M4.4 Dual hostile phase** | End turn advances corp and ICE phases once each | deterministic order, both layers tick, no double AP refresh | +| **P3.M4.5 PIP** | Inactive layer mini-render + status summary | desktop/mobile layout, no input capture | +| **P3.M4.6 Jack-out** | Voluntary and forced jack-out transitions back to Meatspace | cleanup, objective failure rules, snapshot round-trip | **Acceptance:** 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. From 49efaf2ac225b7371b4fe0909b44e79ecd6b8fdb Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Sun, 7 Jun 2026 18:33:57 -0700 Subject: [PATCH 02/55] campaign arc stage foundation --- docs/phase-3-plan.md | 20 +++--- src/game/Campaign.ts | 65 ++++++++++++++++++- src/game/hub/Curator.ts | 14 ++-- src/game/persistence.ts | 10 ++- src/types.ts | 3 + tests/unit/game/Campaign.test.ts | 106 +++++++++++++++++++++++++++++++ 6 files changed, 199 insertions(+), 19 deletions(-) diff --git a/docs/phase-3-plan.md b/docs/phase-3-plan.md index 6caeb6e..190c0c7 100644 --- a/docs/phase-3-plan.md +++ b/docs/phase-3-plan.md @@ -41,7 +41,7 @@ A new **player archetype** recruited mid-campaign (late Act 1 / start of Act 2), | Milestone | Status | |---|---| -| P3.M1 — Campaign arc structure | 🔲 Planned | +| P3.M1 — Campaign arc structure | 🚧 In progress — P3.M1.1 done | | P3.M2 — The Decker archetype | 🔲 Planned | | P3.M3 — Cyberspace grid + ICE | 🔲 Planned | | P3.M4 — Simstim flip (dual-deploy) | 🔲 Planned | @@ -127,14 +127,16 @@ Neural degradation is deferred until Cyberspace is fun enough to deserve a jack- **Implementation slices:** -| Slice | Change | Tests | -|---|---|---| -| **P3.M1.1 Arc record** | 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** | Advance acts from `rep`, `completedJobs`, Decker flag, Score-site visit | boundary tests around job counts and rep tier | -| **P3.M1.3 Score target** | Promote one roster site to `tier: 'score'`; preserve through eviction | exactly-one target, no eviction at roster cap, synthetic target when roster empty | -| **P3.M1.4 Clock** | Start heat after threshold; hard loss at deadline; show status | heat math, deadline loss, no loss after Score attempt | -| **P3.M1.5 Curator bias** | Pass campaign-derived arc context; bias board slots by act and score target | seeded boards show expected `arcStage` and target-site frequency | -| **P3.M1.6 Score entry** | Hub action creates the special Score contract in Act 3 only | availability gates, deployment path, attempted flag | +| 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** | 🔲 Planned | Advance acts from `rep`, `completedJobs`, Decker flag, Score-site visit | boundary tests around job counts and rep tier | +| **P3.M1.3 Score target** | 🔲 Planned | Promote one roster site to `tier: 'score'`; preserve through eviction | exactly-one target, no eviction at roster cap, synthetic target when roster empty | +| **P3.M1.4 Clock** | 🔲 Planned | Start heat after threshold; hard loss at deadline; show status | heat math, deadline loss, no loss after Score attempt | +| **P3.M1.5 Curator bias** | 🔲 Planned | Pass campaign-derived arc context; bias board slots by act and score target | seeded boards show expected `arcStage` and target-site frequency | +| **P3.M1.6 Score entry** | 🔲 Planned | Hub action creates the special Score contract in Act 3 only | availability gates, deployment path, attempted flag | + +**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. **Acceptance:** diff --git a/src/game/Campaign.ts b/src/game/Campaign.ts index 89bbaac..86882e0 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -37,7 +37,7 @@ import { } from './locations.js'; import type { Contract } from './hub/Curator.js'; import type { Crew } from './Crew.js'; -import type { GridPoint, KeyItem, LocationSite, TileDelta } from '../types.js'; +import type { CampaignArcStage, GridPoint, KeyItem, LocationSite, TileDelta } from '../types.js'; import type { RunResult, Outcome } from './Run.js'; /** Max remembered combat locations (P2.5.M7.2). One slot is reserved for Phase 3's score target. */ @@ -57,6 +57,28 @@ export type CampaignState = (typeof CAMPAIGN_STATE)[keyof typeof CAMPAIGN_STATE] // type is a plain Record so they don't cause a type error on restore. export type CampaignMeta = Record; +export type CampaignArc = { + arcStage: CampaignArcStage; + deckerRecruited: boolean; + scoreRevealed: boolean; + clockStarted: boolean; + scoreAttempted: boolean; + scoreCompleted: boolean; +}; + +const CAMPAIGN_ARC_STAGES: readonly CampaignArcStage[] = ['act-1', 'act-2', 'act-3', 'score']; + +export function defaultCampaignArc(): CampaignArc { + return { + arcStage: 'act-1', + deckerRecruited: false, + scoreRevealed: false, + clockStarted: false, + scoreAttempted: false, + scoreCompleted: false, + }; +} + export type CampaignOptions = { id?: string; seed?: unknown; @@ -65,6 +87,7 @@ export type CampaignOptions = { credits?: unknown; rep?: unknown; meta?: unknown; + arc?: unknown; hubReveals?: unknown; completedJobs?: unknown; keyItems?: unknown; @@ -116,6 +139,7 @@ export class Campaign { credits: number; rep: number; meta: CampaignMeta; + arc: CampaignArc; state: CampaignState; activeRun: Run | null; deployedMemberId: string | null; @@ -153,6 +177,7 @@ export class Campaign { credits = 0, rep = REP.START, meta = {}, + arc, hubReveals, completedJobs = 0, keyItems, @@ -205,6 +230,7 @@ export class Campaign { this.credits = credits; this.rep = rep; this.meta = { ...(meta as CampaignMeta) }; + this.arc = normalizeCampaignArc(arc); this.hubReveals = normalizeHubReveals(hubReveals, 'Campaign hubReveals'); this.completedJobs = (completedJobs as number) ?? 0; this.keyItems = normalizeKeyItems(keyItems); @@ -240,6 +266,10 @@ export class Campaign { } } + get arcStage(): CampaignArcStage { + return this.arc.arcStage; + } + enterHub(): void { if (this.state !== CAMPAIGN_STATE.HUB && this.state !== CAMPAIGN_STATE.COMBAT) { throw new Error(`Campaign.enterHub: illegal transition from ${this.state}`); @@ -1007,6 +1037,39 @@ function normalizeSiteRoster(raw: unknown): LocationSite[] { return raw.map(entry => normalizeLocationSite(entry)); } +export function isCampaignArcStage(value: unknown): value is CampaignArcStage { + return typeof value === 'string' && CAMPAIGN_ARC_STAGES.includes(value as CampaignArcStage); +} + +export function normalizeCampaignArc(raw: unknown, context = 'Campaign arc'): CampaignArc { + if (raw === undefined) return defaultCampaignArc(); + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new TypeError(`${context} must be an object when supplied`); + } + + const candidate = raw as Partial>; + if (!isCampaignArcStage(candidate.arcStage)) { + throw new Error(`${context}.arcStage "${candidate.arcStage}" is not known`); + } + + const readBooleanArcFlag = (flag: keyof Omit): boolean => { + const value = candidate[flag]; + if (typeof value !== 'boolean') { + throw new TypeError(`${context}.${flag} must be boolean`); + } + return value; + }; + + return { + arcStage: candidate.arcStage, + deckerRecruited: readBooleanArcFlag('deckerRecruited'), + scoreRevealed: readBooleanArcFlag('scoreRevealed'), + clockStarted: readBooleanArcFlag('clockStarted'), + scoreAttempted: readBooleanArcFlag('scoreAttempted'), + scoreCompleted: readBooleanArcFlag('scoreCompleted'), + }; +} + function makeCampaignId(seed: number): string { return `campaign-${(seed >>> 0).toString(16)}-${Date.now().toString(36)}`; } diff --git a/src/game/hub/Curator.ts b/src/game/hub/Curator.ts index 15c65d2..b789625 100644 --- a/src/game/hub/Curator.ts +++ b/src/game/hub/Curator.ts @@ -22,7 +22,7 @@ import { resolveMapDimensions } from '../procgen/mapDimensions.js'; import type { Rng } from '../../rng.js'; import type { EntityInit } from '../Entity.js'; import type { ContractDifficulty } from '../constants.js'; -import type { LocationSite, LocationToken } from '../../types.js'; +import type { CampaignArcStage, LocationSite, LocationToken } from '../../types.js'; export const OBJECTIVES = Object.freeze({ REACH_EXIT: 'reach-exit', @@ -46,8 +46,6 @@ export type ContractObjective = { params?: ObjectiveParams; }; -export type ContractArcStage = 'act-1' | 'act-2' | 'act-3' | 'score'; - export type ContractContextToken = { id: string; label: string; @@ -62,7 +60,7 @@ export type ContractContext = { asset: ContractContextToken; action: ContractContextToken; tags: string[]; - arcStage?: ContractArcStage; + arcStage?: CampaignArcStage; /** * References a `LocationSite.id` in the campaign roster when the Curator * targets a remembered site for a revisit (P2.5.M7.2). Distinct from the @@ -73,7 +71,7 @@ export type ContractContext = { }; type ContractRecipeContext = { - arcStage?: ContractArcStage; + arcStage?: CampaignArcStage; }; type ContractToken = { @@ -489,7 +487,7 @@ type ContractCampaign = | { rep?: number; meta?: Record; - arcStage?: ContractArcStage | null; + arcStage?: CampaignArcStage | null; /** Remembered combat locations the Curator may steer revisits toward (P2.5.M7.2). */ siteRoster?: LocationSite[]; } @@ -754,7 +752,7 @@ export function buildContractRecipeFixture({ actionId: string; difficulty: ContractDifficulty; seed: number; - arcStage?: ContractArcStage; + arcStage?: CampaignArcStage; }): Contract { if (!isContractDifficulty(difficulty)) { throw new Error(`Curator fixture: unknown difficulty "${difficulty}"`); @@ -895,7 +893,7 @@ function contractRecipeContext(campaign: ContractCampaign): ContractRecipeContex return arcStage ? { arcStage } : {}; } -function isArcStage(value: unknown): value is ContractArcStage { +function isArcStage(value: unknown): value is CampaignArcStage { return value === 'act-1' || value === 'act-2' || value === 'act-3' || value === 'score'; } diff --git a/src/game/persistence.ts b/src/game/persistence.ts index 34aeccf..98f018e 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -73,7 +73,7 @@ import { KeyCard } from './entities/KeyCard.js'; import { BreachingCharge } from './entities/BreachingCharge.js'; import type { BreachingChargeInit } from './entities/BreachingCharge.js'; import { Run, RUN_STATE, PATROL_ARCHETYPE_IDS } from './Run.js'; -import { Campaign, CAMPAIGN_STATE } from './Campaign.js'; +import { Campaign, CAMPAIGN_STATE, normalizeCampaignArc } from './Campaign.js'; import { normalizeContractContext, normalizeObjective } from './hub/Curator.js'; import { migrateLegacyHubReveals, @@ -97,6 +97,7 @@ import type { ConsumablePickupInit } from './entities/ConsumablePickup.js'; import type { EscortNpcInit } from './entities/EscortNpc.js'; import type { KeyCardInit } from './entities/KeyCard.js'; import type { EntityInit } from './Entity.js'; +import type { CampaignArc } from './Campaign.js'; import type { FactionId } from './constants.js'; import type { CrewArchetypeId, @@ -710,6 +711,8 @@ export type CampaignSnapshot = { credits?: number; rep: number; meta: CampaignMeta; + /** Phase 3 campaign arc state. Defaults to Act 1 for pre-P3 saves. */ + arc?: CampaignArc; deployedMemberId: string | null; activeRun: CampaignActiveRunSnapshot | null; /** Recruit candidates available this hub visit. Defaults to [] for pre-P2.M6 saves. */ @@ -775,6 +778,7 @@ export function snapshotCampaign(campaign: Campaign): CampaignSnapshot { credits: campaign.credits, rep: campaign.rep, meta: { ...campaign.meta }, + arc: { ...campaign.arc }, deployedMemberId: campaign.deployedMemberId, activeRun: campaign.activeRun ? snapshotActiveRun(campaign.activeRun) : null, availableRecruits: campaign.availableRecruits.map(snapshotCrewMember), @@ -920,6 +924,7 @@ export function restoreCampaign(record: unknown, options: RestoreCampaignOptions credits: record.credits ?? 0, rep: record.rep, meta: record.meta, + arc: record.arc, hubReveals: normalizeHubReveals( migrateLegacyHubReveals(record.hubReveals, { rep: record.rep, @@ -1574,6 +1579,9 @@ function validateCampaignRecord(record: unknown): asserts record is CampaignSnap ) { throw new TypeError('restoreCampaign: meta must be an object'); } + if (candidate.arc !== undefined) { + normalizeCampaignArc(candidate.arc, 'restoreCampaign arc'); + } if (candidate.state === CAMPAIGN_STATE.COMBAT && !candidate.activeRun) { throw new Error('restoreCampaign: COMBAT state requires activeRun'); } diff --git a/src/types.ts b/src/types.ts index 10b0b2b..8695a63 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,9 @@ /** Integer tile on the tactical grid. */ export type GridPoint = { x: number; y: number }; +/** Phase 3 campaign arc stage, shared by Campaign saves and Curator contract context. */ +export type CampaignArcStage = 'act-1' | 'act-2' | 'act-3' | 'score'; + /** * A JSON-safe value. The persistence layer's opaque entity property bag * (`EntitySnapshotExtra`) is keyed to this so anything stashed in a snapshot diff --git a/tests/unit/game/Campaign.test.ts b/tests/unit/game/Campaign.test.ts index f2522ba..714d003 100644 --- a/tests/unit/game/Campaign.test.ts +++ b/tests/unit/game/Campaign.test.ts @@ -5,6 +5,7 @@ import { Campaign, CAMPAIGN_STATE, buildCrew, + defaultCampaignArc, willEndCampaignOnThisDeath, } from '../../../src/game/Campaign.js'; import { OUTCOME, RUN_STATE } from '../../../src/game/Run.js'; @@ -52,6 +53,8 @@ test('Campaign starts in HUB with crew, salvage, credits, rep, and meta state', assert.equal(campaign.credits, 0); assert.equal(campaign.rep, 20); assert.deepEqual(campaign.meta, {}); + assert.deepEqual(campaign.arc, defaultCampaignArc()); + assert.equal(campaign.arcStage, 'act-1'); assert.equal(campaign.crew.length, 3); assert.ok(campaign.world); assert.ok(campaign.player); @@ -59,6 +62,63 @@ test('Campaign starts in HUB with crew, salvage, credits, rep, and meta state', assert.ok(campaign.terminal); }); +test('Campaign accepts and exposes a valid Phase 3 arc record', () => { + const campaign = new Campaign({ + seed: 42, + arc: { + arcStage: 'act-2', + deckerRecruited: true, + scoreRevealed: true, + clockStarted: false, + scoreAttempted: false, + scoreCompleted: false, + }, + }); + + assert.equal(campaign.arcStage, 'act-2'); + assert.deepEqual(campaign.arc, { + arcStage: 'act-2', + deckerRecruited: true, + scoreRevealed: true, + clockStarted: false, + scoreAttempted: false, + scoreCompleted: false, + }); +}); + +test('Campaign rejects malformed Phase 3 arc records', () => { + assert.throws(() => new Campaign({ seed: 42, arc: null }), /arc must be an object/i); + assert.throws( + () => + new Campaign({ + seed: 42, + arc: { + arcStage: 'act-x', + deckerRecruited: false, + scoreRevealed: false, + clockStarted: false, + scoreAttempted: false, + scoreCompleted: false, + }, + }), + /arcStage/ + ); + assert.throws( + () => + new Campaign({ + seed: 42, + arc: { + arcStage: 'act-1', + deckerRecruited: false, + scoreRevealed: false, + clockStarted: false, + scoreAttempted: false, + }, + }), + /scoreCompleted/ + ); +}); + test('deployCrewMember starts a job Run for a non-flatlined crew member', () => { const campaign = new Campaign({ seed: 42 }); const member = campaign.crew[0]; @@ -524,6 +584,52 @@ test('rep survives campaign snapshot/restore round-trip', () => { assert.equal(restored.rep, 35); }); +test('campaign arc survives persistence round-trip', () => { + const campaign = new Campaign({ + seed: 42, + arc: { + arcStage: 'score', + deckerRecruited: true, + scoreRevealed: true, + clockStarted: true, + scoreAttempted: true, + scoreCompleted: false, + }, + }); + + const snap = snapshotCampaign(campaign); + const restored = restoreCampaign(snap); + + assert.deepEqual(snap.arc, campaign.arc); + assert.deepEqual(restored.arc, campaign.arc); + assert.equal(restored.arcStage, 'score'); +}); + +test('pre-P3 campaign snapshots restore with a default Act 1 arc', () => { + const campaign = new Campaign({ seed: 42 }); + const snap = snapshotCampaign(campaign) as Record; + delete snap.arc; + + const restored = restoreCampaign(snap as never); + + assert.deepEqual(restored.arc, defaultCampaignArc()); + assert.equal(restored.arcStage, 'act-1'); +}); + +test('restoreCampaign rejects corrupt Phase 3 arc snapshots', () => { + const campaign = new Campaign({ seed: 42 }); + const snap = snapshotCampaign(campaign); + + assert.throws( + () => restoreCampaign({ ...snap, arc: { ...snap.arc, arcStage: 'bad-stage' } }), + /arcStage/ + ); + assert.throws( + () => restoreCampaign({ ...snap, arc: { ...snap.arc, scoreAttempted: 'yes' } }), + /scoreAttempted/ + ); +}); + // ─── Recruitment ──────────────────────────────────────────────────────── test('generateRecruits returns 1–2 candidates when Rep ≥ 65', () => { From 42deb13f13c0f363e348a48311f724632e93eb6a Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Sun, 7 Jun 2026 19:31:40 -0700 Subject: [PATCH 03/55] Decker archetype --- docs/phase-3-plan.md | 14 +- src/game/Hostile.ts | 20 ++- src/game/Run.ts | 15 ++- src/game/ai/PatrolHostile.ts | 8 ++ src/game/archetypes/Decker.ts | 67 +++++++++ src/game/archetypes/index.ts | 20 ++- src/game/combatTurnPipeline.ts | 33 +++++ src/game/constants.ts | 18 +++ src/game/droneOverride.ts | 178 ++++++++++++++++++++++++ src/game/persistence.ts | 45 ++++++- src/input/applyIntent.ts | 74 +++++++++- tests/unit/game/Decker.test.ts | 202 ++++++++++++++++++++++++++++ tests/unit/game/archetypes.test.ts | 31 +++++ tests/unit/game/persistence.test.ts | 40 ++++++ 14 files changed, 748 insertions(+), 17 deletions(-) create mode 100644 src/game/archetypes/Decker.ts create mode 100644 src/game/droneOverride.ts create mode 100644 tests/unit/game/Decker.test.ts diff --git a/docs/phase-3-plan.md b/docs/phase-3-plan.md index 190c0c7..260c006 100644 --- a/docs/phase-3-plan.md +++ b/docs/phase-3-plan.md @@ -42,7 +42,7 @@ A new **player archetype** recruited mid-campaign (late Act 1 / start of Act 2), | Milestone | Status | |---|---| | P3.M1 — Campaign arc structure | 🚧 In progress — P3.M1.1 done | -| P3.M2 — The Decker archetype | 🔲 Planned | +| P3.M2 — The Decker archetype | 🚧 In progress — class + Drone Override Hack done; recruitment flow pending P3.M1.2 | | P3.M3 — Cyberspace grid + ICE | 🔲 Planned | | P3.M4 — Simstim flip (dual-deploy) | 🔲 Planned | | P3.M5 — The Score (climactic mission) | 🔲 Planned | @@ -167,11 +167,13 @@ Neural degradation is deferred until Cyberspace is fun enough to deserve a jack- **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: gated by arc state; not available in Act 1; golden-path test for recruitment flow. **Deferred — needs P3.M1.2 act transitions.** 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. --- diff --git a/src/game/Hostile.ts b/src/game/Hostile.ts index 10becef..44abda8 100644 --- a/src/game/Hostile.ts +++ b/src/game/Hostile.ts @@ -1,6 +1,6 @@ import { Entity, type EntityInit } from './Entity.js'; import { EscortNpc } from './entities/EscortNpc.js'; -import { FACTION, SIGHT_RANGE } from './constants.js'; +import { FACTION, SIGHT_RANGE, type FactionId } from './constants.js'; import { hasLineOfSight, withinRange } from './LineOfSight.js'; import type { Rng } from '../rng.js'; import type { TurnActionStep } from '../types.js'; @@ -20,12 +20,30 @@ export interface HostileInit extends EntityInit { export abstract class Hostile extends Entity { sightRange: number; + /** + * Decker drone-override state (P3.M2). While `overrideTurnsRemaining > 0` + * this hostile has been hijacked: its `faction` is temporarily the + * overrider's (PLAYER) and `factionBeforeOverride` records the allegiance to + * restore when the override lapses. Both default to the not-overridden state + * so every existing hostile is unaffected. The countdown is ticked once per + * player turn by `stepOverriddenDrones` (see `droneOverride.ts`). + */ + overrideTurnsRemaining: number; + factionBeforeOverride: FactionId | null; + constructor({ sightRange = SIGHT_RANGE, ...props }: HostileInit) { if (!Number.isInteger(sightRange) || sightRange < 0) { throw new RangeError(`Hostile sightRange must be a non-negative integer, got ${sightRange}`); } super(props); this.sightRange = sightRange; + this.overrideTurnsRemaining = 0; + this.factionBeforeOverride = null; + } + + /** Whether this hostile is currently flipped to the player's side. */ + get isOverridden(): boolean { + return this.overrideTurnsRemaining > 0; } abstract override takeTurn(world: World, rng: Rng): void | TurnActionStep[]; diff --git a/src/game/Run.ts b/src/game/Run.ts index 30f1e0c..75575d4 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -53,6 +53,7 @@ import { Crew } from './Crew.js'; import { Merc } from './archetypes/Merc.js'; import { Razor } from './archetypes/Razor.js'; import { Tech } from './archetypes/Tech.js'; +import { Decker } from './archetypes/Decker.js'; import { Turret } from './Turret.js'; import { Skirmisher } from './ai/Skirmisher.js'; import { Guard } from './ai/Guard.js'; @@ -136,7 +137,7 @@ const KNOWN_OUTCOMES = new Set(Object.values(OUTCOME)); export type RunState = (typeof RUN_STATE)[keyof typeof RUN_STATE]; export type Outcome = (typeof OUTCOME)[keyof typeof OUTCOME]; -export type CrewArchetypeId = 'merc' | 'razor' | 'tech'; +export type CrewArchetypeId = 'merc' | 'razor' | 'tech' | 'decker'; export type EntityArchetypeId = | CrewArchetypeId | 'turret' @@ -1602,12 +1603,19 @@ function exitTileInWorld(world: World): GridPoint | null { /** Shared patrol state-machine slice (Skirmisher, Guard, Bruiser, …). */ function patrolSnapshotExtra(e: PatrolHostile): PatrolSnapshot { - return { + const snap: PatrolSnapshot = { state: e.state, lastKnownTarget: e.lastKnownTarget ? { x: e.lastKnownTarget.x, y: e.lastKnownTarget.y } : null, patrolWaypoints: e.patrolWaypoints.map(wp => ({ x: wp.x, y: wp.y })), patrolIndex: e.patrolIndex, }; + // Only serialise override fields while the hijack is live — keeps the common + // (never-overridden) snapshot identical to its pre-P3 shape. + if (e.isOverridden) { + snap.overrideTurnsRemaining = e.overrideTurnsRemaining; + snap.factionBeforeOverride = e.factionBeforeOverride; + } + return snap; } /** Shared crew slice (Merc, Razor, Tech). Inventory/Gear are JSON-safe at runtime. */ @@ -1634,6 +1642,7 @@ const SNAPSHOT_EXTRACTORS: Partial Enti { merc: e => crewSnapshotExtra(e as Crew) as unknown as EntitySnapshotExtra, razor: e => crewSnapshotExtra(e as Crew) as unknown as EntitySnapshotExtra, + decker: e => crewSnapshotExtra(e as Crew) as unknown as EntitySnapshotExtra, tech: e => ({ ...crewSnapshotExtra(e as Crew), @@ -1757,6 +1766,7 @@ function archetypeOf(entity: Entity): EntityArchetypeId { if (entity instanceof Merc) return 'merc'; if (entity instanceof Razor) return 'razor'; if (entity instanceof Tech) return 'tech'; + if (entity instanceof Decker) return 'decker'; if (entity instanceof Turret) return 'turret'; if (entity instanceof Bruiser) return 'bruiser'; if (entity instanceof Juggernaut) return 'juggernaut'; @@ -2259,6 +2269,7 @@ function archetypeOfCrew(entity: Entity): CrewArchetypeId { if (entity instanceof Merc) return 'merc'; if (entity instanceof Razor) return 'razor'; if (entity instanceof Tech) return 'tech'; + if (entity instanceof Decker) return 'decker'; throw new Error( `archetypeOfCrew: cannot classify crew member ${(entity as Entity | undefined)?.id}` ); diff --git a/src/game/ai/PatrolHostile.ts b/src/game/ai/PatrolHostile.ts index e3eb064..d42534c 100644 --- a/src/game/ai/PatrolHostile.ts +++ b/src/game/ai/PatrolHostile.ts @@ -66,6 +66,14 @@ export type PatrolSnapshot = { lastKnownTarget: GridPoint | null; patrolWaypoints: GridPoint[]; patrolIndex: number; + /** + * Decker drone-override state (P3.M2). Present only while a hostile is + * hijacked: `overrideTurnsRemaining > 0` and `factionBeforeOverride` records + * the allegiance to restore. Omitted (and defaulted to the not-overridden + * state on restore) for the overwhelming majority of hostiles. + */ + overrideTurnsRemaining?: number; + factionBeforeOverride?: FactionId | null; }; type NoiseEventPayload = { diff --git a/src/game/archetypes/Decker.ts b/src/game/archetypes/Decker.ts new file mode 100644 index 0000000..738e314 --- /dev/null +++ b/src/game/archetypes/Decker.ts @@ -0,0 +1,67 @@ +import { Crew } from '../Crew.js'; +import { canOverride, overrideDrone } from '../droneOverride.js'; +import type { CrewInit } from '../Crew.js'; +import type { Entity } from '../Entity.js'; +import type { World } from '../World.js'; +import type { Rng } from '../../rng.js'; + +/** + * Curated callsign pool for the Decker archetype. See `Merc.js` CALLSIGNS for + * the design rationale. Decker names lean console-cowboy / matrix-jockey to + * match the Cyberspace specialist fantasy (Phase 3). + */ +export const CALLSIGNS = Object.freeze([ + 'Case', + 'Jack', + 'Flatline', + 'Blitz', + 'Is0bel', + 'Bytesize', + 'Crash', + 'Ang3l', + 'Z0ne', + 'Neo', +]); + +/** + * Decker — Cyberspace specialist (P3.M2), recruited mid-campaign rather than + * picked at start. In Meatspace they are *viable but not optimised*: baseline + * ranged kit with a slightly lower hit chance than the combat archetypes + * (Merc 0.8, Tech 0.75, Razor 0.7). Their real edge is digital — and, on the + * physical grid, the signature **Drone Override Hack**. + * + * Phase-3 perk: **Override**. Reaches across a clean LOS lane to hijack a corp + * drone's allegiance for a few turns (`droneOverride.ts`). It is a *targeted* + * perk — the intent layer resolves a drone along the aim ray, then calls + * `overrideDrone`. Cyberspace attributes (RAM, intrusion strength, ICE + * resistance) are deferred to P3.M3, when the Cyberspace grid consumes them. + */ +export class Decker extends Crew { + override archetype = 'Decker'; + + override get baseHitChance(): number { + return 0.7; + } + + constructor(props: CrewInit) { + super({ ...props, glyph: '@' }); + } + + /** + * Pre-flight legality check for overriding `target`. Returns `{ ok }` or + * `{ ok: false, reason }`, mirroring the other archetype perks. Delegates to + * the shared `droneOverride` module so the rules live in one place. + */ + canOverride(world: World, target: Entity | null) { + return canOverride(world, this, target); + } + + /** + * Commit an override against `target`. Throws on illegal pre-conditions + * (no AP burned); on a legal attempt, debits AP and either flips the drone + * or trips the alarm depending on the roll. Returns the {@link OverrideResult}. + */ + overrideDrone(world: World, target: Entity, rng: Rng) { + return overrideDrone(world, this, target, rng); + } +} diff --git a/src/game/archetypes/index.ts b/src/game/archetypes/index.ts index 11cb1b9..4a75600 100644 --- a/src/game/archetypes/index.ts +++ b/src/game/archetypes/index.ts @@ -22,22 +22,30 @@ import { Merc, CALLSIGNS as MERC_CALLSIGNS } from './Merc.js'; import { Razor, CALLSIGNS as RAZOR_CALLSIGNS } from './Razor.js'; import { Tech, CALLSIGNS as TECH_CALLSIGNS } from './Tech.js'; +import { Decker, CALLSIGNS as DECKER_CALLSIGNS } from './Decker.js'; import type { Rng } from '../../rng.js'; import type { FactionId } from '../constants.js'; import type { CrewInit } from '../Crew.js'; -export type Archetype = Merc | Razor | Tech; +export type Archetype = Merc | Razor | Tech | Decker; /** * Display order is also the starter crew order in `Campaign.buildCrew`. * Merc first so new players hit the simpler ranged archetype on first load; * Tech last since its gadget loop is the most involved kit to learn. + * + * The **Decker is deliberately absent** (P3.M2): it is a mid-campaign narrative + * recruit, never a starter pick or selector option. Its metadata still lives in + * `ARCHETYPES`/`BUILDERS` so `buildCrewMember('decker', …)` and snapshot + * round-trips work — it just isn't offered through the normal selection paths. */ export const ARCHETYPE_IDS = Object.freeze(['merc', 'razor', 'tech']); /** * Weighted archetype pool for recruitment. 40% Merc, 40% Razor, 20% Tech. * Expressed as a flat array so `rng.pick()` gives the correct distribution. + * The Decker is **not** in this pool — normal random recruitment must never + * roll one; it joins only through the Act-2 narrative beat (P3.M2 / P3.M1). */ export const RECRUIT_ARCHETYPE_POOL = Object.freeze(['merc', 'merc', 'razor', 'razor', 'tech']); @@ -73,6 +81,14 @@ export const ARCHETYPES = Object.freeze({ perkName: 'DEPLOY', perkLabel: 'Techs can DEPLOY: place a turret that will fire on enemies', }), + decker: Object.freeze({ + id: 'decker', + name: 'DECKER', + blurb: 'Console cowboy. Hijacks corp drones; jacks into Cyberspace.', + perks: Object.freeze(['override']), + perkName: 'OVERRIDE', + perkLabel: 'Deckers can OVERRIDE: hijack a corp drone to fight for you', + }), }); export type ArchetypeInfo = (typeof ARCHETYPES)[keyof typeof ARCHETYPES]; @@ -81,6 +97,7 @@ const BUILDERS = Object.freeze({ merc: Merc, razor: Razor, tech: Tech, + decker: Decker, }); /** @@ -93,6 +110,7 @@ export const CALLSIGNS_BY_ARCHETYPE = Object.freeze({ merc: MERC_CALLSIGNS, razor: RAZOR_CALLSIGNS, tech: TECH_CALLSIGNS, + decker: DECKER_CALLSIGNS, }); export function isArchetypeId(value: string) { diff --git a/src/game/combatTurnPipeline.ts b/src/game/combatTurnPipeline.ts index 0da4468..581c025 100644 --- a/src/game/combatTurnPipeline.ts +++ b/src/game/combatTurnPipeline.ts @@ -7,6 +7,8 @@ import { EVENT } from './events.js'; import { chebyshev, findPath } from './Pathfinding.js'; import { detonateBreachingCharge } from './breachBlast.js'; import { BreachingCharge } from './entities/BreachingCharge.js'; +import { stepOverriddenDrones, type OverriddenDroneAction } from './droneOverride.js'; +import type { Hostile } from './Hostile.js'; import type { BlastCasualty } from './breachBlast.js'; import type { Entity } from './Entity.js'; import type { NeutralCivilianTurnStep } from '../types.js'; @@ -65,9 +67,16 @@ export type BreachDetonateAftermathStep = { casualties: BlastCasualty[]; }; +export type OverriddenDroneAftermathStep = { + type: 'overridden-drone'; + entity: Hostile; + action: OverriddenDroneAction; +}; + export type PlayerAftermathStep = | BreachDetonateAftermathStep | TurretAftermathStep + | OverriddenDroneAftermathStep | NeutralCivilianAftermathStep | EscortAftermathStep | HazardAftermathStep; @@ -89,6 +98,9 @@ export function isPlayerAftermathStepLogVisible( } return isTileVisible(turret.x, turret.y); } + if (step.type === 'overridden-drone') { + return isTileVisible(step.entity.x, step.entity.y); + } if (step.type === 'neutral-civilian') { return isTileVisible(step.entity.x, step.entity.y); } @@ -320,6 +332,13 @@ export function* runPlayerAftermathSteps( }; } } + // Phase 1b: overridden drones act on the player's side, then their hijack + // countdown ticks and they revert when it lapses (P3.M2). They are + // player-aligned automated combatants — same aftermath slot as turrets. + for (const { entity, action } of stepOverriddenDrones(world, rng)) { + yield { type: 'overridden-drone', entity, action }; + } + // Phase 2: neutral civilian reactions (flee / panic / idle based on rep) const rep = opts?.rep ?? REP.START; for (const entity of world.entities.values()) { @@ -397,6 +416,9 @@ export function formatPlayerAftermathStepLogLines(step: PlayerAftermathStep) { const line = formatTurretAutofireLine(step.turret, step.action); return line ? [line] : []; } + if (step.type === 'overridden-drone') { + return formatOverriddenDroneLine(step); + } if (step.type === 'neutral-civilian') { return formatNeutralCivilianLine(step.entity, step.step); } @@ -530,6 +552,17 @@ function validatePlayerAftermathDriverCtx( } } +function formatOverriddenDroneLine(step: OverriddenDroneAftermathStep): string[] { + const label = entityLabel(step.entity); + if (step.action.type === 'override-expired') { + return [`${label} shakes off the override — back under corp control.`]; + } + // The drone's individual combat outcome surfaces through ENTITY_DAMAGED + // listeners (same as corp-turn steps); here we only note that the hijacked + // unit is acting on our side. + return [`${label} (overridden) acts.`]; +} + function formatNeutralCivilianLine( entity: NeutralCivilian, step: NeutralCivilianTurnStep diff --git a/src/game/constants.ts b/src/game/constants.ts index 8cf284b..61e277a 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -75,8 +75,26 @@ export const AP_COST = Object.freeze({ VAULT: 2, // Merc — hop a cover tile while firing SLIDE: 2, // Razor — 2-tile reposition with stealth bonus DEPLOY: 2, // Tech — place a turret on an adjacent tile + OVERRIDE: 2, // Decker — hijack a corp drone's allegiance }); +/** + * Decker drone-override parameters (P3.M2). The Decker's signature Meatspace + * ability flips a corp drone to the player's side for a few turns by reusing + * the existing drone AI with a faction swap (the AI targets by faction, so a + * flipped drone fights its former allies for free). + * + * - `OVERRIDE_RANGE` — reach for the intrusion, matched to baseline SIGHT so + * the Decker must have a clean LOS lane like a ranged shot. + * - `OVERRIDE_DURATION` — turns the drone stays player-aligned before its + * firmware reasserts control and it reverts to its original faction. + * - `OVERRIDE_SUCCESS_CHANCE` — probability the intrusion takes. A failed + * attempt still burns AP and trips the facility alarm. + */ +export const OVERRIDE_RANGE = 5; +export const OVERRIDE_DURATION = 3; +export const OVERRIDE_SUCCESS_CHANCE = 0.6; + /** * Tech turret parameters. The turret is a placed grid entity (peer of * `Entity`, not an archetype) deployed by Tech at `AP_COST.DEPLOY`. Tunables: diff --git a/src/game/droneOverride.ts b/src/game/droneOverride.ts new file mode 100644 index 0000000..c0a7a79 --- /dev/null +++ b/src/game/droneOverride.ts @@ -0,0 +1,178 @@ +/** + * Drone Override Hack (P3.M2) — the Decker's signature Meatspace ability. + * + * The Decker reaches out across a clean line of sight and hijacks a corp + * drone's allegiance. On success the drone's `faction` flips to the Decker's + * (PLAYER) for {@link OVERRIDE_DURATION} turns; because the existing hostile AI + * acquires targets purely by faction difference (`Hostile.isHostileTo`), a + * flipped drone immediately fights its former allies with *zero* new AI code. + * When the countdown lapses, the drone's firmware reasserts control and it + * reverts to whatever faction it held before. + * + * State lives on the hostile itself (`overrideTurnsRemaining`, + * `factionBeforeOverride`) so it round-trips through the patrol snapshot. This + * module owns the *verbs*: the legality check, the commit (with the success + * roll + alarm-on-failure), and the per-turn stepping/revert that the combat + * aftermath drives. Keeping it here mirrors `slide.ts` — the archetype class + * stays a thin delegator and the pipeline stays a thin caller. + */ + +import { Hostile } from './Hostile.js'; +import { hasLineOfSight, withinRange } from './LineOfSight.js'; +import { + AP_COST, + OVERRIDE_DURATION, + OVERRIDE_RANGE, + OVERRIDE_SUCCESS_CHANCE, + type FactionId, +} from './constants.js'; +import type { Entity } from './Entity.js'; +import type { World } from './World.js'; +import type { Rng } from '../rng.js'; +import type { TurnActionStep } from '../types.js'; + +/** Pre-flight legality verdict, mirroring the Tech/Razor/Merc perk shape. */ +export type OverrideCheck = { ok: true } | { ok: false; reason: OverrideDenyReason }; + +export type OverrideDenyReason = + | 'dead' + | 'insufficient-ap' + | 'not-overridable' + | 'dead-target' + | 'already-overridden' + | 'friendly' + | 'out-of-range' + | 'no-los'; + +/** Result of a committed override attempt. */ +export type OverrideResult = { + ok: true; + /** Whether the intrusion took. On false the alarm was raised. */ + success: boolean; + /** The success roll, surfaced for log copy / determinism tests. */ + roll: number; + alarm: boolean; +}; + +/** + * Pure legality check. Returns `{ ok }` or `{ ok: false, reason }`; never + * mutates. `decker` is the overriding crew member (any PLAYER-faction + * operator); `target` is the entity it is aiming at. + * + * Only a live, in-range, line-of-sight `Hostile` of a *different* faction that + * is not already overridden can be hijacked. A non-Hostile target (terminal, + * civilian, turret) is `not-overridable` rather than a thrown error — the + * picker may legitimately land on one and we want a legible deny, not a crash. + */ +export function canOverride(world: World, decker: Entity, target: Entity | null): OverrideCheck { + if (!decker.alive) return { ok: false, reason: 'dead' }; + if (!decker.canAfford(AP_COST.OVERRIDE)) return { ok: false, reason: 'insufficient-ap' }; + if (!(target instanceof Hostile)) return { ok: false, reason: 'not-overridable' }; + if (!target.alive) return { ok: false, reason: 'dead-target' }; + if (target.isOverridden) return { ok: false, reason: 'already-overridden' }; + if (target.faction === decker.faction) return { ok: false, reason: 'friendly' }; + if (!withinRange(decker.x, decker.y, target.x, target.y, OVERRIDE_RANGE)) { + return { ok: false, reason: 'out-of-range' }; + } + if ( + !hasLineOfSight(world.grid, decker.x, decker.y, target.x, target.y, { + blockers: world.blockerKeys(), + }) + ) { + return { ok: false, reason: 'no-los' }; + } + return { ok: true }; +} + +/** + * Commit an override attempt. Throws on illegal pre-conditions *before* + * mutating any state (no AP burned). On a legal attempt: debits + * `AP_COST.OVERRIDE`, rolls against {@link OVERRIDE_SUCCESS_CHANCE}, and either + * flips the drone (success) or trips the facility alarm (failure). A failed + * hack still costs AP — the intrusion was noisy whether or not it landed. + */ +export function overrideDrone( + world: World, + decker: Entity, + target: Entity, + rng: Rng +): OverrideResult { + const check = canOverride(world, decker, target); + if (!check.ok) { + throw new Error(`Illegal override for ${decker.id}: ${check.reason}`); + } + decker.spendAp(AP_COST.OVERRIDE); + const roll = rng.next(); + const success = roll < OVERRIDE_SUCCESS_CHANCE; + if (success) { + applyOverride(target as Hostile, decker.faction); + return { ok: true, success: true, roll, alarm: false }; + } + // A botched intrusion pings the building's security net. + const raised = world.raiseAlarm({ source: decker, repPenalty: false }); + return { ok: true, success: false, roll, alarm: raised }; +} + +/** Flip a hostile to `overriderFaction` for the full override duration. */ +export function applyOverride(target: Hostile, overriderFaction: FactionId): void { + target.factionBeforeOverride = target.faction; + target.faction = overriderFaction; + target.overrideTurnsRemaining = OVERRIDE_DURATION; +} + +/** + * Restore a hostile's original allegiance. Throws if there is no recorded + * pre-override faction — reverting an entity we never overrode is corrupt + * bookkeeping, not a recoverable situation. + */ +export function revertOverride(target: Hostile): void { + if (target.factionBeforeOverride === null) { + throw new Error(`revertOverride: ${target.id} has no factionBeforeOverride to restore`); + } + target.faction = target.factionBeforeOverride; + target.factionBeforeOverride = null; + target.overrideTurnsRemaining = 0; +} + +/** One action emitted by an overridden drone during the player aftermath. */ +export type OverriddenDroneAction = TurnActionStep | { type: 'override-expired' }; + +/** + * Step every currently-overridden drone through one turn on the player's side, + * then tick its countdown and revert it when the override lapses. Yields one + * entry per committed action so the aftermath pipeline can pace + log it, plus + * a synthetic `override-expired` action when a drone snaps back to corp. + * + * Dead overridden drones are skipped (their corpse needs no turn and no + * revert). The live entity list is snapshotted up front so a drone that kills + * a corp unit mid-turn can't perturb the iteration. + */ +export function* stepOverriddenDrones( + world: World, + rng: Rng +): Generator<{ entity: Hostile; action: OverriddenDroneAction }, void, undefined> { + const overridden: Hostile[] = []; + for (const e of world.entities.values()) { + if (e instanceof Hostile && e.isOverridden && e.alive) overridden.push(e); + } + for (const drone of overridden) { + if (!drone.alive) continue; // may have died earlier in this aftermath pass + // Corp drones are PatrolHostiles with a step generator; fall back to the + // synchronous `takeTurn` for any future hostile that lacks one. + if (typeof drone.takeTurnSteps === 'function') { + for (const step of drone.takeTurnSteps(world, rng)) { + yield { entity: drone, action: step }; + } + } else if (typeof drone.takeTurn === 'function') { + const steps = drone.takeTurn(world, rng); + if (Array.isArray(steps)) { + for (const step of steps) yield { entity: drone, action: step }; + } + } + drone.overrideTurnsRemaining -= 1; + if (drone.overrideTurnsRemaining <= 0) { + revertOverride(drone); + yield { entity: drone, action: { type: 'override-expired' } }; + } + } +} diff --git a/src/game/persistence.ts b/src/game/persistence.ts index 98f018e..626f92f 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -47,6 +47,7 @@ import { Crew } from './Crew.js'; import { Merc } from './archetypes/Merc.js'; import { Razor } from './archetypes/Razor.js'; import { Tech } from './archetypes/Tech.js'; +import { Decker } from './archetypes/Decker.js'; import { Turret } from './Turret.js'; import { Skirmisher, type SkirmisherProps } from './ai/Skirmisher.js'; import { Guard, type GuardProps } from './ai/Guard.js'; @@ -179,6 +180,7 @@ const ARCHETYPE_FACTORY: Record new Merc(props as CrewInit), razor: (props: RestoreEntityProps) => new Razor(props as CrewInit), tech: (props: RestoreEntityProps) => new Tech(props as CrewInit), + decker: (props: RestoreEntityProps) => new Decker(props as CrewInit), turret: (props: RestoreEntityProps) => new Turret(props as TurretInit), drone: (props: RestoreEntityProps) => new Skirmisher(props as SkirmisherProps), guard: (props: RestoreEntityProps) => new Guard(props as GuardProps), @@ -475,6 +477,46 @@ function restorePatrolState( } entity.patrolIndex = len > 0 ? idx : 0; } + restoreOverrideState(entity, patrol, rec); +} + +/** + * Re-apply Decker drone-override bookkeeping (P3.M2). The two fields travel as + * a pair: a live hijack has a positive countdown *and* a recorded prior + * faction. Either one present without the other — or a countdown that isn't a + * positive integer, or a prior faction that isn't a known faction — is corrupt + * mid-override state and throws, rather than silently restoring a drone that + * can never revert. + */ +function restoreOverrideState( + entity: PatrolHostile, + patrol: Partial, + rec: RunEntitySnapshot +): void { + const hasTurns = patrol.overrideTurnsRemaining !== undefined; + const hasPrior = + patrol.factionBeforeOverride !== undefined && patrol.factionBeforeOverride !== null; + if (!hasTurns && !hasPrior) return; + if (hasTurns !== hasPrior) { + throw new Error( + `restore: ${rec.archetype} ${rec.id} override state is half-populated ` + + `(turns=${patrol.overrideTurnsRemaining}, prior=${patrol.factionBeforeOverride})` + ); + } + const turns = patrol.overrideTurnsRemaining as number; + if (!Number.isInteger(turns) || turns <= 0) { + throw new RangeError( + `restore: ${rec.archetype} ${rec.id} overrideTurnsRemaining must be a positive integer, got ${turns}` + ); + } + const prior = patrol.factionBeforeOverride as FactionId; + if (!KNOWN_FACTIONS.has(prior)) { + throw new Error( + `restore: ${rec.archetype} ${rec.id} factionBeforeOverride "${prior}" is not a known faction` + ); + } + entity.overrideTurnsRemaining = turns; + entity.factionBeforeOverride = prior; } type RestoreEntry = { @@ -1168,7 +1210,7 @@ function validateRecord(record: unknown): asserts record is RunSnapshot { } } -const KNOWN_ARCHETYPES_SET = new Set(['merc', 'razor', 'tech']); +const KNOWN_ARCHETYPES_SET = new Set(['merc', 'razor', 'tech', 'decker']); /** Clamp gear bonuses to archetype caps after restore. */ function repairGearForCrew(member: Crew) { @@ -1620,6 +1662,7 @@ function archetypeOfCrew(member: Crew): CrewArchetypeId { if (member instanceof Merc) return 'merc'; if (member instanceof Razor) return 'razor'; if (member instanceof Tech) return 'tech'; + if (member instanceof Decker) return 'decker'; throw new Error(`snapshotCampaign: cannot classify crew member ${member?.id}`); } diff --git a/src/input/applyIntent.ts b/src/input/applyIntent.ts index 1d8ee32..e5a3ccb 100644 --- a/src/input/applyIntent.ts +++ b/src/input/applyIntent.ts @@ -18,11 +18,13 @@ * automation can commit a melee strike without synthesizing a walk intent. * * The archetype-specific perks (Merc's Vault, Razor's Slide, Tech's Deploy - * Turret) collapse into a single `special` intent at the keymap layer. The - * `doSpecial` dispatcher below routes it to the right verb based on which - * methods the active player class exposes — `canVault` → vault, `canSlide` → - * slide, `canDeploy` → deploy. This keeps the input surface symmetric across - * archetypes (one key, one touch button) and stays out of the player's way: + * Turret, Decker's Override) collapse into a single `special` intent at the + * keymap layer. The `doSpecial` dispatcher below routes it to the right verb + * based on which methods the active player class exposes — `canVault` → vault, + * `canSlide` → slide, `canDeploy` → deploy, `canOverride` → override (the + * Decker resolves a drone along the aim ray, like fire). This keeps the input + * surface symmetric across archetypes (one key, one touch button) and stays + * out of the player's way: * the keymap doesn't need to know which class is in play, and the intent * dispatcher doesn't need an explicit archetype switch. * @@ -39,7 +41,13 @@ * harness assumption. */ -import { FACTION, SIGHT_RANGE, VAULT_DAMAGE, NOISE_RADIUS } from '../game/constants.js'; +import { + FACTION, + SIGHT_RANGE, + VAULT_DAMAGE, + NOISE_RADIUS, + OVERRIDE_RANGE, +} from '../game/constants.js'; import { totalSalvage, formatSalvageCompact } from '../game/salvage.js'; import { canFireRanged, resolveRanged, canMelee, resolveMelee } from '../game/Combat.js'; import { isConcealedFromPlayer } from '../game/playerPerception.js'; @@ -58,6 +66,7 @@ import type { Rng } from '../rng.js'; import type { Tech } from '../game/archetypes/Tech.js'; import type { Merc } from '../game/archetypes/Merc.js'; import type { Razor } from '../game/archetypes/Razor.js'; +import type { Decker } from '../game/archetypes/Decker.js'; export type Intent = { type: string; @@ -347,9 +356,62 @@ function doSpecial(intent: Intent, ctx: ApplyIntentContext) { if (typeof (player as Razor).canSlide === 'function') { return doSlide(intent, ctx); } + if (typeof (player as Decker).canOverride === 'function') { + return doOverride(intent, ctx); + } log('> SPECIAL: this archetype has no perk action.'); } +/** + * Walk the aim ray for the Decker's Override perk and resolve the first hostile + * drone in reach, then attempt the hijack. Mirrors `pickFireTarget`'s geometry + * (bounded by `OVERRIDE_RANGE`, LOS-gated) so the target the picker lands on is + * exactly the one the resolver will act on. A failed roll trips the alarm; an + * out-of-reach ray or non-drone target yields a legible deny, not a crash. + */ +function doOverride(intent: Intent, ctx: ApplyIntentContext) { + const { world, player, log } = ctx; + const decker = player as Decker; + const playerLabel = entityLabel(player); + const target = pickOverrideTarget(ctx, intent.dx!, intent.dy!); + const check = decker.canOverride(world, target); + if (!check.ok) { + log(`> ${playerLabel} OVERRIDE DENIED: ${check.reason}`); + return; + } + const result = decker.overrideDrone(world, target!, ctx.rng); + const targetLabel = entityLabel(target!); + if (result.success) { + log(`> ${playerLabel} OVERRIDES ${targetLabel} — it fights for you! (${player.ap} AP left).`); + } else { + log( + `> ${playerLabel} OVERRIDE FAILED on ${targetLabel}` + + `${result.alarm ? ' — ALARM TRIPPED' : ''} (${player.ap} AP left).` + ); + } + gateOnApExhausted(ctx); +} + +/** + * First hostile along (dx, dy) within `OVERRIDE_RANGE` and LOS. Returns `null` + * when the ray leaves the map, exceeds range, hits a wall, or finds no entity — + * `canOverride` turns that `null` into a `not-overridable` deny. + */ +function pickOverrideTarget(ctx: ApplyIntentContext, dx: number, dy: number) { + const { world, player } = ctx; + const blockers = world.blockerKeys(); + for (let step = 1; step <= OVERRIDE_RANGE; step++) { + const x = player.x + dx * step; + const y = player.y + dy * step; + if (!world.grid.inBounds(x, y)) return null; + if (!withinRange(player.x, player.y, x, y, OVERRIDE_RANGE)) return null; + if (!hasLineOfSight(world.grid, player.x, player.y, x, y, { blockers })) return null; + const e = world.entityAt(x, y); + if (e) return e; + } + return null; +} + function doDeploy(intent: Intent, ctx: ApplyIntentContext) { const { world, player, log } = ctx; const tech = player as Tech; diff --git a/tests/unit/game/Decker.test.ts b/tests/unit/game/Decker.test.ts new file mode 100644 index 0000000..1ab565d --- /dev/null +++ b/tests/unit/game/Decker.test.ts @@ -0,0 +1,202 @@ +/** + * Decker archetype tests (P3.M2) — Drone Override Hack legality matrix, + * commit semantics, and the per-turn stepping/revert loop. + * + * The perk mirrors the Tech/Razor/Merc contract: `canOverride` is a pure + * legality check returning `{ ok, reason }`; `overrideDrone` commits the spend + * (AP + faction flip, or AP + alarm on a failed roll) and throws — without + * mutating state — on any illegal precondition. The override then plays out + * through `stepOverriddenDrones`, which fights the drone on the player's side + * and reverts it when the countdown lapses. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Decker } from '../../../src/game/archetypes/Decker.js'; +import { Crew } from '../../../src/game/Crew.js'; +import { Grid } from '../../../src/game/Grid.js'; +import { World } from '../../../src/game/World.js'; +import { Entity } from '../../../src/game/Entity.js'; +import { Skirmisher } from '../../../src/game/ai/Skirmisher.js'; +import { applyOverride, stepOverriddenDrones } from '../../../src/game/droneOverride.js'; +import { TILE, FACTION, AP_COST, OVERRIDE_DURATION } from '../../../src/game/constants.js'; +import { Rng } from '../../../src/rng.js'; + +function makeWorld({ deckerAt = [1, 1], grid, extraEntities = [] } = {}) { + const g = grid ?? new Grid(12, 12); + const w = new World(g); + const decker = new Decker({ id: 'decker', x: deckerAt[0], y: deckerAt[1] }); + w.addEntity(decker); + for (const e of extraEntities) w.addEntity(e); + return { world: w, decker }; +} + +function makeDrone(id, x, y, faction = FACTION.CORP) { + return new Skirmisher({ id, x, y, faction }); +} + +// Deterministic single-roll stubs for the commit step (overrideDrone only +// consumes one rng.next()). 0.1 < success chance → hijack; 0.9 → failure. +const winRng = { next: () => 0.1 }; +const loseRng = { next: () => 0.9 }; + +// --- class basics ---------------------------------------------------------- + +test('Decker extends Crew and is a PLAYER-faction operator with the @ glyph', () => { + const d = new Decker({ id: 'd', x: 0, y: 0 }); + assert.ok(d instanceof Crew, 'Decker must extend Crew'); + assert.equal(d.faction, FACTION.PLAYER); + assert.equal(d.glyph, '@'); + assert.equal(d.archetype, 'Decker'); + assert.equal(d.baseHitChance, 0.7); +}); + +// --- canOverride legality matrix ------------------------------------------- + +test('canOverride accepts a live in-range LOS corp drone', () => { + const drone = makeDrone('k', 3, 1); + const { world, decker } = makeWorld({ extraEntities: [drone] }); + assert.equal(decker.canOverride(world, drone).ok, true); +}); + +test('canOverride rejects a null / non-Hostile target as not-overridable', () => { + const prop = new Entity({ id: 'prop', x: 2, y: 1, faction: FACTION.CORP, glyph: '#' }); + const { world, decker } = makeWorld({ extraEntities: [prop] }); + assert.equal(decker.canOverride(world, null).reason, 'not-overridable'); + assert.equal(decker.canOverride(world, prop).reason, 'not-overridable'); +}); + +test('canOverride rejects when the Decker is dead', () => { + const drone = makeDrone('k', 3, 1); + const { world, decker } = makeWorld({ extraEntities: [drone] }); + decker.alive = false; + assert.equal(decker.canOverride(world, drone).reason, 'dead'); +}); + +test('canOverride rejects when AP < OVERRIDE cost', () => { + const drone = makeDrone('k', 3, 1); + const { world, decker } = makeWorld({ extraEntities: [drone] }); + decker.spendAp(decker.ap - (AP_COST.OVERRIDE - 1)); + assert.equal(decker.canOverride(world, drone).reason, 'insufficient-ap'); +}); + +test('canOverride rejects a dead target drone', () => { + const drone = makeDrone('k', 3, 1); + const { world, decker } = makeWorld({ extraEntities: [drone] }); + drone.alive = false; + assert.equal(decker.canOverride(world, drone).reason, 'dead-target'); +}); + +test('canOverride rejects a drone that is already overridden', () => { + const drone = makeDrone('k', 3, 1); + const { world, decker } = makeWorld({ extraEntities: [drone] }); + applyOverride(drone, FACTION.PLAYER); + assert.equal(decker.canOverride(world, drone).reason, 'already-overridden'); +}); + +test('canOverride rejects a same-faction (already player-aligned) drone', () => { + const drone = makeDrone('k', 3, 1, FACTION.PLAYER); + const { world, decker } = makeWorld({ extraEntities: [drone] }); + assert.equal(decker.canOverride(world, drone).reason, 'friendly'); +}); + +test('canOverride rejects an out-of-range drone', () => { + const drone = makeDrone('k', 9, 1); // distance 8 > OVERRIDE_RANGE (5) + const { world, decker } = makeWorld({ extraEntities: [drone] }); + assert.equal(decker.canOverride(world, drone).reason, 'out-of-range'); +}); + +test('canOverride rejects a drone behind a wall (no LOS)', () => { + const g = new Grid(12, 12); + g.setTile(2, 1, TILE.WALL); + const drone = makeDrone('k', 3, 1); + const { world, decker } = makeWorld({ grid: g, extraEntities: [drone] }); + assert.equal(decker.canOverride(world, drone).reason, 'no-los'); +}); + +// --- overrideDrone commit semantics ---------------------------------------- + +test('overrideDrone success flips the drone to PLAYER for the full duration', () => { + const drone = makeDrone('k', 3, 1); + const { world, decker } = makeWorld({ extraEntities: [drone] }); + const apBefore = decker.ap; + const result = decker.overrideDrone(world, drone, winRng); + assert.equal(result.success, true); + assert.equal(result.alarm, false); + assert.equal(drone.faction, FACTION.PLAYER); + assert.equal(drone.factionBeforeOverride, FACTION.CORP); + assert.equal(drone.overrideTurnsRemaining, OVERRIDE_DURATION); + assert.equal(drone.isOverridden, true); + assert.equal(decker.ap, apBefore - AP_COST.OVERRIDE); +}); + +test('overrideDrone failure burns AP, trips the alarm, leaves faction intact', () => { + const drone = makeDrone('k', 3, 1); + const { world, decker } = makeWorld({ extraEntities: [drone] }); + const apBefore = decker.ap; + assert.equal(world.alarmActive, false); + const result = decker.overrideDrone(world, drone, loseRng); + assert.equal(result.success, false); + assert.equal(result.alarm, true); + assert.equal(world.alarmActive, true); + assert.equal(drone.faction, FACTION.CORP); + assert.equal(drone.isOverridden, false); + assert.equal(decker.ap, apBefore - AP_COST.OVERRIDE); +}); + +test('overrideDrone throws on an illegal attempt without burning AP', () => { + const drone = makeDrone('k', 9, 1); // out of range + const { world, decker } = makeWorld({ extraEntities: [drone] }); + const apBefore = decker.ap; + assert.throws(() => decker.overrideDrone(world, drone, winRng), /Illegal override/); + assert.equal(decker.ap, apBefore, 'AP not debited on illegal override'); + assert.equal(drone.faction, FACTION.CORP); +}); + +// --- golden path: hijacked drone fights corp, then reverts ----------------- + +test('an overridden drone attacks corp allies for N turns then reverts', () => { + // Open arena: decker, hijacked drone, and a corp victim in a clean LOS line. + const drone = makeDrone('hijacked', 4, 4); + const victim = makeDrone('victim', 6, 4); // corp; the drone's new enemy + const { world, decker } = makeWorld({ deckerAt: [2, 4], extraEntities: [drone, victim] }); + + const result = decker.overrideDrone(world, drone, winRng); + assert.equal(result.success, true); + + const rng = new Rng(7); + const victimHpStart = victim.hp; + let sawEngageAction = false; + + // Drive the override across its whole lifetime. Each pass is one player turn: + // the drone acts, then its countdown ticks. + let expired = false; + for (let turn = 0; turn < OVERRIDE_DURATION; turn++) { + assert.equal(drone.faction, FACTION.PLAYER, `drone should be player-aligned on turn ${turn}`); + drone.refreshAp(); // mimic the AP refresh PLAYER entities get each round + for (const { entity, action } of stepOverriddenDrones(world, rng)) { + assert.equal(entity.id, drone.id); + if (action.type === 'fire' || String(action.type).startsWith('move')) { + sawEngageAction = true; + } + if (action.type === 'override-expired') expired = true; + } + } + + assert.ok(sawEngageAction, 'hijacked drone should engage (fire/move) against corp'); + assert.ok(expired, 'override should emit an override-expired action when it lapses'); + assert.equal(drone.isOverridden, false, 'override countdown should be spent'); + assert.equal(drone.faction, FACTION.CORP, 'drone reverts to its original faction'); + assert.equal(drone.factionBeforeOverride, null, 'override bookkeeping cleared on revert'); + assert.ok(victim.hp <= victimHpStart, 'corp victim took fire from the hijacked drone'); +}); + +test('overrideDrone is a no-op on the corp turn list — driver skips player faction', () => { + // Sanity: once flipped, the drone is PLAYER faction, so the corp-turn driver + // (which filters on corpFaction) will never step it. We assert the faction + // contract that guarantees this rather than re-running the whole driver. + const drone = makeDrone('k', 3, 1); + const { world, decker } = makeWorld({ extraEntities: [drone] }); + decker.overrideDrone(world, drone, winRng); + assert.notEqual(drone.faction, FACTION.CORP); +}); diff --git a/tests/unit/game/archetypes.test.ts b/tests/unit/game/archetypes.test.ts index 9de5385..d5e14d7 100644 --- a/tests/unit/game/archetypes.test.ts +++ b/tests/unit/game/archetypes.test.ts @@ -16,10 +16,12 @@ import { ARCHETYPES, ARCHETYPE_IDS, CALLSIGNS_BY_ARCHETYPE, + RECRUIT_ARCHETYPE_POOL, buildCrewMember, isArchetypeId, pickCallsign, } from '../../../src/game/archetypes/index.js'; +import { Decker } from '../../../src/game/archetypes/Decker.js'; import { FACTION } from '../../../src/game/constants.js'; import { Merc, CALLSIGNS as MERC_CALLSIGNS } from '../../../src/game/archetypes/Merc.js'; import { CALLSIGNS as RAZOR_CALLSIGNS } from '../../../src/game/archetypes/Razor.js'; @@ -145,6 +147,35 @@ test('buildCrewMember rejects a missing or invalid rng', () => { assert.throws(() => buildCrewMember('merc', { x: 0, y: 0 }, {}), /Rng/i); }); +// --- Decker (P3.M2): registered, buildable, but not a starter/recruit pick --- + +test('Decker is registered in ARCHETYPES with its OVERRIDE perk metadata', () => { + const a = ARCHETYPES.decker; + assert.ok(a, 'missing decker archetype'); + assert.equal(a.id, 'decker'); + assert.equal(a.name, 'DECKER'); + assert.deepEqual(a.perks, ['override']); + assert.equal(a.perkName, 'OVERRIDE'); + assert.ok(a.perkLabel.length > 0); +}); + +test('Decker is excluded from the starter selector and the random recruit pool', () => { + // The Decker joins only via the Act-2 narrative beat — never as a starter + // pick or a random recruit roll (P3.M2 design constraint). + assert.ok(!ARCHETYPE_IDS.includes('decker'), 'Decker must not be a selectable starter'); + assert.ok( + !RECRUIT_ARCHETYPE_POOL.includes('decker'), + 'Decker must not be in the random recruit pool' + ); +}); + +test('buildCrewMember can still construct a Decker by id (recruitment path)', () => { + const d = buildCrewMember('decker', { x: 1, y: 2 }, new Rng(9)); + assert.ok(d instanceof Decker); + assert.equal(isArchetypeId('decker'), true); + assert.ok(CALLSIGNS_BY_ARCHETYPE.decker.includes(d.callsign)); +}); + test('isArchetypeId is a string-set membership check', () => { assert.equal(isArchetypeId('merc'), true); assert.equal(isArchetypeId('razor'), true); diff --git a/tests/unit/game/persistence.test.ts b/tests/unit/game/persistence.test.ts index 5b27745..bf496ac 100644 --- a/tests/unit/game/persistence.test.ts +++ b/tests/unit/game/persistence.test.ts @@ -25,6 +25,9 @@ import { } from '../../../src/game/constants.js'; import { makeSalvage, totalSalvage } from '../../../src/game/salvage.js'; import { buildCrewMember } from '../../../src/game/archetypes/index.js'; +import { Decker } from '../../../src/game/archetypes/Decker.js'; +import { PatrolHostile } from '../../../src/game/ai/PatrolHostile.js'; +import { applyOverride } from '../../../src/game/droneOverride.js'; import { Rng } from '../../../src/rng.js'; import { testContractContext } from './contractTestUtils.js'; @@ -104,6 +107,43 @@ test('run snapshot → restore → snapshot is byte-for-byte stable', () => { assert.deepEqual(recB, recA, 'round-trip should reproduce the source record'); }); +test('snapshot/restore round-trips a Decker deploy (P3.M2)', () => { + const run = freshCombatRun(0xc0ffee, 'decker'); + assert.ok(run.player instanceof Decker, 'fixture should deploy a Decker'); + const rec = snapshot(run); + const { run: restoredRun } = restore(rec); + assert.ok(restoredRun.player instanceof Decker, 'Decker should restore as a Decker'); + assert.equal(restoredRun.player.callsign, run.player.callsign); + // Stable round-trip through a second snapshot. + assert.deepEqual(snapshot(restoredRun), rec); +}); + +test('snapshot/restore preserves a live drone-override (P3.M2)', () => { + const run = freshCombatRun(0xbadbeef, 'decker'); + const drone = [...run.world.entities.values()].find(e => e instanceof PatrolHostile); + assert.ok(drone, 'fixture run should contain at least one patrol hostile'); + const originalFaction = drone.faction; + applyOverride(drone, FACTION.PLAYER); + + const rec = snapshot(run); + const { world: restoredWorld } = restore(rec); + const restoredDrone = [...restoredWorld.entities.values()].find(e => e.id === drone.id); + assert.ok(restoredDrone, 'overridden drone should survive the round-trip'); + assert.equal(restoredDrone.faction, FACTION.PLAYER, 'flipped faction preserved'); + assert.equal(restoredDrone.factionBeforeOverride, originalFaction, 'prior faction preserved'); + assert.equal(restoredDrone.overrideTurnsRemaining, drone.overrideTurnsRemaining); + assert.equal(restoredDrone.isOverridden, true); +}); + +test('a never-overridden drone snapshot omits override fields (no shape drift)', () => { + const run = freshCombatRun(0x1234, 'razor'); + const rec = snapshot(run); + const droneRec = rec.entities.find(e => e.archetype === 'drone' || e.archetype === 'guard'); + assert.ok(droneRec, 'expected a patrol hostile in the snapshot'); + assert.equal(droneRec.extra.overrideTurnsRemaining, undefined); + assert.equal(droneRec.extra.factionBeforeOverride, undefined); +}); + test('snapshot/restore round-trips a Tech with a placed turret (M1)', () => { const run = freshCombatRun(0xc0ffee, 'tech'); const tech = run.player; From a5e7943c689ac524fc6e41d67dfc292d5eb25c4a Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Mon, 8 Jun 2026 08:57:53 -0700 Subject: [PATCH 04/55] transitions and score target identification --- docs/phase-3-plan.md | 24 ++-- src/game/Campaign.ts | 137 +++++++++++++++++- src/game/persistence.ts | 2 +- tests/unit/game/Campaign.test.ts | 231 ++++++++++++++++++++++++++++++- 4 files changed, 379 insertions(+), 15 deletions(-) diff --git a/docs/phase-3-plan.md b/docs/phase-3-plan.md index 260c006..068881f 100644 --- a/docs/phase-3-plan.md +++ b/docs/phase-3-plan.md @@ -41,8 +41,8 @@ A new **player archetype** recruited mid-campaign (late Act 1 / start of Act 2), | Milestone | Status | |---|---| -| P3.M1 — Campaign arc structure | 🚧 In progress — P3.M1.1 done | -| P3.M2 — The Decker archetype | 🚧 In progress — class + Drone Override Hack done; recruitment flow pending P3.M1.2 | +| P3.M1 — Campaign arc structure | 🚧 In progress — P3.M1.1–P3.M1.3 done; Hub arc surface next | +| P3.M2 — The Decker archetype | 🚧 In progress — class + Drone Override Hack done; recruitment flow pending | | P3.M3 — Cyberspace grid + ICE | 🔲 Planned | | P3.M4 — Simstim flip (dual-deploy) | 🔲 Planned | | P3.M5 — The Score (climactic mission) | 🔲 Planned | @@ -100,7 +100,7 @@ Score-target sites always use roster-stored dimensions (P2.7.M1.5: `mapWidth`, ` - Act 2 = at least one board slot biased toward the Score target or its principal/site identity when available. - 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 act, Clock pressure, and Score target label once revealed. The Score reveal uses progressive Hub reveal plumbing so the Curator can present the target as a campaign beat instead of leaving the player to infer it from state changes. - **Win/loss conditions:** - **Win:** Complete the Score (extract with objective satisfied from the final mission). - **Loss (flatline):** Entire crew wiped during any run (existing behavior, but now with arc context for the chronicle). @@ -130,18 +130,24 @@ Neural degradation is deferred until Cyberspace is fun enough to deserve a jack- | 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** | 🔲 Planned | Advance acts from `rep`, `completedJobs`, Decker flag, Score-site visit | boundary tests around job counts and rep tier | -| **P3.M1.3 Score target** | 🔲 Planned | Promote one roster site to `tier: 'score'`; preserve through eviction | exactly-one target, no eviction at roster cap, synthetic target when roster empty | -| **P3.M1.4 Clock** | 🔲 Planned | Start heat after threshold; hard loss at deadline; show status | heat math, deadline loss, no loss after Score attempt | -| **P3.M1.5 Curator bias** | 🔲 Planned | Pass campaign-derived arc context; bias board slots by act and score target | seeded boards show expected `arcStage` and target-site frequency | -| **P3.M1.6 Score entry** | 🔲 Planned | Hub action creates the special Score contract in Act 3 only | availability gates, deployment path, attempted flag | +| **P3.M1.2 Transitions** | ✅ Done | Advance acts from `rep`, `completedJobs`, Decker flag, Score-site visit | boundary tests around job counts and rep tier | +| **P3.M1.3 Score target** | ✅ Done | Promote one roster site to `tier: 'score'`; preserve through eviction | exactly-one target, no eviction at roster cap, synthetic target when roster empty | +| **P3.M1.4 Hub arc surface** | 🔲 Planned | Curator presents the Score reveal; Hub / Terminal shows act and Score target; contract selection labels Score-site jobs | one-shot reveal, status render, Score-site badge | +| **P3.M1.5 Clock** | 🔲 Planned | Start heat after threshold; hard loss at deadline; show status | heat math, deadline loss, no loss after Score attempt | +| **P3.M1.6 Curator bias** | 🔲 Planned | Pass campaign-derived arc context; bias board slots by act and score target | seeded boards show expected `arcStage` and target-site frequency | +| **P3.M1.7 Score entry** | 🔲 Planned | Hub action creates the special Score contract in Act 3 only | availability gates, deployment path, attempted flag | **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 `TRUSTED` Rep plus `completedJobs >= 4` and sets `scoreRevealed`; successful extractions increment `completedJobs`, abort extractions do not. Act 2 advances to Act 3 once a Decker has joined the crew, `completedJobs >= 9`, and a score-target roster site has a prior visit marker (`lastVisitedJob > 0`). M1.2 does not synthesize or promote the Score target — that remains P3.M1.3. + +**P3.M1.3 implementation note:** Score reveal now guarantees exactly one Score target. If a roster site exists at Act 2 entry, the most recently visited site is promoted to `tier: 'score'` with `scoreTarget: true`, preserving its seed, dimensions, mutation deltas, and exploration memory. If the roster is empty, Campaign synthesizes a CRITICAL-footprint score site from Curator lexicon principal/site tokens instead of deferring reveal. Multiple persisted score targets, or score-tier sites missing `scoreTarget`, throw during Hub-entry arc evaluation. + **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, Hub / Terminal displays the current act and target label, and contract selection marks Score-site 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). - Exactly one Score target exists after Score reveal; it survives roster eviction and keeps its P2.5.M7 terrain memory. @@ -169,7 +175,7 @@ Neural degradation is deferred until Cyberspace is fun enough to deserve a jack- - ✅ 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: gated by arc state; not available in Act 1; golden-path test for recruitment flow. **Deferred — needs P3.M1.2 act transitions.** Decker is registered but excluded from `ARCHETYPE_IDS` and `RECRUIT_ARCHETYPE_POOL` so random recruitment can't roll one early. +- 🔲 Recruitment: gated by arc state; not available in Act 1; golden-path test for recruitment flow. **Ready after P3.M1.4 confirms the shared Hub reveal surface.** 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`. diff --git a/src/game/Campaign.ts b/src/game/Campaign.ts index 86882e0..f9415b6 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -3,7 +3,16 @@ import { World } from './World.js'; import { TurnQueue } from './TurnQueue.js'; import { EventBus } from './events.js'; import { Entity } from './Entity.js'; -import { FACTION, REP, RECRUIT, SALVAGE_SELL_RATE, CLINIC_COST_PER_HP } from './constants.js'; +import { + CONTRACT_DIFFICULTY, + FACTION, + REP, + REP_TIER, + RECRUIT, + SALVAGE_SELL_RATE, + CLINIC_COST_PER_HP, + repTierForRep, +} from './constants.js'; import { SALVAGE_TYPES, addSalvage, @@ -14,7 +23,7 @@ import { type TypedSalvage, } from './salvage.js'; import { buildCrewMember, RECRUIT_ARCHETYPE_POOL } from './archetypes/index.js'; -import { Curator } from './hub/Curator.js'; +import { CONTRACT_LEXICON, Curator } from './hub/Curator.js'; import { Terminal } from './hub/Terminal.js'; import { Finn } from './hub/Finn.js'; import { Clinic } from './hub/Clinic.js'; @@ -35,13 +44,24 @@ import { mergeSiteSeenKeys as mergeSeen, normalizeLocationSite, } from './locations.js'; +import { resolveMapDimensions } from './procgen/mapDimensions.js'; import type { Contract } from './hub/Curator.js'; import type { Crew } from './Crew.js'; -import type { CampaignArcStage, GridPoint, KeyItem, LocationSite, TileDelta } from '../types.js'; +import type { + CampaignArcStage, + GridPoint, + KeyItem, + LocationSite, + LocationToken, + TileDelta, +} from '../types.js'; import type { RunResult, Outcome } from './Run.js'; /** Max remembered combat locations (P2.5.M7.2). One slot is reserved for Phase 3's score target. */ export const SITE_ROSTER_CAP = 6; +export const ARC_ACT_2_MIN_COMPLETED_JOBS = 4; +export const ARC_ACT_3_MIN_COMPLETED_JOBS = 9; +const SYNTHETIC_SCORE_TARGET_DIFFICULTY = CONTRACT_DIFFICULTY.CRITICAL; export const CAMPAIGN_STATE = Object.freeze({ HUB: 'HUB', @@ -274,6 +294,7 @@ export class Campaign { if (this.state !== CAMPAIGN_STATE.HUB && this.state !== CAMPAIGN_STATE.COMBAT) { throw new Error(`Campaign.enterHub: illegal transition from ${this.state}`); } + this.#advanceArcTransitions(); this.recruitedThisVisit = false; this.healedThisVisit = new Set(); this.lastHubReveal = null; @@ -424,8 +445,8 @@ export class Campaign { // returning to the Hub — breach holes survive even on an aborted exit. this.#mergeRunDeltasIntoRoster(this.activeRun); this.#mergeRunSeenIntoRoster(this.activeRun); - this.completedJobs += 1; if (completed) { + this.completedJobs += 1; addSalvage(this.salvage, extracted); const reward = this.activeRun.contract?.reward; this.credits += reward?.credits ?? 0; @@ -968,6 +989,102 @@ export class Campaign { return this.crew.find(member => member.id === memberId) ?? null; } + #advanceArcTransitions(): void { + if (this.crew.some(member => member.archetype === 'Decker')) { + this.arc.deckerRecruited = true; + } + + if (this.arc.arcStage === 'act-1' && this.#qualifiesForAct2()) { + this.arc.arcStage = 'act-2'; + this.arc.scoreRevealed = true; + } + + if (this.arc.scoreRevealed) { + this.#ensureScoreTargetDesignated(); + } + + if (this.arc.arcStage === 'act-2' && this.#qualifiesForAct3()) { + this.arc.arcStage = 'act-3'; + } + } + + #qualifiesForAct2(): boolean { + return ( + repTierForRep(this.rep).id === REP_TIER.TRUSTED && + this.completedJobs >= ARC_ACT_2_MIN_COMPLETED_JOBS + ); + } + + #qualifiesForAct3(): boolean { + return ( + this.arc.deckerRecruited && + this.completedJobs >= ARC_ACT_3_MIN_COMPLETED_JOBS && + this.siteRoster.some(site => site.scoreTarget && site.lastVisitedJob > 0) + ); + } + + #ensureScoreTargetDesignated(): void { + const scoreTargets = this.siteRoster.filter(site => site.scoreTarget); + if (scoreTargets.length > 1) { + throw new Error('Campaign arc: multiple Score targets in site roster'); + } + const scoreTierNonTargets = this.siteRoster.filter( + site => site.tier === 'score' && !site.scoreTarget + ); + if (scoreTierNonTargets.length > 0) { + throw new Error('Campaign arc: score-tier roster site missing scoreTarget'); + } + if (scoreTargets.length === 1) { + scoreTargets[0]!.tier = 'score'; + return; + } + + const target = this.#selectRememberedScoreTarget() ?? this.#synthesizeScoreTarget(); + target.scoreTarget = true; + target.tier = 'score'; + } + + #selectRememberedScoreTarget(): LocationSite | null { + if (this.siteRoster.length === 0) return null; + return [...this.siteRoster].sort( + (a, b) => b.lastVisitedJob - a.lastVisitedJob || a.id.localeCompare(b.id) + )[0]!; + } + + #synthesizeScoreTarget(): LocationSite { + const seed = this.rng.intRange(0, 0x7fffffff); + const principal = this.rng.pick( + CONTRACT_LEXICON.principals.filter(token => token.groups.includes('corp')) + ); + const site = this.rng.pick( + CONTRACT_LEXICON.sites.filter(token => + token.groups.some(group => + ['corp', 'data', 'security', 'infrastructure', 'hidden'].includes(group) + ) + ) + ); + const dimensions = resolveMapDimensions({ + seed, + difficulty: SYNTHETIC_SCORE_TARGET_DIFFICULTY, + }); + const target = normalizeLocationSite({ + id: `score-${generateSiteId(seed)}`, + seed: String(seed), + mapWidth: dimensions.width, + mapHeight: dimensions.height, + label: `// ${principal.label} ${site.label} - Score target`, + tier: 'score', + scoreTarget: true, + mutationDeltas: [], + seenKeys: [], + lastVisitedJob: 0, + principal: locationTokenFromLexicon(principal), + site: locationTokenFromLexicon(site), + }); + this.siteRoster.push(target); + return target; + } + #persist(): void { this.onPersist?.(this); } @@ -1070,6 +1187,18 @@ export function normalizeCampaignArc(raw: unknown, context = 'Campaign arc'): Ca }; } +function locationTokenFromLexicon(token: { + id: string; + label: string; + groups: readonly string[]; +}): LocationToken { + return { + id: token.id, + label: token.label, + groups: [...token.groups], + }; +} + function makeCampaignId(seed: number): string { return `campaign-${(seed >>> 0).toString(16)}-${Date.now().toString(36)}`; } diff --git a/src/game/persistence.ts b/src/game/persistence.ts index 626f92f..cb0b4dc 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -769,7 +769,7 @@ export type CampaignSnapshot = { healedThisVisit?: string[]; /** Progressive Hub introduction flags. Defaults to {} for pre-P2.5.M5.4 saves. */ hubReveals?: HubRevealsSnapshot; - /** Count of jobs ended with EXIT (extract). Defaults to 0 for pre-P2.5.M5.4 saves. */ + /** Count of completed jobs. Abort extractions do not increment this arc counter. */ completedJobs?: number; /** Persistent key-item inventory (keycards). Defaults to [] for pre-P2.5.M6.2 saves. */ keyItems?: KeyItemSnapshot[]; diff --git a/tests/unit/game/Campaign.test.ts b/tests/unit/game/Campaign.test.ts index 714d003..2547a9e 100644 --- a/tests/unit/game/Campaign.test.ts +++ b/tests/unit/game/Campaign.test.ts @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import { Campaign, CAMPAIGN_STATE, + SITE_ROSTER_CAP, buildCrew, defaultCampaignArc, willEndCampaignOnThisDeath, @@ -12,9 +13,11 @@ import { OUTCOME, RUN_STATE } from '../../../src/game/Run.js'; import { Rng } from '../../../src/rng.js'; import { OBJECTIVES } from '../../../src/game/hub/Curator.js'; import { snapshotCampaign, restoreCampaign } from '../../../src/game/persistence.js'; -import { SALVAGE_SELL_RATE, SHOP_COST } from '../../../src/game/constants.js'; +import { SALVAGE_SELL_RATE, SHOP_COST, TILE } from '../../../src/game/constants.js'; import { emptySalvage, makeSalvage, totalSalvage } from '../../../src/game/salvage.js'; import { testContractContext } from './contractTestUtils.js'; +import { buildCrewMember } from '../../../src/game/archetypes/index.js'; +import type { LocationSite } from '../../../src/types.js'; const fakeContract = (overrides = {}) => ({ seed: 12345, @@ -31,6 +34,22 @@ const fakeContract = (overrides = {}) => ({ ...overrides, }); +function validSite(overrides: Partial = {}): LocationSite { + return { + id: '12345', + seed: '12345', + mapWidth: 24, + mapHeight: 16, + label: '// Matsuda contractor annex - payroll mirror', + tier: 'roster', + scoreTarget: false, + mutationDeltas: [], + seenKeys: [], + lastVisitedJob: 0, + ...overrides, + }; +} + test('buildCrew creates one named member per starter archetype with unique callsigns', () => { const crew = buildCrew(new Rng(0xc0ffee)); assert.equal(crew.length, 3); @@ -630,6 +649,216 @@ test('restoreCampaign rejects corrupt Phase 3 arc snapshots', () => { ); }); +test('P3.M1.2: Act 1 does not advance until Rep and completed job gates both qualify', () => { + const lowRep = new Campaign({ seed: 42, rep: 79, completedJobs: 4 }); + assert.equal(lowRep.arcStage, 'act-1'); + assert.equal(lowRep.arc.scoreRevealed, false); + + const lowJobs = new Campaign({ seed: 43, rep: 80, completedJobs: 3 }); + assert.equal(lowJobs.arcStage, 'act-1'); + assert.equal(lowJobs.arc.scoreRevealed, false); +}); + +test('P3.M1.2: Act 1 advances to Act 2 at TRUSTED Rep plus four completed jobs', () => { + const campaign = new Campaign({ seed: 42, rep: 80, completedJobs: 4 }); + + assert.equal(campaign.arcStage, 'act-2'); + assert.equal(campaign.arc.scoreRevealed, true); +}); + +test('P3.M1.2: successful extraction advances Act 2 when the final gate is crossed', () => { + const campaign = new Campaign({ seed: 42, rep: 79, completedJobs: 3 }); + const member = campaign.crew[0]; + const run = campaign.deployCrewMember( + member.id, + fakeContract({ reward: { credits: 0, repDelta: 1 } }) + ); + run.enterCombat(); + + campaign.onJobEnd({ outcome: OUTCOME.EXIT, completed: true }); + + assert.equal(campaign.completedJobs, 4); + assert.equal(campaign.rep, 80); + assert.equal(campaign.arcStage, 'act-2'); + assert.equal(campaign.arc.scoreRevealed, true); +}); + +test('P3.M1.2: abort extraction does not count as a completed arc job', () => { + const campaign = new Campaign({ seed: 42, rep: 90, completedJobs: 3 }); + const member = campaign.crew[0]; + const run = campaign.deployCrewMember(member.id, fakeContract()); + run.enterCombat(); + + campaign.onJobEnd({ outcome: OUTCOME.EXIT, completed: false }); + + assert.equal(campaign.completedJobs, 3); + assert.equal(campaign.arcStage, 'act-1'); + assert.equal(campaign.arc.scoreRevealed, false); +}); + +test('P3.M1.2: Act 2 waits for Decker recruitment, Score-site visit, and nine jobs', () => { + const decker = buildCrewMember('decker', { x: 0, y: 0 }, new Rng(101), { + id: 'crew-decker', + }); + const missingDecker = new Campaign({ + seed: 42, + rep: 80, + completedJobs: 9, + siteRoster: [ + validSite({ + id: 'score', + seed: '12345', + tier: 'score', + scoreTarget: true, + lastVisitedJob: 5, + }), + ], + }); + assert.equal(missingDecker.arcStage, 'act-2'); + + const missingTargetVisit = new Campaign({ + seed: 43, + rep: 80, + completedJobs: 9, + crew: [decker], + }); + assert.equal(missingTargetVisit.arcStage, 'act-2'); + + const tooFewJobs = new Campaign({ + seed: 44, + rep: 80, + completedJobs: 8, + crew: [decker], + siteRoster: [ + validSite({ + id: 'score', + seed: '12345', + tier: 'score', + scoreTarget: true, + lastVisitedJob: 5, + }), + ], + }); + assert.equal(tooFewJobs.arcStage, 'act-2'); +}); + +test('P3.M1.2: Act 2 advances to Act 3 once Decker, Score-site visit, and nine jobs qualify', () => { + const decker = buildCrewMember('decker', { x: 0, y: 0 }, new Rng(102), { + id: 'crew-decker', + }); + const campaign = new Campaign({ + seed: 42, + rep: 80, + completedJobs: 9, + crew: [decker], + siteRoster: [ + validSite({ + id: 'score', + seed: '12345', + tier: 'score', + scoreTarget: true, + lastVisitedJob: 5, + }), + ], + }); + + assert.equal(campaign.arc.deckerRecruited, true); + assert.equal(campaign.arcStage, 'act-3'); +}); + +test('P3.M1.3: Act 2 entry promotes one remembered site as the Score target', () => { + const campaign = new Campaign({ + seed: 42, + rep: 80, + completedJobs: 4, + siteRoster: [ + validSite({ + id: 'remembered', + seed: '98765', + mapWidth: 30, + mapHeight: 18, + lastVisitedJob: 3, + seenKeys: ['1,1'], + mutationDeltas: [{ kind: 'tile', x: 2, y: 2, from: TILE.WALL, to: TILE.RUBBLE }], + principal: { id: 'orchid-vector', label: 'Orchid Vector', groups: ['corp', 'medical'] }, + site: { id: 'sublevel-3', label: 'Sublevel 3', groups: ['infrastructure', 'hidden'] }, + }), + ], + }); + + const target = campaign.findRosterSite('remembered'); + assert.ok(target); + assert.equal(target!.scoreTarget, true); + assert.equal(target!.tier, 'score'); + assert.equal(target!.mapWidth, 30); + assert.equal(target!.mapHeight, 18); + assert.deepEqual(target!.seenKeys, ['1,1']); + assert.equal(target!.mutationDeltas.length, 1); + assert.equal(campaign.siteRoster.filter(site => site.scoreTarget).length, 1); +}); + +test('P3.M1.3: remembered Score target selection prefers the most recently visited site', () => { + const campaign = new Campaign({ + seed: 42, + rep: 80, + completedJobs: 4, + siteRoster: [ + validSite({ id: 'older', seed: '101', lastVisitedJob: 1 }), + validSite({ id: 'newest', seed: '202', lastVisitedJob: 7 }), + validSite({ id: 'middle', seed: '303', lastVisitedJob: 3 }), + ], + }); + + assert.equal(campaign.siteRoster.find(site => site.scoreTarget)?.id, 'newest'); + assert.equal(campaign.findRosterSite('newest')?.tier, 'score'); +}); + +test('P3.M1.3: Act 2 entry synthesizes a Score target when the roster is empty', () => { + const campaign = new Campaign({ seed: 42, rep: 80, completedJobs: 4 }); + + assert.equal(campaign.arcStage, 'act-2'); + assert.equal(campaign.arc.scoreRevealed, true); + assert.equal(campaign.siteRoster.length, 1); + const target = campaign.siteRoster[0]!; + assert.equal(target.scoreTarget, true); + assert.equal(target.tier, 'score'); + assert.match(target.id, /^score-/); + assert.match(target.seed, /^\d+$/); + assert.ok(target.mapWidth >= 28); + assert.ok(target.mapHeight >= 18); + assert.ok(target.principal?.id); + assert.ok(target.site?.id); +}); + +test('P3.M1.3: designated Score target survives roster eviction at cap', () => { + const campaign = new Campaign({ seed: 42, rep: 80, completedJobs: 4 }); + const targetId = campaign.siteRoster.find(site => site.scoreTarget)!.id; + + for (let i = 0; i < SITE_ROSTER_CAP; i++) { + campaign.addSiteToRoster(validSite({ id: `filler-${i}`, seed: `${1000 + i}` })); + } + + assert.equal(campaign.siteRoster.length, SITE_ROSTER_CAP); + assert.ok(campaign.findRosterSite(targetId), 'Score target must not be evicted'); + assert.equal(campaign.siteRoster.filter(site => site.scoreTarget).length, 1); +}); + +test('P3.M1.3: multiple persisted Score targets crash instead of being normalized', () => { + assert.throws( + () => + new Campaign({ + seed: 42, + rep: 80, + completedJobs: 4, + siteRoster: [ + validSite({ id: 'score-a', seed: '111', tier: 'score', scoreTarget: true }), + validSite({ id: 'score-b', seed: '222', tier: 'score', scoreTarget: true }), + ], + }), + /multiple Score targets/i + ); +}); + // ─── Recruitment ──────────────────────────────────────────────────────── test('generateRecruits returns 1–2 candidates when Rep ≥ 65', () => { From 0e99137e71369a5fed442b096d5d0fc63c5fa2f1 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Mon, 8 Jun 2026 12:46:01 -0700 Subject: [PATCH 05/55] savestate editor --- debug/save.html | 225 +++++++++++++++++++ debug/save.ts | 467 ++++++++++++++++++++++++++++++++++++++++ scripts/copy-assets.mjs | 1 + 3 files changed, 693 insertions(+) create mode 100644 debug/save.html create mode 100644 debug/save.ts 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..4a70c50 --- /dev/null +++ b/debug/save.ts @@ -0,0 +1,467 @@ +/** + * 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'; +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. */ +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/scripts/copy-assets.mjs b/scripts/copy-assets.mjs index 239a007..1447be1 100644 --- a/scripts/copy-assets.mjs +++ b/scripts/copy-assets.mjs @@ -32,6 +32,7 @@ const files = [ // Debug harness pages (their scripts are compiled by tsc into dist/debug/). 'debug/index.html', 'debug/map.html', + 'debug/save.html', ]; // Directories copied recursively. From afc548a5369a4d2ec934ff8658e7c8b7f11de4f8 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 9 Jun 2026 09:38:43 -0700 Subject: [PATCH 06/55] campaign arc reveal in the hub --- components/ContractSelect.ts | 24 ++- components/CrewRoster.ts | 16 ++ debug/save.ts | 58 +++----- docs/phase-3-plan.md | 49 +++--- index.ts | 23 +++ src/game/Campaign.ts | 56 +++++-- src/game/archetypes/Decker.ts | 6 +- src/game/hub/arcSurface.ts | 99 +++++++++++++ src/game/hub/hubReveals.ts | 21 ++- src/game/persistence.ts | 1 + src/types.ts | 5 +- sw-core.js | 1 + tests/unit/game/Campaign.test.ts | 197 ++++++++++++++++--------- tests/unit/game/hub/arcSurface.test.ts | 134 +++++++++++++++++ tests/unit/game/hub/hubReveals.test.ts | 29 ++++ 15 files changed, 563 insertions(+), 156 deletions(-) create mode 100644 src/game/hub/arcSurface.ts create mode 100644 tests/unit/game/hub/arcSurface.test.ts diff --git a/components/ContractSelect.ts b/components/ContractSelect.ts index 2eddd21..9de6f39 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 { isScoreSiteContract } from '/src/game/hub/arcSurface.js'; import { cloneObjective } from '/src/game/hub/Curator.js'; import type { Contract } from '/src/game/hub/Curator.js'; @@ -149,6 +150,12 @@ const CSS = ` white-space: nowrap; } +.known.score-site { + color: #020403; + background: var(--board-warn); + border-color: var(--board-warn); +} + .meta { color: var(--board-dim); font-size: 0.84rem; @@ -185,6 +192,7 @@ const CSS = ` class ContractSelect extends HTMLElement { #contracts: Contract[] = []; + #scoreTargetSiteId: string | null = null; #selectedIndex = 0; #ready = false; #listEl: HTMLElement | null = null; @@ -235,6 +243,14 @@ 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(); + } + show() { this.setAttribute('open', ''); queueMicrotask(() => this.#focusSelected()); @@ -273,7 +289,7 @@ class ContractSelect extends HTMLElement { }), h('span', { className: 'target', textContent: jobTitleCopy(contract) }), ]), - h('div', { className: 'location' }, locationLine(contract)), + h('div', { className: 'location' }, locationLine(contract, this.#scoreTargetSiteId)), h('div', { className: 'meta', textContent: rewardCopy(contract) }), ]), h('div', { className: 'take', textContent: 'TAKE THE JOB' }), @@ -352,12 +368,14 @@ function jobTitleCopy(contract: Contract): string { return `// ${contract.objective.title}${window}`; } -function locationLine(contract: Contract): HTMLElement[] { +function locationLine(contract: Contract, scoreTargetSiteId: string | null): HTMLElement[] { const { principal, site, siteState, locationSiteId } = 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) { + if (isScoreSiteContract(contract, scoreTargetSiteId)) { + nodes.push(h('span', { className: 'known score-site', textContent: 'SCORE SITE' })); + } else if (locationSiteId) { nodes.push(h('span', { className: 'known', textContent: '// known site' })); } return nodes; diff --git a/components/CrewRoster.ts b/components/CrewRoster.ts index 1dd7337..a5abeee 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', * availableRecruits: campaign.availableRecruits, * recruitedThisVisit: campaign.recruitedThisVisit, * }); @@ -116,6 +117,14 @@ const CSS = ` letter-spacing: 0.08em; } +.campaign-status { + margin: -0.35rem 0 0.75rem; + text-align: center; + color: #ffd166; + font-size: 0.82rem; + letter-spacing: 0.08em; +} + .body { display: grid; grid-template-columns: 1fr 1fr; @@ -308,6 +317,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 +339,7 @@ class CrewRoster extends HTMLElement { this.#titleEl = h('h2', { className: 'title', textContent: '── CREW ROSTER ──' }); this.#balanceEl = h('p', { className: 'balance' }); + this.#campaignStatusEl = h('p', { className: 'campaign-status' }); this.#listEl = document.createElement('crew-list') as CrewList; this.#listEl.addEventListener('select', evt => @@ -359,6 +370,7 @@ class CrewRoster extends HTMLElement { this.#panelEl = h('section', { className: 'panel' }, [ this.#titleEl, this.#balanceEl, + this.#campaignStatusEl, body, this.#recruitSectionEl, this.#hintEl, @@ -389,10 +401,12 @@ class CrewRoster extends HTMLElement { crew: CrewMember[], { salvage = emptySalvage(), + campaignStatus = '', availableRecruits = [] as CrewMember[], recruitedThisVisit = false, }: { salvage?: TypedSalvage; + campaignStatus?: string; availableRecruits?: CrewMember[]; recruitedThisVisit?: boolean; } = {} @@ -407,6 +421,8 @@ class CrewRoster extends HTMLElement { // Show typed breakdown + total const total = totalSalvage(this.#salvage); this.#balanceEl!.textContent = `SALVAGE ${total} · ${formatSalvageCompact(this.#salvage)}`; + this.#campaignStatusEl!.textContent = campaignStatus; + this.#campaignStatusEl!.style.display = campaignStatus ? '' : 'none'; this.#recruitFocused = false; this.#selectedRecruitIndex = -1; // Crew list handles its own rendering; selection triggers detail update. diff --git a/debug/save.ts b/debug/save.ts index 4a70c50..d83d662 100644 --- a/debug/save.ts +++ b/debug/save.ts @@ -15,6 +15,7 @@ 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; @@ -79,6 +80,7 @@ function toast(msg: string): void { // --------------------------------------------------------------------------- /** 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; @@ -196,11 +198,7 @@ function qeNumberField( return h('label', null, [document.createTextNode(label), input]); } -function qeBoolField( - label: string, - obj: Record, - key: string -): HTMLElement { +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', () => { @@ -250,12 +248,7 @@ function renderTree(): void { root.appendChild(container); } -function buildNode( - key: string, - value: unknown, - path: string, - startOpen = false -): HTMLElement { +function buildNode(key: string, value: unknown, path: string, startOpen = false): HTMLElement { if (value === null || value === undefined) { return leafNode(key, value, path); } @@ -290,12 +283,7 @@ function objectNode( return details; } -function arrayNode( - key: string, - arr: unknown[], - path: string, - startOpen: boolean -): HTMLElement { +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)]), @@ -313,30 +301,22 @@ function arrayNode( function leafNode(key: string, value: unknown, path: string): HTMLElement { const row = h('div', { className: 'leaf' }); - row.appendChild( - h('span', { className: 'key' }, [document.createTextNode(`${key}: `)]) - ); + row.appendChild(h('span', { className: 'key' }, [document.createTextNode(`${key}: `)])); if (value === null || value === undefined) { - row.appendChild( - h('span', { className: 'val-null' }, [document.createTextNode('null')]) - ); + 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; + 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'); @@ -390,9 +370,7 @@ function renderToolbar(): void { const bar = document.getElementById('toolbar')!; bar.innerHTML = ''; - const saveBtn = h('button', null, [ - document.createTextNode(dirty ? '● Save' : 'Save'), - ]); + const saveBtn = h('button', null, [document.createTextNode(dirty ? '● Save' : 'Save')]); saveBtn.addEventListener('click', saveToStorage); const revertBtn = h('button', null, [document.createTextNode('Revert')]); @@ -434,9 +412,7 @@ function renderToolbar(): void { } }); - const nukeBtn = h('button', { className: 'danger' }, [ - document.createTextNode('Nuke save'), - ]); + 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); diff --git a/docs/phase-3-plan.md b/docs/phase-3-plan.md index 068881f..5cfa057 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 | +| 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** | ~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 3: The Score** | 11–13 | Final prep runs, then the big job. Dual-layer climax: Meatspace breach + Cyberspace penetration. | Everything converges | ### The simstim flip @@ -41,7 +41,7 @@ A new **player archetype** recruited mid-campaign (late Act 1 / start of Act 2), | Milestone | Status | |---|---| -| P3.M1 — Campaign arc structure | 🚧 In progress — P3.M1.1–P3.M1.3 done; Hub arc surface next | +| P3.M1 — Campaign arc structure | 🚧 In progress — P3.M1.1–P3.M1.4 done | | P3.M2 — The Decker archetype | 🚧 In progress — class + Drone Override Hack done; recruitment flow pending | | P3.M3 — Cyberspace grid + ICE | 🔲 Planned | | P3.M4 — Simstim flip (dual-deploy) | 🔲 Planned | @@ -63,7 +63,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). The Score reveal and Decker recruitment share a single `score-reveal` Hub reveal beat rather than using a separate modal. ## Phase 2.5 foundations (prerequisites) @@ -72,12 +72,12 @@ 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 | Phase 3 gates Decker recruitment and Score access at KNOWN (50) | +| **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 @@ -91,16 +91,17 @@ Score-target sites always use roster-stored dimensions (P2.7.M1.5: `mapWidth`, ` - **Arc state:** Campaign save tracks `arcStage` (`act-1` / `act-2` / `act-3` / `score`), run count, and arc-specific flags (`deckerRecruited`, `scoreRevealed`, `clockStarted`, `scoreAttempted`, `scoreCompleted`). 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 successful job count (recommended: `completedJobs >= 4`). Triggers Score reveal and Decker recruitment opportunity. - - Act 2 → Act 3: Decker recruited + Score target site visited at least once + Clock threshold (recommended: `completedJobs >= 9`). Triggers "final prep" phase. + - Act 1 → Act 2: reach `KNOWN` rep tier (50) + minimum successful job count (recommended: `completedJobs >= 4`). Triggers Score reveal and Decker recruitment (same beat). + - 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. - 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. +- **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. The Score reveal uses progressive Hub reveal plumbing so the Curator can present the target as a campaign beat instead of leaving the player to infer it from state changes. +- **Hub surface:** Hub status / Terminal shows current stage, Clock pressure, and Score target label once revealed. User-facing labels use "Stage" (STAGE 1, STAGE 2, etc.) — code and persistence keep `arc`/`arcStage` naming. The Score reveal uses progressive Hub reveal plumbing so the Curator can present the target and the Decker together as a single campaign beat. - **Win/loss conditions:** - **Win:** Complete the Score (extract with objective satisfied from the final mission). - **Loss (flatline):** Entire crew wiped during any run (existing behavior, but now with arc context for the chronicle). @@ -131,17 +132,19 @@ Neural degradation is deferred until Cyberspace is fun enough to deserve a jack- |---|---|---|---| | **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 and rep tier | -| **P3.M1.3 Score target** | ✅ Done | Promote one roster site to `tier: 'score'`; preserve through eviction | exactly-one target, no eviction at roster cap, synthetic target when roster empty | -| **P3.M1.4 Hub arc surface** | 🔲 Planned | Curator presents the Score reveal; Hub / Terminal shows act and Score target; contract selection labels Score-site jobs | one-shot reveal, status render, Score-site badge | +| **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 presents Score + Decker reveal; Hub / Terminal shows stage and Score target; contract selection labels Score-site jobs | one-shot reveal, status render, Score-site badge | | **P3.M1.5 Clock** | 🔲 Planned | Start heat after threshold; hard loss at deadline; show status | heat math, deadline loss, no loss after Score attempt | -| **P3.M1.6 Curator bias** | 🔲 Planned | Pass campaign-derived arc context; bias board slots by act and score target | seeded boards show expected `arcStage` and target-site frequency | +| **P3.M1.6 Curator bias** | 🔲 Planned | 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** | 🔲 Planned | Hub action creates the special Score contract in Act 3 only | availability gates, deployment path, attempted flag | **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 `TRUSTED` Rep plus `completedJobs >= 4` and sets `scoreRevealed`; successful extractions increment `completedJobs`, abort extractions do not. Act 2 advances to Act 3 once a Decker has joined the crew, `completedJobs >= 9`, and a score-target roster site has a prior visit marker (`lastVisitedJob > 0`). M1.2 does not synthesize or promote the Score target — that remains P3.M1.3. +**P3.M1.2 implementation note:** Hub entry now runs a monotonic arc transition evaluator. Act 1 advances to Act 2 at `KNOWN` Rep (50) plus `completedJobs >= 4`, sets `scoreRevealed`, and auto-assigns a Decker to the crew. 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 now guarantees exactly one Score target. If a roster site exists at Act 2 entry, the most recently visited site is promoted to `tier: 'score'` with `scoreTarget: true`, preserving its seed, dimensions, mutation deltas, and exploration memory. If the roster is empty, Campaign synthesizes a CRITICAL-footprint score site from Curator lexicon principal/site tokens instead of deferring reveal. Multiple persisted score targets, or score-tier sites missing `scoreTarget`, throw during Hub-entry arc evaluation. +**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 now player-visible. A one-shot `score-reveal` Hub reveal lets the Curator name the target, introduce the Decker, and teach the `SCORE SITE` board badge — all in a single narrative beat. The Hub status row and Terminal crew roster show the current stage label (user-facing "STAGE N") plus Score target once revealed, and `` highlights contracts whose `locationSiteId` matches the campaign Score target. 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. **Acceptance:** @@ -166,7 +169,7 @@ Neural degradation is deferred until Cyberspace is fun enough to deserve a jack- - **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. @@ -175,7 +178,7 @@ Neural degradation is deferred until Cyberspace is fun enough to deserve a jack- - ✅ 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: gated by arc state; not available in Act 1; golden-path test for recruitment flow. **Ready after P3.M1.4 confirms the shared Hub reveal surface.** Decker is registered but excluded from `ARCHETYPE_IDS` and `RECRUIT_ARCHETYPE_POOL` so random recruitment can't roll one early. +- 🔲 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`. diff --git a/index.ts b/index.ts index 88687d4..b51aab6 100644 --- a/index.ts +++ b/index.ts @@ -74,6 +74,7 @@ import { hasLineOfSight } from '/src/game/LineOfSight.js'; import { ITEM_ID, getItemById } from '/src/game/items.js'; import type { CampaignSnapshot } from '/src/game/persistence.js'; import type { Contract } from '/src/game/hub/Curator.js'; +import { formatHubArcStatus, scoreTargetSiteId } from '/src/game/hub/arcSurface.js'; import { isTerminalAccessible } from '/src/game/hub/hubReveals.js'; import type { Crew } from '/src/game/Crew.js'; import { resolveEntityLabel, type Entity } from '/src/game/Entity.js'; @@ -125,6 +126,7 @@ type RunBriefingElement = ModalElement & { }; type ContractSelectElement = ModalElement & { setContracts(contracts: Contract[]): void; + setScoreTargetSiteId(siteId: string | null): void; }; type CrashDumpElement = ModalElement & { setTelemetry(telemetry: Record): void; @@ -146,6 +148,7 @@ type CrewRosterElement = ModalElement & { crew: Crew[], opts?: { salvage?: TypedSalvage; + campaignStatus?: string; availableRecruits?: Crew[]; recruitedThisVisit?: boolean; } @@ -712,6 +715,7 @@ function presentCrewRoster() { campaign.backfillRecruitsIfEligible(); crewRosterEl.setCrew(campaign.crew, { salvage: campaign.salvage, + campaignStatus: formatHubArcStatus(campaign), availableRecruits: campaign.availableRecruits, recruitedThisVisit: campaign.recruitedThisVisit, }); @@ -740,6 +744,7 @@ function presentBriefing(contract: Contract) { } function presentContractSelect(contracts: Contract[]) { + contractSelectEl.setScoreTargetSiteId(campaign ? scoreTargetSiteId(campaign) : null); contractSelectEl.setContracts(contracts); contractSelectEl.show(); } @@ -2006,6 +2011,22 @@ function buildCombatHudSnapshot(scene: ShellScene | null): CombatHudSummaryInput }; } +function buildHubHudRows(scene: ShellScene | null) { + if (!campaign || scene?.state !== CAMPAIGN_STATE.HUB) return undefined; + return [ + { + text: formatHubArcStatus(campaign), + anchor: 'top-left' as const, + row: 1, + color: '#ffd166', + glowColor: '#ffd166', + accentColor: 'rgba(255, 209, 102, 0.5)', + uppercase: true, + maxWidth: 520, + }, + ]; +} + function paint(stateHint: InputState = activeInputState()): void { const run = currentScene(); if (canvas.hidden) { @@ -2031,6 +2052,7 @@ function paint(stateHint: InputState = activeInputState()): void { lookCursor, principalId, locationLabel: currentLocationLabel(), + hudRows: buildHubHudRows(run), combatHud: buildCombatHudSnapshot(run), }); crt.alertTint = run.state === RUN_STATE.COMBAT && run.world.alarmActive; @@ -2079,6 +2101,7 @@ function statusLine(state: InputState): string { } else { if (!campaign) return stateLabel(); const repLabel = REP_LABEL.find(b => campaign!.rep >= b.min)?.label ?? 'UNKNOWN'; + context = escapeHtml(formatHubArcStatus(campaign)); // Hub identity drops the typed-salvage compact tag — // the inventory overlay (`i` in Hub) is the canonical wallet view with // full bucket names. Total Cred / Rep / crew counts stay on this line. diff --git a/src/game/Campaign.ts b/src/game/Campaign.ts index f9415b6..32a413c 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -61,6 +61,10 @@ import type { RunResult, Outcome } from './Run.js'; export const SITE_ROSTER_CAP = 6; export const ARC_ACT_2_MIN_COMPLETED_JOBS = 4; export const ARC_ACT_3_MIN_COMPLETED_JOBS = 9; +/** Minimum *living* crew size before the Score's final-prep stage unlocks. Starter 2 + Decker = 3, so 4 requires at least one additional recruit who hasn't flatlined. */ +export const ARC_ACT_3_MIN_CREW_ALIVE = 4; +/** Visited sites sharing the Score target's principal required for Act 3. Includes the target itself. */ +export const ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED = 3; const SYNTHETIC_SCORE_TARGET_DIFFICULTY = CONTRACT_DIFFICULTY.CRITICAL; export const CAMPAIGN_STATE = Object.freeze({ @@ -999,7 +1003,11 @@ export class Campaign { this.arc.scoreRevealed = true; } + // Invariant: once the Score is revealed, the Curator's Decker is on the + // crew. Holds for fresh act-2 transitions and for restores/constructs that + // land in a revealed state with a crew that's missing the operative. if (this.arc.scoreRevealed) { + this.#assignDecker(); this.#ensureScoreTargetDesignated(); } @@ -1010,17 +1018,44 @@ export class Campaign { #qualifiesForAct2(): boolean { return ( - repTierForRep(this.rep).id === REP_TIER.TRUSTED && + repTierForRep(this.rep).id === REP_TIER.KNOWN && this.completedJobs >= ARC_ACT_2_MIN_COMPLETED_JOBS ); } #qualifiesForAct3(): boolean { - return ( - this.arc.deckerRecruited && - this.completedJobs >= ARC_ACT_3_MIN_COMPLETED_JOBS && - this.siteRoster.some(site => site.scoreTarget && site.lastVisitedJob > 0) - ); + if (this.completedJobs < ARC_ACT_3_MIN_COMPLETED_JOBS) return false; + const livingCrew = this.crew.filter(m => !m.flatlined).length; + if (livingCrew < ARC_ACT_3_MIN_CREW_ALIVE) return false; + + const scoreTarget = this.siteRoster.find(site => site.scoreTarget); + if (!scoreTarget?.principal?.id) return false; + + const principalId = scoreTarget.principal.id; + const visitedPrincipalSites = this.siteRoster.filter( + site => site.principal?.id === principalId && site.lastVisitedJob > 0 + ).length; + return visitedPrincipalSites >= ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED; + } + + /** + * Assign a Decker to the crew as part of the Act 2 narrative beat. The Curator + * "finds" a Decker for the player — no choice modal, just a named operative. + * Callsign is deduped against existing crew. Idempotent: skips if a Decker is + * already on the roster (e.g. from a restored save that already transitioned). + */ + #assignDecker(): void { + // Guard on the actual roster, not the `deckerRecruited` flag: a restored + // save (or a constructed campaign) can carry the flag while the crew lacks + // the operative, and the Score reveal requires a real Decker present. + if (this.crew.some(member => member.archetype === 'Decker')) return; + + const decker = buildCrewMember('decker', { x: 0, y: 0 }, this.rng, { + id: `crew-decker-${this.rng.intRange(0, 0xffff).toString(16)}`, + excludeCallsigns: this.allUsedCallsigns(), + }); + this.crew.push(decker); + this.arc.deckerRecruited = true; } #ensureScoreTargetDesignated(): void { @@ -1039,18 +1074,11 @@ export class Campaign { return; } - const target = this.#selectRememberedScoreTarget() ?? this.#synthesizeScoreTarget(); + const target = this.#synthesizeScoreTarget(); target.scoreTarget = true; target.tier = 'score'; } - #selectRememberedScoreTarget(): LocationSite | null { - if (this.siteRoster.length === 0) return null; - return [...this.siteRoster].sort( - (a, b) => b.lastVisitedJob - a.lastVisitedJob || a.id.localeCompare(b.id) - )[0]!; - } - #synthesizeScoreTarget(): LocationSite { const seed = this.rng.intRange(0, 0x7fffffff); const principal = this.rng.pick( diff --git a/src/game/archetypes/Decker.ts b/src/game/archetypes/Decker.ts index 738e314..188a374 100644 --- a/src/game/archetypes/Decker.ts +++ b/src/game/archetypes/Decker.ts @@ -11,16 +11,16 @@ import type { Rng } from '../../rng.js'; * match the Cyberspace specialist fantasy (Phase 3). */ export const CALLSIGNS = Object.freeze([ - 'Case', + 'Phreak', 'Jack', 'Flatline', 'Blitz', 'Is0bel', 'Bytesize', - 'Crash', + 'AcidBurn', 'Ang3l', 'Z0ne', - 'Neo', + 'Tr1nity', ]); /** diff --git a/src/game/hub/arcSurface.ts b/src/game/hub/arcSurface.ts new file mode 100644 index 0000000..bc7f4b8 --- /dev/null +++ b/src/game/hub/arcSurface.ts @@ -0,0 +1,99 @@ +import type { Contract } from './Curator.js'; +import type { Campaign } from '../Campaign.js'; +import type { CampaignArcStage, LocationSite } from '../../types.js'; +import type { Decker } from '../archetypes/Decker.js'; +import type { Crew } from '../Crew.js'; + +type ArcSurfaceCampaign = Pick; + +const ARC_STAGE_LABELS: Record = Object.freeze({ + 'act-1': 'STAGE 1: STREET LEVEL', + 'act-2': 'STAGE 2: CASING', + 'act-3': 'STAGE 3: FINAL PREP', + score: 'THE SCORE', +}); + +export function findScoreTargetSite(sites: readonly LocationSite[]): LocationSite | null { + const targets = sites.filter(site => site.scoreTarget); + if (targets.length > 1) { + throw new Error('arcSurface: multiple Score targets in site roster'); + } + return targets[0] ?? null; +} + +export function scoreTargetDisplayName(site: LocationSite): string { + const principal = cleanLabel(site.principal?.label); + const place = cleanLabel(site.site?.label); + if (principal && place) return `${principal} ${place}`; + if (principal) return principal; + if (place) return place; + return cleanSiteLabel(site.label); +} + +export function formatArcStageLabel(stage: CampaignArcStage): string { + const label = ARC_STAGE_LABELS[stage]; + if (!label) { + throw new Error(`arcSurface: unknown arc stage "${stage}"`); + } + return label; +} + +export function formatHubArcStatus(campaign: ArcSurfaceCampaign): string { + const parts = [formatArcStageLabel(campaign.arc.arcStage)]; + if (campaign.arc.scoreRevealed) { + const target = findScoreTargetSite(campaign.siteRoster); + if (!target) { + throw new Error('arcSurface: score revealed without a Score target site'); + } + parts.push(`SCORE: ${scoreTargetDisplayName(target)}`); + } + return parts.join(' | '); +} + +export function scoreRevealLines(campaign: ArcSurfaceCampaign): readonly string[] { + if (!campaign.arc.scoreRevealed) { + throw new Error('arcSurface: cannot build Score reveal copy before scoreRevealed'); + } + const target = findScoreTargetSite(campaign.siteRoster); + if (!target) { + throw new Error('arcSurface: cannot build Score reveal copy without a Score target'); + } + const targetName = scoreTargetDisplayName(target); + const deckerName = findDecker(campaign.crew).callsign; + return [ + `CURATOR: We found it. ${targetName}. The Score has a door.`, + 'CURATOR: Contracts touching that site are casing work now. Watch for SCORE SITE on the board.', + `CURATOR: You'll need a Decker to open the digital side. ${deckerName} just joined your crew.`, + ]; +} + +export function findDecker(crew: readonly Crew[]): Decker { + const decker = crew.find(crew => crew.archetype === 'Decker'); + if (!decker) { + throw new Error('arcSurface: cannot build Score reveal copy without a Decker'); + } + return decker as Decker; +} + +export function isScoreSiteContract( + contract: Contract, + scoreTargetSiteId: string | null | undefined +): boolean { + return !!scoreTargetSiteId && contract.context.locationSiteId === scoreTargetSiteId; +} + +export function scoreTargetSiteId(campaign: ArcSurfaceCampaign): string | null { + return findScoreTargetSite(campaign.siteRoster)?.id ?? null; +} + +function cleanLabel(label: string | undefined): string { + return label?.trim() ?? ''; +} + +function cleanSiteLabel(label: string): string { + return label + .replace(/^\/\//, '') + .replace(/\/\/$/, '') + .replace(/\s+-\s+Score target$/i, '') + .trim(); +} diff --git a/src/game/hub/hubReveals.ts b/src/game/hub/hubReveals.ts index 4013a78..05c25c5 100644 --- a/src/game/hub/hubReveals.ts +++ b/src/game/hub/hubReveals.ts @@ -8,6 +8,7 @@ import { REP } from '../constants.js'; import { totalSalvage } from '../salvage.js'; +import { scoreRevealLines } from './arcSurface.js'; import type { Campaign } from '../Campaign.js'; export type HubReveals = { @@ -17,9 +18,11 @@ export type HubReveals = { /** Rep-gated recruitment channel on the terminal. */ terminalRecruitmentExplained?: boolean; clinicIntroduced?: boolean; + /** Curator has presented the Phase 3 Score target reveal. */ + scoreBriefingPresented?: boolean; }; -export type HubRevealId = 'finn' | 'terminal' | 'terminal-recruit' | 'clinic'; +export type HubRevealId = 'finn' | 'terminal' | 'terminal-recruit' | 'clinic' | 'score-reveal'; export type HubRevealMessage = { id: HubRevealId; @@ -34,7 +37,7 @@ type HubRevealDefinition = { flag: HubRevealFlag; title: string; qualifies: (campaign: Campaign) => boolean; - lines: readonly string[]; + lines: readonly string[] | ((campaign: Campaign) => readonly string[]); }; const HUB_REVEAL_DEFINITIONS: readonly HubRevealDefinition[] = [ @@ -50,6 +53,15 @@ const HUB_REVEAL_DEFINITIONS: readonly HubRevealDefinition[] = [ 'CURATOR: [Space] at the ‡ glyph to review operatives, gear, and salvage.', ], }, + { + id: 'score-reveal', + flag: 'scoreBriefingPresented', + title: '── THE SCORE / THE DECKER ──', + qualifies(campaign) { + return campaign.arc.scoreRevealed; + }, + lines: scoreRevealLines, + }, { id: 'finn', flag: 'finnIntroduced', @@ -106,6 +118,7 @@ export function normalizeHubReveals(raw: unknown, context = 'hubReveals'): HubRe 'terminalExplained', 'terminalRecruitmentExplained', 'clinicIntroduced', + 'scoreBriefingPresented', ] as const) { if (record[key] === undefined) continue; if (typeof record[key] !== 'boolean') { @@ -148,6 +161,7 @@ export function snapshotHubReveals(reveals: HubReveals): HubReveals { if (reveals.finnIntroduced) out.finnIntroduced = true; if (reveals.terminalExplained) out.terminalExplained = true; if (reveals.clinicIntroduced) out.clinicIntroduced = true; + if (reveals.scoreBriefingPresented) out.scoreBriefingPresented = true; return out; } @@ -176,7 +190,8 @@ export function applyFirstHubReveal(campaign: Campaign): HubRevealMessage | null if (campaign.hubReveals[def.flag]) continue; if (!def.qualifies(campaign)) continue; campaign.hubReveals[def.flag] = true; - return { id: def.id, title: def.title, lines: def.lines }; + const lines = typeof def.lines === 'function' ? def.lines(campaign) : def.lines; + return { id: def.id, title: def.title, lines }; } return null; } diff --git a/src/game/persistence.ts b/src/game/persistence.ts index cb0b4dc..b6350e1 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -791,6 +791,7 @@ export type HubRevealsSnapshot = { terminalExplained?: boolean; terminalRecruitmentExplained?: boolean; clinicIntroduced?: boolean; + scoreBriefingPresented?: boolean; }; /** diff --git a/src/types.ts b/src/types.ts index 8695a63..e70c2c5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -206,8 +206,7 @@ export type LocationToken = { id: string; label: string; groups: string[] }; * (objective/asset/action) is rolled fresh. `label` is the name from the most * recent generation. * - * `tier` / `scoreTarget` reserve one slot for Phase 3's "Score target" site; - * `scoreTarget` is always false until Phase 3. + * `tier` / `scoreTarget` reserve one slot for Phase 3's "Score target" site. */ export type LocationSite = { /** Stable, seed-derived id — the roster key. */ @@ -222,7 +221,7 @@ export type LocationSite = { label: string; /** Roster tier — `'score'` is reserved for Phase 3 and never evicted. */ tier: 'score' | 'roster'; - /** Phase 3 hook — always false until Phase 3. */ + /** Phase 3 hook — true for the single campaign Score target. */ scoreTarget: boolean; /** Accumulated terrain mutations replayed on revisit. */ mutationDeltas: TileDelta[]; diff --git a/sw-core.js b/sw-core.js index 777c08d..a8dbe81 100644 --- a/sw-core.js +++ b/sw-core.js @@ -89,6 +89,7 @@ const CacheConfig = { '/src/game/Grid.js', '/src/game/Hostile.js', '/src/game/hub/Curator.js', + '/src/game/hub/arcSurface.js', '/src/game/hub/Clinic.js', '/src/game/hub/Finn.js', '/src/game/hub/hubReveals.js', diff --git a/tests/unit/game/Campaign.test.ts b/tests/unit/game/Campaign.test.ts index 2547a9e..d92cf79 100644 --- a/tests/unit/game/Campaign.test.ts +++ b/tests/unit/game/Campaign.test.ts @@ -650,24 +650,30 @@ test('restoreCampaign rejects corrupt Phase 3 arc snapshots', () => { }); test('P3.M1.2: Act 1 does not advance until Rep and completed job gates both qualify', () => { - const lowRep = new Campaign({ seed: 42, rep: 79, completedJobs: 4 }); + const lowRep = new Campaign({ seed: 42, rep: 49, completedJobs: 4 }); assert.equal(lowRep.arcStage, 'act-1'); assert.equal(lowRep.arc.scoreRevealed, false); - const lowJobs = new Campaign({ seed: 43, rep: 80, completedJobs: 3 }); + const lowJobs = new Campaign({ seed: 43, rep: 60, completedJobs: 3 }); assert.equal(lowJobs.arcStage, 'act-1'); assert.equal(lowJobs.arc.scoreRevealed, false); }); -test('P3.M1.2: Act 1 advances to Act 2 at TRUSTED Rep plus four completed jobs', () => { - const campaign = new Campaign({ seed: 42, rep: 80, completedJobs: 4 }); +test('P3.M1.2: Act 1 advances to Act 2 at KNOWN Rep plus four completed jobs and assigns Decker', () => { + const campaign = new Campaign({ seed: 42, rep: 60, completedJobs: 4 }); assert.equal(campaign.arcStage, 'act-2'); assert.equal(campaign.arc.scoreRevealed, true); + assert.equal(campaign.arc.deckerRecruited, true); + + const decker = campaign.crew.find(m => m.archetype === 'Decker'); + assert.ok(decker, 'Decker should join crew at Act 2 entry'); + assert.ok(decker!.callsign, 'Decker should have a unique callsign'); + assert.match(decker!.id, /^crew-decker-/); }); test('P3.M1.2: successful extraction advances Act 2 when the final gate is crossed', () => { - const campaign = new Campaign({ seed: 42, rep: 79, completedJobs: 3 }); + const campaign = new Campaign({ seed: 42, rep: 49, completedJobs: 3 }); const member = campaign.crew[0]; const run = campaign.deployCrewMember( member.id, @@ -678,9 +684,14 @@ test('P3.M1.2: successful extraction advances Act 2 when the final gate is cross campaign.onJobEnd({ outcome: OUTCOME.EXIT, completed: true }); assert.equal(campaign.completedJobs, 4); - assert.equal(campaign.rep, 80); + assert.equal(campaign.rep, 50); assert.equal(campaign.arcStage, 'act-2'); assert.equal(campaign.arc.scoreRevealed, true); + assert.equal(campaign.arc.deckerRecruited, true); + assert.ok( + campaign.crew.some(m => m.archetype === 'Decker'), + 'Decker should join crew when Act 2 gate is crossed' + ); }); test('P3.M1.2: abort extraction does not count as a completed arc job', () => { @@ -696,80 +707,147 @@ test('P3.M1.2: abort extraction does not count as a completed arc job', () => { assert.equal(campaign.arc.scoreRevealed, false); }); -test('P3.M1.2: Act 2 waits for Decker recruitment, Score-site visit, and nine jobs', () => { - const decker = buildCrewMember('decker', { x: 0, y: 0 }, new Rng(101), { - id: 'crew-decker', +test('P3.M1.2: Act 2 gates — each Act 3 condition checked independently', () => { + // Act 3 requires: completedJobs >= 9, 4 living crew, and 3 visited same-principal sites. + // Default test crew: buildCrew() trio + auto-Decker = 4 living. + const scorePrincipal = { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }; + + // Gate: not enough jobs (crew and sites satisfied by default) + const tooFewJobs = new Campaign({ + seed: 44, + rep: 60, + completedJobs: 8, + siteRoster: [ + validSite({ + id: 'score', + seed: '100', + tier: 'score', + scoreTarget: true, + lastVisitedJob: 5, + principal: scorePrincipal, + }), + validSite({ id: 'case-1', seed: '101', lastVisitedJob: 6, principal: scorePrincipal }), + validSite({ id: 'case-2', seed: '102', lastVisitedJob: 7, principal: scorePrincipal }), + ], }); - const missingDecker = new Campaign({ - seed: 42, - rep: 80, + assert.equal(tooFewJobs.arcStage, 'act-2', 'blocks on job count'); + + // Gate: not enough living crew (need 4 non-flatlined). + // Start in Act 1 (low rep), flatline a member, then cross the Act 2 threshold + // so the Act 3 check sees only 3 living crew. + const attrition = new Campaign({ + seed: 45, + rep: 20, completedJobs: 9, siteRoster: [ validSite({ id: 'score', - seed: '12345', + seed: '100', tier: 'score', scoreTarget: true, lastVisitedJob: 5, + principal: scorePrincipal, }), + validSite({ id: 'case-1', seed: '101', lastVisitedJob: 6, principal: scorePrincipal }), + validSite({ id: 'case-2', seed: '102', lastVisitedJob: 7, principal: scorePrincipal }), ], }); - assert.equal(missingDecker.arcStage, 'act-2'); - - const missingTargetVisit = new Campaign({ - seed: 43, - rep: 80, + assert.equal(attrition.arcStage, 'act-1', 'starts in Act 1 with low rep'); + attrition.crew[0].flatlined = true; + attrition.rep = 60; + attrition.enterHub(); + assert.equal(attrition.crew.length, 4, 'buildCrew trio + auto-Decker'); + assert.equal(attrition.crew.filter(m => !m.flatlined).length, 3, 'only 3 living'); + assert.equal(attrition.arcStage, 'act-2', 'blocks on living crew count'); + + // Gate: not enough same-principal visited sites (2 of 3 required) + const tooCasual = new Campaign({ + seed: 46, + rep: 60, completedJobs: 9, - crew: [decker], - }); - assert.equal(missingTargetVisit.arcStage, 'act-2'); - - const tooFewJobs = new Campaign({ - seed: 44, - rep: 80, - completedJobs: 8, - crew: [decker], siteRoster: [ validSite({ id: 'score', - seed: '12345', + seed: '100', tier: 'score', scoreTarget: true, lastVisitedJob: 5, + principal: scorePrincipal, }), + validSite({ id: 'case-1', seed: '101', lastVisitedJob: 6, principal: scorePrincipal }), + // Only 2 visited sites for this principal — need 3 ], }); - assert.equal(tooFewJobs.arcStage, 'act-2'); -}); + assert.equal(tooCasual.arcStage, 'act-2', 'blocks on principal site visits'); -test('P3.M1.2: Act 2 advances to Act 3 once Decker, Score-site visit, and nine jobs qualify', () => { - const decker = buildCrewMember('decker', { x: 0, y: 0 }, new Rng(102), { - id: 'crew-decker', + // Gate: score target never visited (synthesized, lastVisitedJob: 0) + const noTargetVisit = new Campaign({ + seed: 47, + rep: 60, + completedJobs: 9, }); + const scoreTarget = noTargetVisit.siteRoster.find(s => s.scoreTarget); + assert.ok(scoreTarget); + assert.equal(scoreTarget!.lastVisitedJob, 0, 'synthesized target starts unvisited'); + assert.equal(noTargetVisit.arcStage, 'act-2', 'blocks when score target unvisited'); +}); + +test('P3.M1.2: Act 2 advances to Act 3 with 4 living crew and 3 visited same-principal sites', () => { + const scorePrincipal = { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }; const campaign = new Campaign({ seed: 42, - rep: 80, + rep: 60, completedJobs: 9, - crew: [decker], siteRoster: [ validSite({ id: 'score', - seed: '12345', + seed: '100', tier: 'score', scoreTarget: true, lastVisitedJob: 5, + principal: scorePrincipal, }), + validSite({ id: 'case-1', seed: '101', lastVisitedJob: 6, principal: scorePrincipal }), + validSite({ id: 'case-2', seed: '102', lastVisitedJob: 7, principal: scorePrincipal }), ], }); - assert.equal(campaign.arc.deckerRecruited, true); + const living = campaign.crew.filter(m => !m.flatlined).length; + assert.ok(living >= 4, `need 4 living crew, have ${living}`); assert.equal(campaign.arcStage, 'act-3'); }); -test('P3.M1.3: Act 2 entry promotes one remembered site as the Score target', () => { +test('P3.M2: Decker assignment is idempotent — restored save with existing Decker does not duplicate', () => { + const decker = buildCrewMember('decker', { x: 0, y: 0 }, new Rng(102), { + id: 'crew-decker', + }); + const campaign = new Campaign({ + seed: 42, + rep: 60, + completedJobs: 4, + crew: [decker], + }); + + assert.equal(campaign.arcStage, 'act-2'); + const deckers = campaign.crew.filter(m => m.archetype === 'Decker'); + assert.equal(deckers.length, 1, 'should not duplicate Decker when one already exists'); +}); + +test('P3.M2: Decker callsign does not collide with existing crew', () => { + const campaign = new Campaign({ seed: 42, rep: 60, completedJobs: 4 }); + const decker = campaign.crew.find(m => m.archetype === 'Decker'); + assert.ok(decker); + const otherCallsigns = campaign.crew.filter(m => m.archetype !== 'Decker').map(m => m.callsign); + assert.ok( + !otherCallsigns.includes(decker!.callsign!), + `Decker callsign "${decker!.callsign}" should not collide with existing crew` + ); +}); + +test('P3.M1.3: Act 2 entry always synthesizes a new CRITICAL-tier Score target', () => { const campaign = new Campaign({ seed: 42, - rep: 80, + rep: 60, completedJobs: 4, siteRoster: [ validSite({ @@ -786,35 +864,22 @@ test('P3.M1.3: Act 2 entry promotes one remembered site as the Score target', () ], }); - const target = campaign.findRosterSite('remembered'); - assert.ok(target); - assert.equal(target!.scoreTarget, true); + const remembered = campaign.findRosterSite('remembered'); + assert.ok(remembered); + assert.equal(remembered!.scoreTarget, false, 'existing roster site is not promoted'); + assert.notEqual(remembered!.tier, 'score'); + + const target = campaign.siteRoster.find(site => site.scoreTarget); + assert.ok(target, 'synthesized Score target exists'); + assert.match(target!.id, /^score-/); assert.equal(target!.tier, 'score'); - assert.equal(target!.mapWidth, 30); - assert.equal(target!.mapHeight, 18); - assert.deepEqual(target!.seenKeys, ['1,1']); - assert.equal(target!.mutationDeltas.length, 1); + assert.ok(target!.mapWidth >= 28, 'Score target uses CRITICAL-tier dimensions'); + assert.ok(target!.mapHeight >= 18); assert.equal(campaign.siteRoster.filter(site => site.scoreTarget).length, 1); }); -test('P3.M1.3: remembered Score target selection prefers the most recently visited site', () => { - const campaign = new Campaign({ - seed: 42, - rep: 80, - completedJobs: 4, - siteRoster: [ - validSite({ id: 'older', seed: '101', lastVisitedJob: 1 }), - validSite({ id: 'newest', seed: '202', lastVisitedJob: 7 }), - validSite({ id: 'middle', seed: '303', lastVisitedJob: 3 }), - ], - }); - - assert.equal(campaign.siteRoster.find(site => site.scoreTarget)?.id, 'newest'); - assert.equal(campaign.findRosterSite('newest')?.tier, 'score'); -}); - -test('P3.M1.3: Act 2 entry synthesizes a Score target when the roster is empty', () => { - const campaign = new Campaign({ seed: 42, rep: 80, completedJobs: 4 }); +test('P3.M1.3: Act 2 entry synthesizes a Score target even when roster is empty', () => { + const campaign = new Campaign({ seed: 42, rep: 60, completedJobs: 4 }); assert.equal(campaign.arcStage, 'act-2'); assert.equal(campaign.arc.scoreRevealed, true); @@ -831,7 +896,7 @@ test('P3.M1.3: Act 2 entry synthesizes a Score target when the roster is empty', }); test('P3.M1.3: designated Score target survives roster eviction at cap', () => { - const campaign = new Campaign({ seed: 42, rep: 80, completedJobs: 4 }); + const campaign = new Campaign({ seed: 42, rep: 60, completedJobs: 4 }); const targetId = campaign.siteRoster.find(site => site.scoreTarget)!.id; for (let i = 0; i < SITE_ROSTER_CAP; i++) { @@ -848,7 +913,7 @@ test('P3.M1.3: multiple persisted Score targets crash instead of being normalize () => new Campaign({ seed: 42, - rep: 80, + rep: 60, completedJobs: 4, siteRoster: [ validSite({ id: 'score-a', seed: '111', tier: 'score', scoreTarget: true }), diff --git a/tests/unit/game/hub/arcSurface.test.ts b/tests/unit/game/hub/arcSurface.test.ts new file mode 100644 index 0000000..670ba29 --- /dev/null +++ b/tests/unit/game/hub/arcSurface.test.ts @@ -0,0 +1,134 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { CONTRACT_DIFFICULTY } from '../../../../src/game/constants.js'; +import { buildContractRecipeFixture } from '../../../../src/game/hub/Curator.js'; +import { + findDecker, + findScoreTargetSite, + formatArcStageLabel, + formatHubArcStatus, + isScoreSiteContract, + scoreRevealLines, + scoreTargetDisplayName, + scoreTargetSiteId, +} from '../../../../src/game/hub/arcSurface.js'; +import { Decker } from '../../../../src/game/archetypes/Decker.js'; +import { Merc } from '../../../../src/game/archetypes/Merc.js'; +import type { CampaignArc } from '../../../../src/game/Campaign.js'; +import type { LocationSite, LocationToken } from '../../../../src/types.js'; + +const principal = token('matsuda', 'Matsuda', ['corp']); +const site = token('server-farm', 'server farm', ['corp', 'data']); + +function token(id: string, label: string, groups: string[]): LocationToken { + return { id, label, groups }; +} + +function arc(overrides: Partial = {}): CampaignArc { + return { + arcStage: 'act-1', + deckerRecruited: false, + scoreRevealed: false, + clockStarted: false, + scoreAttempted: false, + scoreCompleted: false, + ...overrides, + }; +} + +function scoreSite(overrides: Partial = {}): LocationSite { + return { + id: 'score-site', + seed: '123', + mapWidth: 32, + mapHeight: 20, + label: '// Matsuda server farm - Score target', + tier: 'score', + scoreTarget: true, + mutationDeltas: [], + seenKeys: [], + lastVisitedJob: 4, + principal, + site, + ...overrides, + }; +} + +test('formatHubArcStatus shows act label and Score target once revealed', () => { + const campaign = { + arc: arc({ arcStage: 'act-2', scoreRevealed: true }), + siteRoster: [scoreSite()], + }; + assert.equal(formatHubArcStatus(campaign), 'STAGE 2: CASING | SCORE: Matsuda server farm'); +}); + +test('formatHubArcStatus throws when revealed state has no Score target', () => { + assert.throws( + () => + formatHubArcStatus({ + arc: arc({ arcStage: 'act-2', scoreRevealed: true }), + siteRoster: [], + }), + /score revealed without a Score target/i + ); +}); + +test('Score target helpers reject multiple targets instead of guessing', () => { + const sites = [scoreSite({ id: 'a' }), scoreSite({ id: 'b' })]; + assert.throws(() => findScoreTargetSite(sites), /multiple Score targets/i); + assert.throws(() => scoreTargetSiteId({ arc: arc(), siteRoster: sites }), /multiple Score/i); +}); + +test('score reveal copy names the target and points at job-board badges', () => { + const lines = scoreRevealLines({ + arc: arc({ arcStage: 'act-2', scoreRevealed: true }), + siteRoster: [scoreSite()], + crew: [new Decker({ id: 'decker', x: 0, y: 0, callsign: 'Case' })], + }); + assert.match(lines.join('\n'), /Matsuda server farm/); + assert.match(lines.join('\n'), /SCORE SITE/); + assert.match(lines.join('\n'), /Case/); +}); + +test('scoreTargetDisplayName falls back to sanitized roster label', () => { + assert.equal( + scoreTargetDisplayName(scoreSite({ principal: undefined, site: undefined })), + 'Matsuda server farm' + ); +}); + +test('formatArcStageLabel has stable player-facing labels', () => { + assert.equal(formatArcStageLabel('act-1'), 'STAGE 1: STREET LEVEL'); + assert.equal(formatArcStageLabel('act-3'), 'STAGE 3: FINAL PREP'); + assert.equal(formatArcStageLabel('score'), 'THE SCORE'); +}); + +test('isScoreSiteContract matches contract locationSiteId to the Score target id', () => { + const contract = buildContractRecipeFixture({ + recipeId: 'terminal-slice', + principalId: 'matsuda', + siteId: 'server-farm', + assetId: 'identity-spool', + actionId: 'slice', + difficulty: CONTRACT_DIFFICULTY.STANDARD, + seed: 11, + }); + contract.context.locationSiteId = 'score-site'; + + assert.equal(isScoreSiteContract(contract, 'score-site'), true); + assert.equal(isScoreSiteContract(contract, 'other-site'), false); + assert.equal(isScoreSiteContract(contract, null), false); +}); + +test('findDecker throws when no Decker is present', () => { + const merc = new Merc({ id: 'merc', x: 0, y: 0, callsign: 'Glitch' }); + assert.throws(() => findDecker([merc]), /without a Decker/i); + assert.throws(() => findDecker([]), /without a Decker/i); +}); + +test('findDecker returns the Decker when one is present', () => { + const decker = new Decker({ id: 'decker', x: 0, y: 0, callsign: 'Case' }); + const crew = [new Merc({ id: 'merc', x: 1, y: 1, callsign: 'Glitch' }), decker]; + assert.equal(findDecker(crew), decker); +}); diff --git a/tests/unit/game/hub/hubReveals.test.ts b/tests/unit/game/hub/hubReveals.test.ts index 00a64d4..9aca71f 100644 --- a/tests/unit/game/hub/hubReveals.test.ts +++ b/tests/unit/game/hub/hubReveals.test.ts @@ -125,6 +125,34 @@ test('terminal recruitment reveal when Rep meets threshold', () => { assert.ok(campaign.hubReveals.terminalRecruitmentExplained); }); +test('Score reveal presents the target and assigns a Decker when Act 2 opens', () => { + const campaign = hubCampaign({ + rep: 60, + completedJobs: 4, + hubReveals: { + terminalExplained: true, + finnIntroduced: true, + clinicIntroduced: true, + terminalRecruitmentExplained: true, + }, + }); + assert.equal(campaign.arc.arcStage, 'act-2'); + assert.equal(campaign.lastHubReveal?.id, 'score-reveal'); + assert.equal(campaign.hubReveals.scoreBriefingPresented, true); + const revealText = campaign.lastHubReveal?.lines.join('\n') ?? ''; + assert.match(revealText, /SCORE SITE/); + assert.match(revealText, /Decker/); + + // Decker was assigned as part of the same transition beat + assert.equal(campaign.arc.deckerRecruited, true); + const decker = campaign.crew.find(m => m.archetype === 'Decker'); + assert.ok(decker, 'Decker should be on the crew after Act 2 entry'); + assert.ok(decker!.callsign, 'Decker should have a callsign'); + + campaign.enterHub(); + assert.equal(campaign.lastHubReveal, null); +}); + test('terminal recruitment reveal when pendingRecruitReward even below Rep', () => { const campaign = hubCampaign({ rep: REP.START, @@ -180,6 +208,7 @@ test('hubReveals and completedJobs round-trip in campaign snapshot', () => { terminalExplained: true, finnIntroduced: true, clinicIntroduced: true, + scoreBriefingPresented: true, }, }); const snap = snapshotCampaign(campaign); From e31008c62352403708a403c20f7cb973522715f1 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 9 Jun 2026 12:13:32 -0700 Subject: [PATCH 07/55] plan status fix --- docs/phase-3-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phase-3-plan.md b/docs/phase-3-plan.md index 5cfa057..8f651a0 100644 --- a/docs/phase-3-plan.md +++ b/docs/phase-3-plan.md @@ -178,7 +178,7 @@ Neural degradation is deferred until Cyberspace is fun enough to deserve a jack- - ✅ 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. +- ✅ 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`. From 3aa139e91a9a92ad80c47ef5589cdca4b7fc2cc9 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 9 Jun 2026 14:31:46 -0700 Subject: [PATCH 08/55] fix hub reveal gating on save reload --- index.ts | 29 ++++++++++++-- src/game/Campaign.ts | 9 ++--- src/game/hub/arcSurface.ts | 4 +- src/game/hub/hubReveals.ts | 44 +++++++++++++++++++-- src/game/items.ts | 18 ++++----- tests/unit/game/Campaign.test.ts | 12 ++++++ tests/unit/game/hub/hubReveals.test.ts | 55 +++++++++++++++++++++++++- 7 files changed, 147 insertions(+), 24 deletions(-) diff --git a/index.ts b/index.ts index b51aab6..5363b27 100644 --- a/index.ts +++ b/index.ts @@ -75,7 +75,12 @@ import { ITEM_ID, getItemById } from '/src/game/items.js'; import type { CampaignSnapshot } from '/src/game/persistence.js'; import type { Contract } from '/src/game/hub/Curator.js'; import { formatHubArcStatus, scoreTargetSiteId } from '/src/game/hub/arcSurface.js'; -import { isTerminalAccessible } from '/src/game/hub/hubReveals.js'; +import { + commitHubReveal, + hubRevealCommitsOnDismiss, + isTerminalAccessible, + type HubRevealId, +} from '/src/game/hub/hubReveals.js'; import type { Crew } from '/src/game/Crew.js'; import { resolveEntityLabel, type Entity } from '/src/game/Entity.js'; import type { RunResult, RunTelemetry, Outcome } from '/src/game/Run.js'; @@ -254,6 +259,8 @@ let systemStartEl: SystemStartElement; let curatorBriefingEl: CuratorBriefingElement; /** Status line to flash after the player dismisses a Hub reveal briefing. */ let hubRevealFollowUpFlash: string | null = null; +/** Score reveal (and future deferred reveals) commit their flag on dismiss. */ +let pendingHubRevealId: HubRevealId | null = null; let initialRecruitEl: InitialRecruitElement; let confirmationModalEl: ConfirmationModalElement; let touchPadEl: TouchPadElement; @@ -608,6 +615,7 @@ function abortShellForFault(): void { invalidateCombatPumps(); pendingJobResult = null; hubRevealFollowUpFlash = null; + pendingHubRevealId = null; corpToneActivityBody = null; clearBreachBlastOverlay(false); resetInputModes(); @@ -681,6 +689,7 @@ function presentHubRevealIfAny(followUpFlash: string): boolean { const reveal = campaign.lastHubReveal; campaign.lastHubReveal = null; hubRevealFollowUpFlash = followUpFlash; + pendingHubRevealId = hubRevealCommitsOnDismiss(reveal.id) ? reveal.id : null; curatorBriefingEl.setBriefing({ title: reveal.title, lines: reveal.lines }); curatorBriefingEl.show(); return true; @@ -688,6 +697,11 @@ function presentHubRevealIfAny(followUpFlash: string): boolean { function onCuratorBriefingDismiss(): void { curatorBriefingEl.hide(); + if (pendingHubRevealId && campaign) { + commitHubReveal(campaign, pendingHubRevealId); + pendingHubRevealId = null; + handlePersist(); + } if (hubRevealFollowUpFlash) { flash(hubRevealFollowUpFlash); hubRevealFollowUpFlash = null; @@ -1195,17 +1209,26 @@ function resumeCampaign(record: CampaignSnapshot | unknown) { briefingEl.setCrew(campaign.crew); } resumePendingCombatSliceIfNeeded(); + const resumeFlashMessage = `RESUMED — crew ${campaign.crew.filter(member => !member.flatlined).length} active.`; if (campaign.state === CAMPAIGN_STATE.ENDED) { pendingJobResult = null; crashEl.setTelemetry(telemetryForEndedCampaign(campaign)); flash('CAMPAIGN ENDED — no surviving crew in this save.'); + renderShell(); } else if (campaign.activeRun?.state === RUN_STATE.RESULT) { pushPendingJobResultOverlay({ ...campaign.activeRun.telemetry }); flash('RESUMED — mission debrief.'); + renderShell(); + } else if (campaign.state === CAMPAIGN_STATE.HUB && campaign.curator) { + renderShell(); + if (!presentHubRevealIfAny(resumeFlashMessage)) { + flash(resumeFlashMessage); + } + currentJobOptions = campaign.curator.generateContracts(campaign.rng, campaign); } else { - flash(`RESUMED — crew ${campaign.crew.filter(member => !member.flatlined).length} active.`); + flash(resumeFlashMessage); + renderShell(); } - renderShell(); } catch (err) { console.error('[shell] failed to restore saved campaign', err); dataStore.deleteCampaign(); diff --git a/src/game/Campaign.ts b/src/game/Campaign.ts index 32a413c..7cb2f07 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -7,11 +7,9 @@ import { CONTRACT_DIFFICULTY, FACTION, REP, - REP_TIER, RECRUIT, SALVAGE_SELL_RATE, CLINIC_COST_PER_HP, - repTierForRep, } from './constants.js'; import { SALVAGE_TYPES, @@ -59,6 +57,8 @@ import type { RunResult, Outcome } from './Run.js'; /** Max remembered combat locations (P2.5.M7.2). One slot is reserved for Phase 3's score target. */ export const SITE_ROSTER_CAP = 6; +/** Minimum Rep to leave Act 1 — matches KNOWN tier floor (50). Higher tiers qualify too. */ +export const ARC_ACT_2_MIN_REP = 50; export const ARC_ACT_2_MIN_COMPLETED_JOBS = 4; export const ARC_ACT_3_MIN_COMPLETED_JOBS = 9; /** Minimum *living* crew size before the Score's final-prep stage unlocks. Starter 2 + Decker = 3, so 4 requires at least one additional recruit who hasn't flatlined. */ @@ -1017,10 +1017,7 @@ export class Campaign { } #qualifiesForAct2(): boolean { - return ( - repTierForRep(this.rep).id === REP_TIER.KNOWN && - this.completedJobs >= ARC_ACT_2_MIN_COMPLETED_JOBS - ); + return this.rep >= ARC_ACT_2_MIN_REP && this.completedJobs >= ARC_ACT_2_MIN_COMPLETED_JOBS; } #qualifiesForAct3(): boolean { diff --git a/src/game/hub/arcSurface.ts b/src/game/hub/arcSurface.ts index bc7f4b8..54b9c84 100644 --- a/src/game/hub/arcSurface.ts +++ b/src/game/hub/arcSurface.ts @@ -61,9 +61,9 @@ export function scoreRevealLines(campaign: ArcSurfaceCampaign): readonly string[ const targetName = scoreTargetDisplayName(target); const deckerName = findDecker(campaign.crew).callsign; return [ - `CURATOR: We found it. ${targetName}. The Score has a door.`, + `CURATOR: We found it: ${targetName}. The Score has a door.`, 'CURATOR: Contracts touching that site are casing work now. Watch for SCORE SITE on the board.', - `CURATOR: You'll need a Decker to open the digital side. ${deckerName} just joined your crew.`, + `CURATOR: You'll need a Decker to crack the ICE; I've got ${deckerName} for you.`, ]; } diff --git a/src/game/hub/hubReveals.ts b/src/game/hub/hubReveals.ts index 05c25c5..9164840 100644 --- a/src/game/hub/hubReveals.ts +++ b/src/game/hub/hubReveals.ts @@ -40,6 +40,9 @@ type HubRevealDefinition = { lines: readonly string[] | ((campaign: Campaign) => readonly string[]); }; +/** Reveal flags committed when the shell dismisses the Curator briefing, not when queued. */ +const HUB_REVEAL_DEFER_FLAG_COMMIT = new Set(['score-reveal']); + const HUB_REVEAL_DEFINITIONS: readonly HubRevealDefinition[] = [ { id: 'terminal', @@ -181,17 +184,52 @@ export function isTerminalRecruitmentUnlocked(reveals: HubReveals): boolean { return !!reveals.terminalRecruitmentExplained; } +function buildHubRevealMessage( + def: HubRevealDefinition, + campaign: Campaign, + commitFlag: boolean +): HubRevealMessage { + if (commitFlag) { + campaign.hubReveals[def.flag] = true; + } + const lines = typeof def.lines === 'function' ? def.lines(campaign) : def.lines; + return { id: def.id, title: def.title, lines }; +} + +export function hubRevealCommitsOnDismiss(id: HubRevealId): boolean { + return HUB_REVEAL_DEFER_FLAG_COMMIT.has(id); +} + +/** Persist a Hub reveal flag after the player dismisses its Curator briefing. */ +export function commitHubReveal(campaign: Campaign, id: HubRevealId): void { + const def = HUB_REVEAL_DEFINITIONS.find(entry => entry.id === id); + if (!def) { + throw new Error(`hubReveals: unknown reveal id "${id}"`); + } + campaign.hubReveals[def.flag] = true; +} + /** * Evaluate reveal triggers in definition order. Sets the first qualifying * unseen flag on `campaign.hubReveals` and returns its message. Does not persist. + * Score reveal is checked first so Act 2 openings are not crowded out by lower + * priority intros on the same visit. Score briefing flags commit on dismiss. */ export function applyFirstHubReveal(campaign: Campaign): HubRevealMessage | null { + const scoreReveal = HUB_REVEAL_DEFINITIONS.find(def => def.id === 'score-reveal'); + if (scoreReveal && !campaign.hubReveals[scoreReveal.flag] && scoreReveal.qualifies(campaign)) { + return buildHubRevealMessage( + scoreReveal, + campaign, + !HUB_REVEAL_DEFER_FLAG_COMMIT.has(scoreReveal.id) + ); + } + for (const def of HUB_REVEAL_DEFINITIONS) { + if (def.id === 'score-reveal') continue; if (campaign.hubReveals[def.flag]) continue; if (!def.qualifies(campaign)) continue; - campaign.hubReveals[def.flag] = true; - const lines = typeof def.lines === 'function' ? def.lines(campaign) : def.lines; - return { id: def.id, title: def.title, lines }; + return buildHubRevealMessage(def, campaign, true); } return null; } diff --git a/src/game/items.ts b/src/game/items.ts index f4bfa8d..7cfe3b1 100644 --- a/src/game/items.ts +++ b/src/game/items.ts @@ -132,6 +132,15 @@ const CATALOG: readonly Item[] = Object.freeze([ needsAim: true, minRepTier: REP_TIER.UNKNOWN, }), + Object.freeze({ + id: ITEM_ID.ARMOUR_PLATING, + label: 'Armour Plating', + scope: ITEM_SCOPE.CAMPAIGN, + cost: SHOP_COST.ARMOUR_PLATING, + description: "+1 max HP (Tech's turrets deploy at that max HP).", + needsTarget: true, + minRepTier: REP_TIER.KNOWN, + }), Object.freeze({ id: ITEM_ID.REFLEX_WEAVE, label: 'Reflex Weave', @@ -150,15 +159,6 @@ const CATALOG: readonly Item[] = Object.freeze([ needsTarget: true, minRepTier: REP_TIER.KNOWN, }), - Object.freeze({ - id: ITEM_ID.ARMOUR_PLATING, - label: 'Armour Plating', - scope: ITEM_SCOPE.CAMPAIGN, - cost: SHOP_COST.ARMOUR_PLATING, - description: "+1 max HP (Tech's turrets deploy at that max HP).", - needsTarget: true, - minRepTier: REP_TIER.KNOWN, - }), Object.freeze({ id: ITEM_ID.BALLISTICS_COIL, label: 'Ballistics Coil', diff --git a/tests/unit/game/Campaign.test.ts b/tests/unit/game/Campaign.test.ts index d92cf79..586a08b 100644 --- a/tests/unit/game/Campaign.test.ts +++ b/tests/unit/game/Campaign.test.ts @@ -672,6 +672,18 @@ test('P3.M1.2: Act 1 advances to Act 2 at KNOWN Rep plus four completed jobs and assert.match(decker!.id, /^crew-decker-/); }); +test('P3.M1.2: Act 1 advances to Act 2 at TRUSTED Rep when job gate is met', () => { + const campaign = new Campaign({ seed: 42, rep: 100, completedJobs: 9 }); + + assert.equal(campaign.arcStage, 'act-2'); + assert.equal(campaign.arc.scoreRevealed, true); + assert.equal(campaign.arc.deckerRecruited, true); + assert.ok( + campaign.crew.some(m => m.archetype === 'Decker'), + 'TRUSTED Rep should not block Act 2 after overshooting KNOWN' + ); +}); + test('P3.M1.2: successful extraction advances Act 2 when the final gate is crossed', () => { const campaign = new Campaign({ seed: 42, rep: 49, completedJobs: 3 }); const member = campaign.crew[0]; diff --git a/tests/unit/game/hub/hubReveals.test.ts b/tests/unit/game/hub/hubReveals.test.ts index 9aca71f..5af2164 100644 --- a/tests/unit/game/hub/hubReveals.test.ts +++ b/tests/unit/game/hub/hubReveals.test.ts @@ -5,7 +5,9 @@ import { Campaign } from '../../../../src/game/Campaign.js'; import { REP } from '../../../../src/game/constants.js'; import { OUTCOME } from '../../../../src/game/Run.js'; import { + commitHubReveal, emptyHubReveals, + hubRevealCommitsOnDismiss, isTerminalAccessible, isTerminalRecruitmentUnlocked, migrateLegacyHubReveals, @@ -138,7 +140,8 @@ test('Score reveal presents the target and assigns a Decker when Act 2 opens', ( }); assert.equal(campaign.arc.arcStage, 'act-2'); assert.equal(campaign.lastHubReveal?.id, 'score-reveal'); - assert.equal(campaign.hubReveals.scoreBriefingPresented, true); + assert.equal(campaign.hubReveals.scoreBriefingPresented, undefined); + assert.ok(hubRevealCommitsOnDismiss('score-reveal')); const revealText = campaign.lastHubReveal?.lines.join('\n') ?? ''; assert.match(revealText, /SCORE SITE/); assert.match(revealText, /Decker/); @@ -149,10 +152,60 @@ test('Score reveal presents the target and assigns a Decker when Act 2 opens', ( assert.ok(decker, 'Decker should be on the crew after Act 2 entry'); assert.ok(decker!.callsign, 'Decker should have a callsign'); + commitHubReveal(campaign, 'score-reveal'); + assert.equal(campaign.hubReveals.scoreBriefingPresented, true); + campaign.enterHub(); assert.equal(campaign.lastHubReveal, null); }); +test('Score reveal takes priority over clinic when Act 2 opens with injured crew', () => { + const campaign = hubCampaign({ + rep: 60, + completedJobs: 4, + hubReveals: { + terminalExplained: true, + finnIntroduced: true, + terminalRecruitmentExplained: true, + }, + }); + campaign.crew[0].hp = 1; + campaign.enterHub(); + assert.equal(campaign.lastHubReveal?.id, 'score-reveal'); + assert.equal(campaign.hubReveals.clinicIntroduced, undefined); +}); + +test('restoreCampaign leaves Score briefing pending until the shell dismisses it', () => { + const snap = snapshotCampaign( + new Campaign({ + seed: 42, + rep: 100, + completedJobs: 9, + hubReveals: { + terminalExplained: true, + finnIntroduced: true, + clinicIntroduced: true, + terminalRecruitmentExplained: true, + }, + arc: { + arcStage: 'act-1', + deckerRecruited: false, + scoreRevealed: false, + clockStarted: false, + scoreAttempted: false, + scoreCompleted: false, + }, + }) + ); + delete snap.arc; + + const restored = restoreCampaign(snap); + assert.equal(restored.arcStage, 'act-2'); + assert.equal(restored.arc.scoreRevealed, true); + assert.equal(restored.lastHubReveal?.id, 'score-reveal'); + assert.equal(restored.hubReveals.scoreBriefingPresented, undefined); +}); + test('terminal recruitment reveal when pendingRecruitReward even below Rep', () => { const campaign = hubCampaign({ rep: REP.START, From 39499b14f7541343d75f5308941078e3fd6cbcea Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 9 Jun 2026 16:51:17 -0700 Subject: [PATCH 09/55] Finish campaign arc narrative beats --- components/ContractSelect.ts | 50 ++++++- components/CrashDump.ts | 89 +++++++++++- components/CrewRoster.ts | 29 +++- components/RunBriefing.ts | 3 + docs/kaizen.md | 1 + docs/phase-3-plan.md | 69 ++++++---- index.ts | 94 ++++++++++--- src/game/Campaign.ts | 184 +++++++++++++++++++++++++ src/game/hub/Curator.ts | 124 ++++++++++++++++- src/game/hub/arcSurface.ts | 78 ++++++++++- src/game/hub/hubReveals.ts | 71 ++++++++-- src/game/persistence.ts | 11 ++ src/types.ts | 4 + sw-dev.js | 2 +- tests/unit/game/Campaign.test.ts | 168 +++++++++++++++++++++- tests/unit/game/hub/Curator.test.ts | 80 +++++++++++ tests/unit/game/hub/arcSurface.test.ts | 116 ++++++++++++++-- tests/unit/game/hub/hubReveals.test.ts | 106 +++++++++++++- 18 files changed, 1181 insertions(+), 98 deletions(-) diff --git a/components/ContractSelect.ts b/components/ContractSelect.ts index 9de6f39..676bd5f 100644 --- a/components/ContractSelect.ts +++ b/components/ContractSelect.ts @@ -6,7 +6,7 @@ import { h } from '/src/domUtils.js'; import { encounterHostileCount } from '/src/game/encounters.js'; -import { isScoreSiteContract } from '/src/game/hub/arcSurface.js'; +import { isScorePrincipalContract, isScoreSiteContract } from '/src/game/hub/arcSurface.js'; import { cloneObjective } from '/src/game/hub/Curator.js'; import type { Contract } from '/src/game/hub/Curator.js'; @@ -140,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); @@ -156,6 +161,12 @@ const CSS = ` border-color: var(--board-warn); } +.known.casing { + color: #020403; + background: #9be7ff; + border-color: #9be7ff; +} + .meta { color: var(--board-dim); font-size: 0.84rem; @@ -193,6 +204,7 @@ const CSS = ` class ContractSelect extends HTMLElement { #contracts: Contract[] = []; #scoreTargetSiteId: string | null = null; + #scorePrincipalId: string | null = null; #selectedIndex = 0; #ready = false; #listEl: HTMLElement | null = null; @@ -251,6 +263,14 @@ class ContractSelect extends HTMLElement { 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()); @@ -284,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, this.#scoreTargetSiteId)), + 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', ''); @@ -353,28 +380,37 @@ 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 { + if (isScoreContract(contract)) return 'final run · no payout · campaign on the line'; const recruit = contract.reward.recruit ? ' · recruit lead' : ''; return `${encounterHostileCount(contract)} hostiles · Cr +${contract.reward.credits} · 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, scoreTargetSiteId: string | null): HTMLElement[] { +function locationLine( + contract: Contract, + scoreTargetSiteId: string | null, + scorePrincipalId: string | null +): HTMLElement[] { const { principal, site, siteState, locationSiteId } = 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 (isScoreSiteContract(contract, scoreTargetSiteId)) { nodes.push(h('span', { className: 'known score-site', textContent: 'SCORE SITE' })); + } else if (isScorePrincipalContract(contract, scorePrincipalId, scoreTargetSiteId)) { + nodes.push(h('span', { className: 'known casing', textContent: 'CASING' })); } else if (locationSiteId) { nodes.push(h('span', { className: 'known', textContent: '// known site' })); } @@ -389,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..ebec5e2 100644 --- a/components/CrashDump.ts +++ b/components/CrashDump.ts @@ -168,6 +168,14 @@ function campaignOverTitle() { return '*** CAMPAIGN TERMINATED ***'; } +function clockExpiredTitle() { + return '*** WINDOW CLOSED ***'; +} + +function scoreCompleteTitle() { + return '*** SCORE COMPLETE ***'; +} + function deathFault() { return ['fault: unhandled_exception_in_meatspace', 'addr: 0x00000@meatspace', 'trace:'].join( '\n' @@ -186,6 +194,18 @@ function campaignOverFault() { ].join('\n'); } +function clockExpiredFault() { + return [ + 'fault: score_operational_window_expired', + 'addr: 0x00000@campaign_layer', + 'trace:', + ].join('\n'); +} + +function scoreCompleteFault() { + return ['status: score_extracted', 'addr: 0x00000@campaign_layer', 'trace:'].join('\n'); +} + function campaignTerminalFault() { return [ 'notice: final_operator_channel_lost — no surviving crew', @@ -206,8 +226,41 @@ function buildCampaignOverTraceLines(crewRoster: CrewMemberStub[]) { }); } +function buildClockExpiredTraceLines() { + return [ + { text: ' 0x01 arc::clock_deadline_reached()', tag: '' }, + { text: ' 0x02 curator::score_contract_cold()', tag: '' }, + { text: ' 0x03 campaign::terminate(window_closed)', tag: '' }, + ]; +} + +function buildScoreCompleteTraceLines(crewRoster: CrewMemberStub[]) { + const survivors = crewRoster.filter(op => !op.flatlined); + const lead = survivors[0]; + const lines = [ + { + text: ` 0x01 score::extract_success(${lead?.callsign ?? 'crew'})`, + tag: '', + }, + { text: ' 0x02 arc::score_completed()', tag: '' }, + ]; + if (survivors.length > 1) { + lines.push({ + text: ` 0x03 roster::survivors(${survivors.length})`, + tag: '', + }); + } + return lines; +} + function buildTraceLines(telemetry: Telemetry) { if (telemetry.outcome === 'campaign-over') { + if (telemetry.campaignEndReason === 'clock-expired') { + return buildClockExpiredTraceLines(); + } + if (telemetry.campaignEndReason === 'score-complete') { + return buildScoreCompleteTraceLines(telemetry.crewRoster ?? []); + } return buildCampaignOverTraceLines(telemetry.crewRoster ?? []); } @@ -360,8 +413,19 @@ class CrashDump extends HTMLElement { const isCampaignTerminalDeath = isDeath && t.campaignTerminal; if (isCampaignOver || isCampaignTerminalDeath) { - this.#els.title.textContent = campaignOverTitle(); - this.#els.fault.textContent = isCampaignOver ? campaignOverFault() : campaignTerminalFault(); + if (isCampaignOver && t.campaignEndReason === 'clock-expired') { + this.#els.title.textContent = clockExpiredTitle(); + this.#els.fault.textContent = clockExpiredFault(); + } else if (isCampaignOver && t.campaignEndReason === 'score-complete') { + this.#els.title.textContent = scoreCompleteTitle(); + this.#els.fault.textContent = scoreCompleteFault(); + this.setAttribute('outcome', 'exit'); + } else { + this.#els.title.textContent = campaignOverTitle(); + this.#els.fault.textContent = isCampaignOver + ? campaignOverFault() + : campaignTerminalFault(); + } } else if (isDeath) { this.#els.title.textContent = deathTitle(); this.#els.fault.textContent = deathFault(); @@ -382,7 +446,12 @@ class CrashDump extends HTMLElement { if (line.tag) { this.#els!.trace.appendChild(document.createTextNode(' ')); const span = h('span', { - className: line.tag.includes('killed') ? 'killed' : '', + className: + line.tag.includes('killed') || + line.tag.includes('flatlined') || + line.tag.includes('expired') + ? 'killed' + : '', textContent: line.tag, }); this.#els!.trace.appendChild(span); @@ -396,10 +465,16 @@ class CrashDump extends HTMLElement { 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'; + if (t.campaignEndReason === 'clock-expired') { + this.#els.causeDd.textContent = 'Score window closed — contract cold'; + } else if (t.campaignEndReason === 'score-complete') { + this.#els.causeDd.textContent = 'Score extracted — campaign complete'; + } else { + 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'; diff --git a/components/CrewRoster.ts b/components/CrewRoster.ts index a5abeee..b3408bb 100644 --- a/components/CrewRoster.ts +++ b/components/CrewRoster.ts @@ -11,7 +11,7 @@ * Usage: * rosterEl.setCrew(campaign.crew, { * salvage: campaign.salvage, - * campaignStatus: 'ACT 2: CASING | SCORE: Matsuda server farm', + * campaignStatus: ['ACT 2: CASING | SCORE: Matsuda server farm', 'CLOCK: HEAT 1 / 4 JOBS LEFT'], * availableRecruits: campaign.availableRecruits, * recruitedThisVisit: campaign.recruitedThisVisit, * }); @@ -119,12 +119,21 @@ const CSS = ` .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; @@ -339,7 +348,7 @@ class CrewRoster extends HTMLElement { this.#titleEl = h('h2', { className: 'title', textContent: '── CREW ROSTER ──' }); this.#balanceEl = h('p', { className: 'balance' }); - this.#campaignStatusEl = h('p', { className: 'campaign-status' }); + this.#campaignStatusEl = h('div', { className: 'campaign-status' }); this.#listEl = document.createElement('crew-list') as CrewList; this.#listEl.addEventListener('select', evt => @@ -406,7 +415,7 @@ class CrewRoster extends HTMLElement { recruitedThisVisit = false, }: { salvage?: TypedSalvage; - campaignStatus?: string; + campaignStatus?: string | readonly string[]; availableRecruits?: CrewMember[]; recruitedThisVisit?: boolean; } = {} @@ -421,8 +430,7 @@ class CrewRoster extends HTMLElement { // Show typed breakdown + total const total = totalSalvage(this.#salvage); this.#balanceEl!.textContent = `SALVAGE ${total} · ${formatSalvageCompact(this.#salvage)}`; - this.#campaignStatusEl!.textContent = campaignStatus; - this.#campaignStatusEl!.style.display = campaignStatus ? '' : 'none'; + this.#renderCampaignStatus(campaignStatus); this.#recruitFocused = false; this.#selectedRecruitIndex = -1; // Crew list handles its own rendering; selection triggers detail update. @@ -430,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/RunBriefing.ts b/components/RunBriefing.ts index 261aa0c..86f5bdd 100644 --- a/components/RunBriefing.ts +++ b/components/RunBriefing.ts @@ -195,6 +195,9 @@ function threatCopy(count: number) { } function rewardCopy(contract: Partial) { + if (contract.context?.tags.includes('score') && contract.context.recipeId === 'score-final') { + return 'Campaign finale'; + } const reward = contract.reward; if (!reward) return '?'; const recruit = reward.recruit ? ' + recruit lead' : ''; diff --git a/docs/kaizen.md b/docs/kaizen.md index 141efe1..7aa124b 100644 --- a/docs/kaizen.md +++ b/docs/kaizen.md @@ -55,6 +55,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-3-plan.md b/docs/phase-3-plan.md index 8f651a0..21ab864 100644 --- a/docs/phase-3-plan.md +++ b/docs/phase-3-plan.md @@ -18,8 +18,8 @@ The campaign is a **Neuromancer-shaped arc**: a crew of operators assembles over | **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** | ~8 | Pressure mounts — rival crew, corp heat, neural degradation. Contracts get harder; delay has cost. | Clock mechanic | -| **Stage 3: The Score** | 11–13 | Final prep runs, then the big job. Dual-layer climax: Meatspace breach + Cyberspace penetration. | Everything converges | +| **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 @@ -41,8 +41,8 @@ A new **player archetype** recruited mid-campaign (late Act 1 / start of Act 2), | Milestone | Status | |---|---| -| P3.M1 — Campaign arc structure | 🚧 In progress — P3.M1.1–P3.M1.4 done | -| P3.M2 — The Decker archetype | 🚧 In progress — class + Drone Override Hack done; recruitment flow pending | +| P3.M1 — Campaign arc structure | ✅ Done | +| P3.M2 — The Decker archetype | ✅ Done | | P3.M3 — Cyberspace grid + ICE | 🔲 Planned | | P3.M4 — Simstim flip (dual-deploy) | 🔲 Planned | | P3.M5 — The Score (climactic mission) | 🔲 Planned | @@ -63,7 +63,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). The Score reveal and Decker recruitment share a single `score-reveal` Hub reveal beat rather than using a separate 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,7 +72,7 @@ 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) | KNOWN rep tier defined and reachable | Phase 3 gates Decker recruitment and Score access at KNOWN (50) | +| **P2.5.M5** (economy/rep) | KNOWN rep tier defined and reachable | Phase 3 gates Decker recruitment and Score access at `rep >= 50` (KNOWN floor; TRUSTED qualifies) | | **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 @@ -81,7 +81,7 @@ The Score target is always a newly synthesized CRITICAL-tier site, never promote ## 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,11 +89,11 @@ The Score target is always a newly synthesized CRITICAL-tier site, never promote **Scope:** -- **Arc state:** Campaign save tracks `arcStage` (`act-1` / `act-2` / `act-3` / `score`), run count, and arc-specific flags (`deckerRecruited`, `scoreRevealed`, `clockStarted`, `scoreAttempted`, `scoreCompleted`). Prefer a typed `Campaign.arc` record over stuffing more opaque keys into `Campaign.meta`; legacy saves can normalize from absent `arc` into Act 1. +- **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 `KNOWN` rep tier (50) + minimum successful job count (recommended: `completedJobs >= 4`). Triggers Score reveal and Decker recruitment (same beat). - - 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. - - Score available: Act 3 + player-initiated (choose to attempt the Score from the Hub). + - Act 1 → Act 2: reach at least `KNOWN` rep tier (`rep >= 50`) + minimum successful job count (recommended: `completedJobs >= 4`). Triggers Score reveal and Decker recruitment (same beat). + - 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: @@ -101,21 +101,26 @@ The Score target is always a newly synthesized CRITICAL-tier site, never promote - 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 stage, Clock pressure, and Score target label once revealed. User-facing labels use "Stage" (STAGE 1, STAGE 2, etc.) — code and persistence keep `arc`/`arcStage` naming. The Score reveal uses progressive Hub reveal plumbing so the Curator can present the target and the Decker together as a single campaign beat. +- **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 debrief: `*** 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 (clock):** `clockJobsTaken >= CLOCK_ACT2_DEADLINE_JOBS` (8) before `scoreAttempted`. Terminal debrief: `*** WINDOW CLOSED ***` — 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. **Recommended first implementation:** combine escalating global difficulty as soft pressure with an operational window as the hard campaign deadline. It is easy to communicate, test, and tune: +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. -- `clockStartsAtJob = 8` -- `scoreDeadlineJob = 13` -- `heat = max(0, completedJobs - clockStartsAtJob)` -- Each point of heat nudges contract threat / alarm sensitivity upward, capped by difficulty tier. -- If the player returns to Hub with `completedJobs >= scoreDeadlineJob` and `scoreAttempted` is false, campaign ends with clock loss. +- `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 +- `CLOCK_ACT2_DEADLINE_JOBS = 8` — total Act 2/3 deploys (`grace + window`) +- `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 +- Heat nudges Curator threat counts upward, capped per difficulty tier +- Returning to Hub at the deadline without `scoreAttempted` sets `Campaign.state` to `ENDED` with `endReason: 'clock-expired'` and presents the terminal game-over screen + +**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: @@ -131,28 +136,34 @@ Neural degradation is deferred until Cyberspace is fun enough to deserve a jack- | 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 and rep tier | +| **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 presents Score + Decker reveal; Hub / Terminal shows stage and Score target; contract selection labels Score-site jobs | one-shot reveal, status render, Score-site badge | -| **P3.M1.5 Clock** | 🔲 Planned | Start heat after threshold; hard loss at deadline; show status | heat math, deadline loss, no loss after Score attempt | -| **P3.M1.6 Curator bias** | 🔲 Planned | 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** | 🔲 Planned | Hub action creates the special Score contract in Act 3 only | availability gates, deployment path, attempted flag | +| **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.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 `KNOWN` Rep (50) plus `completedJobs >= 4`, sets `scoreRevealed`, and auto-assigns a Decker to the crew. Act 2 advances to Act 3 once `completedJobs >= 9`, at least 4 non-flatlined crew, and at least 3 visited roster sites sharing the Score target's principal (including the target itself). The crew gate counts living members only — attrition blocks advancement. Successful extractions increment `completedJobs`, abort extractions do not. M1.2 does not synthesize the Score target — that remains P3.M1.3. +**P3.M1.2 implementation note:** Hub entry now runs a monotonic arc transition evaluator. Act 1 advances to Act 2 at `rep >= ARC_ACT_2_MIN_REP` (50 — the KNOWN tier floor) plus `completedJobs >= 4`, sets `scoreRevealed`, and auto-assigns a Decker to the crew. Higher rep tiers qualify too: a save that overshoots KNOWN into TRUSTED before the job gate is crossed must not stall in Act 1 (an early implementation checked for the KNOWN tier band exactly, which blocked TRUSTED saves). 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 now player-visible. A one-shot `score-reveal` Hub reveal lets the Curator name the target, introduce the Decker, and teach the `SCORE SITE` board badge — all in a single narrative beat. The Hub status row and Terminal crew roster show the current stage label (user-facing "STAGE N") plus Score target once revealed, and `` highlights contracts whose `locationSiteId` matches the campaign Score target. 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.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` and shows a dedicated terminal game-over screen (`*** WINDOW CLOSED ***` via ``), not a clock status footnote. Score win uses `*** SCORE COMPLETE ***`; crew wipe keeps the existing campaign-terminal death path. 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 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, Hub / Terminal displays the current act and target label, and contract selection marks Score-site jobs. +- 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. diff --git a/index.ts b/index.ts index 5363b27..3490956 100644 --- a/index.ts +++ b/index.ts @@ -74,7 +74,11 @@ import { hasLineOfSight } from '/src/game/LineOfSight.js'; import { ITEM_ID, getItemById } from '/src/game/items.js'; import type { CampaignSnapshot } from '/src/game/persistence.js'; import type { Contract } from '/src/game/hub/Curator.js'; -import { formatHubArcStatus, scoreTargetSiteId } from '/src/game/hub/arcSurface.js'; +import { + formatHubArcStatusLines, + scorePrincipalId, + scoreTargetSiteId, +} from '/src/game/hub/arcSurface.js'; import { commitHubReveal, hubRevealCommitsOnDismiss, @@ -132,6 +136,7 @@ type RunBriefingElement = ModalElement & { type ContractSelectElement = ModalElement & { setContracts(contracts: Contract[]): void; setScoreTargetSiteId(siteId: string | null): void; + setScorePrincipalId(principalId: string | null): void; }; type CrashDumpElement = ModalElement & { setTelemetry(telemetry: Record): void; @@ -153,7 +158,7 @@ type CrewRosterElement = ModalElement & { crew: Crew[], opts?: { salvage?: TypedSalvage; - campaignStatus?: string; + campaignStatus?: string | readonly string[]; availableRecruits?: Crew[]; recruitedThisVisit?: boolean; } @@ -721,7 +726,7 @@ function enterHubAndRender() { flash('HUB — Curator has contracts when you are adjacent [Space].'); } // generate job options once on hub enter - currentJobOptions = campaign.curator.generateContracts(campaign.rng, campaign); + currentJobOptions = generateCurrentJobOptions(); } function presentCrewRoster() { @@ -729,7 +734,9 @@ function presentCrewRoster() { campaign.backfillRecruitsIfEligible(); crewRosterEl.setCrew(campaign.crew, { salvage: campaign.salvage, - campaignStatus: formatHubArcStatus(campaign), + campaignStatus: formatHubArcStatusLines(campaign).filter( + (line): line is string => line !== null + ), availableRecruits: campaign.availableRecruits, recruitedThisVisit: campaign.recruitedThisVisit, }); @@ -759,10 +766,22 @@ function presentBriefing(contract: Contract) { function presentContractSelect(contracts: Contract[]) { contractSelectEl.setScoreTargetSiteId(campaign ? scoreTargetSiteId(campaign) : null); + contractSelectEl.setScorePrincipalId(campaign ? scorePrincipalId(campaign) : null); contractSelectEl.setContracts(contracts); contractSelectEl.show(); } +function generateCurrentJobOptions(): Contract[] { + if (!campaign?.curator) { + throw new Error('generateCurrentJobOptions: hub not entered — curator is missing.'); + } + const contracts = campaign.curator.generateContracts(campaign.rng, campaign); + if (campaign.canAttemptScore()) { + contracts.push(campaign.buildScoreContract()); + } + return contracts; +} + function onContractSelected(evt: Event) { const { contract } = (evt as CustomEvent<{ contract?: Contract }>).detail; if (!contract) return; @@ -1140,8 +1159,10 @@ function crewMemberArchetypeId(member: Crew): string { } function telemetryForEndedCampaign(c: Campaign): Telemetry { + const reason = c.endReason ?? 'crew-wipe'; return { outcome: 'campaign-over', + campaignEndReason: reason, seed: c.seed, salvage: c.salvage, crewRoster: c.crew.map(member => ({ @@ -1152,6 +1173,16 @@ function telemetryForEndedCampaign(c: Campaign): Telemetry { }; } +function presentCampaignEnd(c: Campaign): void { + crashEl.setTelemetry(telemetryForEndedCampaign(c)); + renderShell(); +} + +function finishEndedCampaign(): void { + dataStore.deleteCampaign(); + startFreshCampaign(); +} + /** * Drives `` and `pendingJobResult` whenever the active job is in * RESULT — both from a live `Run.onResult` callback and from a cold resume @@ -1213,7 +1244,13 @@ function resumeCampaign(record: CampaignSnapshot | unknown) { if (campaign.state === CAMPAIGN_STATE.ENDED) { pendingJobResult = null; crashEl.setTelemetry(telemetryForEndedCampaign(campaign)); - flash('CAMPAIGN ENDED — no surviving crew in this save.'); + flash( + campaign.endReason === 'clock-expired' + ? 'WINDOW CLOSED — Score contract went cold.' + : campaign.endReason === 'score-complete' + ? 'SCORE COMPLETE — campaign archived.' + : 'CAMPAIGN ENDED — this save has reached a terminal state.' + ); renderShell(); } else if (campaign.activeRun?.state === RUN_STATE.RESULT) { pushPendingJobResultOverlay({ ...campaign.activeRun.telemetry }); @@ -1224,7 +1261,7 @@ function resumeCampaign(record: CampaignSnapshot | unknown) { if (!presentHubRevealIfAny(resumeFlashMessage)) { flash(resumeFlashMessage); } - currentJobOptions = campaign.curator.generateContracts(campaign.rng, campaign); + currentJobOptions = generateCurrentJobOptions(); } else { flash(resumeFlashMessage); renderShell(); @@ -1662,9 +1699,13 @@ function handleInteract(): void { return; } if (currentJobOptions.length === 0) { - currentJobOptions = campaign.curator.generateContracts(campaign.rng, campaign); + currentJobOptions = generateCurrentJobOptions(); } - flash('CURATOR: Three jobs on the board. Pick your trouble.'); + flash( + currentJobOptions.some(contract => contract.context.recipeId === 'score-final') + ? 'CURATOR: Three jobs on the board. The Score is your call.' + : 'CURATOR: Three jobs on the board. Pick your trouble.' + ); presentContractSelect(currentJobOptions); } @@ -1776,8 +1817,12 @@ function onNewRunRequested(): void { } campaign.onJobEnd({ outcome, salvage, completed: objectiveComplete }); if (campaign.state === CAMPAIGN_STATE.ENDED) { - dataStore.deleteCampaign(); - startFreshCampaign(); + pendingJobResult = null; + if (campaign.endReason === 'crew-wipe') { + finishEndedCampaign(); + return; + } + presentCampaignEnd(campaign); return; } else { if (!presentHubRevealIfAny('HUB — choose the next job.')) { @@ -1787,11 +1832,10 @@ function onNewRunRequested(): void { if (!campaign.curator) { throw new Error('onNewRunRequested: hub not entered — curator is missing.'); } - currentJobOptions = campaign.curator.generateContracts(campaign.rng, campaign); + currentJobOptions = generateCurrentJobOptions(); } } else if (campaign.state === CAMPAIGN_STATE.ENDED) { - dataStore.deleteCampaign(); - startFreshCampaign(); + finishEndedCampaign(); return; } crashEl.hide(); @@ -2036,18 +2080,30 @@ function buildCombatHudSnapshot(scene: ShellScene | null): CombatHudSummaryInput function buildHubHudRows(scene: ShellScene | null) { if (!campaign || scene?.state !== CAMPAIGN_STATE.HUB) return undefined; - return [ + const [summary, clock] = formatHubArcStatusLines(campaign); + const rows = [ { - text: formatHubArcStatus(campaign), + text: summary, anchor: 'top-left' as const, row: 1, color: '#ffd166', glowColor: '#ffd166', accentColor: 'rgba(255, 209, 102, 0.5)', uppercase: true, - maxWidth: 520, }, ]; + if (clock) { + rows.push({ + text: clock, + anchor: 'top-left' as const, + row: 2, + color: '#ffd166', + glowColor: '#ffd166', + accentColor: 'rgba(255, 209, 102, 0.5)', + uppercase: true, + }); + } + return rows; } function paint(stateHint: InputState = activeInputState()): void { @@ -2124,7 +2180,11 @@ function statusLine(state: InputState): string { } else { if (!campaign) return stateLabel(); const repLabel = REP_LABEL.find(b => campaign!.rep >= b.min)?.label ?? 'UNKNOWN'; - context = escapeHtml(formatHubArcStatus(campaign)); + context = joinStatusParts( + formatHubArcStatusLines(campaign) + .filter((line): line is string => line !== null) + .map(escapeHtml) + ); // Hub identity drops the typed-salvage compact tag — // the inventory overlay (`i` in Hub) is the canonical wallet view with // full bucket names. Total Cred / Rep / crew counts stay on this line. diff --git a/src/game/Campaign.ts b/src/game/Campaign.ts index 7cb2f07..998d601 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -22,6 +22,7 @@ import { } from './salvage.js'; import { buildCrewMember, RECRUIT_ARCHETYPE_POOL } from './archetypes/index.js'; import { CONTRACT_LEXICON, Curator } from './hub/Curator.js'; +import { OBJECTIVES } from './hub/Curator.js'; import { Terminal } from './hub/Terminal.js'; import { Finn } from './hub/Finn.js'; import { Clinic } from './hub/Clinic.js'; @@ -47,6 +48,7 @@ import type { Contract } from './hub/Curator.js'; import type { Crew } from './Crew.js'; import type { CampaignArcStage, + CampaignEndReason, GridPoint, KeyItem, LocationSite, @@ -65,6 +67,12 @@ export const ARC_ACT_3_MIN_COMPLETED_JOBS = 9; export const ARC_ACT_3_MIN_CREW_ALIVE = 4; /** Visited sites sharing the Score target's principal required for Act 3. Includes the target itself. */ export const ARC_ACT_3_MIN_PRINCIPAL_SITES_VISITED = 3; +/** Act-2/3 deploys taken before corp heat starts (successful or not). */ +export const CLOCK_ACT2_GRACE_JOBS = 3; +/** Deploys after grace before the Score window closes. */ +export const CLOCK_HEAT_WINDOW_JOBS = 5; +/** Total act-2/3 deploys allowed before clock loss (`grace + window`). */ +export const CLOCK_ACT2_DEADLINE_JOBS = CLOCK_ACT2_GRACE_JOBS + CLOCK_HEAT_WINDOW_JOBS; const SYNTHETIC_SCORE_TARGET_DIFFICULTY = CONTRACT_DIFFICULTY.CRITICAL; export const CAMPAIGN_STATE = Object.freeze({ @@ -114,6 +122,8 @@ export type CampaignOptions = { arc?: unknown; hubReveals?: unknown; completedJobs?: unknown; + /** Act-2/3 job deploys that drive the Clock (not completedJobs). */ + clockJobsTaken?: unknown; keyItems?: unknown; siteRoster?: unknown; onPersist?: unknown; @@ -185,6 +195,8 @@ export class Campaign { healedThisVisit: Set; hubReveals: HubReveals; completedJobs: number; + /** Deploys taken during Act 2 / Act 3 casing prep — drives Clock heat and deadline. */ + clockJobsTaken: number; /** Persistent key-item inventory (P2.5.M6.2) — keycards survive across runs. */ keyItems: KeyItem[]; /** Remembered combat locations / site roster (P2.5.M7.2), max `SITE_ROSTER_CAP`. */ @@ -204,6 +216,7 @@ export class Campaign { arc, hubReveals, completedJobs = 0, + clockJobsTaken = 0, keyItems, siteRoster, onPersist, @@ -239,6 +252,14 @@ export class Campaign { `Campaign completedJobs must be a non-negative integer, got ${completedJobs}` ); } + if ( + clockJobsTaken !== undefined && + (!Number.isInteger(clockJobsTaken) || (clockJobsTaken as number) < 0) + ) { + throw new RangeError( + `Campaign clockJobsTaken must be a non-negative integer, got ${clockJobsTaken}` + ); + } if (onPersist !== undefined && typeof onPersist !== 'function') { throw new TypeError('Campaign: onPersist must be a function'); } @@ -257,6 +278,7 @@ export class Campaign { this.arc = normalizeCampaignArc(arc); this.hubReveals = normalizeHubReveals(hubReveals, 'Campaign hubReveals'); this.completedJobs = (completedJobs as number) ?? 0; + this.clockJobsTaken = (clockJobsTaken as number) ?? 0; this.keyItems = normalizeKeyItems(keyItems); this.siteRoster = normalizeSiteRoster(siteRoster); this.state = CAMPAIGN_STATE.HUB; @@ -294,11 +316,40 @@ export class Campaign { return this.arc.arcStage; } + get clockHeat(): number { + if (!this.arc.clockStarted) return 0; + return Math.max(0, this.clockJobsTaken - CLOCK_ACT2_GRACE_JOBS); + } + + get scoreDeadlineJobsRemaining(): number { + return Math.max(0, CLOCK_ACT2_DEADLINE_JOBS - this.clockJobsTaken); + } + + /** Set when `state === ENDED`; null while the campaign is still live. */ + get endReason(): CampaignEndReason | null { + if (this.state !== CAMPAIGN_STATE.ENDED) return null; + if (this.arc.scoreCompleted) return 'score-complete'; + if ( + this.arc.scoreRevealed && + this.clockJobsTaken >= CLOCK_ACT2_DEADLINE_JOBS && + !this.arc.scoreAttempted + ) { + return 'clock-expired'; + } + return 'crew-wipe'; + } + enterHub(): void { if (this.state !== CAMPAIGN_STATE.HUB && this.state !== CAMPAIGN_STATE.COMBAT) { throw new Error(`Campaign.enterHub: illegal transition from ${this.state}`); } this.#advanceArcTransitions(); + if (this.arc.scoreCompleted || this.#clockExpired()) { + this.state = CAMPAIGN_STATE.ENDED; + this.#tearDownHubWorld(); + this.#persist(); + return; + } this.recruitedThisVisit = false; this.healedThisVisit = new Set(); this.lastHubReveal = null; @@ -381,6 +432,11 @@ export class Campaign { if (member.flatlined) { throw new Error(`Campaign.deployCrewMember: ${member.callsign ?? member.id} is flatlined`); } + if (isScoreContract(contract)) { + this.#beginScoreAttempt(contract); + } else if (this.arc.arcStage === 'act-2' || this.arc.arcStage === 'act-3') { + this.clockJobsTaken += 1; + } const deployedContract = this.#contractWithRememberedDimensions(contract); this.#tearDownHubWorld(); this.deployedMemberId = member.id; @@ -442,6 +498,12 @@ export class Campaign { throw new TypeError(`Campaign.onJobEnd: completed must be boolean`); } + const completedScoreRun = + this.activeRun.contract !== null && + isScoreContract(this.activeRun.contract) && + outcome === OUTCOME.EXIT && + completed; + if (outcome === OUTCOME.DEATH) { this.flatlineMember(this.deployedMemberId); } else { @@ -479,10 +541,78 @@ export class Campaign { return; } + if (completedScoreRun) { + this.arc.scoreCompleted = true; + this.arc.arcStage = 'score'; + this.state = CAMPAIGN_STATE.ENDED; + this.#tearDownHubWorld(); + this.#persist(); + return; + } + this.state = CAMPAIGN_STATE.HUB; this.enterHub(); } + canAttemptScore(): boolean { + if (this.state !== CAMPAIGN_STATE.HUB) return false; + if (this.arc.arcStage !== 'act-3') return false; + if (this.arc.scoreAttempted || this.arc.scoreCompleted) return false; + return this.#scoreTargetSiteOrNull() !== null; + } + + buildScoreContract(): Contract { + if (!this.canAttemptScore()) { + throw new Error('Campaign.buildScoreContract: Score is not available'); + } + const target = this.#scoreTargetSiteOrNull(); + if (!target) { + throw new Error('Campaign.buildScoreContract: no Score target designated'); + } + if (!target.principal) { + throw new Error('Campaign.buildScoreContract: Score target is missing principal identity'); + } + const seed = Number(target.seed); + if (!Number.isInteger(seed) || seed < 0) { + throw new Error(`Campaign.buildScoreContract: Score target seed "${target.seed}" is invalid`); + } + const principal = locationContextToken(target.principal); + const site = target.site ? locationContextToken(target.site) : undefined; + const heatThreat = Math.min(6, 4 + this.clockHeat); + return { + seed, + mapWidth: target.mapWidth, + mapHeight: target.mapHeight, + objective: { + kind: OBJECTIVES.REACH_EXIT, + title: 'The Score', + briefing: `Hit ${target.label + .replace(/^\/\//, '') + .replace(/\s+-\s+Score target$/i, '') + .trim()} and extract with the crew alive.`, + }, + difficulty: CONTRACT_DIFFICULTY.CRITICAL, + threatCount: heatThreat, + label: `${target.label.replace(/\s+-\s+Score target$/i, '')} - THE SCORE`, + context: { + recipeId: 'score-final', + principal, + ...(site ? { site } : {}), + asset: { id: 'score-target', label: 'Score target', groups: ['score'] }, + action: { id: 'score-run', label: 'run', groups: ['score'] }, + tags: [ + 'score', + 'meatspace', + `objective:${OBJECTIVES.REACH_EXIT}`, + `principal:${principal.id}`, + ], + arcStage: 'score', + locationSiteId: target.id, + }, + reward: { credits: 0, repDelta: 0 }, + }; + } + flatlineMember(memberId: string): void { const member = this.getCrewMember(memberId); if (!member) { @@ -1014,6 +1144,10 @@ export class Campaign { if (this.arc.arcStage === 'act-2' && this.#qualifiesForAct3()) { this.arc.arcStage = 'act-3'; } + + if (this.arc.scoreRevealed && this.clockJobsTaken >= CLOCK_ACT2_GRACE_JOBS) { + this.arc.clockStarted = true; + } } #qualifiesForAct2(): boolean { @@ -1076,6 +1210,44 @@ export class Campaign { target.tier = 'score'; } + #scoreTargetSiteOrNull(): LocationSite | null { + const scoreTargets = this.siteRoster.filter(site => site.scoreTarget); + if (scoreTargets.length > 1) { + throw new Error('Campaign arc: multiple Score targets in site roster'); + } + if (this.siteRoster.some(site => site.tier === 'score' && !site.scoreTarget)) { + throw new Error('Campaign arc: score-tier roster site missing scoreTarget'); + } + return scoreTargets[0] ?? null; + } + + #beginScoreAttempt(contract: Contract): void { + if (this.arc.arcStage !== 'act-3') { + throw new Error('Campaign.deployCrewMember: Score can only be attempted from Act 3'); + } + if (this.arc.scoreAttempted) { + throw new Error('Campaign.deployCrewMember: Score has already been attempted'); + } + if (this.arc.scoreCompleted) { + throw new Error('Campaign.deployCrewMember: Score is already complete'); + } + const target = this.#scoreTargetSiteOrNull(); + if (!target || contract.context.locationSiteId !== target.id) { + throw new Error('Campaign.deployCrewMember: Score contract does not target the Score site'); + } + this.arc.scoreAttempted = true; + this.arc.arcStage = 'score'; + } + + #clockExpired(): boolean { + return ( + this.arc.scoreRevealed && + this.clockJobsTaken >= CLOCK_ACT2_DEADLINE_JOBS && + !this.arc.scoreAttempted && + !this.arc.scoreCompleted + ); + } + #synthesizeScoreTarget(): LocationSite { const seed = this.rng.intRange(0, 0x7fffffff); const principal = this.rng.pick( @@ -1224,6 +1396,18 @@ function locationTokenFromLexicon(token: { }; } +function locationContextToken(token: LocationToken) { + return { + id: token.id, + label: token.label, + groups: [...token.groups], + }; +} + +function isScoreContract(contract: Contract): boolean { + return contract.context.tags.includes('score') && contract.context.recipeId === 'score-final'; +} + function makeCampaignId(seed: number): string { return `campaign-${(seed >>> 0).toString(16)}-${Date.now().toString(36)}`; } diff --git a/src/game/hub/Curator.ts b/src/game/hub/Curator.ts index b789625..5f20482 100644 --- a/src/game/hub/Curator.ts +++ b/src/game/hub/Curator.ts @@ -488,6 +488,7 @@ type ContractCampaign = rep?: number; meta?: Record; arcStage?: CampaignArcStage | null; + clockHeat?: number; /** Remembered combat locations the Curator may steer revisits toward (P2.5.M7.2). */ siteRoster?: LocationSite[]; } @@ -540,6 +541,11 @@ export class Curator extends Entity { const revisitedSiteIds = new Set(); const context = contractRecipeContext(campaign); const roster = campaign?.siteRoster ?? []; + const scoreTarget = scoreTargetForArcBoard(roster); + const revisitRoster = scoreTarget ? roster.filter(site => site.id !== scoreTarget.id) : roster; + const biasedSlots = + scoreTarget && scoreTarget.principal ? biasedBoardSlots(context.arcStage) : 0; + const heat = campaignClockHeat(campaign); for (let i = 0; i < CONTRACTS_PER_VISIT; i++) { const difficulty = rng.pick([...pool]); @@ -553,9 +559,14 @@ export class Curator extends Entity { // unchanged (no extra rng draws) and pre-P2.5.M7.2 determinism holds. let revisitSite: LocationSite | undefined; let built: RenderedContract | undefined; + let builtFreshBias = false; let seed: number; - if (roster.length > 0 && rng.chance(SITE_REVISIT_CHANCE)) { - const candidate = rng.pick(roster); + if (i < biasedSlots && scoreTarget) { + built = generatePrincipalBiasedContract(rng, labelsUsed, context, scoreTarget) ?? undefined; + builtFreshBias = !!built; + } + if (!built && revisitRoster.length > 0 && rng.chance(SITE_REVISIT_CHANCE)) { + const candidate = rng.pick(revisitRoster); // Pinnable only if it carries identity tokens and isn't already on this // board. Otherwise fall through to a fresh contract. if (candidate.principal && !revisitedSiteIds.has(candidate.id)) { @@ -569,6 +580,8 @@ export class Curator extends Entity { if (!built) { built = generateRecipeContract(rng, labelsUsed, context); seed = rng.intRange(0, 0x7fffffff); + } else if (builtFreshBias) { + seed = rng.intRange(0, 0x7fffffff); } else { seed = siteSeedToContractSeed(revisitSite!); } @@ -588,7 +601,7 @@ export class Curator extends Entity { mapHeight: dimensions.height, objective: applyDoorRoutingToObjective(built.objective, difficulty), difficulty, - threatCount: spec.threatCount, + threatCount: applyHeatToThreat(spec.threatCount, difficulty, heat), label: built.label, context: revisitSite ? { ...built.context, locationSiteId: revisitSite.id } : built.context, reward, @@ -1023,6 +1036,56 @@ function generateRevisitContract( return null; } +function generatePrincipalBiasedContract( + rng: Rng, + labelsUsed: Set, + context: ContractRecipeContext, + scoreTarget: LocationSite +): RenderedContract | null { + if (!scoreTarget.principal) return null; + const principal = lexiconTokenFrom(scoreTarget.principal); + const avoidedSiteId = scoreTarget.site?.id; + const candidates = CONTRACT_RECIPES.filter(recipe => + groupsIntersect(principal.groups, recipe.principalGroups) + ); + if (candidates.length === 0) return null; + + for (let i = 0; i < MAX_UNIQUE_CONTRACT_ATTEMPTS; i++) { + const recipe = rng.pick(candidates); + const site = recipe.siteGroups + ? pickCompatibleTokenAvoiding( + rng, + CONTRACT_LEXICON.sites, + recipe.siteGroups, + avoidedSiteId, + recipe.id + ) + : undefined; + const siteState = recipe.siteStateGroups + ? pickCompatibleToken(rng, CONTRACT_LEXICON.siteStates, recipe.siteStateGroups, recipe.id) + : undefined; + const asset = pickCompatibleToken(rng, CONTRACT_LEXICON.assets, recipe.assetGroups, recipe.id); + const action = pickCompatibleToken( + rng, + CONTRACT_LEXICON.actions, + recipe.actionGroups, + recipe.id + ); + const tokens = { + principal, + ...(site ? { site } : {}), + ...(siteState ? { siteState } : {}), + asset, + action, + }; + const contract = buildContractFromRecipe(recipe, tokens, context); + if (labelsUsed.has(contract.label)) continue; + labelsUsed.add(contract.label); + return contract; + } + return null; +} + /** * Contract label: `// - `. * Site is omitted when the recipe has no site token; transient site state @@ -1048,6 +1111,61 @@ function groupsIntersect(groups: readonly string[], allowed: readonly string[]): return groups.some(group => allowed.includes(group)); } +function pickCompatibleTokenAvoiding( + rng: Rng, + tokens: readonly ContractToken[], + groups: readonly string[], + avoidId: string | undefined, + recipeId: string +): ContractToken { + const compatible = tokens.filter(tokenValue => groupsIntersect(tokenValue.groups, groups)); + if (compatible.length === 0) { + throw new Error(`Curator: recipe "${recipeId}" has no compatible token`); + } + const filtered = + avoidId && compatible.length > 1 + ? compatible.filter(tokenValue => tokenValue.id !== avoidId) + : compatible; + return rng.pick(filtered.length > 0 ? filtered : compatible); +} + +function scoreTargetForArcBoard(roster: readonly LocationSite[]): LocationSite | null { + const targets = roster.filter(site => site.scoreTarget); + if (targets.length > 1) { + throw new Error('Curator.generateContracts: multiple Score targets in site roster'); + } + return targets[0] ?? null; +} + +function biasedBoardSlots(arcStage: CampaignArcStage | undefined): number { + if (arcStage === 'act-2') return 1; + if (arcStage === 'act-3') return 2; + return 0; +} + +function campaignClockHeat(campaign: ContractCampaign): number { + const heat = campaign?.clockHeat ?? 0; + if (!Number.isFinite(heat) || heat < 0) { + throw new RangeError(`Curator.generateContracts: clockHeat must be a non-negative number`); + } + return Math.floor(heat); +} + +function applyHeatToThreat( + baseThreat: number, + difficulty: ContractDifficulty, + heat: number +): number { + if (heat === 0) return baseThreat; + const cap = + difficulty === CONTRACT_DIFFICULTY.STANDARD + ? 3 + : difficulty === CONTRACT_DIFFICULTY.ELEVATED + ? 5 + : 6; + return Math.min(cap, baseThreat + heat); +} + function buildContractFromRecipe( recipe: ContractRecipe, tokens: ContractRecipeTokens, diff --git a/src/game/hub/arcSurface.ts b/src/game/hub/arcSurface.ts index 54b9c84..2fb42d6 100644 --- a/src/game/hub/arcSurface.ts +++ b/src/game/hub/arcSurface.ts @@ -1,10 +1,19 @@ import type { Contract } from './Curator.js'; import type { Campaign } from '../Campaign.js'; +import type { HubReveals } from './hubReveals.js'; +import { CLOCK_ACT2_DEADLINE_JOBS, CLOCK_ACT2_GRACE_JOBS } from '../Campaign.js'; import type { CampaignArcStage, LocationSite } from '../../types.js'; import type { Decker } from '../archetypes/Decker.js'; import type { Crew } from '../Crew.js'; -type ArcSurfaceCampaign = Pick; +type ArcSurfaceCampaign = Pick & + Partial< + Pick + > & { + hubReveals?: HubReveals; + }; + +export type HubArcStatusLines = readonly [summary: string, clock: string | null]; const ARC_STAGE_LABELS: Record = Object.freeze({ 'act-1': 'STAGE 1: STREET LEVEL', @@ -39,6 +48,12 @@ export function formatArcStageLabel(stage: CampaignArcStage): string { } export function formatHubArcStatus(campaign: ArcSurfaceCampaign): string { + return formatHubArcStatusLines(campaign) + .filter((line): line is string => line !== null) + .join('\n'); +} + +export function formatHubArcStatusLines(campaign: ArcSurfaceCampaign): HubArcStatusLines { const parts = [formatArcStageLabel(campaign.arc.arcStage)]; if (campaign.arc.scoreRevealed) { const target = findScoreTargetSite(campaign.siteRoster); @@ -47,7 +62,22 @@ export function formatHubArcStatus(campaign: ArcSurfaceCampaign): string { } parts.push(`SCORE: ${scoreTargetDisplayName(target)}`); } - return parts.join(' | '); + return [parts.join(' | '), formatClockStatus(campaign)]; +} + +/** Clock HUD line — only after the Curator briefing, and only while heat is active. */ +export function formatClockStatus(campaign: ArcSurfaceCampaign): string | null { + if (!campaign.hubReveals?.clockBriefingPresented) return null; + if (!campaign.arc.clockStarted) return null; + if (campaign.arc.scoreAttempted || campaign.arc.scoreCompleted) return null; + + const clockJobsTaken = campaign.clockJobsTaken ?? 0; + if (clockJobsTaken >= CLOCK_ACT2_DEADLINE_JOBS) return null; + + const heat = campaign.clockHeat ?? Math.max(0, clockJobsTaken - CLOCK_ACT2_GRACE_JOBS); + const jobsRemaining = + campaign.scoreDeadlineJobsRemaining ?? Math.max(0, CLOCK_ACT2_DEADLINE_JOBS - clockJobsTaken); + return `CLOCK: HEAT ${heat} / ${jobsRemaining} JOBS LEFT`; } export function scoreRevealLines(campaign: ArcSurfaceCampaign): readonly string[] { @@ -62,11 +92,30 @@ export function scoreRevealLines(campaign: ArcSurfaceCampaign): readonly string[ const deckerName = findDecker(campaign.crew).callsign; return [ `CURATOR: We found it: ${targetName}. The Score has a door.`, - 'CURATOR: Contracts touching that site are casing work now. Watch for SCORE SITE on the board.', + 'CURATOR: Jobs tagged CASING on the board hit the Score org — take those to learn the target.', `CURATOR: You'll need a Decker to crack the ICE; I've got ${deckerName} for you.`, ]; } +export function clockRevealLines(campaign: ArcSurfaceCampaign): readonly string[] { + if (!campaign.arc.clockStarted) { + throw new Error('arcSurface: cannot build Clock reveal copy before clockStarted'); + } + const principal = scorePrincipalLabel(campaign); + return [ + 'CURATOR: The Score window is live — corp security is tracking the org.', + `CURATOR: Every job you take from here adds heat: more hostiles, tighter alarms.`, + `CURATOR: Run ${principal} before the window closes or the contract goes cold.`, + ]; +} + +export function act3RevealLines(): readonly string[] { + return [ + "CURATOR: You're ready. Our window is open, but it's tight.", + 'CURATOR: Do your final prep, watch your heat, and grab THE SCORE from the board while you can.', + ]; +} + export function findDecker(crew: readonly Crew[]): Decker { const decker = crew.find(crew => crew.archetype === 'Decker'); if (!decker) { @@ -82,10 +131,33 @@ export function isScoreSiteContract( return !!scoreTargetSiteId && contract.context.locationSiteId === scoreTargetSiteId; } +export function isScorePrincipalContract( + contract: Contract, + scorePrincipalId: string | null | undefined, + scoreTargetSiteId: string | null | undefined +): boolean { + if (!scorePrincipalId || contract.context.principal?.id !== scorePrincipalId) return false; + if (isScoreSiteContract(contract, scoreTargetSiteId)) return false; + if (contract.context.recipeId === 'score-final') return false; + return true; +} + export function scoreTargetSiteId(campaign: ArcSurfaceCampaign): string | null { return findScoreTargetSite(campaign.siteRoster)?.id ?? null; } +export function scorePrincipalId(campaign: ArcSurfaceCampaign): string | null { + return findScoreTargetSite(campaign.siteRoster)?.principal?.id ?? null; +} + +function scorePrincipalLabel(campaign: ArcSurfaceCampaign): string { + const target = findScoreTargetSite(campaign.siteRoster); + if (!target) { + throw new Error('arcSurface: cannot build Clock reveal copy without a Score target'); + } + return cleanLabel(target.principal?.label) || scoreTargetDisplayName(target); +} + function cleanLabel(label: string | undefined): string { return label?.trim() ?? ''; } diff --git a/src/game/hub/hubReveals.ts b/src/game/hub/hubReveals.ts index 9164840..574fff8 100644 --- a/src/game/hub/hubReveals.ts +++ b/src/game/hub/hubReveals.ts @@ -8,7 +8,7 @@ import { REP } from '../constants.js'; import { totalSalvage } from '../salvage.js'; -import { scoreRevealLines } from './arcSurface.js'; +import { clockRevealLines, act3RevealLines, scoreRevealLines } from './arcSurface.js'; import type { Campaign } from '../Campaign.js'; export type HubReveals = { @@ -20,9 +20,20 @@ export type HubReveals = { clinicIntroduced?: boolean; /** Curator has presented the Phase 3 Score target reveal. */ scoreBriefingPresented?: boolean; + /** Curator has explained Clock heat and the Score window deadline. */ + clockBriefingPresented?: boolean; + /** Curator has presented Act 3 final prep and THE SCORE availability. */ + act3BriefingPresented?: boolean; }; -export type HubRevealId = 'finn' | 'terminal' | 'terminal-recruit' | 'clinic' | 'score-reveal'; +export type HubRevealId = + | 'finn' + | 'terminal' + | 'terminal-recruit' + | 'clinic' + | 'score-reveal' + | 'clock-reveal' + | 'act-3-reveal'; export type HubRevealMessage = { id: HubRevealId; @@ -41,7 +52,18 @@ type HubRevealDefinition = { }; /** Reveal flags committed when the shell dismisses the Curator briefing, not when queued. */ -const HUB_REVEAL_DEFER_FLAG_COMMIT = new Set(['score-reveal']); +const HUB_REVEAL_DEFER_FLAG_COMMIT = new Set([ + 'score-reveal', + 'clock-reveal', + 'act-3-reveal', +]); + +/** Arc beats checked before lower-priority Hub intros; order within this list matters. */ +const PRIORITY_HUB_REVEALS: readonly HubRevealId[] = [ + 'score-reveal', + 'clock-reveal', + 'act-3-reveal', +]; const HUB_REVEAL_DEFINITIONS: readonly HubRevealDefinition[] = [ { @@ -65,6 +87,28 @@ const HUB_REVEAL_DEFINITIONS: readonly HubRevealDefinition[] = [ }, lines: scoreRevealLines, }, + { + id: 'clock-reveal', + flag: 'clockBriefingPresented', + title: '── THE CLOCK ──', + qualifies(campaign) { + return ( + campaign.arc.clockStarted && + !!campaign.hubReveals.scoreBriefingPresented && + !campaign.hubReveals.clockBriefingPresented + ); + }, + lines: clockRevealLines, + }, + { + id: 'act-3-reveal', + flag: 'act3BriefingPresented', + title: '── FINAL PREP / THE SCORE ──', + qualifies(campaign) { + return !!campaign.hubReveals.scoreBriefingPresented && campaign.canAttemptScore(); + }, + lines: act3RevealLines, + }, { id: 'finn', flag: 'finnIntroduced', @@ -122,6 +166,8 @@ export function normalizeHubReveals(raw: unknown, context = 'hubReveals'): HubRe 'terminalRecruitmentExplained', 'clinicIntroduced', 'scoreBriefingPresented', + 'clockBriefingPresented', + 'act3BriefingPresented', ] as const) { if (record[key] === undefined) continue; if (typeof record[key] !== 'boolean') { @@ -165,6 +211,8 @@ export function snapshotHubReveals(reveals: HubReveals): HubReveals { if (reveals.terminalExplained) out.terminalExplained = true; if (reveals.clinicIntroduced) out.clinicIntroduced = true; if (reveals.scoreBriefingPresented) out.scoreBriefingPresented = true; + if (reveals.clockBriefingPresented) out.clockBriefingPresented = true; + if (reveals.act3BriefingPresented) out.act3BriefingPresented = true; return out; } @@ -212,21 +260,18 @@ export function commitHubReveal(campaign: Campaign, id: HubRevealId): void { /** * Evaluate reveal triggers in definition order. Sets the first qualifying * unseen flag on `campaign.hubReveals` and returns its message. Does not persist. - * Score reveal is checked first so Act 2 openings are not crowded out by lower - * priority intros on the same visit. Score briefing flags commit on dismiss. + * Score and Clock reveals are checked first so arc beats are not crowded out by + * lower-priority intros on the same visit. Their briefing flags commit on dismiss. */ export function applyFirstHubReveal(campaign: Campaign): HubRevealMessage | null { - const scoreReveal = HUB_REVEAL_DEFINITIONS.find(def => def.id === 'score-reveal'); - if (scoreReveal && !campaign.hubReveals[scoreReveal.flag] && scoreReveal.qualifies(campaign)) { - return buildHubRevealMessage( - scoreReveal, - campaign, - !HUB_REVEAL_DEFER_FLAG_COMMIT.has(scoreReveal.id) - ); + for (const id of PRIORITY_HUB_REVEALS) { + const def = HUB_REVEAL_DEFINITIONS.find(entry => entry.id === id); + if (!def || campaign.hubReveals[def.flag] || !def.qualifies(campaign)) continue; + return buildHubRevealMessage(def, campaign, !HUB_REVEAL_DEFER_FLAG_COMMIT.has(def.id)); } for (const def of HUB_REVEAL_DEFINITIONS) { - if (def.id === 'score-reveal') continue; + if (PRIORITY_HUB_REVEALS.includes(def.id)) continue; if (campaign.hubReveals[def.flag]) continue; if (!def.qualifies(campaign)) continue; return buildHubRevealMessage(def, campaign, true); diff --git a/src/game/persistence.ts b/src/game/persistence.ts index b6350e1..d22e937 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -771,6 +771,8 @@ export type CampaignSnapshot = { hubReveals?: HubRevealsSnapshot; /** Count of completed jobs. Abort extractions do not increment this arc counter. */ completedJobs?: number; + /** Act-2/3 deploys that drive Clock heat. Defaults to 0 for pre-P3.M1.5 saves. */ + clockJobsTaken?: number; /** Persistent key-item inventory (keycards). Defaults to [] for pre-P2.5.M6.2 saves. */ keyItems?: KeyItemSnapshot[]; /** Remembered combat locations (site roster). Defaults to [] for pre-P2.5.M7.2 saves. */ @@ -792,6 +794,8 @@ export type HubRevealsSnapshot = { terminalRecruitmentExplained?: boolean; clinicIntroduced?: boolean; scoreBriefingPresented?: boolean; + clockBriefingPresented?: boolean; + act3BriefingPresented?: boolean; }; /** @@ -831,6 +835,7 @@ export function snapshotCampaign(campaign: Campaign): CampaignSnapshot { healedThisVisit: [...campaign.healedThisVisit], hubReveals: snapshotHubReveals(campaign.hubReveals), completedJobs: campaign.completedJobs, + clockJobsTaken: campaign.clockJobsTaken, keyItems: campaign.keyItems.map(k => ({ ...k })), siteRoster: campaign.siteRoster.map(snapshotLocationSite), }; @@ -976,6 +981,7 @@ export function restoreCampaign(record: unknown, options: RestoreCampaignOptions 'restoreCampaign hubReveals' ), completedJobs: record.completedJobs ?? 0, + clockJobsTaken: record.clockJobsTaken ?? 0, keyItems: record.keyItems, siteRoster: record.siteRoster, onPersist: options.onPersist, @@ -1649,6 +1655,11 @@ function validateCampaignRecord(record: unknown): asserts record is CampaignSnap throw new RangeError('restoreCampaign: completedJobs must be a non-negative integer'); } } + if (candidate.clockJobsTaken !== undefined) { + if (!Number.isInteger(candidate.clockJobsTaken) || candidate.clockJobsTaken < 0) { + throw new RangeError('restoreCampaign: clockJobsTaken must be a non-negative integer'); + } + } if (candidate.siteRoster !== undefined) { if (!Array.isArray(candidate.siteRoster)) { throw new TypeError('restoreCampaign: siteRoster must be an array when present'); diff --git a/src/types.ts b/src/types.ts index e70c2c5..bc297a7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,6 +16,9 @@ export type GridPoint = { x: number; y: number }; /** Phase 3 campaign arc stage, shared by Campaign saves and Curator contract context. */ export type CampaignArcStage = 'act-1' | 'act-2' | 'act-3' | 'score'; +/** Why a campaign reached `ENDED` — drives terminal debrief copy in the shell. */ +export type CampaignEndReason = 'crew-wipe' | 'clock-expired' | 'score-complete'; + /** * A JSON-safe value. The persistence layer's opaque entity property bag * (`EntitySnapshotExtra`) is keyed to this so anything stashed in a snapshot @@ -240,6 +243,7 @@ export type LocationSite = { export type Telemetry = { outcome: 'death' | 'exit' | 'campaign-over'; + campaignEndReason?: CampaignEndReason; campaignTerminal?: boolean; crewRoster?: { callsign: string; archetype: string; flatlined: boolean }[]; /** Typed-salvage wallet snapshot at the moment the run/campaign ends. */ diff --git a/sw-dev.js b/sw-dev.js index d3b5b9d..25811e2 100644 --- a/sw-dev.js +++ b/sw-dev.js @@ -1,5 +1,5 @@ // Service Worker for Kernel Panic - Development Version -const VERSION = '0.2.10-dev'; +const VERSION = '0.2.11-dev'; importScripts(`/sw-core.js?v=${VERSION}`); const cacheConfig = CacheConfig.create(VERSION); diff --git a/tests/unit/game/Campaign.test.ts b/tests/unit/game/Campaign.test.ts index 586a08b..99e1390 100644 --- a/tests/unit/game/Campaign.test.ts +++ b/tests/unit/game/Campaign.test.ts @@ -4,6 +4,8 @@ import assert from 'node:assert/strict'; import { Campaign, CAMPAIGN_STATE, + CLOCK_ACT2_DEADLINE_JOBS, + CLOCK_ACT2_GRACE_JOBS, SITE_ROSTER_CAP, buildCrew, defaultCampaignArc, @@ -13,7 +15,12 @@ import { OUTCOME, RUN_STATE } from '../../../src/game/Run.js'; import { Rng } from '../../../src/rng.js'; import { OBJECTIVES } from '../../../src/game/hub/Curator.js'; import { snapshotCampaign, restoreCampaign } from '../../../src/game/persistence.js'; -import { SALVAGE_SELL_RATE, SHOP_COST, TILE } from '../../../src/game/constants.js'; +import { + CONTRACT_DIFFICULTY, + SALVAGE_SELL_RATE, + SHOP_COST, + TILE, +} from '../../../src/game/constants.js'; import { emptySalvage, makeSalvage, totalSalvage } from '../../../src/game/salvage.js'; import { testContractContext } from './contractTestUtils.js'; import { buildCrewMember } from '../../../src/game/archetypes/index.js'; @@ -936,6 +943,165 @@ test('P3.M1.3: multiple persisted Score targets crash instead of being normalize ); }); +test('P3.M1.5: Clock ignores completedJobs until act-2/3 deploys cross grace', () => { + const freshAct2 = new Campaign({ seed: 42, rep: 60, completedJobs: 9 }); + assert.equal(freshAct2.arc.clockStarted, false); + assert.equal(freshAct2.clockHeat, 0); + assert.equal(freshAct2.scoreDeadlineJobsRemaining, CLOCK_ACT2_DEADLINE_JOBS); + + const heating = new Campaign({ + seed: 42, + rep: 60, + completedJobs: 9, + clockJobsTaken: CLOCK_ACT2_GRACE_JOBS + 2, + }); + assert.equal(heating.arc.clockStarted, true); + assert.equal(heating.clockHeat, 2); + assert.equal( + heating.scoreDeadlineJobsRemaining, + CLOCK_ACT2_DEADLINE_JOBS - heating.clockJobsTaken + ); + assert.equal(heating.state, CAMPAIGN_STATE.HUB); + + const expired = new Campaign({ + seed: 43, + rep: 60, + completedJobs: 4, + clockJobsTaken: CLOCK_ACT2_DEADLINE_JOBS, + }); + assert.equal(expired.clockHeat, CLOCK_ACT2_DEADLINE_JOBS - CLOCK_ACT2_GRACE_JOBS); + assert.equal(expired.arc.clockStarted, true); + assert.equal(expired.state, CAMPAIGN_STATE.ENDED); + assert.equal(expired.endReason, 'clock-expired'); +}); + +test('P3.M1.5: endReason distinguishes score win, clock loss, and crew wipe', () => { + const clockLoss = new Campaign({ + seed: 43, + rep: 60, + completedJobs: 4, + clockJobsTaken: CLOCK_ACT2_DEADLINE_JOBS, + }); + assert.equal(clockLoss.endReason, 'clock-expired'); + + const scoreWin = new Campaign({ + seed: 44, + rep: 60, + completedJobs: 4, + arc: { + arcStage: 'score', + deckerRecruited: true, + scoreRevealed: true, + clockStarted: true, + scoreAttempted: true, + scoreCompleted: true, + }, + siteRoster: [ + validSite({ id: 'score', seed: '100', tier: 'score', scoreTarget: true, lastVisitedJob: 5 }), + ], + }); + scoreWin.state = CAMPAIGN_STATE.ENDED; + assert.equal(scoreWin.endReason, 'score-complete'); + + const crewWipe = new Campaign({ + seed: 45, + rep: 60, + completedJobs: 2, + }); + crewWipe.crew.forEach(member => { + member.flatlined = true; + }); + crewWipe.state = CAMPAIGN_STATE.ENDED; + assert.equal(crewWipe.endReason, 'crew-wipe'); +}); + +test('P3.M1.5: Clock deadline does not end the campaign after the Score is attempted', () => { + const campaign = new Campaign({ + seed: 42, + rep: 60, + completedJobs: 4, + clockJobsTaken: CLOCK_ACT2_DEADLINE_JOBS, + arc: { + arcStage: 'score', + deckerRecruited: true, + scoreRevealed: true, + clockStarted: true, + scoreAttempted: true, + scoreCompleted: false, + }, + siteRoster: [ + validSite({ id: 'score', seed: '100', tier: 'score', scoreTarget: true, lastVisitedJob: 5 }), + ], + }); + + assert.equal(campaign.state, CAMPAIGN_STATE.HUB); + assert.equal(campaign.clockHeat, CLOCK_ACT2_DEADLINE_JOBS - CLOCK_ACT2_GRACE_JOBS); +}); + +test('P3.M1.7: Score contract is gated to Act 3 and marks attempted on deployment', () => { + const scorePrincipal = { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }; + const campaign = new Campaign({ + seed: 42, + rep: 60, + completedJobs: 9, + siteRoster: [ + validSite({ + id: 'score', + seed: '100', + tier: 'score', + scoreTarget: true, + lastVisitedJob: 5, + principal: scorePrincipal, + site: { id: 'server-farm', label: 'server farm', groups: ['corp', 'data'] }, + }), + validSite({ id: 'case-1', seed: '101', lastVisitedJob: 6, principal: scorePrincipal }), + validSite({ id: 'case-2', seed: '102', lastVisitedJob: 7, principal: scorePrincipal }), + ], + }); + assert.equal(campaign.arcStage, 'act-3'); + assert.equal(campaign.canAttemptScore(), true); + + const score = campaign.buildScoreContract(); + assert.equal(score.difficulty, CONTRACT_DIFFICULTY.CRITICAL); + assert.equal(score.context.locationSiteId, 'score'); + assert.ok(score.context.tags.includes('score')); + assert.match(score.label, /THE SCORE/); + + const run = campaign.deployCrewMember(campaign.crew[0]!.id, score); + assert.equal(campaign.arc.scoreAttempted, true); + assert.equal(campaign.arcStage, 'score'); + assert.equal(run.contract?.context.locationSiteId, 'score'); +}); + +test('P3.M1.7: completed Score contract records campaign win state', () => { + const scorePrincipal = { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }; + const campaign = new Campaign({ + seed: 42, + rep: 60, + completedJobs: 9, + siteRoster: [ + validSite({ + id: 'score', + seed: '100', + tier: 'score', + scoreTarget: true, + lastVisitedJob: 5, + principal: scorePrincipal, + site: { id: 'server-farm', label: 'server farm', groups: ['corp', 'data'] }, + }), + validSite({ id: 'case-1', seed: '101', lastVisitedJob: 6, principal: scorePrincipal }), + validSite({ id: 'case-2', seed: '102', lastVisitedJob: 7, principal: scorePrincipal }), + ], + }); + const run = campaign.deployCrewMember(campaign.crew[0]!.id, campaign.buildScoreContract()); + run.enterCombat(); + + campaign.onJobEnd({ outcome: OUTCOME.EXIT, completed: true }); + + assert.equal(campaign.arc.scoreCompleted, true); + assert.equal(campaign.state, CAMPAIGN_STATE.ENDED); +}); + // ─── Recruitment ──────────────────────────────────────────────────────── test('generateRecruits returns 1–2 candidates when Rep ≥ 65', () => { diff --git a/tests/unit/game/hub/Curator.test.ts b/tests/unit/game/hub/Curator.test.ts index 27e3d9a..b3a6c60 100644 --- a/tests/unit/game/hub/Curator.test.ts +++ b/tests/unit/game/hub/Curator.test.ts @@ -314,6 +314,86 @@ test('same seed plus same arc stage yields identical recipe contracts', () => { } }); +test('P3.M1.5: campaign Clock heat raises threat counts without changing difficulty', () => { + const baseline = new Curator().generateContracts(new Rng(999), { rep: 60, clockHeat: 0 }); + const heated = new Curator().generateContracts(new Rng(999), { rep: 60, clockHeat: 2 }); + + assert.deepEqual( + heated.map(contract => contract.difficulty), + baseline.map(contract => contract.difficulty) + ); + assert.ok( + heated.some((contract, index) => contract.threatCount > baseline[index]!.threatCount), + 'expected at least one board slot to gain heat pressure' + ); +}); + +test('P3.M1.6: Act 2 guarantees at least one same-principal casing contract', () => { + const roster = [ + { + id: 'score', + seed: '100', + mapWidth: 32, + mapHeight: 20, + label: '// Matsuda server farm - Score target', + tier: 'score', + scoreTarget: true, + mutationDeltas: [], + seenKeys: [], + lastVisitedJob: 0, + principal: { id: 'matsuda', label: 'Matsuda', groups: ['corp', 'finance'] }, + site: { id: 'server-farm', label: 'server farm', groups: ['corp', 'data'] }, + }, + ]; + + const contracts = new Curator().generateContracts(new Rng(777), { + rep: 60, + arcStage: 'act-2', + siteRoster: roster, + }); + + assert.equal(contracts.length, 3); + assert.ok( + contracts.some(contract => contract.context.principal.id === 'matsuda'), + 'Act 2 board should include a same-principal casing job' + ); + assert.ok( + contracts.every(contract => contract.context.locationSiteId !== 'score'), + 'normal board should not roll the Score target as the final job' + ); +}); + +test('P3.M1.6: Act 3 board is mostly same-principal prep contracts', () => { + const roster = [ + { + id: 'score', + seed: '100', + mapWidth: 32, + mapHeight: 20, + label: '// Matsuda server farm - Score target', + tier: 'score', + scoreTarget: true, + mutationDeltas: [], + seenKeys: [], + lastVisitedJob: 5, + principal: { id: 'matsuda', label: 'Matsuda', groups: ['corp', 'finance'] }, + site: { id: 'server-farm', label: 'server farm', groups: ['corp', 'data'] }, + }, + ]; + + const contracts = new Curator().generateContracts(new Rng(888), { + rep: 80, + arcStage: 'act-3', + siteRoster: roster, + }); + + assert.ok( + contracts.filter(contract => contract.context.principal.id === 'matsuda').length >= 2, + 'Act 3 board should be mostly Score-principal prep' + ); + assert.ok(contracts.every(contract => contract.context.locationSiteId !== 'score')); +}); + test('different seeds vary compatible recipe output', () => { const labels = new Set(); for (let seed = 0; seed < 8; seed++) { diff --git a/tests/unit/game/hub/arcSurface.test.ts b/tests/unit/game/hub/arcSurface.test.ts index 670ba29..452608e 100644 --- a/tests/unit/game/hub/arcSurface.test.ts +++ b/tests/unit/game/hub/arcSurface.test.ts @@ -4,10 +4,14 @@ import assert from 'node:assert/strict'; import { CONTRACT_DIFFICULTY } from '../../../../src/game/constants.js'; import { buildContractRecipeFixture } from '../../../../src/game/hub/Curator.js'; import { + act3RevealLines, findDecker, findScoreTargetSite, formatArcStageLabel, + formatClockStatus, formatHubArcStatus, + formatHubArcStatusLines, + isScorePrincipalContract, isScoreSiteContract, scoreRevealLines, scoreTargetDisplayName, @@ -15,6 +19,7 @@ import { } from '../../../../src/game/hub/arcSurface.js'; import { Decker } from '../../../../src/game/archetypes/Decker.js'; import { Merc } from '../../../../src/game/archetypes/Merc.js'; +import { CLOCK_ACT2_DEADLINE_JOBS, CLOCK_ACT2_GRACE_JOBS } from '../../../../src/game/Campaign.js'; import type { CampaignArc } from '../../../../src/game/Campaign.js'; import type { LocationSite, LocationToken } from '../../../../src/types.js'; @@ -55,25 +60,101 @@ function scoreSite(overrides: Partial = {}): LocationSite { }; } -test('formatHubArcStatus shows act label and Score target once revealed', () => { +test('formatHubArcStatusLines omits clock until the Curator briefing is dismissed', () => { const campaign = { - arc: arc({ arcStage: 'act-2', scoreRevealed: true }), + arc: arc({ arcStage: 'act-2', scoreRevealed: true, clockStarted: true }), siteRoster: [scoreSite()], + crew: [], + hubReveals: {}, + clockJobsTaken: CLOCK_ACT2_GRACE_JOBS + 1, + clockHeat: 1, + scoreDeadlineJobsRemaining: 4, }; + assert.deepEqual(formatHubArcStatusLines(campaign), [ + 'STAGE 2: CASING | SCORE: Matsuda server farm', + null, + ]); assert.equal(formatHubArcStatus(campaign), 'STAGE 2: CASING | SCORE: Matsuda server farm'); }); +test('formatHubArcStatusLines shows active heat only after clock briefing', () => { + const campaign = { + arc: arc({ arcStage: 'act-2', scoreRevealed: true, clockStarted: true }), + siteRoster: [scoreSite()], + crew: [], + hubReveals: { clockBriefingPresented: true }, + clockJobsTaken: CLOCK_ACT2_GRACE_JOBS + 2, + clockHeat: 2, + scoreDeadlineJobsRemaining: 3, + }; + assert.deepEqual(formatHubArcStatusLines(campaign), [ + 'STAGE 2: CASING | SCORE: Matsuda server farm', + 'CLOCK: HEAT 2 / 3 JOBS LEFT', + ]); +}); + test('formatHubArcStatus throws when revealed state has no Score target', () => { assert.throws( () => formatHubArcStatus({ arc: arc({ arcStage: 'act-2', scoreRevealed: true }), siteRoster: [], + crew: [], }), /score revealed without a Score target/i ); }); +test('formatClockStatus stays hidden before briefing, during grace, and after deadline', () => { + assert.equal( + formatClockStatus({ + arc: arc({ scoreRevealed: true }), + siteRoster: [scoreSite()], + crew: [], + hubReveals: {}, + clockJobsTaken: 1, + }), + null + ); + assert.equal( + formatClockStatus({ + arc: arc({ scoreRevealed: true, clockStarted: false }), + siteRoster: [scoreSite()], + crew: [], + hubReveals: { clockBriefingPresented: true }, + clockJobsTaken: 1, + }), + null + ); + assert.equal( + formatClockStatus({ + arc: arc({ scoreRevealed: true, clockStarted: true, scoreAttempted: true }), + siteRoster: [scoreSite()], + crew: [], + hubReveals: { clockBriefingPresented: true }, + clockJobsTaken: CLOCK_ACT2_DEADLINE_JOBS, + }), + null + ); + assert.equal( + formatClockStatus({ + arc: arc({ scoreRevealed: true, clockStarted: true }), + siteRoster: [scoreSite()], + crew: [], + hubReveals: { clockBriefingPresented: true }, + clockJobsTaken: CLOCK_ACT2_DEADLINE_JOBS, + }), + null + ); +}); + +test('act3 reveal copy points at THE SCORE on the job board', () => { + const lines = act3RevealLines(); + assert.match(lines.join('\n'), /You're ready/i); + assert.match(lines.join('\n'), /THE SCORE/i); + assert.match(lines.join('\n'), /heat/i); +}); + test('Score target helpers reject multiple targets instead of guessing', () => { const sites = [scoreSite({ id: 'a' }), scoreSite({ id: 'b' })]; assert.throws(() => findScoreTargetSite(sites), /multiple Score targets/i); @@ -87,7 +168,7 @@ test('score reveal copy names the target and points at job-board badges', () => crew: [new Decker({ id: 'decker', x: 0, y: 0, callsign: 'Case' })], }); assert.match(lines.join('\n'), /Matsuda server farm/); - assert.match(lines.join('\n'), /SCORE SITE/); + assert.match(lines.join('\n'), /CASING/); assert.match(lines.join('\n'), /Case/); }); @@ -118,17 +199,28 @@ test('isScoreSiteContract matches contract locationSiteId to the Score target id assert.equal(isScoreSiteContract(contract, 'score-site'), true); assert.equal(isScoreSiteContract(contract, 'other-site'), false); - assert.equal(isScoreSiteContract(contract, null), false); }); -test('findDecker throws when no Decker is present', () => { - const merc = new Merc({ id: 'merc', x: 0, y: 0, callsign: 'Glitch' }); - assert.throws(() => findDecker([merc]), /without a Decker/i); - assert.throws(() => findDecker([]), /without a Decker/i); +test('isScorePrincipalContract matches same-principal jobs but not the Score site', () => { + const contract = buildContractRecipeFixture({ + recipeId: 'terminal-slice', + principalId: 'matsuda', + siteId: 'server-farm', + assetId: 'identity-spool', + actionId: 'slice', + difficulty: CONTRACT_DIFFICULTY.STANDARD, + seed: 11, + }); + contract.context.locationSiteId = 'case-site'; + + assert.equal(isScorePrincipalContract(contract, 'matsuda', 'score-site'), true); + contract.context.locationSiteId = 'score-site'; + assert.equal(isScorePrincipalContract(contract, 'matsuda', 'score-site'), false); }); -test('findDecker returns the Decker when one is present', () => { - const decker = new Decker({ id: 'decker', x: 0, y: 0, callsign: 'Case' }); - const crew = [new Merc({ id: 'merc', x: 1, y: 1, callsign: 'Glitch' }), decker]; - assert.equal(findDecker(crew), decker); +test('findDecker throws when crew has no Decker', () => { + assert.throws( + () => findDecker([new Merc({ id: 'merc', x: 0, y: 0, callsign: 'Wraith' })]), + /without a Decker/i + ); }); diff --git a/tests/unit/game/hub/hubReveals.test.ts b/tests/unit/game/hub/hubReveals.test.ts index 5af2164..584d071 100644 --- a/tests/unit/game/hub/hubReveals.test.ts +++ b/tests/unit/game/hub/hubReveals.test.ts @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { Campaign } from '../../../../src/game/Campaign.js'; +import { Campaign, CLOCK_ACT2_GRACE_JOBS } from '../../../../src/game/Campaign.js'; import { REP } from '../../../../src/game/constants.js'; import { OUTCOME } from '../../../../src/game/Run.js'; import { @@ -28,6 +28,7 @@ function hubCampaign( rep?: number; hubReveals?: Record; completedJobs?: number; + clockJobsTaken?: number; } = {} ) { const campaign = new Campaign({ @@ -37,6 +38,7 @@ function hubCampaign( rep: opts.rep ?? REP.START, hubReveals: opts.hubReveals, completedJobs: opts.completedJobs, + clockJobsTaken: opts.clockJobsTaken, }); return campaign; } @@ -143,7 +145,7 @@ test('Score reveal presents the target and assigns a Decker when Act 2 opens', ( assert.equal(campaign.hubReveals.scoreBriefingPresented, undefined); assert.ok(hubRevealCommitsOnDismiss('score-reveal')); const revealText = campaign.lastHubReveal?.lines.join('\n') ?? ''; - assert.match(revealText, /SCORE SITE/); + assert.match(revealText, /CASING/); assert.match(revealText, /Decker/); // Decker was assigned as part of the same transition beat @@ -159,6 +161,106 @@ test('Score reveal presents the target and assigns a Decker when Act 2 opens', ( assert.equal(campaign.lastHubReveal, null); }); +test('Clock reveal fires after grace deploys once Score briefing was dismissed', () => { + const campaign = hubCampaign({ + rep: 60, + completedJobs: 4, + clockJobsTaken: CLOCK_ACT2_GRACE_JOBS, + hubReveals: { + terminalExplained: true, + finnIntroduced: true, + clinicIntroduced: true, + terminalRecruitmentExplained: true, + scoreBriefingPresented: true, + }, + }); + assert.equal(campaign.arc.clockStarted, true); + assert.equal(campaign.lastHubReveal?.id, 'clock-reveal'); + assert.equal(campaign.hubReveals.clockBriefingPresented, undefined); + assert.ok(hubRevealCommitsOnDismiss('clock-reveal')); + const revealText = campaign.lastHubReveal?.lines.join('\n') ?? ''; + assert.match(revealText, /heat/i); + assert.match(revealText, /window closes/i); + + commitHubReveal(campaign, 'clock-reveal'); + assert.equal(campaign.hubReveals.clockBriefingPresented, true); + campaign.enterHub(); + assert.equal(campaign.lastHubReveal, null); +}); + +test('Act 3 reveal presents THE SCORE when final prep unlocks', () => { + const scorePrincipal = { id: 'matsuda', label: 'Matsuda', groups: ['corp'] as const }; + const campaign = new Campaign({ + seed: 42, + rep: 60, + completedJobs: 9, + hubReveals: { + terminalExplained: true, + finnIntroduced: true, + clinicIntroduced: true, + terminalRecruitmentExplained: true, + scoreBriefingPresented: true, + clockBriefingPresented: true, + }, + siteRoster: [ + { + id: 'score', + seed: '100', + mapWidth: 32, + mapHeight: 20, + label: '// Matsuda server farm - Score target', + tier: 'score', + scoreTarget: true, + mutationDeltas: [], + seenKeys: [], + lastVisitedJob: 5, + principal: scorePrincipal, + site: { id: 'server-farm', label: 'server farm', groups: ['corp', 'data'] }, + }, + { + id: 'case-1', + seed: '101', + mapWidth: 24, + mapHeight: 16, + label: '// Matsuda case site', + tier: 'roster', + scoreTarget: false, + mutationDeltas: [], + seenKeys: [], + lastVisitedJob: 6, + principal: scorePrincipal, + }, + { + id: 'case-2', + seed: '102', + mapWidth: 24, + mapHeight: 16, + label: '// Matsuda case site 2', + tier: 'roster', + scoreTarget: false, + mutationDeltas: [], + seenKeys: [], + lastVisitedJob: 7, + principal: scorePrincipal, + }, + ], + }); + assert.equal(campaign.arc.arcStage, 'act-3'); + assert.equal(campaign.canAttemptScore(), true); + assert.equal(campaign.lastHubReveal?.id, 'act-3-reveal'); + assert.equal(campaign.hubReveals.act3BriefingPresented, undefined); + assert.ok(hubRevealCommitsOnDismiss('act-3-reveal')); + const revealText = campaign.lastHubReveal?.lines.join('\n') ?? ''; + assert.match(revealText, /You're ready/i); + assert.match(revealText, /THE SCORE/i); + assert.match(revealText, /heat/i); + + commitHubReveal(campaign, 'act-3-reveal'); + assert.equal(campaign.hubReveals.act3BriefingPresented, true); + campaign.enterHub(); + assert.equal(campaign.lastHubReveal, null); +}); + test('Score reveal takes priority over clinic when Act 2 opens with injured crew', () => { const campaign = hubCampaign({ rep: 60, From 660fd02385d3d97fd4dd68edeaaeff884383956c Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 9 Jun 2026 20:23:40 -0700 Subject: [PATCH 10/55] dedicated GameOver component --- components/CrashDump.ts | 161 +++--------------- components/FaultScreen.ts | 4 +- components/GameOver.ts | 338 ++++++++++++++++++++++++++++++++++++++ index.html | 1 + index.ts | 117 ++++++++++--- main.css | 1 + src/types.ts | 17 +- sw-core.js | 1 + sw-dev.js | 2 +- 9 files changed, 477 insertions(+), 165 deletions(-) create mode 100644 components/GameOver.ts diff --git a/components/CrashDump.ts b/components/CrashDump.ts index ebec5e2..5ec2dd8 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). Score-complete campaign wins reuse the green + * exit presentation. Terminal campaign loss uses sibling ``. * * *** KERNEL PANIC *** * fault: unhandled_exception_in_meatspace @@ -19,17 +17,15 @@ * * 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 / . */ import { h } from '/src/domUtils.js'; -import type { Telemetry } from '/src/types.js'; - -type CrewMemberStub = { callsign: string; archetype: string; flatlined: boolean }; +import type { GameOverCrewStub, Telemetry } from '/src/types.js'; const CSS = ` :host { @@ -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,14 +159,6 @@ function exitTitle() { return '*** JACK OUT ***'; } -function campaignOverTitle() { - return '*** CAMPAIGN TERMINATED ***'; -} - -function clockExpiredTitle() { - return '*** WINDOW CLOSED ***'; -} - function scoreCompleteTitle() { return '*** SCORE COMPLETE ***'; } @@ -186,55 +173,11 @@ 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 clockExpiredFault() { - return [ - 'fault: score_operational_window_expired', - 'addr: 0x00000@campaign_layer', - 'trace:', - ].join('\n'); -} - function scoreCompleteFault() { return ['status: score_extracted', '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 buildClockExpiredTraceLines() { - return [ - { text: ' 0x01 arc::clock_deadline_reached()', tag: '' }, - { text: ' 0x02 curator::score_contract_cold()', tag: '' }, - { text: ' 0x03 campaign::terminate(window_closed)', tag: '' }, - ]; -} - -function buildScoreCompleteTraceLines(crewRoster: CrewMemberStub[]) { +function buildScoreCompleteTraceLines(crewRoster: GameOverCrewStub[]) { const survivors = crewRoster.filter(op => !op.flatlined); const lead = survivors[0]; const lines = [ @@ -255,13 +198,7 @@ function buildScoreCompleteTraceLines(crewRoster: CrewMemberStub[]) { function buildTraceLines(telemetry: Telemetry) { if (telemetry.outcome === 'campaign-over') { - if (telemetry.campaignEndReason === 'clock-expired') { - return buildClockExpiredTraceLines(); - } - if (telemetry.campaignEndReason === 'score-complete') { - return buildScoreCompleteTraceLines(telemetry.crewRoster ?? []); - } - return buildCampaignOverTraceLines(telemetry.crewRoster ?? []); + return buildScoreCompleteTraceLines(telemetry.crewRoster ?? []); } const archetype = telemetry.archetype ?? 'entity'; @@ -269,8 +206,6 @@ function buildTraceLines(telemetry: Telemetry) { 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({ @@ -286,7 +221,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) { @@ -354,39 +288,22 @@ 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; + const { outcome } = telemetry; if (outcome !== 'death' && outcome !== 'exit' && outcome !== 'campaign-over') { throw new Error(`: unknown outcome "${outcome}"`); } - if (campaignTerminal && outcome !== 'death') { - throw new Error(': campaignTerminal is only valid with outcome "death"'); + if (outcome === 'campaign-over' && telemetry.campaignEndReason !== 'score-complete') { + throw new Error(': campaign-over is only valid for score-complete wins'); } if (outcome === 'campaign-over' && !Array.isArray(telemetry.crewRoster)) { - throw new TypeError(': campaign-over requires crewRoster array'); + throw new TypeError(': score-complete requires crewRoster array'); } this.#telemetry = { ...telemetry }; - this.setAttribute('outcome', outcome); + this.setAttribute('outcome', outcome === 'campaign-over' ? 'exit' : outcome); if (this.#ready) this.#render(); } @@ -408,24 +325,12 @@ class CrashDump extends HTMLElement { #render() { if (!this.#els || !this.#telemetry) return; const t = this.#telemetry; - const isCampaignOver = t.outcome === 'campaign-over'; + const isScoreWin = t.outcome === 'campaign-over'; const isDeath = t.outcome === 'death'; - const isCampaignTerminalDeath = isDeath && t.campaignTerminal; - - if (isCampaignOver || isCampaignTerminalDeath) { - if (isCampaignOver && t.campaignEndReason === 'clock-expired') { - this.#els.title.textContent = clockExpiredTitle(); - this.#els.fault.textContent = clockExpiredFault(); - } else if (isCampaignOver && t.campaignEndReason === 'score-complete') { - this.#els.title.textContent = scoreCompleteTitle(); - this.#els.fault.textContent = scoreCompleteFault(); - this.setAttribute('outcome', 'exit'); - } else { - this.#els.title.textContent = campaignOverTitle(); - this.#els.fault.textContent = isCampaignOver - ? campaignOverFault() - : campaignTerminalFault(); - } + + if (isScoreWin) { + this.#els.title.textContent = scoreCompleteTitle(); + this.#els.fault.textContent = scoreCompleteFault(); } else if (isDeath) { this.#els.title.textContent = deathTitle(); this.#els.fault.textContent = deathFault(); @@ -434,11 +339,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) => { @@ -446,12 +346,7 @@ class CrashDump extends HTMLElement { if (line.tag) { this.#els!.trace.appendChild(document.createTextNode(' ')); const span = h('span', { - className: - line.tag.includes('killed') || - line.tag.includes('flatlined') || - line.tag.includes('expired') - ? 'killed' - : '', + className: line.tag.includes('killed') ? 'killed' : '', textContent: line.tag, }); this.#els!.trace.appendChild(span); @@ -462,24 +357,16 @@ class CrashDump extends HTMLElement { }); this.#els.seedDd.textContent = hexSeed(t.seed ?? 0); - if (isCampaignOver) { + if (isScoreWin) { this.#els.turnDd.textContent = '—'; this.#els.killsDd.textContent = '—'; - if (t.campaignEndReason === 'clock-expired') { - this.#els.causeDd.textContent = 'Score window closed — contract cold'; - } else if (t.campaignEndReason === 'score-complete') { - this.#els.causeDd.textContent = 'Score extracted — campaign complete'; - } else { - this.#els.causeDd.textContent = - Number.isInteger(t.salvage) && Number(t.salvage) >= 0 - ? `pool salvage ${t.salvage} (lost with run)` - : 'no-surviving-crew'; - } + this.#els.causeDd.textContent = 'Score extracted — campaign complete'; } 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.newRunBtn.textContent = isScoreWin ? '[ NEW CAMPAIGN ]' : '[ RETURN TO HUB ]'; } #emit(eventName: string, detail: Record = {}) { 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..1ab5419 --- /dev/null +++ b/components/GameOver.ts @@ -0,0 +1,338 @@ +/** + * — full-screen terminal loss overlay. Shown when the campaign is + * truly over: crew wipe, Score window closed, or the last operator flatlines + * on a job. Distinct from ``, which handles recoverable per-job + * setbacks and successful exfil debriefs. + * + * GAME OVER + * The Score window closed. + * Corp security caught up. The contract is cold… + * [ NEW CAMPAIGN ] + * + * Usage: + * const screen = document.querySelector('game-over'); + * screen.addEventListener('new-run', () => finishEndedCampaign()); + * screen.setTelemetry({ campaignEndReason: 'clock-expired', seed }); + * screen.show(); + */ + +import { h } from '/src/domUtils.js'; +import type { GameOverTelemetry } from '/src/types.js'; + +const CSS = ` +:host { + --go-accent: #ff3355; + --go-bg: rgba(4, 2, 3, 0.98); + --go-text: #f0d4da; + --go-dim: #c96b7d; + --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([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); } +} + +.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.75rem, 6vw, 2.5rem); + letter-spacing: 0.28em; + color: var(--go-accent); + text-shadow: 0 0 18px rgba(255, 51, 85, 0.45); + animation: game-over-pulse 1.8s ease-in-out infinite; +} + +@keyframes game-over-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.82; } +} + +.rule { + margin: 0 auto 1.25rem; + width: min(280px, 70%); + 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; +} + +.roster { + margin: 0 0 1.25rem; + padding: 0.65rem 0.85rem; + background: rgba(0, 0, 0, 0.45); + border: 1px solid rgba(255, 51, 85, 0.28); + 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; +} + +.meta { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 1.25rem; + row-gap: 0.2rem; + 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) { + if (!Number.isFinite(seed)) return '?'; + return `0x${(seed >>> 0).toString(16).toUpperCase().padStart(8, '0')}`; +} + +function reasonCopy(telemetry: GameOverTelemetry): string { + if (telemetry.campaignEndReason === 'clock-expired') { + return 'The Score window closed.'; + } + if (telemetry.campaignTerminal) { + return 'Final operator lost on the wire.'; + } + return 'No surviving operators.'; +} + +function detailCopy(telemetry: GameOverTelemetry): string { + if (telemetry.campaignEndReason === 'clock-expired') { + return 'Corp security caught up. The contract is cold and this campaign is over.'; + } + if (telemetry.campaignTerminal) { + const label = telemetry.cause ?? telemetry.archetype ?? 'operator'; + return `Last channel down — ${label}. No backup left on the roster.`; + } + const salvageNote = + telemetry.salvage != null && typeof telemetry.salvage === 'number' && telemetry.salvage >= 0 + ? `Pool salvage ${telemetry.salvage} is lost with the run.` + : 'Every crew slot on the roster is flatlined.'; + return `${salvageNote} Start a new campaign to run the board again.`; +} + +function causeLabel(telemetry: GameOverTelemetry): string { + if (telemetry.campaignEndReason === 'clock-expired') return 'window-closed'; + if (telemetry.campaignTerminal) return 'final-operator-lost'; + return 'crew-wipe'; +} + +class GameOver extends HTMLElement { + #telemetry: GameOverTelemetry | null = null; + #ready = false; + #els: { + reason: HTMLElement; + detail: HTMLElement; + roster: HTMLElement; + rosterList: HTMLElement; + seedDd: HTMLElement; + causeDd: HTMLElement; + newCampaignBtn: HTMLButtonElement; + } | null = null; + + connectedCallback() { + if (this.#ready) return; + const shadow = this.attachShadow({ mode: 'open' }); + const style = h('style'); + style.textContent = CSS; + shadow.appendChild(style); + + const reason = h('p', { className: 'reason' }); + const detail = h('p', { className: 'detail' }); + const rosterList = h('dd'); + const seedDd = h('dd', { id: 'seed' }); + const causeDd = h('dd', { id: 'cause' }); + const roster = h('dl', { className: 'roster' }, [ + h('dt', { textContent: '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' }, [ + h('h1', { className: 'banner', textContent: 'GAME OVER' }), + h('hr', { className: 'rule' }), + reason, + detail, + roster, + h('dl', { className: 'meta' }, [ + h('dt', { textContent: 'seed' }), + seedDd, + h('dt', { textContent: 'cause' }), + causeDd, + ]), + h('div', { className: 'actions' }, [newCampaignBtn]), + ]); + shadow.appendChild(panel); + this.#els = { reason, detail, roster, rosterList, seedDd, causeDd, newCampaignBtn }; + this.#ready = true; + if (this.#telemetry) this.#render(); + } + + setTelemetry(telemetry: GameOverTelemetry) { + if (!telemetry || typeof telemetry !== 'object') { + throw new TypeError('.setTelemetry requires a telemetry object'); + } + const { campaignEndReason, campaignTerminal } = telemetry; + if (!campaignEndReason && !campaignTerminal) { + throw new Error(': requires campaignEndReason or campaignTerminal'); + } + this.#telemetry = { ...telemetry }; + 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.#telemetry) return; + const t = this.#telemetry; + this.#els.reason.textContent = reasonCopy(t); + this.#els.detail.textContent = detailCopy(t); + this.#els.seedDd.textContent = hexSeed(t.seed ?? 0); + this.#els.causeDd.textContent = causeLabel(t); + + if (t.campaignEndReason === 'clock-expired') { + this.#els.roster.hidden = true; + return; + } + + const roster = t.crewRoster ?? []; + this.#els.roster.hidden = roster.length === 0; + this.#els.rosterList.replaceChildren( + ...roster.map(op => + h('div', { className: 'roster-line' }, [ + h('span', { textContent: `${op.callsign} (${op.archetype})` }), + h('span', { + className: 'status', + textContent: op.flatlined ? 'FLATLINED' : 'ACTIVE', + }), + ]) + ) + ); + } + + #emit(eventName: string, detail: Record = {}) { + this.dispatchEvent( + new CustomEvent(eventName, { detail: { ...detail, telemetry: { ...this.#telemetry } } }) + ); + } +} + +customElements.define('game-over', GameOver); + +export default GameOver; diff --git a/index.html b/index.html index cae9c73..1d3ab22 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,7 @@

Log

+ diff --git a/index.ts b/index.ts index 3490956..21aeced 100644 --- a/index.ts +++ b/index.ts @@ -93,7 +93,7 @@ import type { Intent } from '/src/input/applyIntent.js'; import type { AimKind, Mode } from '/src/input/keymap.js'; import { formatCombatHudA11ySummary } from '/src/render/combatHud.js'; import type { CombatHudSummaryInput } from '/src/render/combatHud.js'; -import type { KeyItem, Telemetry, TurnActionStep } from '/src/types.js'; +import type { GameOverTelemetry, KeyItem, Telemetry, TurnActionStep } from '/src/types.js'; import { installErrorBoundary, type FaultSignal } from '/src/errorBoundary.js'; import { isDevelopmentMode } from '/src/domUtils.js'; @@ -103,6 +103,7 @@ import '/components/TouchPad.js'; import '/components/ContractSelect.js'; import '/components/RunBriefing.js'; import '/components/CrashDump.js'; +import '/components/GameOver.js'; import '/components/FaultScreen.js'; import '/components/SystemStart.js'; import '/components/CuratorBriefing.js'; @@ -139,7 +140,10 @@ type ContractSelectElement = ModalElement & { setScorePrincipalId(principalId: string | null): void; }; type CrashDumpElement = ModalElement & { - setTelemetry(telemetry: Record): void; + setTelemetry(telemetry: Telemetry): void; +}; +type GameOverElement = ModalElement & { + setTelemetry(telemetry: GameOverTelemetry): void; }; type FaultScreenElement = ModalElement & { show(detail: { code?: string }): void; @@ -259,6 +263,9 @@ let stageEl: HTMLElement; let briefingEl: RunBriefingElement; let contractSelectEl: ContractSelectElement; let crashEl: CrashDumpElement; +let gameOverEl: GameOverElement; +/** Which overlay owns `RUN_STATE.RESULT` — set in `pushPendingJobResultOverlay`. */ +let resultOverlayTarget: 'crash' | 'game-over' = 'crash'; let faultEl: FaultScreenElement; let systemStartEl: SystemStartElement; let curatorBriefingEl: CuratorBriefingElement; @@ -427,6 +434,7 @@ async function boot() { contractSelectEl = mustGetElement('contract-select'); briefingEl = mustGetElement('briefing'); crashEl = mustGetElement('crash'); + gameOverEl = mustGetElement('game-over'); faultEl = mustGetElement('fault-screen'); systemStartEl = mustGetElement('system-start'); curatorBriefingEl = mustGetElement('curator-briefing'); @@ -470,6 +478,7 @@ async function boot() { briefingEl.addEventListener('deploy', onBriefingDeploy); briefingEl.addEventListener('dismiss', onBriefingDismiss); crashEl.addEventListener('new-run', onNewRunRequested); + gameOverEl.addEventListener('new-run', onNewRunRequested); faultEl.addEventListener('return-to-hub', () => onFaultReturnToHub()); systemStartEl.addEventListener('hub-enter', onSystemStartHubEnter); curatorBriefingEl.addEventListener('dismiss', onCuratorBriefingDismiss); @@ -601,10 +610,15 @@ function onInitialRecruited(evt: Event) { flash(`CURATOR: ${names} on the board. Find me when you want work.`); } +function hideResultOverlays(): void { + crashEl?.hide(); + gameOverEl?.hide(); +} + function hideBlockingShellModals(): void { keyHelpEl?.hide(); briefingEl?.hide(); - crashEl?.hide(); + hideResultOverlays(); contractSelectEl?.hide(); systemStartEl?.hide(); curatorBriefingEl?.hide(); @@ -1158,6 +1172,14 @@ function crewMemberArchetypeId(member: Crew): string { return 'op'; } +function crewRosterStubs(c: Campaign) { + return c.crew.map(member => ({ + callsign: member.callsign ?? member.id, + archetype: crewMemberArchetypeId(member), + flatlined: !!member.flatlined, + })); +} + function telemetryForEndedCampaign(c: Campaign): Telemetry { const reason = c.endReason ?? 'crew-wipe'; return { @@ -1165,16 +1187,33 @@ function telemetryForEndedCampaign(c: Campaign): Telemetry { campaignEndReason: reason, seed: c.seed, salvage: c.salvage, - crewRoster: c.crew.map(member => ({ - callsign: member.callsign ?? member.id, - archetype: crewMemberArchetypeId(member), - flatlined: !!member.flatlined, - })), + crewRoster: crewRosterStubs(c), + }; +} + +function gameOverTelemetryForCampaign(c: Campaign): GameOverTelemetry { + const reason = c.endReason ?? 'crew-wipe'; + if (reason === 'score-complete') { + throw new Error('[shell] score-complete is a win debrief, not game over'); + } + return { + campaignEndReason: reason, + seed: c.seed, + salvage: c.salvage, + crewRoster: crewRosterStubs(c), }; } +function presentEndedCampaignOverlay(c: Campaign): void { + if (c.endReason === 'score-complete') { + crashEl.setTelemetry(telemetryForEndedCampaign(c)); + } else { + gameOverEl.setTelemetry(gameOverTelemetryForCampaign(c)); + } +} + function presentCampaignEnd(c: Campaign): void { - crashEl.setTelemetry(telemetryForEndedCampaign(c)); + presentEndedCampaignOverlay(c); renderShell(); } @@ -1184,9 +1223,9 @@ function finishEndedCampaign(): void { } /** - * Drives `` and `pendingJobResult` whenever the active job is in + * Drives result overlays and `pendingJobResult` whenever the active job is in * RESULT — both from a live `Run.onResult` callback and from a cold resume - * (otherwise `renderShell` opens the overlay with no `setTelemetry` call). + * (otherwise `renderShell` opens an overlay with no `setTelemetry` call). */ function pushPendingJobResultOverlay(telemetry: Partial & { outcome?: unknown }) { const tel = { ...telemetry }; @@ -1200,11 +1239,29 @@ function pushPendingJobResultOverlay(telemetry: Partial & { outcom }; const campaignTerminal = outcome === 'death' && campaign ? willEndCampaignOnThisDeath(campaign) : false; - crashEl.setTelemetry({ - ...tel, - outcome, - campaignTerminal, - }); + if (campaignTerminal && campaign) { + const activeCampaign = campaign; + const deployedId = activeCampaign.deployedMemberId; + resultOverlayTarget = 'game-over'; + gameOverEl.setTelemetry({ + campaignTerminal: true, + seed: tel.seed, + cause: tel.cause, + archetype: tel.archetype, + crewRoster: activeCampaign.crew.map(member => ({ + callsign: member.callsign ?? member.id, + archetype: crewMemberArchetypeId(member), + flatlined: !!member.flatlined || member.id === deployedId, + })), + }); + } else { + resultOverlayTarget = 'crash'; + crashEl.setTelemetry({ + ...tel, + outcome, + cause: tel.cause ?? undefined, + }); + } } function handleResult({ outcome, telemetry }: RunResult) { @@ -1243,7 +1300,7 @@ function resumeCampaign(record: CampaignSnapshot | unknown) { const resumeFlashMessage = `RESUMED — crew ${campaign.crew.filter(member => !member.flatlined).length} active.`; if (campaign.state === CAMPAIGN_STATE.ENDED) { pendingJobResult = null; - crashEl.setTelemetry(telemetryForEndedCampaign(campaign)); + presentEndedCampaignOverlay(campaign); flash( campaign.endReason === 'clock-expired' ? 'WINDOW CLOSED — Score contract went cold.' @@ -1296,13 +1353,14 @@ function performQuitCampaign(): void { if (!campaign) return; keyHelpEl.hide(); briefingEl.hide(); - crashEl.hide(); + hideResultOverlays(); crewRosterEl.hide(); finnShopEl.hide(); clinicModalEl.hide(); itemInventoryEl.hide(); pendingJobResult = null; + resultOverlayTarget = 'crash'; dataStore.deleteCampaign(); startFreshCampaign(); flash('Campaign deleted — new campaign.'); @@ -1838,7 +1896,7 @@ function onNewRunRequested(): void { finishEndedCampaign(); return; } - crashEl.hide(); + hideResultOverlays(); attachVisionListener(); attachAnimationListeners(); attachRepListeners(); @@ -1988,7 +2046,7 @@ function renderShell(): void { case RUN_STATE.COMBAT: canvas.hidden = false; briefingEl.hide(); - crashEl.hide(); + hideResultOverlays(); break; case RUN_STATE.BRIEFING: // The combined briefing modal handles its own show/hide. If we land @@ -2003,17 +2061,29 @@ function renderShell(): void { briefingEl.setCrew(campaign.crew); briefingEl.show(); } - crashEl.hide(); + hideResultOverlays(); break; case RUN_STATE.RESULT: canvas.hidden = true; briefingEl.hide(); - crashEl.show(); + if (resultOverlayTarget === 'game-over') { + crashEl.hide(); + gameOverEl.show(); + } else { + gameOverEl.hide(); + crashEl.show(); + } break; case CAMPAIGN_STATE.ENDED: canvas.hidden = true; briefingEl.hide(); - crashEl.show(); + if (campaign.endReason === 'score-complete') { + gameOverEl.hide(); + crashEl.show(); + } else { + crashEl.hide(); + gameOverEl.show(); + } break; default: throw new Error(`[shell] unknown state "${state}"`); @@ -2480,6 +2550,7 @@ function isAnyBlockingModalOpen(): boolean { if (contractSelectEl?.isOpen) return true; if (briefingEl?.isOpen) return true; if (crashEl?.isOpen) return true; + if (gameOverEl?.isOpen) return true; if (systemStartEl?.isOpen) return true; if (curatorBriefingEl?.isOpen) return true; if (initialRecruitEl?.isOpen) return true; diff --git a/main.css b/main.css index 429b5b7..1786a7e 100644 --- a/main.css +++ b/main.css @@ -354,6 +354,7 @@ main { .game-stage > run-briefing, .game-stage > crash-dump, +.game-stage > game-over, .game-stage > fault-screen, .game-stage > system-start, .game-stage > curator-briefing { diff --git a/src/types.ts b/src/types.ts index bc297a7..eb74f15 100644 --- a/src/types.ts +++ b/src/types.ts @@ -241,17 +241,30 @@ export type LocationSite = { site?: LocationToken; }; +export type GameOverCrewStub = { callsign: string; archetype: string; flatlined: boolean }; + +/** Terminal campaign loss — crew wipe, clock deadline, or last-operator death. */ +export type GameOverTelemetry = { + campaignEndReason?: Exclude; + campaignTerminal?: boolean; + crewRoster?: GameOverCrewStub[]; + salvage?: import('./game/salvage.js').TypedSalvage; + seed?: number; + cause?: string | null; + archetype?: string; +}; + export type Telemetry = { outcome: 'death' | 'exit' | 'campaign-over'; campaignEndReason?: CampaignEndReason; campaignTerminal?: boolean; - crewRoster?: { callsign: string; archetype: string; flatlined: boolean }[]; + crewRoster?: GameOverCrewStub[]; /** Typed-salvage wallet snapshot at the moment the run/campaign ends. */ salvage?: import('./game/salvage.js').TypedSalvage; archetype?: string; turn?: number; kills?: number; - cause?: string; + cause?: string | null; seed?: number; hpAtDeath?: number | null; hpAtDamage?: number | null; diff --git a/sw-core.js b/sw-core.js index a8dbe81..0fb5872 100644 --- a/sw-core.js +++ b/sw-core.js @@ -30,6 +30,7 @@ const CacheConfig = { '/components/CuratorBriefing.js', '/components/ContractSelect.js', '/components/CrashDump.js', + '/components/GameOver.js', '/components/FaultScreen.js', '/components/CrewList.js', '/components/CrewRoster.js', diff --git a/sw-dev.js b/sw-dev.js index 25811e2..33f02aa 100644 --- a/sw-dev.js +++ b/sw-dev.js @@ -1,5 +1,5 @@ // Service Worker for Kernel Panic - Development Version -const VERSION = '0.2.11-dev'; +const VERSION = '0.2.12-dev'; importScripts(`/sw-core.js?v=${VERSION}`); const cacheConfig = CacheConfig.create(VERSION); From c65764848ad4088c22c118b1ead2127ab45fd06a Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 9 Jun 2026 20:24:59 -0700 Subject: [PATCH 11/55] plan updates --- docs/phase-3-plan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/phase-3-plan.md b/docs/phase-3-plan.md index 21ab864..8a8ea77 100644 --- a/docs/phase-3-plan.md +++ b/docs/phase-3-plan.md @@ -142,6 +142,7 @@ Neural degradation is deferred until Cyberspace is fun enough to deserve a jack- | **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. From e4fb86cfc8ff2b76088d6a9bc6a1756402eb619a Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 9 Jun 2026 21:01:45 -0700 Subject: [PATCH 12/55] plan tweaks for M4 --- docs/phase-3-plan.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/phase-3-plan.md b/docs/phase-3-plan.md index 8a8ea77..bff337b 100644 --- a/docs/phase-3-plan.md +++ b/docs/phase-3-plan.md @@ -170,7 +170,7 @@ Neural degradation is deferred until Cyberspace is fun enough to deserve a jack- --- -### 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. @@ -261,16 +261,16 @@ The first jack-in should prove the door between layers before shipping every ICE **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"). + - **Pre–jack-in phase:** The Decker starts 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. 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"). Secondary operator now enters the Meatspace grid. - **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** 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. +- **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 explicit 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. +- **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, but if they're flatlined, the campaign is over - no new Deckers can be recruited. - **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:** From b4be1e7cad0504a73d0f0b75b90880d22306074f Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 9 Jun 2026 21:08:17 -0700 Subject: [PATCH 13/55] P3.M3.1: Cyberspace contract flag + deployment gates DATA_NODE_SLICE objective kind with cross-validated requiresCyberspace/count params, cyber-data-spike recipe gated on Act 2+ with a living Decker (availableWhen filter across all three generation paths), Campaign hasLivingDecker getter + loud deploy gate, and NEEDS DECKER row gating in the briefing crew list. Co-Authored-By: Claude Fable 5 --- components/CrewList.ts | 34 +++- components/RunBriefing.ts | 36 +++- src/game/Campaign.ts | 18 +- src/game/Run.ts | 5 + src/game/hub/Curator.ts | 98 ++++++++- tests/unit/game/hub/cyberContract.test.ts | 230 ++++++++++++++++++++++ 6 files changed, 403 insertions(+), 18 deletions(-) create mode 100644 tests/unit/game/hub/cyberContract.test.ts 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/RunBriefing.ts b/components/RunBriefing.ts index 86f5bdd..0d289bc 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; @@ -206,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; @@ -254,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); } }); @@ -309,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(); } /** @@ -319,9 +324,29 @@ class RunBriefing extends HTMLElement { if (!Array.isArray(crew)) { throw new TypeError('.setCrew requires an array'); } + this.#crew = crew; + this.#syncCrewList(); + } + + /** + * P3.M3.1: Cyberspace contracts can only deploy the Decker — every other + * row renders disabled with a NEEDS DECKER tag. + */ + #rowGate(): CrewRowGate | null { + if (!this.#contract || !contractRequiresCyberspace(this.#contract)) return 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() { @@ -382,10 +407,11 @@ class RunBriefing extends HTMLElement { } #commit() { - if (!this.#contract || !this.#selectedMember || this.#selectedMember.flatlined) return; + const member = this.#selectedMember; + if (!this.#contract || !member || !this.#deployable(member)) return; this.dispatchEvent( new CustomEvent('deploy', { - detail: { memberId: this.#selectedMember.id, contract: cloneContract(this.#contract) }, + detail: { memberId: member.id, contract: cloneContract(this.#contract) }, }) ); } diff --git a/src/game/Campaign.ts b/src/game/Campaign.ts index 998d601..a8b5ba3 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -21,7 +21,7 @@ import { type TypedSalvage, } from './salvage.js'; import { buildCrewMember, RECRUIT_ARCHETYPE_POOL } from './archetypes/index.js'; -import { CONTRACT_LEXICON, Curator } from './hub/Curator.js'; +import { CONTRACT_LEXICON, Curator, contractRequiresCyberspace } from './hub/Curator.js'; import { OBJECTIVES } from './hub/Curator.js'; import { Terminal } from './hub/Terminal.js'; import { Finn } from './hub/Finn.js'; @@ -321,6 +321,15 @@ export class Campaign { return Math.max(0, this.clockJobsTaken - CLOCK_ACT2_GRACE_JOBS); } + /** + * P3.M3.1: whether the roster carries a non-flatlined Decker. Gates + * Cyberspace-capable contract generation (`Curator` reads this through the + * `ContractCampaign` surface) and is the precondition for jack-in. + */ + get hasLivingDecker(): boolean { + return this.crew.some(member => member.archetype === 'Decker' && !member.flatlined); + } + get scoreDeadlineJobsRemaining(): number { return Math.max(0, CLOCK_ACT2_DEADLINE_JOBS - this.clockJobsTaken); } @@ -432,6 +441,13 @@ export class Campaign { if (member.flatlined) { throw new Error(`Campaign.deployCrewMember: ${member.callsign ?? member.id} is flatlined`); } + // P3.M3.1: a Cyberspace contract is unwinnable without the one operator who + // can jack in — fail loudly at the Hub boundary instead of starting it. + if (contractRequiresCyberspace(contract) && member.archetype !== 'Decker') { + throw new Error( + `Campaign.deployCrewMember: contract "${contract.label}" requires a living Decker to jack in` + ); + } if (isScoreContract(contract)) { this.#beginScoreAttempt(contract); } else if (this.arc.arcStage === 'act-2' || this.arc.arcStage === 'act-3') { diff --git a/src/game/Run.ts b/src/game/Run.ts index 75575d4..f992e2d 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -1508,6 +1508,11 @@ function isObjectiveFamilySatisfied( return isReconSatisfied(world, objectiveState); case OBJECTIVES.ESCORT_EXTRACT: return isEscortExtractSatisfied(world); + case OBJECTIVES.DATA_NODE_SLICE: + // P3.M3.1: satisfaction requires the Cyberspace layer (P3.M3.4 wires + // sliced-node counting through ObjectiveState). Until then a cyber + // contract is honestly unsatisfiable — extraction stays gated. + return false; default: { const exhaustive: never = kind; throw new Error(`Run.isObjectiveSatisfied: unknown objective kind "${exhaustive}"`); diff --git a/src/game/hub/Curator.ts b/src/game/hub/Curator.ts index 5f20482..1948e6c 100644 --- a/src/game/hub/Curator.ts +++ b/src/game/hub/Curator.ts @@ -34,6 +34,8 @@ export const OBJECTIVES = Object.freeze({ DUAL_SITE: 'dual-site', RECON: 'recon', ESCORT_EXTRACT: 'escort-extract', + /** P3.M3: slice a data node in Cyberspace — requires a living Decker to jack in. */ + DATA_NODE_SLICE: 'data-node-slice', }); const KNOWN_OBJECTIVE_KINDS = new Set(Object.values(OBJECTIVES)); @@ -72,6 +74,8 @@ export type ContractContext = { type ContractRecipeContext = { arcStage?: CampaignArcStage; + /** P3.M3.1: whether the campaign roster carries a non-flatlined Decker. */ + hasLivingDecker?: boolean; }; type ContractToken = { @@ -108,6 +112,12 @@ type ContractRecipe = { title: (tokens: ContractRecipeTokens) => string; briefing: (tokens: ContractRecipeTokens) => string; params: (tokens: ContractRecipeTokens) => ObjectiveParams | undefined; + /** + * P3.M3.1: campaign-state gate. When present, the recipe only enters the + * generation pool if this returns `true` for the board's recipe context. + * Absent means always available (the pre-P3.M3 pool is unchanged). + */ + availableWhen?: (context: ContractRecipeContext) => boolean; }; const REACH_EXIT_OBJECTIVE: ContractObjective = Object.freeze({ @@ -387,6 +397,27 @@ export const CONTRACT_RECIPES: readonly ContractRecipe[] = Object.freeze([ contact: asset.contact ?? asset.label, }), }, + { + // P3.M3.1: the first Cyberspace-capable contract family. Only enters the + // pool in Act 2+ with a living Decker on the roster — the jack-in port and + // the digital layer are meaningless without one. Params stay minimal: + // `requiresCyberspace` is the layer flag, `count` the data nodes to slice. + id: 'cyber-data-spike', + objectiveKind: OBJECTIVES.DATA_NODE_SLICE, + tags: Object.freeze(['cyberspace', 'digital', 'slice']), + principalGroups: Object.freeze(['corp']), + siteGroups: Object.freeze(['corp', 'data', 'security', 'infrastructure']), + siteStateGroups: Object.freeze(['normal', 'security']), + assetGroups: Object.freeze(['terminal']), + actionGroups: Object.freeze(['terminal']), + title: ({ asset }) => `Spike ${noun(asset)}`, + briefing: ({ principal, site, asset }) => + `Find the jack-in port at ${possessive(principal.label)} ${sitePhrase({ site })}, breach the grid, slice the ${asset.label} node, then extract.`, + params: () => ({ requiresCyberspace: true, count: 1 }), + availableWhen: context => + (context.arcStage === 'act-2' || context.arcStage === 'act-3') && + context.hasLivingDecker === true, + }, ]); const CURATOR_GLYPH = 'C'; @@ -491,6 +522,8 @@ type ContractCampaign = clockHeat?: number; /** Remembered combat locations the Curator may steer revisits toward (P2.5.M7.2). */ siteRoster?: LocationSite[]; + /** P3.M3.1: gates Cyberspace-capable recipes (`availableWhen`). */ + hasLivingDecker?: boolean; } | null | undefined; @@ -667,9 +700,45 @@ export function normalizeObjective(value: unknown): ContractObjective { if (candidate.params !== undefined) { validateObjectiveParams(candidate.params); } + validateCyberspaceObjective(candidate.kind, candidate.params); return cloneObjective(candidate as ContractObjective); } +/** + * P3.M3.1: cross-field rules for the Cyberspace layer flag. `data-node-slice` + * must carry `requiresCyberspace: true` plus a positive integer `count`; no + * other kind may carry the flag. Validated at generation, at + * `Run.enterBriefing`, and on snapshot restore (all call `normalizeObjective`). + */ +function validateCyberspaceObjective(kind: ObjectiveKind, params: ObjectiveParams | undefined): void { + if (kind === OBJECTIVES.DATA_NODE_SLICE) { + if (params?.requiresCyberspace !== true) { + throw new Error( + `contract objective "${kind}" requires params.requiresCyberspace to be exactly true` + ); + } + if (!Number.isInteger(params.count) || (params.count as number) < 1) { + throw new RangeError( + `contract objective "${kind}" requires params.count to be a positive integer` + ); + } + } else if (params?.requiresCyberspace !== undefined) { + throw new Error( + `contract objective param "requiresCyberspace" is only valid on "${OBJECTIVES.DATA_NODE_SLICE}", got kind "${kind}"` + ); + } +} + +/** + * P3.M3.1: single source of truth for "this contract has a Cyberspace + * component". The params flag is validated against the objective kind by + * `normalizeObjective`, so any contract that survived validation answers + * consistently. + */ +export function contractRequiresCyberspace(contract: Pick): boolean { + return contract.objective.params?.requiresCyberspace === true; +} + /** * Throws if recipes, lexicon, and objective coverage drift apart. Called from * unit tests so content additions fail fast without string-parsing labels back @@ -903,7 +972,24 @@ function contractRecipeContext(campaign: ContractCampaign): ContractRecipeContex if (arcStage !== undefined && !isArcStage(arcStage)) { throw new Error(`Curator.generateContracts: unknown arcStage "${arcStage}"`); } - return arcStage ? { arcStage } : {}; + const hasLivingDecker = campaign?.hasLivingDecker; + if (hasLivingDecker !== undefined && typeof hasLivingDecker !== 'boolean') { + throw new TypeError('Curator.generateContracts: hasLivingDecker must be a boolean when set'); + } + return { + ...(arcStage ? { arcStage } : {}), + ...(hasLivingDecker !== undefined ? { hasLivingDecker } : {}), + }; +} + +/** + * P3.M3.1: recipes with an `availableWhen` gate only enter the pool when the + * campaign context satisfies it. Recipes without the gate are always eligible, + * so the filtered pool is identical to the historic pool whenever no gated + * recipe qualifies — seeded generation for pre-P3.M3 saves is unchanged. + */ +function recipeIsAvailable(recipe: ContractRecipe, context: ContractRecipeContext): boolean { + return recipe.availableWhen ? recipe.availableWhen(context) === true : true; } function isArcStage(value: unknown): value is CampaignArcStage { @@ -944,8 +1030,9 @@ function generateRecipeContract( labelsUsed: Set, context: ContractRecipeContext ): RenderedContract { + const availableRecipes = CONTRACT_RECIPES.filter(recipe => recipeIsAvailable(recipe, context)); for (let i = 0; i < MAX_UNIQUE_CONTRACT_ATTEMPTS; i++) { - const recipe = rng.pick(CONTRACT_RECIPES); + const recipe = rng.pick(availableRecipes); const tokens = { principal: pickCompatibleToken( rng, @@ -1003,6 +1090,7 @@ function generateRevisitContract( // Recipes the pinned identity can satisfy: principal groups must intersect, // and any site-using recipe needs a remembered site compatible with it. const candidates = CONTRACT_RECIPES.filter(recipe => { + if (!recipeIsAvailable(recipe, context)) return false; if (!groupsIntersect(principal.groups, recipe.principalGroups)) return false; if (recipe.siteGroups) { return !!siteToken && groupsIntersect(siteToken.groups, recipe.siteGroups); @@ -1045,8 +1133,10 @@ function generatePrincipalBiasedContract( if (!scoreTarget.principal) return null; const principal = lexiconTokenFrom(scoreTarget.principal); const avoidedSiteId = scoreTarget.site?.id; - const candidates = CONTRACT_RECIPES.filter(recipe => - groupsIntersect(principal.groups, recipe.principalGroups) + const candidates = CONTRACT_RECIPES.filter( + recipe => + recipeIsAvailable(recipe, context) && + groupsIntersect(principal.groups, recipe.principalGroups) ); if (candidates.length === 0) return null; diff --git a/tests/unit/game/hub/cyberContract.test.ts b/tests/unit/game/hub/cyberContract.test.ts new file mode 100644 index 0000000..11e57dd --- /dev/null +++ b/tests/unit/game/hub/cyberContract.test.ts @@ -0,0 +1,230 @@ +/** + * P3.M3.1 — Cyberspace-capable contract metadata + gates. + * + * The DATA_NODE_SLICE objective kind carries `requiresCyberspace: true` in its + * params, is generated only in Act 2+ when a living Decker is on the roster, + * and deploying such a contract with anyone but a living Decker fails loudly + * at the Hub boundary. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + Curator, + OBJECTIVES, + CONTRACT_RECIPES, + assertLabelObjectiveRegistryInSync, + contractRequiresCyberspace, + normalizeObjective, +} from '../../../../src/game/hub/Curator.js'; +import { Campaign, CAMPAIGN_STATE } from '../../../../src/game/Campaign.js'; +import { RUN_STATE } from '../../../../src/game/Run.js'; +import { Rng } from '../../../../src/rng.js'; +import { testContractContext } from '../contractTestUtils.js'; +import type { Contract } from '../../../../src/game/hub/Curator.js'; +import type { CampaignArcStage } from '../../../../src/types.js'; + +const cyberObjective = ( + params: Record | null = { + requiresCyberspace: true, + count: 1, + } +) => ({ + kind: OBJECTIVES.DATA_NODE_SLICE, + title: 'Spike the server farm', + briefing: 'Jack in, slice the data node, then extract.', + ...(params ? { params } : {}), +}); + +const cyberContract = (overrides: Partial = {}): Contract => + ({ + seed: 12345, + mapWidth: 24, + mapHeight: 16, + objective: cyberObjective(), + difficulty: 'standard', + threatCount: 1, + label: '// test cyber job', + context: testContractContext(OBJECTIVES.DATA_NODE_SLICE), + reward: { credits: 0, repDelta: 0 }, + ...overrides, + }) as Contract; + +// --- objective validation ------------------------------------------------- + +test('normalizeObjective accepts a well-formed data-node-slice objective', () => { + const normalized = normalizeObjective(cyberObjective()); + assert.equal(normalized.kind, OBJECTIVES.DATA_NODE_SLICE); + assert.equal(normalized.params?.requiresCyberspace, true); + assert.equal(normalized.params?.count, 1); +}); + +test('normalizeObjective throws on data-node-slice without requiresCyberspace', () => { + assert.throws(() => normalizeObjective(cyberObjective(null)), /requiresCyberspace/); + assert.throws(() => normalizeObjective(cyberObjective({ count: 1 })), /requiresCyberspace/); + assert.throws( + () => normalizeObjective(cyberObjective({ requiresCyberspace: false, count: 1 })), + /requiresCyberspace/ + ); + assert.throws( + () => normalizeObjective(cyberObjective({ requiresCyberspace: 'yes', count: 1 })), + /requiresCyberspace/ + ); +}); + +test('normalizeObjective throws on data-node-slice with a malformed count', () => { + assert.throws( + () => normalizeObjective(cyberObjective({ requiresCyberspace: true })), + /count/ + ); + assert.throws( + () => normalizeObjective(cyberObjective({ requiresCyberspace: true, count: 0 })), + /count/ + ); + assert.throws( + () => normalizeObjective(cyberObjective({ requiresCyberspace: true, count: 1.5 })), + /count/ + ); +}); + +test('normalizeObjective throws when requiresCyberspace appears on a non-cyber kind', () => { + assert.throws( + () => + normalizeObjective({ + kind: OBJECTIVES.RETRIEVE, + title: 'Secure cache', + briefing: 'Find the cache.', + params: { requiresCyberspace: true }, + }), + /requiresCyberspace/ + ); +}); + +test('contractRequiresCyberspace is true only for validated cyber contracts', () => { + assert.equal(contractRequiresCyberspace(cyberContract()), true); + assert.equal( + contractRequiresCyberspace( + cyberContract({ + objective: { + kind: OBJECTIVES.REACH_EXIT, + title: 'Extract clean', + briefing: 'Reach the exit.', + }, + }) + ), + false + ); +}); + +// --- recipe + generation gating -------------------------------------------- + +test('a cyber-data-spike recipe covers the data-node-slice kind', () => { + const recipe = CONTRACT_RECIPES.find(r => r.objectiveKind === OBJECTIVES.DATA_NODE_SLICE); + assert.ok(recipe, 'expected a recipe covering data-node-slice'); + assertLabelObjectiveRegistryInSync(); +}); + +function boardKinds(arcStage: CampaignArcStage, hasLivingDecker: boolean, seeds: number) { + const curator = new Curator(); + const kinds: string[] = []; + for (let seed = 1; seed <= seeds; seed++) { + for (const contract of curator.generateContracts(new Rng(seed), { + rep: 60, + arcStage, + hasLivingDecker, + })) { + kinds.push(contract.objective.kind); + } + } + return kinds; +} + +test('data-node-slice never generates in Act 1, even with a living Decker', () => { + assert.ok(!boardKinds('act-1', true, 40).includes(OBJECTIVES.DATA_NODE_SLICE)); +}); + +test('data-node-slice never generates without a living Decker', () => { + assert.ok(!boardKinds('act-2', false, 40).includes(OBJECTIVES.DATA_NODE_SLICE)); + // Plain campaign fixtures that never mention the Decker behave the same. + const curator = new Curator(); + for (let seed = 1; seed <= 40; seed++) { + for (const contract of curator.generateContracts(new Rng(seed), { + rep: 60, + arcStage: 'act-2', + })) { + assert.notEqual(contract.objective.kind, OBJECTIVES.DATA_NODE_SLICE); + } + } +}); + +test('data-node-slice generates in Act 2+ with a living Decker, validated and door-free', () => { + const curator = new Curator(); + let found = 0; + for (let seed = 1; seed <= 40; seed++) { + for (const contract of curator.generateContracts(new Rng(seed), { + rep: 60, + arcStage: 'act-2', + hasLivingDecker: true, + })) { + if (contract.objective.kind !== OBJECTIVES.DATA_NODE_SLICE) continue; + found += 1; + assert.equal(contractRequiresCyberspace(contract), true); + assert.equal(contract.objective.params?.requiresCyberspace, true); + assert.equal(typeof contract.objective.params?.count, 'number'); + // Cyber contracts route through the jack-in port, not prefab doors. + assert.equal(contract.objective.params?.requiresUnlock, undefined); + } + } + assert.ok(found > 0, 'expected at least one cyber contract across 40 seeded boards'); +}); + +test('cyber-capable board generation is deterministic per seed', () => { + const campaign = { rep: 60, arcStage: 'act-2' as const, hasLivingDecker: true }; + const a = new Curator().generateContracts(new Rng(777), campaign); + const b = new Curator().generateContracts(new Rng(777), campaign); + assert.deepEqual(a, b); +}); + +// --- Campaign deploy gate --------------------------------------------------- + +/** Act-2 campaign whose arc transition has auto-assigned a Decker (P3.M1.2). */ +function act2CampaignWithDecker(): Campaign { + const campaign = new Campaign({ seed: 42, rep: 60, completedJobs: 4 }); + assert.equal(campaign.arcStage, 'act-2'); + assert.ok(campaign.crew.some(m => m.archetype === 'Decker')); + return campaign; +} + +test('Campaign.hasLivingDecker tracks roster state', () => { + const fresh = new Campaign({ seed: 42 }); + assert.equal(fresh.hasLivingDecker, false); + + const campaign = act2CampaignWithDecker(); + assert.equal(campaign.hasLivingDecker, true); + + const decker = campaign.crew.find(m => m.archetype === 'Decker')!; + campaign.flatlineMember(decker.id); + assert.equal(campaign.hasLivingDecker, false); +}); + +test('deploying a non-Decker on a cyber contract throws at the Hub boundary', () => { + const campaign = act2CampaignWithDecker(); + const merc = campaign.crew.find(m => m.archetype !== 'Decker')!; + assert.throws(() => campaign.deployCrewMember(merc.id, cyberContract()), /Decker/); + assert.equal(campaign.state, CAMPAIGN_STATE.HUB, 'failed deploy must not leave the Hub'); +}); + +test('a living Decker deploys onto a cyber contract', () => { + const campaign = act2CampaignWithDecker(); + const decker = campaign.crew.find(m => m.archetype === 'Decker')!; + const run = campaign.deployCrewMember(decker.id, cyberContract()); + assert.equal(run.state, RUN_STATE.BRIEFING); + assert.equal(campaign.state, CAMPAIGN_STATE.COMBAT); +}); + +test('a flatlined Decker cannot deploy onto a cyber contract', () => { + const campaign = act2CampaignWithDecker(); + const decker = campaign.crew.find(m => m.archetype === 'Decker')!; + campaign.flatlineMember(decker.id); + assert.throws(() => campaign.deployCrewMember(decker.id, cyberContract()), /flatlined/); +}); From 3719fdc67bd29614e9119fc8f3ad8bd1e31ca808 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 9 Jun 2026 21:24:32 -0700 Subject: [PATCH 14/55] =?UTF-8?q?P3.M3.2:=20jack-in=20point=20=E2=80=94=20?= =?UTF-8?q?the=20Meatspace=20door=20into=20Cyberspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JackInPoint interactable (Ω) placed for data-node-slice contracts: Decker-only capability sniff, linked latch with logged (never thrown) redundant input, one-shot EVENT.JACK_IN emit. Run latches a dormant cyberspace state machine at briefing; snapshot/restore enforce the block ⇔ cyber-contract invariant and throw on unknown phases, dormant payload smuggling, or missing linked state. Co-Authored-By: Claude Fable 5 --- src/game/Run.ts | 64 +++++++ src/game/archetypes/Decker.ts | 6 + src/game/constants.ts | 2 + src/game/entities/JackInPoint.ts | 67 ++++++++ src/game/events.ts | 4 + src/game/persistence.ts | 74 +++++++- tests/unit/game/jackInPoint.test.ts | 255 ++++++++++++++++++++++++++++ 7 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 src/game/entities/JackInPoint.ts create mode 100644 tests/unit/game/jackInPoint.test.ts diff --git a/src/game/Run.ts b/src/game/Run.ts index f992e2d..ed33b2d 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -78,6 +78,7 @@ import { RelayNode } from './entities/RelayNode.js'; import { ConsumablePickup } from './entities/ConsumablePickup.js'; import { EscortNpc } from './entities/EscortNpc.js'; import { KeyCard } from './entities/KeyCard.js'; +import { JackInPoint } from './entities/JackInPoint.js'; import { applyMutationDeltas } from './locations.js'; import { BreachingCharge } from './entities/BreachingCharge.js'; import { ITEM_ID, getItemById } from './items.js'; @@ -93,6 +94,7 @@ import { VisionField } from './Vision.js'; import { OBJECTIVES, cloneObjective, + contractRequiresCyberspace, isContractDifficulty, normalizeContractContext, normalizeObjective, @@ -119,6 +121,7 @@ import type { SyncPadSnapshot } from './entities/SyncPad.js'; import type { RelayNodeSnapshot } from './entities/RelayNode.js'; import type { ConsumablePickupSnapshot } from './entities/ConsumablePickup.js'; import type { EscortNpcSnapshot } from './entities/EscortNpc.js'; +import type { JackInPointSnapshot } from './entities/JackInPoint.js'; import type { KeyCardSnapshot } from './entities/KeyCard.js'; import type { AlarmState } from './World.js'; @@ -163,6 +166,7 @@ export type EntityArchetypeId = | 'escort-npc' | 'keycard' | 'breaching-charge' + | 'jack-in-point' | 'entity'; export type RunTelemetry = { @@ -273,6 +277,31 @@ export type RunSnapshot = { keyItems?: KeyItemSnapshot[]; /** Terrain/entity mutations recorded during the run (P2.5.M7.1). Defaults to []. */ mutationDeltas?: TileDelta[]; + /** + * P3.M3: Cyberspace layer state. Present exactly when the contract requires + * Cyberspace (`contractRequiresCyberspace`) — a mismatch in either direction + * is corrupt and throws on restore. + */ + cyberspace?: RunCyberspaceSnapshot; +}; + +/** + * P3.M3: serialized Cyberspace layer state. The `dormant` phase carries no + * payload — the layer spawns fresh on jack-in. P3.M3.3 extends this with the + * `active` (grid + entities) and `resolved` (objective latch) phases. + */ +export type RunCyberspaceSnapshot = { + phase: 'dormant'; +}; + +/** + * P3.M3: live Cyberspace state machine on the Run. `null` ⇔ the contract has + * no Cyberspace component. P3.M3.3 extends the union with + * `{ phase: 'active'; layer: CyberspaceLayer }` and + * `{ phase: 'resolved'; objectiveComplete: boolean }`. + */ +export type CyberspaceState = { + phase: 'dormant'; }; /** Serializable run-scoped key item (P2.5.M6.2). */ @@ -335,6 +364,8 @@ export class Run { player: Crew | null; contract: Contract | null; exitTile: GridPoint | null; + /** P3.M3: Cyberspace state machine; `null` ⇔ no Cyberspace component. */ + cyberspace: CyberspaceState | null; telemetry: RunTelemetry; objectiveTimer: ObjectiveTimerSnapshot; mapSeen: Set; @@ -402,6 +433,7 @@ export class Run { this.player = null; this.contract = null; this.exitTile = null; + this.cyberspace = null; this.telemetry = freshTelemetry(this.archetype, this.seed); this.objectiveTimer = freshObjectiveTimer(); this.mapSeen = new Set(); @@ -428,6 +460,8 @@ export class Run { throw new Error(`Run.enterBriefing: illegal transition from ${this.state}`); } this.contract = normalizeContractForRun(contract); + // P3.M3: latch the Cyberspace state machine off the validated contract. + this.cyberspace = contractRequiresCyberspace(this.contract) ? { phase: 'dormant' } : null; this.objectiveTimer = freshObjectiveTimer(); this.mapSeen.clear(); this.state = RUN_STATE.BRIEFING; @@ -731,6 +765,8 @@ export class Run { objectiveProgress: { securedPickups: world.securedPickupIds() }, keyItems: this.keyItems.map(k => ({ id: k.id, label: k.label, doorId: k.doorId })), mutationDeltas: world.mutationDeltas.map(delta => ({ ...delta })), + // P3.M3: present exactly when the contract has a Cyberspace component. + ...(this.cyberspace ? { cyberspace: snapshotCyberspace(this.cyberspace) } : {}), }; } @@ -1154,6 +1190,18 @@ export class Run { }) ); } + if (this.contract.objective.kind === OBJECTIVES.DATA_NODE_SLICE) { + // P3.M3.2: the Meatspace door into Cyberspace. One port per contract; + // the data nodes themselves live on the cyber grid (P3.M3.3+). + const anchor = findInteractableAnchor(this.world, this.player, this.exitTile, this.rng); + this.world.addEntity( + new JackInPoint({ + id: 'jack-in-0', + x: anchor.x, + y: anchor.y, + }) + ); + } if (this.contract.objective.kind === OBJECTIVES.RETRIEVE) { const count = objectiveCount(this.contract); for (let i = 0; i < count; i++) { @@ -1738,8 +1786,23 @@ const SNAPSHOT_EXTRACTORS: Partial Enti siteId: k.siteId ?? null, } satisfies KeyCardSnapshot; }, + 'jack-in-point': e => { + const p = e as JackInPoint; + return { label: p.label, linked: p.linked } satisfies JackInPointSnapshot; + }, }; +/** + * P3.M3: serialize the Cyberspace state machine. Dormant carries no payload; + * P3.M3.3 adds the active-layer grid/entities and the resolved latch. + */ +function snapshotCyberspace(state: CyberspaceState): RunCyberspaceSnapshot { + if (state.phase === 'dormant') return { phase: 'dormant' }; + throw new Error( + `Run.snapshot: unknown cyberspace phase "${(state as { phase: string }).phase}"` + ); +} + function snapshotEntity(entity: Entity): RunEntitySnapshot { const archetype = archetypeOf(entity); const base: RunEntitySnapshot = { @@ -1794,6 +1857,7 @@ function archetypeOf(entity: Entity): EntityArchetypeId { if (entity instanceof ConsumablePickup) return 'consumable-pickup'; if (entity instanceof EscortNpc) return 'escort-npc'; if (entity instanceof KeyCard) return 'keycard'; + if (entity instanceof JackInPoint) return 'jack-in-point'; if (entity instanceof BreachingCharge) return 'breaching-charge'; if (entity instanceof Entity) return 'entity'; throw new Error(`archetypeOf: cannot classify entity ${(entity as Entity | undefined)?.id}`); diff --git a/src/game/archetypes/Decker.ts b/src/game/archetypes/Decker.ts index 188a374..f7dcc53 100644 --- a/src/game/archetypes/Decker.ts +++ b/src/game/archetypes/Decker.ts @@ -39,6 +39,12 @@ export const CALLSIGNS = Object.freeze([ export class Decker extends Crew { override archetype = 'Decker'; + /** + * P3.M3.2: capability sniffed by `JackInPoint.interact` — only an actor + * carrying a cyberdeck can open the digital layer. + */ + readonly canJackIn = true; + override get baseHitChance(): number { return 0.7; } diff --git a/src/game/constants.ts b/src/game/constants.ts index 61e277a..84ce1af 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -44,6 +44,8 @@ export const DENY_TARGET_GLYPH = '◆'; export const SYNC_PAD_GLYPH = '§'; export const ESCORT_NPC_GLYPH = 'A'; export const KEYCARD_GLYPH = 'κ'; +/** P3.M3.2: the Meatspace door into Cyberspace. */ +export const JACK_IN_GLYPH = 'Ω'; /** Numeric tile id — one of the `TILE` values. */ export type TileId = (typeof TILE)[keyof typeof TILE]; diff --git a/src/game/entities/JackInPoint.ts b/src/game/entities/JackInPoint.ts new file mode 100644 index 0000000..f2db958 --- /dev/null +++ b/src/game/entities/JackInPoint.ts @@ -0,0 +1,67 @@ +/** + * P3.M3.2 — Jack-in point: the Meatspace door into Cyberspace. + * + * Placed for `data-node-slice` contracts. Only a Decker can link (capability + * sniff on `canJackIn`); a successful link spends interact AP, latches + * `linked`, and emits `EVENT.JACK_IN` exactly once — `Run` listens and spawns + * the Cyberspace layer (P3.M3.3). Re-interacting a linked port is *redundant + * input*, not corruption: it returns a logged refusal and never throws. + * + * The id is namespaced `jack-in-*` deliberately — `Run`'s TERMINAL_SLICE + * satisfaction counts terminals by the `/^terminal-\d+$/` id pattern, and this + * port must never satisfy a slice quota. + */ +import { Interactable, type InteractableInit, type InteractResult } from './Interactable.js'; +import { AP_COST, JACK_IN_GLYPH } from '../constants.js'; +import { EVENT } from '../events.js'; +import type { Entity } from '../Entity.js'; +import type { World } from '../World.js'; + +export interface JackInPointInit extends Omit { + label?: string; + linked?: boolean; +} + +/** P2.7.M6.2: JackInPoint snapshot `extra`. */ +export type JackInPointSnapshot = { + label: string; + linked: boolean; +}; + +export class JackInPoint extends Interactable { + linked: boolean; + + constructor({ label = 'Jack-in port', linked = false, ...props }: JackInPointInit) { + super({ + ...props, + glyph: JACK_IN_GLYPH, + label, + secured: linked, + armed: !linked, + }); + this.linked = !!linked; + } + + override interact(world: World, actor: Entity): InteractResult { + if (this.linked) { + return { ok: false, reason: 'already-linked', message: `${this.label}: already linked.` }; + } + const check = this.canInteract(actor); + if (!check.ok) { + return { ok: false, reason: check.reason, message: `${this.label}: ${check.reason}.` }; + } + if ((actor as { canJackIn?: boolean }).canJackIn !== true) { + return { + ok: false, + reason: 'no-cyberdeck', + message: `${this.label}: no cyberdeck — only a Decker can jack in.`, + }; + } + actor.spendAp(AP_COST.INTERACT); + this.linked = true; + this.secured = true; + this.armed = false; + world.events?.emit(EVENT.JACK_IN, { point: this, actor }); + return { ok: true, message: `${this.label}: link established.` }; + } +} diff --git a/src/game/events.ts b/src/game/events.ts index 0cc6fb9..c5a5ae2 100644 --- a/src/game/events.ts +++ b/src/game/events.ts @@ -33,6 +33,10 @@ export const EVENT = Object.freeze({ HAZARD_DAMAGE: 'hazard:damage', OBJECTIVE_TIMER_EXPIRED: 'objective:timer-expired', DOOR_UNLOCKED: 'door:unlocked', + /** P3.M3.2: a Decker linked a jack-in point. Payload `{ point, actor }`. */ + JACK_IN: 'cyber:jack-in', + /** P3.M3: the Decker left the grid (voluntary or forced). */ + JACK_OUT: 'cyber:jack-out', }); const KNOWN_TYPES = new Set(Object.values(EVENT)); diff --git a/src/game/persistence.ts b/src/game/persistence.ts index d22e937..0f76ebb 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -71,11 +71,16 @@ import { RelayNode } from './entities/RelayNode.js'; import { ConsumablePickup } from './entities/ConsumablePickup.js'; import { EscortNpc } from './entities/EscortNpc.js'; import { KeyCard } from './entities/KeyCard.js'; +import { JackInPoint } from './entities/JackInPoint.js'; import { BreachingCharge } from './entities/BreachingCharge.js'; import type { BreachingChargeInit } from './entities/BreachingCharge.js'; import { Run, RUN_STATE, PATROL_ARCHETYPE_IDS } from './Run.js'; import { Campaign, CAMPAIGN_STATE, normalizeCampaignArc } from './Campaign.js'; -import { normalizeContractContext, normalizeObjective } from './hub/Curator.js'; +import { + contractRequiresCyberspace, + normalizeContractContext, + normalizeObjective, +} from './hub/Curator.js'; import { migrateLegacyHubReveals, normalizeHubReveals, @@ -97,6 +102,7 @@ import type { RelayNodeInit } from './entities/RelayNode.js'; import type { ConsumablePickupInit } from './entities/ConsumablePickup.js'; import type { EscortNpcInit } from './entities/EscortNpc.js'; import type { KeyCardInit } from './entities/KeyCard.js'; +import type { JackInPointInit, JackInPointSnapshot } from './entities/JackInPoint.js'; import type { EntityInit } from './Entity.js'; import type { CampaignArc } from './Campaign.js'; import type { FactionId } from './constants.js'; @@ -112,6 +118,7 @@ import type { ObjectiveTimerSnapshot, MapMemorySnapshot, ObjectiveProgressSnapshot, + CyberspaceState, } from './Run.js'; import type { CrewSnapshot } from './Crew.js'; import type { TechSnapshot } from './archetypes/Tech.js'; @@ -162,7 +169,8 @@ type RestoreEntityProps = Partial< ConsumablePickupInit & EscortNpcInit & KeyCardInit & - BreachingChargeInit + BreachingChargeInit & + JackInPointInit > & { id: string; x: number; @@ -205,6 +213,7 @@ const ARCHETYPE_FACTORY: Record new EscortNpc(props as EscortNpcInit), keycard: (props: RestoreEntityProps) => new KeyCard(props as KeyCardInit), + 'jack-in-point': (props: RestoreEntityProps) => new JackInPoint(props as JackInPointInit), 'breaching-charge': (props: RestoreEntityProps) => new BreachingCharge(props as BreachingChargeInit), // Generic fallback so a future `Entity` subclass (NPCs, items) doesn't break @@ -340,6 +349,17 @@ function readTerminal(extra: EntitySnapshotExtra, id: string): TerminalSnapshot }; } +function readJackInPoint(extra: EntitySnapshotExtra, id: string): JackInPointSnapshot { + if (hasNoState(extra)) { + throw new TypeError(`restore: jack-in point entity ${id} requires jack-in state`); + } + const p = extra as Partial; + return { + label: requireString(p.label, `restore: jack-in point ${id} label must be a non-empty string`), + linked: requireBoolean(p.linked, `restore: jack-in point ${id} linked must be boolean`), + }; +} + function readDoor(extra: EntitySnapshotExtra, id: string): DoorSnapshot { if (hasNoState(extra)) throw new TypeError(`restore: door entity ${id} requires door state`); const d = extra as Partial; @@ -582,6 +602,17 @@ const ENTITY_RESTORE: Partial> = Object. return { label: typeof r.label === 'string' && r.label.length > 0 ? r.label : 'Relay node' }; }, }, + 'jack-in-point': { + buildProps(extra, rec) { + const p = readJackInPoint(extra, rec.id); + return { label: p.label, linked: p.linked }; + }, + apply(entity, _extra, rec) { + if (!(entity instanceof JackInPoint)) { + throw new Error(`restore: jack-in point entity ${rec.id} did not restore as JackInPoint`); + } + }, + }, terminal: { buildProps(extra, rec) { const t = readTerminal(extra, rec.id); @@ -906,6 +937,7 @@ export function restore(record: unknown, options: RestoreOptions = {}) { run.rng = new Rng(record.rng.seed); run.rng.setState(record.rng.state); run.contract = normalizeContract(record.contract); + run.cyberspace = restoreCyberspace(record, run.contract !== null && contractRequiresCyberspace(run.contract)); run.exitTile = record.exitTile ? { ...record.exitTile } : null; run.telemetry = { ...record.telemetry }; run.objectiveTimer = normalizeObjectiveTimer(record.objectiveTimer); @@ -961,6 +993,44 @@ export function restore(record: unknown, options: RestoreOptions = {}) { return { run, world: run.world, queue: run.queue, rng: run.rng, player: run.player }; } +/** + * P3.M3: rebuild the Cyberspace state machine from its snapshot block. + * + * Invariant (both directions): a contract with a Cyberspace component + * (`contractRequiresCyberspace`) carries a `cyberspace` block, and only such + * contracts do. Any mismatch, unknown phase, or payload smuggled onto the + * `dormant` phase is tier-1 corrupt state and throws. P3.M3.3 extends this + * with the `active` (grid + entities) and `resolved` (latch) phases. + */ +function restoreCyberspace(record: RunSnapshot, requiresCyberspace: boolean): CyberspaceState | null { + const block = record.cyberspace; + if (!requiresCyberspace) { + if (block !== undefined && block !== null) { + throw new Error( + 'restore: cyberspace block present on a contract without a Cyberspace component' + ); + } + return null; + } + if (block === undefined || block === null) { + throw new Error('restore: Cyberspace contract snapshot is missing its cyberspace block'); + } + if (typeof block !== 'object' || Array.isArray(block)) { + throw new TypeError('restore: cyberspace block must be an object'); + } + const phase = (block as { phase?: unknown }).phase; + if (phase === 'dormant') { + const payloadKeys = Object.keys(block).filter(key => key !== 'phase'); + if (payloadKeys.length > 0) { + throw new Error( + `restore: dormant cyberspace block must carry no payload, got [${payloadKeys.join(', ')}]` + ); + } + return { phase: 'dormant' }; + } + throw new Error(`restore: unknown cyberspace phase "${String(phase)}"`); +} + export function restoreCampaign(record: unknown, options: RestoreCampaignOptions = {}): Campaign { validateCampaignRecord(record); const crew = record.crew.map(restoreCrewMember); diff --git a/tests/unit/game/jackInPoint.test.ts b/tests/unit/game/jackInPoint.test.ts new file mode 100644 index 0000000..4614777 --- /dev/null +++ b/tests/unit/game/jackInPoint.test.ts @@ -0,0 +1,255 @@ +/** + * P3.M3.2 — Jack-in point: the Meatspace door into Cyberspace. + * + * A `JackInPoint` is placed for data-node-slice contracts. Only a Decker can + * link (capability sniff on `canJackIn`); linking emits `EVENT.JACK_IN` once; + * redundant input logs (never throws); state round-trips through persistence. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Grid } from '../../../src/game/Grid.js'; +import { World } from '../../../src/game/World.js'; +import { Run, RUN_STATE } from '../../../src/game/Run.js'; +import { JackInPoint } from '../../../src/game/entities/JackInPoint.js'; +import { EventBus, EVENT } from '../../../src/game/events.js'; +import { snapshot, restore } from '../../../src/game/persistence.js'; +import { buildCrewMember } from '../../../src/game/archetypes/index.js'; +import { AP_COST, TILE } from '../../../src/game/constants.js'; +import { OBJECTIVES } from '../../../src/game/hub/Curator.js'; +import { Rng } from '../../../src/rng.js'; +import { testContractContext } from './contractTestUtils.js'; + +const fakeContract = (overrides = {}) => ({ + seed: 12345, + objective: { + kind: OBJECTIVES.REACH_EXIT, + title: 'Extract clean', + briefing: 'Reach the exit.', + }, + difficulty: 'standard', + threatCount: 1, + label: 'test job', + context: testContractContext(OBJECTIVES.REACH_EXIT), + reward: { credits: 0, repDelta: 0 }, + ...overrides, +}); + +const cyberContract = (overrides = {}) => + fakeContract({ + objective: { + kind: OBJECTIVES.DATA_NODE_SLICE, + title: 'Spike the server farm', + briefing: 'Jack in, slice the data node, then extract.', + params: { requiresCyberspace: true, count: 1 }, + }, + label: 'cyber test job', + context: testContractContext(OBJECTIVES.DATA_NODE_SLICE), + ...overrides, + }); + +function openWorld(width = 7, height = 5) { + const grid = new Grid(width, height); + return new World(grid, { events: new EventBus() }); +} + +function makeDecker(x = 1, y = 1) { + return buildCrewMember('decker', { x, y }, new Rng(100), { id: 'crew-decker' }); +} + +function makeMerc(x = 1, y = 1) { + return buildCrewMember('merc', { x, y }, new Rng(101), { id: 'crew-merc' }); +} + +function readyForCombat(member: ReturnType) { + member.ap = member.maxAp = 4; + return member; +} + +// --- interact behavior ------------------------------------------------------ + +test('a non-Decker cannot link: refused, no AP spent, no event', () => { + const world = openWorld(); + const merc = readyForCombat(makeMerc(1, 1)); + const point = new JackInPoint({ id: 'jack-in-0', x: 2, y: 1 }); + world.addEntity(merc); + world.addEntity(point); + let events = 0; + world.events!.on(EVENT.JACK_IN, () => events++); + + const apBefore = merc.ap; + const result = point.interact(world, merc); + assert.equal(result.ok, false); + assert.match((result as { reason: string }).reason, /cyberdeck|decker/i); + assert.equal(merc.ap, apBefore); + assert.equal(point.linked, false); + assert.equal(events, 0); +}); + +test('a Decker links: AP debited, linked latched, one JACK_IN event', () => { + const world = openWorld(); + const decker = readyForCombat(makeDecker(1, 1)); + const point = new JackInPoint({ id: 'jack-in-0', x: 2, y: 1 }); + world.addEntity(decker); + world.addEntity(point); + const payloads: unknown[] = []; + world.events!.on(EVENT.JACK_IN, payload => payloads.push(payload)); + + const apBefore = decker.ap; + const result = point.interact(world, decker); + assert.equal(result.ok, true); + assert.equal(decker.ap, apBefore - AP_COST.INTERACT); + assert.equal(point.linked, true); + assert.equal(payloads.length, 1); + const payload = payloads[0] as { point: JackInPoint; actor: unknown }; + assert.equal(payload.point, point); + assert.equal(payload.actor, decker); +}); + +test('re-linking is redundant input: logged refusal, no second event, no AP', () => { + const world = openWorld(); + const decker = readyForCombat(makeDecker(1, 1)); + const point = new JackInPoint({ id: 'jack-in-0', x: 2, y: 1 }); + world.addEntity(decker); + world.addEntity(point); + let events = 0; + world.events!.on(EVENT.JACK_IN, () => events++); + + assert.equal(point.interact(world, decker).ok, true); + const apAfterLink = decker.ap; + const repeat = point.interact(world, decker); + assert.equal(repeat.ok, false); + assert.equal((repeat as { reason: string }).reason, 'already-linked'); + assert.equal(decker.ap, apAfterLink); + assert.equal(events, 1); +}); + +test('linking requires adjacency', () => { + const world = openWorld(); + const decker = readyForCombat(makeDecker(1, 1)); + const point = new JackInPoint({ id: 'jack-in-0', x: 4, y: 1 }); + world.addEntity(decker); + world.addEntity(point); + const result = point.interact(world, decker); + assert.equal(result.ok, false); + assert.equal((result as { reason: string }).reason, 'not-adjacent'); +}); + +// --- Run placement ------------------------------------------------------------ + +function combatRun(seed = 12345) { + const run = new Run({ crewMember: makeDecker(0, 0), seed }); + run.enterBriefing(cyberContract({ seed })); + run.enterCombat(); + return run; +} + +function jackInPoints(run: Run): JackInPoint[] { + return Array.from(run.world!.entities.values()).filter( + (e): e is JackInPoint => e instanceof JackInPoint + ); +} + +test('a cyber contract places exactly one jack-in point, deterministically', () => { + const a = combatRun(); + const b = combatRun(); + const pointsA = jackInPoints(a); + const pointsB = jackInPoints(b); + assert.equal(pointsA.length, 1); + assert.equal(pointsB.length, 1); + const point = pointsA[0]; + assert.equal(point.id, 'jack-in-0'); + assert.equal(point.linked, false); + assert.deepEqual({ x: point.x, y: point.y }, { x: pointsB[0].x, y: pointsB[0].y }); + // The jack-in id must never count toward TERMINAL_SLICE objectives. + assert.ok(!/^terminal-\d+$/.test(point.id)); + // Never stacked on the operator spawn or the exit tile. + assert.ok(point.x !== a.player!.x || point.y !== a.player!.y); + assert.ok(point.x !== a.exitTile!.x || point.y !== a.exitTile!.y); +}); + +test('non-cyber contracts place no jack-in point and carry no cyberspace state', () => { + const run = new Run({ crewMember: makeDecker(0, 0), seed: 999 }); + run.enterBriefing(fakeContract({ seed: 999 })); + run.enterCombat(); + assert.equal(jackInPoints(run).length, 0); + assert.equal(run.cyberspace, null); +}); + +test('a cyber contract latches dormant cyberspace state at briefing', () => { + const run = new Run({ crewMember: makeDecker(0, 0), seed: 12345 }); + run.enterBriefing(cyberContract()); + assert.deepEqual(run.cyberspace, { phase: 'dormant' }); + assert.equal(run.state, RUN_STATE.BRIEFING); +}); + +// --- persistence --------------------------------------------------------------- + +test('jack-in point and dormant cyberspace state round-trip a snapshot', () => { + const run = combatRun(); + const point = jackInPoints(run)[0]; + point.linked = true; + point.secured = true; + point.armed = false; + + const record = snapshot(run); + assert.deepEqual(record.cyberspace, { phase: 'dormant' }); + + const { run: restored } = restore(structuredClone(record)); + const restoredPoint = jackInPoints(restored)[0]; + assert.ok(restoredPoint, 'jack-in point survives the round-trip'); + assert.equal(restoredPoint.linked, true); + assert.equal(restoredPoint.x, point.x); + assert.equal(restoredPoint.y, point.y); + assert.deepEqual(restored.cyberspace, { phase: 'dormant' }); +}); + +test('non-cyber snapshots omit the cyberspace block', () => { + const run = new Run({ crewMember: makeDecker(0, 0), seed: 999 }); + run.enterBriefing(fakeContract({ seed: 999 })); + run.enterCombat(); + const record = snapshot(run); + assert.equal('cyberspace' in record && record.cyberspace !== undefined, false); + assert.equal(restore(structuredClone(record)).run.cyberspace, null); +}); + +test('restore throws on cyberspace/contract mismatches and malformed phases', () => { + const cyberRecord = snapshot(combatRun()); + + // Cyber contract without its cyberspace block is corrupt. + const missingBlock = structuredClone(cyberRecord) as Record; + delete missingBlock.cyberspace; + assert.throws(() => restore(missingBlock), /cyberspace/); + + // Unknown phase is corrupt. + const badPhase = structuredClone(cyberRecord); + (badPhase.cyberspace as { phase: string }).phase = 'jacked-sideways'; + assert.throws(() => restore(badPhase), /phase/); + + // Dormant must not smuggle payload fields. + const dormantPayload = structuredClone(cyberRecord); + (dormantPayload.cyberspace as Record).grid = { w: 1, h: 1, tiles: [0] }; + assert.throws(() => restore(dormantPayload), /dormant/); + + // A cyberspace block on a non-cyber contract is corrupt. + const plainRun = new Run({ crewMember: makeDecker(0, 0), seed: 999 }); + plainRun.enterBriefing(fakeContract({ seed: 999 })); + plainRun.enterCombat(); + const plainRecord = structuredClone(snapshot(plainRun)) as Record; + plainRecord.cyberspace = { phase: 'dormant' }; + assert.throws(() => restore(plainRecord), /cyberspace/); +}); + +test('restore throws on a jack-in point record missing linked state', () => { + const record = structuredClone(snapshot(combatRun())); + const pointRec = record.entities.find(e => e.archetype === 'jack-in-point'); + assert.ok(pointRec, 'snapshot carries the jack-in point'); + delete (pointRec!.extra as Record).linked; + assert.throws(() => restore(record), /linked/); +}); + +test('TILE smoke guard: jack-in anchor sits on a passable tile', () => { + const run = combatRun(); + const point = jackInPoints(run)[0]; + assert.notEqual(run.world!.grid.tileAt(point.x, point.y), TILE.WALL); +}); From 96afb478e8ed35616c13d8978d12104a3a5da48e Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 9 Jun 2026 21:28:27 -0700 Subject: [PATCH 15/55] P3.M3 working plan: scope decisions, slice progress, S3 worklist Records the four scope decisions, S1/S2 implementation notes (shipped), approved architecture for S3-S7, and verified research notes so the Cyberspace effort can resume cold at S3. Co-Authored-By: Claude Fable 5 --- docs/phase-3-cyberspace-plan.md | 389 ++++++++++++++++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 docs/phase-3-cyberspace-plan.md diff --git a/docs/phase-3-cyberspace-plan.md b/docs/phase-3-cyberspace-plan.md new file mode 100644 index 0000000..1347aa4 --- /dev/null +++ b/docs/phase-3-cyberspace-plan.md @@ -0,0 +1,389 @@ +# P3.M3 — Cyberspace Grid + ICE: Working Plan & Progress + +Working document for the P3.M3 implementation effort on branch `3.0-cyberspace`. +Companion to [phase-3-plan.md](phase-3-plan.md) (the milestone spec); this file +tracks the approved slice plan, recorded scope decisions, and live progress so +the effort can be resumed cold. + +## Scope decisions (recorded 2026-06-09, Rylee) + +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 in phase-3-plan.md 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. + +TDD throughout; malformed persisted state throws (no silent fallbacks). +Commits land **per green slice** (user-approved). + +## Progress + +| Slice | Status | Commit | +|---|---|---| +| **S1 — P3.M3.1 Contract flag + gates** | ✅ Done | `f436330` | +| **S2 — P3.M3.2 Jack-in terminal** | ✅ Done | `05013f6` | +| **S3 — P3.M3.3 Cyber layer model + avatar + persistence** | 🔨 In progress (research done, no code yet) | — | +| **S4 — P3.M3.4 Data node objective** | 🔲 Planned | — | +| **S5 — Voluntary jack-out (M4.6 pull-forward)** | 🔲 Planned | — | +| **S6 — P3.M3.5 Probe ICE** | 🔲 Planned | — | +| **S7 — P3.M3.6 Render/input swap + shell ICE phase** | 🔲 Planned | — | +| **S8 — Docs + wrap-up** | 🔲 Planned | — | + +### S1 implementation notes (shipped) + +- `OBJECTIVES.DATA_NODE_SLICE = 'data-node-slice'` with cross-field validation + in `normalizeObjective` (`Curator.ts`): the kind requires + `params.requiresCyberspace === true` plus positive-integer `params.count`; + the flag is forbidden on every other kind. Validates at generation, + `Run.enterBriefing`, and snapshot restore (all call `normalizeObjective`). +- `contractRequiresCyberspace(contract)` exported from `Curator.ts` — single + source of truth for "this contract has a Cyberspace component". +- New recipe `cyber-data-spike` (params `{requiresCyberspace: true, count: 1}`, + no `targetParams` spread — placement is owned by the DATA_NODE_SLICE arm, and + no turn-limit/door-routing machinery engages). Gated by the new + `ContractRecipe.availableWhen?: (ctx) => boolean` hook: requires + `arcStage ∈ {act-2, act-3} && hasLivingDecker === true`. +- `recipeIsAvailable` filter applies to **all three** generation paths (fresh + `generateRecipeContract`, revisit, principal-biased). When no gated recipe + qualifies the filtered pool is identical to the historic pool, so seeded + generation for pre-P3.M3 contexts is unchanged (full suite stayed green). +- `ContractRecipeContext`/`ContractCampaign` gained `hasLivingDecker?`; + `Campaign.get hasLivingDecker()` = some non-flatlined `archetype === 'Decker'`. +- Deploy gate in `Campaign.deployCrewMember`: throws + `requires a living Decker to jack in` for cyber contracts unless the deployed + member is a (non-flatlined) Decker. +- UX: `CrewList.setCrew(crew, rowGate?)` — `CrewRowGate` returns a disable tag + or null; selection/keyboard nav skip gated rows. `RunBriefing` derives the + gate from the contract (`NEEDS DECKER` on non-Decker rows), re-applies on + contract swap, and `#commit`/jack-in button respect it. +- `Run.isObjectiveFamilySatisfied` has a `DATA_NODE_SLICE` case returning + `false` (honestly unsatisfiable until S4 wires sliced-node counting). The + exhaustive `never` default caught the new kind at typecheck. +- Tests: `tests/unit/game/hub/cyberContract.test.ts` (14 tests — validation + throws, generation gating over 40 seeded boards, determinism, deploy gates). + +### S2 implementation notes (shipped) + +- `src/game/entities/JackInPoint.ts` — `JackInPoint extends Interactable`, + glyph `Ω` (`JACK_IN_GLYPH` in constants), default label `Jack-in port`, + id `jack-in-0` (deliberately **not** matching `/^terminal-\d+$/`, which + TERMINAL_SLICE counts — regression-tested). `interact`: linked → logged + `already-linked` refusal (never throws); capability sniff + `actor.canJackIn !== true` → `no-cyberdeck` refusal; success spends interact + AP, latches `linked`, emits `EVENT.JACK_IN` `{point, actor}` exactly once. +- `Decker.canJackIn = true` (readonly capability, P3.M2 sniffing pattern). +- `events.ts`: `JACK_IN: 'cyber:jack-in'`, `JACK_OUT: 'cyber:jack-out'` + registered (known-types-only bus). +- Placement: `Run.#placeObjectiveInteractables` gained the DATA_NODE_SLICE arm — + one port via `findInteractableAnchor`, deterministic per contract seed. +- `Run.cyberspace: CyberspaceState | null` latched in `enterBriefing` from + `contractRequiresCyberspace` (`{phase:'dormant'}` or `null`). S2's + `CyberspaceState` is dormant-only; S3 extends the union. +- Persistence: `RunSnapshot.cyberspace?: RunCyberspaceSnapshot`; + `snapshotCyberspace` in Run.ts; `restoreCyberspace` in persistence.ts + enforcing **block ⇔ cyber-contract both directions**, unknown phase throws, + dormant payload smuggling throws. Entity plumbing: `'jack-in-point'` in + `EntityArchetypeId`, `archetypeOf`, `SNAPSHOT_EXTRACTORS`, + `ARCHETYPE_FACTORY`, `ENTITY_RESTORE` (+ `readJackInPoint` throwing on + missing label/linked). +- Tests: `tests/unit/game/jackInPoint.test.ts` (12 tests — interact behavior, + placement determinism, round-trip, adversarial restore throws). + +## Architecture decisions (approved plan) + +### Cyber layer model — `CyberspaceLayer` owned by `Run`, single `TurnQueue`, both worlds tick + +New `src/game/cyber/CyberspaceLayer.ts`: owns its own `EventBus`, `World` +(own `Grid`), `CyberAvatar`, `entryTile`, `mapSeen`. NOT a nested Run. + +```ts +type CyberspaceState = + | { phase: 'dormant' } // shipped (S2) + | { phase: 'active'; layer: CyberspaceLayer } // S3 + | { phase: 'resolved'; objectiveComplete: boolean }; // S3/S5 +Run.cyberspace: CyberspaceState | null; // null ⇔ no cyber component +``` + +Turn integration: keep the one existing `TurnQueue`. `TurnQueue.endTurn(world)` +(verified: emits `TURN_ENDED {previous, next, turn}`, refreshes incoming-faction +AP in the world it is handed, ticks that world's alarm on round advance) runs on +the meat world as today. `Run`'s existing `TURN_ENDED` listener +(`_reattachCombatListeners`, Run.ts ~887) consumes the payload and, when cyber +is active, calls `layer.onTurnEnded(next)`: refresh incoming-faction AP in the +cyber world + tick the cyber alarm on round advance. The shell's corp phase +(S7) chains two `corpTurnDriver.runCorpTurn` passes: meat hostiles, then ICE +(`ctx.run = {state, world: layer.world, rng: run.rng}`). **Meatspace keeps +ticking during jack-in** — the Decker body stands at the port as `run.player`, +targetable; body death hits the existing player-death path +(`#onEntityDamaged`, Run.ts ~938). M4.2 vulnerability falls out for free. + +### Avatar + cyber stats + +`src/game/cyber/CyberAvatar.ts` — `CyberAvatar extends Entity`: faction PLAYER, +glyph `@`, maxAp 4, `maxHp = decker.ram`, +`damageReduction = decker.iceResistance`, carries `intrusionStrength` + +`callsign`, `baseHitChance` 0.8 (the grid is home turf; `Combat.resolveRanged` +capability-sniffs `'baseHitChance' in attacker`, so a non-Crew avatar fights +with zero combat changes). + +Stats live on `Decker` as validated fields with constants +`DECKER_BASE_RAM = 8`, `DECKER_BASE_INTRUSION = 2`, +`DECKER_BASE_ICE_RESISTANCE = 1` (`src/game/constants.ts`); ctor throws on +non-positive-integer overrides. Persist in **both** crew paths: + +1. `CampaignCrewSnapshot` (persistence.ts ~743) gains optional + `cyber?: { ram; intrusion; iceResistance }` written by `snapshotCrewMember` + for deckers only; `restoreCrewMember` (~1326) applies for + `archetype === 'decker'`. Absent → defaults (legacy normalization); + present-but-malformed or half-populated → throw. +2. Run-entity `decker` extra becomes + `DeckerSnapshot = CrewSnapshot & {ram; intrusion; iceResistance}` — + extractor in Run.ts `SNAPSHOT_EXTRACTORS.decker` (~1698), matching + `ENTITY_RESTORE.decker` apply hook. + +Avatar **current** RAM rides the base entity record (`hp`/`maxHp`/ +`damageReduction`) inside the cyber snapshot block; `intrusionStrength` + +`callsign` ride its `extra`. + +Avatar death: cyber-bus `ENTITY_DAMAGED` listener wired at jack-in (and on +restore via `_reattachCombatListeners` — the single seam both `enterCombat` and +`persistence.restore` already call) → set `telemetry.cause` → +`enterResult({outcome: OUTCOME.DEATH})` → `Campaign.onJobEnd` flatlines the +Decker through existing machinery. + +### Generation — dedicated generator, reuse TILE values, contract-seed determinism + +`src/game/cyber/cyberMapBuild.ts`: `buildCyberMap({rng, difficulty})` → +rooms-as-nodes lattice (5–8 square nodes), 1-tile corridors (data lines), +`TILE.WALL` fill (firewall). **Only FLOOR/WALL tile ids** — Grid +passability/LOS, A*, VisionField, World all work unchanged. Node count scales +with difficulty. Returns `{grid, entryTile, nodeTiles, patrolRings}`. +Connectivity validated from `entryTile` via `mapConnectivity.ts` +`explorationReachableKeys` (verified signature: `(world, start, options)` — +needs a throwaway `World` wrap or a grid-level flood; throws on unreachable +node tiles). ~150 lines; do NOT reuse the BSP/prefab `buildMap`. + +Seed: `new Rng(contract.seed).fork('cyberspace')` — layout independent of +jack-in turn, trivially restorable. + +Distinct visuals via a **tileset axis** (S7), not new TILE ids: `palette.ts` +gains `TilesetId = 'meat' | 'cyber'` + `CYBER_TILE_GLYPH` (FLOOR `·` deep cyan, +WALL `▒` magenta — tune in browser); `glyphForTile(tile, principalId?, +tileset?)` throws on unknown tileset. Thread through +`BuildFrameOptions`/`buildFrame`/`AsciiRenderer.draw`. + +### Data node objective semantics (S4) + +`src/game/cyber/DataNode.ts` — `DataNode extends Interactable` (Terminal +pattern): `sliceProgress`, `sliceDifficulty` (2/3/4 by contract difficulty). +`interact` capability-sniffs `intrusionStrength` (non-avatars refused), spends +interact AP, adds intrusion to progress; sliced at threshold. Glyph `◈` +(`◆` is DenyTarget's). + +Satisfaction: `ObjectiveState` gains `cyber?: {sliced, required}`; +`isObjectiveFamilySatisfied` DATA_NODE_SLICE case reads it; +`#refreshObjectiveTimerState` computes — `active` → count sliced nodes in the +layer world; `resolved` → `objectiveComplete ? required : 0`; `dormant` → 0. +**Jack-out before slicing = objective permanently unsatisfiable** → reaching +the meat exit hits the existing `onAbortRequested` confirm flow (incomplete +blocks *clean* extraction; zero new extraction code). `objectiveProgress.ts` +gains a `NODES` progress chip. Grep `describe.ts`/`combatHud.ts` for +kind-keyed copy in this slice. + +### Jack-in / jack-out (S3 core, S5 finalization) + +- `Run.jackIn(point)` via meat-bus `EVENT.JACK_IN` subscription (registered in + `_reattachCombatListeners`): `state === COMBAT` else throw; + `cyberspace.phase === 'dormant'` else **throw** (corrupt latch); build layer, + wire cyber listeners, autosave (`onPersist` explicitly — the latch transition + must never be lost). +- Jack-out: `EntryPort extends Interactable` at `entryTile` in the cyber world + (glyph `▼`, avatar-only capability sniff); interact emits `EVENT.JACK_OUT` on + the cyber bus → `Run.jackOut()`: throw unless active; latch + `objectiveComplete`, `layer.teardown()` (unbind ICE bus subs), phase + `resolved`, autosave. Re-jack-in after resolve refused (`LINK BURNED` + flavor — `JackInPoint.linked` is already latched). + +### Probe ICE (S6) + +`src/game/cyber/ProbeIce.ts` — `ProbeIce extends PatrolHostile`, faction CORP, +glyph `¶`, displayName `Probe`, explicit small stats (maxHp 3, dmg 1, +sightRange 6 — ICE scaling is its own axis, skip `resolveEnemyStats`). +`engageSteps`: on acquisition raise the cyber alarm +(`world.raiseAlarm({repPenalty: false, ...})`) then weak attack; Probe +**listens** for alarms (unlike Lookout) so packs converge. Add `'probe-ice'` +to `PATROL_ARCHETYPE_IDS` so `PatrolSnapshot` machinery applies. Patrol +waypoint rings around node tiles from `buildCyberMap`. Run's cyber +`ENTITY_DAMAGED` listener also unbinds killed patrol hostiles (mirror of the +meat path). Verify no rep listener attaches to the cyber bus. + +### Render/input swap (S7) + +`Run` accessors keep the shell mechanical: `get cyberActive`, +`get activeWorld`, `get activeActor`. Shell (`index.ts`): module-level +`cyberVision = new VisionField()`; swap `recomputeVision`, `paint` +(+`tileset`), `applyIntent` ctx (`world: run.activeWorld, +player: run.activeActor`), look/describe, `buildCombatHudSnapshot` (identity +`AVATAR`, HP pane labeled RAM), statusLine, location label (`// THE GRID //`), +chained ICE corp pass. `ApplyIntentContext.player: Archetype` widens to accept +`CyberAvatar` (it exposes none of the perk capabilities, so `doSpecial` +sniffing is safe). No keymap/keyHelp changes — jack-in/out are interactions. +PIP deferred to M4.5. **Grep `run.world` / `run.player` / `scene.world` / +`scene.player` exhaustively** — look/describe, touch path, corpse-memory +vision are the known stragglers. + +## Persistence (consolidated; S2 shipped the dormant scaffolding) + +`RunSnapshot.cyberspace` (S3 extends the S2 shape): + +```ts +cyberspace?: { + phase: 'dormant' | 'active' | 'resolved'; + // 'active' only — all required together: + seed?, grid?: {w,h,tiles}, entities?: RunEntitySnapshot[], // avatar, entry-port, data-nodes, probe-ice + entryTile?, alarm?: AlarmState, mapMemory?: {seen: string[]}, + // 'resolved' only: + objectiveComplete?: boolean; +}; +``` + +Restore rules (`restoreCyberspace` in persistence.ts — S2 shipped the dormant +subset; all throws, `restoreOverrideState` style): + +- `contractRequiresCyberspace(contract)` ⇔ block present (both directions). ✅ shipped +- Unknown phase throws. ✅ shipped +- `dormant` carrying payload throws. ✅ shipped +- S3: `active` missing any required field throws; exactly one alive + `cyber-avatar` (0 or 2+ throws); entities bounds-checked against the + **cyber** grid (reuse `restoreEntity(rec, cyberGrid)`); seen keys + bounds-checked. +- S3: `resolved` without boolean `objectiveComplete`, or carrying active-only + fields, throws. +- S3: Decker `cyber` stat blocks (campaign crew + run entity extra): absent → + defaults (legacy normalization); present-but-malformed or half-populated → + throw. +- After rebuild: probes `bindToBus(layer.bus)`; cyber listeners re-wired in + `_reattachCombatListeners`. +- Autosave cadence unchanged (meat-bus `TURN_ENDED` still fires every end-turn + while jacked in — single queue); `jackIn`/`jackOut` call `onPersist` + explicitly. + +## S3 detailed worklist (next up — research complete, no code yet) + +**Create:** +- `src/game/cyber/cyberMapBuild.ts` — `buildCyberMap({rng, difficulty})` per + above; throws on connectivity failure. +- `src/game/cyber/CyberAvatar.ts` — per above. +- `src/game/cyber/EntryPort.ts` — `EntryPort extends Interactable` (glyph `▼`, + label `Exit port`); interact by avatar emits `EVENT.JACK_OUT` (charge + `AP_COST.INTERACT`). +- `src/game/cyber/CyberspaceLayer.ts` — class + + `static build({contractSeed, difficulty, decker})` (forks + `new Rng(contractSeed).fork('cyberspace')`, builds map, spawns avatar at + `entryTile`, `EntryPort`, data nodes (S4 fills in), ICE (S6)); + `onTurnEnded(next: FactionId)`; `recordSeen(keys)` (bounds-validated, + throws); `snapshot(): CyberspaceLayerSnapshot`; `teardown()`. + +**Modify:** +- `src/game/archetypes/Decker.ts` + `constants.ts` — `ram`/`intrusionStrength`/ + `iceResistance` fields, `DECKER_BASE_*` constants, ctor validation. +- `src/game/Run.ts` — extend `CyberspaceState` union + `RunCyberspaceSnapshot`; + `get cyberActive / activeWorld / activeActor`; `jackIn(point)` (driven by + `EVENT.JACK_IN` subscription added to `_reattachCombatListeners`); + `jackOut()` core; `#onTurnEnded` consumes the `{next}` payload (currently + ignores it — Run.ts ~927) for the cyber AP/alarm tick; `snapshotCyberspace` + extended; `SNAPSHOT_EXTRACTORS` entries `'cyber-avatar'`/`'entry-port'`; + `archetypeOf` instanceof arms; `DeckerSnapshot` extra extension. +- `src/game/persistence.ts` — extend `restoreCyberspace` (active/resolved + rules above); `ARCHETYPE_FACTORY`/`ENTITY_RESTORE` for + `'cyber-avatar'`/`'entry-port'`; `ENTITY_RESTORE.decker` apply for cyber + stats; `CampaignCrewSnapshot.cyber?` in `snapshotCrewMember` (~1309) + + `restoreCrewMember` (~1326). + +**Tests (failing first):** +- `tests/unit/game/cyber/cyberMapBuild.test.ts`: equal-seed determinism; + different-seed divergence; every node tile reachable from entry; only + FLOOR/WALL ids; difficulty scales node count. +- `tests/unit/game/cyber/CyberspaceLayer.test.ts`: `build` spawns avatar at + entry with `maxHp === decker.ram`, + `damageReduction === decker.iceResistance`; `onTurnEnded(FACTION.PLAYER)` + refreshes avatar AP only. +- `tests/unit/game/cyber/runJackIn.test.ts`: golden path — seeded cyber run, + walk Decker to port, interact → `phase === 'active'`; layer grid equals a + fresh build from the same contract seed (jack-in-turn independence); + `jackIn` while already active throws; `JACK_IN` on a non-cyber contract + throws. +- `tests/unit/game/cyber/cyberPersistence.test.ts` (the heart): mid-jack-in + snapshot → restore → grids/entities/avatar hp/ap/seen equal; full + adversarial throw matrix (active missing grid/avatar; avatar OOB; multiple + avatars; resolved missing latch; decker cyber stats half-populated in both + paths). + +## S3 research notes (verified against the tree, 2026-06-09) + +- `TurnQueue.endTurn(world)` emits `TURN_ENDED {previous, next, turn}` and + refreshes AP only for entities in the world it is handed; alarm ticks on + round advance (TurnQueue.ts:34-55). `Run.#onTurnEnded()` currently takes no + payload (Run.ts ~927) — S3 threads `{next}` through for the cyber tick. +- `_reattachCombatListeners` (Run.ts ~887) is the single seam used by both + `enterCombat` and `persistence.restore` — register the `EVENT.JACK_IN` + subscription and (re)wire cyber-layer listeners there. +- `#onEntityDamaged` (Run.ts ~938) is the player-death template: avatar-death + listener mirrors `target === avatar && killed` → telemetry.cause → + `enterResult({outcome: OUTCOME.DEATH})`. It also unbinds killed + `PatrolHostile`s — the cyber listener needs the same for probes (S6). +- `persistence.restore` (persistence.ts ~872) rebuilds grid → entities → + Run → world → queue, then calls `_reattachCombatListeners` for COMBAT + snapshots. `restoreEntity(rec, grid)` bounds-checks against the grid it is + given — reuse with the cyber grid for cyber entities. +- `CampaignCrewSnapshot` (~743) and `snapshotCrewMember`/`restoreCrewMember` + (~1309/~1326) are the campaign crew path; run-entity crew state flows through + `crewSnapshotExtra` (Run.ts ~1675) + `ENTITY_RESTORE` crew handling. Decker + cyber stats must round-trip through **both**. +- `Entity` ctor validates `damageReduction` as a non-negative integer + (Entity.ts ~110) — avatar can pass `iceResistance` straight through. +- `explorationReachableKeys(world, start, options)` (mapConnectivity.ts) is the + flood-fill for cyber connectivity validation; it needs a `World`, so + `buildCyberMap` should wrap its fresh grid in a throwaway `World` (no + entities yet → entity blockers moot). +- `World` ctor: `new World(grid, { events: bus })`; alarm API: + `raiseAlarm(ctx)`, `tickAlarm()`, `snapshotAlarm()`, `restoreAlarm(state)`. +- `Crew.archetype` is the class-name string (`'Decker'`, capital D) for live + checks; persistence archetype ids are lowercase (`'decker'`). + +## Verification (per slice + end-to-end) + +1. Per slice: `npm test` (typecheck + test build + `node --test`), `npm run lint`. +2. Determinism: equal-seed trace assertions in S3/S6 tests. +3. Browser smoke after S7 (`npm start`, port 8099): Act-2 save with Decker → + cyber contract on board → non-Decker rows disabled → deploy Decker → jack + in → cyber tileset + RAM HUD + probe attacks → slice node → jack out → meat + renders → clean extract. Plus: Merc bump refusal copy, redundant jack-in + message, mid-cyber reload resumes in the grid, avatar death → flatline + screen, jack-out-early → abort-confirm extraction. Console clean on iOS + Safari sim + Chrome. +4. Persistence adversarial: hand-mutate a save in devtools (delete + `cyberspace.grid`, flip phase) → restore throws to the error boundary, not + a silent fallback. + +## Risks + +- **S7 shell breadth** is the highest-risk surface (~2300-line index.ts reads + `run.world`/`run.player` widely). Mitigation: accessors + exhaustive grep + + the smoke script above. +- **`ApplyIntentContext.player` widening** may ripple into + `doSpecial`/`doUseItem` narrowing; the avatar exposes no perk capabilities, + and `collectTileLoot` guards on `player.inventory`. +- **Shared-rng turn order**: meat pass then ICE pass consumes `run.rng` in a + fixed order; lock with the S7 dual-phase test. +- **Cyber alarm/rep**: Probe passes `repPenalty: false`; confirm no rep + listeners attach to the cyber bus (S6 test). From 76841a05234ce8dad39b49105a231af645250a54 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Tue, 9 Jun 2026 21:33:03 -0700 Subject: [PATCH 16/55] lint --- src/game/Run.ts | 4 +--- src/game/hub/Curator.ts | 5 ++++- src/game/persistence.ts | 10 ++++++++-- tests/unit/game/hub/cyberContract.test.ts | 5 +---- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/game/Run.ts b/src/game/Run.ts index ed33b2d..c3938ab 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -1798,9 +1798,7 @@ const SNAPSHOT_EXTRACTORS: Partial Enti */ function snapshotCyberspace(state: CyberspaceState): RunCyberspaceSnapshot { if (state.phase === 'dormant') return { phase: 'dormant' }; - throw new Error( - `Run.snapshot: unknown cyberspace phase "${(state as { phase: string }).phase}"` - ); + throw new Error(`Run.snapshot: unknown cyberspace phase "${(state as { phase: string }).phase}"`); } function snapshotEntity(entity: Entity): RunEntitySnapshot { diff --git a/src/game/hub/Curator.ts b/src/game/hub/Curator.ts index 1948e6c..d7996c5 100644 --- a/src/game/hub/Curator.ts +++ b/src/game/hub/Curator.ts @@ -710,7 +710,10 @@ export function normalizeObjective(value: unknown): ContractObjective { * other kind may carry the flag. Validated at generation, at * `Run.enterBriefing`, and on snapshot restore (all call `normalizeObjective`). */ -function validateCyberspaceObjective(kind: ObjectiveKind, params: ObjectiveParams | undefined): void { +function validateCyberspaceObjective( + kind: ObjectiveKind, + params: ObjectiveParams | undefined +): void { if (kind === OBJECTIVES.DATA_NODE_SLICE) { if (params?.requiresCyberspace !== true) { throw new Error( diff --git a/src/game/persistence.ts b/src/game/persistence.ts index 0f76ebb..3434b7b 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -937,7 +937,10 @@ export function restore(record: unknown, options: RestoreOptions = {}) { run.rng = new Rng(record.rng.seed); run.rng.setState(record.rng.state); run.contract = normalizeContract(record.contract); - run.cyberspace = restoreCyberspace(record, run.contract !== null && contractRequiresCyberspace(run.contract)); + run.cyberspace = restoreCyberspace( + record, + run.contract !== null && contractRequiresCyberspace(run.contract) + ); run.exitTile = record.exitTile ? { ...record.exitTile } : null; run.telemetry = { ...record.telemetry }; run.objectiveTimer = normalizeObjectiveTimer(record.objectiveTimer); @@ -1002,7 +1005,10 @@ export function restore(record: unknown, options: RestoreOptions = {}) { * `dormant` phase is tier-1 corrupt state and throws. P3.M3.3 extends this * with the `active` (grid + entities) and `resolved` (latch) phases. */ -function restoreCyberspace(record: RunSnapshot, requiresCyberspace: boolean): CyberspaceState | null { +function restoreCyberspace( + record: RunSnapshot, + requiresCyberspace: boolean +): CyberspaceState | null { const block = record.cyberspace; if (!requiresCyberspace) { if (block !== undefined && block !== null) { diff --git a/tests/unit/game/hub/cyberContract.test.ts b/tests/unit/game/hub/cyberContract.test.ts index 11e57dd..e9e7bf7 100644 --- a/tests/unit/game/hub/cyberContract.test.ts +++ b/tests/unit/game/hub/cyberContract.test.ts @@ -73,10 +73,7 @@ test('normalizeObjective throws on data-node-slice without requiresCyberspace', }); test('normalizeObjective throws on data-node-slice with a malformed count', () => { - assert.throws( - () => normalizeObjective(cyberObjective({ requiresCyberspace: true })), - /count/ - ); + assert.throws(() => normalizeObjective(cyberObjective({ requiresCyberspace: true })), /count/); assert.throws( () => normalizeObjective(cyberObjective({ requiresCyberspace: true, count: 0 })), /count/ From 39b357e50eceaec401c1436b8df8ae43b8bda253 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Wed, 10 Jun 2026 13:39:11 -0700 Subject: [PATCH 17/55] =?UTF-8?q?P3.M3.3:=20Cyberspace=20layer=20=E2=80=94?= =?UTF-8?q?=20grid,=20avatar,=20jack-in/out=20core,=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S3 of the cyberspace slice plan. The digital layer exists and round-trips: - cyberMapBuild: deterministic node-lattice generator (spanning random walk over a 4×2 cell lattice, rooms + L-corridors, FLOOR/WALL only), seeded from the contract via Rng.fork('cyberspace') so layout is independent of jack-in turn. Connectivity flood-validated; unreachable port/node throws. - CyberAvatar (Entity subclass): maxHp = Decker RAM, damageReduction = ICE resistance, carries intrusionStrength + callsign, baseHitChance 0.8, isCyberAvatar capability flag. - EntryPort interactable: avatar-only interact emits JACK_OUT (resolve latch lives on Run.cyberspace, not the port). - CyberspaceLayer: own bus + World, build/restore ctors, onTurnEnded AP refresh + alarm tick driven by the single meat TurnQueue, mapSeen. - Run: CyberspaceState active/resolved phases, cyberActive/activeWorld/ activeActor accessors, jackIn/jackOut with throw-on-illegal-phase, cyber ENTITY_DAMAGED listener (avatar death → RESULT/DEATH flatline), snapshot block for all three phases. - persistence: restoreCyberspace with full adversarial throw matrix (allowed-keys per phase, grid/tile validation, exactly-one avatar/port, bounds checks against the cyber grid); Decker cyber stats persist via both crew paths (campaign snapshot + run-entity extra), absent → base defaults, partial → throw. - Decker: ram/intrusionStrength/iceResistance fields with ctor validation and DECKER_BASE_* constants (scope decision #4 — named stats, real effects). 49 new tests under tests/unit/game/cyber/ (failing-first verified). Co-Authored-By: Claude Fable 5 --- src/game/Entity.ts | 3 + src/game/Run.ts | 247 ++++++++++- src/game/archetypes/Decker.ts | 59 ++- src/game/constants.ts | 26 ++ src/game/cyber/CyberAvatar.ts | 103 +++++ src/game/cyber/CyberspaceLayer.ts | 194 +++++++++ src/game/cyber/EntryPort.ts | 53 +++ src/game/cyber/cyberMapBuild.ts | 172 ++++++++ src/game/persistence.ts | 294 ++++++++++++- tests/unit/game/cyber/CyberspaceLayer.test.ts | 172 ++++++++ tests/unit/game/cyber/cyberMapBuild.test.ts | 101 +++++ .../unit/game/cyber/cyberPersistence.test.ts | 404 ++++++++++++++++++ tests/unit/game/cyber/runJackIn.test.ts | 242 +++++++++++ 13 files changed, 2038 insertions(+), 32 deletions(-) create mode 100644 src/game/cyber/CyberAvatar.ts create mode 100644 src/game/cyber/CyberspaceLayer.ts create mode 100644 src/game/cyber/EntryPort.ts create mode 100644 src/game/cyber/cyberMapBuild.ts create mode 100644 tests/unit/game/cyber/CyberspaceLayer.test.ts create mode 100644 tests/unit/game/cyber/cyberMapBuild.test.ts create mode 100644 tests/unit/game/cyber/cyberPersistence.test.ts create mode 100644 tests/unit/game/cyber/runJackIn.test.ts diff --git a/src/game/Entity.ts b/src/game/Entity.ts index b51c3ce..f70e130 100644 --- a/src/game/Entity.ts +++ b/src/game/Entity.ts @@ -274,6 +274,9 @@ function kindFromId(id: string): string { if (id.startsWith('relay-node')) return 'Relay'; if (id.startsWith('escort-npc')) return 'Escort'; if (id.startsWith('keycard')) return 'Keycard'; + if (id.startsWith('jack-in')) return 'Jack-in Port'; + if (id.startsWith('cyber-avatar')) return 'Avatar'; + if (id.startsWith('entry-port')) return 'Exit Port'; if (id.includes('turret')) return 'Turret'; if (id.startsWith('crew')) return 'Operative'; return id; diff --git a/src/game/Run.ts b/src/game/Run.ts index c3938ab..61f4641 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -79,6 +79,9 @@ import { ConsumablePickup } from './entities/ConsumablePickup.js'; import { EscortNpc } from './entities/EscortNpc.js'; import { KeyCard } from './entities/KeyCard.js'; import { JackInPoint } from './entities/JackInPoint.js'; +import { CyberspaceLayer } from './cyber/CyberspaceLayer.js'; +import { CyberAvatar } from './cyber/CyberAvatar.js'; +import { EntryPort } from './cyber/EntryPort.js'; import { applyMutationDeltas } from './locations.js'; import { BreachingCharge } from './entities/BreachingCharge.js'; import { ITEM_ID, getItemById } from './items.js'; @@ -123,6 +126,9 @@ import type { ConsumablePickupSnapshot } from './entities/ConsumablePickup.js'; import type { EscortNpcSnapshot } from './entities/EscortNpc.js'; import type { JackInPointSnapshot } from './entities/JackInPoint.js'; import type { KeyCardSnapshot } from './entities/KeyCard.js'; +import type { CyberAvatarSnapshot } from './cyber/CyberAvatar.js'; +import type { EntryPortSnapshot } from './cyber/EntryPort.js'; +import type { DeckerSnapshot } from './archetypes/Decker.js'; import type { AlarmState } from './World.js'; export const RUN_STATE = Object.freeze({ @@ -167,6 +173,8 @@ export type EntityArchetypeId = | 'keycard' | 'breaching-charge' | 'jack-in-point' + | 'cyber-avatar' + | 'entry-port' | 'entity'; export type RunTelemetry = { @@ -286,23 +294,37 @@ export type RunSnapshot = { }; /** - * P3.M3: serialized Cyberspace layer state. The `dormant` phase carries no - * payload — the layer spawns fresh on jack-in. P3.M3.3 extends this with the - * `active` (grid + entities) and `resolved` (objective latch) phases. + * P3.M3: serialized Cyberspace layer state. + * + * - `dormant` — cyber contract, not yet jacked in. No payload; the layer + * spawns fresh (and deterministically) on jack-in. + * - `active` — the live layer: its grid, entities (avatar, exit port, and + * later data nodes + ICE), alarm cadence, and fog memory. All fields are + * required together; a partial block is corrupt and throws on restore. + * - `resolved` — jacked out; only the objective latch survives. */ -export type RunCyberspaceSnapshot = { - phase: 'dormant'; -}; +export type RunCyberspaceSnapshot = + | { phase: 'dormant' } + | { + phase: 'active'; + grid: { w: number; h: number; tiles: number[] }; + entities: RunEntitySnapshot[]; + entryTile: GridPoint; + alarm: AlarmState; + mapMemory: MapMemorySnapshot; + } + | { phase: 'resolved'; objectiveComplete: boolean }; /** * P3.M3: live Cyberspace state machine on the Run. `null` ⇔ the contract has - * no Cyberspace component. P3.M3.3 extends the union with - * `{ phase: 'active'; layer: CyberspaceLayer }` and - * `{ phase: 'resolved'; objectiveComplete: boolean }`. + * no Cyberspace component. Transitions: dormant → active (`jackIn`) → + * resolved (`jackOut` / P3.M4 forced jack-out). Resolved is a latch — the + * link is burned; re-entry is refused. */ -export type CyberspaceState = { - phase: 'dormant'; -}; +export type CyberspaceState = + | { phase: 'dormant' } + | { phase: 'active'; layer: CyberspaceLayer } + | { phase: 'resolved'; objectiveComplete: boolean }; /** Serializable run-scoped key item (P2.5.M6.2). */ type KeyItemSnapshot = { @@ -351,6 +373,12 @@ type EntityMovedPayload = { to: GridPoint; }; +type TurnEndedPayload = { + previous: FactionId; + next: FactionId; + turn: number; +}; + export class Run { id: string; crewMember: Crew; @@ -779,6 +807,89 @@ export class Run { return this.world?.mutationDeltas ?? []; } + // ------------------------------------------------------------------ + // P3.M3.3 — Cyberspace layer bridge + // ------------------------------------------------------------------ + + /** True while the Decker is jacked in and the cyber layer is live. */ + get cyberActive(): boolean { + return this.cyberspace?.phase === 'active'; + } + + /** The world the shell should render/drive: cyber while jacked in, else meat. */ + get activeWorld(): World | null { + return this.cyberspace?.phase === 'active' ? this.cyberspace.layer.world : this.world; + } + + /** The actor the shell should control: the avatar while jacked in, else the player. */ + get activeActor(): Crew | CyberAvatar | null { + return this.cyberspace?.phase === 'active' ? this.cyberspace.layer.avatar : this.player; + } + + /** + * Dormant → active: spawn the Cyberspace layer. Driven by the meat-bus + * `EVENT.JACK_IN` emission from a `JackInPoint` link. The layer derives + * from the *contract* seed, so the layout is independent of the jack-in + * turn. Every precondition violation throws — a JACK_IN emission outside a + * dormant cyber run is corrupt state, not a recoverable refusal. + */ + jackIn(point: JackInPoint): void { + if (this.state !== RUN_STATE.COMBAT) { + throw new Error(`Run.jackIn: illegal from state ${this.state} (COMBAT only)`); + } + if (!this.contract) { + throw new Error('Run.jackIn: COMBAT state without a contract'); + } + if (!this.cyberspace) { + throw new Error('Run.jackIn: contract has no Cyberspace component'); + } + if (this.cyberspace.phase !== 'dormant') { + throw new Error(`Run.jackIn: illegal from cyberspace phase "${this.cyberspace.phase}"`); + } + if (!(point instanceof JackInPoint) || !point.linked) { + throw new Error('Run.jackIn: requires a linked jack-in point'); + } + if (!(this.player instanceof Decker)) { + throw new Error('Run.jackIn: only a Decker can enter the grid'); + } + const layer = CyberspaceLayer.build({ + contractSeed: this.contract.seed, + difficulty: this.contract.difficulty, + decker: this.player, + }); + this.cyberspace = { phase: 'active', layer }; + this.#wireCyberLayerListeners(layer); + // The latch transition must never be lost — autosave explicitly. + if (this.onPersist) { + this.onPersist(this.snapshot()); + } + } + + /** + * Active → resolved: the avatar routed out through the exit port (cyber-bus + * `EVENT.JACK_OUT`). Latches the objective outcome and tears the layer + * down; the link is burned — re-entry is refused. P3.M4.6 adds the forced + * variant (body under fire). + */ + jackOut(): void { + if (this.state !== RUN_STATE.COMBAT) { + throw new Error(`Run.jackOut: illegal from state ${this.state} (COMBAT only)`); + } + if (this.cyberspace?.phase !== 'active') { + throw new Error( + `Run.jackOut: illegal from cyberspace phase "${this.cyberspace?.phase ?? 'none'}"` + ); + } + const layer = this.cyberspace.layer; + // TODO(P3.M3.4): derive from sliced data-node count once nodes exist. + const objectiveComplete = false; + layer.teardown(); + this.cyberspace = { phase: 'resolved', objectiveComplete }; + if (this.onPersist) { + this.onPersist(this.snapshot()); + } + } + /** * Phase 2.9: the single hostile faction for this run, derived from the * contract principal's groups (rival-group → `RIVAL`, corp/civic → `CORP`). @@ -891,12 +1002,18 @@ export class Run { this.#unwireCombatListeners(); const bus = this.bus; this._busUnsubs.push( - bus.on(EVENT.TURN_ENDED, () => this.#onTurnEnded()), + bus.on(EVENT.TURN_ENDED, payload => this.#onTurnEnded(payload as TurnEndedPayload)), bus.on(EVENT.ENTITY_DAMAGED, payload => this.#onEntityDamaged(payload as EntityDamagedPayload) ), - bus.on(EVENT.ENTITY_MOVED, payload => this.#onEntityMoved(payload as EntityMovedPayload)) + bus.on(EVENT.ENTITY_MOVED, payload => this.#onEntityMoved(payload as EntityMovedPayload)), + // P3.M3.3: a Decker linking a JackInPoint opens the cyber layer. + bus.on(EVENT.JACK_IN, payload => this.jackIn((payload as { point: JackInPoint }).point)) ); + // Restored mid-jack-in: re-wire the cyber-layer listeners on the same seam. + if (this.cyberspace?.phase === 'active') { + this.#wireCyberLayerListeners(this.cyberspace.layer); + } } // ------------------------------------------------------------------ @@ -924,17 +1041,71 @@ export class Run { this._busUnsubs = []; } - #onTurnEnded(): void { + #onTurnEnded(payload: TurnEndedPayload): void { if (this.state !== RUN_STATE.COMBAT) return; if (!this.queue) { throw new Error('Run.#onTurnEnded: COMBAT state without a TurnQueue'); } + if (!payload || typeof payload.next !== 'string') { + throw new Error('Run.#onTurnEnded: TURN_ENDED payload missing the incoming faction'); + } this.telemetry.turn = this.queue.turnNumber; + // P3.M3.3: both worlds tick on the single meat queue — refresh the + // incoming faction's AP on the cyber grid (and its alarm on round + // advance) before the autosave below captures the post-state. + if (this.cyberspace?.phase === 'active') { + this.cyberspace.layer.onTurnEnded(payload.next); + } this.#refreshObjectiveTimerState(); if (!this.onPersist) return; this.onPersist(this.snapshot()); } + /** + * P3.M3.3: cyber-bus listeners, wired on jack-in and re-wired through + * `_reattachCombatListeners` after a mid-jack-in restore. Unsubs join + * `_busUnsubs`, so `enterResult`/re-wiring clears them with the meat set. + */ + #wireCyberLayerListeners(layer: CyberspaceLayer): void { + this._busUnsubs.push( + layer.bus.on(EVENT.ENTITY_DAMAGED, payload => + this.#onCyberEntityDamaged(payload as EntityDamagedPayload) + ), + layer.bus.on(EVENT.JACK_OUT, () => this.jackOut()) + ); + } + + /** + * The cyber twin of {@link #onEntityDamaged}. Avatar death is real death + * (scope decision #3): black ICE burning the last RAM routes through the + * existing DEATH path, and `Campaign.onJobEnd` flatlines the Decker. + */ + #onCyberEntityDamaged({ attacker, target, damage, killed, source }: EntityDamagedPayload): void { + if (this.state !== RUN_STATE.COMBAT) return; + if (this.cyberspace?.phase !== 'active') return; + const layer = this.cyberspace.layer; + if (damage <= 0 && !killed) return; + if (target === layer.avatar) { + this.telemetry.lastDamageSource = source ?? null; + this.telemetry.lastAttacker = attacker?.id ?? null; + this.telemetry.hpAtDamage = layer.avatar.hp; + if (killed) { + this.telemetry.hpAtDeath = 0; + this.telemetry.cause = `${attacker?.id ?? 'unknown'}::${source ?? 'unknown'}(${damage})`; + this.enterResult({ outcome: OUTCOME.DEATH }); + } + return; + } + if (attacker === layer.avatar && killed) { + this.telemetry.kills = (this.telemetry.kills ?? 0) + 1; + } + // Unbind dead ICE patrols immediately (P3.M3.5 spawns them) — mirror of + // the meat-side PatrolHostile unbind. + if (killed && target instanceof PatrolHostile) { + target.unbind(); + } + } + #onEntityDamaged({ attacker, target, damage, killed, source }: EntityDamagedPayload): void { if (this.state !== RUN_STATE.COMBAT) return; if (!this.player) { @@ -1695,7 +1866,17 @@ const SNAPSHOT_EXTRACTORS: Partial Enti { merc: e => crewSnapshotExtra(e as Crew) as unknown as EntitySnapshotExtra, razor: e => crewSnapshotExtra(e as Crew) as unknown as EntitySnapshotExtra, - decker: e => crewSnapshotExtra(e as Crew) as unknown as EntitySnapshotExtra, + decker: e => { + const d = e as Decker; + return { + ...crewSnapshotExtra(d), + // P3.M3.3: named cyber stats ride the run-entity path too, so a + // mid-job save can't lose a stat upgrade. + ram: d.ram, + intrusionStrength: d.intrusionStrength, + iceResistance: d.iceResistance, + } satisfies DeckerSnapshot as unknown as EntitySnapshotExtra; + }, tech: e => ({ ...crewSnapshotExtra(e as Crew), @@ -1790,14 +1971,44 @@ const SNAPSHOT_EXTRACTORS: Partial Enti const p = e as JackInPoint; return { label: p.label, linked: p.linked } satisfies JackInPointSnapshot; }, + 'cyber-avatar': e => { + const a = e as CyberAvatar; + return { + intrusionStrength: a.intrusionStrength, + callsign: a.callsign, + } satisfies CyberAvatarSnapshot; + }, + 'entry-port': e => { + return { label: (e as EntryPort).label } satisfies EntryPortSnapshot; + }, }; /** * P3.M3: serialize the Cyberspace state machine. Dormant carries no payload; - * P3.M3.3 adds the active-layer grid/entities and the resolved latch. + * active serializes the live layer (grid + entities + alarm + fog memory) + * with the same entity codec as the meat world; resolved keeps only its + * objective latch. */ function snapshotCyberspace(state: CyberspaceState): RunCyberspaceSnapshot { if (state.phase === 'dormant') return { phase: 'dormant' }; + if (state.phase === 'active') { + const layer = state.layer; + return { + phase: 'active', + grid: { + w: layer.world.grid.width, + h: layer.world.grid.height, + tiles: Array.from(layer.world.grid.tiles), + }, + entities: Array.from(layer.world.entities.values()).map(snapshotEntity), + entryTile: { ...layer.entryTile }, + alarm: layer.world.snapshotAlarm(), + mapMemory: { seen: layer.mapSeenKeys() }, + }; + } + if (state.phase === 'resolved') { + return { phase: 'resolved', objectiveComplete: state.objectiveComplete }; + } throw new Error(`Run.snapshot: unknown cyberspace phase "${(state as { phase: string }).phase}"`); } @@ -1856,6 +2067,8 @@ function archetypeOf(entity: Entity): EntityArchetypeId { if (entity instanceof EscortNpc) return 'escort-npc'; if (entity instanceof KeyCard) return 'keycard'; if (entity instanceof JackInPoint) return 'jack-in-point'; + if (entity instanceof CyberAvatar) return 'cyber-avatar'; + if (entity instanceof EntryPort) return 'entry-port'; if (entity instanceof BreachingCharge) return 'breaching-charge'; if (entity instanceof Entity) return 'entity'; throw new Error(`archetypeOf: cannot classify entity ${(entity as Entity | undefined)?.id}`); diff --git a/src/game/archetypes/Decker.ts b/src/game/archetypes/Decker.ts index f7dcc53..80dde4c 100644 --- a/src/game/archetypes/Decker.ts +++ b/src/game/archetypes/Decker.ts @@ -1,6 +1,11 @@ import { Crew } from '../Crew.js'; import { canOverride, overrideDrone } from '../droneOverride.js'; -import type { CrewInit } from '../Crew.js'; +import { + DECKER_BASE_ICE_RESISTANCE, + DECKER_BASE_INTRUSION, + DECKER_BASE_RAM, +} from '../constants.js'; +import type { CrewInit, CrewSnapshot } from '../Crew.js'; import type { Entity } from '../Entity.js'; import type { World } from '../World.js'; import type { Rng } from '../../rng.js'; @@ -33,9 +38,27 @@ export const CALLSIGNS = Object.freeze([ * Phase-3 perk: **Override**. Reaches across a clean LOS lane to hijack a corp * drone's allegiance for a few turns (`droneOverride.ts`). It is a *targeted* * perk — the intent layer resolves a drone along the aim ray, then calls - * `overrideDrone`. Cyberspace attributes (RAM, intrusion strength, ICE - * resistance) are deferred to P3.M3, when the Cyberspace grid consumes them. + * `overrideDrone`. + * + * Cyberspace attributes (P3.M3.3) are named stats with real effects: `ram` + * is the avatar HP pool, `intrusionStrength` the slice progress per data-node + * interact, `iceResistance` the avatar's `damageReduction`. They persist + * through both crew paths (campaign crew snapshot + run-entity extra). */ + +export interface DeckerInit extends CrewInit { + ram?: number; + intrusionStrength?: number; + iceResistance?: number; +} + +/** P2.7.M6.2-style snapshot `extra` for the Decker — crew slice + cyber stats. */ +export type DeckerSnapshot = CrewSnapshot & { + ram: number; + intrusionStrength: number; + iceResistance: number; +}; + export class Decker extends Crew { override archetype = 'Decker'; @@ -45,12 +68,40 @@ export class Decker extends Crew { */ readonly canJackIn = true; + /** Avatar HP pool on the cyber grid (P3.M3.3). */ + ram: number; + /** Slice progress per data-node interact (P3.M3.4 consumes it). */ + intrusionStrength: number; + /** Avatar `damageReduction` against ICE (P3.M3.3). */ + iceResistance: number; + override get baseHitChance(): number { return 0.7; } - constructor(props: CrewInit) { + constructor({ + ram = DECKER_BASE_RAM, + intrusionStrength = DECKER_BASE_INTRUSION, + iceResistance = DECKER_BASE_ICE_RESISTANCE, + ...props + }: DeckerInit) { super({ ...props, glyph: '@' }); + if (!Number.isInteger(ram) || ram <= 0) { + throw new RangeError(`Decker ram must be a positive integer, got ${ram}`); + } + if (!Number.isInteger(intrusionStrength) || intrusionStrength <= 0) { + throw new RangeError( + `Decker intrusionStrength must be a positive integer, got ${intrusionStrength}` + ); + } + if (!Number.isInteger(iceResistance) || iceResistance < 0) { + throw new RangeError( + `Decker iceResistance must be a non-negative integer, got ${iceResistance}` + ); + } + this.ram = ram; + this.intrusionStrength = intrusionStrength; + this.iceResistance = iceResistance; } /** diff --git a/src/game/constants.ts b/src/game/constants.ts index 84ce1af..8d628ce 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -46,6 +46,8 @@ export const ESCORT_NPC_GLYPH = 'A'; export const KEYCARD_GLYPH = 'κ'; /** P3.M3.2: the Meatspace door into Cyberspace. */ export const JACK_IN_GLYPH = 'Ω'; +/** P3.M3.3: the Cyberspace door back into Meatspace. */ +export const ENTRY_PORT_GLYPH = '▼'; /** Numeric tile id — one of the `TILE` values. */ export type TileId = (typeof TILE)[keyof typeof TILE]; @@ -97,6 +99,30 @@ export const OVERRIDE_RANGE = 5; export const OVERRIDE_DURATION = 3; export const OVERRIDE_SUCCESS_CHANCE = 0.6; +/** + * Decker cyber stats (P3.M3.3). Named stats with real effects from day one + * (scope decision #4): + * - `DECKER_BASE_RAM` — the avatar's HP pool on the cyber grid. ICE damage + * burns RAM; zero RAM = flatline (avatar death is real death). + * - `DECKER_BASE_INTRUSION` — slice progress added per data-node interact + * (P3.M3.4 consumes it). + * - `DECKER_BASE_ICE_RESISTANCE` — the avatar's `damageReduction`; the + * existing min-1 mitigation in `Combat.ts` applies unchanged. + * Per-Decker overrides validate in the `Decker` ctor (crash on bad data). + */ +export const DECKER_BASE_RAM = 8; +export const DECKER_BASE_INTRUSION = 2; +export const DECKER_BASE_ICE_RESISTANCE = 1; + +/** + * Cyberspace avatar tuning (P3.M3.3). The avatar keeps the player AP budget + * (4) and fights at the Merc's hit chance — the grid is the Decker's home + * turf. `Combat.resolveRanged` capability-sniffs `baseHitChance`, so the + * non-Crew avatar plugs into combat with zero combat-code changes. + */ +export const CYBER_AVATAR_MAX_AP = 4; +export const CYBER_AVATAR_HIT_CHANCE = 0.8; + /** * Tech turret parameters. The turret is a placed grid entity (peer of * `Entity`, not an archetype) deployed by Tech at `AP_COST.DEPLOY`. Tunables: diff --git a/src/game/cyber/CyberAvatar.ts b/src/game/cyber/CyberAvatar.ts new file mode 100644 index 0000000..c4b761d --- /dev/null +++ b/src/game/cyber/CyberAvatar.ts @@ -0,0 +1,103 @@ +/** + * P3.M3.3 — the Decker's avatar on the Cyberspace grid. + * + * A plain `Entity` (NOT a `Crew` — no inventory, gear, or perks): the cyber + * grid has its own verbs. Pools derive from the Decker's named cyber stats: + * + * - `maxHp = decker.ram` — ICE damage burns RAM; zero RAM = flatline + * (avatar death routes through the existing DEATH path; scope decision #3). + * - `damageReduction = decker.iceResistance` — the existing min-1 + * mitigation in `Combat.ts` applies unchanged. + * - `intrusionStrength` — slice progress per data-node interact (P3.M3.4). + * + * `baseHitChance` rides the capability sniff in `Combat.resolveRanged` + * (`'baseHitChance' in attacker`), so the avatar fights with zero combat-code + * changes. `isCyberAvatar` is the capability `EntryPort` sniffs — `Decker` + * also carries `intrusionStrength`, so a flag (not the stat) marks the avatar. + */ +import { Entity } from '../Entity.js'; +import { CYBER_AVATAR_HIT_CHANCE, CYBER_AVATAR_MAX_AP, FACTION } from '../constants.js'; + +export type CyberAvatarInit = { + id: string; + x: number; + y: number; + /** Avatar HP pool — the owning Decker's RAM. */ + ram: number; + intrusionStrength: number; + /** Avatar `damageReduction` — the owning Decker's ICE resistance. */ + iceResistance: number; + callsign?: string | null; + maxAp?: number; +}; + +/** + * P2.7.M6.2: avatar snapshot `extra`. Current RAM rides the base entity + * record (`hp`/`maxHp`/`damageReduction`); only the non-Entity fields live here. + */ +export type CyberAvatarSnapshot = { + intrusionStrength: number; + callsign: string | null; +}; + +export class CyberAvatar extends Entity { + /** Capability sniffed by `EntryPort.interact` — only the avatar routes out. */ + readonly isCyberAvatar = true; + + intrusionStrength: number; + callsign: string | null; + + get baseHitChance(): number { + return CYBER_AVATAR_HIT_CHANCE; + } + + /** Alias for the HP pool in cyber terms. */ + get ram(): number { + return this.maxHp; + } + + /** Alias for `damageReduction` in cyber terms. */ + get iceResistance(): number { + return this.damageReduction; + } + + constructor({ + id, + x, + y, + ram, + intrusionStrength, + iceResistance, + callsign = null, + maxAp = CYBER_AVATAR_MAX_AP, + }: CyberAvatarInit) { + if (!Number.isInteger(ram) || ram <= 0) { + throw new RangeError(`CyberAvatar ram must be a positive integer, got ${ram}`); + } + if (!Number.isInteger(intrusionStrength) || intrusionStrength <= 0) { + throw new RangeError( + `CyberAvatar intrusionStrength must be a positive integer, got ${intrusionStrength}` + ); + } + if (!Number.isInteger(iceResistance) || iceResistance < 0) { + throw new RangeError( + `CyberAvatar iceResistance must be a non-negative integer, got ${iceResistance}` + ); + } + if (callsign !== null && (typeof callsign !== 'string' || callsign.length === 0)) { + throw new TypeError(`CyberAvatar callsign must be a non-empty string or null`); + } + super({ + id, + x, + y, + faction: FACTION.PLAYER, + glyph: '@', + maxAp, + maxHp: ram, + damageReduction: iceResistance, + }); + this.intrusionStrength = intrusionStrength; + this.callsign = callsign; + } +} diff --git a/src/game/cyber/CyberspaceLayer.ts b/src/game/cyber/CyberspaceLayer.ts new file mode 100644 index 0000000..504a229 --- /dev/null +++ b/src/game/cyber/CyberspaceLayer.ts @@ -0,0 +1,194 @@ +/** + * P3.M3.3 — CyberspaceLayer: the digital tactical layer owned by `Run`. + * + * Owns its own `EventBus`, `World` (own `Grid`), `CyberAvatar`, `EntryPort`, + * and map memory. Deliberately NOT a nested `Run` — no contract, telemetry, + * or extraction machinery; the owning `Run` remains the single state machine + * and bridges the two layers: + * + * - **Turn cadence:** there is one `TurnQueue` (the meat one). `Run`'s + * `TURN_ENDED` listener calls {@link onTurnEnded} with the incoming + * faction, refreshing that faction's AP in the cyber world and ticking + * the cyber alarm on round advance — both worlds tick, Meatspace keeps + * moving while the Decker is jacked in. + * - **Determinism:** `build` forks `new Rng(contractSeed).fork('cyberspace')`, + * so the layout is a function of the *contract*, independent of the turn + * the player jacks in — and trivially reproducible on restore. + * - **Persistence:** serialization lives in `Run.snapshot` (symmetric with + * the meat world); `persistence.restoreCyberspace` rebuilds the layer via + * the plain constructor. `nodeTiles`/`patrolRings` are build-time spawn + * anchors only — restored layers carry their spawned entities instead. + */ +import { EventBus } from '../events.js'; +import { World } from '../World.js'; +import { Rng } from '../../rng.js'; +import { buildCyberMap } from './cyberMapBuild.js'; +import { CyberAvatar } from './CyberAvatar.js'; +import { EntryPort } from './EntryPort.js'; +import { PatrolHostile } from '../ai/PatrolHostile.js'; +import { FACTION, type ContractDifficulty, type FactionId } from '../constants.js'; +import { coordKey } from '../mapConnectivity.js'; +import type { Decker } from '../archetypes/Decker.js'; +import type { GridPoint } from '../../types.js'; + +export type CyberspaceLayerInit = { + bus: EventBus; + world: World; + avatar: CyberAvatar; + port: EntryPort; + entryTile: GridPoint; + /** Build-time data-node anchors (P3.M3.4). Empty on a restored layer. */ + nodeTiles?: GridPoint[]; + /** Build-time Probe patrol rings (P3.M3.5). Empty on a restored layer. */ + patrolRings?: GridPoint[][]; +}; + +export type CyberspaceLayerBuildOptions = { + contractSeed: number; + difficulty: ContractDifficulty; + decker: Decker; +}; + +export class CyberspaceLayer { + readonly bus: EventBus; + readonly world: World; + readonly avatar: CyberAvatar; + readonly port: EntryPort; + readonly entryTile: GridPoint; + readonly nodeTiles: GridPoint[]; + readonly patrolRings: GridPoint[][]; + /** Cyber-grid fog memory — the twin of `Run.mapSeen` for the digital layer. */ + mapSeen: Set; + + constructor({ + bus, + world, + avatar, + port, + entryTile, + nodeTiles, + patrolRings, + }: CyberspaceLayerInit) { + if (!(world instanceof World)) { + throw new TypeError('CyberspaceLayer requires a World'); + } + if (!(avatar instanceof CyberAvatar)) { + throw new TypeError('CyberspaceLayer requires a CyberAvatar'); + } + if (!(port instanceof EntryPort)) { + throw new TypeError('CyberspaceLayer requires an EntryPort'); + } + if ( + !entryTile || + !Number.isInteger(entryTile.x) || + !Number.isInteger(entryTile.y) || + !world.grid.inBounds(entryTile.x, entryTile.y) + ) { + throw new RangeError('CyberspaceLayer requires an in-bounds integer entryTile'); + } + this.bus = bus; + this.world = world; + this.avatar = avatar; + this.port = port; + this.entryTile = { ...entryTile }; + this.nodeTiles = (nodeTiles ?? []).map(p => ({ ...p })); + this.patrolRings = (patrolRings ?? []).map(ring => ring.map(p => ({ ...p }))); + this.mapSeen = new Set(); + } + + /** + * Build a fresh layer for `decker` jacking into a contract. Layout is a + * deterministic function of the contract seed (see module docstring). + */ + static build({ contractSeed, difficulty, decker }: CyberspaceLayerBuildOptions): CyberspaceLayer { + if (!Number.isFinite(contractSeed)) { + throw new TypeError(`CyberspaceLayer.build requires a finite contractSeed`); + } + const rng = new Rng(contractSeed).fork('cyberspace'); + const map = buildCyberMap({ rng, difficulty }); + const bus = new EventBus(); + const world = new World(map.grid, { events: bus }); + const avatar = new CyberAvatar({ + id: 'cyber-avatar-0', + x: map.entryTile.x, + y: map.entryTile.y, + ram: decker.ram, + intrusionStrength: decker.intrusionStrength, + iceResistance: decker.iceResistance, + callsign: decker.callsign, + }); + const port = new EntryPort({ id: 'entry-port-0', x: map.portTile.x, y: map.portTile.y }); + world.addEntity(avatar); + world.addEntity(port); + return new CyberspaceLayer({ + bus, + world, + avatar, + port, + entryTile: map.entryTile, + nodeTiles: map.nodeTiles, + patrolRings: map.patrolRings, + }); + } + + /** + * Mirror of `TurnQueue.endTurn`'s per-world work, driven by the meat + * queue's `TURN_ENDED` payload: refresh the incoming faction's AP in the + * cyber world, and tick the cyber alarm on round advance (the incoming + * faction wrapping back to PLAYER). + */ + onTurnEnded(next: FactionId): void { + for (const entity of this.world.entities.values()) { + if (entity.alive && entity.faction === next) { + entity.refreshAp(); + } + } + if (next === FACTION.PLAYER) { + this.world.tickAlarm(); + } + } + + /** Record seen cyber-grid coordinate keys. Out-of-bounds keys are corrupt and throw. */ + recordSeen(keys: Iterable): void { + for (const key of keys) { + const point = parseCoordKey(key); + if (!this.world.grid.inBounds(point.x, point.y)) { + throw new RangeError(`CyberspaceLayer.recordSeen: key "${key}" is out of bounds`); + } + this.mapSeen.add(coordKey(point.x, point.y)); + } + } + + /** Sorted (row-major) seen keys for byte-stable snapshots. */ + mapSeenKeys(): string[] { + return [...this.mapSeen].sort((a, b) => { + const pa = parseCoordKey(a); + const pb = parseCoordKey(b); + return pa.y === pb.y ? pa.x - pb.x : pa.y - pb.y; + }); + } + + /** + * Unbind ICE bus subscriptions on jack-out/death so dead-layer listeners + * can never fire again. Probe ICE lands in P3.M3.5; the hook is wired now + * so `Run.jackOut` has one teardown seam. + */ + teardown(): void { + for (const entity of this.world.entities.values()) { + if (entity instanceof PatrolHostile) { + entity.unbind(); + } + } + } +} + +function parseCoordKey(key: string): GridPoint { + if (typeof key !== 'string') { + throw new TypeError('CyberspaceLayer: coordinate key must be a string'); + } + const match = /^(-?\d+),(-?\d+)$/.exec(key); + if (!match) { + throw new TypeError(`CyberspaceLayer: malformed coordinate key "${key}"`); + } + return { x: Number(match[1]), y: Number(match[2]) }; +} diff --git a/src/game/cyber/EntryPort.ts b/src/game/cyber/EntryPort.ts new file mode 100644 index 0000000..ee9cb91 --- /dev/null +++ b/src/game/cyber/EntryPort.ts @@ -0,0 +1,53 @@ +/** + * P3.M3.3 — Exit port: the Cyberspace door back into Meatspace. + * + * Anchored at the avatar's entry point on the cyber grid. The avatar (and + * only the avatar — capability sniff on `isCyberAvatar`) interacts to route + * out: spends interact AP and emits `EVENT.JACK_OUT` on the *cyber* bus. + * `Run.jackOut()` listens, latches the objective outcome, and resolves the + * layer (voluntary jack-out pulled forward from P3.M4.6; scope decision #2). + * + * Unlike the meat-side `JackInPoint`, the port does not latch on use — the + * resolve latch lives on `Run.cyberspace` (the layer is torn down with it). + */ +import { + Interactable, + type InteractableInit, + type InteractResult, +} from '../entities/Interactable.js'; +import { AP_COST, ENTRY_PORT_GLYPH } from '../constants.js'; +import { EVENT } from '../events.js'; +import type { Entity } from '../Entity.js'; +import type { World } from '../World.js'; + +export interface EntryPortInit extends Omit { + label?: string; +} + +/** P2.7.M6.2: exit-port snapshot `extra`. */ +export type EntryPortSnapshot = { + label: string; +}; + +export class EntryPort extends Interactable { + constructor({ label = 'Exit port', ...props }: EntryPortInit) { + super({ ...props, glyph: ENTRY_PORT_GLYPH, label }); + } + + override interact(world: World, actor: Entity): InteractResult { + const check = this.canInteract(actor); + if (!check.ok) { + return { ok: false, reason: check.reason, message: `${this.label}: ${check.reason}.` }; + } + if ((actor as { isCyberAvatar?: boolean }).isCyberAvatar !== true) { + return { + ok: false, + reason: 'not-an-avatar', + message: `${this.label}: only the avatar can route out.`, + }; + } + actor.spendAp(AP_COST.INTERACT); + world.events?.emit(EVENT.JACK_OUT, { port: this, actor }); + return { ok: true, message: `${this.label}: connection dropped — jacking out.` }; + } +} diff --git a/src/game/cyber/cyberMapBuild.ts b/src/game/cyber/cyberMapBuild.ts new file mode 100644 index 0000000..541e214 --- /dev/null +++ b/src/game/cyber/cyberMapBuild.ts @@ -0,0 +1,172 @@ +/** + * P3.M3.3 — Cyberspace map generation. + * + * Dedicated generator (deliberately NOT the BSP/prefab `buildMap`): a lattice + * of square *nodes* (rooms) linked by 1-tile *data lines* (corridors) through + * solid firewall (`TILE.WALL` fill). Only FLOOR/WALL tile ids are used, so + * Grid passability / LOS, A*, VisionField, and World all work unchanged — + * the distinct look comes from the renderer's tileset axis (P3.M3.6), not + * from new tile ids. + * + * Topology: a random spanning walk over a 4×2 slot lattice picks `nodeCount` + * slots (scaling with contract difficulty); each slot gets a 3–5 tile square + * node and each walk edge a carved L-corridor. The entry node hosts the + * avatar spawn (`entryTile`) and the exit port anchor (`portTile`, adjacent). + * Non-entry node centers are returned as `nodeTiles` (data-node anchors, + * P3.M3.4) with `patrolRings` (Probe ICE waypoints, P3.M3.5) around them. + * + * Deterministic per `rng` — callers seed with the *contract* seed fork so the + * layout is independent of the jack-in turn. Connectivity is validated from + * `entryTile`; an unreachable node is a generator bug and throws. + */ +import { Grid } from '../Grid.js'; +import { World } from '../World.js'; +import { CONTRACT_DIFFICULTY, TILE, type ContractDifficulty } from '../constants.js'; +import { coordKey, explorationReachableKeys } from '../mapConnectivity.js'; +import type { GridPoint } from '../../types.js'; +import type { Rng } from '../../rng.js'; + +const LATTICE_COLS = 4; +const LATTICE_ROWS = 2; +/** Slot pitch in tiles; each slot's interior is `CELL - 1` tiles square. */ +const CELL = 7; + +export const CYBER_MAP_WIDTH = LATTICE_COLS * CELL + 1; // 29 +export const CYBER_MAP_HEIGHT = LATTICE_ROWS * CELL + 1; // 15 + +/** Node count per contract difficulty — the cyber-side threat/objective budget. */ +const NODE_COUNT: Record = Object.freeze({ + [CONTRACT_DIFFICULTY.STANDARD]: 5, + [CONTRACT_DIFFICULTY.ELEVATED]: 6, + [CONTRACT_DIFFICULTY.CRITICAL]: 8, +}); + +type Slot = { c: number; r: number }; + +const slotKey = (slot: Slot): string => `${slot.c},${slot.r}`; + +export type CyberMapBuildResult = { + grid: Grid; + /** Avatar spawn — center of the entry node. */ + entryTile: GridPoint; + /** Exit-port anchor, Chebyshev-adjacent to `entryTile` inside the entry node. */ + portTile: GridPoint; + /** Non-entry node centers — data-node anchors (P3.M3.4). */ + nodeTiles: GridPoint[]; + /** Per-`nodeTiles` waypoint ring just inside each node — Probe patrols (P3.M3.5). */ + patrolRings: GridPoint[][]; +}; + +export function buildCyberMap({ + rng, + difficulty, +}: { + rng: Rng; + difficulty: ContractDifficulty; +}): CyberMapBuildResult { + const nodeCount = NODE_COUNT[difficulty]; + if (!nodeCount) { + throw new Error(`buildCyberMap: unknown contract difficulty "${difficulty}"`); + } + + // 1. Spanning random walk over the slot lattice. Always connected by + // construction (every new slot attaches to a visited one). + const startSlot: Slot = { c: 0, r: rng.intRange(0, LATTICE_ROWS) }; + const visited: Slot[] = [startSlot]; + const visitedKeys = new Set([slotKey(startSlot)]); + const edges: Array<[Slot, Slot]> = []; + while (visited.length < nodeCount) { + const frontier: Array<[Slot, Slot]> = []; + for (const slot of visited) { + for (const [dc, dr] of [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], + ] as const) { + const next: Slot = { c: slot.c + dc, r: slot.r + dr }; + if (next.c < 0 || next.r < 0 || next.c >= LATTICE_COLS || next.r >= LATTICE_ROWS) continue; + if (visitedKeys.has(slotKey(next))) continue; + frontier.push([slot, next]); + } + } + if (frontier.length === 0) { + // Unreachable while nodeCount ≤ lattice slots, but fail loud if the + // lattice dimensions and NODE_COUNT ever drift apart. + throw new Error(`buildCyberMap: lattice walk exhausted at ${visited.length}/${nodeCount}`); + } + const [from, to] = rng.pick(frontier); + visited.push(to); + visitedKeys.add(slotKey(to)); + edges.push([from, to]); + } + + // 2. Carve square nodes into the firewall fill. + const grid = new Grid(CYBER_MAP_WIDTH, CYBER_MAP_HEIGHT, TILE.WALL); + const centers = new Map(); + const rings = new Map(); + for (const slot of visited) { + const size = rng.intRange(3, 6); // 3–5 tiles square + const ox = 1 + slot.c * CELL + Math.floor((CELL - 1 - size) / 2); + const oy = 1 + slot.r * CELL + Math.floor((CELL - 1 - size) / 2); + for (let y = oy; y < oy + size; y++) { + for (let x = ox; x < ox + size; x++) { + grid.setTile(x, y, TILE.FLOOR); + } + } + centers.set(slotKey(slot), { x: ox + Math.floor(size / 2), y: oy + Math.floor(size / 2) }); + // Clockwise perimeter ring just inside the node — Probe patrol waypoints. + const ring: GridPoint[] = []; + for (let x = ox; x < ox + size; x++) ring.push({ x, y: oy }); + for (let y = oy + 1; y < oy + size; y++) ring.push({ x: ox + size - 1, y }); + for (let x = ox + size - 2; x >= ox; x--) ring.push({ x, y: oy + size - 1 }); + for (let y = oy + size - 2; y >= oy + 1; y--) ring.push({ x: ox, y }); + rings.set(slotKey(slot), ring); + } + + // 3. Carve 1-tile L-corridors (data lines) along the walk edges. Adjacent + // slots keep the path inside their two cells, so corridors never clip a + // third node. + for (const [a, b] of edges) { + const ca = centers.get(slotKey(a))!; + const cb = centers.get(slotKey(b))!; + let x = ca.x; + let y = ca.y; + while (x !== cb.x) { + x += Math.sign(cb.x - x); + grid.setTile(x, y, TILE.FLOOR); + } + while (y !== cb.y) { + y += Math.sign(cb.y - y); + grid.setTile(x, y, TILE.FLOOR); + } + } + + // 4. Entry node hosts the avatar spawn and the exit-port anchor. Nodes are + // ≥ 3 tiles square, so center+1 is always inside the node. + const entryTile = centers.get(slotKey(startSlot))!; + const portTile: GridPoint = { x: entryTile.x + 1, y: entryTile.y }; + + // 5. Connectivity gate — crash on an unreachable node rather than ship a + // map the objective can never complete on. + const reachable = explorationReachableKeys(new World(grid), entryTile); + if (!reachable.has(coordKey(portTile.x, portTile.y))) { + throw new Error(`buildCyberMap: port tile (${portTile.x}, ${portTile.y}) unreachable`); + } + const nodeTiles: GridPoint[] = []; + const patrolRings: GridPoint[][] = []; + for (const slot of visited) { + const center = centers.get(slotKey(slot))!; + if (!reachable.has(coordKey(center.x, center.y))) { + throw new Error( + `buildCyberMap: node at (${center.x}, ${center.y}) unreachable from entry ` + + `(${entryTile.x}, ${entryTile.y})` + ); + } + if (slot === startSlot) continue; + nodeTiles.push(center); + patrolRings.push(rings.get(slotKey(slot))!); + } + + return { grid, entryTile, portTile, nodeTiles, patrolRings }; +} diff --git a/src/game/persistence.ts b/src/game/persistence.ts index 3434b7b..58fd76a 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -72,8 +72,14 @@ import { ConsumablePickup } from './entities/ConsumablePickup.js'; import { EscortNpc } from './entities/EscortNpc.js'; import { KeyCard } from './entities/KeyCard.js'; import { JackInPoint } from './entities/JackInPoint.js'; +import { CyberspaceLayer } from './cyber/CyberspaceLayer.js'; +import { CyberAvatar } from './cyber/CyberAvatar.js'; +import { EntryPort } from './cyber/EntryPort.js'; import { BreachingCharge } from './entities/BreachingCharge.js'; import type { BreachingChargeInit } from './entities/BreachingCharge.js'; +import type { CyberAvatarInit, CyberAvatarSnapshot } from './cyber/CyberAvatar.js'; +import type { EntryPortInit, EntryPortSnapshot } from './cyber/EntryPort.js'; +import type { DeckerInit, DeckerSnapshot } from './archetypes/Decker.js'; import { Run, RUN_STATE, PATROL_ARCHETYPE_IDS } from './Run.js'; import { Campaign, CAMPAIGN_STATE, normalizeCampaignArc } from './Campaign.js'; import { @@ -170,7 +176,10 @@ type RestoreEntityProps = Partial< EscortNpcInit & KeyCardInit & BreachingChargeInit & - JackInPointInit + JackInPointInit & + CyberAvatarInit & + EntryPortInit & + DeckerInit > & { id: string; x: number; @@ -188,7 +197,7 @@ const ARCHETYPE_FACTORY: Record new Merc(props as CrewInit), razor: (props: RestoreEntityProps) => new Razor(props as CrewInit), tech: (props: RestoreEntityProps) => new Tech(props as CrewInit), - decker: (props: RestoreEntityProps) => new Decker(props as CrewInit), + decker: (props: RestoreEntityProps) => new Decker(props as DeckerInit), turret: (props: RestoreEntityProps) => new Turret(props as TurretInit), drone: (props: RestoreEntityProps) => new Skirmisher(props as SkirmisherProps), guard: (props: RestoreEntityProps) => new Guard(props as GuardProps), @@ -214,6 +223,8 @@ const ARCHETYPE_FACTORY: Record new EscortNpc(props as EscortNpcInit), keycard: (props: RestoreEntityProps) => new KeyCard(props as KeyCardInit), 'jack-in-point': (props: RestoreEntityProps) => new JackInPoint(props as JackInPointInit), + 'cyber-avatar': (props: RestoreEntityProps) => new CyberAvatar(props as CyberAvatarInit), + 'entry-port': (props: RestoreEntityProps) => new EntryPort(props as EntryPortInit), 'breaching-charge': (props: RestoreEntityProps) => new BreachingCharge(props as BreachingChargeInit), // Generic fallback so a future `Entity` subclass (NPCs, items) doesn't break @@ -360,6 +371,47 @@ function readJackInPoint(extra: EntitySnapshotExtra, id: string): JackInPointSna }; } +function readCyberAvatar(extra: EntitySnapshotExtra, id: string): CyberAvatarSnapshot { + if (hasNoState(extra)) { + throw new TypeError(`restore: cyber avatar entity ${id} requires avatar state`); + } + const a = extra as Partial; + if (!Number.isInteger(a.intrusionStrength) || (a.intrusionStrength as number) <= 0) { + throw new TypeError(`restore: cyber avatar ${id} intrusionStrength must be a positive integer`); + } + const callsign = a.callsign ?? null; + if (callsign !== null && (typeof callsign !== 'string' || callsign.length === 0)) { + throw new TypeError(`restore: cyber avatar ${id} callsign must be a non-empty string or null`); + } + return { intrusionStrength: a.intrusionStrength as number, callsign }; +} + +/** + * P3.M3.3: Decker named cyber stats from the run-entity `extra`. All three + * absent → pre-P3.M3.3 save, base stats apply (legacy normalization). Any + * present-but-malformed or half-populated set is corrupt and throws. + */ +function readDeckerCyberStats(extra: EntitySnapshotExtra, id: string): Partial { + const d = extra as Partial; + if (d.ram === undefined && d.intrusionStrength === undefined && d.iceResistance === undefined) { + return {}; + } + if (!Number.isInteger(d.ram) || (d.ram as number) <= 0) { + throw new TypeError(`restore: decker ${id} ram must be a positive integer`); + } + if (!Number.isInteger(d.intrusionStrength) || (d.intrusionStrength as number) <= 0) { + throw new TypeError(`restore: decker ${id} intrusionStrength must be a positive integer`); + } + if (!Number.isInteger(d.iceResistance) || (d.iceResistance as number) < 0) { + throw new TypeError(`restore: decker ${id} iceResistance must be a non-negative integer`); + } + return { + ram: d.ram as number, + intrusionStrength: d.intrusionStrength as number, + iceResistance: d.iceResistance as number, + }; +} + function readDoor(extra: EntitySnapshotExtra, id: string): DoorSnapshot { if (hasNoState(extra)) throw new TypeError(`restore: door entity ${id} requires door state`); const d = extra as Partial; @@ -547,6 +599,52 @@ type RestoreEntry = { }; const ENTITY_RESTORE: Partial> = Object.freeze({ + decker: { + // P3.M3.3: named cyber stats ride the crew extra. Absent on legacy saves + // (base stats apply); half-populated or malformed throws. + buildProps(extra, rec) { + return readDeckerCyberStats(extra, rec.id); + }, + apply(entity, _extra, rec) { + if (!(entity instanceof Decker)) { + throw new Error(`restore: decker entity ${rec.id} did not restore as Decker`); + } + }, + }, + 'cyber-avatar': { + buildProps(extra, rec) { + const a = readCyberAvatar(extra, rec.id); + // The HP pool rides the base entity record: maxHp IS the RAM pool, + // damageReduction IS the ICE resistance. + return { + ram: rec.maxHp, + iceResistance: rec.damageReduction ?? 0, + intrusionStrength: a.intrusionStrength, + callsign: a.callsign, + }; + }, + apply(entity, _extra, rec) { + if (!(entity instanceof CyberAvatar)) { + throw new Error(`restore: cyber avatar entity ${rec.id} did not restore as CyberAvatar`); + } + }, + }, + 'entry-port': { + buildProps(extra, rec) { + const p = extra as Partial; + return { + label: requireString( + p.label, + `restore: entry port ${rec.id} label must be a non-empty string` + ), + }; + }, + apply(entity, _extra, rec) { + if (!(entity instanceof EntryPort)) { + throw new Error(`restore: entry port entity ${rec.id} did not restore as EntryPort`); + } + }, + }, tech: { // Re-apply the pre-built turret flag (default `true` from the Tech ctor when // a legacy record omits it — preserve that by only assigning when present). @@ -752,6 +850,12 @@ type CampaignCrewSnapshot = { alive: boolean; inventory: Inventory | null; gear: Gear | null; + /** + * P3.M3.3: Decker named cyber stats — written for deckers only. Absent on + * legacy saves (base stats apply); half-populated/malformed throws; present + * on a non-decker throws. + */ + cyber?: { ram: number; intrusion: number; iceResistance: number }; }; type CampaignActiveRunSnapshot = { @@ -1001,9 +1105,9 @@ export function restore(record: unknown, options: RestoreOptions = {}) { * * Invariant (both directions): a contract with a Cyberspace component * (`contractRequiresCyberspace`) carries a `cyberspace` block, and only such - * contracts do. Any mismatch, unknown phase, or payload smuggled onto the - * `dormant` phase is tier-1 corrupt state and throws. P3.M3.3 extends this - * with the `active` (grid + entities) and `resolved` (latch) phases. + * contracts do. Any mismatch, unknown phase, payload smuggled onto the + * `dormant`/`resolved` phases, or a partial `active` block is tier-1 corrupt + * state and throws. */ function restoreCyberspace( record: RunSnapshot, @@ -1026,17 +1130,139 @@ function restoreCyberspace( } const phase = (block as { phase?: unknown }).phase; if (phase === 'dormant') { - const payloadKeys = Object.keys(block).filter(key => key !== 'phase'); - if (payloadKeys.length > 0) { - throw new Error( - `restore: dormant cyberspace block must carry no payload, got [${payloadKeys.join(', ')}]` - ); - } + assertCyberspaceBlockKeys(block, ['phase'], 'dormant'); return { phase: 'dormant' }; } + if (phase === 'resolved') { + assertCyberspaceBlockKeys(block, ['phase', 'objectiveComplete'], 'resolved'); + const latch = (block as { objectiveComplete?: unknown }).objectiveComplete; + if (typeof latch !== 'boolean') { + throw new TypeError('restore: resolved cyberspace block requires boolean objectiveComplete'); + } + return { phase: 'resolved', objectiveComplete: latch }; + } + if (phase === 'active') { + assertCyberspaceBlockKeys( + block, + ['phase', 'grid', 'entities', 'entryTile', 'alarm', 'mapMemory'], + 'active' + ); + return { + phase: 'active', + layer: restoreCyberspaceLayer( + record, + block as Extract, { phase: 'active' }> + ), + }; + } throw new Error(`restore: unknown cyberspace phase "${String(phase)}"`); } +/** Cross-phase payload smuggling (e.g. a grid on `resolved`) is corrupt — throw. */ +function assertCyberspaceBlockKeys(block: object, allowed: readonly string[], phase: string): void { + const rogue = Object.keys(block).filter(key => !allowed.includes(key)); + if (rogue.length > 0) { + throw new Error( + `restore: ${phase} cyberspace block carries illegal payload [${rogue.join(', ')}]` + ); + } +} + +/** + * P3.M3.3: rebuild the live cyber layer. Every field of the `active` block is + * required; entities are bounds-checked against the *cyber* grid via the same + * `restoreEntity` codec as the meat world. Exactly one avatar and one exit + * port must exist (the avatar must be alive while the run is mid-COMBAT — + * avatar death transitions to RESULT before the snapshot is cut). + */ +function restoreCyberspaceLayer( + record: RunSnapshot, + block: Extract, { phase: 'active' }> +): CyberspaceLayer { + const gridRec = block.grid; + if (!gridRec || !Number.isInteger(gridRec.w) || !Number.isInteger(gridRec.h)) { + throw new TypeError('restore: active cyberspace block requires a grid with integer w/h'); + } + if (!Array.isArray(gridRec.tiles)) { + throw new TypeError('restore: active cyberspace block requires a grid tiles array'); + } + const grid = new Grid(gridRec.w, gridRec.h); + if (gridRec.tiles.length !== grid.tiles.length) { + throw new Error( + `restore: cyberspace grid tile count mismatch — record ${gridRec.tiles.length}, ` + + `expected ${grid.tiles.length}` + ); + } + for (let i = 0; i < gridRec.tiles.length; i++) { + const tile = gridRec.tiles[i]; + // Cyber maps are FLOOR/WALL only by construction (`buildCyberMap`); any + // other tile id is corruption, not an old save. + if (tile !== TILE.FLOOR && tile !== TILE.WALL) { + throw new Error(`restore: cyberspace grid tile ${i} has non-cyber tile id ${tile}`); + } + grid.tiles[i] = tile; + } + + const entryTile = block.entryTile; + if (!entryTile || !Number.isInteger(entryTile.x) || !Number.isInteger(entryTile.y)) { + throw new TypeError('restore: active cyberspace block requires an integer entryTile'); + } + if (!grid.inBounds(entryTile.x, entryTile.y) || !grid.isPassable(entryTile.x, entryTile.y)) { + throw new RangeError( + `restore: cyberspace entryTile (${entryTile.x}, ${entryTile.y}) is not a passable cyber tile` + ); + } + + if (!Array.isArray(block.entities)) { + throw new TypeError('restore: active cyberspace block requires an entities array'); + } + if (!block.alarm) { + throw new Error('restore: active cyberspace block requires alarm state'); + } + if (!block.mapMemory || !Array.isArray(block.mapMemory.seen)) { + throw new TypeError('restore: active cyberspace block requires mapMemory with a seen array'); + } + + const bus = new EventBus(); + const world = new World(grid, { events: bus }); + world.restoreAlarm(block.alarm); + + let avatar: CyberAvatar | null = null; + let port: EntryPort | null = null; + for (const rec of block.entities) { + const entity = restoreEntity(rec, grid); + if (entity instanceof CyberAvatar) { + if (avatar) { + throw new Error('restore: active cyberspace block has multiple cyber-avatar entities'); + } + avatar = entity; + } + if (entity instanceof EntryPort) { + if (port) { + throw new Error('restore: active cyberspace block has multiple entry-port entities'); + } + port = entity; + } + world.addEntity(entity); + if (entity instanceof PatrolHostile) { + entity.bindToBus(bus); + } + } + if (!avatar) { + throw new Error('restore: active cyberspace block has no cyber-avatar'); + } + if (record.state === RUN_STATE.COMBAT && !avatar.alive) { + throw new Error('restore: COMBAT snapshot carries a dead cyber-avatar in an active layer'); + } + if (!port) { + throw new Error('restore: active cyberspace block has no entry-port'); + } + + const layer = new CyberspaceLayer({ bus, world, avatar, port, entryTile }); + layer.recordSeen(block.mapMemory.seen); + return layer; +} + export function restoreCampaign(record: unknown, options: RestoreCampaignOptions = {}): Campaign { validateCampaignRecord(record); const crew = record.crew.map(restoreCrewMember); @@ -1326,6 +1552,50 @@ function snapshotCrewMember(member: Crew): CampaignCrewSnapshot { alive: !!member.alive, inventory: member.inventory, gear: member.gear, + // P3.M3.3: Decker cyber stats persist through the campaign crew path too. + ...(member instanceof Decker + ? { + cyber: { + ram: member.ram, + intrusion: member.intrusionStrength, + iceResistance: member.iceResistance, + }, + } + : {}), + }; +} + +/** + * P3.M3.3: validate + translate the campaign crew `cyber` block into Decker + * ctor props. Absent → `{}` (legacy save, base stats). Present on a + * non-decker, half-populated, or malformed → throw. + */ +function readCampaignCrewCyber(rec: CampaignCrewSnapshot): Partial { + const cyber = rec.cyber; + if (cyber === undefined || cyber === null) return {}; + if (rec.archetype !== 'decker') { + throw new Error(`restoreCampaign: crew "${rec.id}" carries cyber stats but is not a decker`); + } + if (typeof cyber !== 'object' || Array.isArray(cyber)) { + throw new TypeError(`restoreCampaign: crew "${rec.id}" cyber block must be an object`); + } + if (!Number.isInteger(cyber.ram) || cyber.ram <= 0) { + throw new TypeError(`restoreCampaign: crew "${rec.id}" cyber.ram must be a positive integer`); + } + if (!Number.isInteger(cyber.intrusion) || cyber.intrusion <= 0) { + throw new TypeError( + `restoreCampaign: crew "${rec.id}" cyber.intrusion must be a positive integer` + ); + } + if (!Number.isInteger(cyber.iceResistance) || cyber.iceResistance < 0) { + throw new TypeError( + `restoreCampaign: crew "${rec.id}" cyber.iceResistance must be a non-negative integer` + ); + } + return { + ram: cyber.ram, + intrusionStrength: cyber.intrusion, + iceResistance: cyber.iceResistance, }; } @@ -1364,6 +1634,8 @@ function restoreCrewMember(rec: CampaignCrewSnapshot): Crew { gear: rec.gear ?? null, maxHp: rec.maxHp, maxAp: rec.maxAp, + // P3.M3.3: Decker cyber stats (validated; throws on a non-decker record). + ...readCampaignCrewCyber(rec), }); if (!(member instanceof Crew)) { throw new Error(`restoreCampaign: crew archetype "${rec.archetype}" did not restore as Crew`); diff --git a/tests/unit/game/cyber/CyberspaceLayer.test.ts b/tests/unit/game/cyber/CyberspaceLayer.test.ts new file mode 100644 index 0000000..988b728 --- /dev/null +++ b/tests/unit/game/cyber/CyberspaceLayer.test.ts @@ -0,0 +1,172 @@ +/** + * P3.M3.3 — CyberspaceLayer: the digital tactical layer owned by `Run`. + * + * Owns its own EventBus / World / CyberAvatar / EntryPort. The avatar's pools + * come from the Decker's named cyber stats: RAM = HP pool, ICE resistance = + * damageReduction, intrusion strength carried for data-node slicing (S4). + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { CyberspaceLayer } from '../../../../src/game/cyber/CyberspaceLayer.js'; +import { CyberAvatar } from '../../../../src/game/cyber/CyberAvatar.js'; +import { EntryPort } from '../../../../src/game/cyber/EntryPort.js'; +import { Decker } from '../../../../src/game/archetypes/Decker.js'; +import { ALARM_PHASE } from '../../../../src/game/World.js'; +import { EVENT } from '../../../../src/game/events.js'; +import { + AP_COST, + CYBER_AVATAR_HIT_CHANCE, + CYBER_AVATAR_MAX_AP, + DECKER_BASE_ICE_RESISTANCE, + DECKER_BASE_INTRUSION, + DECKER_BASE_RAM, + FACTION, +} from '../../../../src/game/constants.js'; + +const makeDecker = (overrides = {}) => + new Decker({ id: 'crew-decker', x: 0, y: 0, callsign: 'Phreak', ...overrides }); + +const buildLayer = (contractSeed = 12345, decker = makeDecker(), difficulty = 'standard') => + CyberspaceLayer.build({ contractSeed, difficulty, decker }); + +// --- Decker cyber stats ------------------------------------------------------ + +test('Decker ships base cyber stats', () => { + const decker = makeDecker(); + assert.equal(decker.ram, DECKER_BASE_RAM); + assert.equal(decker.intrusionStrength, DECKER_BASE_INTRUSION); + assert.equal(decker.iceResistance, DECKER_BASE_ICE_RESISTANCE); +}); + +test('Decker cyber stat overrides are validated', () => { + assert.throws(() => makeDecker({ ram: 0 }), /ram/); + assert.throws(() => makeDecker({ ram: 2.5 }), /ram/); + assert.throws(() => makeDecker({ intrusionStrength: 0 }), /intrusion/); + assert.throws(() => makeDecker({ intrusionStrength: -1 }), /intrusion/); + assert.throws(() => makeDecker({ iceResistance: -1 }), /resistance/i); + assert.throws(() => makeDecker({ iceResistance: 0.5 }), /resistance/i); + // Zero ICE resistance is legal (no mitigation); zero RAM is not (no pool). + assert.equal(makeDecker({ iceResistance: 0 }).iceResistance, 0); + assert.equal(makeDecker({ ram: 12 }).ram, 12); +}); + +// --- avatar ------------------------------------------------------------------ + +test('build spawns the avatar at the entry tile with stats from the Decker', () => { + const decker = makeDecker({ ram: 10, intrusionStrength: 3, iceResistance: 2 }); + const layer = buildLayer(777, decker); + const avatar = layer.avatar; + assert.ok(avatar instanceof CyberAvatar); + assert.deepEqual({ x: avatar.x, y: avatar.y }, layer.entryTile); + assert.equal(avatar.maxHp, 10); + assert.equal(avatar.hp, 10); + assert.equal(avatar.damageReduction, 2); + assert.equal(avatar.intrusionStrength, 3); + assert.equal(avatar.callsign, 'Phreak'); + assert.equal(avatar.faction, FACTION.PLAYER); + assert.equal(avatar.maxAp, CYBER_AVATAR_MAX_AP); + assert.equal(avatar.baseHitChance, CYBER_AVATAR_HIT_CHANCE); +}); + +test('the cyber world contains exactly the avatar and the exit port', () => { + const layer = buildLayer(); + const entities = Array.from(layer.world.entities.values()); + assert.equal(entities.filter(e => e instanceof CyberAvatar).length, 1); + assert.equal(entities.filter(e => e instanceof EntryPort).length, 1); + assert.equal(entities.length, 2); + const cheb = Math.max( + Math.abs(layer.port.x - layer.avatar.x), + Math.abs(layer.port.y - layer.avatar.y) + ); + assert.equal(cheb, 1, 'exit port starts adjacent to the avatar'); +}); + +test('layer layout is deterministic on the contract seed', () => { + const a = buildLayer(424242); + const b = buildLayer(424242); + assert.deepEqual(Array.from(a.world.grid.tiles), Array.from(b.world.grid.tiles)); + assert.deepEqual({ x: a.avatar.x, y: a.avatar.y }, { x: b.avatar.x, y: b.avatar.y }); + assert.deepEqual({ x: a.port.x, y: a.port.y }, { x: b.port.x, y: b.port.y }); +}); + +test('CyberAvatar validates its pools', () => { + const props = { id: 'cyber-avatar-0', x: 1, y: 1 }; + assert.throws( + () => new CyberAvatar({ ...props, ram: 0, intrusionStrength: 2, iceResistance: 1 }), + /ram/i + ); + assert.throws( + () => new CyberAvatar({ ...props, ram: 8, intrusionStrength: 0, iceResistance: 1 }), + /intrusion/i + ); + assert.throws( + () => new CyberAvatar({ ...props, ram: 8, intrusionStrength: 2, iceResistance: -1 }), + /resistance/i + ); +}); + +// --- turn integration --------------------------------------------------------- + +test('onTurnEnded refreshes only the incoming faction in the cyber world', () => { + const layer = buildLayer(); + layer.avatar.spendAp(2); + layer.onTurnEnded(FACTION.CORP); + assert.equal(layer.avatar.ap, layer.avatar.maxAp - 2, 'corp activation leaves the avatar spent'); + layer.onTurnEnded(FACTION.PLAYER); + assert.equal(layer.avatar.ap, layer.avatar.maxAp, 'player activation refreshes the avatar'); +}); + +test('onTurnEnded ticks the cyber alarm on round advance (player activation)', () => { + const layer = buildLayer(); + layer.world.raiseAlarm({ repPenalty: false }); + assert.equal(layer.world.alarm.phase, ALARM_PHASE.ALERT); + layer.onTurnEnded(FACTION.CORP); + assert.equal(layer.world.alarm.phase, ALARM_PHASE.ALERT, 'corp activation is mid-round'); + layer.onTurnEnded(FACTION.PLAYER); + layer.onTurnEnded(FACTION.PLAYER); + assert.equal(layer.world.alarm.phase, ALARM_PHASE.COOLDOWN, 'alert hold expires over rounds'); +}); + +// --- map memory ---------------------------------------------------------------- + +test('recordSeen validates bounds and round-trips sorted keys', () => { + const layer = buildLayer(); + const { x, y } = layer.entryTile; + layer.recordSeen([`${x},${y}`, `${layer.port.x},${layer.port.y}`]); + assert.equal(layer.mapSeenKeys().length, 2); + assert.throws(() => layer.recordSeen(['999,999']), /bounds/); + assert.throws(() => layer.recordSeen(['not-a-key']), /key/); +}); + +// --- exit port ------------------------------------------------------------------ + +test('the avatar interacting with the exit port emits JACK_OUT once and debits AP', () => { + const layer = buildLayer(); + const payloads: unknown[] = []; + layer.bus.on(EVENT.JACK_OUT, payload => payloads.push(payload)); + const apBefore = layer.avatar.ap; + const result = layer.port.interact(layer.world, layer.avatar); + assert.equal(result.ok, true); + assert.equal(layer.avatar.ap, apBefore - AP_COST.INTERACT); + assert.equal(payloads.length, 1); + const payload = payloads[0] as { port: EntryPort; actor: unknown }; + assert.equal(payload.port, layer.port); + assert.equal(payload.actor, layer.avatar); +}); + +test('a non-avatar cannot route out through the exit port', () => { + const layer = buildLayer(); + // A meat-side Decker standing in the grid is corrupt state; the port still + // refuses anything without the avatar capability — including a Decker. + const decker = makeDecker(); + decker.x = layer.avatar.x; + decker.y = layer.avatar.y; + decker.ap = decker.maxAp = 4; + let events = 0; + layer.bus.on(EVENT.JACK_OUT, () => events++); + const result = layer.port.interact(layer.world, decker); + assert.equal(result.ok, false); + assert.equal((result as { reason: string }).reason, 'not-an-avatar'); + assert.equal(events, 0); +}); diff --git a/tests/unit/game/cyber/cyberMapBuild.test.ts b/tests/unit/game/cyber/cyberMapBuild.test.ts new file mode 100644 index 0000000..0a41026 --- /dev/null +++ b/tests/unit/game/cyber/cyberMapBuild.test.ts @@ -0,0 +1,101 @@ +/** + * P3.M3.3 — Cyberspace map generation. + * + * `buildCyberMap` carves a rooms-as-nodes lattice (data nodes linked by + * 1-tile data lines through firewall) using only FLOOR/WALL tile ids, so + * Grid passability / LOS / A* / VisionField all work unchanged. Deterministic + * per rng seed; every node tile must be reachable from the entry tile. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildCyberMap } from '../../../../src/game/cyber/cyberMapBuild.js'; +import { World } from '../../../../src/game/World.js'; +import { explorationReachableKeys, coordKey } from '../../../../src/game/mapConnectivity.js'; +import { TILE, CONTRACT_DIFFICULTY } from '../../../../src/game/constants.js'; +import { Rng } from '../../../../src/rng.js'; + +const build = (seed = 1, difficulty = CONTRACT_DIFFICULTY.STANDARD) => + buildCyberMap({ rng: new Rng(seed), difficulty }); + +test('equal seeds build identical cyber maps', () => { + const a = build(7); + const b = build(7); + assert.deepEqual(Array.from(a.grid.tiles), Array.from(b.grid.tiles)); + assert.deepEqual(a.entryTile, b.entryTile); + assert.deepEqual(a.portTile, b.portTile); + assert.deepEqual(a.nodeTiles, b.nodeTiles); + assert.deepEqual(a.patrolRings, b.patrolRings); +}); + +test('different seeds diverge', () => { + const base = Array.from(build(1).grid.tiles).join(','); + const variants = [2, 3, 4, 5].map(seed => Array.from(build(seed).grid.tiles).join(',')); + assert.ok( + variants.some(tiles => tiles !== base), + 'expected at least one divergent layout across seeds 2-5' + ); +}); + +test('cyber maps carry only FLOOR and WALL tile ids', () => { + for (const difficulty of Object.values(CONTRACT_DIFFICULTY)) { + const { grid } = build(11, difficulty); + for (const tile of grid.tiles) { + assert.ok( + tile === TILE.FLOOR || tile === TILE.WALL, + `unexpected tile id ${tile} on a ${difficulty} cyber map` + ); + } + } +}); + +test('entry, port, and every node tile are mutually reachable', () => { + for (const difficulty of Object.values(CONTRACT_DIFFICULTY)) { + for (let seed = 1; seed <= 10; seed++) { + const map = build(seed, difficulty); + assert.ok(map.grid.isPassable(map.entryTile.x, map.entryTile.y), 'entry tile passable'); + const reachable = explorationReachableKeys(new World(map.grid), map.entryTile); + assert.ok(reachable.has(coordKey(map.portTile.x, map.portTile.y)), 'port reachable'); + for (const node of map.nodeTiles) { + assert.ok( + reachable.has(coordKey(node.x, node.y)), + `node (${node.x}, ${node.y}) reachable from entry (seed ${seed}, ${difficulty})` + ); + } + } + } +}); + +test('the exit port sits adjacent to the entry tile', () => { + for (let seed = 1; seed <= 5; seed++) { + const map = build(seed); + const cheb = Math.max( + Math.abs(map.portTile.x - map.entryTile.x), + Math.abs(map.portTile.y - map.entryTile.y) + ); + assert.equal(cheb, 1); + assert.ok(map.grid.isPassable(map.portTile.x, map.portTile.y)); + } +}); + +test('node count scales with contract difficulty', () => { + const standard = build(3, CONTRACT_DIFFICULTY.STANDARD).nodeTiles.length; + const critical = build(3, CONTRACT_DIFFICULTY.CRITICAL).nodeTiles.length; + assert.ok(standard >= 2, `standard maps need slice targets, got ${standard}`); + assert.ok(critical > standard, `critical (${critical}) must out-node standard (${standard})`); +}); + +test('each non-entry node carries a non-empty passable patrol ring', () => { + const map = build(9, CONTRACT_DIFFICULTY.CRITICAL); + assert.equal(map.patrolRings.length, map.nodeTiles.length); + for (const ring of map.patrolRings) { + assert.ok(ring.length > 0); + for (const wp of ring) { + assert.ok(map.grid.isPassable(wp.x, wp.y), `waypoint (${wp.x}, ${wp.y}) passable`); + } + } +}); + +test('unknown difficulty throws', () => { + assert.throws(() => buildCyberMap({ rng: new Rng(1), difficulty: 'impossible' }), /difficulty/); +}); diff --git a/tests/unit/game/cyber/cyberPersistence.test.ts b/tests/unit/game/cyber/cyberPersistence.test.ts new file mode 100644 index 0000000..456924e --- /dev/null +++ b/tests/unit/game/cyber/cyberPersistence.test.ts @@ -0,0 +1,404 @@ +/** + * P3.M3.3 — Cyberspace persistence: the heart of the slice. + * + * A mid-jack-in snapshot must round-trip the cyber grid, avatar pools, alarm, + * and map memory exactly; the restored run re-wires its cyber listeners. + * Malformed blocks throw (crash > data corruption): missing fields, OOB or + * duplicated avatars, half-populated Decker cyber stats in *both* crew paths. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Run, RUN_STATE, OUTCOME } from '../../../../src/game/Run.js'; +import { CyberspaceLayer } from '../../../../src/game/cyber/CyberspaceLayer.js'; +import { CyberAvatar } from '../../../../src/game/cyber/CyberAvatar.js'; +import { EntryPort } from '../../../../src/game/cyber/EntryPort.js'; +import { JackInPoint } from '../../../../src/game/entities/JackInPoint.js'; +import { Decker } from '../../../../src/game/archetypes/Decker.js'; +import { Campaign } from '../../../../src/game/Campaign.js'; +import { EVENT } from '../../../../src/game/events.js'; +import { + snapshot, + restore, + snapshotCampaign, + restoreCampaign, +} from '../../../../src/game/persistence.js'; +import { buildCrewMember } from '../../../../src/game/archetypes/index.js'; +import { + DECKER_BASE_ICE_RESISTANCE, + DECKER_BASE_INTRUSION, + DECKER_BASE_RAM, + TILE, +} from '../../../../src/game/constants.js'; +import { OBJECTIVES } from '../../../../src/game/hub/Curator.js'; +import { Rng } from '../../../../src/rng.js'; +import { testContractContext } from '../contractTestUtils.js'; +import type { World } from '../../../../src/game/World.js'; +import type { Entity } from '../../../../src/game/Entity.js'; +import type { RunEntitySnapshot, RunSnapshot, RunResult } from '../../../../src/game/Run.js'; + +const cyberContract = (seed = 12345) => ({ + seed, + objective: { + kind: OBJECTIVES.DATA_NODE_SLICE, + title: 'Spike the server farm', + briefing: 'Jack in, slice the data node, then extract.', + params: { requiresCyberspace: true, count: 1 }, + }, + difficulty: 'standard', + threatCount: 1, + label: 'cyber test job', + context: testContractContext(OBJECTIVES.DATA_NODE_SLICE), + reward: { credits: 0, repDelta: 0 }, +}); + +function makeDecker() { + return buildCrewMember('decker', { x: 0, y: 0 }, new Rng(100), { id: 'crew-decker' }); +} + +function adjacentFreeTile(world: World, target: Entity) { + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + const x = target.x + dx; + const y = target.y + dy; + if (world.grid.inBounds(x, y) && world.grid.isPassable(x, y) && !world.entityAt(x, y)) { + return { x, y }; + } + } + } + throw new Error(`no free tile adjacent to ${target.id}`); +} + +function activeRun(seed = 12345, hooks: { onResult?: (r: RunResult) => void } = {}) { + const run = new Run({ crewMember: makeDecker(), seed, ...hooks }); + run.enterBriefing(cyberContract(seed)); + run.enterCombat(); + const point = Array.from(run.world!.entities.values()).find( + e => e instanceof JackInPoint + ) as JackInPoint; + const spot = adjacentFreeTile(run.world!, point); + run.world!.relocateEntity(run.player!, spot.x, spot.y); + run.player!.refreshAp(); + assert.equal(point.interact(run.world!, run.player!).ok, true); + assert.equal(run.cyberspace?.phase, 'active'); + return run; +} + +function layerOf(run: Run): CyberspaceLayer { + assert.equal(run.cyberspace?.phase, 'active'); + return (run.cyberspace as { phase: 'active'; layer: CyberspaceLayer }).layer; +} + +function activeRecord(seed = 12345): RunSnapshot { + const run = activeRun(seed); + const layer = layerOf(run); + // Dirty the layer so the round-trip carries real state. + const dest = adjacentFreeTile(layer.world, layer.avatar); + layer.world.relocateEntity(layer.avatar, dest.x, dest.y); + layer.avatar.spendAp(1); + layer.avatar.damage(2); + layer.recordSeen([ + `${layer.entryTile.x},${layer.entryTile.y}`, + `${layer.avatar.x},${layer.avatar.y}`, + ]); + layer.world.raiseAlarm({ repPenalty: false }); + return structuredClone(snapshot(run)); +} + +function cyberBlock(record: RunSnapshot): Record { + assert.ok(record.cyberspace, 'record carries a cyberspace block'); + return record.cyberspace as unknown as Record; +} + +function cyberEntities(record: RunSnapshot): RunEntitySnapshot[] { + return cyberBlock(record).entities as RunEntitySnapshot[]; +} + +// --- round-trip ------------------------------------------------------------------- + +test('a mid-jack-in snapshot round-trips the layer exactly', () => { + const run = activeRun(); + const layer = layerOf(run); + const dest = adjacentFreeTile(layer.world, layer.avatar); + layer.world.relocateEntity(layer.avatar, dest.x, dest.y); + layer.avatar.spendAp(1); + layer.avatar.damage(2); + layer.recordSeen([`${layer.entryTile.x},${layer.entryTile.y}`]); + layer.world.raiseAlarm({ repPenalty: false }); + + const record = structuredClone(snapshot(run)); + const { run: restored } = restore(record); + assert.equal(restored.cyberspace?.phase, 'active'); + const restoredLayer = layerOf(restored); + + assert.deepEqual(Array.from(restoredLayer.world.grid.tiles), Array.from(layer.world.grid.tiles)); + assert.deepEqual(restoredLayer.entryTile, layer.entryTile); + const avatar = restoredLayer.avatar; + assert.deepEqual( + { + x: avatar.x, + y: avatar.y, + hp: avatar.hp, + maxHp: avatar.maxHp, + ap: avatar.ap, + damageReduction: avatar.damageReduction, + intrusionStrength: avatar.intrusionStrength, + callsign: avatar.callsign, + }, + { + x: layer.avatar.x, + y: layer.avatar.y, + hp: layer.avatar.hp, + maxHp: layer.avatar.maxHp, + ap: layer.avatar.ap, + damageReduction: layer.avatar.damageReduction, + intrusionStrength: layer.avatar.intrusionStrength, + callsign: layer.avatar.callsign, + } + ); + assert.ok(restoredLayer.port instanceof EntryPort); + assert.deepEqual(restoredLayer.mapSeenKeys(), layer.mapSeenKeys()); + assert.deepEqual(restoredLayer.world.snapshotAlarm(), layer.world.snapshotAlarm()); + // Meatspace round-tripped alongside: the Decker body is still the player. + assert.ok(restored.player instanceof Decker); +}); + +test('a restored active run re-wires its cyber listeners', () => { + const results: RunResult[] = []; + const { run: restored } = restore(activeRecord(), { onResult: r => results.push(r) }); + const layer = layerOf(restored); + const avatar = layer.avatar; + avatar.damage(avatar.hp); + layer.bus.emit(EVENT.ENTITY_DAMAGED, { + target: avatar, + attacker: null, + damage: avatar.maxHp, + killed: true, + source: 'black-ice', + }); + assert.equal(restored.state, RUN_STATE.RESULT); + assert.equal(results[0]?.outcome, OUTCOME.DEATH); +}); + +test('a restored active run keeps the dual-world turn cadence', () => { + const { run: restored } = restore(activeRecord()); + const layer = layerOf(restored); + layer.avatar.refreshAp(); + layer.avatar.spendAp(2); + restored.queue!.endTurn(restored.world!); // player → corp + restored.queue!.endTurn(restored.world!); // corp → player + assert.equal(layer.avatar.ap, layer.avatar.maxAp); +}); + +test('a resolved layer round-trips its latch', () => { + const run = activeRun(); + const layer = layerOf(run); + layer.port.interact(layer.world, layer.avatar); + assert.deepEqual(run.cyberspace, { phase: 'resolved', objectiveComplete: false }); + const record = structuredClone(snapshot(run)); + assert.deepEqual(record.cyberspace, { phase: 'resolved', objectiveComplete: false }); + const { run: restored } = restore(record); + assert.deepEqual(restored.cyberspace, { phase: 'resolved', objectiveComplete: false }); + assert.throws(() => restored.jackOut(), /phase/); +}); + +// --- adversarial: active block ----------------------------------------------------- + +test('active block missing a required field throws', () => { + for (const field of ['grid', 'entities', 'entryTile', 'alarm', 'mapMemory']) { + const record = activeRecord(); + delete cyberBlock(record)[field]; + assert.throws( + () => restore(record), + new RegExp(field === 'mapMemory' ? 'mapMemory' : field), + `expected restore to throw on missing cyberspace.${field}` + ); + } +}); + +test('active block with unknown payload keys throws', () => { + const record = activeRecord(); + cyberBlock(record).objectiveComplete = true; // resolved-only latch on active phase + assert.throws(() => restore(record), /objectiveComplete/); +}); + +test('an out-of-bounds cyber entity throws', () => { + const record = activeRecord(); + const avatarRec = cyberEntities(record).find(e => e.archetype === 'cyber-avatar')!; + avatarRec.x = 999; + assert.throws(() => restore(record), /out of bounds/); +}); + +test('zero or multiple avatars throw', () => { + const missing = activeRecord(); + const block = cyberBlock(missing); + block.entities = cyberEntities(missing).filter(e => e.archetype !== 'cyber-avatar'); + assert.throws(() => restore(missing), /avatar/); + + const doubled = activeRecord(); + const entities = cyberEntities(doubled); + const avatarRec = entities.find(e => e.archetype === 'cyber-avatar')!; + const clone = structuredClone(avatarRec); + clone.id = 'cyber-avatar-1'; + entities.push(clone); + assert.throws(() => restore(doubled), /avatar/); +}); + +test('a missing exit port throws', () => { + const record = activeRecord(); + const block = cyberBlock(record); + block.entities = cyberEntities(record).filter(e => e.archetype !== 'entry-port'); + assert.throws(() => restore(record), /port/); +}); + +test('non-cyber tile ids in the cyber grid throw', () => { + const record = activeRecord(); + const grid = cyberBlock(record).grid as { tiles: number[] }; + const wallIndex = grid.tiles.findIndex(t => t === TILE.WALL); + assert.ok(wallIndex >= 0); + grid.tiles[wallIndex] = TILE.COVER; + assert.throws(() => restore(record), /tile/); +}); + +test('an avatar record without intrusion state throws', () => { + const record = activeRecord(); + const avatarRec = cyberEntities(record).find(e => e.archetype === 'cyber-avatar')!; + delete (avatarRec.extra as Record).intrusionStrength; + assert.throws(() => restore(record), /intrusion/); +}); + +// --- adversarial: resolved block ----------------------------------------------------- + +test('resolved block without its latch throws', () => { + const run = activeRun(); + const layer = layerOf(run); + layer.port.interact(layer.world, layer.avatar); + const record = structuredClone(snapshot(run)); + delete cyberBlock(record).objectiveComplete; + assert.throws(() => restore(record), /objectiveComplete/); +}); + +test('resolved block smuggling active payload throws', () => { + const run = activeRun(); + const layer = layerOf(run); + layer.port.interact(layer.world, layer.avatar); + const record = structuredClone(snapshot(run)); + cyberBlock(record).grid = { w: 1, h: 1, tiles: [0] }; + assert.throws(() => restore(record), /grid/); +}); + +// --- Decker cyber stats: run-entity path --------------------------------------------- + +test('Decker cyber stats round-trip through the run-entity extra', () => { + const record = activeRecord(); + const deckerRec = record.entities.find(e => e.archetype === 'decker')!; + assert.deepEqual( + { + ram: (deckerRec.extra as Record).ram, + intrusionStrength: (deckerRec.extra as Record).intrusionStrength, + iceResistance: (deckerRec.extra as Record).iceResistance, + }, + { + ram: DECKER_BASE_RAM, + intrusionStrength: DECKER_BASE_INTRUSION, + iceResistance: DECKER_BASE_ICE_RESISTANCE, + } + ); + const { run: restored } = restore(record); + const decker = restored.player as Decker; + assert.equal(decker.ram, DECKER_BASE_RAM); + assert.equal(decker.intrusionStrength, DECKER_BASE_INTRUSION); + assert.equal(decker.iceResistance, DECKER_BASE_ICE_RESISTANCE); +}); + +test('half-populated Decker cyber stats in the run-entity extra throw', () => { + const record = activeRecord(); + const deckerRec = record.entities.find(e => e.archetype === 'decker')!; + delete (deckerRec.extra as Record).ram; + assert.throws(() => restore(record), /ram/); +}); + +test('a legacy Decker record without cyber stats restores with base stats', () => { + const record = activeRecord(); + const deckerRec = record.entities.find(e => e.archetype === 'decker')!; + const extra = deckerRec.extra as Record; + delete extra.ram; + delete extra.intrusionStrength; + delete extra.iceResistance; + const { run: restored } = restore(record); + const decker = restored.player as Decker; + assert.equal(decker.ram, DECKER_BASE_RAM); + assert.equal(decker.iceResistance, DECKER_BASE_ICE_RESISTANCE); +}); + +// --- Decker cyber stats: campaign crew path ------------------------------------------- + +function act2CampaignWithDecker(): Campaign { + const campaign = new Campaign({ seed: 42, rep: 60, completedJobs: 4 }); + assert.ok(campaign.crew.some(m => m.archetype === 'Decker')); + return campaign; +} + +test('campaign crew snapshots carry the Decker cyber block', () => { + const record = snapshotCampaign(act2CampaignWithDecker()); + const deckerRec = record.crew.find(c => c.archetype === 'decker') as unknown as { + cyber?: { ram: number; intrusion: number; iceResistance: number }; + }; + assert.deepEqual(deckerRec.cyber, { + ram: DECKER_BASE_RAM, + intrusion: DECKER_BASE_INTRUSION, + iceResistance: DECKER_BASE_ICE_RESISTANCE, + }); + const restored = restoreCampaign(structuredClone(record)); + const decker = restored.crew.find(m => m.archetype === 'Decker') as Decker; + assert.equal(decker.ram, DECKER_BASE_RAM); + assert.equal(decker.intrusionStrength, DECKER_BASE_INTRUSION); +}); + +test('half-populated campaign cyber stats throw; absent stats default', () => { + const base = snapshotCampaign(act2CampaignWithDecker()); + + const half = structuredClone(base); + const halfDecker = half.crew.find(c => c.archetype === 'decker') as unknown as { + cyber: Record; + }; + delete halfDecker.cyber.intrusion; + assert.throws(() => restoreCampaign(half), /intrusion/); + + const legacy = structuredClone(base); + const legacyDecker = legacy.crew.find(c => c.archetype === 'decker') as unknown as { + cyber?: unknown; + }; + delete legacyDecker.cyber; + const restored = restoreCampaign(legacy); + const decker = restored.crew.find(m => m.archetype === 'Decker') as Decker; + assert.equal(decker.ram, DECKER_BASE_RAM); +}); + +test('cyber stats on a non-Decker crew record throw', () => { + const record = structuredClone(snapshotCampaign(act2CampaignWithDecker())); + const mercRec = record.crew.find(c => c.archetype !== 'decker') as unknown as { + cyber?: unknown; + }; + mercRec.cyber = { ram: 8, intrusion: 2, iceResistance: 1 }; + assert.throws(() => restoreCampaign(record), /cyber/); +}); + +// --- avatar snapshot extra ------------------------------------------------------------ + +test('the avatar snapshot rides the entity record (hp pool) plus its extra', () => { + const record = activeRecord(); + const avatarRec = cyberEntities(record).find(e => e.archetype === 'cyber-avatar')!; + assert.equal(avatarRec.maxHp, DECKER_BASE_RAM); + assert.equal(avatarRec.damageReduction, DECKER_BASE_ICE_RESISTANCE); + assert.equal(avatarRec.hp, DECKER_BASE_RAM - 2); // damaged in activeRecord() + assert.deepEqual(avatarRec.extra, { + intrusionStrength: DECKER_BASE_INTRUSION, + callsign: (record.entities.find(e => e.archetype === 'decker')!.extra as { callsign: string }) + .callsign, + }); + // Restored avatars are real CyberAvatars. + const { run: restored } = restore(record); + assert.ok(layerOf(restored).avatar instanceof CyberAvatar); +}); diff --git a/tests/unit/game/cyber/runJackIn.test.ts b/tests/unit/game/cyber/runJackIn.test.ts new file mode 100644 index 0000000..d92b752 --- /dev/null +++ b/tests/unit/game/cyber/runJackIn.test.ts @@ -0,0 +1,242 @@ +/** + * P3.M3.3 — Run-level jack-in: the dormant → active transition. + * + * Linking the jack-in point emits `EVENT.JACK_IN` on the meat bus; `Run` + * builds the CyberspaceLayer from the *contract* seed (jack-in-turn + * independent), wires the cyber listeners, and keeps Meatspace ticking. + * Avatar death routes through the existing DEATH/flatline path. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Run, RUN_STATE, OUTCOME } from '../../../../src/game/Run.js'; +import { CyberspaceLayer } from '../../../../src/game/cyber/CyberspaceLayer.js'; +import { JackInPoint } from '../../../../src/game/entities/JackInPoint.js'; +import { EVENT } from '../../../../src/game/events.js'; +import { buildCrewMember } from '../../../../src/game/archetypes/index.js'; +import { OBJECTIVES } from '../../../../src/game/hub/Curator.js'; +import { Rng } from '../../../../src/rng.js'; +import { testContractContext } from '../contractTestUtils.js'; +import type { World } from '../../../../src/game/World.js'; +import type { Entity } from '../../../../src/game/Entity.js'; +import type { RunResult, RunSnapshot } from '../../../../src/game/Run.js'; + +const fakeContract = (overrides = {}) => ({ + seed: 12345, + objective: { + kind: OBJECTIVES.REACH_EXIT, + title: 'Extract clean', + briefing: 'Reach the exit.', + }, + difficulty: 'standard', + threatCount: 1, + label: 'test job', + context: testContractContext(OBJECTIVES.REACH_EXIT), + reward: { credits: 0, repDelta: 0 }, + ...overrides, +}); + +const cyberContract = (overrides = {}) => + fakeContract({ + objective: { + kind: OBJECTIVES.DATA_NODE_SLICE, + title: 'Spike the server farm', + briefing: 'Jack in, slice the data node, then extract.', + params: { requiresCyberspace: true, count: 1 }, + }, + label: 'cyber test job', + context: testContractContext(OBJECTIVES.DATA_NODE_SLICE), + ...overrides, + }); + +function makeDecker(x = 0, y = 0) { + return buildCrewMember('decker', { x, y }, new Rng(100), { id: 'crew-decker' }); +} + +type RunHooks = { + onPersist?: (record: RunSnapshot) => void; + onResult?: (result: RunResult) => void; +}; + +function combatRun(seed = 12345, hooks: RunHooks = {}) { + const run = new Run({ crewMember: makeDecker(), seed, ...hooks }); + run.enterBriefing(cyberContract({ seed })); + run.enterCombat(); + return run; +} + +function jackInPoint(run: Run): JackInPoint { + const point = Array.from(run.world!.entities.values()).find(e => e instanceof JackInPoint); + assert.ok(point, 'cyber contract placed a jack-in point'); + return point as JackInPoint; +} + +function adjacentFreeTile(world: World, target: Entity) { + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + const x = target.x + dx; + const y = target.y + dy; + if (world.grid.inBounds(x, y) && world.grid.isPassable(x, y) && !world.entityAt(x, y)) { + return { x, y }; + } + } + } + throw new Error(`no free tile adjacent to ${target.id}`); +} + +/** Walk the Decker to the port and link. Returns the active layer. */ +function jackIn(run: Run): CyberspaceLayer { + const point = jackInPoint(run); + const spot = adjacentFreeTile(run.world!, point); + run.world!.relocateEntity(run.player!, spot.x, spot.y); + run.player!.refreshAp(); + const result = point.interact(run.world!, run.player!); + assert.equal(result.ok, true, `link failed: ${result.message}`); + assert.equal(run.cyberspace?.phase, 'active'); + return (run.cyberspace as { phase: 'active'; layer: CyberspaceLayer }).layer; +} + +// --- golden path --------------------------------------------------------------- + +test('linking the port flips the run to an active cyber layer', () => { + const run = combatRun(); + const layer = jackIn(run); + assert.ok(layer instanceof CyberspaceLayer); + assert.equal(run.cyberActive, true); + assert.equal(run.activeWorld, layer.world); + assert.equal(run.activeActor, layer.avatar); + // Avatar stats derive from the deployed Decker's base cyber stats. + const decker = run.player! as unknown as { ram: number; iceResistance: number }; + assert.equal(layer.avatar.maxHp, decker.ram); + assert.equal(layer.avatar.damageReduction, decker.iceResistance); + // The Decker body keeps standing at the port in Meatspace. + assert.equal(run.player!.alive, true); + assert.ok(run.world!.entities.has(run.player!.id)); +}); + +test('jack-in autosaves the active layer', () => { + const records: RunSnapshot[] = []; + const run = combatRun(12345, { onPersist: r => records.push(r) }); + jackIn(run); + const last = records.at(-1)!; + assert.equal(last.cyberspace?.phase, 'active'); + const block = last.cyberspace as { grid: { tiles: number[] }; entities: unknown[] }; + assert.ok(block.grid.tiles.length > 0); + assert.ok(block.entities.length >= 2); +}); + +test('layer layout is independent of the jack-in turn', () => { + const immediate = jackIn(combatRun(777)); + + const late = combatRun(777); + late.queue!.endTurn(late.world!); // player → corp + late.queue!.endTurn(late.world!); // corp → player (round advance) + const lateLayer = jackIn(late); + + assert.deepEqual(Array.from(lateLayer.world.grid.tiles), Array.from(immediate.world.grid.tiles)); + assert.deepEqual(lateLayer.entryTile, immediate.entryTile); + + // And both equal a fresh build from the same contract seed. + const fresh = CyberspaceLayer.build({ + contractSeed: 777, + difficulty: 'standard', + decker: makeDecker(), + }); + assert.deepEqual(Array.from(fresh.world.grid.tiles), Array.from(immediate.world.grid.tiles)); +}); + +// --- illegal transitions --------------------------------------------------------- + +test('jackIn while already active throws (corrupt latch)', () => { + const run = combatRun(); + jackIn(run); + assert.throws(() => run.jackIn(jackInPoint(run)), /phase/); +}); + +test('jackIn outside COMBAT throws', () => { + const run = new Run({ crewMember: makeDecker(), seed: 12345 }); + run.enterBriefing(cyberContract()); + assert.throws( + () => run.jackIn(new JackInPoint({ id: 'jack-in-0', x: 1, y: 1, linked: true })), + /COMBAT|illegal/ + ); +}); + +test('a JACK_IN emission on a non-cyber contract throws', () => { + const run = new Run({ crewMember: makeDecker(), seed: 999 }); + run.enterBriefing(fakeContract({ seed: 999 })); + run.enterCombat(); + assert.equal(run.cyberspace, null); + // Smuggle a port into a non-cyber run — the Run listener must refuse loudly. + const point = new JackInPoint({ id: 'jack-in-rogue', x: 0, y: 0 }); + const spot = adjacentFreeTile(run.world!, run.player!); + point.x = spot.x; + point.y = spot.y; + run.world!.addEntity(point); + run.player!.refreshAp(); + assert.throws(() => point.interact(run.world!, run.player!), /[Cc]yberspace/); +}); + +// --- both worlds tick ------------------------------------------------------------- + +test('meat turn cadence drives the cyber AP refresh', () => { + const run = combatRun(); + const layer = jackIn(run); + layer.avatar.spendAp(3); + run.queue!.endTurn(run.world!); // player → corp: avatar stays spent + assert.equal(layer.avatar.ap, layer.avatar.maxAp - 3); + run.queue!.endTurn(run.world!); // corp → player: avatar refreshes + assert.equal(layer.avatar.ap, layer.avatar.maxAp); + // The meat body refreshed on the same cadence (single TurnQueue). + assert.equal(run.player!.ap, run.player!.maxAp); +}); + +// --- avatar death = flatline -------------------------------------------------------- + +test('avatar death routes through the existing DEATH path', () => { + const results: RunResult[] = []; + const run = combatRun(12345, { onResult: r => results.push(r) }); + const layer = jackIn(run); + const avatar = layer.avatar; + avatar.damage(avatar.hp); + layer.bus.emit(EVENT.ENTITY_DAMAGED, { + target: avatar, + attacker: null, + damage: avatar.maxHp, + killed: true, + source: 'black-ice', + }); + assert.equal(run.state, RUN_STATE.RESULT); + assert.equal(results.length, 1); + assert.equal(results[0].outcome, OUTCOME.DEATH); + assert.match(String(results[0].telemetry.cause), /black-ice/); +}); + +// --- voluntary jack-out core (S5 finalizes) ----------------------------------------- + +test('the avatar routing out resolves the layer', () => { + const run = combatRun(); + const layer = jackIn(run); + const result = layer.port.interact(layer.world, layer.avatar); + assert.equal(result.ok, true); + assert.deepEqual(run.cyberspace, { phase: 'resolved', objectiveComplete: false }); + assert.equal(run.state, RUN_STATE.COMBAT, 'jack-out returns to Meatspace, not RESULT'); + assert.equal(run.cyberActive, false); + assert.equal(run.activeWorld, run.world); + assert.equal(run.activeActor, run.player); + // Resolved is a latch: jacking out twice is corrupt state. + assert.throws(() => run.jackOut(), /phase/); +}); + +test('re-interacting the meat port after resolve stays refused (LINK BURNED)', () => { + const run = combatRun(); + const layer = jackIn(run); + layer.port.interact(layer.world, layer.avatar); + const point = jackInPoint(run); + run.player!.refreshAp(); + const repeat = point.interact(run.world!, run.player!); + assert.equal(repeat.ok, false); + assert.equal((repeat as { reason: string }).reason, 'already-linked'); + assert.equal(run.cyberspace?.phase, 'resolved'); +}); From 5f90d44d08ce494e9c95ead6868bd63f519ee325 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Wed, 10 Jun 2026 13:57:13 -0700 Subject: [PATCH 18/55] =?UTF-8?q?P3.M3=20plan:=20S3=20shipped=20=E2=80=94?= =?UTF-8?q?=20progress=20row,=20implementation=20notes,=20deviations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/phase-3-cyberspace-plan.md | 70 +++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/docs/phase-3-cyberspace-plan.md b/docs/phase-3-cyberspace-plan.md index 1347aa4..b03c2ba 100644 --- a/docs/phase-3-cyberspace-plan.md +++ b/docs/phase-3-cyberspace-plan.md @@ -31,7 +31,7 @@ Commits land **per green slice** (user-approved). |---|---|---| | **S1 — P3.M3.1 Contract flag + gates** | ✅ Done | `f436330` | | **S2 — P3.M3.2 Jack-in terminal** | ✅ Done | `05013f6` | -| **S3 — P3.M3.3 Cyber layer model + avatar + persistence** | 🔨 In progress (research done, no code yet) | — | +| **S3 — P3.M3.3 Cyber layer model + avatar + persistence** | ✅ Done | `39b357e` | | **S4 — P3.M3.4 Data node objective** | 🔲 Planned | — | | **S5 — Voluntary jack-out (M4.6 pull-forward)** | 🔲 Planned | — | | **S6 — P3.M3.5 Probe ICE** | 🔲 Planned | — | @@ -98,6 +98,68 @@ Commits land **per green slice** (user-approved). - Tests: `tests/unit/game/jackInPoint.test.ts` (12 tests — interact behavior, placement determinism, round-trip, adversarial restore throws). +### S3 implementation notes (shipped) + +- `src/game/cyber/cyberMapBuild.ts` — `buildCyberMap({rng, difficulty})`: + spanning random walk over a 4×2 cell lattice (29×15 grid), square rooms + (3–6) centered per cell, L-corridors along walk edges, clockwise perimeter + patrol rings. Node count by difficulty: standard 5 / elevated 6 / critical 8 + (unknown difficulty throws). `entryTile` = entry room center; + **deviation: returns `portTile`** (Chebyshev-1 from entry) so layer build + doesn't re-derive placement. Connectivity flood + (`explorationReachableKeys`) validates every node + port reachable; throws + otherwise. Returned `nodeTiles`/`patrolRings` exclude the entry node. +- `src/game/cyber/CyberAvatar.ts` — `Entity` subclass: `maxHp = ram`, + `damageReduction = iceResistance` (0 is legal), `intrusionStrength`, + `callsign`, maxAp 4, `baseHitChance` 0.8 (sniffed by `Combat.resolveRanged` + — zero combat changes needed). **Deviation: capability flag is + `readonly isCyberAvatar = true`**, not the planned `intrusionStrength` + sniff — the Decker now carries `intrusionStrength` too, so the stat sniff + would have let the meat body use cyber interactables. +- `src/game/cyber/EntryPort.ts` — avatar-only interact (`'not-an-avatar'` + refusal), spends interact AP, emits `EVENT.JACK_OUT` once. Deliberately + does **not** latch — the resolve latch lives on `Run.cyberspace`. +- `src/game/cyber/CyberspaceLayer.ts` — own bus + `World`; + `static build({contractSeed, difficulty, decker})` forks + `new Rng(contractSeed).fork('cyberspace')` (layout independent of jack-in + turn — regression-tested); plain ctor used by restore (restored layers have + empty `nodeTiles`/`patrolRings` — those are build-time spawn anchors only); + `onTurnEnded(next)` refreshes incoming-faction AP + ticks cyber alarm on + PLAYER; `recordSeen` bounds-validates (throws); `teardown()` unbinds patrol + hostiles (S6 seam). **Deviation: no `layer.snapshot()`** — serialization + lives in Run's `snapshotCyberspace` beside the entity extractor registry. +- `Run.ts` — `CyberspaceState` active/resolved arms; + `cyberActive`/`activeWorld`/`activeActor` accessors (shell seam for S7); + `jackIn(point)` throws unless COMBAT + cyber contract + dormant + linked + `JackInPoint` + Decker player; `jackOut()` latches + `objectiveComplete: false` (**TODO(P3.M3.4)**: real value once nodes + exist); both autosave explicitly. `#onTurnEnded` now consumes the `{next}` + payload (throws if absent) and forwards to the layer before autosave. Cyber + `ENTITY_DAMAGED` listener mirrors the meat player-death handler — avatar + killed → telemetry cause → `enterResult(DEATH)` → existing flatline path + (scope decision #3). +- `persistence.ts` — `restoreCyberspace` per-phase **allowed-keys checks** + (cross-phase payload smuggling throws); active block validates grid dims + + FLOOR/WALL-only tile values, entryTile in-bounds/passable, alarm, + mapMemory; entities restored via `restoreEntity(rec, grid)` against the + **cyber** grid; exactly one avatar and one port (duplicates throw). + **Deviation: a dead avatar is legal when `record.state !== COMBAT`** — a + RESULT-state death snapshot legitimately carries one; alive required only + for COMBAT records. **Deviation: no `seed` field in the active block** — + the serialized grid is authoritative. Decker cyber stats round-trip through + both crew paths (`CampaignCrewSnapshot.cyber?` + run-entity `decker` + extra): all-absent → `DECKER_BASE_*` defaults (legacy saves), + half-populated/malformed → throw, `cyber` on a non-decker throws. +- `Decker.ts`/`constants.ts` — `ram` 8 / `intrusionStrength` 2 / + `iceResistance` 1 base constants, validated mutable fields (scope + decision #4). +- Tests: 49 across `tests/unit/game/cyber/` — `cyberMapBuild.test.ts` (8), + `CyberspaceLayer.test.ts` (11), `runJackIn.test.ts` (10), + `cyberPersistence.test.ts` (20, the adversarial heart). Failing-first + verified; full suite 1604 green. +- Playtest note (pre-S7): jack-in flips state internally but the shell still + renders Meatspace until the S7 render/input swap — expected, not a bug. + ## Architecture decisions (approved plan) ### Cyber layer model — `CyberspaceLayer` owned by `Run`, single `TurnQueue`, both worlds tick @@ -108,8 +170,8 @@ New `src/game/cyber/CyberspaceLayer.ts`: owns its own `EventBus`, `World` ```ts type CyberspaceState = | { phase: 'dormant' } // shipped (S2) - | { phase: 'active'; layer: CyberspaceLayer } // S3 - | { phase: 'resolved'; objectiveComplete: boolean }; // S3/S5 + | { phase: 'active'; layer: CyberspaceLayer } // shipped (S3) + | { phase: 'resolved'; objectiveComplete: boolean }; // shipped (S3); S5 finalizes Run.cyberspace: CyberspaceState | null; // null ⇔ no cyber component ``` @@ -277,7 +339,7 @@ subset; all throws, `restoreOverrideState` style): while jacked in — single queue); `jackIn`/`jackOut` call `onPersist` explicitly. -## S3 detailed worklist (next up — research complete, no code yet) +## S3 detailed worklist (shipped in `39b357e` — see implementation notes above for deviations) **Create:** - `src/game/cyber/cyberMapBuild.ts` — `buildCyberMap({rng, difficulty})` per From 392314990a5194337b92e2587c39d38426dc1c73 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Wed, 10 Jun 2026 14:14:26 -0700 Subject: [PATCH 19/55] =?UTF-8?q?P3.M3.4:=20data=20node=20objective=20?= =?UTF-8?q?=E2=80=94=20slice=20targets,=20satisfaction,=20NODES=20chip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S4 of the cyberspace slice plan. The layer now has a reason to exist: - DataNode interactable (glyph ◈): interact adds the actor's intrusionStrength to sliceProgress; sliced at sliceDifficulty (2/3/4 by contract difficulty). Avatar-only via the isCyberAvatar sniff — the Decker body also carries intrusionStrength, so the stat is not the capability. Slicing is silent; ICE detection (S6) owns the cyber alarm. Progress is raw (never clamped) so persisted state loses nothing. - CyberspaceLayer.build gains required nodeCount (the contract objective's count — required, not defaulted, so a divergence can't silently corrupt the objective) and spawns nodes on the farthest node anchors from entry, Chebyshev-sorted stable over the deterministic generator order. - Run: ObjectiveState.cyber tally threaded per phase (live count while active, the resolved latch after jack-out, zero while dormant); DATA_NODE_SLICE satisfaction counts sliced >= required; jackOut latches objectiveComplete from the real tally — jacking out early leaves the objective permanently unsatisfiable and extraction gated through the existing abort-confirm flow (zero new extraction code). - objectiveProgress: dataNodeProgress helper + NODES HUD chip; the chip pipeline is kind-generic so no shell changes needed. - persistence: data-node entity codec (label/sliceDifficulty/sliceProgress, malformed throws); active cyberspace blocks must carry exactly the contract's node count (nodes never despawn — any drift is corruption). 25 new tests (dataNode.test.ts, dataNodeObjective.test.ts; failing-first verified), suite at 1629 green. Co-Authored-By: Claude Fable 5 --- src/game/Entity.ts | 1 + src/game/Run.ts | 67 +++- src/game/constants.ts | 2 + src/game/cyber/CyberspaceLayer.ts | 45 ++- src/game/cyber/DataNode.ts | 112 +++++++ src/game/objectiveProgress.ts | 39 ++- src/game/persistence.ts | 47 +++ tests/unit/game/cyber/CyberspaceLayer.test.ts | 7 +- tests/unit/game/cyber/dataNode.test.ts | 171 ++++++++++ .../unit/game/cyber/dataNodeObjective.test.ts | 308 ++++++++++++++++++ tests/unit/game/cyber/runJackIn.test.ts | 1 + 11 files changed, 785 insertions(+), 15 deletions(-) create mode 100644 src/game/cyber/DataNode.ts create mode 100644 tests/unit/game/cyber/dataNode.test.ts create mode 100644 tests/unit/game/cyber/dataNodeObjective.test.ts diff --git a/src/game/Entity.ts b/src/game/Entity.ts index f70e130..8307a6d 100644 --- a/src/game/Entity.ts +++ b/src/game/Entity.ts @@ -277,6 +277,7 @@ function kindFromId(id: string): string { if (id.startsWith('jack-in')) return 'Jack-in Port'; if (id.startsWith('cyber-avatar')) return 'Avatar'; if (id.startsWith('entry-port')) return 'Exit Port'; + if (id.startsWith('data-node')) return 'Data Node'; if (id.includes('turret')) return 'Turret'; if (id.startsWith('crew')) return 'Operative'; return id; diff --git a/src/game/Run.ts b/src/game/Run.ts index 61f4641..be2aa82 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -82,16 +82,19 @@ import { JackInPoint } from './entities/JackInPoint.js'; import { CyberspaceLayer } from './cyber/CyberspaceLayer.js'; import { CyberAvatar } from './cyber/CyberAvatar.js'; import { EntryPort } from './cyber/EntryPort.js'; +import { DataNode } from './cyber/DataNode.js'; import { applyMutationDeltas } from './locations.js'; import { BreachingCharge } from './entities/BreachingCharge.js'; import { ITEM_ID, getItemById } from './items.js'; import { resetCorpTurnStatusCache } from './corpTurnStatusCopy.js'; import { objectiveProgress as resolveObjectiveProgress, + dataNodeProgress, reconObjectiveProgress, sweepQuotaType, SWEEP_QUOTA, } from './objectiveProgress.js'; +import type { CyberNodeProgress } from './objectiveProgress.js'; import { NeutralCivilian } from './entities/NeutralCivilian.js'; import { VisionField } from './Vision.js'; import { @@ -128,6 +131,7 @@ import type { JackInPointSnapshot } from './entities/JackInPoint.js'; import type { KeyCardSnapshot } from './entities/KeyCard.js'; import type { CyberAvatarSnapshot } from './cyber/CyberAvatar.js'; import type { EntryPortSnapshot } from './cyber/EntryPort.js'; +import type { DataNodeSnapshot } from './cyber/DataNode.js'; import type { DeckerSnapshot } from './archetypes/Decker.js'; import type { AlarmState } from './World.js'; @@ -175,6 +179,7 @@ export type EntityArchetypeId = | 'jack-in-point' | 'cyber-avatar' | 'entry-port' + | 'data-node' | 'entity'; export type RunTelemetry = { @@ -856,6 +861,7 @@ export class Run { contractSeed: this.contract.seed, difficulty: this.contract.difficulty, decker: this.player, + nodeCount: objectiveCount(this.contract), }); this.cyberspace = { phase: 'active', layer }; this.#wireCyberLayerListeners(layer); @@ -880,9 +886,15 @@ export class Run { `Run.jackOut: illegal from cyberspace phase "${this.cyberspace?.phase ?? 'none'}"` ); } + if (!this.contract) { + throw new Error('Run.jackOut: COMBAT state without a contract'); + } const layer = this.cyberspace.layer; - // TODO(P3.M3.4): derive from sliced data-node count once nodes exist. - const objectiveComplete = false; + // P3.M3.4: the latch is the sliced-node tally at the moment the link + // drops. Jacking out early leaves the objective permanently unsatisfiable + // — extraction then routes through the existing abort-confirm flow. + const { sliced } = dataNodeProgress(layer.world); + const objectiveComplete = sliced >= objectiveCount(this.contract); layer.teardown(); this.cyberspace = { phase: 'resolved', objectiveComplete }; if (this.onPersist) { @@ -987,7 +999,31 @@ export class Run { objectiveProgress() { if (!this.contract) return null; - return resolveObjectiveProgress(this.contract, this.world, this.mapSeen); + return resolveObjectiveProgress( + this.contract, + this.world, + this.mapSeen, + this.#cyberNodeProgress() ?? null + ); + } + + /** + * P3.M3.4: the data-node tally per cyberspace phase — live count while the + * layer is active, the latch once resolved, zero while dormant. `undefined` + * for contracts without a Cyberspace component. + */ + #cyberNodeProgress(): CyberNodeProgress | undefined { + if (!this.contract || !this.cyberspace) return undefined; + if (this.contract.objective.kind !== OBJECTIVES.DATA_NODE_SLICE) return undefined; + const required = objectiveCount(this.contract); + switch (this.cyberspace.phase) { + case 'dormant': + return { sliced: 0, required }; + case 'active': + return { sliced: dataNodeProgress(this.cyberspace.layer.world).sliced, required }; + case 'resolved': + return { sliced: this.cyberspace.objectiveComplete ? required : 0, required }; + } } /** @@ -1625,6 +1661,7 @@ export class Run { const satisfied = isObjectiveSatisfied(this.contract, this.world, timing, { mapSeen: this.mapSeen, reconSeen: this.mapSeen, + cyber: this.#cyberNodeProgress(), }); const limit = turnLimitForContract(this.contract); if (limit === null || !this.queue) return satisfied; @@ -1681,6 +1718,8 @@ export type ObjectiveTiming = { export type ObjectiveState = { mapSeen?: ReadonlySet | readonly string[]; reconSeen?: ReadonlySet | readonly string[]; + /** P3.M3.4: data-node tally for `data-node-slice` contracts. */ + cyber?: CyberNodeProgress; }; export function isObjectiveSatisfied( @@ -1727,11 +1766,14 @@ function isObjectiveFamilySatisfied( return isReconSatisfied(world, objectiveState); case OBJECTIVES.ESCORT_EXTRACT: return isEscortExtractSatisfied(world); - case OBJECTIVES.DATA_NODE_SLICE: - // P3.M3.1: satisfaction requires the Cyberspace layer (P3.M3.4 wires - // sliced-node counting through ObjectiveState). Until then a cyber - // contract is honestly unsatisfiable — extraction stays gated. - return false; + case OBJECTIVES.DATA_NODE_SLICE: { + // P3.M3.4: `Run` threads the per-phase node tally through + // `ObjectiveState.cyber` (live count / resolved latch / dormant zero). + // Without it — e.g. a bare `isObjectiveSatisfied` call — the objective + // is honestly unsatisfiable and extraction stays gated. + const cyber = objectiveState?.cyber; + return !!cyber && cyber.required > 0 && cyber.sliced >= cyber.required; + } default: { const exhaustive: never = kind; throw new Error(`Run.isObjectiveSatisfied: unknown objective kind "${exhaustive}"`); @@ -1981,6 +2023,14 @@ const SNAPSHOT_EXTRACTORS: Partial Enti 'entry-port': e => { return { label: (e as EntryPort).label } satisfies EntryPortSnapshot; }, + 'data-node': e => { + const n = e as DataNode; + return { + label: n.label, + sliceDifficulty: n.sliceDifficulty, + sliceProgress: n.sliceProgress, + } satisfies DataNodeSnapshot; + }, }; /** @@ -2069,6 +2119,7 @@ function archetypeOf(entity: Entity): EntityArchetypeId { if (entity instanceof JackInPoint) return 'jack-in-point'; if (entity instanceof CyberAvatar) return 'cyber-avatar'; if (entity instanceof EntryPort) return 'entry-port'; + if (entity instanceof DataNode) return 'data-node'; if (entity instanceof BreachingCharge) return 'breaching-charge'; if (entity instanceof Entity) return 'entity'; throw new Error(`archetypeOf: cannot classify entity ${(entity as Entity | undefined)?.id}`); diff --git a/src/game/constants.ts b/src/game/constants.ts index 8d628ce..199b5b2 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -48,6 +48,8 @@ export const KEYCARD_GLYPH = 'κ'; export const JACK_IN_GLYPH = 'Ω'; /** P3.M3.3: the Cyberspace door back into Meatspace. */ export const ENTRY_PORT_GLYPH = '▼'; +/** P3.M3.4: the slice target on the cyber grid (`◆` is DenyTarget's). */ +export const DATA_NODE_GLYPH = '◈'; /** Numeric tile id — one of the `TILE` values. */ export type TileId = (typeof TILE)[keyof typeof TILE]; diff --git a/src/game/cyber/CyberspaceLayer.ts b/src/game/cyber/CyberspaceLayer.ts index 504a229..ed110d4 100644 --- a/src/game/cyber/CyberspaceLayer.ts +++ b/src/game/cyber/CyberspaceLayer.ts @@ -25,6 +25,7 @@ import { Rng } from '../../rng.js'; import { buildCyberMap } from './cyberMapBuild.js'; import { CyberAvatar } from './CyberAvatar.js'; import { EntryPort } from './EntryPort.js'; +import { DataNode, sliceDifficultyFor } from './DataNode.js'; import { PatrolHostile } from '../ai/PatrolHostile.js'; import { FACTION, type ContractDifficulty, type FactionId } from '../constants.js'; import { coordKey } from '../mapConnectivity.js'; @@ -47,6 +48,12 @@ export type CyberspaceLayerBuildOptions = { contractSeed: number; difficulty: ContractDifficulty; decker: Decker; + /** + * P3.M3.4: data nodes to spawn — the contract objective's `count`. + * Deliberately required (no default): a count silently diverging from the + * contract would corrupt the objective. + */ + nodeCount: number; }; export class CyberspaceLayer { @@ -100,12 +107,26 @@ export class CyberspaceLayer { * Build a fresh layer for `decker` jacking into a contract. Layout is a * deterministic function of the contract seed (see module docstring). */ - static build({ contractSeed, difficulty, decker }: CyberspaceLayerBuildOptions): CyberspaceLayer { + static build({ + contractSeed, + difficulty, + decker, + nodeCount, + }: CyberspaceLayerBuildOptions): CyberspaceLayer { if (!Number.isFinite(contractSeed)) { throw new TypeError(`CyberspaceLayer.build requires a finite contractSeed`); } + if (!Number.isInteger(nodeCount) || nodeCount <= 0) { + throw new RangeError(`CyberspaceLayer.build nodeCount must be a positive integer`); + } const rng = new Rng(contractSeed).fork('cyberspace'); const map = buildCyberMap({ rng, difficulty }); + if (nodeCount > map.nodeTiles.length) { + throw new RangeError( + `CyberspaceLayer.build: nodeCount ${nodeCount} exceeds the map's ` + + `${map.nodeTiles.length} node tiles` + ); + } const bus = new EventBus(); const world = new World(map.grid, { events: bus }); const avatar = new CyberAvatar({ @@ -120,6 +141,24 @@ export class CyberspaceLayer { const port = new EntryPort({ id: 'entry-port-0', x: map.portTile.x, y: map.portTile.y }); world.addEntity(avatar); world.addEntity(port); + // P3.M3.4: data nodes claim the *farthest* node anchors from the entry — + // the avatar has to cross the lattice (and, from P3.M3.5, the ICE + // patrolling it). Chebyshev sort is stable over the deterministic + // generator order, so placement is a pure function of the contract seed. + const sliceDifficulty = sliceDifficultyFor(difficulty); + const anchors = [...map.nodeTiles].sort( + (a, b) => chebyshevFrom(map.entryTile, b) - chebyshevFrom(map.entryTile, a) + ); + for (let i = 0; i < nodeCount; i++) { + world.addEntity( + new DataNode({ + id: `data-node-${i}`, + x: anchors[i].x, + y: anchors[i].y, + sliceDifficulty, + }) + ); + } return new CyberspaceLayer({ bus, world, @@ -182,6 +221,10 @@ export class CyberspaceLayer { } } +function chebyshevFrom(a: GridPoint, b: GridPoint): number { + return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y)); +} + function parseCoordKey(key: string): GridPoint { if (typeof key !== 'string') { throw new TypeError('CyberspaceLayer: coordinate key must be a string'); diff --git a/src/game/cyber/DataNode.ts b/src/game/cyber/DataNode.ts new file mode 100644 index 0000000..5833a1e --- /dev/null +++ b/src/game/cyber/DataNode.ts @@ -0,0 +1,112 @@ +/** + * P3.M3.4 — DataNode: the slice target the Decker's avatar came for. + * + * Interacting adds the actor's `intrusionStrength` to `sliceProgress`; the + * node is sliced once progress reaches `sliceDifficulty` (2/3/4 by contract + * difficulty). This is scope decision #4 made concrete — intrusion strength + * is *the* stat that moves the objective. Only the avatar can slice + * (capability sniff on `isCyberAvatar`, same rule as `EntryPort`: the Decker + * body also carries `intrusionStrength`, so the stat must not be the sniff). + * + * Slicing is silent — ICE detection (P3.M3.5), not the slice itself, raises + * the cyber alarm. `sliceProgress` is raw (not clamped to the threshold) so + * persisted state never loses information. + */ +import { + Interactable, + type InteractableInit, + type InteractResult, +} from '../entities/Interactable.js'; +import { AP_COST, DATA_NODE_GLYPH, type ContractDifficulty } from '../constants.js'; +import type { Entity } from '../Entity.js'; +import type { World } from '../World.js'; + +/** Intrusion needed to slice a node, by contract difficulty. */ +const SLICE_DIFFICULTY: Record = Object.freeze({ + standard: 2, + elevated: 3, + critical: 4, +}); + +export function sliceDifficultyFor(difficulty: ContractDifficulty): number { + const threshold = SLICE_DIFFICULTY[difficulty]; + if (threshold === undefined) { + throw new RangeError(`sliceDifficultyFor: unknown contract difficulty "${difficulty}"`); + } + return threshold; +} + +export interface DataNodeInit extends Omit { + label?: string; + /** Intrusion threshold to slice. Required — derived from contract difficulty. */ + sliceDifficulty: number; + sliceProgress?: number; +} + +/** P2.7.M6.2: data-node snapshot `extra`. */ +export type DataNodeSnapshot = { + label: string; + sliceDifficulty: number; + sliceProgress: number; +}; + +export class DataNode extends Interactable { + sliceDifficulty: number; + sliceProgress: number; + + constructor({ label = 'Data node', sliceDifficulty, sliceProgress = 0, ...props }: DataNodeInit) { + if (!Number.isInteger(sliceDifficulty) || sliceDifficulty <= 0) { + throw new RangeError( + `DataNode sliceDifficulty must be a positive integer, got ${sliceDifficulty}` + ); + } + if (!Number.isInteger(sliceProgress) || sliceProgress < 0) { + throw new RangeError( + `DataNode sliceProgress must be a non-negative integer, got ${sliceProgress}` + ); + } + const sliced = sliceProgress >= sliceDifficulty; + super({ + ...props, + glyph: DATA_NODE_GLYPH, + label, + secured: sliced, + armed: !sliced, + }); + this.sliceDifficulty = sliceDifficulty; + this.sliceProgress = sliceProgress; + } + + get sliced(): boolean { + return this.sliceProgress >= this.sliceDifficulty; + } + + override interact(_world: World, actor: Entity): InteractResult { + if (this.sliced) { + return { ok: false, reason: 'already-sliced', message: `${this.label}: already sliced.` }; + } + const check = this.canInteract(actor); + if (!check.ok) { + return { ok: false, reason: check.reason, message: `${this.label}: ${check.reason}.` }; + } + if ((actor as { isCyberAvatar?: boolean }).isCyberAvatar !== true) { + return { + ok: false, + reason: 'not-an-avatar', + message: `${this.label}: only the avatar can slice.`, + }; + } + actor.spendAp(AP_COST.INTERACT); + // The sniff above guarantees a CyberAvatar, which validates the stat. + this.sliceProgress += (actor as unknown as { intrusionStrength: number }).intrusionStrength; + if (this.sliced) { + this.secured = true; + this.armed = false; + return { ok: true, message: `${this.label} sliced.` }; + } + return { + ok: true, + message: `${this.label}: intrusion ${this.sliceProgress}/${this.sliceDifficulty}.`, + }; + } +} diff --git a/src/game/objectiveProgress.ts b/src/game/objectiveProgress.ts index 2f2d377..6b034d8 100644 --- a/src/game/objectiveProgress.ts +++ b/src/game/objectiveProgress.ts @@ -7,6 +7,7 @@ import { Hostile } from './Hostile.js'; import { CorpTurret } from './entities/CorpTurret.js'; import { RelayNode } from './entities/RelayNode.js'; import { EscortNpc } from './entities/EscortNpc.js'; +import { DataNode } from './cyber/DataNode.js'; import { OBJECTIVES } from './hub/Curator.js'; import { coordKey, explorationReachableKeys } from './mapConnectivity.js'; import type { Contract } from './hub/Curator.js'; @@ -30,6 +31,16 @@ export type SweepProgress = { total: number; }; +/** + * P3.M3.4: data-node tally for the `data-node-slice` objective. `Run` derives + * it per cyberspace phase (live count while active, the latch once resolved) + * and threads it into both satisfaction and the HUD chip. + */ +export type CyberNodeProgress = { + sliced: number; + required: number; +}; + export const SWEEP_QUOTA = Object.freeze({ HOSTILE_ALL: 'hostile-all', /** @deprecated Legacy save alias for HOSTILE_ALL. New contracts emit hostile-all. */ @@ -120,14 +131,32 @@ export function reconObjectiveProgress( return { mapped, required: eligible.size }; } +/** Count sliced vs. total data nodes in a (cyber) world. */ +export function dataNodeProgress(world: World | null | undefined): { + sliced: number; + total: number; +} { + if (!world) return { sliced: 0, total: 0 }; + let sliced = 0; + let total = 0; + for (const entity of world.entities.values()) { + if (!(entity instanceof DataNode)) continue; + total++; + if (entity.sliced) sliced++; + } + return { sliced, total }; +} + /** - * Progress tally for objectives that expose a fractional meter (recon, sweep). - * Returns `null` when the contract kind has no meter or `total` is zero. + * Progress tally for objectives that expose a fractional meter (recon, sweep, + * data-node slice). Returns `null` when the contract kind has no meter or + * `total` is zero. */ export function objectiveProgress( contract: Contract, world: World | null | undefined, - mapSeen: ReadonlySet | readonly string[] = [] + mapSeen: ReadonlySet | readonly string[] = [], + cyber: CyberNodeProgress | null = null ): ObjectiveProgress | null { switch (contract.objective.kind) { case OBJECTIVES.RECON: { @@ -140,6 +169,10 @@ export function objectiveProgress( if (total <= 0) return null; return { label: 'SWEEP', current: cleared, total }; } + case OBJECTIVES.DATA_NODE_SLICE: { + if (!cyber || cyber.required <= 0) return null; + return { label: 'NODES', current: cyber.sliced, total: cyber.required }; + } default: return null; } diff --git a/src/game/persistence.ts b/src/game/persistence.ts index 58fd76a..2e1ece1 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -79,6 +79,8 @@ import { BreachingCharge } from './entities/BreachingCharge.js'; import type { BreachingChargeInit } from './entities/BreachingCharge.js'; import type { CyberAvatarInit, CyberAvatarSnapshot } from './cyber/CyberAvatar.js'; import type { EntryPortInit, EntryPortSnapshot } from './cyber/EntryPort.js'; +import { DataNode } from './cyber/DataNode.js'; +import type { DataNodeInit, DataNodeSnapshot } from './cyber/DataNode.js'; import type { DeckerInit, DeckerSnapshot } from './archetypes/Decker.js'; import { Run, RUN_STATE, PATROL_ARCHETYPE_IDS } from './Run.js'; import { Campaign, CAMPAIGN_STATE, normalizeCampaignArc } from './Campaign.js'; @@ -179,6 +181,7 @@ type RestoreEntityProps = Partial< JackInPointInit & CyberAvatarInit & EntryPortInit & + DataNodeInit & DeckerInit > & { id: string; @@ -225,6 +228,7 @@ const ARCHETYPE_FACTORY: Record new JackInPoint(props as JackInPointInit), 'cyber-avatar': (props: RestoreEntityProps) => new CyberAvatar(props as CyberAvatarInit), 'entry-port': (props: RestoreEntityProps) => new EntryPort(props as EntryPortInit), + 'data-node': (props: RestoreEntityProps) => new DataNode(props as DataNodeInit), 'breaching-charge': (props: RestoreEntityProps) => new BreachingCharge(props as BreachingChargeInit), // Generic fallback so a future `Entity` subclass (NPCs, items) doesn't break @@ -371,6 +375,24 @@ function readJackInPoint(extra: EntitySnapshotExtra, id: string): JackInPointSna }; } +function readDataNode(extra: EntitySnapshotExtra, id: string): DataNodeSnapshot { + if (hasNoState(extra)) { + throw new TypeError(`restore: data node entity ${id} requires slice state`); + } + const n = extra as Partial; + if (!Number.isInteger(n.sliceDifficulty) || (n.sliceDifficulty as number) <= 0) { + throw new TypeError(`restore: data node ${id} sliceDifficulty must be a positive integer`); + } + if (!Number.isInteger(n.sliceProgress) || (n.sliceProgress as number) < 0) { + throw new TypeError(`restore: data node ${id} sliceProgress must be a non-negative integer`); + } + return { + label: requireString(n.label, `restore: data node ${id} label must be a non-empty string`), + sliceDifficulty: n.sliceDifficulty as number, + sliceProgress: n.sliceProgress as number, + }; +} + function readCyberAvatar(extra: EntitySnapshotExtra, id: string): CyberAvatarSnapshot { if (hasNoState(extra)) { throw new TypeError(`restore: cyber avatar entity ${id} requires avatar state`); @@ -645,6 +667,16 @@ const ENTITY_RESTORE: Partial> = Object. } }, }, + 'data-node': { + buildProps(extra, rec) { + return readDataNode(extra, rec.id); + }, + apply(entity, _extra, rec) { + if (!(entity instanceof DataNode)) { + throw new Error(`restore: data node entity ${rec.id} did not restore as DataNode`); + } + }, + }, tech: { // Re-apply the pre-built turret flag (default `true` from the Tech ctor when // a legacy record omits it — preserve that by only assigning when present). @@ -1229,8 +1261,10 @@ function restoreCyberspaceLayer( let avatar: CyberAvatar | null = null; let port: EntryPort | null = null; + let dataNodes = 0; for (const rec of block.entities) { const entity = restoreEntity(rec, grid); + if (entity instanceof DataNode) dataNodes++; if (entity instanceof CyberAvatar) { if (avatar) { throw new Error('restore: active cyberspace block has multiple cyber-avatar entities'); @@ -1257,6 +1291,19 @@ function restoreCyberspaceLayer( if (!port) { throw new Error('restore: active cyberspace block has no entry-port'); } + // P3.M3.4: nodes never despawn (sliced nodes stay in the world), so the + // entity count must equal the contract objective's count exactly. + const requiredNodes = (record.contract?.objective?.params as { count?: unknown } | undefined) + ?.count; + if (!Number.isInteger(requiredNodes) || (requiredNodes as number) <= 0) { + throw new TypeError('restore: cyber contract objective count must be a positive integer'); + } + if (dataNodes !== requiredNodes) { + throw new Error( + `restore: active cyberspace block has ${dataNodes} data-node entities, ` + + `contract requires ${requiredNodes}` + ); + } const layer = new CyberspaceLayer({ bus, world, avatar, port, entryTile }); layer.recordSeen(block.mapMemory.seen); diff --git a/tests/unit/game/cyber/CyberspaceLayer.test.ts b/tests/unit/game/cyber/CyberspaceLayer.test.ts index 988b728..f97a87c 100644 --- a/tests/unit/game/cyber/CyberspaceLayer.test.ts +++ b/tests/unit/game/cyber/CyberspaceLayer.test.ts @@ -28,7 +28,8 @@ const makeDecker = (overrides = {}) => new Decker({ id: 'crew-decker', x: 0, y: 0, callsign: 'Phreak', ...overrides }); const buildLayer = (contractSeed = 12345, decker = makeDecker(), difficulty = 'standard') => - CyberspaceLayer.build({ contractSeed, difficulty, decker }); + // nodeCount 1 matches the `cyber-data-spike` recipe (P3.M3.4). + CyberspaceLayer.build({ contractSeed, difficulty, decker, nodeCount: 1 }); // --- Decker cyber stats ------------------------------------------------------ @@ -69,12 +70,12 @@ test('build spawns the avatar at the entry tile with stats from the Decker', () assert.equal(avatar.baseHitChance, CYBER_AVATAR_HIT_CHANCE); }); -test('the cyber world contains exactly the avatar and the exit port', () => { +test('the cyber world contains exactly the avatar, the exit port, and the nodes', () => { const layer = buildLayer(); const entities = Array.from(layer.world.entities.values()); assert.equal(entities.filter(e => e instanceof CyberAvatar).length, 1); assert.equal(entities.filter(e => e instanceof EntryPort).length, 1); - assert.equal(entities.length, 2); + assert.equal(entities.length, 3, 'avatar + port + the contract data node (P3.M3.4)'); const cheb = Math.max( Math.abs(layer.port.x - layer.avatar.x), Math.abs(layer.port.y - layer.avatar.y) diff --git a/tests/unit/game/cyber/dataNode.test.ts b/tests/unit/game/cyber/dataNode.test.ts new file mode 100644 index 0000000..ea18e04 --- /dev/null +++ b/tests/unit/game/cyber/dataNode.test.ts @@ -0,0 +1,171 @@ +/** + * P3.M3.4 — DataNode: the slice target on the cyber grid. + * + * Interact accumulates the actor's `intrusionStrength` toward + * `sliceDifficulty` (scope decision #4: named stats with real effects). + * Only the avatar can slice (capability sniff on `isCyberAvatar`); the + * threshold scales with contract difficulty. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { DataNode, sliceDifficultyFor } from '../../../../src/game/cyber/DataNode.js'; +import { CyberAvatar } from '../../../../src/game/cyber/CyberAvatar.js'; +import { Grid } from '../../../../src/game/Grid.js'; +import { World } from '../../../../src/game/World.js'; +import { buildCrewMember } from '../../../../src/game/archetypes/index.js'; +import { AP_COST } from '../../../../src/game/constants.js'; +import { Rng } from '../../../../src/rng.js'; + +function makeWorld() { + return new World(new Grid(7, 7)); +} + +function makeAvatar(x = 1, y = 1, intrusionStrength = 2) { + return new CyberAvatar({ + id: 'cyber-avatar-0', + x, + y, + ram: 8, + intrusionStrength, + iceResistance: 1, + }); +} + +function makeNode(sliceDifficulty = 3, overrides = {}) { + return new DataNode({ id: 'data-node-0', x: 2, y: 1, sliceDifficulty, ...overrides }); +} + +// --- difficulty table --------------------------------------------------------------- + +test('sliceDifficultyFor maps contract difficulty to the slice threshold', () => { + assert.equal(sliceDifficultyFor('standard'), 2); + assert.equal(sliceDifficultyFor('elevated'), 3); + assert.equal(sliceDifficultyFor('critical'), 4); +}); + +test('sliceDifficultyFor throws on an unknown difficulty', () => { + assert.throws(() => sliceDifficultyFor('apocalyptic' as never), /difficulty/); +}); + +// --- construction --------------------------------------------------------------------- + +test('ctor validates sliceDifficulty and sliceProgress', () => { + assert.throws(() => makeNode(0), /sliceDifficulty/); + assert.throws(() => makeNode(-2), /sliceDifficulty/); + assert.throws(() => makeNode(1.5), /sliceDifficulty/); + // An absent threshold must throw, not default (no silent fallback). + assert.throws( + () => + new DataNode({ + id: 'data-node-0', + x: 2, + y: 1, + sliceDifficulty: undefined as unknown as number, + }), + /sliceDifficulty/ + ); + assert.throws(() => makeNode(3, { sliceProgress: -1 }), /sliceProgress/); + assert.throws(() => makeNode(3, { sliceProgress: 0.5 }), /sliceProgress/); +}); + +test('a fresh node starts unsliced and armed', () => { + const node = makeNode(3); + assert.equal(node.sliceProgress, 0); + assert.equal(node.sliced, false); + assert.equal(node.secured, false); + assert.equal(node.armed, true); +}); + +test('a node restored at threshold is sliced and secured', () => { + const node = makeNode(3, { sliceProgress: 4 }); + assert.equal(node.sliced, true); + assert.equal(node.secured, true); + assert.equal(node.armed, false); +}); + +// --- interact ------------------------------------------------------------------------- + +test('avatar interact debits AP and adds intrusionStrength to progress', () => { + const world = makeWorld(); + const avatar = makeAvatar(); + const node = makeNode(3); + world.addEntity(avatar); + world.addEntity(node); + + const result = node.interact(world, avatar); + assert.equal(result.ok, true); + assert.equal(avatar.ap, avatar.maxAp - AP_COST.INTERACT); + assert.equal(node.sliceProgress, 2); + assert.equal(node.sliced, false, 'below threshold stays unsliced'); + assert.match(result.message, /2\s*\/\s*3/, 'message reports intrusion progress'); +}); + +test('reaching the threshold slices the node', () => { + const world = makeWorld(); + const avatar = makeAvatar(); + const node = makeNode(3); + world.addEntity(avatar); + world.addEntity(node); + + node.interact(world, avatar); + const result = node.interact(world, avatar); + assert.equal(result.ok, true); + assert.equal(node.sliceProgress, 4, 'progress is raw, not clamped'); + assert.equal(node.sliced, true); + assert.equal(node.secured, true); + assert.equal(node.armed, false); + assert.match(result.message, /sliced/i); +}); + +test('an already-sliced node refuses without burning AP', () => { + const world = makeWorld(); + const avatar = makeAvatar(1, 1, 4); + const node = makeNode(3); + world.addEntity(avatar); + world.addEntity(node); + + assert.equal(node.interact(world, avatar).ok, true); + assert.equal(node.sliced, true); + const apBefore = avatar.ap; + const repeat = node.interact(world, avatar); + assert.equal(repeat.ok, false); + assert.equal((repeat as { reason: string }).reason, 'already-sliced'); + assert.equal(avatar.ap, apBefore); +}); + +test('a non-avatar is refused without burning AP — even a Decker', () => { + const world = makeWorld(); + // The Decker carries intrusionStrength too — the sniff must be the + // isCyberAvatar capability flag, not the stat. + const decker = buildCrewMember('decker', { x: 1, y: 1 }, new Rng(7), { id: 'crew-decker' }); + const node = makeNode(3); + world.addEntity(decker); + world.addEntity(node); + decker.refreshAp(); + + const apBefore = decker.ap; + const result = node.interact(world, decker); + assert.equal(result.ok, false); + assert.equal((result as { reason: string }).reason, 'not-an-avatar'); + assert.equal(decker.ap, apBefore); + assert.equal(node.sliceProgress, 0); +}); + +test('standard adjacency and AP gates apply', () => { + const world = makeWorld(); + const far = makeAvatar(5, 5); + const node = makeNode(3); + world.addEntity(far); + world.addEntity(node); + + const notAdjacent = node.interact(world, far); + assert.equal(notAdjacent.ok, false); + assert.equal((notAdjacent as { reason: string }).reason, 'not-adjacent'); + + const broke = makeAvatar(2, 2); + broke.spendAp(broke.ap); + const noAp = node.interact(world, broke); + assert.equal(noAp.ok, false); + assert.equal((noAp as { reason: string }).reason, 'insufficient-ap'); +}); diff --git a/tests/unit/game/cyber/dataNodeObjective.test.ts b/tests/unit/game/cyber/dataNodeObjective.test.ts new file mode 100644 index 0000000..da8464b --- /dev/null +++ b/tests/unit/game/cyber/dataNodeObjective.test.ts @@ -0,0 +1,308 @@ +/** + * P3.M3.4 — the data-node-slice objective wired through the Run. + * + * `CyberspaceLayer.build` spawns the contract's `count` nodes on the far + * node tiles; satisfaction counts sliced nodes (active), the resolved latch + * (jacked out), or zero (dormant). Jack-out before slicing leaves the + * objective permanently unsatisfiable — extraction stays gated through the + * existing abort-confirm flow. Node slice progress round-trips persistence. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Run } from '../../../../src/game/Run.js'; +import { CyberspaceLayer } from '../../../../src/game/cyber/CyberspaceLayer.js'; +import { DataNode, sliceDifficultyFor } from '../../../../src/game/cyber/DataNode.js'; +import { JackInPoint } from '../../../../src/game/entities/JackInPoint.js'; +import { snapshot, restore } from '../../../../src/game/persistence.js'; +import { buildCrewMember } from '../../../../src/game/archetypes/index.js'; +import { OBJECTIVES } from '../../../../src/game/hub/Curator.js'; +import { Rng } from '../../../../src/rng.js'; +import { testContractContext } from '../contractTestUtils.js'; +import type { World } from '../../../../src/game/World.js'; +import type { Entity } from '../../../../src/game/Entity.js'; +import type { RunEntitySnapshot, RunSnapshot } from '../../../../src/game/Run.js'; +import type { ContractDifficulty } from '../../../../src/game/constants.js'; + +const cyberContract = (seed = 12345, difficulty: ContractDifficulty = 'standard', count = 1) => ({ + seed, + objective: { + kind: OBJECTIVES.DATA_NODE_SLICE, + title: 'Spike the server farm', + briefing: 'Jack in, slice the data node, then extract.', + params: { requiresCyberspace: true, count }, + }, + difficulty, + threatCount: 1, + label: 'cyber test job', + context: testContractContext(OBJECTIVES.DATA_NODE_SLICE), + reward: { credits: 0, repDelta: 0 }, +}); + +function makeDecker() { + return buildCrewMember('decker', { x: 0, y: 0 }, new Rng(100), { id: 'crew-decker' }); +} + +function adjacentFreeTile(world: World, target: Entity) { + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + const x = target.x + dx; + const y = target.y + dy; + if (world.grid.inBounds(x, y) && world.grid.isPassable(x, y) && !world.entityAt(x, y)) { + return { x, y }; + } + } + } + throw new Error(`no free tile adjacent to ${target.id}`); +} + +function combatRun(seed = 12345, difficulty: ContractDifficulty = 'standard', count = 1) { + const run = new Run({ crewMember: makeDecker(), seed }); + run.enterBriefing(cyberContract(seed, difficulty, count)); + run.enterCombat(); + return run; +} + +function jackIn(run: Run): CyberspaceLayer { + const point = Array.from(run.world!.entities.values()).find( + e => e instanceof JackInPoint + ) as JackInPoint; + assert.ok(point, 'cyber contract placed a jack-in point'); + const spot = adjacentFreeTile(run.world!, point); + run.world!.relocateEntity(run.player!, spot.x, spot.y); + run.player!.refreshAp(); + assert.equal(point.interact(run.world!, run.player!).ok, true); + assert.equal(run.cyberspace?.phase, 'active'); + return (run.cyberspace as { phase: 'active'; layer: CyberspaceLayer }).layer; +} + +function layerNodes(layer: CyberspaceLayer): DataNode[] { + return Array.from(layer.world.entities.values()).filter( + (e): e is DataNode => e instanceof DataNode + ); +} + +/** Walk the avatar adjacent to `node` and interact until it slices. */ +function sliceNode(layer: CyberspaceLayer, node: DataNode): void { + while (!node.sliced) { + const spot = adjacentFreeTile(layer.world, node); + layer.world.relocateEntity(layer.avatar, spot.x, spot.y); + layer.avatar.refreshAp(); + const result = node.interact(layer.world, layer.avatar); + assert.equal(result.ok, true, `slice attempt failed: ${result.message}`); + } +} + +/** Walk the avatar back to the exit port and route out. */ +function routeOut(layer: CyberspaceLayer): void { + const spot = adjacentFreeTile(layer.world, layer.port); + layer.world.relocateEntity(layer.avatar, spot.x, spot.y); + layer.avatar.refreshAp(); + const result = layer.port.interact(layer.world, layer.avatar); + assert.equal(result.ok, true, `jack-out failed: ${result.message}`); +} + +function cyberEntities(record: RunSnapshot): RunEntitySnapshot[] { + const block = record.cyberspace as unknown as Record; + return block.entities as RunEntitySnapshot[]; +} + +// --- node spawning ------------------------------------------------------------------ + +test('build spawns the contract count of data nodes with the difficulty threshold', () => { + const layer = jackIn(combatRun(4242, 'standard', 1)); + const nodes = layerNodes(layer); + assert.equal(nodes.length, 1); + assert.equal(nodes[0].sliceDifficulty, sliceDifficultyFor('standard')); + assert.equal(nodes[0].sliced, false); + // The node spawned on a passable tile away from the entry point. + assert.ok(layer.world.grid.isPassable(nodes[0].x, nodes[0].y)); + const cheb = Math.max( + Math.abs(nodes[0].x - layer.entryTile.x), + Math.abs(nodes[0].y - layer.entryTile.y) + ); + assert.ok(cheb > 1, 'node does not crowd the entry tile'); +}); + +test('multi-node contracts spawn distinct nodes on distinct tiles', () => { + const layer = jackIn(combatRun(4242, 'critical', 3)); + const nodes = layerNodes(layer); + assert.equal(nodes.length, 3); + const tiles = new Set(nodes.map(n => `${n.x},${n.y}`)); + assert.equal(tiles.size, 3); + const ids = new Set(nodes.map(n => n.id)); + assert.equal(ids.size, 3); + for (const node of nodes) { + assert.equal(node.sliceDifficulty, sliceDifficultyFor('critical')); + } +}); + +test('node placement is deterministic per contract seed', () => { + const a = layerNodes(jackIn(combatRun(909, 'elevated', 2))); + const b = layerNodes(jackIn(combatRun(909, 'elevated', 2))); + assert.deepEqual( + a.map(n => ({ id: n.id, x: n.x, y: n.y })), + b.map(n => ({ id: n.id, x: n.x, y: n.y })) + ); +}); + +test('a node count beyond the map anchors throws at build', () => { + assert.throws( + () => + CyberspaceLayer.build({ + contractSeed: 1, + difficulty: 'standard', + decker: makeDecker(), + nodeCount: 99, + }), + /nodeCount|node tiles/ + ); +}); + +// --- satisfaction -------------------------------------------------------------------- + +test('dormant: objective unsatisfied, extraction gated, chip shows 0 of count', () => { + const run = combatRun(); + assert.equal(run.cyberspace?.phase, 'dormant'); + assert.equal(run.isObjectiveSatisfied(), false); + assert.equal(run.canExtract(), false); + assert.deepEqual(run.objectiveProgress(), { label: 'NODES', current: 0, total: 1 }); +}); + +test('active: slicing the node satisfies the objective and fills the chip', () => { + const run = combatRun(); + const layer = jackIn(run); + assert.equal(run.isObjectiveSatisfied(), false); + assert.deepEqual(run.objectiveProgress(), { label: 'NODES', current: 0, total: 1 }); + + sliceNode(layer, layerNodes(layer)[0]); + assert.equal(run.isObjectiveSatisfied(), true); + assert.equal(run.canExtract(), true); + assert.deepEqual(run.objectiveProgress(), { label: 'NODES', current: 1, total: 1 }); +}); + +test('multi-node: every node must slice', () => { + const run = combatRun(4242, 'critical', 3); + const layer = jackIn(run); + const nodes = layerNodes(layer); + sliceNode(layer, nodes[0]); + assert.equal(run.isObjectiveSatisfied(), false); + assert.deepEqual(run.objectiveProgress(), { label: 'NODES', current: 1, total: 3 }); + sliceNode(layer, nodes[1]); + sliceNode(layer, nodes[2]); + assert.equal(run.isObjectiveSatisfied(), true); +}); + +test('jack-out after slicing latches objectiveComplete: true', () => { + const run = combatRun(); + const layer = jackIn(run); + sliceNode(layer, layerNodes(layer)[0]); + routeOut(layer); + assert.deepEqual(run.cyberspace, { phase: 'resolved', objectiveComplete: true }); + assert.equal(run.isObjectiveSatisfied(), true); + assert.equal(run.canExtract(), true); +}); + +test('jack-out before slicing latches false — permanently unsatisfiable', () => { + const run = combatRun(); + const layer = jackIn(run); + assert.equal(layer.port.interact(layer.world, layer.avatar).ok, true); + assert.deepEqual(run.cyberspace, { phase: 'resolved', objectiveComplete: false }); + assert.equal(run.isObjectiveSatisfied(), false); + assert.equal(run.canExtract(), false, 'clean extraction stays gated (abort flow)'); + assert.deepEqual(run.objectiveProgress(), { label: 'NODES', current: 0, total: 1 }); +}); + +// --- persistence --------------------------------------------------------------------- + +test('mid-slice progress round-trips and finishes after restore', () => { + const run = combatRun(777, 'elevated', 1); // threshold 3, base intrusion 2 + const layer = jackIn(run); + const node = layerNodes(layer)[0]; + const spot = adjacentFreeTile(layer.world, node); + layer.world.relocateEntity(layer.avatar, spot.x, spot.y); + layer.avatar.refreshAp(); + assert.equal(node.interact(layer.world, layer.avatar).ok, true); + assert.equal(node.sliceProgress, 2); + assert.equal(node.sliced, false); + + const record = structuredClone(snapshot(run)); + const { run: restored } = restore(record); + assert.equal(restored.cyberspace?.phase, 'active'); + const restoredLayer = (restored.cyberspace as { phase: 'active'; layer: CyberspaceLayer }).layer; + const restoredNode = layerNodes(restoredLayer)[0]; + assert.equal(restoredNode.sliceProgress, 2); + assert.equal(restoredNode.sliceDifficulty, 3); + assert.equal(restoredNode.sliced, false); + assert.equal(restored.isObjectiveSatisfied(), false); + + sliceNode(restoredLayer, restoredNode); + assert.equal(restored.isObjectiveSatisfied(), true); +}); + +test('a sliced node round-trips as sliced', () => { + const run = combatRun(); + const layer = jackIn(run); + sliceNode(layer, layerNodes(layer)[0]); + + const { run: restored } = restore(structuredClone(snapshot(run))); + const restoredLayer = (restored.cyberspace as { phase: 'active'; layer: CyberspaceLayer }).layer; + assert.equal(layerNodes(restoredLayer)[0].sliced, true); + assert.equal(restored.isObjectiveSatisfied(), true); +}); + +test('the resolved latch round-trips satisfaction both ways', () => { + for (const complete of [true, false]) { + const run = combatRun(); + const layer = jackIn(run); + if (complete) sliceNode(layer, layerNodes(layer)[0]); + routeOut(layer); + + const { run: restored } = restore(structuredClone(snapshot(run))); + assert.deepEqual(restored.cyberspace, { phase: 'resolved', objectiveComplete: complete }); + assert.equal(restored.isObjectiveSatisfied(), complete); + } +}); + +// --- adversarial restore -------------------------------------------------------------- + +test('an active block missing its data nodes throws', () => { + const run = combatRun(); + jackIn(run); + const record = structuredClone(snapshot(run)); + const block = record.cyberspace as unknown as Record; + block.entities = cyberEntities(record).filter(e => e.archetype !== 'data-node'); + assert.throws(() => restore(record), /data.node/i); +}); + +test('a node-count mismatch against the contract throws', () => { + const run = combatRun(); + jackIn(run); + const record = structuredClone(snapshot(run)); + const block = record.cyberspace as unknown as Record; + const node = cyberEntities(record).find(e => e.archetype === 'data-node')!; + const clone = structuredClone(node); + clone.id = 'data-node-rogue'; + clone.x = node.x + 1; + (block.entities as RunEntitySnapshot[]).push(clone); + assert.throws(() => restore(record), /data.node/i); +}); + +test('malformed node extras throw', () => { + const base = () => { + const run = combatRun(); + jackIn(run); + return structuredClone(snapshot(run)); + }; + + const missingDifficulty = base(); + const node1 = cyberEntities(missingDifficulty).find(e => e.archetype === 'data-node')!; + delete (node1.extra as Record).sliceDifficulty; + assert.throws(() => restore(missingDifficulty), /sliceDifficulty/); + + const negativeProgress = base(); + const node2 = cyberEntities(negativeProgress).find(e => e.archetype === 'data-node')!; + (node2.extra as Record).sliceProgress = -1; + assert.throws(() => restore(negativeProgress), /sliceProgress/); +}); diff --git a/tests/unit/game/cyber/runJackIn.test.ts b/tests/unit/game/cyber/runJackIn.test.ts index d92b752..daf11f2 100644 --- a/tests/unit/game/cyber/runJackIn.test.ts +++ b/tests/unit/game/cyber/runJackIn.test.ts @@ -142,6 +142,7 @@ test('layer layout is independent of the jack-in turn', () => { contractSeed: 777, difficulty: 'standard', decker: makeDecker(), + nodeCount: 1, }); assert.deepEqual(Array.from(fresh.world.grid.tiles), Array.from(immediate.world.grid.tiles)); }); From 0489345a816a26ea8c2d44e6fa544c8906d9aab6 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Wed, 10 Jun 2026 14:15:03 -0700 Subject: [PATCH 20/55] =?UTF-8?q?P3.M3=20plan:=20S4=20shipped=20=E2=80=94?= =?UTF-8?q?=20progress=20row=20+=20implementation=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/phase-3-cyberspace-plan.md | 42 ++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/phase-3-cyberspace-plan.md b/docs/phase-3-cyberspace-plan.md index b03c2ba..0f9f3b7 100644 --- a/docs/phase-3-cyberspace-plan.md +++ b/docs/phase-3-cyberspace-plan.md @@ -32,7 +32,7 @@ Commits land **per green slice** (user-approved). | **S1 — P3.M3.1 Contract flag + gates** | ✅ Done | `f436330` | | **S2 — P3.M3.2 Jack-in terminal** | ✅ Done | `05013f6` | | **S3 — P3.M3.3 Cyber layer model + avatar + persistence** | ✅ Done | `39b357e` | -| **S4 — P3.M3.4 Data node objective** | 🔲 Planned | — | +| **S4 — P3.M3.4 Data node objective** | ✅ Done | `3923149` | | **S5 — Voluntary jack-out (M4.6 pull-forward)** | 🔲 Planned | — | | **S6 — P3.M3.5 Probe ICE** | 🔲 Planned | — | | **S7 — P3.M3.6 Render/input swap + shell ICE phase** | 🔲 Planned | — | @@ -160,6 +160,46 @@ Commits land **per green slice** (user-approved). - Playtest note (pre-S7): jack-in flips state internally but the shell still renders Meatspace until the S7 render/input swap — expected, not a bug. +### S4 implementation notes (shipped) + +- `src/game/cyber/DataNode.ts` — `DataNode extends Interactable`, glyph `◈` + (`DATA_NODE_GLYPH`). Interact: sliced → `already-sliced` refusal; standard + adjacency/AP gates; **avatar-only via the `isCyberAvatar` sniff** (same S3 + deviation rationale — the Decker body carries `intrusionStrength` too); + success spends interact AP and adds the avatar's `intrusionStrength` to + `sliceProgress`. Sliced at `sliceDifficulty` — `sliceDifficultyFor`: + standard 2 / elevated 3 / critical 4 (unknown throws). Progress is **raw, + never clamped**, so persistence loses nothing. Slicing is silent — the + cyber alarm belongs to ICE detection (S6), not the slice. +- `CyberspaceLayer.build` gains **required `nodeCount`** (no default — a + count silently diverging from the contract would corrupt the objective; + `Run.jackIn` passes the objective's `count`). Nodes spawn on the + *farthest* node anchors from entry (Chebyshev sort, stable over generator + order → placement is a pure function of the contract seed); + `nodeCount > nodeTiles.length` throws. +- `Run` — `ObjectiveState.cyber?: {sliced, required}` computed per phase by + `#cyberNodeProgress()`: live `dataNodeProgress(layer.world)` while active, + the resolved latch (`required`/0), zero while dormant, `undefined` on + non-cyber contracts. `DATA_NODE_SLICE` satisfaction: `sliced >= required` + (absent tally → honestly unsatisfiable). `jackOut()` latches + `objectiveComplete` from the real tally — the S3 TODO is gone; early + jack-out latches `false`, leaving extraction gated through the existing + abort-confirm flow (zero new extraction code). +- `objectiveProgress.ts` — `CyberNodeProgress` type, `dataNodeProgress(world)` + tally, optional 4th `cyber` param, `NODES` chip case. The HUD chip pipeline + is kind-generic — **no shell copy changes were needed** (the planned + describe/combatHud grep came up empty). +- Persistence — `data-node` codec in both registries (`label` / + `sliceDifficulty` / `sliceProgress`, malformed or half-populated throws); + `restoreCyberspaceLayer` now requires the active block to carry **exactly + the contract's node count** (nodes never despawn, so any drift is + corruption). +- Tests: 25 across `dataNode.test.ts` (10 — entity behavior, difficulty + table, refusal matrix) and `dataNodeObjective.test.ts` (15 — spawn + determinism, satisfaction per phase, both latch directions, mid-slice + round-trip, adversarial node-count/extra throws). Failing-first verified; + suite 1629 green. + ## Architecture decisions (approved plan) ### Cyber layer model — `CyberspaceLayer` owned by `Run`, single `TurnQueue`, both worlds tick From fab3bcb647a326887f45a9f8950b58ab98ad8977 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Wed, 10 Jun 2026 14:20:18 -0700 Subject: [PATCH 21/55] =?UTF-8?q?P3.M3=20S5:=20voluntary=20jack-out=20fina?= =?UTF-8?q?lized=20=E2=80=94=20LINK=20BURNED=20latch=20on=20the=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The P3.M4.6 pull-forward (scope decision #2), completing what S3/S4 started (resolve latch, teardown, autosave, objective tally): - JackInPoint gains the `burned` latch: `Run.jackOut()` burns the meat-side port when the avatar routes out, so re-jack-in is refused with dedicated flavor (`link-burned` / "LINK BURNED — the connection is dead") instead of the redundant-input 'already-linked'. `burn()` on an unlinked port throws (corrupt state), as does constructing one. - Snapshot extra carries `burned`; pre-S5 records restore unburned (legacy normalization), non-boolean or burned-without-linked throws. - Coverage audit of the planned S5 matrix: jackOut from dormant / on a non-cyber contract throws (new); resolved latching both ways, post-jack- out round-trip, and resolved-block smuggling were already locked by S3/S4 tests. 8 new tests (jackOut.test.ts; failing-first verified), suite at 1637 green. Co-Authored-By: Claude Fable 5 --- src/game/Run.ts | 15 +- src/game/entities/JackInPoint.ts | 34 ++++- src/game/persistence.ts | 15 +- tests/unit/game/cyber/jackOut.test.ts | 186 ++++++++++++++++++++++++ tests/unit/game/cyber/runJackIn.test.ts | 4 +- 5 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 tests/unit/game/cyber/jackOut.test.ts diff --git a/src/game/Run.ts b/src/game/Run.ts index be2aa82..7770ff0 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -897,11 +897,24 @@ export class Run { const objectiveComplete = sliced >= objectiveCount(this.contract); layer.teardown(); this.cyberspace = { phase: 'resolved', objectiveComplete }; + // S5: the link is burned — re-jack-in refused for the rest of the run. + this.#meatJackInPoint().burn(); if (this.onPersist) { this.onPersist(this.snapshot()); } } + /** The contract's single meat-side jack-in point. Missing on a cyber run is corrupt. */ + #meatJackInPoint(): JackInPoint { + if (!this.world) { + throw new Error('Run.#meatJackInPoint: no live world'); + } + for (const entity of this.world.entities.values()) { + if (entity instanceof JackInPoint) return entity; + } + throw new Error('Run.#meatJackInPoint: cyber contract has no jack-in point in the world'); + } + /** * Phase 2.9: the single hostile faction for this run, derived from the * contract principal's groups (rival-group → `RIVAL`, corp/civic → `CORP`). @@ -2011,7 +2024,7 @@ const SNAPSHOT_EXTRACTORS: Partial Enti }, 'jack-in-point': e => { const p = e as JackInPoint; - return { label: p.label, linked: p.linked } satisfies JackInPointSnapshot; + return { label: p.label, linked: p.linked, burned: p.burned } satisfies JackInPointSnapshot; }, 'cyber-avatar': e => { const a = e as CyberAvatar; diff --git a/src/game/entities/JackInPoint.ts b/src/game/entities/JackInPoint.ts index f2db958..b78d4e7 100644 --- a/src/game/entities/JackInPoint.ts +++ b/src/game/entities/JackInPoint.ts @@ -20,18 +20,31 @@ import type { World } from '../World.js'; export interface JackInPointInit extends Omit { label?: string; linked?: boolean; + burned?: boolean; } /** P2.7.M6.2: JackInPoint snapshot `extra`. */ export type JackInPointSnapshot = { label: string; linked: boolean; + burned: boolean; }; export class JackInPoint extends Interactable { linked: boolean; + /** + * P3.M3 S5: latched by `Run.jackOut()` once the avatar routes back out — + * the link is dead and re-jack-in is refused with burn flavor. A burned + * port is always a linked port (`burn()` enforces it). + */ + burned: boolean; - constructor({ label = 'Jack-in port', linked = false, ...props }: JackInPointInit) { + constructor({ + label = 'Jack-in port', + linked = false, + burned = false, + ...props + }: JackInPointInit) { super({ ...props, glyph: JACK_IN_GLYPH, @@ -39,10 +52,29 @@ export class JackInPoint extends Interactable { secured: linked, armed: !linked, }); + if (burned && !linked) { + throw new Error(`JackInPoint ${props.id}: burned without linked is corrupt state`); + } this.linked = !!linked; + this.burned = !!burned; + } + + /** Kill the link after jack-out. Burning an unlinked port is corrupt state. */ + burn(): void { + if (!this.linked) { + throw new Error(`JackInPoint ${this.id}: cannot burn an unlinked port`); + } + this.burned = true; } override interact(world: World, actor: Entity): InteractResult { + if (this.burned) { + return { + ok: false, + reason: 'link-burned', + message: `${this.label}: LINK BURNED — the connection is dead.`, + }; + } if (this.linked) { return { ok: false, reason: 'already-linked', message: `${this.label}: already linked.` }; } diff --git a/src/game/persistence.ts b/src/game/persistence.ts index 2e1ece1..48385ba 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -369,9 +369,20 @@ function readJackInPoint(extra: EntitySnapshotExtra, id: string): JackInPointSna throw new TypeError(`restore: jack-in point entity ${id} requires jack-in state`); } const p = extra as Partial; + const linked = requireBoolean(p.linked, `restore: jack-in point ${id} linked must be boolean`); + // S5 added the burn latch; absent on pre-S5 records → unburned (legacy + // normalization). Present-but-malformed, or burned without linked, throws. + const burned = + p.burned === undefined + ? false + : requireBoolean(p.burned, `restore: jack-in point ${id} burned must be boolean`); + if (burned && !linked) { + throw new Error(`restore: jack-in point ${id} is burned but not linked — corrupt state`); + } return { label: requireString(p.label, `restore: jack-in point ${id} label must be a non-empty string`), - linked: requireBoolean(p.linked, `restore: jack-in point ${id} linked must be boolean`), + linked, + burned, }; } @@ -735,7 +746,7 @@ const ENTITY_RESTORE: Partial> = Object. 'jack-in-point': { buildProps(extra, rec) { const p = readJackInPoint(extra, rec.id); - return { label: p.label, linked: p.linked }; + return { label: p.label, linked: p.linked, burned: p.burned }; }, apply(entity, _extra, rec) { if (!(entity instanceof JackInPoint)) { diff --git a/tests/unit/game/cyber/jackOut.test.ts b/tests/unit/game/cyber/jackOut.test.ts new file mode 100644 index 0000000..8a5066f --- /dev/null +++ b/tests/unit/game/cyber/jackOut.test.ts @@ -0,0 +1,186 @@ +/** + * P3.M3 S5 — voluntary jack-out finalization (P3.M4.6 pull-forward). + * + * The resolve core (latch, teardown, autosave) landed in S3/S4; this file + * covers the remaining matrix: illegal-phase throws and the LINK BURNED + * latch on the meat-side port — once the avatar routes out, the link is + * dead and re-jack-in is refused with burn flavor, across persistence. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Run } from '../../../../src/game/Run.js'; +import { CyberspaceLayer } from '../../../../src/game/cyber/CyberspaceLayer.js'; +import { JackInPoint } from '../../../../src/game/entities/JackInPoint.js'; +import { snapshot, restore } from '../../../../src/game/persistence.js'; +import { buildCrewMember } from '../../../../src/game/archetypes/index.js'; +import { OBJECTIVES } from '../../../../src/game/hub/Curator.js'; +import { Rng } from '../../../../src/rng.js'; +import { testContractContext } from '../contractTestUtils.js'; +import type { World } from '../../../../src/game/World.js'; +import type { Entity } from '../../../../src/game/Entity.js'; +import type { RunEntitySnapshot, RunSnapshot } from '../../../../src/game/Run.js'; + +const cyberContract = (seed = 12345) => ({ + seed, + objective: { + kind: OBJECTIVES.DATA_NODE_SLICE, + title: 'Spike the server farm', + briefing: 'Jack in, slice the data node, then extract.', + params: { requiresCyberspace: true, count: 1 }, + }, + difficulty: 'standard', + threatCount: 1, + label: 'cyber test job', + context: testContractContext(OBJECTIVES.DATA_NODE_SLICE), + reward: { credits: 0, repDelta: 0 }, +}); + +function makeDecker() { + return buildCrewMember('decker', { x: 0, y: 0 }, new Rng(100), { id: 'crew-decker' }); +} + +function adjacentFreeTile(world: World, target: Entity) { + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + const x = target.x + dx; + const y = target.y + dy; + if (world.grid.inBounds(x, y) && world.grid.isPassable(x, y) && !world.entityAt(x, y)) { + return { x, y }; + } + } + } + throw new Error(`no free tile adjacent to ${target.id}`); +} + +function combatRun(seed = 12345) { + const run = new Run({ crewMember: makeDecker(), seed }); + run.enterBriefing(cyberContract(seed)); + run.enterCombat(); + return run; +} + +function meatPort(run: Run): JackInPoint { + const point = Array.from(run.world!.entities.values()).find(e => e instanceof JackInPoint); + assert.ok(point, 'cyber contract placed a jack-in point'); + return point as JackInPoint; +} + +function jackIn(run: Run): CyberspaceLayer { + const point = meatPort(run); + const spot = adjacentFreeTile(run.world!, point); + run.world!.relocateEntity(run.player!, spot.x, spot.y); + run.player!.refreshAp(); + assert.equal(point.interact(run.world!, run.player!).ok, true); + return (run.cyberspace as { phase: 'active'; layer: CyberspaceLayer }).layer; +} + +function routeOut(layer: CyberspaceLayer): void { + const spot = adjacentFreeTile(layer.world, layer.port); + layer.world.relocateEntity(layer.avatar, spot.x, spot.y); + layer.avatar.refreshAp(); + assert.equal(layer.port.interact(layer.world, layer.avatar).ok, true); +} + +function portRecord(record: RunSnapshot): RunEntitySnapshot { + const rec = record.entities.find(e => e.archetype === 'jack-in-point'); + assert.ok(rec, 'snapshot carries the jack-in point'); + return rec!; +} + +// --- illegal phases ----------------------------------------------------------------- + +test('jackOut while dormant throws (no layer to resolve)', () => { + const run = combatRun(); + assert.equal(run.cyberspace?.phase, 'dormant'); + assert.throws(() => run.jackOut(), /phase/); +}); + +test('jackOut on a non-cyber contract throws', () => { + const run = new Run({ crewMember: makeDecker(), seed: 999 }); + run.enterBriefing({ + seed: 999, + objective: { kind: OBJECTIVES.REACH_EXIT, title: 'Extract clean', briefing: 'Reach exit.' }, + difficulty: 'standard', + threatCount: 1, + label: 'meat job', + context: testContractContext(OBJECTIVES.REACH_EXIT), + reward: { credits: 0, repDelta: 0 }, + }); + run.enterCombat(); + assert.equal(run.cyberspace, null); + assert.throws(() => run.jackOut(), /phase/); +}); + +// --- LINK BURNED --------------------------------------------------------------------- + +test('jack-out burns the meat-side port', () => { + const run = combatRun(); + const layer = jackIn(run); + const point = meatPort(run); + assert.equal(point.burned, false, 'link is live while the avatar is in the grid'); + + routeOut(layer); + assert.equal(run.cyberspace?.phase, 'resolved'); + assert.equal(point.burned, true); + + run.player!.refreshAp(); + const apBefore = run.player!.ap; + const repeat = point.interact(run.world!, run.player!); + assert.equal(repeat.ok, false); + assert.equal((repeat as { reason: string }).reason, 'link-burned'); + assert.match(repeat.message, /LINK BURNED/); + assert.equal(run.player!.ap, apBefore, 'refusal burns no AP'); + assert.equal(run.cyberspace?.phase, 'resolved', 'the layer does not reactivate'); +}); + +test('burn() on an unlinked port is corrupt state and throws', () => { + const point = new JackInPoint({ id: 'jack-in-0', x: 1, y: 1 }); + assert.throws(() => point.burn(), /link/i); +}); + +// --- persistence ---------------------------------------------------------------------- + +test('the burned latch round-trips and the restored port stays dead', () => { + const run = combatRun(); + routeOut(jackIn(run)); + + const record = structuredClone(snapshot(run)); + assert.equal((portRecord(record).extra as { burned?: boolean }).burned, true); + + const { run: restored } = restore(record); + const point = meatPort(restored); + assert.equal(point.burned, true); + restored.player!.refreshAp(); + const repeat = point.interact(restored.world!, restored.player!); + assert.equal(repeat.ok, false); + assert.equal((repeat as { reason: string }).reason, 'link-burned'); +}); + +test('a pre-S5 record without the burned flag restores unburned', () => { + const run = combatRun(); + jackIn(run); + const record = structuredClone(snapshot(run)); + delete (portRecord(record).extra as Record).burned; + const { run: restored } = restore(record); + assert.equal(meatPort(restored).burned, false); +}); + +test('burned without linked is corrupt and throws on restore', () => { + const run = combatRun(); + jackIn(run); + const record = structuredClone(snapshot(run)); + const extra = portRecord(record).extra as Record; + extra.linked = false; + extra.burned = true; + assert.throws(() => restore(record), /burned/); +}); + +test('a non-boolean burned flag throws on restore', () => { + const run = combatRun(); + jackIn(run); + const record = structuredClone(snapshot(run)); + (portRecord(record).extra as Record).burned = 'yes'; + assert.throws(() => restore(record), /burned/); +}); diff --git a/tests/unit/game/cyber/runJackIn.test.ts b/tests/unit/game/cyber/runJackIn.test.ts index daf11f2..9ed791a 100644 --- a/tests/unit/game/cyber/runJackIn.test.ts +++ b/tests/unit/game/cyber/runJackIn.test.ts @@ -238,6 +238,8 @@ test('re-interacting the meat port after resolve stays refused (LINK BURNED)', ( run.player!.refreshAp(); const repeat = point.interact(run.world!, run.player!); assert.equal(repeat.ok, false); - assert.equal((repeat as { reason: string }).reason, 'already-linked'); + // S5: jack-out burns the link — the refusal carries burn flavor, not the + // redundant-input 'already-linked'. + assert.equal((repeat as { reason: string }).reason, 'link-burned'); assert.equal(run.cyberspace?.phase, 'resolved'); }); From 2e3db6acb29f09323a487f242a0707fca872b6c6 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Wed, 10 Jun 2026 14:20:44 -0700 Subject: [PATCH 22/55] =?UTF-8?q?P3.M3=20plan:=20S5=20shipped=20=E2=80=94?= =?UTF-8?q?=20progress=20row=20+=20implementation=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/phase-3-cyberspace-plan.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/phase-3-cyberspace-plan.md b/docs/phase-3-cyberspace-plan.md index 0f9f3b7..2cc7c91 100644 --- a/docs/phase-3-cyberspace-plan.md +++ b/docs/phase-3-cyberspace-plan.md @@ -33,7 +33,7 @@ Commits land **per green slice** (user-approved). | **S2 — P3.M3.2 Jack-in terminal** | ✅ Done | `05013f6` | | **S3 — P3.M3.3 Cyber layer model + avatar + persistence** | ✅ Done | `39b357e` | | **S4 — P3.M3.4 Data node objective** | ✅ Done | `3923149` | -| **S5 — Voluntary jack-out (M4.6 pull-forward)** | 🔲 Planned | — | +| **S5 — Voluntary jack-out (M4.6 pull-forward)** | ✅ Done | `fab3bcb` | | **S6 — P3.M3.5 Probe ICE** | 🔲 Planned | — | | **S7 — P3.M3.6 Render/input swap + shell ICE phase** | 🔲 Planned | — | | **S8 — Docs + wrap-up** | 🔲 Planned | — | @@ -200,6 +200,29 @@ Commits land **per green slice** (user-approved). round-trip, adversarial node-count/extra throws). Failing-first verified; suite 1629 green. +### S5 implementation notes (shipped) + +- Most of the planned S5 core had already landed: the resolve latch, + teardown, autosave (S3) and the real `objectiveComplete` tally (S4). The + coverage audit confirmed resolved-latching-both-ways, post-jack-out + round-trip, and resolved-block payload smuggling were locked by S3/S4 + tests; the genuinely missing rows were the dormant/non-cyber jackOut + throws (now in `jackOut.test.ts`). +- **LINK BURNED is a real latch, not copy**: `JackInPoint.burned`, set by + `Run.jackOut()` via `burn()` on the meat-side port. A burned port refuses + *before* the linked check with its own reason (`link-burned`, "LINK BURNED + — the connection is dead"), distinct from the redundant-input + `already-linked`. `burn()` on an unlinked port throws — and so does + constructing one (burned ⇒ linked is an invariant). +- Persistence: snapshot `extra` carries `burned`; absent on pre-S5 records → + unburned (legacy normalization, decker-stats pattern); non-boolean or + burned-without-linked throws. +- Tests: 8 in `jackOut.test.ts` (illegal phases, burn-on-jack-out + refusal + flavor + no-AP, burn invariants, round-trip, legacy absence, adversarial + malformed/inconsistent flags). The S3 runJackIn LINK BURNED test was + updated from the placeholder `already-linked` expectation to the real + `link-burned`. Suite 1637 green. + ## Architecture decisions (approved plan) ### Cyber layer model — `CyberspaceLayer` owned by `Run`, single `TurnQueue`, both worlds tick From 0a4687837988228329bc3e1d858b142f2f622eaf Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Wed, 10 Jun 2026 14:38:21 -0700 Subject: [PATCH 23/55] =?UTF-8?q?P3.M3.5:=20Probe=20ICE=20=E2=80=94=20trac?= =?UTF-8?q?e=20flare,=20pack=20convergence,=20the=20grid=20bites=20back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S6 of the cyberspace slice plan: - ProbeIce (glyph ¶, displayName Probe) extends PatrolHostile: explicit small stats (hp 3, melee dmg 1, sight 6 — ICE scaling is its own axis, resolveEnemyStats tiers deliberately unused). EngageSteps = trace flare then Guard-style close-and-strike: raiseAlarm({repPenalty: false}) on the *cyber* world self-gates while ALERT, so the 'trace-alarm' step fires once per alarm window; probes listen for alarms (PatrolHostile default, unlike the Lookout) so one flare converges the pack. - CyberspaceLayer.build spawns one probe per patrol ring at a seed-picked ring tile (pure function of the contract seed) and binds it to the layer bus; the restore path was already re-binding PatrolHostiles. - Registry plumbing: 'probe-ice' joins PATROL_ARCHETYPE_IDS (shares the generic patrol snapshot/restore machinery wholesale), archetypeOf, SNAPSHOT_EXTRACTORS, ARCHETYPE_FACTORY, kindFromId; 'trace-alarm' step type + corp-turn status copy line. - Death paths proven through the real combat pipeline (no synthetic emits): probe melee on a 1-hp avatar → RESULT/DEATH with cause; avatar killing a probe counts in run telemetry. ICE resistance mitigation locked to the min-1 rule (dmg 1 vs resist 1 → 1). - Cyber alarm verified decoupled from Meatspace (no ALARM on the meat bus, meat latch untouched) and ticking on meat round advances. 9 new tests (ProbeIce.test.ts; failing-first verified), suite 1646 green. Co-Authored-By: Claude Fable 5 --- src/game/Entity.ts | 1 + src/game/Run.ts | 6 + src/game/constants.ts | 2 + src/game/corpTurnStatusCopy.ts | 5 + src/game/cyber/CyberspaceLayer.ts | 16 ++ src/game/cyber/ProbeIce.ts | 81 ++++++ src/game/persistence.ts | 3 + src/types.ts | 8 + tests/unit/game/cyber/CyberspaceLayer.test.ts | 10 +- tests/unit/game/cyber/ProbeIce.test.ts | 272 ++++++++++++++++++ 10 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 src/game/cyber/ProbeIce.ts create mode 100644 tests/unit/game/cyber/ProbeIce.test.ts diff --git a/src/game/Entity.ts b/src/game/Entity.ts index 8307a6d..c914783 100644 --- a/src/game/Entity.ts +++ b/src/game/Entity.ts @@ -278,6 +278,7 @@ function kindFromId(id: string): string { if (id.startsWith('cyber-avatar')) return 'Avatar'; if (id.startsWith('entry-port')) return 'Exit Port'; if (id.startsWith('data-node')) return 'Data Node'; + if (id.startsWith('probe-ice')) return 'Probe'; if (id.includes('turret')) return 'Turret'; if (id.startsWith('crew')) return 'Operative'; return id; diff --git a/src/game/Run.ts b/src/game/Run.ts index 7770ff0..36fd613 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -83,6 +83,7 @@ import { CyberspaceLayer } from './cyber/CyberspaceLayer.js'; import { CyberAvatar } from './cyber/CyberAvatar.js'; import { EntryPort } from './cyber/EntryPort.js'; import { DataNode } from './cyber/DataNode.js'; +import { ProbeIce } from './cyber/ProbeIce.js'; import { applyMutationDeltas } from './locations.js'; import { BreachingCharge } from './entities/BreachingCharge.js'; import { ITEM_ID, getItemById } from './items.js'; @@ -180,6 +181,7 @@ export type EntityArchetypeId = | 'cyber-avatar' | 'entry-port' | 'data-node' + | 'probe-ice' | 'entity'; export type RunTelemetry = { @@ -224,6 +226,8 @@ export const PATROL_ARCHETYPE_IDS = Object.freeze([ 'lookout', 'sniper', 'medic', + // P3.M3.5: Probe ICE shares the identical patrol state-machine block. + 'probe-ice', ] as const); export type PatrolArchetypeId = (typeof PATROL_ARCHETYPE_IDS)[number]; @@ -2044,6 +2048,7 @@ const SNAPSHOT_EXTRACTORS: Partial Enti sliceProgress: n.sliceProgress, } satisfies DataNodeSnapshot; }, + 'probe-ice': e => patrolSnapshotExtra(e as PatrolHostile), }; /** @@ -2133,6 +2138,7 @@ function archetypeOf(entity: Entity): EntityArchetypeId { if (entity instanceof CyberAvatar) return 'cyber-avatar'; if (entity instanceof EntryPort) return 'entry-port'; if (entity instanceof DataNode) return 'data-node'; + if (entity instanceof ProbeIce) return 'probe-ice'; if (entity instanceof BreachingCharge) return 'breaching-charge'; if (entity instanceof Entity) return 'entity'; throw new Error(`archetypeOf: cannot classify entity ${(entity as Entity | undefined)?.id}`); diff --git a/src/game/constants.ts b/src/game/constants.ts index 199b5b2..52dba84 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -50,6 +50,8 @@ export const JACK_IN_GLYPH = 'Ω'; export const ENTRY_PORT_GLYPH = '▼'; /** P3.M3.4: the slice target on the cyber grid (`◆` is DenyTarget's). */ export const DATA_NODE_GLYPH = '◈'; +/** P3.M3.5: Probe ICE — the patrol fodder of the cyber grid. */ +export const PROBE_ICE_GLYPH = '¶'; /** Numeric tile id — one of the `TILE` values. */ export type TileId = (typeof TILE)[keyof typeof TILE]; diff --git a/src/game/corpTurnStatusCopy.ts b/src/game/corpTurnStatusCopy.ts index 47db5cc..b127918 100644 --- a/src/game/corpTurnStatusCopy.ts +++ b/src/game/corpTurnStatusCopy.ts @@ -223,6 +223,11 @@ export function formatCorpTurnStep( return `${actorLabel} abandons pursuit — resuming patrol.`; case 'alarm': return `${actorLabel} trips the facility alarm — corp net hot.`; + case 'trace-alarm': { + // P3.M3.5: Probe ICE trace flare on the cyber grid. + const targetLabel = resolve(step.target); + return `${actorLabel} flares a trace on ${targetLabel} — ICE converging.`; + } case 'spot': { const targetLabel = resolve(step.target); return `${actorLabel} marks ${targetLabel} — fire converging on your position.`; diff --git a/src/game/cyber/CyberspaceLayer.ts b/src/game/cyber/CyberspaceLayer.ts index ed110d4..760cd60 100644 --- a/src/game/cyber/CyberspaceLayer.ts +++ b/src/game/cyber/CyberspaceLayer.ts @@ -26,6 +26,7 @@ import { buildCyberMap } from './cyberMapBuild.js'; import { CyberAvatar } from './CyberAvatar.js'; import { EntryPort } from './EntryPort.js'; import { DataNode, sliceDifficultyFor } from './DataNode.js'; +import { ProbeIce } from './ProbeIce.js'; import { PatrolHostile } from '../ai/PatrolHostile.js'; import { FACTION, type ContractDifficulty, type FactionId } from '../constants.js'; import { coordKey } from '../mapConnectivity.js'; @@ -159,6 +160,21 @@ export class CyberspaceLayer { }) ); } + // P3.M3.5: one Probe per patrol ring, spawned at a seed-picked ring tile + // (the rng has fully determined the map by here, so this stays a pure + // function of the contract seed). Probes bind to the layer bus at build — + // the restore path re-binds via `restoreCyberspaceLayer`. + map.patrolRings.forEach((ring, i) => { + const spawn = ring[rng.intRange(0, ring.length)]; + const probe = new ProbeIce({ + id: `probe-ice-${i}`, + x: spawn.x, + y: spawn.y, + patrolWaypoints: ring, + }); + world.addEntity(probe); + probe.bindToBus(bus); + }); return new CyberspaceLayer({ bus, world, diff --git a/src/game/cyber/ProbeIce.ts b/src/game/cyber/ProbeIce.ts new file mode 100644 index 0000000..27e9489 --- /dev/null +++ b/src/game/cyber/ProbeIce.ts @@ -0,0 +1,81 @@ +/** + * P3.M3.5 — Probe ICE: the patrol fodder of the cyber grid. + * + * Walks a perimeter ring around a data-node room via the shared + * patrol → investigate → engage machine. Its identity is the **trace + * flare**: on acquiring the avatar it raises the *cyber* world's alarm + * (`repPenalty: false` — job noise on a private bus, never a social cost) + * so the whole pack converges, then closes and strikes like a Guard. + * Unlike the Lookout it *listens* for alarms (the `PatrolHostile` default): + * one probe's flare is every probe's heading. + * + * Stats are explicit and small — ICE scaling is its own axis (Spark and + * Guardian ICE are the follow-up slices), so `resolveEnemyStats` tiers are + * deliberately not reused. The avatar's `iceResistance` mitigates the weak + * strike down to the engine-wide minimum of 1. + */ +import { PatrolHostile, type PatrolHostileInit, type EngageSteps } from '../ai/PatrolHostile.js'; +import { AP_COST, PROBE_ICE_GLYPH } from '../constants.js'; +import { canMelee, resolveMelee } from '../Combat.js'; +import type { Entity } from '../Entity.js'; +import type { World } from '../World.js'; +import type { Rng } from '../../rng.js'; + +export const PROBE_ICE_HP = 3; +export const PROBE_ICE_DAMAGE = 1; +export const PROBE_ICE_SIGHT_RANGE = 6; + +export interface ProbeIceProps extends Omit {} + +export class ProbeIce extends PatrolHostile { + /** Sniffed by `Combat.attackerMeleeDamage` — the weak ICE strike. */ + readonly meleeDamage = PROBE_ICE_DAMAGE; + + constructor({ + maxHp = PROBE_ICE_HP, + sightRange = PROBE_ICE_SIGHT_RANGE, + patrolWaypoints, + ...props + }: ProbeIceProps) { + super({ + ...props, + maxHp, + sightRange, + glyph: PROBE_ICE_GLYPH, + displayName: 'Probe', + patrolWaypoints, + }); + } + + /** + * Trace flare, then Guard-style close-and-strike. `raiseAlarm` self-gates + * (returns false while the alarm is already ALERT), so the flare step is + * yielded exactly once per alarm window. + */ + protected override *engageSteps(world: World, rng: Rng, target: Entity): EngageSteps { + const raised = world.raiseAlarm({ + source: this, + target, + origin: { x: this.x, y: this.y }, + repPenalty: false, + }); + if (raised) { + yield { type: 'trace-alarm', target: target.id }; + } + + const meleeCheck = canMelee(world, this, target); + if (meleeCheck.ok) { + const result = resolveMelee(world, this, target, rng); + yield { type: 'melee', target: target.id, result }; + return 'continue'; + } + if (meleeCheck.reason !== 'not-adjacent' && meleeCheck.reason !== 'insufficient-ap') { + return 'break'; + } + if (this.ap < AP_COST.MOVE) return 'break'; + const step = this.stepToward(world, target.x, target.y, 'engage'); + if (!step) return 'break'; + yield step; + return 'continue'; + } +} diff --git a/src/game/persistence.ts b/src/game/persistence.ts index 48385ba..9d59a07 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -81,6 +81,8 @@ import type { CyberAvatarInit, CyberAvatarSnapshot } from './cyber/CyberAvatar.j import type { EntryPortInit, EntryPortSnapshot } from './cyber/EntryPort.js'; import { DataNode } from './cyber/DataNode.js'; import type { DataNodeInit, DataNodeSnapshot } from './cyber/DataNode.js'; +import { ProbeIce } from './cyber/ProbeIce.js'; +import type { ProbeIceProps } from './cyber/ProbeIce.js'; import type { DeckerInit, DeckerSnapshot } from './archetypes/Decker.js'; import { Run, RUN_STATE, PATROL_ARCHETYPE_IDS } from './Run.js'; import { Campaign, CAMPAIGN_STATE, normalizeCampaignArc } from './Campaign.js'; @@ -229,6 +231,7 @@ const ARCHETYPE_FACTORY: Record new CyberAvatar(props as CyberAvatarInit), 'entry-port': (props: RestoreEntityProps) => new EntryPort(props as EntryPortInit), 'data-node': (props: RestoreEntityProps) => new DataNode(props as DataNodeInit), + 'probe-ice': (props: RestoreEntityProps) => new ProbeIce(props as ProbeIceProps), 'breaching-charge': (props: RestoreEntityProps) => new BreachingCharge(props as BreachingChargeInit), // Generic fallback so a future `Entity` subclass (NPCs, items) doesn't break diff --git a/src/types.ts b/src/types.ts index eb74f15..f64f750 100644 --- a/src/types.ts +++ b/src/types.ts @@ -144,6 +144,13 @@ export type JuggernautTurnStep = /** Flanker SLIDE — a silent two-tile reposition that vanishes from player view. */ export type FlankerTurnStep = { type: 'slide'; to: GridPoint }; +/** + * Probe ICE trace flare (P3.M3.5) — yielded the moment a probe's acquisition + * actually raises the cyber alarm (the raise self-gates while already ALERT), + * dragging every listening probe onto the avatar. + */ +export type ProbeIceTurnStep = { type: 'trace-alarm'; target: string }; + /** NeutralCivilian aftermath steps — yielded during the player aftermath phase. */ export type NeutralCivilianTurnStep = | { type: 'neutral-idle' } @@ -164,6 +171,7 @@ export type TurnActionStep = | SniperTurnStep | JuggernautTurnStep | FlankerTurnStep + | ProbeIceTurnStep | NeutralCivilianTurnStep; /** diff --git a/tests/unit/game/cyber/CyberspaceLayer.test.ts b/tests/unit/game/cyber/CyberspaceLayer.test.ts index f97a87c..7d54a80 100644 --- a/tests/unit/game/cyber/CyberspaceLayer.test.ts +++ b/tests/unit/game/cyber/CyberspaceLayer.test.ts @@ -70,12 +70,18 @@ test('build spawns the avatar at the entry tile with stats from the Decker', () assert.equal(avatar.baseHitChance, CYBER_AVATAR_HIT_CHANCE); }); -test('the cyber world contains exactly the avatar, the exit port, and the nodes', () => { +test('the cyber world census: avatar, exit port, nodes, ring probes', () => { const layer = buildLayer(); const entities = Array.from(layer.world.entities.values()); assert.equal(entities.filter(e => e instanceof CyberAvatar).length, 1); assert.equal(entities.filter(e => e instanceof EntryPort).length, 1); - assert.equal(entities.length, 3, 'avatar + port + the contract data node (P3.M3.4)'); + assert.equal( + entities.length, + // avatar + port + the contract data node (P3.M3.4) + one Probe per + // patrol ring (P3.M3.5). + 3 + layer.patrolRings.length, + 'no surprise entities on the cyber grid' + ); const cheb = Math.max( Math.abs(layer.port.x - layer.avatar.x), Math.abs(layer.port.y - layer.avatar.y) diff --git a/tests/unit/game/cyber/ProbeIce.test.ts b/tests/unit/game/cyber/ProbeIce.test.ts new file mode 100644 index 0000000..02ad293 --- /dev/null +++ b/tests/unit/game/cyber/ProbeIce.test.ts @@ -0,0 +1,272 @@ +/** + * P3.M3.5 — Probe ICE: the first hostile on the cyber grid. + * + * A weak melee patroller on the node rings. Its identity: on acquiring the + * avatar it raises the *cyber* alarm (trace flare — no rep penalty, no meat + * coupling) so the pack converges, then strikes. Avatar death routes through + * the existing DEATH/flatline path via the real combat pipeline; ICE + * resistance mitigates with the min-1 rule. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Run, RUN_STATE, OUTCOME } from '../../../../src/game/Run.js'; +import { CyberspaceLayer } from '../../../../src/game/cyber/CyberspaceLayer.js'; +import { ProbeIce } from '../../../../src/game/cyber/ProbeIce.js'; +import { PATROL_STATE } from '../../../../src/game/ai/PatrolHostile.js'; +import { JackInPoint } from '../../../../src/game/entities/JackInPoint.js'; +import { resolveMelee } from '../../../../src/game/Combat.js'; +import { EVENT } from '../../../../src/game/events.js'; +import { snapshot, restore } from '../../../../src/game/persistence.js'; +import { buildCrewMember } from '../../../../src/game/archetypes/index.js'; +import { Decker } from '../../../../src/game/archetypes/Decker.js'; +import { FACTION } from '../../../../src/game/constants.js'; +import { OBJECTIVES } from '../../../../src/game/hub/Curator.js'; +import { Rng } from '../../../../src/rng.js'; +import { testContractContext } from '../contractTestUtils.js'; +import type { World } from '../../../../src/game/World.js'; +import type { Entity } from '../../../../src/game/Entity.js'; +import type { RunResult } from '../../../../src/game/Run.js'; + +const cyberContract = (seed = 12345) => ({ + seed, + objective: { + kind: OBJECTIVES.DATA_NODE_SLICE, + title: 'Spike the server farm', + briefing: 'Jack in, slice the data node, then extract.', + params: { requiresCyberspace: true, count: 1 }, + }, + difficulty: 'standard', + threatCount: 1, + label: 'cyber test job', + context: testContractContext(OBJECTIVES.DATA_NODE_SLICE), + reward: { credits: 0, repDelta: 0 }, +}); + +function makeDecker() { + return buildCrewMember('decker', { x: 0, y: 0 }, new Rng(100), { id: 'crew-decker' }); +} + +function buildLayer(contractSeed = 12345) { + return CyberspaceLayer.build({ + contractSeed, + difficulty: 'standard', + decker: new Decker({ id: 'crew-decker', x: 0, y: 0 }), + nodeCount: 1, + }); +} + +function adjacentFreeTile(world: World, target: Entity) { + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + const x = target.x + dx; + const y = target.y + dy; + if (world.grid.inBounds(x, y) && world.grid.isPassable(x, y) && !world.entityAt(x, y)) { + return { x, y }; + } + } + } + throw new Error(`no free tile adjacent to ${target.id}`); +} + +function combatRun(seed = 12345, hooks: { onResult?: (r: RunResult) => void } = {}) { + const run = new Run({ crewMember: makeDecker(), seed, ...hooks }); + run.enterBriefing(cyberContract(seed)); + run.enterCombat(); + return run; +} + +function jackIn(run: Run): CyberspaceLayer { + const point = Array.from(run.world!.entities.values()).find( + e => e instanceof JackInPoint + ) as JackInPoint; + const spot = adjacentFreeTile(run.world!, point); + run.world!.relocateEntity(run.player!, spot.x, spot.y); + run.player!.refreshAp(); + assert.equal(point.interact(run.world!, run.player!).ok, true); + return (run.cyberspace as { phase: 'active'; layer: CyberspaceLayer }).layer; +} + +function probesOf(layer: CyberspaceLayer): ProbeIce[] { + return Array.from(layer.world.entities.values()).filter( + (e): e is ProbeIce => e instanceof ProbeIce + ); +} + +function probeKey(p: ProbeIce) { + return { id: p.id, x: p.x, y: p.y, waypoints: p.patrolWaypoints }; +} + +// --- spawning ------------------------------------------------------------------------- + +test('build spawns one probe per patrol ring with explicit ICE stats', () => { + const layer = buildLayer(); + const probes = probesOf(layer); + assert.equal(probes.length, layer.patrolRings.length); + assert.ok(probes.length > 0, 'standard difficulty patrols at least one ring'); + const ids = new Set(probes.map(p => p.id)); + assert.equal(ids.size, probes.length, 'probe ids are unique'); + for (const probe of probes) { + assert.equal(probe.glyph, '¶'); + assert.equal(probe.displayName, 'Probe'); + assert.equal(probe.maxHp, 3); + assert.equal(probe.sightRange, 6); + assert.equal(probe.meleeDamage, 1); + assert.equal(probe.faction, FACTION.CORP); + assert.ok(probe.patrolWaypoints.length > 0, 'probe walks a ring'); + assert.ok( + probe.patrolWaypoints.some(wp => wp.x === probe.x && wp.y === probe.y), + 'probe spawns on its own ring' + ); + assert.ok(layer.world.grid.isPassable(probe.x, probe.y)); + } +}); + +test('probe spawns are deterministic on the contract seed', () => { + const a = probesOf(buildLayer(909)).map(probeKey); + const b = probesOf(buildLayer(909)).map(probeKey); + assert.deepEqual(a, b); + const c = probesOf(buildLayer(910)).map(probeKey); + assert.notDeepEqual(a, c, 'a different seed diverges'); +}); + +// --- combat --------------------------------------------------------------------------- + +test('probe damage is mitigated by ICE resistance with the min-1 rule', () => { + const layer = buildLayer(); + const avatar = layer.avatar; // RAM 8, iceResistance 1 + const probe = probesOf(layer)[0]; + const spot = adjacentFreeTile(layer.world, avatar); + layer.world.relocateEntity(probe, spot.x, spot.y); + probe.refreshAp(); + + const result = resolveMelee(layer.world, probe, avatar, new Rng(1), { dodgeChance: 0 }); + assert.equal(result.hit, true); + assert.equal(result.damage, 1, 'dmg 1 vs resist 1 still chips the minimum 1'); + assert.equal(avatar.hp, avatar.maxHp - 1); +}); + +test('acquisition raises the cyber alarm and converges the pack', () => { + const layer = buildLayer(); + const avatar = layer.avatar; + const probes = probesOf(layer); + const hunter = probes[0]; + const distant = probes[probes.length - 1]; + assert.notEqual(hunter, distant); + + const spot = adjacentFreeTile(layer.world, avatar); + layer.world.relocateEntity(hunter, spot.x, spot.y); + hunter.refreshAp(); + assert.equal(distant.state, PATROL_STATE.PATROL); + + const steps = hunter.takeTurn(layer.world, new Rng(7)); + assert.equal(layer.world.alarmActive, true, 'trace flare trips the cyber alarm'); + assert.ok( + steps.some(s => s.type === 'trace-alarm'), + 'turn log carries the trace step' + ); + assert.ok( + steps.some(s => s.type === 'melee'), + 'the probe strikes after flaring' + ); + // The far probe heard the flare (alarm bus) and converges on the avatar. + assert.equal(distant.state, PATROL_STATE.ENGAGE); + assert.deepEqual(distant.lastKnownTarget, { x: avatar.x, y: avatar.y }); +}); + +test('the cyber alarm never couples to Meatspace', () => { + const run = combatRun(); + const layer = jackIn(run); + let meatAlarmEvents = 0; + run.bus!.on(EVENT.ALARM, () => meatAlarmEvents++); + + layer.world.raiseAlarm({ target: layer.avatar, repPenalty: false }); + assert.equal(layer.world.alarmActive, true); + assert.equal(run.world!.alarmActive, false, 'meat alarm latch untouched'); + assert.equal(meatAlarmEvents, 0, 'no ALARM event leaks onto the meat bus'); +}); + +test('the cyber alarm cadence ticks on meat round advances', () => { + const run = combatRun(); + const layer = jackIn(run); + layer.world.raiseAlarm({ target: layer.avatar, repPenalty: false }); + const before = layer.world.snapshotAlarm(); + + run.queue!.endTurn(run.world!); // player → corp + run.queue!.endTurn(run.world!); // corp → player: round advance ticks the cyber alarm + const after = layer.world.snapshotAlarm(); + assert.equal(after.holdTurnsRemaining, before.holdTurnsRemaining - 1); +}); + +// --- death paths ------------------------------------------------------------------------ + +test('a probe killing the avatar flatlines through the real combat pipeline', () => { + const results: RunResult[] = []; + const run = combatRun(12345, { onResult: r => results.push(r) }); + const layer = jackIn(run); + const avatar = layer.avatar; + avatar.damage(avatar.hp - 1); // one strike from death + + const probe = probesOf(layer)[0]; + const spot = adjacentFreeTile(layer.world, avatar); + layer.world.relocateEntity(probe, spot.x, spot.y); + probe.refreshAp(); + const result = resolveMelee(layer.world, probe, avatar, new Rng(1), { dodgeChance: 0 }); + assert.equal(result.killed, true); + + assert.equal(run.state, RUN_STATE.RESULT); + assert.equal(results.length, 1); + assert.equal(results[0].outcome, OUTCOME.DEATH); + assert.match(String(results[0].telemetry.cause), new RegExp(probe.id)); +}); + +test('the avatar killing a probe counts toward run telemetry', () => { + const run = combatRun(); + const layer = jackIn(run); + const probe = probesOf(layer)[0]; + const spot = adjacentFreeTile(layer.world, probe); + layer.world.relocateEntity(layer.avatar, spot.x, spot.y); + layer.avatar.refreshAp(); + const before = run.telemetry.kills ?? 0; + + const result = resolveMelee(layer.world, layer.avatar, probe, new Rng(1), { + dodgeChance: 0, + damage: probe.hp, + }); + assert.equal(result.killed, true); + assert.equal(probe.alive, false); + assert.equal(run.telemetry.kills, before + 1); +}); + +// --- persistence -------------------------------------------------------------------------- + +test('probe patrol state round-trips and restored probes are re-bound', () => { + const run = combatRun(); + const layer = jackIn(run); + const probe = probesOf(layer)[0]; + // Dirty the state machine: mid-pursuit, off-ring, chipped. + const spot = adjacentFreeTile(layer.world, layer.avatar); + layer.world.relocateEntity(probe, spot.x, spot.y); + probe.state = PATROL_STATE.ENGAGE; + probe.lastKnownTarget = { x: layer.avatar.x, y: layer.avatar.y }; + probe.patrolIndex = 1 % probe.patrolWaypoints.length; + probe.damage(1); + + const { run: restored } = restore(structuredClone(snapshot(run))); + const restoredLayer = (restored.cyberspace as { phase: 'active'; layer: CyberspaceLayer }).layer; + const twin = probesOf(restoredLayer).find(p => p.id === probe.id); + assert.ok(twin instanceof ProbeIce); + assert.deepEqual( + { x: twin!.x, y: twin!.y, state: twin!.state, idx: twin!.patrolIndex, hp: twin!.hp }, + { x: probe.x, y: probe.y, state: probe.state, idx: probe.patrolIndex, hp: probe.hp } + ); + assert.deepEqual(twin!.lastKnownTarget, probe.lastKnownTarget); + assert.deepEqual(twin!.patrolWaypoints, probe.patrolWaypoints); + + // Re-binding: a fresh cyber alarm drags a still-patrolling probe to ENGAGE. + const patroller = probesOf(restoredLayer).find(p => p.state === PATROL_STATE.PATROL); + assert.ok(patroller, 'another probe is still on its ring'); + restoredLayer.world.raiseAlarm({ target: restoredLayer.avatar, repPenalty: false }); + assert.equal(patroller!.state, PATROL_STATE.ENGAGE); +}); From 27ab16feb34f57ddbddda9f5f1f65b9ae15c6c44 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Wed, 10 Jun 2026 14:39:09 -0700 Subject: [PATCH 24/55] =?UTF-8?q?P3.M3=20plan:=20S6=20shipped=20=E2=80=94?= =?UTF-8?q?=20progress=20row,=20notes,=20ICE-faction=20follow-up=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/phase-3-cyberspace-plan.md | 39 ++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/phase-3-cyberspace-plan.md b/docs/phase-3-cyberspace-plan.md index 2cc7c91..b61de11 100644 --- a/docs/phase-3-cyberspace-plan.md +++ b/docs/phase-3-cyberspace-plan.md @@ -34,7 +34,7 @@ Commits land **per green slice** (user-approved). | **S3 — P3.M3.3 Cyber layer model + avatar + persistence** | ✅ Done | `39b357e` | | **S4 — P3.M3.4 Data node objective** | ✅ Done | `3923149` | | **S5 — Voluntary jack-out (M4.6 pull-forward)** | ✅ Done | `fab3bcb` | -| **S6 — P3.M3.5 Probe ICE** | 🔲 Planned | — | +| **S6 — P3.M3.5 Probe ICE** | ✅ Done | `0a46878` | | **S7 — P3.M3.6 Render/input swap + shell ICE phase** | 🔲 Planned | — | | **S8 — Docs + wrap-up** | 🔲 Planned | — | @@ -223,6 +223,43 @@ Commits land **per green slice** (user-approved). updated from the placeholder `already-linked` expectation to the real `link-burned`. Suite 1637 green. +### S6 implementation notes (shipped) + +- `src/game/cyber/ProbeIce.ts` — `ProbeIce extends PatrolHostile`, glyph `¶` + (`PROBE_ICE_GLYPH`), displayName `Probe`. Explicit stats + (`PROBE_ICE_HP 3` / `PROBE_ICE_DAMAGE 1` via the `meleeDamage` sniff / + `PROBE_ICE_SIGHT_RANGE 6`); `resolveEnemyStats` tiers deliberately unused — + ICE scaling is its own axis for the Spark/Guardian follow-ups. +- **Trace flare**: `engageSteps` raises the *cyber* world's alarm + (`repPenalty: false`) before striking; `raiseAlarm` self-gates while + ALERT so the `trace-alarm` step (new `ProbeIceTurnStep` in `types.ts`, + with corp-turn status copy) fires once per alarm window. Probes keep the + `PatrolHostile` default `listensForAlarm() === true` — one probe's flare + is every probe's heading (verified: a distant patroller flips to ENGAGE + with the avatar's coordinates). +- Spawning: one probe per `buildCyberMap` patrol ring, at a seed-picked ring + tile (rng consumed *after* map generation, so layouts are untouched and + placement stays a pure function of the contract seed). Build binds probes + to the layer bus; restore re-binds via the existing + `restoreCyberspaceLayer` PatrolHostile path. +- Persistence rode the generic machinery: adding `'probe-ice'` to + `PATROL_ARCHETYPE_IDS` bought waypoints/state/index round-trip wholesale; + only `ARCHETYPE_FACTORY` + extractor entries were new. +- Death paths proven through the **real combat pipeline** (resolveMelee on + the cyber world's bus, no synthetic emits): probe kills avatar → + RESULT/DEATH with attacker in the cause; avatar kills probe → telemetry + kill count. Min-1 mitigation locked (dmg 1 vs iceResistance 1 → 1). +- Decoupling verified: cyber alarm raises emit nothing on the meat bus and + leave the meat latch untouched; the cyber alarm cadence ticks once per + meat round advance (proves the single-TurnQueue wiring). +- **Noted for later**: probes default `FACTION.CORP`. The `cyber-data-spike` + recipe locks principals to corp, so this matches `run.hostileFaction` + today — but a future rival-principal cyber recipe would leave ICE AP + unrefreshed (the queue ticks PLAYER/RIVAL only). Revisit when a second + cyber recipe lands (stamp `hostileFaction` onto ICE at `jackIn`, or + validate at build). +- Tests: 9 in `ProbeIce.test.ts`. Failing-first verified; suite 1646 green. + ## Architecture decisions (approved plan) ### Cyber layer model — `CyberspaceLayer` owned by `Run`, single `TurnQueue`, both worlds tick From dd17cb83285d3198b32b8046b376a1f4419674a4 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Thu, 11 Jun 2026 12:04:07 -0700 Subject: [PATCH 25/55] =?UTF-8?q?P3.M3.6=20S7:=20render/input=20swap=20?= =?UTF-8?q?=E2=80=94=20the=20shell=20enters=20the=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cyber tileset axis in palette (glyphForTile tileset param, throws on unknown), buildFrame/AsciiRenderer threading, RAM-labeled avatar HUD, // THE GRID // location line, active-view seam (run.activeWorld / activeActor) through applyIntent + look/describe + vision, and the chained dual-phase corp turn (meat hostiles, then ICE) locked by dualPhaseTurn.test.ts shared-rng order assertions. kaizen.md: index.ts complexity findings recorded for post-M3 cleanup (ShellScene casts, statusLine extraction, listener rewire dedupe, bus listener-order coupling, active-view seam enforcement). Co-Authored-By: Claude Fable 5 --- docs/kaizen.md | 13 + index.ts | 419 ++++++++++++++++---- src/input/applyIntent.ts | 24 +- src/render/combatHud.ts | 6 +- src/render/frame.ts | 25 +- src/render/palette.ts | 29 +- tests/unit/game/cyber/dualPhaseTurn.test.ts | 186 +++++++++ tests/unit/render/cyberTileset.test.ts | 77 ++++ 8 files changed, 683 insertions(+), 96 deletions(-) create mode 100644 tests/unit/game/cyber/dualPhaseTurn.test.ts create mode 100644 tests/unit/render/cyberTileset.test.ts diff --git a/docs/kaizen.md b/docs/kaizen.md index 7aa124b..bead4cd 100644 --- a/docs/kaizen.md +++ b/docs/kaizen.md @@ -38,6 +38,19 @@ When an item lands, gets reclassified, or develops new context, edit it in place - **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: + - **ShellScene = Campaign | Run union** forces isRun() casts everywhere — ~15 sites narrow-and-cast; a SceneView + interface both classes implement would delete most of them. + - **statusLine()** is a ~110-line HTML string builder mixing a11y, alarm tags, hub identity, corp mood, and + activity-row layout — prime extraction target (pure function over a snapshot, like combatHud.ts already does). + - **Four duplicated attachVisionListener/Animation/Rep/Cyber blocks at every scene transition** — one + rewireSceneListeners() would collapse them and prevent the "forgot to add the new attach call" failure mode (I + had to touch all four for S7). + - **Listener-order coupling on the bus**: the shell's JACK_IN/JACK_OUT handlers depend on Run subscribing first + (true today, enforced nowhere). A bus.on(..., {priority}) or explicit shell callbacks on Run would make it + structural. + - **run.world/run.player direct reads remain legal** — the new active-view seam is convention, not + compiler-enforced; a lint rule or accessor deprecation would lock it in. ## ◇ Monitored diff --git a/index.ts b/index.ts index 21aeced..e965395 100644 --- a/index.ts +++ b/index.ts @@ -51,6 +51,9 @@ import { import { EVENT, alarmPayloadTriggersRepPenalty } from '/src/game/events.js'; import { VisionField } from '/src/game/Vision.js'; import { describeTileAt } from '/src/game/describe.js'; +import type { CyberspaceLayer } from '/src/game/cyber/CyberspaceLayer.js'; +import type { World } from '/src/game/World.js'; +import type { TilesetId } from '/src/render/palette.js'; import { AsciiRenderer } from '/src/render/AsciiRenderer.js'; import { CrtFilter } from '/src/render/CrtFilter.js'; import { @@ -251,6 +254,14 @@ function maybeDevFault(site: string): void { let campaign: Campaign | null = null; let vision = new VisionField(); +/** + * P3.M3.6: the cyber grid's own fog field — `vision` stays Meatspace-truth + * for the body (aftermath visibility, meat corpse memory) while the avatar + * sees through this one. Rebuilt (and re-seeded from `layer.mapSeen`) every + * time the cyber listeners attach. + */ +let cyberVision = new VisionField(); +let cyberUnsubs: (() => void)[] = []; let visionMoveUnsub: (() => void) | null = null; let repUnsubs: (() => void)[] = []; let civilianHarmsThisJob = 0; @@ -420,6 +431,40 @@ function isRun(scene: ShellScene): scene is Run { return 'archetype' in scene; } +// --------------------------------------------------------------------------- +// P3.M3.6 — the active-view seam. While the Decker is jacked in, every +// presentation/input surface looks *through* these helpers at the cyber +// layer; in Meatspace (and the Hub) they are identity functions. This is the +// single place the world/actor/vision/tileset swap happens — shell code +// below should never reach for `run.world`/`run.player` directly on a +// surface the player is looking at. +// --------------------------------------------------------------------------- + +function cyberLayerOf(scene: ShellScene | null): CyberspaceLayer | null { + if (!scene || !isRun(scene)) return null; + return scene.cyberspace?.phase === 'active' ? scene.cyberspace.layer : null; +} + +function isJackedIn(scene: ShellScene | null): boolean { + return cyberLayerOf(scene) !== null; +} + +function activeWorldOf(scene: ShellScene): World | null { + return cyberLayerOf(scene)?.world ?? scene.world; +} + +function activeActorOf(scene: ShellScene): Entity | null { + return cyberLayerOf(scene)?.avatar ?? scene.player; +} + +function activeVisionField(scene: ShellScene | null): VisionField { + return isJackedIn(scene) ? cyberVision : vision; +} + +function activeTileset(scene: ShellScene | null): TilesetId { + return isJackedIn(scene) ? 'cyber' : 'meat'; +} + // --------------------------------------------------------------------------- // Boot // --------------------------------------------------------------------------- @@ -734,6 +779,7 @@ function enterHubAndRender() { attachVisionListener(); attachAnimationListeners(); attachRepListeners(); + attachCyberListeners(); recomputeVision(); renderShell(); if (!presentHubRevealIfAny('HUB — Curator has contracts when you are adjacent [Space].')) { @@ -861,6 +907,7 @@ function onBriefingDeploy(evt: Event) { attachVisionListener(); attachAnimationListeners(); attachRepListeners(); + attachCyberListeners(); recomputeVision(); flash('JACKED IN. Reach the exit tile (¤) before the corpos drop you.'); renderShell(); @@ -1102,6 +1149,14 @@ function applyUseConsumableResult( */ function resolveAimedUseItem(aim: { dx: number; dy: number }, run: Run): void { if (!run.world || !run.player) throw new Error('[shell] resolveAimedUseItem: no scene'); + // P3.M3.6: items are meat gear; the inventory gate blocks new aims while + // jacked in, but an aim pending across the jack-in interact must also die. + if (isJackedIn(run)) { + pendingAimItemId = null; + flash('USE FAILED: jacked in — your gear is back in Meatspace.'); + resetInputModes(); + return; + } const itemId = pendingAimItemId; if (!itemId) { // Direction press arrived without a stashed item — shouldn't be reachable @@ -1291,6 +1346,7 @@ function resumeCampaign(record: CampaignSnapshot | unknown) { attachVisionListener(); attachAnimationListeners(); attachRepListeners(); + attachCyberListeners(); recomputeVision(); if (campaign.activeRun?.state === RUN_STATE.BRIEFING && campaign.activeRun.contract) { briefingEl.setContract(campaign.activeRun.contract); @@ -1382,7 +1438,9 @@ function enterLookMode(): void { const run = currentScene(); if (!run?.world || !run.player) return; if (lookCursor) return; - lookCursor = { x: run.player.x, y: run.player.y }; + const actor = activeActorOf(run); + if (!actor) return; + lookCursor = { x: actor.x, y: actor.y }; keyboard.mode = MODE.LOOK; keyboard.aimKind = null; if (touchPadEl.mode !== MODE.LOOK) touchPadEl.setMode(MODE.LOOK); @@ -1406,18 +1464,23 @@ function handleLookMove(dx = 0, dy = 0): void { } const run = currentScene(); if (!run?.world || !run.player) return; + // P3.M3.6: the look cursor roams whichever grid is on screen. + const world = activeWorldOf(run); + const actor = activeActorOf(run); + if (!world || !actor) return; + const fog = activeVisionField(run); if (!lookCursor) { - lookCursor = { x: run.player.x, y: run.player.y }; + lookCursor = { x: actor.x, y: actor.y }; } - const tx = Math.max(0, Math.min(run.world.grid.width - 1, lookCursor.x + dx)); - const ty = Math.max(0, Math.min(run.world.grid.height - 1, lookCursor.y + dy)); - if (run.state === RUN_STATE.COMBAT && !vision.hasSeen(tx, ty)) { + const tx = Math.max(0, Math.min(world.grid.width - 1, lookCursor.x + dx)); + const ty = Math.max(0, Math.min(world.grid.height - 1, lookCursor.y + dy)); + if (run.state === RUN_STATE.COMBAT && !fog.hasSeen(tx, ty)) { flash("You haven't seen that tile."); return; } lookCursor = { x: tx, y: ty }; - const line = describeTileAt(run.world, tx, ty, { - vision: run.state === RUN_STATE.COMBAT ? vision : undefined, + const line = describeTileAt(world, tx, ty, { + vision: run.state === RUN_STATE.COMBAT ? fog : undefined, showStats: true, }); if (line) flash(line); @@ -1461,9 +1524,17 @@ function handleIntent(intent: Intent): void { throw new Error(`[shell] state "${run.state}" is missing playable scene wiring`); } + // P3.M3.6: intents drive whoever the player is being — the crew body in + // Meatspace, the avatar on the grid (same queue either way). + const intentWorld = activeWorldOf(run); + const intentActor = activeActorOf(run); + if (!intentWorld || !intentActor) { + throw new Error(`[shell] state "${run.state}" has no active world/actor for intents`); + } + applyIntent(intent, { - world: run.world, - player: run.player as Parameters[1]['player'], + world: intentWorld, + player: intentActor as Parameters[1]['player'], queue: run.queue, rng: run.rng, // Capture the action line for the next paint(); see lastActionLine docs. @@ -1474,7 +1545,7 @@ function handleIntent(intent: Intent): void { resolveAimedUseItem(aim, run as Run); }, onCorpseSalvaged: entity => { - vision.forgetCorpse(entity); + activeVisionField(run).forgetCorpse(entity); }, keyItems: [...(campaign?.keyItems ?? []), ...(run as Run).keyItems], onKeycardCollected: kc => { @@ -1501,6 +1572,12 @@ function handleIntent(intent: Intent): void { flash('Inventory is only available on your turn.'); return; } + // P3.M3.6: the avatar has no pockets — gear stays with the body + // until the P3.M4 simstim flip makes split control real. + if (isJackedIn(run)) { + flash('Jacked in — your meatspace gear is out of reach.'); + return; + } } presentItemInventory(); break; @@ -1568,7 +1645,11 @@ function driveCombatTurnPipeline(run: Run, options: { resumeFromCorpSlice?: bool onPlayerAftermathStep: step => { if (degrading) return; const scene = currentScene(); - if (step.type === 'breach-detonate') { + // P3.M3.6: aftermath is a Meatspace phase — while jacked in it still + // *applies*, but the canvas is showing the grid, so skip its + // presentation (overlays/shake/log lines). + const jacked = isJackedIn(scene); + if (step.type === 'breach-detonate' && !jacked) { showBreachBlastOverlay(step.charge.x, step.charge.y); if (scene?.player && vision.isVisible(step.charge.x, step.charge.y)) { triggerShake(stageEl); @@ -1576,6 +1657,7 @@ function driveCombatTurnPipeline(run: Run, options: { resumeFromCorpSlice?: bool } } if ( + !jacked && scene?.player && isPlayerAftermathStepLogVisible(step, (x, y) => vision.isVisible(x, y), scene.player.id) ) { @@ -1660,6 +1742,51 @@ function runCorpTurn(onFinish: () => void): void { if (!run.world) { throw new Error(`[shell] cannot drive corp turn without world in state "${run.state}"`); } + // P3.M3.6: while the Decker is jacked in, the corp slice chains two + // driver passes — meat hostiles on the meat world (silent: the canvas is + // showing the grid), then ICE on the cyber world. Both consume the shared + // run rng in this fixed order, keeping the turn deterministic. If the meat + // pass flatlines the body the driver bails terminally and the ICE pass + // (correctly) never runs. + const icePass = () => { + if (degrading) return; + const layer = cyberLayerOf(currentScene()); + if (!layer) { + onFinish(); + return; + } + driveCorpTurn({ + run: { state: run.state ?? '', world: layer.world, rng: run.rng }, + corpFaction: FACTION.CORP, + paint, + animLock, + actionDelayMs: CORP_ACTION_DELAY_MS, + lockMarginMs: ANIMATION_DURATIONS.MUZZLE_FLASH, + onFinish, + schedule: scheduleCombatPump, + shouldAnimateStep: (entityId: string, step: TurnActionStep) => { + if (degrading) return true; + return isCorpTurnStepVisibleToPlayer(layer.world, layer.avatar.id, entityId, step, (x, y) => + cyberVision.isVisible(x, y) + ); + }, + onStep: (entityId: string, step: TurnActionStep) => { + if (degrading) return; + const resolve = (id: string) => resolveEntityLabel(id, layer.world.entities); + const line = formatCorpTurnStep(resolve(entityId), step, resolve); + if ( + !line || + !isCorpTurnStepLogVisibleToPlayer(layer.world, layer.avatar.id, entityId, step, (x, y) => + cyberVision.isVisible(x, y) + ) + ) { + return; + } + flash(line); + }, + }); + }; + driveCorpTurn({ run: { state: run.state ?? '', @@ -1671,12 +1798,15 @@ function runCorpTurn(onFinish: () => void): void { animLock, actionDelayMs: CORP_ACTION_DELAY_MS, lockMarginMs: ANIMATION_DURATIONS.MUZZLE_FLASH, - onFinish, + onFinish: icePass, schedule: scheduleCombatPump, shouldAnimateStep: (entityId: string, step: TurnActionStep) => { if (degrading) return true; const scene = currentScene(); if (!scene?.world || !scene.player) return true; + // Jacked in: meat steps still *apply*, but the canvas is showing the + // grid — drain them silently rather than pacing invisible frames. + if (isJackedIn(scene)) return false; return isCorpTurnStepVisibleToPlayer(scene.world, scene.player.id, entityId, step, (x, y) => vision.isVisible(x, y) ); @@ -1685,6 +1815,7 @@ function runCorpTurn(onFinish: () => void): void { if (degrading) return; const scene = currentScene(); if (!scene?.world || !scene.player) return; + if (isJackedIn(scene)) return; const resolve = (id: string) => resolveEntityLabel(id, scene.world!.entities); const line = formatCorpTurnStep(resolve(entityId), step, resolve); if ( @@ -1797,38 +1928,49 @@ function handleCombatInteract(): void { const run = campaign.activeRun; if (!run || !run.player) return; if (!run.world) throw new Error('[shell] active combat run has no world'); - const player = run.player; - if (!player.inventory) throw new Error('[shell] combat player inventory is not initialised'); - // Scan the 8 neighbours plus the player's own tile for lootable corpses. - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - const tx = player.x + dx; - const ty = player.y + dy; - const entity = run.world.lootableCorpseAt(tx, ty); - if (entity && !entity.alive && entity.loot && totalSalvage(entity.loot.salvage) > 0) { - if (!player.canAfford(AP_COST.INTERACT)) { - flash('Insufficient AP to loot.'); + // P3.M3.6: interact targets the world the player is being in — meat props + // for the body, data nodes / the exit port for the avatar. + const world = activeWorldOf(run); + const player = activeActorOf(run); + if (!world || !player) throw new Error('[shell] combat interact requires an active world/actor'); + // Corpse looting needs pockets — the avatar (no inventory) skips straight + // to interactables. ICE leaves no salvage in this slice. + const inventory = 'inventory' in player ? (player as Crew).inventory : null; + if (!isJackedIn(run) && !inventory) { + throw new Error('[shell] combat player inventory is not initialised'); + } + if (inventory) { + // Scan the 8 neighbours plus the player's own tile for lootable corpses. + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const tx = player.x + dx; + const ty = player.y + dy; + const entity = world.lootableCorpseAt(tx, ty); + if (entity && !entity.alive && entity.loot && totalSalvage(entity.loot.salvage) > 0) { + if (!player.canAfford(AP_COST.INTERACT)) { + flash('Insufficient AP to loot.'); + return; + } + // Typed salvage — show pickup total + post-pickup compact wallet. + const amount = totalSalvage(entity.loot.salvage); + (player as Crew).collectSalvage(world, entity); + activeVisionField(run).forgetCorpse(entity); + flash( + `Salvaged +${amount} — carrying ${formatSalvageCompact(inventory.salvage)}. ${player.ap} AP left.` + ); + paint(); + if (player.ap === 0) { + advanceTurn(); + } return; } - // Typed salvage — show pickup total + post-pickup compact wallet. - const amount = totalSalvage(entity.loot.salvage); - player.collectSalvage(run.world, entity); - vision.forgetCorpse(entity); - flash( - `Salvaged +${amount} — carrying ${formatSalvageCompact(player.inventory.salvage)}. ${player.ap} AP left.` - ); - paint(); - if (player.ap === 0) { - advanceTurn(); - } - return; } } } - const interactable = run.world.adjacentInteractables(player)[0]; + const interactable = world.adjacentInteractables(player)[0]; if (interactable) { - const result = interactable.interact(run.world, player); + const result = interactable.interact(world, player); flash(result.message); if ( result.ok && @@ -1900,6 +2042,7 @@ function onNewRunRequested(): void { attachVisionListener(); attachAnimationListeners(); attachRepListeners(); + attachCyberListeners(); recomputeVision(); renderShell(); } @@ -1970,6 +2113,68 @@ function attachAnimationListeners(): void { run.bus.on(EVENT.DOOR_UNLOCKED, payload => { const { label = 'Door' } = (payload ?? {}) as DoorUnlockPayload; flash(`${label} unlocked — passage open.`); + }), + // P3.M3.6: Run's own JACK_IN listener (wired at enterCombat, so it runs + // first) has already spawned the layer by the time this fires — the + // shell just swaps its presentation onto the grid. + run.bus.on(EVENT.JACK_IN, () => { + attachCyberListeners(); + if (!isJackedIn(currentScene())) return; + recomputeVision(); + flash('LINK ESTABLISHED — entering // THE GRID //.'); + paint(); + }) + ); +} + +/** + * P3.M3.6: subscribe avatar-side feedback to the *cyber* bus while a layer + * is active — combat shake/flash for the avatar, muzzle flashes for ICE + * strikes, corpse memory in `cyberVision`, vision refresh on movement, and + * the jack-out swap back to Meatspace. No-op (and cleanup) when dormant or + * resolved. Called from every scene (re)wiring site plus the JACK_IN hook, + * so a mid-jack-in resume re-attaches automatically. + */ +function attachCyberListeners(): void { + for (const off of cyberUnsubs) off(); + cyberUnsubs = []; + const run = currentScene(); + const layer = cyberLayerOf(run); + if (!run || !layer) return; + cyberVision = new VisionField(); + cyberVision.restoreSeen(layer.mapSeenKeys()); + cyberUnsubs.push( + layer.bus.on(EVENT.ENTITY_MOVED, () => recomputeVision()), + layer.bus.on(EVENT.ENTITY_DAMAGED, payload => { + const { target, damage = 0, killed, source } = (payload ?? {}) as EntityDamagedPayload; + if (target === layer.avatar && damage > 0) { + triggerShake(stageEl); + triggerDamageFlash(stageEl); + animLock.push(ANIMATION_DURATIONS.DAMAGE_FLASH); + } + if (source === 'melee' && target && damage > 0) { + const fired = runMuzzleFlash(renderer, paint, target.x, target.y); + if (fired) animLock.push(ANIMATION_DURATIONS.MUZZLE_FLASH); + } + if (killed && target && cyberVision.isVisible(target.x, target.y)) { + cyberVision.memoriseCorpse(target); + } + }), + layer.bus.on(EVENT.NOISE, payload => { + const noise = (payload ?? {}) as NoisePayload; + if (noise.kind !== 'ranged') return; + const origin = noise.origin; + if (!origin) return; + const fired = runMuzzleFlash(renderer, paint, origin.x, origin.y); + if (fired) animLock.push(ANIMATION_DURATIONS.MUZZLE_FLASH); + }), + layer.bus.on(EVENT.JACK_OUT, () => { + // Run.jackOut (subscribed first) has already resolved the layer. + for (const off of cyberUnsubs) off(); + cyberUnsubs = []; + flash('LINK DROPPED — back in your body.'); + recomputeVision(); + paint(); }) ); } @@ -2024,12 +2229,23 @@ function attachRepListeners(): void { function recomputeVision(): void { const run = currentScene(); - if (!run || !run.world || !run.player) return; - vision.recompute(run.world.grid, run.player, undefined, { - blockers: run.world.blockerKeys(), - }); - if (isRun(run) && run.state === RUN_STATE.COMBAT) { - run.recordMapSeen(vision.visible); + if (!run) return; + // Meatspace vision stays live even while jacked in — the body still + // stands at the port and aftermath/corpse-memory checks read this field. + if (run.world && run.player) { + vision.recompute(run.world.grid, run.player, undefined, { + blockers: run.world.blockerKeys(), + }); + if (isRun(run) && run.state === RUN_STATE.COMBAT) { + run.recordMapSeen(vision.visible); + } + } + const layer = cyberLayerOf(run); + if (layer) { + cyberVision.recompute(layer.world.grid, layer.avatar, undefined, { + blockers: layer.world.blockerKeys(), + }); + layer.recordSeen(cyberVision.visible); } } @@ -2098,6 +2314,9 @@ function renderShell(): void { */ function currentLocationLabel(): string | undefined { if (!campaign) return undefined; + if (isJackedIn(currentScene())) { + return '// THE GRID //'; + } if (campaign.state === CAMPAIGN_STATE.COMBAT && campaign.activeRun?.contract) { const { principal, site } = campaign.activeRun.contract.context; const siteName = site?.label ?? 'Location Unknown'; @@ -2128,19 +2347,7 @@ function buildCombatHudSnapshot(scene: ShellScene | null): CombatHudSummaryInput progress: scene.objectiveProgress(), } : null, - identity: { - callsign: scene.player.callsign, - archetype: scene.archetype, - stealthed: scene.player.stealthed, - }, - hp: { - hp: scene.player.hp, - maxHp: scene.player.maxHp, - }, - ap: { - ap: scene.player.ap, - maxAp: scene.player.maxAp, - }, + ...combatHudBodyPanes(scene), turn: { currentFaction: scene.queue.currentFaction, turnNumber: scene.queue.turnNumber, @@ -2148,6 +2355,36 @@ function buildCombatHudSnapshot(scene: ShellScene | null): CombatHudSummaryInput }; } +/** + * P3.M3.6: identity/HP/AP panes track whoever the player is being right now — + * the crew body in Meatspace, the avatar (RAM pool) on the grid. + */ +function combatHudBodyPanes(scene: Run): Pick { + const layer = cyberLayerOf(scene); + if (layer) { + const avatar = layer.avatar; + return { + identity: { + callsign: avatar.callsign, + archetype: 'Avatar', + stealthed: avatar.stealthed, + }, + hp: { hp: avatar.hp, maxHp: avatar.maxHp, label: 'RAM' }, + ap: { ap: avatar.ap, maxAp: avatar.maxAp }, + }; + } + const player = scene.player!; + return { + identity: { + callsign: player.callsign, + archetype: scene.archetype, + stealthed: player.stealthed, + }, + hp: { hp: player.hp, maxHp: player.maxHp }, + ap: { ap: player.ap, maxAp: player.maxAp }, + }; +} + function buildHubHudRows(scene: ShellScene | null) { if (!campaign || scene?.state !== CAMPAIGN_STATE.HUB) return undefined; const [summary, clock] = formatHubArcStatusLines(campaign); @@ -2183,28 +2420,35 @@ function paint(stateHint: InputState = activeInputState()): void { return; } if (!run || !run.world || !run.player) return; + // P3.M3.6: the rendered pair — meat body in Meatspace, avatar on the grid. + const world = activeWorldOf(run); + const actor = activeActorOf(run); + if (!world || !actor) return; + const jacked = isJackedIn(run); // Hub is a safe space — no fog of war. Vision is only meaningful during // combat where LOS and drone stealth detection matter. - const activeVision = run.state === RUN_STATE.COMBAT ? vision : undefined; + const activeVision = run.state === RUN_STATE.COMBAT ? activeVisionField(run) : undefined; + // Breach blasts are a Meatspace effect — never painted onto the grid. const blastOverlayKeys = - run.state === RUN_STATE.COMBAT && activeBreachBlastOverlayKeys.size > 0 + run.state === RUN_STATE.COMBAT && !jacked && activeBreachBlastOverlayKeys.size > 0 ? activeBreachBlastOverlayKeys : undefined; const principalId = - run.state === RUN_STATE.COMBAT + run.state === RUN_STATE.COMBAT && !jacked ? campaign?.activeRun?.contract?.context?.principal?.id : undefined; - renderer.draw(run.world, run.player, { + renderer.draw(world, actor, { vision: activeVision, - player: run.player, + player: actor, blastOverlayKeys, lookCursor, principalId, + tileset: activeTileset(run), locationLabel: currentLocationLabel(), hudRows: buildHubHudRows(run), combatHud: buildCombatHudSnapshot(run), }); - crt.alertTint = run.state === RUN_STATE.COMBAT && run.world.alarmActive; + crt.alertTint = run.state === RUN_STATE.COMBAT && world.alarmActive; crt.apply(); setStatus(statusLine(stateHint)); } @@ -2234,7 +2478,10 @@ function statusLine(state: InputState): string { combatA11y = `Combat status: ${escapeHtml( formatCombatHudA11ySummary(hudSnapshot) )}`; - const alarm = run.world?.alarm; + // P3.M3.6: alarm + hazard read from the world the player is *being in*. + const statusWorld = activeWorldOf(run); + const statusActor = activeActorOf(run); + const alarm = statusWorld?.alarm; const alertTag = alarm?.phase === 'alert' ? `ALERT ${alarm.holdTurnsRemaining}` @@ -2242,7 +2489,9 @@ function statusLine(state: InputState): string { ? `COOL ${alarm.cooldownTurnsRemaining}` : ''; const onHazard = - run.world && run.player && run.world.grid.tileAt(run.player.x, run.player.y) === TILE.HAZARD; + statusWorld && + statusActor && + statusWorld.grid.tileAt(statusActor.x, statusActor.y) === TILE.HAZARD; const hazardTag = onHazard ? '▓ HAZARD — move or take damage' : ''; @@ -2280,15 +2529,19 @@ function statusLine(state: InputState): string { run.state === RUN_STATE.COMBAT && run.queue.currentFaction === run.hostileFaction ) { - if (!run.world) throw new Error('[shell] combat status requires a world'); + const corpWorld = activeWorldOf(run); + if (!corpWorld) throw new Error('[shell] combat status requires a world'); + // P3.M3.6: while jacked in, the hostile mood row reads the ICE on the + // grid (probes are CORP-faction regardless of the meat principal). + const jacked = isJackedIn(run); const visibleHostiles = countVisibleCorpEntities( - run.world.entities.values(), - (x: number, y: number) => vision.isVisible(x, y), - run.hostileFaction + corpWorld.entities.values(), + (x: number, y: number) => activeVisionField(run).isVisible(x, y), + jacked ? FACTION.CORP : run.hostileFaction ); const body = corpTurnStatusBody(visibleHostiles, run.queue.turnNumber); corpToneActivityBody = body; - const hostileTag = run.hostileFaction.toUpperCase(); + const hostileTag = jacked ? 'ICE' : run.hostileFaction.toUpperCase(); ephemeral = `${hostileTag} — ${body}`; } else if ( isRun(run) && @@ -2297,7 +2550,7 @@ function statusLine(state: InputState): string { corpToneActivityBody !== null ) { // show the last hostile-turn mood until the player acts and flushes it - const hostileTag = run.hostileFaction.toUpperCase(); + const hostileTag = isJackedIn(run) ? 'ICE' : run.hostileFaction.toUpperCase(); ephemeral = `${hostileTag} — ${corpToneActivityBody}`; corpToneActivityBody = null; } @@ -2370,23 +2623,29 @@ function proximityHint(): string { return ''; } if (run.state === RUN_STATE.COMBAT) { - // Loot hint: check for adjacent lootable corpses. - if (run.world && run.player) { - const p = run.player; - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - const e = run.world.lootableCorpseAt(p.x + dx, p.y + dy); - if (e && !e.alive && e.loot && totalSalvage(e.loot.salvage) > 0) { - return 'SALVAGE nearby — press [Space] to loot.'; + // P3.M3.6: hints follow the active body — meat props for the crew, + // nodes/exit port for the avatar. + const world = activeWorldOf(run); + const p = activeActorOf(run); + const jacked = isJackedIn(run); + if (world && p) { + // Loot hint: adjacent lootable corpses (needs pockets — avatar skips). + if (!jacked) { + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const e = world.lootableCorpseAt(p.x + dx, p.y + dy); + if (e && !e.alive && e.loot && totalSalvage(e.loot.salvage) > 0) { + return 'SALVAGE nearby — press [Space] to loot.'; + } } } } - const interactable = run.world.adjacentInteractables(p)[0]; + const interactable = world.adjacentInteractables(p)[0]; if (interactable) { return `${interactable.label.toUpperCase()} nearby — press [Space] to interact.`; } } - if (run.exitTile && isChebyshevAdjacent(run.player, run.exitTile)) { + if (!jacked && run.exitTile && isChebyshevAdjacent(run.player, run.exitTile)) { return 'EXIT (¤) one step away.'; } } diff --git a/src/input/applyIntent.ts b/src/input/applyIntent.ts index e5a3ccb..090decc 100644 --- a/src/input/applyIntent.ts +++ b/src/input/applyIntent.ts @@ -60,6 +60,7 @@ import { EVENT } from '../game/events.js'; import { TILE } from '../game/constants.js'; import type { KeyItem } from '../types.js'; import type { Archetype } from '../game/archetypes/index.js'; +import type { CyberAvatar } from '../game/cyber/CyberAvatar.js'; import type { World } from '../game/World.js'; import type { TurnQueue } from '../game/TurnQueue.js'; import type { Rng } from '../rng.js'; @@ -76,7 +77,13 @@ export type Intent = { export type ApplyIntentContext = { world: World; - player: Archetype; + /** + * P3.M3.6: the acting body — a crew archetype in Meatspace, the + * `CyberAvatar` on the grid. The avatar exposes none of the perk or + * inventory capabilities, so every capability-sniffed branch (`doSpecial`, + * loot, consumables) degrades to its existing refusal path. + */ + player: Archetype | CyberAvatar; queue: TurnQueue; rng: Rng; log: (line: string) => void; @@ -292,8 +299,11 @@ function collectTileLoot(ctx: ApplyIntentContext) { objectivePickup.secureWalkOnto(world); log(`> ${entityLabel(player)} secures ${objectivePickup.label}.`); } - const consumablePickup = player.alive ? world.consumablePickupAt(player.x, player.y) : null; - if (consumablePickup) { + // Capability sniff (P3.M3.6): the CyberAvatar carries no gear — pickups + // stay on the tile for whoever has pockets. + const consumablePickup = + 'addConsumable' in player && player.alive ? world.consumablePickupAt(player.x, player.y) : null; + if (consumablePickup && 'addConsumable' in player) { player.addConsumable(consumablePickup.consumableId); world.removeEntity(consumablePickup.id); log(`> ${entityLabel(player)} picks up ${consumablePickup.label}.`); @@ -313,8 +323,10 @@ function collectTileLoot(ctx: ApplyIntentContext) { log(`> ${entityLabel(player)} picks up ${keycard.label}.`); } const corpse = - player.inventory && player.alive ? world.lootableCorpseAt(player.x, player.y) : null; - if (corpse) { + 'inventory' in player && player.inventory && player.alive + ? world.lootableCorpseAt(player.x, player.y) + : null; + if (corpse && 'inventory' in player) { const amount = totalSalvage(corpse.loot!.salvage); player.collectSalvage(world, corpse, { spendAp: false }); ctx.onCorpseSalvaged?.(corpse); @@ -432,7 +444,7 @@ function doDeploy(intent: Intent, ctx: ApplyIntentContext) { const turret = tech.improviseTurret(world, intent.dx!, intent.dy!); log( `> ${playerLabel} improvises ${entityLabel(turret)} at (${turret.x}, ${turret.y}) — ` + - `${player.inventory!.salvage.scrap} scrap left, ${player.ap} AP left.` + `${tech.inventory!.salvage.scrap} scrap left, ${player.ap} AP left.` ); gateOnApExhausted(ctx); return; diff --git a/src/render/combatHud.ts b/src/render/combatHud.ts index 6cc2547..2429893 100644 --- a/src/render/combatHud.ts +++ b/src/render/combatHud.ts @@ -25,6 +25,8 @@ export type CombatHudIdentityInput = Readonly<{ export type CombatHudVitalInput = Readonly<{ hp: number; maxHp: number; + /** P3.M3.6: pane label — `HP` (default) in Meatspace, `RAM` on the grid. */ + label?: string; }>; export type CombatHudApInput = Readonly<{ @@ -116,7 +118,7 @@ export function formatIdentityHud(identity: CombatHudIdentityInput): string { export function formatHpSegments(vitals: CombatHudVitalInput): string { validateCounter(vitals.hp, vitals.maxHp, 'hp', 'maxHp'); - return `HP ${rightFilledSegments( + return `${vitals.label ?? 'HP'} ${rightFilledSegments( vitals.hp, vitals.maxHp, COMBAT_HUD_GLYPHS.HP_FILLED, @@ -155,7 +157,7 @@ export function formatCombatHudA11ySummary(summary: CombatHudSummaryInput): stri const parts = [ name, archetype.toUpperCase(), - `${summary.hp.hp} of ${summary.hp.maxHp} HP`, + `${summary.hp.hp} of ${summary.hp.maxHp} ${summary.hp.label ?? 'HP'}`, `${summary.ap.ap} of ${summary.ap.maxAp} AP`, turnA11yText(summary.turn), ]; diff --git a/src/render/frame.ts b/src/render/frame.ts index 14181e6..dc8224e 100644 --- a/src/render/frame.ts +++ b/src/render/frame.ts @@ -15,7 +15,7 @@ import { Interactable } from '../game/entities/Interactable.js'; import type { World } from '../game/World.js'; import type { Entity } from '../game/Entity.js'; import type { VisionField } from '../game/Vision.js'; -import type { Glyph } from './palette.js'; +import type { Glyph, TilesetId } from './palette.js'; import { isConcealedFromPlayer, sniperAimOverlayTiles } from '../game/playerPerception.js'; import type { FactionId, TileId } from '../game/constants.js'; @@ -93,6 +93,8 @@ export type BuildFrameOptions = { lookCursor?: { x: number; y: number } | null; /** Combat terrain mood — `contract.context.principal.id` when in a job. */ principalId?: string; + /** P3.M3.6: which look the terrain paints with. Default `'meat'`. */ + tileset?: TilesetId; }; /** Sniper telegraph overlay — red crosshair composited over the target glyph. */ @@ -107,7 +109,7 @@ const BLAST_OVERLAY_GLYPH = glyphForTile(TILE.HAZARD); */ export function buildFrame(world: World, camera: Camera, options: BuildFrameOptions = {}): Frame { const { x: cx, y: cy, width, height } = camera; - const { vision, blastOverlayKeys, lookCursor, player, principalId } = options; + const { vision, blastOverlayKeys, lookCursor, player, principalId, tileset = 'meat' } = options; const cells: Glyph[] = Array.from({ length: width * height }); const omitEntity = (e: Entity) => player && isConcealedFromPlayer(e, player, world); @@ -139,7 +141,15 @@ export function buildFrame(world: World, camera: Camera, options: BuildFrameOpti if (vision) { if (vision.isVisible(wx, wy)) { - cells[idx] = glyphForCell(world, entityIndex, wx, wy, blastOverlayKeys, principalId); + cells[idx] = glyphForCell( + world, + entityIndex, + wx, + wy, + blastOverlayKeys, + principalId, + tileset + ); } else if (vision.hasSeen(wx, wy)) { // Memory pass: tile only for live entities (we don't track where // they are). Memorised corpses render at MEMORY_DIM so the player @@ -154,7 +164,7 @@ export function buildFrame(world: World, camera: Camera, options: BuildFrameOpti cells[idx] = { char: CORPSE_GLYPH_CHAR, fg: dimColor(fg, MEMORY_DIM) }; } else { const tileGlyph = dimGlyph( - glyphForTile(world.grid.tileAt(wx, wy) as TileId, principalId) + glyphForTile(world.grid.tileAt(wx, wy) as TileId, principalId, tileset) ); cells[idx] = blastTerrainOverlay(wx, wy, blastOverlayKeys, tileGlyph, { dim: true }); } @@ -164,7 +174,7 @@ export function buildFrame(world: World, camera: Camera, options: BuildFrameOpti continue; } - cells[idx] = glyphForCell(world, entityIndex, wx, wy, blastOverlayKeys, principalId); + cells[idx] = glyphForCell(world, entityIndex, wx, wy, blastOverlayKeys, principalId, tileset); } } @@ -207,13 +217,14 @@ function glyphForCell( wx: number, wy: number, blastOverlayKeys: ReadonlySet | undefined, - principalId?: string + principalId?: string, + tileset?: TilesetId ): Glyph { const entity = entityIndex.get(`${wx},${wy}`); if (entity) { return glyphForEntityCell(entity); } - const tileGlyph = glyphForTile(world.grid.tileAt(wx, wy) as TileId, principalId); + const tileGlyph = glyphForTile(world.grid.tileAt(wx, wy) as TileId, principalId, tileset); return blastTerrainOverlay(wx, wy, blastOverlayKeys, tileGlyph); } diff --git a/src/render/palette.ts b/src/render/palette.ts index ebf85bf..a8e2b92 100644 --- a/src/render/palette.ts +++ b/src/render/palette.ts @@ -59,7 +59,34 @@ export const OOB_GLYPH = Object.freeze({ char: ' ', fg: '#000000' }); */ export const UNSEEN_GLYPH = Object.freeze({ char: '·', fg: '#1a1a1a' }); -export function glyphForTile(tile: TileId, principalId?: string): Glyph { +/** + * P3.M3.6: tileset axis. The cyber grid reuses the FLOOR/WALL tile ids + * (passability, LOS, pathfinding, persistence untouched) and swaps the look — + * data-line mid-dots on deep cyan, firewall static in magenta. Principal + * terrain palettes are a Meatspace mood axis and never recolor the grid. + */ +export type TilesetId = 'meat' | 'cyber'; + +const CYBER_TILE_GLYPH: Partial> = { + [TILE.FLOOR]: { char: '·', fg: '#0e6b66' }, + [TILE.WALL]: { char: '▒', fg: '#c23bd4' }, +}; + +export function glyphForTile( + tile: TileId, + principalId?: string, + tileset: TilesetId = 'meat' +): Glyph { + if (tileset === 'cyber') { + const cyber = CYBER_TILE_GLYPH[tile]; + // Cyber maps are FLOOR/WALL only by construction (`buildCyberMap`); any + // other id reaching the cyber painter is corruption, not a style gap. + if (!cyber) throw new Error(`palette: tile id ${tile} has no cyber tileset glyph`); + return cyber; + } + if (tileset !== 'meat') { + throw new Error(`palette: unknown tileset "${String(tileset)}"`); + } const g = TILE_GLYPH[tile]; if (!g) throw new Error(`palette: unknown tile id ${tile}`); if (!principalId) return g; diff --git a/tests/unit/game/cyber/dualPhaseTurn.test.ts b/tests/unit/game/cyber/dualPhaseTurn.test.ts new file mode 100644 index 0000000..9d04db1 --- /dev/null +++ b/tests/unit/game/cyber/dualPhaseTurn.test.ts @@ -0,0 +1,186 @@ +/** + * P3.M3.6 — the dual-phase corp turn: while the Decker is jacked in, the + * shell chains two `corpTurnDriver` passes per corp slice — meat hostiles + * on the meat world, then ICE on the cyber world. One shared TurnQueue + * gives each side exactly one AP refresh per round; the shared run rng in + * fixed pass order keeps the whole thing deterministic. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Run } from '../../../../src/game/Run.js'; +import { CyberspaceLayer } from '../../../../src/game/cyber/CyberspaceLayer.js'; +import { ProbeIce } from '../../../../src/game/cyber/ProbeIce.js'; +import { JackInPoint } from '../../../../src/game/entities/JackInPoint.js'; +import { runCorpTurn } from '../../../../src/game/corpTurnDriver.js'; +import { buildCrewMember } from '../../../../src/game/archetypes/index.js'; +import { FACTION } from '../../../../src/game/constants.js'; +import { OBJECTIVES } from '../../../../src/game/hub/Curator.js'; +import { Rng } from '../../../../src/rng.js'; +import { testContractContext } from '../contractTestUtils.js'; +import type { World } from '../../../../src/game/World.js'; +import type { Entity } from '../../../../src/game/Entity.js'; +import type { TurnActionStep } from '../../../../src/types.js'; + +const cyberContract = (seed = 12345) => ({ + seed, + objective: { + kind: OBJECTIVES.DATA_NODE_SLICE, + title: 'Spike the server farm', + briefing: 'Jack in, slice the data node, then extract.', + params: { requiresCyberspace: true, count: 1 }, + }, + difficulty: 'standard', + threatCount: 1, + label: 'cyber test job', + context: testContractContext(OBJECTIVES.DATA_NODE_SLICE), + reward: { credits: 0, repDelta: 0 }, +}); + +function adjacentFreeTile(world: World, target: Entity) { + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + const x = target.x + dx; + const y = target.y + dy; + if (world.grid.inBounds(x, y) && world.grid.isPassable(x, y) && !world.entityAt(x, y)) { + return { x, y }; + } + } + } + throw new Error(`no free tile adjacent to ${target.id}`); +} + +function jackedInRun(seed = 12345): { run: Run; layer: CyberspaceLayer } { + const decker = buildCrewMember('decker', { x: 0, y: 0 }, new Rng(100), { id: 'crew-decker' }); + const run = new Run({ crewMember: decker, seed }); + run.enterBriefing(cyberContract(seed)); + run.enterCombat(); + const point = Array.from(run.world!.entities.values()).find( + e => e instanceof JackInPoint + ) as JackInPoint; + const spot = adjacentFreeTile(run.world!, point); + run.world!.relocateEntity(run.player!, spot.x, spot.y); + run.player!.refreshAp(); + assert.equal(point.interact(run.world!, run.player!).ok, true); + const layer = (run.cyberspace as { phase: 'active'; layer: CyberspaceLayer }).layer; + return { run, layer }; +} + +type StepLog = { id: string; type: string }[]; + +/** Drive one corp pass synchronously and collect the committed steps. */ +function drivePass(run: Run, world: World, faction: string): StepLog { + const log: StepLog = []; + let finished = false; + runCorpTurn({ + run: { state: run.state ?? '', world, rng: run.rng }, + corpFaction: faction, + paint: () => {}, + animLock: { push: () => {} }, + actionDelayMs: 0, + lockMarginMs: 0, + schedule: (fn: () => void) => fn(), + shouldAnimateStep: () => false, + onStep: (id: string, step: TurnActionStep) => log.push({ id, type: step.type }), + onFinish: () => { + finished = true; + }, + }); + assert.equal(finished, true, 'synchronous pass drains to onFinish'); + return log; +} + +/** One full dual-phase corp slice, the way the shell chains it. */ +function dualPhaseRound(run: Run, layer: CyberspaceLayer): { meat: StepLog; ice: StepLog } { + run.queue!.endTurn(run.world!); // player → corp (refreshes both corp sides) + const meat = drivePass(run, run.world!, run.hostileFaction); + const ice = drivePass(run, layer.world, FACTION.CORP); + run.queue!.endTurn(run.world!); // corp → player (refreshes both player sides) + return { meat, ice }; +} + +function probeIds(layer: CyberspaceLayer): Set { + return new Set( + Array.from(layer.world.entities.values()) + .filter(e => e instanceof ProbeIce) + .map(e => e.id) + ); +} + +// --- isolation ----------------------------------------------------------------------- + +test('the meat pass steps only meat hostiles; the ICE pass only probes', () => { + const { run, layer } = jackedInRun(); + const probes = probeIds(layer); + const { meat, ice } = dualPhaseRound(run, layer); + + for (const { id } of meat) { + assert.ok(!probes.has(id), `meat pass must not step ICE (stepped ${id})`); + } + assert.ok(ice.length > 0, 'probes patrol their rings'); + for (const { id } of ice) { + assert.ok(probes.has(id), `ICE pass stepped a non-probe (${id})`); + } +}); + +// --- AP cadence ------------------------------------------------------------------------ + +test('one AP refresh per side per round, on the single shared queue', () => { + const { run, layer } = jackedInRun(); + layer.avatar.spendAp(2); + run.player!.spendAp(run.player!.ap); + + run.queue!.endTurn(run.world!); // player → corp + // Corp-side refresh hit both worlds; player sides stay spent. + assert.equal(layer.avatar.ap, layer.avatar.maxAp - 2); + assert.equal(run.player!.ap, 0); + + drivePass(run, run.world!, run.hostileFaction); + drivePass(run, layer.world, FACTION.CORP); + const probesSpent = Array.from(layer.world.entities.values()).filter( + e => e instanceof ProbeIce && e.ap < e.maxAp + ); + assert.ok(probesSpent.length > 0, 'patrolling probes spent AP during the ICE pass'); + + run.queue!.endTurn(run.world!); // corp → player + assert.equal(layer.avatar.ap, layer.avatar.maxAp, 'avatar refreshed'); + assert.equal(run.player!.ap, run.player!.maxAp, 'meat body refreshed on the same flip'); + for (const probe of probesSpent) { + assert.ok(probe.ap < probe.maxAp, 'probes stay spent until the next corp flip'); + } +}); + +// --- determinism ------------------------------------------------------------------------- + +test('the dual-phase sequence is deterministic per contract seed', () => { + const a = jackedInRun(777); + const b = jackedInRun(777); + const roundA = dualPhaseRound(a.run, a.layer); + const roundB = dualPhaseRound(b.run, b.layer); + assert.deepEqual(roundA, roundB); + + const c = jackedInRun(778); + const roundC = dualPhaseRound(c.run, c.layer); + assert.notDeepEqual(roundA, roundC); +}); + +// --- engagement through the pass -------------------------------------------------------- + +test('an ICE pass with the avatar in sight yields the trace flare', () => { + const { run, layer } = jackedInRun(); + const probe = Array.from(layer.world.entities.values()).find( + e => e instanceof ProbeIce + ) as ProbeIce; + const spot = adjacentFreeTile(layer.world, layer.avatar); + layer.world.relocateEntity(probe, spot.x, spot.y); + + run.queue!.endTurn(run.world!); + drivePass(run, run.world!, run.hostileFaction); + const ice = drivePass(run, layer.world, FACTION.CORP); + assert.ok( + ice.some(s => s.id === probe.id && s.type === 'trace-alarm'), + 'the adjacent probe flared a trace during its pass' + ); + assert.equal(layer.world.alarmActive, true); +}); diff --git a/tests/unit/render/cyberTileset.test.ts b/tests/unit/render/cyberTileset.test.ts new file mode 100644 index 0000000..0cb59a3 --- /dev/null +++ b/tests/unit/render/cyberTileset.test.ts @@ -0,0 +1,77 @@ +/** + * P3.M3.6 — cyber tileset: distinct visuals on a separate *tileset axis*, + * not new TILE ids. The cyber grid reuses FLOOR/WALL (so passability, LOS, + * A*, and persistence stay untouched) and the palette swaps the look. + * Meat rendering must stay byte-stable when no tileset is passed. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { glyphForTile } from '../../../src/render/palette.js'; +import { buildFrame } from '../../../src/render/frame.js'; +import { Grid } from '../../../src/game/Grid.js'; +import { World } from '../../../src/game/World.js'; +import { TILE } from '../../../src/game/constants.js'; +import type { TileId } from '../../../src/game/constants.js'; + +// --- palette --------------------------------------------------------------------- + +test('cyber tiles render distinct glyphs from meat', () => { + const meatFloor = glyphForTile(TILE.FLOOR as TileId); + const cyberFloor = glyphForTile(TILE.FLOOR as TileId, undefined, 'cyber'); + const meatWall = glyphForTile(TILE.WALL as TileId); + const cyberWall = glyphForTile(TILE.WALL as TileId, undefined, 'cyber'); + + assert.equal(cyberFloor.char, '·'); + assert.equal(cyberWall.char, '▒'); + assert.notDeepEqual(cyberFloor, meatFloor); + assert.notDeepEqual(cyberWall, meatWall); + assert.notEqual(cyberFloor.fg, cyberWall.fg, 'floor and wall hues are distinct'); +}); + +test('the meat tileset is the default and stays byte-stable', () => { + for (const tile of [TILE.FLOOR, TILE.WALL, TILE.COVER, TILE.EXIT] as TileId[]) { + assert.deepEqual(glyphForTile(tile, undefined, 'meat'), glyphForTile(tile)); + } +}); + +test('principal terrain palettes do not recolor the cyber tileset', () => { + // The grid is the grid — corp moodboards belong to Meatspace. + const plain = glyphForTile(TILE.FLOOR as TileId, undefined, 'cyber'); + const themed = glyphForTile(TILE.FLOOR as TileId, 'corp-helix', 'cyber'); + assert.deepEqual(themed, plain); +}); + +test('an unknown tileset throws', () => { + assert.throws(() => glyphForTile(TILE.FLOOR as TileId, undefined, 'astral' as never), /tileset/); +}); + +test('non-cyber tile ids on the cyber tileset throw (FLOOR/WALL only by construction)', () => { + assert.throws(() => glyphForTile(TILE.COVER as TileId, undefined, 'cyber'), /cyber/); + assert.throws(() => glyphForTile(TILE.HAZARD as TileId, undefined, 'cyber'), /cyber/); +}); + +// --- frame threading --------------------------------------------------------------- + +function tinyWorld(): World { + const grid = new Grid(4, 3); // all FLOOR + grid.setTile(1, 1, TILE.WALL); + return new World(grid); +} + +test('buildFrame threads the cyber tileset into terrain cells', () => { + const world = tinyWorld(); + const camera = { x: 0, y: 0, width: 4, height: 3 }; + const frame = buildFrame(world, camera, { tileset: 'cyber' }); + assert.deepEqual(frame.cells[0], glyphForTile(TILE.FLOOR as TileId, undefined, 'cyber')); + assert.deepEqual(frame.cells[1 * 4 + 1], glyphForTile(TILE.WALL as TileId, undefined, 'cyber')); +}); + +test('buildFrame without a tileset stays byte-identical to before', () => { + const world = tinyWorld(); + const camera = { x: 0, y: 0, width: 4, height: 3 }; + const implicit = buildFrame(world, camera, {}); + const explicit = buildFrame(world, camera, { tileset: 'meat' }); + assert.deepEqual(explicit, implicit); + assert.deepEqual(implicit.cells[0], glyphForTile(TILE.FLOOR as TileId)); +}); From 3c693dcd2988ff1f6cddf4224f8e9e7aad71d794 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Thu, 11 Jun 2026 12:04:58 -0700 Subject: [PATCH 26/55] =?UTF-8?q?P3:=20The=20Score=20is=20a=20cyber=20run?= =?UTF-8?q?=20=E2=80=94=20DATA=5FNODE=5FSLICE=20objective?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildScoreContract now emits kind DATA_NODE_SLICE with {requiresCyberspace: true, count: 1} (Rylee's call: the Score always has a Cyberspace component, for now). P3.M1.7 tests deploy the Decker through the living-Decker gate and lock the objective shape. Co-Authored-By: Claude Fable 5 --- src/game/Campaign.ts | 14 +++++++------- tests/unit/game/Campaign.test.ts | 12 ++++++++++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/game/Campaign.ts b/src/game/Campaign.ts index a8b5ba3..17f441f 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -595,17 +595,22 @@ export class Campaign { const principal = locationContextToken(target.principal); const site = target.site ? locationContextToken(target.site) : undefined; const heatThreat = Math.min(6, 4 + this.clockHeat); + const objectiveKind = OBJECTIVES.DATA_NODE_SLICE; return { seed, mapWidth: target.mapWidth, mapHeight: target.mapHeight, objective: { - kind: OBJECTIVES.REACH_EXIT, + kind: objectiveKind, title: 'The Score', briefing: `Hit ${target.label .replace(/^\/\//, '') .replace(/\s+-\s+Score target$/i, '') .trim()} and extract with the crew alive.`, + params: { + requiresCyberspace: true, + count: 1, + }, }, difficulty: CONTRACT_DIFFICULTY.CRITICAL, threatCount: heatThreat, @@ -616,12 +621,7 @@ export class Campaign { ...(site ? { site } : {}), asset: { id: 'score-target', label: 'Score target', groups: ['score'] }, action: { id: 'score-run', label: 'run', groups: ['score'] }, - tags: [ - 'score', - 'meatspace', - `objective:${OBJECTIVES.REACH_EXIT}`, - `principal:${principal.id}`, - ], + tags: ['score', 'meatspace', `objective:${objectiveKind}`, `principal:${principal.id}`], arcStage: 'score', locationSiteId: target.id, }, diff --git a/tests/unit/game/Campaign.test.ts b/tests/unit/game/Campaign.test.ts index 99e1390..63d4e7b 100644 --- a/tests/unit/game/Campaign.test.ts +++ b/tests/unit/game/Campaign.test.ts @@ -1066,8 +1066,14 @@ test('P3.M1.7: Score contract is gated to Act 3 and marks attempted on deploymen assert.equal(score.context.locationSiteId, 'score'); assert.ok(score.context.tags.includes('score')); assert.match(score.label, /THE SCORE/); + // The Score is (for now) always a cyber run: DATA_NODE_SLICE end to end. + assert.equal(score.objective.kind, OBJECTIVES.DATA_NODE_SLICE); + assert.equal(score.objective.params?.requiresCyberspace, true); + assert.equal(score.objective.params?.count, 1); - const run = campaign.deployCrewMember(campaign.crew[0]!.id, score); + const decker = campaign.crew.find(m => m.archetype === 'Decker'); + assert.ok(decker, 'Act 3 campaign should include auto-assigned Decker'); + const run = campaign.deployCrewMember(decker!.id, score); assert.equal(campaign.arc.scoreAttempted, true); assert.equal(campaign.arcStage, 'score'); assert.equal(run.contract?.context.locationSiteId, 'score'); @@ -1093,7 +1099,9 @@ test('P3.M1.7: completed Score contract records campaign win state', () => { validSite({ id: 'case-2', seed: '102', lastVisitedJob: 7, principal: scorePrincipal }), ], }); - const run = campaign.deployCrewMember(campaign.crew[0]!.id, campaign.buildScoreContract()); + const decker = campaign.crew.find(m => m.archetype === 'Decker'); + assert.ok(decker, 'Act 3 campaign should include auto-assigned Decker'); + const run = campaign.deployCrewMember(decker!.id, campaign.buildScoreContract()); run.enterCombat(); campaign.onJobEnd({ outcome: OUTCOME.EXIT, completed: true }); From 2d668bd8c565e3d328c943c4bd0f9f7d12f041a5 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Thu, 11 Jun 2026 12:13:31 -0700 Subject: [PATCH 27/55] =?UTF-8?q?P3.M3=20S7.5:=20early=20jack-out=20confir?= =?UTF-8?q?mation=20=E2=80=94=20the=20link=20burn=20warns=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An incomplete jack-out is irreversible (LINK BURNED + objective latched unsatisfiable) but resolved with zero warning. Run.jackOut now defers to onJackOutRequested when the objective is incomplete (mirror of the onAbortRequested seam; no callback → resolve immediately, same harness posture); run.confirmJackOut() finalizes, throwing on illegal states — unlike confirmAbort there is no legal request-voiding race, so a stale confirm is a wiring bug. A complete jack-out never asks. Shell: confirmation modal context 'jack-out-early' with link-burn copy; JACK_OUT bus handler skips the Meatspace swap while the layer is still live (deferred); completeJackOutShellSwap shared by both resolve paths. wireRunConfirmations extracted and now also called on campaign resume — restored runs previously lost onAbortRequested entirely, silently skipping the abort-extraction confirm after any mid-run reload. EntryPort success copy neutralized ("routing out…") since the port no longer knows whether the link actually dropped. Co-Authored-By: Claude Fable 5 --- index.ts | 70 ++++++++++++++++----- src/game/Run.ts | 51 +++++++++++++++ src/game/cyber/EntryPort.ts | 4 +- tests/unit/game/cyber/jackOut.test.ts | 91 +++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 15 deletions(-) diff --git a/index.ts b/index.ts index e965395..e482359 100644 --- a/index.ts +++ b/index.ts @@ -571,6 +571,14 @@ async function boot() { paint(); } break; + case 'jack-out-early': { + const activeRun = campaign?.activeRun; + if (activeRun) { + activeRun.confirmJackOut(); + completeJackOutShellSwap(); + } + break; + } } }); @@ -884,14 +892,8 @@ function onBriefingDeploy(evt: Event) { flash(`CURATOR: ${member.callsign} takes ${contract.label}. JACKING IN.`); } - // Wire abort confirmation so stepping on exit with an incomplete objective - // pops a modal instead of silently ending the run. - run.onAbortRequested = () => { - confirmationModalEl.showModal( - `Objective incomplete. Abort extraction?\n\nYou will lose all rewards and take a REP ${REP.ABORT_PENALTY} penalty.`, - 'abort-run' - ); - }; + // Wire the run's confirmation callbacks (abort extraction, early jack-out). + wireRunConfirmations(run); // Go straight into combat — the player already reviewed the contract and // chose their operative in the combined briefing modal. @@ -1333,12 +1335,40 @@ function currentScene(): ShellScene | null { return campaign.activeRun ?? campaign; } +/** + * Wire the confirmation callbacks a Run raises mid-combat. Callbacks do not + * persist, so this runs at deploy AND on campaign resume — a restored run + * without them would silently skip both confirmations (the no-callback + * harness fallback). + */ +function wireRunConfirmations(run: Run): void { + // Stepping on exit with an incomplete objective pops a modal instead of + // silently ending the run. + run.onAbortRequested = () => { + confirmationModalEl.showModal( + `Objective incomplete. Abort extraction?\n\nYou will lose all rewards and take a REP ${REP.ABORT_PENALTY} penalty.`, + 'abort-run' + ); + }; + // P3.M3 S7.5: routing out of the grid with the objective incomplete is + // irreversible (LINK BURNED, objective unsatisfiable) — confirm first. + run.onJackOutRequested = () => { + confirmationModalEl.showModal( + 'Objective incomplete. Jack out anyway?\n\nThe link will burn — you cannot re-enter the grid this run.', + 'jack-out-early' + ); + }; +} + function resumeCampaign(record: CampaignSnapshot | unknown) { try { campaign = restoreCampaign(record, { onPersist: () => handlePersist(), onResult: handleResult, }); + if (campaign.activeRun) { + wireRunConfirmations(campaign.activeRun); + } if (campaign.activeRun?.state === RUN_STATE.COMBAT) { vision.resetFogState(); vision.restoreSeen(campaign.activeRun.mapSeenKeys()); @@ -2169,16 +2199,28 @@ function attachCyberListeners(): void { if (fired) animLock.push(ANIMATION_DURATIONS.MUZZLE_FLASH); }), layer.bus.on(EVENT.JACK_OUT, () => { - // Run.jackOut (subscribed first) has already resolved the layer. - for (const off of cyberUnsubs) off(); - cyberUnsubs = []; - flash('LINK DROPPED — back in your body.'); - recomputeVision(); - paint(); + // Run.jackOut (subscribed first) has either resolved the layer or, with + // the objective incomplete, deferred to the early-jack-out confirmation + // (S7.5) — the modal's confirm path completes the swap instead. + if (cyberLayerOf(currentScene())) return; + completeJackOutShellSwap(); }) ); } +/** + * S7.5: shell-side half of a finalized jack-out — drop the cyber listeners + * and re-present Meatspace. Reached from the JACK_OUT bus event (immediate + * resolve) or the `jack-out-early` modal confirmation (deferred resolve). + */ +function completeJackOutShellSwap(): void { + for (const off of cyberUnsubs) off(); + cyberUnsubs = []; + flash('LINK DROPPED — back in your body.'); + recomputeVision(); + paint(); +} + /** * Subscribe Rep-affecting events to the active run's bus. * diff --git a/src/game/Run.ts b/src/game/Run.ts index 36fd613..1f2ca35 100644 --- a/src/game/Run.ts +++ b/src/game/Run.ts @@ -358,6 +358,13 @@ export type RunOptions = { * to finalise the abort extraction, or do nothing to let the player * stay on the exit tile and keep playing. */ onAbortRequested?: unknown; + /** P3.M3: called when the avatar routes out with the objective incomplete — + * an irreversible step (the link burns, the objective latches + * unsatisfiable). The shell should show a confirmation prompt; call + * `run.confirmJackOut()` to finalize, or do nothing to keep the layer + * live. No callback registered → jack out immediately (tests/harness), + * matching the `onAbortRequested` posture. */ + onJackOutRequested?: unknown; /** Terrain mutations from a prior visit to this location (P2.5.M7.2), replayed * onto the freshly-built map in `enterCombat`. Empty/omitted for a first visit. */ priorMutationDeltas?: unknown; @@ -417,6 +424,7 @@ export class Run { onPersist: ((record: RunSnapshot) => void) | null; onResult: ((result: RunResult) => void) | null; onAbortRequested: (() => void) | null; + onJackOutRequested: (() => void) | null; _busUnsubs: (() => void)[]; constructor({ @@ -426,6 +434,7 @@ export class Run { onPersist, onResult, onAbortRequested, + onJackOutRequested, priorMutationDeltas, priorKeyItems, priorSeenKeys, @@ -448,6 +457,9 @@ export class Run { if (onAbortRequested !== undefined && typeof onAbortRequested !== 'function') { throw new TypeError('Run: onAbortRequested must be a function'); } + if (onJackOutRequested !== undefined && typeof onJackOutRequested !== 'function') { + throw new TypeError('Run: onJackOutRequested must be a function'); + } if (priorMutationDeltas !== undefined && !Array.isArray(priorMutationDeltas)) { throw new TypeError('Run: priorMutationDeltas must be an array when supplied'); } @@ -486,6 +498,7 @@ export class Run { this.onPersist = (onPersist as ((record: RunSnapshot) => void) | undefined) ?? null; this.onResult = (onResult as ((result: RunResult) => void) | undefined) ?? null; this.onAbortRequested = (onAbortRequested as (() => void) | undefined) ?? null; + this.onJackOutRequested = (onJackOutRequested as (() => void) | undefined) ?? null; /** @type {Array<() => void>} active bus subscriptions */ this._busUnsubs = []; @@ -899,6 +912,44 @@ export class Run { // — extraction then routes through the existing abort-confirm flow. const { sliced } = dataNodeProgress(layer.world); const objectiveComplete = sliced >= objectiveCount(this.contract); + // S7.5: an incomplete jack-out is irreversible — defer to the shell for + // confirmation when a callback is registered (the port's interact AP is + // already spent; a re-request after cancel costs it again). No callback + // → resolve immediately, the `onAbortRequested` posture. + if (!objectiveComplete && this.onJackOutRequested) { + this.onJackOutRequested(); + return; + } + this.#finalizeJackOut(layer, objectiveComplete); + } + + /** + * S7.5: finalize a deferred early jack-out after the shell confirms. + * Unlike `confirmAbort` (where walking off the exit tile legitimately + * voids the request), nothing can legally change between request and + * confirm — the modal blocks turn flow — so an illegal state here is a + * wiring bug and throws rather than silently no-oping. + */ + confirmJackOut(): void { + if (this.state !== RUN_STATE.COMBAT || this.cyberspace?.phase !== 'active') { + throw new Error( + `Run.confirmJackOut: no jack-out pending (state ${this.state}, phase ${ + this.cyberspace?.phase ?? 'none' + })` + ); + } + if (!this.contract) { + throw new Error('Run.confirmJackOut: COMBAT state without a contract'); + } + const layer = this.cyberspace.layer; + // Recompute rather than latch `false` blindly — honest if a future flow + // ever confirms after progress changed. + const { sliced } = dataNodeProgress(layer.world); + this.#finalizeJackOut(layer, sliced >= objectiveCount(this.contract)); + } + + /** Active → resolved: teardown, latch, LINK BURNED, autosave. */ + #finalizeJackOut(layer: CyberspaceLayer, objectiveComplete: boolean): void { layer.teardown(); this.cyberspace = { phase: 'resolved', objectiveComplete }; // S5: the link is burned — re-jack-in refused for the rest of the run. diff --git a/src/game/cyber/EntryPort.ts b/src/game/cyber/EntryPort.ts index ee9cb91..2453348 100644 --- a/src/game/cyber/EntryPort.ts +++ b/src/game/cyber/EntryPort.ts @@ -48,6 +48,8 @@ export class EntryPort extends Interactable { } actor.spendAp(AP_COST.INTERACT); world.events?.emit(EVENT.JACK_OUT, { port: this, actor }); - return { ok: true, message: `${this.label}: connection dropped — jacking out.` }; + // Neutral copy: Run may have resolved the layer or deferred to an + // early-jack-out confirmation (S7.5) — the shell narrates the outcome. + return { ok: true, message: `${this.label}: routing out…` }; } } diff --git a/tests/unit/game/cyber/jackOut.test.ts b/tests/unit/game/cyber/jackOut.test.ts index 8a5066f..1f1f29a 100644 --- a/tests/unit/game/cyber/jackOut.test.ts +++ b/tests/unit/game/cyber/jackOut.test.ts @@ -11,6 +11,7 @@ import assert from 'node:assert/strict'; import { Run } from '../../../../src/game/Run.js'; import { CyberspaceLayer } from '../../../../src/game/cyber/CyberspaceLayer.js'; +import { DataNode } from '../../../../src/game/cyber/DataNode.js'; import { JackInPoint } from '../../../../src/game/entities/JackInPoint.js'; import { snapshot, restore } from '../../../../src/game/persistence.js'; import { buildCrewMember } from '../../../../src/game/archetypes/index.js'; @@ -83,6 +84,18 @@ function routeOut(layer: CyberspaceLayer): void { assert.equal(layer.port.interact(layer.world, layer.avatar).ok, true); } +/** Slice every node in the layer (standard difficulty 2 vs decker intrusion 2 → one pass each). */ +function sliceAllNodes(layer: CyberspaceLayer): void { + for (const entity of Array.from(layer.world.entities.values())) { + if (!(entity instanceof DataNode)) continue; + const spot = adjacentFreeTile(layer.world, entity); + layer.world.relocateEntity(layer.avatar, spot.x, spot.y); + layer.avatar.refreshAp(); + assert.equal(entity.interact(layer.world, layer.avatar).ok, true); + assert.equal(entity.sliced, true, 'standard node slices in one pass at base intrusion'); + } +} + function portRecord(record: RunSnapshot): RunEntitySnapshot { const rec = record.entities.find(e => e.archetype === 'jack-in-point'); assert.ok(rec, 'snapshot carries the jack-in point'); @@ -140,6 +153,84 @@ test('burn() on an unlinked port is corrupt state and throws', () => { assert.throws(() => point.burn(), /link/i); }); +// --- early jack-out confirmation (P3.M3 S7.5) ----------------------------------------- +// +// An incomplete jack-out is irreversible — the link burns and the objective +// latches unsatisfiable. With `onJackOutRequested` registered, Run defers to +// the shell instead of resolving; `confirmJackOut()` finalizes. No callback +// (tests/harness) → resolve immediately, the `onAbortRequested` posture — +// locked by the LINK BURNED tests above, which route out with no callback. + +test('Run rejects a non-function onJackOutRequested', () => { + assert.throws( + () => new Run({ crewMember: makeDecker(), seed: 1, onJackOutRequested: 'nope' }), + TypeError + ); +}); + +test('incomplete jack-out defers to onJackOutRequested — layer stays live, link unburned', () => { + const run = combatRun(); + const layer = jackIn(run); + let requests = 0; + run.onJackOutRequested = () => requests++; + + routeOut(layer); + assert.equal(requests, 1, 'shell asked exactly once'); + assert.equal(run.cyberspace?.phase, 'active', 'layer stays live pending confirmation'); + assert.equal(meatPort(run).burned, false, 'deferred request does not burn the link'); +}); + +test('confirmJackOut finalizes the deferred jack-out: resolved, incomplete, burned', () => { + const run = combatRun(); + const layer = jackIn(run); + run.onJackOutRequested = () => {}; + routeOut(layer); + + run.confirmJackOut(); + assert.equal(run.cyberspace?.phase, 'resolved'); + assert.equal( + (run.cyberspace as { objectiveComplete?: boolean }).objectiveComplete, + false, + 'early jack-out latches the objective unsatisfiable' + ); + assert.equal(meatPort(run).burned, true); +}); + +test('complete objective jacks out immediately — no confirmation requested', () => { + const run = combatRun(); + const layer = jackIn(run); + let requests = 0; + run.onJackOutRequested = () => requests++; + + sliceAllNodes(layer); + routeOut(layer); + assert.equal(requests, 0, 'a clean jack-out never asks'); + assert.equal(run.cyberspace?.phase, 'resolved'); + assert.equal((run.cyberspace as { objectiveComplete?: boolean }).objectiveComplete, true); +}); + +test('confirmJackOut with no jack-out pending throws (dormant and resolved)', () => { + const run = combatRun(); + assert.equal(run.cyberspace?.phase, 'dormant'); + assert.throws(() => run.confirmJackOut(), /pending/); + + const layer = jackIn(run); + routeOut(layer); // no callback → resolves immediately + assert.equal(run.cyberspace?.phase, 'resolved'); + assert.throws(() => run.confirmJackOut(), /pending/); +}); + +test('a pending jack-out confirmation persists as a live layer', () => { + const run = combatRun(); + const layer = jackIn(run); + run.onJackOutRequested = () => {}; + routeOut(layer); + + const { run: restored } = restore(structuredClone(snapshot(run))); + assert.equal(restored.cyberspace?.phase, 'active', 'nothing resolved until the shell confirms'); + assert.equal(meatPort(restored).burned, false); +}); + // --- persistence ---------------------------------------------------------------------- test('the burned latch round-trips and the restored port stays dead', () => { From f35cf258353dea6c86664de3aa591d3947ed6263 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Thu, 11 Jun 2026 12:16:40 -0700 Subject: [PATCH 28/55] =?UTF-8?q?P3.M3=20plan:=20S7/S7.5=20shipped,=20S8?= =?UTF-8?q?=20wrap-up=20=E2=80=94=20first=20playable=20slice=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Progress rows + implementation notes for the render/input swap and the early jack-out confirmation; scope decision #5 recorded (the Score is always a cyber run). phase-3-plan.md M3 status: first playable slice shipped, milestone open for Spark/Guardian ICE. Co-Authored-By: Claude Fable 5 --- docs/phase-3-cyberspace-plan.md | 79 ++++++++++++++++++++++++++++++++- docs/phase-3-plan.md | 11 ++++- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/docs/phase-3-cyberspace-plan.md b/docs/phase-3-cyberspace-plan.md index b61de11..54d5b58 100644 --- a/docs/phase-3-cyberspace-plan.md +++ b/docs/phase-3-cyberspace-plan.md @@ -21,6 +21,11 @@ the effort can be resumed cold. 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** (recorded 2026-06-11, Rylee): + `buildScoreContract` emits `DATA_NODE_SLICE` with + `{requiresCyberspace: true, count: 1}` — for now; revisit if a + meat-only Score variant is ever wanted. Deploy goes through the + living-Decker gate; objective shape locked in the P3.M1.7 tests. TDD throughout; malformed persisted state throws (no silent fallbacks). Commits land **per green slice** (user-approved). @@ -35,8 +40,10 @@ Commits land **per green slice** (user-approved). | **S4 — P3.M3.4 Data node objective** | ✅ Done | `3923149` | | **S5 — Voluntary jack-out (M4.6 pull-forward)** | ✅ Done | `fab3bcb` | | **S6 — P3.M3.5 Probe ICE** | ✅ Done | `0a46878` | -| **S7 — P3.M3.6 Render/input swap + shell ICE phase** | 🔲 Planned | — | -| **S8 — Docs + wrap-up** | 🔲 Planned | — | +| **S7 — P3.M3.6 Render/input swap + shell ICE phase** | ✅ Done | `dd17cb8` | +| **— Score → cyber (scope decision #5)** | ✅ Done | `3c693dc` | +| **S7.5 — Early jack-out confirmation** | ✅ Done | `2d668bd` | +| **S8 — Docs + wrap-up** | ✅ Done | — | ### S1 implementation notes (shipped) @@ -260,6 +267,74 @@ Commits land **per green slice** (user-approved). validate at build). - Tests: 9 in `ProbeIce.test.ts`. Failing-first verified; suite 1646 green. +### S7 implementation notes (shipped) + +- `palette.ts` — `TilesetId = 'meat' | 'cyber'` axis; `CYBER_TILE_GLYPH` + (FLOOR `·` deep cyan `#0e6b66`, WALL `▒` magenta `#c23bd4`). + `glyphForTile(tile, principalId?, tileset?)` throws on an unknown tileset + *and* on a non-FLOOR/WALL tile reaching the cyber painter (cyber maps are + FLOOR/WALL-only by construction — corruption, not a style gap). Principal + terrain palettes are a Meatspace mood axis and never recolor the grid. +- `frame.ts` — `BuildFrameOptions.tileset` (default `'meat'`) threaded + through `buildFrame`/`glyphForCell`; `combatHud.ts` — vitals pane label + axis (`HP` default, `RAM` on the grid). +- `applyIntent.ts` — `ApplyIntentContext.player` widened to + `Archetype | CyberAvatar`; the avatar exposes no perk/inventory + capabilities, so every capability-sniffed branch (`doSpecial`, corpse + loot, consumable pickup) degrades to its existing refusal path. +- `index.ts` — active-view seam (`run.activeWorld`/`run.activeActor`) + through vision, paint, look/describe, touch, and statusLine (location + reads `// THE GRID //`); module-level `cyberVision = new VisionField()`; + shell `JACK_IN`/`JACK_OUT` handlers depend on Run subscribing first + (recorded in kaizen — listener-order coupling); `attachCyberListeners` + called at every scene (re)wiring site plus the `JACK_IN` hook so a + mid-cyber resume re-attaches automatically. +- **Dual-phase corp turn**: while jacked in, the corp slice chains two + `corpTurnDriver` passes — meat hostiles on the meat world (silent: the + canvas shows the grid), then ICE on `layer.world` — consuming the shared + `run.rng` in that fixed order. Body flatlined during the meat pass → + driver bails terminally and the ICE pass never runs. +- Tests: `dualPhaseTurn.test.ts` (shared-rng order/determinism lock), + `cyberTileset.test.ts` (glyph table + throw matrix). kaizen.md gained the + index.ts complexity findings (ShellScene casts, statusLine extraction, + listener rewire dedupe, listener-order coupling, seam enforcement). + +### S7.5 implementation notes (shipped) + +- Gap found in Rylee's S7 smoke: jacking out early resolved instantly — + an **irreversible** step (LINK BURNED + objective latched unsatisfiable) + with zero warning, unlike the exit-abort flow. +- `Run.onJackOutRequested` (ctor-validated, mirror of `onAbortRequested`): + `jackOut()` with the objective **incomplete** defers to the callback — + layer stays live, link unburned, nothing persisted; no callback + registered → resolve immediately (tests/harness posture). A complete + jack-out never asks. The port's interact AP is spent either way; a + re-request after cancel costs it again. +- `run.confirmJackOut()` finalizes (teardown → resolved latch → burn → + autosave, shared `#finalizeJackOut`). **Throws** on illegal states — + unlike `confirmAbort`'s no-op there is no legal request-voiding race + (the modal blocks turn flow), so a stale confirm is a wiring bug. +- Shell: modal context `jack-out-early` with link-burn copy; + `completeJackOutShellSwap()` shared by the immediate path (bus handler, + which now skips while the layer is still live) and the confirmed path. + `wireRunConfirmations(run)` extracted and called at deploy **and** + campaign resume — fixes a latent bug where a restored run lost + `onAbortRequested` entirely, silently skipping the abort confirm after + any mid-run reload. +- `EntryPort` success copy neutralized (`routing out…`) — the port no + longer knows whether the link actually dropped. +- Tests: +6 in `jackOut.test.ts` (defer/confirm matrix, complete-objective + immediacy, illegal-state throws, pending-confirmation persistence as a + live layer). Suite 1663 green. + +### S8 wrap-up + +- P3.M3 milestone status in `phase-3-plan.md` updated: first playable + slice shipped end-to-end; milestone stays open for Spark/Guardian ICE + (scope decision #1) and the deferred follow-ups noted in S6 + (ICE faction stamping) and kaizen (index.ts cleanup). +- Browser re-smoke of the S7.5 confirm flow: pending (Rylee). + ## Architecture decisions (approved plan) ### Cyber layer model — `CyberspaceLayer` owned by `Run`, single `TurnQueue`, both worlds tick diff --git a/docs/phase-3-plan.md b/docs/phase-3-plan.md index bff337b..0af95cb 100644 --- a/docs/phase-3-plan.md +++ b/docs/phase-3-plan.md @@ -43,7 +43,7 @@ A new **player archetype** recruited mid-campaign (late Act 1 / start of Act 2), |---|---| | P3.M1 — Campaign arc structure | ✅ Done | | P3.M2 — The Decker archetype | ✅ Done | -| P3.M3 — Cyberspace grid + ICE | 🔲 Planned | +| P3.M3 — Cyberspace grid + ICE | 🟡 First playable slice shipped (Spark/Guardian ICE open) | | P3.M4 — Simstim flip (dual-deploy) | 🔲 Planned | | P3.M5 — The Score (climactic mission) | 🔲 Planned | | P3.M6 — Chronicle (campaign narrative memory) | 🔲 Planned | @@ -198,7 +198,14 @@ Neural degradation is deferred until Cyberspace is fun enough to deserve a jack- --- -### P3.M3 — Cyberspace grid + ICE 🔲 +### P3.M3 — Cyberspace grid + ICE 🟡 + +> **Status (2026-06-11):** the first playable slice (M3.1–M3.6, plus voluntary +> jack-out pulled forward from M4.6 and an early-jack-out confirmation) is +> shipped end-to-end on `3.0-cyberspace` — see +> [phase-3-cyberspace-plan.md](phase-3-cyberspace-plan.md) for slice notes and +> recorded scope decisions. The milestone stays open for **Spark and Guardian +> ICE**. The Score contract is now always a cyber run (scope decision #5). **Depends on:** P3.M2 (Decker as the Cyberspace avatar). Can prototype grid mechanics independently. From 8c56fc0ba6ba8d8d123657ab4ae8654b9c8b0e63 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Sun, 14 Jun 2026 12:09:38 -0700 Subject: [PATCH 29/55] cyberspace tuning --- components/ContractSelect.ts | 7 +- components/CrashDump.ts | 67 +---- components/GameOver.ts | 179 +++++++----- components/RunBriefing.ts | 2 +- docs/kaizen.md | 7 +- docs/phase-2-plan.md | 2 +- docs/phase-2.6-plan.md | 2 +- docs/phase-3-cyberspace-plan.md | 10 +- docs/phase-3-plan.md | 41 ++- index.ts | 275 +++++++++--------- src/DataStore.ts | 51 +++- src/game/Campaign.ts | 82 +++++- src/game/campaignSummary.ts | 236 +++++++++++++++ src/game/cyber/CyberAvatar.ts | 18 +- src/game/cyber/ProbeIce.ts | 5 +- src/input/applyIntent.ts | 86 ++++-- src/types.ts | 26 +- sw-core.js | 1 + sw-dev.js | 2 +- sw.js | 2 +- tests/unit/DataStore.test.ts | 66 +++++ tests/unit/game/Campaign.test.ts | 188 ++++++++++++ tests/unit/game/campaignSummary.test.ts | 151 ++++++++++ tests/unit/game/cyber/ProbeIce.test.ts | 32 +- .../unit/game/cyber/cyberPersistence.test.ts | 22 ++ tests/unit/input/applyIntent.test.ts | 72 +++++ 26 files changed, 1268 insertions(+), 364 deletions(-) create mode 100644 src/game/campaignSummary.ts create mode 100644 tests/unit/DataStore.test.ts create mode 100644 tests/unit/game/campaignSummary.test.ts diff --git a/components/ContractSelect.ts b/components/ContractSelect.ts index 676bd5f..6220fcb 100644 --- a/components/ContractSelect.ts +++ b/components/ContractSelect.ts @@ -385,9 +385,12 @@ function difficultyLabel(contract: Contract): string { } function rewardCopy(contract: Contract): string { - if (isScoreContract(contract)) return 'final run · no payout · campaign on the line'; + 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 { diff --git a/components/CrashDump.ts b/components/CrashDump.ts index 5ec2dd8..8c8cf41 100644 --- a/components/CrashDump.ts +++ b/components/CrashDump.ts @@ -1,8 +1,8 @@ /** * — 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). Score-complete campaign wins reuse the green - * exit presentation. Terminal campaign loss uses sibling ``. + * 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 @@ -25,7 +25,7 @@ */ import { h } from '/src/domUtils.js'; -import type { GameOverCrewStub, Telemetry } from '/src/types.js'; +import type { Telemetry } from '/src/types.js'; const CSS = ` :host { @@ -159,10 +159,6 @@ function exitTitle() { return '*** JACK OUT ***'; } -function scoreCompleteTitle() { - return '*** SCORE COMPLETE ***'; -} - function deathFault() { return ['fault: unhandled_exception_in_meatspace', 'addr: 0x00000@meatspace', 'trace:'].join( '\n' @@ -173,34 +169,7 @@ function exitFault() { return ['status: exfil_successful', 'addr: 0x00000@meatspace', 'trace:'].join('\n'); } -function scoreCompleteFault() { - return ['status: score_extracted', 'addr: 0x00000@campaign_layer', 'trace:'].join('\n'); -} - -function buildScoreCompleteTraceLines(crewRoster: GameOverCrewStub[]) { - const survivors = crewRoster.filter(op => !op.flatlined); - const lead = survivors[0]; - const lines = [ - { - text: ` 0x01 score::extract_success(${lead?.callsign ?? 'crew'})`, - tag: '', - }, - { text: ' 0x02 arc::score_completed()', tag: '' }, - ]; - if (survivors.length > 1) { - lines.push({ - text: ` 0x03 roster::survivors(${survivors.length})`, - tag: '', - }); - } - return lines; -} - function buildTraceLines(telemetry: Telemetry) { - if (telemetry.outcome === 'campaign-over') { - return buildScoreCompleteTraceLines(telemetry.crewRoster ?? []); - } - const archetype = telemetry.archetype ?? 'entity'; const lastSource = telemetry.lastDamageSource ?? 'unknown'; const attacker = telemetry.lastAttacker ?? 'unknown'; @@ -293,17 +262,11 @@ class CrashDump extends HTMLElement { throw new TypeError('.setTelemetry requires a telemetry object'); } const { outcome } = telemetry; - if (outcome !== 'death' && outcome !== 'exit' && outcome !== 'campaign-over') { + if (outcome !== 'death' && outcome !== 'exit') { throw new Error(`: unknown outcome "${outcome}"`); } - if (outcome === 'campaign-over' && telemetry.campaignEndReason !== 'score-complete') { - throw new Error(': campaign-over is only valid for score-complete wins'); - } - if (outcome === 'campaign-over' && !Array.isArray(telemetry.crewRoster)) { - throw new TypeError(': score-complete requires crewRoster array'); - } this.#telemetry = { ...telemetry }; - this.setAttribute('outcome', outcome === 'campaign-over' ? 'exit' : outcome); + this.setAttribute('outcome', outcome); if (this.#ready) this.#render(); } @@ -325,13 +288,9 @@ class CrashDump extends HTMLElement { #render() { if (!this.#els || !this.#telemetry) return; const t = this.#telemetry; - const isScoreWin = t.outcome === 'campaign-over'; const isDeath = t.outcome === 'death'; - if (isScoreWin) { - this.#els.title.textContent = scoreCompleteTitle(); - this.#els.fault.textContent = scoreCompleteFault(); - } else if (isDeath) { + if (isDeath) { this.#els.title.textContent = deathTitle(); this.#els.fault.textContent = deathFault(); } else { @@ -357,16 +316,10 @@ class CrashDump extends HTMLElement { }); this.#els.seedDd.textContent = hexSeed(t.seed ?? 0); - if (isScoreWin) { - this.#els.turnDd.textContent = '—'; - this.#els.killsDd.textContent = '—'; - this.#els.causeDd.textContent = 'Score extracted — campaign complete'; - } 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.newRunBtn.textContent = isScoreWin ? '[ NEW CAMPAIGN ]' : '[ RETURN TO HUB ]'; + 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/GameOver.ts b/components/GameOver.ts index 1ab5419..fe06edb 100644 --- a/components/GameOver.ts +++ b/components/GameOver.ts @@ -1,23 +1,19 @@ /** - * — full-screen terminal loss overlay. Shown when the campaign is - * truly over: crew wipe, Score window closed, or the last operator flatlines - * on a job. Distinct from ``, which handles recoverable per-job - * setbacks and successful exfil debriefs. + * — the single terminal campaign overlay. * - * GAME OVER - * The Score window closed. - * Corp security caught up. The contract is cold… - * [ NEW CAMPAIGN ] + * 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.setTelemetry({ campaignEndReason: 'clock-expired', seed }); + * screen.setSummary(summary); * screen.show(); */ import { h } from '/src/domUtils.js'; -import type { GameOverTelemetry } from '/src/types.js'; +import { validateCampaignSummary, type CampaignSummary } from '/src/game/campaignSummary.js'; const CSS = ` :host { @@ -25,6 +21,7 @@ const CSS = ` --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; @@ -36,6 +33,19 @@ const CSS = ` 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; @@ -47,6 +57,11 @@ const CSS = ` 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); @@ -60,13 +75,17 @@ const CSS = ` .banner { margin: 0 0 0.35rem; - font-size: clamp(1.75rem, 6vw, 2.5rem); - letter-spacing: 0.28em; + font-size: clamp(1.55rem, 6vw, 2.5rem); + letter-spacing: 0.22em; color: var(--go-accent); - text-shadow: 0 0 18px rgba(255, 51, 85, 0.45); + 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; } @@ -74,7 +93,7 @@ const CSS = ` .rule { margin: 0 auto 1.25rem; - width: min(280px, 70%); + width: min(320px, 76%); border: none; border-top: 2px solid var(--go-accent); opacity: 0.7; @@ -97,7 +116,7 @@ const CSS = ` margin: 0 0 1.25rem; padding: 0.65rem 0.85rem; background: rgba(0, 0, 0, 0.45); - border: 1px solid rgba(255, 51, 85, 0.28); + border: 1px solid var(--go-roster-border); border-radius: 4px; font-size: 0.85rem; text-align: left; @@ -127,11 +146,15 @@ const CSS = ` 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.2rem; + row-gap: 0.25rem; font-size: 0.85rem; margin: 0 0 1.25rem; text-align: left; @@ -178,53 +201,45 @@ button.new-campaign:focus-visible { button.new-campaign:active { transform: scale(0.98); } `; -function hexSeed(seed: number) { - if (!Number.isFinite(seed)) return '?'; +function hexSeed(seed: number): string { return `0x${(seed >>> 0).toString(16).toUpperCase().padStart(8, '0')}`; } -function reasonCopy(telemetry: GameOverTelemetry): string { - if (telemetry.campaignEndReason === 'clock-expired') { - return 'The Score window closed.'; - } - if (telemetry.campaignTerminal) { - return 'Final operator lost on the wire.'; +function reasonCopy(summary: CampaignSummary): string { + if (summary.result === 'win') return 'The Score is complete.'; + 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(telemetry: GameOverTelemetry): string { - if (telemetry.campaignEndReason === 'clock-expired') { +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.endReason === 'clock-expired') { return 'Corp security caught up. The contract is cold and this campaign is over.'; } - if (telemetry.campaignTerminal) { - const label = telemetry.cause ?? telemetry.archetype ?? 'operator'; - return `Last channel down — ${label}. No backup left on the roster.`; + if (summary.endReason === 'decker-flatlined-score') { + return 'The intrusion channel is gone. Until dual-deploy exists, nobody can finish the Score.'; } - const salvageNote = - telemetry.salvage != null && typeof telemetry.salvage === 'number' && telemetry.salvage >= 0 - ? `Pool salvage ${telemetry.salvage} is lost with the run.` - : 'Every crew slot on the roster is flatlined.'; - return `${salvageNote} Start a new campaign to run the board again.`; -} - -function causeLabel(telemetry: GameOverTelemetry): string { - if (telemetry.campaignEndReason === 'clock-expired') return 'window-closed'; - if (telemetry.campaignTerminal) return 'final-operator-lost'; - return 'crew-wipe'; + return 'Every crew slot on the roster is flatlined. Their campaign ends here.'; } class GameOver extends HTMLElement { - #telemetry: GameOverTelemetry | null = null; + #summary: CampaignSummary | null = null; #ready = false; #els: { + banner: HTMLElement; reason: HTMLElement; detail: HTMLElement; - roster: HTMLElement; rosterList: HTMLElement; + jobsDd: HTMLElement; + repDd: HTMLElement; + creditsDd: HTMLElement; seedDd: HTMLElement; causeDd: HTMLElement; - newCampaignBtn: HTMLButtonElement; } | null = null; connectedCallback() { @@ -234,13 +249,17 @@ class GameOver extends HTMLElement { 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 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: 'ROSTER' }), + h('dt', { textContent: 'FINAL ROSTER' }), rosterList, ]); @@ -252,12 +271,18 @@ class GameOver extends HTMLElement { newCampaignBtn.addEventListener('click', () => this.#emit('new-run')); const panel = h('section', { className: 'panel' }, [ - h('h1', { className: 'banner', textContent: 'GAME OVER' }), + banner, h('hr', { className: 'rule' }), reason, detail, 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' }), @@ -266,20 +291,24 @@ class GameOver extends HTMLElement { h('div', { className: 'actions' }, [newCampaignBtn]), ]); shadow.appendChild(panel); - this.#els = { reason, detail, roster, rosterList, seedDd, causeDd, newCampaignBtn }; + this.#els = { + banner, + reason, + detail, + rosterList, + jobsDd, + repDd, + creditsDd, + seedDd, + causeDd, + }; this.#ready = true; - if (this.#telemetry) this.#render(); + if (this.#summary) this.#render(); } - setTelemetry(telemetry: GameOverTelemetry) { - if (!telemetry || typeof telemetry !== 'object') { - throw new TypeError('.setTelemetry requires a telemetry object'); - } - const { campaignEndReason, campaignTerminal } = telemetry; - if (!campaignEndReason && !campaignTerminal) { - throw new Error(': requires campaignEndReason or campaignTerminal'); - } - this.#telemetry = { ...telemetry }; + setSummary(summary: CampaignSummary) { + this.#summary = validateCampaignSummary(summary); + this.setAttribute('result', this.#summary.result); if (this.#ready) this.#render(); } @@ -299,36 +328,34 @@ class GameOver extends HTMLElement { } #render() { - if (!this.#els || !this.#telemetry) return; - const t = this.#telemetry; - this.#els.reason.textContent = reasonCopy(t); - this.#els.detail.textContent = detailCopy(t); - this.#els.seedDd.textContent = hexSeed(t.seed ?? 0); - this.#els.causeDd.textContent = causeLabel(t); - - if (t.campaignEndReason === 'clock-expired') { - this.#els.roster.hidden = true; - return; - } - - const roster = t.crewRoster ?? []; - this.#els.roster.hidden = roster.length === 0; + 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); + 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( - ...roster.map(op => + ...summary.crewRoster.map(operator => h('div', { className: 'roster-line' }, [ - h('span', { textContent: `${op.callsign} (${op.archetype})` }), + h('span', { textContent: `${operator.callsign} (${operator.archetype})` }), h('span', { - className: 'status', - textContent: op.flatlined ? 'FLATLINED' : 'ACTIVE', + className: `status${operator.flatlined ? ' flatlined' : ''}`, + textContent: operator.flatlined ? 'FLATLINED' : 'SURVIVED', }), ]) ) ); } - #emit(eventName: string, detail: Record = {}) { + #emit(eventName: string) { this.dispatchEvent( - new CustomEvent(eventName, { detail: { ...detail, telemetry: { ...this.#telemetry } } }) + new CustomEvent(eventName, { + detail: { summary: this.#summary ? validateCampaignSummary(this.#summary) : null }, + }) ); } } diff --git a/components/RunBriefing.ts b/components/RunBriefing.ts index 0d289bc..420b577 100644 --- a/components/RunBriefing.ts +++ b/components/RunBriefing.ts @@ -197,7 +197,7 @@ function threatCopy(count: number) { function rewardCopy(contract: Partial) { if (contract.context?.tags.includes('score') && contract.context.recipeId === 'score-final') { - return 'Campaign finale'; + return contract.reward ? `Cr +${contract.reward.credits} / Campaign finale` : 'Campaign finale'; } const reward = contract.reward; if (!reward) return '?'; diff --git a/docs/kaizen.md b/docs/kaizen.md index bead4cd..091ca0f 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. **Planning updated:** see [phase-3-plan.md](phase-3-plan.md) P3.M3.1–P3.M3.6 for the first playable "enter Cyberspace" path (contract flag → jack-in terminal → serializable cyber layer → data node → Probe ICE → render swap), and P3.M4.1–P3.M4.6 for dual-deploy / flip integration. -- **Decker archetype.** Cyberspace specialist. Deferred alongside the Matrix layer — design the environment before designing who navigates it. **Planning updated:** see [phase-3-plan.md](phase-3-plan.md) P3.M2 for recruitment through progressive Hub reveal, normal roster ownership, drone override, and the rule that Cyberspace-required deployments fail loudly without a living Decker. +- **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 and [phase-3-cyberspace-plan.md](phase-3-cyberspace-plan.md) 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,7 +31,7 @@ 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. @@ -51,6 +51,7 @@ When an item lands, gets reclassified, or develops new context, edit it in place structural. - **run.world/run.player direct reads remain legal** — the new active-view seam is convention, not compiler-enforced; a lint rule or accessor deprecation would lock it in. +- ~~**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 diff --git a/docs/phase-2-plan.md b/docs/phase-2-plan.md index 7aa480d..516e778 100644 --- a/docs/phase-2-plan.md +++ b/docs/phase-2-plan.md @@ -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. 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-cyberspace-plan.md b/docs/phase-3-cyberspace-plan.md index 54d5b58..750b134 100644 --- a/docs/phase-3-cyberspace-plan.md +++ b/docs/phase-3-cyberspace-plan.md @@ -43,8 +43,16 @@ Commits land **per green slice** (user-approved). | **S7 — P3.M3.6 Render/input swap + shell ICE phase** | ✅ Done | `dd17cb8` | | **— Score → cyber (scope decision #5)** | ✅ Done | `3c693dc` | | **S7.5 — Early jack-out confirmation** | ✅ Done | `2d668bd` | +| **Playtest stabilization** | ✅ Done | — | | **S8 — Docs + wrap-up** | ✅ Done | — | +### Playtest stabilization (2026-06-14) + +- Probe ICE tuned from 3 HP / 4 AP to 2 HP / 2 AP. The one-damage strike stays; reducing repeated actions addresses the real burst-pressure source while making Probes appropriately disposable before Spark and Guardian land. +- CyberAvatar now exposes Override against ICE. It uses the existing 2 AP / 60% / 3-turn override contract, runs allied ICE in the cyber aftermath pass, and preserves override state through active-layer snapshots. +- A flatlined Decker before THE SCORE creates a single free replacement Decker lead in the Terminal. Recruiting them consumes the visit's recruit slot, and THE SCORE remains unavailable without a living Decker. +- A Decker flatline during THE SCORE ends the campaign with the persisted `decker-flatlined-score` reason and dedicated Game Over copy. This remains the rule until dual-deploy gives the Score another meaningful failure/recovery shape. + ### S1 implementation notes (shipped) - `OBJECTIVES.DATA_NODE_SLICE = 'data-node-slice'` with cross-field validation @@ -333,7 +341,7 @@ Commits land **per green slice** (user-approved). slice shipped end-to-end; milestone stays open for Spark/Guardian ICE (scope decision #1) and the deferred follow-ups noted in S6 (ICE faction stamping) and kaizen (index.ts cleanup). -- Browser re-smoke of the S7.5 confirm flow: pending (Rylee). +- Browser smoke of the S7.5 confirm flow: verified. ## Architecture decisions (approved plan) diff --git a/docs/phase-3-plan.md b/docs/phase-3-plan.md index 0af95cb..b31dd0d 100644 --- a/docs/phase-3-plan.md +++ b/docs/phase-3-plan.md @@ -46,7 +46,7 @@ A new **player archetype** recruited mid-campaign (late Act 1 / start of Act 2), | P3.M3 — Cyberspace grid + ICE | 🟡 First playable slice shipped (Spark/Guardian ICE open) | | P3.M4 — Simstim flip (dual-deploy) | 🔲 Planned | | P3.M5 — The Score (climactic mission) | 🔲 Planned | -| P3.M6 — Chronicle (campaign narrative memory) | 🔲 Planned | +| P3.M6 — Chronicle (campaign narrative memory) | 🟡 End-summary foundation shipped | **Phase 3** is complete when: @@ -103,9 +103,10 @@ The Score target is always a newly synthesized CRITICAL-tier site, never promote - Score = special contract build path; not part of the normal three-card job board. - **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). Terminal debrief: `*** SCORE COMPLETE ***`. + - **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):** `clockJobsTaken >= CLOCK_ACT2_DEADLINE_JOBS` (8) before `scoreAttempted`. Terminal debrief: `*** WINDOW CLOSED ***` — not a status-line footnote. Attempted Score keeps the deadline from retroactively killing the save. + - **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:** @@ -152,11 +153,11 @@ Neural degradation is deferred until Cyberspace is fun enough to deserve a jack- **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` and shows a dedicated terminal game-over screen (`*** WINDOW CLOSED ***` via ``), not a clock status footnote. Score win uses `*** SCORE COMPLETE ***`; crew wipe keeps the existing campaign-terminal death path. An attempted Score keeps the deadline from retroactively killing the save. +**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 marks `scoreCompleted` and ends the campaign in a win state. +**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:** @@ -238,16 +239,20 @@ The first jack-in should prove the door between layers before shipping every ICE 4. Latch a run state like `cyberspace.active = true`; repeated jack-in attempts against the same terminal throw or log a deterministic "already linked" message depending on whether state is corrupt or just redundant input. 5. Saving mid-jack-in restores both Meatspace and Cyberspace. Absent or malformed Cyberspace snapshot data for an active jack-in is tier-1 corrupt state and must throw to the boundary. -**Suggested slice order:** +**Implementation slices:** -| Slice | Change | Tests | -|---|---|---| -| **P3.M3.1 Contract flag** | Add Cyberspace-capable contract metadata and validation | generated only Act 2+, invalid flag/params throw | -| **P3.M3.2 Jack-in terminal** | Place a terminal that can start the digital layer | deterministic placement, no collision with objective props | -| **P3.M3.3 Cyber layer model** | Add serializable `Run.cyberspace` layer with grid/world/avatar | snapshot round-trip, active-layer invariants | -| **P3.M3.4 Data node objective** | Slice one data node and feed objective satisfaction | incomplete blocks clean extraction, complete allows it | -| **P3.M3.5 Probe ICE** | Minimal ICE patrol/detect/attack loop | seeded movement, detection/alarm, damage/death | -| **P3.M3.6 Render swap** | Render Cyberspace when active; Meatspace remains reachable for P3.M4 | browser smoke and console-clean verification | +| Slice | Status | Change | Tests | +|---|---|---|---| +| **P3.M3.1 Contract flag** | ✅ Done | Add Cyberspace-capable contract metadata and validation | generated only Act 2+, invalid flag/params throw | +| **P3.M3.2 Jack-in terminal** | ✅ Done | Place a terminal that can start the digital layer | deterministic placement, no collision with objective props | +| **P3.M3.3 Cyber layer model** | ✅ Done | Add serializable `Run.cyberspace` layer with grid/world/avatar | snapshot round-trip, active-layer invariants | +| **P3.M3.4 Data node objective** | ✅ Done | Slice one data node and feed objective satisfaction | incomplete blocks clean extraction, complete allows it | +| **P3.M3.5 Probe ICE** | ✅ Done | Minimal ICE patrol/detect/attack loop | seeded movement, detection/alarm, damage/death | +| **P3.M3.6 Render swap** | ✅ Done | Render Cyberspace when active; Meatspace remains reachable for P3.M4 | browser smoke and console-clean verification | +| **M4.6 pull-forward — voluntary jack-out** | ✅ Done | `JackInPoint.burned` latch; `Run.jackOut()`; shell swap; early jack-out confirmation modal (`onJackOutRequested` / `confirmJackOut`) | 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, terminal end reason | +| **Spark ICE** | 🔲 Planned | Fast, fragile attacker; swarm behavior | — | +| **Guardian ICE** | 🔲 Planned | Heavy guard of critical nodes; high HP/damage | — | **Acceptance:** @@ -257,6 +262,8 @@ The first jack-in should prove the door between layers before shipping every ICE - 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 pressure came from action economy, not its nominal one-damage strike: the original 3 HP / 4 AP unit could close and attack repeatedly after one Probe woke the pack. Probe now has 2 HP / 2 AP while retaining the trace-flare identity. `CyberAvatar` exposes the Decker's Override capability against ICE; successful overrides reuse the existing temporary faction flip, act during the cyber player-aftermath pass, revert on the normal countdown, and round-trip through the existing patrol snapshot. Campaign dead ends are explicit: before THE SCORE, loss of the assigned Decker creates one free Terminal replacement lead and hides THE SCORE until a living Decker joins; once THE SCORE begins, Decker death ends the campaign with `decker-flatlined-score` Game Over copy. + --- ### P3.M4 — Simstim flip (dual-deploy) 🔲 @@ -277,7 +284,7 @@ The first jack-in should prove the door between layers before shipping every ICE - **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 explicit 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, but if they're flatlined, the campaign is over - no new Deckers can be recruited. +- **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:** @@ -330,7 +337,7 @@ The first jack-in should prove the door between layers before shipping every ICE --- -### P3.M6 — Chronicle (campaign narrative memory) 🔲 +### P3.M6 — 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. @@ -352,6 +359,8 @@ The first jack-in should prove the door between layers before shipping every ICE - 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.M6 implementation note:** The end-summary foundation is shipped. A validated `CampaignSummary` is built only after campaign settlement reaches `ENDED`, so the final completed-job increment, Credits (including the Score payoff), Rep, seed, and roster state are captured from the canonical final campaign. Salvage remains a campaign resource rather than a summary measure of value. `DataStore` keeps summaries newest-first, preserves the original record on duplicate archival, and trims history to 50 campaigns. Live completion and restored ended saves share the same idempotent archival path. `` is now the single terminal campaign overlay with success and failure modes; terminal outcomes bypass the recoverable job-level `` debrief. Active per-job chronicle entries, Terminal presentation, and history browsing remain follow-up work within P3.M6. + --- ## Recorded design decisions diff --git a/index.ts b/index.ts index e482359..13fd602 100644 --- a/index.ts +++ b/index.ts @@ -21,7 +21,8 @@ import { serviceWorkerManager } from '/src/ServiceWorkerManager.js'; import dataStore from '/src/DataStore.js'; -import { Campaign, CAMPAIGN_STATE, willEndCampaignOnThisDeath } from '/src/game/Campaign.js'; +import { Campaign, CAMPAIGN_STATE, willEndCampaignAfterResult } from '/src/game/Campaign.js'; +import { buildCampaignSummary, type CampaignSummary } from '/src/game/campaignSummary.js'; import { totalSalvage, formatSalvageCompact, type TypedSalvage } from '/src/game/salvage.js'; import { RUN_STATE, Run } from '/src/game/Run.js'; import { restoreCampaign, snapshotCampaign } from '/src/game/persistence.js'; @@ -96,7 +97,7 @@ import type { Intent } from '/src/input/applyIntent.js'; import type { AimKind, Mode } from '/src/input/keymap.js'; import { formatCombatHudA11ySummary } from '/src/render/combatHud.js'; import type { CombatHudSummaryInput } from '/src/render/combatHud.js'; -import type { GameOverTelemetry, KeyItem, Telemetry, TurnActionStep } from '/src/types.js'; +import type { KeyItem, Telemetry, TurnActionStep } from '/src/types.js'; import { installErrorBoundary, type FaultSignal } from '/src/errorBoundary.js'; import { isDevelopmentMode } from '/src/domUtils.js'; @@ -146,7 +147,7 @@ type CrashDumpElement = ModalElement & { setTelemetry(telemetry: Telemetry): void; }; type GameOverElement = ModalElement & { - setTelemetry(telemetry: GameOverTelemetry): void; + setSummary(summary: CampaignSummary): void; }; type FaultScreenElement = ModalElement & { show(detail: { code?: string }): void; @@ -275,8 +276,6 @@ let briefingEl: RunBriefingElement; let contractSelectEl: ContractSelectElement; let crashEl: CrashDumpElement; let gameOverEl: GameOverElement; -/** Which overlay owns `RUN_STATE.RESULT` — set in `pushPendingJobResultOverlay`. */ -let resultOverlayTarget: 'crash' | 'game-over' = 'crash'; let faultEl: FaultScreenElement; let systemStartEl: SystemStartElement; let curatorBriefingEl: CuratorBriefingElement; @@ -625,6 +624,7 @@ async function boot() { // --------------------------------------------------------------------------- function startFreshCampaign() { + hideBlockingShellModals(); campaign = new Campaign({ seed: seedFromClock(), crew: [], @@ -633,6 +633,7 @@ function startFreshCampaign() { }); pendingJobResult = null; + setStatus('Booting Meatspace…'); systemStartEl.setSession({ seed: campaign.seed }); systemStartEl.show(); } @@ -818,6 +819,14 @@ function onCrewRecruit(evt: Event) { campaign.recruit(recruitId); const member = campaign.getCrewMember(recruitId); flash(`NEW OPERATIVE: ${member?.callsign ?? recruitId} joins the collective.`); + // A replacement Decker can unlock THE SCORE immediately. Preserve the + // existing three-card board and append only the newly legal finale. + if ( + campaign.canAttemptScore() && + !currentJobOptions.some(contract => contract.context.recipeId === 'score-final') + ) { + currentJobOptions.push(campaign.buildScoreContract()); + } // Refresh the roster to reflect the new crew + hide recruit section. presentCrewRoster(); } catch (err) { @@ -1221,52 +1230,9 @@ function handlePersist() { dataStore.setCampaign(snapshotCampaign(campaign)); } -function crewMemberArchetypeId(member: Crew): string { - const n = member?.constructor?.name; - if (n === 'Merc') return 'merc'; - if (n === 'Razor') return 'razor'; - if (n === 'Tech') return 'tech'; - return 'op'; -} - -function crewRosterStubs(c: Campaign) { - return c.crew.map(member => ({ - callsign: member.callsign ?? member.id, - archetype: crewMemberArchetypeId(member), - flatlined: !!member.flatlined, - })); -} - -function telemetryForEndedCampaign(c: Campaign): Telemetry { - const reason = c.endReason ?? 'crew-wipe'; - return { - outcome: 'campaign-over', - campaignEndReason: reason, - seed: c.seed, - salvage: c.salvage, - crewRoster: crewRosterStubs(c), - }; -} - -function gameOverTelemetryForCampaign(c: Campaign): GameOverTelemetry { - const reason = c.endReason ?? 'crew-wipe'; - if (reason === 'score-complete') { - throw new Error('[shell] score-complete is a win debrief, not game over'); - } - return { - campaignEndReason: reason, - seed: c.seed, - salvage: c.salvage, - crewRoster: crewRosterStubs(c), - }; -} - function presentEndedCampaignOverlay(c: Campaign): void { - if (c.endReason === 'score-complete') { - crashEl.setTelemetry(telemetryForEndedCampaign(c)); - } else { - gameOverEl.setTelemetry(gameOverTelemetryForCampaign(c)); - } + const summary = dataStore.archiveCampaign(buildCampaignSummary(c, new Date().toISOString())); + gameOverEl.setSummary(summary); } function presentCampaignEnd(c: Campaign): void { @@ -1280,9 +1246,9 @@ function finishEndedCampaign(): void { } /** - * Drives result overlays and `pendingJobResult` whenever the active job is in - * RESULT — both from a live `Run.onResult` callback and from a cold resume - * (otherwise `renderShell` opens an overlay with no `setTelemetry` call). + * Stage one result for settlement. Non-terminal jobs show ``; + * terminal outcomes are settled immediately by `handleResult` and route to + * the Chronicle-backed `` screen. */ function pushPendingJobResultOverlay(telemetry: Partial & { outcome?: unknown }) { const tel = { ...telemetry }; @@ -1294,31 +1260,20 @@ function pushPendingJobResultOverlay(telemetry: Partial & { outcom outcome, telemetry: { ...tel, outcome } as RunTelemetry & { outcome: Outcome }, }; - const campaignTerminal = - outcome === 'death' && campaign ? willEndCampaignOnThisDeath(campaign) : false; - if (campaignTerminal && campaign) { - const activeCampaign = campaign; - const deployedId = activeCampaign.deployedMemberId; - resultOverlayTarget = 'game-over'; - gameOverEl.setTelemetry({ - campaignTerminal: true, - seed: tel.seed, - cause: tel.cause, - archetype: tel.archetype, - crewRoster: activeCampaign.crew.map(member => ({ - callsign: member.callsign ?? member.id, - archetype: crewMemberArchetypeId(member), - flatlined: !!member.flatlined || member.id === deployedId, - })), - }); - } else { - resultOverlayTarget = 'crash'; - crashEl.setTelemetry({ - ...tel, - outcome, - cause: tel.cause ?? undefined, - }); - } + crashEl.setTelemetry({ + ...tel, + outcome, + cause: tel.cause ?? undefined, + }); +} + +function pendingResultEndsCampaign(result: PendingJobResult): boolean { + if (!campaign) return false; + return willEndCampaignAfterResult( + campaign, + result.outcome, + result.outcome === 'exit' && result.telemetry.objectiveComplete !== false + ); } function handleResult({ outcome, telemetry }: RunResult) { @@ -1327,6 +1282,10 @@ function handleResult({ outcome, telemetry }: RunResult) { ...telemetry, outcome: telemetry?.outcome ?? outcome, }); + if (pendingJobResult && pendingResultEndsCampaign(pendingJobResult)) { + settlePendingJobResult(); + return; + } renderShell(); } @@ -1397,8 +1356,12 @@ function resumeCampaign(record: CampaignSnapshot | unknown) { renderShell(); } else if (campaign.activeRun?.state === RUN_STATE.RESULT) { pushPendingJobResultOverlay({ ...campaign.activeRun.telemetry }); - flash('RESUMED — mission debrief.'); - renderShell(); + if (pendingJobResult && pendingResultEndsCampaign(pendingJobResult)) { + settlePendingJobResult(); + } else { + flash('RESUMED — mission debrief.'); + renderShell(); + } } else if (campaign.state === CAMPAIGN_STATE.HUB && campaign.curator) { renderShell(); if (!presentHubRevealIfAny(resumeFlashMessage)) { @@ -1446,7 +1409,6 @@ function performQuitCampaign(): void { itemInventoryEl.hide(); pendingJobResult = null; - resultOverlayTarget = 'crash'; dataStore.deleteCampaign(); startFreshCampaign(); flash('Campaign deleted — new campaign.'); @@ -1618,7 +1580,14 @@ function handleIntent(intent: Intent): void { if (campaign?.state === CAMPAIGN_STATE.HUB) { flash('Curator: Hang tight! Come talk to me to claim a contract.'); } - advanceTurn(); + // Moving onto an extraction tile synchronously emits the run result. + // Score settlement may therefore clear the active run and tear down + // the campaign world before this callback resumes. Only advance when + // the captured scene is still playable (incomplete abort/escort wait, + // or the Hub's decorative exit tile). + if (run.state === RUN_STATE.COMBAT || run.state === CAMPAIGN_STATE.HUB) { + advanceTurn(); + } break; } }, @@ -1649,15 +1618,31 @@ function driveCombatTurnPipeline(run: Run, options: { resumeFromCorpSlice?: bool rng: run.rng, isTerminal: () => run.state === RUN_STATE.RESULT, drivePlayerAftermath: ({ onStep, onFinish }) => { + const finishMeatAftermath = () => { + if (degrading) return; + clearBreachBlastOverlay(false); + const layer = cyberLayerOf(run); + if (!layer) { + onFinish(); + return; + } + drivePlayerAftermath({ + world: layer.world, + rng: run.rng, + onStep, + onFinish, + animLock, + stepDelayMs: PLAYER_AFTERMATH_ACTION_DELAY_MS, + lockMarginMs: ANIMATION_DURATIONS.MUZZLE_FLASH, + player: layer.avatar, + schedule: scheduleCombatPump, + }); + }; drivePlayerAftermath({ world, rng: run.rng, onStep, - onFinish: () => { - if (degrading) return; - clearBreachBlastOverlay(false); - onFinish(); - }, + onFinish: finishMeatAftermath, animLock, stepDelayMs: PLAYER_AFTERMATH_ACTION_DELAY_MS, lockMarginMs: ANIMATION_DURATIONS.MUZZLE_FLASH, @@ -1679,6 +1664,11 @@ function driveCombatTurnPipeline(run: Run, options: { resumeFromCorpSlice?: bool // *applies*, but the canvas is showing the grid, so skip its // presentation (overlays/shake/log lines). const jacked = isJackedIn(scene); + const cyberLayer = cyberLayerOf(scene); + const isCyberStep = + cyberLayer !== null && + step.type === 'overridden-drone' && + cyberLayer.world.entities.has(step.entity.id); if (step.type === 'breach-detonate' && !jacked) { showBreachBlastOverlay(step.charge.x, step.charge.y); if (scene?.player && vision.isVisible(step.charge.x, step.charge.y)) { @@ -1687,6 +1677,18 @@ function driveCombatTurnPipeline(run: Run, options: { resumeFromCorpSlice?: bool } } if ( + isCyberStep && + cyberLayer && + isPlayerAftermathStepLogVisible( + step, + (x, y) => cyberVision.isVisible(x, y), + cyberLayer.avatar.id + ) + ) { + for (const line of formatPlayerAftermathStepLogLines(step)) { + flash(line); + } + } else if ( !jacked && scene?.player && isPlayerAftermathStepLogVisible(step, (x, y) => vision.isVisible(x, y), scene.player.id) @@ -2021,49 +2023,50 @@ function handleCombatInteract(): void { // onJackIn removed — combat entry is handled in onBriefingDeploy. +function settlePendingJobResult(): 'ended' | 'hub' { + if (!campaign || !pendingJobResult) { + throw new Error('settlePendingJobResult requires an active campaign result'); + } + const jobResult = pendingJobResult; + const { outcome } = jobResult; + pendingJobResult = null; + // Extract typed salvage from the deployed crew member's inventory on exit. + // Death outcomes pass `undefined`, preserving the existing forfeiture rule. + const member = campaign.deployedMemberId + ? campaign.getCrewMember(campaign.deployedMemberId) + : null; + const salvage = member?.inventory?.salvage; + const objectiveComplete = + outcome === 'exit' ? jobResult.telemetry.objectiveComplete !== false : false; + // Apply the clean completion bonus before `onJobEnd`, so the terminal + // Chronicle record sees the final Rep value and Hub recruitment gates retain + // their existing ordering on non-terminal jobs. + if (outcome === 'exit' && objectiveComplete && civilianHarmsThisJob === 0) { + const actual = campaign.adjustRep(REP.CLEAN_COMPLETION_BONUS); + flash(`REP +${actual}: clean extraction — no civilian casualties.`); + } + if (outcome === 'exit' && !objectiveComplete) { + flash(`ABORT: Objective abandoned. REP ${REP.ABORT_PENALTY}.`); + } + campaign.onJobEnd({ outcome, salvage, completed: objectiveComplete }); + if (campaign.state === CAMPAIGN_STATE.ENDED) { + presentCampaignEnd(campaign); + return 'ended'; + } + if (!presentHubRevealIfAny('HUB — choose the next job.')) { + flash('HUB — choose the next job.'); + } + if (!campaign.curator) { + throw new Error('settlePendingJobResult: hub not entered — curator is missing.'); + } + currentJobOptions = generateCurrentJobOptions(); + return 'hub'; +} + function onNewRunRequested(): void { if (!campaign) return; if (pendingJobResult) { - const jobResult = pendingJobResult; - const { outcome } = jobResult; - pendingJobResult = null; - // Extract typed salvage from the deployed crew member's - // inventory on exit. Death outcomes pass `undefined` so onJobEnd defaults - // to an empty wallet (loot forfeited on flatline). - const member = campaign.deployedMemberId - ? campaign.getCrewMember(campaign.deployedMemberId) - : null; - const salvage = member?.inventory?.salvage; - const objectiveComplete = - outcome === 'exit' ? jobResult.telemetry.objectiveComplete !== false : false; - // Clean completion bonus — must run *before* `onJobEnd` so `enterHub` → - // `generateRecruits()` sees the updated Rep (recruitment gates at 65). - if (outcome === 'exit' && objectiveComplete && civilianHarmsThisJob === 0) { - const actual = campaign.adjustRep(REP.CLEAN_COMPLETION_BONUS); - flash(`REP +${actual}: clean extraction — no civilian casualties.`); - } - if (outcome === 'exit' && !objectiveComplete) { - flash(`ABORT: Objective abandoned. REP ${REP.ABORT_PENALTY}.`); - } - campaign.onJobEnd({ outcome, salvage, completed: objectiveComplete }); - if (campaign.state === CAMPAIGN_STATE.ENDED) { - pendingJobResult = null; - if (campaign.endReason === 'crew-wipe') { - finishEndedCampaign(); - return; - } - presentCampaignEnd(campaign); - return; - } else { - if (!presentHubRevealIfAny('HUB — choose the next job.')) { - flash('HUB — choose the next job.'); - } - // reset the current job options - if (!campaign.curator) { - throw new Error('onNewRunRequested: hub not entered — curator is missing.'); - } - currentJobOptions = generateCurrentJobOptions(); - } + if (settlePendingJobResult() === 'ended') return; } else if (campaign.state === CAMPAIGN_STATE.ENDED) { finishEndedCampaign(); return; @@ -2324,24 +2327,14 @@ function renderShell(): void { case RUN_STATE.RESULT: canvas.hidden = true; briefingEl.hide(); - if (resultOverlayTarget === 'game-over') { - crashEl.hide(); - gameOverEl.show(); - } else { - gameOverEl.hide(); - crashEl.show(); - } + gameOverEl.hide(); + crashEl.show(); break; case CAMPAIGN_STATE.ENDED: canvas.hidden = true; briefingEl.hide(); - if (campaign.endReason === 'score-complete') { - gameOverEl.hide(); - crashEl.show(); - } else { - crashEl.hide(); - gameOverEl.show(); - } + crashEl.hide(); + gameOverEl.show(); break; default: throw new Error(`[shell] unknown state "${state}"`); diff --git a/src/DataStore.ts b/src/DataStore.ts index f8eae0c..3d70c29 100644 --- a/src/DataStore.ts +++ b/src/DataStore.ts @@ -1,5 +1,12 @@ // singleton class to manage the user's data +import { + archiveCampaignSummary, + cloneCampaignSummary, + normalizeCampaignHistory, + type CampaignSummary, +} from './game/campaignSummary.js'; + const STORAGE_KEY = 'kp:data'; let instance: DataStore | null = null; @@ -15,13 +22,15 @@ type KPData = { prefs: Record; runs: Run[]; campaign: Campaign | null; + campaignHistory: CampaignSummary[]; }; -type KPDataObject = string | object | Run | Campaign | Run[] | Campaign[]; +type KPDataObject = string | object | Run | Campaign | Run[] | Campaign[] | CampaignSummary[]; class DataStore extends EventTarget { #prefs: KPData['prefs'] = {}; #runs: KPData['runs'] = []; #campaign: KPData['campaign'] = null; + #campaignHistory: KPData['campaignHistory'] = []; constructor() { if (instance) { @@ -36,46 +45,58 @@ class DataStore extends EventTarget { #loadDataFromJson(json: string): KPData { try { const data = JSON.parse(json); - return { prefs: data.prefs ?? {}, runs: data.runs ?? [], campaign: data.campaign ?? null }; + return { + prefs: data.prefs ?? {}, + runs: data.runs ?? [], + campaign: data.campaign ?? null, + campaignHistory: normalizeCampaignHistory(data.campaignHistory), + }; } catch (error) { console.warn('[DataStore] Failed to parse stored JSON, resetting stored data.', error); try { window.localStorage.setItem( STORAGE_KEY, - JSON.stringify({ prefs: {}, runs: [], campaign: null }) + JSON.stringify({ prefs: {}, runs: [], campaign: null, campaignHistory: [] }) ); } catch (storageError) { console.warn('[DataStore] Failed to reset stored data.', storageError); } - return { prefs: {}, runs: [], campaign: null }; + return { prefs: {}, runs: [], campaign: null, campaignHistory: [] }; } } async init() { let savedDataJson = window.localStorage.getItem(STORAGE_KEY); if (!savedDataJson) { - savedDataJson = JSON.stringify({ prefs: {}, runs: [], campaign: null }); + savedDataJson = JSON.stringify({ prefs: {}, runs: [], campaign: null, campaignHistory: [] }); window.localStorage.setItem(STORAGE_KEY, savedDataJson); } - const { prefs, runs, campaign } = this.#loadDataFromJson(savedDataJson); + const { prefs, runs, campaign, campaignHistory } = this.#loadDataFromJson(savedDataJson); this.#prefs = prefs; this.#runs = runs; this.#campaign = campaign; + this.#campaignHistory = campaignHistory; this.#emitChangeEvent('init', '*'); } import(jsonData: string): void { - const { prefs, runs, campaign } = this.#loadDataFromJson(jsonData); + const { prefs, runs, campaign, campaignHistory } = this.#loadDataFromJson(jsonData); this.#prefs = prefs; this.#runs = runs; this.#campaign = campaign; + this.#campaignHistory = campaignHistory; this.#emitChangeEvent('import', '*'); } #saveData() { window.localStorage.setItem( STORAGE_KEY, - JSON.stringify({ prefs: this.#prefs, runs: this.#runs, campaign: this.#campaign }) + JSON.stringify({ + prefs: this.#prefs, + runs: this.#runs, + campaign: this.#campaign, + campaignHistory: this.#campaignHistory, + }) ); } @@ -108,6 +129,20 @@ class DataStore extends EventTarget { return this.#campaign; } + get campaignHistory(): CampaignSummary[] { + return this.#campaignHistory.map(cloneCampaignSummary); + } + + archiveCampaign(summary: CampaignSummary): CampaignSummary { + const archived = archiveCampaignSummary(this.#campaignHistory, summary); + this.#campaignHistory = archived.history; + if (archived.added) { + this.#emitChangeEvent('add', 'campaignHistory', cloneCampaignSummary(archived.summary)); + this.#saveData(); + } + return cloneCampaignSummary(archived.summary); + } + getRunById(id: string): Run | undefined { return this.#runs.find(run => run.id === id); } diff --git a/src/game/Campaign.ts b/src/game/Campaign.ts index 17f441f..fe07896 100644 --- a/src/game/Campaign.ts +++ b/src/game/Campaign.ts @@ -73,6 +73,8 @@ export const CLOCK_ACT2_GRACE_JOBS = 3; export const CLOCK_HEAT_WINDOW_JOBS = 5; /** Total act-2/3 deploys allowed before clock loss (`grace + window`). */ export const CLOCK_ACT2_DEADLINE_JOBS = CLOCK_ACT2_GRACE_JOBS + CLOCK_HEAT_WINDOW_JOBS; +/** Campaign-ending payday for completing THE SCORE. */ +export const SCORE_CREDITS_REWARD = 5_000; const SYNTHETIC_SCORE_TARGET_DIFFICULTY = CONTRACT_DIFFICULTY.CRITICAL; export const CAMPAIGN_STATE = Object.freeze({ @@ -132,13 +134,14 @@ export type CampaignOptions = { type CampaignLike = { crew: { flatlined: boolean }[]; + activeRun?: { contract: Contract | null } | null; + arc?: Pick; + clockJobsTaken?: number; }; /** - * True when exactly one crew member is not yet `flatlined` — the operator - * currently on a job. A `DEATH` outcome on `Campaign.onJobEnd` would flatline - * them and set `Campaign.state` to `ENDED`. The shell uses this to swap the - * debrief overlay before `onJobEnd` runs. + * True when a `DEATH` outcome is campaign-terminal: either the last living + * operator is gone, or the Decker flatlines during the Score. * * @param {{ crew: { flatlined: boolean }[] }} campaign */ @@ -146,7 +149,38 @@ export function willEndCampaignOnThisDeath(campaign: CampaignLike): boolean { if (!campaign || typeof campaign !== 'object' || !Array.isArray(campaign.crew)) { throw new TypeError('willEndCampaignOnThisDeath requires a Campaign-like object with crew[]'); } - return campaign.crew.filter(member => !member.flatlined).length === 1; + return ( + campaign.crew.filter(member => !member.flatlined).length === 1 || + (campaign.activeRun?.contract !== null && + campaign.activeRun?.contract !== undefined && + isScoreContract(campaign.activeRun.contract)) + ); +} + +/** + * True when settling this job result will transition the campaign to ENDED. + * The shell uses this before showing a debrief so terminal outcomes can bypass + * `` and proceed directly to the Chronicle summary. + */ +export function willEndCampaignAfterResult( + campaign: CampaignLike, + outcome: Outcome, + completed: boolean +): boolean { + if (outcome !== OUTCOME.DEATH && outcome !== OUTCOME.EXIT) { + throw new Error(`willEndCampaignAfterResult: unknown outcome "${outcome}"`); + } + if (typeof completed !== 'boolean') { + throw new TypeError('willEndCampaignAfterResult: completed must be boolean'); + } + if (outcome === OUTCOME.DEATH && willEndCampaignOnThisDeath(campaign)) return true; + const contract = campaign.activeRun?.contract; + if (outcome === OUTCOME.EXIT && completed && contract && isScoreContract(contract)) return true; + return Boolean( + campaign.arc?.scoreRevealed && + (campaign.clockJobsTaken ?? 0) >= CLOCK_ACT2_DEADLINE_JOBS && + !campaign.arc.scoreAttempted + ); } export function buildCrew(rng: Rng): Crew[] { @@ -338,6 +372,9 @@ export class Campaign { get endReason(): CampaignEndReason | null { if (this.state !== CAMPAIGN_STATE.ENDED) return null; if (this.arc.scoreCompleted) return 'score-complete'; + if (this.arc.scoreAttempted && this.arc.arcStage === 'score') { + return 'decker-flatlined-score'; + } if ( this.arc.scoreRevealed && this.clockJobsTaken >= CLOCK_ACT2_DEADLINE_JOBS && @@ -424,7 +461,13 @@ export class Campaign { backfillRecruitsIfEligible(): void { if (this.state !== CAMPAIGN_STATE.HUB) return; if (this.recruitedThisVisit) return; - if (this.rep < REP.RECRUIT_THRESHOLD && !this.pendingRecruitReward) return; + if ( + this.rep < REP.RECRUIT_THRESHOLD && + !this.pendingRecruitReward && + !this.#needsReplacementDecker() + ) { + return; + } if (this.availableRecruits.length > 0) return; this.availableRecruits = this.generateRecruits(); this.#persist(); @@ -519,6 +562,10 @@ export class Campaign { isScoreContract(this.activeRun.contract) && outcome === OUTCOME.EXIT && completed; + const failedScoreRun = + this.activeRun.contract !== null && + isScoreContract(this.activeRun.contract) && + outcome === OUTCOME.DEATH; if (outcome === OUTCOME.DEATH) { this.flatlineMember(this.deployedMemberId); @@ -550,6 +597,13 @@ export class Campaign { this.activeRun = null; this.deployedMemberId = null; + if (failedScoreRun) { + this.state = CAMPAIGN_STATE.ENDED; + this.#tearDownHubWorld(); + this.#persist(); + return; + } + if (this.crew.every(member => member.flatlined)) { this.state = CAMPAIGN_STATE.ENDED; this.#tearDownHubWorld(); @@ -574,6 +628,7 @@ export class Campaign { if (this.state !== CAMPAIGN_STATE.HUB) return false; if (this.arc.arcStage !== 'act-3') return false; if (this.arc.scoreAttempted || this.arc.scoreCompleted) return false; + if (!this.hasLivingDecker) return false; return this.#scoreTargetSiteOrNull() !== null; } @@ -625,7 +680,7 @@ export class Campaign { arcStage: 'score', locationSiteId: target.id, }, - reward: { credits: 0, repDelta: 0 }, + reward: { credits: SCORE_CREDITS_REWARD, repDelta: 0 }, }; } @@ -724,6 +779,14 @@ export class Campaign { * Returns an empty array when Rep is below the recruitment threshold. */ generateRecruits(): Crew[] { + if (this.#needsReplacementDecker()) { + const replacement = buildCrewMember('decker', { x: 0, y: 0 }, this.rng, { + id: `recruit-decker-${this.rng.intRange(0, 0xffff).toString(16)}`, + excludeCallsigns: this.allUsedCallsigns(), + }); + this.rewardRecruitIds.add(replacement.id); + return [replacement]; + } const rewardRecruit = this.pendingRecruitReward; if (this.rep < REP.RECRUIT_THRESHOLD && !rewardRecruit) return []; const count = @@ -1205,6 +1268,11 @@ export class Campaign { this.arc.deckerRecruited = true; } + /** A pre-Score flatline opens one free Terminal lead instead of dead-ending the campaign. */ + #needsReplacementDecker(): boolean { + return this.arc.scoreRevealed && !this.arc.scoreAttempted && !this.hasLivingDecker; + } + #ensureScoreTargetDesignated(): void { const scoreTargets = this.siteRoster.filter(site => site.scoreTarget); if (scoreTargets.length > 1) { diff --git a/src/game/campaignSummary.ts b/src/game/campaignSummary.ts new file mode 100644 index 0000000..0387ab9 --- /dev/null +++ b/src/game/campaignSummary.ts @@ -0,0 +1,236 @@ +import type { CampaignEndReason } from '../types.js'; + +export const CAMPAIGN_HISTORY_CAP = 50; + +export type CampaignSummaryResult = 'win' | 'loss'; + +export type CampaignSummaryCrew = { + callsign: string; + archetype: string; + flatlined: boolean; +}; + +export type CampaignSummary = { + campaignId: string; + completedAt: string; + result: CampaignSummaryResult; + endReason: CampaignEndReason; + seed: number; + completedJobs: number; + rep: number; + credits: number; + crewRoster: CampaignSummaryCrew[]; +}; + +type EndedCampaignLike = { + id: string; + state: string; + endReason: CampaignEndReason | null; + seed: number; + completedJobs: number; + rep: number; + credits: number; + crew: Array<{ + id: string; + callsign: string | null; + archetype: string; + flatlined: boolean; + }>; +}; + +const END_REASONS: readonly CampaignEndReason[] = [ + 'crew-wipe', + 'clock-expired', + 'decker-flatlined-score', + 'score-complete', +]; + +export function buildCampaignSummary( + campaign: EndedCampaignLike, + completedAt: string +): CampaignSummary { + if (!campaign || typeof campaign !== 'object') { + throw new TypeError('buildCampaignSummary requires a Campaign-like object'); + } + if (campaign.state !== 'ENDED') { + throw new Error(`buildCampaignSummary requires an ENDED campaign, got ${campaign.state}`); + } + if (!campaign.endReason) { + throw new Error('buildCampaignSummary requires a campaign end reason'); + } + + return validateCampaignSummary({ + campaignId: campaign.id, + completedAt, + result: campaign.endReason === 'score-complete' ? 'win' : 'loss', + endReason: campaign.endReason, + seed: campaign.seed, + completedJobs: campaign.completedJobs, + rep: campaign.rep, + credits: campaign.credits, + crewRoster: campaign.crew.map(member => ({ + callsign: member.callsign ?? member.id, + archetype: member.archetype, + flatlined: member.flatlined, + })), + }); +} + +export function validateCampaignSummary(value: unknown): CampaignSummary { + if (!isPlainObject(value)) { + throw new TypeError('CampaignSummary must be a plain object'); + } + const summary = value as Record; + requireExactKeys(summary, [ + 'campaignId', + 'completedAt', + 'result', + 'endReason', + 'seed', + 'completedJobs', + 'rep', + 'credits', + 'crewRoster', + ]); + const campaignId = requireNonEmptyString(summary.campaignId, 'CampaignSummary.campaignId'); + const completedAt = requireIsoTimestamp(summary.completedAt); + if (summary.result !== 'win' && summary.result !== 'loss') { + throw new Error(`CampaignSummary.result must be win or loss, got ${summary.result}`); + } + if (!END_REASONS.includes(summary.endReason as CampaignEndReason)) { + throw new Error(`CampaignSummary.endReason is invalid: ${summary.endReason}`); + } + const endReason = summary.endReason as CampaignEndReason; + const expectedResult: CampaignSummaryResult = endReason === 'score-complete' ? 'win' : 'loss'; + if (summary.result !== expectedResult) { + throw new Error(`CampaignSummary ${endReason} must have result ${expectedResult}`); + } + const seed = requireFiniteNumber(summary.seed, 'CampaignSummary.seed'); + const completedJobs = requireNonNegativeInteger( + summary.completedJobs, + 'CampaignSummary.completedJobs' + ); + const rep = requireNonNegativeInteger(summary.rep, 'CampaignSummary.rep'); + const credits = requireNonNegativeInteger(summary.credits, 'CampaignSummary.credits'); + if (!Array.isArray(summary.crewRoster)) { + throw new TypeError('CampaignSummary.crewRoster must be an array'); + } + const crewRoster = summary.crewRoster.map((member, index) => validateCrew(member, index)); + + return { + campaignId, + completedAt, + result: summary.result, + endReason, + seed, + completedJobs, + rep, + credits, + crewRoster, + }; +} + +export function normalizeCampaignHistory(value: unknown): CampaignSummary[] { + if (value === undefined) return []; + if (!Array.isArray(value)) { + throw new TypeError('campaignHistory must be an array'); + } + const seen = new Set(); + const history: CampaignSummary[] = []; + for (const candidate of value) { + const summary = validateCampaignSummary(candidate); + if (seen.has(summary.campaignId)) continue; + seen.add(summary.campaignId); + history.push(summary); + if (history.length === CAMPAIGN_HISTORY_CAP) break; + } + return history; +} + +export function archiveCampaignSummary( + history: readonly CampaignSummary[], + candidate: CampaignSummary +): { history: CampaignSummary[]; summary: CampaignSummary; added: boolean } { + const normalized = normalizeCampaignHistory(history); + const summary = validateCampaignSummary(candidate); + const existing = normalized.find(entry => entry.campaignId === summary.campaignId); + if (existing) { + return { history: normalized, summary: existing, added: false }; + } + return { + history: [summary, ...normalized].slice(0, CAMPAIGN_HISTORY_CAP), + summary, + added: true, + }; +} + +export function cloneCampaignSummary(summary: CampaignSummary): CampaignSummary { + return { + ...summary, + crewRoster: summary.crewRoster.map(member => ({ ...member })), + }; +} + +function validateCrew(value: unknown, index: number): CampaignSummaryCrew { + if (!isPlainObject(value)) { + throw new TypeError(`CampaignSummary.crewRoster[${index}] must be a plain object`); + } + const member = value as Record; + requireExactKeys(member, ['callsign', 'archetype', 'flatlined']); + if (typeof member.flatlined !== 'boolean') { + throw new TypeError(`CampaignSummary.crewRoster[${index}].flatlined must be boolean`); + } + return { + callsign: requireNonEmptyString( + member.callsign, + `CampaignSummary.crewRoster[${index}].callsign` + ), + archetype: requireNonEmptyString( + member.archetype, + `CampaignSummary.crewRoster[${index}].archetype` + ), + flatlined: member.flatlined, + }; +} + +function requireIsoTimestamp(value: unknown): string { + const timestamp = requireNonEmptyString(value, 'CampaignSummary.completedAt'); + const parsed = new Date(timestamp); + if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== timestamp) { + throw new Error(`CampaignSummary.completedAt must be an ISO timestamp, got ${timestamp}`); + } + return timestamp; +} + +function requireNonEmptyString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new TypeError(`${label} must be a non-empty string`); + } + return value; +} + +function requireFiniteNumber(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new TypeError(`${label} must be a finite number`); + } + return value; +} + +function requireNonNegativeInteger(value: unknown, label: string): number { + if (!Number.isInteger(value) || (value as number) < 0) { + throw new RangeError(`${label} must be a non-negative integer`); + } + return value as number; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requireExactKeys(value: Record, expected: readonly string[]): void { + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + throw new TypeError(`CampaignSummary keys must be exactly ${wanted.join(', ')}`); + } +} diff --git a/src/game/cyber/CyberAvatar.ts b/src/game/cyber/CyberAvatar.ts index c4b761d..07e7514 100644 --- a/src/game/cyber/CyberAvatar.ts +++ b/src/game/cyber/CyberAvatar.ts @@ -1,8 +1,9 @@ /** * P3.M3.3 — the Decker's avatar on the Cyberspace grid. * - * A plain `Entity` (NOT a `Crew` — no inventory, gear, or perks): the cyber - * grid has its own verbs. Pools derive from the Decker's named cyber stats: + * A plain `Entity` (NOT a `Crew` — no inventory or gear): the cyber grid has + * its own verbs, including the Decker's Override signature against ICE. Pools + * derive from the Decker's named cyber stats: * * - `maxHp = decker.ram` — ICE damage burns RAM; zero RAM = flatline * (avatar death routes through the existing DEATH path; scope decision #3). @@ -16,7 +17,10 @@ * also carries `intrusionStrength`, so a flag (not the stat) marks the avatar. */ import { Entity } from '../Entity.js'; +import { canOverride, overrideDrone } from '../droneOverride.js'; import { CYBER_AVATAR_HIT_CHANCE, CYBER_AVATAR_MAX_AP, FACTION } from '../constants.js'; +import type { World } from '../World.js'; +import type { Rng } from '../../rng.js'; export type CyberAvatarInit = { id: string; @@ -61,6 +65,16 @@ export class CyberAvatar extends Entity { return this.damageReduction; } + /** Decker Override translated into the digital layer: ICE is hostile code. */ + canOverride(world: World, target: Entity | null) { + return canOverride(world, this, target); + } + + /** Attempt to flip ICE to the avatar's faction for the normal override duration. */ + overrideDrone(world: World, target: Entity, rng: Rng) { + return overrideDrone(world, this, target, rng); + } + constructor({ id, x, diff --git a/src/game/cyber/ProbeIce.ts b/src/game/cyber/ProbeIce.ts index 27e9489..7ce0534 100644 --- a/src/game/cyber/ProbeIce.ts +++ b/src/game/cyber/ProbeIce.ts @@ -21,7 +21,8 @@ import type { Entity } from '../Entity.js'; import type { World } from '../World.js'; import type { Rng } from '../../rng.js'; -export const PROBE_ICE_HP = 3; +export const PROBE_ICE_HP = 2; +export const PROBE_ICE_AP = 2; export const PROBE_ICE_DAMAGE = 1; export const PROBE_ICE_SIGHT_RANGE = 6; @@ -33,12 +34,14 @@ export class ProbeIce extends PatrolHostile { constructor({ maxHp = PROBE_ICE_HP, + maxAp = PROBE_ICE_AP, sightRange = PROBE_ICE_SIGHT_RANGE, patrolWaypoints, ...props }: ProbeIceProps) { super({ ...props, + maxAp, maxHp, sightRange, glyph: PROBE_ICE_GLYPH, diff --git a/src/input/applyIntent.ts b/src/input/applyIntent.ts index 090decc..0325470 100644 --- a/src/input/applyIntent.ts +++ b/src/input/applyIntent.ts @@ -22,9 +22,9 @@ * keymap layer. The `doSpecial` dispatcher below routes it to the right verb * based on which methods the active player class exposes — `canVault` → vault, * `canSlide` → slide, `canDeploy` → deploy, `canOverride` → override (the - * Decker resolves a drone along the aim ray, like fire). This keeps the input - * surface symmetric across archetypes (one key, one touch button) and stays - * out of the player's way: + * Decker resolves the nearest visible drone in the aimed eight-way sector). + * This keeps the input surface symmetric across archetypes (one key, one touch + * button) and stays out of the player's way: * the keymap doesn't need to know which class is in play, and the intent * dispatcher doesn't need an explicit archetype switch. * @@ -58,9 +58,11 @@ import { Door } from '../game/entities/Door.js'; import { DenyTarget } from '../game/entities/DenyTarget.js'; import { EVENT } from '../game/events.js'; import { TILE } from '../game/constants.js'; +import { Hostile } from '../game/Hostile.js'; import type { KeyItem } from '../types.js'; import type { Archetype } from '../game/archetypes/index.js'; import type { CyberAvatar } from '../game/cyber/CyberAvatar.js'; +import type { Entity } from '../game/Entity.js'; import type { World } from '../game/World.js'; import type { TurnQueue } from '../game/TurnQueue.js'; import type { Rng } from '../rng.js'; @@ -79,9 +81,8 @@ export type ApplyIntentContext = { world: World; /** * P3.M3.6: the acting body — a crew archetype in Meatspace, the - * `CyberAvatar` on the grid. The avatar exposes none of the perk or - * inventory capabilities, so every capability-sniffed branch (`doSpecial`, - * loot, consumables) degrades to its existing refusal path. + * `CyberAvatar` on the grid. The avatar exposes Override but no inventory + * capabilities, so loot and consumables retain their refusal paths. */ player: Archetype | CyberAvatar; queue: TurnQueue; @@ -375,15 +376,18 @@ function doSpecial(intent: Intent, ctx: ApplyIntentContext) { } /** - * Walk the aim ray for the Decker's Override perk and resolve the first hostile - * drone in reach, then attempt the hijack. Mirrors `pickFireTarget`'s geometry - * (bounded by `OVERRIDE_RANGE`, LOS-gated) so the target the picker lands on is - * exactly the one the resolver will act on. A failed roll trips the alarm; an - * out-of-reach ray or non-drone target yields a legible deny, not a crash. + * Acquire a drone in the Decker's aimed eight-way sector and attempt the + * hijack. Range, LOS, and perception match the resolver and combat targeting; + * a failed roll trips the alarm, while an empty sector yields a legible deny. */ +type OverrideActor = ApplyIntentContext['player'] & { + canOverride(world: World, target: Entity | null): ReturnType; + overrideDrone(world: World, target: Entity, rng: Rng): ReturnType; +}; + function doOverride(intent: Intent, ctx: ApplyIntentContext) { const { world, player, log } = ctx; - const decker = player as Decker; + const decker = player as OverrideActor; const playerLabel = entityLabel(player); const target = pickOverrideTarget(ctx, intent.dx!, intent.dy!); const check = decker.canOverride(world, target); @@ -405,23 +409,57 @@ function doOverride(intent: Intent, ctx: ApplyIntentContext) { } /** - * First hostile along (dx, dy) within `OVERRIDE_RANGE` and LOS. Returns `null` - * when the ray leaves the map, exceeds range, hits a wall, or finds no entity — - * `canOverride` turns that `null` into a `not-overridable` deny. + * Nearest live hostile in the aimed eight-way sector within `OVERRIDE_RANGE` + * and LOS. The 22.5-degree half-angle partitions arbitrary target offsets + * across the eight directions supplied by keyboard and touch controls, so a + * moving Probe does not need to be perfectly collinear with the avatar. */ function pickOverrideTarget(ctx: ApplyIntentContext, dx: number, dy: number) { const { world, player } = ctx; const blockers = world.blockerKeys(); - for (let step = 1; step <= OVERRIDE_RANGE; step++) { - const x = player.x + dx * step; - const y = player.y + dy * step; - if (!world.grid.inBounds(x, y)) return null; - if (!withinRange(player.x, player.y, x, y, OVERRIDE_RANGE)) return null; - if (!hasLineOfSight(world.grid, player.x, player.y, x, y, { blockers })) return null; - const e = world.entityAt(x, y); - if (e) return e; + let best: Hostile | null = null; + let bestDistanceSquared = Infinity; + let bestAlignment = -Infinity; + + for (const entity of world.entities.values()) { + if (!(entity instanceof Hostile) || !entity.alive) continue; + const offsetX = entity.x - player.x; + const offsetY = entity.y - player.y; + if (!isInAimSector(dx, dy, offsetX, offsetY)) continue; + if (!withinRange(player.x, player.y, entity.x, entity.y, OVERRIDE_RANGE)) continue; + if ( + !hasLineOfSight(world.grid, player.x, player.y, entity.x, entity.y, { + blockers, + }) + ) { + continue; + } + if (isConcealedFromPlayer(entity, player, world)) continue; + + const distanceSquared = offsetX * offsetX + offsetY * offsetY; + const alignment = offsetX * dx + offsetY * dy; + if ( + distanceSquared < bestDistanceSquared || + (distanceSquared === bestDistanceSquared && alignment > bestAlignment) || + (distanceSquared === bestDistanceSquared && + alignment === bestAlignment && + best !== null && + entity.id < best.id) + ) { + best = entity; + bestDistanceSquared = distanceSquared; + bestAlignment = alignment; + } } - return null; + return best; +} + +function isInAimSector(dx: number, dy: number, offsetX: number, offsetY: number): boolean { + const aimMagnitude = Math.hypot(dx, dy); + const targetMagnitude = Math.hypot(offsetX, offsetY); + if (aimMagnitude === 0 || targetMagnitude === 0) return false; + const cosine = (dx * offsetX + dy * offsetY) / (aimMagnitude * targetMagnitude); + return cosine >= Math.cos(Math.PI / 8) - Number.EPSILON; } function doDeploy(intent: Intent, ctx: ApplyIntentContext) { diff --git a/src/types.ts b/src/types.ts index f64f750..98b19e3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,7 +17,11 @@ export type GridPoint = { x: number; y: number }; export type CampaignArcStage = 'act-1' | 'act-2' | 'act-3' | 'score'; /** Why a campaign reached `ENDED` — drives terminal debrief copy in the shell. */ -export type CampaignEndReason = 'crew-wipe' | 'clock-expired' | 'score-complete'; +export type CampaignEndReason = + | 'crew-wipe' + | 'clock-expired' + | 'decker-flatlined-score' + | 'score-complete'; /** * A JSON-safe value. The persistence layer's opaque entity property bag @@ -249,26 +253,8 @@ export type LocationSite = { site?: LocationToken; }; -export type GameOverCrewStub = { callsign: string; archetype: string; flatlined: boolean }; - -/** Terminal campaign loss — crew wipe, clock deadline, or last-operator death. */ -export type GameOverTelemetry = { - campaignEndReason?: Exclude; - campaignTerminal?: boolean; - crewRoster?: GameOverCrewStub[]; - salvage?: import('./game/salvage.js').TypedSalvage; - seed?: number; - cause?: string | null; - archetype?: string; -}; - export type Telemetry = { - outcome: 'death' | 'exit' | 'campaign-over'; - campaignEndReason?: CampaignEndReason; - campaignTerminal?: boolean; - crewRoster?: GameOverCrewStub[]; - /** Typed-salvage wallet snapshot at the moment the run/campaign ends. */ - salvage?: import('./game/salvage.js').TypedSalvage; + outcome: 'death' | 'exit'; archetype?: string; turn?: number; kills?: number; diff --git a/sw-core.js b/sw-core.js index 0fb5872..b3f1979 100644 --- a/sw-core.js +++ b/sw-core.js @@ -61,6 +61,7 @@ const CacheConfig = { '/src/game/archetypes/Razor.js', '/src/game/archetypes/Tech.js', '/src/game/Campaign.js', + '/src/game/campaignSummary.js', '/src/game/Combat.js', '/src/game/combatTurnPipeline.js', '/src/game/constants.js', diff --git a/sw-dev.js b/sw-dev.js index 33f02aa..7a0e5dd 100644 --- a/sw-dev.js +++ b/sw-dev.js @@ -1,5 +1,5 @@ // Service Worker for Kernel Panic - Development Version -const VERSION = '0.2.12-dev'; +const VERSION = '0.2.15-dev'; importScripts(`/sw-core.js?v=${VERSION}`); const cacheConfig = CacheConfig.create(VERSION); diff --git a/sw.js b/sw.js index 2039fae..0b5e68d 100644 --- a/sw.js +++ b/sw.js @@ -1,6 +1,6 @@ // Service Worker for Kernel Panic - Production Version // Import shared caching core with cache-busting query parameter -const VERSION = '0.2.10'; +const VERSION = '0.2.15'; importScripts(`/sw-core.js?v=${VERSION}`); const cacheConfig = CacheConfig.create(VERSION); diff --git a/tests/unit/DataStore.test.ts b/tests/unit/DataStore.test.ts new file mode 100644 index 0000000..5de6c29 --- /dev/null +++ b/tests/unit/DataStore.test.ts @@ -0,0 +1,66 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { CAMPAIGN_HISTORY_CAP, type CampaignSummary } from '../../src/game/campaignSummary.js'; + +const STORAGE_KEY = 'kp:data'; + +class MemoryStorage { + #values = new Map(); + + getItem(key: string): string | null { + return this.#values.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.#values.set(key, value); + } +} + +function summary(index: number): CampaignSummary { + return { + campaignId: `campaign-${index}`, + completedAt: '2026-06-14T19:30:00.000Z', + result: 'win', + endReason: 'score-complete', + seed: index, + completedJobs: index, + rep: 50, + credits: index * 100, + crewRoster: [{ callsign: 'Phreak', archetype: 'Decker', flatlined: false }], + }; +} + +test('DataStore migrates legacy data and archives idempotent capped campaign history', async () => { + const localStorage = new MemoryStorage(); + (globalThis as unknown as { window: { localStorage: MemoryStorage } }).window = { localStorage }; + localStorage.setItem(STORAGE_KEY, JSON.stringify({ prefs: {}, runs: [], campaign: null })); + const { default: dataStore } = await import('../../src/DataStore.js'); + + await dataStore.init(); + assert.deepEqual(dataStore.campaignHistory, []); + + const original = summary(0); + const first = dataStore.archiveCampaign(original); + const duplicate = dataStore.archiveCampaign({ + ...original, + completedAt: '2026-06-15T19:30:00.000Z', + completedJobs: 999, + }); + assert.deepEqual(duplicate, first, 'the first archived record remains canonical'); + + for (let index = 1; index <= CAMPAIGN_HISTORY_CAP + 5; index++) { + dataStore.archiveCampaign(summary(index)); + } + assert.equal(dataStore.campaignHistory.length, CAMPAIGN_HISTORY_CAP); + assert.equal(dataStore.campaignHistory[0].campaignId, `campaign-${CAMPAIGN_HISTORY_CAP + 5}`); + assert.equal( + dataStore.campaignHistory.some(entry => entry.campaignId === original.campaignId), + false + ); + + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as { + campaignHistory?: CampaignSummary[]; + }; + assert.equal(stored.campaignHistory?.length, CAMPAIGN_HISTORY_CAP); +}); diff --git a/tests/unit/game/Campaign.test.ts b/tests/unit/game/Campaign.test.ts index 63d4e7b..7b3d938 100644 --- a/tests/unit/game/Campaign.test.ts +++ b/tests/unit/game/Campaign.test.ts @@ -6,9 +6,11 @@ import { CAMPAIGN_STATE, CLOCK_ACT2_DEADLINE_JOBS, CLOCK_ACT2_GRACE_JOBS, + SCORE_CREDITS_REWARD, SITE_ROSTER_CAP, buildCrew, defaultCampaignArc, + willEndCampaignAfterResult, willEndCampaignOnThisDeath, } from '../../../src/game/Campaign.js'; import { OUTCOME, RUN_STATE } from '../../../src/game/Run.js'; @@ -1015,6 +1017,98 @@ test('P3.M1.5: endReason distinguishes score win, clock loss, and crew wipe', () assert.equal(crewWipe.endReason, 'crew-wipe'); }); +test('terminal result detection bypasses debrief for Score, terminal death, and Clock loss', () => { + const scoreCampaign = new Campaign({ + seed: 44, + rep: 60, + completedJobs: 9, + siteRoster: [ + validSite({ + id: 'score', + seed: '100', + tier: 'score', + scoreTarget: true, + principal: { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }, + }), + validSite({ + id: 'case-1', + seed: '101', + principal: { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }, + }), + validSite({ + id: 'case-2', + seed: '102', + principal: { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }, + }), + ], + }); + scoreCampaign.arc.arcStage = 'act-3'; + const decker = scoreCampaign.crew.find(member => member.archetype === 'Decker'); + assert.ok(decker); + scoreCampaign.deployCrewMember(decker.id, scoreCampaign.buildScoreContract()); + assert.equal(willEndCampaignAfterResult(scoreCampaign, OUTCOME.EXIT, true), true); + assert.equal(willEndCampaignAfterResult(scoreCampaign, OUTCOME.EXIT, false), false); + assert.equal(willEndCampaignAfterResult(scoreCampaign, OUTCOME.DEATH, false), true); + + const finalOperator = new Campaign({ seed: 45 }); + finalOperator.crew.slice(1).forEach(member => { + member.flatlined = true; + }); + finalOperator.deployCrewMember(finalOperator.crew[0].id, fakeContract()); + assert.equal(willEndCampaignAfterResult(finalOperator, OUTCOME.DEATH, false), true); + + const clockLoss = new Campaign({ + seed: 46, + rep: 60, + completedJobs: 4, + clockJobsTaken: CLOCK_ACT2_DEADLINE_JOBS, + }); + clockLoss.state = CAMPAIGN_STATE.COMBAT; + assert.equal(willEndCampaignAfterResult(clockLoss, OUTCOME.EXIT, true), true); +}); + +test('completed Score extraction settles synchronously before the exit move callback returns', () => { + let campaign!: Campaign; + campaign = new Campaign({ + seed: 47, + rep: 60, + completedJobs: 9, + onResult: result => { + campaign.onJobEnd({ + outcome: result.outcome, + completed: result.telemetry.objectiveComplete === true, + }); + }, + siteRoster: [ + validSite({ + id: 'score', + seed: '100', + tier: 'score', + scoreTarget: true, + principal: { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }, + }), + ], + }); + campaign.arc.arcStage = 'act-3'; + const decker = campaign.crew.find(member => member.archetype === 'Decker'); + assert.ok(decker); + const run = campaign.deployCrewMember(decker.id, campaign.buildScoreContract()); + run.enterCombat(); + run.cyberspace = { phase: 'resolved', objectiveComplete: true }; + assert.ok(run.world && run.player && run.exitTile); + + run.world.relocateEntity(run.player, run.exitTile.x, run.exitTile.y); + + assert.equal(run.state, RUN_STATE.RESULT, 'the captured Run is no longer turn-playable'); + assert.equal(campaign.state, CAMPAIGN_STATE.ENDED); + assert.equal(campaign.endReason, 'score-complete'); + assert.equal( + campaign.activeRun, + null, + 'Score settlement tears down the active run synchronously' + ); +}); + test('P3.M1.5: Clock deadline does not end the campaign after the Score is attempted', () => { const campaign = new Campaign({ seed: 42, @@ -1070,6 +1164,8 @@ test('P3.M1.7: Score contract is gated to Act 3 and marks attempted on deploymen assert.equal(score.objective.kind, OBJECTIVES.DATA_NODE_SLICE); assert.equal(score.objective.params?.requiresCyberspace, true); assert.equal(score.objective.params?.count, 1); + assert.equal(score.reward.credits, SCORE_CREDITS_REWARD); + assert.equal(score.reward.repDelta, 0); const decker = campaign.crew.find(m => m.archetype === 'Decker'); assert.ok(decker, 'Act 3 campaign should include auto-assigned Decker'); @@ -1079,10 +1175,58 @@ test('P3.M1.7: Score contract is gated to Act 3 and marks attempted on deploymen assert.equal(run.contract?.context.locationSiteId, 'score'); }); +test('a flatlined pre-Score Decker creates a free Terminal replacement lead', () => { + const campaign = new Campaign({ seed: 42, rep: 60, completedJobs: 4 }); + const original = campaign.crew.find(member => member.archetype === 'Decker'); + assert.ok(original); + campaign.flatlineMember(original.id); + campaign.enterHub(); + + assert.equal(campaign.hasLivingDecker, false); + assert.equal(campaign.availableRecruits.length, 1); + const replacement = campaign.availableRecruits[0]; + assert.equal(replacement.archetype, 'Decker'); + campaign.recruit(replacement.id); + assert.equal(campaign.hasLivingDecker, true); + assert.equal(campaign.recruitedThisVisit, true); +}); + +test('the Score remains unavailable until a living replacement Decker is recruited', () => { + const scorePrincipal = { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }; + const campaign = new Campaign({ + seed: 42, + rep: 60, + completedJobs: 9, + siteRoster: [ + validSite({ + id: 'score', + seed: '100', + tier: 'score', + scoreTarget: true, + lastVisitedJob: 5, + principal: scorePrincipal, + }), + validSite({ id: 'case-1', seed: '101', lastVisitedJob: 6, principal: scorePrincipal }), + validSite({ id: 'case-2', seed: '102', lastVisitedJob: 7, principal: scorePrincipal }), + ], + }); + const decker = campaign.crew.find(member => member.archetype === 'Decker'); + assert.ok(decker); + campaign.flatlineMember(decker.id); + campaign.enterHub(); + + assert.equal(campaign.arcStage, 'act-3'); + assert.equal(campaign.canAttemptScore(), false); + const replacement = campaign.availableRecruits[0]; + campaign.recruit(replacement.id); + assert.equal(campaign.canAttemptScore(), true); +}); + test('P3.M1.7: completed Score contract records campaign win state', () => { const scorePrincipal = { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }; const campaign = new Campaign({ seed: 42, + credits: 25, rep: 60, completedJobs: 9, siteRoster: [ @@ -1108,6 +1252,50 @@ test('P3.M1.7: completed Score contract records campaign win state', () => { assert.equal(campaign.arc.scoreCompleted, true); assert.equal(campaign.state, CAMPAIGN_STATE.ENDED); + assert.equal(campaign.credits, 25 + SCORE_CREDITS_REWARD); +}); + +test('a Decker flatline during the Score ends the campaign explicitly', () => { + const scorePrincipal = { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }; + const campaign = new Campaign({ + seed: 42, + rep: 60, + completedJobs: 9, + siteRoster: [ + validSite({ + id: 'score', + seed: '100', + tier: 'score', + scoreTarget: true, + lastVisitedJob: 5, + principal: scorePrincipal, + }), + validSite({ id: 'case-1', seed: '101', lastVisitedJob: 6, principal: scorePrincipal }), + validSite({ id: 'case-2', seed: '102', lastVisitedJob: 7, principal: scorePrincipal }), + ], + }); + const decker = campaign.crew.find(member => member.archetype === 'Decker'); + assert.ok(decker); + const run = campaign.deployCrewMember(decker.id, campaign.buildScoreContract()); + run.enterCombat(); + assert.equal( + willEndCampaignOnThisDeath(campaign), + true, + 'Score death is terminal even with surviving crew' + ); + + campaign.onJobEnd({ outcome: OUTCOME.DEATH }); + + assert.equal(campaign.state, CAMPAIGN_STATE.ENDED); + assert.equal(campaign.endReason, 'decker-flatlined-score'); + assert.ok( + campaign.crew.some(member => !member.flatlined), + 'other survivors do not avert the loss' + ); + + const restored = restoreCampaign(snapshotCampaign(campaign)); + assert.equal(restored.state, CAMPAIGN_STATE.ENDED); + assert.equal(restored.endReason, 'decker-flatlined-score'); }); // ─── Recruitment ──────────────────────────────────────────────────────── diff --git a/tests/unit/game/campaignSummary.test.ts b/tests/unit/game/campaignSummary.test.ts new file mode 100644 index 0000000..a06fcd2 --- /dev/null +++ b/tests/unit/game/campaignSummary.test.ts @@ -0,0 +1,151 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Campaign, CAMPAIGN_STATE, SCORE_CREDITS_REWARD } from '../../../src/game/Campaign.js'; +import { + CAMPAIGN_HISTORY_CAP, + archiveCampaignSummary, + buildCampaignSummary, + normalizeCampaignHistory, + validateCampaignSummary, + type CampaignSummary, +} from '../../../src/game/campaignSummary.js'; +import { OUTCOME } from '../../../src/game/Run.js'; +import { makeSalvage } from '../../../src/game/salvage.js'; +import type { LocationSite } from '../../../src/types.js'; + +const COMPLETED_AT = '2026-06-14T19:30:00.000Z'; + +function validSite(overrides: Partial = {}): LocationSite { + return { + id: 'score', + seed: '100', + mapWidth: 24, + mapHeight: 16, + label: '// Matsuda server farm', + tier: 'score', + scoreTarget: true, + mutationDeltas: [], + seenKeys: [], + lastVisitedJob: 5, + principal: { id: 'matsuda', label: 'Matsuda', groups: ['corp'] }, + ...overrides, + }; +} + +function summary(overrides: Partial = {}): CampaignSummary { + return { + campaignId: 'campaign-1', + completedAt: COMPLETED_AT, + result: 'win', + endReason: 'score-complete', + seed: 42, + completedJobs: 10, + rep: 67, + credits: 1_125, + crewRoster: [{ callsign: 'Phreak', archetype: 'Decker', flatlined: false }], + ...overrides, + }; +} + +test('Score completion summary uses post-settlement jobs, Rep, and Credits', () => { + const campaign = new Campaign({ + id: 'campaign-score', + seed: 42, + credits: 125, + rep: 60, + completedJobs: 9, + siteRoster: [ + validSite(), + validSite({ id: 'case-1', tier: 'roster', scoreTarget: false, seed: '101' }), + validSite({ id: 'case-2', tier: 'roster', scoreTarget: false, seed: '102' }), + ], + }); + const decker = campaign.crew.find(member => member.archetype === 'Decker'); + assert.ok(decker); + const score = campaign.buildScoreContract(); + score.reward.repDelta = 7; + const run = campaign.deployCrewMember(decker.id, score); + run.enterCombat(); + + campaign.onJobEnd({ + outcome: OUTCOME.EXIT, + completed: true, + salvage: makeSalvage({ scrap: 2, data: 3 }), + }); + const record = buildCampaignSummary(campaign, COMPLETED_AT); + + assert.equal(campaign.state, CAMPAIGN_STATE.ENDED); + assert.equal(record.result, 'win'); + assert.equal(record.endReason, 'score-complete'); + assert.equal(record.completedJobs, 10); + assert.equal(record.rep, 67); + assert.equal(record.credits, 125 + SCORE_CREDITS_REWARD); + assert.equal('salvage' in record, false); + assert.ok(record.crewRoster.some(member => member.archetype === 'Decker')); +}); + +test('loss summaries preserve each terminal reason and final roster state', () => { + for (const endReason of ['crew-wipe', 'clock-expired', 'decker-flatlined-score'] as const) { + const campaign = new Campaign({ id: `campaign-${endReason}`, seed: 9 }); + campaign.state = CAMPAIGN_STATE.ENDED; + campaign.crew[0].flatlined = true; + Object.defineProperty(campaign, 'endReason', { value: endReason }); + + const record = buildCampaignSummary(campaign, COMPLETED_AT); + + assert.equal(record.result, 'loss'); + assert.equal(record.endReason, endReason); + assert.equal(record.crewRoster[0].flatlined, true); + } +}); + +test('CampaignSummary validation rejects mismatched outcomes and malformed credit totals', () => { + assert.throws( + () => validateCampaignSummary(summary({ result: 'loss' })), + /score-complete must have result win/ + ); + assert.throws( + () => validateCampaignSummary({ ...summary(), credits: -1 }), + /credits must be a non-negative integer/ + ); +}); + +test('legacy history defaults empty and imported history deduplicates then trims', () => { + assert.deepEqual(normalizeCampaignHistory(undefined), []); + const imported = Array.from({ length: CAMPAIGN_HISTORY_CAP + 5 }, (_, index) => + summary({ campaignId: `campaign-${index}` }) + ); + imported.splice(1, 0, summary({ campaignId: 'campaign-0', completedJobs: 999 })); + + const normalized = normalizeCampaignHistory(imported); + + assert.equal(normalized.length, CAMPAIGN_HISTORY_CAP); + assert.equal(normalized[0].campaignId, 'campaign-0'); + assert.equal(normalized[0].completedJobs, 10, 'newest original row wins duplicate conflict'); +}); + +test('archival is newest-first, preserves an existing record, and caps at 50', () => { + const existing = summary({ campaignId: 'campaign-existing', completedAt: COMPLETED_AT }); + const duplicate = summary({ + campaignId: 'campaign-existing', + completedAt: '2026-06-15T19:30:00.000Z', + completedJobs: 99, + }); + const duplicateResult = archiveCampaignSummary([existing], duplicate); + assert.equal(duplicateResult.added, false); + assert.deepEqual(duplicateResult.summary, existing); + + const full = Array.from({ length: CAMPAIGN_HISTORY_CAP }, (_, index) => + summary({ campaignId: `campaign-${index}` }) + ); + const newest = summary({ campaignId: 'campaign-newest' }); + const added = archiveCampaignSummary(full, newest); + assert.equal(added.added, true); + assert.equal(added.history.length, CAMPAIGN_HISTORY_CAP); + assert.equal(added.history[0].campaignId, 'campaign-newest'); + assert.equal( + added.history.some(entry => entry.campaignId === 'campaign-49'), + false + ); +}); diff --git a/tests/unit/game/cyber/ProbeIce.test.ts b/tests/unit/game/cyber/ProbeIce.test.ts index 02ad293..dcc0977 100644 --- a/tests/unit/game/cyber/ProbeIce.test.ts +++ b/tests/unit/game/cyber/ProbeIce.test.ts @@ -16,6 +16,8 @@ import { ProbeIce } from '../../../../src/game/cyber/ProbeIce.js'; import { PATROL_STATE } from '../../../../src/game/ai/PatrolHostile.js'; import { JackInPoint } from '../../../../src/game/entities/JackInPoint.js'; import { resolveMelee } from '../../../../src/game/Combat.js'; +import { runPlayerAftermathSteps } from '../../../../src/game/combatTurnPipeline.js'; +import { applyOverride } from '../../../../src/game/droneOverride.js'; import { EVENT } from '../../../../src/game/events.js'; import { snapshot, restore } from '../../../../src/game/persistence.js'; import { buildCrewMember } from '../../../../src/game/archetypes/index.js'; @@ -110,7 +112,8 @@ test('build spawns one probe per patrol ring with explicit ICE stats', () => { for (const probe of probes) { assert.equal(probe.glyph, '¶'); assert.equal(probe.displayName, 'Probe'); - assert.equal(probe.maxHp, 3); + assert.equal(probe.maxHp, 2); + assert.equal(probe.maxAp, 2); assert.equal(probe.sightRange, 6); assert.equal(probe.meleeDamage, 1); assert.equal(probe.faction, FACTION.CORP); @@ -147,6 +150,33 @@ test('probe damage is mitigated by ICE resistance with the min-1 rule', () => { assert.equal(avatar.hp, avatar.maxHp - 1); }); +test('overridden Probe ICE fights other ICE and reverts through cyber aftermath', () => { + const layer = buildLayer(); + const probes = probesOf(layer); + assert.ok(probes.length >= 2); + const ally = probes[0]; + const target = probes[1]; + const spot = adjacentFreeTile(layer.world, target); + layer.world.relocateEntity(ally, spot.x, spot.y); + applyOverride(ally, FACTION.PLAYER); + + let sawAttack = false; + for (let turn = 0; turn < 3; turn++) { + ally.refreshAp(); + for (const step of runPlayerAftermathSteps(layer.world, new Rng(turn + 1), { + player: layer.avatar, + })) { + if (step.type === 'overridden-drone' && step.action.type === 'melee') { + sawAttack = true; + } + } + } + + assert.equal(sawAttack, true); + assert.equal(ally.faction, FACTION.CORP); + assert.equal(ally.isOverridden, false); +}); + test('acquisition raises the cyber alarm and converges the pack', () => { const layer = buildLayer(); const avatar = layer.avatar; diff --git a/tests/unit/game/cyber/cyberPersistence.test.ts b/tests/unit/game/cyber/cyberPersistence.test.ts index 456924e..cbc4c20 100644 --- a/tests/unit/game/cyber/cyberPersistence.test.ts +++ b/tests/unit/game/cyber/cyberPersistence.test.ts @@ -15,6 +15,8 @@ import { CyberAvatar } from '../../../../src/game/cyber/CyberAvatar.js'; import { EntryPort } from '../../../../src/game/cyber/EntryPort.js'; import { JackInPoint } from '../../../../src/game/entities/JackInPoint.js'; import { Decker } from '../../../../src/game/archetypes/Decker.js'; +import { ProbeIce } from '../../../../src/game/cyber/ProbeIce.js'; +import { applyOverride } from '../../../../src/game/droneOverride.js'; import { Campaign } from '../../../../src/game/Campaign.js'; import { EVENT } from '../../../../src/game/events.js'; import { @@ -29,6 +31,7 @@ import { DECKER_BASE_INTRUSION, DECKER_BASE_RAM, TILE, + FACTION, } from '../../../../src/game/constants.js'; import { OBJECTIVES } from '../../../../src/game/hub/Curator.js'; import { Rng } from '../../../../src/rng.js'; @@ -164,6 +167,25 @@ test('a mid-jack-in snapshot round-trips the layer exactly', () => { assert.ok(restored.player instanceof Decker); }); +test('overridden ICE state round-trips through an active cyber layer', () => { + const run = activeRun(); + const layer = layerOf(run); + const probe = Array.from(layer.world.entities.values()).find( + (entity): entity is ProbeIce => entity instanceof ProbeIce + ); + assert.ok(probe); + applyOverride(probe, FACTION.PLAYER); + + const { run: restored } = restore(structuredClone(snapshot(run))); + const restoredProbe = Array.from(layerOf(restored).world.entities.values()).find( + (entity): entity is ProbeIce => entity instanceof ProbeIce + ); + assert.ok(restoredProbe); + assert.equal(restoredProbe.faction, FACTION.PLAYER); + assert.equal(restoredProbe.isOverridden, true); + assert.equal(restoredProbe.overrideTurnsRemaining, probe.overrideTurnsRemaining); +}); + test('a restored active run re-wires its cyber listeners', () => { const results: RunResult[] = []; const { run: restored } = restore(activeRecord(), { onResult: r => results.push(r) }); diff --git a/tests/unit/input/applyIntent.test.ts b/tests/unit/input/applyIntent.test.ts index 69f936d..7b60892 100644 --- a/tests/unit/input/applyIntent.test.ts +++ b/tests/unit/input/applyIntent.test.ts @@ -16,6 +16,8 @@ import { makeSalvage, totalSalvage } from '../../../src/game/salvage.js'; import { Merc } from '../../../src/game/archetypes/Merc.js'; import { Razor } from '../../../src/game/archetypes/Razor.js'; import { Tech } from '../../../src/game/archetypes/Tech.js'; +import { CyberAvatar } from '../../../src/game/cyber/CyberAvatar.js'; +import { ProbeIce } from '../../../src/game/cyber/ProbeIce.js'; import { Turret } from '../../../src/game/Turret.js'; import { Skirmisher } from '../../../src/game/ai/Skirmisher.js'; import { ConsumablePickup } from '../../../src/game/entities/ConsumablePickup.js'; @@ -412,6 +414,76 @@ test('special intent routes to Slide on a Razor (moves 2 tiles, engages stealth) assert.equal(player.stealthed, true); }); +test('special intent routes CyberAvatar Override against Probe ICE', () => { + const grid = new Grid(8, 5); + const bus = new EventBus(); + const world = new World(grid, { events: bus }); + const avatar = new CyberAvatar({ + id: 'cyber-avatar-0', + x: 1, + y: 2, + ram: 8, + intrusionStrength: 2, + iceResistance: 1, + }); + const probe = new ProbeIce({ id: 'probe-ice-0', x: 3, y: 2 }); + world.addEntity(avatar); + world.addEntity(probe); + probe.bindToBus(bus); + const log: string[] = []; + const ctx = { + world, + player: avatar, + queue: new TurnQueue([FACTION.PLAYER, FACTION.CORP]), + rng: { next: () => 0 }, + log: (line: string) => log.push(line), + advanceTurn: () => {}, + resetInputModes: () => {}, + onPlayerAction: () => {}, + }; + + applyIntent({ type: 'special', dx: 1, dy: 0 }, ctx); + + assert.equal(probe.faction, FACTION.PLAYER); + assert.equal(avatar.ap, avatar.maxAp - AP_COST.OVERRIDE); + assert.ok(log.some(line => line.includes('OVERRIDES Probe'))); +}); + +test('CyberAvatar Override acquires an off-axis Probe in the aimed direction', () => { + const grid = new Grid(8, 6); + const bus = new EventBus(); + const world = new World(grid, { events: bus }); + const avatar = new CyberAvatar({ + id: 'cyber-avatar-0', + x: 1, + y: 2, + ram: 8, + intrusionStrength: 2, + iceResistance: 1, + }); + const probe = new ProbeIce({ id: 'probe-ice-0', x: 4, y: 3 }); + world.addEntity(avatar); + world.addEntity(probe); + probe.bindToBus(bus); + const log: string[] = []; + const ctx = { + world, + player: avatar, + queue: new TurnQueue([FACTION.PLAYER, FACTION.CORP]), + rng: { next: () => 0 }, + log: (line: string) => log.push(line), + advanceTurn: () => {}, + resetInputModes: () => {}, + onPlayerAction: () => {}, + }; + + applyIntent({ type: 'special', dx: 1, dy: 0 }, ctx); + + assert.equal(probe.faction, FACTION.PLAYER); + assert.equal(avatar.ap, avatar.maxAp - AP_COST.OVERRIDE); + assert.ok(log.some(line => line.includes('OVERRIDES Probe'))); +}); + test('end-turn drains AP to 0, logs wait, and invokes advanceTurn once', () => { const { ctx, player, calls, log } = buildCtx(); applyIntent({ type: 'end-turn' }, ctx); From 75042366c34923ddafe109173a16bbf05edb2907 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Sun, 14 Jun 2026 12:13:23 -0700 Subject: [PATCH 30/55] docs cleanup --- docs/kaizen.md | 2 +- docs/phase-3-cyberspace-plan.md | 634 -------------------------------- docs/phase-3-plan.md | 102 +++-- 3 files changed, 67 insertions(+), 671 deletions(-) delete mode 100644 docs/phase-3-cyberspace-plan.md diff --git a/docs/kaizen.md b/docs/kaizen.md index 091ca0f..9ac466a 100644 --- a/docs/kaizen.md +++ b/docs/kaizen.md @@ -21,7 +21,7 @@ When an item lands, gets reclassified, or develops new context, edit it in place ## ▶ Phase 3 candidates -- **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 and [phase-3-cyberspace-plan.md](phase-3-cyberspace-plan.md) for full slice notes and recorded scope decisions. +- **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: diff --git a/docs/phase-3-cyberspace-plan.md b/docs/phase-3-cyberspace-plan.md deleted file mode 100644 index 750b134..0000000 --- a/docs/phase-3-cyberspace-plan.md +++ /dev/null @@ -1,634 +0,0 @@ -# P3.M3 — Cyberspace Grid + ICE: Working Plan & Progress - -Working document for the P3.M3 implementation effort on branch `3.0-cyberspace`. -Companion to [phase-3-plan.md](phase-3-plan.md) (the milestone spec); this file -tracks the approved slice plan, recorded scope decisions, and live progress so -the effort can be resumed cold. - -## Scope decisions (recorded 2026-06-09, Rylee) - -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 in phase-3-plan.md 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** (recorded 2026-06-11, Rylee): - `buildScoreContract` emits `DATA_NODE_SLICE` with - `{requiresCyberspace: true, count: 1}` — for now; revisit if a - meat-only Score variant is ever wanted. Deploy goes through the - living-Decker gate; objective shape locked in the P3.M1.7 tests. - -TDD throughout; malformed persisted state throws (no silent fallbacks). -Commits land **per green slice** (user-approved). - -## Progress - -| Slice | Status | Commit | -|---|---|---| -| **S1 — P3.M3.1 Contract flag + gates** | ✅ Done | `f436330` | -| **S2 — P3.M3.2 Jack-in terminal** | ✅ Done | `05013f6` | -| **S3 — P3.M3.3 Cyber layer model + avatar + persistence** | ✅ Done | `39b357e` | -| **S4 — P3.M3.4 Data node objective** | ✅ Done | `3923149` | -| **S5 — Voluntary jack-out (M4.6 pull-forward)** | ✅ Done | `fab3bcb` | -| **S6 — P3.M3.5 Probe ICE** | ✅ Done | `0a46878` | -| **S7 — P3.M3.6 Render/input swap + shell ICE phase** | ✅ Done | `dd17cb8` | -| **— Score → cyber (scope decision #5)** | ✅ Done | `3c693dc` | -| **S7.5 — Early jack-out confirmation** | ✅ Done | `2d668bd` | -| **Playtest stabilization** | ✅ Done | — | -| **S8 — Docs + wrap-up** | ✅ Done | — | - -### Playtest stabilization (2026-06-14) - -- Probe ICE tuned from 3 HP / 4 AP to 2 HP / 2 AP. The one-damage strike stays; reducing repeated actions addresses the real burst-pressure source while making Probes appropriately disposable before Spark and Guardian land. -- CyberAvatar now exposes Override against ICE. It uses the existing 2 AP / 60% / 3-turn override contract, runs allied ICE in the cyber aftermath pass, and preserves override state through active-layer snapshots. -- A flatlined Decker before THE SCORE creates a single free replacement Decker lead in the Terminal. Recruiting them consumes the visit's recruit slot, and THE SCORE remains unavailable without a living Decker. -- A Decker flatline during THE SCORE ends the campaign with the persisted `decker-flatlined-score` reason and dedicated Game Over copy. This remains the rule until dual-deploy gives the Score another meaningful failure/recovery shape. - -### S1 implementation notes (shipped) - -- `OBJECTIVES.DATA_NODE_SLICE = 'data-node-slice'` with cross-field validation - in `normalizeObjective` (`Curator.ts`): the kind requires - `params.requiresCyberspace === true` plus positive-integer `params.count`; - the flag is forbidden on every other kind. Validates at generation, - `Run.enterBriefing`, and snapshot restore (all call `normalizeObjective`). -- `contractRequiresCyberspace(contract)` exported from `Curator.ts` — single - source of truth for "this contract has a Cyberspace component". -- New recipe `cyber-data-spike` (params `{requiresCyberspace: true, count: 1}`, - no `targetParams` spread — placement is owned by the DATA_NODE_SLICE arm, and - no turn-limit/door-routing machinery engages). Gated by the new - `ContractRecipe.availableWhen?: (ctx) => boolean` hook: requires - `arcStage ∈ {act-2, act-3} && hasLivingDecker === true`. -- `recipeIsAvailable` filter applies to **all three** generation paths (fresh - `generateRecipeContract`, revisit, principal-biased). When no gated recipe - qualifies the filtered pool is identical to the historic pool, so seeded - generation for pre-P3.M3 contexts is unchanged (full suite stayed green). -- `ContractRecipeContext`/`ContractCampaign` gained `hasLivingDecker?`; - `Campaign.get hasLivingDecker()` = some non-flatlined `archetype === 'Decker'`. -- Deploy gate in `Campaign.deployCrewMember`: throws - `requires a living Decker to jack in` for cyber contracts unless the deployed - member is a (non-flatlined) Decker. -- UX: `CrewList.setCrew(crew, rowGate?)` — `CrewRowGate` returns a disable tag - or null; selection/keyboard nav skip gated rows. `RunBriefing` derives the - gate from the contract (`NEEDS DECKER` on non-Decker rows), re-applies on - contract swap, and `#commit`/jack-in button respect it. -- `Run.isObjectiveFamilySatisfied` has a `DATA_NODE_SLICE` case returning - `false` (honestly unsatisfiable until S4 wires sliced-node counting). The - exhaustive `never` default caught the new kind at typecheck. -- Tests: `tests/unit/game/hub/cyberContract.test.ts` (14 tests — validation - throws, generation gating over 40 seeded boards, determinism, deploy gates). - -### S2 implementation notes (shipped) - -- `src/game/entities/JackInPoint.ts` — `JackInPoint extends Interactable`, - glyph `Ω` (`JACK_IN_GLYPH` in constants), default label `Jack-in port`, - id `jack-in-0` (deliberately **not** matching `/^terminal-\d+$/`, which - TERMINAL_SLICE counts — regression-tested). `interact`: linked → logged - `already-linked` refusal (never throws); capability sniff - `actor.canJackIn !== true` → `no-cyberdeck` refusal; success spends interact - AP, latches `linked`, emits `EVENT.JACK_IN` `{point, actor}` exactly once. -- `Decker.canJackIn = true` (readonly capability, P3.M2 sniffing pattern). -- `events.ts`: `JACK_IN: 'cyber:jack-in'`, `JACK_OUT: 'cyber:jack-out'` - registered (known-types-only bus). -- Placement: `Run.#placeObjectiveInteractables` gained the DATA_NODE_SLICE arm — - one port via `findInteractableAnchor`, deterministic per contract seed. -- `Run.cyberspace: CyberspaceState | null` latched in `enterBriefing` from - `contractRequiresCyberspace` (`{phase:'dormant'}` or `null`). S2's - `CyberspaceState` is dormant-only; S3 extends the union. -- Persistence: `RunSnapshot.cyberspace?: RunCyberspaceSnapshot`; - `snapshotCyberspace` in Run.ts; `restoreCyberspace` in persistence.ts - enforcing **block ⇔ cyber-contract both directions**, unknown phase throws, - dormant payload smuggling throws. Entity plumbing: `'jack-in-point'` in - `EntityArchetypeId`, `archetypeOf`, `SNAPSHOT_EXTRACTORS`, - `ARCHETYPE_FACTORY`, `ENTITY_RESTORE` (+ `readJackInPoint` throwing on - missing label/linked). -- Tests: `tests/unit/game/jackInPoint.test.ts` (12 tests — interact behavior, - placement determinism, round-trip, adversarial restore throws). - -### S3 implementation notes (shipped) - -- `src/game/cyber/cyberMapBuild.ts` — `buildCyberMap({rng, difficulty})`: - spanning random walk over a 4×2 cell lattice (29×15 grid), square rooms - (3–6) centered per cell, L-corridors along walk edges, clockwise perimeter - patrol rings. Node count by difficulty: standard 5 / elevated 6 / critical 8 - (unknown difficulty throws). `entryTile` = entry room center; - **deviation: returns `portTile`** (Chebyshev-1 from entry) so layer build - doesn't re-derive placement. Connectivity flood - (`explorationReachableKeys`) validates every node + port reachable; throws - otherwise. Returned `nodeTiles`/`patrolRings` exclude the entry node. -- `src/game/cyber/CyberAvatar.ts` — `Entity` subclass: `maxHp = ram`, - `damageReduction = iceResistance` (0 is legal), `intrusionStrength`, - `callsign`, maxAp 4, `baseHitChance` 0.8 (sniffed by `Combat.resolveRanged` - — zero combat changes needed). **Deviation: capability flag is - `readonly isCyberAvatar = true`**, not the planned `intrusionStrength` - sniff — the Decker now carries `intrusionStrength` too, so the stat sniff - would have let the meat body use cyber interactables. -- `src/game/cyber/EntryPort.ts` — avatar-only interact (`'not-an-avatar'` - refusal), spends interact AP, emits `EVENT.JACK_OUT` once. Deliberately - does **not** latch — the resolve latch lives on `Run.cyberspace`. -- `src/game/cyber/CyberspaceLayer.ts` — own bus + `World`; - `static build({contractSeed, difficulty, decker})` forks - `new Rng(contractSeed).fork('cyberspace')` (layout independent of jack-in - turn — regression-tested); plain ctor used by restore (restored layers have - empty `nodeTiles`/`patrolRings` — those are build-time spawn anchors only); - `onTurnEnded(next)` refreshes incoming-faction AP + ticks cyber alarm on - PLAYER; `recordSeen` bounds-validates (throws); `teardown()` unbinds patrol - hostiles (S6 seam). **Deviation: no `layer.snapshot()`** — serialization - lives in Run's `snapshotCyberspace` beside the entity extractor registry. -- `Run.ts` — `CyberspaceState` active/resolved arms; - `cyberActive`/`activeWorld`/`activeActor` accessors (shell seam for S7); - `jackIn(point)` throws unless COMBAT + cyber contract + dormant + linked - `JackInPoint` + Decker player; `jackOut()` latches - `objectiveComplete: false` (**TODO(P3.M3.4)**: real value once nodes - exist); both autosave explicitly. `#onTurnEnded` now consumes the `{next}` - payload (throws if absent) and forwards to the layer before autosave. Cyber - `ENTITY_DAMAGED` listener mirrors the meat player-death handler — avatar - killed → telemetry cause → `enterResult(DEATH)` → existing flatline path - (scope decision #3). -- `persistence.ts` — `restoreCyberspace` per-phase **allowed-keys checks** - (cross-phase payload smuggling throws); active block validates grid dims + - FLOOR/WALL-only tile values, entryTile in-bounds/passable, alarm, - mapMemory; entities restored via `restoreEntity(rec, grid)` against the - **cyber** grid; exactly one avatar and one port (duplicates throw). - **Deviation: a dead avatar is legal when `record.state !== COMBAT`** — a - RESULT-state death snapshot legitimately carries one; alive required only - for COMBAT records. **Deviation: no `seed` field in the active block** — - the serialized grid is authoritative. Decker cyber stats round-trip through - both crew paths (`CampaignCrewSnapshot.cyber?` + run-entity `decker` - extra): all-absent → `DECKER_BASE_*` defaults (legacy saves), - half-populated/malformed → throw, `cyber` on a non-decker throws. -- `Decker.ts`/`constants.ts` — `ram` 8 / `intrusionStrength` 2 / - `iceResistance` 1 base constants, validated mutable fields (scope - decision #4). -- Tests: 49 across `tests/unit/game/cyber/` — `cyberMapBuild.test.ts` (8), - `CyberspaceLayer.test.ts` (11), `runJackIn.test.ts` (10), - `cyberPersistence.test.ts` (20, the adversarial heart). Failing-first - verified; full suite 1604 green. -- Playtest note (pre-S7): jack-in flips state internally but the shell still - renders Meatspace until the S7 render/input swap — expected, not a bug. - -### S4 implementation notes (shipped) - -- `src/game/cyber/DataNode.ts` — `DataNode extends Interactable`, glyph `◈` - (`DATA_NODE_GLYPH`). Interact: sliced → `already-sliced` refusal; standard - adjacency/AP gates; **avatar-only via the `isCyberAvatar` sniff** (same S3 - deviation rationale — the Decker body carries `intrusionStrength` too); - success spends interact AP and adds the avatar's `intrusionStrength` to - `sliceProgress`. Sliced at `sliceDifficulty` — `sliceDifficultyFor`: - standard 2 / elevated 3 / critical 4 (unknown throws). Progress is **raw, - never clamped**, so persistence loses nothing. Slicing is silent — the - cyber alarm belongs to ICE detection (S6), not the slice. -- `CyberspaceLayer.build` gains **required `nodeCount`** (no default — a - count silently diverging from the contract would corrupt the objective; - `Run.jackIn` passes the objective's `count`). Nodes spawn on the - *farthest* node anchors from entry (Chebyshev sort, stable over generator - order → placement is a pure function of the contract seed); - `nodeCount > nodeTiles.length` throws. -- `Run` — `ObjectiveState.cyber?: {sliced, required}` computed per phase by - `#cyberNodeProgress()`: live `dataNodeProgress(layer.world)` while active, - the resolved latch (`required`/0), zero while dormant, `undefined` on - non-cyber contracts. `DATA_NODE_SLICE` satisfaction: `sliced >= required` - (absent tally → honestly unsatisfiable). `jackOut()` latches - `objectiveComplete` from the real tally — the S3 TODO is gone; early - jack-out latches `false`, leaving extraction gated through the existing - abort-confirm flow (zero new extraction code). -- `objectiveProgress.ts` — `CyberNodeProgress` type, `dataNodeProgress(world)` - tally, optional 4th `cyber` param, `NODES` chip case. The HUD chip pipeline - is kind-generic — **no shell copy changes were needed** (the planned - describe/combatHud grep came up empty). -- Persistence — `data-node` codec in both registries (`label` / - `sliceDifficulty` / `sliceProgress`, malformed or half-populated throws); - `restoreCyberspaceLayer` now requires the active block to carry **exactly - the contract's node count** (nodes never despawn, so any drift is - corruption). -- Tests: 25 across `dataNode.test.ts` (10 — entity behavior, difficulty - table, refusal matrix) and `dataNodeObjective.test.ts` (15 — spawn - determinism, satisfaction per phase, both latch directions, mid-slice - round-trip, adversarial node-count/extra throws). Failing-first verified; - suite 1629 green. - -### S5 implementation notes (shipped) - -- Most of the planned S5 core had already landed: the resolve latch, - teardown, autosave (S3) and the real `objectiveComplete` tally (S4). The - coverage audit confirmed resolved-latching-both-ways, post-jack-out - round-trip, and resolved-block payload smuggling were locked by S3/S4 - tests; the genuinely missing rows were the dormant/non-cyber jackOut - throws (now in `jackOut.test.ts`). -- **LINK BURNED is a real latch, not copy**: `JackInPoint.burned`, set by - `Run.jackOut()` via `burn()` on the meat-side port. A burned port refuses - *before* the linked check with its own reason (`link-burned`, "LINK BURNED - — the connection is dead"), distinct from the redundant-input - `already-linked`. `burn()` on an unlinked port throws — and so does - constructing one (burned ⇒ linked is an invariant). -- Persistence: snapshot `extra` carries `burned`; absent on pre-S5 records → - unburned (legacy normalization, decker-stats pattern); non-boolean or - burned-without-linked throws. -- Tests: 8 in `jackOut.test.ts` (illegal phases, burn-on-jack-out + refusal - flavor + no-AP, burn invariants, round-trip, legacy absence, adversarial - malformed/inconsistent flags). The S3 runJackIn LINK BURNED test was - updated from the placeholder `already-linked` expectation to the real - `link-burned`. Suite 1637 green. - -### S6 implementation notes (shipped) - -- `src/game/cyber/ProbeIce.ts` — `ProbeIce extends PatrolHostile`, glyph `¶` - (`PROBE_ICE_GLYPH`), displayName `Probe`. Explicit stats - (`PROBE_ICE_HP 3` / `PROBE_ICE_DAMAGE 1` via the `meleeDamage` sniff / - `PROBE_ICE_SIGHT_RANGE 6`); `resolveEnemyStats` tiers deliberately unused — - ICE scaling is its own axis for the Spark/Guardian follow-ups. -- **Trace flare**: `engageSteps` raises the *cyber* world's alarm - (`repPenalty: false`) before striking; `raiseAlarm` self-gates while - ALERT so the `trace-alarm` step (new `ProbeIceTurnStep` in `types.ts`, - with corp-turn status copy) fires once per alarm window. Probes keep the - `PatrolHostile` default `listensForAlarm() === true` — one probe's flare - is every probe's heading (verified: a distant patroller flips to ENGAGE - with the avatar's coordinates). -- Spawning: one probe per `buildCyberMap` patrol ring, at a seed-picked ring - tile (rng consumed *after* map generation, so layouts are untouched and - placement stays a pure function of the contract seed). Build binds probes - to the layer bus; restore re-binds via the existing - `restoreCyberspaceLayer` PatrolHostile path. -- Persistence rode the generic machinery: adding `'probe-ice'` to - `PATROL_ARCHETYPE_IDS` bought waypoints/state/index round-trip wholesale; - only `ARCHETYPE_FACTORY` + extractor entries were new. -- Death paths proven through the **real combat pipeline** (resolveMelee on - the cyber world's bus, no synthetic emits): probe kills avatar → - RESULT/DEATH with attacker in the cause; avatar kills probe → telemetry - kill count. Min-1 mitigation locked (dmg 1 vs iceResistance 1 → 1). -- Decoupling verified: cyber alarm raises emit nothing on the meat bus and - leave the meat latch untouched; the cyber alarm cadence ticks once per - meat round advance (proves the single-TurnQueue wiring). -- **Noted for later**: probes default `FACTION.CORP`. The `cyber-data-spike` - recipe locks principals to corp, so this matches `run.hostileFaction` - today — but a future rival-principal cyber recipe would leave ICE AP - unrefreshed (the queue ticks PLAYER/RIVAL only). Revisit when a second - cyber recipe lands (stamp `hostileFaction` onto ICE at `jackIn`, or - validate at build). -- Tests: 9 in `ProbeIce.test.ts`. Failing-first verified; suite 1646 green. - -### S7 implementation notes (shipped) - -- `palette.ts` — `TilesetId = 'meat' | 'cyber'` axis; `CYBER_TILE_GLYPH` - (FLOOR `·` deep cyan `#0e6b66`, WALL `▒` magenta `#c23bd4`). - `glyphForTile(tile, principalId?, tileset?)` throws on an unknown tileset - *and* on a non-FLOOR/WALL tile reaching the cyber painter (cyber maps are - FLOOR/WALL-only by construction — corruption, not a style gap). Principal - terrain palettes are a Meatspace mood axis and never recolor the grid. -- `frame.ts` — `BuildFrameOptions.tileset` (default `'meat'`) threaded - through `buildFrame`/`glyphForCell`; `combatHud.ts` — vitals pane label - axis (`HP` default, `RAM` on the grid). -- `applyIntent.ts` — `ApplyIntentContext.player` widened to - `Archetype | CyberAvatar`; the avatar exposes no perk/inventory - capabilities, so every capability-sniffed branch (`doSpecial`, corpse - loot, consumable pickup) degrades to its existing refusal path. -- `index.ts` — active-view seam (`run.activeWorld`/`run.activeActor`) - through vision, paint, look/describe, touch, and statusLine (location - reads `// THE GRID //`); module-level `cyberVision = new VisionField()`; - shell `JACK_IN`/`JACK_OUT` handlers depend on Run subscribing first - (recorded in kaizen — listener-order coupling); `attachCyberListeners` - called at every scene (re)wiring site plus the `JACK_IN` hook so a - mid-cyber resume re-attaches automatically. -- **Dual-phase corp turn**: while jacked in, the corp slice chains two - `corpTurnDriver` passes — meat hostiles on the meat world (silent: the - canvas shows the grid), then ICE on `layer.world` — consuming the shared - `run.rng` in that fixed order. Body flatlined during the meat pass → - driver bails terminally and the ICE pass never runs. -- Tests: `dualPhaseTurn.test.ts` (shared-rng order/determinism lock), - `cyberTileset.test.ts` (glyph table + throw matrix). kaizen.md gained the - index.ts complexity findings (ShellScene casts, statusLine extraction, - listener rewire dedupe, listener-order coupling, seam enforcement). - -### S7.5 implementation notes (shipped) - -- Gap found in Rylee's S7 smoke: jacking out early resolved instantly — - an **irreversible** step (LINK BURNED + objective latched unsatisfiable) - with zero warning, unlike the exit-abort flow. -- `Run.onJackOutRequested` (ctor-validated, mirror of `onAbortRequested`): - `jackOut()` with the objective **incomplete** defers to the callback — - layer stays live, link unburned, nothing persisted; no callback - registered → resolve immediately (tests/harness posture). A complete - jack-out never asks. The port's interact AP is spent either way; a - re-request after cancel costs it again. -- `run.confirmJackOut()` finalizes (teardown → resolved latch → burn → - autosave, shared `#finalizeJackOut`). **Throws** on illegal states — - unlike `confirmAbort`'s no-op there is no legal request-voiding race - (the modal blocks turn flow), so a stale confirm is a wiring bug. -- Shell: modal context `jack-out-early` with link-burn copy; - `completeJackOutShellSwap()` shared by the immediate path (bus handler, - which now skips while the layer is still live) and the confirmed path. - `wireRunConfirmations(run)` extracted and called at deploy **and** - campaign resume — fixes a latent bug where a restored run lost - `onAbortRequested` entirely, silently skipping the abort confirm after - any mid-run reload. -- `EntryPort` success copy neutralized (`routing out…`) — the port no - longer knows whether the link actually dropped. -- Tests: +6 in `jackOut.test.ts` (defer/confirm matrix, complete-objective - immediacy, illegal-state throws, pending-confirmation persistence as a - live layer). Suite 1663 green. - -### S8 wrap-up - -- P3.M3 milestone status in `phase-3-plan.md` updated: first playable - slice shipped end-to-end; milestone stays open for Spark/Guardian ICE - (scope decision #1) and the deferred follow-ups noted in S6 - (ICE faction stamping) and kaizen (index.ts cleanup). -- Browser smoke of the S7.5 confirm flow: verified. - -## Architecture decisions (approved plan) - -### Cyber layer model — `CyberspaceLayer` owned by `Run`, single `TurnQueue`, both worlds tick - -New `src/game/cyber/CyberspaceLayer.ts`: owns its own `EventBus`, `World` -(own `Grid`), `CyberAvatar`, `entryTile`, `mapSeen`. NOT a nested Run. - -```ts -type CyberspaceState = - | { phase: 'dormant' } // shipped (S2) - | { phase: 'active'; layer: CyberspaceLayer } // shipped (S3) - | { phase: 'resolved'; objectiveComplete: boolean }; // shipped (S3); S5 finalizes -Run.cyberspace: CyberspaceState | null; // null ⇔ no cyber component -``` - -Turn integration: keep the one existing `TurnQueue`. `TurnQueue.endTurn(world)` -(verified: emits `TURN_ENDED {previous, next, turn}`, refreshes incoming-faction -AP in the world it is handed, ticks that world's alarm on round advance) runs on -the meat world as today. `Run`'s existing `TURN_ENDED` listener -(`_reattachCombatListeners`, Run.ts ~887) consumes the payload and, when cyber -is active, calls `layer.onTurnEnded(next)`: refresh incoming-faction AP in the -cyber world + tick the cyber alarm on round advance. The shell's corp phase -(S7) chains two `corpTurnDriver.runCorpTurn` passes: meat hostiles, then ICE -(`ctx.run = {state, world: layer.world, rng: run.rng}`). **Meatspace keeps -ticking during jack-in** — the Decker body stands at the port as `run.player`, -targetable; body death hits the existing player-death path -(`#onEntityDamaged`, Run.ts ~938). M4.2 vulnerability falls out for free. - -### Avatar + cyber stats - -`src/game/cyber/CyberAvatar.ts` — `CyberAvatar extends Entity`: faction PLAYER, -glyph `@`, maxAp 4, `maxHp = decker.ram`, -`damageReduction = decker.iceResistance`, carries `intrusionStrength` + -`callsign`, `baseHitChance` 0.8 (the grid is home turf; `Combat.resolveRanged` -capability-sniffs `'baseHitChance' in attacker`, so a non-Crew avatar fights -with zero combat changes). - -Stats live on `Decker` as validated fields with constants -`DECKER_BASE_RAM = 8`, `DECKER_BASE_INTRUSION = 2`, -`DECKER_BASE_ICE_RESISTANCE = 1` (`src/game/constants.ts`); ctor throws on -non-positive-integer overrides. Persist in **both** crew paths: - -1. `CampaignCrewSnapshot` (persistence.ts ~743) gains optional - `cyber?: { ram; intrusion; iceResistance }` written by `snapshotCrewMember` - for deckers only; `restoreCrewMember` (~1326) applies for - `archetype === 'decker'`. Absent → defaults (legacy normalization); - present-but-malformed or half-populated → throw. -2. Run-entity `decker` extra becomes - `DeckerSnapshot = CrewSnapshot & {ram; intrusion; iceResistance}` — - extractor in Run.ts `SNAPSHOT_EXTRACTORS.decker` (~1698), matching - `ENTITY_RESTORE.decker` apply hook. - -Avatar **current** RAM rides the base entity record (`hp`/`maxHp`/ -`damageReduction`) inside the cyber snapshot block; `intrusionStrength` + -`callsign` ride its `extra`. - -Avatar death: cyber-bus `ENTITY_DAMAGED` listener wired at jack-in (and on -restore via `_reattachCombatListeners` — the single seam both `enterCombat` and -`persistence.restore` already call) → set `telemetry.cause` → -`enterResult({outcome: OUTCOME.DEATH})` → `Campaign.onJobEnd` flatlines the -Decker through existing machinery. - -### Generation — dedicated generator, reuse TILE values, contract-seed determinism - -`src/game/cyber/cyberMapBuild.ts`: `buildCyberMap({rng, difficulty})` → -rooms-as-nodes lattice (5–8 square nodes), 1-tile corridors (data lines), -`TILE.WALL` fill (firewall). **Only FLOOR/WALL tile ids** — Grid -passability/LOS, A*, VisionField, World all work unchanged. Node count scales -with difficulty. Returns `{grid, entryTile, nodeTiles, patrolRings}`. -Connectivity validated from `entryTile` via `mapConnectivity.ts` -`explorationReachableKeys` (verified signature: `(world, start, options)` — -needs a throwaway `World` wrap or a grid-level flood; throws on unreachable -node tiles). ~150 lines; do NOT reuse the BSP/prefab `buildMap`. - -Seed: `new Rng(contract.seed).fork('cyberspace')` — layout independent of -jack-in turn, trivially restorable. - -Distinct visuals via a **tileset axis** (S7), not new TILE ids: `palette.ts` -gains `TilesetId = 'meat' | 'cyber'` + `CYBER_TILE_GLYPH` (FLOOR `·` deep cyan, -WALL `▒` magenta — tune in browser); `glyphForTile(tile, principalId?, -tileset?)` throws on unknown tileset. Thread through -`BuildFrameOptions`/`buildFrame`/`AsciiRenderer.draw`. - -### Data node objective semantics (S4) - -`src/game/cyber/DataNode.ts` — `DataNode extends Interactable` (Terminal -pattern): `sliceProgress`, `sliceDifficulty` (2/3/4 by contract difficulty). -`interact` capability-sniffs `intrusionStrength` (non-avatars refused), spends -interact AP, adds intrusion to progress; sliced at threshold. Glyph `◈` -(`◆` is DenyTarget's). - -Satisfaction: `ObjectiveState` gains `cyber?: {sliced, required}`; -`isObjectiveFamilySatisfied` DATA_NODE_SLICE case reads it; -`#refreshObjectiveTimerState` computes — `active` → count sliced nodes in the -layer world; `resolved` → `objectiveComplete ? required : 0`; `dormant` → 0. -**Jack-out before slicing = objective permanently unsatisfiable** → reaching -the meat exit hits the existing `onAbortRequested` confirm flow (incomplete -blocks *clean* extraction; zero new extraction code). `objectiveProgress.ts` -gains a `NODES` progress chip. Grep `describe.ts`/`combatHud.ts` for -kind-keyed copy in this slice. - -### Jack-in / jack-out (S3 core, S5 finalization) - -- `Run.jackIn(point)` via meat-bus `EVENT.JACK_IN` subscription (registered in - `_reattachCombatListeners`): `state === COMBAT` else throw; - `cyberspace.phase === 'dormant'` else **throw** (corrupt latch); build layer, - wire cyber listeners, autosave (`onPersist` explicitly — the latch transition - must never be lost). -- Jack-out: `EntryPort extends Interactable` at `entryTile` in the cyber world - (glyph `▼`, avatar-only capability sniff); interact emits `EVENT.JACK_OUT` on - the cyber bus → `Run.jackOut()`: throw unless active; latch - `objectiveComplete`, `layer.teardown()` (unbind ICE bus subs), phase - `resolved`, autosave. Re-jack-in after resolve refused (`LINK BURNED` - flavor — `JackInPoint.linked` is already latched). - -### Probe ICE (S6) - -`src/game/cyber/ProbeIce.ts` — `ProbeIce extends PatrolHostile`, faction CORP, -glyph `¶`, displayName `Probe`, explicit small stats (maxHp 3, dmg 1, -sightRange 6 — ICE scaling is its own axis, skip `resolveEnemyStats`). -`engageSteps`: on acquisition raise the cyber alarm -(`world.raiseAlarm({repPenalty: false, ...})`) then weak attack; Probe -**listens** for alarms (unlike Lookout) so packs converge. Add `'probe-ice'` -to `PATROL_ARCHETYPE_IDS` so `PatrolSnapshot` machinery applies. Patrol -waypoint rings around node tiles from `buildCyberMap`. Run's cyber -`ENTITY_DAMAGED` listener also unbinds killed patrol hostiles (mirror of the -meat path). Verify no rep listener attaches to the cyber bus. - -### Render/input swap (S7) - -`Run` accessors keep the shell mechanical: `get cyberActive`, -`get activeWorld`, `get activeActor`. Shell (`index.ts`): module-level -`cyberVision = new VisionField()`; swap `recomputeVision`, `paint` -(+`tileset`), `applyIntent` ctx (`world: run.activeWorld, -player: run.activeActor`), look/describe, `buildCombatHudSnapshot` (identity -`AVATAR`, HP pane labeled RAM), statusLine, location label (`// THE GRID //`), -chained ICE corp pass. `ApplyIntentContext.player: Archetype` widens to accept -`CyberAvatar` (it exposes none of the perk capabilities, so `doSpecial` -sniffing is safe). No keymap/keyHelp changes — jack-in/out are interactions. -PIP deferred to M4.5. **Grep `run.world` / `run.player` / `scene.world` / -`scene.player` exhaustively** — look/describe, touch path, corpse-memory -vision are the known stragglers. - -## Persistence (consolidated; S2 shipped the dormant scaffolding) - -`RunSnapshot.cyberspace` (S3 extends the S2 shape): - -```ts -cyberspace?: { - phase: 'dormant' | 'active' | 'resolved'; - // 'active' only — all required together: - seed?, grid?: {w,h,tiles}, entities?: RunEntitySnapshot[], // avatar, entry-port, data-nodes, probe-ice - entryTile?, alarm?: AlarmState, mapMemory?: {seen: string[]}, - // 'resolved' only: - objectiveComplete?: boolean; -}; -``` - -Restore rules (`restoreCyberspace` in persistence.ts — S2 shipped the dormant -subset; all throws, `restoreOverrideState` style): - -- `contractRequiresCyberspace(contract)` ⇔ block present (both directions). ✅ shipped -- Unknown phase throws. ✅ shipped -- `dormant` carrying payload throws. ✅ shipped -- S3: `active` missing any required field throws; exactly one alive - `cyber-avatar` (0 or 2+ throws); entities bounds-checked against the - **cyber** grid (reuse `restoreEntity(rec, cyberGrid)`); seen keys - bounds-checked. -- S3: `resolved` without boolean `objectiveComplete`, or carrying active-only - fields, throws. -- S3: Decker `cyber` stat blocks (campaign crew + run entity extra): absent → - defaults (legacy normalization); present-but-malformed or half-populated → - throw. -- After rebuild: probes `bindToBus(layer.bus)`; cyber listeners re-wired in - `_reattachCombatListeners`. -- Autosave cadence unchanged (meat-bus `TURN_ENDED` still fires every end-turn - while jacked in — single queue); `jackIn`/`jackOut` call `onPersist` - explicitly. - -## S3 detailed worklist (shipped in `39b357e` — see implementation notes above for deviations) - -**Create:** -- `src/game/cyber/cyberMapBuild.ts` — `buildCyberMap({rng, difficulty})` per - above; throws on connectivity failure. -- `src/game/cyber/CyberAvatar.ts` — per above. -- `src/game/cyber/EntryPort.ts` — `EntryPort extends Interactable` (glyph `▼`, - label `Exit port`); interact by avatar emits `EVENT.JACK_OUT` (charge - `AP_COST.INTERACT`). -- `src/game/cyber/CyberspaceLayer.ts` — class + - `static build({contractSeed, difficulty, decker})` (forks - `new Rng(contractSeed).fork('cyberspace')`, builds map, spawns avatar at - `entryTile`, `EntryPort`, data nodes (S4 fills in), ICE (S6)); - `onTurnEnded(next: FactionId)`; `recordSeen(keys)` (bounds-validated, - throws); `snapshot(): CyberspaceLayerSnapshot`; `teardown()`. - -**Modify:** -- `src/game/archetypes/Decker.ts` + `constants.ts` — `ram`/`intrusionStrength`/ - `iceResistance` fields, `DECKER_BASE_*` constants, ctor validation. -- `src/game/Run.ts` — extend `CyberspaceState` union + `RunCyberspaceSnapshot`; - `get cyberActive / activeWorld / activeActor`; `jackIn(point)` (driven by - `EVENT.JACK_IN` subscription added to `_reattachCombatListeners`); - `jackOut()` core; `#onTurnEnded` consumes the `{next}` payload (currently - ignores it — Run.ts ~927) for the cyber AP/alarm tick; `snapshotCyberspace` - extended; `SNAPSHOT_EXTRACTORS` entries `'cyber-avatar'`/`'entry-port'`; - `archetypeOf` instanceof arms; `DeckerSnapshot` extra extension. -- `src/game/persistence.ts` — extend `restoreCyberspace` (active/resolved - rules above); `ARCHETYPE_FACTORY`/`ENTITY_RESTORE` for - `'cyber-avatar'`/`'entry-port'`; `ENTITY_RESTORE.decker` apply for cyber - stats; `CampaignCrewSnapshot.cyber?` in `snapshotCrewMember` (~1309) + - `restoreCrewMember` (~1326). - -**Tests (failing first):** -- `tests/unit/game/cyber/cyberMapBuild.test.ts`: equal-seed determinism; - different-seed divergence; every node tile reachable from entry; only - FLOOR/WALL ids; difficulty scales node count. -- `tests/unit/game/cyber/CyberspaceLayer.test.ts`: `build` spawns avatar at - entry with `maxHp === decker.ram`, - `damageReduction === decker.iceResistance`; `onTurnEnded(FACTION.PLAYER)` - refreshes avatar AP only. -- `tests/unit/game/cyber/runJackIn.test.ts`: golden path — seeded cyber run, - walk Decker to port, interact → `phase === 'active'`; layer grid equals a - fresh build from the same contract seed (jack-in-turn independence); - `jackIn` while already active throws; `JACK_IN` on a non-cyber contract - throws. -- `tests/unit/game/cyber/cyberPersistence.test.ts` (the heart): mid-jack-in - snapshot → restore → grids/entities/avatar hp/ap/seen equal; full - adversarial throw matrix (active missing grid/avatar; avatar OOB; multiple - avatars; resolved missing latch; decker cyber stats half-populated in both - paths). - -## S3 research notes (verified against the tree, 2026-06-09) - -- `TurnQueue.endTurn(world)` emits `TURN_ENDED {previous, next, turn}` and - refreshes AP only for entities in the world it is handed; alarm ticks on - round advance (TurnQueue.ts:34-55). `Run.#onTurnEnded()` currently takes no - payload (Run.ts ~927) — S3 threads `{next}` through for the cyber tick. -- `_reattachCombatListeners` (Run.ts ~887) is the single seam used by both - `enterCombat` and `persistence.restore` — register the `EVENT.JACK_IN` - subscription and (re)wire cyber-layer listeners there. -- `#onEntityDamaged` (Run.ts ~938) is the player-death template: avatar-death - listener mirrors `target === avatar && killed` → telemetry.cause → - `enterResult({outcome: OUTCOME.DEATH})`. It also unbinds killed - `PatrolHostile`s — the cyber listener needs the same for probes (S6). -- `persistence.restore` (persistence.ts ~872) rebuilds grid → entities → - Run → world → queue, then calls `_reattachCombatListeners` for COMBAT - snapshots. `restoreEntity(rec, grid)` bounds-checks against the grid it is - given — reuse with the cyber grid for cyber entities. -- `CampaignCrewSnapshot` (~743) and `snapshotCrewMember`/`restoreCrewMember` - (~1309/~1326) are the campaign crew path; run-entity crew state flows through - `crewSnapshotExtra` (Run.ts ~1675) + `ENTITY_RESTORE` crew handling. Decker - cyber stats must round-trip through **both**. -- `Entity` ctor validates `damageReduction` as a non-negative integer - (Entity.ts ~110) — avatar can pass `iceResistance` straight through. -- `explorationReachableKeys(world, start, options)` (mapConnectivity.ts) is the - flood-fill for cyber connectivity validation; it needs a `World`, so - `buildCyberMap` should wrap its fresh grid in a throwaway `World` (no - entities yet → entity blockers moot). -- `World` ctor: `new World(grid, { events: bus })`; alarm API: - `raiseAlarm(ctx)`, `tickAlarm()`, `snapshotAlarm()`, `restoreAlarm(state)`. -- `Crew.archetype` is the class-name string (`'Decker'`, capital D) for live - checks; persistence archetype ids are lowercase (`'decker'`). - -## Verification (per slice + end-to-end) - -1. Per slice: `npm test` (typecheck + test build + `node --test`), `npm run lint`. -2. Determinism: equal-seed trace assertions in S3/S6 tests. -3. Browser smoke after S7 (`npm start`, port 8099): Act-2 save with Decker → - cyber contract on board → non-Decker rows disabled → deploy Decker → jack - in → cyber tileset + RAM HUD + probe attacks → slice node → jack out → meat - renders → clean extract. Plus: Merc bump refusal copy, redundant jack-in - message, mid-cyber reload resumes in the grid, avatar death → flatline - screen, jack-out-early → abort-confirm extraction. Console clean on iOS - Safari sim + Chrome. -4. Persistence adversarial: hand-mutate a save in devtools (delete - `cyberspace.grid`, flip phase) → restore throws to the error boundary, not - a silent fallback. - -## Risks - -- **S7 shell breadth** is the highest-risk surface (~2300-line index.ts reads - `run.world`/`run.player` widely). Mitigation: accessors + exhaustive grep + - the smoke script above. -- **`ApplyIntentContext.player` widening** may ripple into - `doSpecial`/`doUseItem` narrowing; the avatar exposes no perk capabilities, - and `collectTileLoot` guards on `player.inventory`. -- **Shared-rng turn order**: meat pass then ICE pass consumes `run.rng` in a - fixed order; lock with the S7 dual-phase test. -- **Cyber alarm/rep**: Probe passes `repPenalty: false`; confirm no rep - listeners attach to the cyber bus (S6 test). diff --git a/docs/phase-3-plan.md b/docs/phase-3-plan.md index b31dd0d..1f1fbb9 100644 --- a/docs/phase-3-plan.md +++ b/docs/phase-3-plan.md @@ -201,68 +201,98 @@ Neural degradation is deferred until Cyberspace is fun enough to deserve a jack- ### P3.M3 — Cyberspace grid + ICE 🟡 -> **Status (2026-06-11):** the first playable slice (M3.1–M3.6, plus voluntary -> jack-out pulled forward from M4.6 and an early-jack-out confirmation) is -> shipped end-to-end on `3.0-cyberspace` — see -> [phase-3-cyberspace-plan.md](phase-3-cyberspace-plan.md) for slice notes and -> recorded scope decisions. The milestone stays open for **Spark and Guardian -> ICE**. The Score contract is now always a cyber run (scope decision #5). +**Status (2026-06-14):** The first playable slice (M3.1–M3.6, plus voluntary jack-out pulled forward from M4.6 and an early-jack-out confirmation) is shipped end-to-end. The milestone stays open for **Spark and Guardian ICE**. **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): `buildScoreContract` emits `DATA_NODE_SLICE` with `{requiresCyberspace: true, count: 1}` — for now; revisit if a meat-only Score variant is ever wanted. Deploy goes through the living-Decker gate; objective shape locked in the P3.M1.7 tests. + +TDD throughout; malformed persisted state throws (no silent fallbacks). + **Scope:** -- **Cyberspace grid:** Separate `Grid` / `World` instance for the digital layer. **First implementation should reuse the existing square grid engine** with a distinct tileset and generation rules, then reserve a later graph-topology refactor only if the square grid fails the feel test. This keeps pathfinding, rendering, snapshots, and tests inside known machinery. -- **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. + - **Spark:** Fast, fragile attacker. Swarm behavior. 🔲 Planned. + - **Guardian:** Heavy. Guards critical nodes. High HP, high damage, limited mobility. 🔲 Planned. +- **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:** -The first jack-in should prove the door between layers before shipping every ICE behavior: - -1. Add a `requiresCyberspace` / `cyberspaceObjective` contract param for Act 2+ jobs, generated only when the Decker has been recruited. -2. Place a Meatspace jack-in terminal using the existing `Terminal` / interactable placement path, distinct in label from ordinary terminal-slice props. -3. When the Decker interacts with the jack-in terminal, create a `cyberspace` run layer with: - - generated grid and seed metadata, - - Decker digital avatar, - - one data node objective, - - at least one Probe ICE. -4. Latch a run state like `cyberspace.active = true`; repeated jack-in attempts against the same terminal throw or log a deterministic "already linked" message depending on whether state is corrupt or just redundant input. -5. Saving mid-jack-in restores both Meatspace and Cyberspace. Absent or malformed Cyberspace snapshot data for an active jack-in is tier-1 corrupt state and must throw to the boundary. +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 | Add Cyberspace-capable contract metadata and validation | generated only Act 2+, invalid flag/params throw | -| **P3.M3.2 Jack-in terminal** | ✅ Done | Place a terminal that can start the digital layer | deterministic placement, no collision with objective props | -| **P3.M3.3 Cyber layer model** | ✅ Done | Add serializable `Run.cyberspace` layer with grid/world/avatar | snapshot round-trip, active-layer invariants | -| **P3.M3.4 Data node objective** | ✅ Done | Slice one data node and feed objective satisfaction | incomplete blocks clean extraction, complete allows it | -| **P3.M3.5 Probe ICE** | ✅ Done | Minimal ICE patrol/detect/attack loop | seeded movement, detection/alarm, damage/death | -| **P3.M3.6 Render swap** | ✅ Done | Render Cyberspace when active; Meatspace remains reachable for P3.M4 | browser smoke and console-clean verification | -| **M4.6 pull-forward — voluntary jack-out** | ✅ Done | `JackInPoint.burned` latch; `Run.jackOut()`; shell swap; early jack-out confirmation modal (`onJackOutRequested` / `confirmJackOut`) | 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, terminal end reason | +| **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 | +| **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** | 🔲 Planned | Fast, fragile attacker; swarm behavior | — | | **Guardian ICE** | 🔲 Planned | Heavy guard of critical nodes; high HP/damage | — | +**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()`. One probe per patrol ring. `'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`. + +**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. PIP deferred to M4.5. + +**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 and ICE faction stamping for non-corp principals remain 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 pressure came from action economy, not its nominal one-damage strike: the original 3 HP / 4 AP unit could close and attack repeatedly after one Probe woke the pack. Probe now has 2 HP / 2 AP while retaining the trace-flare identity. `CyberAvatar` exposes the Decker's Override capability against ICE; successful overrides reuse the existing temporary faction flip, act during the cyber player-aftermath pass, revert on the normal countdown, and round-trip through the existing patrol snapshot. Campaign dead ends are explicit: before THE SCORE, loss of the assigned Decker creates one free Terminal replacement lead and hides THE SCORE until a living Decker joins; once THE SCORE begins, Decker death ends the campaign with `decker-flatlined-score` Game Over copy. +**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. --- From 9d22e501ff566fb286f8980cea14ea6a28846030 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Sun, 14 Jun 2026 13:06:12 -0700 Subject: [PATCH 31/55] PIP meatspace window --- docs/phase-3-plan.md | 38 ++- index.html | 24 +- index.ts | 95 ++++++- main.css | 51 +++- src/game/Entity.ts | 2 + src/game/Run.ts | 11 + src/game/constants.ts | 6 +- src/game/cyber/CyberspaceLayer.ts | 114 ++++++-- src/game/cyber/GuardianIce.ts | 85 ++++++ src/game/cyber/ProbeIce.ts | 17 +- src/game/cyber/SparkIce.ts | 75 ++++++ src/game/persistence.ts | 6 + src/render/AsciiRenderer.ts | 4 +- src/render/pip.ts | 91 +++++++ sw-core.js | 1 + sw-dev.js | 2 +- sw.js | 2 +- tests/unit/game/cyber/CyberspaceLayer.test.ts | 22 +- tests/unit/game/cyber/ProbeIce.test.ts | 7 +- tests/unit/game/cyber/dualPhaseTurn.test.ts | 15 +- tests/unit/game/cyber/iceRoster.test.ts | 253 ++++++++++++++++++ tests/unit/render/AsciiRenderer.test.ts | 15 ++ tests/unit/render/pip.test.ts | 82 ++++++ 23 files changed, 943 insertions(+), 75 deletions(-) create mode 100644 src/game/cyber/GuardianIce.ts create mode 100644 src/game/cyber/SparkIce.ts create mode 100644 src/render/pip.ts create mode 100644 tests/unit/game/cyber/iceRoster.test.ts create mode 100644 tests/unit/render/pip.test.ts diff --git a/docs/phase-3-plan.md b/docs/phase-3-plan.md index 1f1fbb9..4940580 100644 --- a/docs/phase-3-plan.md +++ b/docs/phase-3-plan.md @@ -43,7 +43,7 @@ A new **player archetype** recruited mid-campaign (late Act 1 / start of Act 2), |---|---| | P3.M1 — Campaign arc structure | ✅ Done | | P3.M2 — The Decker archetype | ✅ Done | -| P3.M3 — Cyberspace grid + ICE | 🟡 First playable slice shipped (Spark/Guardian ICE open) | +| P3.M3 — Cyberspace grid + ICE | ✅ Done (full ICE roster: Probe, Spark, Guardian) | | P3.M4 — Simstim flip (dual-deploy) | 🔲 Planned | | P3.M5 — The Score (climactic mission) | 🔲 Planned | | P3.M6 — Chronicle (campaign narrative memory) | 🟡 End-summary foundation shipped | @@ -199,9 +199,9 @@ Neural degradation is deferred until Cyberspace is fun enough to deserve a jack- --- -### P3.M3 — Cyberspace grid + ICE 🟡 +### P3.M3 — Cyberspace grid + ICE ✅ -**Status (2026-06-14):** The first playable slice (M3.1–M3.6, plus voluntary jack-out pulled forward from M4.6 and an early-jack-out confirmation) is shipped end-to-end. The milestone stays open for **Spark and Guardian 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. @@ -222,9 +222,9 @@ TDD throughout; malformed persisted state throws (no silent fallbacks). - **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). ✅ Shipped. - - **Spark:** Fast, fragile attacker. Swarm behavior. 🔲 Planned. - - **Guardian:** Heavy. Guards critical nodes. High HP, high damage, limited mobility. 🔲 Planned. + - **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. @@ -256,10 +256,12 @@ TDD throughout; malformed persisted state throws (no silent fallbacks). | **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** | 🔲 Planned | Fast, fragile attacker; swarm behavior | — | -| **Guardian ICE** | 🔲 Planned | Heavy guard of critical nodes; high HP/damage | — | +| **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. @@ -269,9 +271,19 @@ TDD throughout; malformed persisted state throws (no silent fallbacks). **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()`. One probe per patrol ring. `'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`. +**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`. -**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. PIP deferred to M4.5. +**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. @@ -282,17 +294,17 @@ TDD throughout; malformed persisted state throws (no silent fallbacks). **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 and ICE faction stamping for non-corp principals remain open. +- 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 (Probe ✅; Spark/Guardian 🔲). +- 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 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). --- diff --git a/index.html b/index.html index 1d3ab22..83a9de7 100644 --- a/index.html +++ b/index.html @@ -46,13 +46,23 @@

Kernel Panic

- +
+ + +