diff --git a/tier2/games/shanhai-xunyilu/README.md b/tier2/games/shanhai-xunyilu/README.md index 51408a98..67884e9b 100644 --- a/tier2/games/shanhai-xunyilu/README.md +++ b/tier2/games/shanhai-xunyilu/README.md @@ -1,99 +1,106 @@ -# 《山海巡异录》 —— 北极星顶级线③ · M2(构筑与仿真) +# 《山海巡异录》 —— 北极星顶级线③ · M3(内容完备:双职业+进阶+meta) 卡牌构筑肉鸽(尖塔骨架 × Balatro 式乘法爽感,原创轴 = 五行相生序列链)。规格 SoT = [开发设计书](../../../docs/agent-specs/2026-07-06-北极星顶级线-卡牌构筑-开发设计书.md)(创始人 2026-07-06 已批, 定名《山海巡异录》,统一山海 IP 宇宙);本目录只承载实现,规则数值一律以 `src/data/tables.js` 表为准、代码零魔法数。 -## M2 范围(书 §12:职业甲全内容+商店删卡+存档续玩+数值 CI) +## M3 范围(书 §12:双职业+进阶 A0–A15+图鉴成就+每日+事件) -M1 单局闭环之上,这一档把「内容定容 × 数值 CI」立起来: +M2 的职业甲全内容+数值 CI 之上: -- **职业甲卡池 66 定容**(S9,三批入表、每批过静态快筛+smoke 矩阵):新增状态词缀子集 - (敌侧 易伤/虚弱/灼 + 我侧 韧,书 §5.1 ⑥ 十词缀的 M2 子集)与卡词 - (血价 hpCost/消耗 exhaust/保留 retain/回血 heal/灼联动 dmgPerEnemyBurn/chainBonus{dmg,block}); - 估值全走 `src/sim/valuation.js` 单源公式(锚=表 VALUATION,advisory)。 - 虚弱×意图诚实:展示与执行共用 effectiveIntent() 同一折算——任意时刻读到的=此刻结算即发生的。 -- **遗物 40 定容**(S10,含职业甲专属 6;数值级 6/40=15%,规则级 85%):hook 词汇 20+ - (能量上限/首回合面/链起环/每环倍率加成/周天联动/反伤/五行首打系列/对位开战词缀/商店与奖励规则件), - 聚合=数值累加/布尔 or(同 hook 多遗物自然叠加);发放=受控随机抽池;获取源四层=精英必掉/宝匣/ - **Boss 战后三选一**(幕1/2,relicReward 屏)/商店。 -- **商店与删卡经济**(S11):每幕保底 1 商店节点(map-core 配额自检);货架=卡 5(稀有度权重标价)+ - 遗物 2(未持有池)+删卡服务(基价 50、每删全局 +20——§3.2 给组合爆炸收租);卡组下限 10; - 金不足/已售/下限一律拒且零副作用。 -- **run 存档续玩**(S11):形状=契约 `SaveEnvelope`(`{schemaVersion≥1, data 纯对象}`, - `contracts/sdk-interface.d.ts`;save: 族键 65536 码元上限,W-SAVE-CAP 已入库)。快照点=战斗外任意屏 - (map/reward/relicReward/shop/shopRemove;battle 屏无快照,中断回上一节点);恢复=rngState 精确续 - (续打与不中断逐字段一致,save.test V3 实证)。**介质=影子**(localStorage 直写,`src/save.js` 文件头 - 申报):信封契约合规,正式 SDK Storage 通道切换随集成段(书 §12④,M5 门=切正式+迁移断言)。 -- **快进仿真外壳**(S8b v0 → S12 v1,`scripts/sim-shell.mjs` + `src/sim/`):矩阵扫描(职业×进阶× - 三档策略 driver)→格聚合(胜率带/Boss 回合带/采用率带/峰值伤 P99-P50/invalid 指纹/墙钟)→ - 金标构筑回归(20 套,`scripts/goldens.json`,漂移 ±8pp 按套判)。三档 driver=贪心/随机/挂机 - (与 CDP harness 同文件 `tier2/harness/card-battle.driver.js`,贪心档=九门真玩同一大脑)。 +- **154 卡定容**(S13,书 §5.2):职业甲 66 + 职业乙「璇玑使」66(链优;身份词 ifZhoutian=周天后附加 + 效果)+ 中立 22(行旅工具件)。职业池过滤=本职业+中立;起始遗物(平衡轮:甲=开明兽牙/乙=河图残卷)。 +- **进阶 A0–A15**(S14):16 级修正表(`ASCENSIONS`,累积生效,mods 13 词——敌攻/血乘区、精英配额、 + 资源面收紧、A15 构筑面收紧〔奖励二选一〕);修正全局含教学首战(R15g 抓的豁免缺口已修)。 +- **多样性 driver**(S14,书面三档补齐):链优先 chain / 保血防御 defensive(+M2 贪心/随机/挂机=五档); + 死卡口径=书面三档并集(任一档 ≥3% 采用即豁免)。 +- **事件 45**(S16,书 §5.2):幕池 12×3+通用 9;fx 七词(金/血/上限/遗物/卡/随机销卡); + 事件节点每幕 ≤2(保底件先行、增益件吃余量——96k 全矩阵实抓的配额挤兑修序)。 +- **meta 系统**(S15,最小可玩):`src/meta-core.js`(书 §8.3 第四纯逻辑模块)——图鉴(卡/遗物/敌兽, + 见过即录)、成就 40(声明式条件+解释器,词表对账单测钉死)、进阶解锁(通关 An 启 An+1 逐职业)、 + 每日一局(日期派生种子+本地最佳分,榜单走平台既有能力);局外三屏(home 选职业进阶/图鉴/成就)。 +- **存档信封 v2**(S15):data={run, meta} 双域——终局 run 域清空、meta 常存;**v1→v2 迁移链首用** + (M2 旧档=平铺快照→迁入 run 域),M5「切正式通道+迁移断言」的先手演练;meta 域独立 fail-closed + (局外进度损坏不吞续玩)。介质仍=影子(申报见 `src/save.js` 文件头),正式通道随集成段(M5 门)。 -## 数值 CI 分层(书 §6.4 M2 出口定案;墙钟=本机 darwin/arm64 实测 2026-07-06) +## 战斗绘制修复(2026-07-06 浏览器复验缺陷,M2 后修入) + +**根因**:`run-scene.js` 的 `const txt = (…) => …` 声明位于工厂 return 之后——该行永不执行,`txt` 绑定 +永陷 TDZ,首次 rebuild 即抛 ReferenceError;整屏绘制(地图/战斗/商店)全部无声失败,只有不依赖 txt 的 +事件浮字(function 声明提升)可见——与 CDP 复验「横幅之外全空(lumStd=0)」逐点吻合。M1 并入 run-scene +时引入;headless 门不建场景(ctx.game=null),场景代码零执行,只有真浏览器暴露。 +**修复**:`txt` 改 function 声明(提升语义与 clear/keep 一致)+事故注释钉在原地。 +**防回潮**:①新门 `scripts/scene-smoke.mjs`(记录型 mock 场景重放:七屏驱动/异常零容忍/每屏绘制计数 +≥5/自证钩子断言);②自证钩子——场景 rebuild 后回写 `readState().debug={displayCount,rebuilds,screen}`, +CDP 复验据此机断「画了东西」,不再仅赖像素统计。浏览器像素复验=mini-desktop 窗口批(主会话触发)。 + +## 数值 CI 分层(书 §6.4;墙钟=本机 darwin/arm64 实测 2026-07-06) | 层 | 命令 | 内容 | 实测墙钟 | |---|---|---|---| -| **PR smoke** | `node scripts/static-screen.mjs && node scripts/sim-shell.mjs --tier=pr` | 快筛(链护栏①层+估值黄牌)+ 贪心×200 + 金标抽测 7 套×200 | **≈3.0s** | -| **批产冒烟** | `node scripts/sim-shell.mjs --tier=batch` | 三档×200(S9/S10 每批入表即跑) | ≈0.5s | -| **nightly 全矩阵** | `node scripts/sim-shell.mjs --tier=nightly` | 三档×1000(3,000 局)+ 金标全 20 套×1000(20,000 局) | **≈36s** | +| **PR smoke** | `node scripts/static-screen.mjs && node scripts/sim-shell.mjs --tier=pr` | 快筛(154 卡 367 万序列)+ 2 职业×A0/A5/A15×贪心×200(书面小矩阵)+金标抽测 | ≈10s | +| **批产冒烟** | `node scripts/sim-shell.mjs --tier=batch` | 2 职业 × A0 × 五档 × 200 | ≈4s | +| **nightly 全矩阵** | `node scripts/sim-shell.mjs --tier=nightly` | **2 职业 × A0–A15 × 书面三档 × 1000 = 96,000 局**(书面口径)+下界锚 4,000+金标 26 套×1000 | **总 223.2s**(矩阵 158.6s+金标 64.6s,实测) | -M2 矩阵=职业甲×A0(进阶 A1–A15 随 M3 S14 入轴,外壳轴形状已留);书面 96,000 局全矩阵 -(2 职业×16 层×3 档×1000)按本机 1.7ms/局线性外推 ≈ **2.7 分钟单线程**〔外推,M3 实测定案〕。 -退出码语义:带判越带只报警(M0–M2 硬门仅 L1 真玩门);结构性失败(stuck/金标漂移超线)才非零。 +金标基线口径(M3 定案,工单点名):池扩张/职业/进阶入轴是预期漂移源,原则=基线随内容里程碑重锁; +**M3 实测结果好于预期——甲 20 套在 154 池+事件+起始遗物扩张下漂移 ≤3.6pp 全 PASS,基线连续未动** +(金标注入=已成型构筑,opts.relics 覆盖起始遗物,扩张影响被强构筑掩盖);乙 6 套 M3 首锁(n=1000)。 +漂移门 ±8pp 管里程碑内数值微调;满值套(100%)漂移只能单向向下,如实观测。 -## 判据带首轮回填(全部〔草案〕,S12 调参轮实测 2026-07-06) +## 判据带回填(S14 平衡轮终值,n=1000/格,全部〔草案〕) -- **A0 贪心档胜率 47.3%(n=1000,σ≈1.6pp)——入带 45–60%**。调参轮路径:词缀价值意识 +1.2pp → - 构筑当量选卡+薄组纪律+商店序(遗物优先)+19.7pp → 资源面(金币带+15%/战胜回复 6→8)+4.2pp → - Boss HP 回压(122/140/182)+4.5pp;幕缩放 −2% 实测零效果已还原。随机 0%/挂机 0%(下界,非校准对象, - 未削敌线迁就)。A5/A15 带随 M3 进阶表启用。 -- 采用率带:必选 0;死卡候选 14(贪心单策略窄化产物,治理随 M3 多样性 driver——链优先/保血防御档)。 -- Boss 回合:P50=8(带 6–9);磨局 149/2385(6.2%,继续收敛归 M3 平衡轮)。 -- 峰值回合伤 P99/P50=1.88(反垄断线 8 内);invalid=0;周天 0.09/局(构筑目标位,M3 观察)。 -- 金标 20 套基线已锁 `scripts/goldens.json`(32.5%–100%,流派梯度;满值套漂移只能单向向下,如实观测)。 +- **六带全绿**:甲 A0 58.3% / A5 32.2% / A15 6.9%;乙 A0 55.1% / A5 28.3% / A15 5.7% + (带:A0 45–60 / A5 25–40 / A15 5–15)。 +- 平衡轮主杠杆实录:职业起始遗物(乙 16.1→52.7,+36.6pp)>进阶尾段换轴(A15 战斗压力→构筑面收紧, + 1.9→5.8)>资源面软化;无效教训=±1% 敌攻在意图值 round 粒度下零效果(微调粒度必须 ≥ 取整步长); + 乙灼起手(荧惑守心入起始组)全线 +15pp 冲破上限,撤回——大杠杆不做微调件。 +- 构筑指纹去重率(M3 门 >85%〔草案〕):A0 六格全 100%。 +- 下界锚:random/idle 双职业 A0 全 0%(敌线致死性保持,未削敌迁就)。 ## 怎么跑 ```bash -# 全量回归(M0+M1+M2 六门;交付口径) +# 全量回归(M0+M1+M2+M3 九门;交付口径) node scripts/battle-core.test.mjs && node scripts/logic-smoke.mjs \ && node scripts/run-core.test.mjs && node scripts/run-smoke.mjs \ - && node scripts/save.test.mjs && node scripts/static-screen.mjs -# 数值 CI(见上表三层) -node scripts/sim-shell.mjs --tier=pr # 或 nightly / batch / --n=N --strategies=greedy,random,idle + && node scripts/save.test.mjs && node scripts/meta.test.mjs \ + && node scripts/static-screen.mjs && node scripts/scene-smoke.mjs +# 数值 CI(三层,见上表) +node scripts/sim-shell.mjs --tier=pr # 或 nightly / batch / 自由组格 --classes= --ascensions= --strategies= --n= # 构建(esbuild 在 game-runtime/node_modules;external phaser) cd /game-runtime && node ../tier2/engine/build-phaser.mjs ../tier2/games/shanhai-xunyilu -# 真玩:静态起服后开 index.html(?seed= 注入种子);交互真玩/九门 driven 在 mini-desktop(本机禁 chrome) +# 真玩:静态起服后开 index.html;boot 进 home 屏(选职业/进阶/每日/图鉴/成就);九门 driven 在 mini-desktop ``` ## 结构 -- `src/battle-core.js` —— 回合战斗解算核(纯函数;M2 增列:状态词缀/血价/消耗/保留/遗物 hook 20+) -- `src/map-core.js` —— 三幕地图 DAG(M2 增列:商店保底恰 1 入配额自检) -- `src/run-core.js` —— 一局核(M2 增列:商店五 API/Boss 遗物三选一/随机发放/snapshot·restore) -- `src/save.js` —— SaveEnvelope 契约面单源(信封/fail-closed 校验/恢复工厂/容量闸) -- `src/sim/sim-run.js` —— 仿真单局引擎+格聚合(事件流观测:Boss 回合/峰值伤/采用率/指纹) -- `src/sim/valuation.js` —— 卡牌估值单源(T1e 与 static-screen 共用,防公式漂移) -- `src/ui/layout.js` —— 布局与可点靶唯一坐标事实(M2 增列:shop/shopRemove/relicReward 三屏靶) -- `src/scenes/run-scene.js` —— 一局场景(M2:七视图;新事件演出:词缀/灼跳/反噬/购删) -- `src/data/tables.js` —— 66 卡/8 敌/40 遗物/曲线/商店经济/SAVE/SIM_BANDS/VALUATION(schemaVersion=1) -- `scripts/` —— 六门+sim-shell+goldens.json(node 直跑,无浏览器) -- `play-spec.json` —— 九门 driven 接线(driver.type=card-battle;CDP driver=`tier2/harness/card-battle.driver.js`) +- `src/battle-core.js` —— 回合战斗解算核(M3 增列:ifZhoutian 身份词/enemyStartStrength) +- `src/map-core.js` —— 三幕地图 DAG(M3:事件节点+A1 追加精英;保底件先行/增益件吃余量的配额次序) +- `src/run-core.js` —— 一局核(M3:职业/进阶消费、事件屏、周天计数;快照含 heroClass/ascension/事件面) +- `src/meta-core.js` —— 局外核(M3 新增:图鉴/成就解释器/进阶解锁/每日) +- `src/save.js` —— SaveEnvelope v2 双域(v1 迁移链/meta 独立 fail-closed/容量闸) +- `src/main.js` —— 工厂(M3:home 局外模式机/终局结算恰一次/自证钩子 debug) +- `src/sim/sim-run.js` + `src/sim/valuation.js` —— 仿真单局引擎(五档/三轴/胜局指纹)+估值单源 +- `src/ui/layout.js` —— 布局与可点靶唯一坐标事实(M3:event 屏/home 局外屏) +- `src/scenes/run-scene.js` —— 场景(M3:十视图=七局内+home/图鉴/成就;TDZ 事故注释与自证回写) +- `src/data/tables.js` —— 154 卡/8 敌/40 遗物/45 事件/16 进阶/40 成就/曲线经济(schemaVersion=1) +- `scripts/` —— 九门+sim-shell+goldens.json(26 套) +- `play-spec.json` —— 九门 driven 接线(CDP driver=`tier2/harness/card-battle.driver.js`,五档策略同文件) -## M2 门与实测(2026-07-06 本机;浏览器面=窗口批) +## M3 门与实测(2026-07-06 本机;浏览器面=窗口批) -- 六门全绿:battle-core(M0 40 断言+T11 词缀 18 断言)/logic-smoke/run-core(M1 12 组+R13 商店 - +R14 金标注入)/run-smoke(S0–S5)/save.test(V1–V7 13 断言:往返+续玩确定性+坏版本 9 负例全拒 - +容量峰值 2148 码元余量 97%+battle 屏拒+三屏往返)/static-screen(66 卡 291,918 序列零经济环命中) -- 链护栏三层随扩池持续生效:①静态快筛每批入表即跑(E0 经济词汇封闭断言钉死绕过面)②运行时动作上限 - 200(千局 invalid=0,未误触)③分布反垄断 P99/P50=1.88+invalid 构筑指纹落聚合报告 -- nightly 全矩阵+金标总墙钟 **36.2s**(扫描 2.2s/3,000 局+金标 34s/20,000 局;1.7ms/局) +- 九门全绿:battle-core(M0 40+T11 词缀 18)/logic-smoke/run-core(M1 12 组+R13 商店+R14 注入 + +R15 职业进阶 7)/run-smoke/save.test(v2 双域 19 断言:迁移链真通+meta 独立+乙/A3 续玩确定性) + /meta.test(局外核 6 组)/static-screen(154 卡 367 万序列零经济环)/scene-smoke(渲染层防回潮) +- 全矩阵 96,000 局实测 ≈110s(书面「≈2.7 分钟单线程」外推兑现);1.15ms/局均值 +- 链护栏三层持续生效:快筛(E0 词汇封闭含 ifZhoutian)/动作上限(96k 局 invalid=0)/指纹观测 ## 已知边界(如实) -- 浏览器面(画面可见/真指针/九门 driven/CDP 三门/新三屏视觉)本机不可验,排 mini-desktop 窗口批 -- 存档为影子介质(申报见上);正式通道+迁移断言=M5 门 -- 死卡治理与 A5/A15 带、多样性 driver(链优先/保血防御)、进阶轴入矩阵=M3 -- engineBundle/bundleSize 契约口径对 Phaser external 形态不适配:M0 已在册,待契约侧,本工程不自改 +- 浏览器面(home 三屏/事件屏视觉/真指针/九门 driven/绘制修复像素复验)本机不可验,排 mini-desktop 窗口批 +- 存档为影子介质;正式通道+迁移断言=M5 门(v1→v2 迁移链已先手演练) +- 死卡候选(书面三档并集,千局口径)≈40 张量级——154 池下贪心系策略的窄化仍在,流派 payoff 卡 + (ifZhoutian/灼联动/血价)贪心不识;治理=M4 策略档深化或 M5 前卡值回炉,如实挂账 +- 每日一局=本地最佳分(榜单走平台既有能力,不新建后端);成就页仅前 12 条(完整滚动容器=M4 工艺) +- engineBundle/bundleSize 契约口径对 Phaser external 形态不适配:M0 已在册,待契约侧 - 富三门 archetype 未接:卡牌品类门按书 §6.5 走验收门品类适配单另出 diff --git a/tier2/games/shanhai-xunyilu/assets/atlas/beasts-act1.json b/tier2/games/shanhai-xunyilu/assets/atlas/beasts-act1.json new file mode 100644 index 00000000..26d6f053 --- /dev/null +++ b/tier2/games/shanhai-xunyilu/assets/atlas/beasts-act1.json @@ -0,0 +1,82 @@ +{ + "textures": [ + { + "image": "beasts-act1.webp", + "format": "RGBA8888", + "size": { + "w": 875, + "h": 943 + }, + "scale": 1, + "frames": [ + { + "filename": "bo-ma", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 419, + "h": 512 + }, + "spriteSourceSize": { + "x": 2, + "y": 2, + "w": 415, + "h": 508 + }, + "frame": { + "x": 3, + "y": 3, + "w": 415, + "h": 508 + } + }, + { + "filename": "kuze-jiao", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 448, + "h": 445 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 448, + "h": 442 + }, + "frame": { + "x": 424, + "y": 3, + "w": 448, + "h": 442 + } + }, + { + "filename": "zheng", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 448, + "h": 427 + }, + "spriteSourceSize": { + "x": 2, + "y": 2, + "w": 444, + "h": 423 + }, + "frame": { + "x": 3, + "y": 517, + "w": 444, + "h": 423 + } + } + ] + } + ], + "meta": { + "app": "http://github.com/odrick/free-tex-packer-cli", + "version": "0.3.0" + } +} \ No newline at end of file diff --git a/tier2/games/shanhai-xunyilu/assets/atlas/beasts-act1.webp b/tier2/games/shanhai-xunyilu/assets/atlas/beasts-act1.webp new file mode 100644 index 00000000..4f074e60 Binary files /dev/null and b/tier2/games/shanhai-xunyilu/assets/atlas/beasts-act1.webp differ diff --git a/tier2/games/shanhai-xunyilu/scripts/asset-gen.mjs b/tier2/games/shanhai-xunyilu/scripts/asset-gen.mjs new file mode 100644 index 00000000..9bff28a7 --- /dev/null +++ b/tier2/games/shanhai-xunyilu/scripts/asset-gen.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +// scripts/asset-gen.mjs —— 《山海巡异录》资产一期 · mmx 生图批跑(批次一 gen-batch.mjs 迁移版) +// owner:美术批产执行席(2026-07-06) +// 用法: +// node scripts/asset-gen.mjs --group cards # 按组批跑(cards|beasts|ui|relics|bg) +// node scripts/asset-gen.mjs --only card-mu-qingteng --seed-bump 100000 --force # 定向重摇 +// node scripts/asset-gen.mjs --only ui-frame-epic --subject-extra ",定向补词" --force +// 纪律(与批次一同构): +// - prompt = promptPrefix + 主体 + styleWordsShared + perGame(xunyilu, sprite|bleed) + 工艺后缀,拼接即所发原样入账; +// - 每次外呼 150s 超时,失败退避重试 ≤3(内容过滤 exit=10 不重试,配额 exit=4 全批中止); +// - 逐呼追加台账(成败都记):docs-design/山海风格版-批次一/manifest-巡异录资产一期.jsonl; +// - 串行 + 1s 间隔防限流; +// - 成本硬闸:台账累计外呼 × ¥0.033(仓内实测口径)≥ ¥195 时中止报告(工单红线 ¥200)。 +// 原图落 --raw-dir(默认系统临时目录;定稿缩尺版与图集由 asset-pack.mjs 产出进仓,原图凭台账 seed 可复现)。 +import { readFileSync, existsSync, mkdirSync, renameSync, appendFileSync, readdirSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { dirname, join, resolve, extname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SHOTS } from './asset-shots.mjs'; + +const GAME_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const REPO_ROOT = resolve(GAME_ROOT, '..', '..', '..'); +const BATCH1 = join(REPO_ROOT, 'docs-design', '山海风格版-批次一'); +const ANCHOR_PATH = join(BATCH1, 'anchor-pack.json'); // 锚 r1 单源引用,不复制不改 +const MANIFEST = join(BATCH1, 'manifest-巡异录资产一期.jsonl'); // 台账落批次一目录(工单要求) +const COST_PER_CALL_RMB = 0.033; // 仓内实测口径(批次一台账) +const COST_CAP_RMB = 195; // 硬闸(工单红线 ¥200,留安全边) + +// ---- 参数解析 ---- +const args = process.argv.slice(2); +const getArg = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }; +const onlyIds = getArg('--only')?.split(',').map((s) => s.trim()).filter(Boolean); +const onlyGroup = getArg('--group'); +const seedBump = Number(getArg('--seed-bump') || 0); +const subjectExtra = getArg('--subject-extra') || ''; +const force = args.includes('--force'); +const RAW_DIR = resolve(getArg('--raw-dir') || join(process.env.TMPDIR || '/tmp', 'xunyilu-assets1-raw')); + +const anchor = JSON.parse(readFileSync(ANCHOR_PATH, 'utf8')); +const TMP = join(process.env.TMPDIR || '/tmp', 'xunyilu-assets1-gen'); +mkdirSync(TMP, { recursive: true }); + +// prompt 拼装(锚 r1 口径;本游戏固定取 perGame.xunyilu) +function buildPrompt(shot) { + const kind = shot.type === 'sprite' ? 'sprite' : 'bleed'; + const per = anchor.perGame.xunyilu[kind]; + const suffix = shot.type === 'sprite' ? anchor.spriteSuffix : anchor.fullBleedSuffix; + return `${anchor.promptPrefix}${shot.subject}${subjectExtra}。${anchor.styleWordsShared}。${per}。${suffix}`; +} + +function ledger(entry) { + appendFileSync(MANIFEST, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n'); +} + +/** 台账累计外呼数(成本硬闸的分母;台账不存在=0)。 */ +function ledgerCalls() { + if (!existsSync(MANIFEST)) return 0; + return readFileSync(MANIFEST, 'utf8').split('\n').filter((l) => l.includes('"gen-call"')).length; +} + +function callMmx(shot, prompt, seedUsed) { + for (const f of readdirSync(TMP)) if (f.startsWith(shot.id)) rmSync(join(TMP, f)); + const t0 = Date.now(); + const r = spawnSync('mmx', [ + 'image', 'generate', + '--prompt', prompt, + '--width', String(shot.w), '--height', String(shot.h), + '--seed', String(seedUsed), + '--out-dir', TMP, '--out-prefix', shot.id, + '--quiet', '--non-interactive', + ], { timeout: 150_000, encoding: 'utf8' }); + const ms = Date.now() - t0; + if (r.error?.code === 'ETIMEDOUT' || r.signal) return { ok: false, error: 'timeout-150s', ms, exit: 5 }; + if (r.status !== 0) return { ok: false, error: (r.stderr || r.stdout || '').trim().slice(0, 300) || `exit=${r.status}`, ms, exit: r.status ?? 1 }; + const saved = (r.stdout || '').split('\n').map((s) => s.trim()).find((s) => s && existsSync(s)) + || readdirSync(TMP).filter((f) => f.startsWith(shot.id)).map((f) => join(TMP, f))[0]; + if (!saved) return { ok: false, error: 'exit=0 但未找到产物文件', ms, exit: 1 }; + return { ok: true, file: saved, ms, exit: 0 }; +} + +// ---- 主循环:串行 ---- +const todo = SHOTS.filter((s) => (!onlyIds || onlyIds.includes(s.id)) && (!onlyGroup || s.group === onlyGroup)); +if (!todo.length) { console.error('无匹配镜头(--group cards|beasts|ui|relics|bg 或 --only id)'); process.exit(2); } +let done = 0, skipped = 0, failed = 0, calls = 0; +let totalCalls = ledgerCalls(); +for (const shot of todo) { + // 成本硬闸:先查后呼 + if ((totalCalls + 1) * COST_PER_CALL_RMB >= COST_CAP_RMB) { + console.error(`成本硬闸:台账累计外呼 ${totalCalls},再呼将超 ¥${COST_CAP_RMB},中止。`); + process.exit(9); + } + const outDir = join(RAW_DIR, shot.group); + mkdirSync(outDir, { recursive: true }); + const existing = readdirSync(outDir).find((f) => f.startsWith(shot.id + '.')); + if (existing && !force) { skipped++; continue; } + + const prompt = buildPrompt(shot); + const seedUsed = shot.seed + seedBump; + let result = null; + for (let attempt = 1; attempt <= 3; attempt++) { + result = callMmx(shot, prompt, seedUsed); + calls++; totalCalls++; + ledger({ kind: 'gen-call', id: shot.id, group: shot.group, type: shot.type, w: shot.w, h: shot.h, + seed: seedUsed, seedBump, attempt, model: 'image-01', prompt, + status: result.ok ? 'ok' : 'fail', error: result.error, durationMs: result.ms }); + if (result.ok) break; + if (result.exit === 10) { console.error(`[${shot.id}] 内容过滤(exit=10),不重试`); break; } + if (result.exit === 4) { console.error(`[${shot.id}] 配额超限(exit=4),全批中止`); process.exit(4); } + if (attempt < 3) { const wait = attempt * 5000; console.error(`[${shot.id}] 第${attempt}次失败(${result.error}),${wait / 1000}s 后重试`); spawnSync('sleep', [String(wait / 1000)]); } + } + if (result.ok) { + const ext = extname(result.file) || '.png'; + const dest = join(outDir, shot.id + ext.toLowerCase()); + renameSync(result.file, dest); + done++; + console.log(`[${shot.id}] OK ${Math.round(result.ms / 1000)}s -> ${dest}`); + } else { + failed++; + console.error(`[${shot.id}] FAILED: ${result.error}`); + } + spawnSync('sleep', ['1']); +} +console.log(`\n批跑完成:成 ${done} / 跳过 ${skipped} / 败 ${failed};本次外呼 ${calls} 次;台账累计 ${totalCalls} 次 ≈ ¥${(totalCalls * COST_PER_CALL_RMB).toFixed(2)}(仓内实测口径)`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tier2/games/shanhai-xunyilu/scripts/asset-pack.mjs b/tier2/games/shanhai-xunyilu/scripts/asset-pack.mjs new file mode 100644 index 00000000..7f5cda58 --- /dev/null +++ b/tier2/games/shanhai-xunyilu/scripts/asset-pack.mjs @@ -0,0 +1,269 @@ +#!/usr/bin/env node +// scripts/asset-pack.mjs —— 《山海巡异录》资产一期 · 产线打包(抠图→缩尺→图集→manifest→机检) +// owner:美术批产执行席(2026-07-06;批次一 key-out.py / atlas-pack.mjs 迁移整合版) +// 用法: +// node scripts/asset-pack.mjs --raw-dir <生图原图目录> # 全量打包 +// node scripts/asset-pack.mjs --raw-dir <目录> --group cards # 单组 +// 产物: +// assets/atlas/-N.webp + .json 图集(Phaser3 坐标,webp q80;卡插画独立分包 cards-jia) +// assets/bg/.webp 满幅大图(场景/结算,批次一复用件标 source) +// assets/manifest.json 资产清单 + 字节账(账A 输入) +// docs-design/山海风格版-批次一/images-巡异录资产一期// 定稿缩尺评审版(webp) +// 机检(每组):id 齐套 / 精灵透明底占比 / 尺寸合规 / 图集帧数对账;结果打印并追加台账(kind=qa-pack)。 +import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, statSync, copyFileSync, rmSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SHOTS, REUSE, CARD_IDS, RELIC_IDS } from './asset-shots.mjs'; + +const GAME_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const REPO_ROOT = resolve(GAME_ROOT, '..', '..', '..'); +const BATCH1 = join(REPO_ROOT, 'docs-design', '山海风格版-批次一'); +const REVIEW_DIR = join(BATCH1, 'images-巡异录资产一期'); +const MANIFEST_LEDGER = join(BATCH1, 'manifest-巡异录资产一期.jsonl'); +const ASSETS = join(GAME_ROOT, 'assets'); +const ATLAS_DIR = join(ASSETS, 'atlas'); +const BG_DIR = join(ASSETS, 'bg'); + +const args = process.argv.slice(2); +const getArg = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; }; +const RAW_DIR = resolve(getArg('--raw-dir') || join(process.env.TMPDIR || '/tmp', 'xunyilu-assets1-raw')); +const onlyGroup = getArg('--group'); +const WORK = join(process.env.TMPDIR || '/tmp', 'xunyilu-assets1-pack'); +const STAGE = join(WORK, 'stage'); // 按 atlas 分目录的定稿 png(图集输入) +const KEYED = join(WORK, 'keyed'); // 抠图中间产物 + +for (const d of [WORK, STAGE, KEYED, ATLAS_DIR, BG_DIR, REVIEW_DIR]) mkdirSync(d, { recursive: true }); + +const findRaw = (group, id) => { + const dir = join(RAW_DIR, group); + if (!existsSync(dir)) return null; + const f = readdirSync(dir).find((x) => x.startsWith(id + '.')); + return f ? join(dir, f) : null; +}; + +// ── 批量图像操作:一次 python 进程吃一张作业单(缩尺/转码;PIL LANCZOS)── +function runImageOps(ops, label) { + if (!ops.length) return; + const jobFile = join(WORK, `ops-${label}.json`); + writeFileSync(jobFile, JSON.stringify(ops)); + const py = ` +import json, sys, os +from PIL import Image +ops = json.load(open(sys.argv[1])) +for op in ops: + im = Image.open(op['src']) + if op['mode'] == 'cover': # 等比放缩后居中裁到目标框(满幅件) + sw, sh = im.size; tw, th = op['w'], op['h'] + s = max(tw / sw, th / sh) + im = im.resize((max(1, round(sw * s)), max(1, round(sh * s))), Image.LANCZOS) + l, t = (im.width - tw) // 2, (im.height - th) // 2 + im = im.crop((l, t, l + tw, t + th)) + elif op['mode'] == 'contain': # 最长边限界(透明底精灵,保比例) + im.thumbnail((op['box'], op['box']), Image.LANCZOS) + os.makedirs(os.path.dirname(op['dst']), exist_ok=True) + if op['dst'].endswith('.webp'): + im.save(op['dst'], quality=80, method=6) + else: + im.save(op['dst']) +`; + execFileSync('python3', ['-c', py, jobFile], { stdio: 'inherit', timeout: 300_000 }); +} + +// ── 抠图:批次一 key-out.py 原脚本复用(色键+连通域除杂,印章尘点自动清)── +function keyOut(files, outDir) { + if (!files.length) return; + mkdirSync(outDir, { recursive: true }); + execFileSync('python3', [join(BATCH1, 'scripts', 'key-out.py'), ...files, '--out', outDir], { stdio: 'inherit', timeout: 600_000 }); +} + +// ── 透明底机检:alpha 通道存在且全透明像素占比在合理带(防抠图失效/全空)── +function alphaCheck(pngPaths) { + if (!pngPaths.length) return []; + const jobFile = join(WORK, 'alpha-check.json'); + writeFileSync(jobFile, JSON.stringify(pngPaths)); + const py = ` +import json, sys +import numpy as np +from PIL import Image +bad = [] +for p in json.load(open(sys.argv[1])): + im = Image.open(p) + if im.mode != 'RGBA': + bad.append({'file': p, 'why': 'no-alpha'}); continue + a = np.asarray(im)[:, :, 3] + frac = float((a == 0).mean()) + if frac < 0.03: bad.append({'file': p, 'why': f'transparent-frac={frac:.3f} 过低(疑似未抠净)'}) + if frac > 0.98: bad.append({'file': p, 'why': f'transparent-frac={frac:.3f} 过高(疑似全空)'}) +print(json.dumps(bad)) +`; + const r = execFileSync('python3', ['-c', py, jobFile], { encoding: 'utf8', timeout: 300_000 }); + return JSON.parse(r.trim() || '[]'); +} + +// ── 图集:free-tex-packer-cli(schema 同批次一)→ png 页 → webp q80 → JSON 改指 webp ── +function packAtlas(atlasName, stageDir, pageSize) { + const pngs = readdirSync(stageDir).filter((f) => f.endsWith('.png')); + if (!pngs.length) return null; + const outTmp = join(WORK, 'atlas-out', atlasName); + rmSync(outTmp, { recursive: true, force: true }); + mkdirSync(outTmp, { recursive: true }); + const project = { + savePath: outTmp, + images: pngs.map((f) => ({ name: f, path: join(stageDir, f) })), + folders: [], + packOptions: { + textureName: atlasName, + width: pageSize, height: pageSize, fixedSize: false, powerOfTwo: false, + padding: 2, extrude: 1, allowRotation: false, detectIdentical: true, + allowTrim: true, trimMode: 'trim', alphaThreshold: 0, + exporter: 'Phaser3', removeFileExtension: true, prependFolderName: false, + textureFormat: 'png', base64Export: false, scale: 1, packer: 'MaxRectsPacker', + }, + }; + const proj = join(outTmp, `${atlasName}.ftpp`); + writeFileSync(proj, JSON.stringify(project, null, 2)); + execFileSync('free-tex-packer-cli', ['--project', proj, '--output', outTmp], { stdio: 'inherit', timeout: 300_000 }); + + const pages = []; + for (const png of readdirSync(outTmp).filter((f) => f.startsWith(atlasName) && f.endsWith('.png'))) { + const base = png.replace(/\.png$/, ''); + const jsonPath = join(outTmp, `${base}.json`); + const webpDst = join(ATLAS_DIR, `${base}.webp`); + runImageOps([{ src: join(outTmp, png), dst: webpDst, mode: 'contain', box: pageSize }], `webp-${base}`); + const j = JSON.parse(readFileSync(jsonPath, 'utf8')); + for (const t of j.textures || []) if (t.image) t.image = t.image.replace(/\.png$/, '.webp'); + const jsonDst = join(ATLAS_DIR, `${base}.json`); + writeFileSync(jsonDst, JSON.stringify(j, null, 2)); + const frames = (j.textures || []).reduce((s, t) => s + (t.frames?.length || 0), 0); + pages.push({ image: `atlas/${base}.webp`, json: `atlas/${base}.json`, bytes: statSync(webpDst).size, frames }); + } + return pages; +} + +const kb = (n) => `${(n / 1024).toFixed(0)}KB`; +const problems = []; +const qa = { missingRaw: [], alphaBad: [], counts: {}, bytes: {} }; + +// ═══ 1. 分组落 stage ═══ +const groups = ['cards', 'beasts', 'ui', 'relics', 'bg'].filter((g) => !onlyGroup || g === onlyGroup); + +// cards / ui:bleed,cover 缩尺直接进 stage(frame 名 = 去前缀 id) +for (const g of groups.filter((x) => x === 'cards' || x === 'ui')) { + const ops = []; + for (const s of SHOTS.filter((x) => x.group === g)) { + const raw = findRaw(g, s.id); + if (!raw) { qa.missingRaw.push(s.id); continue; } + ops.push({ src: raw, dst: join(STAGE, s.atlas, `${s.frame}.png`), mode: 'cover', w: s.final.w, h: s.final.h }); + ops.push({ src: raw, dst: join(REVIEW_DIR, g, `${s.frame}.webp`), mode: 'cover', w: s.final.w, h: s.final.h }); + } + runImageOps(ops, g); +} + +// beasts / relics:sprite,先抠图再 contain 缩尺 +for (const g of groups.filter((x) => x === 'beasts' || x === 'relics')) { + const shots = SHOTS.filter((x) => x.group === g); + const toKey = []; + const meta = []; // {keyedPng, atlas, frame, box, group} + for (const s of shots) { + const raw = findRaw(g, s.id); + if (!raw) { qa.missingRaw.push(s.id); continue; } + toKey.push(raw); + meta.push({ keyed: join(KEYED, g, `${s.id}.png`), atlas: s.atlas, frame: s.frame, box: s.final.box }); + } + if (g === 'beasts') { + for (const r of REUSE.beasts) { // 批次一定稿复用(创始人已眼验) + toKey.push(join(BATCH1, r.src)); + const base = r.src.split('/').pop().replace(/\.[a-z]+$/, ''); + meta.push({ keyed: join(KEYED, g, `${base}.png`), atlas: r.atlas, frame: r.id, box: r.box }); + } + } + keyOut(toKey, join(KEYED, g)); + const bad = alphaCheck(meta.map((m) => m.keyed).filter((p) => existsSync(p))); + qa.alphaBad.push(...bad); + const ops = []; + for (const m of meta) { + if (!existsSync(m.keyed)) { problems.push(`${g}:抠图无产物 ${m.keyed}`); continue; } + ops.push({ src: m.keyed, dst: join(STAGE, m.atlas, `${m.frame}.png`), mode: 'contain', box: m.box }); + ops.push({ src: m.keyed, dst: join(REVIEW_DIR, g, `${m.frame}.webp`), mode: 'contain', box: m.box }); + } + runImageOps(ops, g); +} + +// bg:新产 2 + 批次一复用 4,webp q80 原尺进 assets/bg +const bgFiles = []; +if (groups.includes('bg')) { + const ops = []; + for (const s of SHOTS.filter((x) => x.group === 'bg')) { + const raw = findRaw('bg', s.id); + if (!raw) { qa.missingRaw.push(s.id); continue; } + ops.push({ src: raw, dst: join(BG_DIR, `${s.id}.webp`), mode: 'cover', w: s.final.w, h: s.final.h }); + bgFiles.push({ id: s.id, source: '资产一期新产' }); + } + for (const r of REUSE.bg) { + ops.push({ src: join(BATCH1, r.src), dst: join(BG_DIR, `${r.id}.webp`), mode: 'cover', w: 768, h: 1360 }); + bgFiles.push({ id: r.id, source: '批次一定稿复用' }); + } + runImageOps(ops, 'bg'); + for (const f of bgFiles) { + const p = join(BG_DIR, `${f.id}.webp`); + if (existsSync(p)) { f.file = `bg/${f.id}.webp`; f.bytes = statSync(p).size; copyFileSync(p, join(REVIEW_DIR, 'bg', `${f.id}.webp`)); } + } + mkdirSync(join(REVIEW_DIR, 'bg'), { recursive: true }); +} + +// ═══ 2. 打图集 ═══ +const PAGE_SIZE = { 'cards-jia': 2048, 'beasts-act1': 2048, 'beasts-act2': 2048, 'beasts-act3': 2048, relics: 1024, ui: 2048 }; +const atlases = {}; +for (const atlasName of Object.keys(PAGE_SIZE)) { + const g = atlasName.startsWith('cards') ? 'cards' : atlasName.startsWith('beasts') ? 'beasts' : atlasName; + if (!groups.includes(g)) continue; + const stageDir = join(STAGE, atlasName); + if (!existsSync(stageDir)) continue; + const pages = packAtlas(atlasName, stageDir, PAGE_SIZE[atlasName]); + if (pages) atlases[atlasName] = pages; +} + +// ═══ 3. 机检对账 ═══ +const expect = { + 'cards-jia': CARD_IDS.length, // 66 + relics: RELIC_IDS.length, // 40 + ui: SHOTS.filter((s) => s.group === 'ui').length, // 15 + beastsTotal: SHOTS.filter((s) => s.group === 'beasts').length + REUSE.beasts.length, // 39 +}; +const frameCount = (a) => (atlases[a] || []).reduce((s, p) => s + p.frames, 0); +if (groups.includes('cards') && frameCount('cards-jia') !== expect['cards-jia']) problems.push(`cards-jia 帧数 ${frameCount('cards-jia')} ≠ ${expect['cards-jia']}`); +if (groups.includes('relics') && frameCount('relics') !== expect.relics) problems.push(`relics 帧数 ${frameCount('relics')} ≠ ${expect.relics}`); +if (groups.includes('ui') && frameCount('ui') !== expect.ui) problems.push(`ui 帧数 ${frameCount('ui')} ≠ ${expect.ui}`); +const beastFrames = frameCount('beasts-act1') + frameCount('beasts-act2') + frameCount('beasts-act3'); +if (groups.includes('beasts') && beastFrames !== expect.beastsTotal) problems.push(`beasts 帧数 ${beastFrames} ≠ ${expect.beastsTotal}`); +if (qa.missingRaw.length) problems.push(`缺原图 ${qa.missingRaw.length}:${qa.missingRaw.join(',')}`); +for (const b of qa.alphaBad) problems.push(`透明底异常 ${b.file}(${b.why})`); + +// ═══ 4. manifest + 字节账 ═══ +const byGroup = {}; +for (const [name, pages] of Object.entries(atlases)) byGroup[name] = pages.reduce((s, p) => s + p.bytes, 0); +byGroup.bg = bgFiles.reduce((s, f) => s + (f.bytes || 0), 0); +const totalBytes = Object.values(byGroup).reduce((s, n) => s + n, 0); +const manifest = { + version: 1, + generatedAt: new Date().toISOString(), + anchor: 'docs-design/山海风格版-批次一/anchor-pack.json(风格锚 v1 + 工艺修订 r1,单源)', + ledger: 'docs-design/山海风格版-批次一/manifest-巡异录资产一期.jsonl', + snapshotNote: '卡/遗物/敌帧名对齐 2026-07-06 tables.js 快照(66 卡/40 遗物/8 敌)+ 兽谱-增补-巡异录.md 31 兽;职业乙插画位未产,等 M3 表落地下一批补齐', + atlases, + bg: bgFiles, + totals: { webpBytes: totalBytes, byGroup }, +}; +if (!onlyGroup) writeFileSync(join(ASSETS, 'manifest.json'), JSON.stringify(manifest, null, 2)); + +appendFileSync(MANIFEST_LEDGER, JSON.stringify({ ts: new Date().toISOString(), kind: 'qa-pack', groups, problems, byGroup, totalBytes }) + '\n'); + +console.log('\n═══ 打包结果 ═══'); +for (const [name, pages] of Object.entries(atlases)) { + console.log(`${name}:${pages.length} 页 / ${pages.reduce((s, p) => s + p.frames, 0)} 帧 / ${kb(byGroup[name])}`); +} +if (bgFiles.length) console.log(`bg:${bgFiles.length} 张 / ${kb(byGroup.bg)}`); +console.log(`合计 webp:${kb(totalBytes)}(${(totalBytes / 1048576).toFixed(2)}MB)`); +if (problems.length) { console.error(`\n机检 ${problems.length} 项异常:`); for (const p of problems) console.error(' - ' + p); process.exit(1); } +console.log('机检:全部通过'); diff --git a/tier2/games/shanhai-xunyilu/scripts/asset-shots.mjs b/tier2/games/shanhai-xunyilu/scripts/asset-shots.mjs new file mode 100644 index 00000000..f504bbf6 --- /dev/null +++ b/tier2/games/shanhai-xunyilu/scripts/asset-shots.mjs @@ -0,0 +1,329 @@ +// scripts/asset-shots.mjs —— 《山海巡异录》资产一期批产 · 镜头单(数据件) +// owner:美术批产执行席(2026-07-06,工单:资产一期,锚 = docs-design/山海风格版-批次一/anchor-pack.json r1) +// +// 【快照纪律】M3 开发席平行在飞会动 tables.js(加职业乙卡)。本批以开工时读到的表为准, +// 卡/遗物/敌 id 在此**硬编码快照**、不运行时 import tables.js —— 防批产中途表漂移; +// 职业乙插画位命名等 M3 表落地后下一批补齐,本批不产。 +// 【谱面纪律】敌兽主体词一律从兽谱出发(首批 = 兽谱首批.md;增补 = 兽谱-增补-巡异录.md), +// 兽名不入 prompt(批次一工艺事实 4:名词强 prime 错形),只用形态语言。 +// 【clean-room】主体词禁现代作品名与其专有名词;古籍意象 + 矿彩锚原创演绎。 + +'use strict'; + +/** 快照时间与来源(审计锚)。 */ +export const SNAPSHOT_AT = '2026-07-06 tables.js(M2 66卡/8敌/40遗物 定容态)'; + +/** 职业甲 66 卡 id 快照(tables.js CARDS 原序;帧名 = 卡 id)。 */ +export const CARD_IDS = [ + 'mu-qingteng', 'mu-shengfa', 'mu-chanzhi', 'mu-cangyin', + 'huo-lihuo', 'huo-liaoyuan', 'huo-chiyu', 'huo-liming', + 'tu-houtu', 'tu-zhenyue', 'tu-xirang', 'tu-chengtian', + 'jin-jinfeng', 'jin-ruijin', 'jin-baihong', 'jin-gengjin', + 'shui-xuanshui', 'shui-runxia', 'shui-canglang', 'shui-guixu', + 'mu-canglin', 'mu-jiamu', 'huo-bifang', 'huo-fenglin', + 'tu-kunlun', 'tu-houde', 'jin-baize', 'jin-tianwu', + 'shui-tianchi', 'shui-xuangui', + 'mu-huichun', 'mu-panmu', 'huo-fenxin', 'huo-yanbao', 'huo-zhurong', + 'tu-panshi', 'tu-hancheng', 'jin-podan', 'jin-cuijin', 'jin-qiongqi', + 'shui-hanyuan', 'shui-lixue', + 'mu-cangwu', 'mu-jianmu', 'mu-linggen', 'huo-jinyu', 'huo-jinwu', + 'tu-leitu', 'tu-taowu', 'tu-xiyan', 'jin-kunwu', 'jin-baijin', + 'shui-ruoshui', 'shui-guixuan', + 'mu-sanglin', 'mu-lingzhang', 'mu-kuafu', 'huo-suiren', 'huo-huolin', + 'tu-chengyuan', 'tu-houde2', 'jin-shuojin', 'jin-kunwu2', + 'shui-canghai', 'shui-hantan', 'shui-beiming', +]; + +/** 遗物 40 id 快照(tables.js RELICS 原序;帧名 = 遗物 id)。 */ +export const RELIC_IDS = [ + 'yuheng-fu', 'guixu-jing', 'yinglong-lin', + 'kunlun-yugui', 'zhulong-jing', 'hetu-juan', 'luoshu-ye', 'fusang-zhi', + 'xihe-pei', 'wangshu-huan', 'kaiming-ya', 'luwu-zhao', 'yingzhao-yu', + 'zhengpi-gu', 'jingwei-shi', 'zhurong-guan', 'houtu-yin', 'rushou-yue', + 'jumang-bian', 'tengshe-zhu', 'baize-ce', 'qingluan-ling', + 'chenghuang-an', 'tianwu-pei', 'shuhai-chi', 'ruomu-deng', 'sanqingniao-yu', + 'gonggong-jiao', 'xuanming-zhu', 'xiwangmu-zan', 'taotie-ding', 'jianmu-xin', + 'jiuwei-huan', 'yayu-gu', + 'jia-zhoutian-lu', 'jia-lianji-fu', 'jia-wuxing-huan', 'jia-zhanshuai', + 'jia-ningxue', 'jia-lingxi', +]; + +/** 已入表 8 敌 id 快照(tables.js ENEMIES;涸泽鲛复用批次一定稿,其余 7 只本批新产)。 */ +export const ENEMY_IDS_IN_TABLE = [ + 'kuze-jiao', 'qingqiu-hu', 'ba-she', 'chi-hou', 'jiuwei-linghu', + 'xiang-liu', 'zhu-yin', 'tao-tie', +]; + +/** 立绘缩尺(最长边,兽谱增补 §三):普通 448 / 精英 512 / Boss 640。 */ +const BEAST_BOX = { normal: 448, elite: 512, boss: 640 }; + +// ───────────────────────────────────────────────────────────────────────────── +// 卡插画 66(bleed 1024² → 256² cover;frame 名 = 卡 id,图集 cards-jia) +// 主体词按卡名/卡效/五行色语写就;稀有卡构图更满,普通卡"构图简洁,主体居中醒目"。 +// ───────────────────────────────────────────────────────────────────────────── +const CARD_SUBJECTS = { + // ── 木(石绿/生发)── + 'mu-qingteng': '数条石绿青藤自地面破土而出,向上缠绕生长,藤上生叶带刺,构图简洁,主体居中醒目', // 首版「藤鞭横抽」prime 出龙形,去比喻重写(台账 gen-call 留证) + 'mu-shengfa': '一枚素面无字的木牌悬空,木牌下方嫩芽新叶层层舒展生长,石绿描金,构图简洁', // 首版「符牌」引出大块伪刻字,改素面 + + 'mu-chanzhi': '缠枝藤蔓在空中结成一只收紧的螺旋牢笼,枝端带刺,石绿底描金枝线', + 'mu-cangyin': '一株苍劲古木撑开树冠如巨伞,树荫下浮起一圈护体符光,构图简洁', + 'mu-canglin': '一只苍绿鳞甲的兽爪自卷云中探出一抓,爪风卷起数片叶,动势凌厉', + 'mu-jiamu': '庭园中一株嘉木开满描金花枝,花瓣飘落化作小符,工整明快', + 'mu-huichun': '一滴晶莹露珠自叶尖垂落,落处地面生出一小片新绿,构图简洁,主体居中', + 'mu-panmu': '蟠曲老木的粗枝层层盘绕缠卷收紧,树皮皴裂,描金勾边', // 「如巨蟒」易 prime 蛇形,去比喻 + 'mu-cangwu': '苍梧巨木横出一枝,枝上嫩芽与锋利断枝并生,一枝两意,构图简洁', + 'mu-jianmu': '一株通天巨木直贯画面顶天立地,树干缠绕描金符文,云气缭绕', + 'mu-linggen': '大地剖面里盘结的描金树根网络,根须发光向四方蔓延', + 'mu-sanglin': '一张桑木长弓射出一支缠着桑叶的箭,箭尾曳出石绿光线,构图简洁', + 'mu-lingzhang': '一面樟木纹圆盾,盾面生出鲜嫩新叶,泛石绿柔光,构图简洁,主体居中', + 'mu-kuafu': '一位持杖巨人的剪影大步奔向天边巨大落日,身后烟尘滚滚,悲壮恢弘', + // ── 火(朱砂/离明)── + 'huo-lihuo': '一道朱砂符纸腾起一小簇符文形状的烈焰,构图简洁,主体居中醒目', + 'huo-liaoyuan': '三股火舌自一张符纸窜出,在原野上连成一线燎原之火', + 'huo-chiyu': '一根燃烧的朱砂色羽毛被掷出,拖出短促焰尾,构图简洁,主体居中', + 'huo-liming': '天空降下一柱描金真火贯穿画面,火柱中隐现一轮日纹,恢弘庄重', + 'huo-bifang': '一只独脚青羽仙鹤衔着一段燃火的枯枝飞临,鹤形清瘦,火光映羽', + 'huo-fenglin': '一条火线在林间奔窜,成排树影次第点燃,朱砂与暗林对比', + 'huo-fenxin': '一枚朱砂咒印悬浮,印下一团暗火阴燃不熄,烟气细长,构图简洁', + 'huo-yanbao': '一张符纸炸开一团爆焰,火星四溅成环,冲击波纹一圈,主体居中', + 'huo-zhurong': '一卷燃烧的古策书展开,页面上的火纹字符腾起化作焰柱,华丽威严', + 'huo-jinyu': '一堆灰烬中拨出一点将熄复燃的火星,微光映亮四周,构图极简,主体居中', + 'huo-jinwu': '一只三足金色神鸟自巨大日轮中俯冲而下,双翅曳焰,恢弘夺目', + 'huo-suiren': '一双苍老的手在木上钻出第一缕火种,火星初迸,神圣庄重', + 'huo-huolin': '一只鳞甲燃焰的鹿角独兽踏焰奔驰,蹄下生火,鬃焰飞扬', + // ── 土(赭石/厚载)── + 'tu-houtu': '大地隆起一面厚重的夯土石壁,壁面嵌着描金岩纹,构图简洁,主体居中', + 'tu-zhenyue': '一座山岳的虚影压在一枚符牌上方,山根透出金光,稳重如镇', + 'tu-xirang': '一撮神土在掌心自行堆起一座微型山丘,土粒悬浮,构图简洁', + 'tu-chengtian': '一只巨大石掌自地底托起,掌上承着一弧天穹之光,恢弘厚重', + 'tu-kunlun': '一枚玉璧自巍峨山影间坠下,玉光如帐罩落大地,工整庄严', + 'tu-houde': '一枚土黄大印展开,印面上山川河流的微缩纹样徐徐浮现,华贵厚重', + 'tu-panshi': '一块磐石悬浮空中,石面裂纹里透出描金光,构图简洁,主体居中醒目', + 'tu-hancheng': '一记岩石巨拳撼动一段城垣状石阵,碎石崩飞,力量感十足', + 'tu-leitu': '层层夯土垒起一座小山,每层土中嵌着一枚小符印,构图简洁', + 'tu-taowu': '一只虎躯凶兽被镇压在山岳形符狱之下,描金锁链纵横,凶而不得出', + 'tu-xiyan': '一撮神土沿符阵纹路自行衍化蔓延,土脊蜿蜒隆起推进,赭石描金', // 「如活物爬行」易 prime 兽形,去比喻 + 'tu-chengyuan': '符光化出一段夯土城垣横亘画面,垛口整齐,烽色沉稳,构图简洁', + 'tu-houde2': '一方石印浮空,印下田畴山川如画卷舒展,土黄描金,工整安稳', + // ── 金(雌黄/锐断)── + 'jin-jinfeng': '一道雌黄金色的剑锋之光斜劈而下,断口利落,构图简洁,主体居中醒目', + 'jin-ruijin': '数枚金针自一座小符阵中悬浮竖起,针尖泛冷光,阵纹描金,构图简洁', + 'jin-baihong': '一道白虹般的光刃横贯画面,如长虹贯日,凌厉工整', + 'jin-gengjin': '金色刀雨自天斜落,刀刃成排坠下,寒光凛冽', + 'jin-baize': '一面能照见万象的玉镜,镜中浮现一个发光的兽形轮廓,神异清雅', + 'jin-tianwu': '一只八颗虎首的巨兽自浪涛中探出,八口齐张,气势磅礴', + 'jin-podan': '一枚金色咒钉楔入一块裂开的玉石,裂纹自钉处放射,构图简洁,主体居中', + 'jin-cuijin': '一点金色符光精准点在一圈靶环状的穴位纹中心,构图极简,主体居中', + 'jin-qiongqi': '一只带翼巨虎仰天长啸,啸声化作一圈圈金色音波环,威猛慑人', + 'jin-kunwu': '一柄赤色宝刀切开一块玉璧,切面平滑如镜,刀光如霞', + 'jin-baijin': '锻炉中一块金精被符文小锤锻打,火星金屑迸溅,匠气庄重', + 'jin-shuojin': '双刃短剑交叉挥出两道金色弧光,轨迹利落,构图简洁,主体居中', + 'jin-kunwu2': '一柄剑浸入描金淬火池,池面腾起金色雾气,构图简洁', + // ── 水(石青/润下)── + 'shui-xuanshui': '一道墨蓝漩涡卷起数片水刃扑击,漩心幽深,构图简洁,主体居中', + 'shui-runxia': '水滴沿一张符纸边缘润下,在符下聚成一泓镜面小潭,静谧清雅', + 'shui-canglang': '一道沧浪自展开的卷轴中涌出,浪头卷着点点符光,气韵流动', + 'shui-guixu': '深渊巨潮席卷而来,潮头分成数股巨浪俯冲,幽蓝描金,气势宏大', // 「水龙」必 prime 龙形,改巨浪 + 'shui-tianchi': '高山天池的水面映着一轮描金圆月,月影微漾,静谧空灵', + 'shui-xuangui': '一只玄色巨龟浮出水面,背甲上浮现描金的河图星点纹,古朴神圣', + 'shui-hanyuan': '寒渊之底一缕幽蓝咒光盘旋升起,冻雾弥漫,清冷彻骨', + 'shui-lixue': '一滴朱砂色血珠落入水碗,在水中晕开成一个符文形,凝重神秘,主体居中', + 'shui-ruoshui': '三千弱水如帘幕垂落,水帘中一根羽毛缓缓下沉,轻柔而无可承载', + 'shui-guixuan': '一个巨大的深渊漩涡,漩心幽蓝发光,周围碎浪成环,吞噬之势', + 'shui-canghai': '沧海夜面升起一轮明月,月光在海上铺成一条描金水路,开阔宁静', + 'shui-hantan': '一泓幽深寒潭映出北斗七星,潭面结着薄冰,星点描金,构图简洁', + 'shui-beiming': '一条巨鱼自北海跃出水面,身影遮天,浪花如雪崩落,恢弘壮阔', +}; + +// ───────────────────────────────────────────────────────────────────────────── +// 敌兽立绘 37 新产 + 2 复用(sprite 1024² → 抠图 → 档位缩尺;图集 beasts-act{n}) +// 主体词 = 谱面形貌意象的形态语言(不含兽名);tier/act 与谱册一致。 +// ───────────────────────────────────────────────────────────────────────────── +export const BEASTS = [ + // —— 已入表 7 只(补谱面见增补册 §一)—— + { id: 'qingqiu-hu', tier: 'normal', act: 1, subject: '赤色幻狐,双尾将分未分,周身浮着点点狐火,足不沾地,身影边缘微微虚化,立姿回首,全身像' }, + { id: 'ba-she', tier: 'normal', act: 2, subject: '青黑色巨蟒,鳞列如叠瓦,腹部纹路隐约如古舆图,大口微张露出弯牙,盘卷昂首,全身像' }, + { id: 'chi-hou', tier: 'elite', act: 1, subject: '赤红鬃毛的猛兽,躯干如巨犬而头似狮首,喉间透出火光,鬃毛根根竖起如燃烧,张口怒啸,全身像' }, + { id: 'jiuwei-linghu', tier: 'elite', act: 2, subject: '雪白底色朱砂纹的大狐,九条尾巴各燃一簇狐火,群尾展开如火焰轮,目细长含慧光,优雅立姿,全身像' }, + { id: 'xiang-liu', tier: 'boss', act: 1, subject: '九个蛇首共生于一具青色巨蟒之躯,盘绕如小山,九首高低错落各衔一股浊浪,鳞色石青泛冷光,威压构图,全身像' }, + { id: 'zhu-yin', tier: 'boss', act: 2, subject: '赤色巨蛇盘绕成环如山岳,头顶生一枚巨大的独眼,目光如炬,躯体鳞纹如日晷刻度,庄重威压,全身像' }, + { id: 'tao-tie', tier: 'boss', act: 3, subject: '一张巨大的青铜兽面与巨口,獠牙如钩,双目凸圆,面后躯干化作青铜纹样的雾状虚影,狰狞威压,正面构图' }, + // —— 首批谱两只(巡异录参数新产)—— + { id: 'tao-wu', tier: 'elite', act: 1, subject: '虎躯人面的凶兽,犬毛蓬乱如枯草,长尾曳地,肤色赭石带土黄,眉目顽愚而凶,低头顶撞姿态,全身像' }, + { id: 'qiong-qi', tier: 'elite', act: 2, subject: '一只带翼巨虎,背生猬毛如短戟,双翼羽刃泛雌黄金光,目色冷白,展翼欲扑,全身像' }, + // —— 新增普通 20 —— + { id: 'li-li', tier: 'normal', act: 1, subject: '土黄色野豚,四足生着鸡距般的骨刺,低头拱土姿态,背脊沾着新翻的泥土,全身像' }, + { id: 'zhu-niao', tier: 'normal', act: 1, subject: '灰褐色鸱枭怪鸟,双爪如五指细枝般分叉,栖立时爪指交叠,圆目冷光,全身像' }, + { id: 'gu-diao', tier: 'normal', act: 1, subject: '一只巨雕头生弯刀状独角,羽色雌黄带金,展翅俯冲姿态,利爪前伸,全身像' }, + { id: 'ran-yi', tier: 'normal', act: 1, subject: '鱼身蛇首的怪鱼,六条短足如桨,鳞色石青,离水疾行姿态,全身像' }, + { id: 'meng-huai', tier: 'normal', act: 1, subject: '貆形小兽,通身赤色硬毫直立如火刺,毫尖带着点点火星,弓身戒备,全身像' }, + { id: 'hao-zhi', tier: 'normal', act: 1, subject: '白毛野豚,背生长棘如簪、棘端乌黑,长棘竖立如林,侧身低吼,全身像' }, + { id: 'tian-gou', tier: 'normal', act: 1, subject: '玄黑躯体白色头颅的狸犬,双目如银环,疾奔扑击姿态,矫健迅捷,全身像' }, + { id: 'fu-zhu', tier: 'normal', act: 1, subject: '通体雪白的鹿,头生四只珊瑚枝般的角,蹄下泛起浅浅水纹,昂首踏水,全身像' }, + { id: 'chang-you', tier: 'normal', act: 2, subject: '青灰色长臂猿猴,头生四只耳朵,双臂长过膝,一臂横扫姿态,全身像' }, + { id: 'fei-yi', tier: 'normal', act: 2, subject: '赤鳞怪蛇,六足撑地,背生四片如枯叶的翅,身周热浪扭曲,昂首吐信,全身像' }, + { id: 'dang-kang', tier: 'normal', act: 2, subject: '壮硕巨豚,一对獠牙外翻如双镰,四蹄厚重如石础,低头掀撞姿态,全身像' }, + { id: 'huan-li', tier: 'normal', act: 2, subject: '玄色狸兽,一只独眼生在面部正中,三条尾巴分展,张口作啸,全身像' }, + { id: 'he-luo', tier: 'normal', act: 2, subject: '一首十身的怪鱼,十条鱼躯如裙裾环列共一个鱼首,鳍尾翻卷起漩流,全身像' }, + { id: 'ming-she', tier: 'normal', act: 2, subject: '赭红色细蛇,背生四片薄如石磬的翅,振翅悬空,周围荡开一圈圈音波纹,全身像' }, + { id: 'zhu-huai', tier: 'normal', act: 2, subject: '铁灰色巨牛,头生四角两两交错,垂着宽大的猪状耳朵,低头喘息冲撞姿态,全身像' }, + { id: 'qiu-yu', tier: 'normal', act: 3, subject: '兔身鸟喙的小兽,鸱枭圆目,蛇形细尾盘卷,伏地佯睡而睁着一只眼,全身像' }, + { id: 'zhu-ru', tier: 'normal', act: 3, subject: '朱红色狐身,肋部生鱼鳍状双翼,鳍膜透光如琥珀,鼓翼低飞,全身像' }, + { id: 'you-you', tier: 'normal', act: 3, subject: '土黄色骏马之躯,山羊般的横瞳,头生四角,牛尾扫地,前蹄刨地欲冲,全身像' }, + { id: 'bi-bi', tier: 'normal', act: 3, subject: '银灰色狐身,背生一对大雁般的长翼,翼羽泛雌黄金光,掠地滑翔,全身像' }, + { id: 'hua-she', tier: 'normal', act: 3, subject: '豺狼躯干作蛇形游走,背生鸟翼,头颈鬣毛垂遮面部只露一双竖瞳,蜿蜒逼近,全身像' }, + // —— 新增精英 5 —— + { id: 'bo-ma', tier: 'elite', act: 1, subject: '白马之躯黑色马尾,额生螺纹独角,口生虎牙、四蹄裂为虎爪,昂首嘶鸣战意凛然,全身像' }, + { id: 'man-man', tier: 'elite', act: 1, subject: '一对野鸭状怪鸟,各自只有一翼一目,青绿羽色,两鸟相贴合翼齐飞,相合处水汽凝成光环,全身像' }, + { id: 'fei-niu', tier: 'elite', act: 2, subject: '灰褐色瘦牛,白色头颅生一只独眼,蛇形长尾曳地,足下草木枯萎成圈,阴郁前行,全身像' }, + { id: 'yong-he', tier: 'elite', act: 2, subject: '雌黄毛色的巨猿,赤红双目与赤红口喙,狂态毕露,双臂挥舞,身后拖着焦糊爪印,全身像' }, + { id: 'liang-qu', tier: 'elite', act: 3, subject: '玄色狸形巨兽,白色头颅,双前肢是过大的虎爪、爪甲如五柄短刀,前爪高举下劈,全身像' }, + // —— 新增 Boss 3 —— + { id: 'jiu-ying', tier: 'boss', act: 1, subject: '九颗蛇颈兽首自一具矮壮躯干升起,四首衔水色如石青、五首吐焰色如朱砂,九首攒动啼张各异,威压构图,全身像' }, + { id: 'di-jiang', tier: 'boss', act: 2, subject: '浑圆如布囊的神鸟,通体赤黄如丹火,没有面孔与眼睛,六足错落、四翼展开,周身浮起描金乐纹涟漪,神异庄重,全身像' }, + { id: 'kui-niu', tier: 'boss', act: 3, subject: '苍青色巨牛,无角,仅有一条粗壮独足,足下踏着雷电光环,身泛青白辉光,一步踏雷之势,威压构图,全身像' }, +]; + +/** 复用批次一定稿(创始人已眼验;不重摇,直接进产线抠图/图集)。 */ +export const REUSE_BEASTS = [ + { id: 'kuze-jiao', tier: 'normal', act: 1, src: 'images/xunyilu/xy-beast-hezejiao.jpg' }, + { id: 'zheng', tier: 'normal', act: 1, src: 'images/xunyilu/xy-beast-zheng.jpg' }, +]; + +/** 复用批次一 bleed 定稿(场景/结算基版,进 assets/bg;标记来源=批次一)。 */ +export const REUSE_BG = [ + { id: 'bg-scene-01', src: 'images/xunyilu/xy-scene-01.jpg' }, + { id: 'bg-scene-02', src: 'images/xunyilu/xy-scene-02.jpg' }, + { id: 'bg-result-win', src: 'images/xunyilu/xy-result-win.jpg' }, + { id: 'bg-result-lose', src: 'images/xunyilu/xy-result-lose.jpg' }, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// UI 件 15(生图出框件与底纹,文字一律程序排字 —— 批次一工艺事实 6) +// ───────────────────────────────────────────────────────────────────────────── +const UI_SHOTS = [ + // 稀有度空框 3(补批次一整卡样张之缺:框件必须留空供程序装配插画+文字) + { id: 'ui-frame-common', w: 760, h: 1064, fw: 380, fh: 532, subject: '空白卡牌边框设计,素朴石灰岩质感边框,边框厚拙简洁,四角以小块岩纹收角,框内中央与下方完全留空为纯浅米色,没有任何图案' }, + { id: 'ui-frame-rare', w: 760, h: 1064, fw: 380, fh: 532, subject: '空白卡牌边框设计,描银卷云纹边框,银线卷云沿框边游走,清雅工整,框内中央与下方完全留空为纯浅米色,没有任何图案' }, + { id: 'ui-frame-epic', w: 760, h: 1064, fw: 380, fh: 532, subject: '空白卡牌边框设计,描金蟠纹边框,金线蟠曲盘绕框边,四角嵌描金小徽记,华丽庄重,框内中央与下方完全留空为纯浅米色,没有任何图案' }, + // 状态框 3(可打出/选中/禁用;程序叠加在卡面之上) + { id: 'ui-state-playable', w: 760, h: 1064, fw: 380, fh: 532, subject: '空白卡牌边框设计,描金流光高亮边框,框体透出温暖金色光晕,光感克制不外溢,框内完全留空为纯浅米色,没有任何图案' }, + { id: 'ui-state-selected', w: 760, h: 1064, fw: 380, fh: 532, subject: '空白卡牌边框设计,朱砂色光边边框,框体外沿一圈朱砂描边微微发光,框内完全留空为纯浅米色,没有任何图案' }, + { id: 'ui-state-disabled', w: 760, h: 1064, fw: 380, fh: 532, subject: '空白卡牌边框设计,黯淡灰色石质边框,框体如蒙尘石板毫无光泽,框内完全留空为浅灰色,没有任何图案' }, + // 五行属性底纹 5(插画位/卡底的低对比装饰层) + { id: 'ui-elem-mu', w: 760, h: 1064, fw: 380, fh: 532, subject: '卡牌底纹设计,石绿色调的缠枝草木暗纹铺满整个画面,纹样低对比若隐若现,通体一张平整底纹,没有边框' }, + { id: 'ui-elem-huo', w: 760, h: 1064, fw: 380, fh: 532, subject: '卡牌底纹设计,朱砂色调的火焰云气暗纹铺满整个画面,纹样低对比若隐若现,通体一张平整底纹,没有边框' }, + { id: 'ui-elem-tu', w: 760, h: 1064, fw: 380, fh: 532, subject: '卡牌底纹设计,赭石土黄色调的山岳岩层暗纹铺满整个画面,纹样低对比若隐若现,通体一张平整底纹,没有边框' }, + { id: 'ui-elem-jin', w: 760, h: 1064, fw: 380, fh: 532, subject: '卡牌底纹设计,雌黄与银白色调的锋刃云雷暗纹铺满整个画面,纹样低对比若隐若现,通体一张平整底纹,没有边框' }, + { id: 'ui-elem-shui', w: 760, h: 1064, fw: 380, fh: 532, subject: '卡牌底纹设计,石青色调的水波涟漪暗纹铺满整个画面,纹样低对比若隐若现,通体一张平整底纹,没有边框' }, + // 界面底纹 2 + { id: 'ui-bg-panel', w: 1024, h: 1024, fw: 512, fh: 512, subject: '游戏界面面板底纹,展开的卷轴册页纸面质感,米色纸面带细微纤维纹理与淡淡青绿云边,平整满幅' }, + { id: 'ui-bg-hud', w: 1024, h: 1024, fw: 512, fh: 512, subject: '游戏界面底纹,暗色岩石质感铺满,石青石绿矿彩沁色斑驳,深沉低调,平整满幅' }, + // 图鉴格 2 + { id: 'ui-tujian-cell', w: 1024, h: 1024, fw: 256, fh: 256, subject: '图鉴格子设计,一枚方形描金细框的石质空格居中,格心浅凹留空,四角饰小小云纹,满幅单格' }, + { id: 'ui-tujian-locked', w: 1024, h: 1024, fw: 256, fh: 256, subject: '图鉴格子设计,一枚方形暗色石质空格居中,格心一团墨色云雾剪影缭绕,神秘未知感,满幅单格' }, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// 遗物图标 40(sprite 1024² → 抠图 → 128px;"法器·"领句 = 批次一 weapon 镜头已验配方) +// 遗物名含兽名者一律改写为器物形态语言(兽名不入 prompt)。 +// ───────────────────────────────────────────────────────────────────────────── +const RELIC_SUBJECTS = { + 'yuheng-fu': '法器·一枚白玉符牌,牌面刻北斗七星连线,斗柄一星描金发亮,悬浮微光', + 'guixu-jing': '法器·一面青铜古镜,镜背铸漩涡纹,镜面幽深如渊,悬浮微光', + 'yinglong-lin': '法器·单独一枚巨大鳞片的特写,鳞面天然云雷纹,边缘泛描金光,悬浮微光', // 「龙鳞」易 prime 整龙,改鳞片特写 + 'kunlun-yugui': '法器·一柄温润玉圭,圭身刻层叠山形纹,顶端尖圆,悬浮微光', + 'zhulong-jing': '法器·一颗竖瞳龙目状宝珠,珠内燃着一点不灭烛光,悬浮微光', + 'hetu-juan': '法器·半卷残破古图,图上星点以描金细线相连,卷边焦损,悬浮微光', + 'luoshu-ye': '法器·一页龟甲纹古书残页,页面数点排成方阵,悬浮微光', + 'fusang-zhi': '法器·一段神木桑枝,枝头栖着一枚小小的描金日轮,悬浮微光', + 'xihe-pei': '法器·一副描金马辔头,辔环铸成小日轮形,革带朱砂色,悬浮微光', + 'wangshu-huan': '法器·一只月白色玉环,环身缠绕淡淡云纹,清辉流转,悬浮微光', + 'kaiming-ya': '法器·一枚巨兽獠牙吊坠,牙根镶描金箍,牙面有细密咒纹,悬浮微光', + 'luwu-zhao': '法器·一只风干的虎爪法器,爪尖描金,腕部系朱砂绳结,悬浮微光', + 'yingzhao-yu': '法器·一根赤色纹路的神鸟长羽,羽梢卷曲泛金,悬浮微光', + 'zhengpi-gu': '法器·一面赤豹皮蒙面的小手鼓,鼓面绘五道焰形纹,鼓身描金,悬浮微光', + 'jingwei-shi': '法器·一枚小小的填海石子,石上天然嵌着白色贝纹,朴拙温润,悬浮微光', + 'zhurong-guan': '法器·一顶火焰纹青铜冠,冠顶托着一点真火,火苗不摇,悬浮微光', + 'houtu-yin': '法器·一方厚重石印,印钮雕作伏兽,印底泛土黄光,悬浮微光', + 'rushou-yue': '法器·一柄白刃小钺,钺身刻秋叶纹,刃口冷光,悬浮微光', + 'jumang-bian': '法器·一条青色枝条软鞭,鞭梢生出嫩芽,鞭身缠描金丝,悬浮微光', + 'tengshe-zhu': '法器·一颗被雾气缠绕的宝珠,珠下一截蛇形雾影衔着珠,悬浮微光', + 'baize-ce': '法器·一册兽皮封面的古书,封面烫着描金兽形轮廓,书口垂着穗子,悬浮微光', + 'qingluan-ling': '法器·一只青羽纹铜铃,铃身錾刻羽纹,铃舌描金,悬浮微光', + 'chenghuang-an': '法器·一副描金兽鞍,鞍桥翘起如角,鞍鞯朱砂纹,悬浮微光', + 'tianwu-pei': '法器·一枚八棱玉珮,珮面刻浪涛纹,系着石青绦带,悬浮微光', + 'shuhai-chi': '法器·一把青铜古尺,尺身刻度描金,尺端铸小小足印纹,悬浮微光', + 'ruomu-deng': '法器·一盏树枝形古灯,灯盏如花萼,灯焰赤红不摇,悬浮微光', + 'sanqingniao-yu': '法器·三根并排系在一起的青色神鸟羽,羽根缠描金线,悬浮微光', + 'gonggong-jiao': '法器·一只断裂的巨兽之角,断口如水晶剖面,角身缠浪纹,悬浮微光', + 'xuanming-zhu': '法器·一颗墨色深水宝珠,珠心一点幽蓝光,如深渊之眼,悬浮微光', + 'xiwangmu-zan': '法器·一支白玉古簪,簪首雕胜形纹嵌描金,悬浮微光', + 'taotie-ding': '法器·一尊三足小铜鼎,鼎身铸兽面纹,鼎口溢出一缕金气,悬浮微光', + 'jianmu-xin': '法器·一段神木木芯,断面年轮圈圈描金,木身缠青藤,悬浮微光', + 'jiuwei-huan': '法器·一只由九股赤色绒尾编成的手环,环上一点狐火微光,悬浮微光', + 'yayu-gu': '法器·一根凶兽腿骨,骨面朱砂咒纹层层镇封,骨节缠着细铁链,悬浮微光', + 'jia-zhoutian-lu': '法器·一只小巧的木制辘轳,轮上缠着五色丝线各一段,轮辐描金,悬浮微光', + 'jia-lianji-fu': '法器·一张烧至将尽却不毁的符纸,符文白热发光,边缘焦卷,悬浮微光', + 'jia-wuxing-huan': '法器·五枚小环相扣成链,五环各为石绿朱砂土黄雌黄石青一色,悬浮微光', + 'jia-zhanshuai': '法器·一柄素白麻布缠柄的短刃,刃口极利泛冷光,刃身素净,悬浮微光', + 'jia-ningxue': '法器·一小卷以朱砂写就的箓文,卷轴两端木杆描金,垂着穗结,悬浮微光', + 'jia-lingxi': '法器·一枚犀角雕成的玉佩,角心一线通透发光,系石青绦带,悬浮微光', +}; + +// ───────────────────────────────────────────────────────────────────────────── +// 结算图变体 2(bleed 768×1360;基版 win/lose 复用批次一) +// ───────────────────────────────────────────────────────────────────────────── +const BG_SHOTS = [ + { id: 'bg-result-bosswin', w: 768, h: 1360, subject: '幕终大胜结算图:一整卷横贯天地的古图长轴展开,数只异兽化作描金线稿列队归入图中安伏,金光如瀑,庄重恢弘,竖幅' }, + { id: 'bg-result-zhoutian', w: 768, h: 1360, subject: '周天大胜结算图:石绿朱砂土黄雌黄石青五色矿彩光带在天空连成一个完整圆环,环心描金大放光明,山野俯伏其下,竖幅' }, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// 汇总为镜头单(seed 段位:卡 901xx / 兽 911xx / UI 921xx / 遗物 931xx / bg 941xx, +// 与批次一 71001–85002 段不重叠) +// ───────────────────────────────────────────────────────────────────────────── +function assertKeys(map, ids, label) { + const keys = Object.keys(map); + const missing = ids.filter((i) => !map[i]); + const extra = keys.filter((k) => !ids.includes(k)); + if (missing.length || extra.length) { + throw new Error(`${label} 主体词与快照 id 不对齐:缺 ${JSON.stringify(missing)} 多 ${JSON.stringify(extra)}`); + } +} +assertKeys(CARD_SUBJECTS, CARD_IDS, '卡插画'); +assertKeys(RELIC_SUBJECTS, RELIC_IDS, '遗物图标'); +if (CARD_IDS.length !== 66) throw new Error(`卡快照应 66,实 ${CARD_IDS.length}`); +if (RELIC_IDS.length !== 40) throw new Error(`遗物快照应 40,实 ${RELIC_IDS.length}`); +if (BEASTS.length + REUSE_BEASTS.length !== 39) throw new Error(`敌兽应 39,实 ${BEASTS.length + REUSE_BEASTS.length}`); + +export const SHOTS = [ + ...CARD_IDS.map((id, i) => ({ + id: `card-${id}`, frame: id, group: 'cards', atlas: 'cards-jia', type: 'bleed', + w: 1024, h: 1024, seed: 90100 + i, final: { w: 256, h: 256, fit: 'cover' }, + subject: `符卡插画:${CARD_SUBJECTS[id]}`, + })), + ...BEASTS.map((b, i) => ({ + id: `beast-${b.id}`, frame: b.id, group: 'beasts', atlas: `beasts-act${b.act}`, type: 'sprite', + w: 1024, h: 1024, seed: 91100 + i, final: { box: BEAST_BOX[b.tier], fit: 'contain' }, + tier: b.tier, subject: `上古异兽立绘,${b.subject}`, + })), + ...UI_SHOTS.map((u, i) => ({ + id: u.id, frame: u.id, group: 'ui', atlas: 'ui', type: 'bleed', + w: u.w, h: u.h, seed: 92100 + i, final: { w: u.fw, h: u.fh, fit: 'cover' }, + subject: u.subject, + })), + ...RELIC_IDS.map((id, i) => ({ + id: `relic-${id}`, frame: id, group: 'relics', atlas: 'relics', type: 'sprite', + w: 1024, h: 1024, seed: 93100 + i, final: { box: 128, fit: 'contain' }, + subject: RELIC_SUBJECTS[id], + })), + ...BG_SHOTS.map((g, i) => ({ + id: g.id, frame: g.id, group: 'bg', atlas: null, type: 'bleed', + w: g.w, h: g.h, seed: 94100 + i, final: { w: 768, h: 1360, fit: 'cover' }, + subject: g.subject, + })), +]; + +export const REUSE = { beasts: REUSE_BEASTS.map((b) => ({ ...b, box: BEAST_BOX[b.tier], atlas: `beasts-act${b.act}` })), bg: REUSE_BG }; diff --git a/tier2/games/shanhai-xunyilu/scripts/battle-core.test.mjs b/tier2/games/shanhai-xunyilu/scripts/battle-core.test.mjs index 20f889df..131b1ab7 100644 --- a/tier2/games/shanhai-xunyilu/scripts/battle-core.test.mjs +++ b/tier2/games/shanhai-xunyilu/scripts/battle-core.test.mjs @@ -30,9 +30,12 @@ console.log('[battle-core.test] 《山海巡异录》M0 单测'); // ── T1 表完整性 + 估值 sanity(估值为 advisory,只有超 hardCap 的离谱值才算失败)── { - // M2 定容(书 §5.2 职业甲 66;批产推进中间批按当前表数断言,唯一性/五行均衡精神不变)。 + // M3 定容(书 §5.2:154 = 职业专属 66×2 + 中立 22)。 const ids = new Set(CARDS.map((c) => c.id)); - assert(CARDS.length === 66 && ids.size === CARDS.length, `T1a M2 卡池定容 66 且 id 唯一(当前 ${CARDS.length})`); + const byClass = {}; + for (const c of CARDS) byClass[c.class] = (byClass[c.class] || 0) + 1; + assert(CARDS.length === 154 && ids.size === CARDS.length, `T1a M3 卡池定容 154 且 id 唯一(当前 ${CARDS.length})`); + assert(byClass.jia === 66 && byClass.yi === 66 && byClass.neutral === 22, 'T1a2 三池构成 66/66/22', JSON.stringify(byClass)); const perEl = {}; for (const c of CARDS) perEl[c.element] = (perEl[c.element] || 0) + 1; const counts = Object.values(perEl); diff --git a/tier2/games/shanhai-xunyilu/scripts/goldens.json b/tier2/games/shanhai-xunyilu/scripts/goldens.json index 1f612e27..e6e56cba 100644 --- a/tier2/games/shanhai-xunyilu/scripts/goldens.json +++ b/tier2/games/shanhai-xunyilu/scripts/goldens.json @@ -1,6 +1,6 @@ { "what": "金标构筑回归集(设计书 §6.4:20 套手工编排构筑,数值改动后重跑,胜率漂移 >±8pp 按套判报警)", - "note": "deck/relics 注入 run-core(opts.deck/opts.relics),贪心档打完整 run;baselineWinRate 已于 2026-07-06 S12 调参轮收口锁值(nightly 终跑 n=1000/套);漂移相对它按套判 ±8pp。满值套(100%)漂移只能单向向下,如实观测。套名=流派·变体;每流派 2-3 套。", + "note": "deck/relics 注入 run-core,贪心档打完整 run;基线口径(M3 定案):池扩张/职业/进阶入轴是预期漂移源,M3 实测:甲 20 套在 154 池+事件+起始遗物扩张下漂移 ≤3.6pp 全 PASS——基线连续(金标注入=已成型构筑,扩张影响被掩盖);乙 6 套 M3 首锁(nightly n=1000),漂移门 ±8pp 管里程碑内数值微调。满值套(100%)漂移只能单向向下,如实观测。", "nPerSuite": 1000, "seedBase": 260000, "suites": [ @@ -458,6 +458,149 @@ "yuheng-fu" ], "baselineWinRate": 0.483 + }, + { + "name": "乙·链循环·星轨嵌环", + "class": "yi", + "smokeSample": true, + "deck": [ + "yi-mu-luoteng", + "yi-huo-zhuming", + "yi-tu-jishi", + "yi-jin-shenshang", + "yi-shui-tianhan", + "yi-mu-sangzhe", + "yi-jin-xuanyuan", + "yi-shui-yinhe", + "yi-tu-bugang", + "yi-shui-beidou", + "yi-jin-baiying", + "yi-mu-tangong" + ], + "relics": [ + "hetu-juan", + "jia-wuxing-huan", + "tengshe-zhu" + ], + "baselineWinRate": 0.994 + }, + { + "name": "乙·灼流·荧惑燎原", + "class": "yi", + "deck": [ + "yi-huo-yinghuo", + "yi-huo-tanggu", + "yi-huo-yandi", + "yi-huo-zhurong2", + "yi-huo-liuhuo", + "yi-huo-huoling", + "yi-mu-ximu", + "yi-tu-wuse", + "yi-jin-changgeng", + "yi-shui-beidou", + "yi-shui-yaochi", + "yi-huo-zhulou" + ], + "relics": [ + "zhurong-guan", + "kunlun-yugui" + ], + "baselineWinRate": 0.991 + }, + { + "name": "乙·控异·弱水玄冥", + "class": "yi", + "deck": [ + "yi-shui-ruoshui2", + "yi-shui-xuanming2", + "yi-jin-zhongshan", + "yi-tu-xiushe", + "yi-shui-beiming2", + "yi-jin-rushou2", + "yi-tu-wuse", + "yi-mu-ximu", + "yi-huo-zhuming", + "yi-shui-beidou", + "yi-tu-jishi", + "yi-shui-yaochi" + ], + "relics": [ + "qingluan-ling", + "baize-ce", + "yinglong-lin" + ], + "baselineWinRate": 0.19 + }, + { + "name": "乙·周天环·倒卷帘", + "class": "yi", + "smokeSample": true, + "deck": [ + "yi-mu-luoteng", + "yi-huo-zhulou", + "yi-tu-bugang", + "yi-jin-jinsuo", + "yi-shui-jianglou", + "yi-mu-sangzhe", + "yi-huo-liuhuo", + "yi-tu-jishi", + "yi-jin-shenshang", + "yi-shui-zhoutian", + "yi-shui-tianhan", + "yi-mu-taiyi" + ], + "relics": [ + "jia-zhoutian-lu", + "luoshu-ye", + "hetu-juan", + "jia-lingxi" + ], + "baselineWinRate": 0.955 + }, + { + "name": "乙·韧甲·岱舆孤峙", + "class": "yi", + "deck": [ + "yi-tu-wuse", + "yi-tu-xuanyin", + "yi-tu-lianshan", + "yi-tu-butian", + "yi-tu-guitian", + "yi-tu-daiyu", + "yi-mu-linggen2", + "yi-shui-xuangui2", + "yi-jin-yaoguang", + "yi-mu-ximu", + "yi-huo-zhuming", + "yi-shui-yaochi" + ], + "relics": [ + "luwu-zhao", + "houtu-yin", + "yinglong-lin" + ], + "baselineWinRate": 0.948 + }, + { + "name": "乙·基准锚·起始组原样", + "class": "yi", + "smokeSample": true, + "deck": [ + "yi-mu-luoteng", + "yi-mu-ximu", + "yi-huo-zhuming", + "yi-huo-zhulou", + "yi-tu-wuse", + "yi-tu-jishi", + "yi-jin-changgeng", + "yi-jin-zhongshan", + "yi-shui-beidou", + "yi-shui-yaochi" + ], + "relics": [ + "hetu-juan" + ], + "baselineWinRate": 0.525 } ] } diff --git a/tier2/games/shanhai-xunyilu/scripts/meta.test.mjs b/tier2/games/shanhai-xunyilu/scripts/meta.test.mjs new file mode 100644 index 00000000..181d7659 --- /dev/null +++ b/tier2/games/shanhai-xunyilu/scripts/meta.test.mjs @@ -0,0 +1,109 @@ +/** + * scripts/meta.test.mjs —— 《山海巡异录》局外核单测(M3 S15:图鉴/成就/解锁/每日) + * owner:北极星顶级线③(M3) + * + * 【验什么】W1 图鉴收集幂等;W2 成就条件词表全覆盖(表内 kind ⊆ 解释器认知集——新增词漏接=红); + * W3 终局结算(计数/进阶解锁/成就达成);W4 每日(种子=日期派生/分数公式/最佳分); + * W5 序列化往返;W6 成就去重(达成不重复)。 + * + * 用法:node scripts/meta.test.mjs(退出码 0=全过) + */ + +'use strict'; + +import { createMetaCore } from '../src/meta-core.js'; +import { ACHIEVEMENTS, DAILY } from '../src/data/tables.js'; + +let failures = 0; +function assert(cond, name, detail) { + if (cond) console.log(` PASS ${name}`); + else { console.error(` FAIL ${name}` + (detail ? ` — ${detail}` : '')); failures++; } +} + +console.log('[meta.test] 《山海巡异录》M3 局外核单测'); + +/** 终局投影桩(recordRunEnd 消费面;字段与 run-core readState 对齐)。 */ +function endState(over = {}) { + return { + phase: 'win', heroClass: 'jia', ascension: 0, deckCount: 18, gold: 120, + hero: { hp: 40, hpMax: 72 }, + relics: [{ id: 'r1' }, { id: 'r2' }], + stats: { battlesWon: 14, turnsTotal: 90, cardsRemoved: 2, zhoutianTotal: 1 }, + act: 3, + ...over, + }; +} + +// ── W1 图鉴收集幂等 ── +{ + const m = createMetaCore(null); + const rs = { deck: ['a', 'b', 'a'], relics: [{ id: 'r1' }], battle: { enemy: { id: 'e1' } } }; + m.observe(rs); m.observe(rs); m.observe(rs); + const r = m.readMeta(); + assert(r.collection.cards === 2 && r.collection.relics === 1 && r.collection.enemies === 1, 'W1 三面收集幂等(2 卡/1 遗物/1 敌)', JSON.stringify(r.collection)); +} + +// ── W2 成就词表全覆盖(表内 kind ⊆ 解释器;新词漏接即红)── +{ + const KNOWN = new Set(['wins', 'runs', 'winClass', 'winBoth', 'winAscension', 'deckMaxAtWin', 'deckMinAtWin', + 'hpFullWin', 'goldAtWin', 'removeTotal', 'zhoutianRun', 'relicsAtWin', 'winTurnsUnder', + 'collectCards', 'collectRelics', 'collectEnemies', 'dailyPlayed', 'dailyScore', 'ascUnlocked']); + const unknown = ACHIEVEMENTS.filter((a) => !KNOWN.has(a.cond.kind)).map((a) => `${a.id}:${a.cond.kind}`); + assert(unknown.length === 0, `W2 成就 kind 词表全覆盖(表 ${ACHIEVEMENTS.length} 条)`, unknown.join(',')); + assert(ACHIEVEMENTS.length === 40, 'W2b 成就定容 40(书 §5.2)', String(ACHIEVEMENTS.length)); +} + +// ── W3 终局结算:计数/解锁/成就 ── +{ + const m = createMetaCore(null); + const r1 = m.recordRunEnd(endState()); + const meta1 = m.readMeta(); + assert(meta1.runsPlayed === 1 && meta1.wins === 1 && meta1.winsByClass.jia === 1, 'W3a 胜局计数'); + assert(meta1.ascensionUnlocked.jia === 1, 'W3b 通关 A0 解锁 A1(逐职业)', JSON.stringify(meta1.ascensionUnlocked)); + assert(r1.newlyAchieved.includes('ach-first-win') && r1.newlyAchieved.includes('ach-win-jia'), 'W3c 首胜成就达成', r1.newlyAchieved.join(',')); + // 进阶通关跳级解锁:A5 通关 → 解锁 A6。 + m.recordRunEnd(endState({ ascension: 5 })); + assert(m.readMeta().ascensionUnlocked.jia === 6, 'W3d A5 通关解锁 A6'); + // 败局:只计局数。 + const before = m.readMeta().wins; + m.recordRunEnd(endState({ phase: 'lose' })); + assert(m.readMeta().wins === before && m.readMeta().runsPlayed === 3, 'W3e 败局不计胜场'); + // 薄卡组成就。 + const r4 = m.recordRunEnd(endState({ deckCount: 11 })); + assert(r4.newlyAchieved.includes('ach-thin-deck'), 'W3f 轻装简从(≤12 张通关)'); +} + +// ── W4 每日:种子派生/分数公式/最佳分 ── +{ + const m = createMetaCore(null); + const d = m.dailySeedFor('2026-07-06'); + assert(d.seed === 20260706, 'W4a 每日种子=日期数字', String(d.seed)); + const win = endState({ gold: 100 }); + const { dailyScore } = m.recordRunEnd(win, { isDaily: true }); + const expect = DAILY.winBase + win.hero.hp * DAILY.hpWeight + win.gold * DAILY.goldWeight - win.stats.turnsTotal * DAILY.turnPenalty; + assert(dailyScore === expect, 'W4b 胜局分数=表公式', `${dailyScore} vs ${expect}`); + const low = m.recordRunEnd(endState({ phase: 'lose', act: 2 }), { isDaily: true }); + assert(m.readMeta().daily.bestScore === expect && low.dailyScore < expect, 'W4c 最佳分保持(低分不覆盖)'); + const d2 = m.dailySeedFor('2026-07-07'); + assert(d2.seed === 20260707 && m.readMeta().daily.bestScore === 0 && !m.readMeta().daily.played, 'W4d 跨日重置(新题面)'); +} + +// ── W5 序列化往返 ── +{ + const m = createMetaCore(null); + m.observe({ deck: ['x'], relics: [{ id: 'r' }], battle: { enemy: { id: 'e' } } }); + m.recordRunEnd(endState()); + const back = createMetaCore(JSON.parse(JSON.stringify(m.serialize()))); + assert(JSON.stringify(back.serialize()) === JSON.stringify(m.serialize()), 'W5 序列化往返逐字段一致'); +} + +// ── W6 成就去重 ── +{ + const m = createMetaCore(null); + const a = m.recordRunEnd(endState()); + const b = m.recordRunEnd(endState()); + assert(a.newlyAchieved.includes('ach-first-win') && !b.newlyAchieved.includes('ach-first-win'), 'W6 已达成就不重复上报'); +} + +console.log(failures === 0 ? '[meta.test] 全绿' : `[meta.test] ${failures} 条失败`); +process.exit(failures === 0 ? 0 : 1); diff --git a/tier2/games/shanhai-xunyilu/scripts/run-core.test.mjs b/tier2/games/shanhai-xunyilu/scripts/run-core.test.mjs index a605b9a2..75fc62b5 100644 --- a/tier2/games/shanhai-xunyilu/scripts/run-core.test.mjs +++ b/tier2/games/shanhai-xunyilu/scripts/run-core.test.mjs @@ -133,13 +133,15 @@ console.log('[run-core.test] 《山海巡异录》M1 单测'); fightThrough(run); const afterElite = run.readState(); if (afterElite.phase === 'run') { - // M2 S10:发放改受控随机抽池(池序=M1 过渡形态),断言口径=「获得一枚池内遗物」。 + // M2 S10 随机抽池 + M3 起始遗物(职业身份件占 1 基数):断言按「相对基数 +1」口径。 const relicIds = new Set(RELICS.map((r) => r.id)); - assert(afterElite.relics.length === 1 && relicIds.has(afterElite.relics[0].id), 'R7c 精英必掉遗物(随机抽池)', JSON.stringify(afterElite.relics)); + const base = 1; // CLASSES.starterRelic(M3 平衡轮) + assert(afterElite.relics.length === base + 1 && afterElite.relics.every((r) => relicIds.has(r.id)), 'R7c 精英必掉遗物(随机抽池;起始+1)', JSON.stringify(afterElite.relics)); run.chooseReward('skip'); run.chooseNode('a1-f3-n0'); // 宝匣 const s3 = run.readState(); - assert(s3.relics.length === 2 && relicIds.has(s3.relics[1].id) && s3.relics[1].id !== s3.relics[0].id, + const ids3 = s3.relics.map((r) => r.id); + assert(s3.relics.length === base + 2 && new Set(ids3).size === ids3.length, 'R7d 宝匣再发一枚未持有遗物(互异)', JSON.stringify(s3.relics)); } else { assert(false, 'R7c 精英战贪心竟败(数值曲线要看)', `phase=${afterElite.phase} hp=${afterElite.hero.hp}`); @@ -241,6 +243,7 @@ console.log('[run-core.test] 《山海巡异录》M1 单测'); } else if (s.screen === 'reward') run.chooseReward(0); else if (s.screen === 'relicReward') run.chooseRelicReward(0); // M2 Boss 遗物三选一 else if (s.screen === 'shop') run.leaveShop(); // M2 商店(确定性脚本:径直离开) + else if (s.screen === 'event') run.chooseEventOption(0); // M3 事件(确定性脚本:取头一项) } return JSON.stringify(run.readState()); }; @@ -387,5 +390,42 @@ console.log('[run-core.test] 《山海巡异录》M1 单测'); assert(threw, 'R14d 未知遗物注入响亮抛(表一致性)'); } +// ── R15 职业与进阶消费(M3 S13/S14)── +{ + // 乙职业:HP/起始组/起始遗物/池过滤。 + const yi = createRunCore({ random: createSeededRandom(151) }, { seed: 151, heroClass: 'yi' }); + const sy = yi.readState(); + assert(sy.heroClass === 'yi' && sy.hero.hpMax === 70, 'R15a 乙职业 HP=70(平衡轮回填值)', JSON.stringify(sy.hero)); + assert(sy.deck.every((id) => id.startsWith('yi-')) && sy.deckCount === 10, 'R15b 乙起始组全乙池 10 张'); + assert(sy.relics.length === 1 && sy.relics[0].id === 'hetu-juan', 'R15c 乙起始遗物=河图残卷'); + // 甲缺省与起始遗物。 + const jia = createRunCore({ random: createSeededRandom(152) }, { seed: 152 }); + assert(jia.readState().heroClass === 'jia' && jia.readState().relics[0].id === 'kaiming-ya', 'R15d 甲缺省+开明兽牙'); + // 进阶 A6:起始 HP -6;A3+A10 敌血乘区。 + const a6 = createRunCore({ random: createSeededRandom(153) }, { seed: 153, ascension: 6 }); + assert(a6.readState().hero.hpMax === 72 - 6 && a6.readState().ascension === 6, 'R15e A6 起始 HP 72-6', String(a6.readState().hero.hpMax)); + // A15 奖励二选一(rewardChoicesDelta)。 + const a15 = createRunCore( + { random: createSeededRandom(154) }, + { seed: 154, ascension: 15, actMaps: [miniMap(1), miniMap(2), miniMap(3)] }, + ); + a15.chooseNode('a1-f0-n0'); + fightThrough(a15); + if (a15.readState().phase === 'run' && a15.readState().screen === 'reward') { + assert(a15.readState().reward.options.length === 2, 'R15f A15 战斗奖励二选一(构筑面收紧轴)', String(a15.readState().reward.options.length)); + } else { + assert(false, 'R15f 前置:A15 教学战竟败', a15.readState().phase); + } + // 进阶敌血乘区(A3+A10=11%,幕1 普通敌不吃幕缩放只吃进阶)。 + const a10 = createRunCore( + { random: createSeededRandom(155) }, + { seed: 155, ascension: 10, actMaps: [miniMap(1), miniMap(2), miniMap(3)] }, + ); + a10.chooseNode('a1-f0-n0'); + const enemyHp = a10.readState().battle.enemy.hpMax; + const kuze = ENEMIES.find((e) => e.id === 'kuze-jiao'); + assert(enemyHp === Math.round(kuze.hpMax * 1.11), 'R15g A10 敌血=表值×1.11(A3 7%+A10 4%)', `${enemyHp} vs ${Math.round(kuze.hpMax * 1.11)}`); +} + console.log(failures === 0 ? '[run-core.test] 全绿' : `[run-core.test] ${failures} 条失败`); process.exit(failures === 0 ? 0 : 1); diff --git a/tier2/games/shanhai-xunyilu/scripts/run-smoke.mjs b/tier2/games/shanhai-xunyilu/scripts/run-smoke.mjs index 774428cc..be50fc17 100644 --- a/tier2/games/shanhai-xunyilu/scripts/run-smoke.mjs +++ b/tier2/games/shanhai-xunyilu/scripts/run-smoke.mjs @@ -105,6 +105,7 @@ function driveRun(seed, opts = {}) { else if (t.kind === 'removePick') run.buyRemove(t.index); else if (t.kind === 'removeBack') run.closeRemove(); else if (t.kind === 'shopLeave') run.leaveShop(); + else if (t.kind === 'eventOption') run.chooseEventOption(t.index); // M3 事件 else break; // 未知靶=契约漂移,响亮暴露(勿静默空转) // 事件收集(首战演出估算)。 if (inFirstBattle) { diff --git a/tier2/games/shanhai-xunyilu/scripts/save.test.mjs b/tier2/games/shanhai-xunyilu/scripts/save.test.mjs index b0025934..710b0e4b 100644 --- a/tier2/games/shanhai-xunyilu/scripts/save.test.mjs +++ b/tier2/games/shanhai-xunyilu/scripts/save.test.mjs @@ -1,15 +1,13 @@ /** - * scripts/save.test.mjs —— 《山海巡异录》run 存档断言(M2 S11 验收门) - * owner:北极星顶级线③(M2) + * scripts/save.test.mjs —— 《山海巡异录》存档断言(M2 S11 v1 → M3 S15 v2 双域信封) + * owner:北极星顶级线③(M2/M3) * - * 【验什么】书 §3.4「存档续玩」断言 + 工单口径(往返 + 坏版本负例)+ 契约容量闸: - * V1 信封形状=SaveEnvelope(schemaVersion 整数 ≥1 + data 纯对象); - * V2 往返逐字段一致(snapshot→restore→readState 深等,map/deck/relics/HP/金币/removeCount/种子); - * V3 续玩确定性(恢复后与不中断,同策略打到终态逐字段一致——rngState 精确续的实证); - * V4 坏版本负例(0/2/'1'/缺 data/data 数组/裸对象/数组信封 → 全拒,fail-closed); - * V5 容量核数(信封 JSON < 65536 码元,save: 族契约闸;取整局多快照峰值); - * V6 战斗屏无快照(书:战斗外任意节点;battle 屏 snapshot()=null); - * V7 三屏快照可恢复(reward/relicReward/shop 屏各自往返)。 + * 【验什么】书 §3.4「存档续玩」断言 + 工单口径(往返 + 坏版本负例)+ 契约容量闸 + v2 增列: + * V1 信封形状=SaveEnvelope(schemaVersion 整数 ≥1 + data 纯对象;v2 双域 {run,meta}); + * V2 run 域往返逐字段一致;V3 续玩确定性(恢复续打=不中断直打); + * V4 坏档负例全拒(fail-closed);V5 容量核数(<65536 码元);V6 战斗屏无 run 快照; + * V7 三屏快照可恢复;V8 v1→v2 迁移(M2 旧档平铺 → run 域,meta 空); + * V9 meta 域独立性(meta 坏形只弃 meta,run 仍恢复)。 * * 用法:node scripts/save.test.mjs(退出码 0=全过) */ @@ -21,8 +19,9 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { createRunCore } from '../src/run-core.js'; +import { createMetaCore } from '../src/meta-core.js'; import { createSeededRandom } from '../src/util/seeded-random.js'; -import { makeEnvelope, validateEnvelope, restoreRun, serializeEnvelope } from '../src/save.js'; +import { makeEnvelope, validateEnvelope, restoreRun, restoreMeta, serializeEnvelope } from '../src/save.js'; import { SAVE } from '../src/data/tables.js'; const __dir = dirname(fileURLToPath(import.meta.url)); @@ -35,7 +34,29 @@ function assert(cond, name, detail) { else { console.error(` FAIL ${name}` + (detail ? ` — ${detail}` : '')); failures++; } } -/** 用贪心策略推进一局;stopAt(state)=true 时停(返回是否到达);分发词表与 sim-run 同。 */ +/** 分发词表(与 sim-run 同表)。 */ +function act(run, t) { + if (t.kind === 'card') run.tapCard(t.index); + else if (t.kind === 'enemy') run.confirmPlay(); + else if (t.kind === 'endTurn') run.endTurn(); + else if (t.kind === 'cancel') run.clearSelection(); + else if (t.kind === 'node') run.chooseNode(t.id); + else if (t.kind === 'reward') run.chooseReward(t.index); + else if (t.kind === 'rewardSkip') run.chooseReward('skip'); + else if (t.kind === 'relicReward') run.chooseRelicReward(t.index); + else if (t.kind === 'relicSkip') run.chooseRelicReward('skip'); + else if (t.kind === 'shopCard') run.buyCard(t.index); + else if (t.kind === 'shopRelic') run.buyRelic(t.index); + else if (t.kind === 'shopRemove') run.openRemove(); + else if (t.kind === 'removePick') run.buyRemove(t.index); + else if (t.kind === 'removeBack') run.closeRemove(); + else if (t.kind === 'shopLeave') run.leaveShop(); + else if (t.kind === 'eventOption') run.chooseEventOption(t.index); + else return false; + return true; +} + +/** 用贪心推进;stopAt(state)=true 时停。 */ function drive(run, stopAt, guard = 20000) { for (let i = 0; i < guard; i++) { const s = run.readState(); @@ -43,156 +64,150 @@ function drive(run, stopAt, guard = 20000) { if (stopAt && stopAt(s)) return true; const t = chooseTap(s); if (!t) return false; - if (t.kind === 'card') run.tapCard(t.index); - else if (t.kind === 'enemy') run.confirmPlay(); - else if (t.kind === 'endTurn') run.endTurn(); - else if (t.kind === 'cancel') run.clearSelection(); - else if (t.kind === 'node') run.chooseNode(t.id); - else if (t.kind === 'reward') run.chooseReward(t.index); - else if (t.kind === 'rewardSkip') run.chooseReward('skip'); - else if (t.kind === 'relicReward') run.chooseRelicReward(t.index); - else if (t.kind === 'relicSkip') run.chooseRelicReward('skip'); - else if (t.kind === 'shopCard') run.buyCard(t.index); - else if (t.kind === 'shopRelic') run.buyRelic(t.index); - else if (t.kind === 'shopRemove') run.openRemove(); - else if (t.kind === 'removePick') run.buyRemove(t.index); - else if (t.kind === 'removeBack') run.closeRemove(); - else if (t.kind === 'shopLeave') run.leaveShop(); - else return false; + if (!act(run, t)) return false; } return false; } -/** 打到终态并返回终态投影(V3 用;经 JSON 归一)。 */ function driveToEnd(run) { drive(run, null); return JSON.stringify(run.readState()); } -console.log('[save.test] 《山海巡异录》M2 存档断言(SaveEnvelope 契约面)'); +console.log('[save.test] 《山海巡异录》存档断言(SaveEnvelope v2 双域)'); -// ── V1/V2 信封形状 + 往返逐字段一致(map 屏首快照)── +// ── V1/V2 信封形状 + run 域往返 ── { const run = createRunCore({ random: createSeededRandom(777) }, { seed: 777 }); - // 推进到第二次回到 map 屏(打完教学战、领完奖励),快照内容更饱满。 drive(run, (s) => s.screen === 'map' && s.stats.battlesWon >= 1); - const env = makeEnvelope(run); - assert(env && Number.isInteger(env.schemaVersion) && env.schemaVersion >= 1, 'V1a 信封 schemaVersion 整数 ≥1', JSON.stringify(env && env.schemaVersion)); + const meta = createMetaCore(null); + meta.observe(run.readState()); + const env = makeEnvelope(run, meta); + assert(env && Number.isInteger(env.schemaVersion) && env.schemaVersion >= 1, 'V1a 信封 schemaVersion 整数 ≥1'); assert(env && typeof env.data === 'object' && !Array.isArray(env.data), 'V1b data 为纯对象(非数组)'); - assert(env.schemaVersion === SAVE.schemaVersion, 'V1c 版本=当前表定版本'); + assert(env.schemaVersion === SAVE.schemaVersion && env.schemaVersion === 2, 'V1c 版本=当前表定版本(v2)'); + assert(env.data.run && env.data.meta, 'V1d 双域俱在(run+meta)'); - const restored = restoreRun(JSON.parse(JSON.stringify(env))); // 经 JSON 往返(介质等价) - assert(!!restored, 'V2a 恢复成功'); - const a = run.readState(); - const b = restored.readState(); - assert(JSON.stringify(a) === JSON.stringify(b), 'V2b 往返 readState 逐字段一致(map/deck/relics/HP/金币/靶位全量)'); - assert(b.seed === 777 && b.removeCount === a.removeCount, 'V2c 种子与删卡计数还原', `seed=${b.seed}`); + const restored = restoreRun(JSON.parse(JSON.stringify(env))); + assert(!!restored, 'V2a run 域恢复成功'); + assert(JSON.stringify(run.readState()) === JSON.stringify(restored.readState()), 'V2b 往返 readState 逐字段一致'); + const metaBack = restoreMeta(JSON.parse(JSON.stringify(env))); + assert(metaBack && JSON.stringify(createMetaCore(metaBack).serialize()) === JSON.stringify(meta.serialize()), 'V2c meta 域往返一致'); } -// ── V3 续玩确定性:快照点分叉,「中断恢复续打」与「不中断直打」终态逐字段一致 ── +// ── V3 续玩确定性 ×3 seed(职业乙+进阶也覆盖:恢复续打=不中断直打)── { - const seedList = [301, 302, 303]; - let allSame = true; - let detail = ''; - for (const sd of seedList) { - const runA = createRunCore({ random: createSeededRandom(sd) }, { seed: sd }); + const cases = [[301, {}], [302, { heroClass: 'yi' }], [303, { ascension: 3 }]]; + let allSame = true; let detail = ''; + for (const [sd, opts] of cases) { + const runA = createRunCore({ random: createSeededRandom(sd) }, { seed: sd, ...opts }); drive(runA, (s) => s.screen === 'map' && s.stats.battlesWon >= 2); - const env = makeEnvelope(runA); - if (!env) { allSame = false; detail = `seed=${sd} 无快照`; break; } - const endA = driveToEnd(runA); // 不中断:直接打完 - const runB = restoreRun(JSON.parse(JSON.stringify(env))); // 中断恢复 + const env = makeEnvelope(runA, null); + if (!env.data.run) { allSame = false; detail = `seed=${sd} 无快照`; break; } + const endA = driveToEnd(runA); + const runB = restoreRun(JSON.parse(JSON.stringify(env))); const endB = driveToEnd(runB); - if (endA !== endB) { allSame = false; detail = `seed=${sd} 终态漂移`; break; } + if (endA !== endB) { allSame = false; detail = `seed=${sd} ${JSON.stringify(opts)} 终态漂移`; break; } } - assert(allSame, 'V3 续玩确定性 ×3 seed(恢复续打 = 不中断直打,终态逐字段一致)', detail); + assert(allSame, 'V3 续玩确定性 ×3(甲/乙/A3;恢复续打=不中断直打)', detail); } -// ── V4 坏版本负例(fail-closed:全拒)── +// ── V4 坏档负例(fail-closed 全拒;版本 1 是合法迁移不在此列)── { const run = createRunCore({ random: createSeededRandom(88) }, { seed: 88 }); - const good = makeEnvelope(run); + const good = makeEnvelope(run, null); const cases = [ ['版本 0', { ...good, schemaVersion: 0 }], - ['版本 2(未来版)', { ...good, schemaVersion: 2 }], - ['版本字符串', { ...good, schemaVersion: '1' }], - ['缺 data', { schemaVersion: 1 }], - ['data 为数组', { schemaVersion: 1, data: [1, 2] }], + ['版本 3(未来版)', { ...good, schemaVersion: 3 }], + ['版本字符串', { ...good, schemaVersion: '2' }], + ['缺 data', { schemaVersion: 2 }], + ['data 为数组', { schemaVersion: 2, data: [1, 2] }], ['信封为数组', [good]], - ['裸 data(无信封)', good.data], + ['run 域为数组', { schemaVersion: 2, data: { run: [1], meta: null } }], + ['run 域缺 rngState', { schemaVersion: 2, data: { run: { map: {} }, meta: null } }], ['null', null], - ['data.rngState 缺失', { schemaVersion: 1, data: { ...good.data, rngState: undefined } }], ]; - let allRejected = true; - let leak = ''; + let allRejected = true; let leak = ''; for (const [name, env] of cases) { - if (validateEnvelope(env) !== null || restoreRun(env) !== null) { allRejected = false; leak = name; break; } + const v = validateEnvelope(env); + const rejected = v === null || (v.run === null && name.startsWith('run 域') === false && false); + if (!(v === null) || restoreRun(env) !== null) { allRejected = false; leak = name; break; } } assert(allRejected, `V4 坏档负例 ×${cases.length} 全拒(fail-closed,坏档=无档)`, leak && `漏放:${leak}`); } -// ── V5 容量核数(save: 族 65536 码元;整局路径上的快照峰值)── +// ── V5 容量核数(双域峰值)── { const run = createRunCore({ random: createSeededRandom(505) }, { seed: 505 }); + const meta = createMetaCore(null); let peak = 0; for (let i = 0; i < 20000; i++) { const s = run.readState(); if (s.phase !== 'run') break; - const env = makeEnvelope(run); - if (env) { - const ser = serializeEnvelope(env); + meta.observe(s); + if (s.screen !== 'battle') { + const ser = serializeEnvelope(makeEnvelope(run, meta)); if (!ser.ok) { peak = ser.chars; break; } if (ser.chars > peak) peak = ser.chars; } const t = chooseTap(s); - if (!t) break; - if (t.kind === 'card') run.tapCard(t.index); - else if (t.kind === 'enemy') run.confirmPlay(); - else if (t.kind === 'endTurn') run.endTurn(); - else if (t.kind === 'node') run.chooseNode(t.id); - else if (t.kind === 'reward') run.chooseReward(t.index); - else if (t.kind === 'rewardSkip') run.chooseReward('skip'); - else if (t.kind === 'relicReward') run.chooseRelicReward(t.index); - else if (t.kind === 'relicSkip') run.chooseRelicReward('skip'); - else if (t.kind === 'shopCard') run.buyCard(t.index); - else if (t.kind === 'shopRelic') run.buyRelic(t.index); - else if (t.kind === 'shopRemove') run.openRemove(); - else if (t.kind === 'removePick') run.buyRemove(t.index); - else if (t.kind === 'removeBack') run.closeRemove(); - else if (t.kind === 'shopLeave') run.leaveShop(); - else break; + if (!t || !act(run, t)) break; } - assert(peak > 0 && peak < SAVE.maxChars, `V5 快照峰值 ${peak} 码元 < ${SAVE.maxChars}(契约闸;余量 ${Math.round((1 - peak / SAVE.maxChars) * 100)}%)`); + assert(peak > 0 && peak < SAVE.maxChars, `V5 双域快照峰值 ${peak} 码元 < ${SAVE.maxChars}(余量 ${Math.round((1 - peak / SAVE.maxChars) * 100)}%)`); } -// ── V6 战斗屏无快照 ── +// ── V6 战斗屏无 run 快照(信封 run 域=null)── { const run = createRunCore({ random: createSeededRandom(66) }, { seed: 66 }); - run.chooseNode(run.readState().map.selectable[0]); // 进教学首战 - assert(run.readState().screen === 'battle' && run.snapshot() === null, 'V6 battle 屏 snapshot()=null(战斗外任意节点口径)'); + run.chooseNode(run.readState().map.selectable[0]); + const env = makeEnvelope(run, null); + assert(run.readState().screen === 'battle' && env.data.run === null, 'V6 battle 屏 run 域=null(战斗外任意节点口径)'); } -// ── V7 三屏快照可恢复(reward/shop/relicReward 各一)── +// ── V7 多屏快照可恢复(reward/shop/relicReward/event)── { const stops = [ ['reward', (s) => s.screen === 'reward'], ['shop', (s) => s.screen === 'shop'], ['relicReward', (s) => s.screen === 'relicReward'], + ['event', (s) => s.screen === 'event'], ]; for (const [name, stop] of stops) { let hit = false; - // 多 seed 找到能停在该屏的局(贪心路径依赖 seed;找不到=断言失败,响亮暴露)。 - for (let sd = 900; sd < 940 && !hit; sd++) { + for (let sd = 900; sd < 960 && !hit; sd++) { const run = createRunCore({ random: createSeededRandom(sd) }, { seed: sd }); if (!drive(run, stop)) continue; hit = true; - const env = makeEnvelope(run); - const back = env && restoreRun(JSON.parse(JSON.stringify(env))); + const env = makeEnvelope(run, null); + const back = env.data.run && restoreRun(JSON.parse(JSON.stringify(env))); const same = back && JSON.stringify(run.readState()) === JSON.stringify(back.readState()); assert(!!same, `V7 ${name} 屏快照往返一致(seed=${sd})`); } - if (!hit) assert(false, `V7 ${name} 屏 40 seed 内未达(路径覆盖缺口)`); + if (!hit) assert(false, `V7 ${name} 屏 60 seed 内未达(路径覆盖缺口)`); } } +// ── V8 v1→v2 迁移(M2 旧档=data 平铺 → run 域;meta 空)── +{ + const run = createRunCore({ random: createSeededRandom(112) }, { seed: 112 }); + drive(run, (s) => s.screen === 'map' && s.stats.battlesWon >= 1); + const v1 = { schemaVersion: 1, data: run.snapshot() }; // M2 v1 形状:平铺快照 + const v = validateEnvelope(v1); + assert(v && v.run && v.run.rngState != null && v.meta === null, 'V8a v1 迁移:平铺 → run 域,meta 空'); + const back = restoreRun(v1); + assert(back && JSON.stringify(back.readState()) === JSON.stringify(run.readState()), 'V8b v1 旧档恢复续玩(迁移链真通)'); +} + +// ── V9 meta 域独立性(meta 坏形只弃 meta,run 仍恢复)── +{ + const run = createRunCore({ random: createSeededRandom(113) }, { seed: 113 }); + drive(run, (s) => s.screen === 'map' && s.stats.battlesWon >= 1); + const env = makeEnvelope(run, null); + env.data.meta = [1, 2, 3]; // 坏形 meta(数组) + const v = validateEnvelope(env); + assert(v && v.run && v.meta === null, 'V9a meta 坏形被弃,run 域保留'); + assert(restoreRun(env) !== null && restoreMeta(env) === null, 'V9b run 恢复成功且 meta 返 null(独立 fail-closed)'); +} + console.log(failures === 0 ? '[save.test] 全绿' : `[save.test] ${failures} 条失败`); process.exit(failures === 0 ? 0 : 1); diff --git a/tier2/games/shanhai-xunyilu/scripts/scene-smoke.mjs b/tier2/games/shanhai-xunyilu/scripts/scene-smoke.mjs new file mode 100644 index 00000000..975c6479 --- /dev/null +++ b/tier2/games/shanhai-xunyilu/scripts/scene-smoke.mjs @@ -0,0 +1,174 @@ +/** + * scripts/scene-smoke.mjs —— 《山海巡异录》渲染层冒烟(mock 场景重放;2026-07-06 战斗全空事故防回潮门) + * owner:北极星顶级线③(M2 增补) + * + * 【为什么有这门】战斗全空事故:run-scene 的 `const txt` 声明位于工厂 return 之后 → TDZ 死绑定 → + * 首次 rebuild 即抛 ReferenceError → 地图/战斗/商店整屏绘制全部无声失败,只有事件浮字可见。 + * headless 门(ctx.game=null)不建场景,场景代码零执行——绘制路径的异常只有真浏览器暴露。 + * 本门用「记录型 mock scene」把场景 create/update 在 node 里真执行:七屏全驱动、异常零容忍、 + * 每屏绘制调用计数下限、自证钩子(api.debug 回写)断言。mock 覆盖不了 Phaser 内部渲染行为 + * (那是 CDP 像素/displayCount 复验的事),但「我们代码在绘制路径上的任何异常」在此现形。 + * + * 用法:node scripts/scene-smoke.mjs(退出码 0=全过) + */ + +'use strict'; + +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { createRunCore } from '../src/run-core.js'; +import { createSeededRandom } from '../src/util/seeded-random.js'; +import { createRunScene } from '../src/scenes/run-scene.js'; +import { hitTarget } from '../src/ui/layout.js'; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const { chooseTap } = require(join(__dir, '../../../harness/card-battle.driver.js')); + +let failures = 0; +function assert(cond, name, detail) { + if (cond) console.log(` PASS ${name}` + (detail ? ` — ${detail}` : '')); + else { console.error(` FAIL ${name}` + (detail ? ` — ${detail}` : '')); failures++; } +} + +/** 记录型 mock scene:add.* 全记数;未知链式方法一律 no-op(mock 面窄于真 Phaser,只抓我们代码的异常)。 */ +function makeMockScene() { + const counts = { adds: 0, destroyed: 0 }; + const mkChain = () => new Proxy({}, { + get: (t, k) => { + if (k === 'destroy') return () => { counts.destroyed++; }; + return (...a) => mkChained; // 任意方法 → 链式返回同代理 + }, + }); + // 链式代理单例(所有方法都返回它自己,destroy 记数)。 + const mkChained = new Proxy({}, { + get: (t, k) => { + if (k === 'destroy') return () => { counts.destroyed++; }; + return () => mkChained; + }, + }); + const addObj = () => { counts.adds++; return mkChained; }; + return { + counts, + add: { text: addObj, circle: addObj, rectangle: addObj, graphics: addObj, container: addObj }, + time: { delayedCall: () => {} }, // 演出排程不执行(演出面归 CDP) + tweens: { add: () => {} }, + cameras: { main: { setBackgroundColor: () => {}, shake: () => {} } }, + input: { on: () => {} }, + }; +} + +console.log('[scene-smoke] 《山海巡异录》渲染层冒烟(mock 场景重放)'); + +const seed = 424242; +const run = createRunCore({ random: createSeededRandom(seed) }, { seed }); +const debug = { displayCount: 0, rebuilds: 0, screen: null }; +const sceneObj = createRunScene({ + run: () => run, + debug, + route: (x, y) => { + const t = hitTarget(run.readState().tapTargets, x, y); + if (!t) return; + if (t.kind === 'card') run.tapCard(t.index); + else if (t.kind === 'enemy') run.confirmPlay(); + else if (t.kind === 'endTurn') run.endTurn(); + else if (t.kind === 'node') run.chooseNode(t.id); + else if (t.kind === 'reward') run.chooseReward(t.index); + else if (t.kind === 'rewardSkip') run.chooseReward('skip'); + else if (t.kind === 'relicReward') run.chooseRelicReward(t.index); + else if (t.kind === 'relicSkip') run.chooseRelicReward('skip'); + else if (t.kind === 'shopCard') run.buyCard(t.index); + else if (t.kind === 'shopRelic') run.buyRelic(t.index); + else if (t.kind === 'shopRemove') run.openRemove(); + else if (t.kind === 'removePick') run.buyRemove(t.index); + else if (t.kind === 'removeBack') run.closeRemove(); + else if (t.kind === 'shopLeave') run.leaveShop(); + else if (t.kind === 'eventOption') run.chooseEventOption(t.index); // M3 事件 + }, +}); +const scene = makeMockScene(); + +/** 推 n 帧 update;抛异常=门失败(战斗全空事故的直接口径)。 */ +function frames(n, label) { + for (let i = 0; i < n; i++) { + try { sceneObj.update.call(scene); } catch (e) { + assert(false, `${label}:update 第 ${i + 1} 帧抛异常(绘制路径断裂)`, String(e && e.message)); + return false; + } + } + return true; +} + +// ── C1 create 零异常 ── +try { sceneObj.create.call(scene); assert(true, 'C1 create() 零异常'); } +catch (e) { assert(false, 'C1 create() 零异常', String(e && e.message)); } + +// ── C2 七屏驱动:贪心把整局打到终态,逐屏累计绘制计数;每到一个新屏断言「本屏有绘制」── +const seenScreens = new Map(); // screen → 首见时 rebuild 后的 uiNodes 计数 +let lastScreen = null; +let ok = true; +for (let step = 0; step < 20000 && ok; step++) { + const s = run.readState(); + if (s.phase !== 'run') break; + if (!frames(1, `C2[${s.screen}]`)) { ok = false; break; } + const cur = debug.screen; + if (cur && cur !== lastScreen) { + lastScreen = cur; + if (!seenScreens.has(cur)) seenScreens.set(cur, debug.displayCount); + } + const t = chooseTap(s); + if (!t) break; + // 直接语义调用(与 route 同表;mock 门不做坐标命中)。 + if (t.kind === 'card') run.tapCard(t.index); + else if (t.kind === 'enemy') run.confirmPlay(); + else if (t.kind === 'endTurn') run.endTurn(); + else if (t.kind === 'node') run.chooseNode(t.id); + else if (t.kind === 'reward') run.chooseReward(t.index); + else if (t.kind === 'rewardSkip') run.chooseReward('skip'); + else if (t.kind === 'relicReward') run.chooseRelicReward(t.index); + else if (t.kind === 'relicSkip') run.chooseRelicReward('skip'); + else if (t.kind === 'shopCard') run.buyCard(t.index); + else if (t.kind === 'shopRelic') run.buyRelic(t.index); + else if (t.kind === 'shopRemove') run.openRemove(); + else if (t.kind === 'removePick') run.buyRemove(t.index); + else if (t.kind === 'removeBack') run.closeRemove(); + else if (t.kind === 'shopLeave') run.leaveShop(); + else break; +} +if (ok) frames(2, 'C2[terminal]'); // 终局屏重绘 + +// 屏覆盖与每屏绘制下限(map/battle 是主战场;shop/reward/relicReward 依局路径,断言「见过的屏都画了东西」)。 +assert(seenScreens.has('map') && seenScreens.has('battle'), 'C2a 主屏覆盖(map+battle 必见)', JSON.stringify([...seenScreens.keys()])); +let allDrew = true; let detail = []; +for (const [screen, count] of seenScreens) { + detail.push(`${screen}=${count}`); + if (!(count >= 5)) allDrew = false; // 任何屏 rebuild 后 <5 个显示对象=形同白屏 +} +assert(allDrew, 'C2b 每个到达屏绘制对象 ≥5(白屏即败)', detail.join(' / ')); +// 终局遮罩本体=3–5 件(lose 3 件:遮罩+标题+战果行+提示,win 多一行)——阈值按最小屏 3。 +assert(debug.rebuilds > 0 && debug.displayCount >= 3, 'C2c 自证钩子回写(rebuilds>0 且终局屏 displayCount≥3)', `rebuilds=${debug.rebuilds} display=${debug.displayCount}`); +console.log(` · 屏覆盖:${detail.join(' / ')};终态=${run.readState().phase};mock 总 add=${scene.counts.adds}`); + +// ── C3 选中态重绘(战斗内点选卡的 sig 变化路径,历史上易被签名漏掉)── +{ + const run2 = createRunCore({ random: createSeededRandom(7) }, { seed: 7 }); + const dbg2 = { displayCount: 0, rebuilds: 0, screen: null }; + const so2 = createRunScene({ run: () => run2, debug: dbg2, route: () => {} }); + const sc2 = makeMockScene(); + so2.create.call(sc2); + run2.chooseNode(run2.readState().map.selectable[0]); + so2.update.call(sc2); + const before = dbg2.rebuilds; + run2.tapCard(0); // 选中(ui.selected 变 → sig 变 → 重建) + try { + so2.update.call(sc2); + assert(dbg2.rebuilds === before + 1, 'C3 选中态触发重绘(sig 含 ui.selected)', `rebuilds ${before}→${dbg2.rebuilds}`); + } catch (e) { + assert(false, 'C3 选中态重绘零异常', String(e && e.message)); + } +} + +console.log(failures === 0 ? '[scene-smoke] 全绿' : `[scene-smoke] ${failures} 条失败`); +process.exit(failures === 0 ? 0 : 1); diff --git a/tier2/games/shanhai-xunyilu/scripts/sim-shell.mjs b/tier2/games/shanhai-xunyilu/scripts/sim-shell.mjs index 7f486d41..acfc15d3 100644 --- a/tier2/games/shanhai-xunyilu/scripts/sim-shell.mjs +++ b/tier2/games/shanhai-xunyilu/scripts/sim-shell.mjs @@ -1,22 +1,21 @@ /** - * scripts/sim-shell.mjs —— 《山海巡异录》快进仿真批跑外壳(M2 S8b v0 → S12 v1;node 直跑) - * owner:北极星顶级线③(M2) + * scripts/sim-shell.mjs —— 《山海巡异录》快进仿真批跑外壳(M2 S8b v0 → S12 v1 → M3 S14 三轴全矩阵) + * owner:北极星顶级线③(M2/M3) * - * 【这份是什么】设计书 §6.4「数值 CI」的批跑入口:矩阵扫描(职业×进阶×策略档×n 局)→ - * 格聚合(src/sim/sim-run.js)→ 带判报警(SIM_BANDS,观测不拦)→ 报告(stdout 摘要 + dist/ JSON)。 - * 金标构筑回归=--goldens(S12;基线 scripts/goldens.json)。 + * 【这份是什么】设计书 §6.4「数值 CI」批跑入口:三轴矩阵扫描(职业 × 进阶 A0–A15 × 策略档 × n 局)→ + * 格聚合(src/sim/sim-run.js)→ 带判报警(SIM_BANDS,观测不拦)→ 报告(stdout + dist/ JSON)。 + * 金标构筑回归=--goldens(基线 scripts/goldens.json,漂移 ±8pp 按套判)。 * - * 【矩阵现状】M2 内容面=职业甲 × A0(进阶表随 M3 S14 启用后本壳按表扩轴,轴形状已留)。 + * 【矩阵轴】(M3 S14 全量)职业 {jia,yi} × 进阶 {0..15} × 书面三档 {greedy,chain,defensive} + * × 1000 局/格 = 96,000 局(书 §6.4 口径);random/idle 两档为下界锚,只在 A0 跑观测。 * * 【用法】 - * node scripts/sim-shell.mjs # 全矩阵(3 档 × --n,缺省 1000) - * node scripts/sim-shell.mjs --tier=pr # PR smoke 小矩阵(贪心档 × 200) - * node scripts/sim-shell.mjs --tier=nightly # nightly 全矩阵(3 档 × 1000)+ 金标全量 - * node scripts/sim-shell.mjs --tier=batch # 批产冒烟(3 档 × 200;S9/S10 每批入表即跑) - * node scripts/sim-shell.mjs --goldens # 金标构筑回归(对 scripts/goldens.json 基线判漂移) - * --n=N --seedBase=S --out=dist/xxx.json 可覆写 + * node scripts/sim-shell.mjs --tier=pr # PR smoke:2职业 × A0/A5/A15 × 贪心 × 200(书面小矩阵)+金标抽测 + * node scripts/sim-shell.mjs --tier=batch # 批产冒烟:2职业 × A0 × 五档 × 200(内容入表即跑) + * node scripts/sim-shell.mjs --tier=nightly # 全矩阵 96,000 局 + 下界锚 + 金标全量 + * --classes=jia,yi --ascensions=0,5,15 --strategies=greedy --n=N --seedBase=S --out=… 自由组格 * - * 【退出码】0=跑完(带判越带只报警;M0–M2 硬门仅 L1);1=结构性失败(stuck/guard 截断/金标漂移超线)。 + * 【退出码】0=跑完(带判越带只报警;M0–M3 硬门仅 L1);1=结构性失败(stuck/guard/金标漂移超线)。 */ 'use strict'; @@ -25,75 +24,112 @@ import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; -import { simulateRun, aggregateCell, STRATEGIES } from '../src/sim/sim-run.js'; -import { SIM_BANDS, CARDS, RELICS } from '../src/data/tables.js'; +import { simulateRun, aggregateCell, STRATEGIES, CANON_STRATEGIES } from '../src/sim/sim-run.js'; +import { SIM_BANDS, CARDS, RELICS, CLASSES, ASCENSIONS } from '../src/data/tables.js'; const __dir = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dir, '..'); -// ── 参数解析(零依赖,--k=v)── const argv = Object.fromEntries(process.argv.slice(2).map((a) => { const m = a.match(/^--([^=]+)(?:=(.*))?$/); return m ? [m[1], m[2] != null ? m[2] : true] : [a, true]; })); +const ALL_ASC = Array.from({ length: ASCENSIONS.length + 1 }, (_, i) => i); // 0..15 + /** CI 分层预设(§6.4;墙钟实测随本壳输出,定案写 README)。 */ const TIERS = { - pr: { strategies: ['greedy'], n: 200, goldens: 'sample' }, // PR smoke:贪心×200 + 金标抽测 - nightly: { strategies: ['greedy', 'random', 'idle'], n: 1000, goldens: 'full' }, // 全矩阵 + 金标全量 - batch: { strategies: ['greedy', 'random', 'idle'], n: 200, goldens: 'none' }, // S9/S10 批产入表冒烟 + pr: { classes: ['jia', 'yi'], ascensions: [0, 5, 15], strategies: ['greedy'], n: 200, goldens: 'sample' }, + batch: { classes: ['jia', 'yi'], ascensions: [0], strategies: Object.keys(STRATEGIES), n: 200, goldens: 'none' }, + nightly: { classes: ['jia', 'yi'], ascensions: ALL_ASC, strategies: CANON_STRATEGIES, n: 1000, goldens: 'full', floorAnchors: true }, }; const tier = argv.tier && TIERS[argv.tier] ? argv.tier : null; -const strategies = (argv.strategies ? String(argv.strategies).split(',') : (tier ? TIERS[tier].strategies : ['greedy', 'random', 'idle'])) +const preset = tier ? TIERS[tier] : {}; +const classes = (argv.classes ? String(argv.classes).split(',') : (preset.classes || ['jia'])).filter((c) => CLASSES[c]); +const ascensions = (argv.ascensions ? String(argv.ascensions).split(',').map(Number) : (preset.ascensions || [0])) + .filter((a) => Number.isInteger(a) && a >= 0 && a <= ASCENSIONS.length); +const strategies = (argv.strategies ? String(argv.strategies).split(',') : (preset.strategies || CANON_STRATEGIES)) .filter((s) => STRATEGIES[s]); -const N = argv.n ? parseInt(argv.n, 10) : (tier ? TIERS[tier].n : 1000); +const N = argv.n ? parseInt(argv.n, 10) : (preset.n || 1000); const seedBase = argv.seedBase ? parseInt(argv.seedBase, 10) : 50000; const outPath = argv.out ? String(argv.out) : join(ROOT, 'dist', tier ? `sim-${tier}.json` : 'sim-scan.json'); -const goldensMode = argv.goldens === true ? 'full' : (argv.goldens ? String(argv.goldens) : (tier ? TIERS[tier].goldens : 'none')); +const goldensMode = argv.goldens === true ? 'full' : (argv.goldens ? String(argv.goldens) : (preset.goldens || 'none')); let structuralFailures = 0; const warn = (msg) => console.warn(` WARN ${msg}`); const fail = (msg) => { console.error(` FAIL ${msg}`); structuralFailures++; }; -console.log(`[sim-shell] 矩阵=职业甲 × A0 × {${strategies.join(',')}} × ${N} 局/格;seedBase=${seedBase}${tier ? ` tier=${tier}` : ''}`); +const totalCells = classes.length * ascensions.length * strategies.length; +console.log(`[sim-shell] 矩阵={${classes.join(',')}} × A{${ascensions.join(',')}} × {${strategies.join(',')}} × ${N} 局/格 = ${totalCells} 格 ${totalCells * N} 局;seedBase=${seedBase}${tier ? ` tier=${tier}` : ''}`); -// ── 矩阵扫描 ── +// ── 三轴矩阵扫描 ── const t0 = process.hrtime.bigint(); const cells = []; -for (const strategy of strategies) { - const results = []; - for (let i = 0; i < N; i++) { - results.push(simulateRun({ seed: seedBase + i * 13, strategy })); - } - const agg = aggregateCell(results, { cell: { class: 'jia', ascension: 0, strategy } }); - cells.push(agg); +for (const heroClass of classes) { + for (const ascension of ascensions) { + for (const strategy of strategies) { + const results = []; + // seed 按格错开(进阶/职业散列),格内步进 13(与 M2 单轴口径同源)。 + const cellSeed = (seedBase + ascension * 104729 + (heroClass === 'yi' ? 7919 : 0)) >>> 0; + for (let i = 0; i < N; i++) { + results.push(simulateRun({ seed: cellSeed + i * 13, strategy, runOpts: { heroClass, ascension } })); + } + const agg = aggregateCell(results, { cell: { class: heroClass, ascension, strategy } }); + cells.push(agg); + if (agg.stucks > 0) fail(`${heroClass}/A${ascension}/${strategy} stuck/guard 截断 ${agg.stucks} 局(策略死角或护栏空转)`); + if (agg.invalids > 0) warn(`${heroClass}/A${ascension}/${strategy} invalid ${agg.invalids} 局(动作护栏;指纹见报告)`); - // 结构性失败(与带判分开:这些是仿真环自身的红线,任何档都不许有)。 - if (agg.stucks > 0) fail(`${strategy} 档 stuck/guard 截断 ${agg.stucks} 局(策略死角或护栏空转)`); - - // 带判(观测/报警;校准对象=贪心档)。 - const band = SIM_BANDS.winRateBands[0]; - if (strategy === 'greedy') { - const inBand = agg.winRate >= band[0] && agg.winRate <= band[1]; - console.log(` ${inBand ? 'PASS' : 'WARN'} A0 贪心档胜率 ${(agg.winRate * 100).toFixed(1)}%(带 ${band[0] * 100}–${band[1] * 100}%,σ≈${agg.winRateSigmaPp}pp)${inBand ? '' : ' —— 越带,调参轮处理'}`); - if (agg.bossTurns.grindOverBand > 0 || agg.bossTurns.burstUnderBand > 0) { - warn(`Boss 回合带外:磨(>${SIM_BANDS.bossTurnBand[1]}回合)×${agg.bossTurns.grindOverBand} / 秒(<${SIM_BANDS.bossTurnBand[0]})×${agg.bossTurns.burstUnderBand}(共 ${agg.bossTurns.count} 场)`); + // 带判(观测/报警):贪心档 × 有带进阶层(0/5/15)。 + const band = SIM_BANDS.winRateBands[ascension]; + if (strategy === 'greedy' && band) { + const inBand = agg.winRate >= band[0] && agg.winRate <= band[1]; + console.log(` ${inBand ? 'PASS' : 'WARN'} ${CLASSES[heroClass].name} A${ascension} 贪心胜率 ${(agg.winRate * 100).toFixed(1)}%(带 ${band[0] * 100}–${band[1] * 100}%,σ≈${agg.winRateSigmaPp}pp)${inBand ? '' : ' —— 越带'}`); + } else if (strategy === 'greedy' || N >= 1000) { + console.log(` INFO ${heroClass}/A${ascension}/${strategy} 胜率 ${(agg.winRate * 100).toFixed(1)}%(观测)`); + } + } + } +} +// 下界锚(nightly:random/idle 在 A0 各 1000;观测敌线致死性,不入死卡口径)。 +if (preset.floorAnchors) { + for (const heroClass of classes) { + for (const strategy of ['random', 'idle']) { + const results = []; + for (let i = 0; i < 1000; i++) results.push(simulateRun({ seed: seedBase + i * 13, strategy, runOpts: { heroClass, ascension: 0 } })); + const agg = aggregateCell(results, { cell: { class: heroClass, ascension: 0, strategy, anchor: true } }); + cells.push(agg); + console.log(` INFO ${heroClass}/A0/${strategy} 下界锚胜率 ${(agg.winRate * 100).toFixed(1)}%`); } - if (agg.peakTurnDmg.monopolyRatio != null && agg.peakTurnDmg.monopolyRatio > SIM_BANDS.monopolyMax) { - warn(`峰值回合伤 P99/P50=${agg.peakTurnDmg.monopolyRatio} 超反垄断线 ${SIM_BANDS.monopolyMax}(某流派一枝独秀,削它别抬别人)`); - } - if (agg.deadCards.length) warn(`死卡候选(<${SIM_BANDS.adoptionBand.dead * 100}% 采用):${agg.deadCards.join(',')}`); - if (agg.mustPicks.length) warn(`必选候选(>${SIM_BANDS.adoptionBand.mustPick * 100}% 采用):${agg.mustPicks.join(',')}`); - } else { - console.log(` INFO A0 ${strategy} 档胜率 ${(agg.winRate * 100).toFixed(1)}%(下界观测,非校准对象)`); } - if (agg.invalids > 0) warn(`${strategy} 档 invalid ${agg.invalids} 局(动作护栏触发;指纹样本见报告)`); - console.log(` · ${strategy}:win ${agg.wins}/${agg.n},均战 ${agg.avgBattlesWon},Boss回合 P50=${agg.bossTurns.p50},墙钟 ${agg.wallMs}ms(${agg.msPerRun}ms/局)`); } const scanMs = Number(process.hrtime.bigint() - t0) / 1e6; -// ── 金标构筑回归(S12;每套固定构筑 × 固定 seed 集,胜率漂移按套判)── +// ── 跨档观测:死卡并集(书面三档;A0 格)与指纹去重率(M3 门 >85%〔草案〕)── +{ + const canonA0 = cells.filter((c) => c.cell && !c.cell.anchor && c.cell.ascension === 0 && CANON_STRATEGIES.includes(c.cell.strategy)); + if (canonA0.length) { + const adopted = new Set(); + const seen = new Set(); + for (const c of canonA0) { + for (const [id, rate] of Object.entries(c.adoption)) { + seen.add(id); + if (rate >= SIM_BANDS.adoptionBand.dead) adopted.add(id); + } + } + const dead = [...seen].filter((id) => !adopted.has(id)); + if (dead.length) warn(`死卡候选(书面三档并集 <3%):${dead.join(',')}`); + else console.log(' PASS 死卡并集为空(书面三档下所有出场卡均 ≥3% 采用)'); + for (const c of canonA0) { + if (c.fingerprintUniqueRate != null && c.wins >= 20) { + const okFp = c.fingerprintUniqueRate > 0.85; + console.log(` ${okFp ? 'PASS' : 'WARN'} ${c.cell.class}/A0/${c.cell.strategy} 构筑指纹去重率 ${(c.fingerprintUniqueRate * 100).toFixed(1)}%(门 >85%〔草案〕,胜局 ${c.wins})`); + } + } + } +} + +// ── 金标构筑回归 ── let goldensReport = null; if (goldensMode !== 'none' && goldensMode !== undefined) { const goldensPath = join(ROOT, 'scripts', 'goldens.json'); @@ -112,7 +148,7 @@ if (goldensMode !== 'none' && goldensMode !== undefined) { results.push(simulateRun({ seed: goldens.seedBase + i * 7, strategy: suite.strategy || 'greedy', - runOpts: { deck: suite.deck, relics: suite.relics }, + runOpts: { deck: suite.deck, relics: suite.relics, heroClass: suite.class || 'jia', ascension: suite.ascension || 0 }, })); } const wins = results.filter((r) => r.phase === 'win').length; @@ -130,12 +166,12 @@ if (goldensMode !== 'none' && goldensMode !== undefined) { // ── 报告落盘 ── const report = { - what: '快进仿真矩阵扫描(§6.4 数值 CI)', + what: '快进仿真三轴矩阵扫描(§6.4 数值 CI;M3 S14 全量轴)', date: new Date().toISOString().slice(0, 10), host: `${process.platform}/${process.arch} node ${process.version}(开发机;CI 机型另测)`, tier: tier || 'custom', - matrix: { class: ['jia'], ascension: [0], strategies, nPerCell: N, seedBase }, - tables: { cards: CARDS.length, relics: RELICS.length }, + matrix: { classes, ascensions, strategies, nPerCell: N, seedBase, totalRuns: totalCells * N }, + tables: { cards: CARDS.length, relics: RELICS.length, ascensionLevels: ASCENSIONS.length }, bands: SIM_BANDS, cells, goldens: goldensReport, @@ -144,5 +180,5 @@ const report = { }; mkdirSync(dirname(outPath), { recursive: true }); writeFileSync(outPath, JSON.stringify(report, null, 2)); -console.log(`[sim-shell] 总墙钟 ${report.totalWallMs}ms(扫描 ${report.scanWallMs}ms);报告 → ${outPath}`); +console.log(`[sim-shell] 总墙钟 ${Math.round(report.totalWallMs / 100) / 10}s(扫描 ${Math.round(report.scanWallMs / 100) / 10}s);报告 → ${outPath}`); process.exit(structuralFailures === 0 ? 0 : 1); diff --git a/tier2/games/shanhai-xunyilu/scripts/static-screen.mjs b/tier2/games/shanhai-xunyilu/scripts/static-screen.mjs index eee25fda..22c20e6c 100644 --- a/tier2/games/shanhai-xunyilu/scripts/static-screen.mjs +++ b/tier2/games/shanhai-xunyilu/scripts/static-screen.mjs @@ -26,7 +26,7 @@ const fail = (msg) => { console.error(` FAIL ${msg}`); failures++; }; console.log(`[static-screen] 卡池 ${CARDS.length} 张;深度 ≤3 经济环快筛 + 估值黄牌`); // ── E0 经济词汇封闭性(折算器认知集 = 表中实际经济词集;漂移即红)── -const ECON_WORDS = new Set(['energy', 'draw', 'chainBonus', 'costChainDiscount']); +const ECON_WORDS = new Set(['energy', 'draw', 'chainBonus', 'costChainDiscount', 'ifZhoutian']); { const seen = new Set(); for (const c of CARDS) { @@ -34,6 +34,7 @@ const ECON_WORDS = new Set(['energy', 'draw', 'chainBonus', 'costChainDiscount'] // 产能量/产抽卡的词才是经济词;伤害/护甲/词缀/血价不构成经济环。 if (k === 'energy' || k === 'draw' || k === 'costChainDiscount') seen.add(k); if (k === 'chainBonus' && (c.effects.chainBonus.energy || c.effects.chainBonus.draw)) seen.add('chainBonus'); + if (k === 'ifZhoutian' && (c.effects.ifZhoutian.energy || c.effects.ifZhoutian.draw)) seen.add('ifZhoutian'); } } const unknown = [...seen].filter((w) => !ECON_WORDS.has(w)); @@ -62,6 +63,8 @@ const ECON_WORDS = new Set(['energy', 'draw', 'chainBonus', 'costChainDiscount'] if (c.effects.chainBonus.energy) netEnergy += c.effects.chainBonus.energy; if (c.effects.chainBonus.draw) netDraw += c.effects.chainBonus.draw; } + // ifZhoutian(M3):周天须五行全环,深度 ≤3 序列数学上不可能触发——本筛零入账即精确; + // ≥4 卡的周天经济环归运行时兜底层+指纹观测(§6.3 分层设防,快筛不越界宣称)。 netDraw -= 1; // 打出即消耗一张手牌 prevEl = c.element; // 延续与破序殊途同归:链尾都是本卡元素(破序=重开链,链首=本卡) } diff --git a/tier2/games/shanhai-xunyilu/src/battle-core.js b/tier2/games/shanhai-xunyilu/src/battle-core.js index 7dd66db4..99de1c10 100644 --- a/tier2/games/shanhai-xunyilu/src/battle-core.js +++ b/tier2/games/shanhai-xunyilu/src/battle-core.js @@ -113,7 +113,8 @@ export function createBattleCore(deps, opts = {}) { }, enemy: { id: enemyDef.id, name: enemyDef.name, hpMax: enemyDef.hpMax, hp: enemyDef.hpMax, - block: 0, strength: 0, intentIndex: 0, + block: 0, strength: opts.enemyStartStrength || 0, intentIndex: 0, // M3 进阶 A15:敌开战怀力 + tier: enemyDef.tier || 'normal', // M1 增列(奖励与展示消费) phase2: false, // Boss 相位标记(intents2 激活后为真) // M2 状态词缀(敌侧;易伤/虚弱=回合末-1,灼=敌行动后结算层伤再-1)。 @@ -424,6 +425,21 @@ export function createBattleCore(deps, opts = {}) { const healed = Math.min(fx.heal.n, state.hero.hpMax - state.hero.hp); if (healed > 0) { state.hero.hp += healed; emit({ t: 'heal', n: healed, source: cardId }); } } + // M3 乙职业身份词:周天已开(本回合)则附加效果(链优 payoff;伤/甲吃倍率,资源类不吃)。 + if (fx.ifZhoutian && state.chain.zhoutianUsed) { + const z = fx.ifZhoutian; + if (z.dmg) { + const zd = dealToEnemy(Math.floor(z.dmg * mult)); + emit({ t: 'damage', target: 'enemy', amount: Math.floor(z.dmg * mult), dealt: zd, seg: 1, of: 1, cardId, mult, source: 'ifZhoutian' }); + } + if (z.block) { + const zb = Math.floor(z.block * mult) + state.hero.toughness; + state.hero.block += zb; + emit({ t: 'blockGain', target: 'hero', n: zb, cardId, mult, source: 'ifZhoutian' }); + } + if (z.draw) drawN(z.draw); + if (z.energy) { state.energy += z.energy; emit({ t: 'energyGain', n: z.energy, source: cardId }); } + } if (fx.vulnerable) { state.enemy.statuses.vulnerable += fx.vulnerable.n; emit({ t: 'statusApplied', target: 'enemy', status: 'vulnerable', n: fx.vulnerable.n, cardId }); } if (fx.weak) { state.enemy.statuses.weak += fx.weak.n; emit({ t: 'statusApplied', target: 'enemy', status: 'weak', n: fx.weak.n, cardId }); } if (fx.burn) { state.enemy.statuses.burn += fx.burn.n; emit({ t: 'statusApplied', target: 'enemy', status: 'burn', n: fx.burn.n, cardId }); } diff --git a/tier2/games/shanhai-xunyilu/src/data/tables.js b/tier2/games/shanhai-xunyilu/src/data/tables.js index ae7f17c5..b4177cfb 100644 --- a/tier2/games/shanhai-xunyilu/src/data/tables.js +++ b/tier2/games/shanhai-xunyilu/src/data/tables.js @@ -155,13 +155,132 @@ export const CARDS = [ { id: 'shui-canghai', name: '沧海月明', element: '水', rarity: '普通', cost: 2, type: '术', effects: { draw: { n: 2 }, block: { n: 3 } }, text: '抽 2 张牌,获得 3 点护甲。' }, { id: 'shui-hantan', name: '寒潭映斗', element: '水', rarity: '普通', cost: 1, type: '御', effects: { block: { n: 3 }, chainBonus: { block: 3 } }, text: '获得 3 点护甲;若延续灵脉,追加 3 点。' }, { id: 'shui-beiming', name: '北溟鲲跃', element: '水', rarity: '稀有', cost: 3, type: '攻', effects: { dmg: { n: 9, times: 1 }, draw: { n: 2 }, energy: { n: 1 } }, text: '鲲跃北溟,造成 9 点伤害,抽 2 张牌,得 1 点能量。' }, + // ══ M3 S13 职业乙「璇玑使」66 卡(链优;星曜×山海命名,clean-room;class:'yi')══ + // ── 乙·木(13:普6罕5稀2)── + { id: 'yi-mu-luoteng', class: 'yi', name: '萝藤续脉', element: '木', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 5, times: 1 }, costChainDiscount: { n: 1 } }, text: '造成 5 点伤害;若延续灵脉,费用 -1。' }, + { id: 'yi-mu-ximu', class: 'yi', name: '析木问天', element: '木', rarity: '普通', cost: 1, type: '术', effects: { draw: { n: 2 }, hpCost: { n: 1 } }, text: '失去 1 点气血,抽 2 张牌。' }, + { id: 'yi-mu-ruomu', class: 'yi', name: '若木凝露', element: '木', rarity: '普通', cost: 0, type: '御', effects: { block: { n: 2 } }, text: '获得 2 点护甲。' }, + { id: 'yi-mu-fumu', class: 'yi', name: '扶木朝阳', element: '木', rarity: '普通', cost: 2, type: '攻', effects: { dmg: { n: 8, times: 1 }, heal: { n: 2 } }, text: '造成 8 点伤害,回复 2 点气血。' }, + { id: 'yi-mu-linggen2', class: 'yi', name: '灵根盘结', element: '木', rarity: '普通', cost: 1, type: '御', effects: { block: { n: 3 }, chainBonus: { block: 2 } }, text: '获得 3 点护甲;若成链,追加 2 点。' }, + { id: 'yi-mu-tangong', class: 'yi', name: '檀弓发矢', element: '木', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 6, times: 1 }, chainBonus: { dmg: 3 } }, text: '造成 6 点伤害;若成链,追加 3 点。' }, + { id: 'yi-mu-sangzhe', class: 'yi', name: '桑柘轮转', element: '木', rarity: '罕见', cost: 1, type: '术', effects: { draw: { n: 2 }, costChainDiscount: { n: 1 } }, text: '抽 2 张牌;若延续灵脉,费用 -1。' }, + { id: 'yi-mu-futi', class: 'yi', name: '建木浮梯', element: '木', rarity: '罕见', cost: 2, type: '术', effects: { draw: { n: 2 }, energy: { n: 1 }, hpCost: { n: 3 } }, text: '失去 3 点气血,抽 2 张牌,得 1 点能量。' }, + { id: 'yi-mu-pantao', class: 'yi', name: '蟠桃酿', element: '木', rarity: '罕见', cost: 2, type: '术', effects: { heal: { n: 5 }, block: { n: 4 }, exhaust: true }, text: '回复 5 点气血,获得 4 点护甲。(消耗)' }, + { id: 'yi-mu-qingdi', class: 'yi', name: '青帝手书', element: '木', rarity: '罕见', cost: 2, type: '御', effects: { block: { n: 6 }, chainBonus: { draw: 1 } }, text: '获得 6 点护甲;若成链,抽 1 张牌。' }, + { id: 'yi-mu-kuae', class: 'yi', name: '夸娥负山', element: '木', rarity: '罕见', cost: 3, type: '御', effects: { block: { n: 11 }, vulnerable: { n: 1 } }, text: '获得 11 点护甲,施加 1 回合易伤。' }, + { id: 'yi-mu-taiyi', class: 'yi', name: '太一青符', element: '木', rarity: '稀有', cost: 2, type: '术', effects: { draw: { n: 2 }, toughness: { n: 1 }, ifZhoutian: { energy: 2 } }, text: '抽 2 张牌,获得 1 点韧;本回合已开周天,得 2 点能量。' }, + { id: 'yi-mu-dachun', class: 'yi', name: '青阳大椿', element: '木', rarity: '稀有', cost: 3, type: '御', effects: { block: { n: 9 }, heal: { n: 4 }, toughness: { n: 1 } }, text: '获得 9 点护甲与 1 点韧,回复 4 点气血。' }, + // ── 乙·火(13:普6罕5稀2)── + { id: 'yi-huo-yinghuo', class: 'yi', name: '荧惑守心', element: '火', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 5, times: 1 }, burn: { n: 2 } }, text: '造成 5 点伤害,施加 2 层灼。' }, + { id: 'yi-huo-zhuming', class: 'yi', name: '朱明引', element: '火', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 7, times: 1 } }, text: '朱明之火,造成 7 点伤害。' }, + { id: 'yi-huo-liuhuo', class: 'yi', name: '流火纪', element: '火', rarity: '普通', cost: 0, type: '攻', effects: { dmg: { n: 2, times: 1 }, burn: { n: 1 } }, text: '造成 2 点伤害,施加 1 层灼。' }, + { id: 'yi-huo-tanggu', class: 'yi', name: '汤谷沐日', element: '火', rarity: '普通', cost: 2, type: '术', effects: { burn: { n: 6 }, draw: { n: 1 } }, text: '施加 6 层灼,抽 1 张牌。' }, + { id: 'yi-huo-yangwu', class: 'yi', name: '阳乌啼', element: '火', rarity: '普通', cost: 2, type: '攻', effects: { dmg: { n: 8, times: 1 }, vulnerable: { n: 1 } }, text: '造成 8 点伤害,施加 1 回合易伤。' }, + { id: 'yi-huo-zhulou', class: 'yi', name: '烛楼远照', element: '火', rarity: '普通', cost: 1, type: '术', effects: { burn: { n: 4 }, costChainDiscount: { n: 1 } }, text: '施加 4 层灼;若延续灵脉,费用 -1。' }, + { id: 'yi-huo-huoji', class: 'yi', name: '火纪通明', element: '火', rarity: '罕见', cost: 2, type: '攻', effects: { dmg: { n: 5, times: 2 }, ifZhoutian: { dmg: 8 } }, text: '造成 5 点伤害 2 段;本回合已开周天,追加 8 点。' }, + { id: 'yi-huo-yandi', class: 'yi', name: '炎帝遗策', element: '火', rarity: '罕见', cost: 1, type: '术', effects: { burn: { n: 4 }, draw: { n: 1 } }, text: '施加 4 层灼,抽 1 张牌。' }, + { id: 'yi-huo-zhurong2', class: 'yi', name: '祝融巡南', element: '火', rarity: '罕见', cost: 2, type: '攻', effects: { dmg: { n: 9, times: 1 }, burn: { n: 3 } }, text: '造成 9 点伤害,施加 3 层灼。' }, + { id: 'yi-huo-ligong', class: 'yi', name: '离宫九变', element: '火', rarity: '罕见', cost: 2, type: '术', effects: { burn: { n: 8 }, vulnerable: { n: 1 } }, text: '施加 8 层灼与 1 回合易伤。' }, + { id: 'yi-huo-xiaming', class: 'yi', name: '霞明玉阶', element: '火', rarity: '罕见', cost: 1, type: '御', effects: { block: { n: 3 }, burn: { n: 2 } }, text: '获得 3 点护甲,施加 2 层灼。' }, + { id: 'yi-huo-jinwu2', class: 'yi', name: '金乌浴海', element: '火', rarity: '稀有', cost: 3, type: '攻', effects: { dmg: { n: 7, times: 2 }, burn: { n: 6 }, hpCost: { n: 3 } }, text: '失去 3 点气血,造成 7 点伤害 2 段,施加 6 层灼。' }, + { id: 'yi-huo-huoling', class: 'yi', name: '火铃夷则', element: '火', rarity: '稀有', cost: 2, type: '攻', effects: { dmg: { n: 6, times: 1 }, dmgPerEnemyBurn: { n: 2 }, burn: { n: 3 } }, text: '造成 6 点伤害,敌身每层灼追加 2 点,再施 3 层灼。' }, + // ── 乙·土(13:普6罕5稀2)── + { id: 'yi-tu-wuse', class: 'yi', name: '五色壤', element: '土', rarity: '普通', cost: 1, type: '御', effects: { block: { n: 4 } }, text: '获得 4 点护甲。' }, + { id: 'yi-tu-jishi', class: 'yi', name: '积石传书', element: '土', rarity: '普通', cost: 1, type: '术', effects: { block: { n: 2 }, draw: { n: 1 } }, text: '获得 2 点护甲,抽 1 张牌。' }, + { id: 'yi-tu-lianshan', class: 'yi', name: '磏山铁壁', element: '土', rarity: '普通', cost: 2, type: '御', effects: { block: { n: 7 }, chainBonus: { block: 2 } }, text: '获得 7 点护甲;若成链,追加 2 点。' }, + { id: 'yi-tu-xuanyin', class: 'yi', name: '后土玄印', element: '土', rarity: '普通', cost: 1, type: '术', effects: { toughness: { n: 1 }, block: { n: 2 } }, text: '获得 1 点韧与 2 点护甲。' }, + { id: 'yi-tu-changyang', class: 'yi', name: '常羊拜岳', element: '土', rarity: '普通', cost: 2, type: '攻', effects: { dmg: { n: 9, times: 1 }, toughness: { n: 1 } }, text: '造成 9 点伤害,获得 1 点韧。' }, + { id: 'yi-tu-zhenyue2', class: 'yi', name: '镇岳一击', element: '土', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 7, times: 1 } }, text: '岳势压顶,造成 7 点伤害。' }, + { id: 'yi-tu-xiushe', class: 'yi', name: '修蛇壤穴', element: '土', rarity: '罕见', cost: 2, type: '御', effects: { block: { n: 7 }, weak: { n: 1 } }, text: '获得 7 点护甲,施加 1 回合虚弱。' }, + { id: 'yi-tu-bugang', class: 'yi', name: '步罡踏斗', element: '土', rarity: '罕见', cost: 1, type: '术', effects: { block: { n: 2 }, toughness: { n: 1 }, costChainDiscount: { n: 1 } }, text: '获得 2 点护甲与 1 点韧;若延续灵脉,费用 -1。' }, + { id: 'yi-tu-daiyu', class: 'yi', name: '岱舆孤峙', element: '土', rarity: '罕见', cost: 3, type: '御', effects: { block: { n: 10 }, ifZhoutian: { block: 8 } }, text: '获得 10 点护甲;本回合已开周天,追加 8 点。' }, + { id: 'yi-tu-butian', class: 'yi', name: '息壤补天', element: '土', rarity: '罕见', cost: 2, type: '御', effects: { block: { n: 5 }, heal: { n: 3 } }, text: '获得 5 点护甲,回复 3 点气血。' }, + { id: 'yi-tu-guitian', class: 'yi', name: '圭田祝岁', element: '土', rarity: '罕见', cost: 2, type: '术', effects: { toughness: { n: 2 }, draw: { n: 1 } }, text: '获得 2 点韧,抽 1 张牌。' }, + { id: 'yi-tu-buzhou', class: 'yi', name: '不周残柱', element: '土', rarity: '稀有', cost: 3, type: '御', effects: { block: { n: 11 }, vulnerable: { n: 2 } }, text: '获得 11 点护甲,施加 2 回合易伤。' }, + { id: 'yi-tu-yugao', class: 'yi', name: '帝山玉膏', element: '土', rarity: '稀有', cost: 2, type: '术', effects: { heal: { n: 6 }, toughness: { n: 2 }, exhaust: true }, text: '回复 6 点气血,获得 2 点韧。(消耗)' }, + // ── 乙·金(13:普6罕5稀2)── + { id: 'yi-jin-changgeng', class: 'yi', name: '长庚曳白', element: '金', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 7, times: 1 } }, text: '长庚曳白光,造成 7 点伤害。' }, + { id: 'yi-jin-shenshang', class: 'yi', name: '参商斫桂', element: '金', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 4, times: 1 }, costChainDiscount: { n: 1 } }, text: '造成 4 点伤害;若延续灵脉,费用 -1。' }, + { id: 'yi-jin-baihu', class: 'yi', name: '白虎衔烛', element: '金', rarity: '普通', cost: 2, type: '攻', effects: { dmg: { n: 6, times: 2 }, hpCost: { n: 2 } }, text: '失去 2 点气血,造成 6 点伤害 2 段。' }, + { id: 'yi-jin-yaoguang', class: 'yi', name: '摇光淬刃', element: '金', rarity: '普通', cost: 1, type: '术', effects: { strength: { n: 1 }, block: { n: 2 } }, text: '获得 1 点力与 2 点护甲。' }, + { id: 'yi-jin-jinsuo', class: 'yi', name: '金梭掷影', element: '金', rarity: '普通', cost: 0, type: '攻', effects: { dmg: { n: 2, times: 1 }, costChainDiscount: { n: 1 } }, text: '造成 2 点伤害;若延续灵脉,费用 -1(0 费不变)。' }, + { id: 'yi-jin-zhongshan', class: 'yi', name: '钟山错刀', element: '金', rarity: '普通', cost: 2, type: '攻', effects: { dmg: { n: 10, times: 1 }, weak: { n: 1 } }, text: '造成 10 点伤害,施加 1 回合虚弱。' }, + { id: 'yi-jin-rushou2', class: 'yi', name: '蓐收秉钺', element: '金', rarity: '罕见', cost: 2, type: '攻', effects: { dmg: { n: 8, times: 1 }, vulnerable: { n: 1 }, draw: { n: 1 } }, text: '造成 8 点伤害,施加 1 回合易伤,抽 1 张牌。' }, + { id: 'yi-jin-baiying', class: 'yi', name: '白英鸣镝', element: '金', rarity: '罕见', cost: 1, type: '攻', effects: { dmg: { n: 6, times: 1 }, chainBonus: { energy: 1 } }, text: '造成 6 点伤害;若成链,得 1 点能量。' }, + { id: 'yi-jin-xuanyuan', class: 'yi', name: '轩辕折戟', element: '金', rarity: '罕见', cost: 2, type: '攻', effects: { dmg: { n: 6, times: 2 }, costChainDiscount: { n: 1 } }, text: '造成 6 点伤害 2 段;若延续灵脉,费用 -1。' }, + { id: 'yi-jin-liuyin', class: 'yi', name: '流银八荒', element: '金', rarity: '罕见', cost: 3, type: '攻', effects: { dmg: { n: 5, times: 3 }, vulnerable: { n: 1 } }, text: '造成 5 点伤害 3 段,施加 1 回合易伤。' }, + { id: 'yi-jin-tianqian', class: 'yi', name: '天钤密令', element: '金', rarity: '罕见', cost: 1, type: '术', effects: { strength: { n: 2 }, hpCost: { n: 2 } }, text: '失去 2 点气血,获得 2 点力。' }, + { id: 'yi-jin-changyi', class: 'yi', name: '常仪奏钺', element: '金', rarity: '稀有', cost: 3, type: '攻', effects: { dmg: { n: 8, times: 2 }, ifZhoutian: { dmg: 12 } }, text: '造成 8 点伤害 2 段;本回合已开周天,追加 12 点。' }, + { id: 'yi-jin-juque', class: 'yi', name: '巨阙悬空', element: '金', rarity: '稀有', cost: 2, type: '攻', effects: { dmg: { n: 13, times: 1 }, exhaust: true }, text: '巨阙一落,造成 13 点伤害。(消耗)' }, + // ── 乙·水(14:普8罕4稀2)── + { id: 'yi-shui-tianhan', class: 'yi', name: '天汉横流', element: '水', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 5, times: 1 }, costChainDiscount: { n: 1 } }, text: '造成 5 点伤害;若延续灵脉,费用 -1。' }, + { id: 'yi-shui-yaochi', class: 'yi', name: '瑶池映斗', element: '水', rarity: '普通', cost: 1, type: '御', effects: { block: { n: 4 } }, text: '获得 4 点护甲。' }, + { id: 'yi-shui-ruoshui2', class: 'yi', name: '弱水拂尘', element: '水', rarity: '普通', cost: 1, type: '术', effects: { weak: { n: 1 }, draw: { n: 1 } }, text: '施加 1 回合虚弱,抽 1 张牌。' }, + { id: 'yi-shui-beidou', class: 'yi', name: '北斗酌浆', element: '水', rarity: '普通', cost: 1, type: '术', effects: { draw: { n: 2 } }, text: '斗柄酌天浆,抽 2 张牌。' }, + { id: 'yi-shui-xuanming2', class: 'yi', name: '玄冥司冬', element: '水', rarity: '普通', cost: 2, type: '御', effects: { block: { n: 6 }, weak: { n: 1 } }, text: '获得 6 点护甲,施加 1 回合虚弱。' }, + { id: 'yi-shui-jianglou', class: 'yi', name: '降娄行雨', element: '水', rarity: '普通', cost: 0, type: '术', effects: { block: { n: 1 }, costChainDiscount: { n: 1 } }, text: '获得 1 点护甲;若延续灵脉,费用 -1(0 费不变)。' }, + { id: 'yi-shui-hanjiang', class: 'yi', name: '寒江拾星', element: '水', rarity: '普通', cost: 2, type: '术', effects: { draw: { n: 2 }, heal: { n: 2 } }, text: '抽 2 张牌,回复 2 点气血。' }, + { id: 'yi-shui-tianshu', class: 'yi', name: '天枢洗剑', element: '水', rarity: '普通', cost: 2, type: '攻', effects: { dmg: { n: 8, times: 1 }, draw: { n: 1 } }, text: '造成 8 点伤害,抽 1 张牌。' }, + { id: 'yi-shui-yinhe', class: 'yi', name: '银河倒泻', element: '水', rarity: '罕见', cost: 2, type: '攻', effects: { dmg: { n: 8, times: 1 }, dmgPerChainLink: { n: 2 } }, text: '造成 8 点伤害,灵脉每环追加 2 点。' }, + { id: 'yi-shui-gonggong2', class: 'yi', name: '共工触山', element: '水', rarity: '罕见', cost: 3, type: '攻', effects: { dmg: { n: 20, times: 1 }, hpCost: { n: 5 } }, text: '失去 5 点气血,造成 20 点伤害。' }, + { id: 'yi-shui-tianji', class: 'yi', name: '天玑量海', element: '水', rarity: '罕见', cost: 1, type: '术', effects: { draw: { n: 1 }, energy: { n: 1 }, hpCost: { n: 3 }, exhaust: true }, text: '失去 3 点气血,抽 1 张牌,得 1 点能量。(消耗)' }, + { id: 'yi-shui-xuangui2', class: 'yi', name: '玄龟曳尾', element: '水', rarity: '罕见', cost: 2, type: '御', effects: { block: { n: 7 }, chainBonus: { draw: 1 } }, text: '获得 7 点护甲;若成链,抽 1 张牌。' }, + { id: 'yi-shui-zhoutian', class: 'yi', name: '周天倒卷帘', element: '水', rarity: '稀有', cost: 2, type: '术', effects: { draw: { n: 2 }, ifZhoutian: { energy: 2, draw: 1 } }, text: '抽 2 张牌;本回合已开周天,得 2 点能量再抽 1 张。' }, + { id: 'yi-shui-beiming2', class: 'yi', name: '北溟震怒', element: '水', rarity: '稀有', cost: 3, type: '攻', effects: { dmg: { n: 9, times: 1 }, weak: { n: 2 }, vulnerable: { n: 1 } }, text: '造成 9 点伤害,施加 2 回合虚弱与 1 回合易伤。' }, + // ══ M3 S13 中立 22(行旅工具件;class:'neutral',两职业共入池)══ + { id: 'lu-ganliang', class: 'neutral', name: '行山干粮', element: '木', rarity: '普通', cost: 1, type: '术', effects: { heal: { n: 3 }, exhaust: true }, text: '回复 3 点气血。(消耗)' }, + { id: 'lu-tengzhang', class: 'neutral', name: '青藤杖', element: '木', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 6, times: 1 } }, text: '杖击,造成 6 点伤害。' }, + { id: 'lu-yaonang', class: 'neutral', name: '药囊', element: '木', rarity: '普通', cost: 2, type: '术', effects: { heal: { n: 5 }, block: { n: 2 }, exhaust: true }, text: '回复 5 点气血,获得 2 点护甲。(消耗)' }, + { id: 'lu-mupai', class: 'neutral', name: '里程碑石', element: '土', rarity: '普通', cost: 0, type: '术', effects: { draw: { n: 1 } }, text: '抽 1 张牌。' }, + { id: 'lu-qingnang', class: 'neutral', name: '青囊书', element: '木', rarity: '罕见', cost: 2, type: '术', effects: { draw: { n: 2 }, heal: { n: 2 } }, text: '抽 2 张牌,回复 2 点气血。' }, + { id: 'lu-huozhe', class: 'neutral', name: '火折子', element: '火', rarity: '普通', cost: 0, type: '攻', effects: { dmg: { n: 3, times: 1 } }, text: '掷出火折,造成 3 点伤害。' }, + { id: 'lu-xinghuo', class: 'neutral', name: '星火燧', element: '火', rarity: '普通', cost: 1, type: '术', effects: { burn: { n: 5 } }, text: '施加 5 层灼。' }, + { id: 'lu-nuanlu', class: 'neutral', name: '暖炉', element: '火', rarity: '普通', cost: 1, type: '御', effects: { block: { n: 3 }, heal: { n: 1 } }, text: '获得 3 点护甲,回复 1 点气血。' }, + { id: 'lu-fengsui', class: 'neutral', name: '烽燧急报', element: '火', rarity: '罕见', cost: 2, type: '攻', effects: { dmg: { n: 7, times: 1 }, burn: { n: 4 } }, text: '造成 7 点伤害,施加 4 层灼。' }, + { id: 'lu-tunang', class: 'neutral', name: '土囊', element: '土', rarity: '普通', cost: 1, type: '御', effects: { block: { n: 4 } }, text: '获得 4 点护甲。' }, + { id: 'lu-shipan', class: 'neutral', name: '罗盘石', element: '土', rarity: '普通', cost: 1, type: '术', effects: { block: { n: 2 }, draw: { n: 1 } }, text: '获得 2 点护甲,抽 1 张牌。' }, + { id: 'lu-zhenwen', class: 'neutral', name: '镇纹符', element: '土', rarity: '普通', cost: 2, type: '御', effects: { block: { n: 6 }, vulnerable: { n: 1 } }, text: '获得 6 点护甲,施加 1 回合易伤。' }, + { id: 'lu-anying', class: 'neutral', name: '安营桩', element: '土', rarity: '罕见', cost: 2, type: '御', effects: { block: { n: 5 }, toughness: { n: 1 } }, text: '获得 5 点护甲与 1 点韧。' }, + { id: 'lu-xiaodao', class: 'neutral', name: '行囊小刀', element: '金', rarity: '普通', cost: 1, type: '攻', effects: { dmg: { n: 7, times: 1 } }, text: '出刀,造成 7 点伤害。' }, + { id: 'lu-moshi', class: 'neutral', name: '磨石', element: '金', rarity: '普通', cost: 1, type: '术', effects: { strength: { n: 1 }, block: { n: 2 } }, text: '获得 1 点力与 2 点护甲。' }, + { id: 'lu-lingdang', class: 'neutral', name: '惊鸟铃', element: '金', rarity: '普通', cost: 1, type: '术', effects: { weak: { n: 2 } }, text: '施加 2 回合虚弱。' }, + { id: 'lu-duanzhen', class: 'neutral', name: '断阵刀', element: '金', rarity: '罕见', cost: 2, type: '攻', effects: { dmg: { n: 9, times: 1 }, weak: { n: 1 } }, text: '造成 9 点伤害,施加 1 回合虚弱。' }, + { id: 'lu-shuinang', class: 'neutral', name: '皮水囊', element: '水', rarity: '普通', cost: 1, type: '术', effects: { heal: { n: 2 }, draw: { n: 1 } }, text: '回复 2 点气血,抽 1 张牌。' }, + { id: 'lu-suoyi', class: 'neutral', name: '蓑衣斗笠', element: '水', rarity: '普通', cost: 1, type: '御', effects: { block: { n: 4 } }, text: '获得 4 点护甲。' }, + { id: 'lu-yudi', class: 'neutral', name: '雨笛', element: '水', rarity: '普通', cost: 2, type: '术', effects: { draw: { n: 2 }, weak: { n: 1 } }, text: '抽 2 张牌,施加 1 回合虚弱。' }, + { id: 'lu-luopan', class: 'neutral', name: '水罗盘', element: '水', rarity: '罕见', cost: 1, type: '术', effects: { draw: { n: 1 }, costChainDiscount: { n: 1 }, block: { n: 2 } }, text: '抽 1 张牌,获得 2 点护甲;若延续灵脉,费用 -1。' }, + { id: 'lu-xingtu', class: 'neutral', name: '荧惑星图', element: '火', rarity: '罕见', cost: 2, type: '术', effects: { draw: { n: 3 } }, text: '抽 3 张牌。' }, ]; +/** class 标定收尾(M3 S13):数组前 66 张=M0–M2 存量职业甲卡,统一补 class:'jia'(乙/中立卡对象自带)。 */ +for (const c of CARDS) { if (!c.class) c.class = 'jia'; } + /** M0 教学战卡组:首批 20 卡各 1 份(§11 M0 交付;M0 单战路与回归测试继续用它,字面不动)。 */ export const STARTER_DECK = CARDS.slice(0, 20).map((c) => c.id); /** M1 一局起始卡组(卡组下限 10,§3.2;全普通、五行齐备;攻 5/御 2/术 3 的谱系标准起手配比—— * 首版 攻3 使贪心 driver 面对幕1 Boss dps 不足,回填提攻击密度〔草案〕)。 */ +/** 职业表(M3 S13;书 §5.2 双职业:甲=攻优 HP72 / 乙=链优 HP66;§6.2 数值锚)。 + * 卡池过滤口径:职业池 = class===本职业 + class==='neutral'(中立); + * CARDS 里 class 缺省=甲(M2 存量 66 张,文件尾统一标定);乙/中立卡对象自带 class。 */ +export const CLASSES = { + // starterRelic(M3 S14 平衡轮):职业起始遗物——谱系标准结构;乙=河图残卷(链自带 1 环,身份件), + // 甲=开明兽牙(战始力 1)。乙 HP 66→68:链优税在贪心档无镜像收益,首轮 16.1% 回填〔草案〕。 + jia: { id: 'jia', name: '巡罡使', hpMax: 72, starterDeckKey: 'jia', starterRelic: 'kaiming-ya' }, // 攻优(M0–M2 主角) + yi: { id: 'yi', name: '璇玑使', hpMax: 70, starterDeckKey: 'yi', starterRelic: 'hetu-juan' }, // 链优(M3 S13;66→68→70 平衡轮两轮回填:A15 尾段乙贪心 3.1% 出带的最后一格〔草案〕) +}; + +/** 乙(璇玑使)起始卡组(链优配比 攻3/御2/术5;全普通、五行齐备〔草案〕)。 */ +export const YI_STARTER_DECK = [ + // S14 平衡轮:①shenshang/jianglou→zhongshan/yaochi(16.1%→52.7%);②曾试 ximu→yinghuo 抬 A15, + // 灼起手全线 +15pp 冲破 A0/A5 上限,撤回——尾段缺口改走进阶表全局微软化(A0 不吃修正,零涟漪)〔草案〕 + 'yi-mu-luoteng', 'yi-mu-ximu', + 'yi-huo-zhuming', 'yi-huo-zhulou', + 'yi-tu-wuse', 'yi-tu-jishi', + 'yi-jin-changgeng', 'yi-jin-zhongshan', + 'yi-shui-beidou', 'yi-shui-yaochi', +]; + export const RUN_STARTER_DECK = [ 'mu-qingteng', 'mu-cangyin', 'mu-shengfa', 'huo-lihuo', @@ -370,6 +489,7 @@ export const MAP = { restFloor: 5, // 憩点固定层(单节点) bossFloor: 6, // Boss 层(单节点) shopFloors: [2, 3, 4], // M2 S11:商店保底可落层(每幕恰 1,从仍为 battle 的节点改型) + eventsPerAct: 2, // M3 S16:每幕事件节点数(1–4 层 battle 改型;候选 ≤2 时保战斗密度让位)〔草案〕 // 幕缩放(逐幕表,普通/精英吃、Boss 定幕直调不吃):书 §6.2 锚「幕间 ×2.2」以玩家引擎 // 幕2末 ×4–5 为前提(职业全量卡池/升级面=M2 S9+)。M1 玩家成长面只有加卡+3 遗物, // 首轮仿真回填(贪心 driver ×20)三轮迭代:2.2^n → 1.8^n → 1.4^n 均团灭,幂次复利是主凶, @@ -453,13 +573,297 @@ export const VALUATION = { hardCap: 1.5, // 离谱线(单测硬线,超过=表失误) }; + +/** + * 事件表(M3 S16;书 §5.2:45 = 幕池 12×3 + 通用 9。低成本内容密度主源 §3.2: + * 一段文字+两三个选项+一个代价交换)。acts:适用幕(null=通用池)。 + * 选项 fx 词汇(run-core 唯一消费方,收敛七词): + * gold{n}(±金)/ heal{n}(回血)/ loseHp{n}(掉血,不吃甲)/ hpMax{n}(上限±,现血同步)/ + * relic:true(随机未持有遗物)/ card{rarity}(随机该稀有度职业池卡入组)/ removeRandomCommon:true(随机删一张普通,组>下限才生效) + * 数值全部〔草案·S14 平衡轮回填〕。 + */ +export const EVENTS = [ + // ── 幕 1 池(12)── + { id: 'ev-qiaofu', name: '迷途樵夫', acts: [1], text: '山道旁,樵夫指着两条岔路,愿以酒钱换你带他一程。', options: [ + { label: '带他走大路(得 25 金)', fx: { gold: 25 } }, + { label: '抄小路(失 6 气血,得 45 金)', fx: { loseHp: 6, gold: 45 } }, + ] }, + { id: 'ev-gubei', name: '斑驳古碑', acts: [1], text: '碑文半没于苔藓,依稀是前代巡使的手记。', options: [ + { label: '拓印研读(得罕见符)', fx: { card: '罕见' } }, + { label: '清整碑座(回复 8 气血)', fx: { heal: 8 } }, + ] }, + { id: 'ev-shanci', name: '山神小祠', acts: [1], text: '荒祠无人,香炉尚温。', options: [ + { label: '上香(花 20 金,回复 12 气血)', fx: { gold: -20, heal: 12 } }, + { label: '搜刮供品(得 30 金,失 4 气血)', fx: { gold: 30, loseHp: 4 } }, + { label: '离开', fx: {} }, + ] }, + { id: 'ev-shoushang', name: '受伤的异兽', acts: [1], text: '一只幼兽困在猎户的陷阱里,兽目含泪。', options: [ + { label: '救它(失 5 气血,得遗物)', fx: { loseHp: 5, relic: true } }, + { label: '绕开', fx: {} }, + ] }, + { id: 'ev-caiyao', name: '采药人', acts: [1], text: '背篓里的灵草还带着露水,采药人愿意匀你一株。', options: [ + { label: '买灵草(花 30 金,气血上限 +4)', fx: { gold: -30, hpMax: 4 } }, + { label: '闲聊几句(回复 5 气血)', fx: { heal: 5 } }, + ] }, + { id: 'ev-yeyu', name: '夜雨破庙', acts: [1], text: '雨夜借宿,庙中壁画里的兽影似乎会动。', options: [ + { label: '守夜观画(得普通符)', fx: { card: '普通' }, }, { label: '倒头就睡(回复 10 气血)', fx: { heal: 10 } }, + ] }, + { id: 'ev-duanjian', name: '断剑残冢', acts: [1], text: '剑冢无主,断剑上残留符纹。', options: [ + { label: '拔剑(失 8 气血,得稀有符)', fx: { loseHp: 8, card: '稀有' } }, + { label: '祭拜后离开(得 15 金)', fx: { gold: 15 } }, + ] }, + { id: 'ev-shanxi', name: '山溪淘金', acts: [1], text: '溪水里闪着碎金,也漂着不明的红藻。', options: [ + { label: '下水淘金(得 35 金,失 5 气血)', fx: { gold: 35, loseHp: 5 } }, + { label: '取水解渴(回复 6 气血)', fx: { heal: 6 } }, + ] }, + { id: 'ev-mubei2', name: '无名坟茔', acts: [1], text: '坟前供着半坛酒,碑上无字。', options: [ + { label: '添土祭拜(随机销一张普通符,心境澄明)', fx: { removeRandomCommon: true } }, + { label: '饮那半坛酒(回复 8 气血,失 15 金)', fx: { heal: 8, gold: -15 } }, + ] }, + { id: 'ev-luying', name: '猎户营地', acts: [1], text: '猎户们围着篝火分食烤肉,招手唤你同坐。', options: [ + { label: '同坐分食(回复 10 气血)', fx: { heal: 10 } }, + { label: '换购兽皮(花 25 金,得罕见符)', fx: { gold: -25, card: '罕见' } }, + ] }, + { id: 'ev-tongren', name: '同道巡使', acts: [1], text: '一位同门巡使正整理行囊,准备下山。', options: [ + { label: '交换心得(得普通符)', fx: { card: '普通' } }, + { label: '接济盘缠(失 20 金,气血上限 +3)', fx: { gold: -20, hpMax: 3 } }, + { label: '拱手道别', fx: {} }, + ] }, + { id: 'ev-guquan', name: '枯泉眼', acts: [1], text: '泉眼将枯,一尾小鱼在浅洼里挣扎。', options: [ + { label: '放生小鱼(得 20 金——鱼衔来的谢礼)', fx: { gold: 20 } }, + { label: '疏通泉眼(失 4 气血,回复上限 +4)', fx: { loseHp: 4, hpMax: 4 } }, + ] }, + // ── 幕 2 池(12)── + { id: 'ev-shiqiao', name: '石桥赌局', acts: [2], text: '桥头老者摆着三只倒扣的碗,笑而不语。', options: [ + { label: '押 30 金(五五之数,得 70 或血本无归——按天机定)', fx: { gold: 40 }, weightNote: '简化:期望正,定额结算' }, + { label: '不赌,付 10 金过桥', fx: { gold: -10 } }, + ] }, + { id: 'ev-yunlou', name: '云中楼阁', acts: [2], text: '半山云雾里浮着一座楼阁,梯口悬着铜铃。', options: [ + { label: '登楼(失 10 气血,得稀有符)', fx: { loseHp: 10, card: '稀有' } }, + { label: '摇铃即走(得 25 金)', fx: { gold: 25 } }, + ] }, + { id: 'ev-qimiao', name: '弃庙藏经', acts: [2], text: '经匣上的封条写着「勿启」。', options: [ + { label: '开匣(得罕见符,失 6 气血)', fx: { card: '罕见', loseHp: 6 } }, + { label: '原样供回(回复 8 气血)', fx: { heal: 8 } }, + ] }, + { id: 'ev-yaoshi', name: '妖市夜集', acts: [2], text: '雾里灯笼次第亮起,摊主们都戴着兽面。', options: [ + { label: '以血易宝(失 8 气血,得遗物)', fx: { loseHp: 8, relic: true } }, + { label: '以金易符(花 35 金,得罕见符)', fx: { gold: -35, card: '罕见' } }, + { label: '速速离开', fx: {} }, + ] }, + { id: 'ev-hanyu', name: '寒玉矿洞', acts: [2], text: '洞壁寒玉莹莹,凿声在深处回响。', options: [ + { label: '采玉(得 50 金,失 7 气血)', fx: { gold: 50, loseHp: 7 } }, + { label: '在洞口打坐(回复 12 气血)', fx: { heal: 12 } }, + ] }, + { id: 'ev-duanya', name: '断崖索道', acts: [2], text: '索道年久失修,崖底云海翻涌。', options: [ + { label: '走索道(失 9 气血,拾得 40 金)', fx: { loseHp: 9, gold: 40 } }, + { label: '绕远路(体力消耗,失 3 气血)', fx: { loseHp: 3 } }, + ] }, + { id: 'ev-guishu', name: '归墟商人', acts: [2], text: '商人自称来自海眼归墟,货物泛着咸腥的光。', options: [ + { label: '倾囊购宝(花 60 金,得遗物)', fx: { gold: -60, relic: true } }, + { label: '只买小件(花 25 金,得罕见符)', fx: { gold: -25, card: '罕见' } }, + ] }, + { id: 'ev-wushi', name: '巫祝问卜', acts: [2], text: '巫祝掷出龟甲,卦象晦暗不明。', options: [ + { label: '求解厄(花 30 金,随机销一张普通符)', fx: { gold: -30, removeRandomCommon: true } }, + { label: '求康健(花 30 金,气血上限 +5)', fx: { gold: -30, hpMax: 5 } }, + { label: '不问天命', fx: {} }, + ] }, + { id: 'ev-canjuan', name: '残卷飘零', acts: [2], text: '风里飘来半页符经,字迹被雨水洇开。', options: [ + { label: '拾起研读(得普通符)', fx: { card: '普通' } }, + { label: '任它去(心无挂碍,回复 6 气血)', fx: { heal: 6 } }, + ] }, + { id: 'ev-shou-qun', name: '兽群迁徙', acts: [2], text: '兽群踏过山谷,扬尘蔽日,队伍里有落单的幼崽。', options: [ + { label: '护送幼崽归群(失 6 气血,得遗物)', fx: { loseHp: 6, relic: true } }, + { label: '拾荒(兽群踩落的杂物,得 30 金)', fx: { gold: 30 } }, + ] }, + { id: 'ev-tishen', name: '替身草人', acts: [2], text: '道旁草人穿着巡使的旧袍,眉目被雨打得模糊。', options: [ + { label: '换上新袍(失 20 金,气血上限 +4)', fx: { gold: -20, hpMax: 4 } }, + { label: '取走旧符(得普通符,失 3 气血)', fx: { card: '普通', loseHp: 3 } }, + ] }, + { id: 'ev-yinquan', name: '饮泉辨毒', acts: [2], text: '双泉并涌,一甘一苦,苔色却相反。', options: [ + { label: '饮甘泉(回复 14 气血)', fx: { heal: 14 } }, + { label: '饮苦泉(失 5 气血,气血上限 +6)', fx: { loseHp: 5, hpMax: 6 } }, + ] }, + // ── 幕 3 池(12)── + { id: 'ev-guixu-yan', name: '归墟之眼', acts: [3], text: '海眼漩涡低鸣,万物之水在此归还。', options: [ + { label: '投金问渊(失 50 金,得遗物)', fx: { gold: -50, relic: true } }, + { label: '汲一瓢归墟水(失 8 气血,气血上限 +8)', fx: { loseHp: 8, hpMax: 8 } }, + ] }, + { id: 'ev-tianti', name: '天梯残基', acts: [3], text: '建木折断处只余残基,直上云霄的路早已不通。', options: [ + { label: '攀残基采灵材(失 10 气血,得稀有符)', fx: { loseHp: 10, card: '稀有' } }, + { label: '在基座冥思(回复 15 气血)', fx: { heal: 15 } }, + ] }, + { id: 'ev-zhouye', name: '昼夜倒悬', acts: [3], text: '此地烛阴的余息未散,日月同悬,影子朝着两个方向。', options: [ + { label: '借阴阳淬体(失 12 气血,气血上限 +10)', fx: { loseHp: 12, hpMax: 10 } }, + { label: '速速穿过(失 4 气血)', fx: { loseHp: 4 } }, + ] }, + { id: 'ev-wangchuan', name: '忘川渡口', acts: [3], text: '摆渡人不收金银,只收「记忆」。', options: [ + { label: '交出一段记忆(随机销一张普通符,回复 12 气血)', fx: { removeRandomCommon: true, heal: 12 } }, + { label: '泅渡(失 10 气血)', fx: { loseHp: 10 } }, + ] }, + { id: 'ev-fengshen', name: '封神残坛', acts: [3], text: '坛上符阵尚有余温,似乎还封着什么。', options: [ + { label: '续燃符阵(花 40 金,得遗物)', fx: { gold: -40, relic: true } }, + { label: '拆阵取材(得 45 金,失 6 气血)', fx: { gold: 45, loseHp: 6 } }, + ] }, + { id: 'ev-jinglong', name: '惊龙一瞥', acts: [3], text: '云层裂开一瞬,你与某只巨大的眼睛对视了。', options: [ + { label: '不退反进(失 10 气血,得稀有符)', fx: { loseHp: 10, card: '稀有' } }, + { label: '垂首避让(得 20 金——龙鳞落地成金)', fx: { gold: 20 } }, + ] }, + { id: 'ev-xumi', name: '须弥芥子', acts: [3], text: '一粒芥子悬在半空,里面隐约有山河。', options: [ + { label: '吞下芥子(失 8 气血,气血上限 +8)', fx: { loseHp: 8, hpMax: 8 } }, + { label: '装入行囊(得 55 金)', fx: { gold: 55 } }, + ] }, + { id: 'ev-canglong', name: '苍龙衔烛', acts: [3], text: '烛火自天而降,落在你的掌心不灭。', options: [ + { label: '并入符箓(得稀有符)', fx: { card: '稀有' } }, + { label: '借火疗伤(回复 16 气血)', fx: { heal: 16 } }, + ] }, + { id: 'ev-shanhaitu', name: '山海图残角', acts: [3], text: '图上兽形游走,一角空白正等着新的名字。', options: [ + { label: '以血描图(失 10 气血,得遗物)', fx: { loseHp: 10, relic: true } }, + { label: '收起残角(得 40 金)', fx: { gold: 40 } }, + ] }, + { id: 'ev-kunpeng', name: '鲲鹏过境', acts: [3], text: '天色骤暗,巨翼掠过,落下几枚风羽。', options: [ + { label: '拾羽炼符(得罕见符)', fx: { card: '罕见' } }, + { label: '乘风歇息(回复 12 气血)', fx: { heal: 12 } }, + ] }, + { id: 'ev-mingzhong', name: '暝钟自鸣', acts: [3], text: '古钟无人自鸣,声波荡开处尘埃浮空。', options: [ + { label: '以掌抵钟(失 8 气血,随机销一张普通符)', fx: { loseHp: 8, removeRandomCommon: true } }, + { label: '静听钟纹(得普通符)', fx: { card: '普通' } }, + ] }, + { id: 'ev-xingchen', name: '星辰坠地', acts: [3], text: '一枚星子砸出浅坑,余温灼人。', options: [ + { label: '徒手取星(失 9 气血,得 60 金)', fx: { loseHp: 9, gold: 60 } }, + { label: '围坑取暖(回复 10 气血)', fx: { heal: 10 } }, + ] }, + // ── 通用池(9;任意幕)── + { id: 'ev-xingjiao', name: '行脚商人', acts: null, text: '骡铃叮当,商人打开包袱:「都是山里淘换来的。」', options: [ + { label: '买符(花 28 金,得罕见符)', fx: { gold: -28, card: '罕见' } }, + { label: '卖旧物(得 22 金)', fx: { gold: 22 } }, + ] }, + { id: 'ev-gouhuo', name: '无主篝火', acts: null, text: '火堆还旺,旁边摆着烤好的干粮,主人却不见踪影。', options: [ + { label: '吃了再说(回复 9 气血)', fx: { heal: 9 } }, + { label: '添柴等主人(得 15 金——归来的猎人相谢)', fx: { gold: 15 } }, + ] }, + { id: 'ev-shanhong', name: '山洪断路', acts: null, text: '浊浪卷着断木冲下山谷,吊桥摇摇欲坠。', options: [ + { label: '抢渡(失 7 气血)', fx: { loseHp: 7 } }, + { label: '等水退(错过商机,失 15 金)', fx: { gold: -15 } }, + ] }, + { id: 'ev-qishou', name: '弃兽幼崽', acts: null, text: '兽穴空了,只剩一只幼崽蜷在枯草里。', options: [ + { label: '喂食安置(失 12 金,回复 6 气血——心安)', fx: { gold: -12, heal: 6 } }, + { label: '带去山神祠(得 18 金——祠祝的谢仪)', fx: { gold: 18 } }, + ] }, + { id: 'ev-duanfu', name: '断符残页', acts: null, text: '半张符纸粘在岩壁上,符胆尚存。', options: [ + { label: '补全符纸(得普通符)', fx: { card: '普通' } }, + { label: '取符胆换钱(得 20 金)', fx: { gold: 20 } }, + ] }, + { id: 'ev-jiuweng', name: '埋酒老翁', acts: null, text: '老翁在树下埋酒:「十年后再来挖。」他看了看你:「或者现在就喝。」', options: [ + { label: '对饮(回复 11 气血)', fx: { heal: 11 } }, + { label: '帮他埋酒(得 16 金)', fx: { gold: 16 } }, + ] }, + { id: 'ev-shibei', name: '试胆石碑', acts: null, text: '碑上刻着:「敢者留名。」碑下散落着前人的供钱。', options: [ + { label: '留名取钱(得 32 金,失 5 气血)', fx: { gold: 32, loseHp: 5 } }, + { label: '拱手离开', fx: {} }, + ] }, + { id: 'ev-yishi', name: '遗失的行囊', acts: null, text: '行囊挂在枝头,里面有干粮和一封没寄出的家书。', options: [ + { label: '物归原主(得 25 金——失主的谢礼)', fx: { gold: 25 } }, + { label: '取干粮充饥(回复 8 气血)', fx: { heal: 8 } }, + ] }, + { id: 'ev-wenquan', name: '野汤温泉', acts: null, text: '硫磺味的热气从石缝里涌出来。', options: [ + { label: '泡个痛快(回复 13 气血)', fx: { heal: 13 } }, + { label: '取泉华卖钱(得 24 金)', fx: { gold: 24 } }, + ] }, +]; + + +/** + * 成就表(M3 S15;书 §5.2 成就 40。声明式条件,meta-core 解释器消费——代码零成就逻辑字面量): + * winClass{cls} 用某职业通关 / winAscension{n} 任职业通关 An / winBoth 双职业通关 / + * wins{n} 累计通关数 / runs{n} 累计局数 / deckMaxAtWin{n} 胜局卡组 ≤n / deckMinAtWin{n} 胜局卡组 ≥n / + * hpFullWin 满血通关 / goldAtWin{n} 通关时金 ≥n / removeTotal{n} 累计删卡 / zhoutianRun{n} 单局周天 ≥n / + * collectCards{n} 图鉴卡 ≥n / collectRelics{n} 图鉴遗物 ≥n / collectEnemies{n} 图鉴敌兽 ≥n / + * relicsAtWin{n} 通关时遗物 ≥n / winTurnsUnder{n} 通关总回合 ≤n / dailyPlayed 每日首战 / dailyScore{n} 每日分 ≥n + */ +export const ACHIEVEMENTS = [ + { id: 'ach-first-win', name: '初巡功成', cond: { kind: 'wins', n: 1 }, text: '首次通关三幕。' }, + { id: 'ach-win-jia', name: '罡气纵横', cond: { kind: 'winClass', cls: 'jia' }, text: '用巡罡使通关。' }, + { id: 'ach-win-yi', name: '璇玑妙算', cond: { kind: 'winClass', cls: 'yi' }, text: '用璇玑使通关。' }, + { id: 'ach-win-both', name: '双璧巡天', cond: { kind: 'winBoth' }, text: '两位巡使都完成通关。' }, + { id: 'ach-wins-5', name: '五巡不怠', cond: { kind: 'wins', n: 5 }, text: '累计通关 5 局。' }, + { id: 'ach-wins-10', name: '十巡功德', cond: { kind: 'wins', n: 10 }, text: '累计通关 10 局。' }, + { id: 'ach-wins-25', name: '巡遍山海', cond: { kind: 'wins', n: 25 }, text: '累计通关 25 局。' }, + { id: 'ach-runs-10', name: '行者无疆', cond: { kind: 'runs', n: 10 }, text: '出巡 10 局。' }, + { id: 'ach-runs-30', name: '山海常客', cond: { kind: 'runs', n: 30 }, text: '出巡 30 局。' }, + { id: 'ach-runs-100', name: '百巡老手', cond: { kind: 'runs', n: 100 }, text: '出巡 100 局。' }, + { id: 'ach-asc1', name: '初登异阶', cond: { kind: 'winAscension', n: 1 }, text: '通关进阶一。' }, + { id: 'ach-asc3', name: '三阶不惧', cond: { kind: 'winAscension', n: 3 }, text: '通关进阶三。' }, + { id: 'ach-asc5', name: '五阶巡异', cond: { kind: 'winAscension', n: 5 }, text: '通关进阶五。' }, + { id: 'ach-asc8', name: '八阶履险', cond: { kind: 'winAscension', n: 8 }, text: '通关进阶八。' }, + { id: 'ach-asc10', name: '十阶问天', cond: { kind: 'winAscension', n: 10 }, text: '通关进阶十。' }, + { id: 'ach-asc15', name: '倾覆亦渡', cond: { kind: 'winAscension', n: 15 }, text: '通关进阶十五。' }, + { id: 'ach-thin-deck', name: '轻装简从', cond: { kind: 'deckMaxAtWin', n: 12 }, text: '以不超过 12 张的卡组通关。' }, + { id: 'ach-fat-deck', name: '有备无患', cond: { kind: 'deckMinAtWin', n: 30 }, text: '以 30 张以上的卡组通关。' }, + { id: 'ach-full-hp', name: '毫发无损', cond: { kind: 'hpFullWin' }, text: '满血状态下通关。' }, + { id: 'ach-rich', name: '腰缠万贯', cond: { kind: 'goldAtWin', n: 300 }, text: '通关时持金 300 以上。' }, + { id: 'ach-remove-10', name: '断舍离', cond: { kind: 'removeTotal', n: 10 }, text: '累计销卡 10 张。' }, + { id: 'ach-remove-30', name: '大道至简', cond: { kind: 'removeTotal', n: 30 }, text: '累计销卡 30 张。' }, + { id: 'ach-zhoutian-3', name: '周天三转', cond: { kind: 'zhoutianRun', n: 3 }, text: '单局触发周天 3 次。' }, + { id: 'ach-zhoutian-6', name: '周天六合', cond: { kind: 'zhoutianRun', n: 6 }, text: '单局触发周天 6 次。' }, + { id: 'ach-relics-6', name: '异宝盈袖', cond: { kind: 'relicsAtWin', n: 6 }, text: '通关时持有 6 枚以上遗物。' }, + { id: 'ach-relics-9', name: '百宝行囊', cond: { kind: 'relicsAtWin', n: 9 }, text: '通关时持有 9 枚以上遗物。' }, + { id: 'ach-fast-win', name: '兵贵神速', cond: { kind: 'winTurnsUnder', n: 70 }, text: '总回合数不超过 70 通关。' }, + { id: 'ach-cards-40', name: '符箓初集', cond: { kind: 'collectCards', n: 40 }, text: '图鉴收录 40 种符箓。' }, + { id: 'ach-cards-80', name: '符箓半壁', cond: { kind: 'collectCards', n: 80 }, text: '图鉴收录 80 种符箓。' }, + { id: 'ach-cards-120', name: '符海拾贝', cond: { kind: 'collectCards', n: 120 }, text: '图鉴收录 120 种符箓。' }, + { id: 'ach-cards-154', name: '万符归宗', cond: { kind: 'collectCards', n: 154 }, text: '图鉴收录全部 154 种符箓。' }, + { id: 'ach-relics-20', name: '鉴宝行家', cond: { kind: 'collectRelics', n: 20 }, text: '图鉴收录 20 枚遗物。' }, + { id: 'ach-relics-40', name: '宝录大成', cond: { kind: 'collectRelics', n: 40 }, text: '图鉴收录全部 40 枚遗物。' }, + { id: 'ach-enemies-4', name: '初识异类', cond: { kind: 'collectEnemies', n: 4 }, text: '图鉴收录 4 种异兽。' }, + { id: 'ach-enemies-8', name: '兽谱全览', cond: { kind: 'collectEnemies', n: 8 }, text: '图鉴收录全部 8 种异兽。' }, + { id: 'ach-daily-first', name: '每日一巡', cond: { kind: 'dailyPlayed' }, text: '完成一次每日一局。' }, + { id: 'ach-daily-1000', name: '当日魁首', cond: { kind: 'dailyScore', n: 1000 }, text: '每日一局得分 1000 以上。' }, + { id: 'ach-daily-1500', name: '名动山海', cond: { kind: 'dailyScore', n: 1500 }, text: '每日一局得分 1500 以上。' }, + { id: 'ach-asc-unlock-5', name: '阶梯行者', cond: { kind: 'ascUnlocked', n: 5 }, text: '任一职业解锁进阶五。' }, + { id: 'ach-asc-unlock-10', name: '阶梯尽头', cond: { kind: 'ascUnlocked', n: 10 }, text: '任一职业解锁进阶十。' }, +]; + +/** 每日一局常量(M3 S15;书 §5.1 ⑭:全服同种子、本地最佳分)。 */ +export const DAILY = { + // 分数公式常量(〔草案〕):胜=基石 1000 + 余血×10 + 金×1 - 总回合×2;负=战数×40 + 幕×100。 + winBase: 1000, hpWeight: 10, goldWeight: 1, turnPenalty: 2, battleWeight: 40, actWeight: 100, +}; + +/** + * 进阶修正表(M3 S14;书 §3.2「进阶阶梯」:逐级叠加负面修正,每级独立成表、可组合、累积生效—— + * AN = A1..AN 全部 mods 叠加;A0=无修正基线。数值全部〔草案·S14 平衡轮回填〕。 + * mods 词汇(run-core 聚合消费;Pct 键=百分比累加后一次乘,Delta 键=加区累加): + * enemyAtkPct/enemyHpPct 普通+精英 / eliteHpPct 精英血 / bossHpPct·bossAtkPct Boss / + * elitesPerActDelta 每幕精英配额 / restHealPctDelta 憩点回复比例 / startHpDelta 起始 HP / + * goldPct 战斗金币 / shopPricePct 商店与删卡价 / winHealDelta 战胜回复 / firstTurnEnemyStrength 敌开战力 / + * rewardChoicesDelta 战斗奖励选项数增减(A15:构筑面收紧轴——战斗压力面 A13 已到顶,尾级换轴) + */ +export const ASCENSIONS = [ + { level: 1, text: '异动加剧:每幕精英 +1', mods: { elitesPerActDelta: 1 } }, + { level: 2, text: '兽爪更利:敌攻 +7%', mods: { enemyAtkPct: 7 } }, + { level: 3, text: '兽躯更韧:敌血 +7%', mods: { enemyHpPct: 7 } }, + { level: 4, text: '灵泉枯涩:憩点回复 -12%', mods: { restHealPctDelta: -0.12 } }, + { level: 5, text: '镇主凝形:首领血 +10%', mods: { bossHpPct: 10 } }, + { level: 6, text: '披挂出巡:起始气血 -6', mods: { startHpDelta: -6 } }, + { level: 7, text: '山货歉收:战斗金币 -10%', mods: { goldPct: -10 } }, + { level: 8, text: '兽爪愈利:敌攻再 +6%', mods: { enemyAtkPct: 6 } }, + { level: 9, text: '市价腾贵:商店与销卡价 +15%', mods: { shopPricePct: 15 } }, + { level: 10, text: '兽躯愈韧:敌血再 +4%', mods: { enemyHpPct: 4 } }, + { level: 11, text: '血气难续:战胜回复 -3', mods: { winHealDelta: -3 } }, + { level: 12, text: '精怪成群:精英血 +8%', mods: { eliteHpPct: 8 } }, + { level: 13, text: '镇主暴怒:首领攻 +6%', mods: { bossAtkPct: 6 } }, + { level: 14, text: '灵泉近涸:憩点回复再 -8%', mods: { restHealPctDelta: -0.08 } }, + { level: 15, text: '山海倾覆:机缘稀薄,战斗奖励改为二选一', mods: { rewardChoicesDelta: -1 } }, +]; + /** * run 存档常量(M2 S11;形状=contracts/sdk-interface.d.ts SaveEnvelope:{schemaVersion≥1, data 纯对象}, * 键族='save:'+gameId+':'+versionId、上限 65536 UTF-16 码元、宿主 fail-closed——W-SAVE-CAP 已入库)。 * M2 介质=影子(localStorage 直写/内存),正式 SDK Storage 通道切换随集成段(书 §12④,M5 硬门)。 */ export const SAVE = { - schemaVersion: 1, // 存档版本(信封 schemaVersion;表结构破坏性变更时升并配迁移) + schemaVersion: 2, // v2(M3 S15):data={run,meta} 双域;v1(M2,data=run 平铺)经迁移链升入——首个真实迁移 shadowKey: 'save:shanhai-xunyilu:dev', // 影子键(正式 versionId 随发布集成段注入) maxChars: 65536, // save: 族上限(UTF-16 码元;契约口径) }; diff --git a/tier2/games/shanhai-xunyilu/src/main.js b/tier2/games/shanhai-xunyilu/src/main.js index 91169f0f..127daab7 100644 --- a/tier2/games/shanhai-xunyilu/src/main.js +++ b/tier2/games/shanhai-xunyilu/src/main.js @@ -1,93 +1,164 @@ /** * src/main.js —— 《山海巡异录》入口(A3 entry · PhaserGameFactory 默认导出) - * owner:北极星顶级线③(M1;host 集成修复版) + * owner:北极星顶级线③(M1 host 修复版 → M2 存档续玩 → M3 S15 局外席:home/图鉴/成就/每日) * * 【装载契约】(tier2/contracts/boot-phaser-host.d.ts + boot-phaser-host.js;夜市款同构基准) - * 宿主(bootPhaserHost)自建唯一 Phaser.Game(canvas#tier2-game 挂 opts.parent),调本工厂建实例, - * boot(ctx) 收 { game, canvas, seed, assets, caps, recHook }: - * - real 通道:ctx.game=宿主 Phaser.Game → 本工厂把场景 add 进**宿主实例**并启动; - * - headless/node 通道:ctx.game=null → 只建纯逻辑核,不触渲染(node import-safe)。 + * 宿主(bootPhaserHost)自建唯一 Phaser.Game,调本工厂建实例,boot(ctx) 收 { game, canvas, seed, … }: + * real 通道把场景 add 进宿主实例;headless(ctx.game=null)只建逻辑核,node import-safe。 * - * 【红线 · M0 事故复盘】M0 版曾在 boot 里自建第二个 Phaser.Game 实例且未挂宿主 parent → - * 页面出现游离 canvas 渲到视口外、宿主可见画布全黑、真指针打不到游戏(mini-desktop CDP 取证坐实)。 - * 本版修法:**工厂永不自建引擎实例**(全文件零 Phaser import),场景一律挂 ctx.game; - * run-smoke.mjs S0 静态自查断言钉住此红线防回潮。 + * 【红线 · M0 事故】工厂永不自建引擎实例(全文件零 Phaser import);run-smoke S0 静态自查钉死。 * - * 【输入单一路径】injectInput('tap') 与场景真指针都走 route(x,y) → readState().tapTargets - * 命中序(布局层唯一坐标事实)→ run-core 语义动作。driver/CDP/真人三路同因果。 + * 【M3 模式机】mode='home'|'run':boot 后进 home(选职业/进阶/每日/图鉴/成就;meta 由存档 v2 恢复); + * home 屏 tapTargets 由本工厂产(局外交互不属于一局,不进 run-core);开局/续玩切 'run'。 + * 终局:恰一次 recordRunEnd(成就/解锁/每日分)→ 点遮罩回 home;meta 常存(信封 v2 run=null 仍写)。 + * + * 【输入单一路径】injectInput('tap') 与场景真指针都走 route(x,y) → tapTargets 命中序(home=工厂算, + * run=run-core/layout 算)→ 语义动作。driver/CDP/真人三路同因果。 */ 'use strict'; import { createRunCore } from './run-core.js'; +import { createMetaCore } from './meta-core.js'; import { createSeededRandom } from './util/seeded-random.js'; -import { hitTarget } from './ui/layout.js'; +import { hitTarget, computeHomeTargets } from './ui/layout.js'; import { createRunScene } from './scenes/run-scene.js'; -import { SAVE_KEY, makeEnvelope, restoreRun, serializeEnvelope } from './save.js'; +import { SAVE_KEY, makeEnvelope, restoreRun, restoreMeta, serializeEnvelope } from './save.js'; +import { ASCENSIONS } from './data/tables.js'; const SCENE_KEY = 'shanhai-run'; /** - * 影子存档介质(M2 S11 口径,src/save.js 文件头已申报):浏览器径直写 localStorage 同名键; - * headless/node 无 localStorage 则整段静默跳过(仿真与单测走 run.snapshot() 纯数据面,不经介质)。 - * 正式 SDK Storage 通道(postMessage storage 消息)切换随发布集成段(书 §12④,M5 门)。 + * 影子存档介质(M2 S11 口径;src/save.js 文件头申报):浏览器径直写 localStorage 同名键; + * headless/node 无 localStorage 整段静默跳过。正式 SDK Storage 切换随发布集成段(书 §12④,M5 门)。 */ const shadowStore = { read() { try { const raw = globalThis.localStorage && globalThis.localStorage.getItem(SAVE_KEY); - return raw ? JSON.parse(raw) : null; // 影子介质在游戏侧解析;正式通道由宿主受信边界解析(契约 StorageResultPayload) + return raw ? JSON.parse(raw) : null; // 影子介质游戏侧解析;正式通道由宿主受信边界解析 } catch (e) { return null; } // 坏 JSON/无介质 = 无档(fail-closed) }, write(envelope) { try { if (!globalThis.localStorage) return; const ser = serializeEnvelope(envelope); - if (!ser.ok) { console.error('[save] 存档超容弃写:', ser.chars); return; } // 超容响亮留痕,不静默截断 + if (!ser.ok) { console.error('[save] 存档超容弃写:', ser.chars); return; } // 响亮留痕,不静默截断 globalThis.localStorage.setItem(SAVE_KEY, ser.json); } catch (e) { /* 介质失败不阻游戏(存档是增强,不是主链路) */ } }, - clear() { - try { globalThis.localStorage && globalThis.localStorage.removeItem(SAVE_KEY); } catch (e) { /* 同上 */ } - }, }; /** * PhaserGameFactory:宿主调它建 PhaserGameInstance(boot/readState/injectInput/destroy)。 - * @param {Record} [factoryOpts] 工厂选项(M1 未用) */ export default function createGame(factoryOpts) { /** @type {ReturnType|null} */ let run = null; + /** @type {ReturnType} */ + let metaCore = createMetaCore(null); /** @type {any} 宿主 Phaser.Game(real 通道;headless 为 null)。 */ let hostGame = null; let seed = 12345; + /** 局外模式机(M3):home=局外(main/collection/achievements 三子屏),run=一局。 */ + let mode = 'home'; + let homeScreen = 'main'; + const homeSel = { class: 'jia', ascension: 0 }; + let isDaily = false; + let runEnded = false; // recordRunEnd 去重旗(每局恰一次) + /** 场景自证面(战斗全空事故增补):rebuild 回写 displayCount/rebuilds/screen;headless 恒 null。 */ + let sceneDebug = null; - /** 建/重建一局(seed 派生受控随机;重开局 seed+1 保持确定性谱系)。 */ - function newRun() { - run = createRunCore({ random: createSeededRandom(seed >>> 0) }, { seed: seed >>> 0 }); - } - function restart() { - seed = (seed + 1) >>> 0; - shadowStore.clear(); // 重开=旧档作废(单槽语义) - newRun(); + /** 开一局(职业/进阶/每日种子;M3 局外入口)。 */ + function startRun(cls, ascension, daily) { + isDaily = !!daily; + let runSeed = seed; + if (daily) { + runSeed = metaCore.dailySeedFor().seed; // 全服同种子(日期派生;榜单走平台既有能力) + } else { + seed = (seed + 1) >>> 0; // 普通局:种子步进(同会话重开不同局) + runSeed = seed; + } + run = createRunCore({ random: createSeededRandom(runSeed >>> 0) }, { seed: runSeed >>> 0, heroClass: cls, ascension }); + mode = 'run'; + runEnded = false; + persist(); } - /** 存档节拍:战斗外屏落定即快照(书 §3.4「战斗外任意节点杀进程重进」);终局清档。 */ + /** 终局收口(恰一次):meta 结算(成就/解锁/每日分)→ 存档(run 域清空,meta 常存)。 */ + function settleRunEnd() { + if (!run || runEnded) return; + const end = run.readState(); + if (end.phase === 'run') return; + runEnded = true; + metaCore.recordRunEnd(end, { zhoutianCount: (end.stats && end.stats.zhoutianTotal) || 0, isDaily }); + persist(); + } + + /** 存档节拍:home=只存 meta;run=战斗外屏快照+meta(battle 屏 makeEnvelope 内自然产 run:null—— + * 注意:battle 屏不覆写上一节点快照,跳过写)。 */ function persist() { - if (!run) return; - const s = run.readState(); - if (s.phase !== 'run') { shadowStore.clear(); return; } - if (s.screen === 'battle') return; // 战斗内不存:中断按上一节点快照续 - const env = makeEnvelope(run); - if (env) shadowStore.write(env); + if (mode === 'run' && run) { + const s = run.readState(); + if (s.phase !== 'run') { shadowStore.write(makeEnvelope(null, metaCore)); return; } // 终局:run 域清 + if (s.screen === 'battle') return; // 战斗内不写:保上一节点快照(中断回节点语义) + shadowStore.write(makeEnvelope(run, metaCore)); + } else { + shadowStore.write(makeEnvelope(null, metaCore)); + } + } + + /** home 屏投影(readState home 分支;tapTargets=computeHomeTargets 同源)。 */ + function homeState() { + const meta = metaCore.readMeta(); + const maxAsc = Math.max(0, Math.min(ASCENSIONS.length, meta.ascensionUnlocked[homeSel.class] || 0)); + if (homeSel.ascension > maxAsc) homeSel.ascension = maxAsc; + const daily = metaCore.dailySeedFor(); + const projection = { + schemaVersion: 2, + phase: 'home', + screen: homeScreen, + home: { + sel: { ...homeSel }, + maxAscension: maxAsc, + ascensions: ASCENSIONS.map((a) => ({ level: a.level, text: a.text })), + daily, + meta, + }, + debug: sceneDebug, + }; + projection.tapTargets = computeHomeTargets(projection); + return projection; + } + + /** home 屏语义路由(局外交互;工厂级)。 */ + function routeHome(t) { + if (t.kind === 'homeClass') { homeSel.class = t.class; return; } + if (t.kind === 'ascMinus') { homeSel.ascension = Math.max(0, homeSel.ascension - 1); return; } + if (t.kind === 'ascPlus') { + const meta = metaCore.readMeta(); + const maxAsc = meta.ascensionUnlocked[homeSel.class] || 0; + homeSel.ascension = Math.min(maxAsc, homeSel.ascension + 1); + return; + } + if (t.kind === 'homeStart') { startRun(homeSel.class, homeSel.ascension, false); return; } + if (t.kind === 'homeDaily') { startRun('jia', 0, true); return; } // 每日一局:固定甲 A0(全服同题面〔草案〕) + if (t.kind === 'homeCollection') { homeScreen = 'collection'; return; } + if (t.kind === 'homeAchievements') { homeScreen = 'achievements'; return; } + if (t.kind === 'homeBack') { homeScreen = 'main'; return; } } /** - * 语义路由:坐标 → tapTargets 命中(数组序=优先级)→ run-core 动作。 + * 语义路由:坐标 → tapTargets 命中(数组序=优先级)→ 语义动作。 * 真指针(场景 pointerdown)与 injectInput(driver/CDP)共用,输入因果单源。 */ function route(x, y) { + if (mode === 'home') { + const t = hitTarget(homeState().tapTargets, x, y); + if (t) routeHome(t); + persist(); + return; + } if (!run) return; const t = hitTarget(run.readState().tapTargets, x, y); if (!t) return; @@ -106,39 +177,41 @@ export default function createGame(factoryOpts) { else if (t.kind === 'removePick') run.buyRemove(t.index); else if (t.kind === 'removeBack') run.closeRemove(); else if (t.kind === 'shopLeave') run.leaveShop(); - else if (t.kind === 'overlay') restart(); - persist(); // 每次语义动作后按节拍存档(battle 屏内为 no-op,零热路径开销) + else if (t.kind === 'eventOption') run.chooseEventOption(t.index); // M3 事件 + else if (t.kind === 'overlay') { settleRunEnd(); mode = 'home'; homeScreen = 'main'; persist(); return; } // 终局遮罩:回局外 + // 巡览观测(图鉴收集,幂等)+ 终局结算 + 存档节拍。 + metaCore.observe(run.readState()); + settleRunEnd(); + persist(); } return { /** - * 一次性启动:接宿主 boot 上下文。real 通道把场景挂进宿主 Phaser.Game(装载契约红线: - * 不自建实例、不游离挂载);headless(ctx.game=null)只建逻辑核。 - * @param {{ game?: any, canvas?: HTMLCanvasElement|null, seed?: number }} ctx + * 一次性启动:接宿主 boot 上下文;M3=恢复 meta+续玩(run 域有档直接进局),否则进 home。 */ async boot(ctx) { seed = (ctx && ctx.seed != null ? ctx.seed : 12345) >>> 0; - // M2 S11 续玩:有合法影子档即恢复(fail-closed:坏档=开新局);?seed= 显式注入时视为要开新局。 const saved = shadowStore.read(); + metaCore = createMetaCore(saved ? restoreMeta(saved) : null); const resumed = saved ? restoreRun(saved) : null; - if (resumed) run = resumed; - else newRun(); + if (resumed) { run = resumed; mode = 'run'; runEnded = false; } + else { mode = 'home'; } hostGame = (ctx && ctx.game) || null; if (!hostGame) return true; // headless/node 通道(宿主契约:不真渲染) - // 场景挂宿主实例并自启(宿主 HostScene 常驻管 latch 轮询,本场景只做游戏渲染与输入)。 - hostGame.scene.add(SCENE_KEY, createRunScene({ run: () => run, route }), true); + sceneDebug = { displayCount: 0, rebuilds: 0, screen: null }; // 真渲染通道才有自证面 + hostGame.scene.add(SCENE_KEY, createRunScene({ run: () => run, mode: () => mode, homeState, route, debug: sceneDebug }), true); return true; }, - /** 语义状态(宿主逐帧轮询 phase 做 latch 门;直通 run-core,含 tapTargets)。 */ + /** 语义状态(宿主逐帧轮询 phase 做 latch 门;home 态=局外投影,run 态=run-core 直通+debug)。 */ readState() { - return run ? run.readState() : { phase: 'booting' }; + if (mode === 'home' || !run) return homeState(); + const s = run.readState(); + s.debug = sceneDebug; // 附加取证键(不进 run-core 状态树;场景签名不读它) + return s; }, - /** - * 归一化输入注入(driver/CDP 通道):{type:'tap',x,y} 走与真指针相同的语义路由。 - * @param {{type:string,x?:number,y?:number}} ev - */ + /** 归一化输入注入(driver/CDP 通道):与真指针同一语义路由。 */ injectInput(ev) { if (ev && ev.type === 'tap') route(ev.x || 0, ev.y || 0); }, diff --git a/tier2/games/shanhai-xunyilu/src/map-core.js b/tier2/games/shanhai-xunyilu/src/map-core.js index 2971c705..9a356a9d 100644 --- a/tier2/games/shanhai-xunyilu/src/map-core.js +++ b/tier2/games/shanhai-xunyilu/src/map-core.js @@ -23,7 +23,7 @@ import { MAP } from './data/tables.js'; * @param {number} act 幕号(1..MAP.acts) * @returns {{nodes:Array<{id:string,floor:number,idx:number,type:string}>, edges:Array<[string,string]>}} */ -export function generateActMap(rng, act) { +export function generateActMap(rng, act, genOpts = {}) { const nodes = []; const edges = []; const floors = []; @@ -46,7 +46,7 @@ export function generateActMap(rng, act) { nodes.push(...row); } - // ── 配额:精英每幕 ≥1(2–4 层随机一位);宝匣恰 1(3 层随机一位,且不与精英同节点)── + // ── 配额:精英每幕 ≥1(2–4 层随机一位)+ 进阶 A1 追加(M3 genOpts.extraElites);宝匣恰 1 ── const eliteFloor = MAP.eliteFloors[rng.range(0, MAP.eliteFloors.length - 1)]; const eliteRow = floors[eliteFloor]; const eliteIdx = rng.range(0, eliteRow.length - 1); @@ -86,6 +86,22 @@ export function generateActMap(rng, act) { cand[rng.range(0, cand.length - 1)].type = 'shop'; } + // ── 增益件配额(保底件之后按余量;96k 全矩阵 A1 实抓「事件+追加精英挤兑商店保底」的修序)── + // 事件节点(M3 S16:每幕 ≤2,1–4 层仍为 battle 的随机改型;候选 ≤2 让位保战斗密度)。 + for (let ev = 0; ev < (MAP.eventsPerAct || 0); ev++) { + const cand = []; + for (let f = 1; f <= 4; f++) floors[f].forEach((nd) => { if (nd.type === 'battle') cand.push(nd); }); + if (cand.length <= 2) break; + cand[rng.range(0, cand.length - 1)].type = 'event'; + } + // 进阶 A1 追加精英(M3 S14):2–4 层仍为 battle 的随机改型;无候选跳过(不挤占任何保底)。 + for (let extra = 0; extra < (genOpts.extraElites || 0); extra++) { + const cand = []; + for (const f of MAP.eliteFloors) floors[f].forEach((nd) => { if (nd.type === 'battle') cand.push(nd); }); + if (cand.length === 0) break; + cand[rng.range(0, cand.length - 1)].type = 'elite'; + } + // ── 连边(近邻,保证双向覆盖:上层每节点 ≥1 出边、下层每节点 ≥1 入边)── for (let f = 0; f < MAP.floorsPerAct - 1; f++) { const cur = floors[f]; diff --git a/tier2/games/shanhai-xunyilu/src/meta-core.js b/tier2/games/shanhai-xunyilu/src/meta-core.js new file mode 100644 index 00000000..cbedb26a --- /dev/null +++ b/tier2/games/shanhai-xunyilu/src/meta-core.js @@ -0,0 +1,193 @@ +/** + * src/meta-core.js —— 《山海巡异录》局外核(M3 S15:图鉴/成就/进阶解锁/每日;纯逻辑层) + * owner:北极星顶级线③(M3) + * + * 【这份是什么】设计书 §8.3 四纯逻辑模块的第四块(battle/map/economy〔并入 run〕之后): + * 跨 run 的持久面——图鉴收集(卡/遗物/敌兽,见过即录)、成就 40(声明式条件解释器,表 + * ACHIEVEMENTS)、进阶解锁(通关 An 解锁 An+1,逐职业)、每日一局(日期种子+本地最佳分)。 + * + * 【纯逻辑铁律】零 DOM/引擎 import;无随机(收集与判定都是确定性归纳); + * observe/recordRunEnd 幂等可重放(同一 run 状态重复喂不重复计数)。 + * + * 【持久化】serialize() 产纯对象,进 SaveEnvelope data.meta(v2 信封;src/save.js 统一管介质)。 + */ + +'use strict'; + +import { ACHIEVEMENTS, ASCENSIONS, CLASSES, DAILY } from './data/tables.js'; + +/** + * 建局外核。 + * @param {Object|null} [saved] 存档回填(serialize() 的产物;缺省全新) + */ +export function createMetaCore(saved) { + const meta = { + collection: { cards: {}, relics: {}, enemies: {} }, // 图鉴:id → true(见过) + achievements: {}, // 成就:id → true(达成) + runsPlayed: 0, + wins: 0, + winsByClass: { jia: 0, yi: 0 }, + ascensionUnlocked: { jia: 0, yi: 0 }, // 已解锁可选的最高进阶(通关 An 解锁 An+1) + removeTotal: 0, // 累计删卡(成就轴) + daily: { date: null, seed: null, bestScore: 0, played: false }, + }; + if (saved && typeof saved === 'object') { + // 逐域回填(未知键丢弃;缺键用缺省——向后兼容的窄回填)。 + if (saved.collection) { + for (const k of ['cards', 'relics', 'enemies']) { + if (saved.collection[k] && typeof saved.collection[k] === 'object') meta.collection[k] = { ...saved.collection[k] }; + } + } + if (saved.achievements && typeof saved.achievements === 'object') meta.achievements = { ...saved.achievements }; + if (Number.isInteger(saved.runsPlayed)) meta.runsPlayed = saved.runsPlayed; + if (Number.isInteger(saved.wins)) meta.wins = saved.wins; + if (saved.winsByClass) meta.winsByClass = { jia: saved.winsByClass.jia || 0, yi: saved.winsByClass.yi || 0 }; + if (saved.ascensionUnlocked) meta.ascensionUnlocked = { jia: saved.ascensionUnlocked.jia || 0, yi: saved.ascensionUnlocked.yi || 0 }; + if (Number.isInteger(saved.removeTotal)) meta.removeTotal = saved.removeTotal; + if (saved.daily && typeof saved.daily === 'object') meta.daily = { date: saved.daily.date || null, seed: saved.daily.seed || null, bestScore: saved.daily.bestScore || 0, played: !!saved.daily.played }; + } + + /** 成就判定上下文(recordRunEnd 时的单局观测;跨局量从 meta 读)。 */ + let lastRunCtx = null; + + /** 声明式条件解释器(表 ACHIEVEMENTS.cond;新增 kind 必须在此登记——漏词=成就永不达成,单测钉全覆盖)。 */ + function checkCond(cond, ctx) { + switch (cond.kind) { + case 'wins': return meta.wins >= cond.n; + case 'runs': return meta.runsPlayed >= cond.n; + case 'winClass': return meta.winsByClass[cond.cls] > 0; + case 'winBoth': return meta.winsByClass.jia > 0 && meta.winsByClass.yi > 0; + case 'winAscension': return ctx && ctx.win && ctx.ascension >= cond.n; + case 'deckMaxAtWin': return ctx && ctx.win && ctx.deckCount <= cond.n; + case 'deckMinAtWin': return ctx && ctx.win && ctx.deckCount >= cond.n; + case 'hpFullWin': return ctx && ctx.win && ctx.hp >= ctx.hpMax; + case 'goldAtWin': return ctx && ctx.win && ctx.gold >= cond.n; + case 'removeTotal': return meta.removeTotal >= cond.n; + case 'zhoutianRun': return ctx && ctx.zhoutianCount >= cond.n; + case 'relicsAtWin': return ctx && ctx.win && ctx.relicCount >= cond.n; + case 'winTurnsUnder': return ctx && ctx.win && ctx.turnsTotal <= cond.n; + case 'collectCards': return Object.keys(meta.collection.cards).length >= cond.n; + case 'collectRelics': return Object.keys(meta.collection.relics).length >= cond.n; + case 'collectEnemies': return Object.keys(meta.collection.enemies).length >= cond.n; + case 'dailyPlayed': return meta.daily.played; + case 'dailyScore': return meta.daily.bestScore >= cond.n; + case 'ascUnlocked': return Math.max(meta.ascensionUnlocked.jia, meta.ascensionUnlocked.yi) >= cond.n; + default: return false; // 未知词=永不达成(单测 M3 钉词表全覆盖,防静默) + } + } + + /** 成就重扫(幂等;新达成返回列表供演出)。 */ + function sweepAchievements(ctx) { + const newly = []; + for (const a of ACHIEVEMENTS) { + if (meta.achievements[a.id]) continue; + if (checkCond(a.cond, ctx)) { + meta.achievements[a.id] = true; + newly.push(a.id); + } + } + return newly; + } + + return { + /** + * 巡览观测(工厂每次动作后喂 readState;幂等):图鉴收集——卡组成员/持有遗物/在场敌兽。 + */ + observe(runState) { + if (!runState) return; + for (const id of runState.deck || []) meta.collection.cards[id] = true; + for (const r of runState.relics || []) meta.collection.relics[r.id] = true; + if (runState.battle && runState.battle.enemy) meta.collection.enemies[runState.battle.enemy.id] = true; + }, + + /** + * 终局结算(每局恰一次;调用方负责去重):局数/胜数/进阶解锁/每日分/成就重扫。 + * @param {Object} end 终局 readState 投影 + * @param {{zhoutianCount?:number, isDaily?:boolean}} [extra] 局外补充观测(事件流来源) + * @returns {{newlyAchieved:string[], dailyScore:number|null}} + */ + recordRunEnd(end, extra = {}) { + const win = end.phase === 'win'; + meta.runsPlayed += 1; + meta.removeTotal += (end.stats && end.stats.cardsRemoved) || 0; + if (win) { + meta.wins += 1; + if (meta.winsByClass[end.heroClass] != null) meta.winsByClass[end.heroClass] += 1; + // 进阶解锁:通关 An 解锁 An+1(封顶表长)。 + const cur = meta.ascensionUnlocked[end.heroClass] || 0; + if (end.ascension >= cur && cur < ASCENSIONS.length) { + meta.ascensionUnlocked[end.heroClass] = Math.min(ASCENSIONS.length, end.ascension + 1); + } + } + // 每日分(仅每日局;分数公式=表 DAILY 常量)。 + let dailyScore = null; + if (extra.isDaily) { + dailyScore = win + ? DAILY.winBase + end.hero.hp * DAILY.hpWeight + end.gold * DAILY.goldWeight - (end.stats.turnsTotal || 0) * DAILY.turnPenalty + : (end.stats.battlesWon || 0) * DAILY.battleWeight + (end.act || 1) * DAILY.actWeight; + dailyScore = Math.max(0, Math.round(dailyScore)); + meta.daily.played = true; + if (dailyScore > meta.daily.bestScore) meta.daily.bestScore = dailyScore; + } + lastRunCtx = { + win, + ascension: end.ascension || 0, + deckCount: end.deckCount, + hp: end.hero.hp, + hpMax: end.hero.hpMax, + gold: end.gold, + relicCount: (end.relics || []).length, + turnsTotal: (end.stats && end.stats.turnsTotal) || 0, + zhoutianCount: extra.zhoutianCount || 0, + }; + const newlyAchieved = sweepAchievements(lastRunCtx); + return { newlyAchieved, dailyScore }; + }, + + /** 今日种子(YYYYMMDD 数字;全服同种子的本地实现——服务端榜单走平台既有能力,书 §2)。 */ + dailySeedFor(dateStr) { + const d = dateStr || new Date().toISOString().slice(0, 10); + if (meta.daily.date !== d) { + meta.daily = { date: d, seed: parseInt(d.replace(/-/g, ''), 10) >>> 0, bestScore: meta.daily.date === d ? meta.daily.bestScore : 0, played: false }; + } + return { date: meta.daily.date, seed: meta.daily.seed }; + }, + + /** 局外投影(home/图鉴/成就屏消费)。 */ + readMeta() { + return { + collection: { + cards: Object.keys(meta.collection.cards).length, + relics: Object.keys(meta.collection.relics).length, + enemies: Object.keys(meta.collection.enemies).length, + cardIds: { ...meta.collection.cards }, + relicIds: { ...meta.collection.relics }, + enemyIds: { ...meta.collection.enemies }, + }, + achievements: { ...meta.achievements }, + achievedCount: Object.keys(meta.achievements).length, + runsPlayed: meta.runsPlayed, + wins: meta.wins, + winsByClass: { ...meta.winsByClass }, + ascensionUnlocked: { ...meta.ascensionUnlocked }, + removeTotal: meta.removeTotal, + daily: { ...meta.daily }, + classes: Object.values(CLASSES).map((c) => ({ id: c.id, name: c.name, hpMax: c.hpMax })), + }; + }, + + /** 持久化面(SaveEnvelope data.meta;窄形状,与回填对偶)。 */ + serialize() { + return { + collection: { cards: { ...meta.collection.cards }, relics: { ...meta.collection.relics }, enemies: { ...meta.collection.enemies } }, + achievements: { ...meta.achievements }, + runsPlayed: meta.runsPlayed, + wins: meta.wins, + winsByClass: { ...meta.winsByClass }, + ascensionUnlocked: { ...meta.ascensionUnlocked }, + removeTotal: meta.removeTotal, + daily: { ...meta.daily }, + }; + }, + }; +} diff --git a/tier2/games/shanhai-xunyilu/src/run-core.js b/tier2/games/shanhai-xunyilu/src/run-core.js index 6249adb3..86ed308c 100644 --- a/tier2/games/shanhai-xunyilu/src/run-core.js +++ b/tier2/games/shanhai-xunyilu/src/run-core.js @@ -21,7 +21,8 @@ import { createBattleCore } from './battle-core.js'; import { generateActMap, validateActMap } from './map-core.js'; import { - TABLE_SCHEMA_VERSION, CARDS, ENEMIES, RELICS, MAP, ECONOMY, RUN_STARTER_DECK, CURVES, + TABLE_SCHEMA_VERSION, CARDS, ENEMIES, RELICS, MAP, ECONOMY, RUN_STARTER_DECK, YI_STARTER_DECK, CURVES, + CLASSES, ASCENSIONS, EVENTS, } from './data/tables.js'; import { computeTapTargets } from './ui/layout.js'; @@ -48,6 +49,26 @@ export function createRunCore(deps, opts = {}) { throw new Error('[run-core] 必须注入受控随机源 random(next/range)'); // 错误路径留痕 } + // ── M3 职业与进阶(S13/S14)── + const restoredMeta = opts.restore || null; // 存档回填时职业/进阶随档(先于状态树构建) + const heroClass = restoredMeta && CLASSES[restoredMeta.heroClass] ? restoredMeta.heroClass + : (opts.heroClass && CLASSES[opts.heroClass] ? opts.heroClass : 'jia'); // 缺省甲(M0–M2 兼容) + const classDef = CLASSES[heroClass]; + const ascension = restoredMeta && Number.isInteger(restoredMeta.ascension) ? restoredMeta.ascension + : (Number.isInteger(opts.ascension) ? Math.max(0, Math.min(ASCENSIONS.length, opts.ascension)) : 0); + /** 进阶修正聚合(A1..AN 累积:Pct 累加一次乘,Delta 累加;A0=全零)。 */ + const asc = (() => { + const m = { enemyAtkPct: 0, enemyHpPct: 0, eliteHpPct: 0, bossHpPct: 0, bossAtkPct: 0, elitesPerActDelta: 0, restHealPctDelta: 0, startHpDelta: 0, goldPct: 0, shopPricePct: 0, winHealDelta: 0, firstTurnEnemyStrength: 0, rewardChoicesDelta: 0 }; + for (const a of ASCENSIONS) { + if (a.level > ascension) break; + for (const [k, v] of Object.entries(a.mods)) m[k] = (m[k] || 0) + v; + } + return m; + })(); + /** 职业池(奖励/商店选卡口径):本职业专属 + 中立。 */ + const CLASS_POOL = CARDS.filter((c) => c.class === heroClass || c.class === 'neutral'); + const classStarter = heroClass === 'yi' ? YI_STARTER_DECK : RUN_STARTER_DECK; + /** M2 S11 restore 通道:snapshot() 产出的 data(经 save.js validateEnvelope 校验后传入)。 * 回填=逐字段还原(含 rng 状态由调用方回填到 deps.random),restore 后继续玩与不中断玩逐字段一致。 */ const restored = opts.restore || null; @@ -74,13 +95,15 @@ export function createRunCore(deps, opts = {}) { screen: 'map', // map | battle | reward | relicReward | shop | shopRemove act: 1, gold: 0, - hero: { hp: CURVES.heroHpMax, hpMax: CURVES.heroHpMax }, // 职业甲 72(§6.2;跨战斗携带的长线资源) - deck: (Array.isArray(opts.deck) ? opts.deck.slice() : RUN_STARTER_DECK.slice()), - relics: (Array.isArray(opts.relics) ? opts.relics.slice() : []), + // HP=职业表值+进阶修正(甲72/乙66;A6 起始-8;§6.2 长线资源)。 + hero: { hp: classDef.hpMax + asc.startHpDelta, hpMax: classDef.hpMax + asc.startHpDelta }, + deck: (Array.isArray(opts.deck) ? opts.deck.slice() : classStarter.slice()), + // 起始遗物(M3 平衡轮:职业身份件;金标注入 opts.relics 时以注入为准——金标语义=已成型构筑)。 + relics: (Array.isArray(opts.relics) ? opts.relics.slice() : (classDef.starterRelic ? [classDef.starterRelic] : [])), position: /** @type {string|null} */ (null), // 当前节点 id(null=幕起点,可选 0 层) visited: /** @type {string[]} */ ([]), ui: { selected: -1 }, // 交互态(点选出牌两段式;driver 与真指针共享) - stats: { battlesWon: 0, elitesWon: 0, bossesWon: 0, cardsAdded: 0, relicsGained: 0, playsTotal: 0, turnsTotal: 0, nodesVisited: 0, cardsRemoved: 0, shopPurchases: 0 }, + stats: { battlesWon: 0, elitesWon: 0, bossesWon: 0, cardsAdded: 0, relicsGained: 0, playsTotal: 0, turnsTotal: 0, nodesVisited: 0, cardsRemoved: 0, shopPurchases: 0, zhoutianTotal: 0 }, removeCount: 0, // 全局删卡次数(删价随之递增,跨商店持续——§3.2 给组合爆炸收租) seed: opts.seed != null ? opts.seed : null, }; @@ -93,7 +116,7 @@ export function createRunCore(deps, opts = {}) { /** 建当前幕地图(测试通道优先注入;生成/注入一律过静态可达自检,失败响亮抛)。 */ function buildActMap(act) { - const m = (opts.actMaps && opts.actMaps[act - 1]) || generateActMap(rng, act); + const m = (opts.actMaps && opts.actMaps[act - 1]) || generateActMap(rng, act, { extraElites: asc.elitesPerActDelta }); const v = validateActMap(m); if (!v.ok) throw new Error(`[run-core] 幕 ${act} 地图自检失败:${v.problems.join(';')}`); return m; @@ -119,6 +142,9 @@ export function createRunCore(deps, opts = {}) { let relicReward = null; /** 商店货架(shop/shopRemove 屏时非空):{cards:[{id,price,sold}], relics:[{id,price,sold}], freeRemoveUsed}。 */ let shop = null; + /** 当前事件(event 屏时非空):{id};eventsSeen=本局已遇事件(防重复)。 */ + let currentEvent = null; + let eventsSeen = /** @type {string[]} */ ([]); if (restored) { // 屏内瞬态回填(reward/relicReward/shop 三屏可存档;battle 屏无快照,不在此列)。 if (restored.reward) reward = { options: restored.reward.options.slice(), canSkip: restored.reward.canSkip, after: restored.reward.after }; @@ -130,6 +156,8 @@ export function createRunCore(deps, opts = {}) { freeRemoveUsed: !!restored.shop.freeRemoveUsed, }; } + if (restored.currentEvent) currentEvent = { ...restored.currentEvent }; + if (Array.isArray(restored.eventsSeen)) eventsSeen = restored.eventsSeen.slice(); } /** run 级事件队列(battle 事件在 sync 时合流;takeEvents 取走清空)。 */ @@ -192,11 +220,23 @@ export function createRunCore(deps, opts = {}) { } // ── 内部:敌与缩放 ──────────────────────────────────────────────────────────── - /** 幕缩放(普通/精英;Boss 定幕直调不缩放)——逐幕表 MAP.act*ScaleByAct(消幂次复利,回填纪律见表注)。 */ + /** 幕缩放 × 进阶修正(M3 S14):普通/精英吃幕缩放(Boss 定幕直调不吃)+ 各 tier 吃进阶乘区。 */ function scaleEnemy(def, act) { - if (def.tier === 'boss' || act === 1) return def; - const hpK = MAP.actHpScaleByAct[Math.min(act, MAP.actHpScaleByAct.length) - 1]; - const atkK = MAP.actAtkScaleByAct[Math.min(act, MAP.actAtkScaleByAct.length) - 1]; + // 进阶乘区(A0 全零=恒等;Pct 累加后一次乘)。 + let ascHpK = 1; + let ascAtkK = 1; + if (def.tier === 'boss') { + ascHpK = 1 + asc.bossHpPct / 100; + ascAtkK = 1 + asc.bossAtkPct / 100; + } else { + ascHpK = (1 + asc.enemyHpPct / 100) * (def.tier === 'elite' ? 1 + asc.eliteHpPct / 100 : 1); + ascAtkK = 1 + asc.enemyAtkPct / 100; + } + const actHpK = (def.tier === 'boss' || act === 1) ? 1 : MAP.actHpScaleByAct[Math.min(act, MAP.actHpScaleByAct.length) - 1]; + const actAtkK = (def.tier === 'boss' || act === 1) ? 1 : MAP.actAtkScaleByAct[Math.min(act, MAP.actAtkScaleByAct.length) - 1]; + const hpK = actHpK * ascHpK; + const atkK = actAtkK * ascAtkK; + if (hpK === 1 && atkK === 1) return def; const scaleIntent = (it) => { const out = { ...it }; if (it.kind === 'attack' || it.kind === 'attackBlock') out.n = Math.round(it.n * atkK); @@ -223,7 +263,7 @@ export function createRunCore(deps, opts = {}) { const pool = ENEMIES.filter((e) => e.tier === 'elite' && e.acts.includes(state.act)); return scaleEnemy(pool[rng.range(0, pool.length - 1)], state.act); } - if (state.act === 1 && node.floor === 0) return ENEMIES[0]; // 教学局固定首战「涸泽鲛」(§5.3) + if (state.act === 1 && node.floor === 0) return scaleEnemy(ENEMIES[0], state.act); // 教学局固定首战「涸泽鲛」(§5.3;敌种固定、数值仍吃进阶修正——R15g 抓的缺口:A 级不豁免首战) const pool = ENEMIES.filter((e) => e.tier === 'normal' && e.acts.includes(state.act)); return scaleEnemy(pool[rng.range(0, pool.length - 1)], state.act); } @@ -233,7 +273,8 @@ export function createRunCore(deps, opts = {}) { battle = createBattleCore( { random: rng }, // battleOpts=测试通道透传(fixedDraw/actionLimit 等);run 管理键在后,不可被覆盖。 - { ...(opts.battleOpts || {}), enemy: enemyDef, heroHp: state.hero.hp, heroHpMax: state.hero.hpMax, deck: state.deck.slice(), relicHooks: relicHooks() }, + // A15 敌开战力:限精英/首领(普通敌不带——尾段斜率实测回填,A15 全敌版=1% 出带)。 + { ...(opts.battleOpts || {}), enemy: enemyDef, heroHp: state.hero.hp, heroHpMax: state.hero.hpMax, deck: state.deck.slice(), relicHooks: relicHooks(), enemyStartStrength: (enemyDef.tier === 'elite' || enemyDef.tier === 'boss') ? asc.firstTurnEnemyStrength : 0 }, ); battleNodeType = node.type; state.screen = 'battle'; @@ -251,13 +292,13 @@ export function createRunCore(deps, opts = {}) { const rarities = Object.keys(weights); const total = rarities.reduce((s, r) => s + weights[r], 0); const options = []; - const wantChoices = ECONOMY.cardChoices + (hooks.rewardExtraChoice || 0); // 三青鸟羽:四选一 + const wantChoices = Math.max(2, ECONOMY.cardChoices + (hooks.rewardExtraChoice || 0) + asc.rewardChoicesDelta); // 三青鸟羽:四选一;A15:二选一(下限 2) let guard = 0; while (options.length < wantChoices && guard++ < 200) { let roll = rng.next() * total; let rarity = rarities[0]; for (const r of rarities) { roll -= weights[r]; if (roll <= 0) { rarity = r; break; } } - const pool = CARDS.filter((c) => c.rarity === rarity && !options.includes(c.id)); + const pool = CLASS_POOL.filter((c) => c.rarity === rarity && !options.includes(c.id)); if (pool.length === 0) continue; options.push(pool[rng.range(0, pool.length - 1)].id); } @@ -270,7 +311,7 @@ export function createRunCore(deps, opts = {}) { state.hero.hp = bs.hero.hp; // 职业甲基线「凝血诀」:战胜回复(表 winHealFlat + 遗物 winHealBonus)。 const hooks = relicHooks(); - const wh = Math.min(ECONOMY.winHealFlat + (hooks.winHealBonus || 0), state.hero.hpMax - state.hero.hp); + const wh = Math.min(Math.max(0, ECONOMY.winHealFlat + (hooks.winHealBonus || 0) + asc.winHealDelta), state.hero.hpMax - state.hero.hp); if (wh > 0) { state.hero.hp += wh; emit({ t: 'heal', n: wh, source: 'win-heal' }); } state.stats.turnsTotal += bs.turn; state.stats.battlesWon += 1; @@ -279,7 +320,8 @@ export function createRunCore(deps, opts = {}) { const band = ECONOMY.goldReward[battleNodeType === 'elite' ? 'elite' : battleNodeType === 'boss' ? 'boss' : 'normal']; let gold = rng.range(band[0], band[1]); - if (hooks.goldPct) gold = Math.floor(gold * (1 + hooks.goldPct / 100)); // 乘黄鞍:战斗金币加成 + const goldPctTotal = (hooks.goldPct || 0) + asc.goldPct; // 乘黄鞍 + 进阶 A7(累加一次乘) + if (goldPctTotal) gold = Math.floor(gold * (1 + goldPctTotal / 100)); state.gold += gold; emit({ t: 'battleEnd', result: 'win', tier: battleNodeType, act: state.act }); emit({ t: 'goldGain', n: gold, source: 'battle' }); @@ -312,13 +354,18 @@ export function createRunCore(deps, opts = {}) { if (hooks.freeRemovePerShop && shop && !shop.freeRemoveUsed) return 0; let price = ECONOMY.shop.removeBase + state.removeCount * ECONOMY.shop.removeStep; if (hooks.shopDiscountPct) price = Math.floor(price * (1 - hooks.shopDiscountPct / 100)); + if (asc.shopPricePct) price = Math.floor(price * (1 + asc.shopPricePct / 100)); // 进阶 A9 return price; } /** 进店:生成货架(卡按奖励权重抽互异、遗物未持有池抽、标价随折扣一次定死)。 */ function openShop() { const hooks = relicHooks(); - const disc = (p) => (hooks.shopDiscountPct ? Math.floor(p * (1 - hooks.shopDiscountPct / 100)) : p); + const disc = (p) => { + let out = hooks.shopDiscountPct ? Math.floor(p * (1 - hooks.shopDiscountPct / 100)) : p; + if (asc.shopPricePct) out = Math.floor(out * (1 + asc.shopPricePct / 100)); // 进阶 A9 + return out; + }; const cardIds = []; let guard = 0; while (cardIds.length < ECONOMY.shop.cardSlots && guard++ < 200) { @@ -341,6 +388,17 @@ export function createRunCore(deps, opts = {}) { emit({ t: 'shopEnter', cards: cards.length, relics: relics.length }); } + /** 进事件节点(M3 S16):按幕池+通用池抽未遇事件(受控随机;全遇过则回退通用池复用)。 */ + function openEvent() { + let pool = EVENTS.filter((e) => (e.acts == null || e.acts.includes(state.act)) && !eventsSeen.includes(e.id)); + if (pool.length === 0) pool = EVENTS.filter((e) => e.acts == null); // 兜底:通用池复用 + const ev = pool[rng.range(0, pool.length - 1)]; + eventsSeen.push(ev.id); + currentEvent = { id: ev.id }; + state.screen = 'event'; + emit({ t: 'eventStart', eventId: ev.id, name: ev.name }); + } + /** 进下一幕:幕间整备恢复(表 bossHealPct)→ 重生成地图、位置归零。 */ function nextAct() { const heal = Math.min(Math.floor(state.hero.hpMax * ECONOMY.bossHealPct), state.hero.hpMax - state.hero.hp); @@ -358,7 +416,10 @@ export function createRunCore(deps, opts = {}) { /** 战斗事件合流 + 终态桥接(battle latch → run 级结算/latch)。 */ function syncBattle() { if (!battle) return; - for (const e of battle.takeEvents()) events.push(e); + for (const e of battle.takeEvents()) { + if (e.t === 'zhoutian') state.stats.zhoutianTotal += 1; // M3 成就轴(单局周天计数) + events.push(e); + } const phase = battle.readState().phase; if (phase === 'win') settleBattleWin(); else if (phase === 'lose') { @@ -394,7 +455,8 @@ export function createRunCore(deps, opts = {}) { if (node.type === 'battle' || node.type === 'elite' || node.type === 'boss') { startBattle(node); } else if (node.type === 'rest') { - const pct = ECONOMY.restHealPct + (relicHooks().restHealPctBonus || 0); // 若木灯:憩点回复加成 + // 若木灯加成 + 进阶 A4/A14 减免(比例累加,下限 0.1 防负回复)。 + const pct = Math.max(0.1, ECONOMY.restHealPct + (relicHooks().restHealPctBonus || 0) + asc.restHealPctDelta); const heal = Math.min(Math.floor(state.hero.hpMax * pct), state.hero.hpMax - state.hero.hp); state.hero.hp += heal; emit({ t: 'heal', n: heal, source: 'rest' }); @@ -402,10 +464,63 @@ export function createRunCore(deps, opts = {}) { grantRelic('treasure'); } else if (node.type === 'shop') { openShop(); + } else if (node.type === 'event') { + openEvent(); } return { ok: true }; }, + /** + * 事件选项结算(event 屏;M3 S16)。fx 七词见表 EVENTS 注释;结算后回 map。 + */ + chooseEventOption(index) { + if (terminal || state.screen !== 'event' || !currentEvent) return { ok: false, reason: 'wrong-screen' }; + const ev = EVENTS.find((e) => e.id === currentEvent.id); + const opt = ev.options[index]; + if (!opt) return { ok: false, reason: 'bad-index' }; + const fx = opt.fx || {}; + if (fx.gold) { + state.gold = Math.max(0, state.gold + fx.gold); + emit({ t: fx.gold > 0 ? 'goldGain' : 'goldSpend', n: Math.abs(fx.gold), source: 'event' }); + } + if (fx.heal) { + const h = Math.min(fx.heal, state.hero.hpMax - state.hero.hp); + if (h > 0) { state.hero.hp += h; emit({ t: 'heal', n: h, source: 'event' }); } + } + if (fx.loseHp) { + state.hero.hp = Math.max(1, state.hero.hp - fx.loseHp); // 事件不致死(下限 1;死亡判定只在战斗) + emit({ t: 'hpCost', n: fx.loseHp, source: 'event' }); + } + if (fx.hpMax) { + state.hero.hpMax = Math.max(1, state.hero.hpMax + fx.hpMax); + state.hero.hp = Math.min(Math.max(1, state.hero.hp + fx.hpMax), state.hero.hpMax); + emit({ t: 'hpMaxChange', n: fx.hpMax, source: 'event' }); + } + if (fx.relic) grantRelic('event'); + if (fx.card) { + const pool = CLASS_POOL.filter((c) => c.rarity === fx.card); + if (pool.length) { + const card = pool[rng.range(0, pool.length - 1)]; + state.deck.push(card.id); + state.stats.cardsAdded += 1; + emit({ t: 'cardAdded', cardId: card.id, source: 'event' }); + } + } + if (fx.removeRandomCommon && state.deck.length > ECONOMY.deckMin) { + const commons = state.deck.map((id, i) => ({ id, i })).filter((x) => CARD_BY_ID[x.id].rarity === '普通'); + if (commons.length) { + const pick = commons[rng.range(0, commons.length - 1)]; + state.deck.splice(pick.i, 1); + state.stats.cardsRemoved += 1; + emit({ t: 'cardRemoved', cardId: pick.id, price: 0, source: 'event' }); + } + } + emit({ t: 'eventResolved', eventId: currentEvent.id, option: index }); + currentEvent = null; + state.screen = 'map'; + return { ok: true }; + }, + /** 点手牌(battle 屏;两段式:选中 → 再点同卡打出)。 */ tapCard(handIndex) { if (terminal || state.screen !== 'battle' || !battle) return { ok: false, reason: 'wrong-screen' }; @@ -589,6 +704,10 @@ export function createRunCore(deps, opts = {}) { battle: battle ? battle.readState() : null, reward: reward ? { options: reward.options.map((id) => ({ id, ...CARD_BY_ID[id] })), canSkip: reward.canSkip } : null, relicReward: relicReward ? { options: relicReward.options.map((id) => ({ id, name: RELIC_BY_ID[id].name, text: RELIC_BY_ID[id].text })), canSkip: relicReward.canSkip } : null, + event: currentEvent ? (() => { + const ev = EVENTS.find((e) => e.id === currentEvent.id); + return { id: ev.id, name: ev.name, text: ev.text, options: ev.options.map((o) => ({ label: o.label, fx: o.fx || {} })) }; + })() : null, shop: shop ? { cards: shop.cards.map((c) => ({ ...c, ...CARD_BY_ID[c.id] })), relics: shop.relics.map((r) => ({ ...r, name: RELIC_BY_ID[r.id].name, text: RELIC_BY_ID[r.id].text })), @@ -601,6 +720,8 @@ export function createRunCore(deps, opts = {}) { ui: { selected: state.ui.selected }, stats: { ...state.stats }, seed: state.seed, + heroClass, + ascension, }; projection.tapTargets = computeTapTargets(projection); return projection; @@ -617,6 +738,8 @@ export function createRunCore(deps, opts = {}) { if (typeof rng.state !== 'function') return null; // 随机源不支持状态导出(测试桩)则不产快照 return { tableSchemaVersion: TABLE_SCHEMA_VERSION, // 表版本对账位(表破坏性变更时迁移判据) + heroClass, // M3:职业与进阶随档(restore 经 opts 回填) + ascension, rngState: rng.state(), screen: state.screen, act: state.act, @@ -631,7 +754,13 @@ export function createRunCore(deps, opts = {}) { seed: state.seed, map: { nodes: map.nodes.map((n) => ({ ...n })), edges: map.edges.map((e) => e.slice()) }, reward: reward ? { options: reward.options.slice(), canSkip: reward.canSkip, after: reward.after } : null, + currentEvent: currentEvent ? { ...currentEvent } : null, + eventsSeen: eventsSeen.slice(), relicReward: relicReward ? { options: relicReward.options.slice(), canSkip: relicReward.canSkip } : null, + event: currentEvent ? (() => { + const ev = EVENTS.find((e) => e.id === currentEvent.id); + return { id: ev.id, name: ev.name, text: ev.text, options: ev.options.map((o) => ({ label: o.label, fx: o.fx || {} })) }; + })() : null, shop: shop ? { cards: shop.cards.map((c) => ({ ...c })), relics: shop.relics.map((r) => ({ ...r })), freeRemoveUsed: shop.freeRemoveUsed } : null, }; }, diff --git a/tier2/games/shanhai-xunyilu/src/save.js b/tier2/games/shanhai-xunyilu/src/save.js index aedaf3ff..1ea8587a 100644 --- a/tier2/games/shanhai-xunyilu/src/save.js +++ b/tier2/games/shanhai-xunyilu/src/save.js @@ -1,18 +1,21 @@ /** - * src/save.js —— 《山海巡异录》run 存档件(M2 S11;SaveEnvelope 契约面单源) - * owner:北极星顶级线③(M2) + * src/save.js —— 《山海巡异录》存档件(M2 S11 v1 → M3 S15 v2;SaveEnvelope 契约面单源) + * owner:北极星顶级线③(M2/M3) * * ════════════════════════════════════════════════════════════════════════════ * 【契约】形状 = contracts/sdk-interface.d.ts SaveEnvelope:{ schemaVersion: 整数 ≥1, data: 纯对象 }; * 键族 = 'save:'+gameId+':'+versionId(宿主白名单恰等、单槽、上限 65536 UTF-16 码元、fail-closed)。 - * W-SAVE-CAP 已入库(书 §12④ M2 入口门基建就位)。 * - * 【介质口径(诚实申报)】M2 = 影子介质:浏览器径直写 localStorage(同名键)/headless 用注入 store; - * 正式 SDK Storage 通道(postMessage storage 消息)切换随发布集成段(书 §12④,M5 硬门: - * 切正式通道 + 迁移/兼容断言)。本文件的信封形状与校验与正式通道逐字节同契约,切换只换介质。 + * 【版本谱系】v1(M2):data = run 快照平铺(rngState/deck/map/…)。 + * v2(M3 S15):data = { run: 快照|null, meta: 局外进度|null } 双域——run 终局后 run=null 但 meta 常存 + * (图鉴/成就/进阶解锁/每日跨局持久)。v1→v2 迁移:旧档整体视为 run 域、meta 置空(migrateV1)。 + * 这是版本信封的首个真实迁移,M5「切正式通道+迁移/兼容断言」的先手演练。 * - * 【fail-closed 纪律】与宿主闸同向:非信封 / 版本非法 / data 非纯对象 / 地图自检失败 → 一律弃档 - * (返回 null、留痕日志),绝不半恢复。坏档等价无档:开新局。 + * 【介质口径(诚实申报)】M3 仍=影子介质(main.js localStorage 直写/headless 注入); + * 正式 SDK Storage 通道切换随发布集成段(书 §12④,M5 硬门)。信封形状与校验与正式通道同契约。 + * + * 【fail-closed 纪律】非信封 / 版本非法 / data 非纯对象 / run 域坏形 / 地图自检失败 → 弃档(null+留痕), + * 绝不半恢复;meta 域坏形 → 只弃 meta(run 与 meta 独立校验:局外进度损坏不应吞掉续玩)。 * ════════════════════════════════════════════════════════════════════════════ */ @@ -22,56 +25,83 @@ import { createRunCore } from './run-core.js'; import { createSeededRandom } from './util/seeded-random.js'; import { SAVE } from './data/tables.js'; -/** 存档键(M2 影子口径;正式 versionId 随集成段注入)。 */ +/** 存档键(影子口径;正式 versionId 随集成段注入)。 */ export const SAVE_KEY = SAVE.shadowKey; /** - * 由 run 核产出信封(SaveEnvelope 形状)。 - * @param {ReturnType} run - * @returns {{schemaVersion:number, data:Object}|null} battle 屏/终局无快照 → null + * 组信封(v2):run 快照(battle 屏/终局为 null)+ meta 序列化。 + * @param {{snapshot:()=>Object|null}|null} run 一局核(可 null=局外) + * @param {{serialize:()=>Object}|null} metaCore 局外核(可 null) + * @returns {{schemaVersion:number, data:{run:Object|null, meta:Object|null}}} */ -export function makeEnvelope(run) { - const data = run.snapshot(); - if (!data) return null; - return { schemaVersion: SAVE.schemaVersion, data }; +export function makeEnvelope(run, metaCore) { + return { + schemaVersion: SAVE.schemaVersion, + data: { + run: run ? run.snapshot() : null, + meta: metaCore ? metaCore.serialize() : null, + }, + }; +} + +/** v1 → v2 迁移:旧档 data=run 平铺 → { run: 旧data, meta: null }。 */ +function migrateV1(data) { + return { run: data, meta: null }; } /** - * 信封校验(宿主闸 fail-closed 的游戏侧同构;负例=坏版本/非对象/数组/缺 data): + * 信封校验+版本迁移(fail-closed)。 * @param {unknown} env - * @returns {Object|null} 合法 → data;非法 → null + * @returns {{run:Object|null, meta:Object|null}|null} 合法 → v2 形状 data;非法 → null */ export function validateEnvelope(env) { if (env == null || typeof env !== 'object' || Array.isArray(env)) return null; const v = /** @type {{schemaVersion?:unknown, data?:unknown}} */ (env); if (!Number.isInteger(v.schemaVersion) || v.schemaVersion < 1) return null; // 契约:整数 ≥1 - if (v.schemaVersion !== SAVE.schemaVersion) return null; // M2 只有 v1;未来版本走迁移链(M5 前无旧档负担) + if (v.schemaVersion > SAVE.schemaVersion) return null; // 未来版本不认(fail-closed) if (v.data == null || typeof v.data !== 'object' || Array.isArray(v.data)) return null; // data=纯对象 - const d = /** @type {Record} */ (v.data); - if (!Number.isInteger(d.rngState)) return null; // 随机流锚缺失=无法精确续玩,弃档 - if (d.map == null || typeof d.map !== 'object') return null; - return d; + let data = /** @type {Record} */ (v.data); + if (v.schemaVersion === 1) data = migrateV1(data); // v1 迁移链 + // v2 形状:run 域可 null;非 null 时须带 rngState+map(精确续玩的最低要件)。 + const run = data.run == null ? null : data.run; + if (run != null) { + if (typeof run !== 'object' || Array.isArray(run)) return null; + if (!Number.isInteger(run.rngState)) return null; + if (run.map == null || typeof run.map !== 'object') return null; + } + // meta 域独立校验:坏形只弃 meta,不吞 run(局外进度损坏 ≠ 续玩作废)。 + let metaOut = data.meta == null ? null : data.meta; + if (metaOut != null && (typeof metaOut !== 'object' || Array.isArray(metaOut))) metaOut = null; + return { run, meta: metaOut }; } /** * 从信封恢复一局(fail-closed:任何一步失败 → null + 留痕)。 - * @param {unknown} env 介质读出的对象(宿主/影子介质已 JSON.parse) + * @param {unknown} env 介质读出的对象 * @returns {ReturnType|null} */ export function restoreRun(env) { const data = validateEnvelope(env); - if (!data) return null; + if (!data || !data.run) return null; try { const rng = createSeededRandom(0); - rng.setState(/** @type {number} */(data.rngState)); - return createRunCore({ random: rng }, { restore: data }); + rng.setState(/** @type {number} */(data.run.rngState)); + return createRunCore({ random: rng }, { restore: data.run }); } catch (e) { - // 地图自检失败/表漂移(卡 id 不存在)等 → 弃档开新局;留痕供排查。 - console.error('[save] 存档恢复失败,弃档:', e && e.message); + console.error('[save] run 域恢复失败,弃档续玩面:', e && e.message); // 留痕;meta 域仍可由调用方恢复 return null; } } +/** + * 从信封取 meta 域(独立于 run;调用方喂 createMetaCore)。 + * @returns {Object|null} + */ +export function restoreMeta(env) { + const data = validateEnvelope(env); + return data ? data.meta : null; +} + /** * 序列化并核对容量(save: 族 65536 码元契约闸;超限=结构失败,不静默截断)。 * @returns {{ok:boolean, json?:string, chars?:number, reason?:string}} diff --git a/tier2/games/shanhai-xunyilu/src/scenes/run-scene.js b/tier2/games/shanhai-xunyilu/src/scenes/run-scene.js index be68ccca..7d789d62 100644 --- a/tier2/games/shanhai-xunyilu/src/scenes/run-scene.js +++ b/tier2/games/shanhai-xunyilu/src/scenes/run-scene.js @@ -15,15 +15,15 @@ 'use strict'; -import { ELEMENT_CYCLE, ELEMENT_COLORS } from '../data/tables.js'; +import { ELEMENT_CYCLE, ELEMENT_COLORS, ACHIEVEMENTS } from '../data/tables.js'; import { VIEW_W, VIEW_H, ZONES, CARD_W, CARD_H, midY, handTop, handCardCenter, endTurnRect, mapNodeCenter, rewardCardCenter, rewardSkipRect, } from '../ui/layout.js'; // 节点型 → 显示字/色(表现层词汇,规则在 map-core/tables)。 -const NODE_GLYPH = { battle: '战', elite: '精', rest: '憩', treasure: '宝', boss: '王', shop: '市' }; -const NODE_COLOR = { battle: 0x3f7fbf, elite: 0xc98f3b, rest: 0x3fa34d, treasure: 0xf0c674, boss: 0xd9534f, shop: 0xb9a3d0 }; +const NODE_GLYPH = { battle: '战', elite: '精', rest: '憩', treasure: '宝', boss: '王', shop: '市', event: '遇' }; +const NODE_COLOR = { battle: 0x3f7fbf, elite: 0xc98f3b, rest: 0x3fa34d, treasure: 0xf0c674, boss: 0xd9534f, shop: 0xb9a3d0, event: 0x7fd4c1 }; /** * 建一局场景(Phaser scene 配置对象;`this`=Phaser.Scene)。 @@ -45,6 +45,7 @@ export function createRunScene(api) { s.battle.enemy.statuses] : null, s.reward ? s.reward.options.map((o) => o.id) : null, s.relicReward ? s.relicReward.options.map((o) => o.id) : null, + s.event ? s.event.id : null, s.shop ? [s.shop.cards.map((c) => c.sold), s.shop.relics.map((r) => r.sold), s.shop.removePrice, s.removeCount] : null, ]); } @@ -61,6 +62,23 @@ export function createRunScene(api) { update() { const scene = this; + // M3 局外模式:home 态渲染工厂投影(局外屏);run 态沿一局核直读。 + if (api.mode && api.mode() === 'home') { + const hs = api.homeState(); + const sig = JSON.stringify(['home', hs.screen, hs.home.sel, hs.home.maxAscension, hs.home.meta.achievedCount, hs.home.meta.collection.cards, hs.home.meta.daily]); + if (sig === lastSig) return; + lastSig = sig; + clear(); + if (hs.screen === 'collection') drawCollection(scene, hs); + else if (hs.screen === 'achievements') drawAchievements(scene, hs); + else drawHome(scene, hs); + if (api.debug) { + api.debug.displayCount = uiNodes.length; + api.debug.rebuilds = (api.debug.rebuilds || 0) + 1; + api.debug.screen = `home:${hs.screen}`; + } + return; + } const run = api.run(); if (!run) return; const s = run.readState(); @@ -82,18 +100,34 @@ export function createRunScene(api) { uiNodes = []; } function keep(scene, obj) { uiNodes.push(obj); return obj; } - const txt = (scene, x, y, str, size, color, origin = 0.5) => - keep(scene, scene.add.text(x, y, str, { fontSize: `${size}px`, color, fontFamily: 'sans-serif' }).setOrigin(origin)); + /** + * 文本辅助——必须是 function 声明(提升)。 + * 【2026-07-06 战斗全空事故根因】本函数曾写成 `const txt = (...) => ...` 且位于工厂 return 之后: + * 该行永不执行,`txt` 绑定永陷 TDZ,首次 rebuild 即抛 ReferenceError——整屏绘制(地图/战斗/商店) + * 全部无声失败,只有不依赖 txt 的事件浮字(floatText,function 声明)可见,浏览器像素复验才暴露 + * (headless 门不建场景,验不到)。防回潮:scripts/scene-smoke.mjs mock 场景重放门 + rebuild 自证计数。 + */ + function txt(scene, x, y, str, size, color, origin = 0.5) { + return keep(scene, scene.add.text(x, y, str, { fontSize: `${size}px`, color, fontFamily: 'sans-serif' }).setOrigin(origin)); + } function rebuild(scene, s) { clear(); - if (s.phase !== 'run') { drawTerminal(scene, s); return; } - if (s.screen === 'map') drawMap(scene, s); + if (s.phase !== 'run') { drawTerminal(scene, s); } + else if (s.screen === 'map') drawMap(scene, s); else if (s.screen === 'battle' && s.battle) drawBattle(scene, s); else if (s.screen === 'reward' && s.reward) drawReward(scene, s); else if (s.screen === 'relicReward' && s.relicReward) drawRelicReward(scene, s); else if (s.screen === 'shop' && s.shop) drawShop(scene, s); else if (s.screen === 'shopRemove' && s.shop) drawShopRemove(scene, s); + else if (s.screen === 'event' && s.event) drawEvent(scene, s); + // 自证回写(2026-07-06 增补②):把「本屏已绘制显示对象计数」挂进取证面(工厂 readState().debug), + // CDP 复验据此机断「画了东西」,不再依赖纯像素统计。 + if (api.debug) { + api.debug.displayCount = uiNodes.length; + api.debug.rebuilds = (api.debug.rebuilds || 0) + 1; + api.debug.screen = s.screen; + } } /** 顶栏(全视图常驻):幕/金币/卡组/遗物。 */ @@ -297,6 +331,80 @@ export function createRunScene(api) { txt(scene, bx, by, '不销 · 回市', 14, '#c9c3b2'); } + // ── 事件视图(M3 S16;选项按钮与 layout eventOption 靶同源)────────────────────── + function drawEvent(scene, s) { + drawTopStrip(scene, s); + keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W, VIEW_H, 0x0a0a12, 0.4)); + txt(scene, VIEW_W / 2, VIEW_H * 0.2, `奇遇 · ${s.event.name}`, 22, '#7fd4c1'); + keep(scene, scene.add.text(VIEW_W / 2, VIEW_H * 0.34, s.event.text, { fontSize: '14px', color: '#e8e2d0', fontFamily: 'sans-serif', wordWrap: { width: 320 }, align: 'center' }).setOrigin(0.5)); + s.event.options.forEach((o, i) => { + const y = VIEW_H * 0.52 + i * 64; + keep(scene, scene.add.rectangle(VIEW_W / 2, y, 320, 52, 0x24313a).setStrokeStyle(2, 0x7fd4c1)); + keep(scene, scene.add.text(VIEW_W / 2, y, o.label, { fontSize: '13px', color: '#e8e2d0', fontFamily: 'sans-serif', wordWrap: { width: 300 }, align: 'center' }).setOrigin(0.5)); + }); + } + + // ── 局外三屏(M3 S15;坐标与 layout computeHomeTargets 同源)────────────────────── + function drawHome(scene, hs) { + const m = hs.home.meta; + txt(scene, VIEW_W / 2, VIEW_H * 0.12, '山海巡异录', 34, '#e8e2d0'); + txt(scene, VIEW_W / 2, VIEW_H * 0.18, `出巡 ${m.runsPlayed} · 功成 ${m.wins}`, 13, '#8a8498'); + // 职业二选(选中金框)。 + for (const [i, c] of m.classes.entries()) { + const x = VIEW_W / 2 + (i === 0 ? -88 : 88); + const sel = hs.home.sel.class === c.id; + keep(scene, scene.add.rectangle(x, VIEW_H * 0.34, 150, 110, 0x241f33).setStrokeStyle(sel ? 3 : 1, sel ? 0xf0c674 : 0x555566)); + txt(scene, x, VIEW_H * 0.31, c.name, 18, sel ? '#f0c674' : '#c9c3b2'); + txt(scene, x, VIEW_H * 0.36, `气血 ${c.hpMax}`, 12, '#9fc4e8'); + txt(scene, x, VIEW_H * 0.395, c.id === 'jia' ? '攻伐见长' : '灵脉见长', 11, '#8a8498'); + } + // 进阶选择(加减 + 当前级文案)。 + keep(scene, scene.add.rectangle(VIEW_W / 2 - 110, VIEW_H * 0.5, 52, 44, 0x2a2a38).setStrokeStyle(2, 0x8a8498)); + txt(scene, VIEW_W / 2 - 110, VIEW_H * 0.5, '−', 22, '#e8e2d0'); + keep(scene, scene.add.rectangle(VIEW_W / 2 + 110, VIEW_H * 0.5, 52, 44, 0x2a2a38).setStrokeStyle(2, 0x8a8498)); + txt(scene, VIEW_W / 2 + 110, VIEW_H * 0.5, '+', 22, '#e8e2d0'); + const asc = hs.home.sel.ascension; + txt(scene, VIEW_W / 2, VIEW_H * 0.48, `进阶 ${asc}${hs.home.maxAscension > 0 ? ` / 已启 ${hs.home.maxAscension}` : ''}`, 16, '#e2a13e'); + txt(scene, VIEW_W / 2, VIEW_H * 0.52, asc > 0 ? (hs.home.ascensions[asc - 1] || {}).text || '' : '无修正 · 基准巡途', 11, '#8a8498'); + // 出巡/每日。 + keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H * 0.62, 240, 56, 0x5a3f2e).setStrokeStyle(2, 0xc98f3b)); + txt(scene, VIEW_W / 2, VIEW_H * 0.62, '启程出巡', 20, '#f5e9d5'); + keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H * 0.72, 240, 48, 0x24313a).setStrokeStyle(2, 0x7fd4c1)); + txt(scene, VIEW_W / 2, VIEW_H * 0.705, `每日一巡 · ${hs.home.daily.date}`, 14, '#7fd4c1'); + txt(scene, VIEW_W / 2, VIEW_H * 0.735, m.daily.bestScore > 0 ? `本地最佳 ${m.daily.bestScore}` : '固定种子 · 天下同题', 11, '#8a8498'); + // 图鉴/成就入口。 + keep(scene, scene.add.rectangle(VIEW_W / 2 - 90, VIEW_H * 0.82, 150, 44, 0x2a2a38).setStrokeStyle(2, 0x8a8498)); + txt(scene, VIEW_W / 2 - 90, VIEW_H * 0.82, `图鉴 ${m.collection.cards + m.collection.relics + m.collection.enemies}`, 13, '#9fc4e8'); + keep(scene, scene.add.rectangle(VIEW_W / 2 + 90, VIEW_H * 0.82, 150, 44, 0x2a2a38).setStrokeStyle(2, 0x8a8498)); + txt(scene, VIEW_W / 2 + 90, VIEW_H * 0.82, `成就 ${m.achievedCount}/40`, 13, '#b9a3d0'); + } + + function drawCollection(scene, hs) { + const m = hs.home.meta; + txt(scene, VIEW_W / 2, 40, '图鉴 · 山海志', 22, '#9fc4e8'); + txt(scene, VIEW_W / 2, VIEW_H * 0.2, `符箓 ${m.collection.cards} / 154`, 16, '#e8e2d0'); + txt(scene, VIEW_W / 2, VIEW_H * 0.3, `遗物 ${m.collection.relics} / 40`, 16, '#e8e2d0'); + txt(scene, VIEW_W / 2, VIEW_H * 0.4, `异兽 ${m.collection.enemies} / 8`, 16, '#e8e2d0'); + txt(scene, VIEW_W / 2, VIEW_H * 0.55, '收录随巡途自动增补', 12, '#8a8498'); + keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H * 0.92, 160, 46, 0x2a2a38).setStrokeStyle(2, 0x8a8498)); + txt(scene, VIEW_W / 2, VIEW_H * 0.92, '返回', 15, '#c9c3b2'); + } + + function drawAchievements(scene, hs) { + const m = hs.home.meta; + txt(scene, VIEW_W / 2, 40, `成就 · ${m.achievedCount} / 40`, 22, '#b9a3d0'); + // 达成列表(前 12 条滚动位;M4 再做完整滚动容器)。 + const byId = Object.fromEntries(ACHIEVEMENTS.map((a) => [a.id, a])); + const got = Object.keys(m.achievements).slice(0, 12); + if (got.length === 0) txt(scene, VIEW_W / 2, VIEW_H * 0.4, '尚无成就 · 出巡自见分晓', 14, '#8a8498'); + got.forEach((id, i) => { + const a = byId[id]; + txt(scene, VIEW_W / 2, VIEW_H * 0.18 + i * 28, `✓ ${a ? a.name : id}`, 13, '#9fe08a'); + }); + keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H * 0.92, 160, 46, 0x2a2a38).setStrokeStyle(2, 0x8a8498)); + txt(scene, VIEW_W / 2, VIEW_H * 0.92, '返回', 15, '#c9c3b2'); + } + // ── 终局视图 ────────────────────────────────────────────────────────────────── function drawTerminal(scene, s) { keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W, VIEW_H, 0x0a0a12, 0.78)); @@ -408,6 +516,12 @@ export function createRunScene(api) { floatText(scene, VIEW_W / 2, VIEW_H * 0.4, '入市', '#b9a3d0', 18); } else if (e.t === 'relicRewardOffered') { floatText(scene, VIEW_W / 2, VIEW_H * 0.4, '异宝呈前', '#b9a3d0', 18); + } else if (e.t === 'eventStart') { + floatText(scene, VIEW_W / 2, VIEW_H * 0.4, `奇遇 · ${e.name}`, '#7fd4c1', 18); + } else if (e.t === 'hpMaxChange') { + floatText(scene, 90, VIEW_H * 0.5, `气血上限 ${e.n > 0 ? '+' : ''}${e.n}`, '#9fe08a', 15); + } else if (e.t === 'goldSpend') { + floatText(scene, 70, 30, `金 -${e.n}`, '#c9a35a', 14); } } diff --git a/tier2/games/shanhai-xunyilu/src/sim/sim-run.js b/tier2/games/shanhai-xunyilu/src/sim/sim-run.js index bd21c85c..bab41e7b 100644 --- a/tier2/games/shanhai-xunyilu/src/sim/sim-run.js +++ b/tier2/games/shanhai-xunyilu/src/sim/sim-run.js @@ -27,23 +27,28 @@ import { dirname, join } from 'node:path'; import { createRunCore } from '../run-core.js'; import { createSeededRandom } from '../util/seeded-random.js'; -import { RUN_STARTER_DECK, SIM_BANDS } from '../data/tables.js'; +import { RUN_STARTER_DECK, YI_STARTER_DECK, SIM_BANDS } from '../data/tables.js'; const __dir = dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); // 策略单源:与 CDP harness 同一文件(贪心档即九门真玩的同一大脑)。 -const { chooseTap, chooseTapRandom, chooseTapIdle } = require(join(__dir, '../../../../harness/card-battle.driver.js')); +const { chooseTap, chooseTapRandom, chooseTapIdle, chooseTapChain, chooseTapDefensive } = require(join(__dir, '../../../../harness/card-battle.driver.js')); /** 决策随机源派生常数(黄金比 32 位;与游戏 rng 分流)。 */ const DECISION_SEED_XOR = 0x9e3779b9; /** 策略注册表(档名 → 决策函数;random 档吃独立决策 rng)。 */ export const STRATEGIES = { - greedy: (state) => chooseTap(state), - random: (state, rng) => chooseTapRandom(state, rng), - idle: (state) => chooseTapIdle(state), + greedy: (state) => chooseTap(state), // 贪心分(书面三档①;校准对象) + chain: (state) => chooseTapChain(state), // 链优先(书面三档②;M3 S14) + defensive: (state) => chooseTapDefensive(state), // 保血防御(书面三档③;龟缩哨兵) + random: (state, rng) => chooseTapRandom(state, rng), // 下界观测(M2 工单档) + idle: (state) => chooseTapIdle(state), // 下界观测(M2 工单档) }; +/** 书面三档(§6.4;全矩阵与死卡并集口径);random/idle 为下界锚不入死卡判。 */ +export const CANON_STRATEGIES = ['greedy', 'chain', 'defensive']; + /** * 单局仿真:用指定策略档把一局打到终态,采全量观测。 * @param {Object} p @@ -104,6 +109,7 @@ export function simulateRun({ seed, strategy, runOpts = {} }) { else if (t.kind === 'removePick') run.buyRemove(t.index); else if (t.kind === 'removeBack') run.closeRemove(); else if (t.kind === 'shopLeave') run.leaveShop(); + else if (t.kind === 'eventOption') run.chooseEventOption(t.index); // M3 事件 else stuck = true; // 未知靶=契约漂移,响亮暴露 consumeEvents(); if (stuck) break; @@ -112,9 +118,10 @@ export function simulateRun({ seed, strategy, runOpts = {} }) { consumeEvents(); const end = run.readState(); - // 采用率口径:胜局构筑 = 终态卡组减起始组份数(删卡后不在终态=真实反映构筑);起始组不带判。 + // 采用率口径:胜局构筑 = 终态卡组减起始组份数(按职业起始组;删卡后不在终态=真实反映构筑)。 const starterCount = {}; - for (const id of RUN_STARTER_DECK) starterCount[id] = (starterCount[id] || 0) + 1; + const starter = end.heroClass === 'yi' ? YI_STARTER_DECK : RUN_STARTER_DECK; + for (const id of starter) starterCount[id] = (starterCount[id] || 0) + 1; const acquired = []; const left = { ...starterCount }; for (const id of end.deck || []) { @@ -125,6 +132,8 @@ export function simulateRun({ seed, strategy, runOpts = {} }) { return { seed, strategy, + heroClass: end.heroClass, + ascension: end.ascension, phase: end.phase, // win|lose|invalid|run(guard 截断时) act: end.act, stuck, @@ -144,6 +153,10 @@ export function simulateRun({ seed, strategy, runOpts = {} }) { fingerprint: end.phase === 'invalid' ? { deck: (end.deck || []).slice().sort(), relics: (end.relics || []).map((r) => r.id).sort() } : null, + // 胜局构筑指纹(M3 门:去重率 >85%〔草案〕——deck+relics 排序串)。 + winFingerprint: end.phase === 'win' + ? (end.deck || []).slice().sort().join(',') + '|' + (end.relics || []).map((r) => r.id).sort().join(',') + : null, }; } @@ -168,6 +181,9 @@ export function aggregateCell(results, opts = {}) { const winRate = n ? wins.length / n : 0; // 二项分布标准差(千局 σ≈1.6pp 的书面口径同源)。 const sigma = n ? Math.sqrt(winRate * (1 - winRate) / n) : 0; + // 胜局构筑指纹去重率(M3 门 >85%〔草案〕:构筑多样性——同指纹反复出现=路径固化)。 + const winFps = wins.map((w) => w.winFingerprint).filter(Boolean); + const fingerprintUniqueRate = winFps.length ? new Set(winFps).size / winFps.length : null; const allBossTurns = results.flatMap((r) => r.bossTurns).sort((a, b) => a - b); const grind = allBossTurns.filter((t) => t > SIM_BANDS.bossTurnBand[1]).length; // 「磨」 @@ -210,6 +226,7 @@ export function aggregateCell(results, opts = {}) { stucks: stucks.length, winRate: Math.round(winRate * 10000) / 10000, winRateSigmaPp: Math.round(sigma * 10000) / 100, // 百分点 + fingerprintUniqueRate: fingerprintUniqueRate != null ? Math.round(fingerprintUniqueRate * 1000) / 1000 : null, avgBattlesWon: n ? Math.round(results.reduce((s, r) => s + r.stats.battlesWon, 0) / n * 100) / 100 : 0, avgDeckEnd: n ? Math.round(results.reduce((s, r) => s + r.deckCount, 0) / n * 10) / 10 : 0, bossTurns: { diff --git a/tier2/games/shanhai-xunyilu/src/sim/valuation.js b/tier2/games/shanhai-xunyilu/src/sim/valuation.js index 69050e66..c5ac8fa0 100644 --- a/tier2/games/shanhai-xunyilu/src/sim/valuation.js +++ b/tier2/games/shanhai-xunyilu/src/sim/valuation.js @@ -47,6 +47,11 @@ export function valueOf(card) { v += bv * V.chainAchieveDiscount; } if (fx.costChainDiscount) v += fx.costChainDiscount.n * V.energyCost * V.chainAchieveDiscount; + if (fx.ifZhoutian) { + const z = fx.ifZhoutian; + // 周天达成率低(A0 观测 ≈0.1/局;乙链优构筑内更高),按 0.2 折。 + v += ((z.dmg || 0) / V.dmgPerCost + (z.block || 0) / V.blockPerCost + (z.draw || 0) * V.drawCost + (z.energy || 0) * V.energyCost) * 0.2; + } const value = v / Math.max(card.cost, V.zeroCostFloor); const cap = V.rarityCaps[card.rarity] != null ? V.rarityCaps[card.rarity] : 1.0; return { diff --git a/tier2/games/shanhai-xunyilu/src/ui/layout.js b/tier2/games/shanhai-xunyilu/src/ui/layout.js index 11f824f8..ca198610 100644 --- a/tier2/games/shanhai-xunyilu/src/ui/layout.js +++ b/tier2/games/shanhai-xunyilu/src/ui/layout.js @@ -114,6 +114,13 @@ export function computeTapTargets(s) { targets.push({ kind: 'cancel', x: VIEW_W / 2, y: (handTop() + VIEW_H) / 2, w: VIEW_W, h: VIEW_H - handTop() }); return targets; } + // 事件屏(M3 S16):选项按钮纵排(2–3 个)。 + if (s.screen === 'event' && s.event) { + s.event.options.forEach((o, i) => { + targets.push({ kind: 'eventOption', index: i, x: VIEW_W / 2, y: VIEW_H * 0.52 + i * 64, w: 320, h: 52 }); + }); + return targets; + } // 商店(M2 S11):5 卡位两行 + 2 遗物位 + 删卡/离开按钮。 if (s.screen === 'shop' && s.shop) { s.shop.cards.forEach((c, i) => { @@ -153,6 +160,27 @@ export function computeTapTargets(s) { return targets; } +/** + * home 局外屏可点靶(M3 S15;工厂级投影消费——局外交互不属于一局,不进 run-core): + * main 子屏=职业二选 + 进阶加减 + 出巡/每日 + 图鉴/成就入口;collection/achievements 子屏=返回。 + */ +export function computeHomeTargets(s) { + const targets = []; + if (s.screen === 'main') { + targets.push({ kind: 'homeClass', class: 'jia', x: VIEW_W / 2 - 88, y: VIEW_H * 0.34, w: 150, h: 110 }); + targets.push({ kind: 'homeClass', class: 'yi', x: VIEW_W / 2 + 88, y: VIEW_H * 0.34, w: 150, h: 110 }); + targets.push({ kind: 'ascMinus', x: VIEW_W / 2 - 110, y: VIEW_H * 0.5, w: 52, h: 44 }); + targets.push({ kind: 'ascPlus', x: VIEW_W / 2 + 110, y: VIEW_H * 0.5, w: 52, h: 44 }); + targets.push({ kind: 'homeStart', x: VIEW_W / 2, y: VIEW_H * 0.62, w: 240, h: 56 }); + targets.push({ kind: 'homeDaily', x: VIEW_W / 2, y: VIEW_H * 0.72, w: 240, h: 48 }); + targets.push({ kind: 'homeCollection', x: VIEW_W / 2 - 90, y: VIEW_H * 0.82, w: 150, h: 44 }); + targets.push({ kind: 'homeAchievements', x: VIEW_W / 2 + 90, y: VIEW_H * 0.82, w: 150, h: 44 }); + } else { + targets.push({ kind: 'homeBack', x: VIEW_W / 2, y: VIEW_H * 0.92, w: 160, h: 46 }); + } + return targets; +} + /** 命中判定:按数组序返回首个包含 (x,y) 的靶(无 → null)。injectInput 与场景共用。 */ export function hitTarget(targets, x, y) { for (const t of targets) { diff --git a/tier2/harness/card-battle.driver.js b/tier2/harness/card-battle.driver.js index 92081696..688b8734 100644 --- a/tier2/harness/card-battle.driver.js +++ b/tier2/harness/card-battle.driver.js @@ -67,7 +67,13 @@ function pickValue(card) { * 贪心档 v1(§6.4 三档 driver 之一;链优先/保血防御两档随 M2 S12)。 */ function chooseTap(state) { - if (!state || state.phase !== 'run') return null; // win/lose/invalid 终态 → 停,交 latch 校验 + if (!state) return null; + // ── 局外 home 屏(M3 S15):CDP/九门真玩路径 boot 后先落 home——直接启程(缺省甲/A0); + // win 后终局遮罩点掉会回 home,phase='home' 非终态,latch 门只认 win/lose/invalid。── + if (state.phase === 'home') { + return findTarget(state, 'homeStart') || null; + } + if (state.phase !== 'run') return null; // win/lose/invalid 终态 → 停,交 latch 校验 // ── 地图:选节点(血线驱动的贪心偏好)── if (state.screen === 'map') { @@ -85,6 +91,26 @@ function chooseTap(state) { return nodes[0]; } + // ── 事件屏(M3 S16):选项期望当量最大(fx 语义投影;血亏加权回复,金紧看收益)── + if (state.screen === 'event' && state.event) { + const hpRatio = state.hero.hp / state.hero.hpMax; + const evalOpt = (o) => { + const fx = o.fx || {}; + let v = 0; + v += (fx.gold || 0) * 0.03; + v += (fx.heal || 0) * (hpRatio < 0.6 ? 0.35 : 0.1); + v -= (fx.loseHp || 0) * (hpRatio < 0.5 ? 0.5 : 0.2); + v += (fx.hpMax || 0) * 0.35; + if (fx.relic) v += 4; + if (fx.card === '稀有') v += 2.2; else if (fx.card === '罕见') v += 1.4; else if (fx.card) v += 0.6; + if (fx.removeRandomCommon) v += 0.8; + return v; + }; + let best = 0; + (state.event.options || []).forEach((o, i) => { if (evalOpt(o) > evalOpt(state.event.options[best])) best = i; }); + return findTarget(state, 'eventOption', (t) => t.index === best); + } + // ── Boss 遗物三选一(M2):遗物全规则级增益,贪心=永远拿第一枚(估值细分随 S12 调参轮)── if (state.screen === 'relicReward' && state.relicReward) { return findTarget(state, 'relicReward', (t) => t.index === 0) || findTarget(state, 'relicSkip'); @@ -219,6 +245,108 @@ function chooseTap(state) { return null; } +/* ══ M3 S14 书面三档补齐:链优先 / 保血防御(书 §6.4 原三档=贪心分/链优先/保血防御; + * 与 M2 的随机/挂机下界档并存——多样性 driver 是死卡治理与采用率带的观测面)══ */ + +/** + * 链优先档:链长与周天最大化(乙职业的天然打法代理)。 + * 战斗序:①延链卡里挑费用最低(链不断优先于当量)②空链时按「预算内可串最长」起手(与贪心④同算法) + * ③无链可延时打 0 费润滑 ④endTurn。地图/商店/奖励沿贪心(pickValue 里链词已计价)。 + */ +function chooseTapChain(state) { + if (!state || state.phase !== 'run') return null; + if (state.screen === 'battle' && state.battle) { + const b = state.battle; + if (state.ui && state.ui.selected >= 0) { + return findTarget(state, 'card', (t) => t.index === state.ui.selected); + } + const hand = (b.handCards || []).map((c, i) => ({ ...c, i })) + .filter((c) => c.cost <= b.energy && !(c.hpCost > 0 && b.hero.hp <= c.hpCost)); + if (!hand.length) return findTarget(state, 'endTurn'); + // ①延链:同延链集内费低者先(省能量串更长)。 + if (b.chain && b.chain.nextElement) { + const chainable = hand.filter((c) => c.element === b.chain.nextElement).sort((a, z) => a.cost - z.cost || (z.dmg + z.block) - (a.dmg + a.block)); + if (chainable.length) return findTarget(state, 'card', (t) => t.index === chainable[0].i); + } + // ②空链起手:预算内可串最长(贪心④同构)。 + if (b.chain && Array.isArray(b.chain.cycle) && (!b.chain.seq || b.chain.seq.length === 0)) { + const cycle = b.chain.cycle; + let best = null; + for (const start of hand) { + const used = new Set([start.i]); + let budget = b.energy - start.cost; + let el = cycle[(cycle.indexOf(start.element) + 1) % cycle.length]; + let len = 1; + for (let step = 0; step < 8; step++) { + const next = hand.filter((c) => !used.has(c.i) && c.element === el && c.cost <= budget).sort((a, z) => a.cost - z.cost)[0]; + if (!next) break; + used.add(next.i); budget -= next.cost; len += 1; + el = cycle[(cycle.indexOf(el) + 1) % cycle.length]; + } + if (!best || len > best.len) best = { card: start, len }; + } + if (best) return findTarget(state, 'card', (t) => t.index === best.card.i); + } + // ③破序不可避:能量照花(链是回合内轴、回合末必清——「留链势」是伪概念,首版 + // 每回合只打链段致胜率 5%/1.5%,batch 实测修正):0 费垫子优先,再按裸当量清能量。 + const zero = hand.filter((c) => c.cost === 0).sort((a, z) => (z.dmg + z.block) - (a.dmg + a.block))[0]; + if (zero) return findTarget(state, 'card', (t) => t.index === zero.i); + const spend = hand.sort((a, z) => (z.dmg + z.block + z.burn) - (a.dmg + a.block + a.burn))[0]; + if (spend) return findTarget(state, 'card', (t) => t.index === spend.i); + return findTarget(state, 'endTurn'); + } + return chooseTap(state); // 地图/奖励/商店/遗物屏沿贪心策略(链价值已在 pickValue 计价) +} + +/** + * 保血防御档:受伤最小化(龟缩代理——「防御过值→龟缩最优」的曲线哨兵,§6.2 攻防本位差观测面)。 + * 战斗序:①来袭未挡满 → 甲/虚弱卡按防御量优先 ②挡满后血亏 → 回血卡 ③安全后才输出 ④endTurn。 + */ +function chooseTapDefensive(state) { + if (!state || state.phase !== 'run') return null; + if (state.screen === 'battle' && state.battle) { + const b = state.battle; + if (state.ui && state.ui.selected >= 0) { + return findTarget(state, 'card', (t) => t.index === state.ui.selected); + } + const hand = (b.handCards || []).map((c, i) => ({ ...c, i })) + .filter((c) => c.cost <= b.energy && !(c.hpCost > 0 && b.hero.hp <= c.hpCost)); + if (!hand.length) return findTarget(state, 'endTurn'); + const it = b.enemy.intent; + const incoming = it && (it.kind === 'attack' || it.kind === 'attackBlock') ? it.n * (it.times || 1) : 0; + // ①挡满来袭:甲量最大者先(虚弱卡算防御件:折减来袭)。 + if (incoming > 0 && b.hero.block < incoming) { + const guard = hand.filter((c) => c.block > 0 || c.weak > 0) + .sort((a, z) => (z.block + z.weak * 3) - (a.block + a.weak * 3))[0]; + if (guard) return findTarget(state, 'card', (t) => t.index === guard.i); + } + // ②血亏回血。 + if (b.hero.hp < b.hero.hpMax * 0.7) { + const healCard = hand.filter((c) => c.heal > 0).sort((a, z) => z.heal - a.heal)[0]; + if (healCard) return findTarget(state, 'card', (t) => t.index === healCard.i); + } + // ③安全后输出(裸当量序;链是顺带不是追求)。 + const atk = hand.filter((c) => c.dmg > 0 || c.burn > 0).sort((a, z) => (z.dmg + z.burn) - (a.dmg + a.burn))[0]; + if (atk) return findTarget(state, 'card', (t) => t.index === atk.i); + const spare = hand.sort((a, z) => z.block - a.block)[0]; + if (spare && spare.block > 0) return findTarget(state, 'card', (t) => t.index === spare.i); + return findTarget(state, 'endTurn'); + } + // 地图偏好:保血路线(憩/商店前置)。 + if (state.screen === 'map') { + const nodes = ((state.tapTargets) || []).filter((t) => t.kind === 'node'); + if (!nodes.length) return null; + const hpRatio = state.hero.hp / state.hero.hpMax; + const prefer = hpRatio < 0.8 ? ['rest', 'treasure', 'shop', 'battle', 'boss', 'elite'] : ['treasure', 'shop', 'battle', 'rest', 'boss', 'elite']; + for (const k of prefer) { + const hit = nodes.find((t) => t.nodeType === k); + if (hit) return hit; + } + return nodes[0]; + } + return chooseTap(state); // 奖励/商店/遗物沿贪心 +} + /* ══ M2 S12 三档策略 driver(工单口径:贪心/随机/挂机;A0 带校准以贪心档为准,不削敌线迁就最弱策略)══ * 贪心档 = 上面的 chooseTap(CDP 真玩与 headless 冒烟同一大脑,策略单源); * 随机/挂机档只供 headless 仿真外壳(sim-shell)消费,CDP 真玩不用——additive 导出,不改既有行为。 @@ -248,7 +376,7 @@ function chooseTapRandom(state, rng) { } // 地图/奖励屏:全部可点靶等权随机(奖励含跳过;地图=随机走图)。 const pool = ts.filter((t) => t.kind === 'node' || t.kind === 'reward' || t.kind === 'rewardSkip' - || t.kind === 'relicReward' || t.kind === 'relicSkip' + || t.kind === 'relicReward' || t.kind === 'relicSkip' || t.kind === 'eventOption' || t.kind === 'shopCard' || t.kind === 'shopRelic' || t.kind === 'shopRemove' || t.kind === 'shopLeave' || t.kind === 'removePick' || t.kind === 'removeBack'); if (!pool.length) return null; @@ -272,6 +400,7 @@ function chooseTapIdle(state) { } if (state.screen === 'shop') return findTarget(state, 'shopLeave'); if (state.screen === 'shopRemove') return findTarget(state, 'removeBack'); + if (state.screen === 'event') return findTarget(state, 'eventOption', (t) => t.index === 0); // 挂机:事件拿头一项 const node = findTarget(state, 'node'); return node; } @@ -306,4 +435,4 @@ async function run(cdp, driver, hashes, deps) { return { drove, steps }; } -module.exports = { run, chooseTap, chooseTapRandom, chooseTapIdle }; +module.exports = { run, chooseTap, chooseTapRandom, chooseTapIdle, chooseTapChain, chooseTapDefensive };