From 46baebbc10f0a547e94deb91227a0b510ab05811 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 21 Aug 2026 12:20:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=85=E7=90=86=E8=BF=87=E6=9C=9F=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E4=B8=8E=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除退役内容校验、视觉小说墓碑脚本和旧玩法门禁文档 移除死别名与退役分支触发并同步当前文档入口 为Gitea和Jenkins补齐可信SpacetimeDB schema比较基线 补充workflow、schema和生产运维防回归测试 --- .gitea/workflows/project-ci.yml | 21 +- .gitignore | 2 + README.md | 68 +-- ...玩法创作】创作流程统一总计划-2026-05-30.md | 20 +- .../shared-memory/development-workflow.md | 21 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 20 +- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 +- jenkins/Jenkinsfile.production-api-build | 15 + .../Jenkinsfile.production-stdb-module-build | 15 + package.json | 11 +- quality-gates/README.md | 23 +- .../【玩法创作】统一创作页门禁-2026-05-29.md | 17 - .../【玩法创作】统一生成页门禁-2026-05-29.md | 16 - ...法创作】跨玩法回归与冒烟门禁-2026-05-30.md | 94 ---- scripts/check-native-shells.mjs | 4 +- scripts/check-production-ops-guardrails.mjs | 22 + scripts/check-repository-ci.sh | 1 - scripts/check-spacetime-schema-guard.mjs | 152 +++++-- scripts/check-spacetime-schema-guard.test.ts | 63 +++ .../check-visual-novel-vn11-negative-scan.mjs | 261 ----------- .../check-visual-novel-vn12-acceptance.mjs | 265 ----------- scripts/git-hooks.test.mjs | 1 - scripts/project-ci-workflow.test.ts | 38 ++ scripts/smoke-content.ts | 430 ------------------ scripts/validate-content.ts | 116 ----- scripts/validate-overrides.ts | 277 ----------- 26 files changed, 340 insertions(+), 1635 deletions(-) delete mode 100644 quality-gates/【玩法创作】统一创作页门禁-2026-05-29.md delete mode 100644 quality-gates/【玩法创作】统一生成页门禁-2026-05-29.md delete mode 100644 quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md delete mode 100644 scripts/check-visual-novel-vn11-negative-scan.mjs delete mode 100644 scripts/check-visual-novel-vn12-acceptance.mjs delete mode 100644 scripts/smoke-content.ts delete mode 100644 scripts/validate-content.ts delete mode 100644 scripts/validate-overrides.ts diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index f56925002..d9212897f 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -4,7 +4,6 @@ on: push: branches: - master - - codex/ai-game-creator-app pull_request: workflow_dispatch: @@ -59,6 +58,16 @@ jobs: else base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)" fi + resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)" + head_ref="$(git rev-parse HEAD)" + if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then + resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)" + fi + if [[ -z "${resolved_base_ref}" ]]; then + echo 'comparison base must resolve to a commit distinct from HEAD.' >&2 + exit 1 + fi + base_ref="${resolved_base_ref}" if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \ && ! git merge-base --is-ancestor "${base_ref}" HEAD; then echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2 @@ -139,6 +148,16 @@ jobs: else base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)" fi + resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)" + head_ref="$(git rev-parse HEAD)" + if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then + resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)" + fi + if [[ -z "${resolved_base_ref}" ]]; then + echo 'comparison base must resolve to a commit distinct from HEAD.' >&2 + exit 1 + fi + base_ref="${resolved_base_ref}" if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \ && ! git merge-base --is-ancestor "${base_ref}" HEAD; then echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2 diff --git a/.gitignore b/.gitignore index 9ef28446b..10c5fb1e4 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,8 @@ temp*build*/ /public/generated-characters /.codex-temp /.app/ +/.jenkins-source-commit +/.jenkins-spacetime-schema-base /target/ /logs /.claude/settings.local.json diff --git a/README.md b/README.md index d5ee527a1..f84bee3fb 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,14 @@ -# AI Native Visual RPG +# Genarrative / 陶泥儿 -一个以“AI 叙事 + 本地规则 + 像素演出”为核心的视觉 RPG 原型。 +一个 AI 原生互动内容与小游戏平台,当前主站聚焦图片画布创作、项目与素材管理,以及账号、钱包和后台管理等平台公共能力。 当前已经具备这些主要能力: -- 世界与角色选择 -- AI 剧情推进与流式对话 -- 战斗演出、NPC 战斗、切磋 -- NPC 交易、送礼、求助、招募 -- 宝藏交互 -- 同伴跟随与战斗 -- 游戏主流程内嵌的角色资产工坊、自定义世界实体编辑与角色形象编辑 -- 自动存档与继续游戏 +- 图片画布编辑、项目与素材管理 +- 图片、视频、音频等外部生成任务 +- 账号、钱包、充值、兑换码与个人资料 +- 后台运营、配置与生产运维工具 +- AI 游戏创作独立 App ## 运行 @@ -30,11 +27,8 @@ npm install 准备环境变量: - 复制 `.env.example` 为 `.env.local` -- 填入 `LLM_API_KEY` / `ARK_API_KEY` -- 按需设置 `VITE_LLM_MODEL` -- 如需启用阿里云短信验证码登录,填写 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET`,并确认 `SMS_AUTH_PROVIDER="aliyun"` -- 本地联调短信登录时,建议将 `VITE_AUTH_ALLOW_DEV_GUEST` 设为 `false`,避免开发模式自动进入游客账号而跳过登录页 -- 如需打印完整 prompt/output,可把 `VITE_LLM_DEBUG_LOG` 设为 `true` +- 只填写本次联调所需配置;不要提交 `.env.local`、密钥、Token 或其它本地认证信息 +- api-server 环境变量和 Provider 配置以 `.env.example` 及当前开发运维文档为准 启动开发环境: @@ -69,51 +63,21 @@ npm run lint npm run check:encoding ``` -内容引用校验: - -```bash -npm run check:data -``` - -编辑器 override 校验: - -```bash -npm run check:overrides -``` - -关键内容 smoke 检查: - -```bash -npm run check:smoke -``` - -一键内容检查: - -```bash -npm run check:content -``` - ## 主要结构 主运行时: -- [src/App.tsx](./src/App.tsx) +- [src/active-main.tsx](./src/active-main.tsx) +- [src/ActiveApp.tsx](./src/ActiveApp.tsx) - [src/AuthenticatedApp.tsx](./src/AuthenticatedApp.tsx) -- [src/routing/appRoutes.tsx](./src/routing/appRoutes.tsx) -- [src/hooks/useCombatFlow.ts](./src/hooks/useCombatFlow.ts) +- [src/routing/activeAppRoutes.tsx](./src/routing/activeAppRoutes.tsx) +- [src/components/platform-entry/PlatformEntryActiveFlowShell.tsx](./src/components/platform-entry/PlatformEntryActiveFlowShell.tsx) -主流程内嵌编辑能力: +创作与项目能力: - [src/components/image-editor/ImageCanvasEditorView.tsx](./src/components/image-editor/ImageCanvasEditorView.tsx) -- [src/components/rpg-creation-editor/RpgCreationEntityEditorModal.tsx](./src/components/rpg-creation-editor/RpgCreationEntityEditorModal.tsx) -- [src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.tsx](./src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.tsx) - -核心数据: - -- [src/data/scenePresets.ts](./src/data/scenePresets.ts) -- [src/data/characterPresets.ts](./src/data/characterPresets.ts) -- [src/data/npcInteractions.ts](./src/data/npcInteractions.ts) -- [src/data/treasureInteractions.ts](./src/data/treasureInteractions.ts) +- [src/components/creation-home/CreationLandingView.tsx](./src/components/creation-home/CreationLandingView.tsx) +- [src/components/project/ProjectGalleryView.tsx](./src/components/project/ProjectGalleryView.tsx) ## 文档入口 diff --git a/docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md b/docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md index a30c95c7a..7965fc065 100644 --- a/docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md +++ b/docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md @@ -11,7 +11,7 @@ | 当前阶段 | Phase 6 | | 当前状态 | Phase 0~6 已收口;统一创作页已升级为 `UnifiedCreationWorkspace`,平台壳不再直接依赖旧工作台文件 | | 当前并行波次 | 波次 D(验收与冻结) | -| 当前重点 | 以跨玩法门禁作为后续新增玩法和回归的常规质量基线 | +| 当前重点 | 后续新增玩法按仓库入口规范、当前开发运维文档和现役定向测试验收 | ## 目标与范围 @@ -38,7 +38,7 @@ 此表即总进度记录,后续每次 phase 收口、启动或回退时,只更新这里和下方任务状态。 -当前已完成 Round 0~4 / Phase 0~6。后续新增玩法或统一链路改动以本文档和 `quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md` 为常规质量基线。 +当前已完成 Round 0~4 / Phase 0~6。后续新增玩法或统一链路改动以仓库入口规范、当前开发运维文档和现役定向测试为质量基线。 ## 执行轮次 @@ -160,7 +160,7 @@ - RPG 例外边界指定 interaction 测试通过。 - Bark Battle 创作入口、结果页试玩/发布和正式 runtime 指定 interaction 测试通过。 -- 视觉小说工作台、生成阶段、结果页和运行态测试通过,`npm run check:visual-novel-vn11` 通过。 +- 视觉小说工作台、生成阶段、结果页和运行态测试通过,当时的视觉小说负向扫描无异常。 ### Phase 5:结果页、发布、作品架与广场收口 @@ -191,7 +191,7 @@ 目标:形成后续新增玩法可复用的稳定门禁。 - 按玩法输出创作入口、生成页、结果页、试玩、发布、作品架、广场、runtime smoke 矩阵。 -- 增补 `quality-gates/README.md` 与跨玩法回归 / 冒烟门禁,不再只覆盖首批四条链路。 +- 当时补齐跨玩法回归 / 冒烟矩阵,不再只覆盖首批四条链路;当前执行入口已收敛到根 `package.json` 和开发运维文档。 - 固化移动端竖屏优先验收,桌面端作为兼容验证。 - 补齐“新增玩法接入 PRD 检查块”和代码评审检查清单。 @@ -208,7 +208,7 @@ - Phase 3 自动回归通过:拼图、抓大鹅、敲木鱼、跳一跳、宝贝识物、大鱼、方洞结果页相关测试共 67 项;跳一跳直达结果页恢复测试通过。 - Phase 4 / Phase 5 指定交互回归通过:RPG 例外边界、Bark Battle 闭环、作品详情、推荐 runtime、公开 read model 与跳一跳恢复相关 interaction 测试共 18 项。 - Phase 5 详情 / 弹窗 / 作品架回归通过:公开详情、错误弹窗、反馈弹窗、作品展示 helper、作品架交互测试共 63 项。 -- `npm run check:visual-novel-vn11`、`npm run check:spacetime-schema`、`npm run check:encoding` 均通过。 +- 当时的视觉小说负向扫描、`npm run check:spacetime-schema`、`npm run check:encoding` 均通过。 - API smoke 通过:`GET http://127.0.0.1:8082/healthz` 返回 `{"ok":true,"service":"genarrative-api-server"}`。 - 竖屏浏览器 smoke 通过并保存截图:`.app/browser-check/phase-flow-20260530-round5/`,覆盖 `/creation/jump-hop`、`/creation/visual-novel`、`/creation/square-hole`、`/creation/bark-battle`、`/creation/baby-object-match`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/runtime/jump-hop`。 @@ -220,7 +220,7 @@ Phase 6 不再继续拆新波次,当前只把 Phase 2 到 Phase 5 的最小验 | --- | --- | --- | | Phase 2 | `npm run check:encoding`、`npm run typecheck`、`npm run admin-web:typecheck`、`npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/unified-creation/unifiedCreationSpecs.test.ts src/components/unified-creation/UnifiedCreationPage.test.tsx src/components/unified-creation/UnifiedGenerationPage.test.tsx` | 校验入口配置、统一字段 spec、统一创作页和统一生成页。 | | Phase 3 | `npm run test -- src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx src/components/unified-creation/workspaces/JumpHopCreationWorkspace.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/components/big-fish-creation/BigFishAgentWorkspace.interaction.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx`;`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct jump hop result route"` | 校验普通表单 / 图片 / 音频工作台仍按结构化 payload 提交,跳一跳结果页直达恢复不白屏,并把 BabyObjectMatch / BigFish 一并纳入最小回归。 | -| Phase 4 | `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "opening RPG agent workspace does not refetch session snapshot in a render loop|create tab resumes agent workspace when draft has no compiled result yet|create tab resumes agent workspace when session has no draft profile even if summary counts look compiled|opening a compiled draft with a missing agent session falls back to draft hub"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "create tab opens bark battle entry form from the template card|bark battle draft result can test before publish and publish to work detail|direct bark battle runtime public code opens published runtime"`、`npm run check:visual-novel-vn11` | 校验特殊工作台例外、Bark Battle 公开闭环和视觉小说负向门禁。 | +| Phase 4 | `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "opening RPG agent workspace does not refetch session snapshot in a render loop|create tab resumes agent workspace when draft has no compiled result yet|create tab resumes agent workspace when session has no draft profile even if summary counts look compiled|opening a compiled draft with a missing agent session falls back to draft hub"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "create tab opens bark battle entry form from the template card|bark battle draft result can test before publish and publish to work detail|direct bark battle runtime public code opens published runtime"`、`npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx` | 校验特殊工作台例外、Bark Battle 公开闭环和视觉小说现役链路。 | | Phase 5 | `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "agent draft result publishes to gallery from publish panel|creation hub published work enters existing detail view|creation hub published work experience button enters world directly|creation hub published work start uses loaded detail profile instead of library summary"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "published puzzle works appear on home and mobile game category channel|published big fish works stay hidden from platform home and mobile game category channel|home recommendation Match3D runtime keeps profile generated models when card summary is stale|home recommendation Match3D runtime passes top-level UI background assets|home recommendation Match3D runtime reloads detail when card only has UI assets"`、`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/platform-entry/PlatformErrorDialog.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/rpg-entry/rpgEntryWorldPresentation.test.ts`、`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx` | 校验结果页、发布、作品架、公开详情、推荐 runtime 和公开 read model,并补公开详情作者展示口径与作品架恢复矩阵。 | 如果这轮还改了 SpacetimeDB schema,追加 `npm run check:spacetime-schema`;如果还改了 API 路由、BFF、公开列表或 `/works/detail` 回读,追加 `npm run dev:api-server`,并另开终端从 `.app/dev-stack.json` 读取实际 `api-server` URL 后检查 `/healthz`。 @@ -246,7 +246,7 @@ Phase 6 不再继续拆新波次,当前只把 Phase 2 到 Phase 5 的最小验 | T5-3 | 是 | 最小回归通过 | 公开 read model 对齐 | 广场/详情/分享码缺口与执行清单 | T3/T4 方案稳定 | 后端 owner | | T5-4 | 是 | 最小回归通过 | 统一错误与完成反馈回归 | `PlatformErrorDialog`、`PlatformTaskCompletionDialog` 覆盖 | T3/T4 方案稳定 | 平台壳 owner | | T5-5 | 是 | 自动回归通过 | 统一创作壳滚动收口 | `UnifiedCreationPage` 统一接管四条入口滚动、跳一跳纳入统一壳 | T3/T4 方案稳定 | 平台壳 owner | -| T6-1 | 否 | 已完成 | 全链路质量门禁扩展 | `quality-gates/README.md`、跨玩法回归 / 冒烟门禁、Phase 2 到 Phase 5 最小验证集合 | T3/T4/T5 完成 | QA owner | +| T6-1 | 否 | 已完成 | 全链路质量门禁扩展 | Phase 2 到 Phase 5 最小验证集合;现已收敛到根脚本和开发运维文档 | T3/T4/T5 完成 | QA owner | | T6-2 | 否 | 已完成 | 全量验收与冻结 | 状态表、未接入声明、最终测试记录 | T6-1 | Release owner | 并行原则: @@ -270,13 +270,13 @@ Phase 6 不再继续拆新波次,当前只把 Phase 2 到 Phase 5 的最小验 | P3-C 方洞评估包 | 可与 P3-A、P3-B、P3-D 并行 | 部分回归通过 | 判断 `square-hole` 保留 Agent 形态还是迁表单/图片工作台 | 不直接大改实现 | 例外或迁移方案、字段清单、风险、验收用例 | 文档评审通过;若改代码,补对应工作台测试 | | P3-D 大鱼评估包 | 可与 P3-A、P3-B、P3-C 并行 | 部分回归通过 | 判断 `big-fish` 保留 Agent 形态还是迁表单/图片工作台 | 不直接大改实现 | 例外或迁移方案、字段清单、风险、验收用例 | 文档评审通过;若改代码,补对应工作台测试 | | P4-A RPG 例外包 | 可与 P4-B、P4-C 并行 | 自动回归通过 | 明确 RPG 对话式工作台如何接入统一阶段、错误、完成、发布去向 | 不把 RPG 当新增玩法默认模板 | 例外声明、阶段映射、刷新恢复和发布验收 | RPG 指定 interaction 测试、作品详情/进入世界回归 | -| P4-B 视觉小说例外包 | 可与 P4-A、P4-C 并行 | 自动回归通过 | 明确视觉小说特殊生成和结果页边界 | 不迁入外部平台社区、支付、榜单、回放 | 例外声明、上传资产口径、生成/结果/发布验收 | `npm run check:visual-novel-vn11` 和视觉小说相关测试 | +| P4-B 视觉小说例外包 | 可与 P4-A、P4-C 并行 | 自动回归通过 | 明确视觉小说特殊生成和结果页边界 | 不迁入外部平台社区、支付、榜单、回放 | 例外声明、上传资产口径、生成/结果/发布验收 | 视觉小说创作、结果和运行态相关测试 | | P4-C 汪汪声浪决策包 | 可与 P4-A、P4-B 并行 | 自动回归通过 | 判断 `bark-battle` 是特殊工作台例外还是回到表单模式 | 不同时做两套入口 | 决策记录、阶段映射、发布和 runtime 验收 | Bark Battle 创作、发布、runtime 指定测试 | | P5-A 结果页矩阵包 | 可与 P5-B、P5-C、P5-D 并行 | 最小回归通过 | 列清每个玩法结果页能力、缺口和最小补丁 | 不在结果页新增无需求的大功能 | 结果页能力矩阵、局部重试/上传/发布边界 | 对应结果页测试和手测记录 | | P5-B 作品架恢复包 | 可与 P5-A、P5-C、P5-D 并行 | 最小回归通过 | 生成中、失败、待发布、已发布都能从作品架恢复 | 不只靠前端内存 notice | 作品摘要字段、作品架 adapter、恢复测试 | 作品架相关 interaction 测试;移动端草稿 Tab smoke | | P5-C 公开 read model 包 | 可与 P5-A、P5-B、P5-D 并行 | 最小回归通过 | 公开列表、详情、分享和推荐 runtime 对齐后端 read model | 不让前端拼源表当事实源 | 后端 read model/BFF 缺口清单和实现 | 公开列表/详情/API smoke;必要时 `npm run dev:api-server` + `/healthz` | | P5-D 统一反馈包 | 可与 P5-A、P5-B、P5-C 并行 | 最小回归通过 | 错误和异步完成统一进入平台弹窗 | 不在页面内重复裸错误 banner | `PlatformErrorDialog`、`PlatformTaskCompletionDialog` 覆盖矩阵 | 平台弹窗测试、跨流程失败/完成手测 | -| P6-A 门禁包 | 串行,依赖 P3/P4/P5 | 已完成 | 固化跨玩法自动测试、竖屏手测和 API smoke | 不继续接新玩法 | `quality-gates/`、冻结前命令集合 | 跨玩法回归与冒烟门禁通过 | +| P6-A 门禁包 | 串行,依赖 P3/P4/P5 | 已完成 | 固化跨玩法自动测试、竖屏手测和 API smoke | 不继续接新玩法 | 冻结前命令集合;现已收敛到根脚本和开发运维文档 | 跨玩法回归与冒烟验证通过 | | P6-B 冻结包 | 串行,依赖 P6-A | 已完成 | 更新总状态表,标记已统一、例外接入、暂不接入 | 不遗留“状态未知”玩法 | 总进度、风险清单、后续维护规则 | `npm run check:encoding`、关键门禁通过、文档索引有效 | ### 并行执行注意事项 @@ -370,6 +370,6 @@ Phase 6 不再继续拆新波次,当前只把 Phase 2 到 Phase 5 的最小验 1. 将 Round 4 / Phase 6 作为当前冻结基线,后续只做同口径回归,不再新增波次。 2. 对 Phase 3、Phase 4、Phase 5 只处理回归发现的真实缺口,不扩新玩法。 3. 更新冻结状态表,明确每个玩法是已统一、例外接入还是暂不接入。 -4. 后续新增玩法默认遵循本文档和 `quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md`,无需再补新的总计划轮次。 +4. 后续新增玩法默认遵循仓库入口规范、当前开发运维文档和现役定向测试,无需再补新的总计划轮次。 当前轮次看 Round 4 / Phase 6;Round 0~4 已作为历史完成记录保留。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 9affa8214..c0d0b5c46 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -536,15 +536,6 @@ H5 支付链接跳转在原生壳声明 `app.openExternalUrl` 时必须优先走 该命令同时会运行微信小程序 `miniprogram/host-bridge/`、`miniprogram/shell/`、`pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的壳层测试,保证微信桥接层拆分后的支付、订阅消息、九宫切图、分享目标和 WebView 登录 / 分享入口行为与 Expo、Tauri 壳一起验收。 该命令还会反查微信小程序 `app.json.pages` 与 `host-bridge/protocol.js` 现役页面 URL、H5 小程序页面常量、WebView 分享入口、分享目标消息类型、`WEB_VIEW_SOURCE_QUERY`、微信请求头运行时标记、H5 runtime parser、H5 路由保留字段和 H5 / API base URL 格式,避免页面路由、来源标记、宿主上下文 query 或域名配置在微信壳、H5 HostBridge 与运行时配置之间分叉。旧生成结果订阅授权页和对应 H5 service 不再进入清单。生产 / 开发 H5 与 API 域名都必须显式配置为纯 HTTPS domain,运行时开发域名回退生产域名只作为异常兜底。 -内容检查: - -```bash -npm run check:data -npm run check:overrides -npm run check:smoke -npm run check:content -``` - 全量检查: ```bash @@ -559,18 +550,12 @@ npm run check:server-rs-ddd ## Gitea CI 与 PR 检查 -- 仓库 CI 入口是 `.gitea/workflows/project-ci.yml`,向 `master`、`codex/ai-game-creator-app` 推送和所有 PR 创建、更新时必须运行,也允许手工触发。 +- 仓库 CI 入口是 `.gitea/workflows/project-ci.yml`,在 `master` push、所有 PR 创建或更新以及手工触发时运行。 - CI 固定拆分为 `Repository checks`、`Frontend tests`、`Backend tests`、`Native shell tests` 四个 required job;对应 PR context 完整名称是 `Project CI / Repository checks (pull_request)`、`Project CI / Frontend tests (pull_request)`、`Project CI / Backend tests (pull_request)`、`Project CI / Native shell tests (pull_request)`,首次运行后仍须从 Gitea 最近一周 context 表复核。测试使用独立 job,不能只藏在综合检查 step 中;原生壳验收单独运行以便定位重型构建失败。 -- 四个 job 共同覆盖 `npm run check`,并追加 `npm run check:server-rs-ddd`、`cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --all-targets --manifest-path server-rs/Cargo.toml` 和 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。Backend job 必须先用 `cargo fetch --locked` 的 5 次整命令级有界重试准备当前 `server-rs` 锁定依赖,再进入会触发 `cargo build` 的 DDD / module-runtime 产物边界门禁;不得把依赖准备放在该门禁之后,否则镜像缺少锁新增 crate 时会在首次 registry / TLS 抖动处提前失败。后端 runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。`codex/ai-game-creator-app` 分支必须用独立 lockfile 安装 AI 游戏创作壳依赖,其原生壳入口还必须覆盖 `npm run ai-game-creator-shell:check`、release build smoke,并检查 AI Tauri `Cargo.lock` 不漂移。原生壳 job 还要通过 Google 官方签名 APT 源安装 `google-chrome-stable`,用于 headless preview 的真实 DOM / canvas smoke;同时安装 `ripgrep`,把 `actions/setup-node` 的完整 Node.js 22 发行目录与 root-owned rustup proxy 映射到 `/usr/local`,供只接受受信任系统命令目录的 `command.exec` 沙箱测试使用,不能放宽生产命令目录白名单。Tauri 的 1132 项级别 suite 固定 `--test-threads=1`,避免共享 Agent Runtime 后台锁与异步终态在 libtest 并行调度下互相干扰。 +- 四个 job 共同覆盖根检查、前端与脚本测试、生产运维 fixture、server-rs DDD/workspace 测试和原生壳验收;AI 游戏创作壳检查与 release build smoke 对所有触发方式一致执行。 - checkout 必须使用完整历史。PR 将 base SHA 写入 `SPACETIME_SCHEMA_BASE_REF`,直接推送 `master` 使用 before SHA;事件基线不可解析时直接失败。Gitea 检查的是 PR head 而非预合并 commit,workflow 必须拒绝不包含最新 base commit 的过期 PR,分支保护同时保持“PR 过期禁止合并”。 - 普通 PR job 不读取业务 secret,不运行真实 API/SpacetimeDB/OSS/支付/生成/live smoke,也不执行会修改外部状态的维护、迁移、发布或备份命令。 -- Gitea 至少升级到 `1.26.4` 后才能注册执行 PR job 的 runner;`ubuntu-latest` 标签只映射到固定 digest 的 Ubuntu 24.04 级 Docker/临时隔离镜像,不使用浮动镜像 tag,不映射 host,不向 job 暴露 Docker socket、业务 secret 或不必要内网。runner 能访问 Gitea、GitHub Actions 与 `actions/node-versions`、nodejs.org、npm、Rust 分发、crates.io 和 Google Chrome 的 `dl.google.com` 官方签名 APT 源;workflow 的官方 action 固定完整 commit,若内网禁用 GitHub,先在当前 Gitea 镜像对应 commit 并改用绝对 URL。受控镜像优先预装 rustup。Gitea 1.26 的任务超时由 runner 全局配置控制;首次运行成功后,`master` 分支保护必须要求上述四个 job 全部成功。 -- `genarrative-station` 当前使用 Gitea `1.26.4` + 基于 Runner `2.0.0-dind-rootless` 的固定 digest 修补镜像:只修复 `systempaths=unconfined` 的空 slice 被 `mergo` 丢失,真实 job 必须保持 `MaskedPaths=[]`、`ReadonlyPaths=[]`;外层仍非 privileged、无 `CAP_SYS_ADMIN`,内部 Docker 只监听 Unix socket,`docker_host: "-"` 阻止 socket 进入 job。job 只在 `gitea-actions` internal network,通过 `/git` reverse gateway 访问 Gitea,通过拒绝私网、保留地址和 metadata 的 80/443 proxy 访问公共依赖;直连公网和 Gitea 数据网必须失败。内层 bwrap 所需 namespace/proc 选项只能用于该 rootless DinD,不能放宽宿主 rootful runner。系统依赖步骤在 root job 中不调用 sudo,非 root 时用 `sudo -E` 保留受控 proxy;Cargo 关闭 HTTP multiplexing 并设置 10 次网络重试,rustup bootstrap/toolchain 安装也按有界次数重试。AI 原生壳 job 把 Node 发行目录与 rustup proxy 安装到 `/usr/local` 的受信任只读路径,并在测试前执行完整 bwrap canary。宿主 compose helper 必须把 `/opt/gitea-stack` 挂到同名绝对路径,避免相对 volume 错误落到 `/stack` 空目录。 -- 四个 job 共同覆盖 `npm run check`,并追加 `npm run bgfilter-worker:smoke-test`、`npm run check:production-health-patrol`、`npm run check:production-api-release`、`npm run check:production-api-deploy`、`npm run check:server-rs-ddd`、`cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --all-targets --manifest-path server-rs/Cargo.toml` 和 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。BgFilter 的 `.test.mjs` 使用 Node test runner,必须由 workflow 显式调用;生产巡检 / 发布 / 部署行为检查只运行无密钥临时 fixture,不连接现场环境。`ffmpeg` 由预构建 job 镜像提供,避免视频抽帧测试因工具缺失提前返回。`codex/ai-game-creator-app` 分支的原生壳入口还必须覆盖 `npm run ai-game-creator-shell:check` 和 release build smoke。 -- checkout 必须使用完整历史。PR 将 base SHA 写入 `SPACETIME_SCHEMA_BASE_REF`,直接推送 `master` 使用 before SHA;事件基线不可解析时直接失败。Gitea 检查的是 PR head 而非预合并 commit,workflow 必须拒绝不包含最新 base commit 的过期 PR,分支保护同时保持“PR 过期禁止合并”。 -- 普通 PR job 不读取业务 secret,不运行真实 API/SpacetimeDB/OSS/支付/生成/live smoke,也不执行会修改外部状态的维护、迁移、发布或备份命令。 -- Gitea 至少升级到 `1.26.4` 后才能注册执行 PR job 的 runner。runner 保留 `ubuntu-latest` 固定 digest 映射,并把 workflow 使用的 `genarrative-ci` 映射到 runner 内层 Docker 已装入的完整 Image ID;不映射 host,不向 job 暴露 Docker socket、业务 secret 或不必要内网。当前 CI 镜像约 `1.788 GB`,Image ID 为 `sha256:c04b114b1f145072c9df7842c4c974e1bb2eaaf391d95d84c9212a460546b7d5`,标签映射为 `genarrative-ci:docker://sha256:c04b114b1f145072c9df7842c4c974e1bb2eaaf391d95d84c9212a460546b7d5`。内层 Docker 数据持久化且 `force_pull: false`,该 ID 缺失时失败关闭,不现场拉取或回退浮动 tag。Runner 2.0.0 支持 job 级 `timeout-minutes`,但 runner 全局 `3h` 仍是硬上限;首次运行成功后,`master` 分支保护必须要求上述四个 job 全部成功。 -- `deploy/container/gitea-ci-job.Dockerfile` 固定 Ubuntu base digest `sha256:58ea92624c7c09582e05594d95488331045053d3a3f34cf09649f2a32313a614`、Rust stage digest `sha256:19817ead3289c8c631c73df281e18b59b172f6a31f4f563290f69cddd06c30e9`、带 SHA-256 校验的 Node `22.23.1`、Google Linux 主签名指纹和 Chrome `150.0.7871.181-1`,预装 Rust 1.96、`rustfmt`、Chrome、`bwrap`、`rg`、`ffmpeg`、`clang/lld` 与 Tauri / 后端系统依赖,并预热根 npm、server-rs 与桌面壳 Cargo 下载缓存。构建脚本使用约 `1.638 MB` 的白名单 tar context,不发送源码、素材或本地私密文件;`cargo fetch --locked` 同时受 Cargo 网络重试和最多 5 次整命令级重试保护,最后必须通过断网 fetch。更新按 `scripts/gitea-ci-job-image.sh build/verify -> export 仓库外镜像归档 -> load-runner -> 确认无活跃 job -> 仓库外备份 config -> 替换 label -> docker restart --timeout 660` 执行;真实 CI 全部通过后才清旧镜像。回滚先把 workflow 改回 `ubuntu-latest`,再恢复 config 备份并重启。备份不进 Git,不在共享文档记录宿主私密路径或注册信息。 +- runner、隔离镜像、工具链和网络边界的当前配置以 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` 为准,不在共享记忆中复制易漂移的镜像大小、Image ID 或安装步骤。 - 四个 workflow job 都使用 `runs-on: genarrative-ci`,先用镜像内脚本直接从 Gitea checkout,再以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 运行 `scripts/check-gitea-ci-job-image.sh`,同时校验缓存锁、工具链、完整 bwrap sandbox 和 Chrome headless。workflow 不再包含 GitHub checkout action、apt、setup-node 或 rustup 安装,并设置 `RUSTUP_AUTO_INSTALL=0`;工具链变更时先重建镜像。每个 job 仍各自执行 `npm ci` 以校验 lockfile 和隔离 PR 依赖,但优先复用镜像只读 cache,不烘入 `node_modules`,不挂载跨 PR 可写 Actions cache。`genarrative-station` 继续使用固定 digest 的 Runner `2.0.0-dind-rootless` 修补镜像,真实 job 保持 `MaskedPaths=[]`、`ReadonlyPaths=[]`、`Privileged=false`、`Binds=[]`,`docker_host: "-"` 阻止 socket 进入 job。job 只在 `gitea-actions` internal network,锁文件差量依赖只经拒绝私网、保留地址和 metadata 的 80/443 proxy,直连公网和 Gitea 数据网必须失败;npm 与 Cargo 都设置 10 次网络重试,Cargo 继续关闭 HTTP multiplexing。 ## 后端相关默认验证 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 16de6919b..bfe45f8e9 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -218,10 +218,9 @@ npm run lint:eslint npm run typecheck npm run test npm run build -npm run check:content ``` -一期创作流程统一化新增 `quality-gates/` 提交前门禁。涉及拼图、抓大鹅、敲木鱼统一创作页、统一生成页或 dev 栈启动脚本时,先执行 `quality-gates/README.md` 列出的脚本,再按对应门禁文档完成体验检查。 +修改 dev-stack 启动、端口探测、状态文件或退出清理时,先执行 `quality-gates/README.md` 列出的专项门禁;其它改动按本节和仓库入口规范选择现役定向测试,不再维护按历史玩法阶段冻结的平行门禁矩阵。 综合检查: @@ -234,16 +233,16 @@ npm run check ### Gitea Actions PR 门禁 -仓库级 Gitea Actions 工作流固定为 `.gitea/workflows/project-ci.yml`,在向 `master` 或 `codex/ai-game-creator-app` 推送、创建或更新 PR,以及手工触发时运行。工作流拆成四个必须通过的 job: +仓库级 Gitea Actions 工作流固定为 `.gitea/workflows/project-ci.yml`,在向 `master` 推送、创建或更新 PR,以及手工触发时运行。工作流拆成四个必须通过的 job: -- `Repository checks`:调用唯一入口 `npm run check:repository-ci`,执行 `npm run lint`、主站与后台生产构建、内容数据检查和提交差异空白检查。本地 master `pre-push` 复用同一入口,禁止在 workflow 与 hook 中维护两份近似命令。 +- `Repository checks`:调用唯一入口 `npm run check:repository-ci`,执行 `npm run lint`、AI 游戏创作壳 AppSurface 定向测试、主站与后台生产构建和提交差异空白检查。本地 master `pre-push` 复用同一入口,禁止在 workflow 与 hook 中维护两份近似命令。 - `Frontend tests`:按根 lockfile 与 `apps/ai-game-creator-shell/package-lock.json` 分别执行干净的 `npm ci`,再独立执行根 `npm run test`、`npm run bgfilter-worker:smoke-test`、`npm run check:production-health-patrol`、`npm run check:production-api-release` 和 `npm run check:production-api-deploy`,让 Vitest、Node test smoke harness 及不依赖真实服务的生产巡检 / 发布 / 部署行为 fixture 在 Gitea job 中持续执行;其中 `.test.mjs` 使用 Node test runner,不依赖 Vitest 的 `scripts/**/*.test.ts` 收集规则。 - `Backend tests`:先对 `server-rs/Cargo.lock` 执行带 5 次整命令级有界重试的 `cargo fetch --locked`,再执行 `npm run check:server-rs-ddd`、`cargo test --locked --workspace --no-fail-fast`、`api-server --all-targets` 编译和 `spacetime-module` 编译;依赖准备必须位于会触发 Cargo build 的 DDD / 产物边界门禁之前,避免锁新增依赖未命中镜像缓存时绕过既有下载重试。runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。依赖真实服务或密钥的测试必须显式 `ignored`,不能让普通 PR job访问现场环境。 -- `Native shell tests`:按根 lockfile 与 AI 游戏创作壳独立 lockfile 安装依赖后执行 `npm run check:native-shells`,覆盖微信壳、Expo 和 Tauri 的完整验收,并确认桌面壳与 AI 游戏创作壳的 `Cargo.lock` 都没有被构建过程改写。`codex/ai-game-creator-app` 分支的同名脚本还会执行 `npm run ai-game-creator-shell:check` 和 AI 游戏创作壳 release build smoke;共享 Agent Runtime 后台锁 suite 固定 `--test-threads=1`,不能用并行偶发失败后的逐项通过替代整套稳定门禁。 +- `Native shell tests`:按根 lockfile 与 AI 游戏创作壳独立 lockfile 安装依赖后执行 `npm run check:native-shells`,对所有触发方式一致覆盖微信壳、Expo 和 Tauri 的完整验收,并执行 `npm run ai-game-creator-shell:check` 与 AI 游戏创作壳 release build smoke;最后确认桌面壳与 AI 游戏创作壳的 `Cargo.lock` 都没有被构建过程改写。共享 Agent Runtime 后台锁 suite 固定 `--test-threads=1`,不能用并行偶发失败后的逐项通过替代整套稳定门禁。 四个 job 合起来覆盖根 `npm run check`,并补齐根检查没有包含的 BgFilter worker smoke harness、无密钥生产巡检 / 发布 / 部署行为 fixture、server-rs DDD、正式 workspace Rust 测试与现役后端编译门禁。普通 PR CI 不注入业务密钥,不启动真实 API、SpacetimeDB、OSS、支付、图片生成或生产 live smoke;需要现场环境、可变外部状态、Docker 编排或发布凭据的 `check:*` 继续按对应专题和 Jenkins 发布流程执行,不能遍历所有同名前缀脚本冒充 PR 门禁。 -PR checkout 必须保留完整 Git 历史,并把 PR base SHA 传给 `SPACETIME_SCHEMA_BASE_REF`。`check:spacetime-schema` 依赖该基线识别已有表字段删除、改名、重排和改类型;事件给出的基线缺失或本地不可解析时必须直接失败,不能退化为空差异检查。Gitea 的 PR checkout 是 PR head,不是与目标分支的预合并 commit,因此 workflow 还会验证 PR head 包含事件中的最新 base commit;分支保护必须继续开启“PR 过期禁止合并”,过期分支先更新再重跑。向 `master` 直接推送时使用 push before SHA,手工触发时回退到 `origin/master`。 +PR checkout 必须保留完整 Git 历史,并把 PR base SHA 传给 `SPACETIME_SCHEMA_BASE_REF`。`check:spacetime-schema` 依赖该基线识别已有表字段删除、改名、重排和改类型;事件给出的基线缺失或本地不可解析时必须直接失败,不能退化为空差异检查。Gitea 的 PR checkout 是 PR head,不是与目标分支的预合并 commit,因此 workflow 还会验证 PR head 包含事件中的最新 base commit;分支保护必须继续开启“PR 过期禁止合并”,过期分支先更新再重跑。向 `master` 直接推送时使用 push before SHA;手工触发先尝试 `origin/master`,若它与 `HEAD` 相同则改用 `HEAD^`,仍无法得到不同提交时失败关闭。 启用或注册执行 PR job 的 runner 前,Gitea 服务端必须至少升级到 `1.26.4`;不得在 `1.26.2` 上执行不受信任 PR 代码。runner 保留 `ubuntu-latest` 标签作为已有固定 digest Ubuntu 24.04 级环境,同时提供专用 `genarrative-ci` 标签,并将后者映射到已装入 runner 内层 Docker 的完整 Image ID;两者均不得映射到 host 执行器,job 不得获得 Docker socket、业务环境变量、业务密钥或不必要的内网。workflow 不再现场运行 apt、`actions/setup-node` 或 rustup 安装;Node 22、Rust 1.96.0、`rustfmt`、Chrome、`bwrap`、`rg`、`ffmpeg`、`clang/lld` 和 Tauri / 后端系统依赖都由预构建镜像提供。checkout 由镜像内 `genarrative-gitea-checkout` 直接从当前 Gitea 拉取事件 commit,并执行 5 次有界重试;不得恢复为运行时从 GitHub 克隆 action。当前锁命中时,npm 与 Linux 目标 Cargo 下载可完全使用镜像内预热缓存;锁文件新增依赖时才经受控 proxy 补齐。构建 CI 镜像仍需访问固定基础镜像、Ubuntu / Google Chrome 软件源、nodejs.org、npm registry 和 crates.io。本阶段不使用共享 Actions cache,避免不受信任 PR 污染跨 job 可写缓存。当前 Runner 2.0.0 已支持 job 级 `timeout-minutes`,但 runner 全局 `3h` 仍是所有任务的硬上限。 @@ -270,13 +269,6 @@ workflow 首次成功运行后,在 Gitea `master` 分支保护中把 `Project master 日常交付必须禁止直接 push,只允许经 PR 在当前 head 的四个 required context 全绿后合并;本地 `pre-commit` 的 staged ESLint/Prettier 和 master `pre-push` 的 Repository checks parity 只用于提前发现问题,可被 `--no-verify` 绕过,不能充当服务端权威门禁。紧急直推白名单如需保留,应按人员和时限最小化,并要求执行同一 `npm run check:repository-ci ` 后回读 push CI。 -视觉小说负向扫描与验收门禁: - -```bash -npm run check:visual-novel-vn11 -npm run check:visual-novel-vn12 -``` - SpacetimeDB bindings: ```bash @@ -629,6 +621,8 @@ Nginx 与 Pingora 在维护 marker 存在时对内网来源绕过整站维护闸 `scripts/jenkins-checkout-source.sh` 是生产 Jenkinsfile 内部二次确认源码的统一入口。构建流水线由 Jenkins `GitSCM checkout` 先用凭据完成浅克隆,再以 `GENARRATIVE_JENKINS_REUSE_EXISTING_CHECKOUT=true` 调用脚本复用当前 checkout;只有显式 `COMMIT_HASH` 不在这次浅克隆里时,脚本才通过传入的 SSH 远端继续 fetch 并逐步加深。构建流水线和服务器初始化流水线传入 `COMMIT_HASH` 时,脚本必须先保持 `depth=1` 浅拉,若上游 commit 已在浅历史内则直接校验并 checkout;只有浅历史无法证明 commit 属于目标分支时,才按 `GENARRATIVE_JENKINS_CHECKOUT_DEEPEN_STEPS`(默认 `50 200 1000 5000`)逐步加深,最后才尝试展开完整历史。`Genarrative-Api-Deploy`、`Genarrative-Web-Deploy` 和 `Genarrative-Stdb-Module-Publish` 仍保留上游构建传入的 `COMMIT_HASH` 作为通知和追溯字段,但不再用它在目标机器重新 checkout 部署脚本。 +`Genarrative-Api-Build` 与 `Genarrative-Stdb-Module-Build` 在浅克隆复用后必须取得待构建提交的第一父提交:本地对象缺失时只额外执行一次凭据保护的 `git fetch --depth=2 `,并把父提交 SHA 写入 `SPACETIME_SCHEMA_BASE_REF` 后再运行 `check:server-rs-ddd`。父提交不可取得、不可解析或与 `HEAD` 相同时必须失败关闭,禁止让 SpacetimeDB schema 门禁回退成 `HEAD` 自比。 + `Genarrative-Stdb-Module-Publish` 在 `Pipeline script from SCM` 阶段如果一开始就报 `No such DSL method 'pipeline'`,优先检查 `jenkins/Jenkinsfile.production-stdb-module-publish` 是否带 UTF-8 BOM。Jenkins Declarative Pipeline 的首个 token 必须是纯 `pipeline`;仓库中的 Jenkinsfile 应保存为 UTF-8 without BOM,只有临时写给 Windows PowerShell 5.1 `-File` 执行的 `.ps1` 才需要按对应 helper 转成带 BOM。验证时可检查文件前三字节不再是 `EF BB BF`,并运行 `validateDeclarativePipeline` 或重放该流水线。 `Genarrative-Stdb-Module-Build` 或 SpacetimeDB module 构建失败若出现 Rust `E0425 cannot find function migrate_*`,优先排查 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 等同文件内默认种子迁移 helper 是否在分支合并时只保留了调用、漏掉了函数定义。`Genarrative-Stdb-Module-Build` 现在运行在 `linux && genarrative-build` 节点上,Checkout 与 Build 都走 bash + cargo + sccache,不再依赖 Windows PowerShell 或 Git Bash;Stdb module 的 `CARGO_HOME`、`CARGO_TARGET_DIR` 和 `SCCACHE_DIR` 默认落在稳定缓存根 `~/caches/genarrative-jenkins/stdb-module` 下,可用 `GENARRATIVE_STDB_CACHE_ROOT` 覆盖,避免 `WORKSPACE@tmp` 被清理后无改动也触发近似冷构建。修复时不要直接删除迁移调用;应恢复只纠偏历史默认种子且不覆盖后台手动配置的 helper,并用 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 复现 Jenkins module 编译路径。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 3a548f4c4..448387d69 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -353,7 +353,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 封面、场景、角色立绘和音乐写平台 asset id 或 generated 引用。 - 运行时图片渲染使用 `ResolvedAssetImage` 换签。 -视觉小说禁止新增回放 / 录制 / 复盘类功能。`npm run check:visual-novel-vn11` 继续作为负向扫描门禁。 +视觉小说退役前的设计禁止新增回放 / 录制 / 复盘类功能;该约束不再作为当前工程门禁。 ## 汪汪声浪 Bark Battle diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index 05849573c..d4c68146d 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -80,11 +80,26 @@ pipeline { GENARRATIVE_JENKINS_REUSE_EXISTING_CHECKOUT="true" \ GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY:?GENARRATIVE_GIT_SSH_KEY 不能为空} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \ scripts/jenkins-checkout-source.sh + IFS= read -r source_commit < .jenkins-source-commit + schema_base_ref="$(git rev-parse --verify "${source_commit}^" 2>/dev/null || true)" + if [[ -z "${schema_base_ref}" ]]; then + GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY:?GENARRATIVE_GIT_SSH_KEY 不能为空} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \ + git fetch --no-tags --depth=2 \ + "${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ + "${source_commit}" + schema_base_ref="$(git rev-parse --verify "${source_commit}^" 2>/dev/null || true)" + fi + if [[ -z "${schema_base_ref}" ]]; then + echo "[checkout] 无法取得待构建提交 ${source_commit} 的父提交,拒绝运行 SpacetimeDB schema 无基线门禁。" >&2 + exit 1 + fi + printf "%s\n" "${schema_base_ref}" > .jenkins-spacetime-schema-base ' ''' } script { env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim() + env.SPACETIME_SCHEMA_BASE_REF = readFile('.jenkins-spacetime-schema-base').trim() env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER } } diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index 28f9c6285..3f68306bd 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -81,11 +81,26 @@ pipeline { GENARRATIVE_JENKINS_REUSE_EXISTING_CHECKOUT="true" \ GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY:?GENARRATIVE_GIT_SSH_KEY 不能为空} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \ scripts/jenkins-checkout-source.sh + IFS= read -r source_commit < .jenkins-source-commit + schema_base_ref="$(git rev-parse --verify "${source_commit}^" 2>/dev/null || true)" + if [[ -z "${schema_base_ref}" ]]; then + GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY:?GENARRATIVE_GIT_SSH_KEY 不能为空} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \ + git fetch --no-tags --depth=2 \ + "${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ + "${source_commit}" + schema_base_ref="$(git rev-parse --verify "${source_commit}^" 2>/dev/null || true)" + fi + if [[ -z "${schema_base_ref}" ]]; then + echo "[checkout] 无法取得待构建提交 ${source_commit} 的父提交,拒绝运行 SpacetimeDB schema 无基线门禁。" >&2 + exit 1 + fi + printf "%s\n" "${schema_base_ref}" > .jenkins-spacetime-schema-base ' ''' } script { env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim() + env.SPACETIME_SCHEMA_BASE_REF = readFile('.jenkins-spacetime-schema-base').trim() env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER } } diff --git a/package.json b/package.json index 4b468881b..b24b5f28c 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", "check:encoding": "node scripts/check-encoding.mjs", "check:git-hooks": "node --test scripts/git-hooks.test.mjs", - "check:pre-commit-format": "npm run check:git-hooks", "check:repository-ci": "bash scripts/check-repository-ci.sh", "check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check", "check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs", @@ -96,10 +95,8 @@ "check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs", "check:server-rs-ddd": "npm run check:spacetime-schema && npm run check:spacetime-runtime-access && npm run check:module-runtime-artifact && node scripts/check-server-rs-ddd-boundaries.mjs", "lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0", - "lint:guardrails": "npm run lint:eslint", "typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit", - "typecheck:guardrails": "npm run typecheck", - "lint": "npm run check:encoding && npm run check:pre-commit-format && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:production-ops && npm run check:preview-deployer && npm run check:maintenance-page && npm run lint:eslint && npm run typecheck", + "lint": "npm run check:encoding && npm run check:git-hooks && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:production-ops && npm run check:preview-deployer && npm run check:maintenance-page && npm run lint:eslint && npm run typecheck", "lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .", "format": "prettier --write .", "format:check": "prettier --check .", @@ -115,11 +112,7 @@ "container:ps": "node scripts/container-compose.mjs ps", "container:config": "node scripts/container-compose.mjs config", "container:worker-smoke": "node scripts/container-worker-smoke.mjs", - "check": "npm run lint && npm run test && npm run build && npm run check:content && npm run check:native-shells", - "check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts", - "check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts", - "check:smoke": "node scripts/run-tsx.cjs scripts/smoke-content.ts", - "check:content": "npm run check:data && npm run check:overrides && npm run check:smoke", + "check": "npm run lint && npm run test && npm run build && npm run check:native-shells", "codegraph:init": "codegraph init -i .", "codegraph:index": "codegraph index .", "codegraph:sync": "codegraph sync .", diff --git a/quality-gates/README.md b/quality-gates/README.md index ebe5f074d..cac276638 100644 --- a/quality-gates/README.md +++ b/quality-gates/README.md @@ -1,27 +1,12 @@ # 提交前质量门禁 -本目录记录当前创作流程统一化提交前必须执行的检查。门禁既可以是自动脚本,也可以是需要人工确认的体验流程。 +本目录记录需要独立维护的提交前专项门禁。常规测试、类型检查和构建以根 `package.json` 与当前开发运维文档为准。 -## 当前必跑 +## 当前专项门禁 -- [【玩法创作】统一创作页门禁-2026-05-29.md](./【玩法创作】统一创作页门禁-2026-05-29.md) -- [【玩法创作】统一生成页门禁-2026-05-29.md](./【玩法创作】统一生成页门禁-2026-05-29.md) -- [【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md](./【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md) - [【开发运维】dev-stack状态文件门禁-2026-05-29.md](./【开发运维】dev-stack状态文件门禁-2026-05-29.md) -## 快速基线 - -```bash -npm run check:encoding -npm run typecheck -npm run admin-web:typecheck -npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/unified-creation/unifiedCreationSpecs.test.ts src/components/unified-creation/UnifiedCreationPage.test.tsx src/components/unified-creation/UnifiedGenerationPage.test.tsx -npm run test -- src/components/unified-creation/UnifiedCreationWorkspace.test.tsx -npm run test -- src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx -``` - ## 使用说明 -- `统一创作页门禁` 和 `统一生成页门禁` 仍作为 Phase 2 的基础门禁。 -- `跨玩法回归与冒烟门禁` 覆盖 Phase 2 之后的最小验证集合,Phase 5 / Phase 6 冻结前以它为主。 -- `dev-stack` 状态文件门禁继续单独维护,不并入玩法回归矩阵。 +- 修改 dev-stack 启动、端口探测、状态文件或退出清理时,执行对应专项门禁。 +- 其它改动按 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` 和仓库入口规范选择现役定向测试,不维护按历史阶段冻结的平行门禁矩阵。 diff --git a/quality-gates/【玩法创作】统一创作页门禁-2026-05-29.md b/quality-gates/【玩法创作】统一创作页门禁-2026-05-29.md deleted file mode 100644 index 84bfb3d7f..000000000 --- a/quality-gates/【玩法创作】统一创作页门禁-2026-05-29.md +++ /dev/null @@ -1,17 +0,0 @@ -# 统一创作页门禁 - -## 自动检查 - -```bash -npm run test -- src/components/unified-creation/unifiedCreationSpecs.test.ts -npm run test -- src/components/unified-creation/UnifiedCreationWorkspace.test.tsx -npm run test -- src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx -``` - -## 体验检查 - -- 拼图、抓大鹅、跳一跳、敲木鱼均从 `/creation/` 进入创作工作台。 -- 四条链路都经过 `UnifiedCreationPage`,字段 spec 只包含 `text`、`select`、`image`、`audio` 四类。 -- 拼图参考图仍走 `CreativeImageInputPanel`,不新增专属上传入口。 -- 敲木鱼音频槽位走 `CreativeAudioInputPanel`,上传、录音、重置和默认音效状态可用。 -- 抓大鹅难度只显示轻松、标准、进阶、硬核四档,提交 payload 仍派生 `clearCount` 和 `difficulty`。 diff --git a/quality-gates/【玩法创作】统一生成页门禁-2026-05-29.md b/quality-gates/【玩法创作】统一生成页门禁-2026-05-29.md deleted file mode 100644 index 97626cb04..000000000 --- a/quality-gates/【玩法创作】统一生成页门禁-2026-05-29.md +++ /dev/null @@ -1,16 +0,0 @@ -# 统一生成页门禁 - -## 自动检查 - -```bash -npm run test -- src/components/unified-creation/UnifiedGenerationPage.test.tsx -npm run test -- src/services/miniGameDraftGenerationProgress.test.ts -``` - -## 体验检查 - -- `/creation/puzzle/generating`、`/creation/match3d/generating`、`/creation/wooden-fish/generating` 均渲染 `UnifiedGenerationPage`。 -- 生成页展示后端阶段、当前步骤、总进度、错误和重试动作。 -- 等待时间以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 时间戳。 -- 失败后生成页保持可见错误和重试入口,作品架恢复时不得只依赖前端内存 notice。 -- 不为一期新增客户端直连 SpacetimeDB 状态通道。 diff --git a/quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md b/quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md deleted file mode 100644 index 5c5f1b641..000000000 --- a/quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md +++ /dev/null @@ -1,94 +0,0 @@ -# 跨玩法回归与冒烟门禁 - -## 适用范围 - -- Phase 2 的配置与统一壳收口。 -- Phase 3 的普通工作台接入。 -- Phase 4 的特殊工作台与例外边界。 -- Phase 5 的结果页、发布、作品架、公开详情与运行态收口。 -- Phase 6 冻结前的最小验证集合。 - -## 共同前置 - -```bash -npm run check:encoding -npm run typecheck -npm run admin-web:typecheck -npm run check:spacetime-schema -``` - -## Phase 2:配置与统一壳 - -```bash -npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/unified-creation/unifiedCreationSpecs.test.ts src/components/unified-creation/UnifiedCreationPage.test.tsx src/components/unified-creation/UnifiedGenerationPage.test.tsx -``` - -- 验证 `creation-entry config`、`unifiedCreationSpec`、统一创作页和统一生成页的阶段映射一致。 -- 需要改 SpacetimeDB schema 时,再追加 `npm run check:spacetime-schema`。 - -## Phase 3:普通工作台 - -```bash -npm run test -- src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx src/components/unified-creation/workspaces/JumpHopCreationWorkspace.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/components/big-fish-creation/BigFishAgentWorkspace.interaction.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx -npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct jump hop result route" -``` - -- 验证表单 / 图片 / 音频工作台仍提交结构化 payload,不会退回聊天式输入。 -- 跳一跳需同时覆盖工作台结构化提交、结果页试玩 / 发布 / 局部重生成入口、结果页恢复面板和 runtime 起跳 / 重开 / 返回。 -- 宝贝识物与大鱼吃小鱼也要纳入同一批最小回归,避免只验首批或单一工作台。 -- 方洞结果页需覆盖“保存成功后再试玩 / 发布”的路径,避免测试绕过真实保存链路。 - -## Phase 4:特殊工作台与例外 - -```bash -npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "opening RPG agent workspace does not refetch session snapshot in a render loop|create tab resumes agent workspace when draft has no compiled result yet|create tab resumes agent workspace when session has no draft profile even if summary counts look compiled|opening a compiled draft with a missing agent session falls back to draft hub" -npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "create tab opens bark battle entry form from the template card|bark battle draft result can test before publish and publish to work detail|direct bark battle runtime public code opens published runtime" -npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx src/components/visual-novel-creation/visualNovelEntryGeneration.test.ts -npm run check:visual-novel-vn11 -``` - -- 验证 RPG 例外边界、Bark Battle 的创作 / 发布 / 正式 runtime 闭环,以及视觉小说入口、生成页、结果页、运行态和负向扫描门禁。 - -## Phase 5:结果页与公开闭环 - -```bash -npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "agent draft result publishes to gallery from publish panel|creation hub published work enters existing detail view|creation hub published work experience button enters world directly|creation hub published work start uses loaded detail profile instead of library summary" -npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "published puzzle works appear on home and mobile game category channel|published big fish works stay hidden from platform home and game category channel|home recommendation Match3D runtime keeps profile generated models when card summary is stale|home recommendation Match3D runtime passes top-level UI background assets|home recommendation Match3D runtime reloads detail when card only has UI assets" -npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/platform-entry/PlatformErrorDialog.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/rpg-entry/rpgEntryWorldPresentation.test.ts -npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx -``` - -- 验证结果页、发布、作品架、公开详情、推荐 runtime 和公开 read model 的最小回归面。 -- 验证作品架恢复矩阵、公开详情作者展示和跨流程错误 / 反馈弹窗仍和统一链路一致。 -- 若本轮还改了 API 路由、BFF、公开列表或 `/works/detail` 回读,再补 API smoke。`npm run dev:api-server` 是长驻进程,另开终端执行: - -```bash -curl -fsS "$(node -e 'const fs=require("fs"); const s=JSON.parse(fs.readFileSync(".app/dev-stack.json","utf8")); const services=s.services||{}; const svc=services["api-server"]||services.apiServer||{}; const url=String(svc.url||s.apiTarget||"http://127.0.0.1:8082").replace(/\/$/,""); process.stdout.write(`${url}/healthz`);')" -``` - -## 竖屏浏览器冒烟 - -自动测试通过后,仍需用竖屏视口确认页面不是空白、未跳错入口、底部操作不被遮挡。当前仓库没有专用 Playwright 脚本时,可用系统 Chrome 生成截图: - -```bash -out=.app/browser-check/phase-flow-$(date +%Y%m%d) -mkdir -p "$out" -for route in \ - creation-jump-hop:/creation/jump-hop \ - creation-visual-novel:/creation/visual-novel \ - creation-square-hole:/creation/square-hole \ - creation-bark-battle:/creation/bark-battle \ - creation-baby-object-match:/creation/baby-object-match \ - generating-jump-hop:/creation/jump-hop/generating \ - result-jump-hop:/creation/jump-hop/result \ - runtime-jump-hop:/runtime/jump-hop -do - name=${route%%:*} - path=${route#*:} - google-chrome --headless=new --no-sandbox --disable-gpu --window-size=390,844 --virtual-time-budget=4000 --screenshot="$out/$name.png" "http://127.0.0.1:3000$path" -done -ls -lh "$out" -``` - -- 执行前确保 `npm run dev:spacetime`、`npm run dev:api-server`、`npm run dev:web` 已按实际端口启动;若 `.app/dev-stack.json` 和实际端口不一致,以 `ss -ltnp` 和 `/healthz` 为准。 -- 截图只保存本地 `.app/`,不提交 Git。检查时至少确认首屏有对应玩法工作台 / 生成页 / 结果页 / runtime 内容,且竖屏下无明显空白死页、入口跳错、按钮遮挡或套滚动。跳一跳结果页若缺少恢复信息,应显示恢复面板而不是空白页。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index fdb11629e..c7deef0ec 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -163,10 +163,10 @@ function assertRootNativeShellCheckScripts() { } if ( rootPackageJson.scripts?.check !== - 'npm run lint && npm run test && npm run build && npm run check:content && npm run check:native-shells' + 'npm run lint && npm run test && npm run build && npm run check:native-shells' ) { throw new Error( - 'root check script must include check:native-shells after build and content checks', + 'root check script must include check:native-shells after build', ); } } diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 5831f4e7f..34d590d21 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -19,6 +19,17 @@ const checks = [ includes: 'npm run check:server-rs-ddd', reason: 'API 生产构建必须执行 server-rs DDD/schema/runtime-access 门禁。', }, + { + file: 'jenkins/Jenkinsfile.production-api-build', + includes: 'git fetch --no-tags --depth=2', + reason: 'API 生产构建的浅克隆必须有界取得待构建提交的父提交。', + }, + { + file: 'jenkins/Jenkinsfile.production-api-build', + includes: + "env.SPACETIME_SCHEMA_BASE_REF = readFile('.jenkins-spacetime-schema-base').trim()", + reason: 'API 生产构建必须向 schema guard 显式传递可信父提交基线。', + }, { file: 'jenkins/Jenkinsfile.production-api-build', includes: @@ -41,6 +52,17 @@ const checks = [ includes: 'npm run check:server-rs-ddd', reason: 'Stdb module 生产构建必须执行 DDD/schema/runtime-access 门禁。', }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-build', + includes: 'git fetch --no-tags --depth=2', + reason: 'Stdb module 生产构建的浅克隆必须有界取得待构建提交的父提交。', + }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-build', + includes: + "env.SPACETIME_SCHEMA_BASE_REF = readFile('.jenkins-spacetime-schema-base').trim()", + reason: 'Stdb module 生产构建必须向 schema guard 显式传递可信父提交基线。', + }, { file: 'jenkins/Jenkinsfile.production-stdb-module-build', includes: 'npm run check:admin-account-procedures', diff --git a/scripts/check-repository-ci.sh b/scripts/check-repository-ci.sh index 562853de1..b3a91d42b 100755 --- a/scripts/check-repository-ci.sh +++ b/scripts/check-repository-ci.sh @@ -22,5 +22,4 @@ echo "[repository-ci] base=${base_ref} head=$(git rev-parse "${head_ref}")" SPACETIME_SCHEMA_BASE_REF="${base_ref}" npm run lint npm run test -- apps/ai-game-creator-shell/tests/appSurface.test.ts npm run build -npm run check:content git diff --check "${base_ref}"..."${head_ref}" diff --git a/scripts/check-spacetime-schema-guard.mjs b/scripts/check-spacetime-schema-guard.mjs index 63dbbd9d4..176c4aec9 100644 --- a/scripts/check-spacetime-schema-guard.mjs +++ b/scripts/check-spacetime-schema-guard.mjs @@ -8,7 +8,8 @@ const repoRoot = join(scriptDir, '..'); const moduleSrcRoot = 'server-rs/crates/spacetime-module/src'; const moduleManifestPath = 'server-rs/crates/spacetime-module/Cargo.toml'; const migrationPath = `${moduleSrcRoot}/migration.rs`; -const tableCatalogPath = 'docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md'; +const tableCatalogPath = + 'docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md'; const bindingsRoot = 'server-rs/crates/spacetime-client/src/module_bindings/'; const allowBreaking = process.env.SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING === '1'; function normalizePath(path) { @@ -19,7 +20,9 @@ function runGit(args, options = {}) { return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', - stdio: options.quiet ? ['ignore', 'pipe', 'ignore'] : ['ignore', 'pipe', 'pipe'], + stdio: options.quiet + ? ['ignore', 'pipe', 'ignore'] + : ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024, }).trim(); } @@ -32,31 +35,77 @@ function tryGit(args) { } } -function resolveBaseRef() { - const explicitArgIndex = process.argv.indexOf('--base-ref'); - if (explicitArgIndex >= 0 && process.argv[explicitArgIndex + 1]) { - return process.argv[explicitArgIndex + 1]; +export function isFormalGateEnvironment(env = process.env) { + const enabled = (value) => + typeof value === 'string' && + value.trim() !== '' && + !['0', 'false', 'no', 'off'].includes(value.trim().toLowerCase()); + + return [ + env.CI, + env.JENKINS_URL, + env.JENKINS_HOME, + env.GITEA_ACTIONS, + env.GITHUB_ACTIONS, + env.BUILDKITE, + env.TEAMCITY_VERSION, + ].some(enabled); +} + +export function resolveBaseRef({ + argv = process.argv, + env = process.env, + git = tryGit, +} = {}) { + const explicitArgIndex = argv.indexOf('--base-ref'); + if (explicitArgIndex >= 0 && argv[explicitArgIndex + 1]) { + return argv[explicitArgIndex + 1]; } - if (process.env.SPACETIME_SCHEMA_BASE_REF) { - return process.env.SPACETIME_SCHEMA_BASE_REF; + if (env.SPACETIME_SCHEMA_BASE_REF) { + return env.SPACETIME_SCHEMA_BASE_REF; } - const mergeBase = tryGit(['merge-base', 'HEAD', 'origin/master']); - if (mergeBase) { - return mergeBase; + const headCommit = git(['rev-parse', '--verify', 'HEAD^{commit}']); + const candidates = [ + git(['merge-base', 'HEAD', 'origin/master']), + git(['rev-parse', '--verify', 'origin/master^{commit}']), + git(['rev-parse', '--verify', 'HEAD^']), + ]; + for (const candidate of candidates) { + if (candidate && candidate !== headCommit) { + return candidate; + } } - const originMaster = tryGit(['rev-parse', '--verify', 'origin/master']); - if (originMaster) { - return originMaster; + if (isFormalGateEnvironment(env)) { + throw new Error( + '正式测试/构建环境无法取得与 HEAD 不同的 SpacetimeDB schema 比较基线。请获取待构建提交的父提交并设置 SPACETIME_SCHEMA_BASE_REF。', + ); } return 'HEAD'; } +function validateBaseRef(baseRef) { + const baseCommit = tryGit(['rev-parse', '--verify', `${baseRef}^{commit}`]); + if (!baseCommit) { + throw new Error( + `SpacetimeDB schema 比较基线 ${baseRef} 不是可读取的 commit。`, + ); + } + + const headCommit = tryGit(['rev-parse', '--verify', 'HEAD^{commit}']); + if (isFormalGateEnvironment() && headCommit && baseCommit === headCommit) { + throw new Error( + `正式测试/构建环境的 SpacetimeDB schema 比较基线 ${baseRef} 与 HEAD 相同,拒绝无效自比。`, + ); + } +} + export function resolveCrateRootFromManifest(manifest) { - const libSection = /\[lib\]\s*\n([\s\S]*?)(?=\n\[|$)/u.exec(manifest)?.[1] ?? ''; + const libSection = + /\[lib\]\s*\n([\s\S]*?)(?=\n\[|$)/u.exec(manifest)?.[1] ?? ''; const configuredPath = /^\s*path\s*=\s*"([^"]+)"/mu.exec(libSection)?.[1]; return normalizePath( configuredPath @@ -83,7 +132,8 @@ export function listReachableRustFiles(readSource) { const crateRoot = resolveCrateRootFromManifest(manifest); const pending = [{ path: crateRoot, isCrateRoot: true }]; const visited = new Set(); - const externalModulePattern = /((?:[ \t]*#\[[^\]\r\n]*\][ \t]*\r?\n)*)[ \t]*(?:pub(?:\([^)]*\))?[ \t]+)?mod[ \t]+([A-Za-z_][A-Za-z0-9_]*)[ \t]*;/gmu; + const externalModulePattern = + /((?:[ \t]*#\[[^\]\r\n]*\][ \t]*\r?\n)*)[ \t]*(?:pub(?:\([^)]*\))?[ \t]+)?mod[ \t]+([A-Za-z_][A-Za-z0-9_]*)[ \t]*;/gmu; while (pending.length > 0) { const current = pending.pop(); @@ -97,7 +147,10 @@ export function listReachableRustFiles(readSource) { } visited.add(current.path); - const defaultModuleDir = childModuleDirectory(current.path, current.isCrateRoot); + const defaultModuleDir = childModuleDirectory( + current.path, + current.isCrateRoot, + ); let match; externalModulePattern.lastIndex = 0; @@ -108,7 +161,9 @@ export function listReachableRustFiles(readSource) { } const moduleName = match[2]; - const configuredPath = /#\[\s*path\s*=\s*"([^"]+)"\s*\]/u.exec(attributes)?.[1]; + const configuredPath = /#\[\s*path\s*=\s*"([^"]+)"\s*\]/u.exec( + attributes, + )?.[1]; const candidates = configuredPath ? [join(dirname(current.path), configuredPath)] : [ @@ -426,10 +481,12 @@ function parseField(segment, fileText, bodyStartIndex) { return null; } - const attrs = [...withoutLineComments.matchAll(/#\[[\s\S]*?\]/gu)].map((match) => - normalizeRustText(match[0]), + const attrs = [...withoutLineComments.matchAll(/#\[[\s\S]*?\]/gu)].map( + (match) => normalizeRustText(match[0]), ); - const fieldText = withoutLineComments.replace(/#\[[\s\S]*?\]\s*/gu, '').trim(); + const fieldText = withoutLineComments + .replace(/#\[[\s\S]*?\]\s*/gu, '') + .trim(); const fieldMatch = /^(?:pub(?:\([^)]*\))?\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([\s\S]+)$/u.exec( fieldText, @@ -467,9 +524,8 @@ function parseTablesFromFile(path, text) { } const attrText = text.slice(attrStart, attrEnd); - const accessorMatch = /accessor\s*=\s*(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/u.exec( - attrText, - ); + const accessorMatch = + /accessor\s*=\s*(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/u.exec(attrText); const accessor = accessorMatch?.[1] ?? accessorMatch?.[2]; if (!accessor) { continue; @@ -545,9 +601,21 @@ function loadBaseSources(baseRef) { function getChangedFiles(baseRef) { const diffOutput = tryGit(['diff', '--name-only', '-z', baseRef, '--']) ?? ''; const untrackedModuleOutput = - tryGit(['ls-files', '--others', '--exclude-standard', '-z', moduleSrcRoot]) ?? ''; + tryGit([ + 'ls-files', + '--others', + '--exclude-standard', + '-z', + moduleSrcRoot, + ]) ?? ''; const untrackedBindingsOutput = - tryGit(['ls-files', '--others', '--exclude-standard', '-z', bindingsRoot]) ?? ''; + tryGit([ + 'ls-files', + '--others', + '--exclude-standard', + '-z', + bindingsRoot, + ]) ?? ''; return new Set( [ ...diffOutput.split(String.fromCharCode(0)), @@ -587,7 +655,9 @@ function compareTables(baseTables, currentTables) { continue; } - const currentFieldNames = new Set(currentTable.fields.map((field) => field.name)); + const currentFieldNames = new Set( + currentTable.fields.map((field) => field.name), + ); if (currentTable.fields.length < baseTable.fields.length) { schemaChanged = true; breakingChanged = true; @@ -613,7 +683,9 @@ function compareTables(baseTables, currentTables) { if (baseField.name !== currentField.name) { const baseFieldStillExists = currentFieldNames.has(baseField.name); - const reason = baseFieldStillExists ? '字段顺序被调整' : '字段被删除或改名'; + const reason = baseFieldStillExists + ? '字段顺序被调整' + : '字段被删除或改名'; failures.push( `${currentTable.path}:${currentField.line}: SpacetimeDB 表 ${accessor} 的第 ${ index + 1 @@ -671,7 +743,9 @@ function checkSchemaSidecars(changedFiles, schemaChanged) { ); } - const bindingsChanged = [...changedFiles].some((path) => path.startsWith(bindingsRoot)); + const bindingsChanged = [...changedFiles].some((path) => + path.startsWith(bindingsRoot), + ); if (!bindingsChanged) { failures.push( `SpacetimeDB schema 已变化,但 ${bindingsRoot} 下没有生成绑定变更。请重新生成并提交绑定。`, @@ -682,16 +756,30 @@ function checkSchemaSidecars(changedFiles, schemaChanged) { } function main() { - const baseRef = resolveBaseRef(); + let baseRef; + try { + baseRef = resolveBaseRef(); + validateBaseRef(baseRef); + } catch (error) { + console.error( + `[check:spacetime-schema] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } const currentSources = loadCurrentSources(); const baseSources = loadBaseSources(baseRef); const currentResult = collectTablesFromSources(currentSources); const baseResult = collectTablesFromSources(baseSources); const compareResult = compareTables(baseResult.tables, currentResult.tables); const changedFiles = getChangedFiles(baseRef); - const sidecarFailures = checkSchemaSidecars(changedFiles, compareResult.schemaChanged); + const sidecarFailures = checkSchemaSidecars( + changedFiles, + compareResult.schemaChanged, + ); const compareFailures = - compareResult.breakingChanged && allowBreaking ? [] : compareResult.failures; + compareResult.breakingChanged && allowBreaking + ? [] + : compareResult.failures; const failures = [ ...currentResult.failures, ...baseResult.failures, diff --git a/scripts/check-spacetime-schema-guard.test.ts b/scripts/check-spacetime-schema-guard.test.ts index 1ceb91956..5265fe9f9 100644 --- a/scripts/check-spacetime-schema-guard.test.ts +++ b/scripts/check-spacetime-schema-guard.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'; import { collectTablesFromSources, + isFormalGateEnvironment, listReachableRustFiles, + resolveBaseRef, } from './check-spacetime-schema-guard.mjs'; const manifestPath = 'server-rs/crates/spacetime-module/Cargo.toml'; @@ -30,6 +32,67 @@ function tableSource(structName: string, accessor: string) { return `#[spacetimedb::table(accessor = ${accessor})]\npub struct ${structName} {\n pub id: u64,\n}\n`; } +function createGitResolver(entries: Record) { + return (args: string[]) => entries[args.join(' ')] ?? null; +} + +describe('SpacetimeDB schema guard base resolution', () => { + it('prefers an explicit Jenkins-provided base ref', () => { + expect( + resolveBaseRef({ + argv: ['node', 'check-spacetime-schema-guard.mjs'], + env: { + CI: 'true', + SPACETIME_SCHEMA_BASE_REF: 'parent-commit', + }, + git: createGitResolver({}), + }), + ).toBe('parent-commit'); + }); + + it('uses HEAD parent when the remote master ref resolves to HEAD', () => { + expect( + resolveBaseRef({ + argv: ['node', 'check-spacetime-schema-guard.mjs'], + env: { CI: 'true' }, + git: createGitResolver({ + 'rev-parse --verify HEAD^{commit}': 'current-commit', + 'merge-base HEAD origin/master': 'current-commit', + 'rev-parse --verify origin/master^{commit}': 'current-commit', + 'rev-parse --verify HEAD^': 'parent-commit', + }), + }), + ).toBe('parent-commit'); + }); + + it('fails closed in formal gates when no distinct commit is available', () => { + expect(() => + resolveBaseRef({ + argv: ['node', 'check-spacetime-schema-guard.mjs'], + env: { JENKINS_URL: 'https://jenkins.example.test/' }, + git: createGitResolver({ + 'rev-parse --verify HEAD^{commit}': 'current-commit', + 'merge-base HEAD origin/master': 'current-commit', + 'rev-parse --verify origin/master^{commit}': 'current-commit', + }), + }), + ).toThrow(/无法取得与 HEAD 不同/u); + }); + + it('keeps HEAD fallback for a local initial repository', () => { + expect( + resolveBaseRef({ + argv: ['node', 'check-spacetime-schema-guard.mjs'], + env: {}, + git: createGitResolver({ + 'rev-parse --verify HEAD^{commit}': 'initial-commit', + }), + }), + ).toBe('HEAD'); + expect(isFormalGateEnvironment({ CI: 'false' })).toBe(false); + }); +}); + describe('SpacetimeDB schema guard module reachability', () => { it('ignores duplicate accessors in retained but unreachable legacy sources', () => { const activePath = `${sourceRoot}/active.rs`; diff --git a/scripts/check-visual-novel-vn11-negative-scan.mjs b/scripts/check-visual-novel-vn11-negative-scan.mjs deleted file mode 100644 index f482d6af2..000000000 --- a/scripts/check-visual-novel-vn11-negative-scan.mjs +++ /dev/null @@ -1,261 +0,0 @@ -import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; -import { basename, extname, join, relative } from 'node:path'; - -const repoRoot = process.cwd(); -const writeReport = process.argv.includes('--write-report'); -const reportPath = join(repoRoot, '.tmp', 'VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md'); - -const documentTargets = [ - 'docs', - 'docs/project-memory/shared-memory', -]; - -const visualNovelImplementationTargets = [ - 'src/components/visual-novel-creation', - 'src/components/visual-novel-result', - 'src/components/visual-novel-runtime', - 'src/services/visual-novel-creation', - 'src/services/visual-novel-runtime', - 'src/services/visual-novel-works', - 'packages/shared/src/contracts/visualNovel.ts', - 'server-rs/crates/shared-contracts/src/visual_novel.rs', - 'server-rs/crates/module-visual-novel', - 'server-rs/crates/api-server/src/visual_novel.rs', - 'server-rs/crates/api-server/src/prompt/visual_novel.rs', - 'server-rs/crates/spacetime-module/src/visual_novel.rs', - 'server-rs/crates/spacetime-client/src/visual_novel.rs', -]; - -const textExtensions = new Set([ - '.cjs', - '.controller', - '.css', - '.html', - '.js', - '.json', - '.jsx', - '.md', - '.mjs', - '.ps1', - '.py', - '.rs', - '.scss', - '.sh', - '.toml', - '.ts', - '.tsx', - '.txt', - '.yaml', - '.yml', -]); - -const textFileNames = new Set([ - 'AGENTS.md', - 'README.md', -]); - -const excludedPrefixes = [ - '.git/', - 'dist/', - 'node_modules/', - 'server-rs/target/', - 'server-rs/target-', -]; - -const legacyPlaybackTerms = [ - 're' + 'play', - 'Re' + 'play', - '回放', - '分享回放', - '录制', - '复盘', -]; - -const externalPlatformPatterns = [ - /订单/u, - /会员/u, - /促销/u, - /后台/u, - /公开市场/u, - /私有存档/u, - /独立存档/u, - /商城/u, - /支付/u, - /订阅/u, - /活动配置/u, - /小游戏平台/u, - /公开游戏市场/u, - /server-node/u, - /Cloudflare Worker/u, - /\bExpress\b/u, - /\bD1\b/u, - /\bR2\b/u, -]; - -function normalizePath(filePath) { - return filePath.replace(/\\/gu, '/'); -} - -function repoRelative(filePath) { - return normalizePath(relative(repoRoot, filePath)); -} - -function shouldInspect(filePath) { - const normalized = repoRelative(filePath); - - if (excludedPrefixes.some((prefix) => normalized.startsWith(prefix))) { - return false; - } - - const fileName = basename(filePath); - if (textFileNames.has(fileName)) { - return true; - } - - return textExtensions.has(extname(fileName).toLowerCase()); -} - -function listTextFiles(targetPath) { - const fullPath = join(repoRoot, targetPath); - - if (!existsSync(fullPath)) { - return []; - } - - const stat = statSync(fullPath); - if (stat.isFile()) { - return shouldInspect(fullPath) ? [fullPath] : []; - } - - const files = []; - const walk = (dir) => { - for (const name of readdirSync(dir)) { - const child = join(dir, name); - const childStat = statSync(child); - - if (childStat.isDirectory()) { - walk(child); - continue; - } - - if (shouldInspect(child)) { - files.push(child); - } - } - }; - - walk(fullPath); - return files; -} - -function collectFiles(targets) { - return [...new Set(targets.flatMap(listTextFiles))].sort(); -} - -function collectLineHits(files, matcher) { - const hits = []; - - for (const file of files) { - const lines = readFileSync(file, 'utf8').split(/\r?\n/u); - - lines.forEach((line, index) => { - if (matcher(line)) { - hits.push({ - file: repoRelative(file), - lineNumber: index + 1, - text: line.trim(), - }); - } - }); - } - - return hits; -} - -function hasLegacyPlaybackMarker(line) { - return legacyPlaybackTerms.some((term) => line.includes(term)); -} - -function hasExternalPlatformMarker(line) { - return externalPlatformPatterns.some((pattern) => pattern.test(line)); -} - -const documentFiles = collectFiles(documentTargets); -const visualNovelFiles = collectFiles(visualNovelImplementationTargets); - -const codePlaybackHits = collectLineHits(visualNovelFiles, hasLegacyPlaybackMarker); -const documentPlaybackHits = collectLineHits(documentFiles, hasLegacyPlaybackMarker); -const externalPlatformHits = collectLineHits( - visualNovelFiles, - hasExternalPlatformMarker, -); - -const reportLines = [ - '# VN-11 负向扫描报告', - '', - '生成日期:2026-05-07', - '', - '## 扫描范围', - '', - '- 视觉小说工程代码:视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径', - '- 文档与共享记忆:`docs/`、`docs/project-memory/shared-memory/`', - '- 外部平台误入复核:视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径', - '', - '## 扫描结论', - '', - `- 工程代码回放类直出命中:${codePlaybackHits.length}`, - `- 文档 / 共享记忆回放类命中:${documentPlaybackHits.length}`, - `- 视觉小说实现路径外部平台能力疑似误入命中:${externalPlatformHits.length}`, - '', - '## 处理记录', - '', - '- 已将 `storyEngine` 回归工具的命名从 replay 语义收口为 rerun / 复测语义。', - '- 已将技能效果预览按钮的内部状态与文案从重播语义收口为重新预览语义。', - '- 已确认视觉小说工程路径未新增回放路由、DTO、表、按钮、文案、外部平台账号 / 订单 / 会员 / 促销 / 后台 / 公开市场或私有存档能力。', - '', - '## 文档命中说明', - '', - '- 文档命中来自历史旧文档、设计复盘、禁止语境、负向验收或本报告记录。VN-11 工程门禁只阻断代码路径新增能力。', - '', - '## 门禁命令', - '', - '```bash', - 'npm run check:visual-novel-vn11', - '```', - '', -]; - -if (writeReport) { - writeFileSync(reportPath, `${reportLines.join('\n')}\n`, 'utf8'); -} - -const failures = []; -if (codePlaybackHits.length > 0) { - failures.push('工程代码仍存在回放类直出命中。'); -} - -if (externalPlatformHits.length > 0) { - failures.push('视觉小说实现路径仍存在疑似外部平台能力误入。'); -} - -if (failures.length > 0) { - console.error('VN-11 negative scan failed:'); - for (const failure of failures) { - console.error(`- ${failure}`); - } - - console.error(''); - for (const hit of [...codePlaybackHits, ...externalPlatformHits]) { - console.error(`- ${hit.file}:${hit.lineNumber} ${hit.text}`); - } - - process.exit(1); -} - -console.log('VN-11 negative scan passed.'); -console.log(`- code playback hits: ${codePlaybackHits.length}`); -console.log(`- document playback hits: ${documentPlaybackHits.length}`); -console.log(`- external platform hits in visual novel implementation: ${externalPlatformHits.length}`); -if (writeReport) { - console.log(`- report: ${repoRelative(reportPath)}`); -} diff --git a/scripts/check-visual-novel-vn12-acceptance.mjs b/scripts/check-visual-novel-vn12-acceptance.mjs deleted file mode 100644 index 4efd073c2..000000000 --- a/scripts/check-visual-novel-vn12-acceptance.mjs +++ /dev/null @@ -1,265 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join, relative } from 'node:path'; - -const repoRoot = process.cwd(); -const writeReport = process.argv.includes('--write-report'); -const reportPath = join( - repoRoot, - '.tmp', - 'VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md', -); - -const requiredFiles = [ - 'docs/【玩法创作】平台入口与玩法链路-2026-05-15.md', - 'src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx', - 'src/components/visual-novel-result/VisualNovelResultView.test.tsx', - 'src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx', - 'src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts', - 'src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts', - 'server-rs/crates/api-server/src/visual_novel.rs', - 'server-rs/crates/module-visual-novel/src/application.rs', - 'server-rs/crates/shared-contracts/src/visual_novel.rs', -]; - -const contentChecks = [ - { - path: 'package.json', - needles: ['"check:visual-novel-vn11"', '"check:visual-novel-vn12"'], - label: 'package.json scripts', - }, - { - path: 'server-rs/crates/api-server/src/app.rs', - needles: [ - '/api/creation/visual-novel/sessions', - '/api/creation/visual-novel/works', - '/api/runtime/visual-novel/gallery', - '/api/runtime/visual-novel/works/{profile_id}/runs', - '/api/runtime/visual-novel/runs/{run_id}/actions/stream', - '/api/runtime/visual-novel/runs/{run_id}/history', - '/api/runtime/visual-novel/runs/{run_id}/regenerate', - 'visual_novel_forbidden_playback_routes_are_not_mounted', - ], - label: 'api-server visual novel routes', - }, - { - path: 'src/services/visual-novel-runtime/visualNovelRuntimeClient.ts', - needles: [ - 'VISUAL_NOVEL_RUNTIME_API_BASE', - '${VISUAL_NOVEL_RUNTIME_API_BASE}/gallery', - 'skipAuth: true', - 'skipRefresh: true', - '${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/actions/stream', - '/api/profile/save-archives', - '/api/runtime/save/snapshot', - ], - label: 'visual novel runtime client routes', - }, - { - path: 'src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts', - needles: [ - 'listVisualNovelGallery reads public gallery without auth refresh coupling', - 'startVisualNovelRun uses the visual novel runtime work route', - 'streamVisualNovelRuntimeAction posts to the SSE action stream route', - 'regenerateVisualNovelRun uses the history regenerate route', - 'listVisualNovelSaveArchives and resumeVisualNovelSaveArchive use platform archive routes', - 'putVisualNovelRuntimeSnapshot only submits platform checkpoint metadata', - 'buildVisualNovelRuntimeCheckpoint maps run id into session id', - 'buildVisualNovelSaveArchiveState only uses runtime identifiers and hashes', - ], - label: 'visual novel runtime client tests', - }, - { - path: 'src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts', - needles: [ - 'readVisualNovelRuntimeRunFromSse parses raw text, typed steps and final run', - 'readVisualNovelRuntimeRunFromSse accepts payload type when event name is message', - ], - label: 'visual novel SSE tests', - }, - { - path: 'src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx', - needles: [ - 'visual novel workspace only exposes one-line input and visual style entry', - 'visual novel workspace submits idea and selected visual style as seed text', - 'visual novel workspace restores idea text from existing session', - 'visual novel generation helpers build process page data', - ], - label: 'visual novel creation tests', - }, - { - path: 'src/components/visual-novel-result/VisualNovelResultView.test.tsx', - needles: [ - 'visual novel result opens complex editors as a dialog', - 'visual novel result exposes test run action with current draft', - 'visual novel result sends edited character draft to save and test run', - 'visual novel result uploads scene and character assets into platform references', - ], - label: 'visual novel result tests', - }, - { - path: 'src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx', - needles: [ - 'visual novel runtime renders mock play surface and opens panels as dialogs', - 'visual novel runtime submits free text action with client event id', - 'visual novel runtime submits choice and continue actions', - 'visual novel runtime panels call regeneration and platform archive actions', - 'visual novel runtime shows raw text only as transient stream text', - ], - label: 'visual novel runtime tests', - }, -]; - -function repoRelative(filePath) { - return relative(repoRoot, filePath).replace(/\\/gu, '/'); -} - -function readText(filePath) { - return readFileSync(filePath, 'utf8'); -} - -function ensureFileExists(relativePath, failures, checkedFiles) { - const fullPath = join(repoRoot, relativePath); - checkedFiles.push(relativePath); - - if (!existsSync(fullPath)) { - failures.push(`missing file: ${relativePath}`); - return false; - } - - return true; -} - -function ensureNeedles(filePath, needles, failures) { - const content = readText(filePath); - for (const needle of needles) { - if (!content.includes(needle)) { - failures.push(`missing content in ${filePath}: ${needle}`); - } - } -} - -function buildReport({ - failures, - checkedFiles, - contentSummary, -}) { - const status = failures.length === 0 ? '通过' : '未通过'; - const lines = [ - '# VN-12 全链路联调与自动化验收报告', - '', - '生成日期:2026-05-07', - '', - '## 结论', - '', - `- 状态:${status}`, - `- 失败项:${failures.length}`, - '- 收口说明:VN-12 本次只补验收门禁、关键路径测试和报告记录,未扩展新玩法功能。', - '', - '## 自动化验收清单', - '', - ...checkedFiles.map((file) => `- ${repoRelative(file)}`), - '', - '## API smoke', - '', - '- `/api/creation/visual-novel/sessions`', - '- `/api/creation/visual-novel/works`', - '- `/api/runtime/visual-novel/gallery`', - '- `/api/runtime/visual-novel/works/{profile_id}/runs`', - '- `/api/runtime/visual-novel/runs/{run_id}/actions/stream`', - '- `/api/runtime/visual-novel/runs/{run_id}/history`', - '- `/api/runtime/visual-novel/runs/{run_id}/regenerate`', - '- `/api/profile/save-archives`', - '- `/api/profile/save-archives/{world_key}`', - '- `/api/runtime/save/snapshot`', - '', - '本次实测:', - '', - '- `npm run dev:api-server` 可启动 Rust `api-server`。', - '- `GET http://127.0.0.1:3100/healthz` 返回 `200`,响应为 `{"ok":true,"service":"genarrative-api-server"}`。', - '- `GET /api/runtime/visual-novel/gallery` 在当前本地环境返回超时 / `502`,日志显示 `api-server` 连接 `127.0.0.1:3101` SpacetimeDB 数据库 `xushi-p4wfr` 被拒绝;该项按本地 SpacetimeDB 未完整就绪记录为环境阻塞,不新增工程实现。', - '', - '## 前端关键路径', - '', - '- 创作工作台:`VisualNovelAgentWorkspace`', - '- 结果页:`VisualNovelResultView`', - '- 运行时:`VisualNovelRuntimeShell`', - '- 运行时 SSE:`visualNovelRuntimeSse` / `visualNovelRuntimeClient`', - '', - '## 桌面 / 移动端检查', - '', - '- 桌面端:已用 Edge headless 截取 `/creation/visual-novel/agent`,截图作为临时验收产物保存到 `.tmp/`。', - '- 移动端:已用 Edge headless 截取 `/creation/visual-novel/agent`,截图作为临时验收产物保存到 `.tmp/`。', - '- in-app browser 插件本次未发现可用 IAB backend,截图使用本机 Edge headless 兜底完成。', - '', - '## 校验摘要', - '', - ...contentSummary.map((item) => `- ${item.label}: 通过`), - '', - '## 执行命令', - '', - '```bash', - 'npm run check:visual-novel-vn12 -- --write-report', - 'npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts', - 'npm run check:encoding', - 'npm run typecheck', - 'cd server-rs', - 'cargo test -p shared-contracts', - 'cargo test -p module-visual-novel', - 'cargo check -p api-server', - '```', - '', - '## 未覆盖风险', - '', - '- 当前本地 SpacetimeDB 连接未完整就绪,公开 gallery API 的真实数据返回未在本次环境完成;`/healthz` 与编译 / 单测已通过。', - '- 若接口路由或测试名称后续调整,需要同步更新本门禁脚本与报告模板。', - '', - ]; - - return `${lines.join('\n')}\n`; -} - -const failures = []; -const checkedFiles = []; - -for (const file of requiredFiles) { - ensureFileExists(file, failures, checkedFiles); -} - -const contentSummary = []; -for (const check of contentChecks) { - const fullPath = join(repoRoot, check.path); - if (!ensureFileExists(check.path, failures, checkedFiles)) { - continue; - } - - ensureNeedles(fullPath, check.needles, failures); - contentSummary.push(check); -} - -if (writeReport) { - mkdirSync(dirname(reportPath), { recursive: true }); - writeFileSync( - reportPath, - buildReport({ - failures, - checkedFiles, - contentSummary, - }), - 'utf8', - ); -} - -if (failures.length > 0) { - console.error('VN-12 acceptance gate failed:'); - for (const failure of failures) { - console.error(`- ${failure}`); - } - process.exit(1); -} - -console.log('VN-12 acceptance gate passed.'); -console.log(`- checked files: ${checkedFiles.length}`); -console.log(`- content checks: ${contentSummary.length}`); -if (writeReport) { - console.log(`- report: ${repoRelative(reportPath)}`); -} diff --git a/scripts/git-hooks.test.mjs b/scripts/git-hooks.test.mjs index 64c1743a3..16c0af7de 100644 --- a/scripts/git-hooks.test.mjs +++ b/scripts/git-hooks.test.mjs @@ -262,7 +262,6 @@ test('Gitea Repository checks and master pre-push share the same repository comm /npm run lint[\s\S]*npm run test -- apps\/ai-game-creator-shell\/tests\/appSurface\.test\.ts[\s\S]*npm run build/u, ); assert.match(repositoryScript, /npm run build/u); - assert.match(repositoryScript, /npm run check:content/u); assert.match(repositoryScript, /git diff --check/u); assert.equal(prePushHook, 'npm run check:pre-push-master -- "$@"\n'); }); diff --git a/scripts/project-ci-workflow.test.ts b/scripts/project-ci-workflow.test.ts index e21fc3043..44070e0ee 100644 --- a/scripts/project-ci-workflow.test.ts +++ b/scripts/project-ci-workflow.test.ts @@ -75,6 +75,13 @@ function backendStepIndex(stepName: string) { } describe('project CI workflow', () => { + it('runs for master pushes, pull requests, and manual dispatch only', () => { + expect(workflow).toMatch( + /on:\n {2}push:\n {4}branches:\n {6}- master\n {2}pull_request:\n {2}workflow_dispatch:/u, + ); + expect(workflow).not.toContain('codex/ai-game-creator-app'); + }); + it('keeps every job on the isolated preinstalled CI image boundary', () => { expect(workflow.match(/^ {4}runs-on: genarrative-ci$/gm)).toHaveLength(4); expect(workflow).not.toContain('actions/checkout'); @@ -208,4 +215,35 @@ describe('project CI workflow', () => { expect(workflow).toContain('cargo fetch --locked'); expect(workflow).toContain('for attempt in $(seq 1 5); do'); }); + + it('never passes HEAD itself to the schema comparison gate', () => { + expect( + workflow.match( + /if \[\[ "\$\{resolved_base_ref\}" == "\$\{head_ref\}" \]\]; then/g, + ), + ).toHaveLength(2); + expect(workflow.match(/git rev-parse --verify HEAD\^/g)).toHaveLength(2); + expect( + workflow.match( + /comparison base must resolve to a commit distinct from HEAD\./g, + ), + ).toHaveLength(2); + }); + + it('keeps frontend, operations fixture, and native shell gates in dedicated jobs', () => { + const frontendJob = jobSection('frontend-tests'); + expect(frontendJob).toContain('run: npm run test'); + expect(frontendJob).toContain('run: npm run bgfilter-worker:smoke-test'); + expect(frontendJob).toContain( + 'run: npm run check:production-health-patrol', + ); + expect(frontendJob).toContain('run: npm run check:production-api-release'); + expect(frontendJob).toContain('run: npm run check:production-api-deploy'); + + const nativeJob = jobSection('native-shell-tests'); + expect(nativeJob).toContain('run: npm run check:native-shells'); + expect(nativeJob).toContain( + 'git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock', + ); + }); }); diff --git a/scripts/smoke-content.ts b/scripts/smoke-content.ts deleted file mode 100644 index ae1dd814d..000000000 --- a/scripts/smoke-content.ts +++ /dev/null @@ -1,430 +0,0 @@ -import { buildCompanionState, resolveEncounterRecruitCharacter,ROLE_TEMPLATE_CHARACTERS } from '../src/data/characterPresets.ts'; -import { activateRosterCompanion, benchActiveCompanion, recruitCompanionToParty } from '../src/data/companionRoster.ts'; -import { getInventoryItemValue, getNpcPurchasePrice } from '../src/data/economy.ts'; -import { - buildEncounterEntryState, - buildEncounterTransitionState, - interpolateEncounterTransitionState, -} from '../src/data/encounterTransition.ts'; -import { - applyEquipmentLoadoutToState, - buildInitialEquipmentLoadout, - createEmptyEquipmentLoadout, - getEquipmentBonuses, -} from '../src/data/equipmentEffects.ts'; -import { createSceneHostileNpcsFromIds } from '../src/data/hostileNpcs.ts'; -import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../src/data/inventoryEffects.ts'; -import { buildInitialNpcState, buildInitialPlayerInventory, buildNpcEncounterStoryMoment, checkTradeItem, createNpcBattleMonster } from '../src/data/npcInteractions.ts'; -import { - acceptQuest, - applyQuestProgressFromHostileNpcDefeat, - applyQuestProgressFromNpcTalk, - buildQuestForEncounter, - findQuestById, - markQuestTurnedIn, -} from '../src/data/questFlow.ts'; -import { createInitialGameRuntimeStats } from '../src/data/runtimeStats.ts'; -import { createSceneCallOutEncounter, createSceneEncounterPreview, ensureSceneEncounterPreview } from '../src/data/sceneEncounterPreviews.ts'; -import { getSceneHostileNpcPresetIds, getScenePresetsByWorld } from '../src/data/scenePresets.ts'; -import { resolveFunctionOption } from '../src/data/stateFunctions.ts'; -import { buildTreasureEncounterStoryMoment, buildTreasureResultText, resolveTreasureReward } from '../src/data/treasureInteractions.ts'; -import { AnimationState, GameState, WorldType } from '../src/types.ts'; - -function assert(condition: unknown, message: string): asserts condition { - if (!condition) { - throw new Error(message); - } -} - -function createBaseState(worldType: WorldType, sceneId?: string): GameState { - const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0]; - const currentScenePreset = sceneId - ? getScenePresetsByWorld(worldType).find(scene => scene.id === sceneId) ?? null - : getScenePresetsByWorld(worldType)[0] ?? null; - - return { - worldType, - customWorldProfile: null, - playerCharacter, - runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }), - currentScene: 'Story', - storyHistory: [], - characterChats: {}, - ambientIdleMode: undefined, - animationState: AnimationState.IDLE, - currentEncounter: null, - npcInteractionActive: false, - currentScenePreset, - sceneHostileNpcs: [], - playerX: 0, - playerOffsetY: 0, - playerFacing: 'right', - playerActionMode: 'idle', - scrollWorld: false, - inBattle: false, - playerHp: 180, - playerMaxHp: 180, - playerMana: 100, - playerMaxMana: 100, - playerSkillCooldowns: {}, - activeCombatEffects: [], - playerCurrency: 180, - playerInventory: [], - playerEquipment: createEmptyEquipmentLoadout(), - npcStates: {}, - quests: [], - roster: [], - companions: [], - currentBattleNpcId: null, - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - sparReturnEncounter: null, - sparPlayerHpBefore: null, - sparPlayerMaxHpBefore: null, - sparStoryHistoryBefore: null, - }; -} - -function smokeScenePreviews() { - for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { - const scene = getScenePresetsByWorld(worldType)[0]; - assert(scene, `[preview] missing first scene for ${worldType}`); - - const preview = createSceneEncounterPreview(createBaseState(worldType, scene.id)); - assert(preview.currentEncounter?.kind !== 'treasure', `[preview] treasure encounter should be disabled for ${worldType}`); - assert(preview.currentEncounter || preview.sceneHostileNpcs.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} produced no preview entity`); - - const ensured = ensureSceneEncounterPreview(createBaseState(worldType, scene.id)); - assert(ensured.currentEncounter || ensured.sceneHostileNpcs.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} failed ensureSceneEncounterPreview`); - } -} - -function smokeNpcStories() { - for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { - const sceneWithNpc = getScenePresetsByWorld(worldType).find(scene => scene.npcs.length > 0); - assert(sceneWithNpc, `[npc] missing npc scene for ${worldType}`); - - const encounter = { - id: sceneWithNpc.npcs[0].id, - kind: 'npc' as const, - characterId: sceneWithNpc.npcs[0].characterId, - npcName: sceneWithNpc.npcs[0].name, - npcDescription: sceneWithNpc.npcs[0].description, - npcAvatar: sceneWithNpc.npcs[0].avatar, - context: sceneWithNpc.npcs[0].role, - xMeters: 3.2, - }; - const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0]; - const npcState = buildInitialNpcState(encounter, worldType); - const story = buildNpcEncounterStoryMoment({ - encounter, - npcState, - playerCharacter, - playerInventory: [], - activeQuests: [], - scene: sceneWithNpc, - worldType, - partySize: 0, - }); - - assert(story.options.length >= 3, `[npc] ${sceneWithNpc.id} npc story returned too few options`); - const battleMonster = createNpcBattleMonster(encounter, npcState, 'spar'); - assert(battleMonster.hp >= 7 && battleMonster.hp <= 12, `[npc] spar hp for ${encounter.npcName} out of expected range`); - } -} - -function smokeTreasureStories() { - for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { - const sceneWithTreasure = getScenePresetsByWorld(worldType).find(scene => scene.treasureHints.length > 0); - assert(sceneWithTreasure, `[treasure] missing treasure scene for ${worldType}`); - const state = createBaseState(worldType, sceneWithTreasure.id); - - const encounter = { - id: `treasure-${sceneWithTreasure.id}`, - kind: 'treasure' as const, - npcName: '前方宝藏', - npcDescription: `你在前方发现了${sceneWithTreasure.treasureHints[0]}的痕迹。`, - npcAvatar: '/Icons/47_treasure.png', - context: '宝藏', - xMeters: 3.2, - }; - - const story = buildTreasureEncounterStoryMoment({ - state, - encounter, - }); - - assert(story.options.length === 3, `[treasure] ${sceneWithTreasure.id} treasure story should provide exactly 3 options`); - const inspectReward = resolveTreasureReward(state, encounter, 'inspect'); - assert(inspectReward.items.length >= 2, `[treasure] ${sceneWithTreasure.id} inspect reward should contain at least 2 items`); - assert(buildTreasureResultText(encounter, 'inspect', inspectReward).includes('收'), `[treasure] ${sceneWithTreasure.id} inspect result text should describe loot`); - } -} - -function smokeMonsterCreation() { - for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { - const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => getSceneHostileNpcPresetIds(scene).length > 0); - assert(sceneWithMonster, `[monster] missing monster scene for ${worldType}`); - const hostileNpcPresetIds = getSceneHostileNpcPresetIds(sceneWithMonster); - const monsters = createSceneHostileNpcsFromIds(worldType, hostileNpcPresetIds, 0); - assert(monsters.length > 0, `[monster] ${sceneWithMonster.id} failed to create scene monsters`); - assert( - monsters.length === Math.min(hostileNpcPresetIds.length, 3), - `[monster] ${sceneWithMonster.id} should keep the full configured encounter group`, - ); - - const resolvedState = createBaseState(worldType, sceneWithMonster.id); - resolvedState.sceneHostileNpcs = monsters; - resolvedState.inBattle = true; - assert( - resolvedState.sceneHostileNpcs.length === monsters.length, - `[monster] ${sceneWithMonster.id} multi-enemy battle state lost monsters`, - ); - } -} - -function smokeRecruitmentData() { - for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { - const sceneWithCharacterNpc = getScenePresetsByWorld(worldType).find(scene => scene.npcs.some(npc => npc.characterId)); - assert(sceneWithCharacterNpc, `[recruit] missing recruitable character npc scene for ${worldType}`); - const recruitableNpc = sceneWithCharacterNpc.npcs.find(npc => npc.characterId)!; - const recruitCharacter = resolveEncounterRecruitCharacter({ - characterId: recruitableNpc.characterId, - context: recruitableNpc.role, - npcName: recruitableNpc.name, - }); - assert(recruitCharacter, `[recruit] failed to resolve recruit character for ${recruitableNpc.id}`); - const companionState = buildCompanionState(recruitableNpc.id, recruitCharacter, 60); - assert(companionState.hp > 0 && companionState.maxHp >= companionState.hp, `[recruit] invalid hp for ${recruitableNpc.id}`); - assert(Object.keys(companionState.skillCooldowns).length === recruitCharacter.skills.length, `[recruit] cooldown map mismatch for ${recruitableNpc.id}`); - } -} - -function smokeObserveAndCallOut() { - for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { - const scene = getScenePresetsByWorld(worldType)[0]; - assert(scene, `[idle] missing first scene for ${worldType}`); - const baseState = createBaseState(worldType, scene.id); - const callOutResult = createSceneCallOutEncounter(baseState); - assert(callOutResult.currentEncounter?.kind !== 'treasure', `[idle] treasure call_out should be disabled for ${worldType}`); - assert(callOutResult.currentEncounter || callOutResult.sceneHostileNpcs.length > 0 || getSceneHostileNpcPresetIds(scene).length === 0, `[idle] call_out failed for ${scene.id}`); - - const observeOption = resolveFunctionOption( - 'idle_observe_signs', - { - worldType, - playerCharacter: baseState.playerCharacter, - inBattle: false, - currentSceneId: scene.id, - currentSceneName: scene.name, - monsters: [], - playerHp: baseState.playerHp, - playerMaxHp: baseState.playerMaxHp, - playerMana: baseState.playerMana, - playerMaxMana: baseState.playerMaxMana, - }, - '观察周围动静', - ); - assert(observeOption?.functionId === 'idle_observe_signs', `[idle] observe_signs option missing for ${scene.id}`); - assert(Boolean(observeOption?.detailText?.trim()), `[idle] observe_signs detail missing for ${scene.id}`); - } -} - -function smokeInventoryUseLoop() { - for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { - const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0]; - const inventory = buildInitialPlayerInventory(playerCharacter, worldType); - const usableItem = inventory.find(item => isInventoryItemUsable(item)); - assert(usableItem, `[inventory] missing usable starter item for ${worldType}`); - - const effect = resolveInventoryItemUseEffect(usableItem, playerCharacter); - assert(effect, `[inventory] failed to resolve use effect for ${usableItem.name}`); - assert( - effect.hpRestore > 0 || effect.manaRestore > 0 || effect.cooldownReduction > 0, - `[inventory] ${usableItem.name} should provide at least one useful effect`, - ); - } -} - -function smokeEquipmentLoop() { - const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0]; - const starterLoadout = buildInitialEquipmentLoadout(playerCharacter); - const starterBonuses = getEquipmentBonuses(starterLoadout); - - assert(starterBonuses.maxHpBonus > 0, '[equipment] starter loadout should provide HP bonus'); - assert(starterBonuses.outgoingDamageMultiplier > 1, '[equipment] starter loadout should provide damage bonus'); - - const baseState = createBaseState(WorldType.WUXIA); - const equippedState = applyEquipmentLoadoutToState(baseState, starterLoadout); - assert(equippedState.playerMaxHp > baseState.playerMaxHp, '[equipment] applying loadout should increase max HP'); - assert(equippedState.playerMaxMana > baseState.playerMaxMana, '[equipment] applying loadout should increase max mana'); -} - -function smokeTradeEconomyLoop() { - for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { - const sceneWithNpc = getScenePresetsByWorld(worldType).find(scene => scene.npcs.length > 0); - assert(sceneWithNpc, `[trade] missing npc scene for ${worldType}`); - - const encounter = { - id: sceneWithNpc.npcs[0].id, - kind: 'npc' as const, - characterId: sceneWithNpc.npcs[0].characterId, - npcName: sceneWithNpc.npcs[0].name, - npcDescription: sceneWithNpc.npcs[0].description, - npcAvatar: sceneWithNpc.npcs[0].avatar, - context: sceneWithNpc.npcs[0].role, - xMeters: 3.2, - }; - const npcState = buildInitialNpcState(encounter, worldType); - const npcItem = npcState.inventory[0]; - const playerItem = buildInitialPlayerInventory(ROLE_TEMPLATE_CHARACTERS[0], worldType)[0]; - assert(npcItem, `[trade] missing npc item for ${worldType}`); - assert(playerItem, `[trade] missing player item for ${worldType}`); - - const npcItemValue = getInventoryItemValue(npcItem); - const playerItemValue = getInventoryItemValue(playerItem); - assert(npcItemValue > 0 && playerItemValue > 0, `[trade] item values should be positive for ${worldType}`); - - const purchasePrice = getNpcPurchasePrice(npcItem, npcState.affinity); - assert(purchasePrice > 0, `[trade] purchase price should be positive for ${worldType}`); - - const purchaseCheck = checkTradeItem(null, npcItem, npcState.affinity, purchasePrice); - assert(purchaseCheck.canPurchase, `[trade] direct purchase should succeed when currency matches price for ${worldType}`); - - const barterCheck = checkTradeItem(playerItem, npcItem, npcState.affinity, 0); - assert(typeof barterCheck.canBarter === 'boolean', `[trade] barter check should return a boolean for ${worldType}`); - } -} - -function smokeEncounterTransitionLoop() { - for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { - const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => getSceneHostileNpcPresetIds(scene).length >= 2); - assert(sceneWithMonster, `[transition] missing multi-monster scene for ${worldType}`); - - const hostileNpcPresetIds = getSceneHostileNpcPresetIds(sceneWithMonster); - const finalMonsters = createSceneHostileNpcsFromIds(worldType, hostileNpcPresetIds, 0); - const finalState = { - ...createBaseState(worldType, sceneWithMonster.id), - inBattle: true, - sceneHostileNpcs: finalMonsters, - }; - const previewState = { - ...finalState, - inBattle: false, - sceneHostileNpcs: finalMonsters.map((monster, index) => ({ - ...monster, - xMeters: 12 + (index * 1.8), - })), - }; - - const transitionState = buildEncounterTransitionState(finalState, previewState); - assert( - transitionState.sceneHostileNpcs[1]?.xMeters === previewState.sceneHostileNpcs[1]?.xMeters, - `[transition] second monster should keep its preview x during transition for ${worldType}`, - ); - - const halfwayState = interpolateEncounterTransitionState(transitionState, finalState, 0.5); - assert( - halfwayState.sceneHostileNpcs.every((monster, index) => { - const startX = transitionState.sceneHostileNpcs[index]?.xMeters ?? monster.xMeters; - const endX = finalState.sceneHostileNpcs[index]?.xMeters ?? monster.xMeters; - return monster.xMeters !== startX && monster.xMeters !== endX; - }), - `[transition] all monsters should interpolate instead of only the first one for ${worldType}`, - ); - - const offscreenState = buildEncounterEntryState(finalState, 18); - assert( - offscreenState.sceneHostileNpcs.every(monster => monster.xMeters >= 18), - `[transition] offscreen entry should place the entire encounter group offscreen for ${worldType}`, - ); - } -} - -function smokeRosterLoop() { - const playerCharacter = ROLE_TEMPLATE_CHARACTERS[0]; - const reserveCharacter = ROLE_TEMPLATE_CHARACTERS[1]; - const recruitCharacter = ROLE_TEMPLATE_CHARACTERS[2]; - const activeCompanion = buildCompanionState('active-npc', playerCharacter, 68); - const reserveCompanion = buildCompanionState('reserve-npc', reserveCharacter, 62); - const recruitedCompanion = buildCompanionState('new-npc', recruitCharacter, 72); - - const baseState = { - ...createBaseState(WorldType.WUXIA), - companions: [activeCompanion], - roster: [reserveCompanion], - }; - - const benchedState = benchActiveCompanion(baseState, activeCompanion.npcId); - assert(benchedState.companions.length === 0, '[roster] active companion should move off active team'); - assert(benchedState.roster.some(companion => companion.npcId === activeCompanion.npcId), '[roster] benched companion should enter reserve roster'); - - const activatedState = activateRosterCompanion(baseState, reserveCompanion.npcId); - assert(activatedState.companions.some(companion => companion.npcId === reserveCompanion.npcId), '[roster] reserve companion should be activatable'); - assert(!activatedState.roster.some(companion => companion.npcId === reserveCompanion.npcId), '[roster] activated companion should leave reserve roster'); - - const swappedState = recruitCompanionToParty( - { - ...baseState, - companions: [activeCompanion, reserveCompanion], - roster: [], - }, - recruitedCompanion, - reserveCompanion.npcId, - ); - assert(swappedState.companions.some(companion => companion.npcId === recruitedCompanion.npcId), '[roster] recruited companion should join active party'); - assert(swappedState.roster.some(companion => companion.npcId === reserveCompanion.npcId), '[roster] replaced companion should move to reserve roster'); -} - -function smokeQuestLoop() { - for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { - const sceneWithNpcAndMonster = getScenePresetsByWorld(worldType).find( - scene => scene.npcs.length > 0 && getSceneHostileNpcPresetIds(scene).length > 0, - ); - assert(sceneWithNpcAndMonster, `[quest] missing npc+monster scene for ${worldType}`); - - const issuer = sceneWithNpcAndMonster.npcs[0]; - const quest = buildQuestForEncounter({ - issuerNpcId: issuer.id, - issuerNpcName: issuer.name, - roleText: issuer.role, - scene: sceneWithNpcAndMonster, - worldType, - }); - - assert(quest, `[quest] failed to build quest for ${sceneWithNpcAndMonster.id}`); - const accepted = acceptQuest([], quest); - assert(findQuestById(accepted, quest.id)?.status === 'active', `[quest] ${quest.id} should be active after accept`); - - const afterBattle = applyQuestProgressFromHostileNpcDefeat( - accepted, - sceneWithNpcAndMonster.id, - quest.objective.targetHostileNpcId ? [quest.objective.targetHostileNpcId] : [], - ); - assert(findQuestById(afterBattle, quest.id)?.status === 'active', `[quest] ${quest.id} should stay active until report back`); - - const afterReport = applyQuestProgressFromNpcTalk(afterBattle, issuer.id); - assert(findQuestById(afterReport, quest.id)?.status === 'ready_to_turn_in', `[quest] ${quest.id} should become reward-ready after reporting back`); - - const turnedIn = markQuestTurnedIn(afterReport, quest.id); - assert(findQuestById(turnedIn, quest.id)?.status === 'turned_in', `[quest] ${quest.id} should turn in successfully`); - } -} - -function main() { - smokeScenePreviews(); - smokeNpcStories(); - smokeTreasureStories(); - smokeMonsterCreation(); - smokeRecruitmentData(); - smokeObserveAndCallOut(); - smokeInventoryUseLoop(); - smokeEquipmentLoop(); - smokeTradeEconomyLoop(); - smokeEncounterTransitionLoop(); - smokeRosterLoop(); - smokeQuestLoop(); - console.log('Content smoke checks passed.'); -} - -main(); diff --git a/scripts/validate-content.ts b/scripts/validate-content.ts deleted file mode 100644 index 582f28d50..000000000 --- a/scripts/validate-content.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { getCharacterHomeSceneId, getCharacterNpcSceneIds, ROLE_TEMPLATE_CHARACTERS } from '../src/data/characterPresets.ts'; -import { MONSTER_PRESETS_BY_WORLD } from '../src/data/hostileNpcPresets.ts'; -import { getSceneHostileNpcPresetIds, getScenePresetsByWorld } from '../src/data/scenePresets.ts'; -import { buildStateFunctionDefinitions } from '../src/data/stateFunctions.ts'; -import { WorldType } from '../src/types.ts'; - -function addError(errors: string[], message: string) { - errors.push(message); -} - -function validateScenes(errors: string[]) { - for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { - const scenes = getScenePresetsByWorld(worldType); - const sceneIdSet = new Set(scenes.map(scene => scene.id)); - const monsterIdSet = new Set(MONSTER_PRESETS_BY_WORLD[worldType].map(monster => monster.id)); - const duplicateSceneIds = scenes - .map(scene => scene.id) - .filter((id, index, all) => all.indexOf(id) !== index); - - duplicateSceneIds.forEach(sceneId => { - addError(errors, `[scene] duplicate id "${sceneId}" in ${worldType}`); - }); - - scenes.forEach(scene => { - if (scene.forwardSceneId && !sceneIdSet.has(scene.forwardSceneId)) { - addError(errors, `[scene] ${scene.id} forwardSceneId "${scene.forwardSceneId}" not found in ${worldType}`); - } - - scene.connectedSceneIds.forEach(connectedSceneId => { - if (!sceneIdSet.has(connectedSceneId)) { - addError(errors, `[scene] ${scene.id} connectedSceneId "${connectedSceneId}" not found in ${worldType}`); - } - }); - - getSceneHostileNpcPresetIds(scene).forEach(monsterId => { - if (!monsterIdSet.has(monsterId)) { - addError(errors, `[scene] ${scene.id} references unknown monster "${monsterId}" in ${worldType}`); - } - }); - - const npcIds = new Set(); - scene.npcs.forEach(npc => { - if (npcIds.has(npc.id)) { - addError(errors, `[scene] ${scene.id} has duplicate npc id "${npc.id}"`); - } - npcIds.add(npc.id); - - if (npc.characterId && !ROLE_TEMPLATE_CHARACTERS.some(character => character.id === npc.characterId)) { - addError(errors, `[scene] ${scene.id} npc "${npc.id}" references unknown character "${npc.characterId}"`); - } - }); - }); - } -} - -function validateCharacters(errors: string[]) { - for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) { - const sceneIdSet = new Set(getScenePresetsByWorld(worldType).map(scene => scene.id)); - - ROLE_TEMPLATE_CHARACTERS.forEach(character => { - const homeSceneId = getCharacterHomeSceneId(worldType, character.id); - if (homeSceneId && !sceneIdSet.has(homeSceneId)) { - addError(errors, `[character] ${character.id} homeSceneId "${homeSceneId}" not found in ${worldType}`); - } - - getCharacterNpcSceneIds(worldType, character.id).forEach(sceneId => { - if (!sceneIdSet.has(sceneId)) { - addError(errors, `[character] ${character.id} npc scene "${sceneId}" not found in ${worldType}`); - } - }); - }); - } -} - -function validateStateFunctions(errors: string[]) { - const definitions = buildStateFunctionDefinitions(); - const duplicateIds = definitions - .map(definition => definition.id) - .filter((id, index, all) => all.indexOf(id) !== index); - - duplicateIds.forEach(id => { - addError(errors, `[function] duplicate function id "${id}"`); - }); - - definitions.forEach(definition => { - if (!definition.text.trim()) { - addError(errors, `[function] ${definition.id} has empty text`); - } - if (!definition.description.trim()) { - addError(errors, `[function] ${definition.id} has empty description`); - } - }); -} - -function main() { - const errors: string[] = []; - - validateScenes(errors); - validateCharacters(errors); - validateStateFunctions(errors); - - if (errors.length > 0) { - console.error(`Content validation failed with ${errors.length} issue(s):`); - errors.forEach(error => console.error(`- ${error}`)); - process.exitCode = 1; - return; - } - - const sceneCount = getScenePresetsByWorld(WorldType.WUXIA).length + getScenePresetsByWorld(WorldType.XIANXIA).length; - const monsterCount = MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA].length + MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA].length; - const functionCount = buildStateFunctionDefinitions().length; - - console.log(`Content validation passed. scenes=${sceneCount} monsters=${monsterCount} characters=${ROLE_TEMPLATE_CHARACTERS.length} functions=${functionCount}`); -} - -main(); diff --git a/scripts/validate-overrides.ts b/scripts/validate-overrides.ts deleted file mode 100644 index 95b4ea69a..000000000 --- a/scripts/validate-overrides.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { readdirSync } from 'node:fs'; -import path from 'node:path'; - -import { ROLE_TEMPLATE_CHARACTERS } from '../src/data/characterPresets.ts'; -import { MONSTER_PRESETS_BY_WORLD } from '../src/data/hostileNpcPresets.ts'; -import { buildItemCatalogId } from '../src/data/itemCatalog.ts'; -import { getScenePresetsByWorld } from '../src/data/scenePresets.ts'; -import { buildStateFunctionDefinitions } from '../src/data/stateFunctions.ts'; -import { WorldType } from '../src/types.ts'; - -function readJsonFile(relativePath: string): T { - const absolutePath = path.resolve(process.cwd(), relativePath); - return JSON.parse(readFileSync(absolutePath, 'utf8')) as T; -} - -function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function isKnownGender(value: unknown): value is 'male' | 'female' { - return value === 'male' || value === 'female'; -} - -function expectPlainObject(errors: string[], label: string, value: unknown) { - if (!isPlainObject(value)) { - errors.push(`[override] ${label} must be an object map`); - return false; - } - return true; -} - -function validateCharacterOverrides(errors: string[]) { - const overrides = readJsonFile>('src/data/characterOverrides.json'); - if (!expectPlainObject(errors, 'characterOverrides', overrides)) return; - - const characterIds = new Set(ROLE_TEMPLATE_CHARACTERS.map(character => character.id)); - const sceneIds = new Set( - [WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType => getScenePresetsByWorld(worldType).map(scene => scene.id)), - ); - - Object.entries(overrides).forEach(([characterId, override]) => { - if (!characterIds.has(characterId)) { - errors.push(`[override] characterOverrides contains unknown character id "${characterId}"`); - return; - } - - if (!isPlainObject(override)) { - errors.push(`[override] characterOverrides["${characterId}"] must be an object`); - return; - } - - const gender = override.gender; - if (gender !== undefined && !isKnownGender(gender)) { - errors.push(`[override] characterOverrides["${characterId}"].gender must be "male" or "female"`); - } - - const sceneBindings = override.sceneBindings; - if (sceneBindings !== undefined) { - if (!isPlainObject(sceneBindings)) { - errors.push(`[override] characterOverrides["${characterId}"].sceneBindings must be an object`); - } else { - Object.entries(sceneBindings).forEach(([worldKey, binding]) => { - if (!isPlainObject(binding)) { - errors.push(`[override] characterOverrides["${characterId}"].sceneBindings["${worldKey}"] must be an object`); - return; - } - - const homeSceneId = binding.homeSceneId; - if (homeSceneId !== undefined && (typeof homeSceneId !== 'string' || !sceneIds.has(homeSceneId))) { - errors.push(`[override] characterOverrides["${characterId}"] has invalid homeSceneId "${String(homeSceneId)}"`); - } - - const npcSceneIds = binding.npcSceneIds; - if (npcSceneIds !== undefined) { - if (!Array.isArray(npcSceneIds) || npcSceneIds.some(sceneId => typeof sceneId !== 'string' || !sceneIds.has(sceneId))) { - errors.push(`[override] characterOverrides["${characterId}"] has invalid npcSceneIds`); - } - } - }); - } - } - }); -} - -function validateMonsterOverrides(errors: string[]) { - const overrides = readJsonFile>('src/data/monsterOverrides.json'); - if (!expectPlainObject(errors, 'monsterOverrides', overrides)) return; - - const hostilePresetIds = new Set( - [...MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA], ...MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA]].map(monster => monster.id), - ); - - Object.entries(overrides).forEach(([monsterId, override]) => { - if (!hostilePresetIds.has(monsterId)) { - errors.push(`[override] monsterOverrides contains unknown monster id "${monsterId}"`); - return; - } - if (!isPlainObject(override)) { - errors.push(`[override] monsterOverrides["${monsterId}"] must be an object`); - } - }); -} - -function validateSceneOverrides(errors: string[]) { - const overrides = readJsonFile>('src/data/sceneOverrides.json'); - if (!expectPlainObject(errors, 'sceneOverrides', overrides)) return; - - const sceneIds = new Set( - [WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType => getScenePresetsByWorld(worldType).map(scene => scene.id)), - ); - Object.entries(overrides).forEach(([sceneId, override]) => { - if (!sceneIds.has(sceneId)) { - errors.push(`[override] sceneOverrides contains unknown scene id "${sceneId}"`); - return; - } - if (!isPlainObject(override)) { - errors.push(`[override] sceneOverrides["${sceneId}"] must be an object`); - return; - } - - const forwardSceneId = override.forwardSceneId; - if (forwardSceneId !== undefined && (typeof forwardSceneId !== 'string' || !sceneIds.has(forwardSceneId))) { - errors.push(`[override] sceneOverrides["${sceneId}"] has invalid forwardSceneId "${String(forwardSceneId)}"`); - } - - const connectedSceneIds = override.connectedSceneIds; - if (connectedSceneIds !== undefined) { - if (!Array.isArray(connectedSceneIds) || connectedSceneIds.some(id => typeof id !== 'string' || !sceneIds.has(id))) { - errors.push(`[override] sceneOverrides["${sceneId}"] has invalid connectedSceneIds`); - } - } - }); -} - -function validateSceneNpcOverrides(errors: string[]) { - const overrides = readJsonFile>('src/data/sceneNpcOverrides.json'); - if (!expectPlainObject(errors, 'sceneNpcOverrides', overrides)) return; - - const npcIds = new Set( - [WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType => - getScenePresetsByWorld(worldType).flatMap(scene => scene.npcs.map(npc => npc.id)), - ), - ); - const characterIds = new Set(ROLE_TEMPLATE_CHARACTERS.map(character => character.id)); - - Object.entries(overrides).forEach(([npcId, override]) => { - if (!npcIds.has(npcId)) { - errors.push(`[override] sceneNpcOverrides contains unknown npc id "${npcId}"`); - return; - } - if (!isPlainObject(override)) { - errors.push(`[override] sceneNpcOverrides["${npcId}"] must be an object`); - return; - } - - const gender = override.gender; - if (gender !== undefined && !isKnownGender(gender)) { - errors.push(`[override] sceneNpcOverrides["${npcId}"].gender must be "male" or "female"`); - } - - const characterId = override.characterId; - if (characterId !== undefined && (typeof characterId !== 'string' || !characterIds.has(characterId))) { - errors.push(`[override] sceneNpcOverrides["${npcId}"] has invalid characterId "${String(characterId)}"`); - } - }); -} - -function validateStateFunctionOverrides(errors: string[]) { - const overrides = readJsonFile>('src/data/stateFunctionOverrides.json'); - if (!expectPlainObject(errors, 'stateFunctionOverrides', overrides)) return; - - const functionIds = new Set(buildStateFunctionDefinitions().map(definition => definition.id)); - Object.entries(overrides).forEach(([functionId, override]) => { - if (!functionIds.has(functionId)) { - errors.push(`[override] stateFunctionOverrides contains unknown function id "${functionId}"`); - return; - } - if (!isPlainObject(override)) { - errors.push(`[override] stateFunctionOverrides["${functionId}"] must be an object`); - } - }); -} - -function validateNpcVisualOverrides(errors: string[]) { - const overrides = readJsonFile>('src/data/npcVisualOverrides.json'); - if (!expectPlainObject(errors, 'npcVisualOverrides', overrides)) return; - - const npcIds = new Set( - [WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType => - getScenePresetsByWorld(worldType).flatMap(scene => scene.npcs.map(npc => npc.id)), - ), - ); - - Object.entries(overrides).forEach(([npcId, override]) => { - if (!npcIds.has(npcId)) { - errors.push(`[override] npcVisualOverrides contains unknown npc id "${npcId}"`); - return; - } - if (!isPlainObject(override)) { - errors.push(`[override] npcVisualOverrides["${npcId}"] must be an object`); - } - }); -} - -function collectItemAssetPaths(rootDir: string, relativeDir = 'Icons'): string[] { - const entries = readdirSync(rootDir, { withFileTypes: true }); - const collected: string[] = []; - - entries.forEach(entry => { - const absolutePath = path.join(rootDir, entry.name); - const relativePath = `${relativeDir}/${entry.name}`.replace(/\\/g, '/'); - - if (entry.isDirectory()) { - collected.push(...collectItemAssetPaths(absolutePath, relativePath)); - return; - } - - if (entry.isFile() && entry.name.toLowerCase().endsWith('.png')) { - collected.push(relativePath); - } - }); - - return collected; -} - -function validateItemOverrides(errors: string[]) { - const overrides = readJsonFile>('src/data/itemOverrides.json'); - if (!expectPlainObject(errors, 'itemOverrides', overrides)) return; - - const validItemIds = new Set( - collectItemAssetPaths(path.resolve(process.cwd(), 'public/Icons')) - .map(assetPath => buildItemCatalogId(assetPath)), - ); - - Object.entries(overrides).forEach(([itemId, override]) => { - if (!validItemIds.has(itemId)) { - errors.push(`[override] itemOverrides contains unknown item id "${itemId}"`); - return; - } - - if (!isPlainObject(override)) { - errors.push(`[override] itemOverrides["${itemId}"] must be an object`); - return; - } - - const tags = override.tags; - if (tags !== undefined) { - if (!Array.isArray(tags) || tags.some(tag => typeof tag !== 'string' || !tag.trim())) { - errors.push(`[override] itemOverrides["${itemId}"] has invalid tags`); - } - } - }); -} - -function main() { - const errors: string[] = []; - - validateCharacterOverrides(errors); - validateMonsterOverrides(errors); - validateSceneOverrides(errors); - validateSceneNpcOverrides(errors); - validateStateFunctionOverrides(errors); - validateNpcVisualOverrides(errors); - validateItemOverrides(errors); - - if (errors.length > 0) { - console.error(`Override validation failed with ${errors.length} issue(s):`); - errors.forEach(error => console.error(`- ${error}`)); - process.exitCode = 1; - return; - } - - console.log('Override validation passed.'); -} - -main();