feat(polaris): 《山海巡异录》M1——host集成修根因+单局通关闭环+card driver v1(harness双席合笔)

host修根因(mini-desktop CDP验出双Phaser实例致可见区纯黑+真指针死):main.js重写为宿主契约形态——
工厂零Phaser import、永不自建引擎实例,场景经ctx.game.scene.add挂bootPhaserHost唯一实例(与夜市款同构);
真人pointerdown与injectInput('tap')同走route(x,y)→readState().tapTargets命中序(ui/layout.js唯一坐标事实)=同因果,
M0手牌盖板吃点击缺陷随重构消失;run-smoke S0六条静态自查防回潮;battle-scene.js退役git留档。
M1单局闭环:S5链轴完整化(链轨延链指引/graceUsed/周天金闪)+S6 map-core三幕DAG(战/精英/憩/宝匣/Boss,
静态可达自检)+S7敌8种(多段/甲攻复合/烛阴双相位/饕餮增势,意图回合开始钉住「展示=执行」逐字段成立)+
S8 card driver v1+play-spec;30卡五行各6/3遗物全规则级/奖励三选一可跳过/HP携带/run级三latch。
数值首轮回填全标〔草案〕(贪心v1胜率5%=下界非标定对象,A0带45-60%校准归M2 S12三档driver)。
完整run单局墙钟首实测(§6.4 M1出口):40 seed中位1.6ms/p95 3ms级(headless开发机口径)→外推M2全矩阵
96k局≈分钟级单线程,数值CI可行性坐实。harness共享面双席合笔:card-battle.driver+business-serve.driver新增、
play-phaser.cdp.cjs runDriver type分发additive(卡牌/夜市两席同文件双分发共存复核OK,未声明时行为逐字不变)。
证据:battle-core 40断言(T1a/T1b随定容更新)+run-core 12组+run-smoke全绿(地图150张/首局门5 seed全win/
通关2/40全自然终局零invalid/幕缩放1070采样全中);主会话真跑复验。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-06 11:13:51 -07:00
parent 948ee24f30
commit 329b82cad4
17 changed files with 2184 additions and 410 deletions

View File

@ -1,45 +1,73 @@
# 《山海巡异录》 —— 北极星顶级线③ · M0(战斗核心可玩)
# 《山海巡异录》 —— 北极星顶级线③ · M1(单局闭环)
卡牌构筑肉鸽(尖塔骨架 × Balatro 式乘法爽感,原创轴 = 五行相生序列链)。规格 SoT =
[开发设计书](../../../docs/agent-specs/2026-07-06-北极星顶级线-卡牌构筑-开发设计书.md)(创始人 2026-07-06 已批,
定名《山海巡异录》,统一山海 IP 宇宙);本目录只承载实现,规则数值一律以 `src/data/tables.js` 表为准、代码零魔法数。
## M0 范围(书 §11)
## M1 范围(书 §11:一局通关闭环)
一场完整教学战:出牌 / 能量经济 / 敌意图公开且诚实 / 序列链(每环 +0.25,周天一次性 ×2,上限 ×8,
动作数护栏 200→invalid)/ 胜负 latch。首批 20 卡(五行各 4)+ 首战敌「涸泽鲛」。
渲染层 = 竖屏三段布局(§7.1)+ 手牌点选出牌 + 结算演出 v0(漂字/链轨点亮/周天金闪/受击屏震/终局遮罩)。
零素材:全程序绘制,卡面插画位留空待风格版(§9.1);地图/遗物/商店/局外系统随 M1+。
M0 的单场战斗之上,这一档把「一局」立起来:三幕地图 DAG(战/精英/憩/宝匣/Boss 节点流,
静态可达自检)、HP 跨战斗携带、战斗奖励三选一加卡(可跳过)、金币账本(M1 只挣不花,商店=M2 S11)、
遗物 3 枚(全规则级:玉衡符首回合+2 抽/归墟镜每回合首次破序不断链/应龙鳞护甲跨回合)、
敌 8 种(普通 3/精英 2/Boss 3;多段攻击、甲攻复合、烛阴双相位、饕餮增势竞速;意图回合开始钉住,
展示=执行的诚实铁律在相位切换下仍成立)、卡池 30(五行各 6)、card driver v1+play-spec。
局外系统(进阶/图鉴/每日)随 M3;演出 v2/音频随 M4。
## host 集成(M0 事故修复,红线)
M0 版曾在 `boot()` 里自建第二个 Phaser.Game 且未挂宿主 parent → 宿主画布全黑、真画面渲到视口外、
真指针失效(mini-desktop CDP 取证)。M1 修法:**工厂永不自建引擎实例**(`src/main.js` 零 Phaser import),
场景经 `ctx.game.scene.add` 挂进宿主唯一实例(`tier2/engine/boot-phaser-host.js` 契约);输入单一路径——
真指针(场景级 pointerdown)与 `injectInput('tap')`(driver/CDP)都走 `readState().tapTargets` 命中序
(`src/ui/layout.js` 是唯一坐标事实)。`scripts/run-smoke.mjs` S0 静态自查钉住此红线防回潮。
浏览器端复验(画面可见/真指针点卡)排 mini-desktop 窗口批。
## 怎么跑
```bash
# 纯逻辑单测(40 断言:链语义/意图诚实/守恒/护栏/latch)
node scripts/battle-core.test.mjs
# 端到端冒烟(贪心 win / 挂机 lose / 100 场墙钟)
node scripts/logic-smoke.mjs
# M0 回归:战斗核单测(40 断言)+ 单场双路冒烟
node scripts/battle-core.test.mjs && node scripts/logic-smoke.mjs
# M1 单测:地图/一局状态机/遗物钩子/意图扩展/确定性(12 组)
node scripts/run-core.test.mjs
# M1 冒烟:host 契约静态自查 + 地图×150 + 首局门 + 完整 run×40 + 墙钟锚(产 dist/m1-walltime.json)
node scripts/run-smoke.mjs
# 构建(esbuild 在 game-runtime/node_modules;external phaser)
cd <repo>/game-runtime && node ../tier2/engine/build-phaser.mjs ../tier2/games/shanhai-xunyilu
# 真玩:静态起服后开 index.html(?seed= 注入种子);交互真玩窗口在 mini-desktop(本机禁 chrome)
# 真玩:静态起服后开 index.html(?seed= 注入种子);交互真玩/九门 driven 在 mini-desktop(本机禁 chrome)
```
## 结构
- `src/battle-core.js` —— 回合战斗解算核(纯函数、受控随机注入、事件流、latch;测试通道 opts 仅测试/仿真用)
- `src/data/tables.js` —— 20 卡 / 1 敌 / 链与回合曲线常量(schemaVersion=1;数值锚全〔草案·首轮仿真回填后锁〕)
- `src/scenes/battle-scene.js` —— 战斗场景(布局/交互/演出 v0;只消费 readState+takeEvents)
- `src/main.js` —— PhaserGameFactory(boot/readState/injectInput/destroy;canvas 渲染器)
- `src/battle-core.js` —— 回合战斗解算核(纯函数;M1 增列:敌注入/相位钉意图/遗物钩子/handCards 语义卡面)
- `src/map-core.js` —— 三幕地图 DAG 生成与静态可达校验(纯函数)
- `src/run-core.js` —— 一局核(地图推进×战斗桥接×奖励遗物×跨战斗资源×run 级 latch;tapTargets 出自布局层)
- `src/ui/layout.js` —— 布局与可点靶唯一坐标事实(场景绘制/driver 打点/injectInput 命中三方同源)
- `src/scenes/run-scene.js` —— 一局场景(地图/战斗/奖励/终局四视图;真指针=场景级路由;M0 battle-scene 已退役)
- `src/data/tables.js` —— 30 卡/8 敌/3 遗物/链与地图与经济曲线(schemaVersion=1;数值锚全〔草案〕)
- `scripts/` —— 单测与冒烟(node 直跑,无浏览器)
- `play-spec.json` —— 九门 driven 接线(driver.type=card-battle;CDP driver=`tier2/harness/card-battle.driver.js`)
## M0 包体两本账实测(书 §9.3 的 M0 确认项;2026-07-06 本机)
## M1 门与实测(2026-07-06 本机;浏览器面=窗口批)
- 游戏代码+数据表(bundle.iife.js,未 minify):**35,835 B ≈ 0.034MB**(账 A 预算件 0.35MB 的 10%)
- Phaser 3.80.1 引擎(jsdelivr):**raw 1,181,917 B ≈ 1.13MB;gzip 传输 313,864 B ≈ 0.30MB**
——账 B「引擎冷载 0.95MB」预算按 raw 口径超 0.18MB、按 gzip 传输口径富余 0.65MB;
**传输口径(gzip)下冷载显著优于预算**,账 B 的字节口径应钉「传输字节(含压缩)」,随 M0 真机计时一并定案
- 字体子集 / UI 图集 / 首战卡面 / manifest+cover:M0 未产,按书预算挂账,随 M1+ 逐件实测
- engineBundle 契约口径缺口(书 §9.3 已登记):`contracts/game-package.schema.json` 的 `engineBundle`
语义是「引擎+包装+游戏代码 iife 内嵌、全局名 __GameBundle」(LittleJS 线形态),`bundleSize`=「包总字节」;
本工程 Phaser external(bundle 不含引擎、全局名 __Tier2GameBundle)两字段计法均对不上——**问题在册、
不在本工程自改契约**,待契约侧确认(M0 报告项)
- 真机冷/暖启「到首战可交互」计时断言:本机无真机通道,**排 mini-desktop 窗口**(M0 报告项)
- 首局门(headless 代理):教学首战 5 seed 全 win、3–4 回合、演出估算 6.6–8.8s(≤60s;表 `FIRST_BATTLE` 口径,真机计时归窗口批)
- **完整 run 单局墙钟锚(§6.4 M1 出口)**:40 seed 贪心 driver,**中位 1.46ms/局、p95 2.32ms、max 3.18ms**
(headless、darwin/arm64 开发机,`dist/m1-walltime.json` 再生成)——书「秒–分钟级/格〔外推待实测〕」口径的首个实测,
外推 M2 全矩阵 96,000 局 ≈ 2.4 分钟单线程量级,数值 CI 可行性坐实(以 M2 S12 实测为准)
- 通关可达:win 2/40、零 invalid、全部自然终局;通关局样本=15 胜战/3 Boss/得卡 14/遗物 3(§11「一局通关」兑现)
- 九门 driven:**接线已落**(play-spec + card-battle driver + runDriver 分发),全量真跑在 mini-desktop 窗口批
## 数值首轮回填记录(全部〔草案·M2 S12 调参轮为权威校准〕)
贪心 driver v1 ×20–60 seed 迭代六轮,从 0/20 到 2/40 通关可达。改动全在表内:
相柳 155→130·甲 12→8·增益 2→1(多段吃力斜率最陡);烛阴 210→150;饕餮 280→195;赤犼 96→85;九尾 112→100;
幕缩放改逐幕表 HP[1,1.25,1.45]/攻[1,1.1,1.2](幂次复利是团灭主凶;书 ×2.2 锚以 M2 升级面为前提,届时回评);
憩点 30%→50%、幕间整备 50%、职业基线战胜回复 6(M1 无药水/商店,恢复面就这三件);
起始卡组攻 5/御 2/术 3;地图 1–4 层保底 1 中途憩点(M2 商店/事件落地后回评收回)。
贪心 v1 胜率 5% 是**下界不是标定对象**——把敌线削到让最弱策略赢 45% 等于对真人放水,A0 带校准等 M2 三档 driver。
## 已知边界(如实)
- 浏览器面(画面可见/真指针/九门 driven/CDP 三门)本机不可验,排 mini-desktop 窗口批
- engineBundle/bundleSize 契约口径对 Phaser external 形态不适配:M0 已在册,待契约侧,本工程不自改
- 富游戏三门 archetype 未接:卡牌品类门(构筑联动/曲线/latch 双终态)按书 §6.5 走验收门品类适配单另出
- run 存档/商店删卡/仿真外壳 S12=M2(W-SAVE-CAP 已入库,M2 入口门的存档面就位)

View File

