删除无用脚本
This commit is contained in:
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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -1,10 +0,0 @@
|
||||
name: Quality
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
uses: ./.github/workflows/quality-check.yml
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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节)
|
||||
|
||||
@@ -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`。错误只报告字段与约束,不输出凭据。
|
||||
|
||||
|
||||
@@ -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 在当次开发任务中操作真实本地页面,不将操作固化为一次性仓库脚本。
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
-156
@@ -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()) {
|
||||
|
||||
@@ -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)}.`);
|
||||
}
|
||||
Reference in new issue
Block a user