refactor: 插件系统 & 数据端顶层移植

This commit is contained in:
unanmed 2026-10-14 20:32:46 +08:00
parent b0229c627f
commit 2f3d3387a0
74 changed files with 1322 additions and 3321 deletions

View File

@ -0,0 +1,176 @@
---
phase: 09-minimum-browser-runnable
plan: 20
subsystem: data-state + client-modules (engine-side plugin system)
tags: [plugin-system, dependency-topology, cycle-detection, logger-codes, runtime-guard]
# Dependency graph
requires:
- phase: 09-minimum-browser-runnable
provides: "用户 WIP 新增的数据端插件类型(PluginStatus / IDataPlugin / IDataPluginDefine / ICoreState.getDataPlugin / ICoreState.load / ICoreStateConfig.plugins)与 core.ts 空壳"
provides:
- "packages-user/data-state/src/plugin.ts:orderPlugins(依赖拓扑排序 + 环检测 + 依赖不存在检测)+ IPluginDepends / IPluginHolder / IPluginOrderResult"
- "CoreState 插件驱动:plugins 注册表(config.plugins ?? [] 守卫 + 重复 id 报 78 且不覆盖)+ 四段式 awake/initialize/afterLoad(protected)+ load() 编排 + getDataPlugin"
- "渲染端镜像:IClientPlugin / IClientPluginDefine / clientPlugins / getClientPlugin + ClientCore 对齐实现(orderPlugins 自 @user/data-state 复用)"
- "logger.json error 段新增 76(循环依赖)/ 77(依赖不存在)/ 78(重复 id)"
affects: [09-21, 入口/配置接线(用户后续)]
# Actuals (#2632) — pairs with the plan's estimate to calibrate future estimates.
# Same estimateTokens scale (chars/4 over the realized diff), never a harness token count.
actuals:
tokens: 6274
tasks: 2
commits: 0
# Tech tracking
tech-stack:
added: []
patterns:
- "共享纯函数 orderPlugins 作为数据端与渲染端唯一的拓扑/环/缺失检测实现"
- "依赖拓扑序驱动的四段式插件生命周期(awake 同步 → initialize 异步阻塞 → loader 加载 → afterLoad 异步阻塞)"
- "构造器注册表以 ?? [] 运行时守卫建立,兼容未接线插件配置的既有调用点"
- "插件错误码集中:环 → 76 / 依赖不存在 → 77 / 重复 id → 78(且不覆盖)"
key-files:
created:
- packages-user/data-state/src/plugin.ts
- packages-user/data-state/src/__test__/plugin.test.ts
modified:
- packages-user/data-state/src/core.ts
- packages-user/data-state/src/index.ts
- packages-user/client-modules/src/types.ts
- packages-user/client-modules/src/client.ts
- packages/common/src/logger.json
key-decisions:
- "共享纯函数 orderPlugins 计算可初始化拓扑序、环上插件(cyclic)与直接缺失依赖(missing);被排除集合 = 环上 ∪ 缺失 ∪ 其下游;下游仅缺席于 order,不单独报码"
- "插件错误处置(D-92):循环依赖 → logger.error(76, ...);依赖不存在 → 逐条 logger.error(77, pluginId, missingDepId);重复 id → logger.error(78, id) 且保留先注册者不覆盖;报错插件及下游全部不进入加载流程(status 保持 NotAwakened)"
- "运行时守卫(D-91/D-56):CoreState 用 config.plugins ?? []、ClientCore 用 config.clientPlugins ?? [],未接线插件配置的既有调用点不改动且不崩溃"
- "渲染端复用数据端 PluginStatus(自 @user/data-state 导入,未自建);orderPlugins 亦自 @user/data-state 导入"
- "CoreState.awake/initialize/afterLoad 由 private 改 protected,供 ClientCore 以 super.<stage>() 先驱动数据端、再驱动渲染端(数据端先、渲染端后)"
- "渲染插件 state 参数类型默认 ICClientCore(OI-5)"
patterns-established:
- "插件排序: 单文件零 import 的纯函数,数据端与渲染端复用"
- "生命周期: 单一公开入口 load() 编排四段,子类覆写 protected 阶段实现同阶段横向插入"
requirements-completed: []
coverage:
- id: D1
description: "数据端插件驱动:CoreState 注册表(守卫 + 重复 id 不覆盖)+ 四段式拓扑序驱动 + 状态推进 + 环/依赖不存在(含下游)排除"
verification:
- kind: other
ref: "structural gate (data plugin) — orderPlugins/config.plugins ?? []/this.plugins.has/logger.error(76,77,78)/protected phases/loader.start() — PASS"
status: pass
human_judgment: true
rationale: "运行期 vitest 门禁(plugin.test.ts)因用户并发 WIP 删除 ./enemy 与 ./event 目录而无法解析 ../core,属已知阻断;需用户接线后运行期复核"
- id: D2
description: "渲染端镜像:IClientPlugin / IClientPluginDefine / clientPlugins / getClientPlugin + ClientCore 三阶段覆写(super 先、渲染端后)+ 与数据端一致的 76/77/78 处置"
verification:
- kind: other
ref: "structural gate (render plugin) + barrel/logger gate — PASS"
status: pass
human_judgment: true
rationale: "ClientCore 依赖 canvas/DOM,无法在 Node 下运行期验证,仅结构断言;行为正确性需人工复核"
# Metrics
duration: ~35min
completed: 2026-10-14
status: complete
---
# Phase 09 Plan 20: 数据端插件驱动 + 渲染端插件系统镜像 Summary
**在 CoreState 实现以 `config.plugins ?? []` 守卫注册表、`orderPlugins` 拓扑序驱动的四段式插件生命周期(环报 76 / 依赖不存在报 77 / 重复 id 报 78 且不覆盖),并在 ClientCore 镜像同一套渲染端插件系统(数据端先、渲染端后);仅引擎侧、不接入口配置。**
## Performance
- **Duration:** ~35min
- **Started:** 2026-10-14T11:12:00Z (approx)
- **Completed:** 2026-10-14T11:48:31Z
- **Tasks:** 2 (Task 1 tracer + Task 2 auto)
- **Files modified:** 7 (2 created, 5 modified)
## Accomplishments
- `orderPlugins`(`packages-user/data-state/src/plugin.ts`):零 import、零副作用的共享纯函数,一次计算可初始化拓扑序、环上插件集合、直接缺失依赖映射;被排除集合(环上 ∪ 缺失 ∪ 其下游)不会进入拓扑序。
- `CoreState` 插件驱动(`core.ts`):`plugins` 注册表由 `config.plugins ?? []` 守卫建立(重复 id `logger.error(78,...)` 且不覆盖);`protected awake/initialize/afterLoad` 按拓扑序驱动并通过 `PluginStatus` 推进;`load()` 编排四段(第三段 `for await (const progress of this.loader.start()) { void progress; }` 非空循环体);`awake` 阶段报 76(`[...cyclic].join(', ')`)与 77(逐条 `pluginId, missingDepId`)。
- 桶导出(`index.ts`):追加 `export * from './plugin';`,使渲染端可自 `@user/data-state` 导入 `orderPlugins`。
- 渲染端镜像(`client-modules/src/types.ts` + `client.ts`):顶层新增 `IClientPlugin` / `IClientPluginDefine` / `IClientCoreConfig.clientPlugins` / `IClientCore.getClientPlugin`,复用数据端 `PluginStatus`;`ClientCore` 以 `config.clientPlugins ?? []` 建注册表、复用自 `@user/data-state` 的 `orderPlugins`,以 `protected` 覆写三阶段(`super.<stage>()` 先)实现「数据端先、渲染端后」。
- `logger.json` error 段新增 76/77/78(保留既有 75 不动)。
## Task Commits
**未提交** —— 按执行指令(不要 `git add` / `git commit`),全部改动保留在工作树。
## Files Created/Modified
- `packages-user/data-state/src/plugin.ts`(新)— `orderPlugins` + `IPluginDepends` / `IPluginHolder` / `IPluginOrderResult`
- `packages-user/data-state/src/__test__/plugin.test.ts`(新)— `orderPlugins` 与 `CoreState` 四段驱动的运行期单测(拓扑序 / 环 / 依赖不存在(含下游)/ 重复 id / 运行时守卫)
- `packages-user/data-state/src/core.ts`(改)— `orderPlugins` 导入、`plugins` 注册表、构造器守卫建表、四段式 `load()` 与 `protected` 三阶段、76/77/78 处置
- `packages-user/data-state/src/index.ts`(改)— 追加 `export * from './plugin';`
- `packages-user/client-modules/src/types.ts`(改)— 渲染端插件类型 + `clientPlugins` + `getClientPlugin`;导入 `PluginStatus`
- `packages-user/client-modules/src/client.ts`(改)— `clientPlugins` 注册表(守卫)、`getClientPlugin`、三阶段 `protected` 覆写(`super.` 先)
- `packages/common/src/logger.json`(改)— error 段追加 76/77/78
### logger.json error 段新增文本(原样)
- `"76": "Plugin dependency cycle detected: $1. Cyclic plugins will not be initialized."`
- `"77": "Plugin $1 depends on a non-existent plugin: $2. Related plugins will not be initialized."`
- `"78": "Duplicate plugin id: $1. The duplicate will not be registered."`
既有 `"75": "Plugin $1 error, related plugins will not be initialized. Error info: $2."` 原样保留。
## Decisions Made
- 采用 D-92 裁定:76=环 / 77=依赖不存在 / 78=重复 id 且不覆盖;报错插件及其下游全部不进入加载流程(`status` 保持 `NotAwakened`)。
- 三阶段私有辅助逻辑内联在各自方法内(不新增未获批的私有方法名),复用 `orderPlugins` 与已批准命名。
- `awake`/`initialize`/`afterLoad` 由 `private` 改 `protected`(OI-4),供 `ClientCore` 覆写;`ClientCore` 覆写不传 `config` 给 `super(...)`(OI-9 默认)。
- 渲染插件 `state` 参数类型采用 `IClientCore`(OI-5 默认)。
## Deviations from Plan
None - plan executed as written(仅按执行指令将「运行期 vitest 门禁」记录为已知阻断、并将范围门禁按用户 WIP 放宽,见下)。
## Known Blockers
- **运行期 vitest 门禁(Task 1 `<verify>` 首项)无法执行 —— 已知阻断,未伪造通过。** `plugin.test.ts` 导入 `../core`,而 `core.ts` 依赖用户并发 WIP 删除的 `packages-user/data-state/src/enemy/` 与 `src/event/` 目录(`./enemy` / `./event` 导入已解析失败);`src/index.ts` 亦引用已删除的 `./enemy` / `./event` / `./replay`。按用户指令**不修复**用户 WIP 的破损导入、不运行该门禁。该阻断为**前置条件性质**(用户接线时统一处理),非本计划实现缺陷。
- 影响:数据端驱动的**运行期**行为(拓扑序 / 状态推进 / 排除语义)未在本机运行验证;已由 `plugin.ts` / `core.ts` 的**结构断言**与算法设计覆盖。`plugin.test.ts` 已按计划撰写,待用户 WIP 修复后可运行。
## Issues Encountered
- **范围门禁需放宽**:计划 Task 2 的 `SCOPE GATE` 以 `git status` 判定,用户并发 WIP 的移动/删除(`data-state` 的 `enemy`/`event`/`hero`/`replay`/`loader` 目录、多处 test 与 `src/**` 新增)不在其白名单内,故原样门禁失败。按执行指令放宽为「允许上述**预先存在**的 WIP 变更,仅断言本次新增变更 ⊆ `files_modified`」——已通过(见下)。
- **无全仓门禁**:未运行 `pnpm check:type` / `pnpm build` / `pnpm test:ci`(用户 WIP 存在历史类型错误)。
## Structural Gates (run)
| Gate | Source | Result |
|------|--------|--------|
| 数据端插件结构门禁(gate 3) | Task 1 `<verify>` | **PASS** — `orderPlugins` 导出、三接口含 `missing`、零 import;`logger.json` 76/77/78 且 75 原样;`core.ts` `plugins: Map<string, IDataPlugin...`、`config.plugins ?? []`、`this.plugins.has(`、`logger.error(78`、三阶段 `protected`、`loader.start()`、76/77、无 75;`index.ts` `./plugin` |
| 方向/环门禁(gate 4) | Task 1 `<verify>` | **PASS** — `data-state/src/**` 无 `@user/client-*` 反向导入 |
| 渲染端插件结构门禁(gate 5) | Task 2 `<verify>` | **PASS** — `types.ts` 四命名 + `PluginStatus` 复用(未自建);`client.ts` `clientPlugins` 注册表 + `config.clientPlugins ?? []` + `this.clientPlugins.has(` + `logger.error(78` + `getClientPlugin` + 三阶段 `protected` + `super.<stage>()` + 76/77 + `orderPlugins` 自 `@user/data-state` |
| 桶路由 / logger 门禁(gate 7) | Task 2 `<verify>` | **PASS** — `client-modules/src/index.ts` 仍导出 `./types`;`logger.json` 76/77/78 且 75 原样 |
| 范围门禁(gate 6,**放宽后**) | Task 2 `<verify>` | **PASS(放宽)** — 本次新增变更严格 ⊆ `files_modified`;其余变更均为预先存在的用户 WIP |
| 运行期 vitest 门禁(gate 2) | Task 1 `<verify>` 首项 | **BLOCKED(未运行)** — 见「Known Blockers」 |
## User Setup Required
None - 引擎侧实现,不接入口/配置,无外部服务。用户后续接线:`ins.ts` 传入 `plugins`,`client-modules/src/core.ts` / `src/client/entry.ts` 传入 `clientPlugins`(及 `heroFace` / `events` 等 `ICoreStateConfig` 必填字段)。接线前「数据端先、渲染端后」为空转(注册表为空、无插件可驱动)——已知后果(D-91)。
## Write Boundary
- 仅写入 `files_modified` 所列路径(7 个文件 + 本 SUMMARY)。
- **未**触碰:`src/**`(入口/接线)、`index.html`、`packages-user/data-base/hero/*`、`data-state/src/types.ts`、`ins.ts` 与任何既有 test/fixture 调用点、`initializeEnemy/initializeSave/initializeReplay` 及其注释、任何既有注释/jsDoc、`client-modules/src/index.ts`、`package.json` / lockfile。
- `logger.json` **仅追加** 76/77/78;未改动/未删除/未重排既有条目。
- 用户并发 WIP 的既有改动**全部保留**(编辑均在现有内容之上追加/补全);**未修复**用户 WIP 的 `./enemy` / `./event` 破损导入。
- 未新增依赖;未运行 `git add` / `git commit`(改动留在工作树)。
## Next Phase Readiness
- 数据端与渲染端插件系统机制就位,结构门禁全绿;`plugin.test.ts` 已就绪待用户 WIP 修复后运行。
- 待用户接线:数据端 `plugins`、渲染端 `clientPlugins` 注入各调用点,并统一核对 `ICoreStateConfig` 必填字段造成的既有类型不匹配(用户 WIP)。
---
*Phase: 09-minimum-browser-runnable*
*Completed: 2026-10-14*

View File

@ -185,6 +185,18 @@
- **D-85(OI-3 裁定 = A · 按尺寸分组;修订 D-81 的 npc 计数):** **不同尺寸的素材必须用不同图片**——NPC 素材按尺寸分组:32×32 组与 64×48 组**各自成图**,**不放一块、不留白、不重采样**。`npc` = `npc0..6`(**7 张**:32px 组 4×64×128 + 48px 组 3×128×192),`SPLIT-COUNT` = **43**。D-81 中 `npc0..5` / 42 的 **npc 计数部分由本条取代**(enemy 不受影响,仍 `enemy0..20`=21)。禁止以「统一帧尺寸 + 透明留白」的方式把不同尺寸混入同一张图。(2026-10-13 用户裁定)
### 任务 10(当前规划单元:数据端 + 渲染端插件系统)
> 本节界定**本次规划运行**的范围:任务 10 = 实现**数据端插件驱动**,并在**渲染端镜像一套一样的插件系统**并实现。输入 = `packages-user/data-state/src/types.ts`(用户新增的数据端插件类型)。仅引擎侧,**不接入口配置**(用户最后自行接线)。
- **D-86(本次规划单元 = 插件系统):** 实现数据端插件系统驱动(用户新增的 `IDataPluginDefine` / `IDataPlugin` / `PluginStatus` / `getDataPlugin` / `load()`),并在渲染端镜像一套一样的系统(`IClientPluginDefine` / `IClientPlugin` / `getClientPlugin` / `clientPlugins`)并实现。**仅引擎侧**:不改 `src/` 入口与配置接线(用户最后自行接线);不动用户其它并发改动(D-56)。
- **D-87(数据端驱动 = 四段式 load):** `CoreState.load()` 驱动流程 = ① `awake`(同步,主对象创建后、初始化前)→ ② `initialize`(异步、阻塞,加载开始前)→ ③ 执行 loader 加载 → ④ `afterLoad`(异步、阻塞,加载完毕后)。各阶段**按依赖拓扑序**执行;**依赖环**时抛错/告警且环上插件不初始化。`getDataPlugin(id)` 从注册表读取。
- **D-88(渲染端镜像命名与落点):** 命名用 **`IClient*` 系列**——`IClientPlugin` / `IClientPluginDefine` / `getClientPlugin`,配置字段 **`clientPlugins`**;类型放 **`packages-user/client-modules/src/types.ts`(顶层,与数据端对齐)**;**复用数据端 `PluginStatus`**(不自建枚举)。实现放 `ClientCore`。
- **D-89(生命周期):** 渲染端 `awake` / `initialize` / `afterLoad` 的时机与语义**与数据端一致**(D-87 的四段式)。
- **D-90(驱动顺序):** **数据插件先、渲染插件后**,在**统一 `load()`** 中按同一四段式驱动:数据端阶段(L0-L3)先执行,渲染端阶段(L4-L5)后执行。
- **D-91(范围边界):** 仅实现插件系统机制本身;不改入口/接线;不改 `initializeL0..L3` 等 config 字段口径(用户自行处理);不新增依赖;遵循 `dev.md`(无循环引用、命名、jsDoc、无副作用模块)。
- **D-92(插件错误处理与日志码,修订 D-87):** 插件系统用**新错误码**(`packages/common/src/logger.json` error 段新增):**循环依赖 → 76**;**依赖不存在的插件 → 77**;**重复 id → 78**。**重复 id 不覆盖原插件**(保留先注册者,重复者不注册)。**报错的插件全部不进入加载流程**(`awake`/`initialize`/`afterLoad` 均不执行;其下游依赖者亦不加载)。取代 D-87 中「复用 error 75」的口径;错误消息文本待用户批准。(2026-10-14 用户裁定)
### the agent's Discretion
- 无。用户明确未授权 AI 自行决定 UI 形态或任务范围。

View File

@ -19,27 +19,10 @@
</head>
<body>
<!-- injection -->
<div id="game-draw">
<canvas id="render-main"></canvas>
</div>
<div id='inputDiv'>
<div id='inputDialog'>
<p id="inputMessage">请输入文字...</p>
<input id='inputBox' type="text" autocomplete="off" />
<button id='inputYes'>确定</button>
<button id='inputNo'>取消</button>
</div>
</div>
<div id="root"></div>
<!-- injection -->
<script src='libs/thirdparty/lz-string.min.js'></script>
<script src='libs/thirdparty/priority-queue.min.js'></script>
<script src='libs/thirdparty/localforage.min.js'></script>
<script src='libs/thirdparty/zip.min.js'></script>
<script src="libs/thirdparty/lodash.min.js"></script>
<script id='mainScript' src='main.js'></script>
<script src="/src/main.ts" type="module"></script>
<script src="/src/bootstrap.ts" type="module"></script>
</body>

View File

