240 lines
10 KiB
TypeScript
240 lines
10 KiB
TypeScript
import {
|
|
IChapter1ConfigBundle,
|
|
IEnemyConfig,
|
|
IItemConfig,
|
|
IWeaponConfig,
|
|
ILevelConfig,
|
|
IBossConfig,
|
|
IStorySceneConfig,
|
|
EnemyType,
|
|
ItemType,
|
|
WeaponType,
|
|
ONLY_DIFFICULTY,
|
|
} from './Interfaces';
|
|
|
|
/**
|
|
* Abstraction over how JSON is actually fetched. In Cocos Creator 3.8 this
|
|
* will be backed by `resources.load('configs/enemies', JsonAsset, cb)`. In
|
|
* Jest we inject an in-memory `MapLoader` so tests stay fast and offline.
|
|
*/
|
|
export interface IJsonLoader {
|
|
load<T>(path: string): Promise<T>;
|
|
}
|
|
|
|
/**
|
|
* Minimal in-memory loader, fed with a plain key→JSON map. Used by unit
|
|
* tests and by any non-Cocos runtime (e.g. a future web leaderboard tool).
|
|
*/
|
|
export class MapJsonLoader implements IJsonLoader {
|
|
constructor(private readonly map: Record<string, unknown>) {}
|
|
public async load<T>(path: string): Promise<T> {
|
|
if (!(path in this.map)) {
|
|
throw new Error(`MapJsonLoader: missing path "${path}"`);
|
|
}
|
|
return this.map[path] as T;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads the chapter-1 config bundle and validates it against the interface
|
|
* contract. Any missing required field, unknown enum value, or any reference
|
|
* to a removed difficulty mode causes `load()` to reject with a descriptive
|
|
* error (requirement 13.6 — no casual/普通 mode may ever load).
|
|
*/
|
|
export class ConfigMgr {
|
|
private _bundle: IChapter1ConfigBundle | undefined;
|
|
|
|
constructor(private readonly loader: IJsonLoader) {}
|
|
|
|
public get bundle(): IChapter1ConfigBundle {
|
|
if (!this._bundle) {
|
|
throw new Error('ConfigMgr: load() must be awaited before accessing bundle');
|
|
}
|
|
return this._bundle;
|
|
}
|
|
|
|
public async load(): Promise<IChapter1ConfigBundle> {
|
|
const [enemies, items, weapons, levels, bosses, stories] = await Promise.all([
|
|
this.loader.load<IEnemyConfig[]>('configs/enemies'),
|
|
this.loader.load<IItemConfig[]>('configs/items'),
|
|
this.loader.load<IWeaponConfig[]>('configs/weapons'),
|
|
this.loader.load<ILevelConfig[]>('configs/levels'),
|
|
this.loader.load<IBossConfig[]>('configs/bosses'),
|
|
this.loader.load<IStorySceneConfig[]>('configs/stories'),
|
|
]);
|
|
const bundle: IChapter1ConfigBundle = { enemies, items, weapons, levels, bosses, stories };
|
|
this.validate(bundle);
|
|
this._bundle = bundle;
|
|
return bundle;
|
|
}
|
|
|
|
/** Look up an enemy config, throwing if it's missing. */
|
|
public enemy(id: EnemyType): IEnemyConfig {
|
|
const e = this.bundle.enemies.find((x) => x.id === id);
|
|
if (!e) throw new Error(`ConfigMgr: enemy "${id}" not found`);
|
|
return e;
|
|
}
|
|
|
|
/** Look up an item config, throwing if it's missing. */
|
|
public item(id: ItemType): IItemConfig {
|
|
const it = this.bundle.items.find((x) => x.id === id);
|
|
if (!it) throw new Error(`ConfigMgr: item "${id}" not found`);
|
|
return it;
|
|
}
|
|
|
|
/** Look up a weapon config, throwing if it's missing. */
|
|
public weapon(id: WeaponType): IWeaponConfig {
|
|
const w = this.bundle.weapons.find((x) => x.id === id);
|
|
if (!w) throw new Error(`ConfigMgr: weapon "${id}" not found`);
|
|
return w;
|
|
}
|
|
|
|
/** Look up a level config, throwing if it's missing. */
|
|
public level(id: string): ILevelConfig {
|
|
const lv = this.bundle.levels.find((x) => x.id === id);
|
|
if (!lv) throw new Error(`ConfigMgr: level "${id}" not found`);
|
|
return lv;
|
|
}
|
|
|
|
/** Look up a boss config, throwing if it's missing. */
|
|
public boss(id: string): IBossConfig {
|
|
const b = this.bundle.bosses.find((x) => x.id === id);
|
|
if (!b) throw new Error(`ConfigMgr: boss "${id}" not found`);
|
|
return b;
|
|
}
|
|
|
|
/** Look up a story scene, throwing if it's missing. */
|
|
public story(id: string): IStorySceneConfig {
|
|
const s = this.bundle.stories.find((x) => x.id === id);
|
|
if (!s) throw new Error(`ConfigMgr: story "${id}" not found`);
|
|
return s;
|
|
}
|
|
|
|
// ---------- validation ----------
|
|
|
|
private validate(b: IChapter1ConfigBundle): void {
|
|
// Guard against the D-4 decision — casual mode must never load.
|
|
const stringified = JSON.stringify(b).toLowerCase();
|
|
if (stringified.includes('"casual"') || stringified.includes('"normal_mode"')) {
|
|
throw new Error(
|
|
`ConfigMgr: detected forbidden casual/normal_mode token — only difficulty "${ONLY_DIFFICULTY}" is permitted`
|
|
);
|
|
}
|
|
this.validateEnemies(b.enemies);
|
|
this.validateItems(b.items);
|
|
this.validateWeapons(b.weapons);
|
|
this.validateLevels(b);
|
|
this.validateBosses(b.bosses);
|
|
this.validateStories(b.stories);
|
|
}
|
|
|
|
private validateEnemies(list: IEnemyConfig[]): void {
|
|
if (list.length === 0) throw new Error('ConfigMgr: enemies list is empty');
|
|
const required: Array<keyof IEnemyConfig> = ['id', 'displayName', 'size', 'moveSpeed', 'attackIntervalSec', 'attacks', 'hp'];
|
|
for (const e of list) {
|
|
for (const key of required) {
|
|
if (e[key] === undefined || e[key] === null) {
|
|
throw new Error(`ConfigMgr: enemy "${e.id ?? '?'}" missing field "${String(key)}"`);
|
|
}
|
|
}
|
|
if (!Object.values(EnemyType).includes(e.id as EnemyType)) {
|
|
throw new Error(`ConfigMgr: enemy id "${e.id}" is not a known EnemyType`);
|
|
}
|
|
if (e.attacks.length === 0) throw new Error(`ConfigMgr: enemy "${e.id}" must declare at least one attack`);
|
|
}
|
|
}
|
|
|
|
private validateItems(list: IItemConfig[]): void {
|
|
if (list.length === 0) throw new Error('ConfigMgr: items list is empty');
|
|
for (const it of list) {
|
|
if (!it.id || !it.displayName || !it.icon || typeof it.lifetimeSec !== 'number') {
|
|
throw new Error(`ConfigMgr: item "${it.id ?? '?'}" has missing required fields`);
|
|
}
|
|
if (!Object.values(ItemType).includes(it.id as ItemType)) {
|
|
throw new Error(`ConfigMgr: item id "${it.id}" is not a known ItemType`);
|
|
}
|
|
}
|
|
}
|
|
|
|
private validateWeapons(list: IWeaponConfig[]): void {
|
|
if (list.length === 0) throw new Error('ConfigMgr: weapons list is empty');
|
|
for (const w of list) {
|
|
if (!w.id || !w.displayName || typeof w.baseIntervalSec !== 'number' || typeof w.damage !== 'number') {
|
|
throw new Error(`ConfigMgr: weapon "${w.id ?? '?'}" has missing required fields`);
|
|
}
|
|
if (!Object.values(WeaponType).includes(w.id as WeaponType)) {
|
|
throw new Error(`ConfigMgr: weapon id "${w.id}" is not a known WeaponType`);
|
|
}
|
|
}
|
|
}
|
|
|
|
private validateLevels(b: IChapter1ConfigBundle): void {
|
|
if (b.levels.length === 0) throw new Error('ConfigMgr: levels list is empty');
|
|
const enemyIds = new Set(b.enemies.map((e) => e.id));
|
|
const bossIds = new Set(b.bosses.map((x) => x.id));
|
|
for (const lv of b.levels) {
|
|
if (!lv.id || !lv.displayName || !lv.sceneTheme || !lv.scrollDirection || !lv.objective) {
|
|
throw new Error(`ConfigMgr: level "${lv.id ?? '?'}" has missing required fields`);
|
|
}
|
|
if (lv.timeLimitSec <= 0) {
|
|
throw new Error(`ConfigMgr: level "${lv.id}" must have positive timeLimitSec`);
|
|
}
|
|
if (lv.objective.kind === 'kill_count') {
|
|
if (!lv.objective.enemy || !lv.objective.count) {
|
|
throw new Error(`ConfigMgr: level "${lv.id}" kill_count objective missing enemy/count`);
|
|
}
|
|
if (!enemyIds.has(lv.objective.enemy)) {
|
|
throw new Error(`ConfigMgr: level "${lv.id}" references unknown enemy "${lv.objective.enemy}"`);
|
|
}
|
|
}
|
|
if (lv.objective.kind === 'defeat_boss') {
|
|
if (!lv.objective.bossId || !bossIds.has(lv.objective.bossId)) {
|
|
throw new Error(`ConfigMgr: level "${lv.id}" references unknown boss "${lv.objective.bossId}"`);
|
|
}
|
|
}
|
|
for (const sp of lv.enemySpawns) {
|
|
if (!enemyIds.has(sp.type)) {
|
|
throw new Error(`ConfigMgr: level "${lv.id}" spawn references unknown enemy "${sp.type}"`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private validateBosses(list: IBossConfig[]): void {
|
|
if (list.length === 0) throw new Error('ConfigMgr: bosses list is empty');
|
|
for (const bo of list) {
|
|
if (!bo.id || !bo.displayName || typeof bo.hp !== 'number' || !Array.isArray(bo.phases)) {
|
|
throw new Error(`ConfigMgr: boss "${bo.id ?? '?'}" has missing required fields`);
|
|
}
|
|
if (bo.phases.length === 0) {
|
|
throw new Error(`ConfigMgr: boss "${bo.id}" must have at least one phase`);
|
|
}
|
|
let prev = Number.POSITIVE_INFINITY;
|
|
for (const p of bo.phases) {
|
|
if (p.hpThreshold > prev) {
|
|
throw new Error(`ConfigMgr: boss "${bo.id}" phases must be ordered by descending hpThreshold`);
|
|
}
|
|
prev = p.hpThreshold;
|
|
}
|
|
}
|
|
}
|
|
|
|
private validateStories(list: IStorySceneConfig[]): void {
|
|
if (list.length === 0) throw new Error('ConfigMgr: stories list is empty');
|
|
for (const s of list) {
|
|
if (!s.id || !s.bgm || !Array.isArray(s.pages) || s.pages.length < 3) {
|
|
throw new Error(`ConfigMgr: story "${s.id ?? '?'}" must include id, bgm, and ≥3 pages (req 19.2)`);
|
|
}
|
|
if (s.maxDurationSec > 30) {
|
|
throw new Error(`ConfigMgr: story "${s.id}" maxDurationSec exceeds 30s budget (req 19.1)`);
|
|
}
|
|
const indices = s.pages.map((p) => p.index).sort((a, b) => a - b);
|
|
for (let i = 0; i < indices.length; i++) {
|
|
if (indices[i] !== i + 1) {
|
|
throw new Error(`ConfigMgr: story "${s.id}" page indices must be contiguous starting from 1`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|