@@ -105,7 +33,7 @@
Choose Your Path
diff --git a/src/app/run/run.component.scss b/src/app/run/run.component.scss
index 778f5f61..426874a0 100644
--- a/src/app/run/run.component.scss
+++ b/src/app/run/run.component.scss
@@ -123,6 +123,9 @@
}
.run-gold {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
color: var(--game-gold);
font-weight: 700;
font-variant-numeric: tabular-nums;
@@ -148,170 +151,6 @@
}
}
-// ── Start screen ──────────────────────────────────────────────────────────────
-
-.run-start {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding-top: 15vh;
- flex: 1;
- gap: var(--spacing-lg);
- text-align: center;
-}
-
-.run-title {
- font-family: "Orbitron", sans-serif;
- font-size: var(--font-size-hero);
- font-weight: 800;
- letter-spacing: 0.15em;
- text-transform: uppercase;
- color: var(--theme-purple-lighter);
-}
-
-.run-subtitle {
- color: var(--text-muted);
- font-size: var(--font-size-base);
-}
-
-.run-start-actions {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: var(--spacing-md);
- width: 100%;
- max-width: 24rem;
-}
-
-// ── Resume run block ──────────────────────────────────────────────────────────
-
-.run-resume-block {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: var(--spacing-sm);
- width: 100%;
- padding: var(--spacing-sm) var(--spacing-md);
- background: var(--glass-bg);
- border: var(--border-width-thin) solid var(--border-color);
- border-radius: var(--border-radius-lg);
-}
-
-.run-resume-info {
- display: flex;
- align-items: center;
- gap: var(--spacing-xs);
- font-size: var(--font-size-small);
- color: var(--text-muted);
- flex-wrap: wrap;
- justify-content: center;
-}
-
-.run-resume-info__item {
- white-space: nowrap;
-}
-
-.run-resume-info__sep {
- color: var(--text-faint);
-}
-
-// ── Ascension select ──────────────────────────────────────────────────────────
-
-.run-ascension-select {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: var(--spacing-sm);
- margin-top: var(--spacing-md);
- flex-wrap: wrap;
- justify-content: center;
- width: 100%;
- max-width: 36rem;
-}
-
-.ascension-select__label {
- font-family: "Orbitron", sans-serif;
- font-size: var(--font-size-small);
- font-weight: 600;
- color: var(--text-muted);
- text-transform: uppercase;
- letter-spacing: 0.06em;
-}
-
-.ascension-select__dropdown {
- background: var(--theme-purple-a10);
- color: var(--text-color);
- border: var(--border-width-thin) solid var(--theme-purple-a50);
- border-radius: var(--border-radius-md);
- padding: var(--spacing-xs) var(--spacing-sm);
- min-width: 20rem;
- max-width: 100%;
- font-family: "Orbitron", sans-serif;
- font-size: var(--font-size-small);
-
- option {
- background: var(--nav-bg);
- color: var(--text-color);
- }
-}
-
-// ── Ascension info panel ──────────────────────────────────────────────────────
-
-.ascension-info {
- width: 100%;
- max-width: 32rem;
- background: var(--theme-purple-a10);
- border: var(--border-width-thin) solid var(--theme-purple-a50);
- border-radius: var(--border-radius-lg);
- padding: var(--spacing-md) var(--spacing-lg);
- text-align: left;
-}
-
-.ascension-info__title {
- font-family: "Orbitron", sans-serif;
- font-size: var(--font-size-small);
- font-weight: 700;
- color: var(--text-dim);
- margin: 0 0 var(--spacing-sm);
- text-transform: uppercase;
- letter-spacing: 0.06em;
-}
-
-.ascension-info__effects {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
- gap: var(--spacing-xs);
-}
-
-.ascension-info__effect {
- display: flex;
- align-items: baseline;
- gap: var(--spacing-xs);
- font-size: var(--font-size-small);
-}
-
-.ascension-info__effect-level {
- font-family: "Orbitron", sans-serif;
- font-weight: 700;
- font-size: var(--font-size-2xs);
- min-width: 1.4rem;
- text-align: right;
- opacity: 0.7;
-}
-
-.ascension-info__effect-text {
- flex: 1;
-}
-
-// Difficulty color-coding via design system
-.ascension-info__effect--easy { color: var(--action-success); }
-.ascension-info__effect--medium { color: var(--action-warning); }
-.ascension-info__effect--hard { color: var(--game-accent-orange); }
-.ascension-info__effect--extreme { color: var(--game-health); }
-
// ── Map wrapper ───────────────────────────────────────────────────────────────
.run-map {
@@ -348,10 +187,6 @@
// ── Responsive ────────────────────────────────────────────────────────────
@media (max-width: 768px) {
- .run-title {
- font-size: var(--font-size-large);
- }
-
.run-sidebar {
display: none;
}
@@ -389,30 +224,6 @@
gap: var(--spacing-sm);
}
- .run-start {
- padding-top: 8vh;
- }
-
- .run-start-actions {
- flex-direction: column;
- align-items: center;
- width: 100%;
- }
-
- .run-btn--full {
- width: 100%;
- }
-
- .run-ascension-select {
- width: 100%;
- max-width: 100%;
- }
-
- .run-ascension-select .ascension-select__dropdown {
- min-width: unset;
- width: 100%;
- }
-
.run-map-title {
font-size: var(--font-size-xs);
text-align: center;
diff --git a/src/app/run/run.component.ts b/src/app/run/run.component.ts
index 4c03c613..c374840e 100644
--- a/src/app/run/run.component.ts
+++ b/src/app/run/run.component.ts
@@ -10,7 +10,6 @@ import { RunState, RunStatus } from './models/run-state.model';
import { MapNode, NodeMap, NodeType, getSelectableNodes } from './models/node-map.model';
import { RelicDefinition, RELIC_DEFINITIONS, RelicId } from './models/relic.model';
import { RewardScreenConfig, RewardItem, ShopItem, RunEvent } from './models/encounter.model';
-import { AscensionLevel, ASCENSION_LEVELS } from './models/ascension.model';
import { CardInstance } from './models/card.model';
/**
@@ -42,7 +41,7 @@ export class RunComponent implements OnInit, OnDestroy {
activeRelics: RelicDefinition[] = [];
/** Current view mode. */
- viewMode: 'start' | 'map' | 'reward' | 'shop' | 'rest' | 'event' | 'act-transition' | 'epilogue' | 'summary' = 'start';
+ viewMode: 'map' | 'reward' | 'shop' | 'rest' | 'event' | 'act-transition' | 'epilogue' | 'summary' = 'map';
/**
* The ascension level that was just unlocked by this run's victory.
@@ -51,20 +50,20 @@ export class RunComponent implements OnInit, OnDestroy {
*/
epilogueUnlockedAscension = 0;
+ /**
+ * The BossPreset id of the final-act boss defeated in this run.
+ * Selects the matching epilogue copy variant. '' produces the neutral fallback.
+ */
+ get finalBossPresetId(): string {
+ return this.runService.getFinalBossPresetId();
+ }
+
/** Ascension high-water mark captured at init, before any advanceAct(). */
private priorMaxAscension = 0;
/** Boss preset name for the act-transition screen. */
actTransitionBossName = '';
- /** Currently selected ascension level in the start screen selector. */
- selectedAscension = 0;
-
- /** All ascension levels the player has unlocked (up to maxAscension). */
- get ascensionLevelOptions(): AscensionLevel[] {
- return ASCENSION_LEVELS.slice(0, this.maxAscension);
- }
-
/** Reward screen config, set after combat victory. */
rewardConfig: RewardScreenConfig | null = null;
@@ -135,15 +134,6 @@ export class RunComponent implements OnInit, OnDestroy {
this.viewMode = 'map';
}
- /** Resume an in-progress run from localStorage. */
- resumeRun(): void {
- this.runService.resumeRun();
- // Only advance to map if the service successfully restored state
- if (this.runService.hasActiveRun()) {
- this.viewMode = 'map';
- }
- }
-
/** Select a node on the map to visit next. */
selectNode(node: MapNode): void {
if (!this.isNodeSelectable(node)) return;
@@ -166,6 +156,7 @@ export class RunComponent implements OnInit, OnDestroy {
break;
case NodeType.EVENT:
this.runService.generateEvent();
+ this.eventRemovedCardName = null;
this.viewMode = 'event';
break;
case NodeType.UNKNOWN:
@@ -188,10 +179,36 @@ export class RunComponent implements OnInit, OnDestroy {
this.router.navigate(['/play']);
}
+ /**
+ * Launch an endless post-victory encounter.
+ * Prepares an endless EncounterConfig (reusing the final boss map) and
+ * navigates to /play. The run stays in-progress; the game-board uses its
+ * normal single code path with endless mode enabled at bootstrap.
+ * Defeat in endless returns to /run without corrupting run state because
+ * RunService.recordEncounterResult() only records the result — the run
+ * status is already VICTORY and consumePendingEncounterResult() guards
+ * against double-processing a non-IN_PROGRESS run.
+ */
+ launchEndless(): void {
+ this.runService.prepareEndlessEncounter();
+ this.router.navigate(['/play']);
+ }
+
/** Handle return from /play after encounter completion. */
handleEncounterReturn(): void {
const result = this.runService.consumePendingEncounterResult();
- if (!result) return;
+ if (!result) {
+ // No processable result — this happens when the run is already in a
+ // terminal state (e.g. returning from an endless encounter after a
+ // post-victory run). Show the summary rather than leaving viewMode
+ // undefined or navigating away unexpectedly.
+ if (this.runState) {
+ this.viewMode = 'summary';
+ } else {
+ this.router.navigate(['/']);
+ }
+ return;
+ }
if (result.victory) {
this.rewardConfig = this.runService.generateRewards();
@@ -267,12 +284,11 @@ export class RunComponent implements OnInit, OnDestroy {
}
/**
- * Phase 1 Sprint 4 hardening (red-team Finding 2):
* Memoized snapshot of the current deck for the shop card-removal picker.
* Refreshed only when entering the shop or after a successful removal — NOT
- * via a per-CD-tick method-call binding. The previous `[deckCards]="getDeckCards()"`
- * binding allocated a new array every change-detection tick, which fired
- * ngOnChanges on ShopScreenComponent and reset its one-use card-remove slot.
+ * via a per-CD-tick method-call binding. A per-tick getter would allocate a
+ * new array every change-detection cycle, firing ngOnChanges on ShopScreenComponent
+ * and resetting its one-use card-remove slot.
*/
shopDeckSnapshot: CardInstance[] = [];
@@ -290,11 +306,15 @@ export class RunComponent implements OnInit, OnDestroy {
this.viewMode = 'map';
}
- /** Complete event choice. */
+ /** Complete event choice. Routes to summary if the event drained the last life. */
completeEvent(choiceIndex: number): void {
- this.runService.resolveEvent(choiceIndex);
+ this.eventRemovedCardName = this.runService.resolveEvent(choiceIndex);
this.eventGambleResult = null;
- this.viewMode = 'map';
+ if (this.runState?.status === RunStatus.DEFEAT) {
+ this.viewMode = 'summary';
+ } else {
+ this.viewMode = 'map';
+ }
}
/** Buy item from shop. */
@@ -327,11 +347,19 @@ export class RunComponent implements OnInit, OnDestroy {
/** Seeded gamble preview result — set synchronously when the player picks a gamble choice. */
eventGambleResult: { goldDelta: number; livesDelta: number } | null = null;
+ /** Name of the card removed by the last event outcome; null when no card was removed. */
+ eventRemovedCardName: string | null = null;
+
/** Called by EventScreenComponent when the player picks a gamble choice. Rolls once via seeded RNG. */
onPreviewGamble(index: number): void {
this.eventGambleResult = this.runService.previewEventGamble(index);
}
+ /** Called by EventScreenComponent when the player picks a choice with a removeCard outcome. */
+ onPreviewCardRemoval(index: number): void {
+ this.eventRemovedCardName = this.runService.previewEventCardRemoval(index);
+ }
+
/** Leave shop, return to map. */
leaveShop(): void {
this.runService.leaveShop();
@@ -339,9 +367,9 @@ export class RunComponent implements OnInit, OnDestroy {
}
/**
- * Phase 1 Sprint 4 — handle the shop card-remove action. The ShopScreen
- * component is the source of truth for "one use per visit"; this just
- * delegates to the service and stays on the shop screen.
+ * Handle the shop card-remove action. ShopScreenComponent is the source of truth
+ * for one-use-per-visit enforcement; this delegates to the service and stays on
+ * the shop screen.
*/
onShopCardRemoved(instanceId: string): void {
this.runService.removeCardFromShop(instanceId);
@@ -379,52 +407,6 @@ export class RunComponent implements OnInit, OnDestroy {
return this.availableNodes.some(n => n.id === node.id);
}
- /** Can we resume a saved run? Only true when saved data is complete and valid. */
- get canResume(): boolean {
- if (!this.runService.hasSavedRun()) return false;
- const preview = this.runService.loadSavedRunPreview();
- if (!preview) return false;
- // Require a meaningful run: must have a valid actIndex and at least one encounter result
- return preview.actIndex >= 0 && preview.encounterResults !== undefined;
- }
-
- /**
- * Returns a brief snapshot of the paused run for the start-screen resume button.
- * Returns null when no saved run exists.
- */
- get savedRunSummary(): { act: number; encounters: number; lives: number; relics: number } | null {
- if (!this.canResume) return null;
- const state = this.runService.loadSavedRunPreview();
- if (!state) return null;
- return {
- act: state.actIndex + 1,
- encounters: state.encounterResults.length,
- lives: state.lives,
- relics: state.relicIds.length,
- };
- }
-
- /** Highest ascension level unlocked. */
- get maxAscension(): number {
- return this.runService.getMaxAscension();
- }
-
- /** Returns all ascension levels from 1 up to and including the given level. */
- ascensionLevelsUpTo(level: number): AscensionLevel[] {
- return ASCENSION_LEVELS.slice(0, level);
- }
-
- /**
- * Returns a CSS class suffix for color-coding ascension difficulty.
- * Levels 1-5: easy (green), 6-10: medium (yellow), 11-15: hard (orange), 16-20: extreme (red).
- */
- getAscensionDifficultyClass(level: number): string {
- if (level <= 5) return 'easy';
- if (level <= 10) return 'medium';
- if (level <= 15) return 'hard';
- return 'extreme';
- }
-
private updateRelicDisplay(relicIds: string[]): void {
this.activeRelics = relicIds
.map(id => RELIC_DEFINITIONS[id as RelicId])
@@ -466,6 +448,7 @@ export class RunComponent implements OnInit, OnDestroy {
break;
case NodeType.EVENT:
this.runService.generateEvent();
+ this.eventRemovedCardName = null;
this.viewMode = 'event';
break;
default:
diff --git a/src/app/run/services/encounter.service.spec.ts b/src/app/run/services/encounter.service.spec.ts
index 718af389..ab3d3baf 100644
--- a/src/app/run/services/encounter.service.spec.ts
+++ b/src/app/run/services/encounter.service.spec.ts
@@ -71,6 +71,7 @@ describe('EncounterService', () => {
'generateCombatWaves',
'generateEliteWaves',
'generateBossWaves',
+ 'getBossPreset',
]);
mapBridge = jasmine.createSpyObj('MapBridgeService', ['setEditorMapState']);
runMapService = jasmine.createSpyObj('RunMapService', ['loadLevel']);
@@ -78,6 +79,7 @@ describe('EncounterService', () => {
waveGenerator.generateCombatWaves.and.returnValue(STUB_WAVES);
waveGenerator.generateEliteWaves.and.returnValue(STUB_WAVES);
waveGenerator.generateBossWaves.and.returnValue(STUB_WAVES);
+ waveGenerator.getBossPreset.and.returnValue({ id: 'vanguard_convergence', name: 'Vanguard Convergence', description: '', waves: [] });
runMapService.loadLevel.and.returnValue(MOCK_MAP_STATE);
TestBed.configureTestingModule({
@@ -222,4 +224,22 @@ describe('EncounterService', () => {
expect(waveGenerator.generateCombatWaves).toHaveBeenCalledWith(3, 1, 77);
});
+
+ // ── getBossPresetId ───────────────────────────────────────────
+
+ it('getBossPresetId() returns the preset id from WaveGeneratorService', () => {
+ waveGenerator.getBossPreset.and.returnValue({ id: 'ironclad_march', name: 'Ironclad March', description: '', waves: [] });
+ const id = service.getBossPresetId(2, 12345);
+ expect(waveGenerator.getBossPreset).toHaveBeenCalledWith(2, 12345);
+ expect(id).toBe('ironclad_march');
+ });
+
+ it('getBossPresetId() returns distinct ids for different presets', () => {
+ waveGenerator.getBossPreset.and.returnValues(
+ { id: 'vanguard_convergence', name: 'VC', description: '', waves: [] },
+ { id: 'celestial_deluge', name: 'CD', description: '', waves: [] },
+ );
+ expect(service.getBossPresetId(2, 1)).toBe('vanguard_convergence');
+ expect(service.getBossPresetId(2, 2)).toBe('celestial_deluge');
+ });
});
diff --git a/src/app/run/services/encounter.service.ts b/src/app/run/services/encounter.service.ts
index 6056f01e..255034df 100644
--- a/src/app/run/services/encounter.service.ts
+++ b/src/app/run/services/encounter.service.ts
@@ -75,6 +75,15 @@ export class EncounterService {
return this.waveGenerator.getBossPreset(actIndex, seed).name;
}
+ /**
+ * Returns the id of the boss preset selected for the given act and seed.
+ * Used by RunService.getFinalBossPresetId() to supply the epilogue
+ * copy variant for the Act-3 victory screen.
+ */
+ getBossPresetId(actIndex: number, seed: number): string {
+ return this.waveGenerator.getBossPreset(actIndex, seed).id;
+ }
+
// ── Private helpers ───────────────────────────────────────
/**
diff --git a/src/app/run/services/node-map-generator.service.spec.ts b/src/app/run/services/node-map-generator.service.spec.ts
index 9b1dbafb..fdc1c21a 100644
--- a/src/app/run/services/node-map-generator.service.spec.ts
+++ b/src/app/run/services/node-map-generator.service.spec.ts
@@ -1,7 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { NodeMapGeneratorService } from './node-map-generator.service';
import { NodeType } from '../models/node-map.model';
-import { NODE_MAP_CONFIG } from '../constants/run.constants';
+import { EXPECTED_EVENT_NODES_PER_ACT, NODE_MAP_CONFIG } from '../constants/run.constants';
import { QUALITATIVE_ASCENSION_VALUES } from '../models/ascension.model';
describe('NodeMapGeneratorService', () => {
@@ -184,20 +184,24 @@ describe('NodeMapGeneratorService', () => {
expect(eventCountA14).toBeLessThanOrEqual(eventCountA13);
});
- it('A14 EVENT_NODE_REDUCTION reduces event count by exactly eventNodeReduction (deterministic)', () => {
- // Seed 203 deterministically produces 10 EVENT nodes at A0 (no reduction)
- // and 9 EVENT nodes at A14 (eventNodeReduction = 1), verified via the
- // mulberry32 RNG used by NodeMapGeneratorService.
- const DETERMINISTIC_SEED = 203;
- const BASELINE_EVENT_COUNT = 10; // events at A0 for seed 203
- const mapBaseline = service.generateActMap(0, DETERMINISTIC_SEED, 0);
- const mapA14 = service.generateActMap(0, DETERMINISTIC_SEED, 14);
- const baselineEvents = mapBaseline.nodes.filter(n => n.type === NodeType.EVENT).length;
- const a14Events = mapA14.nodes.filter(n => n.type === NodeType.EVENT).length;
- // Confirm seed still produces the known baseline — if this fails, the RNG changed
- expect(baselineEvents).toBe(BASELINE_EVENT_COUNT);
- // A14 must be exactly baseline minus the reduction constant
- expect(a14Events).toBe(BASELINE_EVENT_COUNT - QUALITATIVE_ASCENSION_VALUES.eventNodeReduction);
+ it('A14 EVENT_NODE_REDUCTION caps event nodes to (EXPECTED_EVENT_NODES_PER_ACT - reduction)', () => {
+ // With EXPECTED_EVENT_NODES_PER_ACT=4 and eventNodeReduction=1 (A14), the cap
+ // is 3 event nodes per act. A0 has no cap and can produce significantly more.
+ // This verifies the cap is enforced, not just that count decreases by 1.
+ const seeds = [203, 42, 99, 500, 1000, 2000];
+ for (const seed of seeds) {
+ const mapA0 = service.generateActMap(0, seed, 0);
+ const mapA14 = service.generateActMap(0, seed, 14);
+ const a0Events = mapA0.nodes.filter(n => n.type === NodeType.EVENT).length;
+ const a14Events = mapA14.nodes.filter(n => n.type === NodeType.EVENT).length;
+ // A0 is uncapped — typically produces several events
+ // A14 must be at most (EXPECTED_EVENT_NODES_PER_ACT - eventNodeReduction)
+ const expectedCap = EXPECTED_EVENT_NODES_PER_ACT - QUALITATIVE_ASCENSION_VALUES.eventNodeReduction;
+ expect(a14Events).toBeLessThanOrEqual(expectedCap,
+ `seed ${seed}: A14 should cap at ${expectedCap} events, got ${a14Events}`);
+ // A0 should typically produce more events than the cap
+ expect(a0Events).toBeGreaterThanOrEqual(a14Events);
+ }
});
});
});
diff --git a/src/app/run/services/node-map-generator.service.ts b/src/app/run/services/node-map-generator.service.ts
index 89dc1679..4db737ec 100644
--- a/src/app/run/services/node-map-generator.service.ts
+++ b/src/app/run/services/node-map-generator.service.ts
@@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { MapNode, NodeMap, NodeType } from '../models/node-map.model';
import {
CAMPAIGN_MAP_TIERS,
+ EXPECTED_EVENT_NODES_PER_ACT,
NODE_MAP_CONFIG,
SeededRng,
createSeededRng,
@@ -177,8 +178,14 @@ export class NodeMapGeneratorService {
// Redistribute elite weight to combat when suppressed
const combatWeight = w.combat + (eliteAllowed ? 0 : (w.elite + eliteSpawnBonus));
- // Suppress event nodes once the reduction budget is exhausted
- const eventBudgetExceeded = eventNodeReduction > 0 && eventNodesPlaced >= (totalRows - eventNodeReduction);
+ // Suppress event nodes once the A14 reduction budget is exhausted.
+ // Without reduction the expected count is EXPECTED_EVENT_NODES_PER_ACT; with
+ // eventNodeReduction active the cap is lowered by that amount so the player
+ // encounters fewer events. The cap is absolute (not relative to totalRows)
+ // so it is reachable in practice.
+ const eventBudgetExceeded =
+ eventNodeReduction > 0 &&
+ eventNodesPlaced >= (EXPECTED_EVENT_NODES_PER_ACT - eventNodeReduction);
const eventWeight = eventBudgetExceeded ? 0 : w.event;
// On the last content row (immediately before the boss), REST and SHOP are
diff --git a/src/app/run/services/run.service.spec.ts b/src/app/run/services/run.service.spec.ts
index c35404f4..50452dac 100644
--- a/src/app/run/services/run.service.spec.ts
+++ b/src/app/run/services/run.service.spec.ts
@@ -143,6 +143,8 @@ describe('RunService', () => {
encounterService = jasmine.createSpyObj('EncounterService', [
'prepareEncounter',
'loadEncounterMap',
+ 'getBossPresetId',
+ 'getBossPresetName',
]);
relicService = jasmine.createSpyObj('RelicService', [
'clearRelics',
@@ -164,6 +166,8 @@ describe('RunService', () => {
nodeMapGenerator.generateActMap.and.returnValue(stubMap);
encounterService.prepareEncounter.and.returnValue(makeEncounterConfig());
encounterService.loadEncounterMap.and.stub();
+ encounterService.getBossPresetId.and.returnValue('vanguard_convergence');
+ encounterService.getBossPresetName.and.returnValue('Vanguard Convergence');
relicService.getAvailableRelics.and.returnValue([]);
persistence.hasSavedRun.and.returnValue(false);
persistence.getMaxAscension.and.returnValue(0);
@@ -719,6 +723,121 @@ describe('RunService', () => {
expect(deckService.getAllCards().length).toBe(cardsBefore);
}));
+ it('resolveEvent() with removeCard returns the removed card name', fakeAsync(() => {
+ service.startNewRun();
+ service.selectNode('node_1_0');
+
+ svc.currentEvent = {
+ id: 'card_purifier',
+ title: 'The Purifier',
+ description: 'Test',
+ choices: [
+ {
+ label: 'Remove a card',
+ description: 'Remove one card.',
+ outcome: { goldDelta: 0, livesDelta: 0, removeCard: true, description: 'Purged.' },
+ },
+ ],
+ };
+
+ const removedName = service.resolveEvent(0);
+ // The deck has starter cards — any of them may be selected, but the name must be a string
+ expect(typeof removedName).toBe('string');
+ expect((removedName as string).length).toBeGreaterThan(0);
+ }));
+
+ it('resolveEvent() without removeCard returns null', fakeAsync(() => {
+ service.startNewRun();
+ service.selectNode('node_1_0');
+
+ svc.currentEvent = {
+ id: 'test_event',
+ title: 'Test',
+ description: 'Test',
+ choices: [
+ {
+ label: 'No-op',
+ description: 'Nothing happens.',
+ outcome: { goldDelta: 0, livesDelta: 0, description: 'OK.' },
+ },
+ ],
+ };
+
+ const result = service.resolveEvent(0);
+ expect(result).toBeNull();
+ }));
+
+ it('resolveEvent() sets status to DEFEAT when livesDelta would drain all lives', fakeAsync(() => {
+ service.startNewRun();
+ service.selectNode('node_1_0');
+
+ // Use a livesDelta large enough to drain starting lives (DEFAULT_RUN_CONFIG.startingLives)
+ const livesToDrain = -(DEFAULT_RUN_CONFIG.startingLives + 1);
+
+ svc.currentEvent = {
+ id: 'cursed_idol_reckoning',
+ title: 'Cursed Idol',
+ description: 'Test',
+ choices: [
+ {
+ label: 'Accept',
+ description: 'Accept the curse.',
+ outcome: { goldDelta: 0, livesDelta: livesToDrain, description: 'You succumb.' },
+ },
+ ],
+ };
+
+ service.resolveEvent(0);
+ expect(service.runState!.status).toBe(RunStatus.DEFEAT);
+ expect(service.runState!.lives).toBe(0);
+ }));
+
+ it('previewEventCardRemoval() returns card name and stashes for resolveEvent()', fakeAsync(() => {
+ service.startNewRun();
+ service.selectNode('node_1_0');
+
+ svc.currentEvent = {
+ id: 'card_purifier',
+ title: 'The Purifier',
+ description: 'Test',
+ choices: [
+ {
+ label: 'Remove a card',
+ description: 'Remove one card.',
+ outcome: { goldDelta: 0, livesDelta: 0, removeCard: true, description: 'Purged.' },
+ },
+ ],
+ };
+
+ const preview = service.previewEventCardRemoval(0);
+ expect(typeof preview).toBe('string');
+ expect((preview as string).length).toBeGreaterThan(0);
+
+ // resolveEvent must reuse the stash — same card name returned
+ const resolved = service.resolveEvent(0);
+ expect(resolved).toBe(preview);
+ }));
+
+ it('previewEventCardRemoval() returns null for a non-removeCard choice', fakeAsync(() => {
+ service.startNewRun();
+ service.selectNode('node_1_0');
+
+ svc.currentEvent = {
+ id: 'test_event',
+ title: 'Test',
+ description: 'Test',
+ choices: [
+ {
+ label: 'No-op',
+ description: 'Nothing happens.',
+ outcome: { goldDelta: 0, livesDelta: 0, description: 'OK.' },
+ },
+ ],
+ };
+
+ expect(service.previewEventCardRemoval(0)).toBeNull();
+ }));
+
// ── gambling_den gamble mechanic ─────────────────────────────────
describe('gambling_den — gamble outcome', () => {
@@ -2976,4 +3095,76 @@ describe('RunService', () => {
expect(service.computeHealAmount(state)).toBe(6);
}));
});
+
+ // ── getFinalBossPresetId ──────────────────────────────────────
+
+ describe('getFinalBossPresetId()', () => {
+ it('returns empty string when no run is active', fakeAsync(() => {
+ expect(service.getFinalBossPresetId()).toBe('');
+ }));
+
+ it('delegates to EncounterService.getBossPresetId with the final act index', fakeAsync(() => {
+ service.startNewRun(0);
+ encounterService.getBossPresetId.and.returnValue('ironclad_march');
+ const id = service.getFinalBossPresetId();
+ // DEFAULT_RUN_CONFIG.actsCount = 3, so finalActIndex = 2
+ expect(encounterService.getBossPresetId).toHaveBeenCalledWith(
+ DEFAULT_RUN_CONFIG.actsCount - 1,
+ service.runState!.seed,
+ );
+ expect(id).toBe('ironclad_march');
+ }));
+
+ it('uses config.actsCount from the run state, not a hardcoded constant', fakeAsync(() => {
+ service.startNewRun(0);
+ // Override actsCount on the live state to verify derivation.
+ service['updateState']({ ...service.runState!, config: { ...service.runState!.config, actsCount: 2 } });
+ encounterService.getBossPresetId.and.returnValue('iron_tide');
+ service.getFinalBossPresetId();
+ expect(encounterService.getBossPresetId).toHaveBeenCalledWith(1, service.runState!.seed);
+ }));
+ });
+
+ // ── prepareEndlessEncounter ───────────────────────────────────
+
+ describe('prepareEndlessEncounter()', () => {
+ it('is a no-op when no run is active', fakeAsync(() => {
+ service.prepareEndlessEncounter();
+ expect(encounterService.loadEncounterMap).not.toHaveBeenCalled();
+ }));
+
+ it('loads a campaign map into EncounterService', fakeAsync(() => {
+ service.startNewRun(0);
+ service.prepareEndlessEncounter();
+ expect(encounterService.loadEncounterMap).toHaveBeenCalled();
+ }));
+
+ it('sets an endless EncounterConfig with isEndless true', fakeAsync(() => {
+ service.startNewRun(0);
+ service.prepareEndlessEncounter();
+ const config = service.getCurrentEncounter();
+ expect(config).not.toBeNull();
+ expect(config!.isEndless).toBeTrue();
+ }));
+
+ it('sets an empty wave list so endless mode drives all wave generation', fakeAsync(() => {
+ service.startNewRun(0);
+ service.prepareEndlessEncounter();
+ const config = service.getCurrentEncounter();
+ expect(config!.waves.length).toBe(0);
+ }));
+
+ it('reuses the last completed encounter map id when available', fakeAsync(() => {
+ service.startNewRun(0);
+ const node = service.nodeMap!.nodes[0];
+ service.prepareEncounter(node);
+ // Simulate returning from that encounter (sets lastCompletedEncounter)
+ service.recordEncounterResult(makeEncounterResult({ nodeId: node.id }));
+ service.consumePendingEncounterResult();
+
+ service.prepareEndlessEncounter();
+ const config = service.getCurrentEncounter();
+ expect(config!.campaignMapId).toBe('campaign_01');
+ }));
+ });
});
diff --git a/src/app/run/services/run.service.ts b/src/app/run/services/run.service.ts
index 5383b2cd..f507c9ce 100644
--- a/src/app/run/services/run.service.ts
+++ b/src/app/run/services/run.service.ts
@@ -36,6 +36,7 @@ import {
RUN_CONFIG,
SHOP_CONFIG,
UNKNOWN_NODE_REVEAL_THRESHOLDS,
+ CAMPAIGN_MAP_TIERS,
SeededRng,
createSeededRng,
} from '../constants/run.constants';
@@ -115,6 +116,13 @@ export class RunService {
*/
private pendingGambleRoll: { choiceIndex: number; goldDelta: number; livesDelta: number } | null = null;
+ /**
+ * Stash set by previewEventCardRemoval() so resolveEvent() can both reuse the
+ * same RNG position and display the card name before confirming.
+ * Cleared after resolveEvent() consumes it (or when the choice has no removeCard).
+ */
+ private pendingRemovedCard: { choiceIndex: number; cardName: string | null; instanceId: string | null } | null = null;
+
/** RNG seeded per-run, advanced by each random action. */
private runRng: SeededRng | null = null;
@@ -201,6 +209,20 @@ export class RunService {
return this.encounterService.getBossPresetName(actIndex, seed);
}
+ /**
+ * Returns the BossPreset id for the final act of the current run.
+ * Used by RunComponent to bind [bossPresetId] on the epilogue screen so
+ * the correct debrief variant is shown. Derives the final act index from
+ * the run config so it stays correct if actsCount ever changes.
+ * Returns '' when no run state exists (safe fallback → neutral epilogue copy).
+ */
+ getFinalBossPresetId(): string {
+ const state = this.runState;
+ if (!state) return '';
+ const finalActIndex = state.config.actsCount - 1;
+ return this.encounterService.getBossPresetId(finalActIndex, state.seed);
+ }
+
getCurrentEncounter(): EncounterConfig | null {
return this.currentEncounter;
}
@@ -240,6 +262,44 @@ export class RunService {
return { goldDelta, livesDelta };
}
+ /**
+ * Preview which card would be removed by a removeCard outcome for the given choice.
+ * Uses the same seeded-RNG selection logic as removeRandomNonStarterCard() and
+ * stashes the result so resolveEvent() can reuse it without a second RNG advance.
+ * Returns the card's display name, or null when the deck is empty or the choice
+ * has no removeCard outcome.
+ */
+ previewEventCardRemoval(choiceIndex: number): string | null {
+ const event = this.currentEvent;
+ if (!event || choiceIndex < 0 || choiceIndex >= event.choices.length) {
+ this.pendingRemovedCard = null;
+ return null;
+ }
+ const outcome = event.choices[choiceIndex].outcome;
+ if (!outcome.removeCard) {
+ this.pendingRemovedCard = null;
+ return null;
+ }
+
+ const allCards = this.deckService.getAllCards();
+ const nonStarters = allCards.filter(c => {
+ const def = CARD_DEFINITIONS[c.cardId as CardId];
+ return def && def.rarity !== CardRarity.STARTER;
+ });
+ const pool = nonStarters.length > 0 ? nonStarters : allCards;
+ if (pool.length === 0) {
+ this.pendingRemovedCard = { choiceIndex, cardName: null, instanceId: null };
+ return null;
+ }
+
+ const rng = this.getRng();
+ const index = Math.floor(rng() * pool.length);
+ const selected = pool[index];
+ const cardName = CARD_DEFINITIONS[selected.cardId as CardId]?.name ?? null;
+ this.pendingRemovedCard = { choiceIndex, cardName, instanceId: selected.instanceId };
+ return cardName;
+ }
+
getRngState(): number | null {
return this.runRng?.getState() ?? null;
}
@@ -284,6 +344,8 @@ export class RunService {
// (guards against re-using stale state if a previous run ended mid-flight)
this.currentEncounter = null;
this.pendingResult = null;
+ this.pendingGambleRoll = null;
+ this.pendingRemovedCard = null;
this.shopService.clearShopItems();
this.currentEvent = null;
this.runRng = null;
@@ -387,6 +449,45 @@ export class RunService {
this.eventBus.emit(RunEventType.ENCOUNTER_START, { nodeId: node.id });
}
+ /**
+ * Prepare an endless post-victory encounter.
+ *
+ * Reuses the last completed encounter's campaign map so the board layout
+ * is consistent with the final boss fight the player just won. Falls back
+ * to the last map in the final-act tier when lastCompletedEncounter is
+ * unavailable. Sets isEndless on the EncounterConfig so
+ * EncounterBootstrapService enables endless mode before wave 1 starts.
+ *
+ * The run stays IN_PROGRESS (isInRun() remains true) throughout endless so
+ * the game-board's single code path continues to function without branching.
+ * Callers must navigate to /play after this returns.
+ */
+ prepareEndlessEncounter(): void {
+ const state = this.runState;
+ if (!state) return;
+
+ const finalActMaps = CAMPAIGN_MAP_TIERS['act3_late'] ?? CAMPAIGN_MAP_TIERS['act2_late'] ?? [];
+ const fallbackMapId = finalActMaps[finalActMaps.length - 1] ?? 'campaign_16';
+ const mapId = this.lastCompletedEncounter?.campaignMapId ?? fallbackMapId;
+
+ const endlessEncounter = {
+ nodeId: 'endless',
+ nodeType: NodeType.COMBAT,
+ campaignMapId: mapId,
+ waves: [],
+ goldReward: 0,
+ isElite: false,
+ isBoss: false,
+ isEndless: true,
+ };
+
+ this.encounterService.loadEncounterMap(endlessEncounter);
+ this.currentEncounter = endlessEncounter;
+ this.relicService.setActiveRelics(state.relicIds);
+ this.relicService.resetEncounterState();
+ this.eventBus.emit(RunEventType.ENCOUNTER_START, { nodeId: 'endless' });
+ }
+
/**
* Restore a checkpointed encounter instead of preparing from scratch.
* Called by RunComponent when the selected node has a saved checkpoint.
@@ -866,12 +967,12 @@ export class RunService {
return true;
}
- /** Resolve an event choice by index. */
- resolveEvent(choiceIndex: number): void {
+ /** Resolve an event choice by index. Returns the removed card name when the outcome removes a card; null otherwise. */
+ resolveEvent(choiceIndex: number): string | null {
const state = this.runState;
const event = this.currentEvent;
- if (!state || !event) return;
- if (choiceIndex < 0 || choiceIndex >= event.choices.length) return;
+ if (!state || !event) return null;
+ if (choiceIndex < 0 || choiceIndex >= event.choices.length) return null;
const outcome = event.choices[choiceIndex].outcome;
@@ -911,12 +1012,30 @@ export class RunService {
}
// Card removal: remove a random non-starter card from the deck.
+ // When previewEventCardRemoval() ran first for this choice, reuse its stash so
+ // the displayed card name and the actual removal target are identical.
// Derive updated deckCardIds from the live deck after removal so the
// persisted list stays in sync; mirrors the removeCardFromShop pattern.
let updatedDeckCardIds: CardId[] | undefined;
+ let removedCardName: string | null = null;
if (outcome.removeCard) {
- this.removeRandomNonStarterCard();
+ if (this.pendingRemovedCard && this.pendingRemovedCard.choiceIndex === choiceIndex) {
+ // Reuse stash: the RNG already advanced during preview; remove the same card.
+ const stashedInstanceId = this.pendingRemovedCard.instanceId;
+ removedCardName = this.pendingRemovedCard.cardName;
+ this.pendingRemovedCard = null;
+ if (stashedInstanceId) {
+ this.deckService.removeCard(stashedInstanceId);
+ } else {
+ this.removeRandomNonStarterCard();
+ }
+ } else {
+ this.pendingRemovedCard = null;
+ removedCardName = this.removeRandomNonStarterCard();
+ }
updatedDeckCardIds = this.deckService.getAllCards().map(c => c.cardId);
+ } else {
+ this.pendingRemovedCard = null;
}
// Item reward: add to consumable inventory.
@@ -958,13 +1077,15 @@ export class RunService {
this.relicService.setActiveRelics(newRelicIds);
this.currentEvent = null;
this.persist();
+ return removedCardName;
}
/**
* Remove a random non-starter card from the deck.
- * If no non-starter cards exist, removes the last card in the deck as a fallback.
+ * Falls back to the last card when no non-starter cards exist.
+ * Returns the display name of the removed card, or null when the deck was empty.
*/
- private removeRandomNonStarterCard(): void {
+ private removeRandomNonStarterCard(): string | null {
const allCards = this.deckService.getAllCards();
const nonStarters = allCards.filter(c => {
const def = CARD_DEFINITIONS[c.cardId as CardId];
@@ -972,11 +1093,13 @@ export class RunService {
});
const pool = nonStarters.length > 0 ? nonStarters : allCards;
- if (pool.length === 0) return;
+ if (pool.length === 0) return null;
const rng = this.getRng();
const index = Math.floor(rng() * pool.length);
- this.deckService.removeCard(pool[index].instanceId);
+ const removed = pool[index];
+ this.deckService.removeCard(removed.instanceId);
+ return CARD_DEFINITIONS[removed.cardId as CardId]?.name ?? null;
}
/** Generate a random event for the current node. */
@@ -1224,6 +1347,8 @@ export class RunService {
this.persistence.clearSavedRun();
this.currentEncounter = null;
this.pendingResult = null;
+ this.pendingGambleRoll = null;
+ this.pendingRemovedCard = null;
this.shopService.clearShopItems();
this.currentEvent = null;
this.runRng = null;
diff --git a/src/styles/_game-hud.scss b/src/styles/_game-hud.scss
index 6536c2cd..d16d9055 100644
--- a/src/styles/_game-hud.scss
+++ b/src/styles/_game-hud.scss
@@ -109,6 +109,10 @@
}
}
+body.reduce-motion .board-container .hud-wave-status--critical {
+ animation: none;
+}
+
.board-container .hud-wave-status .wave-template-badge {
font-size: 0.6rem;
color: var(--theme-purple);
diff --git a/src/styles/_game-tutorial.scss b/src/styles/_game-tutorial.scss
index 32ebbb27..2643a1fb 100644
--- a/src/styles/_game-tutorial.scss
+++ b/src/styles/_game-tutorial.scss
@@ -139,12 +139,15 @@ body .tutorial-target-highlight {
animation: tutorial-pulse 1.5s ease-in-out infinite;
}
-/* Fix 7: Respect prefers-reduced-motion */
+/* Respect both OS-level and in-app reduced-motion preferences. */
@media (prefers-reduced-motion: reduce) {
body .tutorial-target-highlight { animation: none; }
.tutorial-card { animation: none; }
}
+body.reduce-motion .tutorial-target-highlight { animation: none; }
+body.reduce-motion .tutorial-card { animation: none; }
+
.tutorial-card__step-indicator {
position: absolute;
top: 0.5rem;