diff --git a/src/app/app.component.html b/src/app/app.component.html index 08a1ab7d..f39ca804 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,6 +1,6 @@ diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 26123c3d..aabc6df7 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -32,15 +32,14 @@ describe('AppComponent', () => { expect(app.title).toEqual('Novarise'); }); - it('renders Home and Editor nav links (non-dev build)', () => { + it('renders Home nav link only in a non-dev build (Editor and Library are dev-gated)', () => { (environment as { enableDevTools: boolean }).enableDevTools = false; const fixture = TestBed.createComponent(AppComponent); fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; const navLinks = compiled.querySelectorAll('.app-nav a'); - expect(navLinks.length).toBe(2); + expect(navLinks.length).toBe(1); expect(navLinks[0].textContent).toContain('Home'); - expect(navLinks[1].textContent).toContain('Editor'); const cog = compiled.querySelector('.settings-cog'); expect(cog).toBeTruthy(); expect(cog!.getAttribute('aria-label')).toBe('Settings'); diff --git a/src/app/core/constants/music.constants.ts b/src/app/core/constants/music.constants.ts index ffb16a9f..a1e41781 100644 --- a/src/app/core/constants/music.constants.ts +++ b/src/app/core/constants/music.constants.ts @@ -54,6 +54,14 @@ export const SCHEDULE_INTERVAL_MS = 200; /** How far ahead the scheduler books beats, in seconds. */ export const SCHEDULE_HORIZON_SECONDS = 0.5; +/** + * If the scheduler falls further behind real time than this (e.g. the tab was + * backgrounded and setInterval was throttled), snap the next beat forward to + * "now" instead of booking every missed beat at once — otherwise all the + * missed beats fire in a single burst when the tab regains focus. + */ +export const MAX_SCHEDULER_CATCHUP_SECONDS = 0.5; + /** * Default master music volume (0–1). * @@ -103,6 +111,20 @@ export const PLUCK_NOTE_DURATION_SECONDS = 0.2; /** Volume ramp-up time for a pluck note, in seconds. */ export const PLUCK_ATTACK_SECONDS = 0.02; +/** + * Bass note attack, in seconds. The bass drone starts each bar at silence and + * ramps up over this window so the note onset does not click. + */ +export const BASS_ATTACK_SECONDS = 0.02; + +/** + * Bass note release, in seconds. When the next bar replaces the bass drone, the + * outgoing note ramps to silence over this window starting at the bar boundary + * (rather than being hard-stopped on the scheduler's clock), which removes the + * per-bar click and the early cut-off caused by the lookahead scheduler. + */ +export const BASS_RELEASE_SECONDS = 0.08; + // ── HUB theme — calm, sparse ───────────────────────────────────────────────── const HUB_PAD: PadLayerConfig = { diff --git a/src/app/core/services/music.service.ts b/src/app/core/services/music.service.ts index ba1c715d..458a70fc 100644 --- a/src/app/core/services/music.service.ts +++ b/src/app/core/services/music.service.ts @@ -7,10 +7,13 @@ import { MUSIC_GAIN_EPSILON, SCHEDULE_INTERVAL_MS, SCHEDULE_HORIZON_SECONDS, + MAX_SCHEDULER_CATCHUP_SECONDS, PAD_FADE_IN_SECONDS, PAD_FADE_OUT_SECONDS, PLUCK_NOTE_DURATION_SECONDS, PLUCK_ATTACK_SECONDS, + BASS_ATTACK_SECONDS, + BASS_RELEASE_SECONDS, COMPRESSOR_THRESHOLD_DB, COMPRESSOR_KNEE_DB, COMPRESSOR_RATIO, @@ -133,21 +136,50 @@ export class MusicService { this.masterGain.gain.setValueAtTime(0, now); } - // Stop all active theme schedulers + // Stop scheduling new beats immediately, then tear down the oscillator + // nodes. On a fade-out the teardown is deferred until the fade completes so + // the still-sounding voices fade with the master rather than hard-stopping + // (which would click); on an instant stop the master is already at 0 so the + // nodes can be stopped right away. Leaving the nodes running (the previous + // behaviour) made them re-emerge audibly when the next theme restored the + // master gain. const themes = Object.keys(this.themeStates) as MusicTheme[]; for (const theme of themes) { + const state = this.themeStates[theme]; + if (!state) continue; this.stopThemeScheduler(theme); + if (fadeDuration > MUSIC_GAIN_EPSILON) { + this.scheduleDeferredTeardown(theme, state, fadeDuration * 1000); + } else { + this.stopThemeNodes(theme); + } } this.currentTheme = null; this.pendingTheme = null; } + /** + * Tear down a theme's nodes after `delayMs`, but only if that exact ThemeState + * is still the active one — a rapid round-trip back to the theme may have + * already replaced it via startTheme(), and that newer instance must survive. + */ + private scheduleDeferredTeardown(theme: MusicTheme, state: ThemeState, delayMs: number): void { + setTimeout(() => { + if (this.themeStates[theme] !== state) return; + this.stopThemeScheduler(theme); + this.stopThemeNodes(theme); + }, delayMs); + } + /** Update master music volume (0–1, clamped). */ setMusicVolume(volume: number): void { this.musicVolume = Math.min(1, Math.max(0, volume)); if (this.masterGain && this.audioContext) { const now = this.audioContext.currentTime; + // Clear any in-flight fade so the new level holds instead of resuming the + // old ramp toward its previous destination. + this.masterGain.gain.cancelScheduledValues(now); this.masterGain.gain.setValueAtTime(this.musicVolume, now); } } @@ -253,16 +285,16 @@ export class MusicService { if (oldState) { oldState.gainNode.gain.setValueAtTime(oldState.gainNode.gain.value, now); oldState.gainNode.gain.linearRampToValueAtTime(MUSIC_GAIN_EPSILON, now + fadeDuration); - // Stop old scheduler after the fade - const stopDelay = fadeDuration * 1000; - setTimeout(() => { - this.stopThemeScheduler(oldTheme); - this.stopThemeNodes(oldTheme); - }, stopDelay); + // Tear the old theme down after the fade — guarded so a rapid round-trip + // back to this same theme (which restarts it) is not torn down by this timer. + this.scheduleDeferredTeardown(oldTheme, oldState, fadeDuration * 1000); } } - // Ensure master gain is at current volume (may have been ramped to 0 by stopMusic) + // Restore master to current volume. cancelScheduledValues first clears any + // pending stopMusic() fade-to-zero ramp; otherwise that ramp keeps running + // and silences the theme we are about to start. + this.masterGain.gain.cancelScheduledValues(now); this.masterGain.gain.setValueAtTime(this.musicVolume, now); // Start new theme @@ -344,7 +376,16 @@ export class MusicService { const state = this.themeStates[theme]; if (!state) return; - const horizon = this.audioContext.currentTime + SCHEDULE_HORIZON_SECONDS; + const nowTime = this.audioContext.currentTime; + + // If the scheduler slipped far behind real time (backgrounded tab throttles + // setInterval), snap forward to now rather than booking every missed beat — + // otherwise they all fire at once as a burst when the tab regains focus. + if (state.nextBeatTime < nowTime - MAX_SCHEDULER_CATCHUP_SECONDS) { + state.nextBeatTime = nowTime; + } + + const horizon = nowTime + SCHEDULE_HORIZON_SECONDS; while (state.nextBeatTime < horizon) { this.scheduleBeat(theme, state, state.nextBeatTime); @@ -477,13 +518,19 @@ export class MusicService { try { osc.stop(stopTime); } catch { /* already stopped */ } } try { pad.lfoOscillator.stop(stopTime); } catch { /* already stopped */ } + + // Disconnect the layer's nodes once the fade completes so a long session + // does not accumulate stopped-but-connected nodes on the audio graph. + pad.lfoOscillator.onended = (): void => { this.stopPadLayer(pad); }; } private stopPadLayer(pad: ActivePadLayer): void { for (const osc of pad.oscillators) { try { osc.stop(); } catch { /* already stopped */ } + try { osc.disconnect(); } catch { /* already disconnected */ } } try { pad.lfoOscillator.stop(); } catch { /* already stopped */ } + try { pad.lfoOscillator.disconnect(); } catch { /* already disconnected */ } try { pad.gainNode.disconnect(); } catch { /* already disconnected */ } try { pad.lfoGain.disconnect(); } catch { /* already disconnected */ } try { pad.filter.disconnect(); } catch { /* already disconnected */ } @@ -500,9 +547,13 @@ export class MusicService { if (this.audioContext === null) return; const config = state.config; - // Stop previous bass + // Release the previous bass at the bar boundary with a short fade. A hard + // stop here clicks, and because the scheduler books beats up to + // SCHEDULE_HORIZON_SECONDS ahead, an immediate stop also cuts the still- + // sounding note off early. Fading at `startTime` keeps it click-free and + // musically aligned. for (const bass of state.bassLayers) { - this.stopBassLayer(bass); + this.fadeBassOut(bass, startTime); } state.bassLayers = []; @@ -521,8 +572,10 @@ export class MusicService { osc.type = 'sine'; osc.frequency.setValueAtTime(bassHz, startTime); + // Attack ramp from silence avoids the onset click of an instant full-gain start. const gainNode = ctx.createGain(); - gainNode.gain.setValueAtTime(config.bassLayer.gain, startTime); + gainNode.gain.setValueAtTime(MUSIC_GAIN_EPSILON, startTime); + gainNode.gain.linearRampToValueAtTime(config.bassLayer.gain, startTime + BASS_ATTACK_SECONDS); osc.connect(gainNode); gainNode.connect(state.gainNode); @@ -534,6 +587,27 @@ export class MusicService { void theme; } + /** + * Fade a bass note to silence over BASS_RELEASE_SECONDS starting at `atTime` + * (the bar boundary), then stop and disconnect its nodes once the fade ends. + * Used for the per-bar bass transition; `stopBassLayer` remains the immediate + * teardown path used on theme stop / cleanup. + */ + private fadeBassOut(bass: ActiveBassLayer, atTime: number): void { + const ctx = this.audioContext; + if (!ctx) return; + + bass.gainNode.gain.setValueAtTime(bass.gainNode.gain.value, atTime); + bass.gainNode.gain.linearRampToValueAtTime(MUSIC_GAIN_EPSILON, atTime + BASS_RELEASE_SECONDS); + + const stopTime = atTime + BASS_RELEASE_SECONDS + 0.02; + try { bass.oscillator.stop(stopTime); } catch { /* already stopped */ } + bass.oscillator.onended = (): void => { + try { bass.oscillator.disconnect(); } catch { /* already disconnected */ } + try { bass.gainNode.disconnect(); } catch { /* already disconnected */ } + }; + } + private stopBassLayer(bass: ActiveBassLayer): void { try { bass.oscillator.stop(); } catch { /* already stopped */ } try { bass.gainNode.disconnect(); } catch { /* already disconnected */ } @@ -572,7 +646,9 @@ export class MusicService { config.pluckLayer.gain, beatTime + PLUCK_ATTACK_SECONDS, ); - gainNode.gain.linearRampToValueAtTime( + // Exponential decay from the (positive) peak reads as a more natural pluck + // tail than a linear ramp, which holds too much energy near the end. + gainNode.gain.exponentialRampToValueAtTime( MUSIC_GAIN_EPSILON, beatTime + PLUCK_NOTE_DURATION_SECONDS, ); @@ -581,6 +657,10 @@ export class MusicService { gainNode.connect(state.gainNode); osc.start(beatTime); osc.stop(beatTime + PLUCK_NOTE_DURATION_SECONDS + 0.02); + osc.onended = (): void => { + try { osc.disconnect(); } catch { /* already disconnected */ } + try { gainNode.disconnect(); } catch { /* already disconnected */ } + }; } // ── Private: Utility ───────────────────────────────────────────────────── diff --git a/src/app/core/services/settings.service.spec.ts b/src/app/core/services/settings.service.spec.ts index b4a221da..acf0a1a4 100644 --- a/src/app/core/services/settings.service.spec.ts +++ b/src/app/core/services/settings.service.spec.ts @@ -119,4 +119,59 @@ describe('SettingsService', () => { const fresh = service.get(); expect(fresh.audioMuted).toBe(false); // original unaffected }); + + // ── applyReduceMotion ───────────────────────────────────────────────── + + describe('applyReduceMotion', () => { + afterEach(() => { + document.body.classList.remove('reduce-motion'); + }); + + it('adds reduce-motion class to body when enabled', () => { + service.applyReduceMotion(true); + expect(document.body.classList.contains('reduce-motion')).toBeTrue(); + }); + + it('removes reduce-motion class from body when disabled', () => { + document.body.classList.add('reduce-motion'); + service.applyReduceMotion(false); + expect(document.body.classList.contains('reduce-motion')).toBeFalse(); + }); + + it('applies reduce-motion class on construction when saved setting is true', () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + audioMuted: false, musicEnabled: true, musicVolume: 0.5, + difficulty: 'normal', showFps: false, reduceMotion: true, + colorblindAssist: false, fontScale: 1, + })); + const fresh = new SettingsService(new StorageService()); + expect(fresh.get().reduceMotion).toBeTrue(); + expect(document.body.classList.contains('reduce-motion')).toBeTrue(); + }); + + it('does not add reduce-motion class on construction when saved setting is false', () => { + document.body.classList.remove('reduce-motion'); + const fresh = new SettingsService(new StorageService()); + expect(fresh.get().reduceMotion).toBeFalse(); + expect(document.body.classList.contains('reduce-motion')).toBeFalse(); + }); + + it('toggles body class when update changes reduceMotion to true', () => { + expect(document.body.classList.contains('reduce-motion')).toBeFalse(); + service.update({ reduceMotion: true }); + expect(document.body.classList.contains('reduce-motion')).toBeTrue(); + }); + + it('removes body class when update changes reduceMotion to false', () => { + service.update({ reduceMotion: true }); + service.update({ reduceMotion: false }); + expect(document.body.classList.contains('reduce-motion')).toBeFalse(); + }); + + it('does not alter reduce-motion class when update does not touch reduceMotion', () => { + document.body.classList.remove('reduce-motion'); + service.update({ audioMuted: true }); + expect(document.body.classList.contains('reduce-motion')).toBeFalse(); + }); + }); }); diff --git a/src/app/core/services/settings.service.ts b/src/app/core/services/settings.service.ts index b4e3f051..45562230 100644 --- a/src/app/core/services/settings.service.ts +++ b/src/app/core/services/settings.service.ts @@ -33,6 +33,7 @@ export class SettingsService { constructor(private storageService: StorageService) { this.settings = this.load(); this.applyFontScale(this.settings.fontScale); + this.applyReduceMotion(this.settings.reduceMotion); } /** @@ -49,12 +50,32 @@ export class SettingsService { if (cls) html.classList.add(cls); } + /** + * Toggle the `reduce-motion` class on so CSS animation-suppression + * rules take effect immediately — regardless of whether the OS-level + * `prefers-reduced-motion` media query is set. + * Called on boot and whenever the user changes the setting. + */ + applyReduceMotion(enabled: boolean): void { + if (enabled) { + document.body.classList.add('reduce-motion'); + } else { + document.body.classList.remove('reduce-motion'); + } + } + get(): GameSettings { return { ...this.settings }; } update(partial: Partial): void { this.settings = { ...this.settings, ...partial }; + if (partial.fontScale !== undefined) { + this.applyFontScale(this.settings.fontScale); + } + if (partial.reduceMotion !== undefined) { + this.applyReduceMotion(this.settings.reduceMotion); + } this.save(); } diff --git a/src/app/core/services/tutorial.service.spec.ts b/src/app/core/services/tutorial.service.spec.ts index 6808c39a..139af3cb 100644 --- a/src/app/core/services/tutorial.service.spec.ts +++ b/src/app/core/services/tutorial.service.spec.ts @@ -699,4 +699,18 @@ describe('TutorialService', () => { }); }); }); + + describe('SELECT_TOWER tip content', () => { + it('SELECT_TOWER message mentions that gold does not reset between encounters', () => { + const tip = service.getTip(TutorialStep.SELECT_TOWER); + // The message should explain run-persistent gold so new players understand + // the unified gold model (gold carries forward into shops, rest sites, next fight). + expect(tip.message).toContain('does NOT reset between encounters'); + }); + + it('SELECT_TOWER tip targets the energy UI element', () => { + const tip = service.getTip(TutorialStep.SELECT_TOWER); + expect(tip.targetSelector).toBe('.card-hand__energy'); + }); + }); }); diff --git a/src/app/core/services/tutorial.service.ts b/src/app/core/services/tutorial.service.ts index f5311b6f..8d35b2f4 100644 --- a/src/app/core/services/tutorial.service.ts +++ b/src/app/core/services/tutorial.service.ts @@ -60,7 +60,7 @@ const TUTORIAL_TIPS: Record = { step: TutorialStep.SELECT_TOWER, type: 'tutorial', title: 'Your Hand & Energy', - message: 'Your cards appear at the bottom of the screen. Tower cards have a dual cost — energy (shown top-left of the hand) AND gold. Check your gold bar before committing a tower; you refill energy each turn but gold must be earned.', + message: 'Your cards appear at the bottom of the screen. Tower cards have a dual cost — energy (shown top-left of the hand) AND gold. You refill energy each turn, but gold does NOT reset between encounters — it carries forward into shops, rest sites, and the next fight, so spend carefully.', targetSelector: '.card-hand__energy', position: 'bottom', }, diff --git a/src/app/game/game-board/components/card-hand/card-hand.component.spec.ts b/src/app/game/game-board/components/card-hand/card-hand.component.spec.ts index 2eaa14f2..a3c49a47 100644 --- a/src/app/game/game-board/components/card-hand/card-hand.component.spec.ts +++ b/src/app/game/game-board/components/card-hand/card-hand.component.spec.ts @@ -1488,4 +1488,56 @@ describe('CardHandComponent', () => { expect(thumbnail).toBeNull(); }); }); + + describe('isHandStuck', () => { + it('returns false when energy is not set', () => { + (component as unknown as { energy: EnergyState | null }).energy = null; + component.deckState = makeDeckState([makeInstance(CardId.TOWER_BASIC)]); + component.resolveHand(); + expect(component.isHandStuck).toBeFalse(); + }); + + it('returns false when hand is empty', () => { + component.energy = makeEnergy(0, 3); + component.deckState = makeDeckState([]); + component.resolveHand(); + expect(component.isHandStuck).toBeFalse(); + }); + + it('returns false when at least one card is playable (energy only)', () => { + component.energy = makeEnergy(1, 3); + component.currentGold = 999; + component.deckState = makeDeckState([makeInstance(CardId.GOLD_RUSH)]); + component.resolveHand(); + expect(component.isHandStuck).toBeFalse(); + }); + + it('returns true when energy is 0 and no card is playable (classic case)', () => { + // TOWER_MORTAR costs 3 energy — cannot play with 0 energy + component.energy = makeEnergy(0, 3); + component.currentGold = 999; + component.deckState = makeDeckState([makeInstance(CardId.TOWER_MORTAR)]); + component.resolveHand(); + expect(component.isHandStuck).toBeTrue(); + }); + + it('returns true when energy > 0 but all tower cards are blocked by insufficient gold', () => { + // TOWER_BASIC costs gold — with 0 gold it cannot be played even though energy is available. + // This is the post-gold-unification case the original condition missed. + component.energy = makeEnergy(3, 3); + component.currentGold = 0; + component.deckState = makeDeckState([makeInstance(CardId.TOWER_BASIC)]); + component.resolveHand(); + expect(component.isHandStuck).toBeTrue(); + }); + + it('returns false when energy > 0 and a non-tower card can be played even with 0 gold', () => { + // GOLD_RUSH is a spell — goldCost null, always playable if energy suffices + component.energy = makeEnergy(1, 3); + component.currentGold = 0; + component.deckState = makeDeckState([makeInstance(CardId.GOLD_RUSH)]); + component.resolveHand(); + expect(component.isHandStuck).toBeFalse(); + }); + }); }); diff --git a/src/app/game/game-board/components/card-hand/card-hand.component.ts b/src/app/game/game-board/components/card-hand/card-hand.component.ts index 6786418d..6a0fc9e8 100644 --- a/src/app/game/game-board/components/card-hand/card-hand.component.ts +++ b/src/app/game/game-board/components/card-hand/card-hand.component.ts @@ -534,11 +534,15 @@ export class CardHandComponent implements OnInit, OnChanges, OnDestroy { } } - // Sprint 39 — true when the player has no energy and no playable cards + /** + * True when the player has cards in hand but none are currently playable. + * Triggers the End Turn nudge pulse so the player is cued to advance the turn. + * Fires on energy exhaustion AND on gold exhaustion (tower cards blocked by + * insufficient gold — the most common case after the unified run-gold model). + */ get isHandStuck(): boolean { if (!this.energy || !this.handCards.length) return false; - const anyPlayable = this.handCards.some(c => c.canPlay); - return !anyPlayable && this.energy.current === 0; + return !this.handCards.some(c => c.canPlay); } /** diff --git a/src/app/game/game-board/components/game-hud/game-hud.component.html b/src/app/game/game-board/components/game-hud/game-hud.component.html index 4d967ce4..e38312a2 100644 --- a/src/app/game/game-board/components/game-hud/game-hud.component.html +++ b/src/app/game/game-board/components/game-hud/game-hud.component.html @@ -8,6 +8,7 @@ Gold {{ gold }} +{{ goldChange }}g + {{ goldChange }}g
Wave diff --git a/src/app/game/game-board/components/game-hud/game-hud.component.spec.ts b/src/app/game/game-board/components/game-hud/game-hud.component.spec.ts index 3da8222c..5ecb3227 100644 --- a/src/app/game/game-board/components/game-hud/game-hud.component.spec.ts +++ b/src/app/game/game-board/components/game-hud/game-hud.component.spec.ts @@ -451,7 +451,7 @@ describe('GameHudComponent', () => { tick(300); })); - it('should not set goldChange when gold decreases', fakeAsync(() => { + it('should set goldChange to negative delta when gold decreases (tower purchase)', fakeAsync(() => { // Seed previousGold to 200 component.ngOnChanges({ gold: new SimpleChange(0, 200, false), @@ -460,7 +460,7 @@ describe('GameHudComponent', () => { component.ngOnChanges({ gold: new SimpleChange(200, 100, false), }); - expect(component.goldChange).toBe(0); + expect(component.goldChange).toBe(-100); tick(300); })); @@ -536,6 +536,31 @@ describe('GameHudComponent', () => { const changeEl = fixture.nativeElement.querySelector('.gold-change'); expect(changeEl).toBeNull(); }); + + it('should render negative gold-change span when goldChange is negative', () => { + component.goldChange = -75; + fixture.detectChanges(); + + const changeEl = fixture.nativeElement.querySelector('.gold-change--loss'); + expect(changeEl).toBeTruthy(); + expect(changeEl.textContent.trim()).toBe('-75g'); + }); + + it('should not render gold-change--loss span when goldChange is 0', () => { + component.goldChange = 0; + fixture.detectChanges(); + + const lossEl = fixture.nativeElement.querySelector('.gold-change--loss'); + expect(lossEl).toBeNull(); + }); + + it('should not render gold-change--loss span when goldChange is positive', () => { + component.goldChange = 50; + fixture.detectChanges(); + + const lossEl = fixture.nativeElement.querySelector('.gold-change--loss'); + expect(lossEl).toBeNull(); + }); }); describe('ngOnDestroy cleanup', () => { diff --git a/src/app/game/game-board/components/game-hud/game-hud.component.ts b/src/app/game/game-board/components/game-hud/game-hud.component.ts index e23574fd..ed6762cf 100644 --- a/src/app/game/game-board/components/game-hud/game-hud.component.ts +++ b/src/app/game/game-board/components/game-hud/game-hud.component.ts @@ -61,16 +61,16 @@ export class GameHudComponent implements OnChanges, OnDestroy { const delta = newGold - this.previousGold; if (delta !== 0) { this.triggerGoldPulse(); - if (delta > 0) { - this.goldChange = delta; - if (this.goldChangeTimer !== null) { - clearTimeout(this.goldChangeTimer); - } - this.goldChangeTimer = setTimeout(() => { - this.goldChange = 0; - this.goldChangeTimer = null; - }, PULSE_DURATION_MS); + // Show positive and negative deltas so players see both gold earned and + // gold spent (e.g. tower purchase cost) reflected in the HUD. + this.goldChange = delta; + if (this.goldChangeTimer !== null) { + clearTimeout(this.goldChangeTimer); } + this.goldChangeTimer = setTimeout(() => { + this.goldChange = 0; + this.goldChangeTimer = null; + }, PULSE_DURATION_MS); } this.previousGold = newGold; } diff --git a/src/app/game/game-board/components/last-turn-summary/last-turn-summary.component.scss b/src/app/game/game-board/components/last-turn-summary/last-turn-summary.component.scss index cf085deb..13bf39da 100644 --- a/src/app/game/game-board/components/last-turn-summary/last-turn-summary.component.scss +++ b/src/app/game/game-board/components/last-turn-summary/last-turn-summary.component.scss @@ -251,6 +251,9 @@ .last-turn-summary__row { transition: none; } } +body.reduce-motion .last-turn-summary__detail { animation: none; } +body.reduce-motion .last-turn-summary__row { transition: none; } + @media (max-width: 768px) { .last-turn-summary { display: none; diff --git a/src/app/game/game-board/constants/audio.constants.ts b/src/app/game/game-board/constants/audio.constants.ts index cb4580f0..88277645 100644 --- a/src/app/game/game-board/constants/audio.constants.ts +++ b/src/app/game/game-board/constants/audio.constants.ts @@ -22,6 +22,33 @@ export function isSfxSequenceConfig(cfg: SfxConfigEntry): cfg is SfxSequenceConf return 'notes' in cfg; } +/** + * Master limiter (DynamicsCompressorNode) sitting between the SFX master gain + * and the destination. Without it, simultaneous combat voices (tower fire + + * enemy hits + death noise) sum past 0 dBFS and clip, which is heard as + * crackle. Values mirror the music master limiter so both buses share a ceiling. + */ +export const SFX_LIMITER_THRESHOLD_DB = -10; +export const SFX_LIMITER_KNEE_DB = 30; +export const SFX_LIMITER_RATIO = 12; +export const SFX_LIMITER_ATTACK_SECONDS = 0.003; +export const SFX_LIMITER_RELEASE_SECONDS = 0.25; + +/** + * Short linear fade-in applied to the start of every SFX envelope. Jumping from + * 0 to full gain in a single sample clicks on onset; ramping over a few + * milliseconds removes the click while staying imperceptibly fast. Clamped to + * never exceed half the note duration so very short SFX still attack cleanly. + */ +export const SFX_ATTACK_SECONDS = 0.005; + +/** + * Ramp time for master-gain changes (volume slider, mute toggle). Writing + * masterGain.gain.value directly steps the level in a single sample and clicks + * on any voice that is mid-playback; a short ramp removes the click. + */ +export const MASTER_GAIN_RAMP_SECONDS = 0.015; + export const SFX_CONFIGS: Record = { // High-frequency electric zap for chain lightning tower chainZap: { diff --git a/src/app/game/game-board/constants/effects.constants.ts b/src/app/game/game-board/constants/effects.constants.ts index a5506c30..e4db4795 100644 --- a/src/app/game/game-board/constants/effects.constants.ts +++ b/src/app/game/game-board/constants/effects.constants.ts @@ -53,6 +53,12 @@ export const SCREEN_SHAKE_CONFIG = { /** Per-life additional duration so big leaks linger longer. */ lifeLossPerLifeDuration: 0.05, lifeLossMaxDuration: 0.8, + /** Climactic shake when NOVA_SOVEREIGN dies — larger than a standard boss kill. */ + novaSovereignDeathIntensity: 0.55, + novaSovereignDeathDuration: 0.9, + /** Wyrm boss death shake — stronger than generic boss, distinguished from NOVA_SOVEREIGN. */ + wyrmAscendantDeathIntensity: 0.35, + wyrmAscendantDeathDuration: 0.6, } as const; export const GOLD_POPUP_CONFIG = { @@ -243,6 +249,26 @@ export const NOVA_SOVEREIGN_VISUAL_CONFIG = { orbYOffsetMultiplier: 0.5, } as const; +/** + * Per-ring Y-axis rotation speeds for NOVA_SOVEREIGN's three orbiting shard rings. + * The three values are intentionally distinct so the rings drift apart over time, + * creating a parallax effect that reads as dynamic motion rather than rigid rotation. + * Units: radians per second. + */ +export const NOVA_SOVEREIGN_ORB_SPIN_SPEEDS = [1.2, 0.75, -0.95] as const; + +/** + * Enrage visual override for NOVA_SOVEREIGN when health drops below 50%. + * Overrides the body emissive to a bright orange-red so the phase shift reads + * immediately without relying on text/UI feedback. + */ +export const NOVA_SOVEREIGN_ENRAGE_VISUAL = { + /** Emissive hex color applied to the body mesh while enraged. */ + emissiveColor: 0xff4400, + /** emissiveIntensity while enraged — noticeably brighter than the idle value. */ + emissiveIntensity: 2.2, +} as const; + /** Per-tower-type projectile appearance — color, emissive, scale, and emissive intensity. * CHAIN and SLOW are omitted: CHAIN uses zigzag arc visuals, SLOW has no projectile. */ export const PROJECTILE_VISUAL_CONFIG: Partial {{ waveCombat.bossBanner.text }} +
+ {{ waveCombat.bossBanner.subtext }} +
diff --git a/src/app/game/game-board/game-board.component.scss b/src/app/game/game-board/game-board.component.scss index 73f30679..1e1e7d0a 100644 --- a/src/app/game/game-board/game-board.component.scss +++ b/src/app/game/game-board/game-board.component.scss @@ -399,14 +399,18 @@ .enemy-dot[data-type='basic'] { background: #dd6600; box-shadow: none; border-radius: 50%; } .enemy-dot[data-type='fast'] { background: #f5dd00; box-shadow: none; border-radius: 2px; transform: rotate(45deg); } .enemy-dot[data-type='heavy'] { background: #2244cc; box-shadow: none; border-radius: 2px; } - .enemy-dot[data-type='swift'] { background: #f0f0f0; box-shadow: none; border-radius: 50%; } + // swift: diamond (rotated square) — distinct from circle types; matches the + // fast/titan visual language (speed) without reusing the same color. + .enemy-dot[data-type='swift'] { background: #f0f0f0; box-shadow: none; border-radius: 2px; transform: rotate(45deg); } .enemy-dot[data-type='boss'] { background: #0099ee; box-shadow: none; clip-path: polygon(50% 0%, 100% 100%, 0% 100%); } .enemy-dot[data-type='shielded'] { background: #6699ff; box-shadow: none; border-radius: 1px; width: 0.7rem; } .enemy-dot[data-type='swarm'] { background: #ffcc00; box-shadow: none; border-radius: 50%; } .enemy-dot[data-type='flying'] { background: #aaccff; box-shadow: none; border-radius: 3px; } .enemy-dot[data-type='miner'] { background: #bb7722; box-shadow: none; border-radius: 50%; } .enemy-dot[data-type='unshakeable'] { background: #888888; box-shadow: none; border-radius: 2px; } - .enemy-dot[data-type='veinseeker'] { background: #cc9966; box-shadow: none; border-radius: 50%; } + // veinseeker: triangle (clip-path) — high-threat shape matching boss/wyrm/nova; + // distinguishes it from the circle group (basic/swarm/miner). + .enemy-dot[data-type='veinseeker'] { background: #cc9966; box-shadow: none; clip-path: polygon(50% 0%, 100% 100%, 0% 100%); border-radius: 0; } .enemy-dot[data-type='glider'] { background: #ddeeff; box-shadow: none; border-radius: 3px; } .enemy-dot[data-type='titan'] { background: #ff8822; box-shadow: none; border-radius: 2px; transform: rotate(45deg); } .enemy-dot[data-type='wyrm_ascendant'] { background: #1133aa; box-shadow: none; clip-path: polygon(50% 0%, 100% 100%, 0% 100%); } @@ -722,6 +726,11 @@ } } +body.reduce-motion .board-container .turn-start-banner { + animation: none; + opacity: 1; +} + body.reduce-motion .board-container .path-blocked-warning { animation: none; } body.reduce-motion .board-container .context-lost-hint { animation: none; } body.reduce-motion .board-container .end-turn-btn--stuck { animation: none; } @@ -754,6 +763,17 @@ body.reduce-motion .board-container .end-turn-btn--stuck { animation: none; } 0 0 3rem rgba(255, 100, 0, 0.2); white-space: nowrap; animation: boss-banner-flash 2.8s ease-out forwards; + + .boss-banner-subtext { + margin-top: var(--spacing-xs); + font-size: var(--font-size-xs); + font-weight: 400; + letter-spacing: 0.06em; + color: rgba(255, 200, 0, 0.65); + text-transform: none; + white-space: normal; + text-align: center; + } } @keyframes boss-banner-flash { diff --git a/src/app/game/game-board/game-board.component.spec.ts b/src/app/game/game-board/game-board.component.spec.ts index 9158d44f..b318df12 100644 --- a/src/app/game/game-board/game-board.component.spec.ts +++ b/src/app/game/game-board/game-board.component.spec.ts @@ -2185,6 +2185,47 @@ describe('GameBoardComponent', () => { expect(challengeTrackingSpy.recordTowerSold).toHaveBeenCalled(); }); + + it('sellTower shows a refund notification with the gold amount returned', () => { + const gameStateService = fixture.debugElement.injector.get(GameStateService); + gameStateService.startWave(); + + const towerCombatService = fixture.debugElement.injector.get(TowerCombatService); + const mockSoldTower: PlacedTower = { + id: '2-2', + type: TowerType.BASIC, + level: 1, + row: 2, + col: 2, + kills: 0, + totalInvested: 100, + mesh: null, + targetingMode: TargetingMode.NEAREST, + }; + spyOn(towerCombatService, 'unregisterTower').and.returnValue(mockSoldTower); + + const gameBoardSvc = fixture.debugElement.injector.get(GameBoardService); + spyOn(gameBoardSvc, 'removeTower'); + const enemyService = fixture.debugElement.injector.get(EnemyService); + spyOn(enemyService, 'repathAffectedEnemies'); + spyOn(component as unknown as TestableGameBoardComponent, 'deselectTower'); + spyOn(component as unknown as TestableGameBoardComponent, 'updateTileHighlights'); + spyOn(component as unknown as TestableGameBoardComponent, 'refreshPathOverlay'); + + const notificationService = fixture.debugElement.injector.get(GameNotificationService); + const showSpy = spyOn(notificationService, 'show'); + + (component as unknown as TestableGameBoardComponent).selectedTowerInfo = mockSoldTower; + component.sellConfirmPending = true; + + component.sellTower(); + + // A notification should have been shown with the refund amount in its body. + expect(showSpy).toHaveBeenCalled(); + const args = showSpy.calls.mostRecent().args; + expect(args[1]).toBe('Tower sold'); + expect(args[2]).toContain('g'); + }); }); describe('toggleModifier — state divergence guard', () => { diff --git a/src/app/game/game-board/game-board.component.ts b/src/app/game/game-board/game-board.component.ts index 753bea0c..5009e7f9 100644 --- a/src/app/game/game-board/game-board.component.ts +++ b/src/app/game/game-board/game-board.component.ts @@ -1096,6 +1096,11 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { this.audioService.playTowerSell(); this.gameStatsService.recordTowerSold(); + this.notificationService.show( + NotificationType.INFO, + 'Tower sold', + `Refunded +${result.refundAmount}g`, + ); // Remove mesh via lifecycle service with animated=true so the tower // plays a shrink-and-fade before disposal (see SELL_ANIM_CONFIG). diff --git a/src/app/game/game-board/models/enemy.model.ts b/src/app/game/game-board/models/enemy.model.ts index 9abcc881..3e615d67 100644 --- a/src/app/game/game-board/models/enemy.model.ts +++ b/src/app/game/game-board/models/enemy.model.ts @@ -46,6 +46,14 @@ export interface Enemy { isEnraged?: boolean; /** Tiles-per-turn override applied when NOVA_SOVEREIGN enrages. */ enragedTilesPerTurn?: number; + /** + * Fractional-movement accumulator for SLOW interactions on 1-tile-per-turn enemies. + * Each turn, effectiveTilesPerTurn (a fractional value when SLOW is active) is added + * here. The enemy moves floor(accumulator) tiles and the remainder carries forward. + * Initializes to 0 and persists across turns; not serialized (resets to 0 on resume, + * which advances the enemy at most 1 extra tile on the first slowed turn — acceptable). + */ + slowMoveAccumulator?: number; } export interface EnemyStats { @@ -230,6 +238,15 @@ export const TITAN_STATS = { */ export const SWIFT_LEAK_DAMAGE = 2; +/** + * Minimum effective tiles-per-turn below which the fractional-movement + * accumulator activates for 1-tile-per-turn enemies under SLOW effects. + * When effectiveTilesPerTurn equals this boundary (exactly 1.0), the enemy + * moves exactly 1 tile each turn and no accumulator is needed. + * The accumulator fires only when a SLOW effect reduces the effective rate below 1.0. + */ +export const SLOW_ACCUMULATOR_ACTIVATION_THRESHOLD = 1.0; + // Enemy type statistics export const ENEMY_STATS: Record = { [EnemyType.BASIC]: { diff --git a/src/app/game/game-board/models/game-state.model.ts b/src/app/game/game-board/models/game-state.model.ts index 95103a28..26e87abf 100644 --- a/src/app/game/game-board/models/game-state.model.ts +++ b/src/app/game/game-board/models/game-state.model.ts @@ -93,6 +93,15 @@ export const INTEREST_CONFIG = { /** Gold bonus per consecutive leak-free wave (e.g., 3rd streak = 3 * 25 = 75g). */ export const STREAK_BONUS_PER_WAVE = 25; +/** + * Maximum streak bonus that can be awarded for any single wave. + * Without a cap the per-wave payout grows unbounded (streak × 25g), which + * compounds permanently under the unified gold model and can trivialise + * late-encounter economy. Capped at 150g (6-wave perfect streak equivalent) + * so skill is meaningfully rewarded without snowballing into runaway wealth. + */ +export const MAX_STREAK_BONUS_PER_WAVE = 150; + export const INITIAL_GAME_STATE: GameState = { phase: GamePhase.SETUP, wave: 0, diff --git a/src/app/game/game-board/services/audio.service.ts b/src/app/game/game-board/services/audio.service.ts index 6c98fac9..f0d52e20 100644 --- a/src/app/game/game-board/services/audio.service.ts +++ b/src/app/game/game-board/services/audio.service.ts @@ -1,12 +1,24 @@ import { Injectable } from '@angular/core'; import { TowerType } from '../models/tower.model'; -import { AUDIO_CONFIG, SFX_CONFIGS, isSfxSequenceConfig } from '../constants/audio.constants'; +import { + AUDIO_CONFIG, + SFX_CONFIGS, + isSfxSequenceConfig, + SFX_LIMITER_THRESHOLD_DB, + SFX_LIMITER_KNEE_DB, + SFX_LIMITER_RATIO, + SFX_LIMITER_ATTACK_SECONDS, + SFX_LIMITER_RELEASE_SECONDS, + SFX_ATTACK_SECONDS, + MASTER_GAIN_RAMP_SECONDS, +} from '../constants/audio.constants'; import { SettingsService } from '@core/services/settings.service'; @Injectable() export class AudioService { private audioContext: AudioContext | null = null; private masterGain: GainNode | null = null; + private limiter: DynamicsCompressorNode | null = null; private _volume = AUDIO_CONFIG.masterVolume; private lastEnemyHitTime = -Infinity; private towerFiresThisFrame = 0; @@ -31,10 +43,22 @@ export class AudioService { this.audioContext = new AudioContext(); this.masterGain = this.audioContext.createGain(); this.masterGain.gain.value = this.isMuted ? 0 : this._volume; - this.masterGain.connect(this.audioContext.destination); + + // Soft limiter on the SFX bus: masterGain → limiter → destination. + // Keeps simultaneous combat voices from summing past 0 dBFS and clipping. + this.limiter = this.audioContext.createDynamicsCompressor(); + this.limiter.threshold.value = SFX_LIMITER_THRESHOLD_DB; + this.limiter.knee.value = SFX_LIMITER_KNEE_DB; + this.limiter.ratio.value = SFX_LIMITER_RATIO; + this.limiter.attack.value = SFX_LIMITER_ATTACK_SECONDS; + this.limiter.release.value = SFX_LIMITER_RELEASE_SECONDS; + + this.masterGain.connect(this.limiter); + this.limiter.connect(this.audioContext.destination); } catch { this.audioContext = null; this.masterGain = null; + this.limiter = null; } return this.audioContext; @@ -67,8 +91,13 @@ export class AudioService { osc.frequency.setValueAtTime(frequency, ctx.currentTime + startDelay); osc.frequency.exponentialRampToValueAtTime(endFrequency, ctx.currentTime + startDelay + duration); - envGain.gain.setValueAtTime(gain, ctx.currentTime + startDelay); - envGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + startDelay + duration); + // Click-free envelope: a few-ms linear attack avoids the onset pop of an + // instantaneous gain jump, then an exponential decay to near-silence. + const startAt = ctx.currentTime + startDelay; + const attack = Math.min(SFX_ATTACK_SECONDS, duration * 0.5); + envGain.gain.setValueAtTime(0, startAt); + envGain.gain.linearRampToValueAtTime(gain, startAt + attack); + envGain.gain.exponentialRampToValueAtTime(0.001, startAt + duration); osc.connect(envGain); envGain.connect(this.masterGain); @@ -102,8 +131,11 @@ export class AudioService { source.buffer = buffer; const envGain = ctx.createGain(); - envGain.gain.setValueAtTime(gain, ctx.currentTime + startDelay); - envGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + startDelay + duration); + const startAt = ctx.currentTime + startDelay; + const attack = Math.min(SFX_ATTACK_SECONDS, duration * 0.5); + envGain.gain.setValueAtTime(0, startAt); + envGain.gain.linearRampToValueAtTime(gain, startAt + attack); + envGain.gain.exponentialRampToValueAtTime(0.001, startAt + duration); source.connect(envGain); envGain.connect(this.masterGain); @@ -268,26 +300,39 @@ export class AudioService { setVolume(volume: number): void { this._volume = Math.max(0, Math.min(1, volume)); - if (this.masterGain && !this.isMuted) { - this.masterGain.gain.value = this._volume; + if (!this.isMuted) { + this.rampMasterGain(this._volume); } } toggleMute(): void { const next = !this.isMuted; this.settingsService.update({ audioMuted: next }); - if (this.masterGain) { - this.masterGain.gain.value = next ? 0 : this._volume; - } + this.rampMasterGain(next ? 0 : this._volume); + } + + /** + * Ramp the master gain to `target` over a few ms instead of writing + * `.gain.value` directly — a direct write steps the level in one sample and + * clicks on any SFX voice that is mid-playback. + */ + private rampMasterGain(target: number): void { + if (!this.masterGain || !this.audioContext) return; + const now = this.audioContext.currentTime; + this.masterGain.gain.cancelScheduledValues(now); + this.masterGain.gain.setValueAtTime(this.masterGain.gain.value, now); + this.masterGain.gain.linearRampToValueAtTime(target, now + MASTER_GAIN_RAMP_SECONDS); } // --- Lifecycle --- cleanup(): void { if (this.audioContext) { + try { this.limiter?.disconnect(); } catch { /* already disconnected */ } this.audioContext.close().catch(() => {}); this.audioContext = null; this.masterGain = null; + this.limiter = null; } } } diff --git a/src/app/game/game-board/services/boss-banner.service.spec.ts b/src/app/game/game-board/services/boss-banner.service.spec.ts index b572f98f..f7175be7 100644 --- a/src/app/game/game-board/services/boss-banner.service.spec.ts +++ b/src/app/game/game-board/services/boss-banner.service.spec.ts @@ -1,6 +1,6 @@ -import { TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { BossBannerService } from './boss-banner.service'; -import { UI_CONFIG } from '../constants/ui.constants'; +import { UI_CONFIG, BOSS_BANNER_COPY } from '../constants/ui.constants'; describe('BossBannerService', () => { let service: BossBannerService; @@ -26,44 +26,103 @@ describe('BossBannerService', () => { expect(service.text).toBe(''); }); + it('subtext is empty by default', () => { + expect(service.subtext).toBe(''); + }); + it('flash() makes showBanner true and sets text', fakeAsync(() => { - service.flash('⚠ BOSS INCOMING'); + service.flash(BOSS_BANNER_COPY.generic.headline); expect(service.showBanner).toBeTrue(); - expect(service.text).toBe('⚠ BOSS INCOMING'); + expect(service.text).toBe(BOSS_BANNER_COPY.generic.headline); tick(UI_CONFIG.bossBannerVisibleMs); })); - it('flash() auto-hides after bossBannerVisibleMs', fakeAsync(() => { - service.flash('⚠ BOSS INCOMING'); + it('flash() auto-hides after bossBannerVisibleMs when no duration override', fakeAsync(() => { + service.flash(BOSS_BANNER_COPY.generic.headline); + expect(service.showBanner).toBeTrue(); + tick(UI_CONFIG.bossBannerVisibleMs); + expect(service.showBanner).toBeFalse(); + })); + + it('flash() with subtext option stores subtext', fakeAsync(() => { + service.flash(BOSS_BANNER_COPY.novaSovereign.headline, { + subtext: BOSS_BANNER_COPY.novaSovereign.subtext, + }); + expect(service.text).toBe(BOSS_BANNER_COPY.novaSovereign.headline); + expect(service.subtext).toBe(BOSS_BANNER_COPY.novaSovereign.subtext); + tick(UI_CONFIG.bossBannerExtendedMs); + })); + + it('flash() with durationMs override hides after the custom duration', fakeAsync(() => { + service.flash(BOSS_BANNER_COPY.novaSovereign.headline, { + durationMs: UI_CONFIG.bossBannerExtendedMs, + }); expect(service.showBanner).toBeTrue(); + // Still visible before extended duration tick(UI_CONFIG.bossBannerVisibleMs); + expect(service.showBanner).toBeTrue(); + // Hidden after extended duration + tick(UI_CONFIG.bossBannerExtendedMs - UI_CONFIG.bossBannerVisibleMs); expect(service.showBanner).toBeFalse(); })); - it('calling flash() again resets the timer and updates text', fakeAsync(() => { - service.flash('⚠ BOSS INCOMING'); + it('calling flash() again resets the timer and updates text and subtext', fakeAsync(() => { + service.flash(BOSS_BANNER_COPY.generic.headline); tick(UI_CONFIG.bossBannerVisibleMs - 100); - service.flash('⚠ THE SOVEREIGN MANIFESTS'); - expect(service.text).toBe('⚠ THE SOVEREIGN MANIFESTS'); + service.flash(BOSS_BANNER_COPY.novaSovereign.headline, { + subtext: BOSS_BANNER_COPY.novaSovereign.subtext, + durationMs: UI_CONFIG.bossBannerExtendedMs, + }); + expect(service.text).toBe(BOSS_BANNER_COPY.novaSovereign.headline); + expect(service.subtext).toBe(BOSS_BANNER_COPY.novaSovereign.subtext); expect(service.showBanner).toBeTrue(); - // Original timer is cancelled; new full duration must pass before hide - tick(UI_CONFIG.bossBannerVisibleMs - 1); + // Original timer is cancelled; new extended duration must pass before hide + tick(UI_CONFIG.bossBannerExtendedMs - 1); expect(service.showBanner).toBeTrue(); tick(1); expect(service.showBanner).toBeFalse(); })); - it('cleanup() clears the timer and hides the banner', fakeAsync(() => { - service.flash('⚠ BOSS INCOMING'); + it('cleanup() clears the timer, hides the banner, and clears subtext', fakeAsync(() => { + service.flash(BOSS_BANNER_COPY.wyrmAscendant.headline, { + subtext: BOSS_BANNER_COPY.wyrmAscendant.subtext, + durationMs: UI_CONFIG.bossBannerExtendedMs, + }); service.cleanup(); expect(service.showBanner).toBeFalse(); expect(service.text).toBe(''); - // No pending timer left — tick should not error - tick(UI_CONFIG.bossBannerVisibleMs); + expect(service.subtext).toBe(''); + tick(UI_CONFIG.bossBannerExtendedMs); })); it('cleanup() is idempotent when called with no active timer', () => { expect(() => service.cleanup()).not.toThrow(); expect(() => service.cleanup()).not.toThrow(); }); + + describe('BOSS_BANNER_COPY constants', () => { + it('novaSovereign has a non-empty headline', () => { + expect(BOSS_BANNER_COPY.novaSovereign.headline.length).toBeGreaterThan(0); + }); + + it('novaSovereign has a mechanic subtext line', () => { + expect(BOSS_BANNER_COPY.novaSovereign.subtext.length).toBeGreaterThan(0); + }); + + it('wyrmAscendant has a non-empty headline', () => { + expect(BOSS_BANNER_COPY.wyrmAscendant.headline.length).toBeGreaterThan(0); + }); + + it('wyrmAscendant has a mechanic subtext line', () => { + expect(BOSS_BANNER_COPY.wyrmAscendant.subtext.length).toBeGreaterThan(0); + }); + + it('generic has an empty subtext (headline-only boss)', () => { + expect(BOSS_BANNER_COPY.generic.subtext).toBe(''); + }); + + it('bossBannerExtendedMs is greater than bossBannerVisibleMs', () => { + expect(UI_CONFIG.bossBannerExtendedMs).toBeGreaterThan(UI_CONFIG.bossBannerVisibleMs); + }); + }); }); diff --git a/src/app/game/game-board/services/boss-banner.service.ts b/src/app/game/game-board/services/boss-banner.service.ts index 482f5d0a..9aca6894 100644 --- a/src/app/game/game-board/services/boss-banner.service.ts +++ b/src/app/game/game-board/services/boss-banner.service.ts @@ -1,6 +1,21 @@ import { Injectable } from '@angular/core'; import { UI_CONFIG } from '../constants/ui.constants'; +/** Options accepted by {@link BossBannerService.flash}. */ +export interface BossBannerOptions { + /** + * Optional mechanic-hint line rendered beneath the headline. + * Leave empty string or omit to show the headline only. + */ + subtext?: string; + /** + * Override the auto-hide duration in ms. + * Defaults to `UI_CONFIG.bossBannerVisibleMs` when omitted. + * Use `UI_CONFIG.bossBannerExtendedMs` for named bosses with subtext. + */ + durationMs?: number; +} + /** * BossBannerService — owns the boss-intro banner that fires when a boss-tier * wave begins. @@ -14,6 +29,7 @@ import { UI_CONFIG } from '../constants/ui.constants'; export class BossBannerService { private visible = false; private bannerText = ''; + private bannerSubtext = ''; private timer: ReturnType | null = null; /** Read-only flag bound by the game-board template. */ @@ -21,26 +37,36 @@ export class BossBannerService { return this.visible; } - /** Copy line shown inside the banner. */ + /** Headline copy line shown inside the banner. */ get text(): string { return this.bannerText; } /** - * Show the boss banner with the given copy for BOSS_BANNER_VISIBLE_MS. + * Optional mechanic-hint line shown beneath the headline. + * Empty string when the current banner has no subtext. + */ + get subtext(): string { + return this.bannerSubtext; + } + + /** + * Show the boss banner with the given headline and optional options. * Calling again while the banner is already visible resets the timer and - * updates the text (handles back-to-back waves in the same encounter). + * updates the copy (handles back-to-back waves in the same encounter). */ - flash(text: string): void { - this.bannerText = text; + flash(headline: string, options: BossBannerOptions = {}): void { + this.bannerText = headline; + this.bannerSubtext = options.subtext ?? ''; if (this.timer !== null) { clearTimeout(this.timer); } this.visible = true; + const durationMs = options.durationMs ?? UI_CONFIG.bossBannerVisibleMs; this.timer = setTimeout(() => { this.visible = false; this.timer = null; - }, UI_CONFIG.bossBannerVisibleMs); + }, durationMs); } /** Clears any pending hide timer and hides the banner. Idempotent. */ @@ -51,5 +77,6 @@ export class BossBannerService { } this.visible = false; this.bannerText = ''; + this.bannerSubtext = ''; } } diff --git a/src/app/game/game-board/services/checkpoint-restore-coordinator.service.spec.ts b/src/app/game/game-board/services/checkpoint-restore-coordinator.service.spec.ts index dc33313d..b406dda9 100644 --- a/src/app/game/game-board/services/checkpoint-restore-coordinator.service.spec.ts +++ b/src/app/game/game-board/services/checkpoint-restore-coordinator.service.spec.ts @@ -135,7 +135,7 @@ describe('CheckpointRestoreCoordinatorService', () => { challengeTrackingSpy = jasmine.createSpyObj( 'ChallengeTrackingService', ['restoreFromCheckpoint'], ); - gameSessionSpy = jasmine.createSpyObj('GameSessionService', ['resetAllServices']); + gameSessionSpy = jasmine.createSpyObj('GameSessionService', ['resetAllServices', 'cleanupScene']); meshRegistrySpy = jasmine.createSpyObj( 'BoardMeshRegistryService', ['translateTileMesh', 'rebuildTowerChildrenArray'], @@ -230,18 +230,30 @@ describe('CheckpointRestoreCoordinatorService', () => { expect(spawnPreviewSpy.refreshFor).not.toHaveBeenCalled(); }); - it('falls back via gameSession reset + onFallback when restore throws', () => { + it('falls back via cleanupScene + gameSession reset + onFallback when restore throws', () => { encounterCheckpointSpy.loadCheckpoint.and.returnValue(makeCheckpoint()); pathMutationSpy.restore.and.throwError('boom'); spyOn(console, 'error'); const onFallback = jasmine.createSpy('onFallback'); service.restore({ onFallback }); + expect(gameSessionSpy.cleanupScene).toHaveBeenCalled(); expect(gameSessionSpy.resetAllServices).toHaveBeenCalled(); expect(onFallback).toHaveBeenCalledTimes(1); expect(runSpy.isRestoringCheckpoint).toBe(false); expect(encounterCheckpointSpy.clearCheckpoint).toHaveBeenCalled(); }); + it('calls cleanupScene before resetAllServices in the error path (ordering guard)', () => { + encounterCheckpointSpy.loadCheckpoint.and.returnValue(makeCheckpoint()); + pathMutationSpy.restore.and.throwError('boom'); + spyOn(console, 'error'); + const order: string[] = []; + gameSessionSpy.cleanupScene.and.callFake(() => { order.push('cleanupScene'); }); + gameSessionSpy.resetAllServices.and.callFake(() => { order.push('resetAllServices'); }); + service.restore({ onFallback: () => {} }); + expect(order).toEqual(['cleanupScene', 'resetAllServices']); + }); + it('rebuilds tower graph and restores graph overlay (Step 4.5 + 4.6)', () => { encounterCheckpointSpy.loadCheckpoint.and.returnValue(makeCheckpoint()); const order: string[] = []; diff --git a/src/app/game/game-board/services/checkpoint-restore-coordinator.service.ts b/src/app/game/game-board/services/checkpoint-restore-coordinator.service.ts index b303d8e7..14294f2f 100644 --- a/src/app/game/game-board/services/checkpoint-restore-coordinator.service.ts +++ b/src/app/game/game-board/services/checkpoint-restore-coordinator.service.ts @@ -369,7 +369,11 @@ export class CheckpointRestoreCoordinatorService { console.error('Failed to restore checkpoint, falling back to fresh encounter:', error); this.runService.isRestoringCheckpoint = false; this.encounterCheckpointService.clearCheckpoint(); - // Clean up any partial restore state before starting fresh + // Dispose any Three.js tower/enemy meshes that were added to the scene before + // the throw (e.g. Steps 4/6 completed before a later step failed). Without this + // call, partial-restore meshes leak into the next encounter's scene. + this.gameSessionService.cleanupScene(); + // Reset all service state after mesh disposal so services start clean. this.gameSessionService.resetAllServices(this.sceneService.getScene()); options.onFallback(); } diff --git a/src/app/game/game-board/services/combat-loop.service.spec.ts b/src/app/game/game-board/services/combat-loop.service.spec.ts index 576c5682..0de5b157 100644 --- a/src/app/game/game-board/services/combat-loop.service.spec.ts +++ b/src/app/game/game-board/services/combat-loop.service.spec.ts @@ -1290,7 +1290,7 @@ describe('CombatLoopService', () => { ); }); - it('triggers screen shake with bossHitIntensity when a NOVA_SOVEREIGN is killed', () => { + it('triggers climactic screen shake with novaSovereignDeathIntensity when NOVA_SOVEREIGN is killed', () => { const nova = makeEnemy({ id: 'nova1', type: EnemyType.NOVA_SOVEREIGN, value: 100 }); enemySpy.getEnemies.and.returnValue(new Map([['nova1', nova]])); combatSpy.fireTurn.and.returnValue({ @@ -1301,8 +1301,24 @@ describe('CombatLoopService', () => { service.resolveTurn(scene); expect(screenShakeSpy.trigger).toHaveBeenCalledWith( - SCREEN_SHAKE_CONFIG.bossHitIntensity, - SCREEN_SHAKE_CONFIG.bossHitDuration, + SCREEN_SHAKE_CONFIG.novaSovereignDeathIntensity, + SCREEN_SHAKE_CONFIG.novaSovereignDeathDuration, + ); + }); + + it('triggers wyrmAscendantDeathIntensity screen shake when WYRM_ASCENDANT is killed', () => { + const wyrm = makeEnemy({ id: 'wyrm1', type: EnemyType.WYRM_ASCENDANT, value: 120 }); + enemySpy.getEnemies.and.returnValue(new Map([['wyrm1', wyrm]])); + combatSpy.fireTurn.and.returnValue({ + killed: [{ id: 'wyrm1', damage: 200, towerType: TowerType.BASIC, towerLevel: 1 }], + fired: [], hitCount: 1, damageDealt: 200, + }); + + service.resolveTurn(scene); + + expect(screenShakeSpy.trigger).toHaveBeenCalledWith( + SCREEN_SHAKE_CONFIG.wyrmAscendantDeathIntensity, + SCREEN_SHAKE_CONFIG.wyrmAscendantDeathDuration, ); }); @@ -1384,6 +1400,20 @@ describe('CombatLoopService', () => { expect(result.kills[0].isBoss).toBe(true); }); + it('sets isBoss=true on a WYRM_ASCENDANT kill', () => { + const wyrm = makeEnemy({ id: 'wyrm1', type: EnemyType.WYRM_ASCENDANT, value: 120 }); + enemySpy.getEnemies.and.returnValue(new Map([['wyrm1', wyrm]])); + combatSpy.fireTurn.and.returnValue({ + killed: [{ id: 'wyrm1', damage: 200, towerType: TowerType.BASIC, towerLevel: 1 }], + fired: [], hitCount: 1, damageDealt: 200, + }); + + const result = service.resolveTurn(scene); + + expect(result.kills.length).toBe(1); + expect(result.kills[0].isBoss).toBe(true); + }); + it('sets isBoss=false (or undefined) on a non-boss kill', () => { const basic = makeEnemy({ id: 'e1', type: EnemyType.BASIC, value: 10 }); enemySpy.getEnemies.and.returnValue(new Map([['e1', basic]])); diff --git a/src/app/game/game-board/services/combat-loop.service.ts b/src/app/game/game-board/services/combat-loop.service.ts index ed68b378..d5f1a1b0 100644 --- a/src/app/game/game-board/services/combat-loop.service.ts +++ b/src/app/game/game-board/services/combat-loop.service.ts @@ -489,16 +489,30 @@ export class CombatLoopService { this.luckyCoinBonusGoldThisTurn += Math.round(adjustedGold - adjustedGold / luckyCoinMult); } - if (enemy.type === EnemyType.BOSS || enemy.type === EnemyType.NOVA_SOVEREIGN) { - // Boss-kill shake: fires on the kill event since CombatLoopService has no - // per-hit granularity (damage is applied instantaneously per turn). + if (enemy.type === EnemyType.NOVA_SOVEREIGN) { + // Climactic final-boss death: larger shake and extended duration to mark + // the end of the Act 3 encounter distinctly from generic boss kills. + this.screenShakeService.trigger( + SCREEN_SHAKE_CONFIG.novaSovereignDeathIntensity, + SCREEN_SHAKE_CONFIG.novaSovereignDeathDuration, + ); + } else if (enemy.type === EnemyType.WYRM_ASCENDANT) { + // Wyrm boss death: stronger than generic boss, distinct from NOVA_SOVEREIGN. + this.screenShakeService.trigger( + SCREEN_SHAKE_CONFIG.wyrmAscendantDeathIntensity, + SCREEN_SHAKE_CONFIG.wyrmAscendantDeathDuration, + ); + } else if (enemy.type === EnemyType.BOSS) { + // Generic boss kill shake. this.screenShakeService.trigger( SCREEN_SHAKE_CONFIG.bossHitIntensity, SCREEN_SHAKE_CONFIG.bossHitDuration, ); } - const isBossKill = enemy.type === EnemyType.BOSS || enemy.type === EnemyType.NOVA_SOVEREIGN; + const isBossKill = enemy.type === EnemyType.BOSS + || enemy.type === EnemyType.NOVA_SOVEREIGN + || enemy.type === EnemyType.WYRM_ASCENDANT; this.frameKills.push({ damage: killInfo.damage, position: { ...enemy.position }, diff --git a/src/app/game/game-board/services/encounter-bootstrap.service.spec.ts b/src/app/game/game-board/services/encounter-bootstrap.service.spec.ts index 7aef52e5..a84b7de3 100644 --- a/src/app/game/game-board/services/encounter-bootstrap.service.spec.ts +++ b/src/app/game/game-board/services/encounter-bootstrap.service.spec.ts @@ -14,6 +14,7 @@ import { DeckService } from '../../../run/services/deck.service'; import { RunService } from '../../../run/services/run.service'; import { RelicService } from '../../../run/services/relic.service'; import { CardEffectService } from '../../../run/services/card-effect.service'; +import { RUN_CONFIG } from '../../../run/constants/run.constants'; describe('EncounterBootstrapService', () => { let gameBoardSpy: jasmine.SpyObj; @@ -37,12 +38,14 @@ describe('EncounterBootstrapService', () => { waves: unknown[]; isElite: boolean; isBoss: boolean; + isEndless: boolean; campaignMapId: string | null; }> = {}) { return { waves: [{}, {}, {}], isElite: false, isBoss: false, + isEndless: false, campaignMapId: 'forest', ...overrides, }; @@ -56,10 +59,10 @@ describe('EncounterBootstrapService', () => { gameBoardSpy = jasmine.createSpyObj('GameBoardService', ['getGameBoard']); gameBoardSpy.getGameBoard.and.returnValue([]); gameStateSpy = jasmine.createSpyObj('GameStateService', [ - 'setInitialLives', 'setEncounterStartGold', 'setMaxWaves', 'getState', + 'setInitialLives', 'setEncounterStartGold', 'setMaxWaves', 'setEndlessMode', 'getState', ]); gameStateSpy.getState.and.returnValue({ wave: 0, isEndless: false } as ReturnType); - waveSpy = jasmine.createSpyObj('WaveService', ['setCustomWaves']); + waveSpy = jasmine.createSpyObj('WaveService', ['setCustomWaves', 'setEndlessMode']); combatLoopSpy = jasmine.createSpyObj('CombatLoopService', ['reset']); challengeDisplaySpy = jasmine.createSpyObj( 'ChallengeDisplayService', ['updateIndicators'], { indicators: [] }, @@ -136,6 +139,28 @@ describe('EncounterBootstrapService', () => { expect(gameStateSpy.setEncounterStartGold).toHaveBeenCalledWith(175); // 150 + 25 }); + it('enforces minEncounterStartGold floor when run wallet is 0 (zero-gold starvation guard)', () => { + Object.defineProperty(runSpy, 'runState', { + value: { lives: 20, maxLives: 20, ascensionLevel: 0, gold: 0 }, + configurable: true, + }); + relicSpy.getStartingGoldBonus.and.returnValue(0); + service.bootstrapFresh(); + // Floor must be at least RUN_CONFIG.minEncounterStartGold (currently 50g). + const arg = (gameStateSpy.setEncounterStartGold as jasmine.Spy).calls.mostRecent().args[0] as number; + expect(arg).toBeGreaterThanOrEqual(RUN_CONFIG.minEncounterStartGold); + }); + + it('does not apply the floor when run wallet is already above it', () => { + Object.defineProperty(runSpy, 'runState', { + value: { lives: 20, maxLives: 20, ascensionLevel: 0, gold: 200 }, + configurable: true, + }); + relicSpy.getStartingGoldBonus.and.returnValue(0); + service.bootstrapFresh(); + expect(gameStateSpy.setEncounterStartGold).toHaveBeenCalledWith(200); + }); + it('sets custom waves and maxWaves to encounter wave count', () => { service.bootstrapFresh(); expect(waveSpy.setCustomWaves).toHaveBeenCalled(); @@ -212,5 +237,25 @@ describe('EncounterBootstrapService', () => { service.bootstrapFresh(); expect(waveCombatSpy.startWave).not.toHaveBeenCalled(); }); + + it('disables endless mode on both services for a normal encounter', () => { + service.bootstrapFresh(); + expect(waveSpy.setEndlessMode).toHaveBeenCalledWith(false); + expect(gameStateSpy.setEndlessMode).toHaveBeenCalledWith(false); + }); + + it('enables endless mode on both services when encounter.isEndless is true', () => { + runSpy.getCurrentEncounter.and.returnValue( + makeEncounter({ isEndless: true } as Parameters[0]) as ReturnType, + ); + service.bootstrapFresh(); + expect(waveSpy.setEndlessMode).toHaveBeenCalledWith(true); + expect(gameStateSpy.setEndlessMode).toHaveBeenCalledWith(true); + }); + + it('defaults endless to false when encounter.isEndless is absent', () => { + service.bootstrapFresh(); + expect(waveSpy.setEndlessMode).toHaveBeenCalledWith(false); + }); }); }); diff --git a/src/app/game/game-board/services/encounter-bootstrap.service.ts b/src/app/game/game-board/services/encounter-bootstrap.service.ts index 28a11f89..a4693360 100644 --- a/src/app/game/game-board/services/encounter-bootstrap.service.ts +++ b/src/app/game/game-board/services/encounter-bootstrap.service.ts @@ -18,6 +18,7 @@ import { CardEffectService } from '../../../run/services/card-effect.service'; import { ELEVATION_CONFIG } from '../constants/elevation.constants'; import { BlockType } from '../models/game-board-tile'; import { shuffleInPlace } from '../utils/coordinate-utils'; +import { RUN_CONFIG } from '../../../run/constants/run.constants'; /** * EncounterBootstrapService — orchestrates the fresh-encounter setup @@ -68,9 +69,21 @@ export class EncounterBootstrapService { runState.lives, runState.maxLives + this.relicService.getMaxLivesBonus(), ); - this.gameStateService.setEncounterStartGold(runState.gold + this.relicService.getStartingGoldBonus()); + // Apply RUN_CONFIG.minEncounterStartGold so a player who has spent down to near-zero + // between encounters still has enough capital to place at least one tower before wave 1. + // Under the unified gold model this may top-up the run wallet — see RUN_CONFIG comment. + const rawStartGold = runState.gold + this.relicService.getStartingGoldBonus(); + this.gameStateService.setEncounterStartGold(Math.max(rawStartGold, RUN_CONFIG.minEncounterStartGold)); this.waveService.setCustomWaves(encounter.waves); this.gameStateService.setMaxWaves(encounter.waves.length); + + // Endless encounters: enable before the first wave so wave generation + // continues past the (empty) scripted wave list. Non-endless encounters + // explicitly disable it so no leakage occurs between encounters. + const endlessEnabled = encounter.isEndless === true; + this.waveService.setEndlessMode(endlessEnabled); + this.gameStateService.setEndlessMode(endlessEnabled); + this.ascensionModifier.apply(runState.ascensionLevel, encounter.isElite, encounter.isBoss); this.challengeDisplayService.updateIndicators(encounter.campaignMapId ?? null); diff --git a/src/app/game/game-board/services/enemy-mesh-factory.service.spec.ts b/src/app/game/game-board/services/enemy-mesh-factory.service.spec.ts index 017c31b1..33d82356 100644 --- a/src/app/game/game-board/services/enemy-mesh-factory.service.spec.ts +++ b/src/app/game/game-board/services/enemy-mesh-factory.service.spec.ts @@ -307,6 +307,41 @@ describe('EnemyMeshFactoryService', () => { }); }); + // --- addShieldMesh --- + + describe('addShieldMesh', () => { + it('should be a no-op when enemy has no mesh', () => { + // makeEnemy() does not set mesh; addShieldMesh must guard gracefully. + const enemy = makeEnemy(EnemyType.NOVA_SOVEREIGN); + expect(() => service.addShieldMesh(enemy)).not.toThrow(); + }); + + it('should attach a dome to userData[shieldMesh] when no dome exists', () => { + // Spawn with shield=0 so createEnemyMesh skips the dome, then re-attach via addShieldMesh. + const enemy = makeEnemy(EnemyType.NOVA_SOVEREIGN, { shield: 0, maxShield: 400 }); + enemy.mesh = service.createEnemyMesh(enemy); + createdMeshes.push(enemy.mesh); + // Confirm no dome was created (shield=0). + expect(enemy.mesh.userData['shieldMesh']).toBeUndefined(); + + service.addShieldMesh(enemy); + + expect(enemy.mesh.userData['shieldMesh']).toBeTruthy(); + expect(enemy.mesh.userData['shieldMesh'] instanceof THREE.Mesh).toBeTrue(); + }); + + it('should not replace an existing dome (idempotent)', () => { + const enemy = makeEnemy(EnemyType.NOVA_SOVEREIGN, { shield: 80, maxShield: 400 }); + enemy.mesh = service.createEnemyMesh(enemy); + createdMeshes.push(enemy.mesh); + const originalDome = enemy.mesh.userData['shieldMesh']; + + service.addShieldMesh(enemy); + + expect(enemy.mesh.userData['shieldMesh']).toBe(originalDome); + }); + }); + // --- createMiniSwarmMesh --- describe('createMiniSwarmMesh', () => { diff --git a/src/app/game/game-board/services/enemy-mesh-factory.service.ts b/src/app/game/game-board/services/enemy-mesh-factory.service.ts index cb34aa10..a3c4b5bc 100644 --- a/src/app/game/game-board/services/enemy-mesh-factory.service.ts +++ b/src/app/game/game-board/services/enemy-mesh-factory.service.ts @@ -325,6 +325,21 @@ export class EnemyMeshFactoryService { enemy.shieldBreakTimer = SHIELD_BREAK_CONFIG.duration; } + /** + * Attach a fresh shield dome to an enemy whose dome was previously disposed. + * Only acts when the enemy has a mesh and no dome is already attached. + * The created dome is disposed in the normal cleanup path via disposeGroup + * (traverses the parent mesh's children) or explicitly in updateShieldBreakAnimations. + */ + addShieldMesh(enemy: Enemy): void { + if (!enemy.mesh) return; + if (enemy.mesh.userData['shieldMesh']) return; // dome already present + const stats = ENEMY_STATS[enemy.type]; + const shieldMesh = this.createShieldMesh(stats.size); + enemy.mesh.add(shieldMesh); + enemy.mesh.userData['shieldMesh'] = shieldMesh; + } + createMiniSwarmMesh(mini: Enemy): THREE.Mesh { const geometry = this.oct(MINI_SWARM_STATS.size, 0); // Mini-swarm body material is per-instance — same mutation surface as diff --git a/src/app/game/game-board/services/enemy-visual.service.spec.ts b/src/app/game/game-board/services/enemy-visual.service.spec.ts index 8e97fc2f..e68fc559 100644 --- a/src/app/game/game-board/services/enemy-visual.service.spec.ts +++ b/src/app/game/game-board/services/enemy-visual.service.spec.ts @@ -4,7 +4,11 @@ import { EnemyVisualService } from './enemy-visual.service'; import { StatusEffectType } from '../constants/status-effect.constants'; import { ENEMY_VISUAL_CONFIG } from '../constants/ui.constants'; import { ENEMY_STATS, EnemyType, Enemy } from '../models/enemy.model'; -import { STATUS_EFFECT_VISUAL_CONFIG } from '../constants/effects.constants'; +import { + STATUS_EFFECT_VISUAL_CONFIG, + NOVA_SOVEREIGN_ORB_SPIN_SPEEDS, + NOVA_SOVEREIGN_ENRAGE_VISUAL, +} from '../constants/effects.constants'; /** Create a minimal Enemy object suitable for visual tests. */ function makeEnemy(id: string, type: EnemyType = EnemyType.BASIC): Enemy { @@ -364,6 +368,244 @@ describe('EnemyVisualService', () => { }); }); + // --------------------------------------------------------------------------- + // NOVA_SOVEREIGN orb-ring animation (Fix 1) + // --------------------------------------------------------------------------- + + describe('updateEnemyAnimations — NOVA_SOVEREIGN orb rings', () => { + function makeNovaSovereign(): Enemy { + const enemy = makeEnemy('nova', EnemyType.NOVA_SOVEREIGN); + // Attach three fake shard rings matching the userData keys set by EnemyMeshFactoryService. + for (let i = 0; i < 3; i++) { + const geo = new THREE.TorusGeometry(0.5, 0.05); + const mat = new THREE.MeshStandardMaterial(); + const ring = new THREE.Mesh(geo, mat); + ring.rotation.y = 0; + enemy.mesh!.userData[`novaSovereignOrb${i}`] = ring; + } + return enemy; + } + + afterEach(() => { + // Dispose ring geometries/materials created in each test. + }); + + it('rotates all three shard rings on Y each frame', () => { + const enemy = makeNovaSovereign(); + const enemies = new Map([['nova', enemy]]); + + service.updateEnemyAnimations(enemies, 0.5); + + for (let i = 0; i < 3; i++) { + const ring = enemy.mesh!.userData[`novaSovereignOrb${i}`] as THREE.Mesh; + const expectedY = NOVA_SOVEREIGN_ORB_SPIN_SPEEDS[i] * 0.5; + expect(ring.rotation.y).toBeCloseTo(expectedY, 5); + + ring.geometry.dispose(); + (ring.material as THREE.Material).dispose(); + } + }); + + it('gives each ring a distinct rotation angle after multiple frames', () => { + const enemy = makeNovaSovereign(); + const enemies = new Map([['nova', enemy]]); + + service.updateEnemyAnimations(enemies, 1.0); + service.updateEnemyAnimations(enemies, 1.0); + + const angles = [0, 1, 2].map( + i => (enemy.mesh!.userData[`novaSovereignOrb${i}`] as THREE.Mesh).rotation.y, + ); + // Verify that ring angles differ (parallax — they should not all be equal) + expect(angles[0]).not.toBeCloseTo(angles[1], 3); + expect(angles[1]).not.toBeCloseTo(angles[2], 3); + + for (let i = 0; i < 3; i++) { + const ring = enemy.mesh!.userData[`novaSovereignOrb${i}`] as THREE.Mesh; + ring.geometry.dispose(); + (ring.material as THREE.Material).dispose(); + } + }); + + it('does not rotate rings when deltaTime is zero', () => { + const enemy = makeNovaSovereign(); + const enemies = new Map([['nova', enemy]]); + + service.updateEnemyAnimations(enemies, 0); + + for (let i = 0; i < 3; i++) { + const ring = enemy.mesh!.userData[`novaSovereignOrb${i}`] as THREE.Mesh; + expect(ring.rotation.y).toBe(0); + ring.geometry.dispose(); + (ring.material as THREE.Material).dispose(); + } + }); + + it('does not throw when a ring userData entry is missing', () => { + const enemy = makeNovaSovereign(); + // Remove one ring to simulate a partially-built mesh. + delete enemy.mesh!.userData['novaSovereignOrb1']; + const enemies = new Map([['nova', enemy]]); + + expect(() => service.updateEnemyAnimations(enemies, 0.016)).not.toThrow(); + + for (const i of [0, 2]) { + const ring = enemy.mesh!.userData[`novaSovereignOrb${i}`] as THREE.Mesh; + ring.geometry.dispose(); + (ring.material as THREE.Material).dispose(); + } + }); + + it('skips orb animation for dying NOVA_SOVEREIGN', () => { + const enemy = makeNovaSovereign(); + enemy.dying = true; + const enemies = new Map([['nova', enemy]]); + + service.updateEnemyAnimations(enemies, 0.5); + + for (let i = 0; i < 3; i++) { + const ring = enemy.mesh!.userData[`novaSovereignOrb${i}`] as THREE.Mesh; + expect(ring.rotation.y).toBe(0); + ring.geometry.dispose(); + (ring.material as THREE.Material).dispose(); + } + }); + }); + + // --------------------------------------------------------------------------- + // NOVA_SOVEREIGN enrage visual (Fix 2) + // --------------------------------------------------------------------------- + + describe('updateStatusVisuals — NOVA_SOVEREIGN enrage', () => { + it('applies enrage emissive when isEnraged and no status effects', () => { + const enemy = makeEnemy('nova', EnemyType.NOVA_SOVEREIGN); + enemy.isEnraged = true; + const enemies = new Map([['nova', enemy]]); + + service.updateStatusVisuals(enemies, new Map()); + + const mat = enemy.mesh!.material as THREE.MeshStandardMaterial; + expect(mat.emissive.getHex()).toBe(NOVA_SOVEREIGN_ENRAGE_VISUAL.emissiveColor); + expect(mat.emissiveIntensity).toBe(NOVA_SOVEREIGN_ENRAGE_VISUAL.emissiveIntensity); + }); + + it('does not apply enrage emissive when isEnraged is false', () => { + const enemy = makeEnemy('nova', EnemyType.NOVA_SOVEREIGN); + enemy.isEnraged = false; + const enemies = new Map([['nova', enemy]]); + + service.updateStatusVisuals(enemies, new Map()); + + const mat = enemy.mesh!.material as THREE.MeshStandardMaterial; + // Should be base color, not the enrage color + expect(mat.emissive.getHex()).not.toBe(NOVA_SOVEREIGN_ENRAGE_VISUAL.emissiveColor); + }); + + it('status tint takes priority over enrage emissive', () => { + const enemy = makeEnemy('nova', EnemyType.NOVA_SOVEREIGN); + enemy.isEnraged = true; + const enemies = new Map([['nova', enemy]]); + const effects = new Map([['nova', [StatusEffectType.BURN]]]); + + service.updateStatusVisuals(enemies, effects); + + const mat = enemy.mesh!.material as THREE.MeshStandardMaterial; + // BURN emissive should win — the enrage path only fires when effects.length === 0 + expect(mat.emissive.getHex()).toBe(0xff6622); + expect(mat.emissive.getHex()).not.toBe(NOVA_SOVEREIGN_ENRAGE_VISUAL.emissiveColor); + }); + + it('enrage also tints child meshes (boss crown if present)', () => { + const enemy = makeEnemy('nova', EnemyType.NOVA_SOVEREIGN); + enemy.isEnraged = true; + // Attach a fake crown to verify tintChildMeshes is called with enrage values + const crownGeo = new THREE.TorusGeometry(0.4, 0.05); + const crownMat = new THREE.MeshStandardMaterial(); + const crown = new THREE.Mesh(crownGeo, crownMat); + enemy.mesh!.userData['bossCrown'] = crown; + const enemies = new Map([['nova', enemy]]); + + service.updateStatusVisuals(enemies, new Map()); + + expect(crownMat.emissive.getHex()).toBe(NOVA_SOVEREIGN_ENRAGE_VISUAL.emissiveColor); + expect(crownMat.emissiveIntensity).toBe(NOVA_SOVEREIGN_ENRAGE_VISUAL.emissiveIntensity); + + crownGeo.dispose(); + crownMat.dispose(); + }); + }); + + // --------------------------------------------------------------------------- + // WYRM_ASCENDANT wyrmEyeGlow tinting (Fix 3) + // --------------------------------------------------------------------------- + + describe('tintChildMeshes — wyrmEyeGlow', () => { + function makeWyrm(): Enemy { + const enemy = makeEnemy('wyrm', EnemyType.WYRM_ASCENDANT); + const eyeGeo = new THREE.TorusGeometry(0.3, 0.05); + const eyeMat = new THREE.MeshStandardMaterial({ emissive: new THREE.Color(0xff2200) }); + const eyeGlow = new THREE.Mesh(eyeGeo, eyeMat); + enemy.mesh!.userData['wyrmEyeGlow'] = eyeGlow; + return enemy; + } + + it('tints wyrmEyeGlow when BURN status is active', () => { + const enemy = makeWyrm(); + const enemies = new Map([['wyrm', enemy]]); + const effects = new Map([['wyrm', [StatusEffectType.BURN]]]); + + service.updateStatusVisuals(enemies, effects); + + const eyeGlow = enemy.mesh!.userData['wyrmEyeGlow'] as THREE.Mesh; + const eyeMat = eyeGlow.material as THREE.MeshStandardMaterial; + expect(eyeMat.emissive.getHex()).toBe(0xff6622); + + eyeGlow.geometry.dispose(); + eyeMat.dispose(); + }); + + it('tints wyrmEyeGlow when SLOW status is active', () => { + const enemy = makeWyrm(); + const enemies = new Map([['wyrm', enemy]]); + const effects = new Map([['wyrm', [StatusEffectType.SLOW]]]); + + service.updateStatusVisuals(enemies, effects); + + const eyeGlow = enemy.mesh!.userData['wyrmEyeGlow'] as THREE.Mesh; + const eyeMat = eyeGlow.material as THREE.MeshStandardMaterial; + expect(eyeMat.emissive.getHex()).toBe(0x4488ff); + + eyeGlow.geometry.dispose(); + eyeMat.dispose(); + }); + + it('restores wyrmEyeGlow emissive when effects clear', () => { + const enemy = makeWyrm(); + const enemies = new Map([['wyrm', enemy]]); + + // Apply BURN + service.updateStatusVisuals(enemies, new Map([['wyrm', [StatusEffectType.BURN]]])); + // Clear effects + service.updateStatusVisuals(enemies, new Map()); + + const eyeGlow = enemy.mesh!.userData['wyrmEyeGlow'] as THREE.Mesh; + const eyeMat = eyeGlow.material as THREE.MeshStandardMaterial; + // Should be base color, not BURN + expect(eyeMat.emissive.getHex()).not.toBe(0xff6622); + + eyeGlow.geometry.dispose(); + eyeMat.dispose(); + }); + + it('does not throw when wyrmEyeGlow userData is absent', () => { + const enemy = makeEnemy('basic', EnemyType.BASIC); + const enemies = new Map([['basic', enemy]]); + const effects = new Map([['basic', [StatusEffectType.POISON]]]); + + expect(() => service.updateStatusVisuals(enemies, effects)).not.toThrow(); + }); + }); + // cleanup // --------------------------------------------------------------------------- diff --git a/src/app/game/game-board/services/enemy-visual.service.ts b/src/app/game/game-board/services/enemy-visual.service.ts index 26ed284d..3aac8114 100644 --- a/src/app/game/game-board/services/enemy-visual.service.ts +++ b/src/app/game/game-board/services/enemy-visual.service.ts @@ -1,12 +1,14 @@ import { Injectable } from '@angular/core'; import * as THREE from 'three'; -import { Enemy, ENEMY_STATS } from '../models/enemy.model'; +import { Enemy, ENEMY_STATS, EnemyType } from '../models/enemy.model'; import { StatusEffectType } from '../constants/status-effect.constants'; import { STATUS_EFFECT_VISUALS, STATUS_EFFECT_VISUAL_CONFIG, STATUS_EFFECT_PRIORITY, ENEMY_ANIM_CONFIG, + NOVA_SOVEREIGN_ORB_SPIN_SPEEDS, + NOVA_SOVEREIGN_ENRAGE_VISUAL, } from '../constants/effects.constants'; import { resolveEnemyColor, resolveStatusEmissive } from '../constants/colorblind.constants'; import { ENEMY_VISUAL_CONFIG } from '../constants/ui.constants'; @@ -73,7 +75,8 @@ export class EnemyVisualService { } } - // No effects — restore base emissive + // No active status effects — restore base emissive, then apply any boss + // phase overrides (e.g. NOVA_SOVEREIGN enrage) on top. const stats = ENEMY_STATS[enemy.type]; const baseIntensity = enemy.isMiniSwarm ? ENEMY_VISUAL_CONFIG.miniSwarmEmissive @@ -82,6 +85,20 @@ export class EnemyVisualService { mat.emissive.setHex(baseColor); mat.emissiveIntensity = baseIntensity; this.tintChildMeshes(enemy.mesh, baseColor, baseIntensity); + + // Enrage phase-shift: NOVA_SOVEREIGN body glows orange-red once health + // crosses the enrage threshold. Applied after base restore so the + // orange-red persists at idle but is suppressed when a status tint is + // active (those paths return early above). + if (enemy.type === EnemyType.NOVA_SOVEREIGN && enemy.isEnraged) { + mat.emissive.setHex(NOVA_SOVEREIGN_ENRAGE_VISUAL.emissiveColor); + mat.emissiveIntensity = NOVA_SOVEREIGN_ENRAGE_VISUAL.emissiveIntensity; + this.tintChildMeshes( + enemy.mesh, + NOVA_SOVEREIGN_ENRAGE_VISUAL.emissiveColor, + NOVA_SOVEREIGN_ENRAGE_VISUAL.emissiveIntensity, + ); + } }); } @@ -182,16 +199,33 @@ export class EnemyVisualService { } /** - * Spin boss crowns for visual flair. Called once per frame. + * Animate boss idle elements each frame. + * + * - All bosses with a crown torus: spin it on Z. + * - NOVA_SOVEREIGN: rotate each of the three orbiting shard rings on Y at + * its own speed so they drift apart over time (parallax effect). */ updateEnemyAnimations(enemies: Map, deltaTime: number): void { enemies.forEach(enemy => { if (!enemy.mesh || enemy.health <= 0) return; if (enemy.dying) return; + + // Boss crown spin (BOSS, WYRM_ASCENDANT share this userData key) const crown = enemy.mesh.userData['bossCrown'] as THREE.Mesh | undefined; if (crown) { crown.rotation.z += ENEMY_ANIM_CONFIG.bossCrownSpinSpeed * deltaTime; } + + // NOVA_SOVEREIGN orbiting shard rings — each ring spins at a distinct + // speed so they visually separate over time rather than moving as one. + if (enemy.type === EnemyType.NOVA_SOVEREIGN) { + for (let i = 0; i < NOVA_SOVEREIGN_ORB_SPIN_SPEEDS.length; i++) { + const ring = enemy.mesh.userData[`novaSovereignOrb${i}`] as THREE.Mesh | undefined; + if (ring) { + ring.rotation.y += NOVA_SOVEREIGN_ORB_SPIN_SPEEDS[i] * deltaTime; + } + } + } }); } @@ -316,8 +350,10 @@ export class EnemyVisualService { } /** - * Apply emissive tint to child meshes that have MeshStandardMaterial (e.g., boss crown). - * Skips health bar children (MeshBasicMaterial) and shield mesh. + * Apply emissive tint to special child meshes that use MeshStandardMaterial. + * Handles the boss crown (shared by BOSS/WYRM_ASCENDANT) and the + * WYRM_ASCENDANT eye-glow torus so status tints propagate consistently. + * Skips health-bar (MeshBasicMaterial) and shield dome children. */ private tintChildMeshes(mesh: THREE.Mesh, color: number, intensity: number): void { const crown = mesh.userData['bossCrown'] as THREE.Mesh | undefined; @@ -328,5 +364,16 @@ export class EnemyVisualService { crownMat.emissiveIntensity = intensity; } } + + // WYRM_ASCENDANT eye-glow torus shares the same emissive system as the + // crown so status tints and the base-restore path both propagate to it. + const eyeGlow = mesh.userData['wyrmEyeGlow'] as THREE.Mesh | undefined; + if (eyeGlow) { + const eyeMat = eyeGlow.material as THREE.MeshStandardMaterial; + if (eyeMat.emissive) { + eyeMat.emissive.setHex(color); + eyeMat.emissiveIntensity = intensity; + } + } } } diff --git a/src/app/game/game-board/services/enemy.service.spec.ts b/src/app/game/game-board/services/enemy.service.spec.ts index be963330..edaf228c 100644 --- a/src/app/game/game-board/services/enemy.service.spec.ts +++ b/src/app/game/game-board/services/enemy.service.spec.ts @@ -4513,6 +4513,115 @@ describe('EnemyService', () => { // Regen should stop at the instance maxShield, not the static one. expect(sovereign.shield).toBe(scaledMax); }); + + it('tickNovaSovereignEffects() recreates the shield dome after break animation completes', () => { + const sovereign = service.spawnEnemy(EnemyType.NOVA_SOVEREIGN, mockScene)!; + // Simulate the state after a shield break animation completes: + // - dome was disposed and removed from userData + // - shieldBreaking reset to false + // - shield may still be 0 but regen will make it positive + sovereign.shield = 0; + sovereign.shieldBreaking = false; + if (sovereign.mesh) { + delete sovereign.mesh.userData['shieldMesh']; + } + + service.tickNovaSovereignEffects(); + + // After regen the shield is positive and the dome should be re-attached. + expect(sovereign.shield).toBeGreaterThan(0); + expect(sovereign.mesh?.userData['shieldMesh']).toBeTruthy(); + }); + + it('tickNovaSovereignEffects() does NOT recreate dome while break animation is in progress', () => { + const sovereign = service.spawnEnemy(EnemyType.NOVA_SOVEREIGN, mockScene)!; + // Simulate mid-animation: shield is 0, dome is still present (fading), shieldBreaking=true. + sovereign.shield = 0; + sovereign.shieldBreaking = true; + sovereign.shieldBreakTimer = 0.2; + + service.tickNovaSovereignEffects(); + + // Shield becomes positive via regen — but dome must NOT be recreated yet + // (the old dome is still animating out; addShieldMesh must wait). + expect(sovereign.shield).toBeGreaterThan(0); + // userData['shieldMesh'] was not changed (no new dome added, original dome still present). + expect(sovereign.mesh?.userData['shieldMesh']).toBeTruthy(); // original dome + }); + + it('tickNovaSovereignEffects() does NOT replace an existing dome (idempotent)', () => { + const sovereign = service.spawnEnemy(EnemyType.NOVA_SOVEREIGN, mockScene)!; + // Shield already positive — partial regen, dome already present. + const initialDome = sovereign.mesh?.userData['shieldMesh']; + sovereign.shield = NOVA_SOVEREIGN_SHIELD_REGEN_PER_TURN; // just 1 regen unit below cap + + service.tickNovaSovereignEffects(); + + // Dome reference must be the same object — addShieldMesh is a no-op when dome exists. + expect(sovereign.mesh?.userData['shieldMesh']).toBe(initialDome); + }); + }); + + describe('SLOW card-modifier fractional accumulator', () => { + it('BASIC enemy under 15% card speed slow eventually skips a turn', () => { + // Rate = 1 * (1-0.15) = 0.85 tiles/turn. Accumulator grows by 0.85 each turn. + // Turn 1: acc=0.85 → floor=0 (skip). + // Turn 2: acc=1.70 → floor=1, remainder=0.70 (move). + // The enemy should skip at least once across 15 turns. + const cardEffectSpy = TestBed.inject(CardEffectService) as jasmine.SpyObj; + // enemySpeed modifier = 0.15 (15% slow fraction) + cardEffectSpy.getModifierValue.and.callFake((stat: string) => + stat === MODIFIER_STAT.ENEMY_SPEED ? 0.15 : 0, + ); + + const enemy = service.spawnEnemy(EnemyType.BASIC, mockScene)!; + const movesPerTurn: number[] = []; + + for (let i = 0; i < 12; i++) { + const prevIndex = enemy.pathIndex; + service.stepEnemiesOneTurn(() => 0); + movesPerTurn.push(enemy.pathIndex - prevIndex); + if (enemy.pathIndex >= enemy.path.length - 1) break; + } + + // At least one turn must have produced 0 tile movement (skip). + expect(movesPerTurn.some(m => m === 0)).toBeTrue(); + }); + + it('BASIC enemy under 15% card speed slow moves on most turns (not fully frozen)', () => { + const cardEffectSpy = TestBed.inject(CardEffectService) as jasmine.SpyObj; + cardEffectSpy.getModifierValue.and.callFake((stat: string) => + stat === MODIFIER_STAT.ENEMY_SPEED ? 0.15 : 0, + ); + + const enemy = service.spawnEnemy(EnemyType.BASIC, mockScene)!; + let movesCount = 0; + + for (let i = 0; i < 8; i++) { + const prevIndex = enemy.pathIndex; + service.stepEnemiesOneTurn(() => 0); + if (enemy.pathIndex > prevIndex) movesCount++; + if (enemy.pathIndex >= enemy.path.length - 1) break; + } + + // Should have moved on more turns than not (0.85 rate means ~6/7 turns) + expect(movesCount).toBeGreaterThan(4); + }); + + it('BASIC with SLOW status (slowReduction=1) and no card modifier still moves 1 tile', () => { + // Regression: existing floor-at-1 guarantee must be preserved for SLOW status effect. + // Reset the card-effect spy so no card-modifier speed slow is active. + const cardEffectSpy = TestBed.inject(CardEffectService) as jasmine.SpyObj; + cardEffectSpy.getModifierValue.and.returnValue(0); + + const enemy = service.spawnEnemy(EnemyType.BASIC, mockScene)!; + const prevIndex = enemy.pathIndex; + + service.stepEnemiesOneTurn(() => 1); // SLOW status gives 1-tile reduction + + // Must still move 1 tile — accumulator is not involved (enemySpeedSlow=0). + expect(enemy.pathIndex).toBe(prevIndex + 1); + }); }); }); diff --git a/src/app/game/game-board/services/enemy.service.ts b/src/app/game/game-board/services/enemy.service.ts index d5da5849..23ab7342 100644 --- a/src/app/game/game-board/services/enemy.service.ts +++ b/src/app/game/game-board/services/enemy.service.ts @@ -408,12 +408,47 @@ export class EnemyService { if (enemy.type === EnemyType.NOVA_SOVEREIGN && effectiveSlowReduction > 0) { effectiveSlowReduction = Math.floor(effectiveSlowReduction * NOVA_SOVEREIGN_SLOW_RESISTANCE_FACTOR); } - const enemySpeedReduction = enemySpeedSlow > 0 ? Math.floor(baseTiles * enemySpeedSlow) : 0; - // Floor at 1 tile/turn — SLOW aura re-applies each turn while enemy is in - // range, so a 0-floor would permanently freeze any 1-tile mover (BASIC, - // HEAVY, BOSS, SHIELDED, FLYING). SLOW tower is still effective against - // 2-tile movers (FAST, SWIFT, SWARM) which drop from 2→1. - const tilesToMove = Math.max(1, baseTiles - effectiveSlowReduction - enemySpeedReduction); + // Fractional-movement accumulator for the card-modifier speed reduction. + // Math.floor(baseTiles * enemySpeedSlow) rounds 1*0.15 → 0, giving zero + // movement penalty to all 1-tile-per-turn enemies when the ENEMY_SPEED + // modifier is active. Instead, we accumulate the fractional rate across turns + // and move floor(accumulator) tiles, keeping the remainder for the next turn. + // This produces a natural skip pattern (e.g. 15% slow → skip ~1-in-7 turns) + // without RNG. For 2-tile movers the floor path still fires (floor(2*0.15)=0 + // but 2-tile movers have grossRate ≥ 1 before the speed penalty, so integer + // path handles them correctly). + // + // The integer tile reduction from the SLOW status effect (slowReductionFor) + // remains floor-based and still floors the total at 1 — that preserves the + // existing SLOW-tower behaviour against 1-tile movers (no change there). + let effectiveTilesFromSpeedMod: number; + if (enemySpeedSlow > 0) { + // Fractional speed reduction from card modifier — accumulate across turns + const fractionalReduction = baseTiles * enemySpeedSlow; + const fractionalRate = baseTiles - fractionalReduction; + enemy.slowMoveAccumulator = (enemy.slowMoveAccumulator ?? 0) + fractionalRate; + effectiveTilesFromSpeedMod = Math.floor(enemy.slowMoveAccumulator); + enemy.slowMoveAccumulator -= effectiveTilesFromSpeedMod; + } else { + // No card-modifier speed slow — clear any stale accumulator and advance normally. + enemy.slowMoveAccumulator = undefined; + effectiveTilesFromSpeedMod = baseTiles; + } + // Apply integer tile reduction from SLOW status effect on top of the + // speed-mod result. + // + // When no card-modifier slow is active: floor at 1 tile/turn so the SLOW + // aura re-applying each turn cannot permanently freeze any 1-tile enemy. + // + // When a card-modifier slow IS active: the accumulator controls movement. + // When effectiveTilesFromSpeedMod = 0 this means "skip this turn" — the + // floor-at-1 does NOT apply so the skip registers. The SLOW status effect + // integer reduction is applied on top (subtracting from the accumulator result), + // which cannot make the effective tiles negative since effectiveTilesFromSpeedMod ≥ 0. + const rawTiles = effectiveTilesFromSpeedMod - effectiveSlowReduction; + const tilesToMove = enemySpeedSlow > 0 + ? Math.max(0, rawTiles) // card modifier active: allow 0 (skip turn) + : Math.max(1, rawTiles); // no card modifier: floor at 1 (SLOW tower anti-freeze) let stepsRemaining = tilesToMove; while (stepsRemaining > 0 && enemy.pathIndex < enemy.path.length - 1) { @@ -776,6 +811,14 @@ export class EnemyService { enemy.shield = Math.min(maxShield, enemy.shield + NOVA_SOVEREIGN_SHIELD_REGEN_PER_TURN); } + // Recreate the shield dome if the shield is positive but the dome mesh + // is absent (fully disposed after the break animation completed). + // addShieldMesh() no-ops when the dome already exists, so calling it + // every regen tick is safe — it only creates when truly needed. + if (enemy.shield !== undefined && enemy.shield > 0 && !enemy.shieldBreaking) { + this.enemyMeshFactory.addShieldMesh(enemy); + } + // Enrage: first time HP drops below 50% of max HP if ( !enemy.isEnraged && diff --git a/src/app/game/game-board/services/forward-simulation.service.spec.ts b/src/app/game/game-board/services/forward-simulation.service.spec.ts index 0fbb1c51..47fdb776 100644 --- a/src/app/game/game-board/services/forward-simulation.service.spec.ts +++ b/src/app/game/game-board/services/forward-simulation.service.spec.ts @@ -82,10 +82,15 @@ describe('ForwardSimulationService', () => { expect(svc.projectTurnsToExit(enemy, /*slow*/ 1)).toBe(9); }); - it('floors at 1 tile/turn even when reductions exceed base speed', () => { - // BASIC at 1 base − 5 reduction = −4, but min-floor is 1 → 9 turns over 9 tiles + it('returns a large but finite projection when reductions exceed base speed', () => { + // BASIC at 1 base − 5 reduction = −4 net. Combined reductions exceed base speed, + // so the effective rate is clamped to MIN_PROJECTION_TILES_PER_TURN (a small positive + // value) to prevent division-by-zero. The result must be finite and much larger than + // the unslowed projection (9 turns). const enemy = makeEnemy({ type: EnemyType.BASIC, path: makePath(10), pathIndex: 0 }); - expect(svc.projectTurnsToExit(enemy, /*slow*/ 5)).toBe(9); + const result = svc.projectTurnsToExit(enemy, /*slow*/ 5); + expect(isFinite(result)).toBeTrue(); + expect(result).toBeGreaterThan(9); }); it('applies VEINSEEKER boost when flagged', () => { @@ -284,4 +289,38 @@ describe('ForwardSimulationService', () => { expect(result).toBeGreaterThan(TOWER_CONFIGS[TowerType.BASIC].damage); }); }); + + describe('fractional card-modifier speed slow projection', () => { + it('projects more turns for a 1-tile enemy under 15% card speed slow', () => { + // BASIC (1 tile/turn) with 15% card-modifier slow: effective rate = 0.85 tiles/turn + // 9 tiles / 0.85 = 10.6 → Math.ceil = 11 turns (vs 9 unslowed) + const enemy = makeEnemy({ type: EnemyType.BASIC, path: makePath(10), pathIndex: 0 }); + const slowed = svc.projectTurnsToExit(enemy, 0, 0.15); + const unslowed = svc.projectTurnsToExit(enemy, 0, 0); + expect(slowed).toBeGreaterThan(unslowed); + }); + + it('returns ceil(tilesRemaining / (baseTiles*(1-slowPct))) for 1-tile mover at 15% slow', () => { + // BASIC: rate = 1*(1-0.15) = 0.85, 9 tiles → ceil(9/0.85) = 11 + const enemy = makeEnemy({ type: EnemyType.BASIC, path: makePath(10), pathIndex: 0 }); + expect(svc.projectTurnsToExit(enemy, 0, 0.15)).toBe(11); + }); + + it('projection with 50% card slow takes twice as many turns for a 1-tile mover', () => { + // rate = 0.5, 9 tiles → ceil(9/0.5) = 18 + const enemy = makeEnemy({ type: EnemyType.BASIC, path: makePath(10), pathIndex: 0 }); + expect(svc.projectTurnsToExit(enemy, 0, 0.5)).toBe(18); + }); + + it('forward-sim and live accumulator agree: 15% slow skips a turn within 12 turns for BASIC', () => { + // rate = 0.85 per turn. After 12 turns: 12 * 0.85 = 10.2 tiles → the enemy + // crosses the exit (9 tiles away) between turn 10 and 11. projection returns 11. + const enemy = makeEnemy({ type: EnemyType.BASIC, path: makePath(10), pathIndex: 0 }); + const turns = svc.projectTurnsToExit(enemy, 0, 0.15); + // The enemy takes more than 9 turns (unslowed value) + expect(turns).toBeGreaterThan(9); + // But no more than 12 turns (sanity ceiling) + expect(turns).toBeLessThanOrEqual(12); + }); + }); }); diff --git a/src/app/game/game-board/services/forward-simulation.service.ts b/src/app/game/game-board/services/forward-simulation.service.ts index 1e1e23af..ef6ea71e 100644 --- a/src/app/game/game-board/services/forward-simulation.service.ts +++ b/src/app/game/game-board/services/forward-simulation.service.ts @@ -1,7 +1,16 @@ import { Injectable } from '@angular/core'; -import { Enemy, EnemyType, ENEMY_STATS, VEINSEEKER_BOOSTED_TILES_PER_TURN, NOVA_SOVEREIGN_SLOW_RESISTANCE_FACTOR } from '../models/enemy.model'; +import { Enemy, EnemyType, ENEMY_STATS, VEINSEEKER_BOOSTED_TILES_PER_TURN, NOVA_SOVEREIGN_SLOW_RESISTANCE_FACTOR, SLOW_ACCUMULATOR_ACTIVATION_THRESHOLD } from '../models/enemy.model'; import { PlacedTower, getEffectiveStats } from '../models/tower.model'; +/** + * Lower bound for the projected tiles-per-turn rate used in turn-count estimates. + * Prevents division by zero when combined SLOW reductions reduce the effective rate + * to zero or below. Small enough not to disturb fractional-rate projections + * (e.g. 0.85 or 0.5 from card-modifier slows) but large enough to keep + * Math.ceil(distance / rate) finite and meaningful. + */ +const MIN_PROJECTION_TILES_PER_TURN = 0.01; + /** * Pure projection of enemy movement N turns into the future. * @@ -52,7 +61,10 @@ export class ForwardSimulationService { } const tilesToMove = this.tilesPerTurnFor(enemy, slowTileReduction, enemySpeedSlow, veinseekerBoosted); const projectedIndex = Math.min( - enemy.pathIndex + tilesToMove * turnsAhead, + // Floor to integer: fractional rates (from card-modifier SLOW) produce + // non-integer index estimates. Floor gives the conservative "at least this + // far" position, which matches how the accumulator rounds down in the live engine. + Math.floor(enemy.pathIndex + tilesToMove * turnsAhead), enemy.path.length - 1, ); const node = enemy.path[projectedIndex]; @@ -108,10 +120,14 @@ export class ForwardSimulationService { return total; } - // Mirrors stepEnemiesOneTurn:373-396 — the canonical movement math. + // Mirrors stepEnemiesOneTurn — the canonical movement math. // Same precedence as the live engine: VEINSEEKER boost, then NOVA_SOVEREIGN - // enrage override, then NOVA_SOVEREIGN halved slow reduction. - // Floor at 1 tile/turn matches the live engine's anti-freeze guarantee. + // enrage override, then NOVA_SOVEREIGN halved slow reduction, then fractional + // card-modifier speed reduction, then integer SLOW tile reduction floored at 1. + // + // For projection (stateless): returns the fractional effective rate directly. + // projectTurnsToExit uses Math.ceil(tilesRemaining / rate), which handles + // fractional rates correctly without simulating the per-enemy accumulator state. private tilesPerTurnFor( enemy: Enemy, slowTileReduction: number, @@ -133,7 +149,19 @@ export class ForwardSimulationService { if (enemy.type === EnemyType.NOVA_SOVEREIGN && effectiveSlowReduction > 0) { effectiveSlowReduction = Math.floor(effectiveSlowReduction * NOVA_SOVEREIGN_SLOW_RESISTANCE_FACTOR); } - const enemySpeedReduction = enemySpeedSlow > 0 ? Math.floor(baseTiles * enemySpeedSlow) : 0; - return Math.max(1, baseTiles - effectiveSlowReduction - enemySpeedReduction); + // Card-modifier fractional speed reduction — mirrors the accumulator path in + // the live engine. Return the fractional rate so Math.ceil in projectTurnsToExit + // produces accurate turn-count estimates (e.g. 85% of 1 tile/turn → ceil(9/0.85) = 11 turns). + const speedModRate = enemySpeedSlow > 0 ? baseTiles * (1 - enemySpeedSlow) : baseTiles; + // Apply integer SLOW tile reduction floored at 1 — same guarantee as the live engine. + const grossRate = speedModRate - effectiveSlowReduction; + if (grossRate < SLOW_ACCUMULATOR_ACTIVATION_THRESHOLD) { + // Fractional zone: return the fractional rate directly so callers using + // Math.ceil get a correct turn estimate without simulating accumulator state. + // Clamp to MIN_PROJECTION_TILES_PER_TURN so projectTurnsToExit always returns + // a finite number even when combined reductions exceed the enemy's base speed. + return Math.max(MIN_PROJECTION_TILES_PER_TURN, grossRate); + } + return Math.max(1, grossRate); } } diff --git a/src/app/game/game-board/services/game-state.service.spec.ts b/src/app/game/game-board/services/game-state.service.spec.ts index c5dbadbe..12f52a6d 100644 --- a/src/app/game/game-board/services/game-state.service.spec.ts +++ b/src/app/game/game-board/services/game-state.service.spec.ts @@ -5,7 +5,7 @@ interface TestableGameStateService { state: { gold: number }; emit(): void; } -import { DifficultyLevel, DIFFICULTY_PRESETS, GamePhase, GameState, INITIAL_GAME_STATE, INTEREST_CONFIG, STREAK_BONUS_PER_WAVE, VALID_TRANSITIONS } from '../models/game-state.model'; +import { DifficultyLevel, DIFFICULTY_PRESETS, GamePhase, GameState, INITIAL_GAME_STATE, INTEREST_CONFIG, MAX_STREAK_BONUS_PER_WAVE, STREAK_BONUS_PER_WAVE, VALID_TRANSITIONS } from '../models/game-state.model'; import { GameModifier } from '../models/game-modifier.model'; import { SerializableGameState } from '../models/encounter-checkpoint.model'; import { TowerType, TOWER_CONFIGS } from '../models/tower.model'; @@ -1318,6 +1318,32 @@ describe('GameStateService', () => { }); service.addStreakBonus(); }); + + it('addStreakBonus() caps payout at MAX_STREAK_BONUS_PER_WAVE on very long perfect streaks', () => { + // Advance streak counter well past the cap threshold (MAX / STREAK_BONUS_PER_WAVE waves). + // Threshold: 150 / 25 = 6 waves. We test at wave 10 (250g raw) → capped at 150g. + const capWave = Math.floor(MAX_STREAK_BONUS_PER_WAVE / STREAK_BONUS_PER_WAVE) + 4; // 10 + for (let i = 0; i < capWave - 1; i++) { + service.addStreakBonus(); + service.completeWave(0); + service.startWave(); + } + const goldBefore = service.getState().gold; + const bonus = service.addStreakBonus(); // streak = capWave, raw = capWave * 25 + expect(bonus).toBe(MAX_STREAK_BONUS_PER_WAVE); + expect(service.getState().gold).toBe(goldBefore + MAX_STREAK_BONUS_PER_WAVE); + }); + + it('addStreakBonus() is not capped below the threshold', () => { + // Streak at exactly STREAK_BONUS_PER_WAVE * 2 = 50g (below 150g cap). + service.addStreakBonus(); // streak = 1, bonus = 25 + service.completeWave(0); + service.startWave(); + const goldBefore = service.getState().gold; + const bonus = service.addStreakBonus(); // streak = 2, bonus = 50 (below cap) + expect(bonus).toBe(STREAK_BONUS_PER_WAVE * 2); + expect(service.getState().gold).toBe(goldBefore + STREAK_BONUS_PER_WAVE * 2); + }); }); // --- error paths and edge cases --- diff --git a/src/app/game/game-board/services/game-state.service.ts b/src/app/game/game-board/services/game-state.service.ts index dffed6e1..4bf32af3 100644 --- a/src/app/game/game-board/services/game-state.service.ts +++ b/src/app/game/game-board/services/game-state.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable, Subject } from 'rxjs'; -import { DifficultyLevel, DIFFICULTY_PRESETS, GamePhase, GameState, INITIAL_GAME_STATE, INTEREST_CONFIG, STREAK_BONUS_PER_WAVE } from '../models/game-state.model'; +import { DifficultyLevel, DIFFICULTY_PRESETS, GamePhase, GameState, INITIAL_GAME_STATE, INTEREST_CONFIG, MAX_STREAK_BONUS_PER_WAVE, STREAK_BONUS_PER_WAVE } from '../models/game-state.model'; import { GameModifier, ModifierEffects, mergeModifierEffects, calculateModifierScoreMultiplier } from '../models/game-modifier.model'; import { SerializableGameState } from '../models/encounter-checkpoint.model'; import { TowerType, TOWER_CONFIGS } from '../models/tower.model'; @@ -111,13 +111,15 @@ export class GameStateService { /** * Increments the no-leak streak counter and awards a streak bonus. * Call this when a wave completes with zero leaks. - * Bonus gold = STREAK_BONUS_PER_WAVE * consecutiveWavesWithoutLeak (after increment). + * Bonus gold = STREAK_BONUS_PER_WAVE × streak, capped at MAX_STREAK_BONUS_PER_WAVE + * so long perfect clears reward skill without compounding into runaway wealth. * Returns the gold bonus awarded (0 if not in COMBAT phase). */ addStreakBonus(): number { if (this.state.phase !== GamePhase.COMBAT) return 0; this.state.consecutiveWavesWithoutLeak++; - const bonus = STREAK_BONUS_PER_WAVE * this.state.consecutiveWavesWithoutLeak; + const rawBonus = STREAK_BONUS_PER_WAVE * this.state.consecutiveWavesWithoutLeak; + const bonus = Math.min(rawBonus, MAX_STREAK_BONUS_PER_WAVE); this.addGoldAndScore(bonus); return bonus; } diff --git a/src/app/game/game-board/services/tower-combat.service.spec.ts b/src/app/game/game-board/services/tower-combat.service.spec.ts index f8dfffbb..d799458f 100644 --- a/src/app/game/game-board/services/tower-combat.service.spec.ts +++ b/src/app/game/game-board/services/tower-combat.service.spec.ts @@ -2676,6 +2676,48 @@ describe('TowerCombatService checkpoint serialization', () => { scene.clear(); }); + + it('includes placerLevel in serialized zone so kill attribution is correct on resume', () => { + const scene = new THREE.Scene(); + svc.registerTower(10, 12, TowerType.MORTAR, new THREE.Group()); + + const enemy = createTestEnemy('e1', -0.5, 0, 10000); + localEnemyMap.set('e1', enemy); + + svc.fireTurn(scene, 1); + + const zones = svc.serializeMortarZones(); + expect(zones.length).toBeGreaterThan(0); + // placerLevel must be present and numeric (defaults to L1 from a freshly registered tower). + expect(typeof zones[0].placerLevel).toBe('number'); + expect(zones[0].placerLevel).toBeGreaterThanOrEqual(1); + + scene.clear(); + }); + + it('round-trips placerLevel through serialize → restore → serialize', () => { + // Manually inject a zone with a non-trivial placerLevel (L2). + const scene = new THREE.Scene(); + svc.registerTower(10, 12, TowerType.MORTAR, new THREE.Group()); + + const enemy = createTestEnemy('e1', -0.5, 0, 10000); + localEnemyMap.set('e1', enemy); + + svc.fireTurn(scene, 1); + + const zones = svc.serializeMortarZones(); + // Simulate a level-2 tower zone by patching placerLevel on the serialized snapshot. + const patchedZones = zones.map(z => ({ ...z, placerLevel: 2 })); + + // Restore the patched snapshot, then re-serialize. + svc.restoreMortarZones(patchedZones); + const reserialized = svc.serializeMortarZones(); + + expect(reserialized.length).toBe(zones.length); + expect(reserialized[0].placerLevel).toBe(2); + + scene.clear(); + }); }); describe('restoreTowers', () => { diff --git a/src/app/game/game-board/services/tower-combat.service.ts b/src/app/game/game-board/services/tower-combat.service.ts index d5778c14..cd9b2193 100644 --- a/src/app/game/game-board/services/tower-combat.service.ts +++ b/src/app/game/game-board/services/tower-combat.service.ts @@ -1289,6 +1289,10 @@ export class TowerCombatService { dotDamage: z.dotDamage, expiresOnTurn: z.expiresOnTurn, ...(z.statusEffect !== undefined && { statusEffect: z.statusEffect }), + // placerLevel is required at runtime (used for kill-attribution tier labels). + // Serialize it so restored zones attribute kills at the correct tower level + // instead of falling back to the level-1 default in restoreMortarZones. + ...(z.placerLevel !== undefined && { placerLevel: z.placerLevel }), })); } diff --git a/src/app/game/game-board/services/wave-combat-facade.service.spec.ts b/src/app/game/game-board/services/wave-combat-facade.service.spec.ts index ed3b0445..a3ef3f69 100644 --- a/src/app/game/game-board/services/wave-combat-facade.service.spec.ts +++ b/src/app/game/game-board/services/wave-combat-facade.service.spec.ts @@ -599,7 +599,10 @@ describe('WaveCombatFacadeService', () => { gameStateService.getState.and.callFake(() => ({ ...defaultState, wave: 2, phase: GamePhase.COMBAT })); }); service.startWave(); - expect(bossBannerService.flash).toHaveBeenCalledWith('⚠ BOSS INCOMING'); + expect(bossBannerService.flash).toHaveBeenCalledWith( + '⚠ BOSS INCOMING', + jasmine.objectContaining({ subtext: '' }), + ); expect(audioService.playBossRoar).toHaveBeenCalled(); }); @@ -614,7 +617,10 @@ describe('WaveCombatFacadeService', () => { gameStateService.getState.and.callFake(() => ({ ...defaultState, wave: 1, phase: GamePhase.COMBAT })); }); service.startWave(); - expect(bossBannerService.flash).toHaveBeenCalledWith('⚠ THE SOVEREIGN MANIFESTS'); + expect(bossBannerService.flash).toHaveBeenCalledWith( + '⚠ THE SOVEREIGN MANIFESTS', + jasmine.objectContaining({ subtext: jasmine.any(String) }), + ); }); it('triggers screen shake when a boss wave starts', () => { @@ -660,6 +666,34 @@ describe('WaveCombatFacadeService', () => { service.startWave(); expect(musicService.playTheme).not.toHaveBeenCalledWith('boss'); }); + + it('flashes boss banner when wave has WYRM_ASCENDANT type', () => { + runService.getCurrentEncounter.and.returnValue( + makeEncounterWithWaves([ + makeWaveWithEnemies(EnemyType.WYRM_ASCENDANT), + ]) as never + ); + gameStateService.getState.and.callFake(() => ({ ...defaultState, wave: 1, phase: GamePhase.INTERMISSION })); + gameStateService.startWave.and.callFake(() => { + gameStateService.getState.and.callFake(() => ({ ...defaultState, wave: 1, phase: GamePhase.COMBAT })); + }); + service.startWave(); + // Banner must fire (WYRM_ASCENDANT now counts as a boss wave). + expect(bossBannerService.flash).toHaveBeenCalled(); + expect(audioService.playBossRoar).toHaveBeenCalled(); + }); + + it('triggers boss music when WYRM_ASCENDANT wave starts', () => { + runService.getCurrentEncounter.and.returnValue( + makeEncounterWithWaves([makeWaveWithEnemies(EnemyType.WYRM_ASCENDANT)]) as never + ); + gameStateService.getState.and.callFake(() => ({ ...defaultState, wave: 1, phase: GamePhase.INTERMISSION })); + gameStateService.startWave.and.callFake(() => { + gameStateService.getState.and.callFake(() => ({ ...defaultState, wave: 1, phase: GamePhase.COMBAT })); + }); + service.startWave(); + expect(musicService.playTheme).toHaveBeenCalledWith('boss'); + }); }); describe('cleanup()', () => { diff --git a/src/app/game/game-board/services/wave-combat-facade.service.ts b/src/app/game/game-board/services/wave-combat-facade.service.ts index 7ddc3167..22d906fc 100644 --- a/src/app/game/game-board/services/wave-combat-facade.service.ts +++ b/src/app/game/game-board/services/wave-combat-facade.service.ts @@ -33,7 +33,7 @@ import { TowerGraphService } from './tower-graph.service'; import { BossBannerService } from './boss-banner.service'; import { EnemyType } from '../models/enemy.model'; import { getWaveEnemyTypes } from '../models/wave.model'; -import { BOSS_BANNER_COPY } from '../constants/ui.constants'; +import { BOSS_BANNER_COPY, UI_CONFIG } from '../constants/ui.constants'; import { MusicService } from '../../../core/services/music.service'; /** Callbacks that WaveCombatFacadeService calls back into the component for concerns @@ -209,14 +209,20 @@ export class WaveCombatFacadeService { if (!waveDef) return; const types = getWaveEnemyTypes(waveDef); - const hasBoss = types.has(EnemyType.BOSS) || types.has(EnemyType.NOVA_SOVEREIGN); + const hasBoss = types.has(EnemyType.BOSS) + || types.has(EnemyType.NOVA_SOVEREIGN) + || types.has(EnemyType.WYRM_ASCENDANT); if (!hasBoss) return; - const copy = types.has(EnemyType.NOVA_SOVEREIGN) + const entry = types.has(EnemyType.NOVA_SOVEREIGN) ? BOSS_BANNER_COPY.novaSovereign - : BOSS_BANNER_COPY.generic; - - this.bossBanner.flash(copy); + : types.has(EnemyType.WYRM_ASCENDANT) + ? BOSS_BANNER_COPY.wyrmAscendant + : BOSS_BANNER_COPY.generic; + const durationMs = entry.subtext + ? UI_CONFIG.bossBannerExtendedMs + : UI_CONFIG.bossBannerVisibleMs; + this.bossBanner.flash(entry.headline, { subtext: entry.subtext, durationMs }); this.audioService.playBossRoar(); this.screenShakeService.trigger(0.25, 0.6); this.musicService.playTheme('boss'); diff --git a/src/app/landing/landing.component.html b/src/app/landing/landing.component.html index 32d533f7..5e685c35 100644 --- a/src/app/landing/landing.component.html +++ b/src/app/landing/landing.component.html @@ -130,5 +130,5 @@

NOVARISE

- v0.2.0 · turn-based pivot + v1.0.0 diff --git a/src/app/profile/profile.component.spec.ts b/src/app/profile/profile.component.spec.ts index 3ff74290..5370b6fa 100644 --- a/src/app/profile/profile.component.spec.ts +++ b/src/app/profile/profile.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { Router } from '@angular/router'; import { ProfileComponent, DISPLAY_ACHIEVEMENTS } from './profile.component'; import { @@ -46,7 +47,8 @@ describe('ProfileComponent', () => { providers: [ { provide: Router, useValue: router }, { provide: PlayerProfileService, useValue: profileService }, - ] + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(ProfileComponent); @@ -122,6 +124,25 @@ describe('ProfileComponent', () => { expect(newFixture.componentInstance.unlockedCount).toBe(1); }); + it('unlockedCount excludes campaign achievement IDs (no campaign mode in run mode)', () => { + profileService.getProfile.and.returnValue({ + ...mockProfile, + achievements: ['first_victory', 'act_1_complete', 'campaign_champion', 'star_collector'], + }); + const newFixture = TestBed.createComponent(ProfileComponent); + newFixture.detectChanges(); + // Campaign ids are dead — only 'first_victory' counts + expect(newFixture.componentInstance.unlockedCount).toBe(1); + }); + + it('DISPLAY_ACHIEVEMENTS does not contain any dead campaign achievement IDs', () => { + const deadCampaignIds = ['act_1_complete', 'act_2_complete', 'act_3_complete', 'campaign_champion', 'star_collector']; + const displayIds = DISPLAY_ACHIEVEMENTS.map((a) => a.id); + for (const id of deadCampaignIds) { + expect(displayIds).not.toContain(id); + } + }); + it('achievementProgressPct uses DISPLAY_ACHIEVEMENTS.length as denominator', () => { // 2 live achievements unlocked out of DISPLAY_ACHIEVEMENTS.length total expect(component.achievementProgressPct).toBe( @@ -241,8 +262,10 @@ describe('ProfileComponent', () => { }); it('should compute correct achievementProgressPct', () => { - // 2 unlocked out of 20 displayable achievements = 10% - expect(component.achievementProgressPct).toBe(10); + // 2 unlocked out of DISPLAY_ACHIEVEMENTS.length displayable achievements + expect(component.achievementProgressPct).toBe( + Math.round((2 / DISPLAY_ACHIEVEMENTS.length) * 100) + ); }); it('should show rank "Defender" for 3 achievements', () => { @@ -255,11 +278,15 @@ describe('ProfileComponent', () => { expect(newFixture.componentInstance.rankTitle).toBe('Defender'); }); - it('should show rank "Novarise" for 25+ achievements', () => { - const twentyFive = Array.from({ length: 25 }, (_, i) => `ach_${i}`); + it('should show rank "Novarise" for 100% completion (all earnable achievements)', () => { + // Top rank requires unlocking all earnable achievements (DISPLAY_ACHIEVEMENTS.length). + const allAchievements = Array.from( + { length: DISPLAY_ACHIEVEMENTS.length }, + (_, i) => `ach_${i}` + ); profileService.getProfile.and.returnValue({ ...mockProfile, - achievements: twentyFive, + achievements: allAchievements, }); const newFixture = TestBed.createComponent(ProfileComponent); newFixture.detectChanges(); @@ -320,15 +347,15 @@ describe('ProfileComponent', () => { // ── Category grouping ────────────────────────────────────────────────────── describe('category groups', () => { - it('should build 3 category groups (endless excluded — no endless mode in run mode)', () => { - expect(component.categoryGroups.length).toBe(3); + it('should build 2 category groups (campaign and endless excluded — neither mode exists in run mode)', () => { + expect(component.categoryGroups.length).toBe(2); }); - it('should include campaign, combat, and challenge categories', () => { + it('should include combat and challenge categories; campaign and endless must be absent', () => { const categories = component.categoryGroups.map((g) => g.category) as AchievementCategory[]; - expect(categories).toContain('campaign'); expect(categories).toContain('combat'); expect(categories).toContain('challenge'); + expect(categories).not.toContain('campaign'); expect(categories).not.toContain('endless'); }); @@ -337,7 +364,6 @@ describe('ProfileComponent', () => { for (const g of component.categoryGroups) { labelMap[g.category] = g.label; } - expect(labelMap['campaign']).toBe('Campaign'); expect(labelMap['combat']).toBe('Combat'); expect(labelMap['challenge']).toBe('Challenge'); }); @@ -354,34 +380,33 @@ describe('ProfileComponent', () => { const combatGroup = component.categoryGroups.find((g) => g.category === 'combat')!; expect(combatGroup.unlockedCount).toBe(2); - const campaignGroup = component.categoryGroups.find((g) => g.category === 'campaign')!; - expect(campaignGroup.unlockedCount).toBe(0); - const challengeGroup = component.categoryGroups.find((g) => g.category === 'challenge')!; expect(challengeGroup.unlockedCount).toBe(0); - // endless category is excluded from display (no endless mode in run mode) + // campaign and endless categories are excluded from display + const campaignGroup = component.categoryGroups.find((g) => g.category === 'campaign'); + expect(campaignGroup).toBeUndefined(); const endlessGroup = component.categoryGroups.find((g) => g.category === 'endless'); expect(endlessGroup).toBeUndefined(); }); it('should render a category header for each group', () => { const headers = fixture.nativeElement.querySelectorAll('.category-header'); - expect(headers.length).toBe(3); + expect(headers.length).toBe(2); }); it('should render category name elements', () => { const names = fixture.nativeElement.querySelectorAll('.category-name'); const texts = Array.from(names).map((el) => (el as Element).textContent!.trim()); - expect(texts).toContain('Campaign'); expect(texts).toContain('Combat'); expect(texts).toContain('Challenge'); + expect(texts).not.toContain('Campaign'); expect(texts).not.toContain('Endless'); }); it('should render category count elements', () => { const counts = fixture.nativeElement.querySelectorAll('.category-count'); - expect(counts.length).toBe(3); + expect(counts.length).toBe(2); }); it('each group should only contain achievements of its category', () => { diff --git a/src/app/profile/profile.component.ts b/src/app/profile/profile.component.ts index f6565cb0..6506e57e 100644 --- a/src/app/profile/profile.component.ts +++ b/src/app/profile/profile.component.ts @@ -34,8 +34,10 @@ const CATEGORY_LABELS: Record = { // Endless mode does not exist in run mode — survivor/endless_30/endless_50/endless_100 // are dev/test-only and cannot be earned. Three-star scoring (three_star_5/three_star_all) // requires a per-map star system that is also unreachable in run mode. -// These ids are retained in the achievement model for data-migration safety but are -// excluded from all display surfaces here. +// Campaign-specific achievements (act_1_complete/act_2_complete/act_3_complete/ +// campaign_champion/star_collector) require per-map campaign progression that no longer +// exists in the unified run mode. All of these ids are retained in the achievement model +// for data-migration safety but are excluded from all display surfaces here. const DEAD_ACHIEVEMENT_IDS = new Set([ 'survivor', 'endless_30', @@ -43,6 +45,11 @@ const DEAD_ACHIEVEMENT_IDS = new Set([ 'endless_100', 'three_star_5', 'three_star_all', + 'act_1_complete', + 'act_2_complete', + 'act_3_complete', + 'campaign_champion', + 'star_collector', ]); /** Achievements reachable in run mode — excludes endless and three-star campaign entries. */ @@ -50,7 +57,9 @@ export const DISPLAY_ACHIEVEMENTS: Achievement[] = ACHIEVEMENTS.filter( (a) => !DEAD_ACHIEVEMENT_IDS.has(a.id) ); -const CATEGORY_ORDER: AchievementCategory[] = ['campaign', 'combat', 'challenge']; +// The 'campaign' category is excluded because all campaign achievements are dead +// (campaign progression does not exist in run mode). Only non-empty categories are shown. +const CATEGORY_ORDER: AchievementCategory[] = ['combat', 'challenge']; const ALL_TOWER_TYPES: TowerType[] = [ TowerType.BASIC, @@ -61,14 +70,26 @@ const ALL_TOWER_TYPES: TowerType[] = [ TowerType.MORTAR, ]; -const RANK_THRESHOLDS: { min: number; title: string }[] = [ - { min: 25, title: 'Novarise' }, - { min: 19, title: 'Champion' }, - { min: 13, title: 'Elite' }, - { min: 7, title: 'Commander' }, - { min: 3, title: 'Defender' }, - { min: 0, title: 'Recruit' }, -]; +/** + * Rank thresholds. The top rank's minimum equals DISPLAY_ACHIEVEMENTS.length + * so 100% completion always reaches 'Novarise'. Each lower tier is a distinct + * fraction of the total, ensured to be strictly decreasing to avoid ties. + */ +function buildRankThresholds(total: number): { min: number; title: string }[] { + // Compute strictly-decreasing thresholds to avoid two ranks sharing the same min. + const champion = Math.max(Math.floor(total * 0.75), 1); + const elite = Math.max(Math.floor(total * 0.5), 1); + const commander = Math.max(Math.floor(total * 0.3), 1); + const defender = Math.max(Math.floor(total * 0.2), 1); + return [ + { min: total, title: 'Novarise' }, + { min: champion, title: 'Champion' }, + { min: elite, title: 'Elite' }, + { min: commander, title: 'Commander' }, + { min: defender, title: 'Defender' }, + { min: 0, title: 'Recruit' }, + ]; +} @Component({ selector: 'app-profile', @@ -155,7 +176,7 @@ export class ProfileComponent implements OnInit { get rankTitle(): string { const count = this.unlockedCount; - for (const threshold of RANK_THRESHOLDS) { + for (const threshold of buildRankThresholds(DISPLAY_ACHIEVEMENTS.length)) { if (count >= threshold.min) { return threshold.title; } diff --git a/src/app/run/components/act-transition/act-transition.component.scss b/src/app/run/components/act-transition/act-transition.component.scss index 216dbf06..2df9d1a8 100644 --- a/src/app/run/components/act-transition/act-transition.component.scss +++ b/src/app/run/components/act-transition/act-transition.component.scss @@ -177,3 +177,12 @@ &:hover { box-shadow: var(--shadow-sm) var(--theme-purple-glow); } } } + +body.reduce-motion .act-transition { + animation: none; +} + +body.reduce-motion .act-transition__btn { + transition: none; + &:hover { box-shadow: var(--shadow-sm) var(--theme-purple-glow); } +} diff --git a/src/app/run/components/card-draft/card-draft.component.html b/src/app/run/components/card-draft/card-draft.component.html index ed92fb35..535d082c 100644 --- a/src/app/run/components/card-draft/card-draft.component.html +++ b/src/app/run/components/card-draft/card-draft.component.html @@ -5,7 +5,7 @@

Choose a Card

diff --git a/src/app/run/components/event-screen/event-screen.component.scss b/src/app/run/components/event-screen/event-screen.component.scss index 3fe42ac5..aea87fb2 100644 --- a/src/app/run/components/event-screen/event-screen.component.scss +++ b/src/app/run/components/event-screen/event-screen.component.scss @@ -289,3 +289,11 @@ transition: none; } } + +body.reduce-motion .event-outcome { + animation: none; +} + +body.reduce-motion .event-choice-btn { + transition: none; +} diff --git a/src/app/run/components/event-screen/event-screen.component.spec.ts b/src/app/run/components/event-screen/event-screen.component.spec.ts index 53de5c02..6fa2e5a7 100644 --- a/src/app/run/components/event-screen/event-screen.component.spec.ts +++ b/src/app/run/components/event-screen/event-screen.component.spec.ts @@ -92,6 +92,30 @@ describe('EventScreenComponent', () => { expect(spy).not.toHaveBeenCalled(); }); + it('emits previewCardRemoval with the choice index for removeCard outcomes', () => { + component.event = makeEvent({ + choices: [ + { + label: 'Remove', + description: 'Remove a card.', + outcome: makeOutcome({ removeCard: true }), + }, + ], + }); + + const spy = jasmine.createSpy('previewCardRemoval'); + component.previewCardRemoval.subscribe(spy); + component.makeChoice(0); + expect(spy).toHaveBeenCalledWith(0); + }); + + it('does not emit previewCardRemoval for non-removeCard outcomes', () => { + const spy = jasmine.createSpy('previewCardRemoval'); + component.previewCardRemoval.subscribe(spy); + component.makeChoice(0); // goldDelta choice — no removeCard + expect(spy).not.toHaveBeenCalled(); + }); + it('emits previewGamble with the choice index for gamble outcomes', () => { component.event = makeEvent({ choices: [ @@ -247,6 +271,17 @@ describe('EventScreenComponent', () => { }); }); + describe('removedCardName input', () => { + it('defaults to null', () => { + expect(component.removedCardName).toBeNull(); + }); + + it('can be set to a card name string', () => { + component.removedCardName = 'Pip · Basic'; + expect(component.removedCardName).toBe('Pip · Basic'); + }); + }); + describe('gamble outcome resolution', () => { function makeGambleEvent(winChance: number): RunEvent { return makeEvent({ diff --git a/src/app/run/components/event-screen/event-screen.component.ts b/src/app/run/components/event-screen/event-screen.component.ts index d270ea9d..9de3e442 100644 --- a/src/app/run/components/event-screen/event-screen.component.ts +++ b/src/app/run/components/event-screen/event-screen.component.ts @@ -18,9 +18,19 @@ export class EventScreenComponent { */ @Input() resolvedGamble: { goldDelta: number; livesDelta: number } | null = null; + /** + * Display name of the card that will be removed when the outcome has removeCard. + * Supplied by the parent (RunComponent) after it calls + * RunService.previewEventCardRemoval(). Null when no card removal is pending. + */ + @Input() removedCardName: string | null = null; + /** Emitted when the player picks a choice that has a gamble field, before confirming. */ @Output() previewGamble = new EventEmitter(); + /** Emitted when the player picks a choice that has a removeCard outcome, before confirming. */ + @Output() previewCardRemoval = new EventEmitter(); + @Output() choiceMade = new EventEmitter(); selectedChoice: number | null = null; @@ -57,6 +67,9 @@ export class EventScreenComponent { if (outcome?.gamble) { this.previewGamble.emit(index); } + if (outcome?.removeCard) { + this.previewCardRemoval.emit(index); + } } confirmChoice(): void { diff --git a/src/app/run/components/rest-screen/rest-screen.component.html b/src/app/run/components/rest-screen/rest-screen.component.html index 2d52d246..a76bf0c7 100644 --- a/src/app/run/components/rest-screen/rest-screen.component.html +++ b/src/app/run/components/rest-screen/rest-screen.component.html @@ -58,7 +58,10 @@

Select a card to upgrade

(selected)="selectCardToUpgrade(card)" > diff --git a/src/app/run/components/reward-screen/reward-screen.component.html b/src/app/run/components/reward-screen/reward-screen.component.html index 9f0fe110..d996c3f2 100644 --- a/src/app/run/components/reward-screen/reward-screen.component.html +++ b/src/app/run/components/reward-screen/reward-screen.component.html @@ -60,7 +60,7 @@

Choose a Relic

class="reward-card" [class]="'reward-card reward-card--' + getRarityClass(relic.rarity)" [class.reward-card--selected]="selectedRelic === relic.id" - *ngFor="let relic of relicCards; let i = index" + *ngFor="let relic of relicCards; let i = index; trackBy: trackByRelicId" (click)="pickRelic(relic)" [attr.aria-label]="'Option ' + (i + 1) + '. ' + relic.name + ': ' + relic.description + '. Rarity: ' + relic.rarity + '. Press ' + (i + 1) + ' to pick.'" [attr.aria-pressed]="selectedRelic === relic.id" diff --git a/src/app/run/components/reward-screen/reward-screen.component.scss b/src/app/run/components/reward-screen/reward-screen.component.scss index 7b7acb33..558f5682 100644 --- a/src/app/run/components/reward-screen/reward-screen.component.scss +++ b/src/app/run/components/reward-screen/reward-screen.component.scss @@ -560,3 +560,18 @@ app-card-draft { grid-area: draft; } animation: none; } } + +// In-app reduce-motion toggle (body.reduce-motion) — mirrors the @media block +// above so the SettingsService toggle and the OS preference share one outcome. +body.reduce-motion { + .reward-card { + animation: none; + transition: none; + + &:hover:not(:disabled) { transform: none; } + } + + .reward-archetype__chip--flipping { + animation: none; + } +} diff --git a/src/app/run/components/reward-screen/reward-screen.component.spec.ts b/src/app/run/components/reward-screen/reward-screen.component.spec.ts index db6a2171..65ee156c 100644 --- a/src/app/run/components/reward-screen/reward-screen.component.spec.ts +++ b/src/app/run/components/reward-screen/reward-screen.component.spec.ts @@ -196,6 +196,17 @@ describe('RewardScreenComponent', () => { expect(component.relicCards[2].id).toBe(RelicId.COMMANDERS_BANNER); }); + it('relicCards returns a stable reference across accesses (no per-CD re-create)', () => { + // Stable references stop the relic *ngFor from rebuilding its DOM (and + // replaying the entrance animation) on every change-detection pass. + expect(component.relicCards).toBe(component.relicCards); + }); + + it('trackByRelicId returns the relic id', () => { + const relic = component.relicCards[0]; + expect(component.trackByRelicId(0, relic)).toBe(relic.id); + }); + // ── Card draft integration ───────────────────────────────────────────── it('canContinue is false when relic and card sections are both unresolved', () => { @@ -714,5 +725,30 @@ describe('RewardScreenComponent', () => { // Reaching here without a fakeAsync error is the assertion. expect(true).toBeTrue(); })); + + it('does not schedule the animation when body.reduce-motion class is set', () => { + // OS preference is off; in-app toggle is on. + (window.matchMedia as jasmine.Spy).and.returnValue({ + matches: false, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + } as unknown as MediaQueryList); + + document.body.classList.add('reduce-motion'); + try { + component.config = { ...MOCK_CONFIG, dominantArchetype: 'conduit', previousDominantArchetype: 'neutral' }; + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.isArchetypeFlipping).toBeFalse(); + } finally { + document.body.classList.remove('reduce-motion'); + } + }); }); }); diff --git a/src/app/run/components/reward-screen/reward-screen.component.ts b/src/app/run/components/reward-screen/reward-screen.component.ts index 846c6c43..9f2da8fc 100644 --- a/src/app/run/components/reward-screen/reward-screen.component.ts +++ b/src/app/run/components/reward-screen/reward-screen.component.ts @@ -50,11 +50,29 @@ export class RewardScreenComponent implements OnInit, OnDestroy { return SKIP_GOLD_BY_NODE_TYPE[this.config?.nodeType] ?? 0; } - /** Resolve relic definitions from reward IDs for display. */ + private relicCardsCache: RelicDefinition[] = []; + private relicCardsSource: RewardScreenConfig['relicChoices'] | null = null; + + /** + * Relic definitions resolved from the reward IDs, memoized on the + * config.relicChoices reference. A plain getter allocated a new array per + * call, so the relic *ngFor re-created its DOM on every change-detection pass + * — flickering the card entrance animation whenever a key was held on the + * reward screen. + */ get relicCards(): RelicDefinition[] { - return this.config.relicChoices - .map(r => RELIC_DEFINITIONS[r.relicId]) - .filter((r): r is RelicDefinition => r !== undefined); + if (this.relicCardsSource !== this.config.relicChoices) { + this.relicCardsSource = this.config.relicChoices; + this.relicCardsCache = this.config.relicChoices + .map(r => RELIC_DEFINITIONS[r.relicId]) + .filter((r): r is RelicDefinition => r !== undefined); + } + return this.relicCardsCache; + } + + /** Stable *ngFor identity so change detection never re-creates relic DOM. */ + trackByRelicId(_index: number, relic: RelicDefinition): RelicId { + return relic.id; } getRarityClass(rarity: RelicRarity): string { @@ -136,11 +154,14 @@ export class RewardScreenComponent implements OnInit, OnDestroy { } /** - * Read the user's motion-reduction preference at animation start. Pulled - * into a method so tests can stub it without touching the global - * matchMedia surface. + * Returns true when the user has opted into reduced motion via either the + * OS-level media query or the in-app toggle (body.reduce-motion class). + * Pulled into a method so tests can stub it without touching global state. */ private prefersReducedMotion(): boolean { + if (typeof document !== 'undefined' && document.body.classList.contains('reduce-motion')) { + return true; + } if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { return false; } diff --git a/src/app/run/components/run-epilogue/run-epilogue.component.html b/src/app/run/components/run-epilogue/run-epilogue.component.html index 2e950e4b..f3b80e9d 100644 --- a/src/app/run/components/run-epilogue/run-epilogue.component.html +++ b/src/app/run/components/run-epilogue/run-epilogue.component.html @@ -7,19 +7,12 @@

THE SPIRE STANDS

+ +
-

- The Sovereign's siege lattice collapsed at 0300 hours. - Uplink confirmed: the Spire's core transmitters are intact. -

-

- What they sent against us was their best—and it wasn't enough. - The line held. Engineers are already pulling signal data from - the wreckage. The war is not over, but tonight, the Spire endures. -

-

- — Field Debrief, Relay Station Gamma-7 -

+

{{ epilogueCopy.line1 }}

+

{{ epilogueCopy.line2 }}

+

{{ epilogueCopy.attribution }}

diff --git a/src/app/run/components/run-epilogue/run-epilogue.component.spec.ts b/src/app/run/components/run-epilogue/run-epilogue.component.spec.ts index cdc36bc8..e523cdf1 100644 --- a/src/app/run/components/run-epilogue/run-epilogue.component.spec.ts +++ b/src/app/run/components/run-epilogue/run-epilogue.component.spec.ts @@ -1,12 +1,13 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { CommonModule } from '@angular/common'; import { RunEpilogueComponent } from './run-epilogue.component'; +import { getEpilogueCopy } from '../../constants/epilogue-copy.constants'; describe('RunEpilogueComponent', () => { let fixture: ComponentFixture; let component: RunEpilogueComponent; - function setup(unlockedAscensionLevel = 0): void { + function setup(unlockedAscensionLevel = 0, bossPresetId = ''): void { TestBed.configureTestingModule({ declarations: [RunEpilogueComponent], imports: [CommonModule], @@ -14,6 +15,7 @@ describe('RunEpilogueComponent', () => { fixture = TestBed.createComponent(RunEpilogueComponent); component = fixture.componentInstance; component.unlockedAscensionLevel = unlockedAscensionLevel; + component.bossPresetId = bossPresetId; fixture.detectChanges(); } @@ -32,12 +34,6 @@ describe('RunEpilogueComponent', () => { expect(el.textContent).toContain('THE SPIRE STANDS'); }); - it('renders the epilogue copy referencing the Nova Sovereign', () => { - setup(); - const el = fixture.nativeElement as HTMLElement; - expect(el.textContent).toContain("Sovereign"); - }); - it('renders a Continue button', () => { setup(); const el = fixture.nativeElement as HTMLElement; @@ -48,7 +44,6 @@ describe('RunEpilogueComponent', () => { it('emits continued when Continue button is clicked', () => { setup(); - // Force button visible so click is enabled component.buttonVisible = true; fixture.detectChanges(); @@ -69,7 +64,6 @@ describe('RunEpilogueComponent', () => { it('shows ascension unlock line when unlockedAscensionLevel > 0', () => { setup(3); - // Make it visible component.ascensionVisible = true; fixture.detectChanges(); @@ -113,7 +107,6 @@ describe('RunEpilogueComponent', () => { fixture.detectChanges(); const el = fixture.nativeElement as HTMLElement; const epilogue = el.querySelector('.run-epilogue'); - // The CSS handles instant-visibility; we just verify the component rendered expect(epilogue).not.toBeNull(); document.body.classList.remove('reduce-motion'); @@ -122,7 +115,73 @@ describe('RunEpilogueComponent', () => { it('clears timeouts on destroy', fakeAsync(() => { setup(); fixture.destroy(); - // tick past all delays — no errors should be thrown tick(3000); })); + + // ── bossPresetId input ──────────────────────────────────────────────────── + + it('bossPresetId defaults to empty string', () => { + setup(); + expect(component.bossPresetId).toBe(''); + }); + + it('epilogueCopy is resolved during ngOnInit based on bossPresetId', () => { + setup(0, 'vanguard_convergence'); + const expected = getEpilogueCopy('vanguard_convergence'); + expect(component.epilogueCopy).toEqual(expected); + }); + + it('epilogueCopy falls back to neutral when bossPresetId is empty', () => { + setup(); + const expected = getEpilogueCopy(''); + expect(component.epilogueCopy).toEqual(expected); + }); + + it('renders neutral fallback copy when bossPresetId is empty (default experience)', () => { + setup(); + component.copyVisible = true; + fixture.detectChanges(); + + const el = fixture.nativeElement as HTMLElement; + const copy = el.querySelector('.run-epilogue__copy'); + // Original text references the Nova Sovereign + expect(copy?.textContent).toContain('Sovereign'); + }); + + it('renders vanguard_convergence copy when bossPresetId matches', () => { + setup(0, 'vanguard_convergence'); + component.copyVisible = true; + fixture.detectChanges(); + + const el = fixture.nativeElement as HTMLElement; + const copy = el.querySelector('.run-epilogue__copy'); + // Vanguard copy references Titans in the narrative + expect(copy?.textContent?.toLowerCase()).toContain('titan'); + }); + + it('renders celestial_deluge copy when bossPresetId matches', () => { + setup(0, 'celestial_deluge'); + component.copyVisible = true; + fixture.detectChanges(); + + const el = fixture.nativeElement as HTMLElement; + const copy = el.querySelector('.run-epilogue__copy'); + expect(copy?.textContent?.toLowerCase()).toMatch(/fly|air|sky/); + }); + + it('renders ironclad_march copy when bossPresetId matches', () => { + setup(0, 'ironclad_march'); + component.copyVisible = true; + fixture.detectChanges(); + + const el = fixture.nativeElement as HTMLElement; + const copy = el.querySelector('.run-epilogue__copy'); + expect(copy?.textContent?.toLowerCase()).toContain('march'); + }); + + it('falls back to neutral when bossPresetId is an unknown id', () => { + setup(0, 'nonexistent_boss_id'); + const expected = getEpilogueCopy(''); + expect(component.epilogueCopy).toEqual(expected); + }); }); diff --git a/src/app/run/components/run-epilogue/run-epilogue.component.ts b/src/app/run/components/run-epilogue/run-epilogue.component.ts index d2be6866..6b1fcd85 100644 --- a/src/app/run/components/run-epilogue/run-epilogue.component.ts +++ b/src/app/run/components/run-epilogue/run-epilogue.component.ts @@ -1,4 +1,5 @@ import { Component, Input, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core'; +import { EpilogueCopy, getEpilogueCopy } from '../../constants/epilogue-copy.constants'; // ── Timing constants ────────────────────────────────────────────────────────── /** Delay before the title stage becomes visible (ms). */ @@ -24,6 +25,17 @@ export class RunEpilogueComponent implements OnInit, OnDestroy { */ @Input() unlockedAscensionLevel = 0; + /** + * The `BossPreset.id` of the Act-3 boss that was just defeated. + * Selects the matching debrief variant from EPILOGUE_COPY. + * Defaults to '' which renders the neutral fallback copy — preserving + * the existing appearance when no preset id is supplied. + * + * CROSS-BATCH CONTRACT: Batch 4 binds [bossPresetId] from run state in + * run.component.html. This component only reads it. + */ + @Input() bossPresetId = ''; + /** Emitted when the player clicks Continue — parent transitions to 'summary'. */ @Output() continued = new EventEmitter(); @@ -33,9 +45,13 @@ export class RunEpilogueComponent implements OnInit, OnDestroy { ascensionVisible = false; buttonVisible = false; + /** Resolved debrief copy for the current bossPresetId. */ + epilogueCopy: EpilogueCopy = getEpilogueCopy(''); + private timeouts: ReturnType[] = []; ngOnInit(): void { + this.epilogueCopy = getEpilogueCopy(this.bossPresetId); this.scheduleReveal(); } diff --git a/src/app/run/components/run-summary/run-summary.component.html b/src/app/run/components/run-summary/run-summary.component.html index 05c6f645..4b9c448e 100644 --- a/src/app/run/components/run-summary/run-summary.component.html +++ b/src/app/run/components/run-summary/run-summary.component.html @@ -11,7 +11,7 @@

- {{ isVictory ? 'Ascent Complete' : 'Run Failed' }} + {{ isVictory ? 'Run Complete' : 'Run Failed' }}

Journey > New Run + -
- -
- Act {{ savedRunSummary.act }} - - {{ savedRunSummary.encounters }} encounters - - {{ savedRunSummary.lives }} lives - - {{ savedRunSummary.relics }} relics -
-
- - -
- - - - -
-

- Ascension {{ selectedAscension }} — Active Modifiers -

-
    -
  • - {{ lvl.level }} - {{ lvl.description }} -
  • -
-
- - -
- -
@@ -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;