Compare commits
41 Commits
dev/2.0.0
...
feat/mac-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b11382d2e6 | ||
|
|
709912bd1d | ||
|
|
0a363ec3aa | ||
|
|
349cd8292c | ||
|
|
d44071408f | ||
|
|
439115d8b5 | ||
|
|
7980c717be | ||
|
|
644b95a3c9 | ||
|
|
b6c1d4a02c | ||
|
|
e71f2efc1a | ||
|
|
15b7702865 | ||
|
|
f7f5981f78 | ||
|
|
a916df0e5b | ||
|
|
3242558d76 | ||
|
|
3436f735f2 | ||
|
|
f6f8e01274 | ||
|
|
ca65aebe63 | ||
|
|
0cede4de98 | ||
|
|
6dbed41280 | ||
|
|
9300ae2d6d | ||
|
|
c2b7a572a1 | ||
|
|
2ff4f66142 | ||
|
|
33d9cd62ad | ||
|
|
f97653e8ee | ||
|
|
46aef4e11c | ||
|
|
d45945af5b | ||
|
|
72a62cca00 | ||
|
|
f0854902ae | ||
|
|
5164f28f69 | ||
|
|
413f8edce4 | ||
|
|
826047ebfc | ||
|
|
2d5acc5bb0 | ||
|
|
44fc82f456 | ||
|
|
28797531d4 | ||
|
|
cbae7b7e57 | ||
|
|
59d4eb1c83 | ||
|
|
67371e77dd | ||
|
|
bc1c337776 | ||
|
|
d763755117 | ||
|
|
046454d95e | ||
|
|
fd022821ae |
@ -94,7 +94,7 @@
|
|||||||
**★ driver-coverage v2 闭口:H 假阴四因归因铁律(2026-06-15,5 款实测)**。技巧/射击/填充类 H 假阴必先分因、**禁『模型造不出』笼统归因**(spec §9):
|
**★ driver-coverage v2 闭口:H 假阴四因归因铁律(2026-06-15,5 款实测)**。技巧/射击/填充类 H 假阴必先分因、**禁『模型造不出』笼统归因**(spec §9):
|
||||||
1. **宿主门面缺失**(系统性,L0 域):如键盘桥 `d754b71`——**失败严格沿『键盘 vs 指针』分界=最强信号**;宿主经 esbuild 打进 bundle,故修后须**重打包** bundle 才生效(`node scripts/build.mjs <id>/entry-bundle.js <id>/bundle.iife.js --global-name=__GameBundle`)→实证 t2048 `score 0→4`、asteroids `F_wiring 0→26`。
|
1. **宿主门面缺失**(系统性,L0 域):如键盘桥 `d754b71`——**失败严格沿『键盘 vs 指针』分界=最强信号**;宿主经 esbuild 打进 bundle,故修后须**重打包** bundle 才生效(`node scripts/build.mjs <id>/entry-bundle.js <id>/bundle.iife.js --global-name=__GameBundle`)→实证 t2048 `score 0→4`、asteroids `F_wiring 0→26`。
|
||||||
2. **driver 太朴素**(我域,改 `play.cdp.cjs`):本轮增 flap-to-gap vy 前瞻 / paddle-intercept 弃守排空 / drag-aiming / aim-fire 四件;flappy・multiball 经此 + 重生成翻绿。
|
2. **driver 太朴素**(我域,改 `play.cdp.cjs`):本轮增 flap-to-gap vy 前瞻 / paddle-intercept 弃守排空 / drag-aiming / aim-fire 四件;flappy・multiball 经此 + 重生成翻绿。
|
||||||
3. **可测性边界**(游戏 `exportState` 不足以确定性盲玩):asteroids 无陨石坐标、angrybirds 无发射物理常数。**正解=enrich exportState 暴露所需(坐标/物理常数)+ 适配 driver**:asteroids 暴露 `asteroids:[{x,y}]`+aim-fire 旋向瞄准;angrybirds brief **pin+导出 `launch:{gravity,powerScale}`**+drag-aiming 45° 抛解析瞄准——**两者皆经此从『边界』转『确定性闭口』(asteroids score 0→6、angrybirds 0→20 清场)**。**抗死锁类(2048)的 latch 是设计性边界**(游戏意在不结束),宜放宽为强 progress(score↑∧board变)+G 兜。
|
3. **可测性边界**(游戏 `exportState` 不足以确定性盲玩):asteroids 无陨石坐标、angrybirds 无发射物理常数。**正解=enrich exportState 暴露所需(坐标/物理常数)+ 适配 driver**:asteroids 暴露 `asteroids:[{x,y}]`+aim-fire 旋向瞄准;angrybirds brief **pin+导出 `launch:{gravity,powerScale}`**+drag-aiming 45° 抛解析瞄准——**两者皆经此从『边界』转『确定性闭口』(asteroids score 0→6、angrybirds 0→20 清场)**。**抗死锁类(2048/idle/endless)的 latch 是设计性边界**(游戏意在不结束),放宽为强 progress(score↑∧board变)+G 兜。**已实现(t2048,f7f5981f)**:play-spec `expectLatch:false`(逐游戏 opt-in,零 harness 改,对其它游戏 latch 默认仍必需)→ H 退为 `assertAfterPlay`(score↑)必过,机制由 H(score↑)+E(board 变/活性)+G(输入有效)三门兜底;九门全绿。判据=「该体裁终态盲态真不可达 ∧ 本体可玩」才放宽,勿滥用绕 latch。
|
||||||
4. **生成实例缺陷 ≠ 模型能力**:multiball 原件底部丢球未实现(latch 不可达)、flappy 原件缝隙贴顶,但 flash 在**收紧 brief**(『球越底沿即出局不反弹』/『缝心落可达区间』)下**重生成即过**(multiball retries=0)。**区分『此实例 bug』与『模型短板』必须重生成验证,不可凭单件下结论**——这是『模型造不出』的最大伪报源。
|
4. **生成实例缺陷 ≠ 模型能力**:multiball 原件底部丢球未实现(latch 不可达)、flappy 原件缝隙贴顶,但 flash 在**收紧 brief**(『球越底沿即出局不反弹』/『缝心落可达区间』)下**重生成即过**(multiball retries=0)。**区分『此实例 bug』与『模型短板』必须重生成验证,不可凭单件下结论**——这是『模型造不出』的最大伪报源。
|
||||||
|
|
||||||
**结论(scale-20 的 driver-coverage 失败款)**:0 个是模型能力天花板,0 个是 3D 硬界——真难点恒在 **harness driver 覆盖 + 宿主门面完备 + 游戏可测性导出**三者。
|
**结论(scale-20 的 driver-coverage 失败款)**:0 个是模型能力天花板,0 个是 3D 硬界——真难点恒在 **harness driver 覆盖 + 宿主门面完备 + 游戏可测性导出**三者。
|
||||||
@ -118,3 +118,19 @@
|
|||||||
2. **控制体因果探针须点"离默认位最远的边",非默认招/ball-x**:首招点 ball-x 可能 ≈ 控制体默认位(实测 ball≈198≈paddle195→Δ仅3),无法证因果。**改点最远 tapX(如 x=50)+ 连派 `tapsPerTarget` 拍**(镜像 `I_control`)→ 控制体大幅移开(Δ=145)干净。
|
2. **控制体因果探针须点"离默认位最远的边",非默认招/ball-x**:首招点 ball-x 可能 ≈ 控制体默认位(实测 ball≈198≈paddle195→Δ仅3),无法证因果。**改点最远 tapX(如 x=50)+ 连派 `tapsPerTarget` 拍**(镜像 `I_control`)→ 控制体大幅移开(Δ=145)干净。
|
||||||
|
|
||||||
**★ 受控面 determinism = A/B 对照前提**:seed 缺省固定(`0x1234abcd`)+ LittleJS 固定步 → 跨 fresh 实例同帧号(`atFrame`)可比(G 门已用此范式);real 引擎自由运行(host 不掌 RAF、无暂停/回退)故不强求帧精确,靠控制体维度规避抖动。无控制体维度且非确定性的 skill 游戏 → 诚实 SKIP(标血统债:需受控面 seed 或游戏导出控制体位置维度)。
|
**★ 受控面 determinism = A/B 对照前提**:seed 缺省固定(`0x1234abcd`)+ LittleJS 固定步 → 跨 fresh 实例同帧号(`atFrame`)可比(G 门已用此范式);real 引擎自由运行(host 不掌 RAF、无暂停/回退)故不强求帧精确,靠控制体维度规避抖动。无控制体维度且非确定性的 skill 游戏 → 诚实 SKIP(标血统债:需受控面 seed 或游戏导出控制体位置维度)。
|
||||||
|
|
||||||
|
## 10. E1 2D 适配器:声明式 gameDefinition → 可玩件(plan 002 U1/U2 实证,2026-06-17)
|
||||||
|
|
||||||
|
固定架构消费侧 = 把 `SourceProject.gameDefinition`(声明式)适配成命中 `game-host.d.ts` GameInstance 的工厂(非 LLM 直写整 factory)。`game-runtime/src/adapter/`:`adaptGameDefinition(source)→GameHostFactory` 解释 entities/components(render)/scenes/rules + behavior 词汇;`scaffold-from-source.mjs` 把 sourceProject 落成 `generated-factory.js`(entry-bundle 固定 import 目标)→ build.mjs → bundle(= 后端确定性 modify guard-pending 在等的"从源真构建"件)。**六 archetype 家族经同一适配器均过真九门 A–I + 首局门**,覆盖休闲玩法主要机制维度:① 静态点击(tap-targets)② realtime 漂移(drift-targets)③ **控制类(control-x+fall-and-catch,paddle-intercept,点亮仅控制类有的 I 门跟手)**④ flappy(flap+flappy-gap,重力穿缝,flap-to-gap)⑤ 导航/收集(move-lr+reach-goal,seek-x,方向式 steer+抵达计分)⑥ 辨别/规避(tap-safe+forensics `safe` 标记,tap-targets `safeOnly`,负反馈:点对计分/点错即负)。词汇:input(hit-nearest-target/control-x/flap/move-lr/tap-safe)· update(drift-targets/fall-and-catch/flappy-gap/reach-goal)· rule(win/lose,序前优先)· 六类资产消费。退役"声明式过拟合/做不了真游戏"之虑。**加一族的复用配方**:SUPPORTED_*_OPS 注册 op → onTap/update 加 op 块(操作 `entById` 取的实体)→ 关键事件发 `ctx.getEngine()` 反馈(过 F)→ driver 需读的实体态在 `_forensicsView().state()` additive 暴露 → fixture + 单测 + play-spec(driver/assertAfterPlay/expectLatch)→ `scaffoldFromSource`→build→九门→清场临时探针。
|
||||||
|
|
||||||
|
**★ 必踩契约坑(第一版写错):`_forensicsView()` 返 `{ seed?, state: ()=>snapshot }`,不是裸 snapshot。** `boot-game-host.js:283` 的 `host.state()` 调 `gameForensics.state()`——即 `game._forensicsView().state()`(state 是 thunk)。直接返 `{phase,...}` → `host.state()` 调 `.state()` 时 `undefined()` 抛错、九门 H 读不到态。参照 `tictactoe/generated-factory.js:226`。snapshot 给 `targets:[{idx,x,y,occupied}]`(中心逻辑坐标)喂 tap-targets driver、`phase`(booting|playing|gameover)+ 进展字段(score/moves)喂 H 门 latch。
|
||||||
|
|
||||||
|
**★ 声明式件过 F 门(真接线)须自带命中反馈**:适配器在命中/关键事件经 `ctx.getEngine()` 调 `particles.spawnEmitter`+`audio.synth.synthSfx`(null 容错),否则 `expectedEngineCallPrefixes:['particles.','audio.synth']` 的 F 门挂(纯声明式渲染无引擎叶调用)= game-feel + 门双赢。
|
||||||
|
|
||||||
|
**★ 边界**:适配器只解释声明式部分;复杂 behavior 真逻辑由 code agent 产、编进 bundle(`source-project.schema.json` behavior 仅 `{id,trigger}`,诚实边界)。单 behavior 声明式游戏**确可过 H 门 latch**(rule condition→outcome win/lose→phase=gameover+驻留),驳"声明式最小集过不了九门"之虑。
|
||||||
|
|
||||||
|
**★ 族边界 = 机制复杂度,非输入能力**:受控面 `InputEventType` 含 pointerdown/move/up + keydown/keyup(`api.d.ts:218`),足以接 aim-fire(键盘)/drag-aiming(拖拽)/match-3 的输入——但其**抛射物理/旋转瞄准/读盘消除是复杂 behavior 逻辑**,按 KTD1 归 code-agent `__GameBundle`,**不入声明式适配器**(强行内联=越界+过拟合,即 match-3 早被识破的"需更强 driver"同一坑)。适配器止于"简单声明式机制族",已覆 6 族 = 拐点。
|
||||||
|
|
||||||
|
**★ 时间压力维度须用相对 `elapsed`,非绝对 `t`**:受控面 `time.nowMs()` 是绝对单调时(明禁跨设备比绝对值)。若把 `state.t = nowMs/1000` 直接喂 `t >= config.timeLimit` 规则——performance.now 原点漂移 / Date.now 直接是十亿级 → 立判或假阴。正解:init 记 `startMs`,`state.elapsed=(now-startMs)/1000`(相对秒)入 snapshot,规则用 `elapsed >= config.timeLimit`。限时/倒计时**正交叠加任一族,零新增 op**。验法:`createHostDevContext({clock:()=>clockMs})` 注入可控时钟,断言 elapsed 是相对差(init@1s、clock=3s 时 elapsed=2 非 3)。
|
||||||
|
|
||||||
|
**★ 六类资产消费(E3,对齐冻结 assetSpec `{id,category,ref,url?}`,无内联数据)**:sprite/character/ui/effect 经实体 render 组件 `.asset` 引用(url→`new Image`+drawImage,否则几何兜底);**scene = 顶层背景**(非实体引用,取首个 `category==='scene'`,url→满幅 drawImage / 默认底兜底);**music 经 url 不可消费**——冻结 `getEngine().audio` 仅 zzfx `synthSfx/synthSong`(合成非文件播放),无播 url 音频能力 = 跨线契约缺口(落 README,勿臆造消费路)。验 url→drawImage 分支(node 无 Image)用**最小 `Image` stub**(`globalThis.Image=class{complete=true;naturalWidth=64}`,finally 还原),确定性验"非兜底"路不必上浏览器。
|
||||||
|
|||||||
@ -39,6 +39,28 @@
|
|||||||
- 审核台:静态路由 `/wanxiang/review`(登录后侧栏「造梦运营→项目审核」恒在);「通过」= `POST /admin-api/project/review {decision:1}` → 同事务 publish;
|
- 审核台:静态路由 `/wanxiang/review`(登录后侧栏「造梦运营→项目审核」恒在);「通过」= `POST /admin-api/project/review {decision:1}` → 同事务 publish;
|
||||||
- 验收锚点:**UI 队列数变化 + DB 双实证**(`game_project.status=4` + `game_feed_rank` 新行)。
|
- 验收锚点:**UI 队列数变化 + DB 双实证**(`game_project.status=4` + `game_feed_rank` 新行)。
|
||||||
|
|
||||||
|
## 5.5 mock HTTP 快走查(lili-mac,CDP 前置层,省心逮 80% 接线缺陷)
|
||||||
|
|
||||||
|
CDP 全走查重(chrome 生命周期 + 受控输入 + 截图)。前端接新契约(如 U6 studio.ts)**先做一层 HTTP 快走查**逮「mock 没注册 / vite 中间件没拦 / 字段不贴契约」这类接线缺陷,再上 CDP 验 UI 行为:
|
||||||
|
|
||||||
|
1. `npm run build`(vue-tsc -b 类型 + vite build)= 类型/编译门;
|
||||||
|
2. mock 调度 smoke:`npx tsx` 直 import `src/mock/index.ts` 的 `dispatch(method,url,body)`(DOM-free)断言 create/poll/校验/锁——**不起服务器**验 mock 逻辑;
|
||||||
|
3. **mock-over-vite HTTP 快走查**:`vite preview --port 4173`(VITE_API_BASE 空 → mock=ON,`configurePreviewServer` 也挂中间件)后 `curl` 打 `/app-api/<mod>/*`——验**整条 HTTP 路径**(前端 axios 会走的同一条:vite 中间件→mock handler→契约信封 `{code,data,msg}`);异步任务链轮询到终态(status=2 + 回填 versionId)验「无定时器时间式推进」收敛。
|
||||||
|
4. 三层全绿后,UI 行为(Vue 响应式/导航/点击)+ 真实 origin 上下文依赖才上 §3 CDP(权威仍在 mini-desktop 真 origin,localhost 系统性掩盖上下文缺陷见 §1 红线)。
|
||||||
|
- 禁裸 `sleep` 轮询(harness 拦):用计数 `for` 循环 + curl 自身耗时,或 Monitor until-loop。
|
||||||
|
|
||||||
|
> 口径:HTTP 快走查 = lili-mac 本机省心层(逮接线/契约漂移);**权威 UI e2e 仍 = mini-desktop CDP 真 origin**。前者绿不等于后者免做(上下文依赖缺陷只在真 origin 现形)。
|
||||||
|
|
||||||
|
## 5.6 让本地 mock 预览渲染【真适配器游戏】(闭合 create→预览可玩闭环)
|
||||||
|
|
||||||
|
本地 `/create/preview/:versionId` 默认走「runtime mock 无 manifestUrl → demo 兜底」,而 demo 兜底用的自研壳正被 T1b(LittleJS+Runner v2)替换 → 本地 `load_timeout` 玩不了。要让本地预览渲染【真生成物】(E1 适配器产物),把 engineBundle 经静态 manifest 喂给宿主引擎包路:
|
||||||
|
|
||||||
|
1. **源** = 仓内已提交的九门探针 bundle(`game-runtime/games/_wg1-gen/_adapter-probe/bundle.iife.js`,__GameBundle.bootGameHost 入口)。
|
||||||
|
2. **打包** = 脚本(`game-studio/scripts/gen-preview-manifest.mjs`)把 bundle 包成 GamePackage(`engineBundle` 内嵌)写 `public/mock-manifests/9001.json`(gitignore,体积大);打印 **OUTER_CHECKSUM = sha256(被服务 json 原文)**。
|
||||||
|
3. **接线** = `mock/runtime.ts` 对该 versionId 回填 `manifestUrl='/mock-manifests/9001.json'` + `checksum=<OUTER_CHECKSUM>`。宿主 `GamePlayer.resolvePackage`→`fetchAndVerifyManifest`(校 sha256)→ 引擎包路 `inject.ts` 独立内联 `<script>` 暴露 `window.__GameBundle`→`bootGameHost({canvas,seed})`→ 真玩 + 发 `game_loaded`(不再超时)。
|
||||||
|
4. **坑**:① manifestUrl 必须是 **vite 原样服务的静态路径**(`public/`),不能用 `/app-api/*`(会被 mock 中间件套 `{code,data,msg}` 信封,破坏 raw fetch);② checksum 校的是**被服务 json 原文**的 sha256(非 bundle 自身、非 manifest 内层 checksum),换源 bundle 须重跑脚本同步 runtime.ts 常量;③ srcdoc `allow-same-origin` → 文档 origin=宿主 origin(非 'null'),已在 bridge 白名单。
|
||||||
|
5. **验**:CDP 走查预览页状态 `加载失败(load_timeout)`→`可试玩`、`.preview-errtip`=null、截图见游戏渲染(实证:tap-target Score:0/3 + 3 目标)。
|
||||||
|
|
||||||
## 5. 信道探针(回归排查常备)
|
## 5. 信道探针(回归排查常备)
|
||||||
|
|
||||||
宿主↔iframe postMessage 断裂用 `probe_bridge_channel.py` 取证:四锚点 + 判别实验(文档代际/iframe 重建监视/存活代理注入),v-for ref 数组案(host→game 全断)即由它锁凶。修复类波次收口前跑一轮防回归。
|
宿主↔iframe postMessage 断裂用 `probe_bridge_channel.py` 取证:四锚点 + 判别实验(文档代际/iframe 重建监视/存活代理注入),v-for ref 数组案(host→game 全断)即由它锁凶。修复类波次收口前跑一轮防回归。
|
||||||
|
|||||||
@ -110,3 +110,11 @@
|
|||||||
- mini-desktop = 权威构建/测/e2e 门(两线共用)· mini-infra = MySQL/redis/new-api 基建(常驻)。
|
- mini-desktop = 权威构建/测/e2e 门(两线共用)· mini-infra = MySQL/redis/new-api 基建(常驻)。
|
||||||
- 集成:核心纵切(B6+E1/E3/E4)最先拉通(M0)→ 按 §3 风险驱动增量集成 M1→M3。
|
- 集成:核心纵切(B6+E1/E3/E4)最先拉通(M0)→ 按 §3 风险驱动增量集成 M1→M3。
|
||||||
- **founder**:labels.json 亲玩补锚 · 渠道三件套 · 2 安全洞路由 ops(均不卡 P1 大盘)。
|
- **founder**:labels.json 亲玩补锚 · 渠道三件套 · 2 安全洞路由 ops(均不卡 P1 大盘)。
|
||||||
|
|
||||||
|
### 7.4 架构澄清 + 待决跨线项(2026-06-17 后端 U1–U7 落地后 pull 发现)
|
||||||
|
**生命周期两段(非冲突,schema 原设计「改源不改产物」)**:`gameDefinition`(声明式**源**·开发/可便宜确定性 modify)──[编译]──▶ `engineBundle`(`__GameBundle` **产物**·试玩/发布)。
|
||||||
|
- **产出 gameDefinition = 后端线(6c6g)**:SAA 生成编排(SAA 归后端线)。**消费+编译 gameDefinition→engineBundle = 引擎线(E1 适配器 + E2 scaffold/build)**——已建并端到端实证(本地预览跑通 tap-target gameDefinition→engineBundle→试玩)。
|
||||||
|
- **现状**:落地 SAA 全图(U7 e2e 证 60%)**短路了源阶段**——LLM 直接编 `GameHostFactory` 代码 → engineBundle(gameConfig 占位、`engineDriven:true`、U2 落库 gameDefinition 当前空),未走声明式源 + 未喂 E1 编译器。原因=声明式有词汇上限、直接编码无上限(KTD1 权衡)。
|
||||||
|
- **🔶 待决跨线项**:SAA 对**能用声明式表达**的玩法,是否走 gameDefinition 源阶段(喂 E1+E2 编译器、保**便宜确定性 modify**),把直接编码留给超词汇的复杂玩法?(founder 当前倾向 engineBundle 为主线;E1=源阶段编译器**已就绪可随时接**。需 6c6g 空出来定。)
|
||||||
|
- **🔶 待补点(engineBundle 主线副作用)**:直接编码丢了"改源/便宜确定性 modify"——若产品要它(免 LLM 改难度/速度),需 engineBundle 游戏经 gameConfig 暴露可调参 + 宿主注入(动 gameConfig 契约,跨线)。
|
||||||
|
- **引擎线现状(我线)**:E1–E6 + 本地 MVP 双端可玩闭环(create→预览/试玩渲染真 __GameBundle,CDP 实证)+ game-studio(创作流 25 测)+ game-admin(api 28 测)+ 引擎 197 测 = **建成+测全**;**M0 合流待 6c6g + mini-desktop**(merge 两线 + 全栈 e2e)。契约层 studio.yaml/source-project.schema 两线**冻结一致零漂移**(已核)。
|
||||||
|
|||||||
138
docs/brainstorms/2026-06-17-mac-work-list-requirements.md
Normal file
138
docs/brainstorms/2026-06-17-mac-work-list-requirements.md
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
---
|
||||||
|
date: 2026-06-17
|
||||||
|
topic: mac-work-list
|
||||||
|
---
|
||||||
|
|
||||||
|
# Mac 工作清单(前端+引擎线 · Phase 1 主账)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
为「前端+引擎线」(本机 lili-mac)排定 Phase 1 工作账,锚定在**固定游戏架构 + SAA agentic studio**(契约 8/8 已冻,我线全部现在可开工)。我线的活 = 协调档 §7.2 的 **E1–E6 + 广度 8 页**:后端 SAA 图产 `gameDefinition`/`assetSpec`,我做**消费/适配/QA/创作 UI 侧**。最高价值首件 = **E1 2D 适配器(gameDefinition→LittleJS)**;关键路径 = 后端 B6 + 引擎 E1/E3/E4 合流出「一句话→生成→玩」M0 真闭环。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Frame
|
||||||
|
|
||||||
|
2026-06-17 创始人按"要不要浏览器/视觉"切两开发线,本机由"便宜模型生成 L1"转为「前端+引擎线」。随后两次 pull 落地了**契约冻结门 8/8 ✅** + **SAA 固定架构图 16 节点**(B2),协调档 §7 刷新为"契约冻结后各线当前工作清单"。本账**重锚**到这一现状:早版按旧 W-G1 Python worker 主线 + 旧 §6 搭的骨架(R1–R8)已被 §7.2 的 E1–E6 取代——主线现是 SAA 图,worker 降为引擎线**复用**的组件(build.mjs 共调 / asset 角色 / few-shot 来源),不再是"转默认产线"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已落地基线(re-baseline · 不再列为 TODO)
|
||||||
|
|
||||||
|
- **引擎**:`game-runtime/src/plugins/` 8 业务插件(collision 含 swept CCD、physics-lite、particles-juice、gamefeel、palette-post、audio-music、save-progress、runtime-probe;`_example` 样例不计)。预判"4 短板"实测已突破。
|
||||||
|
- **worker 九门 real-play**:scale **18/19 过九门(94.7%)**,仅 t2048 因 latch 挂(见 E4)。driver 覆盖**已补齐**:angrybirds drag-aiming(`runDragAiming`@play.cdp.cjs:368,commit `06e59de6`,pass)、multiball(commit `cc5fa486`,pass)、tap-targets 自产、brief-pin 数值自产化均已落地。首局体验门(组 C,additive 非阻断)已落地、本机 M1 跑通(breakout 全过)。
|
||||||
|
- **worker 派发壳**:`service.py` 已含 `/generate` + HMAC-SHA256 `X-Callback-Signature` + 回调内部路(commit `d59093f8`),供 SAA/worker 两路复用。
|
||||||
|
- **前端**:8 个 studio 页已落地(Feed/Play/Create/Message/Profile/Project/Biz/Dashboard,共 10 个 views 目录含 login/share)+ 设计体系(`tokens.css`/vue-i18n zh+en/组件库/Vant 深色主题)。**注**:这 8 个 ≠ demo 设定的"八大页";demo 八大页里**素材中心、玩法模板中心两页仍缺**(见广度)。
|
||||||
|
- **联合 spike**:缓存透传 ✅ 绿(76–93% 命中)、classify 准确率 ✅ 绿(M3 archetype 93%/$0.003)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- **重锚到 §7.2 E1–E6**:用协调档/执行档的权威 E-ID 体系,本账作其详账,避免与 §7.2 漂移。
|
||||||
|
- **契约 8/8 已冻 → 我线全部可开工**:E1–E6 不再"卡契约";依赖 B 步只是跨线协调(各对 mock 建半边),非阻塞。
|
||||||
|
- **worker = 复用组件,非主线**:固定架构主线是 SAA 图;worker 提供 build.mjs 共调 / asset 角色 / few-shot 来源 / cost 折算。
|
||||||
|
- **插件库不预补门面**:18/19 在无预补下达成(限**当前语料**;若后续 grid/HUD 密集体裁批量重写同逻辑则复评)。
|
||||||
|
- **权威 e2e 在 mini-desktop,本机 = 快走查**。
|
||||||
|
- **创始人 2026-06-17 裁定**:game-admin 前端归本线(入广度,≈M3)· 六类资产 = P-CRT-02 六类(定 E3 assetSpec)。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph FE["前端+引擎线 (本机 lili-mac)"]
|
||||||
|
e6["E6 创作/修改/预览 UI"]
|
||||||
|
e1["E1 2D 适配器 gameDefinition→LittleJS"]
|
||||||
|
e3["E3 asset 产消 (六类资产)"]
|
||||||
|
e4["E4 九门 QA + narrative reviewer"]
|
||||||
|
e5["E5 Prompt Registry few-shot 冻结"]
|
||||||
|
end
|
||||||
|
subgraph BE["后端线 (6c6g / mini-desktop)"]
|
||||||
|
b6["B6 studio 编排 /studio/create,modify"]
|
||||||
|
g["SAA 固定架构图 (16 节点)"]
|
||||||
|
cb["handleCallback 建版本→组包→入 feed (唯一写入路径)"]
|
||||||
|
end
|
||||||
|
e6 -->|"/studio/create (frozen yaml)"| b6
|
||||||
|
b6 --> g
|
||||||
|
g -->|"gameDefinition"| e1
|
||||||
|
g -->|"assetSpec"| e3
|
||||||
|
e1 --> e4
|
||||||
|
e4 -->|"engineBundle / 过九门"| cb
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
> 采协调档/执行档权威 **E-ID**(E1–E6);广度页用列表。状态:✅已落地 / 🔄部分 / ⏳开口。每项附验收(对齐执行档 §10.2)。
|
||||||
|
|
||||||
|
**Tier 0(生成引擎 · 关键路径 · 现在可开工)**
|
||||||
|
|
||||||
|
| E | 工作项 | 文件 | 状态 | 验收(§10.2) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| E1 | **2D 适配器(`SourceProject.gameDefinition`→LittleJS 可玩)= 最高价值首件**:实体→sprite、behavior→逻辑 | `game-runtime/src/`(新增 adapter 层) | ⏳ 开口·新 | 单测 实体→sprite/behavior→逻辑 + 九门真玩绿 |
|
||||||
|
| E2 | 唯一构建实现核对:SAA build 节点与 worker 共调 `scripts/build.mjs`(**不动**) | `game-runtime/scripts/build.mjs` | 🔄 共调已有,补核对 | 同源同参 `__GameBundle` 一致;同 `buildInputHash` 字节等价 |
|
||||||
|
| E3 | **asset 产消(六类资产 = 图元/角色/特效/场景/界面/音乐 · P-CRT-02)**:asset 节点 spec→`assets[]`→宿主消费 + provider=mmx-cli 插桩 | `game-runtime/` + worker asset 角色 | ⏳ 开口·新 | `assets[]` 非空 → GamePackage `assets[]` → 宿主非空 assetContext |
|
||||||
|
| E4 | 九门 QA harness 复用(**禁重写**)+ narrative 路质量门接 reviewer;**含 t2048 latch(唯一剩门)** | `_shared/*`(不动)+ reviewer 角色 | 🔄 18/19,t2048 开口 | 九门真玩绿(mini-desktop 4320/9222) |
|
||||||
|
| E5 | Prompt Registry 冻结 few-shot(byte 稳)+ CI 卡 byte(现 `_few_shot()` 读文件=字节不稳) | `contracts/prompts/`、`prompt.py` | ⏳ 开口·新 | CI:few-shot byte 不变 |
|
||||||
|
| E6 | 前端创作(接 `/studio/create`)/ 修改(接 `/studio/modify`)/ 预览 UI(对 **frozen** studio.yaml + mock) | `game-studio/src/` | 🔄 Create 已存,补真接+进度 | 真机 UI 走查(mini-desktop CDP)create/modify/预览闭环 |
|
||||||
|
|
||||||
|
- **E4 子项(t2048)**:抗死锁盲态体裁——放宽 H 门 latch 为"score↑ ∧ board 变 + G 兜",或升级"填库死锁"driver;立为体裁规则补档。实现期按数据二选一。
|
||||||
|
- **E6 子项(进度反馈)**:补 UI 形态(成功/失败/进行中落点),与 SSE/轮询传输选择**分离**(后者视后端定)。
|
||||||
|
|
||||||
|
**广度(Tier 1+ · 账本级 · 价值×独立性)**
|
||||||
|
|
||||||
|
- 创作页完善 · feed · 钱包 · 消息社交 · 账号(均已有骨架,补真接/完善)
|
||||||
|
- **玩法模板中心页(新页,低)**:浏览/选择/应用模板到草稿;先对 mock,真接等后端模板 API,毕业门绑 **M1**
|
||||||
|
- **素材中心页(新页,低)**:素材浏览/选用加入草稿;先对 mock,真接等后端素材 API,毕业门绑 **M3**
|
||||||
|
- 设计体系 HJ-FE-DS-001(P1–P5 大半已落地)
|
||||||
|
- **game-admin 运营台前端(创始人 2026-06-17 裁定归本线)**:限曝光/导出/经营看板 + 赋余额/订阅入口;`game-admin` repo(huijing-ui-admin-vue3 fork);低优先 ≈ M3
|
||||||
|
- 新页验收(mock 阶段最小状态):加载中 / 空列表 / 选中→应用 交互路径可走查
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria(= 执行档 §10.2 引擎+前端线验收门)
|
||||||
|
|
||||||
|
1. **E1**:`gameDefinition` 渲成可玩(实体→sprite、behavior→逻辑)单测绿。
|
||||||
|
2. **E4**:九门真玩绿(mini-desktop),cheap 在固定架构填出过九门游戏 `verdict.pass=true`;scale 维持 ≥18/19,t2048(唯一剩门)解决后 19/19。**注**:19/19 经放宽 latch 门达成(体裁固有边界,非生成能力增益);语料 19 款(simon×3 变体)无第 20,scale-20 目标另需生成第 20 款。
|
||||||
|
3. **E3**:`sourceProject.assets[]` 非空 → GamePackage `assets[]` → 宿主加载(非空 assetContext)。
|
||||||
|
4. **E2**:同 `buildInputHash` → 字节等价 bundle(从源可复现)。
|
||||||
|
5. **E6**:create/modify/预览闭环真机 UI 走查过(编排器旁路会掩盖 UI 缺陷,真 UI 走查是唯一手段)。
|
||||||
|
6. **M0 失败门**:E1/E4 任一门挂或 GamePackage manifest 不符 schema → M0 不清、阻 M1 在其上开工。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
- **不做(后端线 / 6c6g)**:SAA 图(B2)、studio 编排 B6、源落库/构建落包、`handleCallback` **入 feed**(唯一写入路径在后端)、全部 game-cloud 后端、契约/Flyway/prompt 后端段。**我线只产出过九门的 engineBundle/GamePackage,"入 feed" 是跨线集成点**。
|
||||||
|
- **不做(本 Phase 外 → P2/P3)**:3D 适配器/Cocos(P2)、云部署/监控运维(P2)、真支付/短信/广告/渠道(P3)、全维优化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies / Assumptions
|
||||||
|
|
||||||
|
- **契约 8/8 已冻**(源项目 schema+V18 DB · SAA ALL_KEYS · trace 扩展键 · studio.yaml 生命周期 API · GamePackage/构建 API)→ 两线可独立开工,各对 mock 建半边。
|
||||||
|
- **M0 集成 = 后端 B6 + 引擎 E1/E3/E4 合流**(跨线协调点,非阻塞)。
|
||||||
|
- **硬约束(回归红线)**:九门 harness 禁重写;`build.mjs` 唯一构建脚本不动;GamePackage 产物面既冻;modify 不动 currentVersion、不自动入 feed;new-api 缓存命中只落成本台账、不接收益结算。
|
||||||
|
- **接缝**:`cost.py`(B9 缓存命中字段)、few-shot(E5 来源 `prompt.py`)为 worker/SAA 两路共用,改动须双路兼容。
|
||||||
|
- 权威 e2e 机 = mini-desktop(Linux);本机 M1 = 快走查自检。
|
||||||
|
- 安全:NEWAPI_KEY 仅在 `wg1/gen-worker/.env`(gitignore),不入提交;生成 + 九门在本机跑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Outstanding Questions
|
||||||
|
|
||||||
|
> 原两项 Resolve-Before-Planning 已由创始人 2026-06-17 裁定:**game-admin 前端归本线**(入广度)· **六类资产 = P-CRT-02 六类**(写入 E3)。
|
||||||
|
|
||||||
|
**Deferred to Planning**
|
||||||
|
|
||||||
|
- E4/t2048:走"放宽 latch 门"还是"填库死锁 driver"——实现期按数据定。
|
||||||
|
- E6 进度反馈:SSE 节点流(SAA 强项)还是前端轮询——视 B6 契约定。
|
||||||
|
- 最简模板首件:M0 端到端先打通哪一档 archetype(8 档:clicker/dodge/match/runner/idle/merge/tycoon/generic;倾向 clicker/dodge 最简)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources / Research
|
||||||
|
|
||||||
|
- 协调档:`docs/agent-specs/2026-06-17-多session分工与任务总账.md`(§7.2 我线当前清单 E1–E6、契约 8/8 冻结进度)。
|
||||||
|
- 执行档:`docs/agent-specs/2026-06-17-固定游戏架构与SAA-agentic-studio-execution.md`(§7.2 E 步定义、§10.2 验收门、§4.1 数据流、§3.2/3.3 文件路径、§5.5 asset 契约)。
|
||||||
|
- 现状盘点(file:line 实证):`game-runtime/src/all-plugins.js`(8 业务插件)、`game-runtime/games/_wg1-gen/_shared/play.cdp.cjs:368`(runDragAiming)、`{angrybirds,multiball}/evidence/verdict.json`(pass)、`t2048/evidence/verdict.json`(pass=false)、`wg1/gen-worker/worker/service.py`(/generate+HMAC)、`game-studio/src/views/`(10 目录)、`game-studio/src/host/`(宿主装载契约)。
|
||||||
|
- 提交锚:`06e59de6`(angrybirds drag-aiming)、`cc5fa486`(multiball)、`d59093f8`(worker 派发壳+HMAC)。
|
||||||
312
docs/plans/2026-06-17-002-feat-mac-engine-frontend-tier0-plan.md
Normal file
312
docs/plans/2026-06-17-002-feat-mac-engine-frontend-tier0-plan.md
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
---
|
||||||
|
title: "feat: Mac 前端+引擎线 P1 — E1–E6(固定架构消费/适配/QA/前端侧)"
|
||||||
|
status: active
|
||||||
|
date: 2026-06-17
|
||||||
|
type: feat
|
||||||
|
origin: docs/brainstorms/2026-06-17-mac-work-list-requirements.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: Mac 前端+引擎线 P1 — E1–E6(固定架构消费/适配/QA/前端侧)
|
||||||
|
|
||||||
|
## 摘要
|
||||||
|
|
||||||
|
把固定游戏架构的**引擎线消费侧**从"契约冻结"推进到"端到端可生产":后端 SAA 图产 `SourceProject.gameDefinition` + `assetSpec`,本线建 **E1 2D 适配器(gameDefinition→LittleJS 可玩)→ E2 从 sourceProject 真构建(build.mjs)→ E4 九门 QA → E3 asset 产消 → E5 Prompt Registry 冻结 → E6 前端创作/修改/预览 UI**。关键路径 = **E1+E2(从源真构建)**——它是后端确定性 modify 路 guard-pending 的硬依赖(后端 001 计划 U6 在等本线落)。范围 = 引擎+前端线 Tier 0(E1–E6)+ 两新页广度;`game-admin` 前端、既有页打磨 = Deferred Follow-Up。
|
||||||
|
|
||||||
|
> origin:[`docs/brainstorms/2026-06-17-mac-work-list-requirements.md`](../brainstorms/2026-06-17-mac-work-list-requirements.md)。设计权威:[`docs/agent-specs/2026-06-17-固定游戏架构与SAA-agentic-studio-execution.md`](../agent-specs/2026-06-17-固定游戏架构与SAA-agentic-studio-execution.md) §5/§7.2/§10.2。跨线对侧:[`docs/plans/2026-06-17-001-feat-backend-tier0-gen-mainline-plan.md`](2026-06-17-001-feat-backend-tier0-gen-mainline-plan.md)(后端线 B 步)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题背景
|
||||||
|
|
||||||
|
契约 8/8 已冻、B2 SAA 拓扑 16 节点已合入,但**引擎线消费侧是真空白**:`game-runtime/src/` 下无任何 `gameDefinition` 消费者(末次 grep 零命中);唯一"定义→渲染"先例是 W-G1 的 LLM 直写 `generated-factory.js`(非 JSON 定义→适配器)。后端 001 计划明确把"从 sourceProject 真构建"划归本线 E2,其确定性 modify 路**维持 guard fail-loud、等本线落**(后端 KTD5/U6,创始人 2026-06-17 确认)。同时 `/studio` 前端 api 真空(`game-studio/src/api/` 无 `studio.ts`),`Create.vue` 仍接旧两步 `aigc.generate` 路。地基稳:宿主装载链(`inject.ts` engineBundle 分支)、九门 harness、`build.mjs`、GamePackage 产物面全已冻、可复用零改。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需求追溯(origin → 本计划单元)
|
||||||
|
|
||||||
|
| origin(brainstorm E-ID / §10.2 门) | 单元 |
|
||||||
|
|---|---|
|
||||||
|
| E1 2D 适配器(gameDefinition→LittleJS) | U1 |
|
||||||
|
| E2 build.mjs 共调核对 + 从源真构建 | U2 |
|
||||||
|
| E4 九门 QA harness 复用 + narrative reviewer | U3 |
|
||||||
|
| E3 asset 产消(六类资产) | U4 |
|
||||||
|
| E5 Prompt Registry few-shot 冻结 | U5 |
|
||||||
|
| E6 前端创作/修改/预览 UI | U6 |
|
||||||
|
| 广度:玩法模板中心页 + 素材中心页(新页) | U7 |
|
||||||
|
|
||||||
|
成功标准(origin §10.2):2D 适配器单测绿 · 九门真玩绿(mini-desktop)· asset 产消通 · 构建字节确定性 · 前端真机 UI 走查过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键技术决策(KTD)
|
||||||
|
|
||||||
|
1. **E1 适配器 = 声明式解释器,逻辑代码由 code agent 产、编进 bundle**(跨线已锁,后端 001 KTD5/U6 确认):适配器读 `gameDefinition` 的 entities/components/scenes/rules/config 做渲染+场景装配+胜负判定;`behaviors` 的真逻辑由核心 code agent 产出、经 `build.mjs` 编进 `__GameBundle`(非适配器解释)。适配器输出物须命中 `GameHostFactory`→`GameInstance` 契约(`game-runtime/src/core/game-host.d.ts`),骨架范本 = `_goldenpath-probe/factory.js`。**E1 对冻结契约 `source-project.schema.json` 编程(contract-first)、用 schema 合规 fixtures/golden 自测,不阻塞于 producer 是否已建**(创始人 2026-06-17:gameDefinition 是冻结 keystone 契约、换适配器即换引擎)。方向已锁,但**逻辑码入 entry-bundle 的接缝形态未定**——提为 U1 硬前置:先用单 behavior gameDefinition 手工走通"适配器产骨架 + 逻辑码拼入 → entry-bundle → build → 九门"最小贯穿验形态,再展开 U1 其余。
|
||||||
|
2. **E1+E2 = "从 sourceProject 真构建"管线 = 净新建 + 后端硬依赖**:当前两路(worker/SAA)都直写 factorySrc(整工厂)、不消费 sourceProject;"源 → 适配器 → `generated-factory.js` 文件 → entry-bundle"装配链 与 `buildInputHash/sourceHash`(仅 V18 SQL 有列)**两端皆未建 = 净新建**(非"核对 build.mjs 已统一"——那仅旧 factorySrc 路)。`build.mjs` 本身不动。E1/E2 落地解后端确定性 modify guard;**SAA 改产填充版 gameDefinition(而非 factorySrc)= 跨线集成项**(后端 off-factorySrc,与本线在冻结契约处交汇)。
|
||||||
|
3. **受控面墙(回归红线)**:生成/适配代码只许调插件公开面 + **6 受控面**(getContext2d/onFrame/getInput/getAudioContext/time/random);静态门禁 9 类 token(`import`/`littlejsengine`/`Math.random`/`Date.now`/`addEventListener`/`requestAnimationFrame`/`new AudioContext`/`document.`/`window.`);引擎 import 只活集成段 host,能力经 `boot.ctx.getEngine()`。**注**:GameInstance 的渲染面 = `boot.mainContext`(render(g) 入参),`getContext2d` 是**插件面**(game-host.d.ts:20-22);6 受控面管的是插件/能力访问,非游戏自身渲染目标。
|
||||||
|
4. **九门 harness 禁重写**:E4 复用 `_shared/{serve-and-play.sh,play.cdp.cjs,…}` 子进程不改;新品类只产 play-spec(driver 选型)+ 接 narrative reviewer 质量门。真承重件 = `expectLatch` + `G_input`(非 progress 计数);首局门 additive 不并入 `verdict.pass`;**串行单实例、禁并发 CDP target**。
|
||||||
|
5. **few-shot 字节稳 = 两处生成入口逐字同源**:`wg1/gen-worker/worker/prompt.py` 与后端 `SaaPrompts.java` 的 system+few-shot **逐字同源、grep 比对**(公平性铁律,`a2cbabe6` 先例);冻结走 `contracts/prompts/` 升 version + 四道闸 CI,P0/P1 口径一字不动。
|
||||||
|
6. **E6 对 frozen `studio.yaml` + mock 先做**:`/studio/{create,modify}` 后端在建(001 U1),本线先建 `studio.ts` api + 改造 Create.vue + 新建 modify 入口,对 mock;预览**复用 GamePlayer 零新建**。
|
||||||
|
7. **worker 跑通 ≠ 准予发布**(split-brain 防线):九门绿=机制可玩,发布裁决归治理层(P0 安全/金丝雀/人审),本线不置 `published`。
|
||||||
|
8. **输入坐标 = 逻辑像素**:`x∈[0,390]/y∈[0,844]`(非归一),视口固定 390×844 不自处理 DPR。
|
||||||
|
9. **LittleJS 真接线硬门**(build-vs-buy):E1/E2 收口须 grep `bundle.iife.js` / `.meta.json` 证 LittleJS 真入包("产物里没有 X = 验收 fail")。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 高层技术设计(HTD)
|
||||||
|
|
||||||
|
### 本线在全链中的落点(从源真构建 → 宿主 → 九门)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph BE["后端线 (6c6g · 001 计划)"]
|
||||||
|
G["SAA 固定架构图"] -->|"emit sourceProject (gameDefinition+assets[])"| SRC[("game_source_project")]
|
||||||
|
LOGIC["code agent 产 behavior 逻辑码"]
|
||||||
|
end
|
||||||
|
subgraph ME["前端+引擎线 (本线 · 本计划)"]
|
||||||
|
E1["U1 E1 2D 适配器<br/>gameDefinition → factory(entry-bundle)"]
|
||||||
|
E2["U2 E2 build.mjs<br/>entry-bundle → __GameBundle(iife)"]
|
||||||
|
E4["U3 E4 九门 QA<br/>play.cdp.cjs(禁重写)"]
|
||||||
|
E3["U4 E3 asset 产消<br/>assets[] → 宿主"]
|
||||||
|
E6["U6 E6 创作/修改/预览 UI"]
|
||||||
|
end
|
||||||
|
SRC --> E1
|
||||||
|
LOGIC --> E2
|
||||||
|
E1 -->|"entry-bundle.js"| E2
|
||||||
|
E2 -->|"engineBundle(__GameBundle.bootGameHost)"| CB["handleCallback (后端唯一写入路径)"]
|
||||||
|
CB --> PKG[("GamePackage 产物")]
|
||||||
|
PKG -->|"inject.ts engineBundle 分支(零改)"| E4
|
||||||
|
E3 -.->|"sourceProject.assets[].url"| PKG
|
||||||
|
E6 -->|"/studio/create,modify (mock→真)"| BE
|
||||||
|
```
|
||||||
|
|
||||||
|
### 依赖与排序(U1–U7)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
U1["U1 E1 适配器<br/>(最高价值·真空白)"] --> U2["U2 E2 从源构建<br/>(解后端 guard)"]
|
||||||
|
U2 --> U3["U3 E4 九门 QA"]
|
||||||
|
U1 --> U4["U4 E3 asset 产消"]
|
||||||
|
U5["U5 E5 Prompt 冻结<br/>(较独立)"]
|
||||||
|
U3 --> U6["U6 E6 创作 UI"]
|
||||||
|
U6 --> U7["U7 两新页"]
|
||||||
|
```
|
||||||
|
|
||||||
|
> 关键路径 = U1→U2→U3(从源真构建 + 九门验,M0 核心纵切的引擎半边,且解后端 modify guard)。U4/U5 较独立可并行。U6/U7 接 frozen studio.yaml + mock 起,真接等后端 001 U1。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现单元
|
||||||
|
|
||||||
|
> 顺序按依赖与跨线解阻塞:U1(E1 适配器)→ U2(E2 从源构建,解后端 guard)→ U3(E4 九门验)= M0 引擎半边;U4(E3 asset)/U5(E5 prompt)较独立;U6(E6 前端)→ U7(新页)。测试/权威 e2e:mini-desktop(4320/9222 串行);本机 M1 = 快走查。
|
||||||
|
|
||||||
|
### U1. E1 — 2D 适配器(gameDefinition → LittleJS 可玩)
|
||||||
|
|
||||||
|
**Goal**:在 `SourceProject.gameDefinition` 与 `GameHostFactory`→`GameInstance` 契约间架适配器:entities→sprite、components(render/collision/physics)→渲染/碰撞装配、scenes→实体组装、rules(condition→win/lose/score/advance)→胜负判定、config→平衡参数;按 `profile.tickModel`(realtime/turn-based/event)决定是否挂 update 循环。
|
||||||
|
**Requirements**:E1(origin);§10.2-1。
|
||||||
|
**Dependencies**:无(消费已冻 `source-project.schema.json`;骨架范本就位)。
|
||||||
|
**Files**:
|
||||||
|
- `game-runtime/src/adapter/`(新增 adapter 层:gameDefinition→GameInstance)
|
||||||
|
- 参照(不改):`game-runtime/src/core/game-host.d.ts`(`GameHostFactory`/`GameInstance` 契约)、`game-runtime/games/_wg1-gen/_goldenpath-probe/factory.js`(合契约工厂骨架)、`game-runtime/src/host/generic-host-config.js`(7 插件标准集装配)
|
||||||
|
- 测试:`game-runtime/src/adapter/adapter.test.mjs`
|
||||||
|
**Approach**:
|
||||||
|
- 适配器是**纯声明式解释器**:读 entities/components/scenes/rules/config 装配 GameInstance(init 建实体+订阅输入、update 按 tickModel 推进、render 画到入参 `g`、`_forensicsView` 导出 `phase`+状态)。`behaviors` 真逻辑码不在适配器内(KTD1:由 code agent 产、E2 编进 bundle)——适配器为其留挂载点。
|
||||||
|
- 能力一律经 `boot.ctx.getEngine()` + 6 受控面;零引擎 import(KTD3)。
|
||||||
|
- `transform.position{x,y,z?}` 2D 用 xy/z=0 同字段无分叉;逻辑像素 390×844(KTD8)。
|
||||||
|
- MVP 最小集(执行档 §5.1):profile 三维 + 单 behavior + config + assets 引用渲染;复杂结构随品类迭代。
|
||||||
|
**Patterns to follow**:`_goldenpath-probe/factory.js`(init/update/render/`_forensicsView` 形状)、`generic-host-config.js`(`buildGenericHostConfig`)。
|
||||||
|
**Test scenarios**:
|
||||||
|
- happy:含 2 实体 + render 组件 + 1 rule(score)的 gameDefinition → GameInstance,init 后实体可见、update 推进、render 画出有色像素。
|
||||||
|
- happy:`tickModel=turn-based` → 不挂 rAF-style update 循环(事件驱动);`realtime` → 挂。
|
||||||
|
- edge:空 components / 无 behaviors → 适配器产可玩骨架(几何兜底,不崩)。
|
||||||
|
- edge:rule outcome=win 条件达成 → `_forensicsView().phase` 转 gameover 并驻留(latch 语义)。
|
||||||
|
- error:gameDefinition 缺 required(entities/scenes/rules)→ 明确报错,不静默产空壳。
|
||||||
|
- integration:适配器输出经 host 装配 → `_forensicsView()` 透出 `phase`/状态(供九门 H 门读)。
|
||||||
|
**Verification**:adapter 单测绿;产物喂 host 能装配出 `_forensicsView` 可读的 GameInstance;**硬验:最小集(单 behavior + spec-only asset)产出件须独立过九门 H 门 latch(phase→gameover 驻留),非仅"可装配 _forensicsView"**;若单 behavior 不足驱 latch,上修最小集到"能产胜负推进的最小 behavior 组合"(防 t2048 式 phase 卡 playing)。
|
||||||
|
|
||||||
|
### U2. E2 — 从 sourceProject 真构建(build.mjs)+ 同源核对
|
||||||
|
|
||||||
|
**Goal**:打通"sourceProject → 适配器装配 entry-bundle → `build.mjs` → `__GameBundle`(iife)"管线,使确定性"从源真构建"可达(解后端 001 U6 的 guard-pending);并核对 SAA build 节点与 worker 共调 `build.mjs` 产出一致。
|
||||||
|
**Requirements**:E2(origin);§10.2-4;跨线解后端 modify guard。
|
||||||
|
**Dependencies**:U1(适配器产 entry-bundle)。
|
||||||
|
**Files**:
|
||||||
|
- `game-runtime/scripts/build.mjs`(**不动**;只调用)
|
||||||
|
- `game-runtime/src/adapter/`(装配 entry-bundle.js 的入口拼装,接 `entry-bundle.template.js`)
|
||||||
|
- 测试:`game-runtime/src/adapter/build-from-source.test.mjs`
|
||||||
|
**Approach**:
|
||||||
|
- 命令面:`node scripts/build.mjs <id>/entry-bundle.js <id>/bundle.iife.js --global-name=__GameBundle`(唯一入口,KTD2);`LOCKED_BUILD_OPTIONS` 不改。
|
||||||
|
- 缓存键 `buildInputHash=sha256(sourceHash+buildProfile)`;同 hash → 字节等价 bundle(确定性门)。
|
||||||
|
- **净新建主工作量**:适配器把存储的 sourceProject(`game_source_project` / state)落成 `generated-factory.js` 文件(= entry-bundle 的固定 import 目标)→ scaffold-等价写盘 → `build.mjs` 编;+ `buildInputHash/sourceHash` 规范化序列化 + sha256 + 缓存命中查/落(当前零实现)。'共调 build.mjs 已统一' **仅旧 factorySrc 路**;'同源核对'退为收口校验,非主工作量。
|
||||||
|
- 收口 grep `bundle.iife.js`/`.meta.json` 证 LittleJS 真入包(KTD9)。
|
||||||
|
**Patterns to follow**:`entry-bundle.template.js`(逐字拷为各 game 的 entry-bundle.js)、`build.mjs` metafile 产出。
|
||||||
|
**Test scenarios**:
|
||||||
|
- happy:同一 sourceProject 构建两次 → 字节等价 bundle(buildInputHash 命中)。
|
||||||
|
- happy:适配器装配的 entry-bundle 经 build.mjs → iife 暴露 `window.__GameBundle.bootGameHost`。
|
||||||
|
- edge:sourceProject 变一处 config → buildInputHash 变 → 重新构建(不误命中旧缓存)。
|
||||||
|
- error:本机无 esbuild → build.mjs 显式报错给指引(不静默产空)。
|
||||||
|
- integration:worker 路与 SAA 路对同一源产 `__GameBundle` 字节一致(同源同参核对)。
|
||||||
|
**Verification**:从源构建产可玩 iife;字节确定性单测绿;grep 证 LittleJS 真接线;后端 001 U6 的"确定性 build-from-source"依赖在本单元落地后可解 guard(交后端 follow-up 接)。
|
||||||
|
|
||||||
|
### U3. E4 — 九门 QA harness 复用 + narrative reviewer 接入
|
||||||
|
|
||||||
|
**Goal**:固定架构产出件经九门 harness 真玩验绿(**禁重写 harness**);为 narrative 路(`progressModel=narrative`)接 narrative-reviewer 质量门替九门;闭 t2048 唯一剩门。
|
||||||
|
**Requirements**:E4(origin);§10.2-2;origin 成功标准 worker scale ≥18/19→19/19。
|
||||||
|
**Dependencies**:U1、U2(有可玩产出才可真玩)。
|
||||||
|
**Files**:
|
||||||
|
- `game-runtime/games/_wg1-gen/_shared/*`(**禁改**;子进程复用)
|
||||||
|
- 各 game 的 `play-spec.json`(driver 选型 + assertAfterPlay + expectLatch)
|
||||||
|
- t2048:`game-runtime/games/_wg1-gen/t2048/`(latch 放宽规则或填库死锁 driver,见 Open Questions)
|
||||||
|
- narrative reviewer 角色:`wg1/gen-worker/worker/agent_loop/`(reviewer 角色)+ 后端 narrative 路对接
|
||||||
|
**Approach**:
|
||||||
|
- 复用 A–I 九门 + 首局门(additive 不并入 `verdict.pass`);新品类只产 play-spec(8 driver 类型自适应);真承重件 = `expectLatch`+`G_input`(KTD4)。
|
||||||
|
- narrative 路:`progressModel=narrative` → 走 narrative-reviewer(执行档 §5.9),非九门 H 门。**注:narrative-reviewer 是净新建质量门(worker/_shared 现零代码),非 harness 复用**——判定逻辑/prompt/质量标准全新建,须验判定有效性(已知劣质叙事→needsRepair=true / 合格→false),非仅验路由分流。
|
||||||
|
- t2048:抗死锁盲态体裁,latch 难达——放宽 H 门 latch 为"score↑∧board 变+G 兜"或升级填库死锁 driver,立体裁规则补档。
|
||||||
|
**Execution note**:真玩 e2e 在 mini-desktop(4320/9222),与生成批跑**串行**(flock);串行单实例多趟 fresh navigate,禁并发 CDP target。
|
||||||
|
**Patterns to follow**:`game-e2e-cdp-harness` skill(driver 类型/latch/首局门红线)、现 `asteroids/play-spec.json`。
|
||||||
|
**Test scenarios**:
|
||||||
|
- happy:一款固定架构产出件 → 九门全过 `verdict.pass=true`(mini-desktop)。
|
||||||
|
- happy:t2048 经放宽规则/driver → H 门 latch.pass=true(原 pass=false→true)。
|
||||||
|
- edge:`progressModel=narrative` 游戏 → 走 narrative-reviewer 门、不卡九门 H 进展门。
|
||||||
|
- edge:首局门 FAIL → 不阻断 `verdict.pass`(additive 派生维度)。
|
||||||
|
- error:产出件装载失败 → A 门 fail,九门正确判 fail(不假绿)。
|
||||||
|
- integration:scale 维持 ≥18/19;t2048 闭后 ≥19/19。
|
||||||
|
**Verification**:九门真玩绿(mini-desktop);scale 不回退;narrative 路有独立质量门;harness 文件零改(git diff 证)。
|
||||||
|
|
||||||
|
### U4. E3 — asset 产消(六类资产 → 宿主)
|
||||||
|
|
||||||
|
**Goal**:打通六类资产(`sprite/character/effect/scene/ui/music`)产消通道:asset 节点产 spec → `sourceProject.assets[]` → 构建解析为 GamePackage `assets[]` → 宿主经 `assets[].url` 加载。
|
||||||
|
**Requirements**:E3(origin);§10.2-3;六类资产 = P-CRT-02(创始人 2026-06-17 确认)。
|
||||||
|
**Dependencies**:U1(适配器消费 assets[].ref 渲染)。
|
||||||
|
**Files**:
|
||||||
|
- `game-runtime/src/adapter/`(消费侧:assets[].ref→渲染)
|
||||||
|
- `wg1/gen-worker/worker/agent_loop/`(asset 角色:产六类 spec)+ provider=mmx-cli 插桩
|
||||||
|
- 测试:`game-runtime/src/adapter/asset.test.mjs`
|
||||||
|
**Approach**:
|
||||||
|
- 消费侧:适配器读 `sourceProject.assets[].ref`,构建时解析为 GamePackage `assets[]`(image/audio…),宿主经 url 走 `<img>/drawImage`(inject.ts CSP img-src 已放行)。
|
||||||
|
- **MVP 边界**:asset 节点 v0 可只产 spec 不真生图(canvas 几何兜底),但 `assets[]` 产消通道**必须打通**(补 v2 review "assets[] 空"缺口);真资产 provider(mmx-cli)为 additive 升级。
|
||||||
|
- **单步槽位调度、非六路并发**(studio.yaml:18 已狠切)。
|
||||||
|
**Patterns to follow**:`mmx-asset-pipeline` memory(mmx-cli 默认、音乐记谱→合成两步)、`game-package.schema.json` assets 字段。
|
||||||
|
**Test scenarios**:
|
||||||
|
- happy:含 2 类资产(sprite+music)的 sourceProject → GamePackage `assets[]` 非空 → 宿主 assetContext 非空。
|
||||||
|
- happy:asset spec-only(无真图)→ 适配器 canvas 几何兜底渲染(不崩、九门 D 门有色像素)。
|
||||||
|
- edge:`assets[]` 空 → 适配器全几何兜底(向后兼容现 prompt 硬规则 7)。
|
||||||
|
- edge:六类枚举外的 category → 校验拒(严格枚举)。
|
||||||
|
- integration:asset 节点产 spec → 落 sourceProject → 构建 → 宿主加载,端到端 assetContext 非空(九门 assets 门)。
|
||||||
|
**Verification**:assets[] 产消端到端通(九门 asset 门);spec-only 兜底可玩;provider 可切不动消费侧。
|
||||||
|
|
||||||
|
### U5. E5 — Prompt Registry few-shot 冻结(字节稳 + CI 卡 byte)
|
||||||
|
|
||||||
|
**Goal**:把 `prompt.py` 的 SYSTEM + few-shot 从"从文件读"(字节不稳、破前缀缓存)冻结为 `contracts/prompts/` 版本化资产,CI 卡 byte 不变;两处生成入口(Python/SAA)逐字同源。
|
||||||
|
**Requirements**:E5(origin);CI few-shot byte 不变(执行档 §7.2 E5 / §5.8.1)。**注**:§10.2 五门(适配器/九门/asset/构建/UI)不含 E5,E5 验收 = CI 字节门。
|
||||||
|
**Dependencies**:无(较独立,可并行 U1–U4)。
|
||||||
|
**Files**:
|
||||||
|
- `wg1/gen-worker/worker/prompt.py`(`_few_shot()` 改读版本化常量,非文件)
|
||||||
|
- `contracts/prompts/`(冻结 system+few-shot 为 `id@version` 资产;现有 `registry.yaml` + `config.generic-coder` v0.0.1 STUB)
|
||||||
|
- 后端对侧核对:`SaaPrompts.java`(逐字同源 grep 比对)
|
||||||
|
- CI:`contracts/prompts/eval/`(byte 不变门)
|
||||||
|
**Approach**:
|
||||||
|
- 固化 `config.generic-coder` 的 system+few-shot 为版本化资产;few-shot 不再实时读 `_goldenpath-probe/factory.js`,改读已冻常量(字节确定)。
|
||||||
|
- 改 prompt 走升 version + 四道闸 CI(Schema/成功率≥80%/Golden 回归/成本延迟),P0/P1 口径一字不动(KTD5)。
|
||||||
|
- grep 比对 `prompt.py` 与 `SaaPrompts.java` 的规则段逐字同源。
|
||||||
|
**Patterns to follow**:`prompt-governance` skill(第 8 类契约、id@version 加载、四道闸)、`a2cbabe6`(Python/SAA 逐字同改先例)。
|
||||||
|
**Test scenarios**:
|
||||||
|
- happy:few-shot 从冻结资产读 → 多次构 messages 字节完全一致(CI byte 门)。
|
||||||
|
- happy:`prompt.py` 与 `SaaPrompts.java` 规则段 grep diff = 空(逐字同源)。
|
||||||
|
- edge:改 prompt 未升 version → CI 拒(version 门)。
|
||||||
|
- error:P0/P1 口径被改 → CI 红线拒(一字不动门)。
|
||||||
|
**Verification**:CI few-shot byte 不变门绿;两处入口逐字同源 grep 证;缓存命中率不因 few-shot 抖动下降(spike 已证 76–93%)。
|
||||||
|
|
||||||
|
### U6. E6 — 前端创作/修改/预览 UI(对 frozen studio.yaml + mock)
|
||||||
|
|
||||||
|
**Goal**:新建 `studio.ts` api + 改造 `Create.vue` 接 `/studio/create`(一步式)+ 新建 modify 入口接 `/studio/modify` + 预览复用 GamePlayer;对 frozen studio.yaml + mock 先做,后端 001 U1 落后真接。
|
||||||
|
**Requirements**:E6(origin);§10.2-5(真机 UI 走查)。
|
||||||
|
**Dependencies**:U1–U3(有可玩产出才有意义);后端 001 U1(真接;之前对 mock)。
|
||||||
|
**Files**:
|
||||||
|
- `game-studio/src/api/studio.ts`(新建,对齐 studio.yaml create/modify/extend)
|
||||||
|
- `game-studio/src/views/create/Create.vue`(改造:旧 `aigc.generate` 两步 → `/studio/create` 一步式)
|
||||||
|
- `game-studio/src/views/create/`(新建 modify 入口视图/组件)
|
||||||
|
- 预览:`game-studio/src/host/GamePlayer.vue`(**复用零新建**,喂 preview 版 versionId)
|
||||||
|
- 测试:`game-studio/src/views/create/Create.spec.ts` + 真机 CDP 走查脚本
|
||||||
|
**Approach**:
|
||||||
|
- `studio.ts` 对 frozen studio.yaml 出 create/modify/extend 调用;Create.vue 改一步式(generic 桥接已落,缺省 templateId=generic)。
|
||||||
|
- modify 入口:换美术/调参/改关卡=deterministic、改玩法=regenerate-module(modifyPatch 形态,执行档 §5.6);出新预览版不动 currentVersion。
|
||||||
|
- 进度反馈:补 UI 形态(成功/失败/进行中落点),与 SSE/轮询传输选择分离(后者视后端定,Open Questions)。
|
||||||
|
- 对 mock 先达"可走查骨架"(加载/空/选中→应用三态),后端 U1 真接后真数据走查。
|
||||||
|
**Execution note**:收口前**必做真机 CDP 走查**(编排器/curl 全绿不算)+ **必另跑真实 IP/明文 http origin** 验上下文依赖(crypto.subtle 等);textarea 用原型 setter+dispatch input(Vue v-model),按钮精确文案匹配。
|
||||||
|
**Patterns to follow**:`ui-walkthrough-cdp` skill(真机走查 7 坑 + 文案匹配 + token 注入只读走查)、现 `Create.vue` generic 桥接 + `aigc.ts`/`project.ts` api 形态。
|
||||||
|
**Test scenarios**:
|
||||||
|
- happy:Create 一步式 → POST `/studio/create`(mock)→ 跳 task/预览;templateId 缺省 generic。
|
||||||
|
- happy:modify 入口换美术(deterministic)→ POST `/studio/modify` 带 modifyPatch → 新预览版(GamePlayer 预览)。
|
||||||
|
- edge:`template/list` 空 → 不再"模板体系重构中"死路,走 generic 创作入口。
|
||||||
|
- edge:mock 三态(加载/空/选中→应用)可走查。
|
||||||
|
- error:create 失败 → UI 明确失败态(非静默)。
|
||||||
|
- integration(真机):真 IP/http origin 走查 create→预览闭环,上下文依赖特性不退化。
|
||||||
|
**Verification**:对 mock 骨架可走查;后端 U1 真接后真机 CDP 走查 create/modify/预览闭环过;真实 origin 验过。
|
||||||
|
|
||||||
|
### U7. 广度 — 玩法模板中心页 + 素材中心页(新页,mock-first)
|
||||||
|
|
||||||
|
**Goal**:补 demo 八大页缺的两新页:玩法模板中心(浏览/选择/应用模板到草稿)+ 素材中心(素材浏览/选用加入草稿);先对 mock,真接等后端模板/素材 API。
|
||||||
|
**Requirements**:origin 广度;demo 八大页补全。
|
||||||
|
**Dependencies**:U6(创作页接缝);后端模板/素材 API(真接,deferred 跨线)。
|
||||||
|
**Files**:
|
||||||
|
- `game-studio/src/views/template/`(新建玩法模板中心页 + 路由)
|
||||||
|
- `game-studio/src/views/asset/`(新建素材中心页 + 路由)
|
||||||
|
- `game-studio/src/router/index.ts`(+2 路由)
|
||||||
|
- 测试:对应 `.spec.ts`
|
||||||
|
**Approach**:
|
||||||
|
- 入口/IA:与现有 8 页导航一致(底部 Tab 或创作页二级入口,实现时定);对 mock API 建骨架。
|
||||||
|
- 毕业门:玩法模板页绑 **M1**、素材中心页绑 **M3**(后端 API 就位后真接 + UI 走查才算过 Phase 1)。
|
||||||
|
**Patterns to follow**:现有 8 页视图 + 设计体系组件库(AppButton/Icon/Field/EmptyState)。
|
||||||
|
**Test scenarios**:
|
||||||
|
- happy:玩法模板中心列模板(mock)→ 选择 → 应用到草稿(传 templateId 到创作)。
|
||||||
|
- happy:素材中心列素材(mock)→ 选用 → 加入草稿 assetContext。
|
||||||
|
- edge:mock 空列表 → EmptyState(非崩)。
|
||||||
|
- edge:新路由不与现有 10 目录路由冲突。
|
||||||
|
**Verification**:两页对 mock 可走查;路由接入;后端 API 就位后真接走查(M1/M3 毕业门)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 范围边界(Scope Boundaries)
|
||||||
|
|
||||||
|
### Deferred to Follow-Up Work(P1 内、各起后续)
|
||||||
|
- **game-admin 运营台前端**(创始人裁定归本线,≈M3 低优先):限曝光/导出/经营看板 + 赋余额/订阅入口;`game-admin` repo(huijing-ui-admin-vue3 fork)。未设计到可拆单元,用到时起计划。
|
||||||
|
- **既有页打磨广度**:feed / 钱包 / 消息社交 / 账号(均有骨架,补真接/完善)+ 设计体系迭代。
|
||||||
|
- **真资产 provider 接入**(E3 升级):mmx-cli 真生图/音乐合成(MVP 用 spec-only 兜底)。
|
||||||
|
|
||||||
|
### 不在 P1(边界)
|
||||||
|
- 全部 game-cloud 后端——SAA 图/B 步、studio 编排 B6、源落库、`handleCallback` 入 feed = 后端线(001 计划)。
|
||||||
|
- 3D 适配器 / Cocos → P2;真支付/短信/广告/渠道 → P3;云部署/监控运维 → P2。
|
||||||
|
- 护城河增厚(素材电商/IP/角色 rig/六类资产模块化深化)→ P2/P3。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险与依赖
|
||||||
|
|
||||||
|
- **R1 跨线解阻塞(E1/E2 → 后端 U6)**:后端确定性 modify guard-pending 在本线"从源真构建"。缓解:E1/E2 排最先,落后通知后端起 follow-up 接 guard。
|
||||||
|
- **R2 E1 真空白(无先例)**:无 gameDefinition 消费者先例。缓解:`_goldenpath-probe/factory.js` 作骨架范本 + 契约已冻 + MVP 最小集先跑通一档。
|
||||||
|
- **R3 E1 边界做成孤儿**:适配器 vs code agent 逻辑码的接缝。缓解:KTD1 已锁(跨线 001 确认),E1 kickoff 再与后端核对一次逻辑码入 bundle 的精确接缝(Open Questions)。
|
||||||
|
- **R4 E6 比预期大 + 卡后端**:`studio.ts` 真空 + Create 改造,真接卡后端 001 U1。缓解:对 mock + frozen yaml 先做,真接异步。
|
||||||
|
- **R5 假绿/旁路掩盖缺陷**:九门/编排器/curl 全绿 ≠ 真可玩/真可用。缓解:E4 真承重 latch+G_input;E6 真机走查 + 真实 origin(KTD4/6)。
|
||||||
|
- **依赖**:mini-desktop(权威构建/测/九门 e2e,4320/9222 串行);后端 001(B6/U1 /studio API · SAA 产 gameDefinition/assetSpec);契约 8/8 已冻;`build.mjs`/harness/宿主装载契约/GamePackage 既冻零改。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 待确认(Open Questions)
|
||||||
|
|
||||||
|
**Resolve before E1 实现(跨线协调,非创始人)**
|
||||||
|
1. **逻辑码入 bundle 接缝形态**(U1/U2,U1 硬前置):code agent 产的 behavior 逻辑码以何形态进 entry-bundle(独立模块 / 拼入 factory)?先做最小贯穿实验验形态(见 KTD1),再展开 U1。
|
||||||
|
2. **gameDefinition producer 迁移**(跨线集成):SAA 何时由 factorySrc 改产填充版 gameDefinition?**创始人 2026-06-17 裁定:gameDefinition 是冻结 keystone 契约,E1 contract-first 对 schema 编程、不阻塞于 producer**;producer 迁移 = 后端 off-factorySrc 的集成期事,两线在冻结契约处交汇(非 E1 开工阻塞)。
|
||||||
|
|
||||||
|
**Deferred to 实现期**
|
||||||
|
2. **t2048 latch**(U3):放宽 H 门 latch vs 填库死锁 driver——按数据定。
|
||||||
|
3. **E6 进度反馈**(U6):SSE 节点流(SAA 强项)vs 前端轮询——视后端 001 进度契约定。
|
||||||
|
4. **U7 新页入口形态**:底部 Tab vs 创作页二级入口——实现时按现有导航体系定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources & Research
|
||||||
|
|
||||||
|
- origin 需求档 `docs/brainstorms/2026-06-17-mac-work-list-requirements.md`(E1–E6 + §10.2 门 + 创始人裁定:game-admin 归本线、六类资产=P-CRT-02)。
|
||||||
|
- execution spec `docs/agent-specs/2026-06-17-固定游戏架构与SAA-agentic-studio-execution.md`:§5.1(SourceProject/gameDefinition)、§5.5(asset 产消)、§5.6(modifyPatch)、§5.7(确定性构建)、§7.2(E 步)、§10.2(引擎线验收门)。
|
||||||
|
- 跨线对侧 `docs/plans/2026-06-17-001-feat-backend-tier0-gen-mainline-plan.md`(KTD5/U6:从源真构建=本线 E2,后端 guard-pending;HTD 数据流接缝)。
|
||||||
|
- 实证代码:`game-runtime/src/core/game-host.d.ts`(GameHostFactory/GameInstance 契约)、`game-runtime/games/_wg1-gen/_goldenpath-probe/factory.js`(工厂骨架)、`game-runtime/scripts/build.mjs`(唯一构建)、`game-runtime/games/_wg1-gen/_shared/play.cdp.cjs`(九门,禁改)、`game-studio/src/host/inject.ts`(engineBundle 装载分支)、`game-studio/src/views/create/Create.vue`(现 generic 桥接)、`wg1/gen-worker/worker/prompt.py`(few-shot 字节不稳根因)、`contracts/prompts/registry.yaml`(第 8 类契约)。
|
||||||
|
- skills/rules:`game-e2e-cdp-harness`(九门/latch/首局门红线)、`cheap-model-game-generation`(6 受控面/9 静态门/逻辑像素坐标)、`ui-walkthrough-cdp`(真机走查/真实 origin)、`prompt-governance`(四道闸/逐字同源)、`build-vs-buy`(LittleJS 真接线 grep 门)、`runtime-and-multichannel`(Tier1 LittleJS 增强发行版/三层约束)。
|
||||||
@ -22,7 +22,8 @@
|
|||||||
"lint:eslint": "eslint --fix --ext .js,.ts,.vue ./src",
|
"lint:eslint": "eslint --fix --ext .js,.ts,.vue ./src",
|
||||||
"lint:format": "prettier --write --loglevel warn \"src/**/*.{js,ts,json,tsx,css,less,scss,vue,html,md}\"",
|
"lint:format": "prettier --write --loglevel warn \"src/**/*.{js,ts,json,tsx,css,less,scss,vue,html,md}\"",
|
||||||
"lint:style": "stylelint --fix \"./src/**/*.{vue,less,postcss,css,scss}\" --cache --cache-location node_modules/.cache/stylelint/",
|
"lint:style": "stylelint --fix \"./src/**/*.{vue,less,postcss,css,scss}\" --cache --cache-location node_modules/.cache/stylelint/",
|
||||||
"lint:lint-staged": "lint-staged -c "
|
"lint:lint-staged": "lint-staged -c ",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "2.3.2",
|
"@element-plus/icons-vue": "2.3.2",
|
||||||
@ -139,6 +140,7 @@
|
|||||||
"vite-plugin-purge-icons": "^0.10.0",
|
"vite-plugin-purge-icons": "^0.10.0",
|
||||||
"vite-plugin-svg-icons-ng": "^1.3.1",
|
"vite-plugin-svg-icons-ng": "^1.3.1",
|
||||||
"vite-plugin-top-level-await": "^1.4.4",
|
"vite-plugin-top-level-await": "^1.4.4",
|
||||||
|
"vitest": "^2.1.9",
|
||||||
"vue-eslint-parser": "^9.3.2",
|
"vue-eslint-parser": "^9.3.2",
|
||||||
"vue-tsc": "^1.8.27"
|
"vue-tsc": "^1.8.27"
|
||||||
},
|
},
|
||||||
|
|||||||
281
game-admin/pnpm-lock.yaml
generated
281
game-admin/pnpm-lock.yaml
generated
@ -345,6 +345,9 @@ importers:
|
|||||||
vite-plugin-top-level-await:
|
vite-plugin-top-level-await:
|
||||||
specifier: ^1.4.4
|
specifier: ^1.4.4
|
||||||
version: 1.4.4(rollup@4.27.4)(vite@5.1.4(@types/node@20.17.9)(sass@1.81.0)(terser@5.36.0))
|
version: 1.4.4(rollup@4.27.4)(vite@5.1.4(@types/node@20.17.9)(sass@1.81.0)(terser@5.36.0))
|
||||||
|
vitest:
|
||||||
|
specifier: ^2.1.9
|
||||||
|
version: 2.1.9(@types/node@20.17.9)(sass@1.81.0)(terser@5.36.0)
|
||||||
vue-eslint-parser:
|
vue-eslint-parser:
|
||||||
specifier: ^9.3.2
|
specifier: ^9.3.2
|
||||||
version: 9.4.3(eslint@8.57.1)
|
version: 9.4.3(eslint@8.57.1)
|
||||||
@ -1406,42 +1409,36 @@ packages:
|
|||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@parcel/watcher-linux-arm-musl@2.5.0':
|
'@parcel/watcher-linux-arm-musl@2.5.0':
|
||||||
resolution: {integrity: sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==}
|
resolution: {integrity: sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@parcel/watcher-linux-arm64-glibc@2.5.0':
|
'@parcel/watcher-linux-arm64-glibc@2.5.0':
|
||||||
resolution: {integrity: sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==}
|
resolution: {integrity: sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@parcel/watcher-linux-arm64-musl@2.5.0':
|
'@parcel/watcher-linux-arm64-musl@2.5.0':
|
||||||
resolution: {integrity: sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==}
|
resolution: {integrity: sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@parcel/watcher-linux-x64-glibc@2.5.0':
|
'@parcel/watcher-linux-x64-glibc@2.5.0':
|
||||||
resolution: {integrity: sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==}
|
resolution: {integrity: sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@parcel/watcher-linux-x64-musl@2.5.0':
|
'@parcel/watcher-linux-x64-musl@2.5.0':
|
||||||
resolution: {integrity: sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==}
|
resolution: {integrity: sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@parcel/watcher-win32-arm64@2.5.0':
|
'@parcel/watcher-win32-arm64@2.5.0':
|
||||||
resolution: {integrity: sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==}
|
resolution: {integrity: sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==}
|
||||||
@ -1545,55 +1542,46 @@ packages:
|
|||||||
resolution: {integrity: sha512-9OwUnK/xKw6DyRlgx8UizeqRFOfi9mf5TYCw1uolDaJSbUmBxP85DE6T4ouCMoN6pXw8ZoTeZCSEfSaYo+/s1w==}
|
resolution: {integrity: sha512-9OwUnK/xKw6DyRlgx8UizeqRFOfi9mf5TYCw1uolDaJSbUmBxP85DE6T4ouCMoN6pXw8ZoTeZCSEfSaYo+/s1w==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.27.4':
|
'@rollup/rollup-linux-arm-musleabihf@4.27.4':
|
||||||
resolution: {integrity: sha512-Vgdo4fpuphS9V24WOV+KwkCVJ72u7idTgQaBoLRD0UxBAWTF9GWurJO9YD9yh00BzbkhpeXtm6na+MvJU7Z73A==}
|
resolution: {integrity: sha512-Vgdo4fpuphS9V24WOV+KwkCVJ72u7idTgQaBoLRD0UxBAWTF9GWurJO9YD9yh00BzbkhpeXtm6na+MvJU7Z73A==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.27.4':
|
'@rollup/rollup-linux-arm64-gnu@4.27.4':
|
||||||
resolution: {integrity: sha512-pleyNgyd1kkBkw2kOqlBx+0atfIIkkExOTiifoODo6qKDSpnc6WzUY5RhHdmTdIJXBdSnh6JknnYTtmQyobrVg==}
|
resolution: {integrity: sha512-pleyNgyd1kkBkw2kOqlBx+0atfIIkkExOTiifoODo6qKDSpnc6WzUY5RhHdmTdIJXBdSnh6JknnYTtmQyobrVg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.27.4':
|
'@rollup/rollup-linux-arm64-musl@4.27.4':
|
||||||
resolution: {integrity: sha512-caluiUXvUuVyCHr5DxL8ohaaFFzPGmgmMvwmqAITMpV/Q+tPoaHZ/PWa3t8B2WyoRcIIuu1hkaW5KkeTDNSnMA==}
|
resolution: {integrity: sha512-caluiUXvUuVyCHr5DxL8ohaaFFzPGmgmMvwmqAITMpV/Q+tPoaHZ/PWa3t8B2WyoRcIIuu1hkaW5KkeTDNSnMA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-powerpc64le-gnu@4.27.4':
|
'@rollup/rollup-linux-powerpc64le-gnu@4.27.4':
|
||||||
resolution: {integrity: sha512-FScrpHrO60hARyHh7s1zHE97u0KlT/RECzCKAdmI+LEoC1eDh/RDji9JgFqyO+wPDb86Oa/sXkily1+oi4FzJQ==}
|
resolution: {integrity: sha512-FScrpHrO60hARyHh7s1zHE97u0KlT/RECzCKAdmI+LEoC1eDh/RDji9JgFqyO+wPDb86Oa/sXkily1+oi4FzJQ==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.27.4':
|
'@rollup/rollup-linux-riscv64-gnu@4.27.4':
|
||||||
resolution: {integrity: sha512-qyyprhyGb7+RBfMPeww9FlHwKkCXdKHeGgSqmIXw9VSUtvyFZ6WZRtnxgbuz76FK7LyoN8t/eINRbPUcvXB5fw==}
|
resolution: {integrity: sha512-qyyprhyGb7+RBfMPeww9FlHwKkCXdKHeGgSqmIXw9VSUtvyFZ6WZRtnxgbuz76FK7LyoN8t/eINRbPUcvXB5fw==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.27.4':
|
'@rollup/rollup-linux-s390x-gnu@4.27.4':
|
||||||
resolution: {integrity: sha512-PFz+y2kb6tbh7m3A7nA9++eInGcDVZUACulf/KzDtovvdTizHpZaJty7Gp0lFwSQcrnebHOqxF1MaKZd7psVRg==}
|
resolution: {integrity: sha512-PFz+y2kb6tbh7m3A7nA9++eInGcDVZUACulf/KzDtovvdTizHpZaJty7Gp0lFwSQcrnebHOqxF1MaKZd7psVRg==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.27.4':
|
'@rollup/rollup-linux-x64-gnu@4.27.4':
|
||||||
resolution: {integrity: sha512-Ni8mMtfo+o/G7DVtweXXV/Ol2TFf63KYjTtoZ5f078AUgJTmaIJnj4JFU7TK/9SVWTaSJGxPi5zMDgK4w+Ez7Q==}
|
resolution: {integrity: sha512-Ni8mMtfo+o/G7DVtweXXV/Ol2TFf63KYjTtoZ5f078AUgJTmaIJnj4JFU7TK/9SVWTaSJGxPi5zMDgK4w+Ez7Q==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.27.4':
|
'@rollup/rollup-linux-x64-musl@4.27.4':
|
||||||
resolution: {integrity: sha512-5AeeAF1PB9TUzD+3cROzFTnAJAcVUGLuR8ng0E0WXGkYhp6RD6L+6szYVX+64Rs0r72019KHZS1ka1q+zU/wUw==}
|
resolution: {integrity: sha512-5AeeAF1PB9TUzD+3cROzFTnAJAcVUGLuR8ng0E0WXGkYhp6RD6L+6szYVX+64Rs0r72019KHZS1ka1q+zU/wUw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-win32-arm64-msvc@4.27.4':
|
'@rollup/rollup-win32-arm64-msvc@4.27.4':
|
||||||
resolution: {integrity: sha512-yOpVsA4K5qVwu2CaS3hHxluWIK5HQTjNV4tWjQXluMiiiu4pJj4BN98CvxohNCpcjMeTXk/ZMJBRbgRg8HBB6A==}
|
resolution: {integrity: sha512-yOpVsA4K5qVwu2CaS3hHxluWIK5HQTjNV4tWjQXluMiiiu4pJj4BN98CvxohNCpcjMeTXk/ZMJBRbgRg8HBB6A==}
|
||||||
@ -1639,28 +1627,24 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@swc/core-linux-arm64-musl@1.9.3':
|
'@swc/core-linux-arm64-musl@1.9.3':
|
||||||
resolution: {integrity: sha512-tzVH480RY6RbMl/QRgh5HK3zn1ZTFsThuxDGo6Iuk1MdwIbdFYUY034heWUTI4u3Db97ArKh0hNL0xhO3+PZdg==}
|
resolution: {integrity: sha512-tzVH480RY6RbMl/QRgh5HK3zn1ZTFsThuxDGo6Iuk1MdwIbdFYUY034heWUTI4u3Db97ArKh0hNL0xhO3+PZdg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@swc/core-linux-x64-gnu@1.9.3':
|
'@swc/core-linux-x64-gnu@1.9.3':
|
||||||
resolution: {integrity: sha512-ivXXBRDXDc9k4cdv10R21ccBmGebVOwKXT/UdH1PhxUn9m/h8erAWjz5pcELwjiMf27WokqPgaWVfaclDbgE+w==}
|
resolution: {integrity: sha512-ivXXBRDXDc9k4cdv10R21ccBmGebVOwKXT/UdH1PhxUn9m/h8erAWjz5pcELwjiMf27WokqPgaWVfaclDbgE+w==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@swc/core-linux-x64-musl@1.9.3':
|
'@swc/core-linux-x64-musl@1.9.3':
|
||||||
resolution: {integrity: sha512-ILsGMgfnOz1HwdDz+ZgEuomIwkP1PHT6maigZxaCIuC6OPEhKE8uYna22uU63XvYcLQvZYDzpR3ms47WQPuNEg==}
|
resolution: {integrity: sha512-ILsGMgfnOz1HwdDz+ZgEuomIwkP1PHT6maigZxaCIuC6OPEhKE8uYna22uU63XvYcLQvZYDzpR3ms47WQPuNEg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@swc/core-win32-arm64-msvc@1.9.3':
|
'@swc/core-win32-arm64-msvc@1.9.3':
|
||||||
resolution: {integrity: sha512-e+XmltDVIHieUnNJHtspn6B+PCcFOMYXNJB1GqoCcyinkEIQNwC8KtWgMqUucUbEWJkPc35NHy9k8aCXRmw9Kg==}
|
resolution: {integrity: sha512-e+XmltDVIHieUnNJHtspn6B+PCcFOMYXNJB1GqoCcyinkEIQNwC8KtWgMqUucUbEWJkPc35NHy9k8aCXRmw9Kg==}
|
||||||
@ -2155,6 +2139,35 @@ packages:
|
|||||||
vite: ^5.0.0 || ^6.0.0
|
vite: ^5.0.0 || ^6.0.0
|
||||||
vue: ^3.2.25
|
vue: ^3.2.25
|
||||||
|
|
||||||
|
'@vitest/expect@2.1.9':
|
||||||
|
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
||||||
|
|
||||||
|
'@vitest/mocker@2.1.9':
|
||||||
|
resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==}
|
||||||
|
peerDependencies:
|
||||||
|
msw: ^2.4.9
|
||||||
|
vite: ^5.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
msw:
|
||||||
|
optional: true
|
||||||
|
vite:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@vitest/pretty-format@2.1.9':
|
||||||
|
resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==}
|
||||||
|
|
||||||
|
'@vitest/runner@2.1.9':
|
||||||
|
resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==}
|
||||||
|
|
||||||
|
'@vitest/snapshot@2.1.9':
|
||||||
|
resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==}
|
||||||
|
|
||||||
|
'@vitest/spy@2.1.9':
|
||||||
|
resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==}
|
||||||
|
|
||||||
|
'@vitest/utils@2.1.9':
|
||||||
|
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
|
||||||
|
|
||||||
'@volar/language-core@1.11.1':
|
'@volar/language-core@1.11.1':
|
||||||
resolution: {integrity: sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==}
|
resolution: {integrity: sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==}
|
||||||
|
|
||||||
@ -2434,6 +2447,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
|
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
assertion-error@2.0.1:
|
||||||
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
astral-regex@2.0.0:
|
astral-regex@2.0.0:
|
||||||
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
|
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -2569,6 +2586,10 @@ packages:
|
|||||||
caniuse-lite@1.0.30001684:
|
caniuse-lite@1.0.30001684:
|
||||||
resolution: {integrity: sha512-G1LRwLIQjBQoyq0ZJGqGIJUXzJ8irpbjHLpVRXDvBEScFJ9b17sgK6vlx0GAJFE21okD7zXl08rRRUfq6HdoEQ==}
|
resolution: {integrity: sha512-G1LRwLIQjBQoyq0ZJGqGIJUXzJ8irpbjHLpVRXDvBEScFJ9b17sgK6vlx0GAJFE21okD7zXl08rRRUfq6HdoEQ==}
|
||||||
|
|
||||||
|
chai@5.3.3:
|
||||||
|
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
chalk@1.1.3:
|
chalk@1.1.3:
|
||||||
resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==}
|
resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -2581,6 +2602,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==}
|
resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==}
|
||||||
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
|
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
|
||||||
|
|
||||||
|
check-error@2.1.3:
|
||||||
|
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
||||||
|
engines: {node: '>= 16'}
|
||||||
|
|
||||||
cheerio-select@2.1.0:
|
cheerio-select@2.1.0:
|
||||||
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
|
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
|
||||||
|
|
||||||
@ -2933,6 +2958,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
deep-eql@5.0.2:
|
||||||
|
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
|
|
||||||
@ -3224,6 +3253,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==}
|
resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==}
|
||||||
engines: {node: '>=16.17'}
|
engines: {node: '>=16.17'}
|
||||||
|
|
||||||
|
expect-type@1.3.0:
|
||||||
|
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
ext@1.7.0:
|
ext@1.7.0:
|
||||||
resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==}
|
resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==}
|
||||||
|
|
||||||
@ -3841,6 +3874,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
|
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
|
||||||
engines: {node: '>= 0.6.0'}
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
|
loupe@3.2.1:
|
||||||
|
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||||
|
|
||||||
lru-cache@10.4.3:
|
lru-cache@10.4.3:
|
||||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||||
|
|
||||||
@ -4195,6 +4231,10 @@ packages:
|
|||||||
pathe@2.0.3:
|
pathe@2.0.3:
|
||||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
|
pathval@2.0.1:
|
||||||
|
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||||
|
engines: {node: '>= 14.16'}
|
||||||
|
|
||||||
perfect-debounce@1.0.0:
|
perfect-debounce@1.0.0:
|
||||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||||
|
|
||||||
@ -4535,6 +4575,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==}
|
resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
siginfo@2.0.0:
|
||||||
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
signal-exit@4.1.0:
|
signal-exit@4.1.0:
|
||||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@ -4601,6 +4644,12 @@ packages:
|
|||||||
ssr-window@4.0.2:
|
ssr-window@4.0.2:
|
||||||
resolution: {integrity: sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==}
|
resolution: {integrity: sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==}
|
||||||
|
|
||||||
|
stackback@0.0.2:
|
||||||
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
|
std-env@3.10.0:
|
||||||
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
steady-xml@0.1.0:
|
steady-xml@0.1.0:
|
||||||
resolution: {integrity: sha512-5sk17qO2wWRtonTNoBhoKAB35OSsGJOa3+NEa6D+1GS+de+ujDWxnflMkXBrviOfkNrPTUqduAdXhrMJs89nAw==}
|
resolution: {integrity: sha512-5sk17qO2wWRtonTNoBhoKAB35OSsGJOa3+NEa6D+1GS+de+ujDWxnflMkXBrviOfkNrPTUqduAdXhrMJs89nAw==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@ -4742,6 +4791,9 @@ packages:
|
|||||||
tiny-warning@1.0.3:
|
tiny-warning@1.0.3:
|
||||||
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
|
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
|
||||||
|
|
||||||
|
tinybench@2.9.0:
|
||||||
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
tinyexec@0.3.1:
|
tinyexec@0.3.1:
|
||||||
resolution: {integrity: sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==}
|
resolution: {integrity: sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==}
|
||||||
|
|
||||||
@ -4749,6 +4801,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==}
|
resolution: {integrity: sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tinypool@1.1.1:
|
||||||
|
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
|
||||||
|
tinyrainbow@1.2.0:
|
||||||
|
resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
tinyspy@3.0.2:
|
||||||
|
resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
to-regex-range@5.0.1:
|
to-regex-range@5.0.1:
|
||||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
@ -4916,6 +4980,11 @@ packages:
|
|||||||
videojs-vtt.js@0.15.5:
|
videojs-vtt.js@0.15.5:
|
||||||
resolution: {integrity: sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==}
|
resolution: {integrity: sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==}
|
||||||
|
|
||||||
|
vite-node@2.1.9:
|
||||||
|
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
vite-plugin-compression@0.5.1:
|
vite-plugin-compression@0.5.1:
|
||||||
resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==}
|
resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -4983,6 +5052,31 @@ packages:
|
|||||||
terser:
|
terser:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vitest@2.1.9:
|
||||||
|
resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@edge-runtime/vm': '*'
|
||||||
|
'@types/node': ^18.0.0 || >=20.0.0
|
||||||
|
'@vitest/browser': 2.1.9
|
||||||
|
'@vitest/ui': 2.1.9
|
||||||
|
happy-dom: '*'
|
||||||
|
jsdom: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@edge-runtime/vm':
|
||||||
|
optional: true
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser':
|
||||||
|
optional: true
|
||||||
|
'@vitest/ui':
|
||||||
|
optional: true
|
||||||
|
happy-dom:
|
||||||
|
optional: true
|
||||||
|
jsdom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
vue-demi@0.14.10:
|
vue-demi@0.14.10:
|
||||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@ -5085,6 +5179,11 @@ packages:
|
|||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
wildcard@1.1.2:
|
wildcard@1.1.2:
|
||||||
resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==}
|
resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==}
|
||||||
|
|
||||||
@ -6325,7 +6424,7 @@ snapshots:
|
|||||||
escodegen: 2.1.0
|
escodegen: 2.1.0
|
||||||
estree-walker: 2.0.2
|
estree-walker: 2.0.2
|
||||||
jsonc-eslint-parser: 2.4.0
|
jsonc-eslint-parser: 2.4.0
|
||||||
magic-string: 0.30.14
|
magic-string: 0.30.17
|
||||||
mlly: 1.7.3
|
mlly: 1.7.3
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
yaml-eslint-parser: 1.2.3
|
yaml-eslint-parser: 1.2.3
|
||||||
@ -7027,7 +7126,7 @@ snapshots:
|
|||||||
colorette: 2.0.20
|
colorette: 2.0.20
|
||||||
consola: 3.2.3
|
consola: 3.2.3
|
||||||
fast-glob: 3.3.2
|
fast-glob: 3.3.2
|
||||||
magic-string: 0.30.14
|
magic-string: 0.30.17
|
||||||
pathe: 1.1.2
|
pathe: 1.1.2
|
||||||
perfect-debounce: 1.0.0
|
perfect-debounce: 1.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@ -7105,7 +7204,7 @@ snapshots:
|
|||||||
'@unocss/rule-utils': 0.58.9
|
'@unocss/rule-utils': 0.58.9
|
||||||
css-tree: 2.3.1
|
css-tree: 2.3.1
|
||||||
fast-glob: 3.3.2
|
fast-glob: 3.3.2
|
||||||
magic-string: 0.30.14
|
magic-string: 0.30.17
|
||||||
postcss: 8.4.49
|
postcss: 8.4.49
|
||||||
|
|
||||||
'@unocss/preset-attributify@0.58.9':
|
'@unocss/preset-attributify@0.58.9':
|
||||||
@ -7158,7 +7257,7 @@ snapshots:
|
|||||||
'@unocss/rule-utils@0.58.9':
|
'@unocss/rule-utils@0.58.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@unocss/core': 0.58.9
|
'@unocss/core': 0.58.9
|
||||||
magic-string: 0.30.14
|
magic-string: 0.30.17
|
||||||
|
|
||||||
'@unocss/rule-utils@66.1.0-beta.5':
|
'@unocss/rule-utils@66.1.0-beta.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -7205,7 +7304,7 @@ snapshots:
|
|||||||
'@unocss/transformer-directives': 0.58.9
|
'@unocss/transformer-directives': 0.58.9
|
||||||
chokidar: 3.6.0
|
chokidar: 3.6.0
|
||||||
fast-glob: 3.3.2
|
fast-glob: 3.3.2
|
||||||
magic-string: 0.30.14
|
magic-string: 0.30.17
|
||||||
vite: 5.1.4(@types/node@20.17.9)(sass@1.81.0)(terser@5.36.0)
|
vite: 5.1.4(@types/node@20.17.9)(sass@1.81.0)(terser@5.36.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- rollup
|
- rollup
|
||||||
@ -7298,6 +7397,46 @@ snapshots:
|
|||||||
vite: 5.1.4(@types/node@20.17.9)(sass@1.81.0)(terser@5.36.0)
|
vite: 5.1.4(@types/node@20.17.9)(sass@1.81.0)(terser@5.36.0)
|
||||||
vue: 3.5.12(typescript@5.3.3)
|
vue: 3.5.12(typescript@5.3.3)
|
||||||
|
|
||||||
|
'@vitest/expect@2.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/spy': 2.1.9
|
||||||
|
'@vitest/utils': 2.1.9
|
||||||
|
chai: 5.3.3
|
||||||
|
tinyrainbow: 1.2.0
|
||||||
|
|
||||||
|
'@vitest/mocker@2.1.9(vite@5.1.4(@types/node@20.17.9)(sass@1.81.0)(terser@5.36.0))':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/spy': 2.1.9
|
||||||
|
estree-walker: 3.0.3
|
||||||
|
magic-string: 0.30.17
|
||||||
|
optionalDependencies:
|
||||||
|
vite: 5.1.4(@types/node@20.17.9)(sass@1.81.0)(terser@5.36.0)
|
||||||
|
|
||||||
|
'@vitest/pretty-format@2.1.9':
|
||||||
|
dependencies:
|
||||||
|
tinyrainbow: 1.2.0
|
||||||
|
|
||||||
|
'@vitest/runner@2.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/utils': 2.1.9
|
||||||
|
pathe: 1.1.2
|
||||||
|
|
||||||
|
'@vitest/snapshot@2.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 2.1.9
|
||||||
|
magic-string: 0.30.17
|
||||||
|
pathe: 1.1.2
|
||||||
|
|
||||||
|
'@vitest/spy@2.1.9':
|
||||||
|
dependencies:
|
||||||
|
tinyspy: 3.0.2
|
||||||
|
|
||||||
|
'@vitest/utils@2.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 2.1.9
|
||||||
|
loupe: 3.2.1
|
||||||
|
tinyrainbow: 1.2.0
|
||||||
|
|
||||||
'@volar/language-core@1.11.1':
|
'@volar/language-core@1.11.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@volar/source-map': 1.11.1
|
'@volar/source-map': 1.11.1
|
||||||
@ -7387,7 +7526,7 @@ snapshots:
|
|||||||
'@vue/compiler-ssr': 3.5.13
|
'@vue/compiler-ssr': 3.5.13
|
||||||
'@vue/shared': 3.5.13
|
'@vue/shared': 3.5.13
|
||||||
estree-walker: 2.0.2
|
estree-walker: 2.0.2
|
||||||
magic-string: 0.30.14
|
magic-string: 0.30.17
|
||||||
postcss: 8.4.49
|
postcss: 8.4.49
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
@ -7671,6 +7810,8 @@ snapshots:
|
|||||||
|
|
||||||
array-union@2.1.0: {}
|
array-union@2.1.0: {}
|
||||||
|
|
||||||
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
astral-regex@2.0.0: {}
|
astral-regex@2.0.0: {}
|
||||||
|
|
||||||
async-validator@4.2.5: {}
|
async-validator@4.2.5: {}
|
||||||
@ -7834,6 +7975,14 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001684: {}
|
caniuse-lite@1.0.30001684: {}
|
||||||
|
|
||||||
|
chai@5.3.3:
|
||||||
|
dependencies:
|
||||||
|
assertion-error: 2.0.1
|
||||||
|
check-error: 2.1.3
|
||||||
|
deep-eql: 5.0.2
|
||||||
|
loupe: 3.2.1
|
||||||
|
pathval: 2.0.1
|
||||||
|
|
||||||
chalk@1.1.3:
|
chalk@1.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 2.2.1
|
ansi-styles: 2.2.1
|
||||||
@ -7849,6 +7998,8 @@ snapshots:
|
|||||||
|
|
||||||
chalk@5.3.0: {}
|
chalk@5.3.0: {}
|
||||||
|
|
||||||
|
check-error@2.1.3: {}
|
||||||
|
|
||||||
cheerio-select@2.1.0:
|
cheerio-select@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
boolbase: 1.0.0
|
boolbase: 1.0.0
|
||||||
@ -8221,6 +8372,8 @@ snapshots:
|
|||||||
|
|
||||||
decamelize@1.2.0: {}
|
decamelize@1.2.0: {}
|
||||||
|
|
||||||
|
deep-eql@5.0.2: {}
|
||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
default-passive-events@2.0.0: {}
|
default-passive-events@2.0.0: {}
|
||||||
@ -8589,6 +8742,8 @@ snapshots:
|
|||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
strip-final-newline: 3.0.0
|
strip-final-newline: 3.0.0
|
||||||
|
|
||||||
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
ext@1.7.0:
|
ext@1.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
type: 2.7.3
|
type: 2.7.3
|
||||||
@ -9186,6 +9341,8 @@ snapshots:
|
|||||||
|
|
||||||
loglevel@1.9.2: {}
|
loglevel@1.9.2: {}
|
||||||
|
|
||||||
|
loupe@3.2.1: {}
|
||||||
|
|
||||||
lru-cache@10.4.3: {}
|
lru-cache@10.4.3: {}
|
||||||
|
|
||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
@ -9526,6 +9683,8 @@ snapshots:
|
|||||||
|
|
||||||
pathe@2.0.3: {}
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
|
pathval@2.0.1: {}
|
||||||
|
|
||||||
perfect-debounce@1.0.0: {}
|
perfect-debounce@1.0.0: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
@ -9849,6 +10008,8 @@ snapshots:
|
|||||||
get-intrinsic: 1.2.4
|
get-intrinsic: 1.2.4
|
||||||
object-inspect: 1.13.3
|
object-inspect: 1.13.3
|
||||||
|
|
||||||
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
signal-exit@4.1.0: {}
|
signal-exit@4.1.0: {}
|
||||||
|
|
||||||
signature_pad@3.0.0-beta.4: {}
|
signature_pad@3.0.0-beta.4: {}
|
||||||
@ -9909,6 +10070,10 @@ snapshots:
|
|||||||
|
|
||||||
ssr-window@4.0.2: {}
|
ssr-window@4.0.2: {}
|
||||||
|
|
||||||
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
|
std-env@3.10.0: {}
|
||||||
|
|
||||||
steady-xml@0.1.0: {}
|
steady-xml@0.1.0: {}
|
||||||
|
|
||||||
string-argv@0.3.2: {}
|
string-argv@0.3.2: {}
|
||||||
@ -10083,6 +10248,8 @@ snapshots:
|
|||||||
|
|
||||||
tiny-warning@1.0.3: {}
|
tiny-warning@1.0.3: {}
|
||||||
|
|
||||||
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
tinyexec@0.3.1: {}
|
tinyexec@0.3.1: {}
|
||||||
|
|
||||||
tinyglobby@0.2.10:
|
tinyglobby@0.2.10:
|
||||||
@ -10090,6 +10257,12 @@ snapshots:
|
|||||||
fdir: 6.4.2(picomatch@4.0.2)
|
fdir: 6.4.2(picomatch@4.0.2)
|
||||||
picomatch: 4.0.2
|
picomatch: 4.0.2
|
||||||
|
|
||||||
|
tinypool@1.1.1: {}
|
||||||
|
|
||||||
|
tinyrainbow@1.2.0: {}
|
||||||
|
|
||||||
|
tinyspy@3.0.2: {}
|
||||||
|
|
||||||
to-regex-range@5.0.1:
|
to-regex-range@5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-number: 7.0.0
|
is-number: 7.0.0
|
||||||
@ -10159,7 +10332,7 @@ snapshots:
|
|||||||
escape-string-regexp: 5.0.0
|
escape-string-regexp: 5.0.0
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
local-pkg: 0.5.1
|
local-pkg: 0.5.1
|
||||||
magic-string: 0.30.14
|
magic-string: 0.30.17
|
||||||
mlly: 1.7.3
|
mlly: 1.7.3
|
||||||
pathe: 1.1.2
|
pathe: 1.1.2
|
||||||
picomatch: 4.0.2
|
picomatch: 4.0.2
|
||||||
@ -10297,6 +10470,23 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
global: 4.4.0
|
global: 4.4.0
|
||||||
|
|
||||||
|
vite-node@2.1.9(@types/node@20.17.9)(sass@1.81.0)(terser@5.36.0):
|
||||||
|
dependencies:
|
||||||
|
cac: 6.7.14
|
||||||
|
debug: 4.3.7
|
||||||
|
es-module-lexer: 1.5.4
|
||||||
|
pathe: 1.1.2
|
||||||
|
vite: 5.1.4(@types/node@20.17.9)(sass@1.81.0)(terser@5.36.0)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/node'
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- sass
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
|
||||||
vite-plugin-compression@0.5.1(vite@5.1.4(@types/node@20.17.9)(sass@1.81.0)(terser@5.36.0)):
|
vite-plugin-compression@0.5.1(vite@5.1.4(@types/node@20.17.9)(sass@1.81.0)(terser@5.36.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
@ -10366,6 +10556,40 @@ snapshots:
|
|||||||
sass: 1.81.0
|
sass: 1.81.0
|
||||||
terser: 5.36.0
|
terser: 5.36.0
|
||||||
|
|
||||||
|
vitest@2.1.9(@types/node@20.17.9)(sass@1.81.0)(terser@5.36.0):
|
||||||
|
dependencies:
|
||||||
|
'@vitest/expect': 2.1.9
|
||||||
|
'@vitest/mocker': 2.1.9(vite@5.1.4(@types/node@20.17.9)(sass@1.81.0)(terser@5.36.0))
|
||||||
|
'@vitest/pretty-format': 2.1.9
|
||||||
|
'@vitest/runner': 2.1.9
|
||||||
|
'@vitest/snapshot': 2.1.9
|
||||||
|
'@vitest/spy': 2.1.9
|
||||||
|
'@vitest/utils': 2.1.9
|
||||||
|
chai: 5.3.3
|
||||||
|
debug: 4.3.7
|
||||||
|
expect-type: 1.3.0
|
||||||
|
magic-string: 0.30.17
|
||||||
|
pathe: 1.1.2
|
||||||
|
std-env: 3.10.0
|
||||||
|
tinybench: 2.9.0
|
||||||
|
tinyexec: 0.3.1
|
||||||
|
tinypool: 1.1.1
|
||||||
|
tinyrainbow: 1.2.0
|
||||||
|
vite: 5.1.4(@types/node@20.17.9)(sass@1.81.0)(terser@5.36.0)
|
||||||
|
vite-node: 2.1.9(@types/node@20.17.9)(sass@1.81.0)(terser@5.36.0)
|
||||||
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 20.17.9
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- msw
|
||||||
|
- sass
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
|
||||||
vue-demi@0.14.10(vue@3.5.12(typescript@5.3.3)):
|
vue-demi@0.14.10(vue@3.5.12(typescript@5.3.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.12(typescript@5.3.3)
|
vue: 3.5.12(typescript@5.3.3)
|
||||||
@ -10477,6 +10701,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
siginfo: 2.0.0
|
||||||
|
stackback: 0.0.2
|
||||||
|
|
||||||
wildcard@1.1.2: {}
|
wildcard@1.1.2: {}
|
||||||
|
|
||||||
word-wrap@1.2.5: {}
|
word-wrap@1.2.5: {}
|
||||||
|
|||||||
71
game-admin/src/api/wanxiang/ad.spec.ts
Normal file
71
game-admin/src/api/wanxiang/ad.spec.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* ad.spec.ts —— 造梦运营·广告位 api 单测。重点钉 CRUD 的 HTTP method-per-op(create=post/update=put/delete=delete,
|
||||||
|
* 方法错配=真 bug)+ mock 兜底 + url/data 贴 frozen ad.yaml。跑 `npm test`。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/config/axios', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
|
||||||
|
|
||||||
|
import request from '@/config/axios'
|
||||||
|
import { getAdSlotPage, getAdSlot, createAdSlot, updateAdSlot, deleteAdSlot, getAdRevenuePage } from './ad'
|
||||||
|
|
||||||
|
const req = request as unknown as Record<'get' | 'post' | 'put' | 'delete', ReturnType<typeof vi.fn>>
|
||||||
|
|
||||||
|
describe('wanxiang/ad 广告位 api', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
afterEach(() => vi.unstubAllEnvs())
|
||||||
|
|
||||||
|
describe('mock 兜底', () => {
|
||||||
|
beforeEach(() => vi.stubEnv('VITE_APP_WANXIANG_MOCK', 'true'))
|
||||||
|
it('各接口走 mock,不打真实 request', async () => {
|
||||||
|
await getAdSlotPage({ pageNo: 1, pageSize: 10 })
|
||||||
|
await getAdSlot(1)
|
||||||
|
await createAdSlot({ slotId: 's1', type: 'rewarded', provider: 'mock', enabled: true })
|
||||||
|
await updateAdSlot({ id: 1, slotId: 's1', type: 'rewarded', provider: 'mock', enabled: false })
|
||||||
|
await deleteAdSlot(1)
|
||||||
|
await getAdRevenuePage({ pageNo: 1, pageSize: 10 })
|
||||||
|
expect(req.get).not.toHaveBeenCalled()
|
||||||
|
expect(req.post).not.toHaveBeenCalled()
|
||||||
|
expect(req.put).not.toHaveBeenCalled()
|
||||||
|
expect(req.delete).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('真实模式(method-per-op 钉死)', () => {
|
||||||
|
beforeEach(() => vi.stubEnv('VITE_APP_WANXIANG_MOCK', 'false'))
|
||||||
|
|
||||||
|
it('getAdSlotPage → GET /ad/slot/page', async () => {
|
||||||
|
req.get.mockResolvedValue({ list: [], total: 0 })
|
||||||
|
await getAdSlotPage({ pageNo: 1, pageSize: 10 })
|
||||||
|
expect(req.get).toHaveBeenCalledWith({ url: '/ad/slot/page', params: { pageNo: 1, pageSize: 10 } })
|
||||||
|
})
|
||||||
|
it('getAdSlot → GET /ad/slot/get?id=', async () => {
|
||||||
|
req.get.mockResolvedValue({})
|
||||||
|
await getAdSlot(7)
|
||||||
|
expect(req.get).toHaveBeenCalledWith({ url: '/ad/slot/get?id=7' })
|
||||||
|
})
|
||||||
|
it('createAdSlot → POST /ad/slot/create + data', async () => {
|
||||||
|
req.post.mockResolvedValue(1)
|
||||||
|
const data = { slotId: 's1', type: 'rewarded', provider: 'mock', enabled: true }
|
||||||
|
await createAdSlot(data)
|
||||||
|
expect(req.post).toHaveBeenCalledWith({ url: '/ad/slot/create', data })
|
||||||
|
})
|
||||||
|
it('updateAdSlot → PUT /ad/slot/update + data(非 POST)', async () => {
|
||||||
|
req.put.mockResolvedValue(true)
|
||||||
|
const data = { id: 1, slotId: 's1', type: 'rewarded', provider: 'mock', enabled: false }
|
||||||
|
await updateAdSlot(data)
|
||||||
|
expect(req.put).toHaveBeenCalledWith({ url: '/ad/slot/update', data })
|
||||||
|
expect(req.post).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('deleteAdSlot → DELETE /ad/slot/delete?id=', async () => {
|
||||||
|
req.delete.mockResolvedValue(true)
|
||||||
|
await deleteAdSlot(9)
|
||||||
|
expect(req.delete).toHaveBeenCalledWith({ url: '/ad/slot/delete?id=9' })
|
||||||
|
})
|
||||||
|
it('getAdRevenuePage → GET /ad/revenue/page', async () => {
|
||||||
|
req.get.mockResolvedValue({ list: [], total: 0 })
|
||||||
|
await getAdRevenuePage({ pageNo: 1, pageSize: 10 })
|
||||||
|
expect(req.get).toHaveBeenCalledWith({ url: '/ad/revenue/page', params: { pageNo: 1, pageSize: 10 } })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
44
game-admin/src/api/wanxiang/feed.spec.ts
Normal file
44
game-admin/src/api/wanxiang/feed.spec.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* feed.spec.ts —— 造梦运营·精选池 api 单测。验 mock 兜底 + 真实模式 url/method/data 贴 frozen feed.yaml。跑 `npm test`。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/config/axios', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
|
||||||
|
|
||||||
|
import request from '@/config/axios'
|
||||||
|
import { getFeaturedPage, saveFeatured } from './feed'
|
||||||
|
|
||||||
|
const req = request as unknown as { get: ReturnType<typeof vi.fn>; post: ReturnType<typeof vi.fn> }
|
||||||
|
|
||||||
|
describe('wanxiang/feed 精选 api', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
afterEach(() => vi.unstubAllEnvs())
|
||||||
|
|
||||||
|
describe('mock 兜底', () => {
|
||||||
|
beforeEach(() => vi.stubEnv('VITE_APP_WANXIANG_MOCK', 'true'))
|
||||||
|
it('getFeaturedPage → mock,不打真接口', async () => {
|
||||||
|
const r = await getFeaturedPage({ pageNo: 1, pageSize: 10 })
|
||||||
|
expect(r).toBeTruthy()
|
||||||
|
expect(req.get).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('saveFeatured → mock,不打真接口', async () => {
|
||||||
|
await saveFeatured({ gameId: 1, featured: true, boostWeight: 50 })
|
||||||
|
expect(req.post).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('真实模式', () => {
|
||||||
|
beforeEach(() => vi.stubEnv('VITE_APP_WANXIANG_MOCK', 'false'))
|
||||||
|
it('getFeaturedPage → GET /feed/featured/page + params', async () => {
|
||||||
|
req.get.mockResolvedValue({ list: [], total: 0 })
|
||||||
|
await getFeaturedPage({ pageNo: 1, pageSize: 10 })
|
||||||
|
expect(req.get).toHaveBeenCalledWith({ url: '/feed/featured/page', params: { pageNo: 1, pageSize: 10 } })
|
||||||
|
})
|
||||||
|
it('saveFeatured → POST /feed/featured + data', async () => {
|
||||||
|
req.post.mockResolvedValue(true)
|
||||||
|
const data = { gameId: 1, featured: true, boostWeight: 80 }
|
||||||
|
await saveFeatured(data)
|
||||||
|
expect(req.post).toHaveBeenCalledWith({ url: '/feed/featured', data })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
70
game-admin/src/api/wanxiang/project.spec.ts
Normal file
70
game-admin/src/api/wanxiang/project.spec.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* project.spec.ts —— 造梦运营·项目审核 api 单测(链路②关键门:发布→审核→入 feed)。
|
||||||
|
* 验:① mock 兜底(VITE_APP_WANXIANG_MOCK=true)两端均不打真实接口;② 真实模式 url/method/data 贴 frozen project.yaml;
|
||||||
|
* ③ 审核决策码 1通过/2拒绝/3下架 对齐契约 ReviewReqVO.decision(钉死最易错的决策映射)。跑 `npm test`。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
|
// mock 请求包络(project.ts: `import request from '@/config/axios'`,默认导出 {get,post,...})
|
||||||
|
vi.mock('@/config/axios', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
|
||||||
|
|
||||||
|
import request from '@/config/axios'
|
||||||
|
import { getProjectReviewPage, reviewProject, ReviewDecisionEnum, ProjectStatusEnum } from './project'
|
||||||
|
|
||||||
|
const req = request as unknown as { get: ReturnType<typeof vi.fn>; post: ReturnType<typeof vi.fn> }
|
||||||
|
|
||||||
|
describe('wanxiang/project 审核 api', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
afterEach(() => vi.unstubAllEnvs())
|
||||||
|
|
||||||
|
describe('mock 兜底(VITE_APP_WANXIANG_MOCK=true)', () => {
|
||||||
|
beforeEach(() => vi.stubEnv('VITE_APP_WANXIANG_MOCK', 'true'))
|
||||||
|
|
||||||
|
it('getProjectReviewPage → 本地 mock 分页{list,total},不打真实接口', async () => {
|
||||||
|
const r = await getProjectReviewPage({ pageNo: 1, pageSize: 10 })
|
||||||
|
expect(r.total).toBeGreaterThan(0)
|
||||||
|
expect(r.list.every((p) => p.status === ProjectStatusEnum.REVIEWING)).toBe(true)
|
||||||
|
expect(req.get).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reviewProject → mock 返 true,不打真实接口', async () => {
|
||||||
|
const r = await reviewProject({ gameId: 1001, versionId: 5001, decision: ReviewDecisionEnum.PASS })
|
||||||
|
expect(r).toBe(true)
|
||||||
|
expect(req.post).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('真实模式(VITE_APP_WANXIANG_MOCK!=true)', () => {
|
||||||
|
beforeEach(() => vi.stubEnv('VITE_APP_WANXIANG_MOCK', 'false'))
|
||||||
|
|
||||||
|
it('getProjectReviewPage → GET /project/review/page + params', async () => {
|
||||||
|
req.get.mockResolvedValue({ list: [], total: 0 })
|
||||||
|
await getProjectReviewPage({ pageNo: 2, pageSize: 5, status: 1 })
|
||||||
|
expect(req.get).toHaveBeenCalledWith({ url: '/project/review/page', params: { pageNo: 2, pageSize: 5, status: 1 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reviewProject(通过) → POST /project/review {decision:1}', async () => {
|
||||||
|
req.post.mockResolvedValue(true)
|
||||||
|
await reviewProject({ gameId: 1001, versionId: 5001, decision: ReviewDecisionEnum.PASS })
|
||||||
|
expect(req.post).toHaveBeenCalledWith({ url: '/project/review', data: { gameId: 1001, versionId: 5001, decision: 1 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reviewProject(拒绝) → POST {decision:2} + reason', async () => {
|
||||||
|
req.post.mockResolvedValue(true)
|
||||||
|
await reviewProject({ gameId: 1002, versionId: 5002, decision: ReviewDecisionEnum.REJECT, reason: '内容不当' })
|
||||||
|
expect(req.post).toHaveBeenCalledWith({ url: '/project/review', data: { gameId: 1002, versionId: 5002, decision: 2, reason: '内容不当' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reviewProject(下架) → POST {decision:3} + reason', async () => {
|
||||||
|
req.post.mockResolvedValue(true)
|
||||||
|
await reviewProject({ gameId: 1003, versionId: 5003, decision: ReviewDecisionEnum.OFFLINE, reason: '违规下架' })
|
||||||
|
expect(req.post).toHaveBeenCalledWith({ url: '/project/review', data: { gameId: 1003, versionId: 5003, decision: 3, reason: '违规下架' } })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('决策枚举钉死契约 ReviewReqVO.decision(1通过/2拒绝/3下架)', () => {
|
||||||
|
expect(ReviewDecisionEnum.PASS).toBe(1)
|
||||||
|
expect(ReviewDecisionEnum.REJECT).toBe(2)
|
||||||
|
expect(ReviewDecisionEnum.OFFLINE).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
43
game-admin/src/api/wanxiang/telemetry.spec.ts
Normal file
43
game-admin/src/api/wanxiang/telemetry.spec.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* telemetry.spec.ts —— 造梦运营·数据看板 api 单测。验 mock 兜底 + 真实模式 url/method 贴 frozen telemetry.yaml。跑 `npm test`。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/config/axios', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
|
||||||
|
|
||||||
|
import request from '@/config/axios'
|
||||||
|
import { getGameStatPage, getGameStat } from './telemetry'
|
||||||
|
|
||||||
|
const req = request as unknown as { get: ReturnType<typeof vi.fn> }
|
||||||
|
|
||||||
|
describe('wanxiang/telemetry 看板 api', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
afterEach(() => vi.unstubAllEnvs())
|
||||||
|
|
||||||
|
describe('mock 兜底', () => {
|
||||||
|
beforeEach(() => vi.stubEnv('VITE_APP_WANXIANG_MOCK', 'true'))
|
||||||
|
it('getGameStatPage → mock,不打真接口', async () => {
|
||||||
|
const r = await getGameStatPage({ pageNo: 1, pageSize: 10 })
|
||||||
|
expect(r).toBeTruthy()
|
||||||
|
expect(req.get).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('getGameStat → mock,不打真接口', async () => {
|
||||||
|
await getGameStat(1001)
|
||||||
|
expect(req.get).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('真实模式', () => {
|
||||||
|
beforeEach(() => vi.stubEnv('VITE_APP_WANXIANG_MOCK', 'false'))
|
||||||
|
it('getGameStatPage → GET /telemetry/game-stat/page + params', async () => {
|
||||||
|
req.get.mockResolvedValue({ list: [], total: 0 })
|
||||||
|
await getGameStatPage({ pageNo: 2, pageSize: 20 })
|
||||||
|
expect(req.get).toHaveBeenCalledWith({ url: '/telemetry/game-stat/page', params: { pageNo: 2, pageSize: 20 } })
|
||||||
|
})
|
||||||
|
it('getGameStat → GET /telemetry/game-stat/{id}', async () => {
|
||||||
|
req.get.mockResolvedValue({})
|
||||||
|
await getGameStat(1001)
|
||||||
|
expect(req.get).toHaveBeenCalledWith({ url: '/telemetry/game-stat/1001' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
62
game-admin/src/api/wanxiang/trade.spec.ts
Normal file
62
game-admin/src/api/wanxiang/trade.spec.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* trade.spec.ts —— 造梦运营·提现审核 api 单测(财务门:提现 通过/驳回)。
|
||||||
|
* 验:① mock 兜底两端不打真接口;② 真实模式 url/method/data 贴 frozen trade.yaml;
|
||||||
|
* ③ 审核决策码 1通过/2驳回 钉死契约 WithdrawAuditReqVO.decision(财务误判=资损,最需钉)。跑 `npm test`。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/config/axios', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
|
||||||
|
|
||||||
|
import request from '@/config/axios'
|
||||||
|
import { getWithdrawPage, auditWithdraw, WithdrawAuditDecisionEnum } from './trade'
|
||||||
|
|
||||||
|
const req = request as unknown as { get: ReturnType<typeof vi.fn>; post: ReturnType<typeof vi.fn> }
|
||||||
|
|
||||||
|
describe('wanxiang/trade 提现审核 api(财务门)', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
afterEach(() => vi.unstubAllEnvs())
|
||||||
|
|
||||||
|
describe('mock 兜底(VITE_APP_WANXIANG_MOCK=true)', () => {
|
||||||
|
beforeEach(() => vi.stubEnv('VITE_APP_WANXIANG_MOCK', 'true'))
|
||||||
|
|
||||||
|
it('getWithdrawPage → 本地 mock 分页{list,total},不打真实接口', async () => {
|
||||||
|
const r = await getWithdrawPage({ pageNo: 1, pageSize: 10 })
|
||||||
|
expect(r.total).toBeGreaterThan(0)
|
||||||
|
expect(Array.isArray(r.list)).toBe(true)
|
||||||
|
expect(req.get).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auditWithdraw → mock 返 true,不打真实接口', async () => {
|
||||||
|
const r = await auditWithdraw({ id: 1, decision: WithdrawAuditDecisionEnum.PASS })
|
||||||
|
expect(r).toBe(true)
|
||||||
|
expect(req.post).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('真实模式(VITE_APP_WANXIANG_MOCK!=true)', () => {
|
||||||
|
beforeEach(() => vi.stubEnv('VITE_APP_WANXIANG_MOCK', 'false'))
|
||||||
|
|
||||||
|
it('getWithdrawPage → GET /trade/withdraw/page + params', async () => {
|
||||||
|
req.get.mockResolvedValue({ list: [], total: 0 })
|
||||||
|
await getWithdrawPage({ pageNo: 1, pageSize: 10, status: 0 })
|
||||||
|
expect(req.get).toHaveBeenCalledWith({ url: '/trade/withdraw/page', params: { pageNo: 1, pageSize: 10, status: 0 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auditWithdraw(通过) → POST /trade/withdraw/audit {decision:1}', async () => {
|
||||||
|
req.post.mockResolvedValue(true)
|
||||||
|
await auditWithdraw({ id: 7001, decision: WithdrawAuditDecisionEnum.PASS })
|
||||||
|
expect(req.post).toHaveBeenCalledWith({ url: '/trade/withdraw/audit', data: { id: 7001, decision: 1 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auditWithdraw(驳回) → POST {decision:2} + rejectReason', async () => {
|
||||||
|
req.post.mockResolvedValue(true)
|
||||||
|
await auditWithdraw({ id: 7002, decision: WithdrawAuditDecisionEnum.REJECT, rejectReason: '账户信息不符' })
|
||||||
|
expect(req.post).toHaveBeenCalledWith({ url: '/trade/withdraw/audit', data: { id: 7002, decision: 2, rejectReason: '账户信息不符' } })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('提现决策枚举钉死契约 WithdrawAuditReqVO.decision(1通过/2驳回)', () => {
|
||||||
|
expect(WithdrawAuditDecisionEnum.PASS).toBe(1)
|
||||||
|
expect(WithdrawAuditDecisionEnum.REJECT).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -39,5 +39,5 @@
|
|||||||
"src/types/auto-imports.d.ts",
|
"src/types/auto-imports.d.ts",
|
||||||
"src/types/auto-components.d.ts"
|
"src/types/auto-components.d.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["dist", "target", "node_modules"]
|
"exclude": ["dist", "target", "node_modules", "src/**/*.spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
16
game-admin/vitest.config.ts
Normal file
16
game-admin/vitest.config.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* vitest 配置(game-admin)。环境 node(api 层测无需 DOM);@ 别名→src(对齐 vite.config)。
|
||||||
|
* 用途:wanxiang 运营 api 单测(审核台 review 等);跑 `npm test`。
|
||||||
|
*/
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.spec.ts'],
|
||||||
|
},
|
||||||
|
})
|
||||||
34
game-runtime/games/_wg1-gen/_adapter-probe/bundle.iife.js
Normal file
34
game-runtime/games/_wg1-gen/_adapter-probe/bundle.iife.js
Normal file
File diff suppressed because one or more lines are too long
@ -0,0 +1 @@
|
|||||||
|
{"inputs":{"src/core/plugin.js":{"bytes":44317,"imports":[],"format":"esm"},"src/plugins/runtime-probe/impl.js":{"bytes":16639,"imports":[],"format":"esm"},"host-dev/engine-caps.js":{"bytes":13897,"imports":[],"format":"esm"},"host-dev/engine-math.js":{"bytes":6155,"imports":[],"format":"esm"},"src/host/boot-game-host.js":{"bytes":28239,"imports":[{"path":"src/core/plugin.js","kind":"import-statement","original":"../core/plugin.js"},{"path":"src/plugins/runtime-probe/impl.js","kind":"import-statement","original":"../plugins/runtime-probe/impl.js"},{"path":"host-dev/engine-caps.js","kind":"import-statement","original":"../../host-dev/engine-caps.js"},{"path":"host-dev/engine-math.js","kind":"import-statement","original":"../../host-dev/engine-math.js"}],"format":"esm"},"src/plugins/collision/impl.js":{"bytes":24812,"imports":[],"format":"esm"},"src/plugins/physics-lite/impl.js":{"bytes":14507,"imports":[],"format":"esm"},"src/plugins/gamefeel/impl.js":{"bytes":21715,"imports":[],"format":"esm"},"src/plugins/particles-juice/impl.js":{"bytes":31830,"imports":[],"format":"esm"},"src/plugins/palette-post/impl.js":{"bytes":20240,"imports":[],"format":"esm"},"src/plugins/audio-music/impl.js":{"bytes":24659,"imports":[],"format":"esm"},"src/plugins/save-progress/impl.js":{"bytes":14608,"imports":[],"format":"esm"},"src/host/generic-host-config.js":{"bytes":7803,"imports":[{"path":"src/plugins/collision/impl.js","kind":"import-statement","original":"../plugins/collision/impl.js"},{"path":"src/plugins/physics-lite/impl.js","kind":"import-statement","original":"../plugins/physics-lite/impl.js"},{"path":"src/plugins/gamefeel/impl.js","kind":"import-statement","original":"../plugins/gamefeel/impl.js"},{"path":"src/plugins/particles-juice/impl.js","kind":"import-statement","original":"../plugins/particles-juice/impl.js"},{"path":"src/plugins/palette-post/impl.js","kind":"import-statement","original":"../plugins/palette-post/impl.js"},{"path":"src/plugins/audio-music/impl.js","kind":"import-statement","original":"../plugins/audio-music/impl.js"},{"path":"src/plugins/save-progress/impl.js","kind":"import-statement","original":"../plugins/save-progress/impl.js"}],"format":"esm"},"src/adapter/create-game-from-definition.js":{"bytes":10000,"imports":[],"format":"esm"},"games/_wg1-gen/_adapter-probe/generated-factory.js":{"bytes":1616,"imports":[{"path":"src/adapter/create-game-from-definition.js","kind":"import-statement","original":"../../../src/adapter/create-game-from-definition.js"}],"format":"esm"},"node_modules/littlejsengine/dist/littlejs.esm.js":{"bytes":640153,"imports":[],"format":"esm"},"games/_wg1-gen/_adapter-probe/entry-bundle.js":{"bytes":6230,"imports":[{"path":"src/host/boot-game-host.js","kind":"import-statement","original":"../../../src/host/boot-game-host.js"},{"path":"src/host/generic-host-config.js","kind":"import-statement","original":"../../../src/host/generic-host-config.js"},{"path":"games/_wg1-gen/_adapter-probe/generated-factory.js","kind":"import-statement","original":"./generated-factory.js"},{"path":"node_modules/littlejsengine/dist/littlejs.esm.js","kind":"dynamic-import","original":"littlejsengine"}],"format":"esm"}},"outputs":{"games/_wg1-gen/_adapter-probe/bundle.iife.js":{"imports":[],"exports":[],"entryPoint":"games/_wg1-gen/_adapter-probe/entry-bundle.js","inputs":{"node_modules/littlejsengine/dist/littlejs.esm.js":{"bytesInOutput":164138},"games/_wg1-gen/_adapter-probe/entry-bundle.js":{"bytesInOutput":507},"src/core/plugin.js":{"bytesInOutput":8989},"src/plugins/runtime-probe/impl.js":{"bytesInOutput":2794},"host-dev/engine-caps.js":{"bytesInOutput":1597},"host-dev/engine-math.js":{"bytesInOutput":498},"src/host/boot-game-host.js":{"bytesInOutput":5502},"src/plugins/collision/impl.js":{"bytesInOutput":1605},"src/plugins/physics-lite/impl.js":{"bytesInOutput":115},"src/plugins/gamefeel/impl.js":{"bytesInOutput":2533},"src/plugins/particles-juice/impl.js":{"bytesInOutput":4164},"src/plugins/palette-post/impl.js":{"bytesInOutput":4216},"src/plugins/audio-music/impl.js":{"bytesInOutput":6001},"src/plugins/save-progress/impl.js":{"bytesInOutput":3933},"src/host/generic-host-config.js":{"bytesInOutput":697},"src/adapter/create-game-from-definition.js":{"bytesInOutput":3237},"games/_wg1-gen/_adapter-probe/generated-factory.js":{"bytesInOutput":728}},"bytes":211825}}}
|
||||||
77
game-runtime/games/_wg1-gen/_adapter-probe/entry-bundle.js
Normal file
77
game-runtime/games/_wg1-gen/_adapter-probe/entry-bundle.js
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* entry-bundle.template.js —— W-G1 通用 bundle 入口模板(逐字拷进每个生成 game 目录为 entry-bundle.js)
|
||||||
|
* owner:W-G1 lane | 消费方:esbuild --global-name=__GameBundle → P1 宿主 bootGameHost 真玩
|
||||||
|
*
|
||||||
|
* ════════════════════════════════════════════════════════════════════════════
|
||||||
|
* 【职责】把「生成的游戏工厂 + 通用宿主 + 标准 7 插件 wiring + 引擎」汇成一个 iife 全局 __GameBundle。
|
||||||
|
* 产出靶契约(冻结·逐字对齐 entry-bundle.js / game-package.schema.json engineBundle):
|
||||||
|
* iife 顶层全局名固定 **__GameBundle**;宿主从 window.__GameBundle 取 GameHostFactory 与 bootGameHost。
|
||||||
|
*
|
||||||
|
* 【最大化复用·最小新代码】
|
||||||
|
* - 宿主:复用 src/host/boot-game-host.js 的通用 bootGameHost(不改)。
|
||||||
|
* - 插件 wiring:用官方 L0 通用装载壳 src/host/generic-host-config.js 的 buildGenericHostConfig(标准 7 插件 + 偏序 + 视口 + 工厂 wiring,与官方 entry-generic.js 同一份),
|
||||||
|
* override factory 为本目录生成的工厂 → 生成游戏跑在【官方 generic 标准装配】上。
|
||||||
|
* - 注:本入口 = 官方 entry-generic.js 的「harness 可取证」超集——多透传 recHook/onReady 供 CDP 八门(F门真接线);
|
||||||
|
* studio 渠道调 bootGameHost({canvas,seed}) 时 recHook 为空=no-op,feed 照常(一份 bundle 同时服务 feed + 取证门)。
|
||||||
|
* - 引擎:Option C 动态 import(node import 本入口不触引擎,保 import-safe;esbuild 把引擎内联进单文件 iife)。
|
||||||
|
*
|
||||||
|
* 【Q4 铁律】本入口是「集成段 host」唯一引擎 import 落点(动态 import 后经 opts.engine 注入);
|
||||||
|
* ./factory.js 游戏工厂零引擎 import,能力一律经 boot.ctx.getEngine()。
|
||||||
|
* ════════════════════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 通用宿主(引擎骨架参数化;本身零引擎 import,引擎经 opts.engine 注入)。
|
||||||
|
import { bootGameHost as bootGameHostCore } from '../../../src/host/boot-game-host.js';
|
||||||
|
// 标准插件装配(官方 L0 通用装载壳 buildGenericHostConfig:collision/physics/gamefeel/juice/palettePost/audioMusic/save + 偏序 + 390×844 视口;零 wanglanmei 专属)。
|
||||||
|
import { buildGenericHostConfig } from '../../../src/host/generic-host-config.js';
|
||||||
|
// 本目录生成的游戏工厂(默认导出 GameHostFactory,零引擎 import,A3 硬独立文件;官方靶名 generated-factory.js)。
|
||||||
|
import gameFactory from './generated-factory.js';
|
||||||
|
|
||||||
|
/** 【契约导出】GameHostFactory:宿主从 window.__GameBundle.GameHostFactory 取它(契约冻结)。= 本目录生成的工厂。 */
|
||||||
|
export const GameHostFactory = gameFactory;
|
||||||
|
// 默认导出同为 GameHostFactory(便于 import default 取用;与具名导出同一引用)。
|
||||||
|
export default gameFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 【P2/W-G1 boot 入口】供 host 页在浏览器内启动本 bundle(引擎由本 bundle 注入后驱动)。
|
||||||
|
* - 动态 import 引擎(Option C):调用时才求值,esbuild 已把引擎内联进本 bundle(无外部请求)。
|
||||||
|
* - engineMode 强制 'real'(W-G1 真玩取证一律引擎掌帧)。
|
||||||
|
* - 插件/视口/工厂 wiring 取自 buildRefHostConfig(与 ref 同一标准集),factory override 为生成工厂。
|
||||||
|
* - recHook / onReady 透传:供取证 harness 注入「引擎 call-ID 探针」与「internals(uncaught) 收集」(假绿守卫)。
|
||||||
|
*
|
||||||
|
* @param {Object} opts host 页传入入参。
|
||||||
|
* @param {HTMLCanvasElement} opts.canvas 页面 #game 画布(real 通道引擎自建 mainCanvas#game-engine,本 canvas 作占位/输入兜底)。
|
||||||
|
* @param {HTMLElement} [opts.statusEl] 状态行(可空)。
|
||||||
|
* @param {number} [opts.seed] 主随机种子(缺省固定值,保确定性)。
|
||||||
|
* @param {Function} [opts.recHook] 引擎 call-ID 取证钩子 id=>void(取证路径注入 window.__engineCalls.push;渠道产物不注)。
|
||||||
|
* @param {Function} [opts.onReady] (host, internals)=>void 就绪回调(取证收集 internals.uncaught 等)。
|
||||||
|
* @param {any} [opts.engine] 覆盖引擎名字空间(缺省动态 import littlejsengine)。
|
||||||
|
* @returns {Promise<object>} bootGameHost 的 host 句柄(frameCount/stepFrames/tap/do/state 等)。
|
||||||
|
*/
|
||||||
|
export async function bootGameHost(opts) {
|
||||||
|
const o = opts || {};
|
||||||
|
// 动态 import 引擎:求值时机延后到本函数被调用(浏览器运行期)。esbuild 内联,无外部请求。
|
||||||
|
const engine = o.engine || (await import('littlejsengine'));
|
||||||
|
// 主随机种子(缺省固定值,与 ref 取证同口径,保确定性可复现)。
|
||||||
|
const seed = (o.seed != null ? o.seed : 0x1234abcd) >>> 0;
|
||||||
|
// 官方 generic 标准装配(7 插件 + 偏序 + 视口 + 工厂 wiring;与 entry-generic.js 同一 buildGenericHostConfig)。
|
||||||
|
const cfg = buildGenericHostConfig({ seed });
|
||||||
|
// 调通用宿主:real 通道引擎掌帧;factory override 为本目录生成工厂;其余 wiring 取标准集。
|
||||||
|
return bootGameHostCore({
|
||||||
|
canvas: o.canvas,
|
||||||
|
statusEl: o.statusEl || null,
|
||||||
|
engine,
|
||||||
|
seed,
|
||||||
|
mode: o.mode || 'play', // W-G1 真玩=play
|
||||||
|
engineMode: 'real', // 一律引擎掌帧(real 通道)
|
||||||
|
viewport: cfg.viewport, // 390×844(与 ref 同,移动竖屏基准)
|
||||||
|
factory: gameFactory, // ★ override:本目录【生成的游戏】(其余 wiring 全复用 ref)
|
||||||
|
plugins: cfg.plugins, // 标准 7 插件实例集(经 opts.runtime.plugins 注入给工厂)
|
||||||
|
registerOrder: cfg.registerOrder, // 注册偏序(保六锚)
|
||||||
|
buildFactoryOpts: cfg.buildFactoryOpts, // 注入 runtime:{plugins,bundle,getRealAudioCtx}
|
||||||
|
recHook: o.recHook || null, // 取证:引擎 call-ID 探针(默认 no-op)
|
||||||
|
onReady: o.onReady || null, // 取证:收集 internals(uncaught)
|
||||||
|
});
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
165
game-runtime/games/_wg1-gen/_adapter-probe/evidence/verdict.json
Normal file
165
game-runtime/games/_wg1-gen/_adapter-probe/evidence/verdict.json
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
{
|
||||||
|
"gameId": "_adapter-probe",
|
||||||
|
"url": "http://localhost:4320/games/_wg1-gen/_adapter-probe/index.html",
|
||||||
|
"ts": null,
|
||||||
|
"guards": {
|
||||||
|
"A_boot": {
|
||||||
|
"pass": true
|
||||||
|
},
|
||||||
|
"C_frame": {
|
||||||
|
"pass": true,
|
||||||
|
"f0": 55,
|
||||||
|
"f1": 85,
|
||||||
|
"delta": 30
|
||||||
|
},
|
||||||
|
"I_control": {
|
||||||
|
"pass": true,
|
||||||
|
"skipped": "未声明 controlCheck(向后兼容)"
|
||||||
|
},
|
||||||
|
"D_render": {
|
||||||
|
"pass": true,
|
||||||
|
"bright": 60694,
|
||||||
|
"maxCh": 255
|
||||||
|
},
|
||||||
|
"E_live": {
|
||||||
|
"pass": true,
|
||||||
|
"distinctStates": 5,
|
||||||
|
"samples": 5
|
||||||
|
},
|
||||||
|
"B_uncaught": {
|
||||||
|
"pass": true,
|
||||||
|
"uncaught": []
|
||||||
|
},
|
||||||
|
"F_wiring": {
|
||||||
|
"pass": true,
|
||||||
|
"callCount": 6,
|
||||||
|
"sample": [
|
||||||
|
"particles.spawnEmitter",
|
||||||
|
"audio.synth.synthSfx",
|
||||||
|
"particles.spawnEmitter",
|
||||||
|
"audio.synth.synthSfx",
|
||||||
|
"particles.spawnEmitter",
|
||||||
|
"audio.synth.synthSfx"
|
||||||
|
],
|
||||||
|
"expected": [
|
||||||
|
"particles.",
|
||||||
|
"audio.synth"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"G_input": {
|
||||||
|
"pass": true,
|
||||||
|
"inputHash": "941173d7",
|
||||||
|
"controlHash": "57c7e2dc",
|
||||||
|
"atFrame": 214,
|
||||||
|
"ctrlFrame": 215
|
||||||
|
},
|
||||||
|
"H_progress": {
|
||||||
|
"pass": true,
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"path": "score",
|
||||||
|
"op": "increased",
|
||||||
|
"before": 0,
|
||||||
|
"after": 3,
|
||||||
|
"pass": true,
|
||||||
|
"why": "适配性真玩(自动点目标)→命中计分上升;点击失灵/_forensicsView 没更新则不升"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"latch": {
|
||||||
|
"pass": true,
|
||||||
|
"dwelledMs": 600,
|
||||||
|
"after": "gameover",
|
||||||
|
"reason": ""
|
||||||
|
},
|
||||||
|
"state0": {
|
||||||
|
"phase": "playing",
|
||||||
|
"score": 0,
|
||||||
|
"result": null,
|
||||||
|
"progress": 0,
|
||||||
|
"tickModel": "event",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"x": 112,
|
||||||
|
"y": 232,
|
||||||
|
"occupied": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"x": 202,
|
||||||
|
"y": 452,
|
||||||
|
"occupied": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"x": 292,
|
||||||
|
"y": 652,
|
||||||
|
"occupied": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"state1": {
|
||||||
|
"phase": "gameover",
|
||||||
|
"score": 3,
|
||||||
|
"result": "win",
|
||||||
|
"progress": 1,
|
||||||
|
"tickModel": "event",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"x": 112,
|
||||||
|
"y": 232,
|
||||||
|
"occupied": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"x": 202,
|
||||||
|
"y": 452,
|
||||||
|
"occupied": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"x": 292,
|
||||||
|
"y": 652,
|
||||||
|
"occupied": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pass": true,
|
||||||
|
"notes": [],
|
||||||
|
"firstPlay": {
|
||||||
|
"enabled": true,
|
||||||
|
"categoryDerived": "placement",
|
||||||
|
"playableMs": 51,
|
||||||
|
"playableThresholdMs": 2000,
|
||||||
|
"warmupMs": 188,
|
||||||
|
"firstFeedback": true,
|
||||||
|
"firstMove": {
|
||||||
|
"kind": "tap",
|
||||||
|
"x": 112,
|
||||||
|
"y": 232
|
||||||
|
},
|
||||||
|
"notes": [
|
||||||
|
"playableMs=预热后 warm 稳态,不覆盖 bundle 首加载退化;warmupMs 记冷启;真机 first-load 阈值校准留 staging(P2)。本门 v0 守得住卡死/装载失败退化,守不住大 bundle/慢首加载退化。"
|
||||||
|
],
|
||||||
|
"playableOk": true,
|
||||||
|
"playableScope": "warm",
|
||||||
|
"firstFeedbackDetail": {
|
||||||
|
"method": "single-trip",
|
||||||
|
"stateChanged": true,
|
||||||
|
"frameChanged": true,
|
||||||
|
"category": "placement",
|
||||||
|
"frameIsCausal": true,
|
||||||
|
"phase0": "playing",
|
||||||
|
"phase1": "playing",
|
||||||
|
"score0": 0,
|
||||||
|
"score1": 1
|
||||||
|
},
|
||||||
|
"coreLoopReached": true,
|
||||||
|
"coreLoopElapsedMs": 7026,
|
||||||
|
"coreLoopThresholdMs": 60000,
|
||||||
|
"pass": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* generated-factory.js —— E1 适配器探针(U1 build→九门 验证件,非生产游戏)
|
||||||
|
* owner:前端+引擎线(Mac)|计划 002 U1:验「声明式 gameDefinition 经适配器 → entry-bundle → build.mjs → 九门真玩」端到端。
|
||||||
|
*
|
||||||
|
* 默认导出 GameHostFactory = adaptGameDefinition(内联 SourceProject)。零引擎 import(适配器经 boot.ctx.getEngine)。
|
||||||
|
* 内联 source(与 src/adapter/fixtures/tap-target.source.json 同)保证 esbuild 自包含打包。
|
||||||
|
*/
|
||||||
|
import { adaptGameDefinition } from '../../../src/adapter/create-game-from-definition.js';
|
||||||
|
|
||||||
|
const SOURCE = {
|
||||||
|
schemaVersion: '1.0',
|
||||||
|
sourceHash: '0000000000000000000000000000000000000000000000000000000000000000',
|
||||||
|
profile: { tickModel: 'event', inputModel: 'discrete-choice', progressModel: 'metric' },
|
||||||
|
gameDefinition: {
|
||||||
|
entities: [
|
||||||
|
{ id: 't1', transform: { position: { x: 80, y: 200 } }, components: ['r-target'] },
|
||||||
|
{ id: 't2', transform: { position: { x: 170, y: 420 } }, components: ['r-target'] },
|
||||||
|
{ id: 't3', transform: { position: { x: 260, y: 620 } }, components: ['r-target'] },
|
||||||
|
],
|
||||||
|
components: [
|
||||||
|
{ id: 'r-target', kind: 'render', shape: 'rect', w: 64, h: 64, color: '#ffcc33' },
|
||||||
|
],
|
||||||
|
behaviors: [
|
||||||
|
{ id: 'b-tap', trigger: 'input', op: 'hit-nearest-target' },
|
||||||
|
],
|
||||||
|
scenes: [
|
||||||
|
{ id: 's1', entityRefs: ['t1', 't2', 't3'] },
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{ id: 'win', condition: 'score >= config.winScore', outcome: 'win' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
config: { winScore: 3, hitRadius: 60 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default adaptGameDefinition(SOURCE);
|
||||||
95
game-runtime/games/_wg1-gen/_adapter-probe/index.html
Normal file
95
game-runtime/games/_wg1-gen/_adapter-probe/index.html
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
index.template.html —— W-G1 通用 host 页模板(逐字拷进每个生成 game 目录为 index.html)
|
||||||
|
owner:W-G1 lane | 用法:
|
||||||
|
· 同目录 esbuild 产物 bundle.iife.js(globalName=__GameBundle)由本页 <script src> 加载;
|
||||||
|
· serve game-runtime 根,CDP 导航到 /games/_wg1-gen/<game>/index.html;
|
||||||
|
· 就绪标记 __genBooted(bootGameHost().then 置);real 引擎接管标记 __gameHostEngineInitFired(boot-game-host 内置);
|
||||||
|
· 假绿守卫钩子:window.__engineCalls(recHook 记每次引擎能力调用 call-ID)、window.__genInternals(onReady 收 uncaught[])。
|
||||||
|
布局收编(对齐 ref 教训):canvas 顶在 (0,0) 满幅 390×844;状态行 absolute overlay + pointer-events:none,
|
||||||
|
绝不下推/截触摸(否则 CDP 真输入坐标整体偏移 + 底部不可点)。引擎自建 mainCanvas#game-engine 叠加渲染。
|
||||||
|
-->
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<!-- 固定取证视口:390×844 移动竖屏基准;user-scalable=no 防缩放扰动像素哈希。 -->
|
||||||
|
<meta name="viewport" content="width=390, height=844, initial-scale=1, user-scalable=no" />
|
||||||
|
<title>W-G1 generated game</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; padding: 0; background: #111; color: #ccc;
|
||||||
|
font-family: -apple-system, "PingFang SC", monospace; overflow: hidden; }
|
||||||
|
#wrap { position: relative; width: 390px; margin: 0 auto; }
|
||||||
|
#game { width: 390px; height: 844px; background: #000; display: block; touch-action: none; }
|
||||||
|
/* 状态行默认隐藏:boot-game-host 的「engine RAF running…」调试浮层会被截图带进画面、污染 player 视觉判分;
|
||||||
|
harness 真玩门读 JS 变量(__genBooted/__genBootError/console)判定,不依赖此可视行,故隐藏不影响七门。
|
||||||
|
仅 BOOT ERROR 时由下方脚本显式显示,便于人工排查。 */
|
||||||
|
#status { display: none; position: absolute; top: 0; left: 0; right: 0; z-index: 2; pointer-events: none;
|
||||||
|
font-size: 12px; line-height: 1.4; min-height: 15px; text-align: center; word-break: break-all;
|
||||||
|
opacity: 0.85; text-shadow: 0 1px 2px rgba(0,0,0,0.8); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="wrap">
|
||||||
|
<div id="status">booting…</div>
|
||||||
|
<!-- 受控画布(opts.canvas 占位/输入兜底);real 引擎自建 #game-engine 叠加。 -->
|
||||||
|
<canvas id="game"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- esbuild 产物:iife 全局 __GameBundle(含 GameHostFactory + bootGameHost)。 -->
|
||||||
|
<script src="./bundle.iife.js"></script>
|
||||||
|
<script>
|
||||||
|
// 引导:取 __GameBundle.bootGameHost 启动;注入取证钩子;失败留痕 + 重抛(假绿守卫要看 console 零未捕获)。
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var statusEl = document.getElementById('status');
|
||||||
|
var canvas = document.getElementById('game');
|
||||||
|
// 假绿守卫C·真接线探针:每次引擎能力被调,recHook 记一条 call-ID 进此数组(证「真调引擎/插件库」非自绘伪装)。
|
||||||
|
window.__engineCalls = [];
|
||||||
|
function recHook(id) { try { window.__engineCalls.push(id); } catch (_) {} }
|
||||||
|
// 就绪/错误标记初值。
|
||||||
|
window.__genBooted = false;
|
||||||
|
window.__genBootError = null;
|
||||||
|
// W-G1 组C·首局门断言①「可玩≤2s」精确计时:页面侧 performance.now() 在「可交互首帧」(__genBooted=true) 打点,
|
||||||
|
// 写 window.__playableAtMs(= 自 navigationStart/timeOrigin 起的毫秒)。harness 直接读它,
|
||||||
|
// 规避 ① CDP 轮询 ~300ms 粒度抖动 ② waitBoot 固定 settle floor 对计时的污染——二者都会让 harness 侧计时失真。
|
||||||
|
window.__playableAtMs = null;
|
||||||
|
try {
|
||||||
|
var G = window.__GameBundle;
|
||||||
|
if (!G || typeof G.bootGameHost !== 'function') {
|
||||||
|
throw new Error('__GameBundle.bootGameHost 未找到(bundle.iife.js 未正确加载或 globalName 不符)');
|
||||||
|
}
|
||||||
|
G.bootGameHost({
|
||||||
|
canvas: canvas,
|
||||||
|
statusEl: statusEl,
|
||||||
|
recHook: recHook,
|
||||||
|
// onReady:收集 internals(uncaught[] 等)供假绿守卫B 读。
|
||||||
|
onReady: function (host, internals) {
|
||||||
|
window.__genHost = host;
|
||||||
|
window.__genInternals = internals || null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(function (host) {
|
||||||
|
window.__genHost = window.__genHost || host;
|
||||||
|
// 可测性别名:守卫H 经此读游戏可观测状态(host.state() 透传 game._forensicsView().state;游戏未实现则返回 {phase:'booting'})。
|
||||||
|
// 宿主侧挂载,工厂仍零 window;与 ref 在 onReady 挂 __refForensics 同范式。
|
||||||
|
window.__gameState = function () { try { return host && typeof host.state === 'function' ? host.state() : null; } catch (_) { return null; } };
|
||||||
|
// 首局门断言①打点:可交互首帧达成的墙钟(performance.now() 以 navigationStart 为 0 点),供 harness 读取判 ≤2s。
|
||||||
|
try { window.__playableAtMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : null; } catch (_) {}
|
||||||
|
window.__genBooted = true; // CDP 探活标记(区分「脚本没加载」与「boot 抛错」)
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
window.__genBooted = false;
|
||||||
|
window.__genBootError = String((e && e.message) || e);
|
||||||
|
if (statusEl) { statusEl.style.display = 'block'; statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError; }
|
||||||
|
throw e; // 重抛 → 'unhandledrejection' 被假绿守卫逮住
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
window.__genBooted = false;
|
||||||
|
window.__genBootError = String((e && e.message) || e);
|
||||||
|
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__genBootError;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19
game-runtime/games/_wg1-gen/_adapter-probe/play-spec.json
Normal file
19
game-runtime/games/_wg1-gen/_adapter-probe/play-spec.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"driver": {
|
||||||
|
"type": "tap-targets",
|
||||||
|
"steps": 12,
|
||||||
|
"stepMs": 300
|
||||||
|
},
|
||||||
|
"expectedEngineCallPrefixes": [
|
||||||
|
"particles.",
|
||||||
|
"audio.synth"
|
||||||
|
],
|
||||||
|
"assertAfterPlay": [
|
||||||
|
{
|
||||||
|
"path": "score",
|
||||||
|
"op": "increased",
|
||||||
|
"why": "适配性真玩(自动点目标)→命中计分上升;点击失灵/_forensicsView 没更新则不升"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expectLatch": true
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"gameId": "t2048",
|
"gameId": "t2048",
|
||||||
"url": "http://localhost:4323/games/_wg1-gen/t2048/index.html",
|
"url": "http://localhost:4330/games/_wg1-gen/t2048/index.html",
|
||||||
"ts": null,
|
"ts": null,
|
||||||
"guards": {
|
"guards": {
|
||||||
"A_boot": {
|
"A_boot": {
|
||||||
@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"C_frame": {
|
"C_frame": {
|
||||||
"pass": true,
|
"pass": true,
|
||||||
"f0": 49,
|
"f0": 54,
|
||||||
"f1": 79,
|
"f1": 84,
|
||||||
"delta": 30
|
"delta": 30
|
||||||
},
|
},
|
||||||
"I_control": {
|
"I_control": {
|
||||||
@ -18,7 +18,7 @@
|
|||||||
},
|
},
|
||||||
"D_render": {
|
"D_render": {
|
||||||
"pass": true,
|
"pass": true,
|
||||||
"bright": 81997,
|
"bright": 81998,
|
||||||
"maxCh": 249
|
"maxCh": 249
|
||||||
},
|
},
|
||||||
"E_live": {
|
"E_live": {
|
||||||
@ -45,13 +45,13 @@
|
|||||||
},
|
},
|
||||||
"G_input": {
|
"G_input": {
|
||||||
"pass": true,
|
"pass": true,
|
||||||
"inputHash": "19cc6df1",
|
"inputHash": "8215f608",
|
||||||
"controlHash": "45497b06",
|
"controlHash": "002e4428",
|
||||||
"atFrame": 2766,
|
"atFrame": 2794,
|
||||||
"ctrlFrame": -1
|
"ctrlFrame": -1
|
||||||
},
|
},
|
||||||
"H_progress": {
|
"H_progress": {
|
||||||
"pass": false,
|
"pass": true,
|
||||||
"checks": [
|
"checks": [
|
||||||
{
|
{
|
||||||
"path": "score",
|
"path": "score",
|
||||||
@ -62,11 +62,7 @@
|
|||||||
"why": "循环四向滑动→合并得分上升;不合并/不响应键则不升"
|
"why": "循环四向滑动→合并得分上升;不合并/不响应键则不升"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"latch": {
|
"latch": null,
|
||||||
"pass": false,
|
|
||||||
"reason": "真玩未到达 gameover 终态(终态不可达/无失败态/play-spec 未驱动到结束)",
|
|
||||||
"phaseNow": "playing"
|
|
||||||
},
|
|
||||||
"state0": {
|
"state0": {
|
||||||
"phase": "playing",
|
"phase": "playing",
|
||||||
"result": null,
|
"result": null,
|
||||||
@ -133,6 +129,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pass": false,
|
"pass": true,
|
||||||
"notes": []
|
"notes": [],
|
||||||
|
"firstPlay": {
|
||||||
|
"enabled": true,
|
||||||
|
"categoryDerived": "skill",
|
||||||
|
"playableMs": null,
|
||||||
|
"playableThresholdMs": 2000,
|
||||||
|
"warmupMs": null,
|
||||||
|
"firstFeedback": false,
|
||||||
|
"firstMove": null,
|
||||||
|
"notes": [
|
||||||
|
"未读到 __playableAtMs(页面侧打点缺失或装载未完成)"
|
||||||
|
],
|
||||||
|
"playableOk": false,
|
||||||
|
"coreLoopReached": false,
|
||||||
|
"coreLoopElapsedMs": 66679,
|
||||||
|
"coreLoopThresholdMs": 60000,
|
||||||
|
"coreLoopNote": "核心闭环达成但超 60s(66679ms)",
|
||||||
|
"pass": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -19,5 +19,6 @@
|
|||||||
"why": "循环四向滑动→合并得分上升;不合并/不响应键则不升"
|
"why": "循环四向滑动→合并得分上升;不合并/不响应键则不升"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expectLatch": true
|
"expectLatch": false,
|
||||||
|
"latchRelaxNote": "抗死锁体裁(2048 类):盲态 driver 难逼满盘→终态(满盘 gameover)不可达,但本体可玩。按 docs/agent-specs/2026-06-16-生成引擎-任务清单普查.md 立档规则放宽 latch——机制由 H(score↑) + E(board 变/活性) + G(输入有效) 三门兜底,不强求 latch 终态。harness 忽略本字段(仅自文档)。"
|
||||||
}
|
}
|
||||||
70
game-runtime/src/adapter/README.md
Normal file
70
game-runtime/src/adapter/README.md
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# E1 2D 适配器(声明式 gameDefinition → 可玩 GameInstance)
|
||||||
|
|
||||||
|
> 固定游戏架构的**引擎线消费侧**(plan `docs/plans/2026-06-17-002-feat-mac-engine-frontend-tier0-plan.md` U1/E1)。
|
||||||
|
> 把后端 SAA 图产的 `SourceProject.gameDefinition`(声明式)适配成命中 `../core/game-host.d.ts` 契约的可玩游戏——
|
||||||
|
> **换适配器即换引擎**(gameDefinition 维度无关)。
|
||||||
|
|
||||||
|
## 文件
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| `create-game-from-definition.js` | `adaptGameDefinition(source) → GameHostFactory`:声明式 gameDefinition → GameInstance |
|
||||||
|
| `scaffold-from-source.mjs` | `buildFactorySource` / `scaffoldFromSource`:SourceProject → 可被 `../../scripts/build.mjs` 编译的 game 目录工件(E2) |
|
||||||
|
| `fixtures/*.source.json` | 四族 archetype 的 SourceProject 样例 |
|
||||||
|
| `adapter.test.mjs` / `scaffold.test.mjs` | 零依赖 node 单测(`node --test src/adapter/*.test.mjs`) |
|
||||||
|
|
||||||
|
## 契约要点
|
||||||
|
|
||||||
|
- **输出 = GameInstance**:`{ init(boot), update(dt), render(g), destroy(), _forensicsView() }`(`../core/game-host.d.ts`)。
|
||||||
|
- **🔴 `_forensicsView()` 返 `{ seed?, state: () => snapshot }`**——`state` 是 thunk;宿主 `boot-game-host.js` 的 `host.state()` 调的是 `_forensicsView().state()`。**直接返裸 snapshot 会让 host.state() 抛错、九门读不到态**(必踩坑)。
|
||||||
|
- **受控面铁律**:零引擎 import;能力经 `boot.ctx.getEngine()/getInput()/time/random`;render 只画到 `boot.mainContext`(render(g) 入参);输入/绘制坐标 = 逻辑像素 390×844;禁 `Math.random/Date.now/addEventListener/rAF/new AudioContext/document/window`。
|
||||||
|
|
||||||
|
## 声明式词汇(本切片支持)
|
||||||
|
|
||||||
|
- **input op**(behavior.trigger='input'):`hit-nearest-target`(点最近目标计分)· `control-x`(实体 x 跟手=连续控制)· `flap`(竖直上冲)· `move-lr`(点左/右半 → 实体左/右移一步=方向式 steer)· `tap-safe`(点 safe 目标计分、点 `.hazards` 列出的 hazard 即记 miss=辨别/规避)。
|
||||||
|
- **update op**(trigger='update'):`drift-targets`(目标慢漂移)· `fall-and-catch`(下落物+接住=控制类)· `flappy-gap`(重力+穿缝=flappy)· `reach-goal`(实体 x 抵达目标 x 容差内 → 计分+目标重生=导航/收集)。
|
||||||
|
- **rule**:`{ condition: "<state字段> <op> <数字|config.键>", outcome: win|lose }`(序前优先);胜负 → `phase=gameover` + **latch 驻留**。可引用 state 字段:`score`/`misses`/**`elapsed`**(相对秒,init 起算)。**时间压力维度**(限时/倒计时)= `elapsed >= config.timeLimit → lose`(零新增 op,复用 rule 机制);⚠ 勿用 `t`(绝对单调时,禁作时限判)。
|
||||||
|
- **命中/接住/穿缝/抵达/胜负事件**经 `ctx.getEngine()` 发粒子+音效(满足九门 F 真接线 + game-feel)。
|
||||||
|
|
||||||
|
## 六类资产消费(E3,对齐 `contracts/agent-loop/source-project.schema.json` 冻结 assetSpec)
|
||||||
|
|
||||||
|
assetSpec = `{ id, category∈[sprite,character,effect,scene,ui,music], ref, url?, provider? }`——**引用/url,无内联数据**。消费侧(引擎线)按类:
|
||||||
|
|
||||||
|
| 类 | 消费路径 | 兜底(spec-only 无 url) |
|
||||||
|
|---|---|---|
|
||||||
|
| `sprite` / `character` | 实体 render 组件经 `.asset` 引用 → 有 url 浏览器 `new Image`+drawImage | 几何矩形(命中变暗) |
|
||||||
|
| `ui` / `effect` | 同上(经 render 组件)/ effect 亦由命中粒子反馈体现 | 几何 / 粒子 |
|
||||||
|
| `scene` | **顶层背景**(非实体引用):取首个 `category==='scene'`,有 url → drawImage 满幅 | 默认底色 `#0d1117` |
|
||||||
|
| `music` | **经 url 不可消费**——冻结受控面 `getEngine().audio` 仅 zzfx `synthSfx/synthSong`(合成,非文件播放),无播 url 音频能力 | 静默(跨线契约缺口:需 engine `audio.playUrl` 或 music assetSpec 携 zzfx 歌数据;见 `src/core/api.d.ts`) |
|
||||||
|
|
||||||
|
> **边界(KTD1)**:适配器只解释**声明式**部分;复杂 behavior 真逻辑由核心 code agent 产、经 build.mjs 编进 `__GameBundle`(非适配器解释)。适配器覆盖**简单声明式游戏的主要机制族**(已 6 族,见下)。
|
||||||
|
> **更复杂族(aim-fire 抛射瞄准 / drag-aiming 拖拽弹弓 / match-3 消除)**:虽受控面有 keydown/keyup/pointermove/pointerup 足以接其输入,但其抛射物理/旋转瞄准/读盘消除是**复杂 behavior 逻辑**——按 KTD1 归 code-agent `__GameBundle`,**不入声明式适配器**(强行内联=越界 + 过拟合)。
|
||||||
|
|
||||||
|
## 已验证六族(均过真九门 A–I + 首局门)
|
||||||
|
|
||||||
|
> 覆盖休闲玩法的主要机制维度:点击 / 连续控制 / 离散方向 steer / 物理 / 导航 / 辨别(负反馈)。
|
||||||
|
> **时间压力维度**(限时/倒计时)经 `elapsed >= config.timeLimit` 规则正交叠加于任一族(复用现有 driver,无新族),单测已验。
|
||||||
|
|
||||||
|
| fixture | 族 | driver | 关键门 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `tap-target.source.json` | 静态点击(clicker) | tap-targets | H 进展+latch |
|
||||||
|
| `moving-targets.source.json` | realtime 漂移 | tap-targets | E 活性 + 漂移目标命中 |
|
||||||
|
| `catch.source.json` | 控制类(接住) | paddle-intercept | **I 控制跟手** |
|
||||||
|
| `flappy.source.json` | flappy(重力穿缝) | flap-to-gap | 物理穿缝 |
|
||||||
|
| `seek.source.json` | 导航/收集(seek) | seek-x | 方向式 steer(`move-lr`)+ 抵达计分(`reach-goal`) |
|
||||||
|
| `avoid.source.json` | 辨别/规避(avoidance) | tap-targets(`safeOnly`) | 负反馈(`tap-safe`:点 safe 计分 / 点 hazard 即负)+ forensics `safe` 标记 |
|
||||||
|
|
||||||
|
## 加一个 op / archetype
|
||||||
|
|
||||||
|
1. 在 `SUPPORTED_INPUT_OPS` / `SUPPORTED_UPDATE_OPS` 注册 op 名。
|
||||||
|
2. 在 `onTap`(input)或 `update` 行为循环里加 op 解释(操作 `entById(id)` 取的实体)。
|
||||||
|
3. 关键事件经 `ctx.getEngine()` 发反馈(过 F 门)。
|
||||||
|
4. 若 driver 需读新实体态,在 `_forensicsView().state()` additive 暴露(如 `paddle`/`ball`/`bird`/`nextGap`)。
|
||||||
|
5. 加 fixture + 单测 + play-spec(driver/assertAfterPlay/expectLatch);`scaffoldFromSource` → build → 九门验。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test src/adapter/adapter.test.mjs src/adapter/scaffold.test.mjs # 零依赖单测
|
||||||
|
# 九门(集成段/mini-desktop 权威;本机快走查):scaffoldFromSource → scripts/build.mjs → games/_wg1-gen/_shared/serve-and-play.sh
|
||||||
|
```
|
||||||
438
game-runtime/src/adapter/adapter.test.mjs
Normal file
438
game-runtime/src/adapter/adapter.test.mjs
Normal file
@ -0,0 +1,438 @@
|
|||||||
|
/**
|
||||||
|
* adapter.test.mjs —— E1 2D 适配器零依赖 node 单测(node --test 直跑)
|
||||||
|
* owner:前端+引擎线(Mac)|计划 U1(最小贯穿实验)|运行:node --test src/adapter/adapter.test.mjs
|
||||||
|
*
|
||||||
|
* 验「声明式 gameDefinition → GameInstance」端到端可行(接缝形态验证):
|
||||||
|
* 1. init → phase=playing、目标建好、score=0;
|
||||||
|
* 2. render 画到入参 g(满幅底 + 目标 + HUD);
|
||||||
|
* 3. tap 命中目标 → score++ + occupied(输入因果,对应九门 G/H);
|
||||||
|
* 4. tap 落空 → 不计分;
|
||||||
|
* 5. 命中全部 → rule 评估 → phase=gameover + result=win + latch(终态驻留,对应九门 H latch);
|
||||||
|
* 6. destroy 幂等。
|
||||||
|
* 测试基建:createHostDevContext 真受控面(getInput/time)+ inputBridge._emit 注入合成 pointerdown + 录制版 2D 上下文。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
import { adaptGameDefinition } from './create-game-from-definition.js';
|
||||||
|
import { createHostDevContext } from '../core/plugin.js';
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const SOURCE = JSON.parse(readFileSync(join(HERE, 'fixtures', 'tap-target.source.json'), 'utf8'));
|
||||||
|
const DRIFT_SOURCE = JSON.parse(readFileSync(join(HERE, 'fixtures', 'moving-targets.source.json'), 'utf8'));
|
||||||
|
const CATCH_SOURCE = JSON.parse(readFileSync(join(HERE, 'fixtures', 'catch.source.json'), 'utf8'));
|
||||||
|
const FLAPPY_SOURCE = JSON.parse(readFileSync(join(HERE, 'fixtures', 'flappy.source.json'), 'utf8'));
|
||||||
|
const SEEK_SOURCE = JSON.parse(readFileSync(join(HERE, 'fixtures', 'seek.source.json'), 'utf8'));
|
||||||
|
const AVOID_SOURCE = JSON.parse(readFileSync(join(HERE, 'fixtures', 'avoid.source.json'), 'utf8'));
|
||||||
|
|
||||||
|
/** 录制版 2D 上下文:记 fillRect/fillText 调用,setter 容纳 fillStyle/font/strokeStyle 赋值。 */
|
||||||
|
function recordingCtx2d() {
|
||||||
|
const rec = { fillRects: [], texts: [] };
|
||||||
|
return {
|
||||||
|
set fillStyle(_v) {}, get fillStyle() { return ''; },
|
||||||
|
set strokeStyle(_v) {}, get strokeStyle() { return ''; },
|
||||||
|
set font(_v) {}, get font() { return ''; },
|
||||||
|
set lineWidth(_v) {}, get lineWidth() { return 1; },
|
||||||
|
fillRect(x, y, w, h) { rec.fillRects.push({ x, y, w, h }); },
|
||||||
|
fillText(s) { rec.texts.push(String(s)); },
|
||||||
|
strokeRect() {}, beginPath() {}, arc() {}, stroke() {}, moveTo() {}, lineTo() {},
|
||||||
|
_rec: rec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 造 boot 上下文(真受控面 + 录制 mainContext) + 暴露 inputBridge 注入合成输入。 */
|
||||||
|
function makeBoot() {
|
||||||
|
const { context, inputBridge } = createHostDevContext();
|
||||||
|
const g = recordingCtx2d();
|
||||||
|
return { boot: { ctx: context, mainContext: g, canvas: {}, seed: 1 }, g, inputBridge };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fixture 目标中心点(pos + w/2;w=h=64 → +32)。 */
|
||||||
|
function center(idx) {
|
||||||
|
const e = SOURCE.gameDefinition.entities[idx];
|
||||||
|
return { x: e.transform.position.x + 32, y: e.transform.position.y + 32 };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('init → playing, 目标建好, score=0', () => {
|
||||||
|
const game = adaptGameDefinition(SOURCE)();
|
||||||
|
const { boot } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
const v = game._forensicsView().state();
|
||||||
|
assert.equal(v.phase, 'playing');
|
||||||
|
assert.equal(v.score, 0);
|
||||||
|
assert.equal(v.targets.length, 3);
|
||||||
|
assert.equal(v.targets[0].occupied, false);
|
||||||
|
assert.equal(v.tickModel, 'event');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remaining:随 score 上升而下降, win 时归 0(对齐 HUD 进展契约)', () => {
|
||||||
|
const game = adaptGameDefinition(SOURCE)();
|
||||||
|
const { boot, inputBridge } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
assert.equal(game._forensicsView().state().remaining, 3); // winScore 3, score 0
|
||||||
|
inputBridge._emit('pointerdown', center(0));
|
||||||
|
assert.equal(game._forensicsView().state().remaining, 2);
|
||||||
|
inputBridge._emit('pointerdown', center(1));
|
||||||
|
inputBridge._emit('pointerdown', center(2));
|
||||||
|
assert.equal(game._forensicsView().state().remaining, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('render 画满幅底 + 目标 + HUD', () => {
|
||||||
|
const game = adaptGameDefinition(SOURCE)();
|
||||||
|
const { boot, g } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
game.render(g);
|
||||||
|
// 满幅底(1) + 脉冲条(1) + 3 目标 = ≥5 个 fillRect;HUD 含 "Score:" 文本
|
||||||
|
assert.ok(g._rec.fillRects.length >= 5, 'fillRect 调用应 ≥5');
|
||||||
|
assert.ok(g._rec.texts.some((s) => s.startsWith('Score:')), 'HUD 应有 Score 文本');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tap 命中目标 → score++ + occupied(输入因果 G/H)', () => {
|
||||||
|
const game = adaptGameDefinition(SOURCE)();
|
||||||
|
const { boot, inputBridge } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
const c0 = center(0);
|
||||||
|
inputBridge._emit('pointerdown', { x: c0.x, y: c0.y });
|
||||||
|
const v = game._forensicsView().state();
|
||||||
|
assert.equal(v.score, 1);
|
||||||
|
assert.equal(v.targets[0].occupied, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tap 落空(远离所有目标) → 不计分', () => {
|
||||||
|
const game = adaptGameDefinition(SOURCE)();
|
||||||
|
const { boot, inputBridge } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
inputBridge._emit('pointerdown', { x: 5, y: 820 });
|
||||||
|
assert.equal(game._forensicsView().state().score, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('命中全部 → rule win → phase=gameover + latch 终态驻留', () => {
|
||||||
|
const game = adaptGameDefinition(SOURCE)();
|
||||||
|
const { boot, inputBridge } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
for (let i = 0; i < 3; i++) { const c = center(i); inputBridge._emit('pointerdown', { x: c.x, y: c.y }); }
|
||||||
|
game.update(0.016); // 规则在 update 评估
|
||||||
|
let v = game._forensicsView().state();
|
||||||
|
assert.equal(v.score, 3);
|
||||||
|
assert.equal(v.phase, 'gameover');
|
||||||
|
assert.equal(v.result, 'win');
|
||||||
|
// latch:终态后再点 + update 不改终态
|
||||||
|
const c0 = center(0);
|
||||||
|
inputBridge._emit('pointerdown', { x: c0.x, y: c0.y });
|
||||||
|
game.update(0.016);
|
||||||
|
v = game._forensicsView().state();
|
||||||
|
assert.equal(v.phase, 'gameover');
|
||||||
|
assert.equal(v.score, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroy 幂等', () => {
|
||||||
|
const game = adaptGameDefinition(SOURCE)();
|
||||||
|
const { boot } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
game.destroy();
|
||||||
|
game.destroy();
|
||||||
|
assert.equal(game._forensicsView().state().targets.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drift-targets:update 后目标漂移(realtime/update 行为泛化)', () => {
|
||||||
|
const game = adaptGameDefinition(DRIFT_SOURCE)();
|
||||||
|
const { boot } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
const before = game._forensicsView().state().targets.map((t) => t.x);
|
||||||
|
game.update(0.1);
|
||||||
|
game.update(0.1);
|
||||||
|
const after = game._forensicsView().state().targets.map((t) => t.x);
|
||||||
|
assert.ok(after.some((x, i) => x !== before[i]), '至少一个目标 x 应漂移(update 行为生效)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drift 下 tap 当前(漂移后)位仍命中计分', () => {
|
||||||
|
const game = adaptGameDefinition(DRIFT_SOURCE)();
|
||||||
|
const { boot, inputBridge } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
game.update(0.1);
|
||||||
|
const t0 = game._forensicsView().state().targets[0]; // 当前漂移后中心
|
||||||
|
inputBridge._emit('pointerdown', { x: t0.x, y: t0.y });
|
||||||
|
assert.equal(game._forensicsView().state().score, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lose 规则:连点空达 maxMisses → phase=gameover + result=lose', () => {
|
||||||
|
const game = adaptGameDefinition(SOURCE)();
|
||||||
|
const { boot, inputBridge } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
for (let i = 0; i < 3; i++) inputBridge._emit('pointerdown', { x: 5, y: 820 }); // 3 次点空(远离所有目标)
|
||||||
|
game.update(0.016); // 规则评估
|
||||||
|
const v = game._forensicsView().state();
|
||||||
|
assert.equal(v.misses, 3);
|
||||||
|
assert.equal(v.phase, 'gameover');
|
||||||
|
assert.equal(v.result, 'lose');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('win 优先于 lose:先达 winScore 即 win(规则序)', () => {
|
||||||
|
const game = adaptGameDefinition(SOURCE)();
|
||||||
|
const { boot, inputBridge } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
const c = (i) => { const e = SOURCE.gameDefinition.entities[i]; return { x: e.transform.position.x + 32, y: e.transform.position.y + 32 }; };
|
||||||
|
for (let i = 0; i < 3; i++) { const p = c(i); inputBridge._emit('pointerdown', { x: p.x, y: p.y }); }
|
||||||
|
game.update(0.016);
|
||||||
|
assert.equal(game._forensicsView().state().result, 'win');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('时间压力族:elapsed >= config.timeLimit 规则 + 注入时钟 → 到时 lose(零新增 op,复用 rule 机制)', () => {
|
||||||
|
// 注入可控时钟,验 time-based 规则:elapsed 是【相对秒】(init 起点起算),非 state.t 的绝对单调值。
|
||||||
|
let clockMs = 0;
|
||||||
|
const { context } = createHostDevContext({ clock: () => clockMs });
|
||||||
|
const src = {
|
||||||
|
gameDefinition: {
|
||||||
|
entities: [{ id: 't', transform: { position: { x: 100, y: 200 } }, components: ['r'] }],
|
||||||
|
components: [{ id: 'r', kind: 'render', shape: 'rect', w: 50, h: 50, color: '#3cf' }],
|
||||||
|
behaviors: [{ id: 'b', trigger: 'input', op: 'hit-nearest-target' }],
|
||||||
|
scenes: [{ id: 's', entityRefs: ['t'] }],
|
||||||
|
rules: [{ id: 'timeout', condition: 'elapsed >= config.timeLimit', outcome: 'lose' }],
|
||||||
|
},
|
||||||
|
config: { timeLimit: 3 },
|
||||||
|
};
|
||||||
|
const game = adaptGameDefinition(src)();
|
||||||
|
clockMs = 1000; // init 在绝对 t=1s 记起点 → 验 elapsed 用相对差(否则绝对 1>... 立判,本例必假阴/假阳)
|
||||||
|
game.init({ ctx: context, mainContext: recordingCtx2d(), canvas: {}, seed: 1 });
|
||||||
|
clockMs = 3000; game.update(0.1); // elapsed=(3000-1000)/1000=2 < 3 → 未到时(若误用绝对 t=3 则会错误触发)
|
||||||
|
assert.equal(game._forensicsView().state().phase, 'playing', '未到时限应仍 playing');
|
||||||
|
assert.ok(Math.abs(game._forensicsView().state().elapsed - 2) < 1e-6, 'elapsed 应=相对 2 秒(非绝对 3)');
|
||||||
|
clockMs = 4500; game.update(0.1); // elapsed=3.5 >= 3 → 到时
|
||||||
|
const v = game._forensicsView().state();
|
||||||
|
assert.equal(v.phase, 'gameover');
|
||||||
|
assert.equal(v.result, 'lose');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E3:assets[] 入 forensics(通道可观测)+ spec-only 几何兜底不崩', () => {
|
||||||
|
const game = adaptGameDefinition(SOURCE)();
|
||||||
|
const { boot, g } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
const v = game._forensicsView().state();
|
||||||
|
assert.ok(Array.isArray(v.assets) && v.assets.length >= 1, 'forensics 应暴露 assets[]');
|
||||||
|
assert.equal(v.assets[0].category, 'sprite');
|
||||||
|
// spec-only(无 url)+ node 无 Image → 几何兜底:render 不崩、仍画目标矩形。
|
||||||
|
game.render(g);
|
||||||
|
assert.ok(g._rec.fillRects.length >= 5, '几何兜底仍画目标');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E3:scene 类资产 → 背景消费(node/无 url 默认底兜底不崩)+ assets[] 可观测 scene 类', () => {
|
||||||
|
const src = {
|
||||||
|
gameDefinition: {
|
||||||
|
entities: [{ id: 't', transform: { position: { x: 100, y: 200 } }, components: ['r'] }],
|
||||||
|
components: [{ id: 'r', kind: 'render', shape: 'rect', w: 50, h: 50, color: '#3cf' }],
|
||||||
|
behaviors: [{ id: 'b', trigger: 'input', op: 'hit-nearest-target' }],
|
||||||
|
scenes: [{ id: 's', entityRefs: ['t'] }], rules: [],
|
||||||
|
},
|
||||||
|
assets: [{ id: 'bg1', category: 'scene', ref: 'asset://bg1', url: 'https://example.com/bg.png' }],
|
||||||
|
config: {},
|
||||||
|
};
|
||||||
|
const game = adaptGameDefinition(src)();
|
||||||
|
const { boot, g } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
// node 无 Image → bgImg null → 默认底色兜底:render 不崩、仍画满幅底(D 门)。
|
||||||
|
assert.doesNotThrow(() => game.render(g));
|
||||||
|
assert.ok(g._rec.fillRects.length >= 1, '默认底色兜底仍画满幅');
|
||||||
|
assert.ok(game._forensicsView().state().assets.some((a) => a.category === 'scene'), 'forensics assets[] 应含 scene 类');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E3:scene 背景有 url + 已加载 → render 走 drawImage 满幅(浏览器路;Image stub 验分支)', () => {
|
||||||
|
// node 无 Image → 以最小 Image stub 模拟浏览器侧已加载图,验 render 走 drawImage(0,0,满幅) 分支(非兜底)。
|
||||||
|
const prevImage = globalThis.Image;
|
||||||
|
globalThis.Image = class { constructor() { this.complete = true; this.naturalWidth = 64; this.naturalHeight = 64; } set src(_v) {} };
|
||||||
|
try {
|
||||||
|
const src = {
|
||||||
|
gameDefinition: { entities: [], components: [], behaviors: [], scenes: [], rules: [] },
|
||||||
|
assets: [{ id: 'bg1', category: 'scene', ref: 'asset://bg1', url: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=' }],
|
||||||
|
config: {},
|
||||||
|
};
|
||||||
|
const game = adaptGameDefinition(src)();
|
||||||
|
const { boot } = makeBoot();
|
||||||
|
const g = recordingCtx2d();
|
||||||
|
const drawn = [];
|
||||||
|
g.drawImage = (_img, x, y, w, h) => drawn.push({ x, y, w, h });
|
||||||
|
game.init({ ctx: boot.ctx, mainContext: g, canvas: {}, seed: 1 });
|
||||||
|
game.render(g);
|
||||||
|
assert.equal(drawn.length, 1, '背景应 drawImage 一次');
|
||||||
|
assert.deepEqual(drawn[0], { x: 0, y: 0, w: 390, h: 844 }, '背景应画满幅 390×844');
|
||||||
|
} finally {
|
||||||
|
globalThis.Image = prevImage; // 还原全局,勿污染其它用例
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 控制类 archetype(paddle-intercept 族:连续控制 + 下落接住,exercises I 门) ──
|
||||||
|
test('控制类:forensics 暴露 paddle/ball(供 paddle-intercept driver)', () => {
|
||||||
|
const game = adaptGameDefinition(CATCH_SOURCE)();
|
||||||
|
const { boot } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
const v = game._forensicsView().state();
|
||||||
|
assert.ok(v.paddle && typeof v.paddle.x === 'number', '应暴露 paddle.x');
|
||||||
|
assert.ok(v.ball && typeof v.ball.x === 'number' && typeof v.ball.y === 'number', '应暴露 ball.x/y');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('control-x:输入移动 paddle.x 中心对齐触点', () => {
|
||||||
|
const game = adaptGameDefinition(CATCH_SOURCE)();
|
||||||
|
const { boot, inputBridge } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
inputBridge._emit('pointerdown', { x: 300, y: 790 });
|
||||||
|
assert.ok(Math.abs(game._forensicsView().state().paddle.x - 300) < 5, 'paddle 中心应≈触点 x');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fall-and-catch:球随 update 下落', () => {
|
||||||
|
const game = adaptGameDefinition(CATCH_SOURCE)();
|
||||||
|
const { boot } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
const y0 = game._forensicsView().state().ball.y;
|
||||||
|
game.update(0.1);
|
||||||
|
assert.ok(game._forensicsView().state().ball.y > y0, '球应下落');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('控制类:paddle 对齐下落球 → 接住计分', () => {
|
||||||
|
const game = adaptGameDefinition(CATCH_SOURCE)();
|
||||||
|
const { boot, inputBridge } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
const ball0x = game._forensicsView().state().ball.x;
|
||||||
|
inputBridge._emit('pointerdown', { x: ball0x, y: 790 }); // paddle 对齐首球 x
|
||||||
|
for (let i = 0; i < 30; i++) game.update(0.1); // 推进至球落到 paddle 层
|
||||||
|
assert.ok(game._forensicsView().state().score >= 1, '对齐后应接住至少 1 次');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── flappy(flap-to-gap 族:重力 + 振翅 + 穿缝,第 4 游戏族) ──
|
||||||
|
test('flappy:forensics 暴露 bird{y,vy} + nextGap{centerY}(供 flap-to-gap driver)', () => {
|
||||||
|
const game = adaptGameDefinition(FLAPPY_SOURCE)();
|
||||||
|
const { boot } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
const v = game._forensicsView().state();
|
||||||
|
assert.ok(v.bird && typeof v.bird.y === 'number' && typeof v.bird.vy === 'number', '应暴露 bird.y/vy');
|
||||||
|
assert.ok(v.nextGap && typeof v.nextGap.centerY === 'number', '应暴露 nextGap.centerY');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flappy:flap 给 bird 向上冲量(vy 变负)', () => {
|
||||||
|
const game = adaptGameDefinition(FLAPPY_SOURCE)();
|
||||||
|
const { boot, inputBridge } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
game.update(0.05); // 先因重力 vy>0
|
||||||
|
inputBridge._emit('pointerdown', { x: 195, y: 400 });
|
||||||
|
assert.ok(game._forensicsView().state().bird.vy < 0, 'flap 后 vy 应为负(上冲)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flappy:重力下落 + 缝隙滚过 → 穿缝计分或撞漏(机制生效)', () => {
|
||||||
|
const game = adaptGameDefinition(FLAPPY_SOURCE)();
|
||||||
|
const { boot } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
for (let i = 0; i < 60; i++) game.update(0.05); // 推进:bird 落 + 缝隙滚过 bird.x
|
||||||
|
const v = game._forensicsView().state();
|
||||||
|
assert.ok(v.score + v.misses >= 1, '缝隙至少越过一次(穿缝计分或撞=漏接)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── seek-x(导航/收集 族:方向式左右 steer + 抵达目标计分,第 5 游戏族) ──
|
||||||
|
test('seek-x:forensics 暴露 mover{x} + nextGoal{x}(供 seek-x driver)', () => {
|
||||||
|
const game = adaptGameDefinition(SEEK_SOURCE)();
|
||||||
|
const { boot } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
const v = game._forensicsView().state();
|
||||||
|
assert.ok(v.mover && typeof v.mover.x === 'number', '应暴露 mover.x');
|
||||||
|
assert.ok(v.nextGoal && typeof v.nextGoal.x === 'number', '应暴露 nextGoal.x');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seek-x:move-lr 方向式(点左半左移 / 点右半右移)', () => {
|
||||||
|
const game = adaptGameDefinition(SEEK_SOURCE)();
|
||||||
|
const { boot, inputBridge } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
const x0 = game._forensicsView().state().mover.x;
|
||||||
|
inputBridge._emit('pointerdown', { x: 30, y: 760 }); // 左半 → 左移
|
||||||
|
const x1 = game._forensicsView().state().mover.x;
|
||||||
|
assert.ok(x1 < x0, '点左半应使 mover 左移');
|
||||||
|
inputBridge._emit('pointerdown', { x: 360, y: 760 }); // 右半 → 右移
|
||||||
|
assert.ok(game._forensicsView().state().mover.x > x1, '点右半应使 mover 右移');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seek-x:steer 抵达目标(容差内)→ 计分(update 评估)', () => {
|
||||||
|
const game = adaptGameDefinition(SEEK_SOURCE)();
|
||||||
|
const { boot, inputBridge } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
// mover 中心 195、goal 中心 85、step 36、tol 34:连点左半 steer 向左,update 评估抵达。
|
||||||
|
for (let i = 0; i < 4; i++) { inputBridge._emit('pointerdown', { x: 30, y: 760 }); game.update(0.05); }
|
||||||
|
assert.ok(game._forensicsView().state().score >= 1, '抵达目标应计分');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 规避/辨别(tap-safe 族:点 safe 计分、点 hazard 即负,第 6 游戏族;exercises safeOnly driver) ──
|
||||||
|
test('规避:forensics 每个 target 暴露 safe(3 safe + 2 hazard)', () => {
|
||||||
|
const game = adaptGameDefinition(AVOID_SOURCE)();
|
||||||
|
const { boot } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
const ts = game._forensicsView().state().targets;
|
||||||
|
assert.equal(ts.filter((t) => t.safe === true).length, 3, '应 3 个 safe');
|
||||||
|
assert.equal(ts.filter((t) => t.safe === false).length, 2, '应 2 个 hazard');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tap-safe:点 safe → 计分;点 hazard → 记 miss 不计分', () => {
|
||||||
|
const game = adaptGameDefinition(AVOID_SOURCE)();
|
||||||
|
const { boot, inputBridge } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
inputBridge._emit('pointerdown', { x: 80, y: 180 }); // s1 中心(50+30,150+30)=safe
|
||||||
|
let v = game._forensicsView().state();
|
||||||
|
assert.equal(v.score, 1, '点 safe 应计分');
|
||||||
|
assert.equal(v.misses, 0);
|
||||||
|
inputBridge._emit('pointerdown', { x: 80, y: 460 }); // h1 中心(50+30,430+30)=hazard
|
||||||
|
v = game._forensicsView().state();
|
||||||
|
assert.equal(v.score, 1, '点 hazard 不计分');
|
||||||
|
assert.equal(v.misses, 1, '点 hazard 记一次 miss');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('规避:点 hazard → lose 规则(misses>=maxMisses)→ gameover lose', () => {
|
||||||
|
const game = adaptGameDefinition(AVOID_SOURCE)();
|
||||||
|
const { boot, inputBridge } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
inputBridge._emit('pointerdown', { x: 360, y: 460 }); // h2 中心 = hazard
|
||||||
|
game.update(0.016); // 规则评估
|
||||||
|
const v = game._forensicsView().state();
|
||||||
|
assert.equal(v.phase, 'gameover');
|
||||||
|
assert.equal(v.result, 'lose');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 鲁棒性:便宜模型产的 gameDefinition 常残缺/畸形,生产路适配器须优雅降级不崩 ──
|
||||||
|
test('鲁棒:空 gameDefinition → 不崩、phase playing、可渲染', () => {
|
||||||
|
const game = adaptGameDefinition({})();
|
||||||
|
const { boot, g } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
assert.equal(game._forensicsView().state().phase, 'playing');
|
||||||
|
assert.doesNotThrow(() => { game.update(0.016); game.render(g); });
|
||||||
|
assert.ok(g._rec.fillRects.length >= 1, '至少画背景');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('鲁棒:实体缺 transform / 组件引用不存在 → 默认值不崩', () => {
|
||||||
|
const src = { gameDefinition: { entities: [{ id: 'x', components: ['nope'] }], scenes: [{ id: 's', entityRefs: ['x'] }], rules: [] } };
|
||||||
|
const game = adaptGameDefinition(src)();
|
||||||
|
const { boot, g } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
assert.equal(typeof game._forensicsView().state().targets[0].x, 'number');
|
||||||
|
assert.doesNotThrow(() => game.render(g));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('鲁棒:畸形 rule condition → 不触发不崩', () => {
|
||||||
|
const src = { gameDefinition: { entities: [], scenes: [], rules: [{ id: 'r', condition: '乱七八糟', outcome: 'win' }] }, config: {} };
|
||||||
|
const game = adaptGameDefinition(src)();
|
||||||
|
const { boot } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
game.update(0.016);
|
||||||
|
assert.equal(game._forensicsView().state().phase, 'playing'); // 畸形条件不误触发 win
|
||||||
|
});
|
||||||
|
|
||||||
|
test('鲁棒:不支持的 behavior op → 忽略不崩', () => {
|
||||||
|
const src = { gameDefinition: { entities: [], scenes: [], rules: [], behaviors: [{ id: 'b', trigger: 'input', op: 'unknown-op' }] }, config: {} };
|
||||||
|
const game = adaptGameDefinition(src)();
|
||||||
|
const { boot, inputBridge } = makeBoot();
|
||||||
|
game.init(boot);
|
||||||
|
assert.doesNotThrow(() => inputBridge._emit('pointerdown', { x: 100, y: 100 }));
|
||||||
|
assert.equal(game._forensicsView().state().score, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('鲁棒:render/update 在 init 前调 → 不崩(防御阶段错)', () => {
|
||||||
|
const game = adaptGameDefinition(SOURCE)();
|
||||||
|
const { g } = makeBoot();
|
||||||
|
assert.doesNotThrow(() => { game.update(0.016); game.render(g); });
|
||||||
|
});
|
||||||
405
game-runtime/src/adapter/create-game-from-definition.js
Normal file
405
game-runtime/src/adapter/create-game-from-definition.js
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* create-game-from-definition.js —— E1 2D 适配器(U1 最小贯穿实验切片)
|
||||||
|
* owner:前端+引擎线(Mac)|计划:docs/plans/2026-06-17-002-feat-mac-engine-frontend-tier0-plan.md U1
|
||||||
|
*
|
||||||
|
* 【做什么】把固定架构的声明式 `SourceProject.gameDefinition`(contracts/agent-loop/source-project.schema.json)
|
||||||
|
* 适配成命中 game-host.d.ts `GameHostFactory`→`GameInstance` 契约的可玩实例:
|
||||||
|
* entities → 渲染对象 · components(render) → 画法 · behaviors → 逻辑 · scenes → 关卡构成 · rules → 胜负 · config → 平衡参数。
|
||||||
|
*
|
||||||
|
* 【边界(KTD1,跨线已锁方向)】本切片是**声明式解释器**:解释 entities/components/scenes/rules/config
|
||||||
|
* + 一个**最小 behavior 词汇**(够单 behavior 游戏端到端过九门)。复杂 behavior 的真逻辑由 code agent 产、
|
||||||
|
* 经 build.mjs 编进 __GameBundle(非本适配器解释)——本切片只证「声明式路」端到端可行(接缝形态验证)。
|
||||||
|
*
|
||||||
|
* 【受控面铁律】零引擎 import;能力一律经 boot.ctx(getEngine 可能为 null,须容错);render 只画到入参 g(=boot.mainContext);
|
||||||
|
* 输入坐标 = 逻辑像素 x∈[0,390]/y∈[0,844];随机/时钟走 ctx.random/ctx.time(禁 Math.random/Date.now)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VIEWPORT_W = 390;
|
||||||
|
const VIEWPORT_H = 844;
|
||||||
|
|
||||||
|
/** 最小 behavior 词汇表(声明式路支持的 op;其余 op = 走 code-agent 逻辑码,本切片不解释)。 */
|
||||||
|
const SUPPORTED_INPUT_OPS = new Set(['hit-nearest-target', 'control-x', 'flap', 'move-lr', 'tap-safe']);
|
||||||
|
/** trigger=update op:drift-targets 漂移 · fall-and-catch 接住(控制类)· flappy-gap 穿缝(flappy)· reach-goal 抵达(seek-x 导航类)。 */
|
||||||
|
const SUPPORTED_UPDATE_OPS = new Set(['drift-targets', 'fall-and-catch', 'flappy-gap', 'reach-goal']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 极小安全条件求值(无 eval):解析 "<field> <op> <rhs>"。
|
||||||
|
* field ∈ state 数值字段(如 score);op ∈ >= > == <= <;rhs = 数字字面量 或 "config.<key>"。
|
||||||
|
* @returns {boolean} 解析失败/字段缺失 → false(不抛,胜负条件保守不触发)。
|
||||||
|
*/
|
||||||
|
function evalCondition(cond, state, config) {
|
||||||
|
if (typeof cond !== 'string') return false;
|
||||||
|
const parts = cond.trim().split(/\s+/);
|
||||||
|
if (parts.length !== 3) return false;
|
||||||
|
const [lhs, op, rhsRaw] = parts;
|
||||||
|
const lhsVal = state[lhs];
|
||||||
|
if (typeof lhsVal !== 'number') return false;
|
||||||
|
const rhsVal = rhsRaw.startsWith('config.') ? config[rhsRaw.slice('config.'.length)] : Number(rhsRaw);
|
||||||
|
if (typeof rhsVal !== 'number' || Number.isNaN(rhsVal)) return false;
|
||||||
|
switch (op) {
|
||||||
|
case '>=': return lhsVal >= rhsVal;
|
||||||
|
case '>': return lhsVal > rhsVal;
|
||||||
|
case '==': return lhsVal === rhsVal;
|
||||||
|
case '<=': return lhsVal <= rhsVal;
|
||||||
|
case '<': return lhsVal < rhsVal;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 适配 SourceProject(或裸 gameDefinition)→ GameHostFactory。
|
||||||
|
* @param {object} source SourceProject(含 .gameDefinition/.config/.assets/.profile)或裸 gameDefinition。
|
||||||
|
* @returns {(opts?: object) => import('../core/game-host.d.ts').GameInstance} GameHostFactory
|
||||||
|
*/
|
||||||
|
export function adaptGameDefinition(source) {
|
||||||
|
const src = source || {};
|
||||||
|
// 兼容:传整 SourceProject 取 .gameDefinition;传裸 gameDefinition 直接用。
|
||||||
|
const gd = src.gameDefinition || src || {};
|
||||||
|
const config = src.config || gd.config || {};
|
||||||
|
const profile = src.profile || {};
|
||||||
|
const entities = Array.isArray(gd.entities) ? gd.entities : [];
|
||||||
|
const components = Array.isArray(gd.components) ? gd.components : [];
|
||||||
|
const scenes = Array.isArray(gd.scenes) ? gd.scenes : [];
|
||||||
|
const rules = Array.isArray(gd.rules) ? gd.rules : [];
|
||||||
|
const behaviors = Array.isArray(gd.behaviors) ? gd.behaviors : [];
|
||||||
|
// 规避/辨别族:tap-safe 行为经 .hazards 列出「点中即负」的 hazard 实体 id;其余可点目标为 safe。
|
||||||
|
const hazardIds = new Set(
|
||||||
|
behaviors.filter((b) => b && b.op === 'tap-safe' && Array.isArray(b.hazards)).flatMap((b) => b.hazards)
|
||||||
|
);
|
||||||
|
// E3:SourceProject.assets[](§5.1 顶层,六类 assetSpec);render 组件经 .asset 引用其 id。
|
||||||
|
const assets = Array.isArray(src.assets) ? src.assets : (Array.isArray(gd.assets) ? gd.assets : []);
|
||||||
|
const assetById = new Map(assets.map((a) => [a.id, a]));
|
||||||
|
// E3:scene 类资产 = 关卡背景(非实体引用,取首个 category==='scene')。有 url → 浏览器 new Image 画满幅;
|
||||||
|
// 否则默认底色兜底(§5.5 MVP spec-only)。这是 sprite/character/ui/effect(经 render 组件 .asset 消费)之外
|
||||||
|
// 唯一的「顶层资产消费」缺口——补齐后六类资产消费侧对齐冻结契约(music 经 url 不可消费,见 README 受控面说明)。
|
||||||
|
const sceneAssetSpec = assets.find((a) => a && a.category === 'scene') || null;
|
||||||
|
|
||||||
|
const compById = new Map(components.map((c) => [c.id, c]));
|
||||||
|
// 激活场景 = 首个 scene(无则全实体);激活实体按 entityRefs 过滤。
|
||||||
|
const activeScene = scenes[0];
|
||||||
|
const activeIds = activeScene && Array.isArray(activeScene.entityRefs)
|
||||||
|
? new Set(activeScene.entityRefs)
|
||||||
|
: null;
|
||||||
|
const activeEntities = activeIds ? entities.filter((e) => activeIds.has(e.id)) : entities;
|
||||||
|
|
||||||
|
// 预解析每个实体的 render 组件(声明式画法)。
|
||||||
|
function renderCompOf(entity) {
|
||||||
|
const ids = Array.isArray(entity.components) ? entity.components : [];
|
||||||
|
for (const cid of ids) {
|
||||||
|
const c = compById.get(cid);
|
||||||
|
if (c && c.kind === 'render') return c;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return function createGame(/* opts */) {
|
||||||
|
let ctx = null;
|
||||||
|
let sub = null;
|
||||||
|
let nowMs = 0;
|
||||||
|
let startMs = null; // 时间压力族:init 记游戏起点;state.t 是绝对单调时(禁作时限判),elapsed 才是相对秒
|
||||||
|
let bgImg = null; // E3:scene 类背景图(有 url 时浏览器侧 new Image;node/无 url → null 走默认底色)
|
||||||
|
// 运行态:目标(可点击实体)+ 计分 + phase(booting|playing|gameover,对齐 P0 latch 终态)。
|
||||||
|
/** @type {{id:string,x:number,y:number,w:number,h:number,color:string,hit:boolean}[]} */
|
||||||
|
let targets = [];
|
||||||
|
const state = { phase: 'booting', score: 0, misses: 0, result: null, t: 0, elapsed: 0 };
|
||||||
|
const entById = (id) => targets.find((t) => t && t.id === id); // 控制类按实体 id 取(paddle/faller)
|
||||||
|
|
||||||
|
/** init:建目标、订阅受控输入、置 playing。 */
|
||||||
|
function init(boot) {
|
||||||
|
ctx = boot.ctx;
|
||||||
|
startMs = ctx.time.nowMs(); // 时间压力族:记游戏起点 → elapsed=(now-start)/1000(相对秒),正确支撑 `elapsed >= config.timeLimit` 规则
|
||||||
|
// E3:预载 scene 背景图(有 url 才在浏览器侧 new Image;node 无 Image / 无 url → bgImg 保持 null,render 走默认底色)。
|
||||||
|
if (sceneAssetSpec && sceneAssetSpec.url && typeof Image !== 'undefined') {
|
||||||
|
try { bgImg = new Image(); bgImg.src = sceneAssetSpec.url; } catch (_) { bgImg = null; }
|
||||||
|
}
|
||||||
|
targets = activeEntities.map((e, i) => {
|
||||||
|
const rc = renderCompOf(e) || {};
|
||||||
|
const pos = (e.transform && e.transform.position) || { x: 0, y: 0 };
|
||||||
|
// E3:render 组件经 rc.asset 引用 sprite 资产;有真 url 则浏览器侧 new Image,否则几何兜底(§5.5 MVP)。
|
||||||
|
const asset = rc.asset ? assetById.get(rc.asset) : null;
|
||||||
|
let img = null;
|
||||||
|
if (asset && asset.url && typeof Image !== 'undefined') {
|
||||||
|
try { img = new Image(); img.src = asset.url; } catch (_) { img = null; } // 浏览器侧;node 无 Image → 走几何兜底
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: e.id,
|
||||||
|
x: pos.x, y: pos.y,
|
||||||
|
baseX: pos.x, baseY: pos.y, phase: i * 1.7, // drift-targets 用:漂移基准 + 错相
|
||||||
|
vy: 0, // flappy/重力类用:竖直速度
|
||||||
|
w: typeof rc.w === 'number' ? rc.w : 48,
|
||||||
|
h: typeof rc.h === 'number' ? rc.h : 48,
|
||||||
|
color: typeof rc.color === 'string' ? rc.color : '#3cf',
|
||||||
|
assetRef: asset ? asset.id : null,
|
||||||
|
img,
|
||||||
|
hit: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
sub = ctx.getInput().on('pointerdown', onTap); // 受控订阅(禁 addEventListener)
|
||||||
|
state.phase = 'playing';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 输入:解释 trigger=input 的 behavior(本切片支持 op=hit-nearest-target)。 */
|
||||||
|
function onTap(e) {
|
||||||
|
if (state.phase !== 'playing') return; // latch:终态后输入无效
|
||||||
|
nowMs = ctx.time.nowMs();
|
||||||
|
const px = e.x || 0, py = e.y || 0; // 已是逻辑像素,勿再乘视口
|
||||||
|
for (const b of behaviors) {
|
||||||
|
if (b.trigger !== 'input' || !SUPPORTED_INPUT_OPS.has(b.op)) continue;
|
||||||
|
if (b.op === 'hit-nearest-target') {
|
||||||
|
const radius = typeof config.hitRadius === 'number' ? config.hitRadius : 60;
|
||||||
|
let best = null, bestD2 = radius * radius;
|
||||||
|
for (const tg of targets) {
|
||||||
|
if (tg.hit) continue;
|
||||||
|
const cx = tg.x + tg.w / 2, cy = tg.y + tg.h / 2;
|
||||||
|
const d2 = (cx - px) * (cx - px) + (cy - py) * (cy - py);
|
||||||
|
if (d2 <= bestD2) { best = tg; bestD2 = d2; }
|
||||||
|
}
|
||||||
|
if (best) {
|
||||||
|
best.hit = true;
|
||||||
|
state.score += 1;
|
||||||
|
// 命中反馈(game-feel + 满足九门 F 真接线):引擎粒子+音效,经 ctx.getEngine 容错(null 不连坐)。
|
||||||
|
const eng = ctx.getEngine && ctx.getEngine();
|
||||||
|
if (eng) {
|
||||||
|
const cx = best.x + best.w / 2, cy = best.y + best.h / 2;
|
||||||
|
if (eng.particles) {
|
||||||
|
try { eng.particles.spawnEmitter({ pos: { x: cx, y: cy }, count: 20, speed: 120, particleTime: 0.5,
|
||||||
|
colorStart: { r: 1, g: 0.8, b: 0.2, a: 1 }, colorEnd: { r: 1, g: 0.3, b: 0, a: 0 } }); } catch (_) { /* 引擎缺省不连坐 */ }
|
||||||
|
}
|
||||||
|
if (eng.audio && eng.audio.synth) {
|
||||||
|
try { eng.audio.synth.synthSfx([1, 0.05, 260, , , 0.15, , 1.3]); } catch (_) { /* 同上 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.misses += 1; // 点空(半径内无目标)= 一次 miss,供 lose 规则(misses >= N)用
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (b.op === 'tap-safe') {
|
||||||
|
// 规避/辨别族:点中 safe 目标 → 计分(+占用+反馈);点中 hazard → 记 miss(交 lose 规则)。
|
||||||
|
// safeOnly driver 只点 safe===true → 确定性避负把核心循环跑透(对齐 play.cdp.cjs runTapTargets safeOnly)。
|
||||||
|
const radius = typeof config.hitRadius === 'number' ? config.hitRadius : 60;
|
||||||
|
let best = null, bestD2 = radius * radius;
|
||||||
|
for (const tg of targets) {
|
||||||
|
if (tg.hit) continue;
|
||||||
|
const cx = tg.x + tg.w / 2, cy = tg.y + tg.h / 2;
|
||||||
|
const d2 = (cx - px) * (cx - px) + (cy - py) * (cy - py);
|
||||||
|
if (d2 <= bestD2) { best = tg; bestD2 = d2; }
|
||||||
|
}
|
||||||
|
if (best) {
|
||||||
|
if (hazardIds.has(best.id)) {
|
||||||
|
state.misses += 1; // 点中 hazard = 一次失误(交 lose 规则 misses>=maxMisses)
|
||||||
|
} else {
|
||||||
|
best.hit = true;
|
||||||
|
state.score += 1;
|
||||||
|
const eng = ctx.getEngine && ctx.getEngine(); // 安全命中反馈(F 真接线;绿色调区别于普通命中)
|
||||||
|
if (eng) {
|
||||||
|
const cx = best.x + best.w / 2, cy = best.y + best.h / 2;
|
||||||
|
if (eng.particles) { try { eng.particles.spawnEmitter({ pos: { x: cx, y: cy }, count: 20, speed: 120, particleTime: 0.5, colorStart: { r: 0.4, g: 1, b: 0.4, a: 1 }, colorEnd: { r: 0.1, g: 0.5, b: 0.1, a: 0 } }); } catch (_) { /* 引擎缺省不连坐 */ } }
|
||||||
|
if (eng.audio && eng.audio.synth) { try { eng.audio.synth.synthSfx([1, 0.05, 300, , , 0.14, , 1.3]); } catch (_) { /* 同上 */ } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (b.op === 'control-x') {
|
||||||
|
// 控制类:把指定实体(paddle)x 中心对齐触点(连续控制;paddle-intercept driver 经此移挡板)。
|
||||||
|
const ent = entById(b.entity);
|
||||||
|
if (ent) ent.x = Math.max(0, Math.min(VIEWPORT_W - ent.w, px - ent.w / 2));
|
||||||
|
}
|
||||||
|
if (b.op === 'flap') {
|
||||||
|
// flappy 类:点击给控制体向上竖直冲量(y 向下为正 → 上冲为负)。
|
||||||
|
const ent = entById(b.entity);
|
||||||
|
if (ent) ent.vy = -(typeof b.impulse === 'number' ? b.impulse : 360);
|
||||||
|
}
|
||||||
|
if (b.op === 'move-lr') {
|
||||||
|
// seek-x 类:点左半 → 控制体左移一步,点右半 → 右移一步(方向式;seek-x driver 经此左右 steer)。
|
||||||
|
const ent = entById(b.entity);
|
||||||
|
if (ent) {
|
||||||
|
const step = typeof b.step === 'number' ? b.step : 36;
|
||||||
|
ent.x = px < VIEWPORT_W / 2 ? Math.max(0, ent.x - step) : Math.min(VIEWPORT_W - ent.w, ent.x + step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** update:刷钟 + 评估 rules(胜负 → phase=gameover + 终态驻留)。 */
|
||||||
|
function update(dt) {
|
||||||
|
if (ctx) { nowMs = ctx.time.nowMs(); state.t = nowMs / 1000; state.elapsed = startMs != null ? (nowMs - startMs) / 1000 : 0; }
|
||||||
|
if (state.phase !== 'playing') return; // 已终态:latch 驻留,不再评估
|
||||||
|
// trigger=update 行为(本切片:drift-targets 目标慢漂移,证 realtime/update 路)。
|
||||||
|
for (const b of behaviors) {
|
||||||
|
if (b.trigger !== 'update' || !SUPPORTED_UPDATE_OPS.has(b.op)) continue;
|
||||||
|
if (b.op === 'drift-targets') {
|
||||||
|
const amp = typeof b.amplitude === 'number' ? b.amplitude : 24;
|
||||||
|
const spd = typeof b.speed === 'number' ? b.speed : 0.8;
|
||||||
|
for (const tg of targets) {
|
||||||
|
tg.x = tg.baseX + amp * Math.sin(state.t * spd + tg.phase); // 慢漂移:driver 逐步读 _forensicsView 仍可命中
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (b.op === 'fall-and-catch') {
|
||||||
|
// 控制类:下落物每步下移;触底层 → 接住(中心距 ≤ 半宽和)计分,漏接计 miss;重生回顶 + 受控随机 x。
|
||||||
|
const faller = entById(b.faller), catcher = entById(b.catcher);
|
||||||
|
if (faller && catcher) {
|
||||||
|
faller.y += (typeof b.speed === 'number' ? b.speed : 300) * dt;
|
||||||
|
if (faller.y + faller.h >= catcher.y) {
|
||||||
|
const fcx = faller.x + faller.w / 2, ccx = catcher.x + catcher.w / 2;
|
||||||
|
if (Math.abs(fcx - ccx) <= catcher.w / 2 + faller.w / 2) {
|
||||||
|
state.score += 1; // 接住
|
||||||
|
const eng = ctx && ctx.getEngine && ctx.getEngine(); // 接住反馈(game-feel + 满足九门 F 真接线)
|
||||||
|
if (eng) {
|
||||||
|
if (eng.particles) { try { eng.particles.spawnEmitter({ pos: { x: ccx, y: catcher.y }, count: 18, speed: 110, particleTime: 0.5, colorStart: { r: 0.4, g: 0.7, b: 1, a: 1 }, colorEnd: { r: 0.1, g: 0.3, b: 0.6, a: 0 } }); } catch (_) { /* 引擎缺省不连坐 */ } }
|
||||||
|
if (eng.audio && eng.audio.synth) { try { eng.audio.synth.synthSfx([1, 0.05, 300, , , 0.14, , 1.2]); } catch (_) { /* 同上 */ } }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.misses += 1; // 漏接
|
||||||
|
}
|
||||||
|
faller.y = typeof b.topY === 'number' ? b.topY : 40;
|
||||||
|
const maxX = VIEWPORT_W - faller.w;
|
||||||
|
faller.x = (ctx && ctx.random) ? Math.max(0, Math.min(maxX, Math.floor(ctx.random.range(0, maxX)))) : Math.floor(maxX / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (b.op === 'flappy-gap') {
|
||||||
|
// flappy:bird 重力下落 + 缝隙左滚;缝隙越过 bird.x → 在缝内计分否则漏接,回收缝隙;出界=漏接+复位。
|
||||||
|
const bird = entById(b.bird), gap = entById(b.gap);
|
||||||
|
if (bird && gap) {
|
||||||
|
bird.vy = (bird.vy || 0) + (typeof b.gravity === 'number' ? b.gravity : 1100) * dt;
|
||||||
|
bird.y += bird.vy * dt;
|
||||||
|
const gapH = typeof b.gapHeight === 'number' ? b.gapHeight : 200;
|
||||||
|
const birdCx = bird.x + bird.w / 2, prevCx = gap.x + gap.w / 2;
|
||||||
|
gap.x -= (typeof b.scrollSpeed === 'number' ? b.scrollSpeed : 150) * dt;
|
||||||
|
if (prevCx >= birdCx && gap.x + gap.w / 2 < birdCx) { // 缝隙越过 bird
|
||||||
|
if (Math.abs((bird.y + bird.h / 2) - (gap.y + gap.h / 2)) <= gapH / 2) {
|
||||||
|
state.score += 1; // 穿缝
|
||||||
|
const eng = ctx && ctx.getEngine && ctx.getEngine(); // 穿缝反馈(F 门)
|
||||||
|
if (eng) {
|
||||||
|
if (eng.particles) { try { eng.particles.spawnEmitter({ pos: { x: birdCx, y: bird.y }, count: 16, speed: 100, particleTime: 0.45, colorStart: { r: 0.3, g: 1, b: 0.5, a: 1 }, colorEnd: { r: 0.1, g: 0.4, b: 0.2, a: 0 } }); } catch (_) { /* 不连坐 */ } }
|
||||||
|
if (eng.audio && eng.audio.synth) { try { eng.audio.synth.synthSfx([1, 0.05, 340, , , 0.13, , 1.1]); } catch (_) { /* 同 */ } }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.misses += 1; // 撞
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (gap.x + gap.w < 0) { gap.x = VIEWPORT_W; gap.y = (ctx && ctx.random) ? Math.floor(ctx.random.range(180, VIEWPORT_H - 220)) : VIEWPORT_H / 2; } // 回收
|
||||||
|
if (bird.y < 0 || bird.y > VIEWPORT_H) { state.misses += 1; bird.y = VIEWPORT_H / 2; bird.vy = 0; } // 出界=漏+复位
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (b.op === 'reach-goal') {
|
||||||
|
// seek-x 类:控制体 x 抵达目标 x(容差内)→ 计分 + 反馈 + 目标重生(受控随机 x)。
|
||||||
|
const mover = entById(b.mover), goal = entById(b.goal);
|
||||||
|
if (mover && goal && Math.abs((mover.x + mover.w / 2) - (goal.x + goal.w / 2)) <= (typeof b.tol === 'number' ? b.tol : 30)) {
|
||||||
|
state.score += 1;
|
||||||
|
const eng = ctx && ctx.getEngine && ctx.getEngine(); // 达成反馈(F 门)
|
||||||
|
if (eng) {
|
||||||
|
if (eng.particles) { try { eng.particles.spawnEmitter({ pos: { x: goal.x + goal.w / 2, y: goal.y }, count: 16, speed: 100, particleTime: 0.45, colorStart: { r: 1, g: 0.9, b: 0.3, a: 1 }, colorEnd: { r: 0.6, g: 0.4, b: 0.1, a: 0 } }); } catch (_) { /* 不连坐 */ } }
|
||||||
|
if (eng.audio && eng.audio.synth) { try { eng.audio.synth.synthSfx([1, 0.05, 320, , , 0.12, , 1.2]); } catch (_) { /* 同 */ } }
|
||||||
|
}
|
||||||
|
const maxX = VIEWPORT_W - goal.w;
|
||||||
|
goal.x = (ctx && ctx.random) ? Math.max(0, Math.min(maxX, Math.floor(ctx.random.range(0, maxX)))) : Math.floor(maxX / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of rules) {
|
||||||
|
if (evalCondition(r.condition, state, config)) {
|
||||||
|
if (r.outcome === 'win' || r.outcome === 'lose') {
|
||||||
|
state.phase = 'gameover';
|
||||||
|
state.result = r.outcome;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** render:画到入参 g(=mainContext)。满幅底(D 门)+ 目标(命中变暗)+ 计分 HUD + 时间脉冲(E 门活性)+ 终态字。 */
|
||||||
|
function render(g) {
|
||||||
|
// E3:scene 背景图已加载 → 画满幅;否则默认底色兜底(满幅底=D 门;spec-only 无 url 时走此路)。
|
||||||
|
if (bgImg && bgImg.complete && bgImg.naturalWidth > 0) {
|
||||||
|
try { g.drawImage(bgImg, 0, 0, VIEWPORT_W, VIEWPORT_H); } catch (_) { g.fillStyle = '#0d1117'; g.fillRect(0, 0, VIEWPORT_W, VIEWPORT_H); }
|
||||||
|
} else {
|
||||||
|
g.fillStyle = '#0d1117';
|
||||||
|
g.fillRect(0, 0, VIEWPORT_W, VIEWPORT_H);
|
||||||
|
}
|
||||||
|
// 时间脉冲条:保证无输入时帧间也有变化(E 门活性,走 ctx.time 非 Date.now)。
|
||||||
|
const pulse = 0.5 + 0.5 * Math.sin(state.t * 2);
|
||||||
|
g.fillStyle = 'rgba(80,160,255,' + (0.25 + 0.5 * pulse).toFixed(3) + ')';
|
||||||
|
g.fillRect(0, 0, VIEWPORT_W, 6);
|
||||||
|
for (const tg of targets) {
|
||||||
|
// E3:有已加载真图 → drawImage;否则几何兜底(命中变暗)。
|
||||||
|
if (tg.img && tg.img.complete && tg.img.naturalWidth > 0) {
|
||||||
|
try { g.drawImage(tg.img, tg.x, tg.y, tg.w, tg.h); } catch (_) { g.fillStyle = tg.color; g.fillRect(tg.x, tg.y, tg.w, tg.h); }
|
||||||
|
if (tg.hit) { g.fillStyle = 'rgba(20,30,40,0.6)'; g.fillRect(tg.x, tg.y, tg.w, tg.h); }
|
||||||
|
} else {
|
||||||
|
g.fillStyle = tg.hit ? '#2a3340' : tg.color;
|
||||||
|
g.fillRect(tg.x, tg.y, tg.w, tg.h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const winScore = typeof config.winScore === 'number' ? config.winScore : targets.length;
|
||||||
|
g.fillStyle = '#cdd9e5';
|
||||||
|
g.font = '20px monospace';
|
||||||
|
g.fillText('Score: ' + state.score + '/' + winScore, 20, 40);
|
||||||
|
g.fillText('剩余 ' + Math.max(0, winScore - state.score), 20, 66); // HUD 进展(对齐 prompt.py 硬规则:显示剩余目标数)
|
||||||
|
if (state.phase === 'gameover') {
|
||||||
|
g.fillStyle = state.result === 'win' ? '#4ade80' : '#f87171';
|
||||||
|
g.font = '32px monospace';
|
||||||
|
g.fillText(state.result === 'win' ? 'YOU WIN' : 'GAME OVER', 110, 430);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取证视图:契约 = { seed?, state:()=>snapshot }(boot-game-host.js:283 调 `_forensicsView().state()`;参照 tictactoe)。
|
||||||
|
* harness 九门(H 进展/latch)+ tap-targets driver 经 host.state() 读 snapshot。 */
|
||||||
|
function _forensicsView() {
|
||||||
|
return {
|
||||||
|
seed: 1,
|
||||||
|
state: () => {
|
||||||
|
const ws = typeof config.winScore === 'number' ? config.winScore : targets.length;
|
||||||
|
// 控制类暴露 paddle/ball(中心逻辑坐标)供 paddle-intercept driver 的 controlCheck.paddlePath/ballPath 读。
|
||||||
|
let paddle = null, ball = null, bird = null, nextGap = null, mover = null, nextGoal = null;
|
||||||
|
for (const b of behaviors) {
|
||||||
|
if (b.trigger === 'input' && b.op === 'control-x') { const e = entById(b.entity); if (e) paddle = { x: e.x + e.w / 2, y: e.y, w: e.w }; }
|
||||||
|
if (b.trigger === 'update' && b.op === 'fall-and-catch') { const e = entById(b.faller); if (e) ball = { x: e.x + e.w / 2, y: e.y + e.h / 2 }; }
|
||||||
|
if (b.trigger === 'update' && b.op === 'flappy-gap') {
|
||||||
|
const bd = entById(b.bird), gp = entById(b.gap);
|
||||||
|
if (bd) bird = { x: bd.x + bd.w / 2, y: bd.y + bd.h / 2, vy: bd.vy || 0 };
|
||||||
|
if (gp) nextGap = { centerY: gp.y + gp.h / 2, x: gp.x };
|
||||||
|
}
|
||||||
|
if (b.trigger === 'update' && b.op === 'reach-goal') {
|
||||||
|
const mv = entById(b.mover), gl = entById(b.goal);
|
||||||
|
if (mv) mover = { x: mv.x + mv.w / 2 };
|
||||||
|
if (gl) nextGoal = { x: gl.x + gl.w / 2 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
phase: state.phase,
|
||||||
|
score: state.score,
|
||||||
|
misses: state.misses,
|
||||||
|
result: state.result,
|
||||||
|
progress: ws ? state.score / ws : 0,
|
||||||
|
remaining: Math.max(0, ws - state.score), // 剩余到通关(对齐 prompt.py HUD 硬规则 + harness 读)
|
||||||
|
elapsed: state.elapsed || 0, // 时间压力族:相对秒(供 `elapsed >= config.timeLimit` 规则 + 倒计时 HUD;绝对 state.t 禁作时限)
|
||||||
|
tickModel: profile.tickModel || null,
|
||||||
|
assets: assets.map((a) => ({ id: a.id, category: a.category })), // E3 通道可观测(§10.2-3)
|
||||||
|
// tap-targets driver 读 targets[{idx,x,y,occupied,safe}] 自适应出招(逻辑坐标=中心点)。
|
||||||
|
// safe:非 hazard 即 true(规避族 safeOnly driver 据此只点安全目标;无 hazard 声明时全 true,向后兼容)。
|
||||||
|
targets: targets.map((tg, idx) => ({
|
||||||
|
idx, x: tg.x + tg.w / 2, y: tg.y + tg.h / 2, occupied: tg.hit, safe: !hazardIds.has(tg.id),
|
||||||
|
})),
|
||||||
|
...(paddle ? { paddle } : {}),
|
||||||
|
...(ball ? { ball } : {}),
|
||||||
|
...(bird ? { bird } : {}),
|
||||||
|
...(nextGap ? { nextGap } : {}),
|
||||||
|
...(mover ? { mover } : {}),
|
||||||
|
...(nextGoal ? { nextGoal } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** destroy:幂等卸载(取消订阅)。 */
|
||||||
|
function destroy() {
|
||||||
|
try { sub && sub.cancel(); } catch (_) { /* 幂等 */ }
|
||||||
|
sub = null;
|
||||||
|
targets = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, update, render, _forensicsView, destroy };
|
||||||
|
};
|
||||||
|
}
|
||||||
29
game-runtime/src/adapter/fixtures/avoid.source.json
Normal file
29
game-runtime/src/adapter/fixtures/avoid.source.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"sourceHash": "5555555555555555555555555555555555555555555555555555555555555555",
|
||||||
|
"profile": { "tickModel": "event", "inputModel": "discrete-choice", "progressModel": "metric" },
|
||||||
|
"gameDefinition": {
|
||||||
|
"entities": [
|
||||||
|
{ "id": "s1", "transform": { "position": { "x": 50, "y": 150 } }, "components": ["r-safe"] },
|
||||||
|
{ "id": "s2", "transform": { "position": { "x": 200, "y": 150 } }, "components": ["r-safe"] },
|
||||||
|
{ "id": "s3", "transform": { "position": { "x": 330, "y": 150 } }, "components": ["r-safe"] },
|
||||||
|
{ "id": "h1", "transform": { "position": { "x": 50, "y": 430 } }, "components": ["r-hazard"] },
|
||||||
|
{ "id": "h2", "transform": { "position": { "x": 330, "y": 430 } }, "components": ["r-hazard"] }
|
||||||
|
],
|
||||||
|
"components": [
|
||||||
|
{ "id": "r-safe", "kind": "render", "shape": "rect", "w": 60, "h": 60, "color": "#4ade80" },
|
||||||
|
{ "id": "r-hazard", "kind": "render", "shape": "rect", "w": 60, "h": 60, "color": "#f87171" }
|
||||||
|
],
|
||||||
|
"behaviors": [
|
||||||
|
{ "id": "b-tap", "trigger": "input", "op": "tap-safe", "hazards": ["h1", "h2"] }
|
||||||
|
],
|
||||||
|
"scenes": [
|
||||||
|
{ "id": "scene1", "entityRefs": ["s1", "s2", "s3", "h1", "h2"] }
|
||||||
|
],
|
||||||
|
"rules": [
|
||||||
|
{ "id": "win", "condition": "score >= config.winScore", "outcome": "win" },
|
||||||
|
{ "id": "lose", "condition": "misses >= config.maxMisses", "outcome": "lose" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"config": { "winScore": 3, "hitRadius": 50, "maxMisses": 1 }
|
||||||
|
}
|
||||||
27
game-runtime/src/adapter/fixtures/catch.source.json
Normal file
27
game-runtime/src/adapter/fixtures/catch.source.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"sourceHash": "2222222222222222222222222222222222222222222222222222222222222222",
|
||||||
|
"profile": { "tickModel": "realtime", "inputModel": "continuous", "progressModel": "metric" },
|
||||||
|
"gameDefinition": {
|
||||||
|
"entities": [
|
||||||
|
{ "id": "paddle", "transform": { "position": { "x": 155, "y": 790 } }, "components": ["r-paddle"] },
|
||||||
|
{ "id": "ball", "transform": { "position": { "x": 170, "y": 40 } }, "components": ["r-ball"] }
|
||||||
|
],
|
||||||
|
"components": [
|
||||||
|
{ "id": "r-paddle", "kind": "render", "shape": "rect", "w": 80, "h": 16, "color": "#4af" },
|
||||||
|
{ "id": "r-ball", "kind": "render", "shape": "rect", "w": 24, "h": 24, "color": "#fc4" }
|
||||||
|
],
|
||||||
|
"behaviors": [
|
||||||
|
{ "id": "b-control", "trigger": "input", "op": "control-x", "entity": "paddle" },
|
||||||
|
{ "id": "b-fall", "trigger": "update", "op": "fall-and-catch", "faller": "ball", "catcher": "paddle", "speed": 400, "topY": 40 }
|
||||||
|
],
|
||||||
|
"scenes": [
|
||||||
|
{ "id": "s1", "entityRefs": ["paddle", "ball"] }
|
||||||
|
],
|
||||||
|
"rules": [
|
||||||
|
{ "id": "win", "condition": "score >= config.winScore", "outcome": "win" },
|
||||||
|
{ "id": "lose", "condition": "misses >= config.maxMisses", "outcome": "lose" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"config": { "winScore": 3, "maxMisses": 8 }
|
||||||
|
}
|
||||||
27
game-runtime/src/adapter/fixtures/flappy.source.json
Normal file
27
game-runtime/src/adapter/fixtures/flappy.source.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"sourceHash": "3333333333333333333333333333333333333333333333333333333333333333",
|
||||||
|
"profile": { "tickModel": "realtime", "inputModel": "continuous", "progressModel": "metric" },
|
||||||
|
"gameDefinition": {
|
||||||
|
"entities": [
|
||||||
|
{ "id": "bird", "transform": { "position": { "x": 90, "y": 400 } }, "components": ["r-bird"] },
|
||||||
|
{ "id": "gap", "transform": { "position": { "x": 390, "y": 420 } }, "components": ["r-gap"] }
|
||||||
|
],
|
||||||
|
"components": [
|
||||||
|
{ "id": "r-bird", "kind": "render", "shape": "rect", "w": 28, "h": 28, "color": "#fc4" },
|
||||||
|
{ "id": "r-gap", "kind": "render", "shape": "rect", "w": 40, "h": 40, "color": "#4f8" }
|
||||||
|
],
|
||||||
|
"behaviors": [
|
||||||
|
{ "id": "b-flap", "trigger": "input", "op": "flap", "entity": "bird", "impulse": 360 },
|
||||||
|
{ "id": "b-flappy", "trigger": "update", "op": "flappy-gap", "bird": "bird", "gap": "gap", "gravity": 1100, "scrollSpeed": 150, "gapHeight": 200 }
|
||||||
|
],
|
||||||
|
"scenes": [
|
||||||
|
{ "id": "s1", "entityRefs": ["bird", "gap"] }
|
||||||
|
],
|
||||||
|
"rules": [
|
||||||
|
{ "id": "win", "condition": "score >= config.winScore", "outcome": "win" },
|
||||||
|
{ "id": "lose", "condition": "misses >= config.maxMisses", "outcome": "lose" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"config": { "winScore": 3, "maxMisses": 8 }
|
||||||
|
}
|
||||||
26
game-runtime/src/adapter/fixtures/moving-targets.source.json
Normal file
26
game-runtime/src/adapter/fixtures/moving-targets.source.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"sourceHash": "1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"profile": { "tickModel": "realtime", "inputModel": "discrete-choice", "progressModel": "metric" },
|
||||||
|
"gameDefinition": {
|
||||||
|
"entities": [
|
||||||
|
{ "id": "m1", "transform": { "position": { "x": 100, "y": 240 } }, "components": ["r-mov"] },
|
||||||
|
{ "id": "m2", "transform": { "position": { "x": 200, "y": 440 } }, "components": ["r-mov"] },
|
||||||
|
{ "id": "m3", "transform": { "position": { "x": 150, "y": 640 } }, "components": ["r-mov"] }
|
||||||
|
],
|
||||||
|
"components": [
|
||||||
|
{ "id": "r-mov", "kind": "render", "shape": "rect", "w": 60, "h": 60, "color": "#7cf" }
|
||||||
|
],
|
||||||
|
"behaviors": [
|
||||||
|
{ "id": "b-drift", "trigger": "update", "op": "drift-targets", "amplitude": 24, "speed": 0.8 },
|
||||||
|
{ "id": "b-tap", "trigger": "input", "op": "hit-nearest-target" }
|
||||||
|
],
|
||||||
|
"scenes": [
|
||||||
|
{ "id": "s1", "entityRefs": ["m1", "m2", "m3"] }
|
||||||
|
],
|
||||||
|
"rules": [
|
||||||
|
{ "id": "win", "condition": "score >= config.winScore", "outcome": "win" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"config": { "winScore": 3, "hitRadius": 60 }
|
||||||
|
}
|
||||||
26
game-runtime/src/adapter/fixtures/seek.source.json
Normal file
26
game-runtime/src/adapter/fixtures/seek.source.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"sourceHash": "4444444444444444444444444444444444444444444444444444444444444444",
|
||||||
|
"profile": { "tickModel": "realtime", "inputModel": "continuous", "progressModel": "metric" },
|
||||||
|
"gameDefinition": {
|
||||||
|
"entities": [
|
||||||
|
{ "id": "mover", "transform": { "position": { "x": 170, "y": 760 } }, "components": ["r-mover"] },
|
||||||
|
{ "id": "goal", "transform": { "position": { "x": 60, "y": 760 } }, "components": ["r-goal"] }
|
||||||
|
],
|
||||||
|
"components": [
|
||||||
|
{ "id": "r-mover", "kind": "render", "shape": "rect", "w": 50, "h": 30, "color": "#4af" },
|
||||||
|
{ "id": "r-goal", "kind": "render", "shape": "rect", "w": 50, "h": 50, "color": "#fc4" }
|
||||||
|
],
|
||||||
|
"behaviors": [
|
||||||
|
{ "id": "b-move", "trigger": "input", "op": "move-lr", "entity": "mover", "step": 36 },
|
||||||
|
{ "id": "b-reach", "trigger": "update", "op": "reach-goal", "mover": "mover", "goal": "goal", "tol": 34 }
|
||||||
|
],
|
||||||
|
"scenes": [
|
||||||
|
{ "id": "s1", "entityRefs": ["mover", "goal"] }
|
||||||
|
],
|
||||||
|
"rules": [
|
||||||
|
{ "id": "win", "condition": "score >= config.winScore", "outcome": "win" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"config": { "winScore": 3 }
|
||||||
|
}
|
||||||
29
game-runtime/src/adapter/fixtures/tap-target.source.json
Normal file
29
game-runtime/src/adapter/fixtures/tap-target.source.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"sourceHash": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"profile": { "tickModel": "event", "inputModel": "discrete-choice", "progressModel": "metric" },
|
||||||
|
"gameDefinition": {
|
||||||
|
"entities": [
|
||||||
|
{ "id": "t1", "transform": { "position": { "x": 80, "y": 200 } }, "components": ["r-target"] },
|
||||||
|
{ "id": "t2", "transform": { "position": { "x": 170, "y": 420 } }, "components": ["r-target"] },
|
||||||
|
{ "id": "t3", "transform": { "position": { "x": 260, "y": 620 } }, "components": ["r-target"] }
|
||||||
|
],
|
||||||
|
"components": [
|
||||||
|
{ "id": "r-target", "kind": "render", "shape": "sprite", "w": 64, "h": 64, "color": "#ffcc33", "asset": "spr-target" }
|
||||||
|
],
|
||||||
|
"behaviors": [
|
||||||
|
{ "id": "b-tap", "trigger": "input", "op": "hit-nearest-target" }
|
||||||
|
],
|
||||||
|
"scenes": [
|
||||||
|
{ "id": "s1", "entityRefs": ["t1", "t2", "t3"] }
|
||||||
|
],
|
||||||
|
"rules": [
|
||||||
|
{ "id": "win", "condition": "score >= config.winScore", "outcome": "win" },
|
||||||
|
{ "id": "lose", "condition": "misses >= config.maxMisses", "outcome": "lose" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"assets": [
|
||||||
|
{ "id": "spr-target", "category": "sprite", "ref": "mmx://target", "provider": "mmx-cli" }
|
||||||
|
],
|
||||||
|
"config": { "winScore": 3, "hitRadius": 60, "maxMisses": 3 }
|
||||||
|
}
|
||||||
59
game-runtime/src/adapter/scaffold-from-source.mjs
Normal file
59
game-runtime/src/adapter/scaffold-from-source.mjs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* scaffold-from-source.mjs —— E2「从 sourceProject 真构建」的工件落地(U2,plan 002)
|
||||||
|
* owner:前端+引擎线(Mac)
|
||||||
|
*
|
||||||
|
* 【做什么】把存储的 SourceProject 落成可被 build.mjs 编译的 game 目录工件:
|
||||||
|
* ① buildFactorySource(sourceProject) → generated-factory.js 文本(适配器 import + 内联 source + 默认导出工厂);
|
||||||
|
* ② scaffoldFromSource(...) → 写 generated-factory.js + 拷 entry-bundle/index 模板,返 build.mjs 入口路径。
|
||||||
|
*
|
||||||
|
* 【为什么】当前两路(worker/SAA)都直写 factorySrc(整工厂)、不消费 sourceProject;后端确定性 modify guard-pending
|
||||||
|
* 在「从 sourceProject 真构建」上(plan 001 U6 / plan 002 KTD2)。本模块 = 引擎线交付的那个落地工件:
|
||||||
|
* sourceProject →(本模块)→ generated-factory.js(entry-bundle 的固定 import 目标)→ build.mjs → __GameBundle。
|
||||||
|
* 端到端已由 _adapter-probe 真九门验证(commit 046454d9)。
|
||||||
|
*
|
||||||
|
* 【边界】纯文本生成 buildFactorySource 零 fs(可零依赖单测);scaffoldFromSource 做 fs 编排(集成段用,需模板路径)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writeFileSync, copyFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
/** 标准 game 目录(games/_wg1-gen/<game>/)到适配器的相对 import(与 entry-bundle 的 ../../../src 同深)。 */
|
||||||
|
export const STD_ADAPTER_IMPORT = '../../../src/adapter/create-game-from-definition.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 generated-factory.js 文本:适配器 import + 内联 SourceProject + 默认导出 GameHostFactory。
|
||||||
|
* @param {object} sourceProject SourceProject(source-project.schema.json)。
|
||||||
|
* @param {object} [opts] { adapterImport?:string } 适配器相对 import(默认标准深度)。
|
||||||
|
* @returns {string} generated-factory.js 文件内容。
|
||||||
|
*/
|
||||||
|
export function buildFactorySource(sourceProject, opts = {}) {
|
||||||
|
const adapterImport = opts.adapterImport || STD_ADAPTER_IMPORT;
|
||||||
|
// 内联 source 保证 esbuild 自包含打包(无运行期外部 JSON 请求)。2 空格缩进,可读 + diff 友好。
|
||||||
|
const json = JSON.stringify(sourceProject, null, 2);
|
||||||
|
return [
|
||||||
|
"'use strict';",
|
||||||
|
'// 自动生成(E2 scaffold-from-source.mjs):SourceProject → GameHostFactory。勿手改,改源重生成。',
|
||||||
|
`import { adaptGameDefinition } from '${adapterImport}';`,
|
||||||
|
`const SOURCE = ${json};`,
|
||||||
|
'export default adaptGameDefinition(SOURCE);',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 SourceProject 落成可构建 game 目录:写 generated-factory.js + 拷 entry-bundle/index 模板。
|
||||||
|
* @param {object} args
|
||||||
|
* @param {object} args.sourceProject SourceProject。
|
||||||
|
* @param {string} args.gameDir 目标 game 目录绝对路径(须为 games/_wg1-gen/<x>/ 标准深度,否则传 opts.adapterImport)。
|
||||||
|
* @param {string} args.sharedDir _shared 目录绝对路径(取 entry-bundle.template.js / index.template.html)。
|
||||||
|
* @param {object} [args.opts] 透传 buildFactorySource opts。
|
||||||
|
* @returns {string} build.mjs 入口路径(gameDir/entry-bundle.js)。
|
||||||
|
*/
|
||||||
|
export function scaffoldFromSource({ sourceProject, gameDir, sharedDir, opts }) {
|
||||||
|
mkdirSync(gameDir, { recursive: true });
|
||||||
|
writeFileSync(join(gameDir, 'generated-factory.js'), buildFactorySource(sourceProject, opts || {}));
|
||||||
|
copyFileSync(join(sharedDir, 'entry-bundle.template.js'), join(gameDir, 'entry-bundle.js'));
|
||||||
|
copyFileSync(join(sharedDir, 'index.template.html'), join(gameDir, 'index.html'));
|
||||||
|
return join(gameDir, 'entry-bundle.js'); // → node scripts/build.mjs <此> <out> --global-name=__GameBundle
|
||||||
|
}
|
||||||
41
game-runtime/src/adapter/scaffold.test.mjs
Normal file
41
game-runtime/src/adapter/scaffold.test.mjs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* scaffold.test.mjs —— E2 buildFactorySource 零依赖 node 单测(node --test)
|
||||||
|
* owner:前端+引擎线(Mac)|计划 002 U2。验「SourceProject → generated-factory.js 文本」工件正确。
|
||||||
|
*
|
||||||
|
* 注:scaffoldFromSource 的 fs 编排 + build.mjs 全链是集成段(需 esbuild),已由 _adapter-probe 真九门验证(commit 046454d9);
|
||||||
|
* 本单测只覆盖纯文本生成 buildFactorySource(零 fs/零 esbuild,守 package.json「lane 本机零依赖只跑 node --test」)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
import { buildFactorySource, STD_ADAPTER_IMPORT } from './scaffold-from-source.mjs';
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const SOURCE = JSON.parse(readFileSync(join(HERE, 'fixtures', 'tap-target.source.json'), 'utf8'));
|
||||||
|
|
||||||
|
test('buildFactorySource 产合契约 generated-factory 文本', () => {
|
||||||
|
const src = buildFactorySource(SOURCE);
|
||||||
|
// 适配器 import(标准深度)+ 默认导出工厂 + 内联 SOURCE。
|
||||||
|
assert.ok(src.includes(`import { adaptGameDefinition } from '${STD_ADAPTER_IMPORT}'`), '应含适配器 import');
|
||||||
|
assert.ok(src.includes('export default adaptGameDefinition(SOURCE)'), '应默认导出工厂');
|
||||||
|
assert.ok(src.includes('const SOURCE ='), '应内联 SOURCE 常量');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('内联 SOURCE 可往返 parse 且与原 SourceProject 深等', () => {
|
||||||
|
const src = buildFactorySource(SOURCE);
|
||||||
|
// 抽出 "const SOURCE = {...};" 的 JSON 体,JSON.parse 往返应与原 source 深等(无丢字段/无污染)。
|
||||||
|
const m = src.match(/const SOURCE = ([\s\S]*?);\nexport default/);
|
||||||
|
assert.ok(m, '应能定位内联 SOURCE 字面量');
|
||||||
|
const roundTrip = JSON.parse(m[1]);
|
||||||
|
assert.deepEqual(roundTrip, SOURCE, '内联 source 应与原 SourceProject 深等');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adapterImport 可覆盖(非标准深度 game 目录)', () => {
|
||||||
|
const src = buildFactorySource(SOURCE, { adapterImport: '../../src/adapter/create-game-from-definition.js' });
|
||||||
|
assert.ok(src.includes(`from '../../src/adapter/create-game-from-definition.js'`), '应用覆盖的 import 路径');
|
||||||
|
assert.ok(!src.includes(STD_ADAPTER_IMPORT), '不应再含标准路径');
|
||||||
|
});
|
||||||
@ -37,9 +37,11 @@ game-studio/
|
|||||||
- [x] **4 个 contractGaps 已由契约 owner 在 Wave3 补齐**:publish 门禁明细(GAP-1 ProjectPublishRespVO) / GamePackage 取包端点(GAP-2 runtime manifestUrl) / ShareMeta versionId(GAP-3) / 遥测 snake→camel(GAP-4)。
|
- [x] **4 个 contractGaps 已由契约 owner 在 Wave3 补齐**:publish 门禁明细(GAP-1 ProjectPublishRespVO) / GamePackage 取包端点(GAP-2 runtime manifestUrl) / ShareMeta versionId(GAP-3) / 遥测 snake→camel(GAP-4)。
|
||||||
- [x] **已 git 提交**(前端脊柱 `67ad750`)。
|
- [x] **已 git 提交**(前端脊柱 `67ad750`)。
|
||||||
- [ ] **真实联调(staging:切 `VITE_API_BASE_URL` 接 mini-desktop 后端,5 条链路端到端走查)** ← 当前最高价值待办,见总账 §5「联调」。
|
- [ ] **真实联调(staging:切 `VITE_API_BASE_URL` 接 mini-desktop 后端,5 条链路端到端走查)** ← 当前最高价值待办,见总账 §5「联调」。
|
||||||
- [ ] 安全测试补:postMessage 双校验"拒非法 origin/schema"独立验证。
|
- [x] **本地渲染真玩件**(创作预览 + 玩家试玩均渲染【真生成物】,非 demo):runtime mock 对 studio 生成版本(≥9001)回填 manifestUrl 指本地静态 manifest(内嵌 E1 适配器 engineBundle/__GameBundle)。**本地预览/试玩前先跑 `node scripts/gen-preview-manifest.mjs`**(生成 `public/mock-manifests/9001.json`,gitignore 不入库)。绕开 T1b 替换中自研壳的 `load_timeout`(CDP 走查实证:create→preview/9001、改进→preview/9002、feed→play/1/9001 均渲染 tap-target 真玩件)。
|
||||||
|
- [x] 安全测试补:postMessage 双校验"拒非法 origin/schema"独立验证(`src/host/bridge.security.test.mts`,12 例覆盖全 RejectReason + 空白名单默认拒 + dispose 失活;跑:`node --import tsx --test src/host/bridge.security.test.mts`)。
|
||||||
- [ ] 可选:卡片点击热区扩大到整卡;mock 假数据品牌文案;srcdoc 生产收紧 origin。
|
- [ ] 可选:卡片点击热区扩大到整卡;mock 假数据品牌文案;srcdoc 生产收紧 origin。
|
||||||
|
|
||||||
## 验证门铁律(本仓特有,勿踩)
|
## 验证门铁律(本仓特有,勿踩)
|
||||||
- **`vue-tsc --noEmit` 在本仓恒退 0 = 假门禁**(根 tsconfig files:[])。类型检查权威 = `npm run build`(含 `vue-tsc -b`)。
|
- **`vue-tsc --noEmit` 在本仓恒退 0 = 假门禁**(根 tsconfig files:[])。类型检查权威 = `npm run build`(含 `vue-tsc -b`)。
|
||||||
|
- **单测 = `npm test`(vitest,`src/**/*.spec.ts`)**:U6 已立 store/Create 测;新增 store/组件逻辑须带 spec(vi.mock 隔离 api/router)。另 `src/host/bridge.security.test.mts` 走 `node --import tsx --test`(`.mts` 不入构建,见文件头)。
|
||||||
- mock 数据严格贴契约 YAML,发现缺口上报 contractGaps、**不私造契约**。
|
- mock 数据严格贴契约 YAML,发现缺口上报 contractGaps、**不私造契约**。
|
||||||
|
|||||||
3
game-studio/.gitignore
vendored
3
game-studio/.gitignore
vendored
@ -22,3 +22,6 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# 本地预览静态 manifest(含大 engineBundle)——由 scripts/gen-preview-manifest.mjs 生成,不入库
|
||||||
|
public/mock-manifests/
|
||||||
|
|||||||
2332
game-studio/package-lock.json
generated
2332
game-studio/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.17.0",
|
"axios": "^1.17.0",
|
||||||
@ -19,9 +20,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"@vue/test-utils": "^2.4.11",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"happy-dom": "^20.10.5",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
|
"vitest": "^3.2.6",
|
||||||
"vue-tsc": "^3.2.8"
|
"vue-tsc": "^3.2.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
game-studio/scripts/gen-preview-manifest.mjs
Normal file
63
game-studio/scripts/gen-preview-manifest.mjs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* gen-preview-manifest.mjs —— 生成本地预览的静态 GamePackage manifest(含 E1 适配器 engineBundle)。
|
||||||
|
*
|
||||||
|
* 为什么:本地 mock 预览(/create/preview/9001)旧走「无 manifestUrl → demo 兜底」,而 demo 兜底用的
|
||||||
|
* 自研壳正被 T1b(LittleJS+Runner v2)替换 → 本地 load_timeout 玩不了。本脚本把【已验九门的 E1 适配器产物
|
||||||
|
* bundle】(__GameBundle) 包成一份 GamePackage 静态件,宿主 GamePlayer 经 fetchAndVerifyManifest 校 sha256 后
|
||||||
|
* 走【引擎包路】真玩——本地闭合 create→预览【可玩】闭环(CDP 走查实证)。
|
||||||
|
*
|
||||||
|
* 用法(在 game-studio/ 下):node scripts/gen-preview-manifest.mjs
|
||||||
|
* → 写 public/mock-manifests/9001.json(gitignore,不入库,体积大)
|
||||||
|
* → 打印 OUTER_CHECKSUM;若与 src/mock/runtime.ts 的 LOCAL_MANIFEST_CHECKSUM 不一致,按打印值更新它。
|
||||||
|
*
|
||||||
|
* 确定性:源 bundle = 仓内已提交的 game-runtime/games/_wg1-gen/_adapter-probe/bundle.iife.js(esbuild 确定性产物),
|
||||||
|
* 故同一源 bundle → 同一 manifest json → 同一 sha256 → 硬编码 checksum 稳定。换源 bundle 须重跑并同步 checksum。
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const studioRoot = resolve(HERE, '..')
|
||||||
|
// 源 = 已提交的九门探针 bundle(E1 适配器 tap-target 产物,__GameBundle.bootGameHost 入口)
|
||||||
|
const BUNDLE_PATH = resolve(studioRoot, '../game-runtime/games/_wg1-gen/_adapter-probe/bundle.iife.js')
|
||||||
|
const OUT_PATH = resolve(studioRoot, 'public/mock-manifests/9001.json')
|
||||||
|
|
||||||
|
const bundle = readFileSync(BUNDLE_PATH, 'utf8')
|
||||||
|
|
||||||
|
// GamePackage(对齐 contract.ts GamePackage / game-package.schema.json#4;assets 空走几何兜底)
|
||||||
|
const pkg = {
|
||||||
|
schemaVersion: '1.0',
|
||||||
|
gameId: '1',
|
||||||
|
versionId: '9001',
|
||||||
|
templateId: 'generic',
|
||||||
|
gameConfig: { title: '本地预览 · 点目标', winScore: 3 },
|
||||||
|
assets: [],
|
||||||
|
manifest: {
|
||||||
|
runtimeVersion: '1.0.0',
|
||||||
|
entry: 'index.html',
|
||||||
|
preloadPolicy: 'eager',
|
||||||
|
bundleSize: Buffer.byteLength(bundle),
|
||||||
|
checksum: createHash('sha256').update(bundle).digest('hex'), // 内层=bundle 自身 sha256(非校验门)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
title: '本地预览 · 点目标',
|
||||||
|
summary: 'E1 适配器产物(__GameBundle)经本地 mock 预览试玩——闭合 create→预览可玩闭环。',
|
||||||
|
cover: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
|
||||||
|
ageRating: 'all',
|
||||||
|
},
|
||||||
|
engineBundle: bundle, // 宿主 inject.ts 取此作独立内联 <script> → window.__GameBundle
|
||||||
|
provenance: { model: 'adapter-probe', traceId: 'local-preview-9001' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = JSON.stringify(pkg) // 被服务的原文(vite 原样静态服务)
|
||||||
|
mkdirSync(dirname(OUT_PATH), { recursive: true })
|
||||||
|
writeFileSync(OUT_PATH, json)
|
||||||
|
|
||||||
|
// 外层 checksum = sha256(被服务 json 原文),宿主 fetchAndVerifyManifest 据此校验(须 = runtime.ts LOCAL_MANIFEST_CHECKSUM)
|
||||||
|
const checksum = createHash('sha256').update(json, 'utf8').digest('hex')
|
||||||
|
console.log('[gen-preview-manifest] 写出:', OUT_PATH.replace(studioRoot + '/', ''))
|
||||||
|
console.log('[gen-preview-manifest] bytes:', Buffer.byteLength(json))
|
||||||
|
console.log('[gen-preview-manifest] OUTER_CHECKSUM:', checksum)
|
||||||
|
console.log('[gen-preview-manifest] ↑ 若与 src/mock/runtime.ts LOCAL_MANIFEST_CHECKSUM 不一致,按此更新。')
|
||||||
50
game-studio/src/api/studio.spec.ts
Normal file
50
game-studio/src/api/studio.spec.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* studio.spec.ts —— studio api 客户端单测(plan 002 U6;补齐 api 层,与 store/组件测共成 U6 单测纵切)。
|
||||||
|
* 验每个 api 函数对 frozen studio.yaml 的 url/method/data 映射(契约钉);request 经 mock 拦截。跑 `npm test`。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
// mock request 包络(studio.ts: `import { request } from './request'`)
|
||||||
|
vi.mock('./request', () => ({ request: vi.fn().mockResolvedValue({}) }))
|
||||||
|
|
||||||
|
import { request } from './request'
|
||||||
|
import { getTemplates, createGame, modifyGame, extendGame, getTaskChain, regenerate } from './studio'
|
||||||
|
|
||||||
|
const req = request as unknown as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
describe('studio api 客户端(对 frozen studio.yaml 的 url/method 钉)', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('getTemplates → GET /app-api/studio/template/list', async () => {
|
||||||
|
await getTemplates()
|
||||||
|
expect(req).toHaveBeenCalledWith({ url: '/app-api/studio/template/list', method: 'get' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createGame → POST /app-api/studio/create + data', async () => {
|
||||||
|
const body = { title: 'T', prompt: 'p', templateId: 'generic' }
|
||||||
|
await createGame(body)
|
||||||
|
expect(req).toHaveBeenCalledWith({ url: '/app-api/studio/create', method: 'post', data: body })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('modifyGame → POST /app-api/studio/modify + data', async () => {
|
||||||
|
const body = { baseVersionId: 9001, mode: 'deterministic' as const, target: { kind: 'config' as const, path: '/config/speed' }, payload: { value: { speed: 5 } } }
|
||||||
|
await modifyGame(body)
|
||||||
|
expect(req).toHaveBeenCalledWith({ url: '/app-api/studio/modify', method: 'post', data: body })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extendGame → POST /app-api/studio/extend + data', async () => {
|
||||||
|
const body = { baseVersionId: 9001, intent: '再加一关' }
|
||||||
|
await extendGame(body)
|
||||||
|
expect(req).toHaveBeenCalledWith({ url: '/app-api/studio/extend', method: 'post', data: body })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getTaskChain → GET /app-api/studio/task/{id}', async () => {
|
||||||
|
await getTaskChain(1001)
|
||||||
|
expect(req).toHaveBeenCalledWith({ url: '/app-api/studio/task/1001', method: 'get' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('regenerate → POST /app-api/studio/task/{id}/regenerate', async () => {
|
||||||
|
await regenerate(1001)
|
||||||
|
expect(req).toHaveBeenCalledWith({ url: '/app-api/studio/task/1001/regenerate', method: 'post' })
|
||||||
|
})
|
||||||
|
})
|
||||||
92
game-studio/src/api/studio.ts
Normal file
92
game-studio/src/api/studio.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* studio 模块 API(严格贴 contracts/api-schemas/studio.yaml)。
|
||||||
|
* 端点前缀 /app-api/studio/*;创作为异步任务链(提交→轮询 task/{id}→完成取 versionId 预览)。
|
||||||
|
*
|
||||||
|
* 生命周期(execution §5.6 / 契约 C3):
|
||||||
|
* - create:一步式创作(draft+generate 封装),templateId 缺省 generic;
|
||||||
|
* - modify:改源不改产物,产新预览版(deterministic 确定性覆写 / regenerate-module 重生成单 behavior);
|
||||||
|
* - extend:additive 内容扩展(加关卡/机制/内容);
|
||||||
|
* - 三者均返 StudioTaskChain(轮询 task/{id} 至 status=2 取 versionId)。
|
||||||
|
*/
|
||||||
|
import { request } from './request'
|
||||||
|
import type {
|
||||||
|
StudioTemplate,
|
||||||
|
StudioCreateReq,
|
||||||
|
StudioModifyReq,
|
||||||
|
StudioExtendReq,
|
||||||
|
StudioTaskChain,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩法模板列表(透传 aigc 模板注册表;一句话直生可不选)。
|
||||||
|
* GET /app-api/studio/template/list
|
||||||
|
*/
|
||||||
|
export function getTemplates(): Promise<StudioTemplate[]> {
|
||||||
|
return request<StudioTemplate[]>({
|
||||||
|
url: '/app-api/studio/template/list',
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一步式创作(draft+generate 封装,异步返回任务链)。
|
||||||
|
* POST /app-api/studio/create
|
||||||
|
* @param req title/prompt 必填,templateId 缺省 generic,assetContext 六类素材引用
|
||||||
|
*/
|
||||||
|
export function createGame(req: StudioCreateReq): Promise<StudioTaskChain> {
|
||||||
|
return request<StudioTaskChain>({
|
||||||
|
url: '/app-api/studio/create',
|
||||||
|
method: 'post',
|
||||||
|
data: req,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改源(改源不改产物,产新预览版,不动 currentVersion)。
|
||||||
|
* POST /app-api/studio/modify
|
||||||
|
* @param req baseVersionId + mode(deterministic/regenerate-module) + target + payload
|
||||||
|
*/
|
||||||
|
export function modifyGame(req: StudioModifyReq): Promise<StudioTaskChain> {
|
||||||
|
return request<StudioTaskChain>({
|
||||||
|
url: '/app-api/studio/modify',
|
||||||
|
method: 'post',
|
||||||
|
data: req,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内容扩展(additive 加关卡/机制/内容,不重写既有部件)。
|
||||||
|
* POST /app-api/studio/extend
|
||||||
|
* @param req baseVersionId + intent(+ 可选 assetContext)
|
||||||
|
*/
|
||||||
|
export function extendGame(req: StudioExtendReq): Promise<StudioTaskChain> {
|
||||||
|
return request<StudioTaskChain>({
|
||||||
|
url: '/app-api/studio/extend',
|
||||||
|
method: 'post',
|
||||||
|
data: req,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询创作任务链状态(前端轮询直至终态 status=2/3)。
|
||||||
|
* GET /app-api/studio/task/{id}
|
||||||
|
* @param id 创作任务链 ID(taskChainId)
|
||||||
|
*/
|
||||||
|
export function getTaskChain(id: number): Promise<StudioTaskChain> {
|
||||||
|
return request<StudioTaskChain>({
|
||||||
|
url: `/app-api/studio/task/${id}`,
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重生成(失败/不满意时重试,新建任务链,retryOf 指向原链)。
|
||||||
|
* POST /app-api/studio/task/{id}/regenerate
|
||||||
|
* @param id 原创作任务链 ID
|
||||||
|
*/
|
||||||
|
export function regenerate(id: number): Promise<StudioTaskChain> {
|
||||||
|
return request<StudioTaskChain>({
|
||||||
|
url: `/app-api/studio/task/${id}/regenerate`,
|
||||||
|
method: 'post',
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -687,3 +687,119 @@ export interface BizConfirmReq {
|
|||||||
/** 确认的 demo 版本 ID */
|
/** 确认的 demo 版本 ID */
|
||||||
demoVersionId?: number
|
demoVersionId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================== studio.yaml ============================== */
|
||||||
|
|
||||||
|
/** 玩法模板(studio.yaml StudioTemplateRespVO,透传 aigc 模板注册表) */
|
||||||
|
export interface StudioTemplate {
|
||||||
|
templateId: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
coverUrl?: string
|
||||||
|
/** 示例 Prompt(P-CRT-11) */
|
||||||
|
examplePrompt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 六类素材类别(对齐 source-project.schema.json assetSpec.category) */
|
||||||
|
export type StudioAssetCategory = 'sprite' | 'character' | 'effect' | 'scene' | 'ui' | 'music'
|
||||||
|
|
||||||
|
/** 六类素材引用(studio.yaml StudioAssetContextItem,取代旧 attachments) */
|
||||||
|
export interface StudioAssetContextItem {
|
||||||
|
category: StudioAssetCategory
|
||||||
|
/** 平台 assetId/ref(主) */
|
||||||
|
ref: string
|
||||||
|
/** 只读镜像 URL(可空) */
|
||||||
|
url?: string
|
||||||
|
/** 素材来源 provider(可空,默认 mmx-cli) */
|
||||||
|
provider?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一步式创作请求体(studio.yaml StudioCreateReqVO;draft+generate 封装) */
|
||||||
|
export interface StudioCreateReq {
|
||||||
|
/** 作品标题(≤128) */
|
||||||
|
title: string
|
||||||
|
/** 一句话 Prompt(≤1000,必填) */
|
||||||
|
prompt: string
|
||||||
|
/** 玩法模板/品类引导 ID,缺省 generic */
|
||||||
|
templateId?: string
|
||||||
|
/** 六类素材引用(空=纯一句话生成) */
|
||||||
|
assetContext?: StudioAssetContextItem[]
|
||||||
|
/** 幂等键(防重复提交触发重复生成,可空) */
|
||||||
|
idempotencyKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** modify 模式(studio.yaml StudioModifyReqVO.mode) */
|
||||||
|
export type StudioModifyMode = 'deterministic' | 'regenerate-module'
|
||||||
|
|
||||||
|
/** modify 目标部件类别(studio.yaml StudioModifyTarget.kind) */
|
||||||
|
export type StudioModifyKind = 'asset' | 'config' | 'level' | 'behavior'
|
||||||
|
|
||||||
|
/** 寻址源项目部件(studio.yaml StudioModifyTarget;path 与 id 至少其一) */
|
||||||
|
export interface StudioModifyTarget {
|
||||||
|
/** 换美术=asset / 调参=config / 改关卡=level / 改玩法=behavior */
|
||||||
|
kind: StudioModifyKind
|
||||||
|
/** 源项目内 JSON 指针(如 /config/speed、/gameDefinition/scenes/2) */
|
||||||
|
path?: string
|
||||||
|
/** 或按部件 id 寻址(behaviors[].id / assets[].id) */
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** modify 载荷(studio.yaml StudioModifyPayload;deterministic 用 value,regenerate-module 用 intent) */
|
||||||
|
export interface StudioModifyPayload {
|
||||||
|
/** deterministic 新值(确定性覆写免 LLM) */
|
||||||
|
value?: Record<string, unknown>
|
||||||
|
/** regenerate-module 改玩法逻辑的自然语言意图 */
|
||||||
|
intent?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改源请求体(studio.yaml StudioModifyReqVO;改源不改产物,产新预览版)。
|
||||||
|
* mode×target.kind 锁定映射(后端可信边界校验):
|
||||||
|
* deterministic ⇔ kind∈{asset,config,level} ∧ payload.value 必填;
|
||||||
|
* regenerate-module ⇔ kind=behavior ∧ payload.intent 必填。
|
||||||
|
*/
|
||||||
|
export interface StudioModifyReq {
|
||||||
|
/** 被改的 base 产物版本 ID */
|
||||||
|
baseVersionId: number
|
||||||
|
mode: StudioModifyMode
|
||||||
|
target: StudioModifyTarget
|
||||||
|
payload: StudioModifyPayload
|
||||||
|
idempotencyKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内容扩展请求体(studio.yaml StudioExtendReqVO;additive 加关卡/机制/内容) */
|
||||||
|
export interface StudioExtendReq {
|
||||||
|
baseVersionId: number
|
||||||
|
/** 扩展意图(加关卡/机制/内容) */
|
||||||
|
intent: string
|
||||||
|
/** 扩展用的新增六类素材引用(可空) */
|
||||||
|
assetContext?: StudioAssetContextItem[]
|
||||||
|
idempotencyKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创作任务链状态(studio.yaml StudioTaskChainRespVO.status):
|
||||||
|
* 0 初始 / 1 生成中 / 2 完成 / 3 失败
|
||||||
|
*/
|
||||||
|
export type StudioTaskChainStatus = 0 | 1 | 2 | 3
|
||||||
|
|
||||||
|
/** 创作任务链(studio.yaml StudioTaskChainRespVO;轮询用) */
|
||||||
|
export interface StudioTaskChain {
|
||||||
|
/** 创作任务链 ID(taskChainId,轮询用) */
|
||||||
|
id: number
|
||||||
|
/** 所属创作会话 ID */
|
||||||
|
sessionId?: number
|
||||||
|
/** 委托的 aigc 生成任务 ID */
|
||||||
|
aigcTaskId?: number
|
||||||
|
/** 关联游戏 ID */
|
||||||
|
gameId?: number
|
||||||
|
/** 产物版本 ID;完成后非空 */
|
||||||
|
versionId?: number
|
||||||
|
status: StudioTaskChainStatus
|
||||||
|
/** 进度百分比 0-100 */
|
||||||
|
progress: number
|
||||||
|
/** 若为重生成,指向原任务链 ID(P-CRT-09 血缘) */
|
||||||
|
retryOf?: number
|
||||||
|
/** modify/extend 血缘:本预览版派生自的 base 版本(create/generate 为空) */
|
||||||
|
baseVersionId?: number
|
||||||
|
}
|
||||||
|
|||||||
143
game-studio/src/host/bridge.security.test.mts
Normal file
143
game-studio/src/host/bridge.security.test.mts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* bridge.security.test.mts —— 宿主 postMessage 桥【双校验安全红线】独立验证
|
||||||
|
* (game-studio/.agent TODO「postMessage 双校验"拒非法 origin/schema"独立验证」;
|
||||||
|
* security-and-reliability §1.1 沙箱安全红线:不可信边界先校 origin 再校 schema,任一不过即丢弃绝不分发)。
|
||||||
|
*
|
||||||
|
* 运行:node --import tsx --test src/host/bridge.security.test.mts
|
||||||
|
* (.mts 不在 tsconfig.app include[src/**\/*.ts] 内,故不入 vue-tsc -b 构建,仅本测试单独跑——同 game-runtime node --test 范式)
|
||||||
|
*
|
||||||
|
* 验证手法:桩 window 捕获 message handler,合成 {origin,data} 事件直灌(无需 jsdom/真浏览器),
|
||||||
|
* 断言「合法→分发不拒 / 非法 origin|schema→拒且不分发」。覆盖全部 RejectReason + 空白名单默认拒 + dispose 失活。
|
||||||
|
*/
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
// —— 桩 window:必须在 new HostBridge 前置好(构造期 addEventListener;bridge 模块级不碰 window)——
|
||||||
|
let captured: ((ev: { origin: string; data: unknown }) => void) | null = null;
|
||||||
|
(globalThis as { window?: unknown }).window = {
|
||||||
|
addEventListener: (type: string, h: (ev: { origin: string; data: unknown }) => void) => {
|
||||||
|
if (type === 'message') captured = h;
|
||||||
|
},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { HostBridge } = await import('./bridge.ts');
|
||||||
|
const { SDK_CHANNEL } = await import('./contract.ts');
|
||||||
|
|
||||||
|
const ALLOWED = 'https://play.example.com';
|
||||||
|
|
||||||
|
/** 造一座桥 + 间谍收集 onMessage/onReject + 合成事件触发器 */
|
||||||
|
function setup(allowedOrigins: string[] = [ALLOWED]) {
|
||||||
|
captured = null;
|
||||||
|
const delivered: unknown[] = [];
|
||||||
|
const rejected: { reason: string; origin: string }[] = [];
|
||||||
|
const bridge = new HostBridge({
|
||||||
|
targetWindow: null,
|
||||||
|
allowedOrigins,
|
||||||
|
onMessage: (e) => delivered.push(e),
|
||||||
|
onReject: (reason, _raw, origin) => rejected.push({ reason, origin }),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
bridge,
|
||||||
|
delivered,
|
||||||
|
rejected,
|
||||||
|
fire: (ev: { origin: string; data: unknown }) => captured!(ev),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合法信封(可覆写字段造非法用例) */
|
||||||
|
function envelope(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
channel: SDK_CHANNEL,
|
||||||
|
type: 'lifecycle',
|
||||||
|
direction: 'game_to_host',
|
||||||
|
traceId: 't-1',
|
||||||
|
payload: { event: 'game_loaded' },
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('合法 origin + 合法信封 → 分发且不拒', () => {
|
||||||
|
const { fire, delivered, rejected } = setup();
|
||||||
|
fire({ origin: ALLOWED, data: envelope() });
|
||||||
|
assert.equal(delivered.length, 1, '合法消息应分发');
|
||||||
|
assert.equal(rejected.length, 0, '合法消息不应被拒');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非法 origin → 拒 origin_not_allowed 且不分发(第一道)', () => {
|
||||||
|
const { fire, delivered, rejected } = setup();
|
||||||
|
fire({ origin: 'https://evil.example.com', data: envelope() });
|
||||||
|
assert.equal(delivered.length, 0, '非法 origin 绝不分发');
|
||||||
|
assert.equal(rejected[0]?.reason, 'origin_not_allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空白名单 → 默认拒所有 origin(安全默认拒绝)', () => {
|
||||||
|
const { fire, delivered, rejected } = setup([]);
|
||||||
|
fire({ origin: ALLOWED, data: envelope() });
|
||||||
|
assert.equal(delivered.length, 0);
|
||||||
|
assert.equal(rejected[0]?.reason, 'origin_not_allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('合法 origin + 非对象 data → 拒 not_object', () => {
|
||||||
|
const { fire, delivered, rejected } = setup();
|
||||||
|
fire({ origin: ALLOWED, data: 'definitely-not-an-object' });
|
||||||
|
assert.equal(delivered.length, 0);
|
||||||
|
assert.equal(rejected[0]?.reason, 'not_object');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel 不匹配(伪造/噪声)→ 拒 channel_mismatch', () => {
|
||||||
|
const { fire, delivered, rejected } = setup();
|
||||||
|
fire({ origin: ALLOWED, data: envelope({ channel: 'evil-channel' }) });
|
||||||
|
assert.equal(delivered.length, 0);
|
||||||
|
assert.equal(rejected[0]?.reason, 'channel_mismatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('type 非白名单(如注入 exec)→ 拒 bad_type', () => {
|
||||||
|
const { fire, delivered, rejected } = setup();
|
||||||
|
fire({ origin: ALLOWED, data: envelope({ type: 'exec' }) });
|
||||||
|
assert.equal(delivered.length, 0);
|
||||||
|
assert.equal(rejected[0]?.reason, 'bad_type');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('direction 非法 → 拒 bad_direction', () => {
|
||||||
|
const { fire, rejected } = setup();
|
||||||
|
fire({ origin: ALLOWED, data: envelope({ direction: 'sideways' }) });
|
||||||
|
assert.equal(rejected[0]?.reason, 'bad_direction');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('缺 traceId → 拒 bad_payload', () => {
|
||||||
|
const { fire, rejected } = setup();
|
||||||
|
const e = envelope();
|
||||||
|
delete e.traceId;
|
||||||
|
fire({ origin: ALLOWED, data: e });
|
||||||
|
assert.equal(rejected[0]?.reason, 'bad_payload');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('缺 payload 字段 → 拒 bad_payload', () => {
|
||||||
|
const { fire, rejected } = setup();
|
||||||
|
const e = envelope();
|
||||||
|
delete e.payload;
|
||||||
|
fire({ origin: ALLOWED, data: e });
|
||||||
|
assert.equal(rejected[0]?.reason, 'bad_payload');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requestId 存在但非字符串 → 拒 bad_payload', () => {
|
||||||
|
const { fire, rejected } = setup();
|
||||||
|
fire({ origin: ALLOWED, data: envelope({ requestId: 123 }) });
|
||||||
|
assert.equal(rejected[0]?.reason, 'bad_payload');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('合法 requestId(字符串)随合法信封 → 分发', () => {
|
||||||
|
const { fire, delivered, rejected } = setup();
|
||||||
|
fire({ origin: ALLOWED, data: envelope({ type: 'ad', requestId: 'r-1' }) });
|
||||||
|
assert.equal(rejected.length, 0);
|
||||||
|
assert.equal(delivered.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispose 后任何消息均不再处理', () => {
|
||||||
|
const { bridge, fire, delivered, rejected } = setup();
|
||||||
|
bridge.dispose();
|
||||||
|
fire({ origin: ALLOWED, data: envelope() });
|
||||||
|
assert.equal(delivered.length, 0, 'dispose 后不分发');
|
||||||
|
assert.equal(rejected.length, 0, 'dispose 后直接 return(不走校验/不回调 onReject)');
|
||||||
|
});
|
||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
export default {
|
export default {
|
||||||
common: {
|
common: {
|
||||||
brand: 'Huijing AI',
|
brand: '造梦AI',
|
||||||
nav: { feed: 'Feed', create: 'Create', message: 'Inbox', me: 'Me', discover: 'Discover' },
|
nav: { feed: 'Feed', create: 'Create', message: 'Inbox', me: 'Me', discover: 'Discover' },
|
||||||
action: { play: 'Play now', share: 'Share', save: 'Save', submit: 'Submit', cancel: 'Cancel', confirm: 'Confirm', back: 'Back', retry: 'Retry', more: 'More', prev: 'Previous', next: 'Next' },
|
action: { play: 'Play now', share: 'Share', save: 'Save', submit: 'Submit', cancel: 'Cancel', confirm: 'Confirm', back: 'Back', retry: 'Retry', more: 'More', prev: 'Previous', next: 'Next' },
|
||||||
state: { loading: 'Loading', empty: 'Nothing here yet', saved: 'Saved', failed: 'Failed', success: 'Success' },
|
state: { loading: 'Loading', empty: 'Nothing here yet', saved: 'Saved', failed: 'Failed', success: 'Success' },
|
||||||
|
|||||||
@ -45,8 +45,36 @@ export default {
|
|||||||
stateReady: 'Playable',
|
stateReady: 'Playable',
|
||||||
stateError: 'Load failed',
|
stateError: 'Load failed',
|
||||||
loadFailed: 'Load failed',
|
loadFailed: 'Load failed',
|
||||||
|
refine: 'Refine',
|
||||||
regenerate: 'Redo',
|
regenerate: 'Redo',
|
||||||
goPublish: 'Publish',
|
goPublish: 'Publish',
|
||||||
needProjectToast: 'Open this work from “My Projects” before publishing',
|
needProjectToast: 'Open this work from “My Projects” before publishing',
|
||||||
},
|
},
|
||||||
|
// —— Refine page (/create/modify/:versionId; maps studio extend / modify) ——
|
||||||
|
modify: {
|
||||||
|
title: 'Refine',
|
||||||
|
baseLabel: 'Based on version',
|
||||||
|
tabExtend: 'Extend',
|
||||||
|
tabBehavior: 'Gameplay',
|
||||||
|
tabTweak: 'Tweak',
|
||||||
|
extendHint: 'Describe new content to append to the existing game',
|
||||||
|
extendPlaceholder: 'e.g. add a boss level',
|
||||||
|
behaviorHint: 'Describe how the gameplay should change; AI rewrites only that logic',
|
||||||
|
behaviorPlaceholder: 'e.g. make the meteors sway left and right',
|
||||||
|
targetLabel: 'Target behavior (optional, defaults to first)',
|
||||||
|
targetPlaceholder: 'behavior id or path, e.g. /gameDefinition/behaviors/0',
|
||||||
|
tweakKindLabel: 'Target',
|
||||||
|
kindConfig: 'Param',
|
||||||
|
kindAsset: 'Asset',
|
||||||
|
kindLevel: 'Level',
|
||||||
|
tweakPathLabel: 'Path',
|
||||||
|
tweakPathPlaceholder: 'e.g. /config/speed',
|
||||||
|
tweakValueLabel: 'New value (JSON)',
|
||||||
|
tweakValuePlaceholder: 'JSON, e.g. "speed": 5',
|
||||||
|
valueInvalid: 'New value is not valid JSON',
|
||||||
|
emptyIntent: 'Please enter a description first',
|
||||||
|
emptyPath: 'Please enter the path to modify',
|
||||||
|
submit: 'Submit',
|
||||||
|
submitting: 'Submitting…',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,9 +55,38 @@ export default {
|
|||||||
stateError: '加载失败',
|
stateError: '加载失败',
|
||||||
loadFailed: '加载失败', // onError 无 message 时的兜底文案
|
loadFailed: '加载失败', // onError 无 message 时的兜底文案
|
||||||
// 底部操作
|
// 底部操作
|
||||||
|
refine: '改进作品',
|
||||||
regenerate: '不满意,重做',
|
regenerate: '不满意,重做',
|
||||||
goPublish: '满意,去发布',
|
goPublish: '满意,去发布',
|
||||||
// 缺项目 ID 无法发布时的 Toast 提示
|
// 缺项目 ID 无法发布时的 Toast 提示
|
||||||
needProjectToast: '请从「我的项目」进入该作品后再发布',
|
needProjectToast: '请从「我的项目」进入该作品后再发布',
|
||||||
},
|
},
|
||||||
|
// —— 改进作品页(/create/modify/:versionId;映射 studio 契约 extend / modify) ——
|
||||||
|
modify: {
|
||||||
|
title: '改进作品',
|
||||||
|
baseLabel: '基于版本',
|
||||||
|
// 三种改进方式:扩展(extend) / 改玩法(modify regenerate-module) / 调参(modify deterministic)
|
||||||
|
tabExtend: '扩展',
|
||||||
|
tabBehavior: '改玩法',
|
||||||
|
tabTweak: '调参',
|
||||||
|
extendHint: '描述要新增的内容,AI 会在现有作品上追加',
|
||||||
|
extendPlaceholder: '如:再加一个 boss 关卡',
|
||||||
|
behaviorHint: '用一句话描述玩法怎么改,AI 只重写对应玩法逻辑',
|
||||||
|
behaviorPlaceholder: '如:把陨石改成会左右摇摆',
|
||||||
|
targetLabel: '目标行为(可空,默认首个)',
|
||||||
|
targetPlaceholder: '行为 id 或路径,如 /gameDefinition/behaviors/0',
|
||||||
|
tweakKindLabel: '调整对象',
|
||||||
|
kindConfig: '参数',
|
||||||
|
kindAsset: '素材',
|
||||||
|
kindLevel: '关卡',
|
||||||
|
tweakPathLabel: '路径',
|
||||||
|
tweakPathPlaceholder: '如 /config/speed',
|
||||||
|
tweakValueLabel: '新值(JSON)',
|
||||||
|
tweakValuePlaceholder: 'JSON,如 "speed": 5',
|
||||||
|
valueInvalid: '新值不是合法 JSON,请检查',
|
||||||
|
emptyIntent: '请先填写描述',
|
||||||
|
emptyPath: '请填写要修改的路径',
|
||||||
|
submit: '提交改进',
|
||||||
|
submitting: '提交中…',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
export default {
|
export default {
|
||||||
common: {
|
common: {
|
||||||
brand: '绘境AI',
|
brand: '造梦AI',
|
||||||
nav: { feed: '游戏流', create: '创作', message: '消息', me: '我的', discover: '发现' },
|
nav: { feed: '游戏流', create: '创作', message: '消息', me: '我的', discover: '发现' },
|
||||||
action: { play: '立即玩', share: '分享', save: '保存', submit: '提交', cancel: '取消', confirm: '确定', back: '返回', retry: '重试', more: '更多', prev: '上一个', next: '下一个' },
|
action: { play: '立即玩', share: '分享', save: '保存', submit: '提交', cancel: '取消', confirm: '确定', back: '返回', retry: '重试', more: '更多', prev: '上一个', next: '下一个' },
|
||||||
state: { loading: '加载中', empty: '这里还没有内容', saved: '已保存', failed: '失败', success: '成功' },
|
state: { loading: '加载中', empty: '这里还没有内容', saved: '已保存', failed: '失败', success: '成功' },
|
||||||
|
|||||||
@ -24,7 +24,7 @@ import type {
|
|||||||
/** 四类消息标题/正文词库(拼出有辨识度的 demo 文案) */
|
/** 四类消息标题/正文词库(拼出有辨识度的 demo 文案) */
|
||||||
const TYPE_SAMPLES: Record<CommunityMsgType, { title: string; content: string }[]> = {
|
const TYPE_SAMPLES: Record<CommunityMsgType, { title: string; content: string }[]> = {
|
||||||
1: [
|
1: [
|
||||||
{ title: '欢迎来到绘境AI', content: '完善资料、发布你的第一个作品,开启创作之旅。' },
|
{ title: '欢迎来到造梦AI', content: '完善资料、发布你的第一个作品,开启创作之旅。' },
|
||||||
{ title: '账号安全提醒', content: '检测到你在新设备登录,如非本人操作请及时修改密码。' },
|
{ title: '账号安全提醒', content: '检测到你在新设备登录,如非本人操作请及时修改密码。' },
|
||||||
],
|
],
|
||||||
2: [
|
2: [
|
||||||
|
|||||||
@ -41,7 +41,7 @@ function cover(seed: number, title: string): string {
|
|||||||
<rect width="375" height="640" fill="url(#g)"/>
|
<rect width="375" height="640" fill="url(#g)"/>
|
||||||
<circle cx="300" cy="120" r="90" fill="${accent}" opacity="0.12"/>
|
<circle cx="300" cy="120" r="90" fill="${accent}" opacity="0.12"/>
|
||||||
<text x="28" y="540" fill="#eef6ff" font-size="30" font-family="sans-serif" font-weight="700">${title}</text>
|
<text x="28" y="540" fill="#eef6ff" font-size="30" font-family="sans-serif" font-weight="700">${title}</text>
|
||||||
<text x="28" y="575" fill="${accent}" font-size="15" font-family="sans-serif">绘境 demo 游戏</text></svg>`
|
<text x="28" y="575" fill="${accent}" font-size="15" font-family="sans-serif">造梦 demo 游戏</text></svg>`
|
||||||
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
|
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,7 +182,7 @@ function shareHandler(req: MockRequest): ShareMeta {
|
|||||||
shareUrl: `https://wanxiang.ai/share/${gameId}?utm_source=${encodeURIComponent(channel)}`,
|
shareUrl: `https://wanxiang.ai/share/${gameId}?utm_source=${encodeURIComponent(channel)}`,
|
||||||
ogTitle: `来玩《${title}》`,
|
ogTitle: `来玩《${title}》`,
|
||||||
ogImage: cover(gameId, title),
|
ogImage: cover(gameId, title),
|
||||||
ogDescription: '绘境AI 一句话生成的小游戏,点开即玩',
|
ogDescription: '造梦AI 一句话生成的小游戏,点开即玩',
|
||||||
channel,
|
channel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import type { MockRequest } from './shared'
|
|||||||
import { MockError, matchPath } from './shared'
|
import { MockError, matchPath } from './shared'
|
||||||
import { feedRoutes } from './feed'
|
import { feedRoutes } from './feed'
|
||||||
import { aigcRoutes } from './aigc'
|
import { aigcRoutes } from './aigc'
|
||||||
|
import { studioRoutes } from './studio'
|
||||||
import { runtimeRoutes } from './runtime'
|
import { runtimeRoutes } from './runtime'
|
||||||
import { projectRoutes } from './project'
|
import { projectRoutes } from './project'
|
||||||
import { telemetryRoutes } from './telemetry'
|
import { telemetryRoutes } from './telemetry'
|
||||||
@ -21,6 +22,7 @@ import { bizRoutes } from './biz'
|
|||||||
const ALL_ROUTES = [
|
const ALL_ROUTES = [
|
||||||
...feedRoutes,
|
...feedRoutes,
|
||||||
...aigcRoutes,
|
...aigcRoutes,
|
||||||
|
...studioRoutes,
|
||||||
...runtimeRoutes,
|
...runtimeRoutes,
|
||||||
...projectRoutes,
|
...projectRoutes,
|
||||||
...telemetryRoutes,
|
...telemetryRoutes,
|
||||||
|
|||||||
@ -49,7 +49,7 @@ function ensureSeed() {
|
|||||||
projects.set(id, {
|
projects.set(id, {
|
||||||
id,
|
id,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
summary: '绘境AI 生成的小游戏 demo',
|
summary: '造梦AI 生成的小游戏 demo',
|
||||||
coverUrl: '',
|
coverUrl: '',
|
||||||
tags: ['休闲', 'demo'],
|
tags: ['休闲', 'demo'],
|
||||||
templateId: s.templateId,
|
templateId: s.templateId,
|
||||||
|
|||||||
@ -22,44 +22,67 @@ const tokenToSession = new Map<string, number>()
|
|||||||
const endedSessions = new Set<number>()
|
const endedSessions = new Set<number>()
|
||||||
let sessionSeq = 50000
|
let sessionSeq = 50000
|
||||||
|
|
||||||
|
/** iframe 沙箱策略(本地预览/开发同源;srcdoc allow-same-origin → 文档 origin=宿主 origin,bridge 双校验放行) */
|
||||||
|
const LOCAL_SANDBOX = {
|
||||||
|
sandboxAttr: 'allow-scripts allow-same-origin',
|
||||||
|
allowOrigins: [
|
||||||
|
'http://localhost:5173',
|
||||||
|
'http://localhost:4173',
|
||||||
|
'http://127.0.0.1:5173',
|
||||||
|
'http://127.0.0.1:4173',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* demo 版本(9001)的本地静态 manifest(public/mock-manifests/9001.json,vite 原样服务=非 /app-api mock 中间件路径)。
|
||||||
|
* 内嵌 E1 适配器产物 engineBundle(__GameBundle,九门已验);宿主 fetchAndVerifyManifest 拉取并按 checksum(sha256) 校验后
|
||||||
|
* 走【引擎包路】真玩——本地闭合 create→预览【可玩】闭环。
|
||||||
|
* checksum = sha256(被服务 json 原文)。本 json 由 `scripts/gen-preview-manifest.mjs` 生成(gitignore 不入库,体积大):
|
||||||
|
* 本地预览前先跑一次 `node scripts/gen-preview-manifest.mjs`;若其打印的 OUTER_CHECKSUM 变了(换源 bundle)则按其更新下方常量。
|
||||||
|
*/
|
||||||
|
const LOCAL_MANIFEST_URL = '/mock-manifests/9001.json'
|
||||||
|
const LOCAL_MANIFEST_CHECKSUM = '3f90757fb5e26a8d5117628546d27369411662696037e6b5a8a0ec0bfa2652d0'
|
||||||
|
const LOCAL_MANIFEST_BYTES = 215772
|
||||||
|
|
||||||
/** GET /app-api/runtime/package/:versionId?scene */
|
/** GET /app-api/runtime/package/:versionId?scene */
|
||||||
function packageHandler(req: MockRequest): RuntimePackage {
|
function packageHandler(req: MockRequest): RuntimePackage {
|
||||||
const versionId = Number(req.params.versionId)
|
const versionId = Number(req.params.versionId)
|
||||||
if (!versionId) throw new MockError(1102001001, '无对应就绪运行包')
|
if (!versionId) throw new MockError(1102001001, '无对应就绪运行包')
|
||||||
// gameId 在真实后端由 version 关联得到;mock 给一个稳定派生值
|
// studio mock 生成的版本(create=9001 / modify·extend=9002,9003…,均自 DEMO_VERSION_ID 起增):
|
||||||
const gameId = versionId === DEMO_VERSION_ID ? 1 : versionId
|
// 回填 manifestUrl 指本地静态 manifest(内嵌 engineBundle),宿主走引擎包路真玩——create/改进 全路径预览皆可玩。
|
||||||
// 整包字节:贴 §8 AC7 Runtime <15KB 语境,这里给一个合理 demo 包体积
|
// 旧路「无 manifestUrl→demo 兜底」走的是【被 T1b 替换中的自研壳】→ 本地 load_timeout;
|
||||||
const bundleSize = 14_336
|
// 改走 engineBundle(__GameBundle.bootGameHost) 后本地预览即试玩真生成物(CDP 走查实证)。
|
||||||
|
// 同一静态 manifest 服所有版本(demo 同一玩件;manifest 内 versionId 字段仅元信息,不影响渲染)。
|
||||||
|
if (versionId >= DEMO_VERSION_ID) {
|
||||||
|
return {
|
||||||
|
gameId: 1, // studio mock 链一律 gameId=1(DEMO_GAME_ID)
|
||||||
|
versionId,
|
||||||
|
templateId: 'generic',
|
||||||
|
packageUrl: LOCAL_MANIFEST_URL,
|
||||||
|
manifestUrl: LOCAL_MANIFEST_URL,
|
||||||
|
entry: 'index.html',
|
||||||
|
runtimeVersion: '1.0.0',
|
||||||
|
preloadPolicy: 'eager',
|
||||||
|
bundleSize: LOCAL_MANIFEST_BYTES,
|
||||||
|
checksum: LOCAL_MANIFEST_CHECKSUM, // = sha256(被服务 9001.json 原文),fetchAndVerifyManifest 据此校验
|
||||||
|
sandbox: LOCAL_SANDBOX,
|
||||||
|
traceId: `tr-pkg-${versionId}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非 studio 生成的旧版本号(<9001):保持无 manifestUrl → demo 兜底(同原行为;真 manifestUrl 走 staging 联调)。
|
||||||
return {
|
return {
|
||||||
gameId,
|
gameId: versionId,
|
||||||
versionId,
|
versionId,
|
||||||
// 模板:clicker(与 demo 点击计数玩法一致)
|
|
||||||
templateId: 'clicker',
|
templateId: 'clicker',
|
||||||
packageUrl: `/games/${gameId}/versions/${versionId}/manifest.json`,
|
packageUrl: `/games/${versionId}/versions/${versionId}/manifest.json`,
|
||||||
// GAP-2:mock 刻意「不」回填 manifestUrl。
|
// manifestUrl: undefined(无对应静态件 → GamePlayer.resolvePackage 走 demo 兜底)
|
||||||
// 宿主 GamePlayer.resolvePackage 对「无 manifestUrl」走 demo 兜底(assets 为空的 clicker),
|
|
||||||
// 保证本地 mock 链路无需真实 OSS 即可跑通;而 manifestUrl 的 fetch+sha256 校验路径在
|
|
||||||
// 隔离 staging 联调(runtime 回填真实 manifestUrl + OSS CORS)时验证。
|
|
||||||
// 若在此填一个 mock 中间件(仅拦 /app-api、/admin-api)无法服务的 /games/... 路径,
|
|
||||||
// 只会触发取包失败兜底,污染 error 事件,故不填。详见返回 openIssues。
|
|
||||||
// manifestUrl: undefined,
|
|
||||||
// 入口约定为 'demo':A2 Runtime 据此加载内置 demo 游戏(非真实文件路径,链路约定值)
|
|
||||||
entry: 'demo',
|
entry: 'demo',
|
||||||
runtimeVersion: '1.0.0',
|
runtimeVersion: '1.0.0',
|
||||||
preloadPolicy: 'eager',
|
preloadPolicy: 'eager',
|
||||||
bundleSize,
|
bundleSize: 14_336, // §8 AC7 Runtime <15KB 语境的合理 demo 包体积
|
||||||
checksum: fakeSha256(`pkg-${gameId}-${versionId}`),
|
checksum: fakeSha256(`pkg-${versionId}`),
|
||||||
sandbox: {
|
sandbox: LOCAL_SANDBOX,
|
||||||
// iframe 沙箱属性:允许脚本 + 同源(本地宿主加载内置 demo 需要)
|
|
||||||
sandboxAttr: 'allow-scripts allow-same-origin',
|
|
||||||
// postMessage origin 白名单:本地开发/预览同源(宿主 bridge 双校验放行)
|
|
||||||
allowOrigins: [
|
|
||||||
'http://localhost:5173',
|
|
||||||
'http://localhost:4173',
|
|
||||||
'http://127.0.0.1:5173',
|
|
||||||
'http://127.0.0.1:4173',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
traceId: `tr-pkg-${versionId}`,
|
traceId: `tr-pkg-${versionId}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
178
game-studio/src/mock/studio.ts
Normal file
178
game-studio/src/mock/studio.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* studio 模块 mock handler(严格贴 contracts/api-schemas/studio.yaml)。
|
||||||
|
* 覆盖端点:
|
||||||
|
* GET /app-api/studio/template/list → StudioTemplateRespVO[]
|
||||||
|
* POST /app-api/studio/create → StudioTaskChainRespVO(一步式,异步入队)
|
||||||
|
* POST /app-api/studio/modify → StudioTaskChainRespVO(改源产新预览版,带 baseVersionId 血缘)
|
||||||
|
* POST /app-api/studio/extend → StudioTaskChainRespVO(additive 扩展,带 baseVersionId 血缘)
|
||||||
|
* GET /app-api/studio/task/:id → StudioTaskChainRespVO(进度随时间递增→终态)
|
||||||
|
* POST /app-api/studio/task/:id/regenerate→ StudioTaskChainRespVO(新建任务链,retryOf 血缘)
|
||||||
|
*
|
||||||
|
* 异步生成模拟:create/modify/extend 各落一条任务链并记 startMs;task/:id 按「已耗时」算 progress,
|
||||||
|
* 达阈值落终态(status=2,回填 versionId)。无后台定时器,轮询多少次都收敛(与 aigc mock 同范式)。
|
||||||
|
* 错误码段 = 112(studio),格式 1-112-xxx-xxx(对齐 aigc 的 1-101-xxx-xxx)。
|
||||||
|
*/
|
||||||
|
import type { MockRoute, MockRequest } from './shared'
|
||||||
|
import { MockError, nowISO } from './shared'
|
||||||
|
import type {
|
||||||
|
StudioTemplate,
|
||||||
|
StudioTaskChain,
|
||||||
|
StudioCreateReq,
|
||||||
|
StudioModifyReq,
|
||||||
|
StudioExtendReq,
|
||||||
|
} from '../api/types'
|
||||||
|
|
||||||
|
/** demo 产物版本 ID(与 aigc/runtime mock 对齐:生成成功后预览试玩同一 demo 包) */
|
||||||
|
const DEMO_VERSION_ID = 9001
|
||||||
|
/** 模拟生成总时长(毫秒):约 6s 走完 0→100(与 aigc mock 一致) */
|
||||||
|
const GEN_DURATION = 6000
|
||||||
|
/** demo 游戏 ID(与 runtime mock 对齐) */
|
||||||
|
const DEMO_GAME_ID = 1
|
||||||
|
|
||||||
|
/** 任务链内存表(taskChainId → 链态) */
|
||||||
|
interface MockChain {
|
||||||
|
id: number
|
||||||
|
sessionId: number
|
||||||
|
aigcTaskId: number
|
||||||
|
gameId: number
|
||||||
|
/** 完成后回填的产物版本 ID */
|
||||||
|
versionId: number
|
||||||
|
startMs: number
|
||||||
|
/** modify/extend 血缘:派生自的 base 版本(create 为空) */
|
||||||
|
baseVersionId?: number
|
||||||
|
/** 若为重生成,指向原任务链 ID */
|
||||||
|
retryOf?: number
|
||||||
|
createTime: string
|
||||||
|
}
|
||||||
|
const chains = new Map<number, MockChain>()
|
||||||
|
let chainSeq = 1000 // taskChainId 起始
|
||||||
|
let sessionSeq = 500
|
||||||
|
let aigcSeq = 8800
|
||||||
|
let versionSeq = 9001 // 预览版本号自增(modify/extend 各产新版)
|
||||||
|
|
||||||
|
/** 玩法模板(贴 StudioTemplateRespVO;透传口径,与 aigc 模板集合一致) */
|
||||||
|
const TEMPLATES: StudioTemplate[] = [
|
||||||
|
{ templateId: 'generic', name: '通用', description: '一句话直生,免选模板', examplePrompt: '做一个躲避陨石的小游戏' },
|
||||||
|
{ templateId: 'clicker', name: '点击放置', description: '点击累积数值的放置类玩法', coverUrl: tplCover('clicker', '#31e6ff'), examplePrompt: '点击就能赚金币、越点越快的放置小游戏' },
|
||||||
|
{ templateId: 'dodge', name: '躲避闪避', description: '控制角色躲避来袭障碍', coverUrl: tplCover('dodge', '#ff669d'), examplePrompt: '躲避陨石的太空船小游戏' },
|
||||||
|
{ templateId: 'runner', name: '跑酷', description: '横版自动奔跑 + 跳跃躲障碍', coverUrl: tplCover('runner', '#7cf2a4'), examplePrompt: '霓虹城市跑酷,跳过障碍吃金币' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 生成模板封面(内联 SVG data URI) */
|
||||||
|
function tplCover(label: string, accent: string): string {
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">
|
||||||
|
<rect width="160" height="120" fill="#101522"/>
|
||||||
|
<rect x="1" y="1" width="158" height="118" fill="none" stroke="${accent}" stroke-opacity="0.4"/>
|
||||||
|
<circle cx="130" cy="30" r="36" fill="${accent}" opacity="0.15"/>
|
||||||
|
<text x="16" y="68" fill="#eef6ff" font-size="18" font-family="sans-serif" font-weight="700">${label}</text></svg>`
|
||||||
|
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 据耗时把任务链投影成对外 StudioTaskChainRespVO(进度递增 + 终态产物版本) */
|
||||||
|
function projectChain(c: MockChain): StudioTaskChain {
|
||||||
|
const elapsed = Date.now() - c.startMs
|
||||||
|
const base = {
|
||||||
|
id: c.id,
|
||||||
|
sessionId: c.sessionId,
|
||||||
|
aigcTaskId: c.aigcTaskId,
|
||||||
|
gameId: c.gameId,
|
||||||
|
retryOf: c.retryOf,
|
||||||
|
baseVersionId: c.baseVersionId,
|
||||||
|
}
|
||||||
|
if (elapsed < GEN_DURATION) {
|
||||||
|
// 进行中:0-99 进度。前 5% 视为初始(0),其后生成中(1)
|
||||||
|
const progress = Math.min(99, Math.floor((elapsed / GEN_DURATION) * 100))
|
||||||
|
return { ...base, status: progress < 5 ? 0 : 1, progress }
|
||||||
|
}
|
||||||
|
// 终态:完成(2),回填产物 versionId
|
||||||
|
return { ...base, status: 2, progress: 100, versionId: c.versionId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建一条任务链(create/modify/extend/regenerate 共用) */
|
||||||
|
function newChain(opts: { versionId?: number; baseVersionId?: number; retryOf?: number }): MockChain {
|
||||||
|
const id = ++chainSeq
|
||||||
|
const c: MockChain = {
|
||||||
|
id,
|
||||||
|
sessionId: ++sessionSeq,
|
||||||
|
aigcTaskId: ++aigcSeq,
|
||||||
|
gameId: DEMO_GAME_ID,
|
||||||
|
versionId: opts.versionId ?? DEMO_VERSION_ID,
|
||||||
|
startMs: Date.now(),
|
||||||
|
baseVersionId: opts.baseVersionId,
|
||||||
|
retryOf: opts.retryOf,
|
||||||
|
createTime: nowISO(),
|
||||||
|
}
|
||||||
|
chains.set(id, c)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /app-api/studio/template/list */
|
||||||
|
function templateListHandler(): StudioTemplate[] {
|
||||||
|
return TEMPLATES
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /app-api/studio/create —— 一步式创作 */
|
||||||
|
function createHandler(req: MockRequest): StudioTaskChain {
|
||||||
|
const body = (req.body ?? {}) as Partial<StudioCreateReq>
|
||||||
|
// 贴契约:title + prompt 必填(prompt 1-1000)
|
||||||
|
if (!body.title || !body.title.trim()) throw new MockError(1112001001, '作品标题不能为空')
|
||||||
|
if (!body.prompt || !body.prompt.trim()) throw new MockError(1112001002, 'Prompt 不能为空')
|
||||||
|
if (body.prompt.length > 1000) throw new MockError(1112001003, 'Prompt 超长(≤1000)')
|
||||||
|
return projectChain(newChain({})) // create 无 baseVersionId 血缘
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /app-api/studio/modify —— 改源产新预览版 */
|
||||||
|
function modifyHandler(req: MockRequest): StudioTaskChain {
|
||||||
|
const body = (req.body ?? {}) as Partial<StudioModifyReq>
|
||||||
|
if (typeof body.baseVersionId !== 'number') throw new MockError(1112002001, 'baseVersionId 必填')
|
||||||
|
if (!body.mode) throw new MockError(1112002002, 'mode 必填')
|
||||||
|
const kind = body.target?.kind
|
||||||
|
if (!kind) throw new MockError(1112002003, 'target.kind 必填')
|
||||||
|
// mode×kind 锁定映射(后端可信边界校验,违则拒):
|
||||||
|
if (body.mode === 'deterministic') {
|
||||||
|
if (!['asset', 'config', 'level'].includes(kind)) throw new MockError(1112002004, 'deterministic 仅适用 asset/config/level')
|
||||||
|
if (!body.payload || body.payload.value === undefined) throw new MockError(1112002005, 'deterministic 须带 payload.value')
|
||||||
|
} else if (body.mode === 'regenerate-module') {
|
||||||
|
if (kind !== 'behavior') throw new MockError(1112002006, 'regenerate-module 仅适用 behavior')
|
||||||
|
if (!body.payload || !body.payload.intent) throw new MockError(1112002007, 'regenerate-module 须带 payload.intent')
|
||||||
|
} else {
|
||||||
|
throw new MockError(1112002008, 'mode 非法')
|
||||||
|
}
|
||||||
|
// 产新预览版(versionId 自增),带 baseVersionId 血缘
|
||||||
|
return projectChain(newChain({ versionId: ++versionSeq, baseVersionId: body.baseVersionId }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /app-api/studio/extend —— additive 内容扩展 */
|
||||||
|
function extendHandler(req: MockRequest): StudioTaskChain {
|
||||||
|
const body = (req.body ?? {}) as Partial<StudioExtendReq>
|
||||||
|
if (typeof body.baseVersionId !== 'number') throw new MockError(1112003001, 'baseVersionId 必填')
|
||||||
|
if (!body.intent || !body.intent.trim()) throw new MockError(1112003002, '扩展意图不能为空')
|
||||||
|
return projectChain(newChain({ versionId: ++versionSeq, baseVersionId: body.baseVersionId }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /app-api/studio/task/:id */
|
||||||
|
function taskHandler(req: MockRequest): StudioTaskChain {
|
||||||
|
const id = Number(req.params.id)
|
||||||
|
const c = chains.get(id)
|
||||||
|
if (!c) throw new MockError(1112004001, '任务链不存在')
|
||||||
|
return projectChain(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /app-api/studio/task/:id/regenerate */
|
||||||
|
function regenerateHandler(req: MockRequest): StudioTaskChain {
|
||||||
|
const id = Number(req.params.id)
|
||||||
|
const orig = chains.get(id)
|
||||||
|
if (!orig) throw new MockError(1112004001, '任务链不存在')
|
||||||
|
// 新建任务链,retryOf 指向原链,沿用原 baseVersionId 血缘
|
||||||
|
return projectChain(newChain({ versionId: ++versionSeq, baseVersionId: orig.baseVersionId, retryOf: id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出本模块路由表(更具体的 /regenerate 须排在 /:id 之前) */
|
||||||
|
export const studioRoutes: MockRoute[] = [
|
||||||
|
{ method: 'GET', pattern: '/app-api/studio/template/list', handler: templateListHandler },
|
||||||
|
{ method: 'POST', pattern: '/app-api/studio/create', handler: createHandler },
|
||||||
|
{ method: 'POST', pattern: '/app-api/studio/modify', handler: modifyHandler },
|
||||||
|
{ method: 'POST', pattern: '/app-api/studio/extend', handler: extendHandler },
|
||||||
|
{ method: 'POST', pattern: '/app-api/studio/task/:id/regenerate', handler: regenerateHandler },
|
||||||
|
{ method: 'GET', pattern: '/app-api/studio/task/:id', handler: taskHandler },
|
||||||
|
]
|
||||||
@ -86,6 +86,13 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: { title: '预览', tab: 'create', hideTabBar: true, requiresAuth: true },
|
meta: { title: '预览', tab: 'create', hideTabBar: true, requiresAuth: true },
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/create/modify/:versionId',
|
||||||
|
name: 'create-modify',
|
||||||
|
component: () => import('../views/create/Modify.vue'), // U6 改进入口(studio modify/extend)
|
||||||
|
meta: { title: '改进作品', tab: 'create', hideTabBar: true, requiresAuth: true },
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
|
||||||
// —— 消息区(玩家侧站内信,闭合评审 L-1「消息无一级入口」)——
|
// —— 消息区(玩家侧站内信,闭合评审 L-1「消息无一级入口」)——
|
||||||
{
|
{
|
||||||
|
|||||||
122
game-studio/src/store/create.spec.ts
Normal file
122
game-studio/src/store/create.spec.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* create.spec.ts —— useCreateStore(studio 一步式任务链编排)单测(plan 002 U6)。
|
||||||
|
* 验 store 对 studio.yaml 的编排逻辑:loadTemplates / create / pollTask 终态停轮询 / modify / extend / regenerate / reset。
|
||||||
|
* studioApi 经 vi.mock 拦截(store 用 `import * as studioApi`),轮询用 fake timers 驱动。跑 `npm test`。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
|
||||||
|
// mock 整个 studio api 模块(store 经 `import * as studioApi` 消费)
|
||||||
|
vi.mock('../api/studio', () => ({
|
||||||
|
getTemplates: vi.fn(),
|
||||||
|
createGame: vi.fn(),
|
||||||
|
modifyGame: vi.fn(),
|
||||||
|
extendGame: vi.fn(),
|
||||||
|
getTaskChain: vi.fn(),
|
||||||
|
regenerate: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as studioApi from '../api/studio'
|
||||||
|
import { useCreateStore } from './create'
|
||||||
|
|
||||||
|
/** 取 mock 句柄(类型收窄便于断言) */
|
||||||
|
const api = studioApi as unknown as Record<string, ReturnType<typeof vi.fn>>
|
||||||
|
const POLL_INTERVAL = 1200 // 与 store 内常量一致
|
||||||
|
|
||||||
|
describe('useCreateStore(studio 一步式任务链编排)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loadTemplates → 调 studio.getTemplates 并落 templates', async () => {
|
||||||
|
api.getTemplates.mockResolvedValue([{ templateId: 'generic', name: '通用' }])
|
||||||
|
const s = useCreateStore()
|
||||||
|
await s.loadTemplates()
|
||||||
|
expect(api.getTemplates).toHaveBeenCalledOnce()
|
||||||
|
expect(s.templates).toHaveLength(1)
|
||||||
|
expect(s.templates[0].templateId).toBe('generic')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('create → 调 createGame(req) 返任务链 + 落 currentChain/progress', async () => {
|
||||||
|
api.createGame.mockResolvedValue({ id: 1001, gameId: 1, status: 1, progress: 0 })
|
||||||
|
api.getTaskChain.mockResolvedValue({ id: 1001, gameId: 1, status: 1, progress: 0 })
|
||||||
|
const s = useCreateStore()
|
||||||
|
const chain = await s.create({ title: 'T', prompt: '躲陨石', templateId: 'generic' })
|
||||||
|
expect(api.createGame).toHaveBeenCalledWith({ title: 'T', prompt: '躲陨石', templateId: 'generic' })
|
||||||
|
expect(chain.id).toBe(1001)
|
||||||
|
expect(s.currentChain?.id).toBe(1001)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pollTask 首拉即终态(status=2) → 停轮询、回填 versionId、不起 interval', async () => {
|
||||||
|
api.getTaskChain.mockResolvedValue({ id: 1001, gameId: 1, status: 2, progress: 100, versionId: 9001 })
|
||||||
|
const s = useCreateStore()
|
||||||
|
await s.pollTask(1001)
|
||||||
|
expect(s.currentChain?.status).toBe(2)
|
||||||
|
expect(s.currentChain?.versionId).toBe(9001)
|
||||||
|
expect(s.polling).toBe(false)
|
||||||
|
// 终态后不应再有定时轮询:推进时间不再触发 getTaskChain
|
||||||
|
api.getTaskChain.mockClear()
|
||||||
|
await vi.advanceTimersByTimeAsync(POLL_INTERVAL * 2)
|
||||||
|
expect(api.getTaskChain).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pollTask 进行中→定时轮询至终态停(fake timers 驱动)', async () => {
|
||||||
|
api.getTaskChain
|
||||||
|
.mockResolvedValueOnce({ id: 1001, gameId: 1, status: 1, progress: 40 })
|
||||||
|
.mockResolvedValue({ id: 1001, gameId: 1, status: 2, progress: 100, versionId: 9001 })
|
||||||
|
const s = useCreateStore()
|
||||||
|
await s.pollTask(1001) // 首拉:进行中(40) → 起 interval
|
||||||
|
expect(s.polling).toBe(true)
|
||||||
|
expect(s.progress).toBe(40)
|
||||||
|
await vi.advanceTimersByTimeAsync(POLL_INTERVAL) // 第二拉:终态 → 停
|
||||||
|
expect(s.currentChain?.status).toBe(2)
|
||||||
|
expect(s.polling).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('modify → 调 modifyGame(req) 落 currentChain', async () => {
|
||||||
|
const req = { baseVersionId: 9001, mode: 'deterministic' as const, target: { kind: 'config' as const, path: '/config/speed' }, payload: { value: { speed: 5 } } }
|
||||||
|
api.modifyGame.mockResolvedValue({ id: 1002, gameId: 1, status: 1, progress: 0, baseVersionId: 9001 })
|
||||||
|
api.getTaskChain.mockResolvedValue({ id: 1002, gameId: 1, status: 1, progress: 0, baseVersionId: 9001 })
|
||||||
|
const s = useCreateStore()
|
||||||
|
const chain = await s.modify(req)
|
||||||
|
expect(api.modifyGame).toHaveBeenCalledWith(req)
|
||||||
|
expect(chain.baseVersionId).toBe(9001)
|
||||||
|
expect(s.currentChain?.id).toBe(1002)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extend → 调 extendGame(req) 落 currentChain', async () => {
|
||||||
|
const req = { baseVersionId: 9001, intent: '再加一关' }
|
||||||
|
api.extendGame.mockResolvedValue({ id: 1003, gameId: 1, status: 1, progress: 0, baseVersionId: 9001 })
|
||||||
|
api.getTaskChain.mockResolvedValue({ id: 1003, gameId: 1, status: 1, progress: 0, baseVersionId: 9001 })
|
||||||
|
const s = useCreateStore()
|
||||||
|
const chain = await s.extend(req)
|
||||||
|
expect(api.extendGame).toHaveBeenCalledWith(req)
|
||||||
|
expect(chain.id).toBe(1003)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('regenerate → 调 regenerate(id) 落新链', async () => {
|
||||||
|
api.regenerate.mockResolvedValue({ id: 1004, gameId: 1, status: 1, progress: 0, retryOf: 1001 })
|
||||||
|
api.getTaskChain.mockResolvedValue({ id: 1004, gameId: 1, status: 1, progress: 0, retryOf: 1001 })
|
||||||
|
const s = useCreateStore()
|
||||||
|
const chain = await s.regenerate(1001)
|
||||||
|
expect(api.regenerate).toHaveBeenCalledWith(1001)
|
||||||
|
expect(chain.retryOf).toBe(1001)
|
||||||
|
expect(s.currentChain?.id).toBe(1004)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reset → 清 currentChain/progress + 停轮询', async () => {
|
||||||
|
api.getTaskChain.mockResolvedValue({ id: 1001, gameId: 1, status: 1, progress: 30 })
|
||||||
|
const s = useCreateStore()
|
||||||
|
await s.pollTask(1001)
|
||||||
|
expect(s.polling).toBe(true)
|
||||||
|
s.reset()
|
||||||
|
expect(s.currentChain).toBeNull()
|
||||||
|
expect(s.progress).toBe(0)
|
||||||
|
expect(s.polling).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,25 +1,39 @@
|
|||||||
/**
|
/**
|
||||||
* useCreateStore —— 一句话生成(模板 + 异步任务 + 进度轮询)。
|
* useCreateStore —— 一步式创作(studio.yaml /studio/*:模板 + 异步任务链 + 进度轮询)。
|
||||||
* 对应 spec §3.2:state templates/currentTask/progress;actions loadTemplates/generate/pollTask/cancel/retry。
|
* 对应 plan 002 U6 / execution §5.6:state templates/currentChain/progress;
|
||||||
* 异步生成走「提交→轮询 getTask→终态」,轮询计时器在终态/取消/卸载时务必清理(防泄漏)。
|
* actions loadTemplates/create/modify/extend/pollTask/regenerate/reset。
|
||||||
|
*
|
||||||
|
* 创作链路(改源不改产物,产新预览版):
|
||||||
|
* create → 一步式(draft+generate 封装),返回任务链;
|
||||||
|
* modify → 改源(deterministic 覆写 / regenerate-module 重生成单 behavior),产新预览版;
|
||||||
|
* extend → additive 内容扩展;
|
||||||
|
* 三者均返 StudioTaskChain,前端轮询 task/{id} 至终态(status=2 完成取 versionId / 3 失败)。
|
||||||
|
* 轮询计时器在终态/卸载时务必清理(防泄漏)。studio 契约无「取消」端点,失败经 regenerate 重试。
|
||||||
*/
|
*/
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import * as aigcApi from '../api/aigc'
|
import * as studioApi from '../api/studio'
|
||||||
import type { AigcTemplate, AigcTask, AigcTaskStatus } from '../api/types'
|
import type {
|
||||||
|
StudioTemplate,
|
||||||
|
StudioTaskChain,
|
||||||
|
StudioTaskChainStatus,
|
||||||
|
StudioCreateReq,
|
||||||
|
StudioModifyReq,
|
||||||
|
StudioExtendReq,
|
||||||
|
} from '../api/types'
|
||||||
|
|
||||||
/** 终态集合:succeeded(2)/failed(3)/timed_out(4)/canceled(5) → 停止轮询 */
|
/** 终态集合:完成(2)/失败(3) → 停止轮询(studio 任务链无 4/5) */
|
||||||
const TERMINAL: ReadonlySet<AigcTaskStatus> = new Set<AigcTaskStatus>([2, 3, 4, 5])
|
const TERMINAL: ReadonlySet<StudioTaskChainStatus> = new Set<StudioTaskChainStatus>([2, 3])
|
||||||
|
|
||||||
/** 轮询间隔(毫秒)。mock 进度递增节奏与之匹配(见 mock/aigc)。 */
|
/** 轮询间隔(毫秒)。mock 进度递增节奏与之匹配(见 mock/studio)。 */
|
||||||
const POLL_INTERVAL = 1200
|
const POLL_INTERVAL = 1200
|
||||||
|
|
||||||
export const useCreateStore = defineStore('create', () => {
|
export const useCreateStore = defineStore('create', () => {
|
||||||
/** 玩法模板列表 */
|
/** 玩法模板列表(透传 aigc 模板;一句话直生可不选) */
|
||||||
const templates = ref<AigcTemplate[]>([])
|
const templates = ref<StudioTemplate[]>([])
|
||||||
/** 当前生成任务(轮询对象) */
|
/** 当前创作任务链(轮询对象) */
|
||||||
const currentTask = ref<AigcTask | null>(null)
|
const currentChain = ref<StudioTaskChain | null>(null)
|
||||||
/** 进度百分比(0-100,进度条用,冗余自 currentTask.progress 便于直接绑定) */
|
/** 进度百分比(0-100,进度条用,冗余自 currentChain.progress 便于直接绑定) */
|
||||||
const progress = ref<number>(0)
|
const progress = ref<number>(0)
|
||||||
/** 是否轮询中 */
|
/** 是否轮询中 */
|
||||||
const polling = ref<boolean>(false)
|
const polling = ref<boolean>(false)
|
||||||
@ -38,18 +52,18 @@ export const useCreateStore = defineStore('create', () => {
|
|||||||
|
|
||||||
/** 加载玩法模板列表 */
|
/** 加载玩法模板列表 */
|
||||||
async function loadTemplates() {
|
async function loadTemplates() {
|
||||||
templates.value = await aigcApi.getTemplates()
|
templates.value = await studioApi.getTemplates()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拉取一次任务状态并更新 state;命中终态自动停轮询。
|
* 拉取一次任务链状态并更新 state;命中终态自动停轮询。
|
||||||
* @param taskId 任务 ID
|
* @param id 任务链 ID
|
||||||
*/
|
*/
|
||||||
async function fetchOnce(taskId: number) {
|
async function fetchOnce(id: number) {
|
||||||
const task = await aigcApi.getTask(taskId)
|
const chain = await studioApi.getTaskChain(id)
|
||||||
currentTask.value = task
|
currentChain.value = chain
|
||||||
progress.value = task.progress
|
progress.value = chain.progress
|
||||||
if (TERMINAL.has(task.status)) {
|
if (TERMINAL.has(chain.status)) {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -57,81 +71,87 @@ export const useCreateStore = defineStore('create', () => {
|
|||||||
/**
|
/**
|
||||||
* 启动轮询(提交后或进入进度页时调用)。
|
* 启动轮询(提交后或进入进度页时调用)。
|
||||||
* 先立刻拉一次(避免首屏空白),再定时轮询直至终态。
|
* 先立刻拉一次(避免首屏空白),再定时轮询直至终态。
|
||||||
* @param taskId 任务 ID
|
* @param id 任务链 ID
|
||||||
*/
|
*/
|
||||||
async function pollTask(taskId: number) {
|
async function pollTask(id: number) {
|
||||||
// 切换任务前先清理旧轮询
|
// 切换任务前先清理旧轮询
|
||||||
stopPolling()
|
stopPolling()
|
||||||
polling.value = true
|
polling.value = true
|
||||||
await fetchOnce(taskId)
|
await fetchOnce(id)
|
||||||
// 已是终态则无需起定时器
|
// 已是终态则无需起定时器
|
||||||
if (!polling.value) return
|
if (!polling.value) return
|
||||||
timer = setInterval(() => {
|
timer = setInterval(() => {
|
||||||
// 轮询内异常已被 axios 拦截器 Toast;这里吞掉 reject 防止未捕获
|
// 轮询内异常已被 axios 拦截器 Toast;这里吞掉 reject 防止未捕获
|
||||||
void fetchOnce(taskId).catch(() => {})
|
void fetchOnce(id).catch(() => {})
|
||||||
}, POLL_INTERVAL)
|
}, POLL_INTERVAL)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提交生成任务并自动开始轮询,返回 taskId(供路由跳转 /create/task/:taskId)。
|
* 一步式创作(draft+generate 封装),自动开始轮询,返回任务链(含 id/gameId)。
|
||||||
* @param prompt 一句话 Prompt
|
* @param req StudioCreateReq(title/prompt 必填,templateId 缺省 generic,assetContext 六类素材)
|
||||||
* @param templateId 玩法模板 ID(可选,缺省 generic:W-CLEAN 废玩法模板层后由后端归一)
|
|
||||||
* @param extra 关联草稿信息(gameId/versionId)
|
|
||||||
*/
|
*/
|
||||||
async function generate(
|
async function create(req: StudioCreateReq): Promise<StudioTaskChain> {
|
||||||
prompt: string,
|
const chain = await studioApi.createGame(req)
|
||||||
templateId?: string,
|
currentChain.value = chain
|
||||||
extra?: { gameId?: number; versionId?: number },
|
progress.value = chain.progress
|
||||||
): Promise<number> {
|
void pollTask(chain.id)
|
||||||
const resp = await aigcApi.generate(prompt, templateId, extra)
|
return chain
|
||||||
progress.value = 0
|
|
||||||
// 不阻塞返回 taskId:轮询在后台进行
|
|
||||||
void pollTask(resp.taskId)
|
|
||||||
return resp.taskId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消当前/指定任务(仅 queued/running 可取消)。成功后停轮询。
|
* 改源(产新预览版,不动 currentVersion),自动轮询,返回任务链。
|
||||||
* @param taskId 任务 ID
|
* @param req StudioModifyReq(baseVersionId + mode + target + payload)
|
||||||
*/
|
*/
|
||||||
async function cancel(taskId: number): Promise<boolean> {
|
async function modify(req: StudioModifyReq): Promise<StudioTaskChain> {
|
||||||
const ok = await aigcApi.cancelTask(taskId)
|
const chain = await studioApi.modifyGame(req)
|
||||||
if (ok) {
|
currentChain.value = chain
|
||||||
stopPolling()
|
progress.value = chain.progress
|
||||||
// 立即刷新一次拿到 canceled 终态
|
void pollTask(chain.id)
|
||||||
await fetchOnce(taskId).catch(() => {})
|
return chain
|
||||||
}
|
|
||||||
return ok
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重试任务(仅 failed/timed_out 可重试,新建任务),返回新 taskId 并自动轮询。
|
* 内容扩展(additive),自动轮询,返回任务链。
|
||||||
* @param taskId 原任务 ID
|
* @param req StudioExtendReq(baseVersionId + intent)
|
||||||
*/
|
*/
|
||||||
async function retry(taskId: number): Promise<number> {
|
async function extend(req: StudioExtendReq): Promise<StudioTaskChain> {
|
||||||
const resp = await aigcApi.retryTask(taskId)
|
const chain = await studioApi.extendGame(req)
|
||||||
progress.value = 0
|
currentChain.value = chain
|
||||||
void pollTask(resp.taskId)
|
progress.value = chain.progress
|
||||||
return resp.taskId
|
void pollTask(chain.id)
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重生成(失败/不满意时重试,新建任务链,retryOf 指原链),返回新任务链并自动轮询。
|
||||||
|
* @param id 原任务链 ID
|
||||||
|
*/
|
||||||
|
async function regenerate(id: number): Promise<StudioTaskChain> {
|
||||||
|
const chain = await studioApi.regenerate(id)
|
||||||
|
currentChain.value = chain
|
||||||
|
progress.value = chain.progress
|
||||||
|
void pollTask(chain.id)
|
||||||
|
return chain
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重置(离开创作流时调用,清理状态与计时器) */
|
/** 重置(离开创作流时调用,清理状态与计时器) */
|
||||||
function reset() {
|
function reset() {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
currentTask.value = null
|
currentChain.value = null
|
||||||
progress.value = 0
|
progress.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
templates,
|
templates,
|
||||||
currentTask,
|
currentChain,
|
||||||
progress,
|
progress,
|
||||||
polling,
|
polling,
|
||||||
loadTemplates,
|
loadTemplates,
|
||||||
generate,
|
create,
|
||||||
|
modify,
|
||||||
|
extend,
|
||||||
pollTask,
|
pollTask,
|
||||||
cancel,
|
regenerate,
|
||||||
retry,
|
|
||||||
reset,
|
reset,
|
||||||
stopPolling,
|
stopPolling,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,7 +86,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
/** 用户 ID:真实登录持久化优先,否则 mock 固定 '1001' */
|
/** 用户 ID:真实登录持久化优先,否则 mock 固定 '1001' */
|
||||||
const userId = ref<string>(resolveInitUserId())
|
const userId = ref<string>(resolveInitUserId())
|
||||||
/** 昵称(真实登录持久化优先,否则 mock 默认) */
|
/** 昵称(真实登录持久化优先,否则 mock 默认) */
|
||||||
const nickname = ref<string>(lsGet(LS_NICKNAME) ?? '绘境创作者')
|
const nickname = ref<string>(lsGet(LS_NICKNAME) ?? '造梦创作者')
|
||||||
/** 头像(mock 固定占位;passport 暂不回头像,保留内存态) */
|
/** 头像(mock 固定占位;passport 暂不回头像,保留内存态) */
|
||||||
const avatar = ref<string>('')
|
const avatar = ref<string>('')
|
||||||
/** 创作者白名单位:0玩家 1创作者(拍板4;前端据此渲染创作入口)。真实登录持久化优先,否则 0。 */
|
/** 创作者白名单位:0玩家 1创作者(拍板4;前端据此渲染创作入口)。真实登录持久化优先,否则 0。 */
|
||||||
|
|||||||
99
game-studio/src/views/create/Create.spec.ts
Normal file
99
game-studio/src/views/create/Create.spec.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Create.spec.ts —— 创作入口页组件单测(plan 002 U6 命名交付件)。
|
||||||
|
* 验:渲染 → 填 Prompt 启用提交 → 点提交调 createStore.create(一步式) → router.push 进度页。
|
||||||
|
* 经 mock vue-router/studioApi 隔离;child 组件 stub。跑 `npm test`。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
// router push 间谍
|
||||||
|
const push = vi.fn()
|
||||||
|
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
|
||||||
|
|
||||||
|
// mock studio api(让真 store 无需 HTTP 即可跑)
|
||||||
|
vi.mock('../../api/studio', () => ({
|
||||||
|
getTemplates: vi.fn().mockResolvedValue([{ templateId: 'generic', name: '通用', description: '一句话直生', examplePrompt: '做个躲陨石' }]),
|
||||||
|
createGame: vi.fn().mockResolvedValue({ id: 1001, gameId: 1, status: 1, progress: 0 }),
|
||||||
|
getTaskChain: vi.fn().mockResolvedValue({ id: 1001, gameId: 1, status: 1, progress: 0 }),
|
||||||
|
modifyGame: vi.fn(),
|
||||||
|
extendGame: vi.fn(),
|
||||||
|
regenerate: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as studioApi from '../../api/studio'
|
||||||
|
import Create from './Create.vue'
|
||||||
|
|
||||||
|
const api = studioApi as unknown as Record<string, ReturnType<typeof vi.fn>>
|
||||||
|
const i18n = createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': {} }, missingWarn: false, fallbackWarn: false })
|
||||||
|
|
||||||
|
/** 可点击的 AppButton stub(透传 disabled + 转发 click,供提交测试) */
|
||||||
|
const AppButtonStub = {
|
||||||
|
props: ['disabled', 'loading', 'block', 'size', 'type'],
|
||||||
|
emits: ['click'],
|
||||||
|
template: '<button class="app-btn" :disabled="disabled" @click="$emit(\'click\')"><slot/></button>',
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountCreate() {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
return mount(Create, {
|
||||||
|
global: {
|
||||||
|
plugins: [i18n],
|
||||||
|
stubs: { LoadingBar: true, EmptyState: true, Icon: true, AppButton: AppButtonStub },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Create.vue(一步式创作入口)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
push.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('挂载即拉模板(studio.getTemplates)', async () => {
|
||||||
|
mountCreate()
|
||||||
|
await flushPromises()
|
||||||
|
expect(api.getTemplates).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Prompt 空时提交禁用;填入后启用', async () => {
|
||||||
|
const w = mountCreate()
|
||||||
|
await flushPromises()
|
||||||
|
const btn = w.find('.create-foot .app-btn')
|
||||||
|
expect(btn.attributes('disabled')).toBeDefined() // 空 Prompt → 禁用
|
||||||
|
await w.find('textarea.prompt-input').setValue('做一个躲避陨石的小游戏')
|
||||||
|
expect(btn.attributes('disabled')).toBeUndefined() // 有 Prompt → 启用
|
||||||
|
})
|
||||||
|
|
||||||
|
it('提交 → 调 studio.createGame(一步式) 并 router.push 进度页(带 gameId)', async () => {
|
||||||
|
const w = mountCreate()
|
||||||
|
await flushPromises()
|
||||||
|
await w.find('textarea.prompt-input').setValue('做一个躲避陨石的小游戏')
|
||||||
|
await w.find('.create-foot .app-btn').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
// 一步式:createGame 被调(title 取 Prompt 前 30 字、templateId 缺省 generic)
|
||||||
|
expect(api.createGame).toHaveBeenCalledOnce()
|
||||||
|
const arg = api.createGame.mock.calls[0][0]
|
||||||
|
expect(arg.prompt).toBe('做一个躲避陨石的小游戏')
|
||||||
|
expect(arg.templateId).toBe('generic')
|
||||||
|
// 跳进度页,带任务链 id + gameId
|
||||||
|
expect(push).toHaveBeenCalledOnce()
|
||||||
|
const nav = push.mock.calls[0][0]
|
||||||
|
expect(nav.name).toBe('create-task')
|
||||||
|
expect(nav.params.taskId).toBe('1001')
|
||||||
|
expect(nav.query.gameId).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不走旧两步:不调 project.create(仅一步式 studio.create)', async () => {
|
||||||
|
const w = mountCreate()
|
||||||
|
await flushPromises()
|
||||||
|
await w.find('textarea.prompt-input').setValue('点目标小游戏')
|
||||||
|
await w.find('.create-foot .app-btn').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(api.createGame).toHaveBeenCalled()
|
||||||
|
// 旧两步的 aigc.generate 已不在创作流(store 仅用 studio.*)——createGame 是唯一生成入口
|
||||||
|
expect(api.modifyGame).not.toHaveBeenCalled()
|
||||||
|
expect(api.extendGame).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -3,23 +3,20 @@
|
|||||||
* 创作入口页(路由 /create,spec §3.1 / §4.2 创作者闭环第 1 步)
|
* 创作入口页(路由 /create,spec §3.1 / §4.2 创作者闭环第 1 步)
|
||||||
* ----------------------------------------------------------------------------
|
* ----------------------------------------------------------------------------
|
||||||
* 职责:
|
* 职责:
|
||||||
* 1. 拉取玩法模板列表(aigc.getTemplates,契约 GET /app-api/aigc/template/list,MVP 3-5 个)。
|
* 1. 拉取玩法模板列表(studio.getTemplates,契约 GET /app-api/studio/template/list,MVP 3-5 个)。
|
||||||
* 2. 选模板 + 一句话自然语言描述输入 + 示例 Prompt 提示(点击示例可一键填入)。
|
* 2. 选模板 + 一句话自然语言描述输入 + 示例 Prompt 提示(点击示例可一键填入)。
|
||||||
* 3. 提交生成:先建草稿项目(project.create,契约 POST /app-api/project)拿 projectId,
|
* 3. 一步式创作:create.create → 契约 POST /app-api/studio/create(draft+generate 封装,一次调用拿任务链),
|
||||||
* 再投递生成任务(create.generate → 契约 POST /app-api/aigc/generate)拿 taskId,
|
* 最后跳进度页 /create/task/:taskChainId(携带任务链返回的 gameId 便于终态后续链路)。
|
||||||
* 最后跳进度页 /create/task/:taskId(携带 gameId 便于终态后续链路)。
|
|
||||||
*
|
*
|
||||||
* 契约纪律:
|
* 契约纪律:
|
||||||
* - 仅消费 useCreateStore / useProjectStore(地基 store),不直连后端、不发明端点。
|
* - 仅消费 useCreateStore(地基 store),不直连后端、不发明端点。
|
||||||
* - 生成请求体严格贴 AigcGenerateReqVO:prompt + templateId(+ gameId 关联草稿)。
|
* - 创作请求体严格贴 StudioCreateReqVO:title + prompt 必填(templateId 缺省 generic,assetContext 六类素材)。
|
||||||
* - 创建草稿请求体严格贴 ProjectCreateReqVO:title + templateId(均必填)。
|
|
||||||
*/
|
*/
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useCreateStore } from '../../store/create'
|
import { useCreateStore } from '../../store/create'
|
||||||
import { useProjectStore } from '../../store/project'
|
import type { StudioTemplate } from '../../api/types'
|
||||||
import type { AigcTemplate } from '../../api/types'
|
|
||||||
import { LoadingBar, EmptyState, AppButton } from '../../components'
|
import { LoadingBar, EmptyState, AppButton } from '../../components'
|
||||||
import Icon from '../../components/Icon.vue'
|
import Icon from '../../components/Icon.vue'
|
||||||
|
|
||||||
@ -28,7 +25,6 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const createStore = useCreateStore()
|
const createStore = useCreateStore()
|
||||||
const projectStore = useProjectStore()
|
|
||||||
|
|
||||||
/** 当前选中的模板 ID(默认空,需用户主动选) */
|
/** 当前选中的模板 ID(默认空,需用户主动选) */
|
||||||
const selectedTemplateId = ref<string>('')
|
const selectedTemplateId = ref<string>('')
|
||||||
@ -38,14 +34,14 @@ const prompt = ref<string>('')
|
|||||||
const loadingTpl = ref<boolean>(false)
|
const loadingTpl = ref<boolean>(false)
|
||||||
/** 模板加载失败标记(用于空态重试) */
|
/** 模板加载失败标记(用于空态重试) */
|
||||||
const tplError = ref<boolean>(false)
|
const tplError = ref<boolean>(false)
|
||||||
/** 提交中(建草稿 + 投递生成期间禁用按钮,防重复提交) */
|
/** 提交中(一步式创作期间禁用按钮,防重复提交) */
|
||||||
const submitting = ref<boolean>(false)
|
const submitting = ref<boolean>(false)
|
||||||
|
|
||||||
/** Prompt 最大长度(贴契约 AigcGenerateReqVO.prompt maxLength=1000) */
|
/** Prompt 最大长度(贴契约 StudioCreateReqVO.prompt maxLength=1000) */
|
||||||
const PROMPT_MAX = 1000
|
const PROMPT_MAX = 1000
|
||||||
|
|
||||||
/** 当前选中的模板对象(用于取示例 Prompt / 作为草稿标题兜底) */
|
/** 当前选中的模板对象(用于取示例 Prompt / 作为草稿标题兜底) */
|
||||||
const selectedTemplate = computed<AigcTemplate | undefined>(() =>
|
const selectedTemplate = computed<StudioTemplate | undefined>(() =>
|
||||||
createStore.templates.find((t) => t.templateId === selectedTemplateId.value),
|
createStore.templates.find((t) => t.templateId === selectedTemplateId.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -97,34 +93,29 @@ function goFeed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提交生成(创作者闭环核心动作)。
|
* 一步式创作(创作者闭环核心动作)。
|
||||||
* 步骤:建草稿项目 → 拿 projectId 作为 gameId → 投递生成任务 → 跳进度页。
|
* 步骤:studio.create(draft+generate 封装)→ 拿任务链(id/gameId) → 跳进度页。
|
||||||
* 失败路径:任一步异常 → 错误码 Toast(拦截器)+ 解除提交锁,停留本页可重试。
|
* 失败路径:异常 → 错误码 Toast(拦截器)+ 解除提交锁,停留本页可重试。
|
||||||
*/
|
*/
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
if (!canSubmit.value) return
|
if (!canSubmit.value) return
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
// generic 桥接(W-CLEAN 废玩法模板层后无需选模板):缺省 templateId 传 'generic'(后端亦会归一),
|
// generic 桥接(一句话直生无需选模板):缺省 templateId 传 'generic'(后端亦归一),
|
||||||
// 兼容用户未选模板(template/list 返空、空态)与显式选中两种情况。
|
// 兼容用户未选模板(template/list 返空、空态)与显式选中两种情况。
|
||||||
const templateId = selectedTemplateId.value || 'generic'
|
const templateId = selectedTemplateId.value || 'generic'
|
||||||
|
// title 取 Prompt 前 30 字兜底(契约 StudioCreateReqVO.title 必填 ≤128)。
|
||||||
// 1) 建草稿项目(title 取 Prompt 前 30 字兜底)
|
|
||||||
// 契约 ProjectCreateReqVO required:[title](templateId 可选,缺省 generic)
|
|
||||||
const title = prompt.value.trim().slice(0, 30) || selectedTemplate.value?.name || '未命名作品'
|
const title = prompt.value.trim().slice(0, 30) || selectedTemplate.value?.name || '未命名作品'
|
||||||
const projectId = await projectStore.create({
|
|
||||||
title,
|
|
||||||
templateId,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 2) 投递生成任务,关联草稿(gameId=projectId);store 内部已自动开始轮询。
|
// 一步式创作:POST /app-api/studio/create → 任务链(含 id/gameId);store 内部已自动开始轮询。
|
||||||
// 契约 AigcGenerateReqVO:prompt(+ gameId 关联草稿;templateId 可选,缺省 generic)。
|
const chain = await createStore.create({ title, prompt: prompt.value.trim(), templateId })
|
||||||
const taskId = await createStore.generate(prompt.value.trim(), templateId, {
|
|
||||||
gameId: projectId,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3) 跳进度页(携带 gameId,供终态后预览/发布链路使用)
|
// 跳进度页(携带任务链返回的 gameId,供终态后预览/发布链路使用)。
|
||||||
router.push({ name: 'create-task', params: { taskId: String(taskId) }, query: { gameId: String(projectId) } })
|
router.push({
|
||||||
|
name: 'create-task',
|
||||||
|
params: { taskId: String(chain.id) },
|
||||||
|
query: chain.gameId != null ? { gameId: String(chain.gameId) } : {},
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// 错误已由拦截器 Toast;解除锁,停留本页
|
// 错误已由拦截器 Toast;解除锁,停留本页
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
|
|||||||
100
game-studio/src/views/create/Modify.spec.ts
Normal file
100
game-studio/src/views/create/Modify.spec.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Modify.spec.ts —— 改进入口组件单测(plan 002 U6)。
|
||||||
|
* 验三方式提交各构造【契约合法】请求:扩展→extend / 改玩法→modify(regenerate-module) / 调参→modify(deterministic);
|
||||||
|
* 调参 JSON 非法 → Toast 不提交。经 mock store/router/vant 隔离。跑 `npm test`。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
// router push 间谍
|
||||||
|
const push = vi.fn()
|
||||||
|
const back = vi.fn()
|
||||||
|
vi.mock('vue-router', () => ({ useRouter: () => ({ push, back }) }))
|
||||||
|
|
||||||
|
// vant showToast 间谍(JSON 非法路径校验)。
|
||||||
|
// 注:vant mock 工厂【直接返回】showToast(非嵌套在函数内),会被 vi.mock 提升到顶→须用 vi.hoisted 先建间谍,
|
||||||
|
// 否则「Cannot access 'showToast' before initialization」(router/store 的 spy 嵌在函数内惰性引用故无此问题)。
|
||||||
|
const { showToast } = vi.hoisted(() => ({ showToast: vi.fn() }))
|
||||||
|
vi.mock('vant', () => ({ showToast }))
|
||||||
|
|
||||||
|
// store 间谍(直接断言 Modify 构造的请求对象)
|
||||||
|
const modify = vi.fn().mockResolvedValue({ id: 1002, gameId: 1 })
|
||||||
|
const extend = vi.fn().mockResolvedValue({ id: 1003, gameId: 1 })
|
||||||
|
vi.mock('../../store/create', () => ({ useCreateStore: () => ({ modify, extend }) }))
|
||||||
|
|
||||||
|
import Modify from './Modify.vue'
|
||||||
|
|
||||||
|
const i18n = createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': {} }, missingWarn: false, fallbackWarn: false })
|
||||||
|
const AppButtonStub = {
|
||||||
|
props: ['disabled', 'loading', 'block', 'size', 'type'],
|
||||||
|
emits: ['click'],
|
||||||
|
template: '<button class="app-btn" :disabled="disabled" @click="$emit(\'click\')"><slot/></button>',
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountModify() {
|
||||||
|
return mount(Modify, {
|
||||||
|
props: { versionId: '9001' },
|
||||||
|
global: { plugins: [i18n], stubs: { Icon: true, AppButton: AppButtonStub } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Modify.vue(改进入口·三方式→契约请求)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
modify.mockResolvedValue({ id: 1002, gameId: 1 })
|
||||||
|
extend.mockResolvedValue({ id: 1003, gameId: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('扩展(默认 tab) → extend({baseVersionId, intent}) + 跳进度页', async () => {
|
||||||
|
const w = mountModify()
|
||||||
|
await w.find('.modify-body textarea').setValue('再加一个 boss 关卡')
|
||||||
|
await w.find('.modify-foot .app-btn').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(extend).toHaveBeenCalledWith({ baseVersionId: 9001, intent: '再加一个 boss 关卡' })
|
||||||
|
expect(modify).not.toHaveBeenCalled()
|
||||||
|
expect(push).toHaveBeenCalledOnce()
|
||||||
|
expect(push.mock.calls[0][0].params.taskId).toBe('1003')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('改玩法 tab → modify(regenerate-module, kind=behavior, payload.intent);目标空走默认首个 behavior 路径', async () => {
|
||||||
|
const w = mountModify()
|
||||||
|
await w.findAll('.modify-body .seg-item')[1].trigger('click') // 改玩法
|
||||||
|
await w.find('.modify-body textarea').setValue('把陨石改成会左右摇摆')
|
||||||
|
await w.find('.modify-foot .app-btn').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(modify).toHaveBeenCalledOnce()
|
||||||
|
const arg = modify.mock.calls[0][0]
|
||||||
|
expect(arg.mode).toBe('regenerate-module')
|
||||||
|
expect(arg.target.kind).toBe('behavior')
|
||||||
|
expect(arg.target.path).toBe('/gameDefinition/behaviors/0') // 空目标 → 默认首个
|
||||||
|
expect(arg.payload.intent).toBe('把陨石改成会左右摇摆')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('调参 tab → modify(deterministic, kind/path, payload.value 解析 JSON)', async () => {
|
||||||
|
const w = mountModify()
|
||||||
|
await w.findAll('.modify-body .seg-item')[2].trigger('click') // 调参(默认 kind=config)
|
||||||
|
const inputs = w.findAll('.modify-body input')
|
||||||
|
await inputs[0].setValue('/config/speed') // path
|
||||||
|
await w.find('.modify-body textarea').setValue('{"speed": 5}')
|
||||||
|
await w.find('.modify-foot .app-btn').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(modify).toHaveBeenCalledOnce()
|
||||||
|
const arg = modify.mock.calls[0][0]
|
||||||
|
expect(arg.mode).toBe('deterministic')
|
||||||
|
expect(arg.target).toEqual({ kind: 'config', path: '/config/speed' })
|
||||||
|
expect(arg.payload.value).toEqual({ speed: 5 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('调参 JSON 非法 → showToast 且不提交', async () => {
|
||||||
|
const w = mountModify()
|
||||||
|
await w.findAll('.modify-body .seg-item')[2].trigger('click')
|
||||||
|
await w.findAll('.modify-body input')[0].setValue('/config/speed')
|
||||||
|
await w.find('.modify-body textarea').setValue('{速度: 5}') // 非法 JSON
|
||||||
|
await w.find('.modify-foot .app-btn').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(showToast).toHaveBeenCalledOnce()
|
||||||
|
expect(modify).not.toHaveBeenCalled()
|
||||||
|
expect(push).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
335
game-studio/src/views/create/Modify.vue
Normal file
335
game-studio/src/views/create/Modify.vue
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 改进作品页(路由 /create/modify/:versionId,plan 002 U6 modify 入口)
|
||||||
|
* ----------------------------------------------------------------------------
|
||||||
|
* 职责:在某个产物版本(baseVersionId=versionId)上发起「改进」,产新预览版后跳进度页。
|
||||||
|
* 三种改进方式映射 studio 契约:
|
||||||
|
* - 扩展(extend) → POST /app-api/studio/extend(intent-only,additive 加内容,零门槛 NL);
|
||||||
|
* - 改玩法(behavior) → POST /app-api/studio/modify mode=regenerate-module(target.kind=behavior + payload.intent);
|
||||||
|
* - 调参(tweak) → POST /app-api/studio/modify mode=deterministic(target.kind∈config/asset/level + payload.value)。
|
||||||
|
*
|
||||||
|
* 契约纪律:
|
||||||
|
* - 仅消费 useCreateStore(modify/extend + 轮询封装其中),不直连后端、不发明端点/字段。
|
||||||
|
* - mode×target.kind 锁定映射由后端可信边界校验;前端按方式构造合法请求(deterministic 必带 value、regenerate-module 必带 intent)。
|
||||||
|
* - deterministic 须知源结构(path/value),属进阶;零门槛主路径=扩展/改玩法(NL)。
|
||||||
|
*/
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { showToast } from 'vant'
|
||||||
|
import { useCreateStore } from '../../store/create'
|
||||||
|
import type { StudioModifyKind } from '../../api/types'
|
||||||
|
import { AppButton } from '../../components'
|
||||||
|
import Icon from '../../components/Icon.vue'
|
||||||
|
import taskflowZh from '../../locales/taskflow.zh'
|
||||||
|
import taskflowEn from '../../locales/taskflow.en'
|
||||||
|
|
||||||
|
/** 路由 props(path 定义 props:true,versionId 即被改的 baseVersionId) */
|
||||||
|
const props = defineProps<{ versionId: string }>()
|
||||||
|
|
||||||
|
const { t } = useI18n({
|
||||||
|
useScope: 'local',
|
||||||
|
messages: { 'zh-CN': taskflowZh, 'en-US': taskflowEn },
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const createStore = useCreateStore()
|
||||||
|
|
||||||
|
/** 被改的 base 产物版本 ID(契约 modify/extend baseVersionId) */
|
||||||
|
const baseVersionId = computed(() => Number(props.versionId))
|
||||||
|
|
||||||
|
/** 当前改进方式 tab */
|
||||||
|
type RefineTab = 'extend' | 'behavior' | 'tweak'
|
||||||
|
const tab = ref<RefineTab>('extend')
|
||||||
|
|
||||||
|
/** 扩展 / 改玩法 的自然语言意图 */
|
||||||
|
const intent = ref<string>('')
|
||||||
|
/** 改玩法的目标行为(可空,默认首个 behavior 路径) */
|
||||||
|
const targetInput = ref<string>('')
|
||||||
|
/** 调参:目标类别(config/asset/level) */
|
||||||
|
const tweakKind = ref<Exclude<StudioModifyKind, 'behavior'>>('config')
|
||||||
|
/** 调参:源项目 JSON 指针路径 */
|
||||||
|
const tweakPath = ref<string>('')
|
||||||
|
/** 调参:新值(JSON 文本,提交时解析) */
|
||||||
|
const tweakValue = ref<string>('')
|
||||||
|
|
||||||
|
/** 提交中(防重复提交) */
|
||||||
|
const submitting = ref<boolean>(false)
|
||||||
|
|
||||||
|
const PROMPT_MAX = 1000
|
||||||
|
|
||||||
|
/** 调参的类别选项(排除 behavior,behavior 走「改玩法」走 regenerate-module) */
|
||||||
|
const TWEAK_KINDS: { kind: Exclude<StudioModifyKind, 'behavior'>; labelKey: string }[] = [
|
||||||
|
{ kind: 'config', labelKey: 'modify.kindConfig' },
|
||||||
|
{ kind: 'asset', labelKey: 'modify.kindAsset' },
|
||||||
|
{ kind: 'level', labelKey: 'modify.kindLevel' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 是否可提交:按 tab 校验必填项 + 不在提交中 */
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
if (submitting.value) return false
|
||||||
|
if (tab.value === 'tweak') return tweakPath.value.trim().length > 0 && tweakValue.value.trim().length > 0
|
||||||
|
return intent.value.trim().length > 0 // extend / behavior 需意图
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 切 tab(清空与该 tab 无关的输入避免误带) */
|
||||||
|
function switchTab(next: RefineTab) {
|
||||||
|
tab.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回(回上一页,通常是预览页) */
|
||||||
|
function goBack() {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交改进:按 tab 构造合法请求 → 拿任务链 → 跳进度页(与 Create 同一进度/预览链路)。
|
||||||
|
* 失败路径:校验失败 Toast 不提交;请求异常由拦截器 Toast,解除提交锁。
|
||||||
|
*/
|
||||||
|
async function onSubmit() {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
let chain
|
||||||
|
if (tab.value === 'extend') {
|
||||||
|
// 扩展:intent-only additive
|
||||||
|
chain = await createStore.extend({ baseVersionId: baseVersionId.value, intent: intent.value.trim() })
|
||||||
|
} else if (tab.value === 'behavior') {
|
||||||
|
// 改玩法:regenerate-module + target.kind=behavior(path 以 / 开头按路径,否则按 id;空则默认首个 behavior 路径)
|
||||||
|
const raw = targetInput.value.trim()
|
||||||
|
const target = raw
|
||||||
|
? raw.startsWith('/')
|
||||||
|
? { kind: 'behavior' as const, path: raw }
|
||||||
|
: { kind: 'behavior' as const, id: raw }
|
||||||
|
: { kind: 'behavior' as const, path: '/gameDefinition/behaviors/0' }
|
||||||
|
chain = await createStore.modify({
|
||||||
|
baseVersionId: baseVersionId.value,
|
||||||
|
mode: 'regenerate-module',
|
||||||
|
target,
|
||||||
|
payload: { intent: intent.value.trim() },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 调参:deterministic + value(JSON 解析失败则 Toast 不提交)
|
||||||
|
let value: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
value = JSON.parse(tweakValue.value)
|
||||||
|
} catch {
|
||||||
|
showToast(t('modify.valueInvalid'))
|
||||||
|
submitting.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chain = await createStore.modify({
|
||||||
|
baseVersionId: baseVersionId.value,
|
||||||
|
mode: 'deterministic',
|
||||||
|
target: { kind: tweakKind.value, path: tweakPath.value.trim() },
|
||||||
|
payload: { value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 跳进度页(携带任务链返回的 gameId);终态完成后进预览看改进结果
|
||||||
|
router.push({
|
||||||
|
name: 'create-task',
|
||||||
|
params: { taskId: String(chain.id) },
|
||||||
|
query: chain.gameId != null ? { gameId: String(chain.gameId) } : {},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// 请求异常已由拦截器 Toast;解除锁,停留本页
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="modify-page">
|
||||||
|
<!-- 顶部状态条(沉浸页无 TabBar) -->
|
||||||
|
<header class="modify-head">
|
||||||
|
<button class="modify-back" type="button" @click="goBack" :aria-label="t('common.action.back')">
|
||||||
|
<Icon name="back" :size="22" />
|
||||||
|
</button>
|
||||||
|
<span class="modify-title">{{ t('modify.title') }}</span>
|
||||||
|
<span class="modify-base">{{ t('modify.baseLabel') }} #{{ baseVersionId }}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modify-body">
|
||||||
|
<!-- 改进方式分段(扩展 / 改玩法 / 调参) -->
|
||||||
|
<div class="seg">
|
||||||
|
<button class="seg-item" :class="{ 'is-on': tab === 'extend' }" type="button" @click="switchTab('extend')">{{ t('modify.tabExtend') }}</button>
|
||||||
|
<button class="seg-item" :class="{ 'is-on': tab === 'behavior' }" type="button" @click="switchTab('behavior')">{{ t('modify.tabBehavior') }}</button>
|
||||||
|
<button class="seg-item" :class="{ 'is-on': tab === 'tweak' }" type="button" @click="switchTab('tweak')">{{ t('modify.tabTweak') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 扩展:intent-only -->
|
||||||
|
<section v-if="tab === 'extend'" class="field">
|
||||||
|
<p class="field-hint">{{ t('modify.extendHint') }}</p>
|
||||||
|
<textarea v-model="intent" class="ta" :maxlength="PROMPT_MAX" rows="4" :placeholder="t('modify.extendPlaceholder')" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 改玩法:intent + 目标行为(可空默认首个) -->
|
||||||
|
<section v-else-if="tab === 'behavior'" class="field">
|
||||||
|
<p class="field-hint">{{ t('modify.behaviorHint') }}</p>
|
||||||
|
<textarea v-model="intent" class="ta" :maxlength="PROMPT_MAX" rows="4" :placeholder="t('modify.behaviorPlaceholder')" />
|
||||||
|
<label class="field-label">{{ t('modify.targetLabel') }}</label>
|
||||||
|
<input v-model="targetInput" class="inp" type="text" :placeholder="t('modify.targetPlaceholder')" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 调参:kind + path + value(JSON) -->
|
||||||
|
<section v-else class="field">
|
||||||
|
<label class="field-label">{{ t('modify.tweakKindLabel') }}</label>
|
||||||
|
<div class="seg seg-sm">
|
||||||
|
<button
|
||||||
|
v-for="k in TWEAK_KINDS"
|
||||||
|
:key="k.kind"
|
||||||
|
class="seg-item"
|
||||||
|
:class="{ 'is-on': tweakKind === k.kind }"
|
||||||
|
type="button"
|
||||||
|
@click="tweakKind = k.kind"
|
||||||
|
>{{ t(k.labelKey) }}</button>
|
||||||
|
</div>
|
||||||
|
<label class="field-label">{{ t('modify.tweakPathLabel') }}</label>
|
||||||
|
<input v-model="tweakPath" class="inp" type="text" :placeholder="t('modify.tweakPathPlaceholder')" />
|
||||||
|
<label class="field-label">{{ t('modify.tweakValueLabel') }}</label>
|
||||||
|
<textarea v-model="tweakValue" class="ta ta-mono" rows="3" :placeholder="t('modify.tweakValuePlaceholder')" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部提交 -->
|
||||||
|
<footer class="modify-foot">
|
||||||
|
<AppButton block size="large" :loading="submitting" :disabled="!canSubmit" @click="onSubmit">
|
||||||
|
{{ submitting ? t('modify.submitting') : t('modify.submit') }}
|
||||||
|
</AppButton>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* D4 深色科技风(token 来自全局 src/styles/tokens.css),结构参照 Create.vue / Preview.vue */
|
||||||
|
.modify-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 顶部状态条 —— */
|
||||||
|
.modify-head {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.modify-back {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.modify-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.modify-base {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 主体 —— */
|
||||||
|
.modify-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 分段控件 —— */
|
||||||
|
.seg {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.seg-sm {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.seg-item {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.seg-item.is-on {
|
||||||
|
color: var(--on-accent);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 字段区 —— */
|
||||||
|
.field {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.field-hint {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.field-label {
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.ta,
|
||||||
|
.inp {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
outline: 0;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-family: inherit;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.ta {
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
.ta-mono {
|
||||||
|
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.ta::placeholder,
|
||||||
|
.inp::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 底部提交 —— */
|
||||||
|
.modify-foot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 12px 16px calc(12px + var(--safe-bottom));
|
||||||
|
background: var(--panel);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -85,6 +85,15 @@ function goPublish() {
|
|||||||
function regenerate() {
|
function regenerate() {
|
||||||
router.replace({ name: 'create' })
|
router.replace({ name: 'create' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 改进本版本 → 改进页(U6 modify/extend 入口;versionId 作为 baseVersionId) */
|
||||||
|
function goModify() {
|
||||||
|
router.push({
|
||||||
|
name: 'create-modify',
|
||||||
|
params: { versionId: versionId.value },
|
||||||
|
query: gameId.value ? { gameId: gameId.value } : {},
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -113,8 +122,11 @@ function regenerate() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部操作:重新生成 / 去发布 -->
|
<!-- 底部操作:改进(modify/extend 入口)/ 重新生成 / 去发布 -->
|
||||||
<footer class="preview-foot">
|
<footer class="preview-foot">
|
||||||
|
<div class="preview-refine">
|
||||||
|
<AppButton type="ghost" block @click="goModify">{{ t('preview.refine') }}</AppButton>
|
||||||
|
</div>
|
||||||
<div class="preview-actions">
|
<div class="preview-actions">
|
||||||
<AppButton type="ghost" @click="regenerate">{{ t('preview.regenerate') }}</AppButton>
|
<AppButton type="ghost" @click="regenerate">{{ t('preview.regenerate') }}</AppButton>
|
||||||
<AppButton type="primary" :disabled="!canPublish" @click="goPublish">{{ t('preview.goPublish') }}</AppButton>
|
<AppButton type="primary" :disabled="!canPublish" @click="goPublish">{{ t('preview.goPublish') }}</AppButton>
|
||||||
@ -199,6 +211,9 @@ function regenerate() {
|
|||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
.preview-refine {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
.preview-actions {
|
.preview-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
87
game-studio/src/views/create/Task.spec.ts
Normal file
87
game-studio/src/views/create/Task.spec.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Task.spec.ts —— 生成进度页组件单测(plan 002 U6)。
|
||||||
|
* 验:进入即轮询;终态完成(2)→自动跳预览(versionId+gameId);终态失败(3)→重生成→跳新任务链。
|
||||||
|
* 用真 store + mock studioApi(保 currentChain 真响应式,watch 能触发)+ mock router。跑 `npm test`。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const replace = vi.fn()
|
||||||
|
const push = vi.fn()
|
||||||
|
vi.mock('vue-router', () => ({ useRouter: () => ({ replace, push }) }))
|
||||||
|
|
||||||
|
vi.mock('../../api/studio', () => ({
|
||||||
|
getTemplates: vi.fn(),
|
||||||
|
createGame: vi.fn(),
|
||||||
|
modifyGame: vi.fn(),
|
||||||
|
extendGame: vi.fn(),
|
||||||
|
getTaskChain: vi.fn(),
|
||||||
|
regenerate: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as studioApi from '../../api/studio'
|
||||||
|
import Task from './Task.vue'
|
||||||
|
|
||||||
|
const api = studioApi as unknown as Record<string, ReturnType<typeof vi.fn>>
|
||||||
|
const i18n = createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': {} }, missingWarn: false, fallbackWarn: false })
|
||||||
|
const AppButtonStub = {
|
||||||
|
props: ['disabled', 'loading', 'block', 'size', 'type'],
|
||||||
|
emits: ['click'],
|
||||||
|
template: '<button class="app-btn" :disabled="disabled" @click="$emit(\'click\')"><slot/></button>',
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountTask(taskId = '1001') {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
return mount(Task, {
|
||||||
|
props: { taskId },
|
||||||
|
global: {
|
||||||
|
plugins: [i18n],
|
||||||
|
stubs: { LoadingBar: true, Icon: true, AppButton: AppButtonStub, EmptyState: { template: '<div class="empty"><slot name="action"/></div>' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Task.vue(生成进度页·studio 任务链)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
replace.mockClear()
|
||||||
|
push.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('进入即对 taskChainId 启动轮询(getTaskChain)', async () => {
|
||||||
|
api.getTaskChain.mockResolvedValue({ id: 1001, gameId: 1, status: 1, progress: 20 })
|
||||||
|
mountTask('1001')
|
||||||
|
await flushPromises()
|
||||||
|
expect(api.getTaskChain).toHaveBeenCalledWith(1001)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('终态完成(2)+versionId → 自动 router.replace 跳预览(带 gameId)', async () => {
|
||||||
|
api.getTaskChain.mockResolvedValue({ id: 1001, gameId: 1, status: 2, progress: 100, versionId: 9001 })
|
||||||
|
mountTask('1001')
|
||||||
|
await flushPromises()
|
||||||
|
expect(replace).toHaveBeenCalledOnce()
|
||||||
|
const nav = replace.mock.calls[0][0]
|
||||||
|
expect(nav.name).toBe('create-preview')
|
||||||
|
expect(nav.params.versionId).toBe('9001')
|
||||||
|
expect(nav.query.gameId).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('终态失败(3) → 不跳预览;点重生成 → regenerate 并跳新任务链', async () => {
|
||||||
|
api.getTaskChain.mockResolvedValue({ id: 1001, gameId: 1, status: 3, progress: 0 })
|
||||||
|
api.regenerate.mockResolvedValue({ id: 1009, gameId: 1, status: 1, progress: 0, retryOf: 1001 })
|
||||||
|
const w = mountTask('1001')
|
||||||
|
await flushPromises()
|
||||||
|
expect(replace).not.toHaveBeenCalled() // 失败不跳预览
|
||||||
|
// 失败态有重生成按钮(EmptyState #action 内 primary 按钮)
|
||||||
|
const btns = w.findAll('.app-btn')
|
||||||
|
expect(btns.length).toBeGreaterThan(0)
|
||||||
|
await btns[btns.length - 1].trigger('click') // 重生成(末按钮=primary)
|
||||||
|
await flushPromises()
|
||||||
|
expect(api.regenerate).toHaveBeenCalledWith(1001)
|
||||||
|
const nav = replace.mock.calls[0][0]
|
||||||
|
expect(nav.name).toBe('create-task')
|
||||||
|
expect(nav.params.taskId).toBe('1009')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,25 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 生成进度页(路由 /create/task/:taskId,spec §3.1 / §4.2 创作者闭环第 2 步)
|
* 生成进度页(路由 /create/task/:taskId,taskId 语义=taskChainId,spec §3.1 / §4.2 创作者闭环第 2 步)
|
||||||
* ----------------------------------------------------------------------------
|
* ----------------------------------------------------------------------------
|
||||||
* 职责:
|
* 职责:
|
||||||
* 1. 进入即对该 taskId 启动轮询(create.pollTask → 契约 GET /app-api/aigc/task/{id}),
|
* 1. 进入即对该 taskChainId 启动轮询(create.pollTask → 契约 GET /app-api/studio/task/{id}),
|
||||||
* 用 LoadingBar 展示「步骤文案 + 百分比」,状态机驱动 UI。
|
* 用 LoadingBar 展示「步骤文案 + 百分比」,状态机驱动 UI。
|
||||||
* 2. 终态 succeeded(2):自动跳生成后预览页 /create/preview/:versionId(携带 gameId)。
|
* 2. 终态 完成(2):自动跳生成后预览页 /create/preview/:versionId(携带 gameId)。
|
||||||
* 3. 终态 failed(3)/timed_out(4):展示失败态 + 失败原因(FailureReason 映射可读提示)
|
* 3. 终态 失败(3):展示失败态 + 重生成按钮(create.regenerate → 契约 POST /app-api/studio/task/{id}/regenerate,新建任务链)。
|
||||||
* + 重试按钮(create.retry → 契约 POST /app-api/aigc/task/{id}/retry,新建任务)。
|
* 4. 进行中 初始(0)/生成中(1):展示进度(studio 契约无「取消」端点,故不提供取消)。
|
||||||
* 4. 进行中 queued(0)/running(1):提供取消按钮(create.cancel → 契约 POST .../cancel)。
|
|
||||||
*
|
*
|
||||||
* 契约纪律:
|
* 契约纪律:
|
||||||
* - 仅消费 useCreateStore(轮询/取消/重试逻辑封装其中),不直连后端、不发明端点/字段。
|
* - 仅消费 useCreateStore(轮询/重生成逻辑封装其中),不直连后端、不发明端点/字段。
|
||||||
* - 状态机取值与 AigcTaskRespVO.status 一致:0 queued/1 running/2 succeeded/3 failed/4 timed_out/5 canceled。
|
* - 状态机取值与 StudioTaskChainRespVO.status 一致:0 初始 / 1 生成中 / 2 完成 / 3 失败。
|
||||||
* - FailureReason 映射严格取契约 aigc.yaml FailureReason 七种枚举,不私造。
|
|
||||||
*/
|
*/
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useCreateStore } from '../../store/create'
|
import { useCreateStore } from '../../store/create'
|
||||||
import type { AigcTaskStatus, AigcFailureReason } from '../../api/types'
|
import type { StudioTaskChainStatus } from '../../api/types'
|
||||||
import { LoadingBar, AppButton, EmptyState } from '../../components'
|
import { LoadingBar, AppButton, EmptyState } from '../../components'
|
||||||
import Icon from '../../components/Icon.vue'
|
import Icon from '../../components/Icon.vue'
|
||||||
import taskflowZh from '../../locales/taskflow.zh'
|
import taskflowZh from '../../locales/taskflow.zh'
|
||||||
@ -38,26 +36,22 @@ const { t } = useI18n({
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const createStore = useCreateStore()
|
const createStore = useCreateStore()
|
||||||
|
|
||||||
/** 是否正在执行取消(防重复点击) */
|
/** 是否正在执行重生成(防重复点击) */
|
||||||
const cancelling = ref<boolean>(false)
|
const regenerating = ref<boolean>(false)
|
||||||
/** 是否正在执行重试(防重复点击) */
|
|
||||||
const retrying = ref<boolean>(false)
|
|
||||||
|
|
||||||
/** 当前 taskId(数字化) */
|
/** 当前任务链 ID(数字化,=taskChainId) */
|
||||||
const taskId = computed(() => Number(props.taskId))
|
const chainId = computed(() => Number(props.taskId))
|
||||||
|
|
||||||
/** 当前任务(来自 store;轮询持续刷新) */
|
/** 当前任务链(来自 store;轮询持续刷新) */
|
||||||
const task = computed(() => createStore.currentTask)
|
const chain = computed(() => createStore.currentChain)
|
||||||
/** 当前状态机值(无任务时视为 queued 占位) */
|
/** 当前状态机值(无任务链时视为初始(0)占位) */
|
||||||
const status = computed<AigcTaskStatus>(() => task.value?.status ?? 0)
|
const status = computed<StudioTaskChainStatus>(() => chain.value?.status ?? 0)
|
||||||
|
|
||||||
/** 是否终态成功 */
|
/** 是否终态完成 */
|
||||||
const isSucceeded = computed(() => status.value === 2)
|
const isSucceeded = computed(() => status.value === 2)
|
||||||
/** 是否终态失败(failed/timed_out) */
|
/** 是否终态失败 */
|
||||||
const isFailed = computed(() => status.value === 3 || status.value === 4)
|
const isFailed = computed(() => status.value === 3)
|
||||||
/** 是否已取消 */
|
/** 是否进行中(初始/生成中) */
|
||||||
const isCanceled = computed(() => status.value === 5)
|
|
||||||
/** 是否进行中(queued/running) */
|
|
||||||
const isRunning = computed(() => status.value === 0 || status.value === 1)
|
const isRunning = computed(() => status.value === 0 || status.value === 1)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -79,33 +73,13 @@ const stepText = computed(() => {
|
|||||||
return t('task.step.succeeded')
|
return t('task.step.succeeded')
|
||||||
case 3:
|
case 3:
|
||||||
return t('task.step.failed')
|
return t('task.step.failed')
|
||||||
case 4:
|
|
||||||
return t('task.step.timedOut')
|
|
||||||
case 5:
|
|
||||||
return t('task.step.canceled')
|
|
||||||
default:
|
default:
|
||||||
return t('task.step.fallback')
|
return t('task.step.fallback')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/** 失败提示文案(studio 任务链无 failureReason 结构化字段,用通用兜底提示)。 */
|
||||||
* 失败原因可读映射(严格对齐契约 aigc.yaml FailureReason 七种枚举)。
|
const failureText = computed(() => t('task.failure.fallback'))
|
||||||
* 仅用于把结构化错误码翻译成用户提示,不新增枚举值。
|
|
||||||
* 注:放在 computed 内构建,使文案随语言切换实时重算(key 即契约枚举值,不可改)。
|
|
||||||
*/
|
|
||||||
const failureText = computed(() => {
|
|
||||||
const FAILURE_TEXT: Record<AigcFailureReason, string> = {
|
|
||||||
unsafe_prompt: t('task.failure.unsafePrompt'),
|
|
||||||
intent_unclear: t('task.failure.intentUnclear'),
|
|
||||||
no_template_match: t('task.failure.noTemplateMatch'),
|
|
||||||
config_invalid: t('task.failure.configInvalid'),
|
|
||||||
llm_error: t('task.failure.llmError'),
|
|
||||||
timeout: t('task.failure.timeout'),
|
|
||||||
asset_gen_failed: t('task.failure.assetGenFailed'),
|
|
||||||
}
|
|
||||||
const reason = task.value?.failureReason
|
|
||||||
return (reason && FAILURE_TEXT[reason]) || t('task.failure.fallback')
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 进度条颜色:成功绿 / 失败红 / 进行中琥珀(裸旧令牌改语义名,主题切换只动语义层) */
|
/** 进度条颜色:成功绿 / 失败红 / 进行中琥珀(裸旧令牌改语义名,主题切换只动语义层) */
|
||||||
const barColor = computed(() => {
|
const barColor = computed(() => {
|
||||||
@ -120,10 +94,10 @@ const barColor = computed(() => {
|
|||||||
* 发布跳转 /project/:id/publish 也需 projectId=gameId)。
|
* 发布跳转 /project/:id/publish 也需 projectId=gameId)。
|
||||||
*/
|
*/
|
||||||
watch(
|
watch(
|
||||||
() => [isSucceeded.value, task.value?.versionId] as const,
|
() => [isSucceeded.value, chain.value?.versionId] as const,
|
||||||
([ok, versionId]) => {
|
([ok, versionId]) => {
|
||||||
if (ok && versionId != null) {
|
if (ok && versionId != null) {
|
||||||
const gameId = task.value?.gameId
|
const gameId = chain.value?.gameId
|
||||||
router.replace({
|
router.replace({
|
||||||
name: 'create-preview',
|
name: 'create-preview',
|
||||||
params: { versionId: String(versionId) },
|
params: { versionId: String(versionId) },
|
||||||
@ -133,41 +107,26 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 取消生成任务(仅进行中可取消;成功后任务转 canceled 终态) */
|
|
||||||
async function onCancel() {
|
|
||||||
if (cancelling.value) return
|
|
||||||
cancelling.value = true
|
|
||||||
try {
|
|
||||||
await createStore.cancel(taskId.value)
|
|
||||||
} catch {
|
|
||||||
// 错误已由拦截器 Toast(如终态拒绝 1-101-002-001)
|
|
||||||
} finally {
|
|
||||||
cancelling.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重试生成(仅 failed/timed_out 可重试,新建任务并跳到新任务进度页)。
|
* 重生成(失败/不满意时,新建任务链并跳到新链进度页)。
|
||||||
* 携带原 gameId 关联草稿(从当前任务取)。
|
|
||||||
*/
|
*/
|
||||||
async function onRetry() {
|
async function onRegenerate() {
|
||||||
if (retrying.value) return
|
if (regenerating.value) return
|
||||||
retrying.value = true
|
regenerating.value = true
|
||||||
try {
|
try {
|
||||||
const newTaskId = await createStore.retry(taskId.value)
|
const chainNew = await createStore.regenerate(chainId.value)
|
||||||
const gameId = task.value?.gameId
|
// 跳到新任务链进度页(replace 避免回退到旧失败链)
|
||||||
// 跳到新任务进度页(replace 避免回退到旧失败任务)
|
|
||||||
router.replace({
|
router.replace({
|
||||||
name: 'create-task',
|
name: 'create-task',
|
||||||
params: { taskId: String(newTaskId) },
|
params: { taskId: String(chainNew.id) },
|
||||||
query: gameId != null ? { gameId: String(gameId) } : {},
|
query: chainNew.gameId != null ? { gameId: String(chainNew.gameId) } : {},
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
retrying.value = false
|
regenerating.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 返回创作入口(取消/失败后可重新创作) */
|
/** 返回创作入口(失败后可重新创作) */
|
||||||
function backToCreate() {
|
function backToCreate() {
|
||||||
router.replace({ name: 'create' })
|
router.replace({ name: 'create' })
|
||||||
}
|
}
|
||||||
@ -175,11 +134,11 @@ function backToCreate() {
|
|||||||
/**
|
/**
|
||||||
* 进入页面即启动轮询(pollTask 幂等:会先清理旧轮询再起新的)。
|
* 进入页面即启动轮询(pollTask 幂等:会先清理旧轮询再起新的)。
|
||||||
* 覆盖两种入口:① 从 Create 提交跳入(store 已在轮询,这里重入也安全);
|
* 覆盖两种入口:① 从 Create 提交跳入(store 已在轮询,这里重入也安全);
|
||||||
* ② 直接 URL/刷新进入(currentTask 为空,需主动起轮询)。
|
* ② 直接 URL/刷新进入(currentChain 为空,需主动起轮询)。
|
||||||
*/
|
*/
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (Number.isFinite(taskId.value)) {
|
if (Number.isFinite(chainId.value)) {
|
||||||
void createStore.pollTask(taskId.value)
|
void createStore.pollTask(chainId.value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -191,7 +150,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="task-page">
|
<div class="task-page">
|
||||||
<!-- 中部状态区:进行中 / 成功(瞬时,随即跳转)/ 失败 / 取消 -->
|
<!-- 中部状态区:进行中 / 成功(瞬时,随即跳转)/ 失败 -->
|
||||||
<div class="task-body">
|
<div class="task-body">
|
||||||
<!-- 进行中 + 成功瞬间:展示进度环境 -->
|
<!-- 进行中 + 成功瞬间:展示进度环境 -->
|
||||||
<template v-if="isRunning || isSucceeded">
|
<template v-if="isRunning || isSucceeded">
|
||||||
@ -211,14 +170,10 @@ onBeforeUnmount(() => {
|
|||||||
<p class="task-hint">
|
<p class="task-hint">
|
||||||
{{ isSucceeded ? t('task.hintSucceeded') : t('task.hintRunning') }}
|
{{ isSucceeded ? t('task.hintSucceeded') : t('task.hintRunning') }}
|
||||||
</p>
|
</p>
|
||||||
|
<!-- studio 契约无「取消」端点:进行中不提供取消,等生成完成/失败 -->
|
||||||
<!-- 进行中可取消 -->
|
|
||||||
<div v-if="isRunning" class="task-actions">
|
|
||||||
<AppButton type="ghost" :loading="cancelling" @click="onCancel">{{ t('task.cancelAction') }}</AppButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 失败态:原因 + 重试 / 返回(失败图标用 SVG close 替原 ✕ emoji,满足 no-emoji-icons 红线) -->
|
<!-- 失败态:提示 + 重新生成 / 返回(失败图标用 SVG close 替原 ✕ emoji,满足 no-emoji-icons 红线) -->
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-else-if="isFailed"
|
v-else-if="isFailed"
|
||||||
icon-name="close"
|
icon-name="close"
|
||||||
@ -228,21 +183,7 @@ onBeforeUnmount(() => {
|
|||||||
<template #action>
|
<template #action>
|
||||||
<div class="task-actions">
|
<div class="task-actions">
|
||||||
<AppButton type="ghost" @click="backToCreate">{{ t('common.action.back') }}</AppButton>
|
<AppButton type="ghost" @click="backToCreate">{{ t('common.action.back') }}</AppButton>
|
||||||
<AppButton type="primary" :loading="retrying" @click="onRetry">{{ t('task.retryAction') }}</AppButton>
|
<AppButton type="primary" :loading="regenerating" @click="onRegenerate">{{ t('task.retryAction') }}</AppButton>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</EmptyState>
|
|
||||||
|
|
||||||
<!-- 已取消态:返回创作(取消图标用 SVG ban 替原 ○ emoji) -->
|
|
||||||
<EmptyState
|
|
||||||
v-else-if="isCanceled"
|
|
||||||
icon-name="ban"
|
|
||||||
:title="t('task.canceledTitle')"
|
|
||||||
:description="t('task.canceledDesc')"
|
|
||||||
>
|
|
||||||
<template #action>
|
|
||||||
<div class="task-actions">
|
|
||||||
<AppButton type="primary" @click="backToCreate">{{ t('task.backToCreate') }}</AppButton>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
|
|||||||
16
game-studio/vitest.config.ts
Normal file
16
game-studio/vitest.config.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* vitest 配置(独立于 vite.config.ts,避开本地 mock 中间件插件——单测不需要 dev/preview 的 mock server)。
|
||||||
|
* 环境 happy-dom:供 @vue/test-utils 组件测试 + store 测试(轻量 DOM)。
|
||||||
|
* 用途:U6 创作流单测(plan 002 U6 Create.spec.ts + store 编排);跑 `npm test`。
|
||||||
|
*/
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
globals: true,
|
||||||
|
include: ['src/**/*.spec.ts'],
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user