@ -1,5 +1,16 @@
import { CoreState } from '@user/data-state';
import { IClientCore, IClientCoreConfig } from './types';
import {
CoreState,
IPluginOrderResult,
orderPlugins,
PluginStatus
} from '@user/data-state';
import {
IClientCore,
IClientCoreConfig,
IClientPlugin,
IClientPluginDefine
} from './types';
import { logger } from '@motajs/common';
import {
IMotaAudioContext,
ISoundPlayer,
@ -37,7 +48,6 @@ import {
MAIN_WIDTH,
VARIATOR_DEBUG_SPEED
} from './shared';
import { WebLoadStarter } from '@motajs/loader';
import { IRendererUsing, RendererUsing } from '@motajs/render-vue';
import { IUIController, UIController } from '@motajs/system';
import { createApp } from './render';
@ -63,37 +73,31 @@ export class ClientCore extends CoreState implements IClientCore {
// Layer 5 渲染顶层
/** 已注册的渲染端插件 */
private readonly clientPlugins: Map<
string,
IClientPlugin<IClientPluginDefine>
> = new Map();
private readonly orderedClientPlugins: IPluginOrderResult<
IClientPlugin<IClientPluginDefine>
>;
constructor(config: IClientCoreConfig) {
super({
loadStarter: new WebLoadStarter(),
coreURL: 'placeholder'
});
super(config);
//#region Layer 4
this.save = new SaveSystem();
this.save.init(`@game/${core.firstData.name}`);
//#region 音频系统
this.save.init('');
this.audioContext = new MotaAudioContext();
this.soundPlayer = new SoundPlayer(this.audioContext);
this.bgmPlayer = new BGMPlayer(this.audioContext);
//#endregion
//#region 素材系统
this.materials = new TextureManager(
this,
config.tilesetReserve,
config.tilesetUnit
);
//#endregion
//#region 渲染系统
const rafExcitation = new RafExcitation();
const excitationDivider = new ExcitationDivider<number>();
this.rafExcitation = rafExcitation;
@ -133,9 +137,92 @@ export class ClientCore extends CoreState implements IClientCore {
this.loader.addCoreConfig('client', config.clientURL);
this.init();
// 渲染端插件注册表
for (const define of config.clientPlugins) {
if (this.clientPlugins.has(define.id)) {
logger.error(78, define.id);
continue;
}
this.clientPlugins.set(define.id, {
status: PluginStatus.NotAwakened,
plugin: define
});
}
this.orderedClientPlugins = orderPlugins(this.clientPlugins);
}
//#region 插件方法
getClientPlugin<T extends IClientPluginDefine>(
id: string
): Readonly<IClientPlugin<T>> | null {
return (this.clientPlugins.get(id) as IClientPlugin<T>) ?? null;
}
/**
* 插件同步初始化
*/
protected awake(): void {
super.awake();
const result = this.orderedClientPlugins;
if (result.cyclic.size > 0) {
logger.error(76, [...result.cyclic].join(', '));
}
for (const [pluginId, lacked] of result.missing) {
for (const dependency of lacked) {
logger.error(77, pluginId, dependency);
}
}
for (const holder of result.order) {
if (holder.status !== PluginStatus.NotAwakened) continue;
const dependencies: IClientPlugin<IClientPluginDefine>[] = [];
for (const dependency of holder.plugin.dependencies) {
const plugin = this.clientPlugins.get(dependency);
if (plugin) dependencies.push(plugin);
}
holder.plugin.awake(this, dependencies);
holder.status = PluginStatus.Awakened;
}
}
/**
* 插件异步初始化
*/
protected async initialize(): Promise<void> {
await super.initialize();
const result = this.orderedClientPlugins;
for (const holder of result.order) {
if (holder.status !== PluginStatus.Awakened) continue;
const dependencies: IClientPlugin<IClientPluginDefine>[] = [];
for (const dependency of holder.plugin.dependencies) {
const plugin = this.clientPlugins.get(dependency);
if (plugin) dependencies.push(plugin);
}
await holder.plugin.initialize(this, dependencies);
holder.status = PluginStatus.Initialized;
}
}
/**
* 插件加载初始化
*/
protected async afterLoad(): Promise<void> {
await super.afterLoad();
const result = this.orderedClientPlugins;
for (const holder of result.order) {
if (holder.status !== PluginStatus.Initialized) continue;
const dependencies: IClientPlugin<IClientPluginDefine>[] = [];
for (const dependency of holder.plugin.dependencies) {
const plugin = this.clientPlugins.get(dependency);
if (plugin) dependencies.push(plugin);
}
await holder.plugin.afterLoad(this, dependencies);
holder.status = PluginStatus.Loaded;
}
}
//#endregion
/**
* 初始化渲染端主对象
*/

View File

@ -1,11 +0,0 @@
import { ClientCore } from './client';
// TODO: 逐渐弱化 ClientCore 的单例概念,每个接口都通过参数传入 IClientCore 对象
/** 客户端实例 */
export const client = new ClientCore({
clientURL: 'placeholder',
// 这两个值不要随意调整,若需要调整,务必先在编辑器中调整“额外素材预留”与“额外素材单位”,否则必定会导致游戏出错
tilesetReserve: 100000,
tilesetUnit: 5000
});

View File

@ -9,6 +9,7 @@ export * from './render';
export * from './ui';
export * from './client';
export * from './common';
export * from './core';
export * from './shared';
export * from './types';

View File

@ -1,12 +1,75 @@
import { IClientSystem } from '@user/client-system';
import { ICoreStateConfig, PluginStatus } from '@user/data-state';
export interface IClientCoreConfig {
/** 渲染端数据配置文件路径,相对于 `src/content` */
readonly clientURL: string;
/** Tileset 预留数量 */
readonly tilesetReserve: number;
/** Tileset 单元数量 */
readonly tilesetUnit: number;
//#region 插件系统
export interface IClientPlugin<T extends IClientPluginDefine> {
/** 插件的当前状态 */
status: PluginStatus;
/** 插件内容 */
plugin: T;
}
export interface IClientCore extends IClientSystem {}
export interface IClientPluginDefine {
/** 插件标识符 */
readonly id: string;
/** 插件所依赖的其他插件 id */
readonly dependencies: readonly string[];
/**
* 插件同步初始化,在渲染端主对象创建完毕,初始化之前时调用
* @param state 渲染端主对象
* @param dependencies 此插件的依赖插件对象
*/
awake(
state: IClientCore,
dependencies: IClientPlugin<IClientPluginDefine>[]
): void;
/**
* 插件异步初始化,在主对象创建完毕,初始化时,加载开始前调用。
* 此方法具有阻塞效果,如果返回值迟迟不兑现,那么会导致依赖其的插件不会开始异步初始化
* @param state 渲染端主对象
* @param dependencies 此插件的依赖插件对象
*/
initialize(
state: IClientCore,
dependencies: IClientPlugin<IClientPluginDefine>[]
): Promise<void>;
/**
* 当主对象加载完毕后调用,可以用于依赖加载内容的初始化。
* 此方法具有阻塞效果,如果返回值迟迟不兑现,那么会导致依赖其的插件不会开始加载初始化
* @param state 渲染端主对象
* @param dependencies 此插件的依赖插件对象
*/
afterLoad(
state: IClientCore,
dependencies: IClientPlugin<IClientPluginDefine>[]
): Promise<void>;
}
//#endregion
export interface IClientCoreConfig extends ICoreStateConfig {
/** 渲染端数据配置文件路径,相对于 `src/content` */
readonly clientURL: string;
/** Tileset 预留数量,用于控制最大允许注册多少个普通图块 */
readonly tilesetReserve: number;
/** Tileset 单元数量,用于控制一个 Tileset 会占用多少个图块 */
readonly tilesetUnit: number;
/** 渲染器挂载至的画布元素 */
readonly rendererCanvas: string;
/** 渲染端插件列表 */
readonly clientPlugins: IClientPluginDefine[];
}
export interface IClientCore extends IClientSystem {
/**
* 根据 id 获取已加装的渲染端插件
* @param id 插件的标识符
*/
getClientPlugin<T extends IClientPluginDefine>(
id: string
): Readonly<IClientPlugin<T>> | null;
}

View File

@ -6,6 +6,7 @@ import {
} from '@motajs/common';
import { SaveCompression, shouldReplay } from '@user/data-common';
import {
HeroModifierCreator,
IHeroAttribute,
IHeroAttributeCloneOption,
IHeroAttributeHooks,
@ -20,7 +21,9 @@ export class HeroAttribute<THero>
implements IHeroAttribute<THero>
{
/** 当前的勇士基础属性 */
private attribute: THero;
private attribute!: THero;
/** 当前勇士最终属性 */
private finalAttribute!: THero;
/** 当前勇士属性修饰器 */
private readonly modifier: Map<keyof THero, IHeroModifier[]> = new Map();
@ -29,16 +32,11 @@ export class HeroAttribute<THero>
/** 当前标记为不存档的修饰器集合 */
private readonly modifierNosave: Set<IHeroModifier> = new Set();
/** 修饰器工厂函数注册表 */
private readonly registry: Map<
string,
<K extends keyof THero>() => IHeroModifier<THero[K]>
> = new Map();
/** 当前勇士最终属性 */
private readonly finalAttribute: THero;
private readonly registry: Map<string, HeroModifierCreator<THero>> =
new Map();
constructor(attribute: THero) {
super();
// 克隆入参,避免调用方传入的共享基础属性对象被本实例改写
this.attribute = structuredClone(attribute);
this.finalAttribute = structuredClone(attribute);
}

View File

@ -22,30 +22,22 @@ import {
import {
Hookable,
HookController,
IHookController,
IFaceHandler
IFaceHandler,
IHookController
} from '@motajs/common';
import { HeroAttribute } from './attribute';
export class HeroState<THero>
extends Hookable<IHeroStateHooks>
implements IHeroState<THero>
{
readonly location: IHeroLocation;
location!: IHeroLocation;
attribute!: IHeroAttribute<THero>;
readonly items: IHeroItems<THero>;
readonly equip: IHeroEquipment<THero>;
constructor(
state: IDataCommon,
faceHandler: IFaceHandler<FaceDirection>,
public readonly attribute: IHeroAttribute<THero>
) {
constructor(readonly state: IDataCommon) {
super();
const defaultLoc: IFacedTileLocator = {
x: 0,
y: 0,
direction: FaceDirection.Down
};
this.location = new HeroLocation(state, defaultLoc, faceHandler);
this.items = new HeroItems(state);
this.equip = new HeroEquipment(this.items.equipment, this.attribute);
}
@ -56,6 +48,19 @@ export class HeroState<THero>
return new HookController(this, hook);
}
createLocation(
location: IFacedTileLocator,
faceHandler: IFaceHandler<FaceDirection>
): void {
if (this.location) return;
this.location = new HeroLocation(this.state, location, faceHandler);
}
createAttribute(attribute: THero): void {
if (this.attribute) return;
this.attribute = new HeroAttribute(attribute);
}
getLocation(): IFacedTileLocator {
return {
x: this.location.x,

View File

@ -179,6 +179,10 @@ export interface IReadonlyHeroAttribute<THero> extends IHookable<
getModifierSaveEnabled(modifier: IHeroModifier): boolean;
}
export type HeroModifierCreator<THero> = <
K extends keyof THero
>() => IHeroModifier<THero[K]>;
export interface IHeroAttribute<THero>
extends
IReadonlyHeroAttribute<THero>,
@ -259,10 +263,7 @@ export interface IHeroAttribute<THero>
* @param type 修饰器类型
* @param cons 工厂函数
*/
registerModifier(
type: string,
cons: <K extends keyof THero>() => IHeroModifier<THero[K]>
): void;
registerModifier(type: string, cons: HeroModifierCreator<THero>): void;
/**
* 创建指定类型的修饰器实例
@ -926,8 +927,9 @@ export interface IHeroStateSave<THero> {
export interface IHeroState<THero>
extends
ISaveableContent<IHeroStateSave<THero>>,
IHookable<IHeroStateHooks> {
/** 勇士移动对象 */
IHookable<IHeroStateHooks>,
IDataCommonExtended {
/** 勇士移动位置对象 */
readonly location: IHeroLocation;
/** 勇士属性对象 */
readonly attribute: IReadonlyHeroAttribute<THero>;
@ -936,6 +938,24 @@ export interface IHeroState<THero>
/** 勇士装备系统 */
readonly equip: IHeroEquipment<THero>;
/**
* 创建勇士移动位置对象,在创建前不可使用 `this.location`,但为避免频繁判空,类型表示为必选。
* 仅能调用一次,多次调用无效。
* @param location 勇士的初始位置
* @param faceHandler 朝向处理器
*/
createLocation(
location: IFacedTileLocator,
faceHandler: IFaceHandler<FaceDirection>
): void;
/**
* 绑定勇士属性对象,在绑定前不可使用 `this.attribute`,但为避免频繁判空,类型表示为必选。
* 仅能调用一次,多次调用无效
* @param attribute 勇士属性对象
*/
createAttribute(attribute: THero): void;
/**
* 获取勇士当前的位置
*/

View File

@ -2,6 +2,5 @@ export * from './enemy';
export * from './flag';
export * from './hero';
export * from './map';
export * from './shop';
export * from './types';

View File

@ -1 +0,0 @@
export * from './types';

View File

@ -15,14 +15,14 @@ import {
type ReplayParamValue
} from '@user/data-common';
import { CoreState } from '../src/core';
import { DefaultPassPredicateImpl } from '../src/hero/predicate';
import { DefaultPassPredicateImpl } from '../../../src/data/hero/predicate';
import {
ReplayEquip,
ReplayMove,
ReplayTeleport,
ReplayUnequip,
ReplayUseItem
} from '../src/replay/commands';
} from '../../../src/data/replay/commands';
vi.hoisted(() => {
Map.prototype.getOrInsert ??= function <K, V>(

View File

@ -0,0 +1,162 @@
// 测试数据端插件系统的依赖排序与四段式加载驱动
import { describe, expect, it } from 'vitest';
import { WebLoadStarter } from '@motajs/loader';
import { CoreState } from '../core';
import { IDataPluginDefine, PluginStatus } from '../types';
import { orderPlugins } from '../plugin';
/** 创建一个记录三阶段调用顺序的测试插件定义 */
function createPlugin(
id: string,
dependencies: string[],
calls: string[]
): IDataPluginDefine {
return {
id,
dependencies,
awake: () => {
calls.push(`${id}.awake`);
},
initialize: async () => {
calls.push(`${id}.initialize`);
},
afterLoad: async () => {
calls.push(`${id}.afterLoad`);
}
};
}
describe('orderPlugins', () => {
// 验证无依赖插件保持注册顺序,有依赖插件排在其依赖之后
it('sorts plugins by dependency order', () => {
const a = { plugin: { id: 'a', dependencies: [] as string[] } };
const b = { plugin: { id: 'b', dependencies: ['a'] } };
const result = orderPlugins(
new Map([
['b', b],
['a', a]
])
);
expect(result.cyclic.size).toBe(0);
expect(result.missing.size).toBe(0);
expect(result.order.map(holder => holder.plugin.id)).toEqual([
'a',
'b'
]);
});
// 验证处于依赖环中的插件被标记,且不会出现在可初始化序列中
it('detects dependency cycles', () => {
const x = { plugin: { id: 'x', dependencies: ['y'] } };
const y = { plugin: { id: 'y', dependencies: ['x'] } };
const result = orderPlugins(
new Map([
['x', x],
['y', y]
])
);
expect([...result.cyclic].sort()).toEqual(['x', 'y']);
expect(result.order).toEqual([]);
});
// 验证直接引用不存在依赖的插件及其下游均被排除,并记录缺失依赖
it('detects missing dependencies and excludes downstream', () => {
const c = { plugin: { id: 'c', dependencies: ['nope'] } };
const d = { plugin: { id: 'd', dependencies: ['c'] } };
const result = orderPlugins(
new Map([
['c', c],
['d', d]
])
);
expect(result.missing.get('c')).toEqual(['nope']);
expect(result.order).toEqual([]);
});
});
describe('CoreState plugin driving', () => {
// 验证插件按拓扑序依次执行同步初始化、异步初始化与加载后初始化
it('drives plugins through four phases in topological order', async () => {
const calls: string[] = [];
const a = createPlugin('a', [], calls);
const b = createPlugin('b', ['a'], calls);
const state = new CoreState({
loadStarter: new WebLoadStarter(),
coreURL: 'placeholder',
heroFace: 8,
dataPlugins: [b, a],
events: []
});
await state.load();
expect(calls).toEqual([
'a.awake',
'b.awake',
'a.initialize',
'b.initialize',
'a.afterLoad',
'b.afterLoad'
]);
expect(state.getDataPlugin('a')!.status).toBe(PluginStatus.Loaded);
expect(state.getDataPlugin('b')!.status).toBe(PluginStatus.Loaded);
});
// 验证环上插件的三阶段方法均不被调用且状态保持未唤醒
it('excludes cyclic plugins from every phase', async () => {
const calls: string[] = [];
const x = createPlugin('x', ['y'], calls);
const y = createPlugin('y', ['x'], calls);
const state = new CoreState({
loadStarter: new WebLoadStarter(),
coreURL: 'placeholder',
heroFace: 8,
dataPlugins: [x, y],
events: []
});
await state.load();
expect(calls).toEqual([]);
expect(state.getDataPlugin('x')!.status).toBe(PluginStatus.NotAwakened);
expect(state.getDataPlugin('y')!.status).toBe(PluginStatus.NotAwakened);
});
// 验证依赖不存在的插件及其下游均不被驱动且状态保持未唤醒
it('excludes plugins with missing dependencies and downstream', async () => {
const calls: string[] = [];
const c = createPlugin('c', ['nope'], calls);
const d = createPlugin('d', ['c'], calls);
const state = new CoreState({
loadStarter: new WebLoadStarter(),
coreURL: 'placeholder',
heroFace: 8,
dataPlugins: [c, d],
events: []
});
await state.load();
expect(calls).toEqual([]);
expect(state.getDataPlugin('c')!.status).toBe(PluginStatus.NotAwakened);
expect(state.getDataPlugin('d')!.status).toBe(PluginStatus.NotAwakened);
});
// 验证重复 id 的插件保留先注册者,重复者不会被注册
it('keeps the first plugin on duplicate id', () => {
const first = createPlugin('dup', [], []);
const second = createPlugin('dup', [], []);
const state = new CoreState({
loadStarter: new WebLoadStarter(),
coreURL: 'placeholder',
heroFace: 8,
dataPlugins: [first, second],
events: []
});
expect(state.getDataPlugin('dup')!.plugin).toBe(first);
});
// 验证省略 plugins 等配置时构造与 load 均不抛错,证明运行时守卫生效
it('builds and loads without plugin config (runtime guard)', async () => {
const state = new CoreState({
loadStarter: new WebLoadStarter(),
coreURL: 'placeholder'
} as any);
await expect(state.load()).resolves.toBeUndefined();
});
});

View File

@ -9,10 +9,6 @@ import {
SaveCompression,
RoleFaceBinder,
FaceManager,
Dir4FaceHandler,
Dir8FaceHandler,
FaceGroup,
FaceDirection,
IHeroAttr,
IEnemyAttr,
GameEventStore,
@ -22,13 +18,11 @@ import {
IMapStore,
MapStore,
IReplaySystem,
ReplaySystem,
ReplayCode
ReplaySystem
} from '@user/data-common';
import {
EnemyManager,
IEnemyManager,
HeroAttribute,
HeroState,
IHeroState,
IFlagSystem,
@ -37,41 +31,23 @@ import {
MapState
} from '@user/data-base';
import {
DamageSystem,
EnemyContext,
GameEventSystem,
IEnemyContext,
IGameEventSystem,
MapDamage,
IPathfindingSystem,
PathfindingSystem
} from '@user/data-system';
import { ICoreState, ICoreStateConfig, ISaveableExecutor } from './types';
import {
CommonAuraConverter,
GuardAuraConverter,
MainDamageCalculator,
MainEnemyFinalEffect,
MainMapDamageConverter,
MainMapDamageReducer,
createSpecials,
MainEnemyComparer
} from './enemy';
import { HERO_DEFAULT_ATTRIBUTE } from './shared';
import { DefaultHeroMoveTopImpl, DefaultPassPredicateImpl } from './hero';
import { createEventRegistrations } from './event';
import {
ReplayEquip,
ReplayMove,
ReplayTeleport,
ReplayUnequip,
ReplayUseItem
} from './replay';
import {
IMotaDataLoader,
MotaDataLoader,
DefaultDataLoaderHook
} from './loader';
ICoreState,
ICoreStateConfig,
IDataPlugin,
IDataPluginDefine,
ISaveableExecutor,
PluginStatus
} from './types';
import { IPluginOrderResult, orderPlugins } from './plugin';
import { IMotaDataLoader, MotaDataLoader } from './loader';
export class CoreState implements ICoreState {
// Layer 0 公共层,最底层的接口,不会依赖任何其他内容,一般是工具性接口及不需要存档的数据
@ -108,9 +84,15 @@ export class CoreState implements ICoreState {
ISaveableExecutor<any>
> = new Map();
constructor(config: Readonly<ICoreStateConfig>) {
//#region L0 定义
/** 已注册的插件 */
private readonly plugins: Map<string, IDataPlugin<IDataPluginDefine>> =
new Map();
private readonly orderedDataPlugins: IPluginOrderResult<
IDataPlugin<IDataPluginDefine>
>;
constructor(config: Readonly<ICoreStateConfig>) {
// L0 定义
this.roleFace = new RoleFaceBinder();
this.faceManager = new FaceManager();
this.tileStore = new TileStore();
@ -118,155 +100,114 @@ export class CoreState implements ICoreState {
this.mapStore = new MapStore();
this.eventStore = new GameEventStore();
this.initializeL0();
//#endregion
//#region L1 定义
// L1 定义
this.flags = new FlagSystem();
this.maps = new MapState(this);
const dir8 = this.faceManager.get(FaceGroup.Dir8)!;
const heroAttribute = new HeroAttribute(HERO_DEFAULT_ATTRIBUTE);
this.hero = new HeroState(this, dir8, heroAttribute);
this.hero = new HeroState(this);
this.enemyManager = new EnemyManager<IEnemyAttr>(this.tileStore);
this.initializeL1();
//#endregion
//#region L2 定义
// L2 定义
this.enemyContext = new EnemyContext<IEnemyAttr, IHeroAttr>(this);
const events = createEventRegistrations();
this.eventSystem = new GameEventSystem(this, events);
this.eventSystem = new GameEventSystem(this, config.events);
this.replaySystem = new ReplaySystem();
this.pathfinding = new PathfindingSystem(this);
this.initializeL2();
//#endregion
//#region L3 定义
// L3 定义
this.loadManager = new LoadManager();
this.loader = new MotaDataLoader(this.loadManager, config.loadStarter);
this.initializeL3(config);
this.loader.addCoreConfig('core', config.coreURL);
//#endregion
// 数据端插件注册表
for (const define of config.dataPlugins) {
if (this.plugins.has(define.id)) {
logger.error(78, define.id);
continue;
}
this.plugins.set(define.id, {
status: PluginStatus.NotAwakened,
plugin: define
});
}
this.orderedDataPlugins = orderPlugins(this.plugins);
this.awake();
}
//#region 初始化方法
//#region 插件方法
/**
* 初始化数据端 L0
* 插件同步初始化
*/
private initializeL0() {
const dir4 = new Dir4FaceHandler();
const dir8 = new Dir8FaceHandler();
this.faceManager.register(FaceGroup.Dir4, dir4);
this.faceManager.registerById('dir4', dir4);
this.faceManager.register(FaceGroup.Dir8, dir8);
this.faceManager.registerById('dir8', dir8);
}
/**
* 初始化数据端 L1
*/
private initializeL1() {
const comparer = new MainEnemyComparer();
this.enemyManager.attachEnemyComparer(comparer);
const specials = createSpecials(this);
for (const [code, cons] of specials) {
this.enemyManager.registerSpecial(code, cons);
protected awake(): void {
const result = this.orderedDataPlugins;
if (result.cyclic.size > 0) {
logger.error(76, [...result.cyclic].join(', '));
}
for (const [pluginId, lacked] of result.missing) {
for (const dependency of lacked) {
logger.error(77, pluginId, dependency);
}
}
for (const holder of result.order) {
if (holder.status !== PluginStatus.NotAwakened) continue;
const dependencies: IDataPlugin<IDataPluginDefine>[] = [];
for (const dependency of holder.plugin.dependencies) {
const plugin = this.plugins.get(dependency);
if (plugin) dependencies.push(plugin);
}
holder.plugin.awake(this, dependencies);
holder.status = PluginStatus.Awakened;
}
}
/**
* 初始化数据端 L2
* 插件异步初始化
*/
private initializeL2() {
const predicate = new DefaultPassPredicateImpl(this);
this.pathfinding.finder.usePassPredicate(predicate);
// 初始状态下勇士不在任何楼层,切换楼层后再具体设置
this.pathfinding.finder.useMapLayer(null);
this.pathfinding.useMover(this.hero.location.mover);
this.initializeEnemy();
}
private initializeL3(config: Readonly<ICoreStateConfig>) {
// 勇士顶层初始化
const heroMoveTopImpl = new DefaultHeroMoveTopImpl(this);
this.hero.location.mover.useTopImplementation(heroMoveTopImpl);
// 加载
this.loader.addHook(new DefaultDataLoaderHook());
this.loader.addCoreConfig('core', config.coreURL);
this.initializeSave();
this.initializeReplay();
protected async initialize(): Promise<void> {
const result = this.orderedDataPlugins;
for (const holder of result.order) {
if (holder.status !== PluginStatus.Awakened) continue;
const dependencies: IDataPlugin<IDataPluginDefine>[] = [];
for (const dependency of holder.plugin.dependencies) {
const plugin = this.plugins.get(dependency);
if (plugin) dependencies.push(plugin);
}
await holder.plugin.initialize(this, dependencies);
holder.status = PluginStatus.Initialized;
}
}
/**
* 初始化怪物信息
* 插件加载初始化
*/
private initializeEnemy() {
// 初始化怪物上下文
const ctx = this.enemyContext;
const damageSystem = new DamageSystem(ctx);
const mapDamage = new MapDamage(ctx);
damageSystem.useCalculator(new MainDamageCalculator());
mapDamage.useReducer(new MainMapDamageReducer());
mapDamage.useConverter(new MainMapDamageConverter());
ctx.attachDamageSystem(damageSystem);
ctx.attachMapDamage(mapDamage);
ctx.bindHero(this.hero.getAttribute());
// 注册光环转换器
ctx.registerAuraConverter(new CommonAuraConverter());
ctx.registerAuraConverter(new GuardAuraConverter());
// 注册怪物最终效果
ctx.registerFinalEffect(new MainEnemyFinalEffect());
protected async afterLoad(): Promise<void> {
const result = this.orderedDataPlugins;
for (const holder of result.order) {
if (holder.status !== PluginStatus.Initialized) continue;
const dependencies: IDataPlugin<IDataPluginDefine>[] = [];
for (const dependency of holder.plugin.dependencies) {
const plugin = this.plugins.get(dependency);
if (plugin) dependencies.push(plugin);
}
await holder.plugin.afterLoad(this, dependencies);
holder.status = PluginStatus.Loaded;
}
}
/**
* 初始化存档内容
*/
private initializeSave() {
this.addSaveableContent('@system/hero', this.hero);
this.addSaveableContent('@system/flags', this.flags);
this.addSaveableContent('@system/maps', this.maps);
this.addSaveableContent('@system/enemy', this.enemyManager);
this.addSaveableContent('@system/replay', this.replaySystem);
getDataPlugin<T extends IDataPluginDefine>(
id: string
): Readonly<IDataPlugin<T>> | null {
return (this.plugins.get(id) as IDataPlugin<T>) ?? null;
}
/**
* 注册全部录像指令
*/
private initializeReplay() {
const replay = this.replaySystem;
//#endregion
const up = new ReplayMove(this, FaceDirection.Up);
const right = new ReplayMove(this, FaceDirection.Right);
const down = new ReplayMove(this, FaceDirection.Down);
const left = new ReplayMove(this, FaceDirection.Left);
const teleport = new ReplayTeleport(this);
const useItem = new ReplayUseItem(this);
const equip = new ReplayEquip(this);
const unequip = new ReplayUnequip(this);
//#region 初始化方法
replay.registerCommand(ReplayCode.Up, up);
replay.registerCommand(ReplayCode.Right, right);
replay.registerCommand(ReplayCode.Down, down);
replay.registerCommand(ReplayCode.Left, left);
replay.registerCommand(ReplayCode.Teleport, teleport);
replay.registerCommand(ReplayCode.UseItem, useItem);
replay.registerCommand(ReplayCode.Equip, equip);
replay.registerCommand(ReplayCode.Unequip, unequip);
async *load(): AsyncIterator<number, void> {
await this.initialize();
yield* this.loader.start();
await this.afterLoad();
}
//#endregion

View File

@ -1,438 +0,0 @@
// 测试光环转换器:光环范围与参数选择、属性加成结算、支援光环注册与守卫定位符添加
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { type ITileLocator } from '@motajs/common';
import { type IEnemyAttr, type IHeroAttr } from '@user/data-common';
import {
type IEnemyContext,
type IEnemyView,
type IReadonlyEnemyHandler,
type IEnemyHandler
} from '@user/data-system';
import {
type IEnemy,
type IReadonlyEnemy,
type ISpecial
} from '@user/data-base';
import { type ISpecial23Value } from '../special';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
Map.prototype.getOrInsertComputed ??= function <K, V>(
this: Map<K, V>,
key: K,
callback: (key: K) => V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
const value = callback(key);
this.set(key, value);
return value;
};
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,
key: K,
defaultValue: V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
this.set(key, defaultValue);
return defaultValue;
};
});
interface TestModules {
CommonAura: typeof import('../aura').CommonAura;
CommonAuraConverter: typeof import('../aura').CommonAuraConverter;
GuardAura: typeof import('../aura').GuardAura;
GuardAuraConverter: typeof import('../aura').GuardAuraConverter;
FullRange: typeof import('@motajs/common').FullRange;
RectRange: typeof import('@motajs/common').RectRange;
ManhattanRange: typeof import('@motajs/common').ManhattanRange;
}
let modules: TestModules;
beforeAll(async () => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
const auraModule = await import('../aura');
const motaModule = await import('@motajs/common');
modules = {
CommonAura: auraModule.CommonAura,
CommonAuraConverter: auraModule.CommonAuraConverter,
GuardAura: auraModule.GuardAura,
GuardAuraConverter: auraModule.GuardAuraConverter,
FullRange: motaModule.FullRange,
RectRange: motaModule.RectRange,
ManhattanRange: motaModule.ManhattanRange
};
});
interface FakeEnemy {
/** 可写怪物对象 */
enemy: IEnemy<IEnemyAttr>;
/** 当前怪物属性,供断言使用 */
attrs: IEnemyAttr;
}
/**
* 创建一个只实现光环所需能力的内联假怪物
* @param attrs 覆盖的怪物属性
*/
function createEnemy(attrs: Partial<IEnemyAttr> = {}): FakeEnemy {
const values: IEnemyAttr = {
hp: 20,
atk: 8,
def: 5,
money: 2,
exp: 3,
point: 1,
guard: new Set(),
...attrs
};
const enemy = {
id: 'test-enemy',
code: 1,
getSpecial: () => null,
hasSpecial: () => false,
iterateSpecials: () => [],
getAttribute: (key: string) => (values as never)[key],
cloneAttributes: () => structuredClone(values),
clone: () => enemy,
addSpecial: () => {},
deleteSpecial: () => {},
setAttribute: (key: string, value: unknown) => {
// 假怪物按动态字符串键读写属性,固定形状的 IEnemyAttr 无法表达字符串索引
(values as unknown as Record<string, unknown>)[key] = value;
},
addAttribute: (key: string, value: number) => {
(values as unknown as Record<string, number>)[key] += value;
},
copyFrom: () => {},
saveState: () => ({
attrs: structuredClone(values),
specials: new Map()
}),
loadState: () => {}
} as never;
return { enemy, attrs: values };
}
/**
* 创建一个只携带代码与数值的内联假特殊属性
* @param code 特殊属性代码
* @param value 特殊属性数值
*/
function createSpecial<T>(code: number, value: T): ISpecial<T> {
return { code, value } as never;
}
/**
* 创建一个只实现视图反查的内联假怪物上下文
* @param resolve 根据计算后怪物对象查找视图的回调
*/
function createContext(
resolve?: (
enemy: IReadonlyEnemy<IEnemyAttr>
) => IEnemyView<IEnemyAttr> | null
): IEnemyContext<IEnemyAttr, IHeroAttr> {
return {
width: 8,
height: 8,
getViewByComputed: (enemy: IReadonlyEnemy<IEnemyAttr>) =>
resolve ? resolve(enemy) : null
} as never;
}
/**
* 创建一个只用于占据视图位置的内联假视图
*/
function createView(): IEnemyView<IEnemyAttr> {
return {} as never;
}
/**
* 组装一个光环转换所需的最小只读信息对象
* @param enemy 怪物对象
* @param locator 怪物定位符
*/
function createReadonlyHandler(
enemy: IReadonlyEnemy<IEnemyAttr>,
locator: ITileLocator
): IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr> {
return { enemy, locator } as never;
}
/**
* 组装一个光环结算所需的最小可写信息对象
* @param enemy 可写怪物对象
* @param locator 怪物定位符
*/
function createWritableHandler(
enemy: IEnemy<IEnemyAttr>,
locator: ITileLocator
): IEnemyHandler<IEnemyAttr, IHeroAttr> {
return { enemy, locator } as never;
}
/**
* 构造一个完整的光环数值
* @param overrides 覆盖的光环字段
*/
function createHalo(overrides: Partial<ISpecial23Value> = {}): ISpecial23Value {
return {
range: 0,
square: false,
hpBuff: 0,
atkBuff: 0,
defBuff: 0,
...overrides
};
}
describe('CommonAuraConverter', () => {
// 验证只有代码 25 的特殊属性会被光环转换器接管
it('converts only special code 25', () => {
const converter = new modules.CommonAuraConverter();
const halo = createSpecial(25, createHalo());
const guard = createSpecial(26, undefined);
expect(converter.shouldConvert(halo)).toBe(true);
expect(converter.shouldConvert(guard)).toBe(false);
});
// 验证转换结果为携带怪物、特殊属性与定位符的光环视图
it('converts a code 25 special into a common aura', () => {
const converter = new modules.CommonAuraConverter();
const { enemy } = createEnemy();
const locator: ITileLocator = { x: 3, y: 4 };
const special = createSpecial(25, createHalo());
const aura = converter.convert(
special,
createReadonlyHandler(enemy, locator)
);
expect(aura).toBeInstanceOf(modules.CommonAura);
expect(aura.enemy).toBe(enemy);
expect(aura.locator).toBe(locator);
expect(aura.special).toBe(special);
});
});
describe('CommonAura range', () => {
// 验证光环范围不大于 0 时使用全图范围且无范围参数
it('uses the full range when the halo range is not positive', () => {
const { enemy } = createEnemy();
const aura = new modules.CommonAura(
enemy,
createSpecial(25, createHalo({ range: 0 })),
{ x: 3, y: 4 }
);
expect(aura.range).toBeInstanceOf(modules.FullRange);
expect(aura.getRangeParam()).toBeUndefined();
});
// 验证九宫格光环使用以定位符为中心的矩形范围参数
it('uses a centered rect for a square halo', () => {
const { enemy } = createEnemy();
const aura = new modules.CommonAura(
enemy,
createSpecial(25, createHalo({ range: 2, square: true })),
{ x: 3, y: 4 }
);
expect(aura.range).toBeInstanceOf(modules.RectRange);
expect(aura.getRangeParam()).toEqual({ x: 1, y: 2, w: 5, h: 5 });
});
// 验证十字光环使用以定位符为中心的曼哈顿范围参数
it('uses a manhattan range for a cross halo', () => {
const { enemy } = createEnemy();
const aura = new modules.CommonAura(
enemy,
createSpecial(25, createHalo({ range: 2, square: false })),
{ x: 3, y: 4 }
);
expect(aura.range).toBeInstanceOf(modules.ManhattanRange);
expect(aura.getRangeParam()).toEqual({ cx: 3, cy: 4, radius: 2 });
});
});
describe('CommonAura apply', () => {
// 验证生命、攻击与防御加成按基础属性比例取整后加到目标怪物上
it('adds floored attribute buffs to the target enemy', () => {
const base = createEnemy({ hp: 100, atk: 50, def: 30 });
const target = createEnemy({ hp: 20, atk: 8, def: 5 });
const aura = new modules.CommonAura(
base.enemy,
createSpecial(
25,
createHalo({ hpBuff: 10, atkBuff: 50, defBuff: 20 })
),
{ x: 0, y: 0 }
);
aura.apply(
createWritableHandler(target.enemy, { x: 1, y: 0 }),
base.enemy
);
expect(target.attrs.hp).toBe(30);
expect(target.attrs.atk).toBe(33);
expect(target.attrs.def).toBe(11);
});
// 验证加成为 0 时不修改目标怪物的任何属性
it('leaves the target untouched when every buff is zero', () => {
const base = createEnemy({ hp: 100, atk: 50, def: 30 });
const target = createEnemy({ hp: 20, atk: 8, def: 5 });
const aura = new modules.CommonAura(
base.enemy,
createSpecial(25, createHalo()),
{ x: 0, y: 0 }
);
aura.apply(
createWritableHandler(target.enemy, { x: 1, y: 0 }),
base.enemy
);
expect(target.attrs).toMatchObject({ hp: 20, atk: 8, def: 5 });
});
// 验证光环的优先级与修饰能力声明,且不产生特殊属性修饰器
it('reports its priority, capabilities and no special modifier', () => {
const { enemy } = createEnemy();
const aura = new modules.CommonAura(
enemy,
createSpecial(25, createHalo()),
{ x: 0, y: 0 }
);
expect(aura.priority).toBe(25);
expect(aura.couldApplyBase).toBe(true);
expect(aura.couldApplySpecial).toBe(false);
expect(aura.applySpecial()).toBeNull();
});
});
describe('GuardAuraConverter', () => {
// 验证只有代码 26 的特殊属性会被支援光环转换器接管
it('converts only special code 26', () => {
const converter = new modules.GuardAuraConverter();
expect(converter.shouldConvert(createSpecial(26, undefined))).toBe(
true
);
expect(converter.shouldConvert(createSpecial(25, undefined))).toBe(
false
);
});
// 验证转换结果为携带上下文、怪物、特殊属性与定位符的支援光环视图
it('converts a code 26 special into a guard aura', () => {
const converter = new modules.GuardAuraConverter();
const { enemy } = createEnemy();
const locator: ITileLocator = { x: 3, y: 4 };
const special = createSpecial(26, undefined);
const aura = converter.convert(
special,
createReadonlyHandler(enemy, locator),
createContext(() => createView())
);
expect(aura).toBeInstanceOf(modules.GuardAura);
expect(aura.enemy).toBe(enemy);
expect(aura.locator).toBe(locator);
expect(aura.special).toBe(special);
});
});
describe('GuardAura', () => {
// 验证支援光环的范围参数为以定位符为中心的 3x3 矩形
it('uses a 3x3 rect centered on the locator', () => {
const { enemy } = createEnemy();
const aura = new modules.GuardAura(
createContext(() => createView()),
enemy,
createSpecial(26, undefined),
{ x: 3, y: 4 }
);
expect(aura.getRangeParam()).toEqual({ x: 2, y: 3, w: 3, h: 3 });
});
// 验证支援光环把来源定位符添加到周围怪物的支援集合
it('adds the source locator to a different target enemy guard set', () => {
const source = createEnemy();
const target = createEnemy();
const sourceLocator: ITileLocator = { x: 1, y: 1 };
const context = createContext(() => createView());
const aura = new modules.GuardAura(
context,
source.enemy,
createSpecial(26, undefined),
sourceLocator
);
aura.apply(createWritableHandler(target.enemy, { x: 2, y: 1 }));
expect(
(target.attrs.guard as Set<ITileLocator>).has(sourceLocator)
).toBe(true);
});
// 验证支援光环不会把来源定位符添加到自身
it('does not add the source locator to the source itself', () => {
const source = createEnemy();
const sourceLocator: ITileLocator = { x: 1, y: 1 };
const aura = new modules.GuardAura(
createContext(() => createView()),
source.enemy,
createSpecial(26, undefined),
sourceLocator
);
aura.apply(createWritableHandler(source.enemy, sourceLocator));
expect(source.attrs.guard.size).toBe(0);
});
// 验证来源视图不存在时不添加任何支援定位符
it('does nothing when the source view is missing', () => {
const source = createEnemy();
const target = createEnemy();
const aura = new modules.GuardAura(
createContext(() => null),
source.enemy,
createSpecial(26, undefined),
{ x: 1, y: 1 }
);
aura.apply(createWritableHandler(target.enemy, { x: 2, y: 1 }));
expect(target.attrs.guard.size).toBe(0);
});
// 验证支援光环的优先级与修饰能力声明,且不产生特殊属性修饰器
it('reports its priority, capabilities and no special modifier', () => {
const { enemy } = createEnemy();
const aura = new modules.GuardAura(
createContext(() => createView()),
enemy,
createSpecial(26, undefined),
{ x: 1, y: 1 }
);
expect(aura.priority).toBe(26);
expect(aura.couldApplyBase).toBe(true);
expect(aura.couldApplySpecial).toBe(false);
expect(aura.range).toBeInstanceOf(modules.RectRange);
expect(aura.applySpecial()).toBeNull();
});
});

View File

@ -1,554 +0,0 @@
// 测试内置伤害计算器:基础伤害、无敌、魔攻、连击、多段、支援、先攻、破甲、反击、净化、吸血、负伤、固伤、仇恨与临界上界
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { type ITileLocator } from '@motajs/common';
import { type IEnemyAttr, type IHeroAttr } from '@user/data-common';
import {
type IEnemyContext,
type IEnemyView,
type IReadonlyEnemyHandler
} from '@user/data-system';
import {
type IEnemy,
type IReadonlyHeroAttribute,
type ISpecial,
type IStateBase
} from '@user/data-base';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
Map.prototype.getOrInsertComputed ??= function <K, V>(
this: Map<K, V>,
key: K,
callback: (key: K) => V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
const value = callback(key);
this.set(key, value);
return value;
};
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,
key: K,
defaultValue: V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
this.set(key, defaultValue);
return defaultValue;
};
});
interface TestModules {
MainDamageCalculator: typeof import('../calculator').MainDamageCalculator;
logger: typeof import('@motajs/common').logger;
}
let modules: TestModules;
beforeAll(async () => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
const calculatorModule = await import('../calculator');
const motaModule = await import('@motajs/common');
modules = {
MainDamageCalculator: calculatorModule.MainDamageCalculator,
logger: motaModule.logger
};
});
interface FakeEnemy {
/** 可写怪物对象 */
enemy: IEnemy<IEnemyAttr>;
/** 当前怪物属性,供断言与后续修改 */
attrs: IEnemyAttr;
}
interface FakeEnemyOptions {
/** 覆盖的怪物属性 */
attrs?: Partial<IEnemyAttr>;
/** 怪物特殊属性表,键为代码,值为该特殊属性的数值 */
specials?: Map<number, unknown>;
}
/**
* 创建一个只实现计算器所需能力的内联假怪物,特殊属性以代码表驱动
* @param options 怪物属性与特殊属性配置
*/
function createEnemy(options: FakeEnemyOptions = {}): FakeEnemy {
const specials = options.specials ?? new Map<number, unknown>();
const attrs: IEnemyAttr = {
hp: 20,
atk: 8,
def: 5,
money: 2,
exp: 3,
point: 1,
guard: new Set(),
...options.attrs
};
const enemy = {
id: 'test-enemy',
code: 1,
getSpecial: (code: number): ISpecial<any> | null =>
specials.has(code)
? ({
code,
value: specials.get(code),
deepEqualsTo: () => false
} as unknown as ISpecial<any>)
: null,
hasSpecial: (code: number) => specials.has(code),
iterateSpecials: () => [],
getAttribute: (key: string) => (attrs as never)[key],
cloneAttributes: () => structuredClone(attrs),
clone: () => enemy,
addSpecial: () => {},
deleteSpecial: () => {},
setAttribute: (key: string, value: unknown) => {
// 假怪物按动态字符串键读写属性,固定形状的 IEnemyAttr 无法表达字符串索引
(attrs as unknown as Record<string, unknown>)[key] = value;
},
addAttribute: (key: string, value: number) => {
(attrs as unknown as Record<string, number>)[key] += value;
},
copyFrom: () => {},
saveState: () => ({
attrs: structuredClone(attrs),
specials: new Map()
}),
loadState: () => {}
} as IEnemy<IEnemyAttr>;
return { enemy, attrs };
}
/**
* 创建一个只提供基础与最终属性读取的内联假勇士
* @param attrs 覆盖的勇士属性
*/
function createHero(
attrs: Partial<IHeroAttr> = {}
): IReadonlyHeroAttribute<IHeroAttr> {
const values: IHeroAttr = {
name: 'hero',
hp: 100,
hpmax: 100,
atk: 20,
def: 5,
mdef: 0,
mana: 0,
manamax: 0,
money: 0,
exp: 0,
...attrs
};
return {
getBaseAttribute: (name: string) => (values as never)[name],
getFinalAttribute: (name: string) => (values as never)[name]
} as unknown as IReadonlyHeroAttribute<IHeroAttr>;
}
interface FakeStateOptions {
/** 十字架数量,未配置表示背包中没有十字架 */
cross?: number;
/** flag 字段值表 */
flags?: Record<string, unknown>;
}
/**
* 创建一个只实现计算器所需状态读取的内联假状态对象
* @param options 十字架数量与 flag 字段配置
*/
function createState(options: FakeStateOptions = {}): IStateBase {
const flags = options.flags ?? {};
return {
hero: {
items: {
getItemState: (id: string) =>
id === 'cross' && options.cross !== undefined
? { count: options.cross }
: null
}
},
flags: {
getFieldValueDefaults: (name: string, defaultValue: unknown) =>
name in flags ? flags[name] : defaultValue
}
} as IStateBase;
}
/**
* 创建一个只实现定位符查找的内联假地图上下文
* @param resolve 按定位符查找怪物视图的回调
*/
function createContext(
resolve?: (locator: ITileLocator) => IEnemyView<IEnemyAttr> | null
): IEnemyContext<IEnemyAttr, IHeroAttr> {
return {
width: 8,
height: 8,
getEnemyByLocator: (locator: ITileLocator) =>
resolve ? resolve(locator) : null
} as IEnemyContext<IEnemyAttr, IHeroAttr>;
}
/**
* 将一个计算后怪物对象包装为只提供该对象读取的假视图
* @param computed 计算后怪物对象
*/
function createView(computed: IEnemy<IEnemyAttr>): IEnemyView<IEnemyAttr> {
return {
getComputedEnemy: () => computed
} as unknown as IEnemyView<IEnemyAttr>;
}
interface HandlerOptions {
/** 怪物对象 */
enemy: IEnemy<IEnemyAttr>;
/** 勇士属性 */
hero?: IReadonlyHeroAttribute<IHeroAttr>;
/** 状态对象 */
state?: IStateBase;
/** 地图上下文 */
context?: IEnemyContext<IEnemyAttr, IHeroAttr>;
/** 怪物定位符 */
locator?: ITileLocator;
}
/**
* 组装一个计算器所需的最小只读信息对象
* @param options 信息对象各字段
*/
function createHandler(
options: HandlerOptions
): IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr> {
return {
enemy: options.enemy,
context: options.context ?? createContext(),
locator: options.locator ?? { x: 0, y: 0 },
hero: options.hero ?? createHero(),
state: options.state ?? createState()
} as IReadonlyEnemyHandler<IEnemyAttr, IHeroAttr>;
}
/**
* 创建一个带特殊属性代码表的怪物属性配置
* @param entries 特殊属性代码与数值对
*/
function specialsOf(entries: Array<[number, unknown]>): Map<number, unknown> {
return new Map(entries);
}
/**
* 创建一个计算器实例
*/
function createCalculator(): InstanceType<TestModules['MainDamageCalculator']> {
return new modules.MainDamageCalculator();
}
describe('MainDamageCalculator base and defeat branches', () => {
// 验证无特殊属性时按攻防差计算每轮伤害与回合数
it('computes damage and turns from the attack and defense difference', () => {
const { enemy } = createEnemy();
const calculator = createCalculator();
const info = calculator.calculate(createHandler({ enemy }));
expect(info).toEqual({ damage: 3, turn: 2 });
});
// 验证勇士无法破防时返回无敌伤害与 0 回合
it('returns infinite damage when the hero cannot break the defense', () => {
const { enemy } = createEnemy({ attrs: { def: 20 } });
const calculator = createCalculator();
const info = calculator.calculate(createHandler({ enemy }));
expect(info).toEqual({ damage: Infinity, turn: 0 });
});
// 验证无敌怪物在没有十字架时不可战胜,持有十字架后恢复正常计算
it('blocks 无敌 without a cross and allows it with one', () => {
const { enemy } = createEnemy({
specials: specialsOf([[20, undefined]])
});
const calculator = createCalculator();
const blocked = calculator.calculate(createHandler({ enemy }));
const allowed = calculator.calculate(
createHandler({ enemy, state: createState({ cross: 1 }) })
);
expect(blocked).toEqual({ damage: Infinity, turn: 0 });
expect(allowed).toEqual({ damage: 3, turn: 2 });
});
// 验证魔攻怪物的每轮伤害不减免勇士防御
it('ignores hero defense for 魔攻', () => {
const { enemy } = createEnemy({
specials: specialsOf([[2, undefined]])
});
const calculator = createCalculator();
const info = calculator.calculate(createHandler({ enemy }));
expect(info).toEqual({ damage: 8, turn: 2 });
});
// 验证 2连击与 3连击分别把每轮伤害乘以 2 和 3
it('multiplies enemy damage for 2连击 and 3连击', () => {
const calculator = createCalculator();
const double = createEnemy({ specials: specialsOf([[4, undefined]]) });
const triple = createEnemy({ specials: specialsOf([[5, undefined]]) });
expect(
calculator.calculate(createHandler({ enemy: double.enemy }))
).toEqual({ damage: 6, turn: 2 });
expect(
calculator.calculate(createHandler({ enemy: triple.enemy }))
).toEqual({ damage: 9, turn: 2 });
});
// 验证多段伤害按特殊属性数值倍乘每轮伤害
it('multiplies enemy damage by the 多段 value', () => {
const { enemy } = createEnemy({ specials: specialsOf([[6, 4]]) });
const calculator = createCalculator();
const info = calculator.calculate(createHandler({ enemy }));
expect(info).toEqual({ damage: 12, turn: 2 });
});
});
describe('MainDamageCalculator support and additive branches', () => {
// 验证支援怪会递归累加其回合与伤害,且同一支援怪只被计算一次
it('adds the guard turn and damage through recursion', () => {
const guard = createEnemy();
const { enemy } = createEnemy({
attrs: { guard: new Set<ITileLocator>([{ x: 1, y: 0 }]) }
});
const calls: string[] = [];
const context = createContext(locator => {
calls.push(`${locator.x},${locator.y}`);
return locator.x === 1 ? createView(guard.enemy) : null;
});
const calculator = createCalculator();
const info = calculator.calculate(createHandler({ enemy, context }));
expect(info).toEqual({ damage: 12, turn: 4 });
expect(calls).toEqual(['1,0']);
});
// 验证同一计算器连续两次顶层计算互不影响,支援标记不会泄漏
it('does not leak the in-guard flag across top-level calls', () => {
const guard = createEnemy();
const { enemy } = createEnemy({
attrs: { guard: new Set<ITileLocator>([{ x: 1, y: 0 }]) }
});
const context = createContext(locator =>
locator.x === 1 ? createView(guard.enemy) : null
);
const calculator = createCalculator();
const handler = createHandler({ enemy, context });
const first = calculator.calculate(handler);
const second = calculator.calculate(handler);
expect(first).toEqual({ damage: 12, turn: 4 });
expect(second).toEqual(first);
});
// 验证支援怪不存在时告警 137 并跳过该支援
it('warns 137 when a guard locator has no enemy', () => {
const { enemy } = createEnemy({
attrs: { guard: new Set<ITileLocator>([{ x: 2, y: 0 }]) }
});
const calculator = createCalculator();
const result = modules.logger.catch(() =>
calculator.calculate(createHandler({ enemy }))
);
expect(result.ret).toEqual({ damage: 3, turn: 2 });
expect(result.info.map(item => item.code)).toContain(137);
});
// 验证先攻会额外附加一次每轮伤害
it('adds one enemy hit for 先攻', () => {
const { enemy } = createEnemy({
specials: specialsOf([[1, undefined]])
});
const calculator = createCalculator();
const info = calculator.calculate(createHandler({ enemy }));
expect(info).toEqual({ damage: 6, turn: 2 });
});
// 验证破甲按比例附加勇士防御作为伤害
it('adds a share of hero defense for 破甲', () => {
const { enemy } = createEnemy({ specials: specialsOf([[7, 100]]) });
const calculator = createCalculator();
const info = calculator.calculate(createHandler({ enemy }));
expect(info).toEqual({ damage: 8, turn: 2 });
});
// 验证伤害最终向下取整
it('floors the final damage value', () => {
const { enemy } = createEnemy({ specials: specialsOf([[7, 33]]) });
const calculator = createCalculator();
const info = calculator.calculate(createHandler({ enemy }));
expect(info).toEqual({ damage: 4, turn: 2 });
});
// 验证反击按比例把勇士攻击附加到每轮伤害
it('adds a share of hero attack for 反击', () => {
const { enemy } = createEnemy({ specials: specialsOf([[8, 50]]) });
const calculator = createCalculator();
const info = calculator.calculate(createHandler({ enemy }));
expect(info).toEqual({ damage: 13, turn: 2 });
});
// 验证净化按倍数附加勇士魔防,且随后的减伤仍扣除一次魔防
it('adds hero mdef for 净化 and then subtracts it once', () => {
const { enemy } = createEnemy({ specials: specialsOf([[9, 2]]) });
const calculator = createCalculator();
const info = calculator.calculate(
createHandler({ enemy, hero: createHero({ mdef: 3 }) })
);
expect(info).toEqual({ damage: 6, turn: 2 });
});
});
describe('MainDamageCalculator vampire and bypass branches', () => {
// 验证吸血按勇士生命上限比例附加伤害,不回复自身时不改变怪物血量
it('adds vampire damage without healing the enemy by default', () => {
const { enemy } = createEnemy({
specials: specialsOf([[11, { vampire: 10, add: false }]])
});
const calculator = createCalculator();
const info = calculator.calculate(
createHandler({ enemy, hero: createHero({ hp: 200 }) })
);
expect(info).toEqual({ damage: 23, turn: 2 });
});
// 验证开启回复后吸血数值加到怪物血量上并延长回合数
it('adds vampire damage to the enemy health when add is true', () => {
const { enemy } = createEnemy({
specials: specialsOf([[11, { vampire: 10, add: true }]])
});
const calculator = createCalculator();
const info = calculator.calculate(
createHandler({ enemy, hero: createHero({ hp: 200 }) })
);
expect(info).toEqual({ damage: 26, turn: 3 });
});
// 验证未开启负伤时负伤害被夹到 0
it('clamps negative damage to zero when negative damage is disabled', () => {
const { enemy } = createEnemy({ attrs: { atk: 0 } });
const calculator = createCalculator();
const info = calculator.calculate(
createHandler({ enemy, hero: createHero({ mdef: 5 }) })
);
expect(info).toEqual({ damage: 0, turn: 2 });
});
// 验证开启负伤后负伤害被保留
it('keeps negative damage when negative damage is enabled', () => {
const { enemy } = createEnemy({ attrs: { atk: 0 } });
const calculator = createCalculator();
const info = calculator.calculate(
createHandler({
enemy,
hero: createHero({ mdef: 5 }),
state: createState({ flags: { enableNegativeDamage: true } })
})
);
expect(info).toEqual({ damage: -5, turn: 2 });
});
// 验证固伤在扣除魔防之后附加,不受魔防影响
it('adds 固伤 after the mdef subtraction', () => {
const { enemy } = createEnemy({ specials: specialsOf([[22, 7]]) });
const calculator = createCalculator();
const info = calculator.calculate(
createHandler({ enemy, hero: createHero({ mdef: 2 }) })
);
expect(info).toEqual({ damage: 8, turn: 2 });
});
// 验证仇恨按 flag 字段值附加伤害且不受魔防影响
it('adds the hatred flag value for 仇恨', () => {
const { enemy } = createEnemy({
specials: specialsOf([[17, undefined]])
});
const calculator = createCalculator();
const info = calculator.calculate(
createHandler({
enemy,
hero: createHero({ mdef: 2 }),
state: createState({ flags: { hatred: 5 } })
})
);
expect(info).toEqual({ damage: 6, turn: 2 });
});
});
describe('MainDamageCalculator critical limit', () => {
// 验证攻击临界上界为怪物防御加生命,坚固怪物则为无穷
it('returns def plus hp for atk, and Infinity for 坚固', () => {
const normal = createEnemy();
const solid = createEnemy({ specials: specialsOf([[3, undefined]]) });
const calculator = createCalculator();
expect(
calculator.getCriticalLimit(
createHandler({ enemy: normal.enemy }),
'atk'
)
).toBe(25);
expect(
calculator.getCriticalLimit(
createHandler({ enemy: solid.enemy }),
'atk'
)
).toBe(Infinity);
});
// 验证非攻击属性直接返回勇士对应最终属性
it('returns the hero final attribute for other attributes', () => {
const { enemy } = createEnemy();
const calculator = createCalculator();
const limit = calculator.getCriticalLimit(
createHandler({ enemy, hero: createHero({ def: 7 }) }),
'def'
);
expect(limit).toBe(7);
});
});

View File

@ -1,153 +0,0 @@
// 测试怪物比较器:基础属性全部一致且特殊属性集合深比较相等时判定为相同
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { type IEnemyAttr } from '@user/data-common';
import { type IReadonlyEnemy, type ISpecial } from '@user/data-base';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
Map.prototype.getOrInsertComputed ??= function <K, V>(
this: Map<K, V>,
key: K,
callback: (key: K) => V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
const value = callback(key);
this.set(key, value);
return value;
};
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,
key: K,
defaultValue: V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
this.set(key, defaultValue);
return defaultValue;
};
});
interface TestModules {
MainEnemyComparer: typeof import('../comparer').MainEnemyComparer;
}
let modules: TestModules;
beforeAll(async () => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
const comparerModule = await import('../comparer');
modules = {
MainEnemyComparer: comparerModule.MainEnemyComparer
};
});
/**
* 创建一个按代码与数值做深比较的内联假特殊属性
* @param code 特殊属性代码
* @param value 特殊属性数值
*/
function createSpecial(code: number, value: unknown): ISpecial<any> {
return {
code,
value,
deepEqualsTo: (other: ISpecial<any>) =>
other.code === code &&
JSON.stringify(other.value) === JSON.stringify(value)
} as never;
}
/**
* 创建一个只实现比较器所需读取能力的内联假怪物
* @param attrs 覆盖的怪物属性
* @param specials 怪物拥有的特殊属性列表
*/
function createEnemy(
attrs: Partial<IEnemyAttr> = {},
specials: ISpecial<any>[] = []
): IReadonlyEnemy<IEnemyAttr> {
const values: IEnemyAttr = {
hp: 20,
atk: 8,
def: 5,
money: 2,
exp: 3,
point: 1,
guard: new Set(),
...attrs
};
return {
id: 'test-enemy',
code: 1,
getSpecial: (code: number) =>
specials.find(special => special.code === code) ?? null,
hasSpecial: (code: number) =>
specials.some(special => special.code === code),
iterateSpecials: () => specials,
getAttribute: (key: string) => (values as never)[key],
cloneAttributes: () => structuredClone(values),
clone: () => createEnemy(attrs, specials)
} as never;
}
describe('MainEnemyComparer', () => {
// 验证基础属性与特殊属性都一致时判定为相同
it('returns true for identical attributes and specials', () => {
const comparer = new modules.MainEnemyComparer();
const enemyA = createEnemy({}, [createSpecial(6, 3)]);
const enemyB = createEnemy({}, [createSpecial(6, 3)]);
expect(comparer.compare(enemyA, enemyB)).toBe(true);
});
// 验证任一基础属性不同都会判定为不同
it('returns false when any base attribute differs', () => {
const comparer = new modules.MainEnemyComparer();
const base = createEnemy();
const cases: Array<[keyof IEnemyAttr, number]> = [
['hp', 21],
['atk', 9],
['def', 6],
['money', 3],
['exp', 4],
['point', 2]
];
for (const [key, value] of cases) {
const other = createEnemy({ [key]: value });
expect(comparer.compare(base, other)).toBe(false);
}
});
// 验证特殊属性数量不同时判定为不同
it('returns false when the special counts differ', () => {
const comparer = new modules.MainEnemyComparer();
const enemyA = createEnemy({}, [createSpecial(6, 3)]);
const enemyB = createEnemy({}, [
createSpecial(6, 3),
createSpecial(7, 0)
]);
expect(comparer.compare(enemyA, enemyB)).toBe(false);
});
// 验证特殊属性代码集合不同时判定为不同
it('returns false when the special codes differ', () => {
const comparer = new modules.MainEnemyComparer();
const enemyA = createEnemy({}, [createSpecial(6, 3)]);
const enemyB = createEnemy({}, [createSpecial(7, 3)]);
expect(comparer.compare(enemyA, enemyB)).toBe(false);
});
// 验证同代码特殊属性数值不同时判定为不同
it('returns false when a special deep comparison fails', () => {
const comparer = new modules.MainEnemyComparer();
const enemyA = createEnemy({}, [createSpecial(6, 3)]);
const enemyB = createEnemy({}, [createSpecial(6, 5)]);
expect(comparer.compare(enemyA, enemyB)).toBe(false);
});
});

View File

@ -1,189 +0,0 @@
// 测试怪物最终效果:坚固把防御提升到勇士攻击减一、模仿复制勇士攻防、无相关特殊属性时保持不变
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { type IEnemyAttr, type IHeroAttr } from '@user/data-common';
import { type IEnemyHandler } from '@user/data-system';
import { type IEnemy, type IReadonlyHeroAttribute } from '@user/data-base';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
Map.prototype.getOrInsertComputed ??= function <K, V>(
this: Map<K, V>,
key: K,
callback: (key: K) => V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
const value = callback(key);
this.set(key, value);
return value;
};
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,
key: K,
defaultValue: V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
this.set(key, defaultValue);
return defaultValue;
};
});
interface TestModules {
MainEnemyFinalEffect: typeof import('../final').MainEnemyFinalEffect;
}
let modules: TestModules;
beforeAll(async () => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
const finalModule = await import('../final');
modules = {
MainEnemyFinalEffect: finalModule.MainEnemyFinalEffect
};
});
interface FakeEnemy {
/** 可写怪物对象 */
enemy: IEnemy<IEnemyAttr>;
/** 当前怪物属性,供断言使用 */
attrs: IEnemyAttr;
}
/**
* 创建一个只实现最终效果所需能力的内联假怪物
* @param attrs 覆盖的怪物属性
* @param specials 怪物拥有的特殊属性代码
*/
function createEnemy(
attrs: Partial<IEnemyAttr> = {},
specials: number[] = []
): FakeEnemy {
const values: IEnemyAttr = {
hp: 20,
atk: 8,
def: 5,
money: 2,
exp: 3,
point: 1,
guard: new Set(),
...attrs
};
const enemy = {
id: 'test-enemy',
code: 1,
getSpecial: () => null,
hasSpecial: (code: number) => specials.includes(code),
iterateSpecials: () => [],
getAttribute: (key: string) => (values as never)[key],
cloneAttributes: () => structuredClone(values),
clone: () => enemy,
addSpecial: () => {},
deleteSpecial: () => {},
setAttribute: (key: string, value: unknown) => {
// 假怪物按动态字符串键读写属性,固定形状的 IEnemyAttr 无法表达字符串索引
(values as unknown as Record<string, unknown>)[key] = value;
},
addAttribute: (key: string, value: number) => {
(values as unknown as Record<string, number>)[key] += value;
},
copyFrom: () => {},
saveState: () => ({
attrs: structuredClone(values),
specials: new Map()
}),
loadState: () => {}
} as never;
return { enemy, attrs: values };
}
/**
* 创建一个只提供最终属性读取的内联假勇士
* @param attrs 覆盖的勇士属性
*/
function createHero(
attrs: Partial<IHeroAttr> = {}
): IReadonlyHeroAttribute<IHeroAttr> {
const values: IHeroAttr = {
name: 'hero',
hp: 100,
hpmax: 100,
atk: 20,
def: 7,
mdef: 0,
mana: 0,
manamax: 0,
money: 0,
exp: 0,
...attrs
};
return {
getBaseAttribute: (name: string) => (values as never)[name],
getFinalAttribute: (name: string) => (values as never)[name]
} as never;
}
/**
* 组装一个最终效果所需的最小信息对象
* @param enemy 怪物对象
* @param hero 勇士属性对象
*/
function createHandler(
enemy: IEnemy<IEnemyAttr>,
hero: IReadonlyHeroAttribute<IHeroAttr> = createHero()
): IEnemyHandler<IEnemyAttr, IHeroAttr> {
return { enemy, hero } as never;
}
describe('MainEnemyFinalEffect', () => {
// 验证最终效果的优先级为 0
it('reports priority 0', () => {
const effect = new modules.MainEnemyFinalEffect();
expect(effect.priority).toBe(0);
});
// 验证坚固把低于勇士攻击减一的防御提升到该值
it('raises defense to hero attack minus one for 坚固', () => {
const { enemy, attrs } = createEnemy({ def: 5 }, [3]);
const effect = new modules.MainEnemyFinalEffect();
effect.apply(createHandler(enemy));
expect(attrs.def).toBe(19);
});
// 验证坚固不会降低已高于勇士攻击减一的防御
it('keeps a defense already above hero attack minus one for 坚固', () => {
const { enemy, attrs } = createEnemy({ def: 25 }, [3]);
const effect = new modules.MainEnemyFinalEffect();
effect.apply(createHandler(enemy));
expect(attrs.def).toBe(25);
});
// 验证模仿把怪物的攻击与防御改成勇士的最终攻击与防御
it('copies hero attack and defense for 模仿', () => {
const { enemy, attrs } = createEnemy({ atk: 3, def: 3 }, [10]);
const effect = new modules.MainEnemyFinalEffect();
effect.apply(createHandler(enemy));
expect(attrs.atk).toBe(20);
expect(attrs.def).toBe(7);
});
// 验证没有相关特殊属性时怪物的攻击与防御保持不变
it('leaves enemies without the effect specials untouched', () => {
const { enemy, attrs } = createEnemy({ atk: 3, def: 3 }, [1, 2]);
const effect = new modules.MainEnemyFinalEffect();
effect.apply(createHandler(enemy));
expect(attrs.atk).toBe(3);
expect(attrs.def).toBe(3);
});
});

View File

@ -1,592 +0,0 @@
// 测试地图伤害:领域/阻击/激光/夹击/捕捉五种视图、转换器按特殊属性组装视图、合并器求和与额外信息并集
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { type ITileLocator } from '@motajs/common';
import { type IEnemyAttr, type IHeroAttr } from '@user/data-common';
import {
type IDamageSystem,
type IEnemyContext,
type IEnemyView,
type IMapDamageInfo
} from '@user/data-system';
import {
type IReadonlyEnemy,
type IReadonlyHeroAttribute,
type ISpecial
} from '@user/data-base';
import { type ISpecial13Value } from '../special';
const testGlobals = vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
const core = { flags: { betweenAttackMax: false } };
vi.stubGlobal('core', core);
Map.prototype.getOrInsertComputed ??= function <K, V>(
this: Map<K, V>,
key: K,
callback: (key: K) => V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
const value = callback(key);
this.set(key, value);
return value;
};
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,
key: K,
defaultValue: V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
this.set(key, defaultValue);
return defaultValue;
};
return { core };
});
interface TestModules {
ZoneDamageView: typeof import('../mapDamage').ZoneDamageView;
RepulseDamageView: typeof import('../mapDamage').RepulseDamageView;
LaserDamageView: typeof import('../mapDamage').LaserDamageView;
BetweenDamageView: typeof import('../mapDamage').BetweenDamageView;
AmbushDamageView: typeof import('../mapDamage').AmbushDamageView;
MainMapDamageConverter: typeof import('../mapDamage').MainMapDamageConverter;
MainMapDamageReducer: typeof import('../mapDamage').MainMapDamageReducer;
MapDamageType: typeof import('../types').MapDamageType;
ManhattanRange: typeof import('@motajs/common').ManhattanRange;
RectRange: typeof import('@motajs/common').RectRange;
FaceGroup: typeof import('@user/data-common').FaceGroup;
}
let modules: TestModules;
beforeAll(async () => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
const mapDamageModule = await import('../mapDamage');
const typesModule = await import('../types');
const motaModule = await import('@motajs/common');
const commonModule = await import('@user/data-common');
modules = {
ZoneDamageView: mapDamageModule.ZoneDamageView,
RepulseDamageView: mapDamageModule.RepulseDamageView,
LaserDamageView: mapDamageModule.LaserDamageView,
BetweenDamageView: mapDamageModule.BetweenDamageView,
AmbushDamageView: mapDamageModule.AmbushDamageView,
MainMapDamageConverter: mapDamageModule.MainMapDamageConverter,
MainMapDamageReducer: mapDamageModule.MainMapDamageReducer,
MapDamageType: {
Unknown: typesModule.MapDamageType.Unknown,
Zone: typesModule.MapDamageType.Zone,
Layer: typesModule.MapDamageType.Layer,
Repulse: typesModule.MapDamageType.Repulse,
Between: typesModule.MapDamageType.Between
},
ManhattanRange: motaModule.ManhattanRange,
RectRange: motaModule.RectRange,
FaceGroup: {
Dir4: commonModule.FaceGroup.Dir4,
Dir8: commonModule.FaceGroup.Dir8
}
};
});
/**
* 创建一个只携带代码与数值的内联假特殊属性
* @param code 特殊属性代码
* @param value 特殊属性数值
*/
function createSpecial<T>(code: number, value: T): ISpecial<T> {
return { code, value } as never;
}
/**
* 创建一个只实现地图伤害所需读取能力的内联假怪物
* @param specials 怪物特殊属性表
*/
function createEnemy(
specials: Map<number, unknown> = new Map()
): IReadonlyEnemy<IEnemyAttr> {
return {
id: 'test-enemy',
code: 1,
getSpecial: (code: number) =>
specials.has(code) ? createSpecial(code, specials.get(code)) : null,
hasSpecial: (code: number) => specials.has(code),
iterateSpecials: () => [],
getAttribute: () => 0,
cloneAttributes: () => ({}) as IEnemyAttr,
clone: () => createEnemy(specials)
} as never;
}
/**
* 创建一个只提供生命值读取的内联假勇士
* @param hp 勇士最终生命值
*/
function createHero(hp: number = 100): IReadonlyHeroAttribute<IHeroAttr> {
return {
getBaseAttribute: () => hp,
getFinalAttribute: () => hp
} as never;
}
interface FakeContextOptions {
/** 地图宽度 */
width?: number;
/** 地图高度 */
height?: number;
/** 按坐标存放的怪物视图 */
enemies?: Map<string, IEnemyView<IEnemyAttr>>;
/** 绑定的伤害系统 */
damageSystem?: IDamageSystem<IEnemyAttr, IHeroAttr> | null;
}
/**
* 创建一个只实现地图伤害所需能力的内联假怪物上下文
* @param options 宽度、高度、怪物视图与伤害系统配置
*/
function createContext(
options: FakeContextOptions = {}
): IEnemyContext<IEnemyAttr, IHeroAttr> {
const width = options.width ?? 6;
const height = options.height ?? 6;
const enemies =
options.enemies ?? new Map<string, IEnemyView<IEnemyAttr>>();
return {
width,
height,
indexer: {
locToIndex: (x: number, y: number) => y * width + x,
locaterToIndex: (locator: ITileLocator) =>
locator.y * width + locator.x,
indexToLocator: (index: number) => ({
x: index % width,
y: Math.floor(index / width)
})
},
getEnemyByLoc: (x: number, y: number) =>
enemies.get(`${x},${y}`) ?? null,
getDamageSystem: () => options.damageSystem ?? null
} as never;
}
/**
* 将一个计算后怪物对象包装为只提供该对象读取的假视图
* @param computed 计算后怪物对象
*/
function createView(
computed: IReadonlyEnemy<IEnemyAttr>
): IEnemyView<IEnemyAttr> {
return { getComputedEnemy: () => computed } as never;
}
/**
* 创建一个按固定方向表返回移动描述的假朝向处理器
* @param dirs 朝向与坐标增量对
*/
function createFace(dirs: Array<[number, { x: number; y: number }]>): {
mapMovement: () => Array<[number, { x: number; y: number }]>;
} {
return { mapMovement: () => dirs };
}
/**
* 构造一条地图伤害信息
* @param damage 伤害值
* @param type 伤害类型
* @param extra 额外信息
*/
function createInfo(
damage: number,
type: number,
extra: Partial<IMapDamageInfo['extra']> = {}
): IMapDamageInfo {
return {
damage,
type,
extra: {
catch: extra.catch ?? new Set(),
repulse: extra.repulse ?? new Set()
}
};
}
describe('ZoneDamageView', () => {
// 验证十字领域使用曼哈顿范围参数并输出领域伤害
it('builds a manhattan range and zone damage for a cross zone', () => {
const special = createSpecial<ISpecial13Value>(15, {
zone: 7,
square: false,
range: 1
});
const view = new modules.ZoneDamageView(
createContext(),
{ x: 2, y: 3 },
special
);
expect(view.getRange()).toBeInstanceOf(modules.ManhattanRange);
expect(view.getRangeParam()).toEqual({ cx: 2, cy: 3, radius: 1 });
expect(view.getDamageWithoutCheck()).toEqual(
createInfo(7, modules.MapDamageType.Zone)
);
});
// 验证九宫格领域使用矩形范围参数并输出领域伤害
it('builds a rect range and zone damage for a square zone', () => {
const special = createSpecial<ISpecial13Value>(15, {
zone: 4,
square: true,
range: 2
});
const view = new modules.ZoneDamageView(
createContext(),
{ x: 2, y: 3 },
special
);
expect(view.getRange()).toBeInstanceOf(modules.RectRange);
expect(view.getRangeParam()).toEqual({ x: 0, y: 1, w: 5, h: 5 });
expect(view.getDamageWithoutCheck()).toEqual(
createInfo(4, modules.MapDamageType.Zone)
);
});
// 验证领域范围判定命中范围内的坐标并忽略范围外坐标
it('checks coordinates against the zone range', () => {
const special = createSpecial<ISpecial13Value>(15, {
zone: 7,
square: false,
range: 1
});
const view = new modules.ZoneDamageView(
createContext(),
{ x: 2, y: 3 },
special
);
expect(view.getDamageAt({ x: 2, y: 4 })).toEqual(
createInfo(7, modules.MapDamageType.Zone)
);
expect(view.getDamageAt({ x: 5, y: 5 })).toBeNull();
});
});
describe('RepulseDamageView', () => {
// 验证阻击范围半径为 1 且在来源点返回空
it('uses a radius one range and returns null at the source', () => {
const special = createSpecial(18, 4);
const source: ITileLocator = { x: 2, y: 3 };
const view = new modules.RepulseDamageView(
createContext(),
source,
special
);
expect(view.getRangeParam()).toEqual({ cx: 2, cy: 3, radius: 1 });
expect(view.getDamageWithoutCheck(source)).toBeNull();
});
// 验证阻击在相邻点输出伤害并把来源定位符放入额外信息
it('returns repulse damage carrying the source locator', () => {
const special = createSpecial(18, 4);
const source: ITileLocator = { x: 2, y: 3 };
const view = new modules.RepulseDamageView(
createContext(),
source,
special
);
const info = view.getDamageWithoutCheck({ x: 3, y: 3 });
expect(info?.damage).toBe(4);
expect(info?.type).toBe(modules.MapDamageType.Repulse);
expect(info?.extra.repulse.has(source)).toBe(true);
});
// 验证阻击范围判定忽略范围外的坐标
it('ignores coordinates outside the repulse range', () => {
const special = createSpecial(18, 4);
const view = new modules.RepulseDamageView(
createContext(),
{ x: 2, y: 3 },
special
);
expect(view.getDamageAt({ x: 5, y: 5 })).toBeNull();
});
});
describe('LaserDamageView', () => {
// 验证激光范围参数取自朝向处理器的移动方向并输出激光伤害
it('uses the face handler movements as the ray directions', () => {
const dirs: Array<[number, { x: number; y: number }]> = [
[0, { x: 0, y: 0 }],
[1, { x: 1, y: 0 }]
];
const special = createSpecial(24, 9);
const view = new modules.LaserDamageView(
createContext(),
{ x: 2, y: 3 },
special,
createFace(dirs) as never
);
expect(view.getRangeParam()).toEqual({
cx: 2,
cy: 3,
dir: [
{ x: 0, y: 0 },
{ x: 1, y: 0 }
]
});
expect(view.getDamageWithoutCheck()).toEqual(
createInfo(9, modules.MapDamageType.Layer)
);
});
});
describe('BetweenDamageView', () => {
// 验证夹击只在向下的相邻格命中并要求镜像位置存在同类怪物
it('returns half hero hp for a qualifying adjacent pair', () => {
const source = createEnemy();
const mirror = createEnemy(new Map([[16, undefined]]));
const context = createContext({
enemies: new Map([['3,1', createView(mirror)]])
});
const view = new modules.BetweenDamageView(
context,
source,
{ x: 1, y: 1 },
createHero(100)
);
expect(view.getDamageWithoutCheck({ x: 2, y: 1 })).toEqual(
createInfo(50, modules.MapDamageType.Between)
);
});
// 验证向左与向上方向的相邻格不构成夹击
it('ignores the left and upper adjacent locators', () => {
const source = createEnemy();
const mirror = createEnemy(new Map([[16, undefined]]));
const context = createContext({
enemies: new Map([
['1,1', createView(mirror)],
['-1,1', createView(mirror)]
])
});
const view = new modules.BetweenDamageView(
context,
source,
{ x: 1, y: 1 },
createHero(100)
);
expect(view.getDamageWithoutCheck({ x: 0, y: 1 })).toBeNull();
expect(view.getDamageWithoutCheck({ x: 1, y: 0 })).toBeNull();
});
// 验证斜向相邻格与非相邻格都不构成夹击
it('ignores diagonal and non-adjacent locators', () => {
const source = createEnemy();
const mirror = createEnemy(new Map([[16, undefined]]));
const context = createContext({
enemies: new Map([['3,2', createView(mirror)]])
});
const view = new modules.BetweenDamageView(
context,
source,
{ x: 1, y: 1 },
createHero(100)
);
expect(view.getDamageWithoutCheck({ x: 2, y: 2 })).toBeNull();
expect(view.getDamageWithoutCheck({ x: 4, y: 1 })).toBeNull();
});
// 验证镜像位置没有怪物时不构成夹击
it('returns null when the mirrored locator has no enemy', () => {
const source = createEnemy();
const view = new modules.BetweenDamageView(
createContext(),
source,
{ x: 1, y: 1 },
createHero(100)
);
expect(view.getDamageWithoutCheck({ x: 2, y: 1 })).toBeNull();
});
// 验证镜像怪物没有夹击属性时不构成夹击
it('returns null when the mirrored enemy lacks special 16', () => {
const source = createEnemy();
const mirror = createEnemy();
const context = createContext({
enemies: new Map([['3,1', createView(mirror)]])
});
const view = new modules.BetweenDamageView(
context,
source,
{ x: 1, y: 1 },
createHero(100)
);
expect(view.getDamageWithoutCheck({ x: 2, y: 1 })).toBeNull();
});
// 验证存在伤害系统时夹击取半血与双方伤害的最小值
it('takes the minimum of half hp and both damage infos', () => {
const source = createEnemy();
const mirror = createEnemy(new Map([[16, undefined]]));
const half = createHero(100);
const damageSystem = {
getDamageInfoByComputed: (enemy: IReadonlyEnemy<IEnemyAttr>) => ({
damage: enemy === source ? 30 : 40,
turn: 1
})
} as never;
const context = createContext({
enemies: new Map([['3,1', createView(mirror)]]),
damageSystem
});
const view = new modules.BetweenDamageView(
context,
source,
{ x: 1, y: 1 },
half
);
testGlobals.core.flags.betweenAttackMax = true;
const info = view.getDamageWithoutCheck({ x: 2, y: 1 });
testGlobals.core.flags.betweenAttackMax = false;
expect(info).toEqual(createInfo(30, modules.MapDamageType.Between));
});
// 验证缺少伤害系统时夹击回退为半血
it('falls back to half hp when the damage system is missing', () => {
const source = createEnemy();
const mirror = createEnemy(new Map([[16, undefined]]));
const context = createContext({
enemies: new Map([['3,1', createView(mirror)]])
});
const view = new modules.BetweenDamageView(
context,
source,
{ x: 1, y: 1 },
createHero(100)
);
testGlobals.core.flags.betweenAttackMax = true;
const info = view.getDamageWithoutCheck({ x: 2, y: 1 });
testGlobals.core.flags.betweenAttackMax = false;
expect(info).toEqual(createInfo(50, modules.MapDamageType.Between));
});
});
describe('AmbushDamageView', () => {
// 验证捕捉视图输出 0 点未知伤害并把定位符放入捕捉信息
it('returns zero unknown damage carrying the locator', () => {
const locator: ITileLocator = { x: 2, y: 3 };
const view = new modules.AmbushDamageView(createContext(), locator);
const info = view.getDamageWithoutCheck();
expect(info?.damage).toBe(0);
expect(info?.type).toBe(modules.MapDamageType.Unknown);
expect(info?.extra.catch.has(locator)).toBe(true);
expect(info?.extra.repulse.size).toBe(0);
});
});
describe('MainMapDamageConverter', () => {
// 验证转换器按领域、夹击、阻击、激光、捕捉的顺序为每个特殊属性各建一个视图
it('builds one view per present special in a stable order', () => {
const specials = new Map<number, unknown>([
[15, { zone: 3, zoneSquare: false, range: 1 }],
[16, undefined],
[18, 4],
[24, 9],
[27, undefined]
]);
const enemy = createEnemy(specials);
const faceCalls: number[] = [];
const face = createFace([[1, { x: 1, y: 0 }]]);
const handler = {
enemy,
locator: { x: 2, y: 3 },
hero: createHero(),
state: {
faceManager: {
get: (group: number) => {
faceCalls.push(group);
return face;
}
}
}
} as never;
const converter = new modules.MainMapDamageConverter();
const views = converter.convert(handler, createContext());
expect(views).toHaveLength(5);
expect(views[0]).toBeInstanceOf(modules.ZoneDamageView);
expect(views[1]).toBeInstanceOf(modules.BetweenDamageView);
expect(views[2]).toBeInstanceOf(modules.RepulseDamageView);
expect(views[3]).toBeInstanceOf(modules.LaserDamageView);
expect(views[4]).toBeInstanceOf(modules.AmbushDamageView);
expect(faceCalls).toEqual([modules.FaceGroup.Dir4]);
});
// 验证没有相关特殊属性时不产生任何视图
it('builds no view when no map damage special is present', () => {
const handler = {
enemy: createEnemy(),
locator: { x: 2, y: 3 },
hero: createHero(),
state: { faceManager: { get: () => null } }
} as never;
const converter = new modules.MainMapDamageConverter();
expect(converter.convert(handler, createContext())).toEqual([]);
});
});
describe('MainMapDamageReducer', () => {
// 验证合并器求和、取最大伤害项的类型并合并捕捉与阻击定位符
it('sums damage, picks the max type and unions the extras', () => {
const repulse: ITileLocator = { x: 1, y: 0 };
const caught: ITileLocator = { x: 0, y: 1 };
const reducer = new modules.MainMapDamageReducer();
const info = reducer.reduce([
createInfo(3, modules.MapDamageType.Zone),
createInfo(5, modules.MapDamageType.Repulse, {
repulse: new Set([repulse])
}),
createInfo(5, modules.MapDamageType.Between, {
catch: new Set([caught])
})
]);
expect(info.damage).toBe(13);
expect(info.type).toBe(modules.MapDamageType.Repulse);
expect([...info.extra.repulse]).toEqual([repulse]);
expect([...info.extra.catch]).toEqual([caught]);
});
// 验证空输入返回 0 点未知伤害与空的额外信息
it('returns zero unknown damage for an empty input', () => {
const reducer = new modules.MainMapDamageReducer();
const info = reducer.reduce([]);
expect(info.damage).toBe(0);
expect(info.type).toBe(modules.MapDamageType.Unknown);
expect(info.extra.catch.size).toBe(0);
expect(info.extra.repulse.size).toBe(0);
});
});

View File

@ -1,170 +0,0 @@
// 测试怪物特殊属性注册:代码 0-27 全量注册、守卫默认值、名称与描述生成
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { type ISpecial } from '@user/data-base';
import { type ISpecial23Value } from '../special';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
Map.prototype.getOrInsertComputed ??= function <K, V>(
this: Map<K, V>,
key: K,
callback: (key: K) => V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
const value = callback(key);
this.set(key, value);
return value;
};
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,
key: K,
defaultValue: V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
this.set(key, defaultValue);
return defaultValue;
};
});
interface TestModules {
registerSpecials: typeof import('../special').createSpecials;
}
let modules: TestModules;
beforeAll(async () => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
const specialModule = await import('../special');
modules = { registerSpecials: specialModule.createSpecials };
});
type SpecialCreation = (enemy: unknown) => ISpecial<any>;
interface FakeEnemyManager {
/** 记录注册的属性默认值 */
defaults: Map<string, unknown>;
/** 记录注册的代码与创建函数 */
specials: Map<number, SpecialCreation>;
/** 记录每个代码的注册次数 */
registerCounts: Map<number, number>;
/** 注册一个特殊属性 */
registerSpecial(code: number, cons: SpecialCreation): void;
/** 注册一个属性默认值 */
setAttributeDefaults(name: string, value: unknown): void;
}
/**
* 创建一个只实现注册接口的内联假怪物管理器
*/
function createManager(): FakeEnemyManager {
const defaults = new Map<string, unknown>();
const specials = new Map<number, SpecialCreation>();
const registerCounts = new Map<number, number>();
return {
defaults,
specials,
registerCounts,
registerSpecial: (code, cons) => {
specials.set(code, cons);
registerCounts.set(code, (registerCounts.get(code) ?? 0) + 1);
},
setAttributeDefaults: (name, value) => {
defaults.set(name, value);
}
};
}
/**
* 取出注册表中的一个特殊属性创建函数并实例化
* @param manager 假怪物管理器
* @param code 特殊属性代码
*/
function createSpecial(manager: FakeEnemyManager, code: number): ISpecial<any> {
const creation = manager.specials.get(code);
if (!creation) throw new Error(`special ${code} was not registered`);
return creation(undefined);
}
describe('registerSpecials', () => {
// 验证代码 0 到 27 每个都被恰好注册一次
it('registers every special code from 0 to 27 exactly once', () => {
const manager = createManager();
modules.registerSpecials(manager as never);
const codes = [...manager.specials.keys()].sort((a, b) => a - b);
const counts = [...manager.registerCounts.values()];
expect(codes).toEqual([...Array(28).keys()]);
expect(counts).toEqual(Array(28).fill(1));
});
// 验证注册时写入守卫属性默认值为空集合
it('registers an empty set default for the guard attribute', () => {
const manager = createManager();
modules.registerSpecials(manager as never);
expect(manager.defaults.has('guard')).toBe(true);
expect(manager.defaults.get('guard')).toBeInstanceOf(Set);
expect((manager.defaults.get('guard') as Set<unknown>).size).toBe(0);
});
// 验证每个注册的特殊属性都提供名称与描述方法
it('provides a name and description accessor for every special', () => {
const manager = createManager();
modules.registerSpecials(manager as never);
for (const code of manager.specials.keys()) {
const special = createSpecial(manager, code);
expect(typeof special.getSpecialName).toBe('function');
expect(typeof special.getDescription).toBe('function');
}
});
// 验证可序列化特殊属性按当前数值生成名称与描述
it('builds the name and description of a serializable special', () => {
const manager = createManager();
modules.registerSpecials(manager as never);
const combo = createSpecial(manager, 6);
expect(combo.getSpecialName()).toBe('4连击');
combo.setValue(5);
expect(combo.getSpecialName()).toBe('5连击');
expect(combo.getDescription()).toBe('怪物每回合攻击5次。');
});
// 验证光环特殊属性按范围与加成生成描述
it('builds the description of the halo special from its value', () => {
const manager = createManager();
modules.registerSpecials(manager as never);
const halo = createSpecial(manager, 25);
expect(halo.getSpecialName()).toBe('光环');
expect(halo.getDescription()).toContain('同楼层所有怪物');
expect(halo.getDescription()).toContain('线性叠加。');
const value: ISpecial23Value = {
range: 2,
square: true,
hpBuff: 10,
atkBuff: 0,
defBuff: 0
};
halo.setValue(value);
expect(halo.getDescription()).toContain('九宫格2格范围内所有怪物');
expect(halo.getDescription()).toContain('生命提升10%');
});
// 验证无属性特殊属性提供固定名称与描述
it('provides a fixed name and description for a none-property special', () => {
const manager = createManager();
modules.registerSpecials(manager as never);
const firstStrike = createSpecial(manager, 1);
expect(firstStrike.getSpecialName()).toBe('先攻');
expect(firstStrike.getDescription()).toBe('怪物首先攻击。');
});
});

