simulation-data-balance-tuning
数值参数调优 / 体验量化 / Monte Carlo 模拟 / playtest 方法论 skill —— 给「找一组合适的参数让体验达标」类任务用。
兼容平台:Claude Code / OpenClaw / Cursor / Windsurf
simulation-data-balance-tuning · 数值参数调优 + 体验量化
把「拍脑袋调数值 vs 跑数据找 sweet spot」从「我感觉 X 好」升级成「用 Monte Carlo 模拟 + 5 指标量化体验 → 数据驱动决策 + 给主人 BLUF + trade-off」。核心是先研究 量化体系再开始跑,单变量先后组合,simulator 必有 fidelity 自检。
何时触发
主人说「跑数据找参数」「调一下 X 看效果」「模拟 N 场看 KO 率」「这个数怎么定 / 拍数值 / 数值平衡」「体验量化 / playtest」「找 sweet spot」「我要看数据」「跑 Monte Carlo」「N 场对比」「重新模拟一遍」「加上 X 指标再跑」。
也触发:主人收到外部「数据反馈差」(如「100% timeout」「KO 0%」「策略撞墙」 「死锁」)并要求「找参数」时。
提到「跑数据 + 找参数」语义就调,不要等主人指定 skill 名 —— 主人只会描述场景。
四条铁律(违反 = 数据误导 / 主人拒收 / 推荐被打回)
铁律 1 · 先研究方法论再开始跑
起因 2026-05-24 实战踩坑:主人指令「跑数据找参数」,我立刻开始拍 3 个维度 (maxTurns × startEnergy × HP)开跑,主人当场打断:「你需要先找到对体验有影响的 参数 → 分别分析 → 再组合验证 → 找一些验证数据模型的 skill 结合量化体验的 skill 来研究 → 这个你先研究清楚再开始任务」。
正确顺序:
- WebSearch + Agent 调研「游戏数值平衡方法论」/「playtest 量化」/「Monte Carlo in game design」/「类似游戏的基准参考」
- 提炼量化体验的 5 指标(每条带「目标值依据」,不是拍的)
- 列「影响参数维度」清单 + 排优先级(P0/P1)
- 给主人 BLUF protocol + 等 ack
- ack 后才开始跑 Phase 0
引用源(Sirlin / Csikszentmihalyi / Hearthstone BG MC / Street Fighter 基准等) 是 evidence,不是装饰。
铁律 2 · 5 指标量化体验(单一 KO 率不够)
单看 KO 率 = Divekick 化陷阱:KO 100% 但中位 3 回合 = 一招秒杀,完全没策略。
完整 5 指标体系(2026-05-24 调研产出,Sirlin + Csikszentmihalyi):
| 指标 | 目标 | 依据 | 防什么 |
|---|---|---|---|
| decisive resolution rate(KO 率) | ≥ 70% | Sirlin:玩家要收到「我赢了」明确信号,非「时间到」 | timeout 体验 |
| median match duration | 60-180s(入门)/ 180-300s(策略) | Csikszentmihalyi flow channel + Street Fighter 99s timer 中位 KO 45-60s | 太短无策略 / 太长拖 |
| action entropy (Shannon) | > 1.5 | 玩家应该有多种 viable 选择 | dominant strategy(无脑按一个键最优) |
| decision-level usage 分布 | 关键档位 > 10% | 每个 viable choice 都要被用到 | 局部 dominant(只用 e5 不用 e1-e3) |
| mirror match A/B winrate | 50±5% | zero-sum 对称游戏 baseline | side bias / first-mover advantage bug |
| comeback rate(可选) | 10-25% | 落后方有反败概率,不雪球也不靠运气 | 完全雪球 / 完全运气 |
5 指标全过才推荐,部分过就标 trade-off 给主人。
铁律 3 · simulator 必有 fidelity 自检 + ε-greedy 破 mirror 死锁
起因 2026-05-24 实战踩坑:写完 simulator 跑 baseline,KO 率 0% / 0 dmg / 全平局 —— 因为 mirror match 两边用同一份 rule-based player,决策完全对称 → 永远 standoff 不开打。
修法:
- ε-greedy 探索:rule player 加 ε=10-15% 概率从 available 中随机选,模拟「真 人不完美 + 破对称死锁」
- fidelity 自检:Phase 0 跑 baseline 跟已知数据(主人/外部报告的现状) 对照 — 不一致就是 simulator 有 bug(rule layer / engine port / 指标计算)
- 不要用纯 random player 当 ground truth:random 100% timeout 通常是「不会 蓄能」的策略问题,不一定是参数问题。用 scripted EWMA(中等智能)+ ε 才是真实 玩家代理
铁律 4 · 单变量先后组合 + N≥1000
为什么不能直接跑组合 grid:
- 单变量先跑出每维趋势 + lever 强度(哪个维度对指标杠杆最大)
- 组合时聚焦 top-2 / top-3 维度,避免 5 维 grid 爆炸(3^5 = 243 cell)
- 单变量数据本身就是给主人理解参数空间的 evidence
N 样本量(基于 95% CI ±3% winrate):
- N=500 → CI ±4.4%(千夏 PR #552 用的,偏紧)
- N=1000 → CI ±3%(本 skill 标准)
- N=5000 → CI ±1.4%(只在 winrate 差距 <3% 时用)
Pitfall:
- winrate 差 < 3% 在 N=1000 下不显著,不要拿来下结论
- 真人测 ≠ AI 测,Phase 3 推荐后必须让主人(或真实玩家)实测 3-5 局兜底体感
工作流
1. INTAKE: 主人说「跑数据找参数 / 模拟一遍 / 验证 / 调参」
↓
2. RESEARCH (Agent + WebSearch 并行):
- 该领域数值平衡现有方法论(Sirlin/Riot/Blizzard/Csikszentmihalyi)
- 类似产品的基准参考(街霸 TTK / TFT 单战 / Hearthstone BG)
- Monte Carlo 标准 protocol
- **不超过 800 字,引用源**
↓
3. PROTOCOL DESIGN:
- 量化 5 指标(目标 + 依据)
- 参数维度清单(P0/P1)
- Phase 0/1/2/3 步骤 + N
- 给主人 BLUF + 等 ack
↓
4. PHASE 0 BASELINE (验 fidelity vs 外部已知数据):
- 跑 1000 场 baseline mirror match
- 对照外部报告/主人观察
- 不一致 → debug simulator → 重新跑
↓
5. PHASE 1 单变量 sweep:
- 每维 3-5 个值,固定其他 = baseline
- 输出 5 指标表 + 杠杆强度排序(★ 1-5)
↓
6. PHASE 2 组合验证:
- top-2 / top-3 维度 grid
- 2^(4-1)=8 cell(fractional factorial)或 3×3=9 cell
- 找 5 指标全过的 cell
↓
7. PHASE 3 BLUF 推荐:
- 主推 cell + 2-3 个 trade-off 对照(保守 / 激进 / 中庸)
- 5 指标表 + 中文参数解释
- 警示(数值改动幅度 / Owner review 需要 / 真人测兜底)
↓
8. OWNER ACK + 真人实测 + 落地(改 source const,走 config-constant-audit skill)
实战范本 · combat-v4 平衡调参(2026-05-24)
主人指令:千夏 PR #552 报告 500 场 100% timeout / 0% KO,要找参数 sweet spot。
Phase 0 fidelity check(N=1000 mirror EWMA + ε=0.15):
- baseline (HP=100 / MT=20 / SE=0 / DMG×1): KO=5.6% / medT=20 / dmg/t=4.0
- 对照千夏 random 0% KO:我 5.6% 略高(rule + exploration 比 random 强)合理
- ✅ fidelity OK
Phase 1 单变量 sweep(N=1000/cell × 6 维):
- DMG × k:★★★★★(KO 5.7%→39% 当 ×1→×2)
- HP_max:★★★★(KO 5.5%→37% 当 100→60)
- max_turns:★★(20→30 仅到 25%,时长代价大)
- startEnergy:★(几乎无效)
- chargeGain:⚠️ 非单调(=2 反而 KO 暴跌 1.3%,双方蓄太快 standoff)
- blockReduction:×(无明显影响)
Phase 2 组合(主人约束 HP=100 后):MT × DMG × startE 3 维 × 25 cell
Phase 3 推荐:MT=40 / DMG×1.5 [12,27,48,72,105] / startE=1 / HP=100
- KO 73% / 平均 287s / e3+ 58% / mirror 50/48 / cb 4.9%
- 5 指标全过 ✅
- 对比 DMG×3 数字膨胀更小,小天/千夏好 review
落地:走 [config-constant-audit] skill 改 actions.ts MAX_TURNS + DMG_CURVE +
state.ts createInitialState 默认 startEnergy + 同步 spec/UI/template/测试。commit
e3d00600。
反例(本次实战踩坑 → 写进 skill 防再犯)
反例 1 · 跳过研究直接跑 simulator
主人指令「跑数据找参数」,我立刻拍 3 个维度开跑 → 主人打断「先研究再做」。
修法:任何「数据驱动调参」任务,Phase 0 之前必须:
- WebSearch + Agent 并行调研该领域方法论
- 提炼 5 指标(每条带「目标值依据」)
- BLUF 给主人 protocol + 等 ack
时间盒:研究 10-15 分钟 << 跑错方向重做 1 小时。
反例 2 · mirror EWMA 死锁 → 0% KO 假信号
写完 simulator 跑 baseline:KO=0% / dmg=0 / 全平局。
根因:mirror match 两边用同一 rule-based pickAction,决策完全对称 → t5+ 双方 e4 互 block 锁死,永远不 slash。
修法:rule player 加 ε=10-15% 概率从 available 中均匀随机选(if (Math.random() < EPSILON) return randomFromAvailable(state))。模拟真人不完美 + 破对称死锁。
且:Phase 0 baseline 必跟外部已知数据(主人 / PR / 报告)对照,KO 0% 这种 极端值十有八九是 simulator bug 不是参数问题。
反例 3 · 单看 KO 率 → Divekick 化推荐
DMG×3 单维 sweep KO 可以到 88%,但 e3+% 仅 55% / 满能 210 dmg 一击秒。如果只看 KO 率,推荐 DMG×3 → 主人收到「数字膨胀 + cheap 秒杀感」反馈。
修法:必跑 5 指标(KO + medT + entropy + e3+ + mirror + comeback),全过才推。
反例 4 · 直接跑大 grid 不单变量
5 维 4-level grid = 4^5 = 1024 cell × 1000 场 = 100 万场,本地 5-10 分钟跑得起来 但数据无信号(主人看不出哪个维度起作用)。
修法:单变量先(每维 3-5 值 × 1000 = ~18 cell),拿杠杆强度 ★ 排序; 组合后(top-2 / top-3 × 3 level = 9-27 cell),聚焦 sweet spot 区域。
反例 5 · 用 random player 当 ground truth
千夏 500 场 random player 报告 100% timeout / 0% KO。如果直接照搬「random KO 0 % = 参数问题」结论,忽略策略层影响 → 可能错误归因。
修法:三策略层并行(random / scripted EWMA / mirror EWMA),三层数据矛盾时 优先 scripted EWMA(中等智能 = 真实玩家代理)。random 100% timeout 通常是 「策略不会蓄能」不是「参数严重失衡」。
实施细节模板
simulator 文件结构(本 skill 自带 scripts/balance-sim.mjs,可直接运行 / 改 config 复用)
// 1. DEFAULTS - 跟 production 1:1
const DEFAULTS = { hpMax: 100, maxTurns: 20, startEnergy: 0, dmgCurve: [...], ... }
// 2. ENGINE - port from production engine (resolveTurn / validateAction)
function resolveTurn(state, aKind, bKind, config) { ... }
// 3. SCRIPTED PLAYER - rule layer + ε-greedy
class EwmaPlayer {
pickAction(me, foe, available, turn, maxTurns) {
if (Math.random() < EPSILON) return random(available) // 关键:破 mirror 死锁
// ...rule fall-through cases
}
}
// 4. MATCH RUNNER
function runMatch(c) { ... return {winner, koBy, turns, hpA, hpB, actionLog, slashEs} }
// 5. METRICS aggregation
function aggregate(results, c) {
return { koRate, medianTurns, avgTurns, avgSec, avgDmgPerTurn,
actionEntropy, e3plusRate, aWinRate, bWinRate, drawRate, comebackRate }
}
// 6. CLI: phase0 / phase1 / phase2 / phase3 / custom HP=X MT=Y SE=Z DMGK=k
BLUF 报告模板
## Phase N 数据表(N=1000/cell,显示关键 cell)
| 维度1 | 维度2 | 维度3 | KO% | medT | 平均时长 | dmg/t | e3+% | cb% | A/B | 5 指标 |
|---|---|---|---:|---:|---:|---:|---:|---:|---|:-:|
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ✅/⚠️/❌ |
## 推荐方案(三选一)
**A 主推**:`参数组合 1` — KO X% / 平均 Y 秒 / 5 指标全过 ★
**B 保守**:`参数组合 2` — trade-off
**C 激进**:`参数组合 3` — trade-off
## 参数中文解释(对照源文件 path)
| 参数 | 中文含义 | 默认 | 推荐 A |
| ... | ... | ... | ... |
## 警示
1. 数值改动幅度 → owner review 需要(谁 own 这块?)
2. simulator 是 ε-greedy 不是真人 → Phase 3 后必须真人实测兜底
3. 其他 trade-off / leverage 已 verified 无效的维度
相关 memory + skill 边界
- feedback_subagent_report_is_assertion —— grep / script 产出是「候选」不是「结论」(本 skill 单变量数据是 evidence 不是 ground truth)
- feedback_act_default_穷尽自主 —— Act > Ask,但本 skill 例外:Phase 0 之前必须 BLUF 等主人 ack protocol,因为「跑数据」决策跟主人 value judgment 强相关
- lesson_2026-05-23_combat_v4_victory —— combat-v4 实战起源
- 实战 simulator:本 skill 自带
scripts/balance-sim.mjs(432 行,combat-v4 实现 + 5 指标 + CLI phase0/1/2/3,可照搬模板调改 config)
跟其他 skill 的边界
- config-constant-audit:Phase 3 推荐落地时调用,改 source const 走 audit 流程
- claude-md-eval:eval 框架(fresh sub-agent + golden dataset + grader),本 skill 是 simulation 数据驱动(纯函数 + Monte Carlo),不是 LLM judge
- code-audit:审计找 bug,本 skill 找参数 sweet spot
- delivery-gate:本 skill 跑完 simulator 输出表格给主人,不交付文件
- clawgamers-git-commit:落地后调用 commit
关键 Sources(WebSearch 实证,2026-05-24 调研)
- Sirlin: Game Balance and Yomi
- TTK — Game Balance Project Wiki
- Jenova Chen: Flow in Games MFA Thesis
- Yu-kai Chou: Flow Theory Complete Guide
- Hearthstone Battleground AI with MCTS
- Automatic Playtesting for Game Parameter Tuning via Active Learning (arXiv)
- JMP: Fractional Factorial Designs
- Street Fighter Round Timer
#!/usr/bin/env node // scripts/arena/balance-sim.mjs · Combat V4 parameter balance Monte Carlo simulator // // 起因:2026-05-24 千夏 PR #552 报告 500 场 100% timeout / 0% KO,主人要求数据驱动找参数 sweet spot // 方法论:Sirlin matchup spread + Csikszentmihalyi flow channel + Hearthstone BG Monte Carlo playtest // untracked,数据脚本,不纳主仓 commit(留集成判断给小天) // // usage: // node scripts/arena/balance-sim.mjs phase0 # baseline 1000 场 mirror,验证 fidelity vs 千夏数据 // node scripts/arena/balance-sim.mjs phase1 # 6 维单变量 sweep // node scripts/arena/balance-sim.mjs phase2 # Phase 1 top-2 维度组合验证(动态) // node scripts/arena/balance-sim.mjs custom HP=80 MT=15 SE=1 DMGK=1.5 # 单 cell 自定义 // // 5 指标(源 §研究报告): // KO% (decisive resolution) 目标 ≥ 70% // medianTurns 目标 8-14(~60-180s 真人) // avgDmgPerTurn diagnostic,无目标 // actionEntropy (Shannon) 目标 > 1.5(防 dominant strategy) // e3+ slash usage rate 目标 > 30%(防只用低能 slash 撞) // mirror A winrate 目标 50±3%(side bias 检测)
// ─── DEFAULT CONFIG(跟 src/lib/arena/{actions,engine,state}.ts 1:1)─── const DEFAULTS = { hpMax: 100, energyMax: 5, maxTurns: 20, // 基线用千夏 500 场的 20(不是我刚改的 30),验证 fidelity startEnergy: 0, dmgCurve: [0, 8, 18, 32, 48, 70], // index 0 unused, [1..5] spec curve blockReduction: 0.2, parryCounterDmg: 12, grappleVsParryDmg: 8, grappleVsBlockEnergyDrain: 3, chargeGainPerTurn: 1, parryCost: 2, grappleCost: 2, slashMinEnergy: 1, }
// ─── ENGINE(port from src/lib/arena/engine.ts,数学层 1:1)───
function dmg(c, e) { return c.dmgCurve[e] ?? 0 } function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) }
function listAvailable(side, c) { return { charge: true, block: true, slash: side.energy >= c.slashMinEnergy, parry: side.energy >= c.parryCost, grapple: side.energy >= c.grappleCost, } }
function energyCost(kind, currentEnergy, c) { switch (kind) { case 'charge': case 'block': return 0 case 'slash': return currentEnergy case 'parry': return c.parryCost case 'grapple': return c.grappleCost } }
// 5x5 interaction table(row = A, col = B) function resolveEffects(aKind, bKind, aE, bE, c) { const Z = { aHpDmg: 0, bHpDmg: 0, aExtraEnergy: 0, bExtraEnergy: 0 } if (aKind === 'charge') { if (bKind === 'slash') return { ...Z, aHpDmg: dmg(c, bE) } return Z } if (aKind === 'block') { if (bKind === 'slash') return { ...Z, aHpDmg: Math.floor(c.blockReduction * dmg(c, bE)) } if (bKind === 'grapple') return { ...Z, aExtraEnergy: -c.grappleVsBlockEnergyDrain } return Z } if (aKind === 'slash') { if (bKind === 'charge') return { ...Z, bHpDmg: dmg(c, aE) } if (bKind === 'block') return { ...Z, bHpDmg: Math.floor(c.blockReduction * dmg(c, aE)) } if (bKind === 'slash') { const aD = dmg(c, aE), bD = dmg(c, bE) if (aD === bD) return Z const net = Math.abs(aD - bD) return aD > bD ? { ...Z, bHpDmg: net } : { ...Z, aHpDmg: net } } if (bKind === 'parry') return { ...Z, aHpDmg: c.parryCounterDmg } if (bKind === 'grapple') return { ...Z, bHpDmg: dmg(c, aE) } } if (aKind === 'parry') { if (bKind === 'slash') return { ...Z, bHpDmg: c.parryCounterDmg } if (bKind === 'grapple') return { ...Z, aHpDmg: c.grappleVsParryDmg } return Z } if (aKind === 'grapple') { if (bKind === 'block') return { ...Z, bExtraEnergy: -c.grappleVsBlockEnergyDrain } if (bKind === 'slash') return { ...Z, aHpDmg: dmg(c, bE) } if (bKind === 'parry') return { ...Z, bHpDmg: c.grappleVsParryDmg } return Z } return Z }
function resolveTurn(state, aKind, bKind, c) { const aPreE = state.A.energy, bPreE = state.B.energy const aCost = energyCost(aKind, aPreE, c) const bCost = energyCost(bKind, bPreE, c) const fx = resolveEffects(aKind, bKind, aPreE, bPreE, c)
state.A.hp = Math.max(0, state.A.hp - fx.aHpDmg) state.B.hp = Math.max(0, state.B.hp - fx.bHpDmg)
const aGain = aKind === 'charge' && aPreE < c.energyMax ? c.chargeGainPerTurn : 0 const bGain = bKind === 'charge' && bPreE < c.energyMax ? c.chargeGainPerTurn : 0 state.A.energy = clamp(aPreE - aCost + aGain + fx.aExtraEnergy, 0, c.energyMax) state.B.energy = clamp(bPreE - bCost + bGain + fx.bExtraEnergy, 0, c.energyMax)
state.turn += 1
if (state.A.hp <= 0 && state.B.hp <= 0) state.status = 'draw' else if (state.A.hp <= 0) state.status = 'B_wins' else if (state.B.hp <= 0) state.status = 'A_wins' else if (state.turn > c.maxTurns) { state.status = state.A.hp > state.B.hp ? 'A_wins' : state.A.hp < state.B.hp ? 'B_wins' : 'draw' } }
function createInitialState(c) { return { turn: 1, A: { hp: c.hpMax, hpMax: c.hpMax, energy: c.startEnergy, energyMax: c.energyMax }, B: { hp: c.hpMax, hpMax: c.hpMax, energy: c.startEnergy, energyMax: c.energyMax }, status: 'ongoing', } }
// ─── EWMA SCRIPTED PLAYER(port from combat-player.mjs pickAction)───
const ACTIONS = ['charge', 'block', 'slash', 'parry', 'grapple'] const ALPHA = 0.35 const EPSILON = 0.15 // ε-greedy:15% 随机扰动,模拟「真人不完美」+ 破 mirror 死锁
class EwmaPlayer { constructor() { this.foeFreq = Object.fromEntries(ACTIONS.map(a => [a, 0])) } bumpFoe(action) { for (const k of ACTIONS) this.foeFreq[k] *= (1 - ALPHA) if (this.foeFreq[action] !== undefined) this.foeFreq[action] += ALPHA } p(k) { const s = Object.values(this.foeFreq).reduce((a, b) => a + b, 0) || 0.001 return this.foeFreq[k] / s } pickAction(me, foe, available, turn, maxTurns) { // ε-greedy:15% 概率从 available 中均匀随机选,破 mirror match 死锁 + 模拟真人小失误 if (Math.random() < EPSILON) { const opts = Object.entries(available).filter(([_, v]) => v).map(([k]) => k) return opts[Math.floor(Math.random() * opts.length)] } const myE = me.energy, foeE = foe.energy const hpDelta = me.hp - foe.hp const turnsLeft = maxTurns - turn + 1
// 致命窗口
if (myE === 5 && foeE < 2 && available.slash) return 'slash'
// 高能 vs 低能
if (myE >= 3 && foeE <= 1 && available.slash && this.p('parry') < 0.25) return 'slash'
// 对手满能爆发
if (foeE >= 4) {
const foeSlashProb = Math.max(this.p('slash'), 0.4)
if (myE >= 2 && available.parry && foeSlashProb > 0.4) return 'parry'
if (available.block) return 'block'
}
// 对手 parry-heavy
if (this.p('parry') > 0.35 && myE >= 2 && available.grapple) return 'grapple'
// 对手低能 → slash 收割
if (foeE < 2 && myE >= 2 && available.slash && this.p('charge') > 0.4) return 'slash'
// HP 落后
if (hpDelta < -20 && myE >= 1 && available.block) return 'block'
// 双方满能 standoff
if (myE === 5 && foeE === 5 && available.parry) return 'parry'
// endgame HP 领先 → stall
if (turnsLeft <= 3 && hpDelta > 0 && available.block) return 'block'
// endgame HP 落后 → gamble
if (turnsLeft <= 3 && hpDelta < 0 && myE >= 1 && available.slash) return 'slash'
return 'charge'
} }
// ─── MATCH RUNNER ───
function runMatch(c) { const state = createInitialState(c) const pA = new EwmaPlayer() const pB = new EwmaPlayer() const actionLog = [] const slashEs = [] const hpHistory = [] // for comeback metric
while (state.status === 'ongoing') { const aKind = pA.pickAction(state.A, state.B, listAvailable(state.A, c), state.turn, c.maxTurns) const bKind = pB.pickAction(state.B, state.A, listAvailable(state.B, c), state.turn, c.maxTurns)
if (aKind === 'slash') slashEs.push(state.A.energy)
if (bKind === 'slash') slashEs.push(state.B.energy)
actionLog.push(aKind, bKind)
hpHistory.push({ a: state.A.hp, b: state.B.hp })
resolveTurn(state, aKind, bKind, c)
pA.bumpFoe(bKind)
pB.bumpFoe(aKind)
}
const koBy = state.A.hp <= 0 || state.B.hp <= 0
// comeback: 50% turn 时落后 ≥ 10hp,最终赢 const mid = Math.floor(hpHistory.length / 2) const midHp = hpHistory[mid] || { a: c.hpMax, b: c.hpMax } const aComeback = (midHp.a - midHp.b <= -10) && state.status === 'A_wins' const bComeback = (midHp.b - midHp.a <= -10) && state.status === 'B_wins'
return { winner: state.status, koBy, turns: state.turn - 1, hpA: state.A.hp, hpB: state.B.hp, actionLog, slashEs, comeback: aComeback || bComeback, } }
// ─── METRICS ───
function median(arr) { const s = [...arr].sort((a, b) => a - b) return s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2 } function avg(arr) { return arr.reduce((a, b) => a + b, 0) / arr.length } function shannon(dist) { const tot = Object.values(dist).reduce((a, b) => a + b, 0) if (tot === 0) return 0 let h = 0 for (const k of Object.keys(dist)) { const p = dist[k] / tot if (p > 0) h += -p * Math.log2(p) } return h }
function aggregate(results, c) { const koCount = results.filter(r => r.koBy).length const turns = results.map(r => r.turns) const allActions = {} const allSlashEs = [] for (const r of results) { for (const a of r.actionLog) allActions[a] = (allActions[a] || 0) + 1 allSlashEs.push(...r.slashEs) } const slashByE = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 } for (const e of allSlashEs) if (slashByE[e] !== undefined) slashByE[e]++ const totalSlash = allSlashEs.length const e3plus = totalSlash > 0 ? (slashByE[3] + slashByE[4] + slashByE[5]) / totalSlash : 0
const aWins = results.filter(r => r.winner === 'A_wins').length const bWins = results.filter(r => r.winner === 'B_wins').length const draws = results.filter(r => r.winner === 'draw').length
return { n: results.length, koRate: koCount / results.length, medianTurns: median(turns), avgTurns: avg(turns), avgDmgPerTurn: avg(results.map(r => (2 * c.hpMax - r.hpA - r.hpB) / r.turns)), actionEntropy: shannon(allActions), e3plusRate: e3plus, aWinRate: aWins / results.length, bWinRate: bWins / results.length, drawRate: draws / results.length, comebackRate: results.filter(r => r.comeback).length / results.length, slashByE, actionDist: allActions, } }
// ─── SWEEP / CLI ───
function runCell(config, N = 1000) { const results = [] for (let i = 0; i < N; i++) results.push(runMatch(config)) return { config, metrics: aggregate(results, config) } }
// 真人时长估算:每回合 ~10s(BURST_WINDOW 5s + 真人决策 ~5s);AI-only 更快 ~6s const SECS_PER_TURN = 10
function fmtCell(label, cell) {
const m = cell.metrics
const ko = (m.koRate * 100).toFixed(1).padStart(5)
const medT = String(m.medianTurns).padStart(3)
const avgT = m.avgTurns.toFixed(1).padStart(5)
const avgSec = Math.round(m.avgTurns * SECS_PER_TURN).toString().padStart(4) // 平均时长 (s)
const dmg = m.avgDmgPerTurn.toFixed(1).padStart(5)
const ent = m.actionEntropy.toFixed(2).padStart(4)
const e3 = (m.e3plusRate * 100).toFixed(0).padStart(3)
const ab = ${(m.aWinRate * 100).toFixed(0)}/${(m.bWinRate * 100).toFixed(0)}/${(m.drawRate * 100).toFixed(0)}.padStart(10)
const cb = (m.comebackRate * 100).toFixed(1).padStart(4)
return ${label.padEnd(28)} | KO%=${ko} medT=${medT} avgT=${avgT} avgSec=${avgSec} dmg/t=${dmg} ent=${ent} e3+%=${e3} A/B/D=${ab} cb%=${cb}
}
const HEADER = ' '.repeat(28) + ' | KO% medT avgT avgSec dmg/t ent e3+% A/B/D cb%'
function phase0() { console.log('=== Phase 0: Baseline (HP=100 / MT=20 / SE=0 / DMG=spec, EWMA mirror, N=1000) ===') console.log(HEADER) console.log(fmtCell('baseline (spec defaults)', runCell(DEFAULTS, 1000))) console.log() console.log('Fidelity target vs 千夏 PR #552:') console.log(' - KO% should be very low (报告 0% with random; EWMA 略高但仍低)') console.log(' - medT 20 = timeout (走满上限)') console.log(' - avgDmgPerTurn ≈ 1.9 (千夏报告)') }
function phase1() { console.log('=== Phase 1: Single-variable sweeps (N=1000/cell, all else = baseline) ===\n')
console.log('-- HP_max sweep --')
console.log(HEADER)
for (const hp of [60, 80, 100, 120]) {
console.log(fmtCell(HP=${hp}, runCell({ ...DEFAULTS, hpMax: hp }, 1000)))
}
console.log('\n-- max_turns sweep --')
console.log(HEADER)
for (const mt of [12, 15, 20, 25, 30]) {
console.log(fmtCell(MT=${mt}, runCell({ ...DEFAULTS, maxTurns: mt }, 1000)))
}
console.log('\n-- startEnergy sweep --')
console.log(HEADER)
for (const se of [0, 1, 2, 3]) {
console.log(fmtCell(SE=${se}, runCell({ ...DEFAULTS, startEnergy: se }, 1000)))
}
console.log('\n-- DMG × k sweep --')
console.log(HEADER)
for (const k of [1.0, 1.25, 1.5, 2.0]) {
const dc = [0, ...[8, 18, 32, 48, 70].map(v => Math.round(v * k))]
console.log(fmtCell(DMG×${k} = [${dc.slice(1).join(',')}], runCell({ ...DEFAULTS, dmgCurve: dc }, 1000)))
}
console.log('\n-- chargeGainPerTurn sweep --')
console.log(HEADER)
for (const cg of [1, 2, 3]) {
console.log(fmtCell(chargeGain=${cg}, runCell({ ...DEFAULTS, chargeGainPerTurn: cg }, 1000)))
}
console.log('\n-- blockReduction sweep --')
console.log(HEADER)
for (const br of [0.0, 0.1, 0.2, 0.3, 0.5]) {
console.log(fmtCell(blockRed=${br}, runCell({ ...DEFAULTS, blockReduction: br }, 1000)))
}
}
function phase2(args) {
// Phase 1 实证 top-3 维度:HP_max(★4) × DMG×k(★5) × max_turns(★2)
// 控制:startEnergy=1 (Phase 1 显示略提 e3+%)
// 3 × 3 × 2 = 18 cell,N=1000/cell
console.log('=== Phase 2: HP × DMG × MT combination (N=1000/cell, SE=1 控制) ===\n')
console.log(HEADER)
for (const mt of [15, 20]) {
console.log(\n-- maxTurns=${mt} --)
for (const hp of [60, 80, 100]) {
for (const k of [1.0, 1.5, 2.0]) {
const dc = [0, ...[8, 18, 32, 48, 70].map(v => Math.round(v * k))]
const cfg = { ...DEFAULTS, hpMax: hp, maxTurns: mt, dmgCurve: dc, startEnergy: 1 }
console.log(fmtCell(HP=${hp} DMG×${k}, runCell(cfg, 1000)))
}
}
}
}
function custom(args) {
const c = { ...DEFAULTS }
for (const a of args) {
const [k, v] = a.split('=')
if (k === 'HP') c.hpMax = +v
else if (k === 'MT') c.maxTurns = +v
else if (k === 'SE') c.startEnergy = +v
else if (k === 'CR') c.chargeGainPerTurn = +v
else if (k === 'BR') c.blockReduction = +v
else if (k === 'EC') { c.parryCost = +v; c.grappleCost = +v }
else if (k === 'DMGK') {
const kf = +v
c.dmgCurve = [0, ...[8, 18, 32, 48, 70].map(d => Math.round(d * kf))]
}
}
console.log(=== Custom: ${args.join(' ')} (N=1000) ===)
console.log(HEADER)
console.log(fmtCell(custom, runCell(c, 1000)))
}
// Phase 3:HP 固定 100 (主人 2026-05-24 约束「符合认知习惯」),重跑 MT × DMG × SE grid
// MT 范围扩到 40 (主人 2026-05-24 第二轮指令「回合上限可以调整到 40 再模拟一遍」)
function phase3() {
console.log('=== Phase 3: HP=100 固定 · MT × DMG × SE grid (N=1000/cell, MT up to 40) ===\n')
console.log(HEADER)
for (const se of [0, 1, 2]) {
console.log(\n-- startEnergy=${se} --)
for (const mt of [20, 25, 30, 35, 40]) {
for (const k of [1.0, 1.25, 1.5, 1.75, 2.0]) {
const dc = [0, ...[8, 18, 32, 48, 70].map(v => Math.round(v * k))]
const cfg = { ...DEFAULTS, hpMax: 100, maxTurns: mt, dmgCurve: dc, startEnergy: se }
console.log(fmtCell(MT=${mt} DMG×${k}, runCell(cfg, 1000)))
}
}
}
}
const argv = process.argv.slice(2) const cmd = argv[0] ?? 'phase0' const rest = argv.slice(1)
if (cmd === 'phase0') phase0() else if (cmd === 'phase1') phase1() else if (cmd === 'phase2') phase2(rest) else if (cmd === 'phase3') phase3() else if (cmd === 'custom') custom(rest) else { console.error('usage: balance-sim.mjs [phase0 | phase1 | phase2 | phase3 | custom HP=X MT=Y SE=Z DMGK=k CR=c BR=b]') process.exit(1) }
<!-- @@END_BUNDLED_FILE -->⚡ 一键安装
复制给智能体安装:
npx clawgamers install simulation-data-balance-tuning把上面的命令丢给智能体 (Claude Code / Cursor / Codex 任一), ta 会装到当前工作目录的 skills/ 文件夹