@ -14,7 +14,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>山海巡异录 · 北极星顶级线③ M0</title>
<title>山海巡异录 · 北极星顶级线③ M1</title>
<style>
html, body { margin: 0; padding: 0; background: #0a0a12; height: 100%; }
#game-parent { width: 390px; height: 844px; margin: 0 auto; }

View File

@ -0,0 +1,21 @@
{
"_comment": "《山海巡异录》M1 play-spec(S8;play-phaser.cdp.cjs 缺省读 games/<gameId>/play-spec.json——本工程真跑用 --spec 指到本文件)。driver=card-battle(tier2/harness/card-battle.driver.js,读 readState().tapTargets 语义靶,两段式点选出牌;runDriver 分发已接线)。九门 driven 全量真跑在 mini-desktop 窗口批(本机禁 chrome);富游戏三门 archetype 未接=卡牌品类门适配单另出(书 §6.5,RICHGAME_GATED_ARCHETYPES 仅 business-sim)。",
"taskId": "polaris-shanhai-xunyilu-m1",
"designId": "0000000000000000000000000000000000000000000000000000000000000000",
"traceId": "polaris-card-m1-trace",
"round": 0,
"structureOk": true,
"driver": {
"type": "card-battle",
"steps": 1600,
"stepMs": 110
},
"expectLatch": true,
"latchTerminals": ["win", "lose"],
"assertAfterPlay": [
{ "path": "gold", "op": "increased", "why": "战斗奖励金币在涨(核心循环真有进展)" },
{ "path": "phase", "op": "in", "value": ["run", "win", "lose"], "why": "真玩中或已到终态,非卡死;invalid 出现=回归失败另判" }
],
"_controlCheckNote": "卡牌构筑=离散点选(选卡→确认/选节点/选奖励),非连续控制体;按 harness 文档对此类档位 I_control 缺省 SKIP,不声明 controlCheck。",
"_walltimeAnchor": "headless 完整 run 中位 1.46ms/局(dist/m1-walltime.json,2026-07-06 开发机);浏览器帧下墙钟由本 spec 真跑另测"
}

View File

@ -29,12 +29,13 @@ console.log('[battle-core.test] 《山海巡异录》M0 单测');
// ── T1 表完整性 + 估值 sanity(估值为 advisory,只有 >1.5 的离谱值才算失败)──
{
// M1 定容更新(书 §11 M1「30 卡」;2026-07-06 随 M1 单):普查断言随里程碑定容走,唯一性/五行均衡精神不变。
const ids = new Set(CARDS.map((c) => c.id));
assert(CARDS.length === 20 && ids.size === 20, 'T1a 首批 20 卡且 id 唯一');
assert(CARDS.length === 30 && ids.size === 30, 'T1a M1 卡池 30 且 id 唯一');
const perEl = {};
for (const c of CARDS) perEl[c.element] = (perEl[c.element] || 0) + 1;
assert(Object.values(perEl).every((n) => n === 4) && Object.keys(perEl).length === 5, 'T1b 五行各 4 张', JSON.stringify(perEl));
assert(STARTER_DECK.length === 20, 'T1c 教学卡组 20 张');
assert(Object.values(perEl).every((n) => n === 6) && Object.keys(perEl).length === 5, 'T1b 五行各 6 张', JSON.stringify(perEl));
assert(STARTER_DECK.length === 20, 'T1c 教学卡组 20 张(M0 首批,字面不动)');
assert(CURVES.actionLimit === 200, 'T1d 动作上限表值 200');
// 估值(§6.2 锚:伤/7 甲/4 抽 0.5 能 1.0 力 0.5;0 费按 0.5 计)——advisory 打印,硬线只卡 1.5。
let worst = 0;

View File

@ -0,0 +1,262 @@
/**
* scripts/run-core.test.mjs —— 《山海巡异录》M1 单测(run-core/map-core/遗物钩子/意图扩展)
* owner:北极星顶级线③(M1)
*
* 【验什么】设计书 §3.4 在 M1 新可测的条目 + §11 M1 门支撑断言:
* R1 地图确定性+静态可达+配额;R2 一局初态;R3 非法选点零副作用;R4 教学固定首战;
* R5 战斗桥接→奖励三选一→加卡/跳过;R6 HP 跨战斗携带;R7 憩点回复与宝匣遗物(脚本图);
* R8 遗物三钩子(玉衡符/归墟镜/应龙鳞);R9 意图扩展诚实(多段/甲攻/Boss 相位);
* R10 invalid 传导 run 级;R11 一局确定性(同 seed 同动作序 → 终态一致);R12 tapTargets 形状。
*
* 用法:node scripts/run-core.test.mjs(退出码 0=全过)
*/
'use strict';
import { createRunCore } from '../src/run-core.js';
import { createBattleCore } from '../src/battle-core.js';
import { generateActMap, validateActMap } from '../src/map-core.js';
import { createSeededRandom } from '../src/util/seeded-random.js';
import { ENEMIES, RELICS, ECONOMY, CURVES } 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++; }
}
/** 简易贪心打完当前战斗(能出就出,出不动结束回合;guard 防死循环)。 */
function fightThrough(run) {
for (let guard = 0; guard < 4000 && run.readState().screen === 'battle' && run.readState().phase === 'run'; guard++) {
const s = run.readState();
const b = s.battle;
const idx = b.handCards.findIndex((c) => c.cost <= b.energy);
if (idx >= 0) { run.tapCard(idx); run.tapCard(idx); } // 两段式:选中→确认
else run.endTurn();
}
}
/** 手工合法迷你图(单宽七层,链式;配额:战×2/憩×2/精英/宝匣/Boss 各达标)。 */
function miniMap(act) {
const mk = (f, type) => ({ id: `a${act}-f${f}-n0`, floor: f, idx: 0, type });
const nodes = [mk(0, 'battle'), mk(1, 'rest'), mk(2, 'elite'), mk(3, 'treasure'), mk(4, 'battle'), mk(5, 'rest'), mk(6, 'boss')];
const edges = nodes.slice(0, -1).map((n, i) => [n.id, nodes[i + 1].id]);
return { nodes, edges };
}
console.log('[run-core.test] 《山海巡异录》M1 单测');
// ── R1 地图:确定性 + 静态可达 + 配额(×30 seed × 3 幕)──
{
const a = generateActMap(createSeededRandom(99), 1);
const b = generateActMap(createSeededRandom(99), 1);
assert(JSON.stringify(a) === JSON.stringify(b), 'R1a 同 seed 同图(确定性)');
let allOk = true;
let firstProblem = '';
for (let s = 0; s < 30 && allOk; s++) {
for (let act = 1; act <= 3; act++) {
const v = validateActMap(generateActMap(createSeededRandom(1000 + s * 7 + act), act));
if (!v.ok) { allOk = false; firstProblem = v.problems.join(';'); break; }
}
}
assert(allOk, 'R1b 30 seed × 3 幕全过静态可达+配额自检', firstProblem);
}
// ── R2/R3 初态与非法选点 ──
{
const run = createRunCore({ random: createSeededRandom(5) }, { seed: 5 });
const s = run.readState();
assert(s.phase === 'run' && s.screen === 'map' && s.act === 1, 'R2a 初态 map 屏第一幕');
assert(s.hero.hp === CURVES.heroHpMax && s.hero.hpMax === CURVES.heroHpMax, 'R2b 初始 HP=表值(职业甲 72)');
assert(s.deckCount === 10, 'R2c 起始卡组 10(卡组下限)');
assert(s.map.selectable.length >= 1 && s.map.selectable.every((id) => id.includes('-f0-')), 'R2d 幕起点只可选 0 层');
const before = JSON.stringify(run.readState());
const r = run.chooseNode('a1-f6-n0'); // Boss 节点不可直选
assert(!r.ok && r.reason === 'not-selectable', 'R3a 非可选节点被拒');
assert(JSON.stringify(run.readState()) === before, 'R3b 拒绝后状态逐字段不变');
}
// ── R4/R5/R6 教学首战 → 奖励 → 加卡/跳过 → HP 携带 ──
{
const run = createRunCore({ random: createSeededRandom(11) }, { seed: 11 });
run.chooseNode(run.readState().map.selectable[0]);
let s = run.readState();
assert(s.screen === 'battle' && s.battle && s.battle.enemy.id === 'kuze-jiao', 'R4 幕1·0 层=教学固定首战「涸泽鲛」', s.battle && s.battle.enemy.id);
const goldBefore = s.gold;
fightThrough(run);
s = run.readState();
assert(s.screen === 'reward' && s.reward && s.reward.options.length === ECONOMY.cardChoices, 'R5a 战胜进奖励屏(三选一)');
const ids = s.reward.options.map((o) => o.id);
assert(new Set(ids).size === 3, 'R5b 三张互异', ids.join(','));
const band = ECONOMY.goldReward.normal;
assert(s.gold - goldBefore >= band[0] && s.gold - goldBefore <= band[1], 'R5c 金币入带', String(s.gold - goldBefore));
const hpAfterBattle = s.hero.hp;
const deckBefore = s.deckCount;
run.chooseReward(0);
s = run.readState();
assert(s.deckCount === deckBefore + 1 && s.screen === 'map', 'R5d 加卡入组并回图');
// 下一战开局 HP=携带值(跨战斗长线资源)。
const next = s.map.selectable[0];
run.chooseNode(next);
const s2 = run.readState();
if (s2.screen === 'battle') {
assert(s2.battle.hero.hp === hpAfterBattle, 'R6 HP 跨战斗携带', `${s2.battle.hero.hp} vs ${hpAfterBattle}`);
} else {
// 该 seed 下一节点非战斗(憩/宝):HP 语义由 R7 脚本图覆盖,这里只验 run 侧账一致。
assert(s2.hero.hp >= hpAfterBattle, 'R6 HP 跨节点账一致(非战斗节点)', `${s2.hero.hp}`);
}
// 跳过路:重开一局验 skip 不加卡。
const run2 = createRunCore({ random: createSeededRandom(12) }, { seed: 12 });
run2.chooseNode(run2.readState().map.selectable[0]);
fightThrough(run2);
const d0 = run2.readState().deckCount;
run2.chooseReward('skip');
assert(run2.readState().deckCount === d0 && run2.readState().screen === 'map', 'R5e 跳过不加卡(§3.2 可跳过)');
}
// ── R7 脚本图:憩点回复 + 宝匣遗物 + 精英必掉(miniMap 测试通道)──
{
const run = createRunCore(
{ random: createSeededRandom(21) },
{ seed: 21, actMaps: [miniMap(1), miniMap(2), miniMap(3)] },
);
run.chooseNode('a1-f0-n0'); // 教学首战
fightThrough(run);
run.chooseReward('skip');
const hpAfterFight = run.readState().hero.hp;
run.chooseNode('a1-f1-n0'); // 憩
const s = run.readState();
const expectHeal = Math.min(Math.floor(s.hero.hpMax * ECONOMY.restHealPct), s.hero.hpMax - hpAfterFight);
assert(s.hero.hp === hpAfterFight + expectHeal, 'R7a 憩点回复=表值(30% 封顶)', `${hpAfterFight}→${s.hero.hp}(期望+${expectHeal})`);
run.chooseNode('a1-f2-n0'); // 精英
assert(run.readState().battle.enemy.tier === 'elite', 'R7b 精英节点起精英战');
fightThrough(run);
const afterElite = run.readState();
if (afterElite.phase === 'run') {
assert(afterElite.relics.length === 1 && afterElite.relics[0].id === RELICS[0].id, 'R7c 精英必掉遗物(池序首枚)', JSON.stringify(afterElite.relics));
run.chooseReward('skip');
run.chooseNode('a1-f3-n0'); // 宝匣
const s3 = run.readState();
assert(s3.relics.length === 2 && s3.relics[1].id === RELICS[1].id, 'R7d 宝匣发池序次枚遗物', JSON.stringify(s3.relics));
} else {
assert(false, 'R7c 精英战贪心竟败(数值曲线要看)', `phase=${afterElite.phase} hp=${afterElite.hero.hp}`);
}
}
// ── R8 遗物三钩子(battle-core 直测)──
{
const mk = (seed, opts) => createBattleCore({ random: createSeededRandom(seed) }, opts);
// 玉衡符:首回合抽 5+2。
const a = mk(31, { relicHooks: { firstTurnDraw: 2 } });
assert(a.readState().counts.hand === 7, 'R8a 玉衡符首回合 7 张', String(a.readState().counts.hand));
// 归墟镜:首次破序不断链(牌不入链、链不清),同回合第二次破序才真断。
const seq = ['mu-qingteng', 'huo-chiyu', 'jin-jinfeng', 'shui-xuanshui'];
const b = mk(32, { relicHooks: { chainGracePerTurn: 1 }, energyMax: 9, drawPerTurn: 4, deck: seq.slice(), fixedDraw: seq.slice() });
b.takeEvents();
b.play(b.readState().hand.indexOf('mu-qingteng')); // 开链:木
b.play(b.readState().hand.indexOf('huo-chiyu')); // 木→火 相生:links=1
b.play(b.readState().hand.indexOf('shui-xuanshui')); // 火→水 破序 → 豁免
const evs = b.takeEvents();
assert(evs.some((e) => e.t === 'chainGrace') && !evs.some((e) => e.t === 'chainBreak'), 'R8b 归墟镜:首次破序豁免(chainGrace,无 chainBreak)');
assert(b.readState().chain.links === 1 && b.readState().chain.seq.join('') === '木火', 'R8c 豁免后链保持(1 环·木火)', JSON.stringify(b.readState().chain));
b.play(b.readState().hand.indexOf('jin-jinfeng')); // 火→金 第二次破序 → 真断
const evs2 = b.takeEvents();
assert(evs2.some((e) => e.t === 'chainBreak') && b.readState().chain.links === 0, 'R8d 同回合第二次破序真断链');
// 应龙鳞:护甲跨回合保留(承天载甲 10,敌首击 8 → 余 2 留到下回合)。
const c = mk(33, { relicHooks: { blockPersist: true }, energyMax: 9, drawPerTurn: 1, deck: ['tu-chengtian', 'tu-houtu', 'tu-houtu'], fixedDraw: ['tu-chengtian'] });
c.play(0);
c.endTurn(); // 涸泽鲛 啸浪 8:甲 10 吸 8 → 余 2;应龙鳞跨回合保留
assert(c.readState().hero.block === 2 && c.readState().hero.hp === CURVES.heroHpMax, 'R8e 应龙鳞:余甲 2 跨回合保留且未掉血', JSON.stringify(c.readState().hero));
}
// ── R9 意图扩展诚实(多段/甲攻/Boss 相位;battle-core 直测)──
{
const mk = (seed, opts) => createBattleCore({ random: createSeededRandom(seed) }, opts);
// 多段:相柳 九首轮啮 4×3(展示=执行,dealt 合计 12)。
const xiangliu = ENEMIES.find((e) => e.id === 'xiang-liu');
const a = mk(41, { enemy: xiangliu });
const shown = a.readState().enemy.intent;
assert(shown.kind === 'attack' && shown.times === 3 && shown.n === 4, 'R9a 多段意图展示 4×3', JSON.stringify(shown));
a.takeEvents();
a.endTurn();
const act = a.takeEvents().find((e) => e.t === 'enemyAction');
assert(act.dealt === 12 && JSON.stringify(act.fromIntent) === JSON.stringify(shown), 'R9b 多段执行=展示(合计 12)', JSON.stringify(act));
// 甲攻:赤犼 鬃振双爪(甲 6 + 攻 8)。
const chihou = ENEMIES.find((e) => e.id === 'chi-hou');
const b = mk(42, { enemy: chihou });
b.endTurn(); // 意图 1:裂空啸
b.takeEvents();
const shown2 = b.readState().enemy.intent;
assert(shown2.kind === 'attackBlock' && shown2.n === 8 && shown2.block === 6, 'R9c 甲攻复合意图展示', JSON.stringify(shown2));
const hpBefore = b.readState().hero.hp;
b.endTurn();
const s2 = b.readState();
assert(s2.enemy.block === 6 && hpBefore - s2.hero.hp === 8, 'R9d 甲攻执行:敌甲 6 且我方 -8', JSON.stringify({ block: s2.enemy.block, dhp: hpBefore - s2.hero.hp }));
// Boss 相位:自定小血 Boss,落线后后续回合走 intents2,展示=执行不破。
const testBoss = {
id: 'test-boss', name: '试炼影', tier: 'boss', acts: [1], hpMax: 30, phase2At: 0.5,
intents: [{ kind: 'block', n: 5, name: '守' }],
intents2: [{ kind: 'attack', n: 9, name: '怒' }],
};
const c = mk(43, { enemy: testBoss, energyMax: 9, drawPerTurn: 3, deck: ['huo-liming', 'huo-liming', 'huo-liming'], fixedDraw: ['huo-liming'] });
c.play(0); // 26 伤 → 30-26=4 ≤ 15(50%):相位条件已达,但本回合已钉「守」
const pinNow = c.readState().enemy.intent;
assert(pinNow.kind === 'block', 'R9e 相位落线当回合意图不变(已钉)', JSON.stringify(pinNow));
c.takeEvents();
c.endTurn();
const evs = c.takeEvents();
const acted = evs.find((e) => e.t === 'enemyAction');
const phased = evs.find((e) => e.t === 'enemyPhase');
assert(acted.kind === 'block' && !!phased, 'R9f 执行仍为已钉意图,相位事件在其后', JSON.stringify({ acted: acted.kind, phased: !!phased }));
const nextShown = c.readState().enemy.intent;
assert(nextShown.kind === 'attack' && nextShown.n === 9, 'R9g 下回合意图来自第二组', JSON.stringify(nextShown));
}
// ── R10 invalid 传导 run 级(battleOpts 测试通道压低动作上限)──
{
const run = createRunCore(
{ random: createSeededRandom(51) },
{ seed: 51, actMaps: [miniMap(1), miniMap(2), miniMap(3)], battleOpts: { actionLimit: 2, energyMax: 99 } },
);
run.chooseNode('a1-f0-n0');
for (let i = 0; i < 4; i++) { const s = run.readState(); if (s.phase !== 'run' || !s.battle) break; const idx = s.battle.handCards.findIndex((c) => c.cost <= s.battle.energy); if (idx < 0) break; run.tapCard(idx); run.tapCard(idx); }
const s = run.readState();
assert(s.phase === 'invalid', 'R10 护栏触发传导为 run 级 invalid(计回归失败)', s.phase);
assert(!run.chooseNode('a1-f1-n0').ok && !run.endTurn().ok, 'R10b 终局后动作全拒');
}
// ── R11 一局确定性(同 seed 同策略 → 终态逐字段一致)──
{
const script = (seed) => {
const run = createRunCore({ random: createSeededRandom(seed) }, { seed });
for (let guard = 0; guard < 20000 && run.readState().phase === 'run'; guard++) {
const s = run.readState();
if (s.screen === 'map') run.chooseNode(s.map.selectable[0]);
else if (s.screen === 'battle') {
const idx = s.battle.handCards.findIndex((c) => c.cost <= s.battle.energy);
if (idx >= 0) { run.tapCard(idx); run.tapCard(idx); } else run.endTurn();
} else if (s.screen === 'reward') run.chooseReward(0);
}
return JSON.stringify(run.readState());
};
assert(script(61) === script(61), 'R11 同 seed 同动作序 → 终态一致');
}
// ── R12 tapTargets 形状(driver 契约)──
{
const run = createRunCore({ random: createSeededRandom(71) }, { seed: 71 });
let s = run.readState();
assert(s.tapTargets.length === s.map.selectable.length && s.tapTargets.every((t) => t.kind === 'node'), 'R12a map 屏靶=可选节点');
run.chooseNode(s.map.selectable[0]);
s = run.readState();
const cards = s.tapTargets.filter((t) => t.kind === 'card');
assert(cards.length === s.battle.hand.length, 'R12b battle 屏卡靶=手牌数');
assert(s.tapTargets.some((t) => t.kind === 'endTurn') && s.tapTargets.some((t) => t.kind === 'enemy'), 'R12c END/敌区靶在位');
fightThrough(run);
s = run.readState();
const rewards = s.tapTargets.filter((t) => t.kind === 'reward');
assert(rewards.length === 3 && s.tapTargets.some((t) => t.kind === 'rewardSkip'), 'R12d 奖励屏 3 卡靶+跳过靶');
}
console.log(failures === 0 ? '[run-core.test] 全绿' : `[run-core.test] ${failures} 条失败`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -0,0 +1,198 @@
/**
* scripts/run-smoke.mjs —— 《山海巡异录》M1 冒烟(node 直跑 · 无浏览器)
* owner:北极星顶级线③(M1)
*
* 【验什么】§11 M1 验收门的 headless 面:
* S0 host 集成契约静态自查(M0 事故红线:禁自建 Phaser 实例/场景必挂 ctx.game/退役件不残留);
* S1 地图静态可达 ×50 seed × 3 幕;
* S2 首局门(60s 首战闭环)headless 代理:教学首战 win + 回合 ≤表值 + 演出估算 ≤60s
* (真浏览器计时归 mini-desktop 窗口批,FIRST_BATTLE 表口径);
* S3 完整 run 贪心 driver ×40 seed:通关可达(≥1 win)+ 零 invalid + 幕缩放对表;
* S4 完整 run 单局 headless 墙钟锚实测(§6.4 M1 出口验收)→ dist/m1-walltime.json;
* S5 driver 策略与 harness 同源自查(createRequire 载入 tier2/harness/card-battle.driver.js)。
*
* 用法:node scripts/run-smoke.mjs(退出码 0=全过)
*/
'use strict';
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { createRequire } from 'node:module';
import { createRunCore } from '../src/run-core.js';
import { generateActMap, validateActMap } from '../src/map-core.js';
import { createSeededRandom } from '../src/util/seeded-random.js';
import { ENEMIES, MAP, PRESENTATION, FIRST_BATTLE } from '../src/data/tables.js';
const __dir = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dir, '..');
const require = createRequire(import.meta.url);
// 策略单源:harness CDP driver 与 headless 冒烟同一个大脑(防两套策略漂移)。
const { chooseTap } = require(join(ROOT, '../../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++; }
}
console.log('[run-smoke] 《山海巡异录》M1 冒烟');
// ── S0 host 集成契约静态自查(M0 事故红线钉死防回潮)──
{
const mainSrc = readFileSync(join(ROOT, 'src/main.js'), 'utf8');
assert(!/new\s+Phaser\.Game/.test(mainSrc), 'S0a 工厂不自建 Phaser 实例(new Phaser.Game 零出现)');
assert(!/^import\s+Phaser/m.test(mainSrc), 'S0b 工厂零 Phaser 顶层 import(场景挂宿主实例,无需名字空间)');
assert(/hostGame\.scene\.add\(SCENE_KEY/.test(mainSrc), 'S0c 场景挂宿主 ctx.game(scene.add)');
assert(/ctx\s*&&\s*ctx\.game/.test(mainSrc), 'S0d boot 消费宿主 ctx.game(headless 判空)');
assert(!existsSync(join(ROOT, 'src/scenes/battle-scene.js')), 'S0e M0 battle-scene 已退役(git 留档)');
const bundlePath = join(ROOT, 'bundle.iife.js');
if (existsSync(bundlePath)) {
assert(!/new\s+Phaser\.Game/.test(readFileSync(bundlePath, 'utf8')), 'S0f 构建产物同样零自建实例');
} else {
console.log(' SKIP S0f 构建产物不存在(先跑 build-phaser.mjs 再复查)');
}
}
// ── S1 地图静态可达 ×50 seed × 3 幕 ──
{
let ok = true; let firstProblem = '';
for (let s = 0; s < 50 && ok; s++) {
for (let act = 1; act <= 3; act++) {
const v = validateActMap(generateActMap(createSeededRandom(20000 + s * 13 + act), act));
if (!v.ok) { ok = false; firstProblem = `seed=${20000 + s * 13 + act} ${v.problems.join(';')}`; }
}
}
assert(ok, 'S1 50 seed × 3 幕地图全过静态可达+配额', firstProblem);
}
/** 用 harness 同源策略把一局打到终态;返回统计。 */
function driveRun(seed, opts = {}) {
const run = createRunCore({ random: createSeededRandom(seed) }, { seed, ...opts });
const scalingChecks = [];
let decisions = 0;
const GUARD = 20000;
let firstBattleEvents = null;
let collecting = [];
let inFirstBattle = true;
const t0 = process.hrtime.bigint();
for (; decisions < GUARD; decisions++) {
const s = run.readState();
if (s.phase !== 'run') break;
// 幕缩放对表采样(普通敌;battle 屏首帧)。
if (s.screen === 'battle' && s.battle && s.battle.turn === 1 && s.battle.enemy.tier === 'normal' && s.act >= 2) {
const def = ENEMIES.find((e) => e.id === s.battle.enemy.id);
const expect = Math.round(def.hpMax * MAP.actHpScaleByAct[Math.min(s.act, MAP.actHpScaleByAct.length) - 1]);
scalingChecks.push({ id: def.id, act: s.act, got: s.battle.enemy.hpMax, expect });
}
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 === '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');
// 事件收集(首战演出估算)。
if (inFirstBattle) {
collecting.push(...run.takeEvents());
if (collecting.some((e) => e.t === 'battleEnd' || e.t === 'runEnd')) {
firstBattleEvents = collecting;
inFirstBattle = false;
}
}
}
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
const end = run.readState();
return {
seed, phase: end.phase, act: end.act, ms: Math.round(ms * 100) / 100,
decisions, guardHit: decisions >= GUARD,
stats: end.stats, deck: end.deckCount, relics: end.relics.length, gold: end.gold,
hp: end.hero.hp, firstBattleEvents, scalingChecks,
};
}
/** 首战演出时长估算(表 PRESENTATION 口径;真浏览器计时归窗口批)。 */
function estimatePresentationMs(events) {
let ms = 0;
for (const e of events) {
if (e.t === 'zhoutian') ms += PRESENTATION.zhoutianMs;
else if (e.t === 'turnStart') ms += PRESENTATION.turnStartMs;
else if (e.t === 'battleEnd' || e.t === 'runEnd') ms += PRESENTATION.battleEndMs;
else ms += PRESENTATION.perEventMs;
}
return ms;
}
// ── S2 首局门(headless 代理口径)──
{
const seeds = [1, 7, 42, 888, 2026];
const results = seeds.map((s) => {
const r = driveRun(s);
const evs = r.firstBattleEvents || [];
const battleEnd = evs.find((e) => e.t === 'battleEnd');
const turnStarts = evs.filter((e) => e.t === 'turnStart').length;
const estMs = estimatePresentationMs(evs);
return { seed: s, win: !!battleEnd && battleEnd.result === 'win', turns: turnStarts, estMs };
});
const allWin = results.every((r) => r.win);
const allTurns = results.every((r) => r.turns <= FIRST_BATTLE.maxTurns);
const allEst = results.every((r) => r.estMs <= FIRST_BATTLE.maxPresentationMs);
assert(allWin, 'S2a 教学首战 5 seed 全 win', JSON.stringify(results.map((r) => r.win)));
assert(allTurns, `S2b 首战回合 ≤${FIRST_BATTLE.maxTurns}`, JSON.stringify(results.map((r) => r.turns)));
assert(allEst, 'S2c 首战演出估算 ≤60s(headless 代理;真机计时归窗口批)', JSON.stringify(results.map((r) => r.estMs)));
console.log(' · 首战样例:', JSON.stringify(results[0]));
}
// ── S3 完整 run 贪心 ×40 seed(通关可达门;胜率带校准=M2 S12,贪心 v1 是下界不是标定对象)──
const N = 40;
const runs = [];
for (let i = 0; i < N; i++) runs.push(driveRun(3000 + i * 17));
{
const wins = runs.filter((r) => r.phase === 'win');
const invalids = runs.filter((r) => r.phase === 'invalid');
const guards = runs.filter((r) => r.guardHit);
assert(wins.length >= 1, `S3a 通关可达(win ${wins.length}/${N};判据带校准归 M2 S12)`, runs.map((r) => r.phase).join(','));
assert(invalids.length === 0, 'S3b 零 invalid(护栏未误触)', String(invalids.length));
assert(guards.length === 0, 'S3c 全部自然终局(无 guard 截断)');
if (wins.length) {
const w = wins[0];
assert(w.stats.bossesWon === MAP.acts, 'S3d 通关局 Boss 战 3/3', JSON.stringify(w.stats));
}
const scaling = runs.flatMap((r) => r.scalingChecks);
const scaleOk = scaling.every((c) => c.got === c.expect);
assert(scaling.length > 0 && scaleOk, `S3e 幕缩放对表(采样 ${scaling.length} 场)`, scaleOk ? '' : JSON.stringify(scaling.filter((c) => c.got !== c.expect).slice(0, 3)));
console.log(` · 胜率 ${wins.length}/${N};终态分布:${JSON.stringify(runs.reduce((m, r) => { m[r.phase] = (m[r.phase] || 0) + 1; return m; }, {}))}`);
console.log(` · 通关局样例:${JSON.stringify(wins[0] ? { seed: wins[0].seed, ms: wins[0].ms, deck: wins[0].deck, relics: wins[0].relics, gold: wins[0].gold, hp: wins[0].hp, stats: wins[0].stats } : null)}`);
}
// ── S4 完整 run 单局墙钟锚(§6.4 M1 出口验收)──
{
const times = runs.map((r) => r.ms).sort((a, b) => a - b);
const median = times[Math.floor(times.length / 2)];
const mean = times.reduce((s, x) => s + x, 0) / times.length;
const p95 = times[Math.min(times.length - 1, Math.ceil(times.length * 0.95) - 1)];
const anchor = {
what: '完整 run 单局 headless 墙钟(贪心 driver,§6.4 M1 出口锚)',
date: '2026-07-06',
host: `${process.platform}/${process.arch} node ${process.version}(开发机;CI 机型另测)`,
seeds: N,
medianMs: median, meanMs: Math.round(mean * 100) / 100, p95Ms: p95, maxMs: times[times.length - 1],
note: '书 §6.4 口径「秒–分钟级/格〔外推待实测〕」的首个单局实测;全矩阵总墙钟=M2 S12 出口',
};
mkdirSync(join(ROOT, 'dist'), { recursive: true });
writeFileSync(join(ROOT, 'dist/m1-walltime.json'), JSON.stringify(anchor, null, 2));
console.log(' · 墙钟锚:', JSON.stringify(anchor));
assert(median < 60000, 'S4 单局墙钟中位 < 60s(秒–分钟级/格 带内 sanity)', `${median}ms`);
}
// ── S5 策略同源自查(chooseTap 来自 harness 文件本体)──
{
assert(typeof chooseTap === 'function', 'S5 headless 冒烟与 CDP harness 共用同一 chooseTap(策略单源)');
}
console.log(failures === 0 ? '[run-smoke] 全绿' : `[run-smoke] ${failures} 条失败`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -17,7 +17,15 @@
* 渲染层经 takeEvents() 回放成结算演出(§7.2 演出与逻辑解耦),仿真侧读同一事件流做断言(意图诚实等)。
*
* 【测试通道】opts.fixedDraw(按队列定序抽牌)/opts.energyMax/opts.actionLimit/opts.deck 仅供单测与
* 仿真注入确定性场景;游戏路径(main.js)不传,走表默认值。
* 仿真注入确定性场景;游戏路径不传,走表默认值。
*
* 【M1 增列】(run-core 消费;全部缺省关闭,M0 行为不变)
* opts.enemy 敌定义注入(地图节点敌;缺省表首敌=教学涸泽鲛)
* opts.heroHp/heroHpMax 跨战斗 HP 携带(一局的长线资源,§6.1)
* opts.relicHooks 遗物规则钩子 {firstTurnDraw, chainGracePerTurn, blockPersist}(表 RELICS.hook 聚合)
* 意图扩展:attack{times} 多段 / attackBlock 甲+攻 / Boss 相位(phase2At+intents2);
* 意图于回合开始「钉住」(pin),读到的即将执行的——诚实铁律在相位切换下依然成立
* (切相位只影响后续回合的选取,当回合已钉意图不变)。
* ════════════════════════════════════════════════════════════════════════════
*/
@ -65,26 +73,34 @@ export function createBattleCore(deps, opts = {}) {
const energyMax = opts.energyMax != null ? opts.energyMax : CURVES.energyPerTurn;
const actionLimit = opts.actionLimit != null ? opts.actionLimit : CURVES.actionLimit;
const drawPerTurn = opts.drawPerTurn != null ? opts.drawPerTurn : CURVES.drawPerTurn;
const enemyDef = ENEMIES[0]; // M0 单敌(教学首战);多敌随 M1 地图接入
const enemyDef = opts.enemy || ENEMIES[0]; // M1:run-core 按地图节点注入;缺省表首敌(教学涸泽鲛,M0 路)
/** 遗物规则钩子(表 RELICS.hook 聚合;缺省全关=M0 行为)。 */
const relicHooks = opts.relicHooks || {};
/** 测试定序抽牌队列(cardId[]);耗尽后回退受控随机洗抽。 */
const fixedDraw = Array.isArray(opts.fixedDraw) ? opts.fixedDraw.slice() : null;
const heroHpMax = opts.heroHpMax != null ? opts.heroHpMax : CURVES.heroHpMax;
const heroHp = opts.heroHp != null ? opts.heroHp : heroHpMax;
// ── 战斗状态(单一状态树;readState 导出只读投影)──
const state = {
schemaVersion: TABLE_SCHEMA_VERSION,
phase: 'battle', // battle → win|lose|invalid(latch)
turn: 1,
energy: energyMax,
hero: { hpMax: CURVES.heroHpMax, hp: CURVES.heroHpMax, block: 0, strength: 0 },
hero: { hpMax: heroHpMax, hp: heroHp, block: 0, strength: 0 },
enemy: {
id: enemyDef.id, name: enemyDef.name, hpMax: enemyDef.hpMax, hp: enemyDef.hpMax,
block: 0, strength: 0, intentIndex: 0,
tier: enemyDef.tier || 'normal', // M1 增列(奖励与展示消费)
phase2: false, // Boss 相位标记(intents2 激活后为真)
},
hand: /** @type {string[]} */ ([]),
drawPile: /** @type {string[]} */ ([]),
discardPile: /** @type {string[]} */ ([]),
// 序列链(回合内轴,§6.1):seq=本链已打元素序;links=环数;zhoutianUsed=本回合周天已触发。
chain: { seq: /** @type {string[]} */ ([]), links: 0, zhoutianUsed: false },
// 序列链(回合内轴,§6.1):seq=本链已打元素序;links=环数;zhoutianUsed=本回合周天已触发;
// graceUsed=本回合已消耗的破序豁免次数(归墟镜)。
chain: { seq: /** @type {string[]} */ ([]), links: 0, zhoutianUsed: false, graceUsed: 0 },
actionsThisTurn: 0,
invalid: false,
};
@ -134,13 +150,31 @@ export function createBattleCore(deps, opts = {}) {
function drawN(n) { for (let i = 0; i < n; i++) drawOne(); }
// ── 内部:意图与结算 ─────────────────────────────────────────────────────────
/** 当前公开意图(诚实铁律:展示对象=执行对象,同一表条目)。 */
function currentIntent() {
const it = enemyDef.intents[state.enemy.intentIndex % enemyDef.intents.length];
// attack 类展示值 = 基础值+敌方力(玩家看到的就是将要吃到的,§3.2 意图必须永远诚实)。
if (it.kind === 'attack') return { kind: 'attack', n: it.n + state.enemy.strength, name: it.name };
if (it.kind === 'block') return { kind: 'block', n: it.n, name: it.name };
return { kind: 'buff', strength: it.strength, name: it.name };
/** 本回合已钉住的意图(pin 于回合开始;readState 展示它,endTurn 执行它——同一对象,诚实铁律)。 */
let pinnedIntent = null;
/**
* 回合开始钉意图:按相位选意图组、按敌方力折算展示值,钉成本回合唯一事实。
* Boss 相位(phase2At+intents2):HP 落线后**后续回合**改用第二组(切换时组内序归零并记事件);
* 当回合已钉意图不受切换影响——玩家看到的永远是将要执行的(§3.2)。
*/
function pinIntent() {
let list = enemyDef.intents;
if (enemyDef.intents2 && enemyDef.phase2At != null) {
if (!state.enemy.phase2 && state.enemy.hp <= state.enemy.hpMax * enemyDef.phase2At) {
state.enemy.phase2 = true;
state.enemy.intentIndex = 0;
emit({ t: 'enemyPhase', phase: 2, enemyId: state.enemy.id });
}
if (state.enemy.phase2) list = enemyDef.intents2;
}
const it = list[state.enemy.intentIndex % list.length];
// attack 类展示值 = 基础值+敌方力(玩家看到的就是将要吃到的);times 缺省 1。
if (it.kind === 'attack') pinnedIntent = { kind: 'attack', n: it.n + state.enemy.strength, times: it.times || 1, name: it.name };
else if (it.kind === 'block') pinnedIntent = { kind: 'block', n: it.n, name: it.name };
else if (it.kind === 'attackBlock') pinnedIntent = { kind: 'attackBlock', n: it.n + state.enemy.strength, block: it.block, name: it.name };
else pinnedIntent = { kind: 'buff', strength: it.strength, name: it.name };
return pinnedIntent;
}
/** 对敌单段伤害结算(先甲后血);返回实际掉血。 */
@ -183,14 +217,14 @@ export function createBattleCore(deps, opts = {}) {
emit({ t: 'invalid', reason: 'actionLimit', limit: actionLimit, turn: state.turn });
}
// ── 初始化:建卡组、开局抽牌、公开首意图 ──────────────────────────────────────
// ── 初始化:建卡组、开局抽牌(玉衡符:首回合额外抽)、钉首意图 ─────────────────
const deckIds = Array.isArray(opts.deck) ? opts.deck.slice() : STARTER_DECK.slice();
for (const id of deckIds) {
if (!CARD_BY_ID[id]) throw new Error(`[battle-core] 卡组含未知卡:${id}`); // 表一致性,错误路径留痕
}
state.drawPile = shuffle(deckIds);
drawN(drawPerTurn);
emit({ t: 'turnStart', turn: 1, intent: currentIntent() });
drawN(drawPerTurn + (relicHooks.firstTurnDraw || 0));
emit({ t: 'turnStart', turn: 1, intent: pinIntent() });
// ── 对外 API ────────────────────────────────────────────────────────────────
return {
@ -225,11 +259,18 @@ export function createBattleCore(deps, opts = {}) {
state.actionsThisTurn += 1;
state.hand.splice(handIndex, 1);
// 链推进:延续 → 环+1;破序/首张 → 以本卡重开链。
// 链推进:延续 → 环+1;破序 → 归墟镜豁免(本回合限次:牌不入链、链不清)或断链重开;首张 → 开链。
if (extendsChain) {
state.chain.seq.push(card.element);
state.chain.links += 1;
emit({ t: 'chainLink', element: card.element, links: state.chain.links });
} else if (
state.chain.seq.length > 0 &&
(relicHooks.chainGracePerTurn || 0) - state.chain.graceUsed > 0
) {
// 归墟镜:破序豁免——打出的牌游离于链外(不推进也不清断),倍率按现有环数照常吃。
state.chain.graceUsed += 1;
emit({ t: 'chainGrace', element: card.element, links: state.chain.links });
} else {
if (state.chain.seq.length > 0) emit({ t: 'chainBreak', element: card.element });
state.chain.seq = [card.element];
@ -246,10 +287,11 @@ export function createBattleCore(deps, opts = {}) {
const mult = computeChainMult(state.chain.links, zhoutian);
emit({ t: 'cardPlayed', cardId, element: card.element, cost, links: state.chain.links, mult, zhoutian });
// 成链加成(沧浪引:成链得能量;不吃倍率——倍率只作用于伤害与护甲,防经济环,见设计书 §6.3 护栏思路)。
// 成链加成(沧浪引得能量/苍麟爪抽牌;资源类不吃倍率——防经济环,设计书 §6.3 护栏思路)。
if (card.effects.chainBonus && extendsChain) {
const bonus = card.effects.chainBonus;
if (bonus.energy) { state.energy += bonus.energy; emit({ t: 'energyGain', n: bonus.energy, source: cardId }); }
if (bonus.draw) drawN(bonus.draw);
}
// 数值结算(伤害/护甲吃倍率;抽牌/能量/力不吃——资源类过倍率会开无限连口子)。
@ -264,8 +306,11 @@ export function createBattleCore(deps, opts = {}) {
emit({ t: 'damage', target: 'enemy', amount, dealt, seg: s + 1, of: times, cardId, mult });
}
}
if (fx.block) {
const gained = Math.floor(fx.block.n * mult);
if (fx.block || fx.blockPerChainLink) {
// 昆仑坠玉:环数入基甲再吃倍率(与归墟潮 dmgPerChainLink 同构)。
const base = (fx.block ? fx.block.n : 0)
+ (fx.blockPerChainLink ? fx.blockPerChainLink.n * state.chain.links : 0);
const gained = Math.floor(base * mult);
state.hero.block += gained;
emit({ t: 'blockGain', target: 'hero', n: gained, cardId, mult });
}
@ -291,15 +336,22 @@ export function createBattleCore(deps, opts = {}) {
emit({ t: 'discardHand' });
}
// 敌行动:执行的就是回合内一直公开的那份意图(诚实铁律;事件带 fromIntent 供仿真断言逐字段比对)。
const intent = currentIntent();
// 敌行动:执行的就是回合开始钉住并一直公开的那份意图(诚实铁律;事件带 fromIntent 供仿真逐字段比对)。
const intent = pinnedIntent;
state.enemy.block = 0; // 敌甲于其行动前清零(回合甲语义)
if (intent.kind === 'attack') {
const dealt = dealToHero(intent.n);
emit({ t: 'enemyAction', kind: 'attack', n: intent.n, dealt, name: intent.name, fromIntent: intent });
// 多段攻击(times 缺省 1):逐段先甲后血,dealt=各段实际掉血合计。
let dealt = 0;
for (let s = 0; s < (intent.times || 1); s++) dealt += dealToHero(intent.n);
emit({ t: 'enemyAction', kind: 'attack', n: intent.n, times: intent.times || 1, dealt, name: intent.name, fromIntent: intent });
} else if (intent.kind === 'block') {
state.enemy.block += intent.n;
emit({ t: 'enemyAction', kind: 'block', n: intent.n, name: intent.name, fromIntent: intent });
} else if (intent.kind === 'attackBlock') {
// 甲+攻复合意图(赤犼类):先获甲再单段攻击,两值都在展示里。
state.enemy.block += intent.block;
const dealt = dealToHero(intent.n);
emit({ t: 'enemyAction', kind: 'attackBlock', n: intent.n, block: intent.block, dealt, name: intent.name, fromIntent: intent });
} else {
state.enemy.strength += intent.strength;
emit({ t: 'enemyAction', kind: 'buff', strength: intent.strength, name: intent.name, fromIntent: intent });
@ -311,11 +363,11 @@ export function createBattleCore(deps, opts = {}) {
state.enemy.intentIndex += 1;
state.turn += 1;
state.energy = energyMax;
state.hero.block = 0; // 玩家甲回合开始清零(回合甲语义)
state.chain = { seq: [], links: 0, zhoutianUsed: false }; // 链是回合内轴(§6.1)
if (!relicHooks.blockPersist) state.hero.block = 0; // 应龙鳞:护甲跨回合保留;否则回合开始清零
state.chain = { seq: [], links: 0, zhoutianUsed: false, graceUsed: 0 }; // 链是回合内轴(§6.1)
state.actionsThisTurn = 0;
drawN(drawPerTurn);
emit({ t: 'turnStart', turn: state.turn, intent: currentIntent() });
emit({ t: 'turnStart', turn: state.turn, intent: pinIntent() });
return { ok: true };
},
@ -331,11 +383,30 @@ export function createBattleCore(deps, opts = {}) {
enemy: {
id: state.enemy.id, name: state.enemy.name, hpMax: state.enemy.hpMax,
hp: state.enemy.hp, block: state.enemy.block, strength: state.enemy.strength,
intent: terminal ? null : currentIntent(),
tier: state.enemy.tier, phase2: state.enemy.phase2,
intent: terminal ? null : { ...pinnedIntent }, // 钉住的意图(展示=执行,诚实铁律)
},
hand: state.hand.slice(),
// M1 增列:手牌语义卡面(card driver 决策消费;dmg/block 为不含倍率的粗当量)。
handCards: state.hand.map((id) => {
const c = CARD_BY_ID[id];
return {
id, name: c.name, cost: c.cost, element: c.element, rarity: c.rarity, type: c.type,
dmg: c.effects.dmg ? c.effects.dmg.n * (c.effects.dmg.times || 1) : 0,
block: c.effects.block ? c.effects.block.n : 0,
strength: c.effects.strength ? c.effects.strength.n : 0,
};
}),
counts: { hand: state.hand.length, draw: state.drawPile.length, discard: state.discardPile.length },
chain: { seq: state.chain.seq.slice(), links: state.chain.links, zhoutianUsed: state.chain.zhoutianUsed },
chain: {
seq: state.chain.seq.slice(), links: state.chain.links,
zhoutianUsed: state.chain.zhoutianUsed, graceUsed: state.chain.graceUsed,
// 延链元素(相生环下一位;空链 null——任意元素都开新链):driver/渲染直接消费,免第二份环表。
nextElement: state.chain.seq.length > 0
? ELEMENT_CYCLE[(ELEMENT_CYCLE.indexOf(state.chain.seq[state.chain.seq.length - 1]) + 1) % ELEMENT_CYCLE.length]
: null,
cycle: ELEMENT_CYCLE.slice(), // 相生环序(driver 择链起手用;单源导出,免驱动侧第二份环表)
},
actionsThisTurn: state.actionsThisTurn,
invalid: state.invalid,
};

View File

@ -1,24 +1,24 @@
/**
* src/data/tables.js —— 《山海巡异录》M0 数据表(S2:首批 20 卡 + 1 敌 + 曲线常量)
* owner:北极星顶级线③(M0 · S2)
* src/data/tables.js —— 《山海巡异录》数据表(M0 S2 首批 20 卡+1 敌 → M1 30 卡/8 敌/3 遗物/地图与经济曲线)
* owner:北极星顶级线③(M1)
*
* 【表驱动纪律】(设计书 §6.1)代码零魔法数:卡牌/敌人/链曲线/回合常量全在本表;
* 改数值只动本文件,battle-core 与渲染层不含任何数值字面量。
* 表 schema 带版本字段(§8.3);M1 起随构建外置为 JSON,M0 以 JS 模块承载(对齐 mini-fei-e
* fixture 的 src/data/tables.js 先例,role=config)。
* 【表驱动纪律】(设计书 §6.1)代码零魔法数:卡牌/敌人/遗物/链曲线/地图/经济常量全在本表;
* 改数值只动本文件,battle-core / run-core / 渲染层不含任何数值字面量。
* 表 schema 带版本字段(§8.3);M1 为增列式扩展(M0 消费方零破坏,版本保持 1;
* 首个破坏性变更或 M2 存档落地时再升并配迁移)。
*
* 【数值锚】(设计书 §6.2,全部〔草案·首轮仿真回填后锁〕)
* 1 费=7 点单体伤;1 费=4 甲;抽 1 卡=0.5 费当量;得 1 能量=1.0 费当量;力 1 点≈0.5 费当量(M0 假设)。
* 1 费=7 点单体伤;1 费=4 甲;抽 1 卡=0.5 费当量;得 1 能量=1.0 费当量;力 1 点≈0.5 费当量。
* 估值=效果当量合计÷费用(0 费卡按 0.5 费计):普通≈1.0、罕见≤1.15、稀有≤1.3;条件卡按达成率折价。
* 每张卡的估值随表注释,便于静态自检(advisory,S12 接入数值 CI)。
*
* 【clean-room】卡名/敌名/文案全原创(山海巡异使世界观);结构常数(能量 3/抽 5/手上限 10/回合末弃手)
* 为品类通约保留项(设计书 §3.1 例外清单,创始人已批)。
* 【clean-room】卡名/敌名/遗物名/文案全原创(山海巡异使世界观,异兽取公有领域山海经意象、造型表达原创);
* 结构常数(能量 3/抽 5/手上限 10/回合末弃手)为品类通约保留项(设计书 §3.1 例外清单,创始人已批)。
*/
'use strict';
/** 表 schema 版本(存档/仿真兼容锚)。 */
/** 表 schema 版本(存档/仿真兼容锚;M1 增列式扩展不升版,见文件头)。 */
export const TABLE_SCHEMA_VERSION = 1;
/** 五行相生环(链的唯一事实源):木→火→土→金→水→木。 */
@ -82,20 +82,52 @@ export const CARDS = [
{ id: 'shui-runxia', name: '润下诀', element: '水', rarity: '普通', cost: 1, type: '术', effects: { draw: { n: 1 }, block: { n: 2 } }, text: '抽 1 张牌,获得 2 点护甲。' }, // 1.0
{ id: 'shui-canglang', name: '沧浪引', element: '水', rarity: '罕见', cost: 1, type: '术', effects: { draw: { n: 2 }, chainBonus: { energy: 1 } }, text: '抽 2 张牌;若成链,得 1 点能量。' }, // (1.0+1.0×0.15达成折)÷1≈1.15
{ id: 'shui-guixu', name: '归墟潮', element: '水', rarity: '稀有', cost: 2, type: '攻', effects: { dmg: { n: 9, times: 1 }, dmgPerChainLink: { n: 3 } }, text: '造成 9 点伤害,灵脉每环追加 3 点。' }, // (9/7+链达成折)÷2≈1.15–1.3
// ══ M1 扩池 +10(30 卡;effects 新词只加两个:chainBonus{draw} 成链抽牌 / blockPerChainLink 环数护甲)══
// ── 木(续)──
{ id: 'mu-canglin', name: '苍麟爪', element: '木', rarity: '罕见', cost: 1, type: '攻', effects: { dmg: { n: 5, times: 1 }, chainBonus: { draw: 1 } }, text: '造成 5 点伤害;若成链,抽 1 张牌。' }, // (5/7+0.5×成链折0.5)÷1≈1.07
{ id: 'mu-jiamu', name: '嘉木盈庭', element: '木', rarity: '稀有', cost: 2, type: '术', effects: { draw: { n: 2 }, energy: { n: 1 }, block: { n: 2 } }, text: '抽 2 张牌,得 1 点能量与 2 点护甲。' }, // (1.0+1.0+0.5)÷2=1.25
// ── 火(续)──
{ id: 'huo-bifang', name: '毕方衔火', element: '火', rarity: '普通', cost: 0, type: '术', effects: { draw: { n: 1 } }, text: '独脚火鹤衔枝而至,抽 1 张牌。' }, // 0.5÷0.5=1.0(0 费链润滑)
{ id: 'huo-fenglin', name: '焚林奔焰', element: '火', rarity: '罕见', cost: 2, type: '攻', effects: { dmg: { n: 4, times: 2 }, dmgPerChainLink: { n: 2 } }, text: '造成 4 点伤害 2 段,灵脉每环各段追加 2 点。' }, // (8/7+链折)÷2≈1.0–1.15
// ── 土(续)──
{ id: 'tu-kunlun', name: '昆仑坠玉', element: '土', rarity: '罕见', cost: 2, type: '御', effects: { block: { n: 6 }, blockPerChainLink: { n: 1 } }, text: '获得 6 点护甲,灵脉每环追加 1 点。' }, // ((6+环)/4)÷2≈0.75–1.13 条件
{ id: 'tu-houde', name: '厚德载物', element: '土', rarity: '稀有', cost: 3, type: '御', effects: { block: { n: 9 }, strength: { n: 2 }, draw: { n: 1 } }, text: '获得 9 点护甲、2 点力,抽 1 张牌。' }, // (2.25+1.0+0.5)÷3=1.25
// ── 金(续)──
{ id: 'jin-baize', name: '白泽鉴', element: '金', rarity: '罕见', cost: 1, type: '术', effects: { draw: { n: 1 }, strength: { n: 1 }, costChainDiscount: { n: 1 } }, text: '抽 1 张牌,获得 1 点力;若延续灵脉,费用 -1。' }, // (0.5+0.5)÷1+条件≈1.1
{ id: 'jin-tianwu', name: '天吴八首', element: '金', rarity: '稀有', cost: 3, type: '攻', effects: { dmg: { n: 6, times: 4 } }, text: '八首齐击,造成 6 点伤害,共 4 段。' }, // 24/7÷3≈1.14(多段×倍率联动位)
// ── 水(续)──
{ id: 'shui-tianchi', name: '天池映月', element: '水', rarity: '普通', cost: 2, type: '攻', effects: { dmg: { n: 6, times: 1 }, block: { n: 4 } }, text: '造成 6 点伤害,获得 4 点护甲。' }, // (6/7+1.0)÷2≈0.93
{ id: 'shui-xuangui', name: '玄龟负图', element: '水', rarity: '罕见', cost: 1, type: '御', effects: { block: { n: 4 }, costChainDiscount: { n: 1 } }, text: '获得 4 点护甲;若延续灵脉,费用 -1。' }, // 4/4÷1+条件≈1.1
];
/** M0 教学战卡组:20 卡各 1 份(§11 M0 交付「首批 20 卡」)。 */
export const STARTER_DECK = CARDS.map((c) => c.id);
/** 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 不足,回填提攻击密度〔草案〕)。 */
export const RUN_STARTER_DECK = [
'mu-qingteng', 'mu-cangyin', 'mu-shengfa',
'huo-lihuo',
'tu-houtu', 'tu-xirang',
'jin-jinfeng', 'jin-gengjin', 'jin-ruijin',
'shui-xuanshui',
];
/**
* 首战敌人(S2:1 敌)——「涸泽鲛」,教学局固定首战(幕 1 普通档上沿 HP,战斗 5–8 回合)。
* 意图循环(intents,按序循环;敌意图诚实铁律 §3.2:展示什么执行什么):
* attack{n}=对玩家伤害(吃敌方力) / block{n}=获得护甲 / buff{strength}=获得力。
* 敌表(M1 S7:8 敌=普通 3+精英 2+Boss 3;意图组扩展)。
* 意图 kind(敌意图诚实铁律 §3.2:回合开始钉住展示,执行即所示):
* attack{n,times?}=对玩家伤害(吃敌方力;times 段,缺省 1)/ block{n}=获得护甲 /
* buff{strength}=获得力 / attackBlock{n,block}=先获甲再攻击(展示两值)。
* 增列字段:tier(normal|elite|boss)/ acts(可出现的幕;普通与精英按幕缩放,Boss 定幕不缩放)/
* phase2At+intents2(Boss 相位:HP 比例 ≤ 阈值后,后续回合改用第二意图组;当回合已钉意图不变——诚实铁律)。
* 数值锚:幕 1 普通 HP 18–52、精英 ~100、Boss 战设计 6–9 回合分胜负(§6.2,全部〔草案〕)。
*/
export const ENEMIES = [
{
id: 'kuze-jiao',
name: '涸泽鲛',
tier: 'normal',
acts: [1],
hpMax: 46,
intents: [
{ kind: 'attack', n: 8, name: '啸浪' },
@ -104,4 +136,171 @@ export const ENEMIES = [
{ kind: 'buff', strength: 2, name: '磨鳞砺齿' },
],
},
{
id: 'qingqiu-hu',
name: '青丘幻狐',
tier: 'normal',
acts: [1, 2],
hpMax: 38,
intents: [
{ kind: 'attack', n: 6, name: '魅影爪' },
{ kind: 'buff', strength: 2, name: '聚狐火' },
{ kind: 'attack', n: 9, name: '扑袭' },
{ kind: 'block', n: 5, name: '幻遁' },
],
},
{
id: 'ba-she',
name: '巴蛇',
tier: 'normal',
acts: [2, 3],
hpMax: 52,
intents: [
{ kind: 'attack', n: 5, times: 2, name: '缠绞' },
{ kind: 'block', n: 8, name: '蛇蜕' },
{ kind: 'attack', n: 13, name: '吞象口' },
{ kind: 'buff', strength: 2, name: '腥雾' },
],
},
{
id: 'chi-hou',
name: '赤犼',
tier: 'elite',
acts: [1, 2],
hpMax: 85, // 首版 96:精英战 t8 敌剩 3–34 的近失陷阱,回填收口〔草案〕
intents: [
{ kind: 'attack', n: 14, name: '裂空啸' },
{ kind: 'attackBlock', n: 8, block: 6, name: '鬃振双爪' },
{ kind: 'buff', strength: 3, name: '怒炎' },
{ kind: 'attack', n: 7, times: 2, name: '连扑' },
],
},
{
id: 'jiuwei-linghu',
name: '九尾灵狐',
tier: 'elite',
acts: [2, 3],
hpMax: 100, // 与赤犼同轮回填〔草案〕
intents: [
{ kind: 'buff', strength: 2, name: '尾焰九转' },
{ kind: 'attack', n: 11, name: '狐火灼' },
{ kind: 'attackBlock', n: 9, block: 6, name: '曳尾扫' },
{ kind: 'attack', n: 7, times: 2, name: '双尾击' },
],
},
{
id: 'xiang-liu',
name: '相柳',
tier: 'boss',
acts: [1],
hpMax: 130,
intents: [
// 多段吃力:每点力=+3 伤/轮啮,增益 1 已是最陡的斜率位;hp/甲按贪心 driver 回填压进
// 6–9 回合带(首版 155/12 打到 11–17 回合=「磨」,§6.2 曲线失败口径)。
{ kind: 'attack', n: 4, times: 3, name: '九首轮啮' },
{ kind: 'block', n: 8, name: '盘山之躯' },
{ kind: 'attack', n: 15, name: '浊浪吐息' },
{ kind: 'buff', strength: 1, name: '蛇怒' },
],
},
{
id: 'zhu-yin',
name: '烛阴',
tier: 'boss',
acts: [2],
hpMax: 150, // 首版 210 超 M1 玩家可及 dps(6–9 回合带),回填 210→165→150(近失局 t10–14 敌剩 17–34 收口)〔草案〕
phase2At: 0.5,
intents: [
{ kind: 'attack', n: 12, name: '启明炬' },
{ kind: 'block', n: 14, name: '暝幕' },
{ kind: 'attack', n: 9, times: 2, name: '晷刻双击' },
],
intents2: [
{ kind: 'attack', n: 16, name: '烛九阴' },
{ kind: 'buff', strength: 3, name: '夜张' },
{ kind: 'attack', n: 11, times: 2, name: '晦冥爪' },
],
},
{
id: 'tao-tie',
name: '饕餮',
tier: 'boss',
acts: [3],
hpMax: 195, // 首版 280 同上回填〔草案〕;增势循环本身即时间压力
intents: [
{ kind: 'attack', n: 13, name: '暴食撕咬' },
{ kind: 'buff', strength: 3, name: '饕沴增势' },
{ kind: 'attack', n: 8, times: 2, name: '双颚合击' },
{ kind: 'block', n: 16, name: '鳞壁' },
],
},
];
/**
* 遗物表(M1 首批 3;§3.2 规则修改器铁律:效果必须规则级,纯数值加成占比 ≤30%——本批 3/3 规则级)。
* hook 词汇表(battle-core / run-core 消费):
* firstTurnDraw{n} 每场战斗首回合额外抽 n 张(玉衡符)
* chainGracePerTurn{n} 每回合前 n 次破序不断链(打出的牌不入链、不清链;归墟镜)
* blockPersist 护甲不再于回合开始清零(应龙鳞)
*/
export const RELICS = [
{ id: 'yuheng-fu', name: '玉衡符', hook: { firstTurnDraw: 2 }, text: '每场战斗首回合,额外抽 2 张牌。' },
{ id: 'guixu-jing', name: '归墟镜', hook: { chainGracePerTurn: 1 }, text: '每回合第一次破序不断灵脉(该牌不入链)。' },
{ id: 'yinglong-lin', name: '应龙鳞', hook: { blockPersist: true }, text: '你的护甲不再在回合开始时消散。' },
];
/**
* 地图与一局曲线(M1 S6;结构与数值全部〔草案·首轮仿真回填后锁〕)。
* 结构:三幕,每幕 7 层(0 起战、5 憩、6 王);中段层宽 2–3、每节点连向下一层 1–2 个近邻;
* 每幕配额:精英 ≥1(2–4 层)、宝匣恰 1(3 层)、其余战斗。事件/商店节点随 M2+(S11/S16),
* 本表 nodeKinds 预留全集、M1 只产 battle/elite/rest/treasure/boss。
* 缩放:普通/精英 HP ×actHpScale^(幕-1)、攻击与护甲意图值 ×actAtkScale^(幕-1)(Boss 定幕直调不缩放)。
*/
export const MAP = {
acts: 3, // 三幕
floorsPerAct: 7, // 每幕层数(0..6)
midFloorWidth: [2, 3], // 中段层宽区间(1–4 层)
eliteFloors: [2, 3, 4], // 精英可出现层
treasureFloor: 3, // 宝匣固定层(该层随机一节点替换)
restFloor: 5, // 憩点固定层(单节点)
bossFloor: 6, // Boss 层(单节点)
// 幕缩放(逐幕表,普通/精英吃、Boss 定幕直调不吃):书 §6.2 锚「幕间 ×2.2」以玩家引擎
// 幕2末 ×4–5 为前提(职业全量卡池/升级面=M2 S9+)。M1 玩家成长面只有加卡+3 遗物,
// 首轮仿真回填(贪心 driver ×20)三轮迭代:2.2^n → 1.8^n → 1.4^n 均团灭,幂次复利是主凶,
// 改逐幕表消复利;M2 升级面就位后随 S12 调参轮回评向书锚回摆。全部〔草案〕。
actHpScaleByAct: [1, 1.25, 1.45],
actAtkScaleByAct: [1, 1.1, 1.2],
nodeKinds: ['battle', 'elite', 'rest', 'treasure', 'boss', 'event', 'shop'], // event/shop=M2+ 预留
};
/**
* 一局经济与奖励(M1 版;金币 M1 只挣不花——商店/删卡经济=M2 S11,本表先当账本口径)。
*/
export const ECONOMY = {
goldReward: { normal: [18, 26], elite: [42, 55], boss: [70, 90] }, // 战斗金币带(闭区间随机)
restHealPct: 0.5, // 憩点回复(hpMax 比例,向下取整;M1 无药水/商店,憩点独担恢复面故取 0.5,M2 S11 商店落地后回评〔草案〕)
bossHealPct: 0.5, // 幕间整备:战胜 Boss 进下一幕前回复(谱系常见变体做法;0.35 仍残血进幕,回填 0.5〔草案〕)
winHealFlat: 6, // 战胜回复(职业甲基线「凝血诀」;谱系先例=尖塔起始遗物型战后回复——M1 无药水/商店,战后小回复是品类的标准攻防内循环件〔草案〕)
cardChoices: 3, // 战斗奖励三选一(可跳过,§3.2)
rarityWeights: { 普通: 60, 罕见: 30, 稀有: 10 }, // 普通/精英战奖励稀有度权重
bossRarityWeights: { 普通: 15, 罕见: 50, 稀有: 35 }, // Boss 战奖励权重
eliteRelicDrop: true, // 精英必掉遗物(§3.2 获取源分层;遗物池空则折金币)
relicPoolGoldFallback: 40, // 遗物池取尽后宝匣/精英折算金币
};
/**
* 演出时长估算表(首局门 60s 的 headless 代理口径:按事件类型累计估算毫秒;
* 真浏览器计时归 CDP 窗口批,本表值对齐 run-scene 错峰节拍与 §7.2 演出上限,〔草案〕)。
*/
export const PRESENTATION = {
perEventMs: 110, // 一般事件错峰节拍(与场景回放节拍同源)
zhoutianMs: 700, // 周天演出
turnStartMs: 220, // 回合切换
battleEndMs: 900, // 终局遮罩入场
};
/** 首局门(60s 首战闭环)headless 判据(§11 M1 验收;浏览器真跑归窗口批)。 */
export const FIRST_BATTLE = {
maxTurns: 9, // 教学首战回合上限(超=「磨」,曲线失败口径 §6.2)
maxPresentationMs: 60000, // 事件演出估算总时长上限(60s 首战闭环的机检代理)
};

View File

@ -1,87 +1,107 @@
/**
* src/main.js —— 《山海巡异录》入口(A3 entry · PhaserGameFactory 默认导出)
* owner:北极星顶级线③(M0)
* owner:北极星顶级线③(M1;host 集成修复版)
*
* 【装载契约】(对齐 tier2 平台 mini-fei-e 先例)
* esbuild(build-phaser.mjs)打成 iife 挂全局 __Tier2GameBundle;宿主(boot-phaser-host.js)取
* default 工厂建 PhaserGameInstance:boot(ctx) / readState() / injectInput(ev) / destroy()。
* Phaser 经 external+垫片解析为宿主预载的 window.Phaser(引擎不进 bundle,§8.1/§9.3 账 A 口径)。
* 【装载契约】(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)。
*
* 【M0 范围】单场教学战(battle-core + battle-scene);地图/局外系统随 M1+。
* 重开一局 = 重建 battle-core(seed 自增,受控随机纪律不破)。
* 【红线 · M0 事故复盘】M0 版曾在 boot 里自建第二个 Phaser.Game 实例且未挂宿主 parent →
* 页面出现游离 canvas 渲到视口外、宿主可见画布全黑、真指针打不到游戏(mini-desktop CDP 取证坐实)。
* 本版修法:**工厂永不自建引擎实例**(全文件零 Phaser import),场景一律挂 ctx.game;
* run-smoke.mjs S0 静态自查断言钉住此红线防回潮。
*
* 【输入单一路径】injectInput('tap') 与场景真指针都走 route(x,y) → readState().tapTargets
* 命中序(布局层唯一坐标事实)→ run-core 语义动作。driver/CDP/真人三路同因果。
*/
'use strict';
import Phaser from 'phaser';
import { createBattleCore } from './battle-core.js';
import { createRunCore } from './run-core.js';
import { createSeededRandom } from './util/seeded-random.js';
import { createBattleScene } from './scenes/battle-scene.js';
import { hitTarget } from './ui/layout.js';
import { createRunScene } from './scenes/run-scene.js';
const SCENE_KEY = 'shanhai-run';
/**
* PhaserGameFactory:宿主调它建 PhaserGameInstance。
* @param {Record<string, unknown>} [factoryOpts] 工厂选项(M0 未用)
* PhaserGameFactory:宿主调它建 PhaserGameInstance(boot/readState/injectInput/destroy)。
* @param {Record<string, unknown>} [factoryOpts] 工厂选项(M1 未用)
*/
export default function createGame(factoryOpts) {
/** @type {ReturnType<typeof createBattleCore>|null} */
let core = null;
/** @type {any} Phaser.Game 实例 */
let game = null;
/** @type {ReturnType<typeof createRunCore>|null} */
let run = null;
/** @type {any} 宿主 Phaser.Game(real 通道;headless 为 null)。 */
let hostGame = null;
let seed = 12345;
let tapRoute = null; // injectInput('tap') → 场景命中逻辑(driver 通道,M1 card driver 消费)
/** 建/重建一场战斗核(seed 派生受控随机;重开局 seed+1 保持确定性谱系)。 */
function newBattle() {
core = createBattleCore({ random: createSeededRandom(seed >>> 0) });
/** 建/重建一局(seed 派生受控随机;重开局 seed+1 保持确定性谱系)。 */
function newRun() {
run = createRunCore({ random: createSeededRandom(seed >>> 0) }, { seed: seed >>> 0 });
}
function restart() {
seed = (seed + 1) >>> 0;
newRun();
}
/**
* 语义路由:坐标 → tapTargets 命中(数组序=优先级)→ run-core 动作。
* 真指针(场景 pointerdown)与 injectInput(driver/CDP)共用,输入因果单源。
*/
function route(x, y) {
if (!run) return;
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 === '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 === 'overlay') restart();
}
return {
/**
* 一次性启动:接宿主 boot 上下文(parent/seed/viewport),建核 + 起 Phaser(canvas 渲染,§8.1 锁 canvas)。
* @param {{ parent?: HTMLElement, seed?: number, viewport?: {w:number,h:number} }} ctx
* 一次性启动:接宿主 boot 上下文。real 通道把场景挂进宿主 Phaser.Game(装载契约红线:
* 不自建实例、不游离挂载);headless(ctx.game=null)只建逻辑核。
* @param {{ game?: any, canvas?: HTMLCanvasElement|null, seed?: number }} ctx
*/
async boot(ctx) {
seed = (ctx && ctx.seed != null ? ctx.seed : 12345) >>> 0;
newBattle();
const vw = (ctx && ctx.viewport && ctx.viewport.w) || 390;
const vh = (ctx && ctx.viewport && ctx.viewport.h) || 844;
const scene = createBattleScene({
core: () => core,
restart: () => { seed = (seed + 1) >>> 0; newBattle(); },
bindTap: (fn) => { tapRoute = fn; },
});
game = new Phaser.Game({
type: Phaser.CANVAS, // 渲染器锁 canvas(九门 CDP 像素取证兼容,§8.1)
parent: (ctx && ctx.parent) || undefined,
width: vw,
height: vh,
backgroundColor: '#141420',
scene,
banner: false,
});
newRun();
hostGame = (ctx && ctx.game) || null;
if (!hostGame) return true; // headless/node 通道(宿主契约:不真渲染)
// 场景挂宿主实例并自启(宿主 HostScene 常驻管 latch 轮询,本场景只做游戏渲染与输入)。
hostGame.scene.add(SCENE_KEY, createRunScene({ run: () => run, route }), true);
return true;
},
/** 语义状态(宿主逐帧轮询 phase 做 latch 门;直通 battle-core)。 */
/** 语义状态(宿主逐帧轮询 phase 做 latch 门;直通 run-core,含 tapTargets)。 */
readState() {
return core ? core.readState() : { phase: 'booting' };
return run ? run.readState() : { phase: 'booting' };
},
/**
* 归一化输入注入(driver 通道):{type:'tap',x,y} 走场景命中逻辑;其余类型 M1 随 card driver 定义。
* 归一化输入注入(driver/CDP 通道):{type:'tap',x,y} 走与真指针相同的语义路由。
* @param {{type:string,x?:number,y?:number}} ev
*/
injectInput(ev) {
if (ev && ev.type === 'tap' && tapRoute) tapRoute(ev.x || 0, ev.y || 0);
if (ev && ev.type === 'tap') route(ev.x || 0, ev.y || 0);
},
/** 卸载卫生。 */
/** 卸载卫生:只摘掉自己挂的场景,宿主 Phaser.Game 归宿主销毁(host.destroy 负责)。 */
destroy() {
try { if (game) game.destroy(true); } catch (e) { /* 卸载失败不外抛,宿主统一收尸 */ }
game = null;
core = null;
try {
if (hostGame && hostGame.scene && hostGame.scene.getScene(SCENE_KEY)) {
hostGame.scene.remove(SCENE_KEY);
}
} catch (e) { /* 卸载失败不外抛,宿主统一收尸 */ }
hostGame = null;
run = null;
},
};
}

View File

@ -0,0 +1,157 @@
/**
* src/map-core.js —— 《山海巡异录》地图核(M1 S6:三幕节点 DAG 生成与推进;纯逻辑层)
* owner:北极星顶级线③(M1)
*
* 【纯逻辑铁律】零 DOM/引擎 import;随机经受控源注入;同 seed 同图(headless 仿真与重放地基,§8.3)。
*
* 【结构】(表 MAP 驱动,〔草案〕)每幕 7 层:0=起战(单节点)、1–4=中段(宽 2–3,战斗/精英混布)、
* 5=憩点(单)、6=Boss(单)。宝匣恰 1(3 层随机替换一位);精英每幕 ≥1(2–4 层)。
* 连边:相邻层近邻连接(每节点 → 下层 1–2 个、下层每节点 ≥1 入边)→ 天然全连通
* (validateMap 仍独立校验:全节点自起点可达 + 全节点可达 Boss,静态可达门口径)。
*
* 【M1 边界】事件/商店节点是表 nodeKinds 的预留位,本档只产 battle/elite/rest/treasure/boss
* (S11 商店、S16 事件随 M2/M3 加型,加型=改本文件生成配额+表权重,结构不动)。
*/
'use strict';
import { MAP } from './data/tables.js';
/**
* 生成一幕地图(节点 DAG)。
* @param {{next:()=>number,range:(a:number,b:number)=>number}} rng 受控随机源
* @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) {
const nodes = [];
const edges = [];
const floors = [];
const nodeId = (floor, idx) => `a${act}-f${floor}-n${idx}`;
// ── 逐层定宽与节点 ──
for (let f = 0; f < MAP.floorsPerAct; f++) {
let width;
if (f === 0 || f === MAP.restFloor || f === MAP.bossFloor) width = 1;
else width = rng.range(MAP.midFloorWidth[0], MAP.midFloorWidth[1]);
const row = [];
for (let i = 0; i < width; i++) {
let type = 'battle';
if (f === MAP.restFloor) type = 'rest';
else if (f === MAP.bossFloor) type = 'boss';
row.push({ id: nodeId(f, i), floor: f, idx: i, type });
}
floors.push(row);
nodes.push(...row);
}
// ── 配额:精英每幕 ≥1(2–4 层随机一位);宝匣恰 1(3 层随机一位,且不与精英同节点)──
const eliteFloor = MAP.eliteFloors[rng.range(0, MAP.eliteFloors.length - 1)];
const eliteRow = floors[eliteFloor];
const eliteIdx = rng.range(0, eliteRow.length - 1);
eliteRow[eliteIdx].type = 'elite';
const tRow = floors[MAP.treasureFloor];
let tIdx = rng.range(0, tRow.length - 1);
if (eliteFloor === MAP.treasureFloor && tIdx === eliteIdx) tIdx = (tIdx + 1) % tRow.length; // 撞位让开(该层宽≥2 才可能同层)
if (tRow.length === 1 && eliteFloor === MAP.treasureFloor) {
// 极端:3 层宽 1 且精英也落 3 层 → 精英改层(保证宝匣恰 1、精英 ≥1 两配额同时成立)。
tRow[0].type = 'treasure';
const alt = MAP.eliteFloors.find((x) => x !== MAP.treasureFloor);
const altRow = floors[alt];
altRow[rng.range(0, altRow.length - 1)].type = 'elite';
} else {
tRow[tIdx].type = 'treasure';
}
// ── 中途憩点保底 1(1–4 层随机一个仍为 battle 的节点改 rest):M1 无事件/商店/药水,
// 5 层憩点独木难支——首轮仿真回填坐实每幕 40–55 血/战的净流出必须有中途回血口;
// M2 商店(S11)/事件(S16)落地后随调参轮回评本保底是否收回〔草案〕。──
{
const cand = [];
for (let f = 1; f <= 4; f++) {
floors[f].forEach((nd) => { if (nd.type === 'battle') cand.push(nd); });
}
if (cand.length > 0) cand[rng.range(0, cand.length - 1)].type = 'rest';
}
// ── 连边(近邻,保证双向覆盖:上层每节点 ≥1 出边、下层每节点 ≥1 入边)──
for (let f = 0; f < MAP.floorsPerAct - 1; f++) {
const cur = floors[f];
const nxt = floors[f + 1];
const covered = new Set(); // 下层已获入边的 idx
cur.forEach((nd, i) => {
// 近邻映射:按比例落位,再按随机决定连 1 或 2 个(第二条连相邻位)。
const base = Math.min(nxt.length - 1, Math.round((i * (nxt.length - 1)) / Math.max(1, cur.length - 1)) || 0);
edges.push([nd.id, nxt[base].id]);
covered.add(base);
if (nxt.length > 1 && rng.next() < 0.55) {
const alt = base + 1 < nxt.length ? base + 1 : base - 1;
edges.push([nd.id, nxt[alt].id]);
covered.add(alt);
}
});
// 下层缺入边的节点 → 从上层最近节点补一条(全连通兜底)。
nxt.forEach((nd, j) => {
if (covered.has(j)) return;
const from = cur[Math.min(cur.length - 1, Math.round((j * (cur.length - 1)) / Math.max(1, nxt.length - 1)) || 0)];
edges.push([from.id, nd.id]);
});
}
return { nodes, edges };
}
/**
* 静态可达校验(测试与 run-core 建图后自检):
* ①全节点自起点(0 层)可达;②全节点可达 Boss;③宝匣恰 1、精英 ≥1、憩 ≥1、Boss 恰 1。
* @returns {{ok:boolean, problems:string[]}}
*/
export function validateActMap(map) {
const problems = [];
const out = new Map();
const inn = new Map();
for (const [a, b] of map.edges) {
if (!out.has(a)) out.set(a, []);
out.get(a).push(b);
if (!inn.has(b)) inn.set(b, []);
inn.get(b).push(a);
}
const byId = new Map(map.nodes.map((n) => [n.id, n]));
const start = map.nodes.find((n) => n.floor === 0);
const boss = map.nodes.find((n) => n.type === 'boss');
// ①正向 BFS。
const seen = new Set([start.id]);
const queue = [start.id];
while (queue.length) {
for (const nx of out.get(queue.shift()) || []) {
if (!seen.has(nx)) { seen.add(nx); queue.push(nx); }
}
}
for (const n of map.nodes) if (!seen.has(n.id)) problems.push(`自起点不可达:${n.id}`);
// ②反向 BFS(自 Boss)。
const rseen = new Set([boss.id]);
const rq = [boss.id];
while (rq.length) {
for (const pv of inn.get(rq.shift()) || []) {
if (!rseen.has(pv)) { rseen.add(pv); rq.push(pv); }
}
}
for (const n of map.nodes) if (!rseen.has(n.id)) problems.push(`不可达 Boss:${n.id}`);
// ③配额。
const count = (t) => map.nodes.filter((n) => n.type === t).length;
if (count('boss') !== 1) problems.push(`Boss 恰 1 失败:${count('boss')}`);
if (count('treasure') !== 1) problems.push(`宝匣恰 1 失败:${count('treasure')}`);
if (count('elite') < 1) problems.push('精英 ≥1 失败');
if (count('rest') < 1) problems.push('憩点 ≥1 失败');
// 边合法性:只许相邻层向上。
for (const [a, b] of map.edges) {
const na = byId.get(a); const nb = byId.get(b);
if (!na || !nb || nb.floor !== na.floor + 1) problems.push(`非法边:${a}→${b}`);
}
return { ok: problems.length === 0, problems };
}

View File

@ -0,0 +1,414 @@
/**
* src/run-core.js —— 《山海巡异录》一局核(M1:地图推进 × 战斗桥接 × 奖励与遗物 × 跨战斗资源;纯逻辑层)
* owner:北极星顶级线③(M1)
*
* 【这份是什么】设计书 §8.3 四纯逻辑模块的第二块(map-core 已单列,economy/meta 随 M2/M3):
* 把 battle-core(单场战斗)串成「一局」——三幕地图节点流、HP 跨战斗携带(§6.1 长线资源)、
* 金币账本(M1 只挣不花,商店=M2 S11)、战斗奖励三选一加卡(§3.2)、遗物获取与规则钩子聚合、
* run 级 latch(win=幕 3 Boss 落地 / lose=任意战斗败 / invalid=护栏触发,计回归失败)。
*
* 【纯逻辑铁律】零 DOM/引擎 import;随机全经注入源;同 seed+同动作序 → 状态逐字段一致。
*
* 【拒绝路径零副作用】所有动作错屏/终局后一律 {ok:false} 且状态不变(对齐 battle-core 纪律)。
*
* 【事件流】run 级事件(actStart/nodeEntered/battleStart/battleEnd/rewardOffered/cardAdded/
* relicGained/goldGain/heal/runEnd)与 battle-core 事件合流经 takeEvents() 给渲染层回放;
* 仿真侧读同一事件流做断言(首局门演出估算等)。
*/
'use strict';
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,
} from './data/tables.js';
import { computeTapTargets } from './ui/layout.js';
const CARD_BY_ID = Object.fromEntries(CARDS.map((c) => [c.id, c]));
const RELIC_BY_ID = Object.fromEntries(RELICS.map((r) => [r.id, r]));
/**
* 创建一局核。
* @param {Object} deps
* @param {{next:()=>number,range:(a:number,b:number)=>number}} deps.random 受控随机源(必传)
* @param {Object} [opts]
* @param {number} [opts.seed] 展示/取证用种子标注(随机性本身由 deps.random 承载)
* @param {string[]} [opts.deck] 起始卡组(缺省表 RUN_STARTER_DECK)
* @param {Array<Object>} [opts.actMaps] 测试通道:逐幕注入手工地图(仍过 validateActMap;仅单测/仿真)
* @param {Object} [opts.battleOpts] 测试通道:透传 battle-core opts(fixedDraw/actionLimit 等;
* run 管理键 enemy/deck/heroHp/heroHpMax/relicHooks 不可被覆盖;仅单测/仿真)
* @returns {RunCore}
*/
export function createRunCore(deps, opts = {}) {
const rng = deps && deps.random;
if (!rng || typeof rng.next !== 'function') {
throw new Error('[run-core] 必须注入受控随机源 random(next/range)'); // 错误路径留痕
}
// ── 一局状态(单一状态树)──
const state = {
schemaVersion: TABLE_SCHEMA_VERSION,
phase: 'run', // run → win|lose|invalid(latch)
screen: 'map', // map | battle | reward
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: /** @type {string[]} */ ([]),
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 },
seed: opts.seed != null ? opts.seed : null,
};
for (const id of state.deck) {
if (!CARD_BY_ID[id]) throw new Error(`[run-core] 起始卡组含未知卡:${id}`);
}
/** 建当前幕地图(测试通道优先注入;生成/注入一律过静态可达自检,失败响亮抛)。 */
function buildActMap(act) {
const m = (opts.actMaps && opts.actMaps[act - 1]) || generateActMap(rng, act);
const v = validateActMap(m);
if (!v.ok) throw new Error(`[run-core] 幕 ${act} 地图自检失败:${v.problems.join(';')}`);
return m;
}
/** 当前幕地图(进幕时生成;三幕结构见 map-core)。 */
let map = buildActMap(state.act);
/** 当前战斗核(battle 屏时非空)。 */
let battle = null;
/** 当前战斗的节点类型(奖励与统计用)。 */
let battleNodeType = null;
/** 奖励屏数据(reward 屏时非空):{options:[cardId×3], canSkip, after:'map'|'nextAct'} */
let reward = null;
/** run 级事件队列(battle 事件在 sync 时合流;takeEvents 取走清空)。 */
let events = [];
const emit = (e) => { events.push(e); };
let terminal = false;
// ── 内部:图查询 ──────────────────────────────────────────────────────────────
const nodeById = () => new Map(map.nodes.map((n) => [n.id, n]));
const outEdges = (id) => map.edges.filter(([a]) => a === id).map(([, b]) => b);
/** 当前可选节点(map 屏语义):幕起点=0 层全部;否则=当前节点出边。 */
function selectableNodes() {
if (state.screen !== 'map' || terminal) return [];
if (state.position == null) return map.nodes.filter((n) => n.floor === 0).map((n) => n.id);
return outEdges(state.position);
}
// ── 内部:遗物 ────────────────────────────────────────────────────────────────
/** 聚合已持有遗物的规则钩子(battle-core opts.relicHooks 消费)。 */
function relicHooks() {
const hooks = {};
for (const id of state.relics) {
const h = RELIC_BY_ID[id].hook;
if (h.firstTurnDraw) hooks.firstTurnDraw = (hooks.firstTurnDraw || 0) + h.firstTurnDraw;
if (h.chainGracePerTurn) hooks.chainGracePerTurn = (hooks.chainGracePerTurn || 0) + h.chainGracePerTurn;
if (h.blockPersist) hooks.blockPersist = true;
}
return hooks;
}
/** 发下一枚未持有遗物(池序;池空按表折金币)。 */
function grantRelic(source) {
const next = RELICS.find((r) => !state.relics.includes(r.id));
if (next) {
state.relics.push(next.id);
state.stats.relicsGained += 1;
emit({ t: 'relicGained', relicId: next.id, name: next.name, source });
} else {
state.gold += ECONOMY.relicPoolGoldFallback;
emit({ t: 'goldGain', n: ECONOMY.relicPoolGoldFallback, source: `${source}-relic-fallback` });
}
}
// ── 内部:敌与缩放 ────────────────────────────────────────────────────────────
/** 幕缩放(普通/精英;Boss 定幕直调不缩放)——逐幕表 MAP.act*ScaleByAct(消幂次复利,回填纪律见表注)。 */
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];
const scaleIntent = (it) => {
const out = { ...it };
if (it.kind === 'attack' || it.kind === 'attackBlock') out.n = Math.round(it.n * atkK);
if (it.kind === 'block') out.n = Math.round(it.n * atkK);
if (it.kind === 'attackBlock') out.block = Math.round(it.block * atkK);
return out;
};
return {
...def,
hpMax: Math.round(def.hpMax * hpK),
intents: def.intents.map(scaleIntent),
intents2: def.intents2 ? def.intents2.map(scaleIntent) : undefined,
};
}
/** 按节点选敌:0 层幕 1=教学固定首战(表首敌);普通/精英按幕池受控随机;Boss 定幕唯一。 */
function pickEnemy(node) {
if (node.type === 'boss') {
const boss = ENEMIES.find((e) => e.tier === 'boss' && e.acts.includes(state.act));
if (!boss) throw new Error(`[run-core] 幕 ${state.act} 无 Boss 定义`); // 表一致性,错误路径留痕
return boss;
}
if (node.type === 'elite') {
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)
const pool = ENEMIES.filter((e) => e.tier === 'normal' && e.acts.includes(state.act));
return scaleEnemy(pool[rng.range(0, pool.length - 1)], state.act);
}
function startBattle(node) {
const enemyDef = pickEnemy(node);
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() },
);
battleNodeType = node.type;
state.screen = 'battle';
state.ui.selected = -1;
emit({ t: 'battleStart', enemyId: enemyDef.id, enemyName: enemyDef.name, tier: enemyDef.tier, act: state.act, floor: node.floor });
syncBattle(); // 合流开局事件(turnStart/draw)
}
// ── 内部:奖励 ────────────────────────────────────────────────────────────────
/** 战斗奖励三选一选卡(稀有度权重表驱动;三张互异)。 */
function pickRewardOptions(tier) {
const weights = tier === 'boss' ? ECONOMY.bossRarityWeights : ECONOMY.rarityWeights;
const rarities = Object.keys(weights);
const total = rarities.reduce((s, r) => s + weights[r], 0);
const options = [];
let guard = 0;
while (options.length < ECONOMY.cardChoices && 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));
if (pool.length === 0) continue;
options.push(pool[rng.range(0, pool.length - 1)].id);
}
return options;
}
/** 战斗胜利收口:回写 HP(+职业基线战胜回复)→ 统计 → 金币 → 精英必掉遗物 → 奖励屏或收官。 */
function settleBattleWin() {
const bs = battle.readState();
state.hero.hp = bs.hero.hp;
// 职业甲基线「凝血诀」:战胜回复(表 winHealFlat;M1 攻防内循环件,谱系起始遗物型先例)。
const wh = Math.min(ECONOMY.winHealFlat, 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;
if (battleNodeType === 'elite') state.stats.elitesWon += 1;
if (battleNodeType === 'boss') state.stats.bossesWon += 1;
const band = ECONOMY.goldReward[battleNodeType === 'elite' ? 'elite' : battleNodeType === 'boss' ? 'boss' : 'normal'];
const gold = rng.range(band[0], band[1]);
state.gold += gold;
emit({ t: 'battleEnd', result: 'win', tier: battleNodeType, act: state.act });
emit({ t: 'goldGain', n: gold, source: 'battle' });
if (battleNodeType === 'elite' && ECONOMY.eliteRelicDrop) grantRelic('elite'); // 精英必掉(§3.2 获取源分层)
if (battleNodeType === 'boss' && state.act >= MAP.acts) {
// 幕 3 Boss 落地 → 一局通关(run 级 win latch)。
battle = null;
state.screen = 'battle'; // 终局遮罩盖在战斗屏上(渲染语义);tapTargets 只剩 overlay
state.phase = 'win';
terminal = true;
emit({ t: 'runEnd', result: 'win', act: state.act, stats: { ...state.stats } });
return;
}
// 奖励三选一(Boss 用高稀有权重;选完回图或进下一幕)。
reward = {
options: pickRewardOptions(battleNodeType),
canSkip: true,
after: battleNodeType === 'boss' ? 'nextAct' : 'map',
};
battle = null;
state.screen = 'reward';
emit({ t: 'rewardOffered', options: reward.options.slice(), tier: battleNodeType });
}
/** 进下一幕:幕间整备恢复(表 bossHealPct)→ 重生成地图、位置归零。 */
function nextAct() {
const heal = Math.min(Math.floor(state.hero.hpMax * ECONOMY.bossHealPct), state.hero.hpMax - state.hero.hp);
if (heal > 0) {
state.hero.hp += heal;
emit({ t: 'heal', n: heal, source: 'boss-rest' });
}
state.act += 1;
map = buildActMap(state.act);
state.position = null;
state.screen = 'map';
emit({ t: 'actStart', act: state.act });
}
/** 战斗事件合流 + 终态桥接(battle latch → run 级结算/latch)。 */
function syncBattle() {
if (!battle) return;
for (const e of battle.takeEvents()) events.push(e);
const phase = battle.readState().phase;
if (phase === 'win') settleBattleWin();
else if (phase === 'lose') {
state.hero.hp = 0;
state.stats.turnsTotal += battle.readState().turn;
battle = null;
state.phase = 'lose';
terminal = true;
emit({ t: 'battleEnd', result: 'lose', tier: battleNodeType, act: state.act });
emit({ t: 'runEnd', result: 'lose', act: state.act, stats: { ...state.stats } });
} else if (phase === 'invalid') {
// 护栏触发:本局无效(计回归失败,§6.3 运行时兜底层)——run 级同判。
battle = null;
state.phase = 'invalid';
terminal = true;
emit({ t: 'runEnd', result: 'invalid', act: state.act, stats: { ...state.stats } });
}
}
// ── 对外 API ────────────────────────────────────────────────────────────────
const api = {
/**
* 选地图节点(map 屏)。非可选节点/错屏/终局 → {ok:false} 零副作用。
*/
chooseNode(nodeId) {
if (terminal || state.screen !== 'map') return { ok: false, reason: 'wrong-screen' };
if (!selectableNodes().includes(nodeId)) return { ok: false, reason: 'not-selectable' };
const node = nodeById().get(nodeId);
state.position = nodeId;
state.visited.push(nodeId);
state.stats.nodesVisited += 1;
emit({ t: 'nodeEntered', nodeId, nodeType: node.type, act: state.act, floor: node.floor });
if (node.type === 'battle' || node.type === 'elite' || node.type === 'boss') {
startBattle(node);
} else if (node.type === 'rest') {
const heal = Math.min(Math.floor(state.hero.hpMax * ECONOMY.restHealPct), state.hero.hpMax - state.hero.hp);
state.hero.hp += heal;
emit({ t: 'heal', n: heal, source: 'rest' });
} else if (node.type === 'treasure') {
grantRelic('treasure');
}
return { ok: true };
},
/** 点手牌(battle 屏;两段式:选中 → 再点同卡打出)。 */
tapCard(handIndex) {
if (terminal || state.screen !== 'battle' || !battle) return { ok: false, reason: 'wrong-screen' };
if (state.ui.selected === handIndex) {
state.ui.selected = -1;
const r = battle.play(handIndex);
if (r.ok) state.stats.playsTotal += 1;
syncBattle();
return r;
}
if (battle.readState().hand[handIndex] == null) return { ok: false, reason: 'bad-index' };
state.ui.selected = handIndex;
return { ok: true, selected: handIndex };
},
/** 打出当前选中卡(点敌区确认;无选中 → no-op)。 */
confirmPlay() {
if (terminal || state.screen !== 'battle' || !battle) return { ok: false, reason: 'wrong-screen' };
const idx = state.ui.selected;
if (idx < 0) return { ok: false, reason: 'no-selection' };
state.ui.selected = -1;
const r = battle.play(idx);
if (r.ok) state.stats.playsTotal += 1;
syncBattle();
return r;
},
/** 结束回合(battle 屏)。 */
endTurn() {
if (terminal || state.screen !== 'battle' || !battle) return { ok: false, reason: 'wrong-screen' };
state.ui.selected = -1;
const r = battle.endTurn();
syncBattle();
return r;
},
/** 取消选中(点手牌区空白;误触防护)。 */
clearSelection() {
if (state.screen !== 'battle') return { ok: false, reason: 'wrong-screen' };
state.ui.selected = -1;
return { ok: true };
},
/**
* 奖励三选一(reward 屏):index=0..2 加卡;'skip' 跳过(§3.2 可跳过)。
*/
chooseReward(choice) {
if (terminal || state.screen !== 'reward' || !reward) return { ok: false, reason: 'wrong-screen' };
if (choice !== 'skip') {
const cardId = reward.options[choice];
if (cardId == null) return { ok: false, reason: 'bad-index' };
state.deck.push(cardId);
state.stats.cardsAdded += 1;
emit({ t: 'cardAdded', cardId });
}
const after = reward.after;
reward = null;
if (after === 'nextAct') nextAct();
else state.screen = 'map';
return { ok: true };
},
/** 语义状态只读投影(readState 契约;宿主逐帧轮询 phase 做 latch 门,driver 读 tapTargets 打点)。 */
readState() {
const widthByFloor = {};
for (const nd of map.nodes) widthByFloor[nd.floor] = (widthByFloor[nd.floor] || 0) + 1;
const projection = {
schemaVersion: state.schemaVersion,
phase: state.phase,
screen: state.screen,
act: state.act,
acts: MAP.acts,
gold: state.gold,
hero: { ...state.hero },
deckCount: state.deck.length,
deck: state.deck.slice(),
relics: state.relics.map((id) => ({ id, name: RELIC_BY_ID[id].name })),
map: {
nodes: map.nodes.map((n) => ({ ...n, visited: state.visited.includes(n.id), width: widthByFloor[n.floor] })),
edges: map.edges.map((e) => e.slice()),
position: state.position,
selectable: selectableNodes(),
},
battle: battle ? battle.readState() : null,
reward: reward ? { options: reward.options.map((id) => ({ id, ...CARD_BY_ID[id] })), canSkip: reward.canSkip } : null,
ui: { selected: state.ui.selected },
stats: { ...state.stats },
seed: state.seed,
};
projection.tapTargets = computeTapTargets(projection);
return projection;
},
/** 取走事件队列(渲染回放/仿真断言;取走即清)。含合流的战斗事件。 */
takeEvents() {
if (battle) for (const e of battle.takeEvents()) events.push(e); // 兜底合流(渲染逐帧调时战斗事件即时可见)
const out = events;
events = [];
return out;
},
/** 取证视图(harness/仿真只读)。 */
_forensicsView() {
return {
state: () => api.readState(),
cardById: (id) => CARD_BY_ID[id] || null,
relicById: (id) => RELIC_BY_ID[id] || null,
validateMap: () => validateActMap(map),
tables: { schemaVersion: TABLE_SCHEMA_VERSION, cards: CARDS.length, enemies: ENEMIES.length, relics: RELICS.length },
};
},
};
return api;
}

View File

@ -1,287 +0,0 @@
/**
* src/scenes/battle-scene.js —— 《山海巡异录》战斗场景(S3 手牌交互 + S4 结算演出 v0)
* owner:北极星顶级线③(M0)
*
* 【这份是什么】渲染层唯一场景:按设计书 §7.1 竖屏三段布局(敌区 38%/中段 14% 链轨/手牌区 48%)
* 把 battle-core 的状态画出来、把触控转成 core 命令、把事件流回放成结算演出 v0。
* 演出与逻辑解耦(§7.2):本文件只消费 readState() 与 takeEvents(),不含任何规则数值。
*
* 【M0 交互简化】(书 §7.2 全量手感表是 M4 验收,此处先做可玩闭环)
* 点卡=选中(浮起 1.15×/70ms);再点该卡或点敌区=打出(攻击自动指向唯一敌);点空白=取消;
* END 按钮=结束回合。拖拽出牌线/误触防护随 S3 后续与 M4 打磨。
*
* 【M0 视觉】零素材(程序绘制:圆角矩形卡框+五行色条+符文圆敌身),插画位留空待风格版(§9.1)。
*/
'use strict';
import { ELEMENT_CYCLE, ELEMENT_COLORS } from '../data/tables.js';
// 竖屏三段布局常量(§7.1;基准 390×844,按实际 viewport 等比)。
const LAYOUT = { enemyZone: 0.38, midZone: 0.14 };
/**
* 建战斗场景(Phaser scene 配置对象;`this` = Phaser.Scene)。
* @param {Object} api 主入口注入面
* @param {() => import('../battle-core.js')} api.core 取当前战斗核(重开局后引用会换)
* @param {() => void} api.restart 重开一局(终局遮罩点击)
* @param {(fn: (x:number,y:number)=>void) => void} [api.bindTap] 注册 injectInput 的 tap 路由(driver 通道)
*/
export function createBattleScene(api) {
// 场景闭包状态(纯表现;规则状态一律以 core.readState() 为准)。
let W = 390; let H = 844;
let cardNodes = []; // 手牌容器 [{container, hit, index}]
let selected = -1; // 选中的手牌位(-1 无)
let hud = {}; // 常驻文本/图形句柄
let lastSig = ''; // 状态签名(变了才重建手牌行)
let overlayShown = false;
/** 手牌签名(重建判据)。 */
const sigOf = (s) => JSON.stringify([s.hand, s.energy, s.phase, s.turn, s.chain.seq, s.hero.hp, s.enemy.hp, s.hero.block, s.enemy.block, s.hero.strength, s.enemy.strength]);
return {
key: 'battle',
create() {
const scene = this;
W = scene.scale.width; H = scene.scale.height;
scene.cameras.main.setBackgroundColor('#141420');
const eZoneH = H * LAYOUT.enemyZone;
const midY = eZoneH + H * LAYOUT.midZone / 2;
// ── 敌区(38%):名牌/血条/意图徽章/程序绘敌身 ──
hud.enemyName = scene.add.text(W / 2, 34, '', { fontSize: '20px', color: '#e8e2d0', fontFamily: 'sans-serif' }).setOrigin(0.5);
hud.enemyBody = scene.add.graphics();
hud.enemyHpBarBg = scene.add.rectangle(W / 2, eZoneH - 58, 240, 12, 0x333344).setOrigin(0.5);
hud.enemyHpBar = scene.add.rectangle(W / 2 - 120, eZoneH - 58, 240, 12, 0xd9534f).setOrigin(0, 0.5);
hud.enemyHpText = scene.add.text(W / 2, eZoneH - 40, '', { fontSize: '13px', color: '#c9c3b2', fontFamily: 'sans-serif' }).setOrigin(0.5);
hud.intent = scene.add.text(W / 2, 66, '', { fontSize: '16px', color: '#f0c674', fontFamily: 'sans-serif' }).setOrigin(0.5);
// 敌区点击面(打出选中卡的替代靶;M0 单敌自动指向)。
scene.add.rectangle(W / 2, eZoneH / 2, W, eZoneH, 0xffffff, 0.001).setInteractive()
.on('pointerdown', () => { if (selected >= 0) playSelected(scene); });
// ── 中段(14%):我方状态 + 能量珠 + 链轨 + END ──
hud.heroText = scene.add.text(12, midY - 26, '', { fontSize: '14px', color: '#bfe3bf', fontFamily: 'sans-serif' });
hud.energyOrb = scene.add.circle(34, midY + 16, 20, 0x2d5f8a).setStrokeStyle(2, 0x7fb2e5);
hud.energyText = scene.add.text(34, midY + 16, '', { fontSize: '15px', color: '#ffffff', fontFamily: 'sans-serif' }).setOrigin(0.5);
// 链轨:五行五槽(§7.1 一屏五要素之一)。
hud.chainSlots = ELEMENT_CYCLE.map((el, i) => {
const x = W / 2 - 70 + i * 35;
const c = scene.add.circle(x, midY + 14, 12, 0x2a2a38).setStrokeStyle(2, ELEMENT_COLORS[el]);
scene.add.text(x, midY + 14, el, { fontSize: '12px', color: '#9a94a8', fontFamily: 'sans-serif' }).setOrigin(0.5);
return c;
});
hud.multText = scene.add.text(W / 2 + 118, midY + 14, '', { fontSize: '16px', color: '#f0c674', fontFamily: 'sans-serif' }).setOrigin(0.5);
hud.turnText = scene.add.text(W / 2, midY - 26, '', { fontSize: '13px', color: '#8a8498', fontFamily: 'sans-serif' }).setOrigin(0.5);
// END 按钮。
const endBtn = scene.add.rectangle(W - 46, midY + 14, 72, 34, 0x5a3f2e).setStrokeStyle(2, 0xc98f3b).setInteractive();
scene.add.text(W - 46, midY + 14, '结束', { fontSize: '15px', color: '#f5e9d5', fontFamily: 'sans-serif' }).setOrigin(0.5);
endBtn.on('pointerdown', () => { selected = -1; api.core().endTurn(); });
// 空白处点击=取消选中(误触防护 v0)。
scene.add.rectangle(W / 2, H - (H * (1 - LAYOUT.enemyZone - LAYOUT.midZone)) / 2, W, H * (1 - LAYOUT.enemyZone - LAYOUT.midZone), 0xffffff, 0.001)
.setInteractive().on('pointerdown', (p) => {
// 命中卡牌时卡牌自身的 handler 先响;这里只处理点到手牌区空白。
if (!hitAnyCard(p.x, p.y)) { selected = -1; refreshHand(scene, true); }
});
// driver 通道:injectInput({type:'tap',x,y}) 经 main.js 路由到这里(M1 card driver 用)。
if (api.bindTap) {
api.bindTap((x, y) => {
scene.input.manager.activePointer.x = x; // 仅记录;直接走命中逻辑
const idx = cardIndexAt(x, y);
if (idx >= 0) tapCard(scene, idx);
else if (y < eZoneH && selected >= 0) playSelected(scene);
else if (Math.abs(x - (W - 46)) < 36 && Math.abs(y - (midY + 14)) < 17) { selected = -1; api.core().endTurn(); }
else { selected = -1; refreshHand(scene, true); }
});
}
},
update() {
const scene = this;
const core = api.core();
const s = core.readState();
// 事件流 → 演出 v0(逐个错峰回放,§7.2 事件流回放思路)。
const evs = core.takeEvents();
evs.forEach((e, i) => scene.time.delayedCall(i * 110, () => playFx(scene, e, s)));
// 常驻 HUD 同步(五要素:能量/手牌费用/敌意图/双方 HP/链轨)。
const sig = sigOf(s);
if (sig !== lastSig) {
lastSig = sig;
hud.enemyName.setText(`${s.enemy.name}${s.enemy.strength > 0 ? ` 力+${s.enemy.strength}` : ''}${s.enemy.block > 0 ? ` 甲${s.enemy.block}` : ''}`);
hud.enemyHpBar.width = 240 * (s.enemy.hp / s.enemy.hpMax);
hud.enemyHpText.setText(`${s.enemy.hp} / ${s.enemy.hpMax}`);
hud.intent.setText(s.enemy.intent ? intentLabel(s.enemy.intent) : '');
hud.heroText.setText(`巡异使 ${s.hero.hp}/${s.hero.hpMax}${s.hero.block > 0 ? ` 甲${s.hero.block}` : ''}${s.hero.strength > 0 ? ` 力+${s.hero.strength}` : ''}`);
hud.energyText.setText(`${s.energy}/${s.energyMax}`);
hud.turnText.setText(`第 ${s.turn} 回合`);
hud.multText.setText(s.chain.links > 0 || s.chain.seq.length > 0 ? `×${(1 + s.chain.links * 0.25).toFixed(2)}` : '');
// 链轨点亮:当前链已覆盖的元素亮、其余暗。
const litSet = new Set(s.chain.seq);
hud.chainSlots.forEach((slot, i) => {
const el = ELEMENT_CYCLE[i];
slot.fillColor = litSet.has(el) ? ELEMENT_COLORS[el] : 0x2a2a38;
});
drawEnemyBody(scene, s);
refreshHand(scene, false);
// 终局遮罩(latch 后一次性)。
if ((s.phase === 'win' || s.phase === 'lose' || s.phase === 'invalid') && !overlayShown) {
overlayShown = true;
showOverlay(scene, s.phase);
}
}
},
};
// ── 内部:手牌 ────────────────────────────────────────────────────────────────
/** 命中检测:坐标落在哪张卡(自上而下)。 */
function cardIndexAt(x, y) {
for (let i = cardNodes.length - 1; i >= 0; i--) {
const n = cardNodes[i];
const b = n.hitRect;
if (x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h) return n.index;
}
return -1;
}
function hitAnyCard(x, y) { return cardIndexAt(x, y) >= 0; }
/** 点卡:未选中→选中浮起;已选中同卡→打出。 */
function tapCard(scene, idx) {
if (selected === idx) { playSelected(scene); return; }
selected = idx;
refreshHand(scene, true);
}
function playSelected(scene) {
const idx = selected;
selected = -1;
if (idx >= 0) api.core().play(idx);
}
/** 重建手牌行(M0 简化:全量重绘;≤10 张无性能压力)。 */
function refreshHand(scene, force) {
const core = api.core();
const s = core.readState();
for (const n of cardNodes) n.container.destroy();
cardNodes = [];
const handTop = H * (LAYOUT.enemyZone + LAYOUT.midZone);
const cw = 96; const ch = 140;
const n = s.hand.length;
const span = Math.min(W - cw - 16, Math.max(1, n - 1) * 72);
const x0 = (W - span) / 2;
s.hand.forEach((cardId, i) => {
const card = core._forensicsView().cardById(cardId);
const cx = n === 1 ? W / 2 : x0 + (span * i) / Math.max(1, n - 1);
const lift = selected === i ? 26 : 0;
const cy = handTop + 96 + (selected === i ? -lift : Math.abs(i - (n - 1) / 2) * 6);
const cont = scene.add.container(cx, cy);
const g = scene.add.graphics();
const affordable = card.cost <= s.energy;
g.fillStyle(affordable ? 0x241f33 : 0x1a1724, 1);
g.fillRoundedRect(-cw / 2, -ch / 2, cw, ch, 10);
g.lineStyle(2, selected === i ? 0xf0c674 : ELEMENT_COLORS[card.element], 1);
g.strokeRoundedRect(-cw / 2, -ch / 2, cw, ch, 10);
g.fillStyle(ELEMENT_COLORS[card.element], 1);
g.fillRoundedRect(-cw / 2, -ch / 2, cw, 20, { tl: 10, tr: 10, bl: 0, br: 0 });
cont.add(g);
cont.add(scene.add.text(0, -ch / 2 + 10, `${card.element} · ${card.name}`, { fontSize: '12px', color: '#141420', fontFamily: 'sans-serif' }).setOrigin(0.5));
// 费用珠(左上)。
cont.add(scene.add.circle(-cw / 2 + 4, -ch / 2 + 4, 12, 0x2d5f8a).setStrokeStyle(2, 0x7fb2e5));
cont.add(scene.add.text(-cw / 2 + 4, -ch / 2 + 4, String(card.cost), { fontSize: '13px', color: '#fff', fontFamily: 'sans-serif' }).setOrigin(0.5));
// 插画位(留空框,待风格版 §9.1)。
const ph = scene.add.graphics();
ph.lineStyle(1, 0x4a4460, 1); ph.strokeRect(-cw / 2 + 10, -ch / 2 + 28, cw - 20, 52);
cont.add(ph);
cont.add(scene.add.text(0, ch / 2 - 34, card.text, { fontSize: '10px', color: '#c9c3b2', fontFamily: 'sans-serif', wordWrap: { width: cw - 14 }, align: 'center' }).setOrigin(0.5, 0.5));
if (selected === i) {
cont.setScale(1.15);
if (force) scene.tweens.add({ targets: cont, scale: { from: 1.0, to: 1.15 }, duration: 70 }); // ≤80ms 浮起(§7.2 手1 v0)
}
cardNodes.push({ container: cont, index: i, hitRect: { x: cx - cw / 2, y: cy - ch / 2, w: cw, h: ch } });
});
}
// ── 内部:演出 v0 ────────────────────────────────────────────────────────────
function intentLabel(it) {
if (it.kind === 'attack') return `意图:${it.name} · 攻 ${it.n}`;
if (it.kind === 'block') return `意图:${it.name} · 甲 ${it.n}`;
return `意图:${it.name} · 力 +${it.strength}`;
}
/** 程序绘敌身(原创符文水泽异兽;受击由 playFx 做位移)。 */
function drawEnemyBody(scene, s) {
const g = hud.enemyBody;
g.clear();
const cx = W / 2; const cy = H * LAYOUT.enemyZone / 2 + 14;
const alive = s.enemy.hp > 0;
g.fillStyle(alive ? 0x2e6e78 : 0x3a3a44, 1);
// 鲛形:菱形身 + 双鳍(纯几何原创造型,占位待风格版立绘)。
g.fillTriangle(cx, cy - 52, cx - 44, cy + 26, cx + 44, cy + 26);
g.fillTriangle(cx - 40, cy + 6, cx - 78, cy + 34, cx - 30, cy + 34);
g.fillTriangle(cx + 40, cy + 6, cx + 78, cy + 34, cx + 30, cy + 34);
g.fillStyle(0xf0c674, 1);
g.fillCircle(cx - 12, cy - 12, 4); g.fillCircle(cx + 12, cy - 12, 4); // 目
if (s.enemy.block > 0) { g.lineStyle(3, 0x7fb2e5, 0.9); g.strokeCircle(cx, cy, 64); } // 甲环
}
/** 事件 → 演出 v0。 */
function playFx(scene, e, s) {
const eCy = H * LAYOUT.enemyZone / 2 + 14;
const heroY = H * (LAYOUT.enemyZone + LAYOUT.midZone / 2);
if (e.t === 'damage') {
floatText(scene, W / 2 + (e.seg - 1) * 22 - 10, eCy - 40, `-${e.dealt}${e.mult > 1 ? ` ×${e.mult.toFixed(2)}` : ''}`, '#ff6b5e', 18 + Math.min(10, e.dealt / 3));
scene.tweens.add({ targets: hud.enemyBody, x: { from: 6, to: 0 }, duration: 70, yoyo: true }); // 受击位移
} else if (e.t === 'blockGain') {
floatText(scene, 90, heroY, `甲 +${e.n}`, '#7fb2e5', 15);
} else if (e.t === 'strengthGain' && e.target === 'hero') {
floatText(scene, 90, heroY - 18, `力 +${e.n}`, '#e2a13e', 15);
} else if (e.t === 'chainLink') {
floatText(scene, W / 2, heroY + 6, `灵脉 ${e.links} 环`, '#9fe08a', 14);
} else if (e.t === 'chainBreak') {
floatText(scene, W / 2, heroY + 6, '脉断', '#8a8498', 13);
} else if (e.t === 'zhoutian') {
// 周天:全屏金闪 + 大字(§5.1 结算爽感高潮;音高递升随 M4 音频)。
const flash = scene.add.rectangle(W / 2, H / 2, W, H, 0xf0c674, 0.28).setDepth(50);
scene.tweens.add({ targets: flash, alpha: 0, duration: 320, onComplete: () => flash.destroy() });
const t = scene.add.text(W / 2, H * 0.44, '周 天', { fontSize: '46px', color: '#f0c674', fontFamily: 'sans-serif' }).setOrigin(0.5).setDepth(51);
scene.tweens.add({ targets: t, scale: { from: 0.6, to: 1.25 }, alpha: { from: 1, to: 0 }, duration: 700, onComplete: () => t.destroy() });
} else if (e.t === 'enemyAction' && e.kind === 'attack') {
scene.cameras.main.shake(90, 0.004); // 屏震 ≤4px/90ms 量级(§7.2 手4 v0)
floatText(scene, W / 2, heroY - 8, `${e.name} -${e.dealt}`, '#ff6b5e', 17);
} else if (e.t === 'enemyAction' && e.kind === 'block') {
floatText(scene, W / 2, eCy + 40, `${e.name} 甲+${e.n}`, '#7fb2e5', 14);
} else if (e.t === 'enemyAction' && e.kind === 'buff') {
floatText(scene, W / 2, eCy + 40, `${e.name} 力+${e.strength}`, '#e2a13e', 14);
} else if (e.t === 'shuffle') {
floatText(scene, W - 60, H * (LAYOUT.enemyZone + LAYOUT.midZone) + 40, '洗牌', '#8a8498', 12);
} else if (e.t === 'turnStart') {
floatText(scene, W / 2, H * 0.5, `第 ${e.turn} 回合`, '#c9c3b2', 16);
}
}
/** 漂字(上浮渐隐)。 */
function floatText(scene, x, y, str, color, size) {
const t = scene.add.text(x, y, str, { fontSize: `${Math.round(size)}px`, color, fontFamily: 'sans-serif' }).setOrigin(0.5).setDepth(40);
scene.tweens.add({ targets: t, y: y - 34, alpha: { from: 1, to: 0 }, duration: 620, ease: 'Cubic.Out', onComplete: () => t.destroy() });
}
/** 终局遮罩(win/lose/invalid;点按重开一局)。 */
function showOverlay(scene, phase) {
const veil = scene.add.rectangle(W / 2, H / 2, W, H, 0x0a0a12, 0.72).setDepth(60).setInteractive();
const title = phase === 'win' ? '收服成功' : phase === 'lose' ? '巡异失利' : '本局无效';
const color = phase === 'win' ? '#9fe08a' : phase === 'lose' ? '#ff6b5e' : '#f0c674';
scene.add.text(W / 2, H * 0.42, title, { fontSize: '36px', color, fontFamily: 'sans-serif' }).setOrigin(0.5).setDepth(61);
scene.add.text(W / 2, H * 0.50, '点按任意处 · 再战一局', { fontSize: '15px', color: '#c9c3b2', fontFamily: 'sans-serif' }).setOrigin(0.5).setDepth(61);
veil.on('pointerdown', () => {
overlayShown = false; lastSig = ''; selected = -1;
api.restart();
scene.scene.restart();
});
}
}

View File

@ -0,0 +1,309 @@
/**
* src/scenes/run-scene.js —— 《山海巡异录》一局场景(M1 S5:地图/战斗/奖励/终局四视图 + 链轨完整化)
* owner:北极星顶级线③(M1;吸收并取代 M0 battle-scene.js,后者随本单退役、M0 版见 git)
*
* 【渲染层铁律】只消费 run-core 的 readState()+takeEvents(),不含任何规则数值(§7.2 演出与逻辑解耦)。
*
* 【输入单一路径】(host 集成修复的一半)真人指针 = scene 级 pointerdown → api.route(x,y);
* driver/CDP = injectInput('tap') → 同一 route。route 内部按 readState().tapTargets 命中序判定
* (布局层唯一坐标事实)——不给单个对象挂 setInteractive,避免双派发;真指针点卡可选中、再点打出,
* M0「手牌区盖板吃掉点击」的缺陷随之消除。
*
* 【M1 视觉】仍零素材程序绘制(卡框/节点圆/符文敌身;插画位留空待风格版 §9.1);
* 状态签名变更整屏重建(M1 规模 ≤~30 对象,无性能压力;增量重绘随 M4 打磨)。
*/
'use strict';
import { ELEMENT_CYCLE, ELEMENT_COLORS } 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: '王' };
const NODE_COLOR = { battle: 0x3f7fbf, elite: 0xc98f3b, rest: 0x3fa34d, treasure: 0xf0c674, boss: 0xd9534f };
/**
* 建一局场景(Phaser scene 配置对象;`this`=Phaser.Scene)。
* @param {Object} api
* @param {() => import('../run-core.js')} api.run 取当前一局核(重开局后引用会换)
* @param {(x:number,y:number)=>void} api.route 语义路由(与 injectInput 同一命中路径)
*/
export function createRunScene(api) {
let uiNodes = []; // 本次重建创建的显示对象(签名变更时整批销毁)
let lastSig = '';
/** 状态签名(变了才整屏重建;演出走事件流不进签名)。 */
function sigOf(s) {
return JSON.stringify([
s.phase, s.screen, s.act, s.gold, s.hero, s.deckCount, s.relics, s.seed, s.ui.selected,
s.map.position, s.map.selectable,
s.battle ? [s.battle.hand, s.battle.energy, s.battle.turn, s.battle.chain.seq, s.battle.chain.graceUsed,
s.battle.hero, s.battle.enemy.hp, s.battle.enemy.block, s.battle.enemy.strength, s.battle.enemy.phase2] : null,
s.reward ? s.reward.options.map((o) => o.id) : null,
]);
}
return {
key: 'shanhai-run',
create() {
const scene = this;
scene.cameras.main.setBackgroundColor('#141420');
// 输入单一路径:场景级 pointerdown → 语义路由(真指针与 driver 注入同一命中序)。
scene.input.on('pointerdown', (p) => api.route(p.x, p.y));
},
update() {
const scene = this;
const run = api.run();
if (!run) return;
const s = run.readState();
// 事件流 → 演出(逐个错峰回放;节拍与 PRESENTATION.perEventMs 同源=110ms)。
const evs = run.takeEvents();
evs.forEach((e, i) => scene.time.delayedCall(i * 110, () => playFx(scene, e)));
const sig = sigOf(s);
if (sig === lastSig) return;
lastSig = sig;
rebuild(scene, s);
},
};
// ── 整屏重建 ──────────────────────────────────────────────────────────────────
function clear() {
for (const n of uiNodes) { try { n.destroy(); } catch (e) { /* 已随场景销毁则忽略 */ } }
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 rebuild(scene, s) {
clear();
if (s.phase !== 'run') { drawTerminal(scene, s); return; }
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);
}
/** 顶栏(全视图常驻):幕/金币/卡组/遗物。 */
function drawTopStrip(scene, s) {
txt(scene, 10, 8, `第${s.act}幕`, 13, '#c9c3b2', 0);
txt(scene, 70, 8, `金 ${s.gold}`, 13, '#f0c674', 0);
txt(scene, 140, 8, `牌 ${s.deckCount}`, 13, '#9fc4e8', 0);
txt(scene, 200, 8, s.relics.map((r) => r.name).join(' · ') || '—', 12, '#b9a3d0', 0);
}
// ── 地图视图 ──────────────────────────────────────────────────────────────────
function drawMap(scene, s) {
drawTopStrip(scene, s);
txt(scene, VIEW_W / 2, 40, `山海图 · 第${s.act}幕`, 20, '#e8e2d0');
txt(scene, VIEW_W / 2, 64, `巡异使 ${s.hero.hp}/${s.hero.hpMax}`, 14, '#bfe3bf');
const g = keep(scene, scene.add.graphics());
// 边(先画,压在节点下)。
g.lineStyle(2, 0x3a3a4c, 1);
const centers = new Map();
for (const nd of s.map.nodes) centers.set(nd.id, mapNodeCenter(nd.floor, nd.idx, nd.width));
for (const [a, b] of s.map.edges) {
const ca = centers.get(a); const cb = centers.get(b);
g.lineBetween(ca.x, ca.y, cb.x, cb.y);
}
// 节点。
for (const nd of s.map.nodes) {
const c = centers.get(nd.id);
const selectable = s.map.selectable.includes(nd.id);
const here = s.map.position === nd.id;
const base = keep(scene, scene.add.circle(c.x, c.y, 24, nd.visited && !here ? 0x2a2a38 : NODE_COLOR[nd.type] || 0x555566));
base.setStrokeStyle(selectable ? 3 : 1, selectable ? 0xf0c674 : 0x555566);
txt(scene, c.x, c.y, NODE_GLYPH[nd.type] || '?', 15, nd.visited && !here ? '#6a6478' : '#141420');
if (here) keep(scene, scene.add.circle(c.x, c.y, 30).setStrokeStyle(2, 0x9fe08a));
if (selectable) {
const ring = keep(scene, scene.add.circle(c.x, c.y, 27).setStrokeStyle(2, 0xf0c674));
scene.tweens.add({ targets: ring, alpha: { from: 1, to: 0.25 }, duration: 620, yoyo: true, repeat: -1 });
}
}
txt(scene, VIEW_W / 2, VIEW_H - 22, '点亮的节点 · 点按前往', 13, '#8a8498');
}
// ── 战斗视图(M0 battle-scene 语义迁入 + 链轨完整化)─────────────────────────
function drawBattle(scene, s) {
const b = s.battle;
drawTopStrip(scene, s);
const eZoneH = VIEW_H * ZONES.enemyZone;
const my = midY();
// 敌区。
const tierTag = b.enemy.tier === 'boss' ? '〔王〕' : b.enemy.tier === 'elite' ? '〔精〕' : '';
const phaseTag = b.enemy.phase2 ? ' · 相位二' : '';
txt(scene, VIEW_W / 2, 34, `${tierTag}${b.enemy.name}${b.enemy.strength > 0 ? ` 力+${b.enemy.strength}` : ''}${b.enemy.block > 0 ? ` 甲${b.enemy.block}` : ''}${phaseTag}`, 19, '#e8e2d0');
drawEnemyBody(scene, s, eZoneH);
keep(scene, scene.add.rectangle(VIEW_W / 2, eZoneH - 58, 240, 12, 0x333344).setOrigin(0.5));
keep(scene, scene.add.rectangle(VIEW_W / 2 - 120, eZoneH - 58, 240 * (b.enemy.hp / b.enemy.hpMax), 12, 0xd9534f).setOrigin(0, 0.5));
txt(scene, VIEW_W / 2, eZoneH - 40, `${b.enemy.hp} / ${b.enemy.hpMax}`, 13, '#c9c3b2');
txt(scene, VIEW_W / 2, 66, b.enemy.intent ? intentLabel(b.enemy.intent) : '', 16, '#f0c674');
// 中段:我方状态 + 能量 + 链轨(五槽+倍率常驻,§7.1 一屏五要素)+ END。
txt(scene, 12, my - 26, `巡异使 ${b.hero.hp}/${b.hero.hpMax}${b.hero.block > 0 ? ` 甲${b.hero.block}` : ''}${b.hero.strength > 0 ? ` 力+${b.hero.strength}` : ''}`, 14, '#bfe3bf', 0);
keep(scene, scene.add.circle(34, my + 16, 20, 0x2d5f8a).setStrokeStyle(2, 0x7fb2e5));
txt(scene, 34, my + 16, `${b.energy}/${b.energyMax}`, 15, '#ffffff');
const litSet = new Set(b.chain.seq);
ELEMENT_CYCLE.forEach((el, i) => {
const x = VIEW_W / 2 - 70 + i * 35;
const lit = litSet.has(el);
keep(scene, scene.add.circle(x, my + 14, 12, lit ? ELEMENT_COLORS[el] : 0x2a2a38).setStrokeStyle(2, ELEMENT_COLORS[el]));
txt(scene, x, my + 14, el, 12, lit ? '#141420' : '#9a94a8');
// 延链指引:下一相生位描亮(链轨完整化,driver 与玩家同一信息)。
if (b.chain.nextElement === el && !b.chain.zhoutianUsed) {
keep(scene, scene.add.circle(x, my + 14, 16).setStrokeStyle(2, 0x9fe08a));
}
});
txt(scene, VIEW_W / 2 + 118, my + 14, b.chain.links > 0 || b.chain.seq.length > 0 ? `×${(1 + b.chain.links * 0.25).toFixed(2)}` : '', 16, '#f0c674');
txt(scene, VIEW_W / 2, my - 26, `第 ${b.turn} 回合`, 13, '#8a8498');
const end = endTurnRect();
keep(scene, scene.add.rectangle(end.x, end.y, end.w, end.h, 0x5a3f2e).setStrokeStyle(2, 0xc98f3b));
txt(scene, end.x, end.y, '结束', 15, '#f5e9d5');
// 手牌(点选出牌两段式;selected 从 run 状态来——真指针与 driver 同源)。
const n = b.hand.length;
b.handCards.forEach((card, i) => {
const c = handCardCenter(i, n);
const selected = s.ui.selected === i;
drawCardFront(scene, c.x, c.y - (selected ? 26 : 0), card, { selected, affordable: card.cost <= b.energy, scale: selected ? 1.12 : 1.0 });
});
if (s.ui.selected >= 0) txt(scene, VIEW_W / 2, handTop() + 8, '再点该卡或点敌身 · 打出;点空白取消', 12, '#8a8498');
}
/** 程序绘敌身(按 tier 变形的原创几何造型;占位待风格版立绘)。 */
function drawEnemyBody(scene, s, eZoneH) {
const b = s.battle;
const g = keep(scene, scene.add.graphics());
const cx = VIEW_W / 2; const cy = eZoneH / 2 + 14;
const alive = b.enemy.hp > 0;
const scale = b.enemy.tier === 'boss' ? 1.5 : b.enemy.tier === 'elite' ? 1.2 : 1.0;
g.fillStyle(alive ? (b.enemy.phase2 ? 0x6e2e5f : 0x2e6e78) : 0x3a3a44, 1);
g.fillTriangle(cx, cy - 52 * scale, cx - 44 * scale, cy + 26 * scale, cx + 44 * scale, cy + 26 * scale);
g.fillTriangle(cx - 40 * scale, cy + 6, cx - 78 * scale, cy + 34, cx - 30 * scale, cy + 34);
g.fillTriangle(cx + 40 * scale, cy + 6, cx + 78 * scale, cy + 34, cx + 30 * scale, cy + 34);
g.fillStyle(0xf0c674, 1);
g.fillCircle(cx - 12 * scale, cy - 12, 4); g.fillCircle(cx + 12 * scale, cy - 12, 4);
if (b.enemy.block > 0) { g.lineStyle(3, 0x7fb2e5, 0.9); g.strokeCircle(cx, cy, 64 * scale); }
}
// ── 奖励视图(战利三选一)──────────────────────────────────────────────────────
function drawReward(scene, s) {
drawTopStrip(scene, s);
keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W, VIEW_H, 0x0a0a12, 0.55));
txt(scene, VIEW_W / 2, VIEW_H * 0.24, '战利 · 三选一', 24, '#f0c674');
s.reward.options.forEach((card, i) => {
const c = rewardCardCenter(i);
drawCardFront(scene, c.x, c.y, card, { selected: false, affordable: true, scale: 1.05 });
txt(scene, c.x, c.y + CARD_H / 2 + 16, card.rarity, 12, card.rarity === '稀有' ? '#f0c674' : card.rarity === '罕见' ? '#9fc4e8' : '#8a8498');
});
const skip = rewardSkipRect();
keep(scene, scene.add.rectangle(skip.x, skip.y, skip.w, skip.h, 0x2a2a38).setStrokeStyle(2, 0x8a8498));
txt(scene, skip.x, skip.y, '不取 · 前行', 14, '#c9c3b2');
}
// ── 终局视图 ──────────────────────────────────────────────────────────────────
function drawTerminal(scene, s) {
keep(scene, scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W, VIEW_H, 0x0a0a12, 0.78));
const title = s.phase === 'win' ? '巡异功成' : s.phase === 'lose' ? '巡异失利' : '本局无效';
const color = s.phase === 'win' ? '#9fe08a' : s.phase === 'lose' ? '#ff6b5e' : '#f0c674';
txt(scene, VIEW_W / 2, VIEW_H * 0.36, title, 38, color);
if (s.phase === 'win') txt(scene, VIEW_W / 2, VIEW_H * 0.43, `三幕镇服 · 异兽尽收山海图`, 15, '#c9c3b2');
txt(scene, VIEW_W / 2, VIEW_H * 0.50,
`战 ${s.stats.battlesWon} · 精英 ${s.stats.elitesWon} · 王 ${s.stats.bossesWon} · 得卡 ${s.stats.cardsAdded} · 金 ${s.gold}`,
14, '#c9c3b2');
txt(scene, VIEW_W / 2, VIEW_H * 0.58, '点按任意处 · 再启一局', 15, '#e8e2d0');
}
// ── 通用卡面 ──────────────────────────────────────────────────────────────────
function drawCardFront(scene, cx, cy, card, opt) {
const cont = keep(scene, scene.add.container(cx, cy));
const g = scene.add.graphics();
g.fillStyle(opt.affordable ? 0x241f33 : 0x1a1724, 1);
g.fillRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, 10);
g.lineStyle(2, opt.selected ? 0xf0c674 : ELEMENT_COLORS[card.element], 1);
g.strokeRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, 10);
g.fillStyle(ELEMENT_COLORS[card.element], 1);
g.fillRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, 20, { tl: 10, tr: 10, bl: 0, br: 0 });
cont.add(g);
cont.add(scene.add.text(0, -CARD_H / 2 + 10, `${card.element} · ${card.name}`, { fontSize: '12px', color: '#141420', fontFamily: 'sans-serif' }).setOrigin(0.5));
cont.add(scene.add.circle(-CARD_W / 2 + 4, -CARD_H / 2 + 4, 12, 0x2d5f8a).setStrokeStyle(2, 0x7fb2e5));
cont.add(scene.add.text(-CARD_W / 2 + 4, -CARD_H / 2 + 4, String(card.cost), { fontSize: '13px', color: '#fff', fontFamily: 'sans-serif' }).setOrigin(0.5));
const ph = scene.add.graphics(); // 插画位(留空框,待风格版 §9.1)
ph.lineStyle(1, 0x4a4460, 1); ph.strokeRect(-CARD_W / 2 + 10, -CARD_H / 2 + 28, CARD_W - 20, 52);
cont.add(ph);
const text = card.text || '';
cont.add(scene.add.text(0, CARD_H / 2 - 34, text, { fontSize: '10px', color: '#c9c3b2', fontFamily: 'sans-serif', wordWrap: { width: CARD_W - 14 }, align: 'center' }).setOrigin(0.5));
cont.setScale(opt.scale || 1);
return cont;
}
// ── 事件演出(M0 语汇迁入 + run 级新事件)────────────────────────────────────────
function intentLabel(it) {
if (it.kind === 'attack') return `意图:${it.name} · 攻 ${it.n}${(it.times || 1) > 1 ? `×${it.times}` : ''}`;
if (it.kind === 'block') return `意图:${it.name} · 甲 ${it.n}`;
if (it.kind === 'attackBlock') return `意图:${it.name} · 甲 ${it.block} 攻 ${it.n}`;
return `意图:${it.name} · 力 +${it.strength}`;
}
function playFx(scene, e) {
const eCy = VIEW_H * ZONES.enemyZone / 2 + 14;
const heroY = VIEW_H * (ZONES.enemyZone + ZONES.midZone / 2);
if (e.t === 'damage') {
floatText(scene, VIEW_W / 2 + (e.seg - 1) * 22 - 10, eCy - 40, `-${e.dealt}${e.mult > 1 ? ` ×${e.mult.toFixed(2)}` : ''}`, '#ff6b5e', 18 + Math.min(10, e.dealt / 3));
} else if (e.t === 'blockGain') {
floatText(scene, 90, heroY, `甲 +${e.n}`, '#7fb2e5', 15);
} else if (e.t === 'strengthGain' && e.target === 'hero') {
floatText(scene, 90, heroY - 18, `力 +${e.n}`, '#e2a13e', 15);
} else if (e.t === 'chainLink') {
floatText(scene, VIEW_W / 2, heroY + 6, `灵脉 ${e.links} 环`, '#9fe08a', 14);
} else if (e.t === 'chainBreak') {
floatText(scene, VIEW_W / 2, heroY + 6, '脉断', '#8a8498', 13);
} else if (e.t === 'chainGrace') {
floatText(scene, VIEW_W / 2, heroY + 6, '归墟镜 · 脉未断', '#b9a3d0', 13);
} else if (e.t === 'zhoutian') {
const flash = scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W, VIEW_H, 0xf0c674, 0.28).setDepth(50);
scene.tweens.add({ targets: flash, alpha: 0, duration: 320, onComplete: () => flash.destroy() });
const t = scene.add.text(VIEW_W / 2, VIEW_H * 0.44, '周 天', { fontSize: '46px', color: '#f0c674', fontFamily: 'sans-serif' }).setOrigin(0.5).setDepth(51);
scene.tweens.add({ targets: t, scale: { from: 0.6, to: 1.25 }, alpha: { from: 1, to: 0 }, duration: 700, onComplete: () => t.destroy() });
} else if (e.t === 'enemyAction' && (e.kind === 'attack' || e.kind === 'attackBlock')) {
scene.cameras.main.shake(90, 0.004);
floatText(scene, VIEW_W / 2, heroY - 8, `${e.name} -${e.dealt}`, '#ff6b5e', 17);
} else if (e.t === 'enemyAction' && e.kind === 'block') {
floatText(scene, VIEW_W / 2, eCy + 40, `${e.name} 甲+${e.n}`, '#7fb2e5', 14);
} else if (e.t === 'enemyAction' && e.kind === 'buff') {
floatText(scene, VIEW_W / 2, eCy + 40, `${e.name} 力+${e.strength}`, '#e2a13e', 14);
} else if (e.t === 'enemyPhase') {
floatText(scene, VIEW_W / 2, eCy, '相位转换!', '#e2543e', 22);
} else if (e.t === 'shuffle') {
floatText(scene, VIEW_W - 60, handTop() + 40, '洗牌', '#8a8498', 12);
} else if (e.t === 'turnStart') {
floatText(scene, VIEW_W / 2, VIEW_H * 0.5, `第 ${e.turn} 回合`, '#c9c3b2', 16);
} else if (e.t === 'goldGain') {
floatText(scene, 70, 30, `金 +${e.n}`, '#f0c674', 14);
} else if (e.t === 'heal') {
floatText(scene, VIEW_W / 2, VIEW_H * 0.5, `调息 +${e.n}`, '#9fe08a', 18);
} else if (e.t === 'relicGained') {
floatText(scene, VIEW_W / 2, VIEW_H * 0.34, `得遗物 · ${e.name}`, '#b9a3d0', 20);
} else if (e.t === 'cardAdded') {
floatText(scene, VIEW_W / 2, VIEW_H * 0.34, '收入卡组', '#9fc4e8', 15);
} else if (e.t === 'actStart') {
const t = scene.add.text(VIEW_W / 2, VIEW_H * 0.4, `第 ${e.act} 幕`, { fontSize: '40px', color: '#e8e2d0', fontFamily: 'sans-serif' }).setOrigin(0.5).setDepth(51);
scene.tweens.add({ targets: t, alpha: { from: 1, to: 0 }, duration: 1100, onComplete: () => t.destroy() });
} else if (e.t === 'battleStart') {
floatText(scene, VIEW_W / 2, VIEW_H * 0.4, `遭遇 · ${e.enemyName}`, '#e8e2d0', 20);
}
}
function floatText(scene, x, y, str, color, size) {
const t = scene.add.text(x, y, str, { fontSize: `${Math.round(size)}px`, color, fontFamily: 'sans-serif' }).setOrigin(0.5).setDepth(40);
scene.tweens.add({ targets: t, y: y - 34, alpha: { from: 1, to: 0 }, duration: 620, ease: 'Cubic.Out', onComplete: () => t.destroy() });
}
}