View File

@ -1,29 +0,0 @@
export interface IEnemyAttr {
/** 怪物生命值 */
hp: number;
/** 怪物攻击力 */
atk: number;
/** 怪物防御力 */
def: number;
/** 怪物金币 */
money: number;
/** 怪物经验值 */
exp: number;
/** 怪物加点量 */
point: number;
/** 支援来源怪物坐标索引列表 */
guard: Set<number>;
}
export const enum MapDamageType {
/** 未知伤害 */
Unknown,
/** 领域伤害 */
Zone,
/** 激光伤害 */
Layer,
/** 阻击伤害 */
Repulse,
/** 夹击伤害 */
Between
}

View File

@ -1,390 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { WebLoadStarter } from '@motajs/loader';
import { logger } from '@motajs/common';
import { Statement, StatementType } from '@motajs/anon-tokyo';
import {
FaceDirection,
IGameEvent,
ObjectMoveType,
EventTrigger,
TileType
} from '@user/data-common';
import { BlockEventType, IBlockEventEnv } from '@user/data-system';
import { CoreState } from '../../core';
import {
createEventRegistrations,
EventRemoveBlock,
EventInsertEvent,
EventInsertEvents,
EventMoveBlock,
EventMoveHero,
EventStepHero,
EventSetBlock
} from '../index';
import { EventSetBlock as MapEventSetBlock } from '../map';
import { EventInsertEvent as ControlEventInsertEvent } from '../event';
import * as dataStateRoot from '../../index';
vi.hoisted(() => {
Map.prototype.getOrInsertComputed ??= function <K, V>(
this: Map<K, V>,
key: K,
callback: (key: K) => V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
const value = callback(key);
this.set(key, value);
return value;
};
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,
key: K,
defaultValue: V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
this.set(key, defaultValue);
return defaultValue;
};
});
interface EventFixture {
readonly state: CoreState;
readonly map: ReturnType<CoreState['maps']['createMap']>;
readonly layer: ReturnType<
ReturnType<CoreState['maps']['createMap']>['addLayer']
>;
readonly env: IBlockEventEnv;
}
type RegisteredBuiltin = ReturnType<typeof createEventRegistrations>[number];
function invokeBuiltin<TParam>(
registration: RegisteredBuiltin,
param: TParam,
env: IBlockEventEnv
): Promise<void> {
return Promise.resolve(registration.func(param, env));
}
function getRegistration(name: string): RegisteredBuiltin {
const registration = createEventRegistrations().find(
item => item.name === name
);
if (!registration) throw new Error(`${name} registration missing`);
return registration;
}
function createFixture(): EventFixture {
const state = new CoreState({
loadStarter: new WebLoadStarter(),
coreURL: 'placeholder'
});
state.tileStore.addTile({
num: 1,
id: 'floor',
events: {},
type: TileType.Terrain,
pass: { onlyEvents: false, outPass: 15, inPass: 15 },
eventPass: true
});
state.tileStore.addTile({
num: 2,
id: 'block',
events: {},
type: TileType.Terrain,
pass: { onlyEvents: false, outPass: 15, inPass: 15 },
eventPass: true
});
const map = state.maps.createMap('F1', 4, 1);
const layer = map.addLayer('event');
layer.setMapRef(new Uint32Array([1, 1, 1, 1]));
map.setEventLayer(layer);
state.hero.location.setFloor(map);
state.hero.location.setPos(0, 0);
state.hero.location.mover.setFaceDir(FaceDirection.Right);
const env: IBlockEventEnv = {
state,
type: BlockEventType.CommonEvent,
trigger: EventTrigger.None,
system: state.eventSystem,
heroLocator: state.hero.getLocation(),
heroFloor: 'F1',
triggerLocator: null,
tile: null,
layer,
map
};
return { state, map, layer, env };
}
function createEvent(
state: CoreState,
trigger: EventTrigger,
execute: (env: IBlockEventEnv) => Promise<void>
): IGameEvent<Record<string, never>, IBlockEventEnv, void> {
return {
interpreter: state.eventSystem.executor.interpreter,
trigger,
rawEvent: [],
compiled: null,
compile: () => null,
execute: async (_param, env) => execute(env),
setTrigger: () => {},
setRaw: () => {}
};
}
describe('event built-ins', () => {
// 验证真实注册项设置图块并安全跳过无效图块
it('sets a block and safely skips an unknown tile', async () => {
const fixture = createFixture();
const registration = getRegistration('setBlock');
await invokeBuiltin(
registration,
{ x: 1, y: 0, tile: 'block' },
fixture.env
);
expect(fixture.layer.getBlock(1, 0)).toBe(2);
await invokeBuiltin(
registration,
{ x: 1, y: 0, tile: 'missing' },
fixture.env
);
expect(fixture.layer.getBlock(1, 0)).toBe(2);
});
// 验证 setBlock 遇到未注册图块时告警码 1001 并跳过
it('warns code 1001 when setBlock receives an unknown tile', async () => {
const fixture = createFixture();
const warn = vi.spyOn(logger, 'warn');
await invokeBuiltin(
getRegistration('setBlock'),
{ x: 1, y: 0, tile: 'missing' },
fixture.env
);
expect(warn).toHaveBeenCalledWith(1001);
});
// 验证真实注册项移动动态图块并按 safe 分支还原
it('moves a dynamic block and respects safe static transfer', async () => {
const fixture = createFixture();
await invokeBuiltin(
getRegistration('moveBlock'),
{
x: 1,
y: 0,
steps: [
{ type: ObjectMoveType.Teleport, x: 2, y: 0, rel: false }
],
safe: true
},
fixture.env
);
expect([...fixture.layer.getDynamicTilesAt(2, 0)]).toHaveLength(1);
expect(fixture.layer.getBlock(2, 0)).toBe(1);
});
// 验证真实注册项移除静态图块,并可按 dynamic 选项一并移除动态图块
it('removes static blocks and optionally dynamic blocks', async () => {
const fixture = createFixture();
fixture.layer.setBlock(2, 1, 0);
await invokeBuiltin(
getRegistration('removeBlock'),
{ x: 1, y: 0 },
fixture.env
);
expect(fixture.layer.getBlock(1, 0)).toBe(0);
fixture.layer.setBlock(1, 1, 0);
fixture.layer.transferToDynamic(1, 0);
await invokeBuiltin(
getRegistration('removeBlock'),
{ x: 1, y: 0, dynamic: true },
fixture.env
);
expect([...fixture.layer.getDynamicTilesAt(1, 0)]).toHaveLength(0);
expect(fixture.layer.getBlock(1, 0)).toBe(0);
});
// 验证真实注册项的勇士移动序列和向前一步都等待移动结束
it('awaits hero sequence and forward-step movement', async () => {
const fixture = createFixture();
await invokeBuiltin(
getRegistration('moveHero'),
{
steps: [{ type: ObjectMoveType.Dir, move: FaceDirection.Right }]
},
fixture.env
);
expect(fixture.state.hero.location.x).toBe(1);
await invokeBuiltin(getRegistration('stepHero'), {}, fixture.env);
expect(fixture.state.hero.location.x).toBe(2);
});
// 验证真实注册项按顺序等待临时事件序列并直接执行语句体
it('awaits id sequences and executes a direct statement body', async () => {
const fixture = createFixture();
const calls: string[] = [];
fixture.state.eventStore.addEvent(
'first',
createEvent(fixture.state, EventTrigger.None, async () => {
await Promise.resolve();
calls.push('first');
})
);
fixture.state.eventStore.addEvent(
'second',
createEvent(fixture.state, EventTrigger.None, async () => {
calls.push('second');
})
);
await invokeBuiltin(
getRegistration('insertEvents'),
{ ids: ['first', 'second', 'missing'] },
fixture.env
);
const body: Statement[] = [
{
type: StatementType.Call,
functionName: 'setBlock',
builtIn: true,
async: true,
parameters: { x: 3, y: 0, tile: 2 }
}
];
await invokeBuiltin(getRegistration('insertEvent'), body, fixture.env);
expect(calls).toEqual(['first', 'second']);
expect(fixture.layer.getBlock(3, 0)).toBe(2);
expect(fixture.state.eventStore.getEvent('inline-body')).toBeNull();
});
// 验证默认注册项只包含批准的七个稳定名称
it('registers exactly the approved built-ins in AnonTokyo', () => {
const fixture = createFixture();
const names = createEventRegistrations().map(item => item.name);
expect(names).toEqual([
'setBlock',
'moveBlock',
'removeBlock',
'moveHero',
'stepHero',
'insertEvents',
'insertEvent'
]);
for (const name of names) {
expect(
fixture.state.eventSystem.executor.interpreter.getBuiltInFunction(
name
)
).toMatchObject({
name,
func: expect.any(Function)
});
}
});
// 验证真实注册项在缺失地图和事件 id 时都安全返回
it('safely skips missing targets and event ids', async () => {
const fixture = createFixture();
const missingEnv: IBlockEventEnv = {
...fixture.env,
map: null,
layer: null,
heroFloor: 'missing'
};
await expect(
invokeBuiltin(
getRegistration('moveBlock'),
{ x: 0, y: 0, steps: [] },
missingEnv
)
).resolves.toBeUndefined();
await expect(
invokeBuiltin(
getRegistration('removeBlock'),
{ x: 0, y: 0 },
missingEnv
)
).resolves.toBeUndefined();
await expect(
invokeBuiltin(getRegistration('insertEvent'), [], fixture.env)
).resolves.toBeUndefined();
});
});
describe('event registration ownership', () => {
// 验证单一注册函数按地图勇士控制三类顺序返回全部七个新鲜实例
it('returns the seven fresh registrations in stable category order', () => {
const registrations = createEventRegistrations();
expect(registrations).toHaveLength(7);
expect(registrations.map(item => item.name)).toEqual([
'setBlock',
'moveBlock',
'removeBlock',
'moveHero',
'stepHero',
'insertEvents',
'insertEvent'
]);
expect(registrations[0]).toBeInstanceOf(EventSetBlock);
expect(registrations[1]).toBeInstanceOf(EventMoveBlock);
expect(registrations[2]).toBeInstanceOf(EventRemoveBlock);
expect(registrations[3]).toBeInstanceOf(EventMoveHero);
expect(registrations[4]).toBeInstanceOf(EventStepHero);
expect(registrations[5]).toBeInstanceOf(EventInsertEvents);
expect(registrations[6]).toBeInstanceOf(EventInsertEvent);
expect(createEventRegistrations()).not.toBe(registrations);
expect(createEventRegistrations()[0]).not.toBe(registrations[0]);
});
// 验证每个注册实例直接拥有自己的稳定名称与可调用函数
it('owns a stable name and callable function on every instance', () => {
for (const registration of createEventRegistrations()) {
expect(typeof registration.name).toBe('string');
expect(registration.name.length).toBeGreaterThan(0);
expect(typeof registration.func).toBe('function');
}
});
// 验证注册类分别来自地图勇士事件模块且勇士面前注册类归 hero 所有
it('originates registration classes from their owning event modules', () => {
expect(EventSetBlock).toBe(MapEventSetBlock);
expect(EventInsertEvent).toBe(ControlEventInsertEvent);
});
// 验证事件 barrel 仅含导出语句且既有公开事件符号仍可从根索引导入
it('keeps both barrels export-only and preserves public event symbols', () => {
const eventBarrel = readFileSync(
new URL('../index.ts', import.meta.url),
'utf8'
);
const rootBarrel = readFileSync(
new URL('../../index.ts', import.meta.url),
'utf8'
);
expect(eventBarrel).not.toMatch(/^\s*import\s/m);
expect(rootBarrel).not.toMatch(/^\s*import\s/m);
expect(eventBarrel).not.toMatch(
/^\s*(?:export\s+)?(?:async\s+)?function\s/m
);
const reachableNames = [
'createEventRegistrations',
'EventSetBlock',
'EventMoveBlock',
'EventRemoveBlock',
'EventMoveHero',
'EventStepHero',
'EventInsertEvents',
'EventInsertEvent'
];
const rootKeys = Object.keys(dataStateRoot);
for (const name of reachableNames) {
expect(rootKeys).toContain(name);
}
});
});

