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(path: string): Promise; } /** * 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) {} public async load(path: string): Promise { 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 { const [enemies, items, weapons, levels, bosses, stories] = await Promise.all([ this.loader.load('configs/enemies'), this.loader.load('configs/items'), this.loader.load('configs/weapons'), this.loader.load('configs/levels'), this.loader.load('configs/bosses'), this.loader.load('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 = ['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}"`); } } if (lv.reinforcements) { for (const rule of lv.reinforcements) { if (!enemyIds.has(rule.type)) { throw new Error(`ConfigMgr: level "${lv.id}" reinforcement references unknown enemy "${rule.type}"`); } if (rule.intervalSec <= 0) { throw new Error(`ConfigMgr: level "${lv.id}" reinforcement intervalSec must be positive`); } if (rule.count <= 0) { throw new Error(`ConfigMgr: level "${lv.id}" reinforcement count must be positive`); } } } } } 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`); } } } } }