删除无用脚本

This commit is contained in:
桃树夭 committed 2026-10-04 17:32:38 +08:00
1 parent ee5d56b5c7
commit 4b35f13ff9
31 files changed
+150 -2736

No files matched your search

@@ -21,7 +21,7 @@ description: Daoyou NestJS API、认证、授权、Better Auth、ALTCHA、admin
## Configuration and Workspace Boundary
- API builds with `nest build` (Nest CLI 12 default tsc, NodeNext ESM); the six libraries build with tsc in dependency order and export dist JavaScript/declarations; Web builds with Vite. API lint uses Oxlint with type-aware Promise checks and import boundaries; Web/packages/tools retain ESLint. API consumes explicit `@daoyou/*` library exports, never Web; libraries cannot import either host. The old shared workspace is removed. `pnpm run check:boundaries` enforces package direction and declared dependencies. LLM generators and prompt rendering belong in `apps/api/src/lib/generation`.
- API builds with `nest build` (Nest CLI 12 default tsc, NodeNext ESM); the six libraries build with tsc in dependency order and export dist JavaScript/declarations; Web builds with Vite. API lint uses Oxlint correctness rules with type-aware Promise checks; Web/packages/tools retain ESLint. API consumes explicit `@daoyou/*` library exports, never Web; libraries cannot import either host. The old shared workspace is removed. The migration-only package-boundary script and import restrictions are retired; package direction and declared dependencies remain architecture conventions. LLM generators and prompt rendering belong in `apps/api/src/lib/generation`.
- Nest services inject `AppConfigService`; independent libraries read `getRuntimeEnvironment()`. The snapshot is validated once with Zod; dotenv discovery is disabled. Never log credential values on validation failure.
- `DatabaseModule` exports the existing Drizzle client, preserving one pool and transaction propagation. Runtime closes it after request/message drain.
@@ -42,7 +42,7 @@ Arena rules project an `ArenaSnapshot`; the API's `combat/arena-view.ts` adds AP
## Engine Boundary
- `core` is a pure, rules-independent we-go engine. It must not import Daoyou rules, projection or sect content. Keep sect-specific behavior in content/rules rather than adding sect ID branches to core.
- V6 must not import `battle-v5` or `creation-v2`; inspect `eslint.config.js` for enforced dependency boundaries. Old directory names or residual legacy DTOs do not establish a supported engine API.
- V6 must not import `battle-v5` or `creation-v2`; this remains an architecture convention after retiring migration-only import restrictions. Old directory names or residual legacy DTOs do not establish a supported engine API.
- V5 `AttributeSet`, `AbilityFactory`, `AbilityConfig`, `projectAbilityConfig` and `battleProjection` are not the V6 pipeline. Do not restore them to implement current combat or items.
- Use the V6 core types and existing content pack validators. `GameplayTags` / `CreationTags` in the old shared tag domain are not a universal V6 tag contract.
- Preserve seeded RNG and deterministic command/round behavior. Keep clocks, DB, Redis, network and LLM calls out of shared engine logic; pass required inputs from the host.
-4
View File
@@ -9,11 +9,7 @@ env:
APP_IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/daoyou-app
jobs:
quality:
uses: ./.github/workflows/quality-check.yml
build-and-push:
needs: quality
runs-on: ubuntu-latest
environment: production
steps:
-28
View File
@@ -1,28 +0,0 @@
name: Quality Check
on:
workflow_call:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.18.0
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run typecheck
- run: pnpm run test
- run: pnpm run build
- name: Record frontend build identity
run: |
node --input-type=module <<'NODE'
import { readFileSync, appendFileSync } from 'node:fs';
const { buildId } = JSON.parse(readFileSync('apps/web/dist/version.json', 'utf8'));
appendFileSync(process.env.GITHUB_STEP_SUMMARY,
`Revision: \`${process.env.GITHUB_SHA}\`\n\nSPA build ID: \`${buildId}\`\n`);
NODE
-10
View File
@@ -1,10 +0,0 @@
name: Quality
on:
pull_request:
push:
branches: [master]
jobs:
check:
uses: ./.github/workflows/quality-check.yml
+2 -2
View File
@@ -9,7 +9,7 @@ AI agents should read this first. Keep changes small, project-specific, and back
- This repo is `NestJS + React SPA`, not Next.js or SSR.
- Runtime stack: Node.js 24, NestJS 12 (Express/native ws), pnpm + Turborepo tooling, React 19, React Router 8, Vite, Tailwind CSS 4, PostgreSQL, Drizzle ORM, Better Auth, Redis, NATS, AI SDK.
- Use the pinned `pnpm` version and `pnpm-lock.yaml` for development and deployment. Do not introduce Bun/npm/yarn lockfiles. Turborepo orchestrates package builds and typechecks; turbo watch rebuilds dependencies and restarts persistent development tasks.
- pnpm workspaces: `apps/api`, `apps/web`, and six compiled libraries under `packages/*`. The dependency graph and acceptance gates are in `docs/architecture-boundaries.md`. Each owns its runtime dependencies; root owns lint/test/maintenance tooling. Libraries must not import either app; apps consume package exports. The old shared workspace is removed. ESLint (Web/packages/tools) and Oxlint (API) enforce these boundaries, repository direction, and combat core independence.
- pnpm workspaces: `apps/api`, `apps/web`, and six compiled libraries under `packages/*`. The dependency graph and acceptance gates are in `docs/architecture-boundaries.md`. Each owns its runtime dependencies; root owns lint/test/maintenance tooling. Libraries must not import either app; apps consume package exports. The old shared workspace is removed. ESLint (Web/packages/tools) and Oxlint (API) check code correctness; migration-only import restrictions and the package-boundary script are retired. Keep the dependency direction above as an architecture convention.
- Path aliases are `@app` -> `apps/web/src` and `@server` -> `apps/api/src`. `@daoyou/*` libraries resolve through workspace package exports (no source alias bypass); library-internal imports use relative source paths rather than their own dist exports.
- Public package APIs use business subpaths with explicit named exports in `src/public`; small modules with intentional loading boundaries may remain direct exports. Do not add wildcard exports or a package-wide barrel. Keep lightweight models/helpers separate from complete validators and content registries. `game-content/authoring/*` is for content validation tests and maintenance tools, not application/runtime imports. See the public API policy in `docs/architecture-boundaries.md`.
@@ -42,7 +42,7 @@ pnpm run db:migrate
- `pnpm run build` uses Turbo to build the independent API and Web packages; Nest CLI 12 builds `apps/api` with its default tsc builder; libraries are compiled in dependency order with tsc and export JavaScript/declarations from dist; Vite builds `apps/web`. The old V5 resolver Worker target was retired in Phase 10H. Preserve the remaining CI/CD entrypoints.
- Vitest uses node environment and discovers pure logic tests under `packages/*/src`; tests move with their owning domain during package extraction.
- Docker runtime contains Node, the pnpm-deployed API production dependencies, package metadata and `dist`; ALTCHA uses the server-side `ALTCHA_HMAC_SECRET` and does not require a frontend site key.
- GitHub Actions runs lint/typecheck/pure package tests/build on PRs and master pushes; tag pushes build the API image and always publish latest.
- GitHub Actions only builds and pushes the API image on tag pushes, always publishing latest; quality checks are run locally as needed.
## Skills To Use
+12 -341
View File
@@ -1,98 +1,41 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"env": {
"node": true
},
"categories": {
"correctness": "off"
"correctness": "error"
},
"rules": {
"constructor-super": "off",
"for-direction": "error",
"getter-return": "off",
"no-async-promise-executor": "error",
"no-array-constructor": "error",
"no-case-declarations": "error",
"no-class-assign": "off",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "off",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "off",
"no-dupe-else-if": "error",
"no-dupe-keys": "off",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "off",
"no-global-assign": "error",
"no-import-assign": "off",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "off",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "off",
"no-prototype-builtins": "error",
"no-redeclare": "off",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "off",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "off",
"no-unassigned-vars": "error",
"no-undef": "off",
"no-unexpected-multiline": "error",
"no-unreachable": "off",
"no-unsafe-finally": "error",
"no-unsafe-negation": "off",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": "error",
"no-useless-assignment": "error",
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "off",
"preserve-caught-error": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error",
"no-var": "error",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"preserve-caught-error": "error",
"typescript/ban-ts-comment": "error",
"no-array-constructor": "error",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "error",
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-misused-new": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-require-imports": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-declaration-merging": "error",
"typescript/no-unsafe-function-type": "error",
"no-unused-expressions": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "error",
"typescript/no-floating-promises": "error"
"typescript/no-floating-promises": "error",
"typescript/await-thenable": "off",
"typescript/no-base-to-string": "off",
"typescript/no-misused-spread": "off",
"typescript/no-redundant-type-constituents": "off",
"typescript/no-useless-default-assignment": "off",
"typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off"
},
"ignorePatterns": ["dist/**", "node_modules/**"],
"overrides": [
@@ -106,278 +49,6 @@
"rules": {
"typescript/no-misused-promises": "error"
}
},
{
"files": ["src/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": ["@app/*", "@daoyou/web*", "**/apps/web/**"]
}
]
},
"excludeFiles": [
"src/lib/repositories/**/*.ts",
"src/**/*.service.ts",
"src/market/**/*.ts",
"src/inventory/**/*.ts",
"src/lib/mq/**/*.ts",
"src/auction/**/*.ts",
"src/mail/**/*.ts"
]
},
{
"files": ["src/lib/repositories/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@app/*", "@daoyou/web*", "**/apps/web/**"]
},
{
"group": [
"@server/*/application/**",
"@server/sects/organization/**"
],
"message": "Repositories must not call application services."
}
]
}
]
}
},
{
"files": ["src/**/*.service.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": ["@app/*", "@daoyou/web*", "**/apps/web/**"],
"paths": [
{
"name": "@server/lib/drizzle/db.js",
"importNames": ["db", "getExecutor"],
"message": "Inject DRIZZLE_DATABASE in Nest services; pass it to repository calls."
}
]
}
]
},
"excludeFiles": [
"src/market/**/*.service.ts",
"src/inventory/**/*.service.ts",
"src/auction/**/*.service.ts",
"src/mail/**/*.service.ts"
]
},
{
"files": ["src/market/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@app/*", "@daoyou/web*", "**/apps/web/**"]
},
{
"group": [
"@server/cultivator/application/**",
"@server/inventory/application/**"
],
"message": "Market uses public character queries and inventory operations/providers."
}
]
}
]
},
"excludeFiles": ["src/**/*.service.ts"]
},
{
"files": ["src/inventory/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@app/*", "@daoyou/web*", "**/apps/web/**"]
},
{
"group": ["@server/market/**", "**/market/application/**"],
"message": "Inventory owns recycling and must not depend on Market."
}
]
}
]
},
"excludeFiles": ["src/**/*.service.ts"]
},
{
"files": ["src/market/**/*.service.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@app/*", "@daoyou/web*", "**/apps/web/**"]
},
{
"group": [
"@server/cultivator/application/**",
"@server/inventory/application/**"
],
"message": "Market uses public character queries and inventory operations/providers."
}
],
"paths": [
{
"name": "@server/lib/drizzle/db.js",
"importNames": ["db", "getExecutor"],
"message": "Inject DRIZZLE_DATABASE in Nest services; pass it to repository calls."
}
]
}
]
}
},
{
"files": ["src/inventory/**/*.service.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@app/*", "@daoyou/web*", "**/apps/web/**"]
},
{
"group": ["@server/market/**", "**/market/application/**"],
"message": "Inventory owns recycling and must not depend on Market."
}
],
"paths": [
{
"name": "@server/lib/drizzle/db.js",
"importNames": ["db", "getExecutor"],
"message": "Inject DRIZZLE_DATABASE in Nest services; pass it to repository calls."
}
]
}
]
}
},
{
"files": ["src/lib/mq/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@app/*", "@daoyou/web*", "**/apps/web/**"]
},
{
"group": [
"@server/*/application/**",
"@server/sects/organization/**",
"@server/runtime/**"
],
"message": "Business message registration belongs to Runtime; MQ libraries own transport."
}
]
}
]
}
},
{
"files": ["src/auction/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@app/*", "@daoyou/web*", "**/apps/web/**"]
},
{
"group": [
"@server/cultivator/application/**",
"@server/inventory/application/**"
],
"message": "Auction and Mail use public character facts and inventory operations/presentation."
},
{
"group": ["@server/mail/application/**"],
"message": "Auction uses MailDeliveryService and public attachment presentation."
}
],
"paths": [
{
"name": "@server/lib/drizzle/db.js",
"importNames": ["db", "getExecutor"],
"message": "Inject DRIZZLE_DATABASE in Nest services; pass it to repository calls."
}
]
}
]
}
},
{
"files": ["src/mail/**/*.ts"],
"excludeFiles": ["src/**/*.service.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@app/*", "@daoyou/web*", "**/apps/web/**"]
},
{
"group": [
"@server/cultivator/application/**",
"@server/inventory/application/**"
],
"message": "Auction and Mail use public character facts and inventory operations/presentation."
}
]
}
]
}
},
{
"files": ["src/mail/**/*.service.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@app/*", "@daoyou/web*", "**/apps/web/**"]
},
{
"group": [
"@server/cultivator/application/**",
"@server/inventory/application/**"
],
"message": "Auction and Mail use public character facts and inventory operations/presentation."
}
],
"paths": [
{
"name": "@server/lib/drizzle/db.js",
"importNames": ["db", "getExecutor"],
"message": "Inject DRIZZLE_DATABASE in Nest services; pass it to repository calls."
}
]
}
]
}
}
]
}
+4 -2
View File
@@ -2,6 +2,8 @@
日期:2026-10-04。目标:NestJS 模块化单体、React SPA,以及按职责拆分的内部编译包(tsc 输出 JavaScript 和声明文件)。
> 2026-10-04 配置简化:迁移已完成,包边界检查脚本及 ESLint/Oxlint 的迁移导入限制已退役,lint 改为推荐规则/原生 correctness 基线加必要的代码正确性约束。依赖图与公共 API 约定继续适用;下文脚本和 Quality CI 的执行记录属于历史验收,当前 GitHub Actions 仅在 tag 推送时构建并推送镜像。
## 当前迁移目标与验收
用户已授权按顺序推进六包拆分,直到移除旧 shared 并完成验收。下面的目标替代后文历史批次中“保留三个工作区”的决策;历史实施记录不作为本轮完成证据。
@@ -17,7 +19,7 @@
旧 shared workspace 已删除,不保留兼容 barrel。所有类型与运行值导入都受包方向限制;测试依赖上层玩法时,应归入拥有该玩法的包,而不是让底层包反向依赖。应用之间不互相导入,各自消费包 exports,不通过源码别名绕过边界。
实际迁移细化:装备、功法、内容定义与人物投影的模型共同引用 `SkillDef`、`LineupUnit`、`CombatV6VersionStamp` 等内核类型。将这些中立模型归入 game-domain,并允许它单向 type-only 依赖 combat-core;边界检查禁止运行值导入。这样保留权威类型、保持内核零业务依赖,也避免为了初始图形复制类型或引入无业务价值的泛型。领域包仍不依赖游戏内容、规则、契约和应用。
实际迁移细化:装备、功法、内容定义与人物投影的模型共同引用 `SkillDef`、`LineupUnit`、`CombatV6VersionStamp` 等内核类型。将这些中立模型归入 game-domain,并允许它单向 type-only 依赖 combat-core;架构约定禁止运行值导入。这样保留权威类型、保持内核零业务依赖,也避免为了初始图形复制类型或引入无业务价值的泛型。领域包仍不依赖游戏内容、规则、契约和应用。
执行顺序和完成条件:
@@ -320,7 +322,7 @@ Docker 本地镜像 `daoyou-monorepo-review:local` 构建通过,以非 root
- 领域模型、Schema 构造器、内容目录和绑定完整校验的规则仍由各自的包负责。入口合并不改变 `contracts ↔ game-domain ↔ game-rules` 的依赖方向;API 原有组合位置保留。
- 类型入口保留被合并模块的类型声明,包括没有被直接 import、但可能被推导出的公开函数签名引用的类型。仅按直接调用统计删除类型会使消费者的声明生成失败(本批曾发现 TalismanSpec 的 TS2883,并已修正)。
- 轻量入口与重型内容初始化保持分开:content 的 `equipment/base`、`equipment/forging`、`equipment/special`;domain 的 `character/generation`、`equipment/authoring/*`、`sects/commands`;rules 的 `inventory/stacking`、`sect-organization/tasks`、`combat/log`、`combat/appearance`、`combat/presentation`、炼体进度/训练及两类消耗品规则。它们是加载与职责边界,不应仅为减少行数继续合并。
- `game-content/authoring/**` 面向内容校验测试和维护工具;边界检查禁止应用和库的非测试源码消费它。包内校验仍通过相对路径访问自身实现。五个原始 JSON 入口 `authoring/equipment/{base,forging}`、`authoring/beasts/{species,skills,progression}` 单独保留,以维持既有内容替换测试的 mock 边界。
- `game-content/authoring/**` 面向内容校验测试和维护工具;应用和库的非测试源码不得消费它。包内校验仍通过相对路径访问自身实现。五个原始 JSON 入口 `authoring/equipment/{base,forging}`、`authoring/beasts/{species,skills,progression}` 单独保留,以维持既有内容替换测试的 mock 边界。
- 没有添加全包 `sideEffects: false`,原注册表与 Schema 初始化校验仍执行。导出数量下降不等于所有加载成本下降;新增入口必须同时考虑消费关系和首屏依赖。
上述规则进入 `AGENTS.md`;技能中的实际包导入路径同步更新。`check-package-boundaries.ts` 新增 runtime/authoring 和 public 具名导出检查,随现有 `pnpm run lint` 进入 CI。临时违规探针验证两条规则均能拒绝对应导入/再导出,探针已删除。
@@ -561,6 +561,8 @@ pack.ts # 加载与严格校验
## 37. 当前逐项验收矩阵
2026-10-04 测试维护说明:本节为迁移完成时的历史验收证据。其中生成、人物装配、宗门定义和奖励的整包哈希已清理,配置联动及业务边界测试继续保留;当前保留依据见[测试规范](testing.md#测试保留与清理)。
本节汇总最终状态,取代第22节的阶段性缺口表;前文保留各次实施和验证的历史事实。2026-09-11完成实际文件、加载/编译入口、策划说明和测试内容核对,全量147文件/984项测试通过(最新17:37运行)。通过项限所列证据范围,本规划已结项,最终检查见第46节。
### 共用要求(第4节)
+7 -1
View File
@@ -28,7 +28,13 @@ pnpm run dev # 启动 API 与 Vite
- `packages/*`:由 tsc 编译为 `dist` 中的 JavaScript 与声明文件,JSON 内容随编译复制。API 在运行时加载,Web 由 Vite 打包,不独立部署。常量、领域模型、战斗 core、内容、玩法规则与契约分别位于 `constants`、`game-domain`、`combat-core`、`game-content`、`game-rules`、`contracts`;旧 shared 已清退,依赖方向和验收进度见 [应用边界](architecture-boundaries.md)。宿主使用明确的 `exports`,不以TS/Vite源码别名绕过边界;包内使用相对源码导入,避免类型检查读取自身陈旧 dist。公开叶子入口需单独注册。
- 根目录保留 `pnpm-workspace.yaml`、单一 `pnpm-lock.yaml`、`turbo.json`、lint/test/typecheck、迁移配置和部署入口。内部依赖使用 `workspace:*`,各包自行声明依赖。
Web/packages/根工具使用 ESLint,API 使用 `oxlint --type-aware`。`pnpm run check:boundaries` 检查声明依赖、公开入口、相对路径越界、类型/运行值/测试导入和 workspace 环;已接入 lint。API 的仓储与 Service 约束位于 `apps/api/.oxlintrc.json`;Promise 检查保留类型信息。LLM生成和提示词位于API的 `lib/generation`;共同的修为与突破计算位于共享包。
Web/packages/根工具使用 ESLint 的 JS、TypeScript、React Hooks 推荐规则及 Vite Fast Refresh 配置,API 使用 `oxlint --type-aware`。
Oxlint 以 TypeScript 插件的 `correctness: error` 为基线,补充原有推荐规则中未被基线覆盖的检查;保留全 API 的 `no-floating-promises` 和入口、HTTP、Runtime、Realtime 的 `no-misused-promises`。新增的 `await-thenable`、`no-base-to-string`、`no-misused-spread`、`no-redundant-type-constituents`、`no-useless-default-assignment`、`restrict-template-expressions`、`unbound-method` 暂不启用,避免配置整理扩大为既有业务代码的类型规则整改。
迁移完成后已删除包边界检查脚本、`check:boundaries` 命令及两套 linter 的导入限制;包依赖方向、公开入口和数据库注入仍遵循架构约定。战斗内核继续禁止直接使用 `Math.random()` 和 `Date.now()`。
LLM生成和提示词位于API的 `lib/generation`;共同的修为与突破计算位于共享包。
Nest `ConfigurationModule` 使用 `@nestjs/config`、关闭dotenv自动发现,并提供一次Zod校验后的不可变环境快照。Nest服务注入 `AppConfigService`,使用类型化的 `get()` 读取配置,独立仓储/基础库使用 `getRuntimeEnvironment()`。端口、数据库URL、连接池上限、认证配置和消息配置在启动时校验;production还要求Redis地址与cron密钥且禁止 `APP_ENV=local`。错误只报告字段与约束,不输出凭据。
+25
View File
@@ -8,6 +8,31 @@
- 使用 Vitest;先运行受影响的测试,再根据影响范围运行 `pnpm run test`。
- 不引入数据库、Redis、HTTP、认证或第三方服务模拟;不在 `apps/api/src`、`apps/web/src` 或 `scripts` 添加单元测试。
### 测试保留与清理
- 保留能说明业务错误的断言:非法输入、经济数量与费用边界、确定性、不修改输入、状态转换、实际战斗效果及仍在使用的兼容逻辑。
- 迁移完成后移除只证明新旧实现完全一致的整包哈希。混合文件只删这类断言,保留配置联动、引用完整性和拒绝非法内容的测试。
- 不把源码关键词扫描、固定目录树、静态文案和美术文件存在性作为领域单元测试。架构约束通过代码审查核对,文案和素材在相关页面验收。
- 不按文件名或运行耗时删除测试。`phase` 可能仍验证真实战斗规则,`legacy`/`migration` 可能仍保护正式兑换和存量数据;先查实际调用方。
2026-10-04 清理记录:盘点 256 个测试文件,清理前 2402 个用例通过。此次处理如下,运行时实现不变:
| 对象(路径相对 `packages/`) | 处理及依据 |
| --- | --- |
| `game-rules/src/sect-organization/testing/architecture/architectureGuard.test.ts` | 删除目录形状、源码关键词和已退役接口名扫描;原文件中的宗门结构与关键词限制不再由单元测试自动检查,后续改动仍须遵守架构文档并进行代码审查 |
| `game-rules/src/equipment/generation-baseline.test.ts` | 删除旧生成流的整批哈希;保留装备生成、随机阈值、打造、器形和阵纹专项测试 |
| `game-rules/src/beasts/generation-baseline.test.ts` | 删除生成/成长/投影整批哈希;三个物种的资质、成长范围用例并入 `beasts.test.ts` |
| `game-rules/src/combat/projection/character-build.test.ts` 及 `fixtures/character-build.json` | 删除整个人物投影哈希和大型快照;经脉装备测试使用的唯一输入移到该测试内,保留属性贡献、资源策略及装备联动专项测试 |
| `game-content/src/combat/training/pack.test.ts`、`game-rules/src/sects/progression/pack.test.ts`、`game-rules/src/sects/skill-learning.test.ts`、`game-rules/src/rewards/dungeon-pack.test.ts` | 仅删除迁移等价哈希,保留 Schema 同步、配置生效和错误输入测试 |
| `game-content/src/items/equipment-blueprints.test.ts` | 删除图纸静态数量、名称和禁止字符断言 |
| `game-rules/src/sect-organization/production/onboardingPresentation.test.ts`、`affairsPresentation.test.ts` | 删除固定 NPC 姓名、地图说明、剧情段数和素材文件扫描;访客入口用例并入后者,保留任务和设施交互绑定 |
| `game-rules/src/world/mapAtlas.test.ts` | 仅删除固定“大晋 20 地点”断言;保留区域归属、全地点覆盖和锚点检查 |
| `game-rules/src/tower/strategy-value.test.ts` | 删除 420 场阶段校准比较:仅要求至少一个策略的胜率、伤害或回合数不同,不能判定策略是否正确;保留实际护卫/治疗/重击机制、平衡及七境界通关测试 |
旧法宝与旧功法兑换的规则和契约测试继续保留:`apps/api/src/legacy-items` 和对应前端页面仍调用它们。`combat-core/src/phase-3-primitives.test.ts`、幻境 `phase-two.test.ts`/`phase-three.test.ts` 也继续保留,它们覆盖现行资源扣除、战斗与通关行为。
本轮清理后 `pnpm run test` 通过 249 个文件/2360 个用例,净移除 42 个用例;测试及其快照净减少 1998 行。`pnpm run lint`、`pnpm run typecheck`、`git diff --check` 均通过。测试命令已包含六个库的构建;因本次仅修改测试及文档,未另跑应用完整构建和浏览器验收。
## 2. Codex 浏览器/Playwright 模拟
由 Codex 在当次开发任务中操作真实本地页面,不将操作固化为一次性仓库脚本。
+1 -1
View File
@@ -242,7 +242,7 @@ G_output(f) = 1 + 0.025 × (f − 1)
所有样本首回合死亡为零。九劫无宠 AUTO 会穿插低收益物理攻击/控制;本轮没有为追求 AUTO 全胜而全面降低普通层输出。幽都无宠节奏仍偏长,不能宣称已达到普通 2–3 / 首领 6–8 回合目标。
`strategy-value.test.ts` 在 10/20 层对照三宗门、十种子,共 420 场。20 层幽都治疗阵容:先主敌总计 108 回合/19,326 承伤,先辅助 89 回合/22,182 承伤,均 10 胜;拆辅助更快但承伤增加。20 层幽都蓄势阵容:抢攻 93 回合/30,917 承伤,防御重击 96 回合/22,946 承伤,均 10 胜。10 层凌霄双护卫:先主敌 58 回合,先护卫 49 回合,均 10 胜。数据说明同种子下存在策略差异,不代表统一最优打法。
当时的 `strategy-value.test.ts` 在 10/20 层对照三宗门、十种子,共 420 场。20 层幽都治疗阵容:先主敌总计 108 回合/19,326 承伤,先辅助 89 回合/22,182 承伤,均 10 胜;拆辅助更快但承伤增加。20 层幽都蓄势阵容:抢攻 93 回合/30,917 承伤,防御重击 96 回合/22,946 承伤,均 10 胜。10 层凌霄双护卫:先主敌 58 回合,先护卫 49 回合,均 10 胜。数据说明同种子下存在策略差异,不代表统一最优打法。该测试于 2026-10-04 清理:其断言仅要求策略结果存在差异;本段保留历史校准结果,实际战斗机制与通关检查仍由其他幻境测试覆盖。
仍未完成正常养成上下沿、集中祝福路径、所有后段候选的全策略覆盖及所有开放境界定标。浏览器强化角色通关仅验证交互、恢复和结算,不充当正常养成平衡证据。
+1 -109
View File
@@ -4,27 +4,6 @@ import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
import tseslint from 'typescript-eslint';
const sharedImportBoundaries = [
'@server/*',
'@app/*',
'@daoyou/api*',
'@daoyou/web*',
'node:*',
'**/apps/**',
'@nestjs/**',
'express',
'pg',
'drizzle-orm',
'drizzle-orm/**',
'ioredis',
'nats',
'react',
'react-dom',
'react-dom/**',
'clsx',
'tailwind-merge',
];
export default tseslint.config(
{ ignores: ['**/dist/**', 'apps/api/**'] },
{
@@ -35,12 +14,8 @@ export default tseslint.config(
{
files: ['apps/web/**/*.{ts,tsx}'],
languageOptions: { globals: globals.browser },
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
extends: [reactHooks.configs.flat.recommended, reactRefresh.configs.vite],
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true, allowExportNames: ['loader'] },
@@ -51,93 +26,10 @@ export default tseslint.config(
files: ['scripts/**/*.ts', '*.ts'],
languageOptions: { globals: globals.node },
},
{
files: ['packages/*/src/**/*.{ts,tsx}', 'apps/web/src/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
'@server/*',
'@daoyou/api',
'@daoyou/api/*',
'@daoyou/web',
'@daoyou/web/*',
'**/apps/api/**',
'**/apps/web/**',
],
},
],
},
},
{
files: ['apps/web/src/{lib,components,providers}/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
'@app/routes/**',
'**/routes/**',
'@server/*',
'@daoyou/api*',
'@daoyou/web*',
'**/apps/api/**',
'**/apps/web/**',
],
},
],
},
},
{
files: ['packages/*/src/**/*.{ts,tsx}'],
ignores: ['**/*.test.*', '**/*.spec.*'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: sharedImportBoundaries,
},
],
},
},
{
files: [
'packages/{constants,game-domain,game-content,game-rules,contracts}/src/**/*.ts',
],
ignores: ['**/*.test.*', '**/*.spec.*'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{ group: sharedImportBoundaries },
{
regex: '(?:^|/)(?:battle-v5|creation-v2)(?:/|$)',
message: 'V6 must not depend on retired engines.',
},
],
},
],
},
},
{
files: ['packages/combat-core/src/**/*.ts'],
ignores: ['**/*.test.*', '**/*.spec.*'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{ group: sharedImportBoundaries },
{
regex:
'(?:^|/)(?:rules-daoyou|projection|content|battle-v5|creation-v2)(?:/|$)',
message: 'Combat core receives rules and content from its host.',
},
],
},
],
'no-restricted-properties': [
'error',
{ object: 'Math', property: 'random', message: 'Use the battle RNG.' },
+2 -3
View File
@@ -15,7 +15,7 @@
"dev": "turbo watch dev --env-mode=loose",
"dev:api": "turbo watch dev --filter=@daoyou/api --env-mode=loose",
"dev:web": "turbo watch dev --filter=@daoyou/web --env-mode=loose",
"lint": "pnpm run check:boundaries && eslint . && pnpm --filter @daoyou/api run lint",
"lint": "eslint . && pnpm --filter @daoyou/api run lint",
"prd": "turbo watch prd --env-mode=loose",
"prd:api": "turbo watch prd --filter=@daoyou/api --env-mode=loose",
"prd:web": "turbo watch prd --filter=@daoyou/web --env-mode=loose",
@@ -25,8 +25,7 @@
"sponsors:render": "node scripts/render-sponsors.ts",
"sponsors:sync": "pnpm run sponsors:fetch && pnpm run sponsors:render && pnpm run sponsors:check",
"test": "turbo run build --filter=\"./packages/*\" && vitest run",
"typecheck": "turbo run typecheck && tsc --noEmit -p tsconfig.node.json",
"check:boundaries": "node scripts/check-package-boundaries.ts"
"typecheck": "turbo run typecheck && tsc --noEmit -p tsconfig.node.json"
},
"devDependencies": {
"@eslint/js": "10.0.1",
@@ -1,4 +1,3 @@
import { createHash } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import raw from './data/training.json' with { type: 'json' };
@@ -8,9 +7,6 @@ import { COMBAT_V6_TRAINING_CONTENT_V1 } from './content.js';
describe('训练遭遇配置', () => {
it('Schema 同步', () => expect(z.toJSONSchema(TrainingPackShape, { reused: 'ref' })).toEqual(schema));
it('全部21单位、六场景、技能状态保持迁移前内容与顺序', () => {
expect(createHash('sha256').update(JSON.stringify(COMBAT_V6_TRAINING_CONTENT_V1)).digest('hex')).toBe('302834d0f108662aec1929cff94ec4c51f404f72b08eb1e026527db1e5459eaa');
});
it('基础面板和模板配方决定敌友实际输入', () => {
const data = structuredClone(raw);
data.tiers[0].attrs.hp = data.tiers[0].attrs.maxHp = 6001;
@@ -1,16 +0,0 @@
import { describe, expect, it } from 'vitest';
import { BLUEPRINTS } from './equipment-blueprints.js';
describe('图纸境界名称', () => {
it('保留物品标识与档位,所有部位均使用无数字的境界名称', () => {
expect(BLUEPRINTS).toHaveLength(54);
expect(new Set(BLUEPRINTS.map((item) => item.name)).size).toBe(54);
for (const item of BLUEPRINTS) {
expect(item.id).toBe(`blueprint.${item.slot}.${item.level}`);
expect(item.name).not.toMatch(/\d|级|图纸/);
}
expect(
BLUEPRINTS.find((item) => item.id === 'blueprint.armor.10')?.name,
).toBe('炼气期法衣');
});
});
@@ -291,3 +291,53 @@ describe('手游参照召唤兽派生公式', () => {
).toBe(376);
});
});
it.each([
{
speciesId: 'combat.wild.species.spirit-fox',
ranges: [
[672, 840],
[864, 1080],
[2880, 3600],
[1536, 1920],
[864, 1080],
],
growth: [982, 1030],
},
{
speciesId: 'combat.wild.species.rock-boar',
ranges: [
[816, 1020],
[1180, 1440],
[3960, 4950],
[1536, 1920],
[576, 720],
],
growth: [1012, 1060],
},
{
speciesId: 'combat.wild.species.wind-wolf',
ranges: [
[1104, 1380],
[624, 780],
[2160, 2700],
[960, 1200],
[864, 1080],
],
growth: [952, 1000],
},
])(
'species $speciesId rolls stay within the confirmed design ranges',
({ speciesId, ranges, growth }) => {
const id = '00000000-0000-4000-8000-000000000001';
for (let seed = 0; seed < 128; seed++) {
const beast = generateStarterBeast(id, id, speciesId, seed);
Object.values(beast.aptitudes).forEach((value, i) => {
expect(value).toBeGreaterThanOrEqual(ranges[i][0]);
expect(value).toBeLessThanOrEqual(ranges[i][1]);
});
expect(beast.growth).toBeGreaterThanOrEqual(growth[0] / 1000);
expect(beast.growth).toBeLessThanOrEqual(growth[1] / 1000);
}
},
);
@@ -1,73 +0,0 @@
import { createHash } from 'node:crypto';
import { expect, it } from 'vitest';
import { beastPanel, projectBeastRoster } from './projection.js';
import { generateStarterBeast } from './generator.js';
import { beastRestCost, gainBeastExp } from './progression.js';
import { generateCapturedBeast } from './generator.js';
// Species revision 11 rolls each candidate skill independently at 1/2.
// Individual v3 adds baby free points and a permanent wild point deficit.
// Aptitude/growth draw order and progression revision 3 remain unchanged.
const baseline = [
{
speciesId: 'combat.wild.species.spirit-fox',
hash: '606f8c403d80c6ce559bc7872d1c680546965287a6cbf183a14a8e65dae485eb',
},
{
speciesId: 'combat.wild.species.rock-boar',
hash: 'e0afd87408f969e32d6018c4d0bd76833043adfd2e5d1d4af08004090311dc71',
},
{
speciesId: 'combat.wild.species.wind-wolf',
hash: '94b19b7ed5ae904fa6d8215509561b2d198901a8159412ea7dc9e6101312b64c',
},
];
function digest(speciesId: string) {
const owner = '00000000-0000-4000-8000-000000000001',
id = '00000000-0000-4000-8000-000000000002';
const results = Array.from({ length: 128 }, (_, seed) =>
[
generateStarterBeast(id, owner, speciesId, seed),
...[0, 10, 90, 180].map((level) =>
generateCapturedBeast(id, owner, speciesId, level, seed),
),
].map((beast) => ({
beast,
panel: beastPanel(beast),
grown: gainBeastExp(beast, 5000, 180),
cost: beastRestCost({ ...beast, currentLifespan: 123 }),
roster: projectBeastRoster(
{
beasts: [beast],
lineup: { carriedBeastIds: [id], leadBeastId: id, revision: 0 },
},
owner,
0,
0,
),
})),
);
const facts = JSON.stringify(results);
return createHash('sha256').update(facts).digest('hex');
}
it.each(baseline)(
'matches current $speciesId outputs for 128 seeds',
({ speciesId, hash }) => expect(digest(speciesId)).toBe(hash),
);
it.each([
{ index: 0, ranges: [[672, 840], [864, 1080], [2880, 3600], [1536, 1920], [864, 1080]], growth: [982, 1030] },
{ index: 1, ranges: [[816, 1020], [1180, 1440], [3960, 4950], [1536, 1920], [576, 720]], growth: [1012, 1060] },
{ index: 2, ranges: [[1104, 1380], [624, 780], [2160, 2700], [960, 1200], [864, 1080]], growth: [952, 1000] },
])('new species $index rolls stay within the confirmed design ranges', ({ index, ranges, growth }) => {
const id = '00000000-0000-4000-8000-000000000001';
for (let seed = 0; seed < 128; seed++) {
const beast = generateStarterBeast(id, id, baseline[index].speciesId, seed);
Object.values(beast.aptitudes).forEach((value, i) => {
expect(value).toBeGreaterThanOrEqual(ranges[i][0]);
expect(value).toBeLessThanOrEqual(ranges[i][1]);
});
expect(beast.growth).toBeGreaterThanOrEqual(growth[0] / 1000);
expect(beast.growth).toBeLessThanOrEqual(growth[1] / 1000);
}
});
@@ -1,14 +0,0 @@
import { createHash } from "node:crypto"
import { describe, expect, it } from "vitest"
import { projectCharacterToCombatV6 } from './project-character.js';
import { type CharacterCombatInput } from '@daoyou/game-domain/combat';
import baseline from "./fixtures/character-build.json" with { type: 'json' }
describe("current character build", () => {
it("preserves approved panels, content, resources and diagnostics", () => {
const actual = baseline.inputs.map((input) => createHash("sha256")
.update(JSON.stringify(projectCharacterToCombatV6(input as unknown as CharacterCombatInput)))
.digest("hex"))
expect(actual).toEqual(baseline.expected)
})
})
File diff suppressed because it is too large. Load diff
@@ -5,9 +5,35 @@ import { generateForgedEquipment } from '../../equipment/forging.js';
import { generateDaoEquipmentV2 } from '../../equipment/generator.js';
import { projectCharacterToCombatV6 } from './project-character.js';
import { type CharacterCombatInput } from '@daoyou/game-domain/combat';
import baseline from './fixtures/character-build.json' with { type: 'json' };
const input = baseline.inputs[0] as unknown as CharacterCombatInput;
const input: CharacterCombatInput = {
cultivator: {
id: 'a1',
name: '装配基线',
realm: '炼气',
realm_stage: '后期',
attributes: {
vitality: 10,
strength: 20,
spirit: 30,
endurance: 40,
speed: 50,
willpower: 60,
},
},
equipment: {},
manuals: {
version: 1,
revision: 0,
learned: [],
build: {
slots: [],
},
},
side: 0,
slot: 0,
resourcePolicy: 'full',
};
const weaponInput = {
id: 'meridian-weapon', createdAt: '2026-09-22', seed: 17,
templateId: 'dao_equipment.standard.weapon.v1', equipmentLevel: 10,
@@ -1,150 +0,0 @@
import { createHash } from 'node:crypto';
import { expect, it } from 'vitest';
import { generateForgedEquipment } from './forging.js';
import { generateDaoEquipmentV1, generateDaoEquipmentV2 } from './generator.js';
import { DAO_EQUIPMENT_SLOTS } from '@daoyou/game-domain/equipment';
import { forgedName } from '../forging/names.js';
// 经确认的14器蕴与38器诀产出池基线:炼气、化神、金丹。
// V4 八行由 elements.test.ts、V5 器形与命名由 weapons.test.ts 验证;这里锁定 V3 的旧数值随机流。
const baselines = [
{
slot: 'weapon',
level: 10,
hash: '2000971a143fee5f0977c15bfeb359df9212f9666bcfb36de9a4dac3a74af5bf',
},
{
slot: 'weapon',
level: 90,
hash: 'e43b208878180e72fa7a7df128596586f9cd1276f4ea3a4cfce5a463cd8360cb',
},
{
slot: 'weapon',
level: 50,
hash: 'f597cc780f99d35f8aeea6d3ddf6fa9608415ceb443f388f7d7386880b508125',
},
{
slot: 'head',
level: 10,
hash: '54e78b5a74047193a77f891e545a87fdd0625549ed1a74fa8065962c10b1f0b3',
},
{
slot: 'head',
level: 90,
hash: '9fac3c40a3da4fb25f8b4f27609dc51c38b87cca624fce134588b814294e3db5',
},
{
slot: 'head',
level: 50,
hash: '37ff566219696071ea98840a54c09e94720cbab42cd58a48e6e4b13c1b2c3c97',
},
{
slot: 'armor',
level: 10,
hash: '43b1605459c688d3c87ea615d8dfe999183e9852e95b4634e28c0ed292422194',
},
{
slot: 'armor',
level: 90,
hash: 'a51bb2f7c7e425950122a4ab29aafaaef6f2aa384018fe1899b57f8d1319baab',
},
{
slot: 'armor',
level: 50,
hash: '2b1fac655caf76204acc4bf172c9115b89af6ca9318ca2d137de8af10baeb222',
},
{
slot: 'necklace',
level: 10,
hash: '5238b217dbf342b20ede5b9af61fbde3d07943a2e03f21a38d7f2872c662adea',
},
{
slot: 'necklace',
level: 90,
hash: '4432e4b9aa689470d3309f1e9e8552deefda57c016813c6f8617c5e2444c6407',
},
{
slot: 'necklace',
level: 50,
hash: '5b15bada5676730e9d7e867de59d02c3d7fd6dddf5831f321167b9490d07b9e6',
},
{
slot: 'belt',
level: 10,
hash: '80af41d96281bae6965b7113a709b30ae1f187bc7f70f5d3904c2d79a9002d2c',
},
{
slot: 'belt',
level: 90,
hash: '7ee3881bed1859d3ece8e2cbd548a67d70271777211d1595ef08338188284778',
},
{
slot: 'belt',
level: 50,
hash: '5ea732ee6ead0c2f43ba93a86cd2d71c2e097dd1107da2463e36f753493b4a14',
},
{
slot: 'footwear',
level: 10,
hash: '8d34ccd231f0e1bd9da49fab95ca6e294818ff2d39066f87e2a6c3e0231a0e07',
},
{
slot: 'footwear',
level: 90,
hash: '57cd033b0c5dc866c112feac22359a5d1217a1830d5b59ebeea4f8c3ca49ddf0',
},
{
slot: 'footwear',
level: 50,
hash: '1fafc842d0907918c69eace383c707ebc5dd9849c17cde978d785626c37cd73c',
},
] as const;
function digest(
slot: (typeof DAO_EQUIPMENT_SLOTS)[number],
equipmentLevel: number,
) {
const results = Array.from({ length: 256 }, (_, seed) => {
const input = {
id: 'baseline',
createdAt: '2026-09-11T00:00:00.000Z',
templateId: `dao_equipment.standard.${slot}.v1`,
equipmentLevel,
seed,
};
return [
generateDaoEquipmentV1({
...input,
generatorVersion: 'dao_equipment_generator_v1',
}),
generateDaoEquipmentV2({
...input,
generatorVersion: 'dao_equipment_generator_v2',
}),
...[
{ ore: 5, essence: 0, attributes: 0 },
{ ore: 0, essence: 0, attributes: 5 },
{ ore: 2, essence: 1, attributes: 2 },
].map((boosts) => {
const result = generateForgedEquipment({ ...input, boosts });
if (!result.ok) return result;
const { element: _element, weaponType: _weaponType, ...instance } = result.instance;
return { ...result, instance: { ...instance, name: forgedName(slot, equipmentLevel, seed), generatorVersion: 'dao_equipment_generator_v3' } };
}),
].map(result => {
if (!result.ok) return result;
// 双空孔是新增存储结构;独立验证后剔除,以继续锁定原数值和随机流摘要。
expect(result.instance.formationInscriptions).toEqual([null, null]);
const { formationInscriptions: _formations, ...instance } = result.instance;
return { ...result, instance };
});
});
return createHash('sha256').update(JSON.stringify(results)).digest('hex');
}
it.each(baselines)(
'preserves realm-tier output for $slot at level $level across 256 seeds',
({ slot, level, hash }) => {
expect(digest(slot, level)).toBe(hash);
},
);
@@ -1,4 +1,3 @@
import { createHash } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import {
@@ -16,41 +15,6 @@ describe('副本奖励数据包', () => {
expect(z.toJSONSchema(DungeonRewardPackShape, { reused: 'ref' })).toEqual(
schema,
));
it('三类来源、四档等级、128种子保持原有掉落件数', () => {
const pack = loadDungeonRewardPack(raw);
for (const source of Object.values(pack.sources))
source.bonusChances = {
originDew: 0,
superiorOriginDew: 0,
superiorBook: 0,
blueprint: 0,
};
const rows = (['exploration', 'battle', 'completion'] as const).flatMap(
(source) =>
[1, 60, 120, 180].flatMap((level) =>
Array.from({ length: 128 }, (_, seed) =>
(() => {
const reward = planDungeonReward(
seed,
`baseline-${seed}`,
source,
level,
pack,
);
return {
key: reward.key,
quantity:
reward.materialCount +
reward.items.reduce((sum, item) => sum + item.quantity, 0),
};
})(),
),
),
);
expect(
createHash('sha256').update(JSON.stringify(rows)).digest('hex'),
).toBe('83dd6a2ab498a603a3516716e2df8a42307847e8caadfa5690f17d5e38de3c0f');
});
it('材料、数量及掉落配置进入最终奖励', () => {
const data = structuredClone(raw);
data.sources.battle = {
@@ -3,16 +3,10 @@ import {
STANDARD_SECT_PRESENTATION,
StandardSectOrganizationModule,
} from '../core/index.js';
import type { SectPresentationTheme } from '../core/index.js';
import { LINGXIAO_SECT_PRESENTATION } from './lingxiao/index.js';
import {
PRODUCTION_SECT_PRESENTATIONS,
PRODUCTION_SECTS,
} from './productionRuntime.js';
import { TIANYAN_SECT_PRESENTATION } from './tianyan/index.js';
import { WUXIANG_SECT_PRESENTATION } from './wuxiang/index.js';
import { YOUDU_SECT_PRESENTATION } from './youdu/index.js';
import { JIUJIE_SECT_PRESENTATION } from './jiujie/index.js';
const taskIds = [
'gate_sweep',
@@ -27,24 +21,6 @@ const taskIds = [
'elder_trial',
] as const;
const canonicalNpcRoles = [
{
sigil: '执',
identity: '值日执事',
responsibility: '负责日常委托。',
},
{
sigil: '簿',
identity: '功簿执事',
responsibility: '负责周常委托。',
},
{
sigil: '传',
identity: '传功长老',
responsibility: '负责晋升试炼。',
},
] as const;
const facilityActorKeys = [
['hall', 'registry'],
['hall', 'stipend'],
@@ -66,179 +42,7 @@ const facilityActorKeys = [
['gate', 'facility'],
] as const;
const expectedFacilityNames: Readonly<Record<string, readonly string[]>> = {
[LINGXIAO_SECT_PRESENTATION.sectId]: [
'顾怀真',
'柳七',
'叶归鸿',
'杜长庚',
'苗小满',
'温不言',
'祝平生',
'霍千钧',
'苏放鹤',
'晏无声',
'程晚照',
'谭折柳',
'邵沉川',
'秦晚晴',
'骆长亭',
'灵脉矿场',
'宗门药田',
'山门',
],
[TIANYAN_SECT_PRESENTATION.sectId]: [
'含章真人',
'清和',
'怀谷道人',
'鸣谦道人',
'履霜',
'既白真人',
'观复真人',
'玄同道人',
'景初',
'抱一真人',
'允中道人',
'松乔道人',
'见素',
'元吉',
'望舒',
'坤元地脉',
'长生圃',
'观象门',
],
[WUXIANG_SECT_PRESENTATION.sectId]: [
'慧澄',
'明济',
'寂照禅师',
'行深',
'明简',
'空渡禅师',
'慧照',
'法忍禅师',
'行觉',
'寂然禅师',
'明恕',
'法圆',
'慧海',
'行愿',
'道安禅师',
'骨玉窟',
'血莲池',
'不二门',
],
[YOUDU_SECT_PRESENTATION.sectId]: [
'沈故衣',
'温婆婆',
'阮秋声',
'戚百岁',
'阿七',
'褚先生',
'商无咎',
'迟归鹤',
'桑小满',
'宁无恙',
'白蘅',
'祝余',
'贺寒川',
'柳十三',
'顾长夜',
'黑水阴脉',
'彼岸圃',
'无日关',
],
[JIUJIE_SECT_PRESENTATION.sectId]: [
'司雷',
'听劫',
'衡天',
'营造监',
'捐献使',
'掌卷',
'引路天官',
'演武教习',
'执刑',
'守静',
'听雷丹师',
'断劫使',
'雷髓监',
'守园',
'守天阶',
'中天雷池',
'天听木圃',
'九劫天阶',
],
};
const productionThemes: readonly SectPresentationTheme[] = [
LINGXIAO_SECT_PRESENTATION,
TIANYAN_SECT_PRESENTATION,
WUXIANG_SECT_PRESENTATION,
YOUDU_SECT_PRESENTATION,
JIUJIE_SECT_PRESENTATION,
];
const canonicalMapNotes: Readonly<Record<string, string>> = {
hall: '身份 · 同门 · 周俸',
archive: '心法研习',
cliff: '流派 · 参悟',
arena: '神通 · 战术 · 小比',
affairs: '日常 · 周常 · 晋升',
treasury: '贡献兑换',
industries: '设施建设 · 灵石捐献',
cultivation: '闭关修炼 · 设施灵效',
alchemy: '炼丹 · 设施灵效',
refinery: '炼器 · 设施灵效',
vein: '矿场巡视 · 灵石收益 · 采矿',
garden: '草木长势 · 产出待开放',
gate: '山门动态 · 清扫差事',
cave: '弟子居所',
formation: '宗门战后续开放',
};
describe('production sect affairs presentations', () => {
it('keeps map notes aligned with current facility responsibilities', () => {
for (const theme of productionThemes) {
const notes = Object.fromEntries(
theme.map?.hotspots?.map((hotspot) => [hotspot.id, hotspot.note]) ?? [],
);
expect(notes).toEqual(
theme.sectId === 'jiujie'
? {
...canonicalMapNotes,
cliff: '霹雳真君 · 流派参悟',
condemnation: '踏雷天尊 · 流派参悟',
}
: canonicalMapNotes,
);
}
});
it('provides twelve named NPCs with canonical roles', () => {
const expectedNames = [
['陆青崖', '裴守拙', '听剑老人'],
['法明', '慧觉', '空慈方丈'],
['知微', '玄衡道人', '观澜真人'],
['照灯', '守簿翁', '归魂婆婆'],
['照雷执事', '守簿翁', '九门长老'],
];
const presentations = Object.values(PRODUCTION_SECT_PRESENTATIONS);
expect(presentations).toHaveLength(5);
expect(
presentations.map((presentation) =>
presentation.rooms.affairs.actors.map((npc) => npc.name),
),
).toEqual(expectedNames);
for (const presentation of presentations) {
const npcs = presentation.rooms.affairs.actors;
expect(new Set(npcs.map((npc) => npc.id)).size).toBe(3);
expect(npcs).toMatchObject(canonicalNpcRoles);
for (const npc of npcs) expect(npc.greeting.trim()).not.toBe('');
expect(presentation.terms.sweepActivity).toBe('清扫山门');
expect(presentation.terms.sweepCanvasLabel).toBe('清扫山门游戏画布');
}
});
it('uses one canonical presentation for every standard task', () => {
const standard = new StandardSectOrganizationModule();
@@ -275,18 +79,14 @@ describe('production sect affairs presentations', () => {
}
});
it('uses the exact themed facility names without changing role semantics', () => {
for (const [sectId, expectedNames] of Object.entries(
expectedFacilityNames,
)) {
const presentation = PRODUCTION_SECT_PRESENTATIONS[sectId];
it('preserves standard facility roles and conversation bindings', () => {
for (const presentation of Object.values(PRODUCTION_SECT_PRESENTATIONS)) {
const actors = facilityActorKeys.map(([roomKey, roleKey]) =>
presentation.rooms[roomKey].actors.find(
(actor) => actor.roleKey === roleKey,
),
);
expect(actors.map((actor) => actor?.name)).toEqual(expectedNames);
for (const [index, actor] of actors.entries()) {
const [roomKey, roleKey] = facilityActorKeys[index];
const standardActor = STANDARD_SECT_PRESENTATION.rooms[
@@ -301,30 +101,22 @@ describe('production sect affairs presentations', () => {
});
expect(actor?.greeting.trim()).not.toBe('');
}
const affairsNames = presentation.rooms.affairs.actors.map(
(actor) => actor.name,
);
const allNames = [...affairsNames, ...expectedNames];
expect(new Set(allNames).size).toBe(21);
expect(expectedNames.slice(0, 15).join('')).not.toMatch(
/[籍禄库仓材门脉药炉铸场魂鬼阴灯影]/u,
);
}
});
it('requires every production theme to explicitly name facility actors', () => {
for (const theme of productionThemes) {
const expectedNames = expectedFacilityNames[theme.sectId];
const overrides = facilityActorKeys.map(([roomKey, roleKey]) => {
const actor = theme.rooms?.[roomKey]?.actors?.[roleKey];
expect(actor?.id?.trim()).not.toBe('');
expect(actor?.name?.trim()).not.toBe('');
expect(actor?.greeting?.trim()).not.toBe('');
return actor;
});
it('exposes only the gate and formation to foreign visitors', () => {
for (const presentation of Object.values(PRODUCTION_SECT_PRESENTATIONS)) {
const visitorHotspots = presentation.map.hotspots
.filter((hotspot) => hotspot.visitor)
.map((hotspot) => hotspot.id)
.sort();
expect(overrides.map((actor) => actor?.name)).toEqual(expectedNames);
expect(visitorHotspots).toEqual(['formation', 'gate']);
expect(
presentation.map.hotspots
.filter((hotspot) => hotspot.visitor)
.every((hotspot) => hotspot.visitor!.description.trim().length > 0),
).toBe(true);
}
});
});
@@ -1,53 +0,0 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { PRODUCTION_SECT_PRESENTATIONS } from './productionRuntime.js';
const forbiddenPlayerTerms =
/[0-9%]|倍率|冷却|配置|构筑|技能槽|数值|开发|接口|数据库/;
describe('production sect onboarding presentations', () => {
it('provides immersive scripts and local key art without player-facing system terms', () => {
for (const presentation of Object.values(PRODUCTION_SECT_PRESENTATIONS)) {
const onboarding = presentation.onboarding;
expect(onboarding).toBeDefined();
expect(onboarding?.traits).toHaveLength(3);
expect(onboarding?.script.acts).toHaveLength(5);
expect(onboarding?.script.backdrop.alt.trim()).not.toBe('');
const playerCopy = [
onboarding?.summary,
...(onboarding?.traits ?? []),
...(onboarding?.script.acts.flatMap((act) => [
act.title,
act.scene,
act.body,
act.speaker ?? '',
]) ?? []),
].join('\n');
expect(playerCopy).not.toMatch(forbiddenPlayerTerms);
const assetPath = onboarding?.script.backdrop.src.replace(/^\//, '');
expect(assetPath).toBeTruthy();
expect(
existsSync(join(process.cwd(), 'apps/web/public', assetPath!)),
).toBe(true);
}
});
it('exposes only the gate and formation to foreign visitors', () => {
for (const presentation of Object.values(PRODUCTION_SECT_PRESENTATIONS)) {
const visitorHotspots = presentation.map.hotspots
.filter((hotspot) => hotspot.visitor)
.map((hotspot) => hotspot.id)
.sort();
expect(visitorHotspots).toEqual(['formation', 'gate']);
expect(
presentation.map.hotspots
.filter((hotspot) => hotspot.visitor)
.every((hotspot) => hotspot.visitor!.description.trim().length > 0),
).toBe(true);
}
});
});
@@ -1,156 +0,0 @@
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const root = resolve(
process.cwd(),
'packages/game-rules/src/sect-organization',
);
const contentRoot = resolve(
process.cwd(),
'packages/game-content/src/sect-organization',
);
const forbiddenSectContentDependency =
/from ['"](?:@daoyou\/game-content\/sect-organization\/(?!standard(?:\/|['"]))|@daoyou\/game-content\/sects(?:\/|['"])|[^'"]*\/(?:content|production)(?:\/|['"]))/;
const productionSectTerms =
/lingxiao|红尘剑宗|凌霄|wuxiang|无相|tianyan|天衍|youdu|幽都|jiujie|九劫天宫|sect\.(?:lingxiao|wuxiang|tianyan|youdu|jiujie)/i;
const removedBattleExtensions =
/postDamageEffects|DamageDisplayMetadata|buffLayerScalar|ElementHistory|element_history|elementHistories|BuffPeriodicSettlement|buff_periodic_settlement|manualSettlementEffects/;
const forbiddenBattleDependency =
/from ['"][^'"]*(?:engine\/sect\/content|sect\/content|react-app)[^'"]*['"]/;
function sourceFiles(directory: string): string[] {
return readdirSync(directory).flatMap((name) => {
const path = join(directory, name);
return statSync(path).isDirectory()
? sourceFiles(path)
: /\.(ts|tsx)$/.test(name)
? [path]
: [];
});
}
describe('宗门插件架构守卫', () => {
it('宗门组织和静态历史目录不再导入旧战斗或造物引擎', () => {
for (const file of [root, contentRoot]
.flatMap(sourceFiles)
.filter((path) => !/\.test\.(ts|tsx)$/.test(path))) {
expect(readFileSync(file, 'utf8'), relative(root, file)).not.toMatch(
/battle-v5|creation-v2/,
);
}
});
it('根目录只保留公共入口、说明和分层目录', () => {
expect(readdirSync(root).sort()).toEqual(
['README.md', 'production', 'core', 'index.ts', 'testing'].sort(),
);
});
it('sect/core 不依赖具体生产宗门、流派、节点或内容目录', () => {
for (const file of sourceFiles(join(root, 'core')).filter(
(path) =>
!path.includes('/tests/') && !/\.(?:test|spec)\.(ts|tsx)$/.test(path),
)) {
const source = readFileSync(file, 'utf8');
const label = relative(root, file);
expect(source, label).not.toMatch(productionSectTerms);
expect(source, label).not.toMatch(/swift-sword|heavy-sword|fixture-sect/);
expect(source, label).not.toMatch(forbiddenSectContentDependency);
expect(source, label).not.toContain('paths[0]');
}
const publicEntry = readFileSync(join(root, 'index.ts'), 'utf8');
expect(publicEntry).not.toMatch(productionSectTerms);
expect(publicEntry).not.toMatch(
/(?:export|import)[^\n]*from ['"]\.\/(?:content|production)/,
);
});
it('已退出的单宗门战斗扩展不会重新进入生产源码', () => {
const productionRoots = [
join(root, 'core'),
join(root, 'production'),
contentRoot,
];
for (const file of productionRoots
.flatMap(sourceFiles)
.filter((path) => !/\.(?:test|spec)\.(ts|tsx)$/.test(path))) {
const source = readFileSync(file, 'utf8');
expect(source, relative(process.cwd(), file)).not.toMatch(
removedBattleExtensions,
);
}
});
it('架构关键词守卫能识别生产宗门、非法依赖和退出接口', () => {
expect('sect.youdu.soul').toMatch(productionSectTerms);
expect('天衍反应').toMatch(productionSectTerms);
expect('sect.jiujie.calamity').toMatch(productionSectTerms);
expect('九劫天宫').toMatch(productionSectTerms);
expect("from '@daoyou/shared/engine/sect/content/tianyan'").toMatch(
forbiddenBattleDependency,
);
expect('manualSettlementEffects').toMatch(removedBattleExtensions);
expect("from '@daoyou/game-content/sects/registry'").toMatch(
forbiddenSectContentDependency,
);
expect(
"from '@daoyou/game-content/sect-organization/lingxiao/definition'",
).toMatch(forbiddenSectContentDependency);
expect("from '../../content/productionRuntime.js'").toMatch(
forbiddenSectContentDependency,
);
expect("from '../../production/productionRuntime.js'").toMatch(
forbiddenSectContentDependency,
);
expect(
"from '@daoyou/game-content/cultivation/cultivationExpGain'",
).not.toMatch(forbiddenSectContentDependency);
expect(
"from '@daoyou/game-content/sect-organization/standard/presentation'",
).not.toMatch(forbiddenSectContentDependency);
});
it('通用核心不固定流派层数或每层节点数', () => {
for (const file of sourceFiles(join(root, 'core')).filter(
(path) =>
!path.includes('/tests/') && !/\.(?:test|spec)\.(ts|tsx)$/.test(path),
)) {
const source = readFileSync(file, 'utf8');
expect(source, relative(root, file)).not.toMatch(
/EXPECTED_LAYERS|固定六层|必须恰有3个节点/,
);
}
});
it('测试宗门不会进入生产组合根', () => {
const production = readFileSync(
join(root, 'production/productionRuntime.ts'),
'utf8',
);
expect(production).not.toMatch(/fixture|testing/);
});
it('通用宗门前端不依赖具体宗门或固定内容数量', () => {
const frontendRoots = [
resolve(process.cwd(), 'apps/web/src/routes/game/sect'),
resolve(process.cwd(), 'apps/web/src/components/feature/sect'),
resolve(process.cwd(), 'apps/web/src/lib/sect'),
];
for (const file of frontendRoots
.flatMap(sourceFiles)
.filter((path) => !/\.test\.(ts|tsx)$/.test(path))) {
const source = readFileSync(file, 'utf8');
const label = relative(process.cwd(), file);
expect(source, label).not.toMatch(
/lingxiao|凌霄|红尘剑宗|剑道|剑路|剑痕|九门|六层|藏经阁|剑录阁|悟道崖|照影崖|演武台|试剑台|执事堂|百业院|云阶扫叶|山阶扫叶/,
);
expect(source, label).not.toMatch(
/presentation\/lingxiao|\[null, null, null, null\]|\[1, 2, 3\]/,
);
}
});
});
@@ -1,16 +1,10 @@
import { createHash } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { SECTS_PROGRESSION_PROGRESSION_DATA as raw } from '@daoyou/game-content/sects/progression';
import { SECTS_PROGRESSION_PROGRESSION_SCHEMA as schema } from '@daoyou/game-content/authoring/sects';
import { SectProgressionPackShape, loadSectProgressionPack, configuredMethodCost, configuredMeridianCost } from '../progression-pack.js';
import { methodTrainingCost, meridianUnlockCost } from '../progression.js';
describe('宗门养成配置', () => {
it('保留迁移前全部 180 级心法和七层经脉费用', () => {
const costs = [Array.from({ length: 180 }, (_, i) => methodTrainingCost(i + 1)), Array.from({ length: 7 }, (_, i) => meridianUnlockCost(i + 1))];
expect(createHash('sha256').update(JSON.stringify(costs)).digest('hex')).toBe('8a0fd8b55fa34709a63fbec9205209d9336bda428ac0c33e249e0657e435462d');
});
it('编辑器 Schema 与运行时结构一致', () => {
expect(z.toJSONSchema(SectProgressionPackShape)).toEqual(schema);
});
@@ -1,4 +1,3 @@
import { createHash } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import {
@@ -13,10 +12,6 @@ import { compileSectDefinitionV6 } from './compiler.js';
import { createEmptySectCombatProgressV6, createFreshCombatV6MethodLevels } from './build-state.js';
describe('宗门技能学习配置', () => {
it('技能、状态、流派和心法保持基线(含比例伤害分类)', () => {
// 九劫 paths revision 4 将共鸣文案中的奔雷更名为九霄神雷;机制由专项测试验证。
expect(createHash('sha256').update(JSON.stringify(COMBAT_V6_SECT_DEFINITIONS)).digest('hex')).toBe("3419722218ff4dcf2d494bbee4100ef963a05031aeabb3fd3948b9dbf6779513");
});
it('Schema 与编辑器一致', () => {
expect(z.toJSONSchema(SectSkillLearningShape)).toEqual(schema);
});
@@ -1,182 +0,0 @@
import { expect, it } from 'vitest';
import { automaticCommands } from '../combat/auto.js';
import { getTowerSeasonMeta } from './season.js';
import { createTowerWeek } from './weekly.js';
import { type TowerBlessings } from '@daoyou/game-domain/tower';
import { createTowerHost } from './host.js';
import { towerReferenceBuild } from './reference-fixtures.js';
const week = createTowerWeek(getTowerSeasonMeta(new Date('2026-09-19')));
const seeds = [7, 19, 42, 73, 101, 211, 307, 401, 503, 601];
type Policy = 'main' | 'support' | 'control-support' | 'defend-heavy';
function fight(
sect: 'lingxiao' | 'jiujie' | 'youdu',
formation: 'solo' | 'healer' | 'guarded',
combo: string,
policy: Policy,
seed: number,
floor: number,
) {
const player = towerReferenceBuild(sect);
const selected = {
...week,
floors: week.floors.map((r) =>
r.floor === floor
? { ...r, combinationId: combo, formationId: formation }
: r,
),
};
const blessings: TowerBlessings =
sect === 'lingxiao'
? { physical_power: 2, guard: 2, beast_power: 1 }
: { spell_power: 2, guard: 2, beast_power: 1 };
if (floor === 20) {
blessings[sect === 'lingxiao' ? 'physical_power' : 'spell_power'] = 3;
blessings.guard = 3;
blessings.beast_power = 3;
}
const host = createTowerHost(
player,
'金丹',
floor,
blessings,
selected,
seed,
);
for (let i = 0; i < 60 && !host.finished; i++) {
const snapshot = host.runtimeSnapshot();
const skills = snapshot.input.skills ?? [];
const options = (id: string) => host.queryCommands(id);
const commands = automaticCommands(
host.state,
host.playerId,
skills,
options,
{ statusDefs: snapshot.input.statusDefs },
);
const enemies = host.state.units.filter(
(u) => u.side === 1 && u.attrs.hp > 0,
);
const target =
policy === 'support' || policy === 'control-support'
? (enemies.find((u) => u.slot > 0) ?? enemies[0])
: enemies[0];
for (const entry of commands) {
const choices = options(entry.unitId);
if (
entry.command.type === 'attack' &&
choices.attackTargetIds.includes(target.id)
)
entry.command = { type: 'attack', target: target.id };
if (entry.command.type === 'skill') {
const choice = choices.skills.find(
(s) =>
s.skillId ===
(entry.command.type === 'skill' ? entry.command.skillId : ''),
);
if (choice?.selectableTargetIds.includes(target.id))
entry.command = {
...entry.command,
targets: [
target.id,
...choice.selectableTargetIds.filter((id) => id !== target.id),
].slice(0, choice.targetCount),
};
}
if (entry.unitId !== host.playerId) continue;
if (
policy === 'control-support' &&
target.slot > 0 &&
!target.statuses.some((s) => s.category === 'control')
) {
const control = choices.skills.find(
(s) =>
s.ready &&
s.selectableTargetIds.includes(target.id) &&
skills.find((d) => d.id === s.skillId)?.tags?.includes('seal'),
);
if (control)
entry.command = {
type: 'skill',
skillId: control.skillId,
targets: [target.id],
};
}
const leader = enemies.find((u) => u.slot === 0);
const plan = leader ? snapshot.npcPlans[leader.id] : undefined;
if (
policy === 'defend-heavy' &&
plan?.cycle[(host.state.round - 1) % plan.cycle.length] ===
'tower.heavy'
)
entry.command = { type: 'defend' };
}
host.submitGroup(commands);
host.resolveRound();
}
expect(host.finished).toBe(true);
const damage = host
.trace()
.events.reduce(
(total, e) =>
total +
(e.type === 'damage' && e.targetId === host.playerId ? e.amount : 0),
0,
);
return {
win: host.trace().outcome === 'victory',
rounds: host.trace().rounds.length,
damage,
};
}
it.each([10, 20])(
'第 %i 层:相同构筑与种子对照辅助、护卫和重击策略',
(floor) => {
const rows = [];
for (const sect of ['lingxiao', 'jiujie', 'youdu'] as const) {
for (const [formation, combo, policies] of [
['healer', 'mage-vital-swift', ['main', 'support', 'control-support']],
['guarded', 'warrior-ward-calm', ['main', 'support']],
['solo', 'warrior-armor-charge', ['main', 'defend-heavy']],
] as const) {
for (const policy of policies) {
const results = seeds.map((seed) =>
fight(sect, formation, combo, policy, seed, floor),
);
rows.push({
floor,
sect,
formation,
policy,
wins: results.filter((r) => r.win).length,
rounds: results.reduce((n, r) => n + r.rounds, 0),
damage: results.reduce((n, r) => n + r.damage, 0),
});
}
}
}
console.info('tower-phase2-strategies', JSON.stringify(rows));
for (const formation of ['healer', 'guarded', 'solo']) {
expect(
rows.some(
(r) =>
r.floor === floor &&
r.formation === formation &&
r.policy !== 'main' &&
rows.some(
(base) =>
base.floor === floor &&
base.sect === r.sect &&
base.formation === formation &&
base.policy === 'main' &&
(r.wins !== base.wins ||
r.damage !== base.damage ||
r.rounds !== base.rounds),
),
),
`${floor}/${formation}`,
).toBe(true);
}
},
15_000,
);
@@ -51,13 +51,6 @@ describe('world atlas location coverage', () => {
},
);
it('keeps 20 locations in Dajin after separating north and south', () => {
expect(
getAtlasLocations().filter(
(location) => getAtlasRegion(location)?.id === 'dajin',
),
).toHaveLength(20);
});
it('resolves every existing location, including satellites and sects, to a region', () => {
for (const location of getAtlasLocations()) {
-143
View File
@@ -1,143 +0,0 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
type Manifest = {
name: string;
exports?: Record<string, string>;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const allowed: Record<string, string[]> = {
constants: [],
'game-domain': ['constants', 'combat-core'],
'combat-core': [],
'game-content': ['constants', 'game-domain', 'combat-core'],
'game-rules': ['constants', 'game-domain', 'combat-core', 'game-content'],
contracts: ['constants', 'game-domain', 'combat-core'],
};
const root = process.cwd();
const workspaces = ['apps', 'packages'].flatMap((folder) =>
readdirSync(folder, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(folder, entry.name))
.filter((directory) => existsSync(path.join(directory, 'package.json')))
.map((directory) => ({
directory,
manifest: JSON.parse(readFileSync(path.join(directory, 'package.json'), 'utf8')) as Manifest,
})),
);
const byName = new Map(workspaces.map((workspace) => [workspace.manifest.name, workspace]));
const errors: string[] = [];
let imports = 0;
function sourceFiles(directory: string): string[] {
if (!existsSync(directory)) return [];
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const file = path.join(directory, entry.name);
return entry.isDirectory() ? sourceFiles(file) : /\.[cm]?[jt]sx?$/.test(file) ? [file] : [];
});
}
for (const { directory, manifest } of workspaces) {
const library = directory.startsWith('packages/');
const permitted = allowed[path.basename(directory)];
if (library && !permitted) errors.push(`${directory}: library has no dependency policy`);
const dependencies = { ...manifest.dependencies, ...manifest.devDependencies };
for (const name of Object.keys(dependencies).filter((name) => name.startsWith('@daoyou/'))) {
if (!byName.has(name)) errors.push(`${directory}: unknown workspace dependency ${name}`);
if (library && !permitted?.includes(name.slice('@daoyou/'.length))) {
errors.push(`${directory}: forbidden dependency ${name}`);
}
if (!library && name.match(/^@daoyou\/(api|web)$/)) {
errors.push(`${directory}: applications cannot depend on each other`);
}
}
for (const [entry, target] of Object.entries(manifest.exports ?? {})) {
if (entry.includes('*')) errors.push(`${directory}: wildcard export ${entry}`);
const source = path.join(directory, target.replace(/^\.\/dist\//, 'src/').replace(/\.js$/, '.ts'));
if (!existsSync(source)) errors.push(`${directory}: export ${entry} has no source: ${source}`);
}
for (const file of sourceFiles(path.join(directory, 'src'))) {
const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true);
function check(specifier: string, typeOnly = false) {
imports++;
if (specifier.startsWith('.')) {
const resolved = path.resolve(path.dirname(file), specifier);
if (!resolved.startsWith(path.resolve(directory, 'src') + path.sep)) {
errors.push(`${file}: relative import leaves workspace source: ${specifier}`);
}
return;
}
if (library && (specifier.startsWith('@app/') || specifier.startsWith('@server/'))) {
errors.push(`${file}: library imports host alias ${specifier}`);
}
if (!specifier.startsWith('@daoyou/')) return;
const [scope, packageName, ...subpath] = specifier.split('/');
const name = `${scope}/${packageName}`;
if (name === '@daoyou/game-content' && subpath[0] === 'authoring' && !/\.(test|spec)\.[cm]?[jt]sx?$/.test(file) && !file.startsWith('scripts/')) {
errors.push(`${file}: content authoring entrypoints are only for content validation tests and maintenance tools`);
}
if (manifest.name === '@daoyou/game-domain' && name === '@daoyou/combat-core' && !typeOnly) {
errors.push(`${file}: domain models may only import combat-core types`);
}
if (library && name === manifest.name) {
errors.push(`${file}: use relative source imports inside a library, not its own dist exports`);
}
const target = byName.get(name);
if (!target) {
errors.push(`${file}: unknown workspace import ${specifier}`);
return;
}
if (name !== manifest.name && !dependencies[name]) {
errors.push(`${file}: undeclared dependency ${name}`);
}
const entry = subpath.length ? `./${subpath.join('/')}` : '.';
if (!target.manifest.exports?.[entry]) errors.push(`${file}: private or missing export ${specifier}`);
if (!/\.(test|spec)\.[jt]sx?$/.test(file) && name !== manifest.name && !manifest.dependencies?.[name]) {
errors.push(`${file}: production import requires a runtime dependency: ${name}`);
}
}
function visit(node: ts.Node) {
if (file.includes('/src/public/') && ts.isExportDeclaration(node) && (!node.exportClause || ts.isNamespaceExport(node.exportClause))) {
errors.push(`${file}: public entrypoints must name their exports explicitly`);
}
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
const typeOnly = ts.isImportDeclaration(node)
? Boolean(node.importClause?.isTypeOnly || (node.importClause && !node.importClause.name && node.importClause.namedBindings && ts.isNamedImports(node.importClause.namedBindings) && node.importClause.namedBindings.elements.length > 0 && node.importClause.namedBindings.elements.every((element) => element.isTypeOnly)))
: Boolean(node.isTypeOnly || (node.exportClause && ts.isNamedExports(node.exportClause) && node.exportClause.elements.length > 0 && node.exportClause.elements.every((element) => element.isTypeOnly)));
check(node.moduleSpecifier.text, typeOnly);
} else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && ts.isStringLiteral(node.argument.literal)) {
check(node.argument.literal.text, true);
} else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) {
const argument = node.arguments[0];
if (argument && ts.isStringLiteral(argument)) check(argument.text);
}
ts.forEachChild(node, visit);
}
visit(source);
}
}
const visited = new Set<string>();
function visitDependencies(name: string, ancestors: string[]) {
if (ancestors.includes(name)) {
errors.push(`Workspace dependency cycle: ${[...ancestors, name].join(' -> ')}`);
return;
}
if (visited.has(name)) return;
const manifest = byName.get(name)!.manifest;
for (const dependency of Object.keys({ ...manifest.dependencies, ...manifest.devDependencies })) {
if (byName.has(dependency)) visitDependencies(dependency, [...ancestors, name]);
}
visited.add(name);
}
for (const name of byName.keys()) visitDependencies(name, []);
if (errors.length) {
console.error([...new Set(errors)].join('\n'));
process.exitCode = 1;
} else {
console.info(`Package boundaries passed: ${workspaces.length} workspaces, ${imports} imports (including types and tests), ${path.basename(root)}.`);
}