View File

@ -1,5 +0,0 @@
//#region 勇士属性
export interface IHeroAttr {}
//#endregion

View File

@ -1,11 +1,7 @@
export * from './enemy';
export * from './event';
export * from './hero';
export * from './loader';
export * from './replay';
export * from './common';
export * from './core';
export * from './ins';
export * from './shared';
export * from './types';
export * from './plugin';

View File

@ -1,14 +0,0 @@
import { WebLoadStarter } from '@motajs/loader';
import { CoreState } from './core';
// TODO: 逐渐弱化 CoreState 的单例概念,每个接口都通过参数传入 ICoreState 对象
/**
* 数据端核心状态,目前处于过渡阶段,仅服务于渲染,不负责任何逻辑计算,会在后续把核心逻辑逐渐移动至此对象。
* 此对象是数据端状态,本身不负责任何渲染操作,仅会向渲染端发送数据让渲染端渲染,不要把渲染操作直接放到此对象上,
* 否则可能导致录像验证失败。
*/
export const state = new CoreState({
loadStarter: new WebLoadStarter(),
coreURL: 'placeholder'
});

View File

@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import { ILoadTask, LoadDataType } from '@motajs/loader';
import { logger } from '@motajs/common';
import { PrefixedJSONCProcessor } from '../jsoncProcessor';
import { DefaultDataLoaderHook } from '../hook';
import { DefaultDataLoaderHook } from '../../../../../src/data/loader/hook';
import { MotaDataLoader } from '../loader';
/** 与生产 loader 一致的 jsonc 前缀分隔串 */