View File

@ -0,0 +1,125 @@
/**
* src/ui/layout.js —— 《山海巡异录》布局与可点靶(M1 S5/S8;纯函数,零引擎 import)
* owner:北极星顶级线③(M1)
*
* 【这份是什么】竖屏三段布局(§7.1)的唯一坐标事实源:场景按它画、readState().tapTargets 按它算、
* card driver 按 tapTargets 打、injectInput 按同一命中序路由——四方同源,输入因果全链一致
* (夜市款 core/layout.js 同构先例)。
*
* 【tapTargets 契约】{ kind, x, y, w, h, ...语义键 }(x/y=中心,逻辑像素 390×844)。
* 命中判定按数组序「先命中先得」——排序即优先级:终局遮罩 > 奖励 > 手牌 > END > 敌区 > 地图节点 > 取消。
* kind 词汇:overlay / reward(index)/ rewardSkip / card(index)/ endTurn / enemy / node(id)/ cancel。
*/
'use strict';
/** 逻辑视口(bootPhaserHost viewport 同值;§7.1 竖屏 390×844 定死)。 */
export const VIEW_W = 390;
export const VIEW_H = 844;
/** 三段布局比例(§7.1:敌区 38% / 中段 14% / 手牌区 48%)。 */
export const ZONES = { enemyZone: 0.38, midZone: 0.14 };
/** 手牌卡尺寸与摆位常量(M0 场景同值抽出)。 */
export const CARD_W = 96;
export const CARD_H = 140;
/** 中段纵中心 y。 */
export function midY() {
return VIEW_H * ZONES.enemyZone + (VIEW_H * ZONES.midZone) / 2;
}
/** 手牌区顶 y。 */
export function handTop() {
return VIEW_H * (ZONES.enemyZone + ZONES.midZone);
}
/**
* 手牌第 i/n 张的中心位(与场景绘制同一公式;selected 抬升不改命中框纵位,命中框按未选中位算,
* 避免「选中后命中框漂移导致第二击落空」)。
*/
export function handCardCenter(i, n) {
const span = Math.min(VIEW_W - CARD_W - 16, Math.max(1, n - 1) * 72);
const x0 = (VIEW_W - span) / 2;
const cx = n === 1 ? VIEW_W / 2 : x0 + (span * i) / Math.max(1, n - 1);
const cy = handTop() + 96 + Math.abs(i - (n - 1) / 2) * 6;
return { x: cx, y: cy };
}
/** END 按钮中心与尺寸。 */
export function endTurnRect() {
return { x: VIEW_W - 46, y: midY() + 14, w: 72, h: 34 };
}
/** 地图节点中心位(层 0 在下、Boss 在上;idx 横向铺开)。 */
export function mapNodeCenter(floor, idx, floorWidth) {
const x = VIEW_W / 2 + (idx - (floorWidth - 1) / 2) * 96;
const y = VIEW_H * 0.80 - floor * 92;
return { x, y };
}
/** 奖励三选一卡位(reward 屏)。 */
export function rewardCardCenter(i) {
return { x: VIEW_W / 2 + (i - 1) * 118, y: VIEW_H * 0.44 };
}
/** 奖励跳过按钮。 */
export function rewardSkipRect() {
return { x: VIEW_W / 2, y: VIEW_H * 0.62, w: 130, h: 38 };
}
/**
* 由 run 状态算全量可点靶(readState().tapTargets;数组序=命中优先级)。
* @param {Object} s run-core 语义状态(readState 的在建投影;见 run-core.js)
* @returns {Array<Object>}
*/
export function computeTapTargets(s) {
const targets = [];
// 终局遮罩:全屏一靶(点按重开;最高优先)。
if (s.phase === 'win' || s.phase === 'lose' || s.phase === 'invalid') {
targets.push({ kind: 'overlay', x: VIEW_W / 2, y: VIEW_H / 2, w: VIEW_W, h: VIEW_H });
return targets;
}
if (s.screen === 'reward' && s.reward) {
s.reward.options.forEach((opt, i) => {
const c = rewardCardCenter(i);
targets.push({ kind: 'reward', index: i, cardId: opt.id, x: c.x, y: c.y, w: 104, h: 150 });
});
const skip = rewardSkipRect();
targets.push({ kind: 'rewardSkip', x: skip.x, y: skip.y, w: skip.w, h: skip.h });
return targets;
}
if (s.screen === 'battle' && s.battle) {
const n = s.battle.hand.length;
s.battle.hand.forEach((cardId, i) => {
const c = handCardCenter(i, n);
targets.push({ kind: 'card', index: i, cardId, x: c.x, y: c.y, w: CARD_W, h: CARD_H });
});
const end = endTurnRect();
targets.push({ kind: 'endTurn', x: end.x, y: end.y, w: end.w, h: end.h });
// 敌区整面=「打出选中卡」确认靶(点选出牌两段式的第二段)。
targets.push({ kind: 'enemy', x: VIEW_W / 2, y: (VIEW_H * ZONES.enemyZone) / 2, w: VIEW_W, h: VIEW_H * ZONES.enemyZone });
// 手牌区空白=取消选中(误触防护;排最后,先让卡/按钮命中)。
targets.push({ kind: 'cancel', x: VIEW_W / 2, y: (handTop() + VIEW_H) / 2, w: VIEW_W, h: VIEW_H - handTop() });
return targets;
}
if (s.screen === 'map' && s.map) {
const widthByFloor = {};
for (const nd of s.map.nodes) widthByFloor[nd.floor] = (widthByFloor[nd.floor] || 0) + 1;
for (const nd of s.map.nodes) {
if (!s.map.selectable.includes(nd.id)) continue; // 只有可选节点成靶
const c = mapNodeCenter(nd.floor, nd.idx, widthByFloor[nd.floor]);
targets.push({ kind: 'node', id: nd.id, nodeType: nd.type, x: c.x, y: c.y, w: 72, h: 72 });
}
return targets;
}
return targets;
}
/** 命中判定:按数组序返回首个包含 (x,y) 的靶(无 → null)。injectInput 与场景共用。 */
export function hitTarget(targets, x, y) {
for (const t of targets) {
if (x >= t.x - t.w / 2 && x <= t.x + t.w / 2 && y >= t.y - t.h / 2 && y <= t.y + t.h / 2) return t;
}
return null;
}

