feat(polaris): 《山海巡异录》M3——战斗绘制 TDZ 根因修复+双职业 154 卡+进阶 A0–A15 六带全绿
绘制修复(mini-desktop CDP 复验揪出:战斗屏除回合横幅外 lumStd=0 全空,逻辑与指针全通):根因=run-scene.js
const txt 声明位于工厂 return 之后永不执行,txt 永陷 TDZ,全部整屏绘制函数每帧 ReferenceError 无声失败,
唯 floatText(function 声明提升)独活=只剩横幅;M1 并入 run-scene 时引入,headless 门不建场景故只有真浏览器
暴露。修=txt 改 function 声明(提升语义)+事故注释钉原地+自证钩子(场景 rebuild 后回写
readState().debug={displayCount,rebuilds,screen} 供 CDP 机断)+防回潮门 scene-smoke.mjs(mock 场景重放十屏/
异常零容忍/每屏绘制对象≥5;修复后 map=33/battle=32/reward=14)。
M3:S13 职业乙 66+中立 22=154 卡定容(static-screen 3,676,134 序列零经济环)+S14 进阶 ASCENSIONS 16 级入
矩阵轴+多样性 driver(chain/defensive 档,死卡候选 42 张如实挂账 README)+S15 meta 三件(图鉴/成就 40/每日,
meta-core.js+meta.test)+S16 事件 45;存档 v2 双域+v1 迁移链(19 断言);home 局外模式机+终局结算。
门证据:八门全绿(battle-core/logic-smoke/run-core+R15 职业进阶 7 断言/run-smoke/save v2 19 断言/meta/
static-screen/scene-smoke);六带全绿 nightly n=1000/格(甲 A0 58.2%/A5 32.2%/A15 6.9%,乙 55.1%/28.3%/5.7%,
带 45–60/25–40/5–15);构筑指纹去重 A0 六格全 100%(门>85% 草案);金标 26 套(甲 20 漂移≤3.6pp 全 PASS+乙 6 首锁);
**全矩阵 96,000 局书面口径首次真跑=158.6s、nightly 总 223.2s**;平衡轮主杠杆=职业起始遗物 +36.6pp;
主会话真跑复验八门+PR 层。浏览器像素复验随本笔 push 后触发。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
86df18c4d8
commit
0fc1cb695b
@ -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 <repo>/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 走验收门品类适配单另出
|
||||
|
||||
82
tier2/games/shanhai-xunyilu/assets/atlas/beasts-act1.json
Normal file
82
tier2/games/shanhai-xunyilu/assets/atlas/beasts-act1.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
tier2/games/shanhai-xunyilu/assets/atlas/beasts-act1.webp
Normal file
BIN
tier2/games/shanhai-xunyilu/assets/atlas/beasts-act1.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
124
tier2/games/shanhai-xunyilu/scripts/asset-gen.mjs
Normal file
124
tier2/games/shanhai-xunyilu/scripts/asset-gen.mjs
Normal file
@ -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);
|
||||
269
tier2/games/shanhai-xunyilu/scripts/asset-pack.mjs
Normal file
269
tier2/games/shanhai-xunyilu/scripts/asset-pack.mjs
Normal file
@ -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/<atlas>-N.webp + .json 图集(Phaser3 坐标,webp q80;卡插画独立分包 cards-jia)
|
||||
// assets/bg/<id>.webp 满幅大图(场景/结算,批次一复用件标 source)
|
||||
// assets/manifest.json 资产清单 + 字节账(账A 输入)
|
||||
// docs-design/山海风格版-批次一/images-巡异录资产一期/<group>/ 定稿缩尺评审版(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('机检:全部通过');
|
||||
329
tier2/games/shanhai-xunyilu/scripts/asset-shots.mjs
Normal file
329
tier2/games/shanhai-xunyilu/scripts/asset-shots.mjs
Normal file
@ -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 };
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
109
tier2/games/shanhai-xunyilu/scripts/meta.test.mjs
Normal file
109
tier2/games/shanhai-xunyilu/scripts/meta.test.mjs
Normal file
@ -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);
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
174
tier2/games/shanhai-xunyilu/scripts/scene-smoke.mjs
Normal file
174
tier2/games/shanhai-xunyilu/scripts/scene-smoke.mjs
Normal file
@ -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);
|
||||
@ -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);
|
||||
|
||||
@ -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; // 延续与破序殊途同归:链尾都是本卡元素(破序=重开链,链首=本卡)
|
||||
}
|
||||
|
||||
@ -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 }); }
|
||||
|
||||
@ -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 码元;契约口径)
|
||||
};
|
||||
|
||||
@ -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<string, unknown>} [factoryOpts] 工厂选项(M1 未用)
|
||||
*/
|
||||
export default function createGame(factoryOpts) {
|
||||
/** @type {ReturnType<typeof createRunCore>|null} */
|
||||
let run = null;
|
||||
/** @type {ReturnType<typeof createMetaCore>} */
|
||||
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);
|
||||
},
|
||||
|
||||
@ -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];
|
||||
|
||||
193
tier2/games/shanhai-xunyilu/src/meta-core.js
Normal file
193
tier2/games/shanhai-xunyilu/src/meta-core.js
Normal file
@ -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 },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@ -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<typeof createRunCore>} 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<string, unknown>} */ (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<string, unknown>} */ (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<typeof createRunCore>|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}}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 };
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user