View File

@ -1,4 +1,3 @@
export * from './hook';
export * from './jsoncProcessor';
export * from './loader';
export * from './types';

View File

@ -0,0 +1,123 @@
//#region 插件排序接口
export interface IPluginDepends {
/** 插件标识符 */
readonly id: string;
/** 插件所依赖的其他插件 id */
readonly dependencies: readonly string[];
}
export interface IPluginHolder<T extends IPluginDepends> {
/** 插件内容 */
readonly plugin: T;
}
export interface IPluginOrderResult<T> {
/** 可初始化的插件拓扑序 */
readonly order: T[];
/** 处于依赖环中的插件 id */
readonly cyclic: Set<string>;
/** 直接引用了不存在依赖的插件 id,与其缺失的依赖 id 列表 */
readonly missing: Map<string, string[]>;
}
//#endregion
//#region 依赖排序
/**
* 依据插件的依赖关系计算可初始化的拓扑序,并检测依赖环与不存在的依赖
* @param plugins 以插件 id 为键的插件映射表
*/
export function orderPlugins<H extends IPluginHolder<IPluginDepends>>(
plugins: ReadonlyMap<string, H>
): IPluginOrderResult<H> {
const cyclic = new Set<string>();
const missing = new Map<string, string[]>();
// 依赖不存在检测:记录直接引用了不存在依赖的插件及其缺失的依赖
for (const holder of plugins.values()) {
const define = holder.plugin;
const lacked: string[] = [];
for (const dependency of define.dependencies) {
if (!plugins.has(dependency)) {
lacked.push(dependency);
}
}
if (lacked.length > 0) {
missing.set(define.id, lacked);
}
}
// 环检测:从每个插件出发沿依赖边搜索,若能回到自身则其处于依赖环中
for (const id of plugins.keys()) {
const start = plugins.get(id)!;
const visited = new Set<string>();
const pending: string[] = [];
for (const dependency of start.plugin.dependencies) {
pending.push(dependency);
}
while (pending.length > 0) {
const current = pending.pop()!;
if (current === id) {
cyclic.add(id);
break;
}
if (visited.has(current)) continue;
visited.add(current);
const node = plugins.get(current);
if (!node) continue;
for (const dependency of node.plugin.dependencies) {
pending.push(dependency);
}
}
}
// 被排除集合:环上插件、依赖不存在的插件,以及依赖它们的下游插件
const blocked = new Set<string>();
for (const id of cyclic) blocked.add(id);
for (const id of missing.keys()) blocked.add(id);
let expanded = true;
while (expanded) {
expanded = false;
for (const holder of plugins.values()) {
const define = holder.plugin;
if (blocked.has(define.id)) continue;
for (const dependency of define.dependencies) {
if (blocked.has(dependency)) {
blocked.add(define.id);
expanded = true;
break;
}
}
}
}
// 拓扑排序:反复扫描,逐轮输出依赖均已就绪的插件,保证依赖先于被依赖者
const order: H[] = [];
const emitted = new Set<string>();
let progressed = true;
while (progressed) {
progressed = false;
for (const holder of plugins.values()) {
const define = holder.plugin;
if (blocked.has(define.id) || emitted.has(define.id)) continue;
let ready = true;
for (const dependency of define.dependencies) {
if (!emitted.has(dependency)) {
ready = false;
break;
}
}
if (!ready) continue;
order.push(holder);
emitted.add(define.id);
progressed = true;
}
}
return { order, cyclic, missing };
}
//#endregion