View File

@ -0,0 +1,76 @@
/**
* business-serve.driver.js —— 经营·服务循环形状(business-serve)确定性 harness driver(tier2 富游戏线)
* owner:北极星顶级线①(W-POLARIS)《夜市一条街》M1 九门接线 | 被 play-phaser.cdp.cjs runDriver 分发调用
* (与 business-sim/card-battle 并列,按 runDriver 注释官方配方接入)
*
* ════════════════════════════════════════════════════════════════════════════
* 【这份是什么】「点顾客即成单」服务循环的确定性驱动:读语义 state 自适应出招(不盲打坐标),
* 把经营日循环真输入驱动到 day_end 终态,供九门判定(E_live/G_input/H_progress 的 driven 供给端)。
* 与 business-sim.driver(mini-肥鹅 merge/orders 形状)是同一经营品类的两个 state 形状;
* 富三门的经营-serve 语义归 M2 定义,本 driver 只管九门 driven——spec 用独立 driver.type
* 'business-serve',富三门按 harness 品类占位「门待建」走,不套 merge 语义防假绿。
*
* 【读什么 state】(game-core readState 契约)
* { phase:'title'|'play'|'day_end', coins, served,
* tapTargets:[{customerId,x,y,patienceLeft,servable}], // 等待中顾客可点逻辑坐标
* stoves:[{idx,busy,...}] }
*
* 【出招策略】(与 scripts/logic-smoke.mjs 勤快席同族,浏览器侧无白盒进货,只按 servable 优先)
* 有空灶时:优先「可做(servable)且耐心最紧」的顾客,真触摸点其逻辑坐标;
* 无可点(灶全忙/无客)则小步等待;phase 进入 terminalPhases 即停,交 latch 校验。
* ════════════════════════════════════════════════════════════════════════════
*/
'use strict';
/** 逻辑坐标钳制(避免点出取证视口外;竖屏 390×844)。 */
function clamp(v, max) {
const hi = max != null ? max : 386;
if (typeof v !== 'number' || Number.isNaN(v)) return Math.floor(hi / 2);
return Math.max(4, Math.min(hi - 4, v));
}
/** 从 state 选出招目标:可做优先、耐心紧迫优先;无灶/无客返 null。(纯函数,单测直测) */
function pickTarget(state) {
if (!state) return null;
const targets = Array.isArray(state.tapTargets) ? state.tapTargets : [];
const stoves = Array.isArray(state.stoves) ? state.stoves : [];
if (!targets.length) return null;
if (!stoves.some((s) => s && !s.busy)) return null; // 无空灶不空点(点了也 stoves_busy)
const byUrgency = [...targets].sort((a, b) => a.patienceLeft - b.patienceLeft);
return byUrgency.find((t) => t.servable !== false) || byUrgency[0];
}
/**
* 主路径:驱动服务循环到 day_end(或步数上限)。
* @param cdp CDP 会话
* @param driver play-spec.driver({type:'business-serve', steps?, stepMs?, terminalPhases?})
* @param hashes 帧哈希数组(供 E 门活性采样)
* @param deps {tap,key,drag,readGameState,sampleHash,getPath,delay}
* @returns {drove, steps}
*/
async function run(cdp, driver, hashes, deps) {
const { tap, readGameState, sampleHash, delay } = deps;
const steps = driver.steps || 110;
const stepMs = driver.stepMs != null ? driver.stepMs : 400;
const terminals = Array.isArray(driver.terminalPhases) && driver.terminalPhases.length
? driver.terminalPhases : ['day_end'];
let drove = 0;
for (let i = 0; i < steps; i++) {
const s = await readGameState(cdp);
if (s && terminals.indexOf(s.phase) >= 0) break; // 已收钟 → 停,交 latch 校验
const target = pickTarget(s);
if (target) {
await tap(cdp, clamp(target.x), clamp(target.y, 840)); // 真触摸(经浏览器→Phaser input→core.tapAt)
drove++;
} else {
await delay(Math.min(stepMs, 200)); // 无可点(灶忙/无客/缺料无可做单):小步等出餐腾灶
}
try { hashes.push(await sampleHash(cdp)); } catch (_) {}
await delay(stepMs);
}
return { drove, steps };
}
module.exports = { run, pickTarget, clamp };

