127 lines
4.7 KiB
TypeScript
127 lines
4.7 KiB
TypeScript
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(/救/);
|
|
});
|
|
});
|