View File

@ -1,443 +0,0 @@
import { logger } from '@motajs/common';
import {
FaceDirection,
IReplayStepHandler,
IMoverController,
beginReplaySafetyCollection,
endReplaySafetyCollection,
logReplaySafetyDetail,
shouldReplay,
ReplayCode,
ReplayCommandResult
} from '@user/data-common';
import { readFileSync } from 'node:fs';
import { describe, expect, it, vi } from 'vitest';
import { WebLoadStarter } from '@motajs/loader';
import { CoreState } from '../../core';
import { ReplaySystem } from '../../../../data-common/src/replay/system';
import {
ReplayEquip,
ReplayMove,
ReplayTeleport,
ReplayUnequip,
ReplayUseItem
} from '../commands';
/** 供测试读取的稳定指令码顺序 */
export const REPLAY_COMMAND_ORDER: readonly ReplayCode[] = [
ReplayCode.Up,
ReplayCode.Right,
ReplayCode.Down,
ReplayCode.Left,
ReplayCode.Teleport,
ReplayCode.UseItem,
ReplayCode.Equip,
ReplayCode.Unequip
];
function createCoreState(): CoreState {
return new CoreState({
loadStarter: new WebLoadStarter(),
coreURL: 'placeholder'
});
}
function step(
command: number,
params: IReplayStepHandler['params']
): IReplayStepHandler {
return { command, params, index: 0 };
}
function controller(onEnd: Promise<void>): Readonly<IMoverController> {
return {
done: false,
onEnd,
push: () => {},
insert: () => {},
stop: () => onEnd
};
}
describe('replay commands', () => {
// 验证每个 CoreState 都按稳定顺序装配八个指令
it('registers the eight stable commands in order on every CoreState', () => {
const state = createCoreState();
expect(REPLAY_COMMAND_ORDER).toEqual([
ReplayCode.Up,
ReplayCode.Right,
ReplayCode.Down,
ReplayCode.Left,
ReplayCode.Teleport,
ReplayCode.UseItem,
ReplayCode.Equip,
ReplayCode.Unequip
]);
expect(
REPLAY_COMMAND_ORDER.every(code =>
state.replaySystem.getCommand(code)
)
).toBe(true);
});
// 验证每个 CoreState 独立装配,且四向共用一个参数化移动类
it('assembles an independent command set per CoreState', () => {
const first = createCoreState();
const second = createCoreState();
const firstCommands = REPLAY_COMMAND_ORDER.map(
code => first.replaySystem.getCommand(code)!
);
const secondCommands = REPLAY_COMMAND_ORDER.map(
code => second.replaySystem.getCommand(code)!
);
expect(new Set(firstCommands).size).toBe(8);
expect(new Set(secondCommands).size).toBe(8);
for (let index = 0; index < firstCommands.length; index++) {
expect(firstCommands[index]).not.toBe(secondCommands[index]);
}
expect(firstCommands.map(command => command.constructor.name)).toEqual([
'ReplayMove',
'ReplayMove',
'ReplayMove',
'ReplayMove',
'ReplayTeleport',
'ReplayUseItem',
'ReplayEquip',
'ReplayUnequip'
]);
});
// 验证移动指令只加入方向步,由 notExecuted 统一启动并等待
it('steps direction without starting and finalizes through notExecuted', async () => {
const state = createCoreState();
const mover = state.hero.location.mover;
const move = vi.spyOn(mover, 'step');
const first = Promise.withResolvers<void>();
const start = vi
.spyOn(mover, 'start')
.mockReturnValueOnce(controller(first.promise));
const command = new ReplayMove(state, FaceDirection.Right);
await expect(command.execute(step(ReplayCode.Right, []))).resolves.toBe(
ReplayCommandResult.Success
);
expect(move).toHaveBeenCalledWith(FaceDirection.Right);
expect(start).not.toHaveBeenCalled();
let result: ReplayCommandResult | undefined;
const pending = command.finalize().then(value => {
result = value;
});
await Promise.resolve();
expect(start).toHaveBeenCalledTimes(1);
expect(result).toBeUndefined();
first.resolve();
await pending;
expect(result).toBe(ReplayCommandResult.Success);
});
// 验证移动已在进行中或无法启动时返回 false 并记录错误码
it('rejects a move while moving and a missing controller', async () => {
const state = createCoreState();
const error = vi.spyOn(logger, 'error');
const mover = state.hero.location.mover;
(mover as unknown as { moving: boolean }).moving = true;
const command = new ReplayMove(state, FaceDirection.Up);
await expect(command.execute(step(ReplayCode.Up, []))).resolves.toBe(
ReplayCommandResult.Failed
);
expect(error).toHaveBeenCalledWith(2003);
(mover as unknown as { moving: boolean }).moving = false;
vi.spyOn(mover, 'start').mockReturnValueOnce(null);
await expect(command.finalize()).resolves.toBe(
ReplayCommandResult.Failed
);
expect(error).toHaveBeenCalledWith(2004);
error.mockRestore();
});
// 验证瞬移等待寻路控制器完成,无路径时返回 false
it('teleports and awaits the pathfinding controller', async () => {
const state = createCoreState();
const first = Promise.withResolvers<void>();
const teleport = vi
.spyOn(state.pathfinding, 'teleportTo')
.mockReturnValueOnce({
controller: controller(first.promise),
path: []
});
const command = new ReplayTeleport(state);
let result: ReplayCommandResult | undefined;
const pending = command
.execute(step(ReplayCode.Teleport, [2, 3]))
.then(value => {
result = value;
});
await Promise.resolve();
expect(teleport).toHaveBeenCalledWith({ x: 2, y: 3 });
expect(result).toBeUndefined();
first.resolve();
await pending;
expect(result).toBe(ReplayCommandResult.Success);
const error = vi.spyOn(logger, 'error');
teleport.mockReturnValueOnce(null);
await expect(
command.execute(step(ReplayCode.Teleport, [4, 5]))
).resolves.toBe(ReplayCommandResult.Failed);
expect(error).toHaveBeenCalledWith(2005, '4', '5');
error.mockRestore();
});
// 验证道具使用直接返回既有状态接口结果,失败时记录错误码
it('returns the hero item-use result', async () => {
const state = createCoreState();
const error = vi.spyOn(logger, 'error');
const useItem = vi
.spyOn(state.hero.items, 'useItem')
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
const command = new ReplayUseItem(state);
await expect(
command.execute(step(ReplayCode.UseItem, [12]))
).resolves.toBe(ReplayCommandResult.Success);
await expect(
command.execute(step(ReplayCode.UseItem, [34]))
).resolves.toBe(ReplayCommandResult.Failed);
expect(useItem).toHaveBeenNthCalledWith(1, 12);
expect(error).toHaveBeenCalledWith(2006, '34');
error.mockRestore();
});
// 验证装备指令复用既有装备边界并区分三种失败位置
// 验证装备指令复用既有装备边界并以最终槽位校验结果
it('equips through the existing equipment boundary', async () => {
const state = createCoreState();
const equipment = state.hero.equip;
const error = vi.spyOn(logger, 'error');
const getEquipped = vi.spyOn(equipment, 'getEquipped');
const equip = vi
.spyOn(equipment, 'equip')
.mockImplementation(() => undefined);
const command = new ReplayEquip(state);
// 装备后槽位为指定 uid
getEquipped.mockReturnValueOnce(99);
await expect(
command.execute(step(ReplayCode.Equip, [99, 0, true]))
).resolves.toBe(ReplayCommandResult.Success);
expect(equip).toHaveBeenCalledWith(99, 0, true);
// 装备后槽位未变为指定 uid
getEquipped.mockReturnValueOnce(undefined);
await expect(
command.execute(step(ReplayCode.Equip, [99, 1, false]))
).resolves.toBe(ReplayCommandResult.Failed);
expect(error).toHaveBeenCalledWith(2007, '99', '1');
error.mockRestore();
});
// 验证卸下指令复用既有装备边界并以最终槽位校验结果
it('unequips through the existing equipment boundary', async () => {
const state = createCoreState();
const equipment = state.hero.equip;
const error = vi.spyOn(logger, 'error');
const getEquipped = vi.spyOn(equipment, 'getEquipped');
const unequip = vi
.spyOn(equipment, 'unequip')
.mockImplementation(() => undefined);
const command = new ReplayUnequip(state);
// 卸下后槽位为空
getEquipped.mockReturnValueOnce(undefined);
await expect(
command.execute(step(ReplayCode.Unequip, [0]))
).resolves.toBe(ReplayCommandResult.Success);
expect(unequip).toHaveBeenCalledWith(0);
// 卸下后槽位仍有装备
getEquipped.mockReturnValueOnce(88);
await expect(
command.execute(step(ReplayCode.Unequip, [1]))
).resolves.toBe(ReplayCommandResult.Failed);
expect(error).toHaveBeenCalledWith(2008, '1');
error.mockRestore();
});
// 验证沙箱在下一步指令前先结束上一步的移动批次
it('finalizes the previous move before executing a different command', async () => {
const state = createCoreState();
const mover = state.hero.location.mover;
const move = vi.spyOn(mover, 'step');
const first = Promise.withResolvers<void>();
const start = vi
.spyOn(mover, 'start')
.mockReturnValueOnce(controller(first.promise));
const useItem = vi
.spyOn(state.hero.items, 'useItem')
.mockReturnValueOnce(true);
const replay = state.replaySystem;
replay.record(ReplayCode.Right);
replay.record(ReplayCode.UseItem, 5);
const sandbox = replay.createReplaySandbox({
route: replay.array,
reseter: { reset: () => {} }
});
await expect(sandbox.step()).resolves.toBe(true);
expect(move).toHaveBeenCalledWith(FaceDirection.Right);
expect(start).not.toHaveBeenCalled();
const next = sandbox.step();
await Promise.resolve();
expect(start).toHaveBeenCalledTimes(1);
expect(useItem).not.toHaveBeenCalled();
first.resolve();
await expect(next).resolves.toBe(true);
expect(useItem).toHaveBeenCalledWith(5);
});
// 验证参数数量或类型不符的指令以 false 结束
it('returns false for invalid command parameters', async () => {
const state = createCoreState();
const move = new ReplayMove(state, FaceDirection.Up);
const teleport = new ReplayTeleport(state);
const useItem = new ReplayUseItem(state);
const equip = new ReplayEquip(state);
const unequip = new ReplayUnequip(state);
const invalid = [
move.execute(step(ReplayCode.Up, [1])),
teleport.execute(step(ReplayCode.Teleport, ['x', 1])),
teleport.execute(step(ReplayCode.Teleport, [1])),
useItem.execute(step(ReplayCode.UseItem, [])),
useItem.execute(step(ReplayCode.UseItem, ['id'])),
equip.execute(step(ReplayCode.Equip, [1, 0])),
equip.execute(step(ReplayCode.Equip, [1, 0, 'x'])),
unequip.execute(step(ReplayCode.Unequip, ['slot']))
];
await expect(Promise.all(invalid)).resolves.toEqual([
ReplayCommandResult.Failed,
ReplayCommandResult.Failed,
ReplayCommandResult.Failed,
ReplayCommandResult.Failed,
ReplayCommandResult.Failed,
ReplayCommandResult.Failed,
ReplayCommandResult.Failed,
ReplayCommandResult.Failed
]);
});
// 验证注册直接写在 CoreState 内,且指令模块自包含、不含 replay safety
it('keeps registration direct and the command module self-contained', () => {
const commands = readFileSync(
new URL('../commands.ts', import.meta.url),
'utf8'
);
expect(commands).not.toContain('createReplayCommandItems');
expect(commands).not.toContain('registerReplayCommandItems');
expect(commands).not.toContain('prettier-ignore');
expect(commands).not.toContain('function isNumber');
expect(commands).not.toContain('function resolveSlot');
expect(commands).not.toContain('shouldReplay');
const core = readFileSync(
new URL('../../core.ts', import.meta.url),
'utf8'
);
expect(core).not.toContain('createReplayCommandItems');
expect(core).not.toContain('registerReplayCommandItems');
expect(core).toContain('private registerReplayCommands()');
expect((core.match(/new ReplayMove\(this,/g) ?? []).length).toBe(4);
expect(core).toContain('new ReplayTeleport(this)');
expect(core).toContain('new ReplayUseItem(this)');
expect(core).toContain('new ReplayEquip(this)');
expect(core).toContain('new ReplayUnequip(this)');
});
});
describe('replay safety decorators', () => {
// 验证同步 decorator 在方法返回后恢复嵌套 collection 上下文
it('restores collection context after synchronous nested actions', () => {
interface DecoratedFixture {
inner(): void;
outer(): void;
}
const inner = shouldReplay('inner')(
function (this: DecoratedFixture): void {},
{ name: 'inner' } as ClassMethodDecoratorContext<
DecoratedFixture,
(this: DecoratedFixture) => void
>
);
const outer = shouldReplay('outer')(
function (this: DecoratedFixture): void {
this.inner();
},
{ name: 'outer' } as ClassMethodDecoratorContext<
DecoratedFixture,
(this: DecoratedFixture) => void
>
);
const fixture: DecoratedFixture = {
inner,
outer
};
const replay = new ReplaySystem();
const warning = vi.spyOn(logger, 'warn');
const group = vi.spyOn(console, 'group').mockImplementation(() => {});
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
beginReplaySafetyCollection(replay);
try {
fixture.outer();
fixture.inner();
endReplaySafetyCollection();
const detail = warning.mock.calls.find(call => call[0] === 161);
expect(detail).toBeDefined();
const command = String(detail![1]);
const match = command.match(/\((\d+)\)/);
expect(match).not.toBeNull();
logReplaySafetyDetail(Number(match![1]));
expect(group).toHaveBeenCalled();
expect(
output.mock.calls.filter(call =>
String(call[0]).startsWith('inner:')
)
).toHaveLength(2);
} finally {
warning.mockRestore();
group.mockRestore();
output.mockRestore();
}
});
// 验证同步 collection 在结束后可重新开始且不会残留旧上下文
it('resets the collection lifecycle after completion', () => {
const replay = new ReplaySystem();
const warning = vi.spyOn(logger, 'warn');
beginReplaySafetyCollection(replay);
endReplaySafetyCollection();
beginReplaySafetyCollection(replay);
endReplaySafetyCollection();
expect(warning).not.toHaveBeenCalledWith(159);
warning.mockRestore();
});
// 验证用户拥有的勇士属性变更方法带有 replay decorator 标注
it('annotates user-owned attribute mutations with replay decorators', () => {
const source = readFileSync(
new URL(
'../../../../data-base/src/hero/attribute.ts',
import.meta.url
),
'utf8'
);
expect(source).toContain('@shouldReplay');
});
});

View File

@ -1 +0,0 @@
export * from './commands';

View File

@ -3,6 +3,68 @@ import { ILoadManager, ILoadTaskStarter } from '@motajs/loader';
import { IStateSystem } from '@user/data-system';
import { ISaveableContent } from '@user/data-common';
import { IMotaDataLoader } from './loader/types';
import { BuiltInFunction } from '@motajs/anon-tokyo';
//#region 插件系统
export const enum PluginStatus {
/** 还未进行插件同步初始化 */
NotAwakened,
/** 已经进行了插件同步初始化,但尚未执行异步初始化 */
Awakened,
/** 已经进行了插件异步初始化,但尚未执行加载初始化 */
Initialized,
/** 插件已经完全加载完毕 */
Loaded
}
export interface IDataPlugin<T extends IDataPluginDefine> {
/** 插件的当前状态 */
status: PluginStatus;
/** 插件内容 */
plugin: T;
}
export interface IDataPluginDefine {
/** 插件标识符 */
readonly id: string;
/** 插件所依赖的其他插件 id。依赖插件不允许循环依赖,否则会抛出错误,且所有在环上的插件都不会初始化 */
readonly dependencies: string[];
/**
* 插件同步初始化,在数据端主对象创建完毕,初始化之前时调用
* @param state 数据端主对象
* @param dependencies 此插件的依赖插件对象
*/
awake(
state: ICoreState,
dependencies: IDataPlugin<IDataPluginDefine>[]
): void;
/**
* 插件异步初始化,在主对象创建完毕,初始化时,加载开始前调用。
* 此方法具有阻塞效果,如果返回值迟迟不兑现,那么会导致依赖其的插件不会开始异步初始化,游戏加载也不会开始
* @param state 数据端主对象
* @param dependencies 此插件的依赖插件对象
*/
initialize(
state: ICoreState,
dependencies: IDataPlugin<IDataPluginDefine>[]
): Promise<void>;
/**
* 当主对象加载完毕后调用,可以用于依赖加载内容的初始化。
* 此方法具有阻塞效果,如果返回值迟迟不兑现,那么会导致依赖其的插件不会开始加载初始化,游戏也不会开始
* @param state 数据端主对象
* @param dependencies 此插件的依赖插件对象
*/
afterLoad(
state: ICoreState,
dependencies: IDataPlugin<IDataPluginDefine>[]
): Promise<void>;
}
//#endregion
export interface ICoreStateConfig {
/**
@ -12,6 +74,10 @@ export interface ICoreStateConfig {
readonly loadStarter: ILoadTaskStarter;
/** 核心配置文件的 URL 路径,也就是 `core.jsonc` 的文件路径 */
readonly coreURL: string;
/** 设置游戏使用的插件列表 */
readonly dataPlugins: IDataPluginDefine[];
/** 游戏的事件内建函数列表 */
readonly events: Readonly<BuiltInFunction>[];
}
export interface ISaveableExecutor<T> {
@ -30,6 +96,19 @@ export interface ICoreState
/** 加载管理器 */
readonly loadManager: ILoadManager;
/**
* 根据 id 获取已加装的插件
* @param id 插件的标识符
*/
getDataPlugin<T extends IDataPluginDefine>(
id: string
): Readonly<IDataPlugin<T>> | null;
/**
* 执行数据端主对象的加载行为,并进行必要的初始化
*/
load(): AsyncIterator<number, void>;
/**
* 将某个存档执行器绑定至指定的可存档对象,一个可存档对象只能绑定一个执行器,
* 但一个执行器可以绑定多个可存档对象,主要用来在读档后进行一些全局性的操作

View File

@ -89,7 +89,7 @@ beforeAll(async () => {
vi.stubGlobal('location', { origin: 'http://localhost' });
const executorModule = await import('../executor');
const systemModule = await import('../system');
const stateModule = await import('../../../../data-state/src/hero');
const stateModule = await import('../../../../../src/data/hero');
const baseModule = await import('@user/data-base');
const commonModule = await import('@user/data-common');
modules = {

View File

@ -1,6 +1,5 @@
export * from './combat';
export * from './event';
export * from './path';
export * from './shop';
export * from './types';

View File

@ -1 +0,0 @@
export * from './types';

View File

@ -1,73 +0,0 @@
import {
IDataBaseExtended,
IHeroState,
IShopManager,
IShopState
} from '@user/data-base';
export interface IShopHeroAttr {
/** 勇士拥有的金币 */
money: number;
}
export const enum ShopTradeResult {
/** 成功购入或售出 */
Success,
/** 操作不合法,一般是因为道具不存在或未打开商店 */
Invalid,
/** 商店库存不足,无法购入这么多道具 */
ShopLack,
/** 玩家库存不足,无法卖出这么多道具 */
HeroLack,
/** 玩家货币不足,无法购入这么多道具 */
MoneyLack
}
export interface IShopSystem extends IDataBaseExtended {
/** 当前使用的商店管理器 */
readonly manager: IShopManager | null;
/** 当前绑定的勇士状态对象 */
readonly hero: IHeroState<IShopHeroAttr> | null;
/**
* 绑定商店管理器
* @param manager 商店管理器
*/
bindManager(manager: IShopManager | null): void;
/**
* 绑定系统使用的勇士状态对象
* @param hero 勇士状态对象
*/
bindHero(hero: IHeroState<IShopHeroAttr> | null): void;
/**
* 打开指定商店,仅数据端操作,不会打开商店 UI
* @param shop 商店状态对象
*/
openShop(shop: IShopState): void;
/**
* 获取当前已打开的商店状态对象
*/
getCurrentShop(): IShopState | null;
/**
* 关闭当前打开的商店
*/
closeShop(): void;
/**
* 在当前打开的商店里面购买指定道具
* @param item 道具数字或 id
* @param count 购买的道具数量
*/
buyItem(item: number | string, count: number): ShopTradeResult;
/**
* 在当前打开的商店里面售出指定道具
* @param item 道具数字或 id
* @param count 卖出的道具数量
*/
sellItem(item: number | string, count: number): ShopTradeResult;
}

View File

@ -74,6 +74,11 @@
"72": "Replay error when replaying $1 replay step, detailed error message will show together with this error info.",
"73": "No passive replay needed currently, but a 'getPassive' call detected, check if there's an issue on replay array or your replay logic.",
"74": "A UI base props creator binding is needed before using ui controller.",
"75": "Plugin $1 error, related plugins will not be initialized. Error info: $2.",
"76": "Plugin dependency cycle detected: $1. Cyclic plugins will not be initialized.",
"77": "Plugin $1 depends on a non-existent plugin: $2. Related plugins will not be initialized.",
"78": "Duplicate plugin id: $1. The duplicate will not be registered.",
"79": "Hero attribute initialize failed since no core config loaded.",
"2001": "Replay($1): Replay parameter count mismatch: expected $2, got $3.",
"2002": "Replay($1): Replay parameter type mismatch: index $2, expected $3, got $4.",
"2003": "Replay(move): Expected hero to be stopped before execute moving step.",

12
src/bootstrap.ts Normal file
View File

@ -0,0 +1,12 @@
import { WebLoadStarter } from '@motajs/loader';
import { createClientCore } from './client/entry';
import { IClientCore } from '@user/client-modules';
declare global {
interface Window {
mainClient: IClientCore;
}
}
const client = await createClientCore(new WebLoadStarter());
window.mainClient = client;

38
src/client/entry.ts Normal file
View File

@ -0,0 +1,38 @@
import { ILoadTaskStarter } from '@motajs/loader';
import {
ClientCore,
IClientCore,
IClientCoreConfig
} from '@user/client-modules';
import { CLIENT_CORE_URL, TILESET_RESERVE, TILESET_UNIT } from '../constant';
import { createCoreStateConfig } from '../data/entry';
/**
* 创建渲染端配置对象
* @param starter 加载启动器
*/
export function createClientCoreConfig(
starter: ILoadTaskStarter
): IClientCoreConfig {
const config: IClientCoreConfig = {
clientURL: CLIENT_CORE_URL,
tilesetReserve: TILESET_RESERVE,
tilesetUnit: TILESET_UNIT,
...createCoreStateConfig(starter)
};
return config;
}
/**
* 创建渲染端主对象,返回经过初始化的主对象实例
* @param starter 加载启动器
*/
export async function createClientCore(
starter: ILoadTaskStarter
): Promise<IClientCore> {
const config = createClientCoreConfig(starter);
const state = new ClientCore(config);
await state.load();
return state;
}

23
src/constant.ts Normal file
View File

@ -0,0 +1,23 @@
import { FaceGroup } from '@user/data-common';
// 本文件是游戏的底层核心配置,如果不懂的话千万不要动
//#region 数据端配置
/** 数据端核心配置对象,不懂的话就不要动 */
export const CORE_URL = 'content/core.jsonc';
/** 勇士的默认朝向组,用于控制勇士的移动,不懂的话就不要动 */
export const HERO_FACE = FaceGroup.Dir8;
//#endregion
//#region 渲染端配置
/** 渲染端核心配置对象,不懂的话就不要动 */
export const CLIENT_CORE_URL = 'content/client.jsonc';
/** Tileset 预留数量,用于控制最大允许注册多少个普通图块,例如填写 10000 表示最大 10000 个普通图块,不懂的话不要动 */
export const TILESET_RESERVE = 100000;
/** 一个 Tileset 的图块数量单位,例如 10000 时表示一个 Tileset 会占用 10000 或 10000 的倍数个图块,不懂的话不要动 */
export const TILESET_UNIT = 10000;
//#endregion

View File

@ -2,6 +2,7 @@ export * from './aura';
export * from './calculator';
export * from './comparer';
export * from './final';
export * from './initialize';
export * from './mapDamage';
export * from './special';
export * from './types';

View File

@ -0,0 +1,48 @@
import { ICoreState } from '@user/data-state';
import { createSpecials } from './special';
import { DamageSystem, MapDamage } from '@user/data-system';
import {
SpecialAuraConverter,
CommonAuraConverter,
GuardAuraConverter
} from './aura';
import { MainEnemyFinalEffect } from './final';
import { MainDamageCalculator } from './calculator';
import { MainEnemyComparer } from './comparer';
import { MainMapDamageConverter, MainMapDamageReducer } from './mapDamage';
/**
* 初始化怪物相关内容
* @param state 数据端主对象
* @param config 数据端配置
*/
export function initializeEnemy(state: ICoreState) {
// 怪物管理器初始化
const comparer = new MainEnemyComparer();
state.enemyManager.attachEnemyComparer(comparer);
// 特殊属性定义注册
const specials = createSpecials(state);
for (const [code, cons] of specials) {
state.enemyManager.registerSpecial(code, cons);
}
// 初始化怪物上下文
const ctx = state.enemyContext;
const damageSystem = new DamageSystem(ctx);
const mapDamage = new MapDamage(ctx);
damageSystem.useCalculator(new MainDamageCalculator());
mapDamage.useReducer(new MainMapDamageReducer());
mapDamage.useConverter(new MainMapDamageConverter());
ctx.attachDamageSystem(damageSystem);
ctx.attachMapDamage(mapDamage);
ctx.bindHero(state.hero.getAttribute());
// 注册光环转换器
ctx.registerAuraConverter(new CommonAuraConverter());
ctx.registerAuraConverter(new GuardAuraConverter());
ctx.registerAuraConverter(new SpecialAuraConverter());
// 注册怪物最终效果
ctx.registerFinalEffect(new MainEnemyFinalEffect());
}

View File

@ -3,7 +3,9 @@ import {
defineNonePropertySpecial,
SpecialCreation
} from '@user/data-base';
import { IStateSystem } from '@user/data-system';
import { ICoreState } from '@user/data-state';
// 该文件中的一些注释会充当编辑器的识别模式,如果删除可能导致编辑器无法识别,谨慎删除!!!
// 重要!!!下面这一行的注释不要删
//#region 复合属性值类型
@ -81,7 +83,7 @@ export interface ISpecial26Value {
* 8. 重生属性:在插件系统中,目前尚未实装
*/
export function createSpecials(
state: IStateSystem
state: ICoreState
): ReadonlyMap<number, SpecialCreation<any>> {
const hero = state.hero;

12
src/data/enemy/types.ts Normal file
View File

@ -0,0 +1,12 @@
export const enum MapDamageType {
/** 未知伤害 */
Unknown,
/** 领域伤害 */
Zone,
/** 激光伤害 */
Layer,
/** 阻击伤害 */
Repulse,
/** 夹击伤害 */
Between
}

35
src/data/entry.ts Normal file
View File

@ -0,0 +1,35 @@
import { CoreState, ICoreState, ICoreStateConfig } from '@user/data-state';
import { CORE_URL, HERO_FACE } from '../constant';
import { ILoadTaskStarter } from '@motajs/loader';
import { MainDataPlugin } from './initialize';
import { dataPlugins } from './plugin';
import { createEventRegistrations } from './event';
/**
* 创建数据端配置对象
* @param starter 加载启动器
*/
export function createCoreStateConfig(
starter: ILoadTaskStarter
): ICoreStateConfig {
const config: ICoreStateConfig = {
loadStarter: starter,
coreURL: CORE_URL,
dataPlugins: [new MainDataPlugin(HERO_FACE), ...dataPlugins],
events: createEventRegistrations()
};
return config;
}
/**
* 创建数据端主对象,返回经过初始化的主对象实例
* @param starter 加载启动器
*/
export async function createCoreState(
starter: ILoadTaskStarter
): Promise<ICoreState> {
const config = createCoreStateConfig(starter);
const state = new CoreState(config);
await state.load();
return state;
}

View File

@ -1,3 +1,2 @@
export * from './moverImpl';
export * from './predicate';
export * from './types';

82
src/data/initialize.ts Normal file
View File

@ -0,0 +1,82 @@
import {
Dir4FaceHandler,
Dir8FaceHandler,
FaceDirection,
FaceGroup,
IFacedTileLocator
} from '@user/data-common';
import {
ICoreState,
ICoreStateCoreConfig,
IDataPluginDefine
} from '@user/data-state';
import { initializeEnemy } from './enemy';
import { initializeReplay } from './replay';
import { logger } from '@motajs/common';
import { DefaultHeroMoveTopImpl, DefaultPassPredicateImpl } from './hero';
import { DefaultDataLoaderHook } from './loader';
export class MainDataPlugin implements IDataPluginDefine {
readonly id: string = 'main-data-plugin';
readonly dependencies: string[] = [];
constructor(readonly heroFace: FaceGroup) {}
awake(state: ICoreState): void {
// 朝向组初始化
const dir4 = new Dir4FaceHandler();
const dir8 = new Dir8FaceHandler();
state.faceManager.register(FaceGroup.Dir4, dir4);
state.faceManager.registerById('dir4', dir4);
state.faceManager.register(FaceGroup.Dir8, dir8);
state.faceManager.registerById('dir8', dir8);
// 寻路初始化
const predicate = new DefaultPassPredicateImpl(state);
state.pathfinding.finder.usePassPredicate(predicate);
// 初始状态下勇士不在任何楼层,切换楼层后再具体设置
state.pathfinding.finder.useMapLayer(null);
state.pathfinding.useMover(state.hero.location.mover);
// 勇士同步初始化
const heroMoveTopImpl = new DefaultHeroMoveTopImpl(state);
state.hero.location.mover.useTopImplementation(heroMoveTopImpl);
// 加载
state.loader.addHook(new DefaultDataLoaderHook());
// 其他复杂初始化
initializeEnemy(state);
initializeReplay(state);
// 存档初始化
state.addSaveableContent('@system/hero', state.hero);
state.addSaveableContent('@system/flags', state.flags);
state.addSaveableContent('@system/maps', state.maps);
state.addSaveableContent('@system/enemy', state.enemyManager);
state.addSaveableContent('@system/replay', state.replaySystem);
}
async initialize(): Promise<void> {}
async afterLoad(state: ICoreState): Promise<void> {
// 进行勇士的加载后初始化
const coreConfig = state.loader.getConfig<ICoreStateCoreConfig>('core');
if (!coreConfig) {
logger.error(79);
return;
}
const dir = state.faceManager.get(this.heroFace);
if (!dir) {
logger.error(80);
return;
}
state.hero.createAttribute(coreConfig.init.hero.attribute);
const location: IFacedTileLocator = {
x: coreConfig.init.hero.x,
y: coreConfig.init.hero.y,
direction: FaceDirection.Down
};
state.hero.createLocation(location, dir);
}
}

View File

@ -1,6 +1,9 @@
import { logger } from '@motajs/common';
import { ICoreStateCoreConfig } from '../common';
import { IMotaDataLoader, IMotaDataLoaderHooks } from './types';
import {
ICoreStateCoreConfig,
IMotaDataLoader,
IMotaDataLoaderHooks
} from '@user/data-state';
export class DefaultDataLoaderHook implements IMotaDataLoaderHooks {
onCoreConfigLoaded(loader: IMotaDataLoader): Promise<void> {

1
src/data/loader/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './hook';

1
src/data/plugin/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './list';

5
src/data/plugin/list.ts Normal file
View File

@ -0,0 +1,5 @@
import { IDataPluginDefine } from '@user/data-state';
import { ShopPlugin } from './shop';
/** 数据端插件列表 */
export const dataPlugins: IDataPluginDefine[] = [new ShopPlugin()];

View File

@ -0,0 +1 @@
export * from './shop';

View File

@ -1,14 +1,15 @@
import { IDataCommon, SaveCompression } from '@user/data-common';
import { SaveCompression } from '@user/data-common';
import { IShopManager, IShopManagerSave, IShopState } from './types';
import { logger } from '@motajs/common';
import { ShopState } from './state';
import { isNil } from 'lodash-es';
import { ICoreState } from '@user/data-state';
export class ShopManager implements IShopManager {
/** 当前管理器包含的商店列表 */
private readonly shops: Map<string, IShopState> = new Map();
constructor(readonly state: IDataCommon) {}
constructor(readonly state: ICoreState) {}
/**
* 传入商店 id 或实例,输出商店实例

View File

@ -0,0 +1,32 @@
import { ICoreState } from '@user/data-state';
import { IShopManager, IShopPlugin, IShopState, IShopSystem } from './types';
import { ShopManager } from './manager';
import { ShopState } from './state';
import { ShopSystem } from './system';
export class ShopPlugin implements IShopPlugin {
id: string = 'shop-plugin';
dependencies: string[] = [];
shop!: IShopSystem;
manager!: IShopManager;
/** 数据端主对象 */
private state!: ICoreState;
createShopState(id: string): IShopState {
return new ShopState(id, this.state);
}
awake(state: ICoreState): void {
this.state = state;
this.shop = new ShopSystem(state);
this.manager = new ShopManager(state);
state.addSaveableContent('@plugin/shop', this.manager);
}
async initialize(): Promise<void> {}
async afterLoad(): Promise<void> {}
}

View File

@ -1,7 +1,7 @@
import { IDataCommon } from '@user/data-common';
import { IShopState, IShopStateSave } from './types';
import { isNil } from 'lodash-es';
import { logger } from '@motajs/common';
import { ICoreState } from '@user/data-state';
export class ShopState implements IShopState {
enabled: boolean = false;
@ -15,7 +15,7 @@ export class ShopState implements IShopState {
constructor(
readonly id: string,
readonly state: IDataCommon
readonly state: ICoreState
) {}
addItem(token: number | string, count: number): void {

View File

@ -1,13 +1,15 @@
import {
IShopHeroAttr,
IShopManager,
IHeroState,
IShopState,
IStateBase
} from '@user/data-base';
import { IShopHeroAttr, IShopSystem, ShopTradeResult } from './types';
IShopSystem,
ShopTradeResult
} from './types';
import { logger } from '@motajs/common';
import { isNil } from 'lodash-es';
import { shouldReplay } from '@user/data-common';
import { ICoreState } from '@user/data-state';
import { IHeroState } from '@user/data-base';
export class ShopSystem implements IShopSystem {
manager: IShopManager | null = null;
@ -15,7 +17,7 @@ export class ShopSystem implements IShopSystem {
private current: IShopState | null = null;
constructor(readonly state: IStateBase) {}
constructor(readonly state: ICoreState) {}
bindManager(manager: IShopManager | null): void {
this.closeShop();

View File

@ -1,4 +1,19 @@
import { IDataCommonExtended, ISaveableContent } from '@user/data-common';
import { IHeroState } from '@user/data-base';
import { ISaveableContent } from '@user/data-common';
import { ICoreStateExtended, IDataPluginDefine } from '@user/data-state';
export interface IShopPlugin extends IDataPluginDefine {
/** 商店插件所使用的商店系统对象 */
readonly shop: IShopSystem;
/** 商店插件所使用的商店管理器对象 */
readonly manager: IShopManager;
/**
* 创建商店状态对象
* @param id 商店状态对象的标识符
*/
createShopState(id: string): IShopState;
}
export interface IShopStateSave {
/** 商店 id */
@ -14,7 +29,7 @@ export interface IShopStateSave {
}
export interface IShopState
extends IDataCommonExtended, ISaveableContent<IShopStateSave> {
extends ICoreStateExtended, ISaveableContent<IShopStateSave> {
/** 当前商店是否已经启用 */
readonly enabled: boolean;
/** 当前商店的标识符 */
@ -69,7 +84,7 @@ export interface IShopManagerSave {
}
export interface IShopManager
extends IDataCommonExtended, ISaveableContent<IShopManagerSave> {
extends ICoreStateExtended, ISaveableContent<IShopManagerSave> {
/**
* 添加新的商店
* @param shop 商店状态对象
@ -121,3 +136,70 @@ export interface IShopManager
*/
disableShop(shop: string | IShopState): void;
}
export interface IShopHeroAttr {
/** 勇士拥有的金币 */
money: number;
}
export const enum ShopTradeResult {
/** 成功购入或售出 */
Success,
/** 操作不合法,一般是因为道具不存在或未打开商店 */
Invalid,
/** 商店库存不足,无法购入这么多道具 */
ShopLack,
/** 玩家库存不足,无法卖出这么多道具 */
HeroLack,
/** 玩家货币不足,无法购入这么多道具 */
MoneyLack
}
export interface IShopSystem extends ICoreStateExtended {
/** 当前使用的商店管理器 */
readonly manager: IShopManager | null;
/** 当前绑定的勇士状态对象 */
readonly hero: IHeroState<IShopHeroAttr> | null;
/**
* 绑定商店管理器
* @param manager 商店管理器
*/
bindManager(manager: IShopManager | null): void;
/**
* 绑定系统使用的勇士状态对象
* @param hero 勇士状态对象
*/
bindHero(hero: IHeroState<IShopHeroAttr> | null): void;
/**
* 打开指定商店,仅数据端操作,不会打开商店 UI
* @param shop 商店状态对象
*/
openShop(shop: IShopState): void;
/**
* 获取当前已打开的商店状态对象
*/
getCurrentShop(): IShopState | null;
/**
* 关闭当前打开的商店
*/
closeShop(): void;
/**
* 在当前打开的商店里面购买指定道具
* @param item 道具数字或 id
* @param count 购买的道具数量
*/
buyItem(item: number | string, count: number): ShopTradeResult;
/**
* 在当前打开的商店里面售出指定道具
* @param item 道具数字或 id
* @param count 卖出的道具数量
*/
sellItem(item: number | string, count: number): ShopTradeResult;
}

2
src/data/replay/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './commands';
export * from './initialize';

View File

@ -0,0 +1,35 @@
import { FaceDirection, ReplayCode } from '@user/data-common';
import { ICoreState } from '@user/data-state';
import {
ReplayEquip,
ReplayMove,
ReplayTeleport,
ReplayUnequip,
ReplayUseItem
} from './commands';
/**
* 初始化录像指令
* @param state 数据端主对象
*/
export function initializeReplay(state: ICoreState) {
const replay = state.replaySystem;
const up = new ReplayMove(state, FaceDirection.Up);
const right = new ReplayMove(state, FaceDirection.Right);
const down = new ReplayMove(state, FaceDirection.Down);
const left = new ReplayMove(state, FaceDirection.Left);
const teleport = new ReplayTeleport(state);
const useItem = new ReplayUseItem(state);
const equip = new ReplayEquip(state);
const unequip = new ReplayUnequip(state);
replay.registerCommand(ReplayCode.Up, up);
replay.registerCommand(ReplayCode.Right, right);
replay.registerCommand(ReplayCode.Down, down);
replay.registerCommand(ReplayCode.Left, left);
replay.registerCommand(ReplayCode.Teleport, teleport);
replay.registerCommand(ReplayCode.UseItem, useItem);
replay.registerCommand(ReplayCode.Equip, equip);
replay.registerCommand(ReplayCode.Unequip, unequip);
}