View File

@ -0,0 +1,166 @@
/**
* card-battle.driver.js —— 卡牌构筑品类(card-battle)确定性 harness driver(tier2 富游戏线)
* owner:北极星顶级线③《山海巡异录》M1(S8)| 被 play-phaser.cdp.cjs runDriver 分发调用
*
* ════════════════════════════════════════════════════════════════════════════
* 【这份是什么】卡牌构筑一局(地图→战斗→奖励→…→Boss)的确定性驱动策略。它读语义 state 自适应
* 出招(不盲打固定坐标),把完整 run 真输入驱动到终态,供九门判定;同一份策略被游戏侧 headless
* 冒烟(tier2/games/shanhai-xunyilu/scripts/run-smoke.mjs 经 createRequire 载入)复用——
* 浏览器路与仿真路一个大脑,防两套策略漂移。
*
* 【读什么 state】(游戏 readState 契约,host 透传到 __gameState())
* {
* phase: 'run'|'win'|'lose'|'invalid',
* screen: 'map'|'battle'|'reward',
* hero: {hp, hpMax}, deckCount, gold,
* battle: { energy, hero:{hp}, enemy:{hp, block, intent:{kind,n,times?}},
* handCards:[{id,cost,element,dmg,block,rarity}], chain:{nextElement} } | null,
* reward: { options:[{id,rarity,...}] } | null,
* ui: { selected },
* tapTargets: [{kind,x,y,w,h,...}] // 布局层唯一坐标事实,驱动只打这些点
* }
*
* 【出牌两段式】游戏交互 = 点卡选中 → 再点同卡打出(与真人指针同一路径);
* chooseTap 无内部状态:selected<0 时返回选卡靶,selected≥0 时返回同卡靶(确认)——
* 连续调用自然完成两段,真输入与 headless 语义调用等价。
* ════════════════════════════════════════════════════════════════════════════
*/
'use strict';
/** 在 tapTargets 里找靶。 */
function findTarget(state, kind, pred) {
const ts = (state && state.tapTargets) || [];
return ts.find((t) => t.kind === kind && (!pred || pred(t))) || null;
}
/**
* 策略大脑:给定语义 state,返回下一击的靶(tapTargets 元素)或 null(终态/无可动)。
* 贪心档 v1(§6.4 三档 driver 之一;链优先/保血防御两档随 M2 S12)。
*/
function chooseTap(state) {
if (!state || state.phase !== 'run') return null; // win/lose/invalid 终态 → 停,交 latch 校验
// ── 地图:选节点(血线驱动的贪心偏好)──
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;
// 偏好序:低血先憩;宝匣白拿;普通战优先攒卡;精英只在血很厚时碰(精英=陷阱首因,门槛 0.75);Boss 是必经终点。
let prefer = ['treasure', 'battle', 'rest', 'boss', 'elite'];
if (hpRatio < 0.45) prefer = ['rest', 'treasure', 'battle', 'boss', 'elite'];
else if (hpRatio >= 0.75) prefer = ['treasure', 'battle', 'elite', 'rest', 'boss'];
for (const k of prefer) {
const hit = nodes.find((t) => t.nodeType === k);
if (hit) return hit;
}
return nodes[0];
}
// ── 奖励:卡组瘦身纪律(≥25 张跳过),否则拿最稀有 ──
if (state.screen === 'reward' && state.reward) {
if (state.deckCount >= 25) return findTarget(state, 'rewardSkip');
const order = { 稀有: 0, 罕见: 1, 普通: 2 };
let best = 0;
state.reward.options.forEach((o, i) => {
if ((order[o.rarity] ?? 3) < (order[state.reward.options[best].rarity] ?? 3)) best = i;
});
return findTarget(state, 'reward', (t) => t.index === best) || findTarget(state, 'rewardSkip');
}
// ── 战斗:两段式出牌 ──
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);
if (!hand.length) return findTarget(state, 'endTurn');
// 本卡若打出时的链倍率(延链=环+1,破序/开链=归基;周天/上限细节不algo,保守估)。
const links = (b.chain && b.chain.links) || 0;
const multOf = (c) => (b.chain && b.chain.nextElement && c.element === b.chain.nextElement ? 1 + (links + 1) * 0.25 : 1.0);
// ①斩杀:带链倍率的期望伤已够穿甲破血 → 直接打(裸伤口径低估 25%+,近失局的主因)。
const kill = hand.find((c) => Math.floor(c.dmg * multOf(c)) >= b.enemy.hp + b.enemy.block);
if (kill) return findTarget(state, 'card', (t) => t.index === kill.i);
// ②起手成型:长战(敌 HP ≥60,精英/Boss 量级)前两回合先立力——持续增益跑赢一次 7 伤。
if (b.enemy.hp >= 60 && b.turn <= 2) {
const str = hand.filter((c) => c.strength > 0).sort((a, z) => z.strength - a.strength)[0];
if (str) return findTarget(state, 'card', (t) => t.index === str.i);
}
// ③护甲纪律:来袭 ≥9 且吃下后血线跌破 60% → 补甲到够(已有甲 ≥ 来袭则不再补)。
const it = b.enemy.intent;
const incoming = it && (it.kind === 'attack' || it.kind === 'attackBlock') ? it.n * (it.times || 1) : 0;
if (incoming >= 9 && b.hero.block < incoming && (b.hero.hp + b.hero.block - incoming) < b.hero.hpMax * 0.6) {
const guard = hand.filter((c) => c.block > 0).sort((a, z) => z.block - a.block)[0];
if (guard) return findTarget(state, 'card', (t) => t.index === guard.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) {
// 从 start 起沿相生环贪心数链长(每步取该元素最便宜一张,受剩余能量约束)。
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;
let dmgSum = start.dmg;
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;
dmgSum += next.dmg;
len += 1;
el = cycle[(cycle.indexOf(el) + 1) % cycle.length];
}
const score = len * 100 + dmgSum; // 链长第一、当量第二
if (!best || score > best.score) best = { card: start, score };
}
if (best) return findTarget(state, 'card', (t) => t.index === best.card.i);
}
// ⑤链延续:能延灵脉的先打;同类里挑带倍率的期望当量最大。
const chainable = b.chain && b.chain.nextElement
? hand.filter((c) => c.element === b.chain.nextElement)
: [];
const pool = chainable.length ? chainable : hand;
const pick = pool.sort((a, z) => (z.dmg * multOf(z) + z.block) - (a.dmg * multOf(a) + a.block))[0];
return findTarget(state, 'card', (t) => t.index === pick.i);
}
return null;
}
/**
* 赢路径主驱动(runDriver 分发入口):读 state → chooseTap → 真 tap,循环到终态。
* @param cdp CDP 会话
* @param driver play-spec.driver({type:'card-battle', steps?, stepMs?})
* @param hashes 帧哈希数组(供守卫 E 活性采样)
* @param deps {tap,key,drag,readGameState,sampleHash,getPath,delay}
* @returns {{drove:number, steps:number}}
*/
async function run(cdp, driver, hashes, deps) {
const { tap, readGameState, sampleHash, delay } = deps;
const steps = driver.steps || 1200; // 完整 run 动作量级:十余战×每战数十击(上限护栏,非目标步数)
const stepMs = driver.stepMs != null ? driver.stepMs : 120;
let drove = 0;
for (let i = 0; i < steps; i++) {
const s = await readGameState(cdp);
if (s && (s.phase === 'win' || s.phase === 'lose' || s.phase === 'invalid')) break; // 终态 → 停,交 latch 校验
const t = chooseTap(s);
if (t) {
await tap(cdp, Math.round(t.x), Math.round(t.y));
drove++;
} else {
await delay(stepMs); // 读不到可操作靶(state 未导出 tapTargets)→ 空转,H/G 门判红线
}
try { hashes.push(await sampleHash(cdp)); } catch (_) { /* 采帧失败不阻驱动 */ }
await delay(stepMs);
}
return { drove, steps };
}
module.exports = { run, chooseTap };

