first commmit
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Lightweight `cc` module mock used by Jest to allow platform-agnostic
|
||||
* modules (`common/*`, `data/*`, some `logic/*`) to be unit-tested without
|
||||
* pulling in the full Cocos Creator engine.
|
||||
*
|
||||
* Anything that actually touches `cc.Node` / scene graph must **not** live
|
||||
* in a module that is directly tested by Jest; put it behind a thin facade
|
||||
* instead (see `common/StorageMgr.ts` which isolates `sys.localStorage`).
|
||||
*/
|
||||
|
||||
class FakeEventTarget {
|
||||
private listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
|
||||
on(event: string, cb: (...args: unknown[]) => void): void {
|
||||
const list = this.listeners.get(event) ?? [];
|
||||
list.push(cb);
|
||||
this.listeners.set(event, list);
|
||||
}
|
||||
|
||||
off(event: string, cb?: (...args: unknown[]) => void): void {
|
||||
if (!cb) {
|
||||
this.listeners.delete(event);
|
||||
return;
|
||||
}
|
||||
const list = this.listeners.get(event);
|
||||
if (!list) return;
|
||||
this.listeners.set(
|
||||
event,
|
||||
list.filter((fn) => fn !== cb)
|
||||
);
|
||||
}
|
||||
|
||||
emit(event: string, ...args: unknown[]): void {
|
||||
const list = this.listeners.get(event);
|
||||
if (!list) return;
|
||||
for (const fn of list) {
|
||||
fn(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const EventTarget = FakeEventTarget;
|
||||
|
||||
export const _decorator = {
|
||||
ccclass: (_name?: string) => (target: unknown) => target as any,
|
||||
property: (_opts?: unknown) => (_target: unknown, _key: string) => {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal stand-in for `cc.Node`. Only the surface actually exercised by
|
||||
* Scene Entry components is modelled so unit tests stay deterministic.
|
||||
*/
|
||||
export class Node {
|
||||
public name: string = '';
|
||||
public active: boolean = true;
|
||||
public layer: number = 0;
|
||||
|
||||
constructor(name?: string) {
|
||||
this.name = name ?? '';
|
||||
}
|
||||
|
||||
public addChild(_child: Node): void {}
|
||||
public on(_ev: string, _cb: (...args: unknown[]) => void, _target?: unknown): void {}
|
||||
public off(_ev: string, _cb?: (...args: unknown[]) => void): void {}
|
||||
public getComponent<T>(_ctor: new (...args: unknown[]) => T): T | null { return null; }
|
||||
public addComponent<T>(_ctor: new (...args: unknown[]) => T): T { return {} as T; }
|
||||
public setPosition(..._args: unknown[]): void {}
|
||||
|
||||
public static EventType = {
|
||||
TOUCH_START: 'touch-start',
|
||||
TOUCH_MOVE: 'touch-move',
|
||||
TOUCH_END: 'touch-end',
|
||||
TOUCH_CANCEL: 'touch-cancel',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal stand-in for `cc.Component`. Provides `.node` so Scene Entry TS
|
||||
* types resolve. Runtime behaviour is injected by the Cocos Creator editor.
|
||||
*/
|
||||
export class Component {
|
||||
public node: Node = new Node();
|
||||
public enabled: boolean = true;
|
||||
}
|
||||
export const director = {
|
||||
addPersistRootNode: (..._args: unknown[]) => {},
|
||||
loadScene: (..._args: unknown[]) => {},
|
||||
};
|
||||
export const view = {
|
||||
setDesignResolutionSize: (..._args: unknown[]) => {},
|
||||
};
|
||||
export const screen = {
|
||||
orientation: 0,
|
||||
};
|
||||
export const game = {
|
||||
frameRate: 60,
|
||||
};
|
||||
export const sys = {
|
||||
localStorage: {
|
||||
_store: new Map<string, string>(),
|
||||
getItem(key: string): string | null {
|
||||
return (sys.localStorage as any)._store.get(key) ?? null;
|
||||
},
|
||||
setItem(key: string, value: string): void {
|
||||
(sys.localStorage as any)._store.set(key, value);
|
||||
},
|
||||
removeItem(key: string): void {
|
||||
(sys.localStorage as any)._store.delete(key);
|
||||
},
|
||||
clear(): void {
|
||||
(sys.localStorage as any)._store.clear();
|
||||
},
|
||||
},
|
||||
};
|
||||
export const settings = {};
|
||||
export const Settings = {};
|
||||
export const profiler = {};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Additional stubs for types imported by Scene Entry components. All the
|
||||
// methods below are NO-OPs; the Cocos Creator editor injects the real
|
||||
// implementations at runtime. Having them declared here keeps the TS
|
||||
// language server happy and lets Jest exercise scene-entry unit tests.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export class Color {
|
||||
public r: number;
|
||||
public g: number;
|
||||
public b: number;
|
||||
public a: number;
|
||||
constructor(r: number = 255, g: number = 255, b: number = 255, a: number = 255) {
|
||||
this.r = r; this.g = g; this.b = b; this.a = a;
|
||||
}
|
||||
public static WHITE = new Color(255, 255, 255, 255);
|
||||
public static BLACK = new Color(0, 0, 0, 255);
|
||||
}
|
||||
|
||||
export class Vec3 {
|
||||
public x: number;
|
||||
public y: number;
|
||||
public z: number;
|
||||
constructor(x: number = 0, y: number = 0, z: number = 0) {
|
||||
this.x = x; this.y = y; this.z = z;
|
||||
}
|
||||
}
|
||||
|
||||
export class UITransform {
|
||||
public width: number = 0;
|
||||
public height: number = 0;
|
||||
public setContentSize(_w: number, _h: number): void {}
|
||||
}
|
||||
|
||||
export class Label {
|
||||
public string: string = '';
|
||||
public fontSize: number = 24;
|
||||
public lineHeight: number = 28;
|
||||
public color: Color = Color.WHITE;
|
||||
public horizontalAlign: number = 0;
|
||||
public verticalAlign: number = 0;
|
||||
public useSystemFont: boolean = true;
|
||||
}
|
||||
|
||||
export class Button {
|
||||
public transition: number = 0;
|
||||
public target: Node | null = null;
|
||||
public zoomScale: number = 1;
|
||||
|
||||
public static Transition = { NONE: 0, COLOR: 1, SPRITE: 2, SCALE: 3 };
|
||||
}
|
||||
|
||||
export class Graphics {
|
||||
public fillColor: Color = Color.WHITE;
|
||||
public strokeColor: Color = Color.BLACK;
|
||||
public lineWidth: number = 1;
|
||||
public rect(_x: number, _y: number, _w: number, _h: number): void {}
|
||||
public fill(): void {}
|
||||
public stroke(): void {}
|
||||
}
|
||||
|
||||
export class Sprite {
|
||||
public spriteFrame: unknown = null;
|
||||
public type: number = 0;
|
||||
public sizeMode: number = 0;
|
||||
public static Type = { SIMPLE: 0, SLICED: 1, TILED: 2, FILLED: 3 };
|
||||
public static SizeMode = { CUSTOM: 0, TRIMMED: 1, RAW: 2 };
|
||||
}
|
||||
|
||||
export class SpriteFrame {
|
||||
public texture: unknown = null;
|
||||
}
|
||||
|
||||
export class Texture2D {
|
||||
public image: unknown = null;
|
||||
public static PixelFormat = { RGBA8888: 35 };
|
||||
}
|
||||
|
||||
export class ImageAsset {
|
||||
constructor(_opts?: unknown) {}
|
||||
}
|
||||
|
||||
export class JsonAsset {
|
||||
public json: unknown = null;
|
||||
}
|
||||
|
||||
export class Canvas {}
|
||||
export class EventTouch {}
|
||||
|
||||
export const resources = {
|
||||
load: (_path: string, _type: unknown, _cb: (err: Error | null, asset: unknown) => void): void => {},
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
DESIGN_WIDTH,
|
||||
DESIGN_HEIGHT,
|
||||
TARGET_FPS,
|
||||
MOVE_SPEED,
|
||||
PlayerColorState,
|
||||
JUMP_HEIGHT_STANDARD,
|
||||
JUMP_HEIGHT_CHARGED,
|
||||
JUMP_HEIGHT_YELLOW,
|
||||
JUMP_PREPARE_DELAY_MS,
|
||||
PARABOLIC_ANGLE_RIGHT,
|
||||
PARABOLIC_ANGLE_LEFT,
|
||||
SHURIKEN_INTERVAL_BASE,
|
||||
SHURIKEN_INTERVAL_UPGRADED,
|
||||
SWORD_INTERVAL,
|
||||
PERF_TOUCH_RESPONSE_MAX_MS,
|
||||
PERF_COMBO_RECOGNITION_MAX_MS,
|
||||
STORAGE_KEY,
|
||||
} from '@common/Constants';
|
||||
|
||||
describe('Constants — requirement baseline values', () => {
|
||||
it('uses 960x540 landscape baseline (req tech-stack)', () => {
|
||||
expect(DESIGN_WIDTH).toBe(960);
|
||||
expect(DESIGN_HEIGHT).toBe(540);
|
||||
expect(DESIGN_WIDTH / DESIGN_HEIGHT).toBeCloseTo(16 / 9, 3);
|
||||
});
|
||||
|
||||
it('locks target frame rate at 30 fps (req 18.1-18.3)', () => {
|
||||
expect(TARGET_FPS).toBe(30);
|
||||
});
|
||||
|
||||
it('assigns 100/100/150 px/s move speeds per color state (req 5.1-5.2)', () => {
|
||||
expect(MOVE_SPEED[PlayerColorState.Red]).toBe(100);
|
||||
expect(MOVE_SPEED[PlayerColorState.Green]).toBe(100);
|
||||
expect(MOVE_SPEED[PlayerColorState.Yellow]).toBe(150);
|
||||
});
|
||||
|
||||
it('uses 250/375/300 jump heights (req 2.2-2.3)', () => {
|
||||
expect(JUMP_HEIGHT_STANDARD).toBe(250);
|
||||
expect(JUMP_HEIGHT_CHARGED).toBe(375);
|
||||
expect(JUMP_HEIGHT_YELLOW).toBe(300);
|
||||
});
|
||||
|
||||
it('reserves ~150ms crouch delay before leaving ground (req 2.8)', () => {
|
||||
expect(JUMP_PREPARE_DELAY_MS).toBe(150);
|
||||
});
|
||||
|
||||
it('parabolic angles anchored at 45° and 135° (req 2.5)', () => {
|
||||
expect(PARABOLIC_ANGLE_RIGHT).toBe(45);
|
||||
expect(PARABOLIC_ANGLE_LEFT).toBe(135);
|
||||
});
|
||||
|
||||
it('weapon intervals conform to 0.3/0.25/0.5s (req 3.4/3.6)', () => {
|
||||
expect(SHURIKEN_INTERVAL_BASE).toBe(0.3);
|
||||
expect(SHURIKEN_INTERVAL_UPGRADED).toBe(0.25);
|
||||
expect(SWORD_INTERVAL).toBe(0.5);
|
||||
});
|
||||
|
||||
it('enforces <50ms touch and <100ms combo KPI thresholds (req 20.1/20.4)', () => {
|
||||
expect(PERF_TOUCH_RESPONSE_MAX_MS).toBe(50);
|
||||
expect(PERF_COMBO_RECOGNITION_MAX_MS).toBe(100);
|
||||
});
|
||||
|
||||
it('defines all storage keys required by persistence (req 17 & 19.5)', () => {
|
||||
expect(STORAGE_KEY.LevelUnlock).toBeDefined();
|
||||
expect(STORAGE_KEY.ControlLayout).toBeDefined();
|
||||
expect(STORAGE_KEY.AudioVolume).toBeDefined();
|
||||
expect(STORAGE_KEY.TutorialDone).toBeDefined();
|
||||
expect(STORAGE_KEY.StoryIntroSeen).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { EventBus } from '@common/EventBus';
|
||||
|
||||
describe('EventBus', () => {
|
||||
let bus: EventBus;
|
||||
|
||||
beforeEach(() => {
|
||||
bus = new EventBus();
|
||||
});
|
||||
|
||||
it('delivers emitted payload to on() subscribers', () => {
|
||||
const spy = jest.fn();
|
||||
bus.on<number>('hit', spy);
|
||||
bus.emit('hit', 42);
|
||||
expect(spy).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('ignores duplicate subscriptions of the same handler', () => {
|
||||
const spy = jest.fn();
|
||||
bus.on('hit', spy);
|
||||
bus.on('hit', spy);
|
||||
bus.emit('hit', 1);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(bus.listenerCount('hit')).toBe(1);
|
||||
});
|
||||
|
||||
it('fires once() handlers exactly one time', () => {
|
||||
const spy = jest.fn();
|
||||
bus.once('hit', spy);
|
||||
bus.emit('hit', 1);
|
||||
bus.emit('hit', 2);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(bus.listenerCount('hit')).toBe(0);
|
||||
});
|
||||
|
||||
it('off(event, fn) removes only that handler', () => {
|
||||
const a = jest.fn();
|
||||
const b = jest.fn();
|
||||
bus.on('hit', a);
|
||||
bus.on('hit', b);
|
||||
bus.off('hit', a);
|
||||
bus.emit('hit', 1);
|
||||
expect(a).not.toHaveBeenCalled();
|
||||
expect(b).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('off(event) clears all handlers of that event', () => {
|
||||
const a = jest.fn();
|
||||
const b = jest.fn();
|
||||
bus.on('hit', a);
|
||||
bus.on('hit', b);
|
||||
bus.off('hit');
|
||||
bus.emit('hit', 1);
|
||||
expect(a).not.toHaveBeenCalled();
|
||||
expect(b).not.toHaveBeenCalled();
|
||||
expect(bus.listenerCount('hit')).toBe(0);
|
||||
});
|
||||
|
||||
it('isolates exceptions — one bad listener does not break fan-out', () => {
|
||||
const err = jest.fn();
|
||||
bus.setErrorHook(err);
|
||||
const good = jest.fn();
|
||||
bus.on('hit', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
bus.on('hit', good);
|
||||
bus.emit('hit', 1);
|
||||
expect(good).toHaveBeenCalledTimes(1);
|
||||
expect(err).toHaveBeenCalledTimes(1);
|
||||
expect(err.mock.calls[0][0]).toBe('hit');
|
||||
});
|
||||
|
||||
it('emit on an unknown event is a no-op', () => {
|
||||
expect(() => bus.emit('ghost', 1)).not.toThrow();
|
||||
});
|
||||
|
||||
it('clear() drops every handler of every event', () => {
|
||||
bus.on('a', () => {});
|
||||
bus.on('b', () => {});
|
||||
bus.clear();
|
||||
expect(bus.listenerCount('a')).toBe(0);
|
||||
expect(bus.listenerCount('b')).toBe(0);
|
||||
});
|
||||
|
||||
it('supports off for a non-existent handler without error', () => {
|
||||
const spy = jest.fn();
|
||||
bus.off('hit', spy);
|
||||
expect(bus.listenerCount('hit')).toBe(0);
|
||||
});
|
||||
|
||||
it('handles a once-handler unsubscribing itself during emit', () => {
|
||||
const calls: number[] = [];
|
||||
bus.once<number>('tick', (n) => calls.push(n));
|
||||
bus.on<number>('tick', (n) => calls.push(n * 10));
|
||||
bus.emit('tick', 1);
|
||||
bus.emit('tick', 2);
|
||||
expect(calls).toEqual([1, 10, 20]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Logger, LogLevel } from '@common/Logger';
|
||||
|
||||
describe('Logger — leveled logging', () => {
|
||||
let logger: Logger;
|
||||
let sink: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = new Logger();
|
||||
sink = jest.fn();
|
||||
logger.setSink(sink);
|
||||
});
|
||||
|
||||
it('suppresses messages below the threshold', () => {
|
||||
logger.setLevel(LogLevel.Warn);
|
||||
logger.debug('mod', 'hidden');
|
||||
logger.info('mod', 'hidden');
|
||||
logger.warn('mod', 'shown');
|
||||
logger.error('mod', 'shown');
|
||||
expect(sink).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('forwards module + message + rest args to the sink', () => {
|
||||
logger.setLevel(LogLevel.Debug);
|
||||
logger.info('Pool', 'size', 5);
|
||||
expect(sink).toHaveBeenCalledWith(LogLevel.Info, 'Pool', 'size', 5);
|
||||
});
|
||||
|
||||
it('Silent level disables all output', () => {
|
||||
logger.setLevel(LogLevel.Silent);
|
||||
logger.error('mod', 'ignored');
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logger — performance metrics', () => {
|
||||
let logger: Logger;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = new Logger();
|
||||
});
|
||||
|
||||
it('records samples under a metric name', () => {
|
||||
for (const v of [10, 20, 30, 40, 50]) {
|
||||
logger.metric({ name: 'touch_ms', value: v });
|
||||
}
|
||||
const agg = logger.aggregate('touch_ms');
|
||||
expect(agg).toBeDefined();
|
||||
expect(agg!.count).toBe(5);
|
||||
expect(agg!.min).toBe(10);
|
||||
expect(agg!.max).toBe(50);
|
||||
expect(agg!.avg).toBeCloseTo(30);
|
||||
expect(agg!.p50).toBe(30);
|
||||
expect(agg!.p95).toBe(50);
|
||||
});
|
||||
|
||||
it('returns undefined aggregate for unknown metric', () => {
|
||||
expect(logger.aggregate('ghost')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('timerStart/timerEnd records elapsed time', () => {
|
||||
logger.timerStart('frame', 1000);
|
||||
const elapsed = logger.timerEnd('frame', 1016);
|
||||
expect(elapsed).toBe(16);
|
||||
expect(logger.aggregate('frame')!.avg).toBe(16);
|
||||
});
|
||||
|
||||
it('timerEnd without start returns undefined', () => {
|
||||
expect(logger.timerEnd('never', 100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resetMetrics clears all samples and running timers', () => {
|
||||
logger.metric({ name: 'a', value: 1 });
|
||||
logger.timerStart('b', 0);
|
||||
logger.resetMetrics();
|
||||
expect(logger.aggregate('a')).toBeUndefined();
|
||||
expect(logger.timerEnd('b', 10)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ObjectPool } from '@common/ObjectPool';
|
||||
|
||||
interface Bullet {
|
||||
x: number;
|
||||
alive: boolean;
|
||||
}
|
||||
|
||||
const makeBullet = (): Bullet => ({ x: 0, alive: false });
|
||||
const resetBullet = (b: Bullet): void => {
|
||||
b.x = 0;
|
||||
b.alive = false;
|
||||
};
|
||||
|
||||
describe('ObjectPool', () => {
|
||||
it('creates new instances when the free list is empty', () => {
|
||||
const pool = new ObjectPool<Bullet>({ factory: makeBullet });
|
||||
const a = pool.acquire();
|
||||
const b = pool.acquire();
|
||||
expect(a).not.toBe(b);
|
||||
expect(pool.stats().created).toBe(2);
|
||||
expect(pool.borrowedCount).toBe(2);
|
||||
expect(pool.freeCount).toBe(0);
|
||||
});
|
||||
|
||||
it('reuses instances after release', () => {
|
||||
const pool = new ObjectPool<Bullet>({ factory: makeBullet, resetter: resetBullet });
|
||||
const a = pool.acquire();
|
||||
a.x = 100;
|
||||
a.alive = true;
|
||||
pool.release(a);
|
||||
expect(a.x).toBe(0);
|
||||
expect(a.alive).toBe(false);
|
||||
const b = pool.acquire();
|
||||
expect(b).toBe(a);
|
||||
expect(pool.stats().recycled).toBe(1);
|
||||
});
|
||||
|
||||
it('pre-allocates instances when preAlloc is set', () => {
|
||||
const pool = new ObjectPool<Bullet>({ factory: makeBullet, preAlloc: 5 });
|
||||
expect(pool.freeCount).toBe(5);
|
||||
expect(pool.stats().created).toBe(5);
|
||||
});
|
||||
|
||||
it('discards excess releases when over maxSize', () => {
|
||||
const pool = new ObjectPool<Bullet>({ factory: makeBullet, maxSize: 2 });
|
||||
const a = pool.acquire();
|
||||
const b = pool.acquire();
|
||||
const c = pool.acquire();
|
||||
pool.release(a);
|
||||
pool.release(b);
|
||||
pool.release(c); // should be dropped
|
||||
expect(pool.freeCount).toBe(2);
|
||||
});
|
||||
|
||||
it('fires onDoubleRelease and ignores double-release', () => {
|
||||
const spy = jest.fn();
|
||||
const pool = new ObjectPool<Bullet>({
|
||||
factory: makeBullet,
|
||||
onDoubleRelease: spy,
|
||||
});
|
||||
const a = pool.acquire();
|
||||
pool.release(a);
|
||||
pool.release(a);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(pool.freeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('drain() clears both free and borrowed', () => {
|
||||
const pool = new ObjectPool<Bullet>({ factory: makeBullet, preAlloc: 3 });
|
||||
pool.acquire();
|
||||
pool.drain();
|
||||
expect(pool.freeCount).toBe(0);
|
||||
expect(pool.borrowedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('stats track acquired / recycled totals', () => {
|
||||
const pool = new ObjectPool<Bullet>({ factory: makeBullet });
|
||||
const a = pool.acquire();
|
||||
const b = pool.acquire();
|
||||
pool.release(a);
|
||||
pool.release(b);
|
||||
pool.acquire();
|
||||
const s = pool.stats();
|
||||
expect(s.acquired).toBe(3);
|
||||
expect(s.recycled).toBe(2);
|
||||
expect(s.created).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Logger } from '@common/Logger';
|
||||
import { CORE_PERF_THRESHOLDS, PerfMonitor } from '@common/PerfMonitor';
|
||||
import {
|
||||
PERF_TOUCH_RESPONSE_MAX_MS,
|
||||
PERF_COMBO_RECOGNITION_MAX_MS,
|
||||
MAX_FIRST_PACKAGE_BYTES,
|
||||
MAX_AUDIO_BUNDLE_BYTES,
|
||||
MAX_MEMORY_PEAK_BYTES,
|
||||
} from '@common/Constants';
|
||||
|
||||
describe('PerfMonitor — threshold catalog (req 18 & 20)', () => {
|
||||
it('includes every KPI threshold called out in requirements 20.1-20.5', () => {
|
||||
const names = CORE_PERF_THRESHOLDS.map((t) => t.metric);
|
||||
expect(names).toContain('input/touchStart');
|
||||
expect(names).toContain('jump/state_toggle_ms');
|
||||
expect(names).toContain('input/combo_recognition_ms');
|
||||
expect(names).toContain('input/parabolic_accuracy');
|
||||
expect(names).toContain('jump/air_jump_block_rate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PerfMonitor — pass/fail evaluation', () => {
|
||||
function seedPassing(logger: Logger): void {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
logger.metric({ name: 'input/touchStart', value: 20 });
|
||||
logger.metric({ name: 'jump/state_toggle_ms', value: 25 });
|
||||
logger.metric({ name: 'input/combo_recognition_ms', value: 40 });
|
||||
}
|
||||
logger.metric({ name: 'input/parabolic_accuracy', value: 0.97 });
|
||||
logger.metric({ name: 'jump/air_jump_block_rate', value: 0.995 });
|
||||
}
|
||||
|
||||
it('passes when all metrics are within budget', () => {
|
||||
const logger = new Logger();
|
||||
seedPassing(logger);
|
||||
const monitor = new PerfMonitor(logger);
|
||||
const report = monitor.collectReport();
|
||||
expect(report.allPassing).toBe(true);
|
||||
});
|
||||
|
||||
it('fails when no samples exist for a threshold', () => {
|
||||
const monitor = new PerfMonitor(new Logger());
|
||||
const report = monitor.collectReport();
|
||||
expect(report.allPassing).toBe(false);
|
||||
expect(report.checks[0].reason).toMatch(/no samples/);
|
||||
});
|
||||
|
||||
it('fails when p95 latency exceeds the limit', () => {
|
||||
const logger = new Logger();
|
||||
seedPassing(logger);
|
||||
// Push a huge batch of slow touches so p95 exceeds budget.
|
||||
for (let i = 0; i < 200; i++) {
|
||||
logger.metric({ name: 'input/touchStart', value: PERF_TOUCH_RESPONSE_MAX_MS + 50 });
|
||||
}
|
||||
const report = new PerfMonitor(logger).collectReport();
|
||||
const touchCheck = report.checks.find((c) => c.threshold.metric === 'input/touchStart')!;
|
||||
expect(touchCheck.passing).toBe(false);
|
||||
});
|
||||
|
||||
it('fails when parabolic accuracy drops below 95%', () => {
|
||||
const logger = new Logger();
|
||||
seedPassing(logger);
|
||||
logger.resetMetrics();
|
||||
logger.metric({ name: 'input/touchStart', value: 20 });
|
||||
logger.metric({ name: 'jump/state_toggle_ms', value: 20 });
|
||||
logger.metric({ name: 'input/combo_recognition_ms', value: 20 });
|
||||
logger.metric({ name: 'input/parabolic_accuracy', value: 0.8 });
|
||||
logger.metric({ name: 'jump/air_jump_block_rate', value: 0.999 });
|
||||
const report = new PerfMonitor(logger).collectReport();
|
||||
expect(report.allPassing).toBe(false);
|
||||
});
|
||||
|
||||
it('evaluates build-size budget when provided', () => {
|
||||
const logger = new Logger();
|
||||
logger.metric({ name: 'input/touchStart', value: 10 });
|
||||
logger.metric({ name: 'jump/state_toggle_ms', value: 10 });
|
||||
logger.metric({ name: 'input/combo_recognition_ms', value: 10 });
|
||||
logger.metric({ name: 'input/parabolic_accuracy', value: 1 });
|
||||
logger.metric({ name: 'jump/air_jump_block_rate', value: 1 });
|
||||
const monitor = new PerfMonitor(logger);
|
||||
|
||||
const okReport = monitor.collectReport({
|
||||
firstPackageBytes: MAX_FIRST_PACKAGE_BYTES - 1,
|
||||
audioBundleBytes: MAX_AUDIO_BUNDLE_BYTES - 1,
|
||||
memoryPeakBytes: MAX_MEMORY_PEAK_BYTES - 1,
|
||||
});
|
||||
expect(okReport.sizeBudgetPassing).toBe(true);
|
||||
expect(okReport.allPassing).toBe(true);
|
||||
|
||||
const badReport = monitor.collectReport({
|
||||
firstPackageBytes: MAX_FIRST_PACKAGE_BYTES + 1,
|
||||
audioBundleBytes: MAX_AUDIO_BUNDLE_BYTES + 1,
|
||||
memoryPeakBytes: MAX_MEMORY_PEAK_BYTES + 1,
|
||||
});
|
||||
expect(badReport.sizeBudgetPassing).toBe(false);
|
||||
expect(badReport.allPassing).toBe(false);
|
||||
});
|
||||
|
||||
it('honours the <= comparator at exactly the boundary', () => {
|
||||
const logger = new Logger();
|
||||
logger.metric({ name: 'input/touchStart', value: PERF_TOUCH_RESPONSE_MAX_MS });
|
||||
logger.metric({ name: 'jump/state_toggle_ms', value: 10 });
|
||||
logger.metric({ name: 'input/combo_recognition_ms', value: PERF_COMBO_RECOGNITION_MAX_MS });
|
||||
logger.metric({ name: 'input/parabolic_accuracy', value: 1 });
|
||||
logger.metric({ name: 'jump/air_jump_block_rate', value: 1 });
|
||||
const report = new PerfMonitor(logger).collectReport();
|
||||
const touch = report.checks.find((c) => c.threshold.metric === 'input/touchStart')!;
|
||||
expect(touch.passing).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { StorageMgr, IStorageDriver } from '@common/StorageMgr';
|
||||
|
||||
function makeMemoryDriver(): IStorageDriver {
|
||||
const m = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => (m.has(k) ? (m.get(k) as string) : null),
|
||||
setItem: (k, v) => {
|
||||
m.set(k, v);
|
||||
},
|
||||
removeItem: (k) => {
|
||||
m.delete(k);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('StorageMgr', () => {
|
||||
it('returns default when key is missing', () => {
|
||||
const sm = new StorageMgr(makeMemoryDriver());
|
||||
expect(sm.get('nope', { v: 1 })).toEqual({ v: 1 });
|
||||
});
|
||||
|
||||
it('round-trips structured values via JSON', () => {
|
||||
const sm = new StorageMgr(makeMemoryDriver());
|
||||
const layout = { jump: { x: 100, y: 40 }, shuriken: { x: 820, y: 40 } };
|
||||
sm.set('layout', layout);
|
||||
expect(sm.get('layout', null)).toEqual(layout);
|
||||
});
|
||||
|
||||
it('returns default when value is malformed JSON (req 17.6)', () => {
|
||||
const broken: IStorageDriver = {
|
||||
getItem: () => '{not json',
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const sm = new StorageMgr(broken);
|
||||
expect(sm.get('x', 'fallback')).toBe('fallback');
|
||||
});
|
||||
|
||||
it('does not throw when the driver throws (req 17.6)', () => {
|
||||
const exploding: IStorageDriver = {
|
||||
getItem: () => {
|
||||
throw new Error('I/O error');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('I/O error');
|
||||
},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const sm = new StorageMgr(exploding);
|
||||
expect(() => sm.get('x', 'ok')).not.toThrow();
|
||||
expect(sm.get('x', 'ok')).toBe('ok');
|
||||
expect(() => sm.set('x', 'value')).not.toThrow();
|
||||
});
|
||||
|
||||
it('remove() deletes the key', () => {
|
||||
const sm = new StorageMgr(makeMemoryDriver());
|
||||
sm.set('k', 123);
|
||||
sm.remove('k');
|
||||
expect(sm.get('k', -1)).toBe(-1);
|
||||
});
|
||||
|
||||
it('setDriver swaps the underlying driver', () => {
|
||||
const sm = new StorageMgr(makeMemoryDriver());
|
||||
sm.set('k', 1);
|
||||
const fresh = makeMemoryDriver();
|
||||
sm.setDriver(fresh);
|
||||
expect(sm.get('k', 0)).toBe(0);
|
||||
sm.set('k', 2);
|
||||
expect(sm.get('k', 0)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { TimeMgr } from '@common/TimeMgr';
|
||||
|
||||
describe('TimeMgr', () => {
|
||||
let tm: TimeMgr;
|
||||
beforeEach(() => {
|
||||
tm = new TimeMgr();
|
||||
});
|
||||
|
||||
it('advances both clocks at timeScale 1', () => {
|
||||
tm.update(1);
|
||||
tm.update(0.5);
|
||||
expect(tm.gameTime).toBeCloseTo(1.5);
|
||||
expect(tm.realTime).toBeCloseTo(1.5);
|
||||
});
|
||||
|
||||
it('freezes gameTime when paused but keeps realTime ticking', () => {
|
||||
tm.pause();
|
||||
tm.update(1);
|
||||
expect(tm.gameTime).toBe(0);
|
||||
expect(tm.realTime).toBe(1);
|
||||
tm.resume();
|
||||
tm.update(0.5);
|
||||
expect(tm.gameTime).toBeCloseTo(0.5);
|
||||
expect(tm.realTime).toBeCloseTo(1.5);
|
||||
});
|
||||
|
||||
it('honors timeScale for slow-mo', () => {
|
||||
tm.setTimeScale(0.5);
|
||||
tm.update(2);
|
||||
expect(tm.gameTime).toBeCloseTo(1);
|
||||
expect(tm.realTime).toBe(2);
|
||||
});
|
||||
|
||||
it('clamps negative timeScale to 0', () => {
|
||||
tm.setTimeScale(-3);
|
||||
expect(tm.timeScale).toBe(0);
|
||||
tm.update(10);
|
||||
expect(tm.gameTime).toBe(0);
|
||||
});
|
||||
|
||||
it('scaledDelta() reflects pause and timeScale', () => {
|
||||
tm.setTimeScale(2);
|
||||
expect(tm.scaledDelta(0.1)).toBeCloseTo(0.2);
|
||||
tm.pause();
|
||||
expect(tm.scaledDelta(0.1)).toBe(0);
|
||||
});
|
||||
|
||||
it('reset() zeros everything', () => {
|
||||
tm.update(1);
|
||||
tm.pause();
|
||||
tm.setTimeScale(0.2);
|
||||
tm.reset();
|
||||
expect(tm.gameTime).toBe(0);
|
||||
expect(tm.realTime).toBe(0);
|
||||
expect(tm.timeScale).toBe(1);
|
||||
expect(tm.paused).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { ConfigMgr, MapJsonLoader } from '@data/ConfigMgr';
|
||||
import { EnemyType, ItemType, WeaponType } from '@data/Interfaces';
|
||||
|
||||
// Import the real JSON delivered by task 2.1 — if these files are malformed
|
||||
// the test suite will catch it on CI before any Cocos editor run.
|
||||
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';
|
||||
|
||||
function makeLoader(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
const base: Record<string, unknown> = {
|
||||
'configs/enemies': enemies,
|
||||
'configs/items': items,
|
||||
'configs/weapons': weapons,
|
||||
'configs/levels': levels,
|
||||
'configs/bosses': bosses,
|
||||
'configs/stories': stories,
|
||||
};
|
||||
return new MapJsonLoader({ ...base, ...overrides });
|
||||
}
|
||||
|
||||
describe('ConfigMgr — happy path with delivered JSON', () => {
|
||||
it('loads and validates the chapter-1 bundle', async () => {
|
||||
const mgr = new ConfigMgr(makeLoader());
|
||||
const bundle = await mgr.load();
|
||||
expect(bundle.enemies.length).toBe(4);
|
||||
expect(bundle.items.length).toBe(5);
|
||||
expect(bundle.weapons.length).toBe(2);
|
||||
expect(bundle.levels.length).toBe(5);
|
||||
expect(bundle.bosses.length).toBe(1);
|
||||
expect(bundle.stories.length).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves every enemy, item, weapon, level, boss, and story by id', async () => {
|
||||
const mgr = new ConfigMgr(makeLoader());
|
||||
await mgr.load();
|
||||
expect(mgr.enemy(EnemyType.QingRen).displayName).toBe('青忍');
|
||||
expect(mgr.item(ItemType.CrystalJade).displayName).toBe('水晶玉');
|
||||
expect(mgr.weapon(WeaponType.NinjaSword).canParry).toBe(true);
|
||||
expect(mgr.level('1-5').objective.kind).toBe('defeat_boss');
|
||||
expect(mgr.boss('shuang_huan_fang').phases.length).toBe(3);
|
||||
expect(mgr.story('chapter_1_intro').pages.length).toBe(3);
|
||||
});
|
||||
|
||||
it('throws when accessed before load()', () => {
|
||||
const mgr = new ConfigMgr(makeLoader());
|
||||
expect(() => mgr.enemy(EnemyType.QingRen)).toThrow(/load\(\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfigMgr — validation rejects malformed configs', () => {
|
||||
it('rejects a config bundle that contains the forbidden "casual" token (req 13.6)', async () => {
|
||||
const polluted = JSON.parse(JSON.stringify(levels));
|
||||
polluted[0].displayName = 'casual'; // inject disallowed token
|
||||
const mgr = new ConfigMgr(makeLoader({ 'configs/levels': polluted }));
|
||||
await expect(mgr.load()).rejects.toThrow(/casual/);
|
||||
});
|
||||
|
||||
it('rejects an enemy entry missing required fields', async () => {
|
||||
const bad = [{ id: 'qing_ren' }];
|
||||
const mgr = new ConfigMgr(makeLoader({ 'configs/enemies': bad }));
|
||||
await expect(mgr.load()).rejects.toThrow(/missing field/);
|
||||
});
|
||||
|
||||
it('rejects an enemy with an unknown EnemyType id', async () => {
|
||||
const bad = JSON.parse(JSON.stringify(enemies));
|
||||
bad[0].id = 'not_a_real_enemy';
|
||||
const mgr = new ConfigMgr(makeLoader({ 'configs/enemies': bad }));
|
||||
await expect(mgr.load()).rejects.toThrow(/EnemyType/);
|
||||
});
|
||||
|
||||
it('rejects a level referencing an unknown boss', async () => {
|
||||
const bad = JSON.parse(JSON.stringify(levels));
|
||||
bad[4].objective.bossId = 'ghost_boss';
|
||||
const mgr = new ConfigMgr(makeLoader({ 'configs/levels': bad }));
|
||||
await expect(mgr.load()).rejects.toThrow(/unknown boss/);
|
||||
});
|
||||
|
||||
it('rejects a level spawn referencing an unknown enemy', async () => {
|
||||
const bad = JSON.parse(JSON.stringify(levels));
|
||||
bad[0].enemySpawns[0].type = 'white_ninja';
|
||||
const mgr = new ConfigMgr(makeLoader({ 'configs/levels': bad }));
|
||||
await expect(mgr.load()).rejects.toThrow(/unknown enemy/);
|
||||
});
|
||||
|
||||
it('rejects boss phases that are not monotonically descending', async () => {
|
||||
const bad = JSON.parse(JSON.stringify(bosses));
|
||||
bad[0].phases = [
|
||||
{ hpThreshold: 0.33, mode: 'a', actionIntervalSec: 1 },
|
||||
{ hpThreshold: 1.0, mode: 'b', actionIntervalSec: 1 },
|
||||
];
|
||||
const mgr = new ConfigMgr(makeLoader({ 'configs/bosses': bad }));
|
||||
await expect(mgr.load()).rejects.toThrow(/descending hpThreshold/);
|
||||
});
|
||||
|
||||
it('rejects a story with fewer than 3 pages (req 19.2)', async () => {
|
||||
const bad = JSON.parse(JSON.stringify(stories));
|
||||
bad[0].pages = bad[0].pages.slice(0, 2);
|
||||
const mgr = new ConfigMgr(makeLoader({ 'configs/stories': bad }));
|
||||
await expect(mgr.load()).rejects.toThrow(/≥3 pages/);
|
||||
});
|
||||
|
||||
it('rejects a story whose maxDurationSec exceeds the 30s budget (req 19.1)', async () => {
|
||||
const bad = JSON.parse(JSON.stringify(stories));
|
||||
bad[0].maxDurationSec = 45;
|
||||
const mgr = new ConfigMgr(makeLoader({ 'configs/stories': bad }));
|
||||
await expect(mgr.load()).rejects.toThrow(/30s budget/);
|
||||
});
|
||||
|
||||
it('rejects a story with non-contiguous page indices', async () => {
|
||||
const bad = JSON.parse(JSON.stringify(stories));
|
||||
bad[0].pages[1].index = 5;
|
||||
const mgr = new ConfigMgr(makeLoader({ 'configs/stories': bad }));
|
||||
await expect(mgr.load()).rejects.toThrow(/contiguous/);
|
||||
});
|
||||
|
||||
it('rejects an empty enemies list', async () => {
|
||||
const mgr = new ConfigMgr(makeLoader({ 'configs/enemies': [] }));
|
||||
await expect(mgr.load()).rejects.toThrow(/enemies list is empty/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
ControlId,
|
||||
DEFAULT_LAYOUT,
|
||||
MultiTouchRouter,
|
||||
applySafeArea,
|
||||
classifyDirection,
|
||||
hitTest,
|
||||
isInsideRect,
|
||||
joystickDirection,
|
||||
ZERO_DIRECTION,
|
||||
} from '@ui/InputModel';
|
||||
|
||||
describe('InputModel — layout geometry', () => {
|
||||
it('default landscape layout places joystick on left, attacks on right', () => {
|
||||
expect(DEFAULT_LAYOUT.joystick.cx).toBeLessThan(480);
|
||||
expect(DEFAULT_LAYOUT.shuriken.cx).toBeGreaterThan(480);
|
||||
expect(DEFAULT_LAYOUT.ninjaSword.cx).toBeGreaterThan(DEFAULT_LAYOUT.shuriken.cx);
|
||||
});
|
||||
|
||||
it('isInsideRect is correct for corners and near-misses', () => {
|
||||
const r = { cx: 100, cy: 100, w: 40, h: 40 };
|
||||
expect(isInsideRect(r, 100, 100)).toBe(true);
|
||||
expect(isInsideRect(r, 120, 120)).toBe(true);
|
||||
expect(isInsideRect(r, 121, 100)).toBe(false);
|
||||
expect(isInsideRect(r, 100, 79)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InputModel — hitTest priority', () => {
|
||||
it('routes a finger pressing both an attack and the joystick to the attack', () => {
|
||||
// Arrange: stretch the joystick rect so it overlaps the shuriken button.
|
||||
const layout = {
|
||||
...DEFAULT_LAYOUT,
|
||||
joystick: { cx: 120, cy: 100, w: 900, h: 200 },
|
||||
};
|
||||
const id = hitTest(layout, layout.shuriken.cx, layout.shuriken.cy);
|
||||
expect(id).toBe(ControlId.Shuriken);
|
||||
});
|
||||
|
||||
it('returns null when the touch misses every control', () => {
|
||||
expect(hitTest(DEFAULT_LAYOUT, 480, 400)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('InputModel — joystick dead zone (req 1.5)', () => {
|
||||
it('returns ZERO_DIRECTION inside the 10px dead-zone', () => {
|
||||
const dir = joystickDirection(DEFAULT_LAYOUT, DEFAULT_LAYOUT.joystick.cx + 4, DEFAULT_LAYOUT.joystick.cy - 3);
|
||||
expect(dir).toBe(ZERO_DIRECTION);
|
||||
});
|
||||
|
||||
it('returns a normalised vector with magnitude > deadzone outside it', () => {
|
||||
const dir = joystickDirection(
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_LAYOUT.joystick.cx + 60,
|
||||
DEFAULT_LAYOUT.joystick.cy + 0
|
||||
);
|
||||
expect(dir.magnitude).toBeCloseTo(60);
|
||||
expect(dir.x).toBeCloseTo(1);
|
||||
expect(dir.y).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InputModel — parabolic angle classification (req 2.5, 20.3)', () => {
|
||||
it.each([
|
||||
[45, 'parabolic_right'],
|
||||
[50, 'parabolic_right'],
|
||||
[40, 'parabolic_right'],
|
||||
[60, 'parabolic_right'],
|
||||
[135, 'parabolic_left'],
|
||||
[140, 'parabolic_left'],
|
||||
[120, 'parabolic_left'],
|
||||
[0, 'horizontal'],
|
||||
[180, 'horizontal'],
|
||||
[90, 'other'],
|
||||
] as Array<[number, string]>)('degree %p → %p', (deg, klass) => {
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const dir = { x: Math.cos(rad), y: Math.sin(rad), magnitude: 1 };
|
||||
expect(classifyDirection(dir)).toBe(klass);
|
||||
});
|
||||
|
||||
it('hits ≥95% recognition rate when sampling evenly around 45° ± 15°', () => {
|
||||
let hits = 0;
|
||||
const samples = 200;
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const deg = 30 + (i / samples) * 30; // 30°..60°
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const k = classifyDirection({ x: Math.cos(rad), y: Math.sin(rad), magnitude: 1 });
|
||||
if (k === 'parabolic_right') hits++;
|
||||
}
|
||||
expect(hits / samples).toBeGreaterThanOrEqual(0.95);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InputModel — applySafeArea (req 1.7, 18.6)', () => {
|
||||
it('slides left-group rightwards and right-group leftwards by the insets', () => {
|
||||
const shifted = applySafeArea(DEFAULT_LAYOUT, { left: 40, right: 60, top: 0, bottom: 0 });
|
||||
expect(shifted.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx + 40);
|
||||
expect(shifted.jump.cx).toBe(DEFAULT_LAYOUT.jump.cx + 40);
|
||||
expect(shifted.shuriken.cx).toBe(DEFAULT_LAYOUT.shuriken.cx - 60);
|
||||
expect(shifted.ninjaSword.cx).toBe(DEFAULT_LAYOUT.ninjaSword.cx - 60);
|
||||
});
|
||||
|
||||
it('keeps every control inside the visible area after shifting', () => {
|
||||
const shifted = applySafeArea(DEFAULT_LAYOUT, { left: 20, right: 20, top: 20, bottom: 20 });
|
||||
const all = [shifted.joystick, shifted.jump, shifted.shuriken, shifted.ninjaSword];
|
||||
for (const r of all) {
|
||||
expect(r.cx + r.w / 2).toBeLessThanOrEqual(960);
|
||||
expect(r.cx - r.w / 2).toBeGreaterThanOrEqual(0);
|
||||
expect(r.cy + r.h / 2).toBeLessThanOrEqual(540);
|
||||
expect(r.cy - r.h / 2).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MultiTouchRouter — req 1.8 (≥3 simultaneous touches)', () => {
|
||||
it('routes three fingers to joystick + jump + shuriken independently', () => {
|
||||
const router = new MultiTouchRouter(DEFAULT_LAYOUT);
|
||||
const j = router.begin(0, DEFAULT_LAYOUT.joystick.cx, DEFAULT_LAYOUT.joystick.cy, 0);
|
||||
const p = router.begin(1, DEFAULT_LAYOUT.jump.cx, DEFAULT_LAYOUT.jump.cy, 10);
|
||||
const s = router.begin(2, DEFAULT_LAYOUT.shuriken.cx, DEFAULT_LAYOUT.shuriken.cy, 20);
|
||||
expect(j).toBe(ControlId.Joystick);
|
||||
expect(p).toBe(ControlId.Jump);
|
||||
expect(s).toBe(ControlId.Shuriken);
|
||||
expect(router.activeTouchCount).toBe(3);
|
||||
expect(router.isPressed(ControlId.Jump)).toBe(true);
|
||||
});
|
||||
|
||||
it('end() returns the previously-bound control and removes the slot', () => {
|
||||
const router = new MultiTouchRouter(DEFAULT_LAYOUT);
|
||||
router.begin(5, DEFAULT_LAYOUT.ninjaSword.cx, DEFAULT_LAYOUT.ninjaSword.cy, 0);
|
||||
expect(router.end(5)).toBe(ControlId.NinjaSword);
|
||||
expect(router.isPressed(ControlId.NinjaSword)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls through (returns null) when the touch lands outside all controls (req 1.3)', () => {
|
||||
const router = new MultiTouchRouter(DEFAULT_LAYOUT);
|
||||
expect(router.begin(7, 480, 400, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('earliestPressTs returns the oldest timestamp among the given controls', () => {
|
||||
const router = new MultiTouchRouter(DEFAULT_LAYOUT);
|
||||
router.begin(0, DEFAULT_LAYOUT.jump.cx, DEFAULT_LAYOUT.jump.cy, 100);
|
||||
router.begin(1, DEFAULT_LAYOUT.shuriken.cx, DEFAULT_LAYOUT.shuriken.cy, 80);
|
||||
expect(router.earliestPressTs([ControlId.Jump, ControlId.Shuriken])).toBe(80);
|
||||
expect(router.earliestPressTs([ControlId.NinjaSword])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
DEFAULT_LAYOUT_DELTA,
|
||||
LAYOUT_DELTA_BOUNDS,
|
||||
LayoutCustomizer,
|
||||
applyLayoutDelta,
|
||||
sanitiseLayoutDelta,
|
||||
} from '@ui/LayoutCustomizer';
|
||||
import { DEFAULT_LAYOUT } from '@ui/InputModel';
|
||||
import { StorageMgr } from '@common/StorageMgr';
|
||||
import { STORAGE_KEY } from '@common/Constants';
|
||||
|
||||
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('sanitiseLayoutDelta', () => {
|
||||
it('returns a defensive copy of the default when given null', () => {
|
||||
const d = sanitiseLayoutDelta(null);
|
||||
expect(d).toEqual(DEFAULT_LAYOUT_DELTA);
|
||||
d.opacity = 0.1;
|
||||
expect(DEFAULT_LAYOUT_DELTA.opacity).toBe(0.7); // ensure we did not mutate
|
||||
});
|
||||
|
||||
it('clamps offset beyond the allowed range', () => {
|
||||
const d = sanitiseLayoutDelta({ joystickOffset: { dx: 9999, dy: -9999 } });
|
||||
expect(d.joystickOffset.dx).toBe(LAYOUT_DELTA_BOUNDS.offsetPxMax);
|
||||
expect(d.joystickOffset.dy).toBe(-LAYOUT_DELTA_BOUNDS.offsetPxMax);
|
||||
});
|
||||
|
||||
it('clamps size scale and opacity to allowed ranges', () => {
|
||||
const d = sanitiseLayoutDelta({ buttonSizeScale: 5, opacity: 2 });
|
||||
expect(d.buttonSizeScale).toBe(LAYOUT_DELTA_BOUNDS.sizeScaleMax);
|
||||
expect(d.opacity).toBe(LAYOUT_DELTA_BOUNDS.opacityMax);
|
||||
});
|
||||
|
||||
it('replaces NaN-like inputs with safe midpoints', () => {
|
||||
const d = sanitiseLayoutDelta({ buttonSizeScale: NaN });
|
||||
expect(d.buttonSizeScale).toBeGreaterThanOrEqual(LAYOUT_DELTA_BOUNDS.sizeScaleMin);
|
||||
expect(d.buttonSizeScale).toBeLessThanOrEqual(LAYOUT_DELTA_BOUNDS.sizeScaleMax);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyLayoutDelta', () => {
|
||||
it('produces a layout identical to baseline when delta is default', () => {
|
||||
const result = applyLayoutDelta(DEFAULT_LAYOUT, DEFAULT_LAYOUT_DELTA);
|
||||
expect(result.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx);
|
||||
expect(result.opacity).toBe(DEFAULT_LAYOUT_DELTA.opacity);
|
||||
});
|
||||
|
||||
it('shifts centres and scales widths', () => {
|
||||
const delta = sanitiseLayoutDelta({
|
||||
joystickOffset: { dx: 30, dy: 10 },
|
||||
buttonSizeScale: 1.2,
|
||||
opacity: 0.9,
|
||||
});
|
||||
const r = applyLayoutDelta(DEFAULT_LAYOUT, delta);
|
||||
expect(r.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx + 30);
|
||||
expect(r.joystick.cy).toBe(DEFAULT_LAYOUT.joystick.cy + 10);
|
||||
expect(r.shuriken.w).toBeCloseTo(DEFAULT_LAYOUT.shuriken.w * 1.2);
|
||||
expect(r.opacity).toBe(0.9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LayoutCustomizer — persistence', () => {
|
||||
it('returns the default layout when nothing is stored (req 17.6)', () => {
|
||||
const cust = new LayoutCustomizer(DEFAULT_LAYOUT, new StorageMgr(mem()));
|
||||
const { delta, layout } = cust.loadLayout();
|
||||
expect(delta).toEqual(DEFAULT_LAYOUT_DELTA);
|
||||
expect(layout.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx);
|
||||
});
|
||||
|
||||
it('round-trips a custom delta through saveDelta() / loadLayout()', () => {
|
||||
const storage = new StorageMgr(mem());
|
||||
const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage);
|
||||
cust.saveDelta({
|
||||
joystickOffset: { dx: 20, dy: -15 },
|
||||
jumpOffset: { dx: 0, dy: 0 },
|
||||
shurikenOffset: { dx: -10, dy: 5 },
|
||||
ninjaSwordOffset: { dx: 0, dy: 0 },
|
||||
buttonSizeScale: 1.1,
|
||||
opacity: 0.85,
|
||||
});
|
||||
const { delta } = cust.loadLayout();
|
||||
expect(delta.joystickOffset.dx).toBe(20);
|
||||
expect(delta.shurikenOffset.dx).toBe(-10);
|
||||
expect(delta.opacity).toBe(0.85);
|
||||
});
|
||||
|
||||
it('reset() clears the stored layout', () => {
|
||||
const storage = new StorageMgr(mem());
|
||||
const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage);
|
||||
cust.saveDelta({ ...DEFAULT_LAYOUT_DELTA, opacity: 1.0 });
|
||||
cust.reset();
|
||||
const { delta } = cust.loadLayout();
|
||||
expect(delta.opacity).toBe(DEFAULT_LAYOUT_DELTA.opacity);
|
||||
});
|
||||
|
||||
it('falls back to defaults when storage returns corrupted JSON (req 17.6)', () => {
|
||||
const driver = {
|
||||
getItem: () => 'not valid json',
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const storage = new StorageMgr(driver);
|
||||
const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage);
|
||||
const { delta } = cust.loadLayout();
|
||||
expect(delta).toEqual(DEFAULT_LAYOUT_DELTA);
|
||||
});
|
||||
|
||||
it('uses the kl_control_layout storage key (req 17.2)', () => {
|
||||
const driver = mem();
|
||||
const setSpy = jest.fn(driver.setItem);
|
||||
driver.setItem = setSpy;
|
||||
const storage = new StorageMgr(driver);
|
||||
const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage);
|
||||
cust.saveDelta(DEFAULT_LAYOUT_DELTA);
|
||||
expect(setSpy).toHaveBeenCalled();
|
||||
expect(setSpy.mock.calls[0][0]).toBe(STORAGE_KEY.ControlLayout);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { StorySceneCtrl, BASE_TYPING_CPS } from '@ui/StorySceneCtrl';
|
||||
import { StorageMgr } from '@common/StorageMgr';
|
||||
import { IStorySceneConfig } from '@data/Interfaces';
|
||||
import { STORAGE_KEY } from '@common/Constants';
|
||||
|
||||
const SCENE: IStorySceneConfig = {
|
||||
id: 'chapter_1_intro',
|
||||
bgm: 'bgm_story',
|
||||
maxDurationSec: 30,
|
||||
pages: [
|
||||
{ index: 1, illustration: 'p1', text: 'A' },
|
||||
{ index: 2, illustration: 'p2', text: 'BCDE' },
|
||||
{ index: 3, illustration: 'p3', text: 'FGH' },
|
||||
],
|
||||
};
|
||||
|
||||
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('StorySceneCtrl — first-time gate (req 19.5)', () => {
|
||||
it('start() reports "already_seen" when storage flag is set', () => {
|
||||
const storage = new StorageMgr(mem());
|
||||
storage.set(STORAGE_KEY.StoryIntroSeen, true);
|
||||
const ctrl = new StorySceneCtrl(SCENE, storage);
|
||||
expect(ctrl.start()).toBe('already_seen');
|
||||
});
|
||||
|
||||
it('start() reports "playing" on first run', () => {
|
||||
const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem()));
|
||||
expect(ctrl.start()).toBe('playing');
|
||||
expect(ctrl.status).toBe('typing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StorySceneCtrl — typewriter (req 19.2-19.3)', () => {
|
||||
it('reveals characters at BASE_TYPING_CPS', () => {
|
||||
const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem()));
|
||||
ctrl.start();
|
||||
// Advance enough time to type "A".
|
||||
ctrl.tick(1 / BASE_TYPING_CPS + 0.001);
|
||||
expect(ctrl.visibleText).toBe('A');
|
||||
// Page "A" complete → waiting_next.
|
||||
expect(ctrl.status).toBe('waiting_next');
|
||||
});
|
||||
|
||||
it('tap during typing fully reveals the current page', () => {
|
||||
const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem()));
|
||||
ctrl.start();
|
||||
ctrl.onTap(); // accelerate page 1 ("A")
|
||||
expect(ctrl.visibleText).toBe('A');
|
||||
expect(ctrl.status).toBe('waiting_next');
|
||||
// Next tap → advance to page 2.
|
||||
ctrl.onTap();
|
||||
expect(ctrl.currentPageNumber).toBe(2);
|
||||
expect(ctrl.status).toBe('typing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StorySceneCtrl — skip (req 19.4)', () => {
|
||||
it('onSkip immediately finishes and marks seen', () => {
|
||||
const storage = new StorageMgr(mem());
|
||||
const onFinished = jest.fn();
|
||||
const ctrl = new StorySceneCtrl(SCENE, storage, { onFinished });
|
||||
ctrl.start();
|
||||
ctrl.onSkip();
|
||||
expect(ctrl.status).toBe('finished');
|
||||
expect(onFinished).toHaveBeenCalledWith(true);
|
||||
expect(storage.get(STORAGE_KEY.StoryIntroSeen, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('re-skipping after finish is a no-op', () => {
|
||||
const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem()));
|
||||
ctrl.start();
|
||||
ctrl.onSkip();
|
||||
expect(() => ctrl.onSkip()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StorySceneCtrl — reset() (req 19.6)', () => {
|
||||
it('clears the "seen" flag', () => {
|
||||
const storage = new StorageMgr(mem());
|
||||
const ctrl = new StorySceneCtrl(SCENE, storage);
|
||||
ctrl.start();
|
||||
ctrl.onSkip();
|
||||
expect(ctrl.hasBeenSeen()).toBe(true);
|
||||
ctrl.reset();
|
||||
expect(ctrl.hasBeenSeen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StorySceneCtrl — natural finish (all pages)', () => {
|
||||
it('calls onFinished(false) after advancing past the last page', () => {
|
||||
const onFinished = jest.fn();
|
||||
const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem()), { onFinished });
|
||||
ctrl.start();
|
||||
// Full page 1.
|
||||
ctrl.onTap();
|
||||
ctrl.onTap(); // → page 2
|
||||
ctrl.onTap(); // reveal page 2
|
||||
ctrl.onTap(); // → page 3
|
||||
ctrl.onTap(); // reveal page 3
|
||||
ctrl.onTap(); // → finish
|
||||
expect(onFinished).toHaveBeenCalledWith(false);
|
||||
expect(ctrl.status).toBe('finished');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { UIFlowMgr, ISceneEnter } from '@ui/UIFlowMgr';
|
||||
import { StorageMgr } from '@common/StorageMgr';
|
||||
import { STORAGE_KEY } from '@common/Constants';
|
||||
|
||||
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('UIFlowMgr — boot path (req 19.1, 19.5)', () => {
|
||||
it('routes first-time boot into story_intro', () => {
|
||||
const events: ISceneEnter[] = [];
|
||||
const flow = new UIFlowMgr(new StorageMgr(mem()), { onSceneEnter: (e) => events.push(e) });
|
||||
flow.onBoot();
|
||||
expect(events[0].scene).toBe('story_intro');
|
||||
});
|
||||
|
||||
it('routes repeat boot into main_menu', () => {
|
||||
const storage = new StorageMgr(mem());
|
||||
storage.set(STORAGE_KEY.StoryIntroSeen, true);
|
||||
const events: ISceneEnter[] = [];
|
||||
const flow = new UIFlowMgr(storage, { onSceneEnter: (e) => events.push(e) });
|
||||
flow.onBoot();
|
||||
expect(events[0].scene).toBe('main_menu');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UIFlowMgr — level/settlement/dead transitions', () => {
|
||||
it('onPickLevel → gameplay with payload', () => {
|
||||
const events: ISceneEnter[] = [];
|
||||
const flow = new UIFlowMgr(new StorageMgr(mem()), { onSceneEnter: (e) => events.push(e) });
|
||||
flow.onPickLevel('1-3');
|
||||
expect(events[0]).toEqual({ scene: 'gameplay', payload: { levelId: '1-3' } });
|
||||
});
|
||||
|
||||
it('onPlayerDied pushes a settlement with dead:true', () => {
|
||||
const events: ISceneEnter[] = [];
|
||||
const flow = new UIFlowMgr(new StorageMgr(mem()), { onSceneEnter: (e) => events.push(e) });
|
||||
flow.onPlayerDied('1-2');
|
||||
expect(events[0].scene).toBe('settlement');
|
||||
expect(events[0].payload).toEqual({ levelId: '1-2', dead: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('UIFlowMgr — difficulty guardrail (req 13.1)', () => {
|
||||
it('availableSettingsEntries does NOT include difficulty', () => {
|
||||
const flow = new UIFlowMgr(new StorageMgr(mem()));
|
||||
const entries = flow.availableSettingsEntries();
|
||||
expect(entries).not.toContain('difficulty');
|
||||
expect(entries).toContain('audio_volume');
|
||||
expect(entries).toContain('replay_story_intro');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user