import { BossController } from '@logic/BossController'; import { BANNED_RESCUE_SEQUENCE, ChapterSettlement } from '@logic/ChapterSettlement'; import { IBossConfig } from '@data/Interfaces'; const bossCfg: IBossConfig = { id: 'shuang_huan_fang', displayName: '双幻坊', hp: 3, butterflyReveal: true, princessCutsceneAtHpRatio: 0.5, phases: [ { hpThreshold: 1.0, mode: 'pair_pincer', actionIntervalSec: 2.2 }, { hpThreshold: 0.66, mode: 'fireball_spread', actionIntervalSec: 1.8 }, { hpThreshold: 0.33, mode: 'clone_confuse', actionIntervalSec: 1.4 }, ], }; describe('BossController — butterfly reveal (req 9.1-9.3)', () => { it('body hits are ignored until butterfly is hit', () => { const boss = new BossController(bossCfg); expect(boss.onBodyHit()).toEqual([]); expect(boss.currentHp).toBe(3); }); it('butterfly hit emits reveal event once', () => { const boss = new BossController(bossCfg); expect(boss.onButterflyHit()).toEqual([{ kind: 'butterfly_revealed' }]); expect(boss.onButterflyHit()).toEqual([]); // second hit is a no-op expect(boss.isButterflyRevealed).toBe(true); }); it('body hits after reveal decrement HP one at a time', () => { const boss = new BossController(bossCfg); boss.onButterflyHit(); boss.onBodyHit(); expect(boss.currentHp).toBe(2); }); }); describe('BossController — phase transitions (req 9.4)', () => { it('HP drop to 2/3 triggers phase_changed to fireball_spread', () => { const boss = new BossController(bossCfg); boss.onButterflyHit(); const events = boss.onBodyHit(); // 3 → 2 (ratio 0.66) expect(events.some((e) => e.kind === 'phase_changed' && e.phase === 'fireball_spread')).toBe(true); }); it('HP drop to 1/3 triggers clone_confuse', () => { const boss = new BossController(bossCfg); boss.onButterflyHit(); boss.onBodyHit(); const events = boss.onBodyHit(); // 2 → 1 (ratio 0.33) expect(events.some((e) => e.kind === 'phase_changed' && e.phase === 'clone_confuse')).toBe(true); }); }); describe('BossController — princess cutscene + death (req 8.6, 14.1)', () => { it('emits princess_taken_cutscene when HP reaches 1/2', () => { const boss = new BossController(bossCfg); boss.onButterflyHit(); const events = boss.onBodyHit(); // 3 → 2 → ratio 0.66 (> 0.5, no cutscene yet) expect(events.some((e) => e.kind === 'princess_taken_cutscene')).toBe(false); const events2 = boss.onBodyHit(); // 2 → 1 → ratio 0.33 (< 0.5) expect(events2.some((e) => e.kind === 'princess_taken_cutscene')).toBe(true); }); it('emits boss_killed on final hit and marks isDead', () => { const boss = new BossController(bossCfg); boss.onButterflyHit(); boss.onBodyHit(); boss.onBodyHit(); const events = boss.onBodyHit(); expect(events.some((e) => e.kind === 'boss_killed')).toBe(true); expect(boss.isDead).toBe(true); }); it('further body hits after death are no-ops', () => { const boss = new BossController(bossCfg); boss.onButterflyHit(); boss.onBodyHit(); boss.onBodyHit(); boss.onBodyHit(); expect(boss.onBodyHit()).toEqual([]); }); }); describe('ChapterSettlement — rope-cut ban (req 14.5)', () => { it('assertCutsceneAllowed throws on every banned id', () => { const cs = new ChapterSettlement({ totalScore: 0, stageScore: 0, comboCount: 0, flawless: true, remainingTimeSec: 0, }); for (const bad of BANNED_RESCUE_SEQUENCE) { expect(() => cs.assertCutsceneAllowed(bad)).toThrow(/banned/); } }); it('allows the legitimate cutscene ids', () => { const cs = new ChapterSettlement({ totalScore: 0, stageScore: 0, comboCount: 0, flawless: true, remainingTimeSec: 0, }); expect(() => cs.assertCutsceneAllowed('princess_taken')).not.toThrow(); expect(() => cs.assertCutsceneAllowed('boss_killed_freeze')).not.toThrow(); expect(() => cs.assertCutsceneAllowed('settlement_screen')).not.toThrow(); }); it('build() returns the "princess taken" closing line, not a rescue one', () => { const cs = new ChapterSettlement({ totalScore: 1000, stageScore: 500, comboCount: 2, flawless: true, remainingTimeSec: 10, }); const r = cs.build(); expect(r.closingLine).toMatch(/公主被带走/); expect(r.closingLine).not.toMatch(/救/); }); });