View File

@ -337,6 +337,18 @@ async function runDriver(cdp, driver, hashes, deps) {
const businessSim = require('./business-sim.driver.js');
return businessSim.run(cdp, driver, hashes, deps);
}
if (type === 'card-battle') {
// 卡牌构筑品类(北极星《山海巡异录》M1 S8 接入;driver 读 readState().tapTargets 语义靶,契约见其文件头)。
const cardBattle = require('./card-battle.driver.js');
return cardBattle.run(cdp, driver, hashes, deps);
}
if (type === 'business-serve') {
// 经营·服务循环形状(北极星《夜市一条街》M1 九门接线;点顾客即成单,读 tapTargets/stoves 自适应出招)。
// 与 business-sim(mini-肥鹅 merge/orders 形状)同品类不同 state 形状;富三门的经营-serve 语义归 M2,
// 本 type 不在 RICHGAME_GATED_ARCHETYPES → 富三门走「门待建」占位,不套 merge 语义防假绿。
const businessServe = require('./business-serve.driver.js');
return businessServe.run(cdp, driver, hashes, deps);
}
// 未注册 driver.type → 空转 + 响亮记 note(H/G 门判红线;不静默假装驱动过)。
// 加新品类:在 tier2/harness/<品类>.driver.js 写 driver,并在此加一条 `type===<品类>` 分发(见 archetypes.py 配方)。
for (let i = 0; i < (driver.steps || 10); i++) {
@ -737,7 +749,9 @@ async function main() {
// 兜底:spec.richGame 自带 orders.patience(play-spec.example 即如此;worker 也会从数据表 derive 进 spec.richGame)。
if (!dataTable && spec.richGame && Array.isArray(spec.richGame.orders)) dataTable = spec.richGame;
const url = `${base}/games/${gameId}/index.html`;
// 门参数化(additive 2026-07-06 W-POLARIS M1):spec.urlQuery 追加到装载 URL(seed 钉死/短营业日/清存档),
// G 门对照实例同 url = 同参数,确定性对照成立;未声明时行为逐字不变。
const url = `${base}/games/${gameId}/index.html` + (spec.urlQuery ? `?${spec.urlQuery}` : '');
const round = spec.round != null ? spec.round : 0;
// driven 感知:有 driver 或非空 inputs → driven=true,E_live/H_progress 恢复致命。
const driven = !!spec.driver || ((spec.inputs || []).some((e) => e && (e.t === 'tap' || e.t === 'key' || e.t === 'drag')));