first commmit
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { AttackController, IJumpStateProvider } from '@logic/AttackController';
|
||||
import { WeaponType } from '@data/Interfaces';
|
||||
import { PlayerColorState } from '@common/Constants';
|
||||
|
||||
function makeJumpState(ts?: number): IJumpStateProvider {
|
||||
return {
|
||||
lastJumpPressTs: () => ts,
|
||||
isGrounded: () => ts === undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AttackController — mutual exclusion (req 3.1-3.3)', () => {
|
||||
it('first-pressed weapon wins when both buttons go down together', () => {
|
||||
const ac = new AttackController();
|
||||
ac.press(WeaponType.Shuriken, 10);
|
||||
ac.press(WeaponType.NinjaSword, 11);
|
||||
expect(ac.getActive()).toBe(WeaponType.Shuriken);
|
||||
});
|
||||
|
||||
it('releasing the active weapon transfers activation to the still-held one', () => {
|
||||
const ac = new AttackController();
|
||||
ac.press(WeaponType.Shuriken, 0);
|
||||
ac.press(WeaponType.NinjaSword, 10);
|
||||
ac.release(WeaponType.Shuriken);
|
||||
expect(ac.getActive()).toBe(WeaponType.NinjaSword);
|
||||
});
|
||||
|
||||
it('releasing the only pressed weapon deactivates everything', () => {
|
||||
const ac = new AttackController();
|
||||
ac.press(WeaponType.Shuriken, 0);
|
||||
ac.release(WeaponType.Shuriken);
|
||||
expect(ac.getActive()).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AttackController — firing intervals (req 3.4, 3.6)', () => {
|
||||
it('shuriken fires every 300ms for red/green, 250ms for yellow', () => {
|
||||
const ac = new AttackController();
|
||||
ac.press(WeaponType.Shuriken, 0);
|
||||
expect(ac.tick(0, PlayerColorState.Red).length).toBe(1);
|
||||
expect(ac.tick(299, PlayerColorState.Red).length).toBe(0);
|
||||
expect(ac.tick(300, PlayerColorState.Red).length).toBe(1);
|
||||
|
||||
const fast = new AttackController();
|
||||
fast.press(WeaponType.Shuriken, 0);
|
||||
fast.tick(0, PlayerColorState.Yellow);
|
||||
expect(fast.tick(249, PlayerColorState.Yellow).length).toBe(0);
|
||||
expect(fast.tick(250, PlayerColorState.Yellow).length).toBe(1);
|
||||
});
|
||||
|
||||
it('sword fires every 500ms', () => {
|
||||
const ac = new AttackController();
|
||||
ac.press(WeaponType.NinjaSword, 0);
|
||||
ac.tick(0, PlayerColorState.Red);
|
||||
expect(ac.tick(499, PlayerColorState.Red).length).toBe(0);
|
||||
expect(ac.tick(500, PlayerColorState.Red).length).toBe(1);
|
||||
});
|
||||
|
||||
it('shuriken burst index caps at SHURIKEN_BURST_MAX', () => {
|
||||
const ac = new AttackController();
|
||||
ac.press(WeaponType.Shuriken, 0);
|
||||
const indexes: number[] = [];
|
||||
for (let i = 0; i <= 600; i += 300) {
|
||||
const fires = ac.tick(i, PlayerColorState.Red);
|
||||
if (fires.length) indexes.push(fires[0].burstIndex);
|
||||
}
|
||||
expect(indexes).toEqual([1, 2, 3]);
|
||||
// One more attempt must still cap at 3 not 4.
|
||||
const more = ac.tick(900, PlayerColorState.Red);
|
||||
expect(more[0].burstIndex).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AttackController — combo window (req 4.1)', () => {
|
||||
it('comboWithJump is true when jump timestamp is within 100ms', () => {
|
||||
const ac = new AttackController(makeJumpState(95));
|
||||
ac.press(WeaponType.Shuriken, 100);
|
||||
const fires = ac.tick(100, PlayerColorState.Red);
|
||||
expect(fires[0].comboWithJump).toBe(true);
|
||||
});
|
||||
|
||||
it('comboWithJump is false when jump was pressed >100ms ago', () => {
|
||||
const ac = new AttackController(makeJumpState(0));
|
||||
ac.press(WeaponType.Shuriken, 200);
|
||||
const fires = ac.tick(200, PlayerColorState.Red);
|
||||
expect(fires[0].comboWithJump).toBe(false);
|
||||
});
|
||||
|
||||
it('comboWithJump is false when the player has not jumped yet', () => {
|
||||
const ac = new AttackController(makeJumpState(undefined));
|
||||
ac.press(WeaponType.Shuriken, 0);
|
||||
const fires = ac.tick(0, PlayerColorState.Red);
|
||||
expect(fires[0].comboWithJump).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AttackController — reset()', () => {
|
||||
it('clears active / pressed / cooldowns', () => {
|
||||
const ac = new AttackController();
|
||||
ac.press(WeaponType.Shuriken, 0);
|
||||
ac.tick(0, PlayerColorState.Red);
|
||||
ac.reset();
|
||||
expect(ac.getActive()).toBe('none');
|
||||
expect(ac.isPressed(WeaponType.Shuriken)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
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(/救/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ConfigMgr, MapJsonLoader } from '@data/ConfigMgr';
|
||||
import { LevelMgr } from '@logic/LevelMgr';
|
||||
import { cameraFromLevel } from '@logic/CameraScroller';
|
||||
import { EnemyType } from '@data/Interfaces';
|
||||
|
||||
// Reuse delivered JSON.
|
||||
import enemies from '../../assets/resources/configs/enemies.json';
|
||||
import items from '../../assets/resources/configs/items.json';
|
||||
import weapons from '../../assets/resources/configs/weapons.json';
|
||||
import levels from '../../assets/resources/configs/levels.json';
|
||||
import bosses from '../../assets/resources/configs/bosses.json';
|
||||
import stories from '../../assets/resources/configs/stories.json';
|
||||
|
||||
async function loadBundle() {
|
||||
const mgr = new ConfigMgr(
|
||||
new MapJsonLoader({
|
||||
'configs/enemies': enemies,
|
||||
'configs/items': items,
|
||||
'configs/weapons': weapons,
|
||||
'configs/levels': levels,
|
||||
'configs/bosses': bosses,
|
||||
'configs/stories': stories,
|
||||
})
|
||||
);
|
||||
await mgr.load();
|
||||
return mgr;
|
||||
}
|
||||
|
||||
describe('Chapter-1 levels — JSON × LevelMgr integration (task 7.2)', () => {
|
||||
it('exposes all 5 levels (1-1 … 1-5)', async () => {
|
||||
const mgr = await loadBundle();
|
||||
for (const id of ['1-1', '1-2', '1-3', '1-4', '1-5']) {
|
||||
expect(mgr.level(id)).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('1-1 requires killing 3 妖坊 within 75s (req 8.1)', async () => {
|
||||
const mgr = await loadBundle();
|
||||
const lv = mgr.level('1-1');
|
||||
expect(lv.timeLimitSec).toBe(75);
|
||||
expect(lv.objective).toEqual({ kind: 'kill_count', enemy: EnemyType.YaoFang, count: 3 });
|
||||
});
|
||||
|
||||
it('1-3 is a bi-directional cave stage with 10 青忍 objective (req 8.3)', async () => {
|
||||
const mgr = await loadBundle();
|
||||
const lv = mgr.level('1-3');
|
||||
expect(lv.scrollDirection).toBe('horizontal_bi');
|
||||
expect(lv.objective).toEqual({ kind: 'kill_count', enemy: EnemyType.QingRen, count: 10 });
|
||||
});
|
||||
|
||||
it('1-4 is a vertical castle-wall stage with reach_top objective (req 8.4)', async () => {
|
||||
const mgr = await loadBundle();
|
||||
const lv = mgr.level('1-4');
|
||||
expect(lv.scrollDirection).toBe('vertical');
|
||||
expect(lv.objective).toEqual({ kind: 'reach_top' });
|
||||
});
|
||||
|
||||
it('1-5 is a defeat-boss objective pointing at 双幻坊 (req 8.5)', async () => {
|
||||
const mgr = await loadBundle();
|
||||
const lv = mgr.level('1-5');
|
||||
expect(lv.objective.kind).toBe('defeat_boss');
|
||||
expect(lv.objective.bossId).toBe('shuang_huan_fang');
|
||||
});
|
||||
|
||||
it('CameraScroller instantiates cleanly from each chapter-1 level', async () => {
|
||||
const mgr = await loadBundle();
|
||||
for (const id of ['1-1', '1-2', '1-3', '1-4', '1-5']) {
|
||||
const cam = cameraFromLevel(mgr.level(id));
|
||||
expect(cam.offsetX).toBe(0);
|
||||
expect(cam.offsetY).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('LevelMgr drives every level to victory via its configured objective', async () => {
|
||||
const mgr = await loadBundle();
|
||||
for (const id of ['1-1', '1-2', '1-3', '1-4', '1-5']) {
|
||||
const lv = new LevelMgr(mgr.level(id));
|
||||
switch (lv.level.objective.kind) {
|
||||
case 'kill_count':
|
||||
for (let k = 0; k < (lv.level.objective.count ?? 0); k++) {
|
||||
lv.onEnemyKilled(lv.level.objective.enemy!);
|
||||
}
|
||||
break;
|
||||
case 'reach_top':
|
||||
lv.onReachedTop();
|
||||
break;
|
||||
case 'defeat_boss':
|
||||
lv.onBossKilled();
|
||||
break;
|
||||
}
|
||||
expect(lv.tick(0.016)).toBe('victory');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { DamageSystem, FIREBALL_KILL_RADIUS, SMOKE_KILL_RADIUS } from '@logic/DamageSystem';
|
||||
import { PlayerStateMachine } from '@logic/PlayerStateMachine';
|
||||
import { PlayerColorState } from '@common/Constants';
|
||||
|
||||
function setup() {
|
||||
const psm = new PlayerStateMachine();
|
||||
return { psm, ds: new DamageSystem(psm) };
|
||||
}
|
||||
|
||||
describe('DamageSystem — fireball distance gate (req 10.4)', () => {
|
||||
it('misses when distance exceeds FIREBALL_KILL_RADIUS', () => {
|
||||
const { ds } = setup();
|
||||
const r = ds.applyToPlayer({
|
||||
attackType: 'fireball',
|
||||
attackerX: 0,
|
||||
attackerY: 0,
|
||||
victimX: FIREBALL_KILL_RADIUS + 10,
|
||||
victimY: 0,
|
||||
});
|
||||
expect(r).toBeNull();
|
||||
});
|
||||
|
||||
it('kills when within radius regardless of color', () => {
|
||||
const { psm, ds } = setup();
|
||||
psm.pickupCrystalJade();
|
||||
psm.pickupCrystalJade();
|
||||
const r = ds.applyToPlayer({
|
||||
attackType: 'fireball',
|
||||
attackerX: 0,
|
||||
attackerY: 0,
|
||||
victimX: 50,
|
||||
victimY: 0,
|
||||
});
|
||||
expect(r!.kind).toBe('died');
|
||||
expect(psm.color).toBe(PlayerColorState.Red);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DamageSystem — smoke bomb distance gate (req 10.5)', () => {
|
||||
it('misses when distance exceeds SMOKE_KILL_RADIUS', () => {
|
||||
const { ds } = setup();
|
||||
const r = ds.applyToPlayer({
|
||||
attackType: 'smoke_bomb',
|
||||
attackerX: 0,
|
||||
attackerY: 0,
|
||||
victimX: SMOKE_KILL_RADIUS + 1,
|
||||
victimY: 0,
|
||||
});
|
||||
expect(r).toBeNull();
|
||||
});
|
||||
|
||||
it('kills when within radius', () => {
|
||||
const { ds } = setup();
|
||||
const r = ds.applyToPlayer({
|
||||
attackType: 'smoke_bomb',
|
||||
attackerX: 0,
|
||||
attackerY: 0,
|
||||
victimX: 40,
|
||||
victimY: 0,
|
||||
});
|
||||
expect(r!.kind).toBe('died');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DamageSystem — precedence (req 10.3)', () => {
|
||||
it('i-frames beat fireball distance check', () => {
|
||||
const { psm, ds } = setup();
|
||||
psm.takeHit('shuriken'); // die → consumes a life and starts i-frames
|
||||
const r = ds.applyToPlayer({
|
||||
attackType: 'fireball',
|
||||
attackerX: 0,
|
||||
attackerY: 0,
|
||||
victimX: 10,
|
||||
victimY: 0,
|
||||
});
|
||||
expect(r).toEqual({ kind: 'no_effect', reason: 'iframe' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DamageSystem — applyToEnemy helper', () => {
|
||||
it('reduces HP and floors at 0', () => {
|
||||
const { ds } = setup();
|
||||
expect(ds.applyToEnemy(3, 2)).toBe(1);
|
||||
expect(ds.applyToEnemy(1, 5)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { DropSystem } from '@logic/DropSystem';
|
||||
import { EnemyType, ItemType } from '@data/Interfaces';
|
||||
|
||||
describe('DropSystem — crystal jade deterministic rule (req 7.1)', () => {
|
||||
it('spawns a crystal jade on exactly the 12th kill', () => {
|
||||
const ds = new DropSystem({ random: () => 1 });
|
||||
let crystalEvents = 0;
|
||||
for (let i = 1; i <= 24; i++) {
|
||||
const drops = ds.onEnemyKilled(EnemyType.YaoFang, { x: 100, y: 0 });
|
||||
if (drops.some((d) => d.item === ItemType.CrystalJade)) crystalEvents++;
|
||||
}
|
||||
expect(crystalEvents).toBe(2);
|
||||
});
|
||||
|
||||
it('spawns the crystal above the kill point', () => {
|
||||
const ds = new DropSystem({ random: () => 1 });
|
||||
for (let i = 0; i < 11; i++) ds.onEnemyKilled(EnemyType.QingRen, { x: 0, y: 0 });
|
||||
const drops = ds.onEnemyKilled(EnemyType.QingRen, { x: 300, y: 20 });
|
||||
const crystal = drops.find((d) => d.item === ItemType.CrystalJade);
|
||||
expect(crystal!.y).toBeGreaterThan(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DropSystem — Chi Ren consecutive rule (req 7.3)', () => {
|
||||
it('drops dian_wan or shu_wan on the 3rd consecutive Chi Ren kill if RNG <0.5', () => {
|
||||
const ds = new DropSystem({
|
||||
dianShuWanProbability: 0.5,
|
||||
random: (() => {
|
||||
// probability check then which-item check both pass
|
||||
const vals = [0.1, 0.2];
|
||||
let i = 0;
|
||||
return () => vals[i++ % vals.length];
|
||||
})(),
|
||||
});
|
||||
ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 });
|
||||
ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 });
|
||||
const drops = ds.onEnemyKilled(EnemyType.ChiRen, { x: 50, y: 10 });
|
||||
expect(drops.find((d) => d.item === ItemType.DianWan)).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not drop when RNG fails probability', () => {
|
||||
const ds = new DropSystem({ dianShuWanProbability: 0.5, random: () => 0.95 });
|
||||
ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 });
|
||||
ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 });
|
||||
const drops = ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 });
|
||||
expect(drops.filter((d) => d.item !== ItemType.CrystalJade).length).toBe(0);
|
||||
});
|
||||
|
||||
it('non-Chi-Ren kill resets the consecutive counter', () => {
|
||||
const ds = new DropSystem({ dianShuWanProbability: 1.0, random: () => 0 });
|
||||
ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 });
|
||||
ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 });
|
||||
ds.onEnemyKilled(EnemyType.QingRen, { x: 0, y: 0 });
|
||||
const drops = ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 });
|
||||
// Only 1 Chi Ren kill since reset — below threshold.
|
||||
expect(drops.filter((d) => d.item === ItemType.DianWan || d.item === ItemType.ShuWan).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DropSystem — reset()', () => {
|
||||
it('zeroes kill counters', () => {
|
||||
const ds = new DropSystem();
|
||||
ds.onEnemyKilled(EnemyType.QingRen, { x: 0, y: 0 });
|
||||
ds.reset();
|
||||
expect(ds.kills).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ChiRenAI, EnemyManager, HeiRenAI, QingRenAI, YaoFangAI } from '@logic/EnemyAI';
|
||||
import { EnemyType, IEnemyConfig } from '@data/Interfaces';
|
||||
|
||||
function cfg(id: EnemyType, intervalSec: number, speed = 0): IEnemyConfig {
|
||||
return {
|
||||
id,
|
||||
displayName: id,
|
||||
size: { w: 16, h: 16 },
|
||||
moveSpeed: speed,
|
||||
attackIntervalSec: intervalSec,
|
||||
attacks: ['shuriken'],
|
||||
hp: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const idlePlayer = { x: 300, y: 16, isGrounded: true };
|
||||
|
||||
describe('QingRenAI (req 6.1)', () => {
|
||||
it('throws a shuriken at the player when out of melee range', () => {
|
||||
const ai = new QingRenAI(cfg(EnemyType.QingRen, 2.0), 0, 16);
|
||||
const actions = ai.update({ dtSec: 2.0, nowMs: 0, player: idlePlayer });
|
||||
expect(actions[0].kind).toBe('fire_bullet');
|
||||
expect(actions[0].attackType).toBe('shuriken');
|
||||
expect(actions[0].velX).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('melee swings when player is close', () => {
|
||||
const ai = new QingRenAI(cfg(EnemyType.QingRen, 2.0), 310, 16);
|
||||
const actions = ai.update({ dtSec: 2.0, nowMs: 0, player: idlePlayer });
|
||||
expect(actions[0].kind).toBe('melee_swing');
|
||||
});
|
||||
|
||||
it('respects attack interval — no burst within one tick', () => {
|
||||
const ai = new QingRenAI(cfg(EnemyType.QingRen, 2.0), 0, 16);
|
||||
ai.update({ dtSec: 2.0, nowMs: 0, player: idlePlayer });
|
||||
const actions = ai.update({ dtSec: 0.1, nowMs: 0, player: idlePlayer });
|
||||
expect(actions.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChiRenAI (req 6.2-6.3)', () => {
|
||||
it('moves horizontally toward the player at 120 px/s', () => {
|
||||
const ai = new ChiRenAI(cfg(EnemyType.ChiRen, 1.5, 120), 0, 16);
|
||||
ai.update({ dtSec: 1, nowMs: 0, player: idlePlayer });
|
||||
expect(ai.pos.x).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('throws smoke bombs at the configured cadence', () => {
|
||||
const ai = new ChiRenAI(cfg(EnemyType.ChiRen, 1.5, 120), 0, 16);
|
||||
const actions = ai.update({ dtSec: 1.5, nowMs: 0, player: idlePlayer });
|
||||
expect(actions.some((a) => a.attackType === 'smoke_bomb')).toBe(true);
|
||||
});
|
||||
|
||||
it('bumps upward when the player is idle within intercept range', () => {
|
||||
const ai = new ChiRenAI(cfg(EnemyType.ChiRen, 1.5, 120), 250, 16);
|
||||
const before = ai.pos.y;
|
||||
ai.update({ dtSec: 0.016, nowMs: 0, player: idlePlayer });
|
||||
expect(ai.pos.y).toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeiRenAI (req 6.5)', () => {
|
||||
it('drops exactly one magic flute on kill', () => {
|
||||
const ai = new HeiRenAI(cfg(EnemyType.HeiRen, 2.5), 100, 16);
|
||||
const firstDrop = ai.onKilled();
|
||||
const secondDrop = ai.onKilled();
|
||||
expect(firstDrop[0].itemId).toBe('mo_di');
|
||||
expect(secondDrop.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('YaoFangAI (req 6.6)', () => {
|
||||
it('launches straight-line fireballs at 3s cadence', () => {
|
||||
const ai = new YaoFangAI(cfg(EnemyType.YaoFang, 3.0), 0, 16);
|
||||
const a = ai.update({ dtSec: 3.0, nowMs: 0, player: idlePlayer });
|
||||
expect(a[0].attackType).toBe('fireball');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnemyManager culling (req 6.7)', () => {
|
||||
it('skips update for enemies outside the camera cull rect', () => {
|
||||
const mgr = new EnemyManager();
|
||||
const far = new YaoFangAI(cfg(EnemyType.YaoFang, 3.0), 5000, 16);
|
||||
mgr.spawn(far);
|
||||
const actions = mgr.update(3.0, 0, idlePlayer, {
|
||||
leftX: 0,
|
||||
rightX: 960,
|
||||
topY: 540,
|
||||
bottomY: 0,
|
||||
});
|
||||
expect(actions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('updates enemies inside the cull rect', () => {
|
||||
const mgr = new EnemyManager();
|
||||
const near = new YaoFangAI(cfg(EnemyType.YaoFang, 3.0), 200, 16);
|
||||
mgr.spawn(near);
|
||||
const actions = mgr.update(3.0, 0, idlePlayer, {
|
||||
leftX: 0,
|
||||
rightX: 960,
|
||||
topY: 540,
|
||||
bottomY: 0,
|
||||
});
|
||||
expect(actions.length).toBe(1);
|
||||
expect(actions[0].kind).toBe('fire_bullet');
|
||||
});
|
||||
|
||||
it('kill() converts Hei Ren to a magic-flute drop', () => {
|
||||
const mgr = new EnemyManager();
|
||||
const hei = new HeiRenAI(cfg(EnemyType.HeiRen, 2.5), 50, 16);
|
||||
mgr.spawn(hei);
|
||||
const drops = mgr.kill(hei);
|
||||
expect(drops[0].itemId).toBe('mo_di');
|
||||
expect(hei.alive).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { PlayerMotionModel } from '@logic/PlayerMotionModel';
|
||||
import { JumpController, PARABOLIC_HORIZONTAL_SPEED, heightToImpulse } from '@logic/JumpController';
|
||||
import {
|
||||
JUMP_HEIGHT_STANDARD,
|
||||
JUMP_HEIGHT_CHARGED,
|
||||
JUMP_HEIGHT_YELLOW,
|
||||
JUMP_PREPARE_DELAY_MS,
|
||||
PlayerColorState,
|
||||
} from '@common/Constants';
|
||||
|
||||
function newPair(color: PlayerColorState = PlayerColorState.Red) {
|
||||
const motion = new PlayerMotionModel({
|
||||
aabb: { x: 0, y: 16, w: 16, h: 32 },
|
||||
platforms: [{ topY: 0, leftX: -500, rightX: 500 }],
|
||||
initialColorState: color,
|
||||
});
|
||||
motion.update(0.016); // settle on ground
|
||||
const jump = new JumpController(motion);
|
||||
return { motion, jump };
|
||||
}
|
||||
|
||||
describe('JumpController — press / release lifecycle (req 2.2, 2.3, 2.4, 2.8)', () => {
|
||||
it('refuses to press when airborne (req 2.4)', () => {
|
||||
const { motion, jump } = newPair();
|
||||
motion.applyJumpImpulse(500); // lift off manually
|
||||
const res = jump.pressJump(0);
|
||||
expect(res.reason).toBe('airborne');
|
||||
expect(jump.isButtonEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('standard-press + quick release → standard jump height', () => {
|
||||
const { jump } = newPair();
|
||||
jump.pressJump(0);
|
||||
const res = jump.releaseJump(100, 'horizontal');
|
||||
expect(res.height).toBe(JUMP_HEIGHT_STANDARD);
|
||||
expect(res.phase).toBe('crouching');
|
||||
});
|
||||
|
||||
it('holding ≥500ms produces the charged jump (req 2.3)', () => {
|
||||
const { jump } = newPair();
|
||||
jump.pressJump(0);
|
||||
const res = jump.releaseJump(600, 'horizontal');
|
||||
expect(res.height).toBe(JUMP_HEIGHT_CHARGED);
|
||||
});
|
||||
|
||||
it('yellow color state uses 300px baseline jump (req 2.2)', () => {
|
||||
const { jump } = newPair(PlayerColorState.Yellow);
|
||||
jump.pressJump(0);
|
||||
const res = jump.releaseJump(50, 'horizontal', PlayerColorState.Yellow);
|
||||
expect(res.height).toBe(JUMP_HEIGHT_YELLOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JumpController — parabolic impulse (req 2.5)', () => {
|
||||
it('parabolic_right imparts +PARABOLIC_HORIZONTAL_SPEED', () => {
|
||||
const { jump } = newPair();
|
||||
jump.pressJump(0);
|
||||
const res = jump.releaseJump(100, 'parabolic_right');
|
||||
expect(res.horizontalImpulse).toBe(PARABOLIC_HORIZONTAL_SPEED);
|
||||
});
|
||||
|
||||
it('parabolic_left imparts −PARABOLIC_HORIZONTAL_SPEED', () => {
|
||||
const { jump } = newPair();
|
||||
jump.pressJump(0);
|
||||
const res = jump.releaseJump(100, 'parabolic_left');
|
||||
expect(res.horizontalImpulse).toBe(-PARABOLIC_HORIZONTAL_SPEED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JumpController — crouch delay + launch + re-enable (req 2.8, 2.4)', () => {
|
||||
it('does not apply impulse until after 150ms crouch delay', () => {
|
||||
const { motion, jump } = newPair();
|
||||
jump.pressJump(0);
|
||||
jump.releaseJump(100, 'horizontal');
|
||||
// Inside the crouch window — still grounded because impulse not applied.
|
||||
jump.tick(200);
|
||||
expect(motion.vy).toBe(0);
|
||||
expect(motion.isGrounded).toBe(true);
|
||||
jump.tick(100 + JUMP_PREPARE_DELAY_MS + 1);
|
||||
expect(motion.vy).toBeGreaterThan(0);
|
||||
expect(motion.isGrounded).toBe(false);
|
||||
});
|
||||
|
||||
it('re-enables the jump button once the player lands again', () => {
|
||||
const { motion, jump } = newPair();
|
||||
jump.pressJump(0);
|
||||
jump.releaseJump(50, 'horizontal');
|
||||
jump.tick(50 + JUMP_PREPARE_DELAY_MS + 1);
|
||||
// Let gravity bring the player back down.
|
||||
for (let i = 0; i < 120; i++) motion.update(1 / 60);
|
||||
jump.tick(1_000);
|
||||
expect(motion.isGrounded).toBe(true);
|
||||
expect(jump.isButtonEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('heightToImpulse — physics math', () => {
|
||||
it('computes impulse such that peak equals the requested height', () => {
|
||||
const g = 2500;
|
||||
const h = 250;
|
||||
const v0 = heightToImpulse(h, g);
|
||||
// At apex v=0 ⇒ t = v0/g ⇒ peak = v0 * t - 0.5 * g * t^2 ⇒ v0^2 / (2g)
|
||||
expect((v0 * v0) / (2 * g)).toBeCloseTo(h, 3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { CameraScroller, PARALLAX_LAYERS, PARALLAX_RATIOS, cameraFromLevel } from '@logic/CameraScroller';
|
||||
import { LevelMgr } from '@logic/LevelMgr';
|
||||
import { EnemyType, ILevelConfig } from '@data/Interfaces';
|
||||
|
||||
const HORIZONTAL_LEVEL: ILevelConfig = {
|
||||
id: '1-1',
|
||||
chapter: 1,
|
||||
displayName: '初始森林',
|
||||
sceneTheme: 'forest',
|
||||
scrollDirection: 'horizontal',
|
||||
timeLimitSec: 75,
|
||||
objective: { kind: 'kill_count', enemy: EnemyType.YaoFang, count: 3 },
|
||||
levelLengthPx: 3840,
|
||||
bgm: 'bgm_forest',
|
||||
enemySpawns: [],
|
||||
};
|
||||
|
||||
describe('CameraScroller — horizontal (req 8.1)', () => {
|
||||
it('camera scrolls forward as player advances', () => {
|
||||
const cam = cameraFromLevel(HORIZONTAL_LEVEL, 960, 540);
|
||||
cam.followPlayer(480, 270);
|
||||
expect(cam.offsetX).toBe(0);
|
||||
cam.followPlayer(900, 270);
|
||||
expect(cam.offsetX).toBe(420);
|
||||
});
|
||||
|
||||
it('camera never rewinds on horizontal scroll (req 8.1)', () => {
|
||||
const cam = cameraFromLevel(HORIZONTAL_LEVEL);
|
||||
cam.followPlayer(1500, 270);
|
||||
const forward = cam.offsetX;
|
||||
cam.followPlayer(100, 270);
|
||||
expect(cam.offsetX).toBe(forward); // did not rewind
|
||||
});
|
||||
|
||||
it('camera stops at level end', () => {
|
||||
const cam = cameraFromLevel(HORIZONTAL_LEVEL);
|
||||
cam.followPlayer(10_000, 270);
|
||||
expect(cam.offsetX).toBe(HORIZONTAL_LEVEL.levelLengthPx - 960);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CameraScroller — parallax layers (req 8.8 — 1:2:4:4)', () => {
|
||||
it('exposes 4 layers with ratios 1,2,4,4', () => {
|
||||
expect(PARALLAX_LAYERS).toEqual(['far', 'mid', 'near', 'fx']);
|
||||
expect([...PARALLAX_RATIOS]).toEqual([1, 2, 4, 4]);
|
||||
});
|
||||
|
||||
it('far/mid/near produce progressively smaller offsets', () => {
|
||||
const cam = cameraFromLevel(HORIZONTAL_LEVEL);
|
||||
cam.followPlayer(1200, 270);
|
||||
const far = cam.offsetForLayer('far');
|
||||
const mid = cam.offsetForLayer('mid');
|
||||
const near = cam.offsetForLayer('near');
|
||||
expect(far.x).toBeGreaterThan(mid.x);
|
||||
expect(mid.x).toBeGreaterThan(near.x);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CameraScroller — bi-directional + vertical', () => {
|
||||
it('horizontal_bi rewinds when the player walks backward', () => {
|
||||
const cam = new CameraScroller({
|
||||
direction: 'horizontal_bi',
|
||||
lengthX: 4800,
|
||||
viewportW: 960,
|
||||
viewportH: 540,
|
||||
});
|
||||
cam.followPlayer(2000, 0);
|
||||
cam.followPlayer(400, 0);
|
||||
expect(cam.offsetX).toBe(0);
|
||||
});
|
||||
|
||||
it('vertical rising clamps at top', () => {
|
||||
const cam = new CameraScroller({
|
||||
direction: 'vertical',
|
||||
lengthX: 960,
|
||||
lengthY: 3240,
|
||||
viewportW: 960,
|
||||
viewportH: 540,
|
||||
});
|
||||
cam.followPlayer(480, 6000);
|
||||
expect(cam.offsetY).toBe(3240 - 540);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LevelMgr — objective / timer / result', () => {
|
||||
it('reports victory when kill objective is met', () => {
|
||||
const lv = new LevelMgr(HORIZONTAL_LEVEL);
|
||||
lv.onEnemyKilled(EnemyType.YaoFang);
|
||||
lv.onEnemyKilled(EnemyType.YaoFang);
|
||||
lv.onEnemyKilled(EnemyType.YaoFang);
|
||||
expect(lv.tick(0.016)).toBe('victory');
|
||||
});
|
||||
|
||||
it('reports timeout when time-limit expires', () => {
|
||||
const lv = new LevelMgr(HORIZONTAL_LEVEL);
|
||||
expect(lv.tick(HORIZONTAL_LEVEL.timeLimitSec + 0.1)).toBe('timeout');
|
||||
});
|
||||
|
||||
it('player_dead is terminal and outranks victory', () => {
|
||||
const lv = new LevelMgr(HORIZONTAL_LEVEL);
|
||||
lv.onPlayerDied();
|
||||
expect(lv.tick(0.1)).toBe('player_dead');
|
||||
});
|
||||
|
||||
it('reach_top objective', () => {
|
||||
const cfg: ILevelConfig = { ...HORIZONTAL_LEVEL, objective: { kind: 'reach_top' } };
|
||||
const lv = new LevelMgr(cfg);
|
||||
lv.onReachedTop();
|
||||
expect(lv.tick(0.016)).toBe('victory');
|
||||
});
|
||||
|
||||
it('defeat_boss objective', () => {
|
||||
const cfg: ILevelConfig = {
|
||||
...HORIZONTAL_LEVEL,
|
||||
objective: { kind: 'defeat_boss', bossId: 'shuang_huan_fang' },
|
||||
};
|
||||
const lv = new LevelMgr(cfg);
|
||||
lv.onBossKilled();
|
||||
expect(lv.tick(0.016)).toBe('victory');
|
||||
});
|
||||
|
||||
it('result() returns kills and remaining seconds', () => {
|
||||
const lv = new LevelMgr(HORIZONTAL_LEVEL);
|
||||
lv.onEnemyKilled(EnemyType.QingRen);
|
||||
lv.tick(10);
|
||||
const r = lv.result();
|
||||
expect(r.kills[EnemyType.QingRen]).toBe(1);
|
||||
expect(r.remainingSec).toBeCloseTo(65, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { PlayerMotionModel, IPlatform, DEFAULT_GRAVITY } from '@logic/PlayerMotionModel';
|
||||
import { MOVE_SPEED, PlayerColorState } from '@common/Constants';
|
||||
|
||||
function makeGroundPlatform(): IPlatform {
|
||||
return { topY: 0, leftX: -1000, rightX: 1000 };
|
||||
}
|
||||
|
||||
function makeModel(color: PlayerColorState = PlayerColorState.Red) {
|
||||
return new PlayerMotionModel({
|
||||
aabb: { x: 0, y: 16, w: 16, h: 32 }, // character 16x32 resting on y=0 ground
|
||||
platforms: [makeGroundPlatform()],
|
||||
initialColorState: color,
|
||||
});
|
||||
}
|
||||
|
||||
describe('PlayerMotionModel — horizontal movement (req 2.1, 5.1-5.2)', () => {
|
||||
it('stands still on initialisation', () => {
|
||||
const m = makeModel();
|
||||
m.update(0.016);
|
||||
expect(m.vx).toBe(0);
|
||||
expect(m.isGrounded).toBe(true);
|
||||
});
|
||||
|
||||
it('moves at 100 px/s in red state', () => {
|
||||
const m = makeModel(PlayerColorState.Red);
|
||||
m.update(0.016); // settle
|
||||
m.setHorizontalInput(1);
|
||||
m.update(1);
|
||||
expect(m.vx).toBe(MOVE_SPEED[PlayerColorState.Red]);
|
||||
expect(m.aabb.x).toBeCloseTo(100, 1);
|
||||
});
|
||||
|
||||
it('moves at 150 px/s in yellow state', () => {
|
||||
const m = makeModel(PlayerColorState.Yellow);
|
||||
m.update(0.016);
|
||||
m.setHorizontalInput(-1);
|
||||
m.update(1);
|
||||
expect(m.vx).toBe(-MOVE_SPEED[PlayerColorState.Yellow]);
|
||||
});
|
||||
|
||||
it('reflects speed immediately when setColorState is called mid-run', () => {
|
||||
const m = makeModel(PlayerColorState.Red);
|
||||
m.update(0.016);
|
||||
m.setHorizontalInput(1);
|
||||
m.setColorState(PlayerColorState.Yellow);
|
||||
m.update(1);
|
||||
expect(m.vx).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlayerMotionModel — jump / gravity (req 2.4, 13.4)', () => {
|
||||
it('applyJumpImpulse is rejected when in the air (req 2.4)', () => {
|
||||
const m = makeModel();
|
||||
m.update(0.016);
|
||||
expect(m.applyJumpImpulse(600)).toBe(true); // first jump succeeds
|
||||
expect(m.isGrounded).toBe(false);
|
||||
expect(m.applyJumpImpulse(600)).toBe(false); // second jump in air denied
|
||||
});
|
||||
|
||||
it('gravity pulls the player back to the ground', () => {
|
||||
const m = makeModel();
|
||||
m.update(0.016);
|
||||
m.applyJumpImpulse(600);
|
||||
// Simulate ~1 second of flight — gravity reclaims the player.
|
||||
for (let i = 0; i < 120; i++) m.update(1 / 60);
|
||||
expect(m.isGrounded).toBe(true);
|
||||
expect(m.vy).toBe(0);
|
||||
});
|
||||
|
||||
it('preserves mid-air vx (起跳定型 — req 13.4)', () => {
|
||||
const m = makeModel();
|
||||
m.update(0.016);
|
||||
// Build horizontal speed + jump.
|
||||
m.setHorizontalInput(1);
|
||||
m.update(0.016);
|
||||
m.applyJumpImpulse(500);
|
||||
const airVx = m.vx;
|
||||
// Even if the player now tries to flip direction while airborne, vx stays put.
|
||||
m.setHorizontalInput(-1);
|
||||
m.update(0.05);
|
||||
expect(m.vx).toBe(airVx);
|
||||
});
|
||||
|
||||
it('applyHorizontalImpulse overrides vx (for parabolic jumps, req 2.5)', () => {
|
||||
const m = makeModel();
|
||||
m.update(0.016);
|
||||
m.applyJumpImpulse(600);
|
||||
m.applyHorizontalImpulse(120);
|
||||
m.update(0.016);
|
||||
expect(m.vx).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlayerMotionModel — platform switching', () => {
|
||||
it('setPlatforms clears grounded and re-settles on next update', () => {
|
||||
const m = makeModel();
|
||||
m.update(0.016);
|
||||
expect(m.isGrounded).toBe(true);
|
||||
m.setPlatforms([{ topY: -500, leftX: -10, rightX: 10 }]);
|
||||
expect(m.isGrounded).toBe(false);
|
||||
});
|
||||
|
||||
it('gravity constant matches the documented default', () => {
|
||||
expect(DEFAULT_GRAVITY).toBe(2500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { PlayerStateMachine } from '@logic/PlayerStateMachine';
|
||||
import { PlayerColorState, PLAYER_IFRAME_SECONDS } from '@common/Constants';
|
||||
|
||||
describe('PlayerStateMachine — auto-upgrade (req 5.1-5.2)', () => {
|
||||
it('Red → Green on first crystal pickup', () => {
|
||||
const sm = new PlayerStateMachine();
|
||||
expect(sm.pickupCrystalJade()).toBe(PlayerColorState.Green);
|
||||
});
|
||||
|
||||
it('Green → Yellow on second crystal pickup', () => {
|
||||
const sm = new PlayerStateMachine();
|
||||
sm.pickupCrystalJade();
|
||||
expect(sm.pickupCrystalJade()).toBe(PlayerColorState.Yellow);
|
||||
});
|
||||
|
||||
it('Yellow → Yellow on additional crystal pickups (no overflow)', () => {
|
||||
const sm = new PlayerStateMachine();
|
||||
sm.pickupCrystalJade();
|
||||
sm.pickupCrystalJade();
|
||||
expect(sm.pickupCrystalJade()).toBe(PlayerColorState.Yellow);
|
||||
});
|
||||
|
||||
it('Zeng Wan adds one life (req 7.5)', () => {
|
||||
const sm = new PlayerStateMachine(1);
|
||||
expect(sm.pickupZengWan()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlayerStateMachine — damage (req 5.3-5.6, 10.4-10.5)', () => {
|
||||
it('downgrades from Yellow to Red on shuriken hit', () => {
|
||||
const sm = new PlayerStateMachine();
|
||||
sm.pickupCrystalJade();
|
||||
sm.pickupCrystalJade();
|
||||
const out = sm.takeHit('shuriken');
|
||||
expect(out).toEqual({ kind: 'downgraded', from: PlayerColorState.Yellow, to: PlayerColorState.Red });
|
||||
expect(sm.color).toBe(PlayerColorState.Red);
|
||||
});
|
||||
|
||||
it('downgrades from Green to Red on sword hit', () => {
|
||||
const sm = new PlayerStateMachine();
|
||||
sm.pickupCrystalJade();
|
||||
const out = sm.takeHit('sword');
|
||||
expect(out.kind).toBe('downgraded');
|
||||
expect(sm.color).toBe(PlayerColorState.Red);
|
||||
});
|
||||
|
||||
it('Red + shuriken → death, consumes one life', () => {
|
||||
const sm = new PlayerStateMachine(2);
|
||||
const out = sm.takeHit('shuriken');
|
||||
expect(out.kind).toBe('died');
|
||||
expect(sm.lives).toBe(1);
|
||||
expect(sm.isDead).toBe(false);
|
||||
});
|
||||
|
||||
it('fireball is always lethal regardless of color', () => {
|
||||
const sm = new PlayerStateMachine(2);
|
||||
sm.pickupCrystalJade();
|
||||
sm.pickupCrystalJade();
|
||||
const out = sm.takeHit('fireball');
|
||||
expect(out).toEqual({ kind: 'died', cause: 'fireball' });
|
||||
expect(sm.color).toBe(PlayerColorState.Red);
|
||||
});
|
||||
|
||||
it('smoke bomb is always lethal', () => {
|
||||
const sm = new PlayerStateMachine();
|
||||
sm.pickupCrystalJade();
|
||||
const out = sm.takeHit('smoke_bomb');
|
||||
expect(out.kind).toBe('died');
|
||||
});
|
||||
|
||||
it('zero lives → isDead=true', () => {
|
||||
const sm = new PlayerStateMachine(1);
|
||||
sm.takeHit('sword');
|
||||
expect(sm.lives).toBe(0);
|
||||
expect(sm.isDead).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlayerStateMachine — iframes & sword parry (req 3.7-3.8, 10.2-10.3)', () => {
|
||||
it('i-frames start at PLAYER_IFRAME_SECONDS after a hit', () => {
|
||||
const sm = new PlayerStateMachine();
|
||||
sm.pickupCrystalJade();
|
||||
sm.takeHit('shuriken');
|
||||
expect(sm.snapshot.iframeSec).toBe(PLAYER_IFRAME_SECONDS);
|
||||
});
|
||||
|
||||
it('hits during i-frames are ignored', () => {
|
||||
const sm = new PlayerStateMachine();
|
||||
sm.pickupCrystalJade();
|
||||
sm.takeHit('shuriken');
|
||||
const out = sm.takeHit('shuriken');
|
||||
expect(out).toEqual({ kind: 'no_effect', reason: 'iframe' });
|
||||
});
|
||||
|
||||
it('tick() drains i-frames so the player is vulnerable again', () => {
|
||||
const sm = new PlayerStateMachine();
|
||||
sm.pickupCrystalJade();
|
||||
sm.takeHit('shuriken');
|
||||
sm.tick(PLAYER_IFRAME_SECONDS + 0.01);
|
||||
expect(sm.snapshot.iframeSec).toBe(0);
|
||||
});
|
||||
|
||||
it('sword parry nullifies shuriken/sword damage', () => {
|
||||
const sm = new PlayerStateMachine();
|
||||
sm.setSwordActive(true);
|
||||
const out = sm.takeHit('shuriken');
|
||||
expect(out).toEqual({ kind: 'no_effect', reason: 'parried' });
|
||||
expect(sm.color).toBe(PlayerColorState.Red);
|
||||
});
|
||||
|
||||
it('sword parry does NOT nullify fireball / smoke_bomb (req 3.8, 10.4-10.5)', () => {
|
||||
const sm = new PlayerStateMachine();
|
||||
sm.setSwordActive(true);
|
||||
expect(sm.takeHit('fireball').kind).toBe('died');
|
||||
const sm2 = new PlayerStateMachine();
|
||||
sm2.setSwordActive(true);
|
||||
expect(sm2.takeHit('smoke_bomb').kind).toBe('died');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { TutorialMgr } from '@logic/TutorialMgr';
|
||||
import { ScoreSystem, BASE_ENEMY_SCORE, COMBO_BONUS } from '@logic/ScoreSystem';
|
||||
import { WeaponType } from '@data/Interfaces';
|
||||
import { StorageMgr } from '@common/StorageMgr';
|
||||
|
||||
function mem() {
|
||||
const m = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null),
|
||||
setItem: (k: string, v: string) => {
|
||||
m.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
m.delete(k);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('TutorialMgr — built-in sequences for 1-1..1-3 (req 11.1-11.3)', () => {
|
||||
it('maybeStart returns the first step of 1-1', () => {
|
||||
const t = new TutorialMgr(new StorageMgr(mem()));
|
||||
const step = t.maybeStart('1-1');
|
||||
expect(step).toBeDefined();
|
||||
expect(step!.id).toBe('attack');
|
||||
});
|
||||
|
||||
it('reportAction advances through the sequence', () => {
|
||||
const t = new TutorialMgr(new StorageMgr(mem()));
|
||||
t.maybeStart('1-1');
|
||||
expect(t.reportAction('fire_shuriken')).toMatchObject({ id: 'joystick' });
|
||||
expect(t.reportAction('move')).toMatchObject({ id: 'jump' });
|
||||
expect(t.reportAction('jump')).toBe('finished');
|
||||
});
|
||||
|
||||
it('completed tutorials are persisted and skipped on replay (req 11.4)', () => {
|
||||
const storage = new StorageMgr(mem());
|
||||
const t = new TutorialMgr(storage);
|
||||
t.maybeStart('1-1');
|
||||
t.reportAction('fire_shuriken');
|
||||
t.reportAction('move');
|
||||
t.reportAction('jump');
|
||||
expect(t.isCompleted('1-1')).toBe(true);
|
||||
expect(t.maybeStart('1-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('resetAll clears the completion set (req 11.5)', () => {
|
||||
const t = new TutorialMgr(new StorageMgr(mem()));
|
||||
t.maybeStart('1-2');
|
||||
t.reportAction('parabolic_jump');
|
||||
t.reportAction('attack_switch');
|
||||
t.reportAction('parry');
|
||||
t.reportAction('jump_attack');
|
||||
t.reportAction('pickup_crystal');
|
||||
t.resetAll();
|
||||
expect(t.maybeStart('1-2')).toBeDefined();
|
||||
});
|
||||
|
||||
it('reportAction with wrong action is a no-op', () => {
|
||||
const t = new TutorialMgr(new StorageMgr(mem()));
|
||||
t.maybeStart('1-1');
|
||||
expect(t.reportAction('wrong')).toBe('no_op');
|
||||
expect(t.currentStep()!.id).toBe('attack');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScoreSystem — scoring table (req 12.1-12.6)', () => {
|
||||
it('sword kill is ×2, shuriken kill is ×1', () => {
|
||||
const s = new ScoreSystem();
|
||||
s.recordEnemyKill(WeaponType.NinjaSword);
|
||||
s.recordEnemyKill(WeaponType.Shuriken);
|
||||
expect(s.snapshot().baseScore).toBe(BASE_ENEMY_SCORE * 3);
|
||||
});
|
||||
|
||||
it('parry kill is ×3 base', () => {
|
||||
const s = new ScoreSystem();
|
||||
s.recordParryKill();
|
||||
expect(s.snapshot().baseScore).toBe(BASE_ENEMY_SCORE * 3);
|
||||
});
|
||||
|
||||
it('5 blade contacts award a +1500 combo bonus (req 12.4)', () => {
|
||||
const s = new ScoreSystem();
|
||||
for (let i = 0; i < 5; i++) s.recordBladeContact();
|
||||
const snap = s.snapshot();
|
||||
expect(snap.comboBonus).toBe(COMBO_BONUS);
|
||||
expect(snap.comboCount).toBe(1);
|
||||
expect(snap.consecutiveBladeHits).toBe(0);
|
||||
});
|
||||
|
||||
it('breakBladeChain resets the streak', () => {
|
||||
const s = new ScoreSystem();
|
||||
s.recordBladeContact();
|
||||
s.recordBladeContact();
|
||||
s.breakBladeChain();
|
||||
s.recordBladeContact();
|
||||
expect(s.snapshot().consecutiveBladeHits).toBe(1);
|
||||
});
|
||||
|
||||
it('flawless run triples total score; taking damage removes the bonus (req 12.5)', () => {
|
||||
const s = new ScoreSystem();
|
||||
s.recordEnemyKill(WeaponType.Shuriken);
|
||||
const flawlessSnap = s.snapshot();
|
||||
expect(flawlessSnap.flawlessMultiplier).toBe(3);
|
||||
s.markTaken();
|
||||
const damagedSnap = s.snapshot();
|
||||
expect(damagedSnap.flawlessMultiplier).toBe(1);
|
||||
});
|
||||
|
||||
it('remaining time bonus adds 10 pts / sec (req 12.6)', () => {
|
||||
const s = new ScoreSystem();
|
||||
s.recordEnemyKill(WeaponType.Shuriken);
|
||||
s.setRemainingTimeBonus(30);
|
||||
expect(s.snapshot().timeBonus).toBe(300);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user