feat(slice): 护甲等级掷点批收编——d20 判定式+defense_up→ac_up 换装+armor_roll 流(冒烟 233 项全绿)

- 判定式:1d20 ≥ AC+attack_bonus 全额,低掷三档折扣,nat20 暴击×2,下限 max(1,·)
- 换装:defense_up 退役 → ac_up(+1 AC/层×2);hero AC10 / 敌方 0 暂定
- rng_service 增 armor_roll 封闭流;settlement.tres 七掷点参数
- 文档:战斗结算 v0.10/敌人系统 v0.4/难度曲线与Boss v0.5/增益状态 v0.4/地图战斗资源 v0.6 + registry + 06-待办清单
- EV 口径注记:AC12 按判定式 28.75%(提案 32.5% 不符,按规范登记)
- 顺手清偿 supply_scan.gd 遗留 .uid
This commit is contained in:
DKChan committed 2026-09-16 20:54:56 +08:00
1 parent 1c63830317
commit 131d1047e1
39 files changed
+1461 -182

No files matched your search

@@ -4,7 +4,8 @@
# 战斗结算编排中枢:轮次生命周期唯一 owner · 五段结算(授予→增益/减益→战斗→衰减→判定——2026-09-15
# 格面资源批,道具窗口/道具段随可选类删除下线)· 授予段极性路由(「动作 × 格型」矩阵:开安全格/标中旗
# = 正向,开雷格/标错旗 = 负向;结算队列 + 抵消过滤——标旗后被撤旗以轮末在场旗为基线不判)·
# 10 类行协议可回放步骤流(零随机零 await)· 高光双落(完美排雷 ×2 / 标中旗 highlight)·
# 10 类行协议可回放步骤流(零 await;随机消费 = 开战遭遇骰初始化随机 + 护甲等级掷点回合内随机——
# 均走封闭命名流,2026-09-16 护甲等级掷点批)· 高光双落(完美排雷 ×2 / 标中旗 highlight)·
# Waiting 相位结算触发(不依赖事件到达序)。
# 切片登记:① 事件总线载体 = 直连信号(实现期 ADR 项,provisional)② Waiting 相位刷新通道 2026-09-15 用户拍板整体
# 拆除(仅 Active 挖掘期受理)③ 效果行协议 = 授予段逐 grant 行 + 增益/减益段续存实例组合行。
@@ -402,32 +403,40 @@ func _combat_segment(ri: int) -> void:
# ⓪ 主角先手攻击(基础攻击恒存在——2026-09-11 用户裁决;先于敌方行动;授予段负向直伤致死 → 致电短路不先手)。
# 三段伤害构成(2026-09-12 用户拍板;2026-09-15 命名统一批 + 攻击类型/奖励抽象分离批):普通(atk×(1+attack_up),不吃克制)
# + 穿刺/魔法(由充能供给——充能按攻击类型分桶,谁能充/充哪个桶归奖励定义 charge_grants)。攻击类型只管查甲效矩阵哪一行,与来源无关。
# 护甲等级掷点批(2026-09-16):普通分量吃掷点判定(含暴击);穿刺/魔法充能分量与棋盘来源伤害不掷不折;
# 任何攻击落地伤害 ≥ damage_floor(全局 max(1,·))——"被减伤归零 v=0"零值伤害行分支随批退役。
var hero_alive: bool = int(_enemy_system.hero_unit().get("hp", 0)) > 0
var hero_target = _enemy_system.default_target()
if hero_alive and hero_target != null:
var armor: String = String(hero_target.get("armor_type", "light"))
var up := clampi(_pool_effective("attack_up"), int(db.settlement.pool_clamp_lo), int(db.settlement.pool_clamp_hi))
var points := _charge_points() # {攻击类型: 充能点数}——奖励定义投递的结果(本段不认识"开格/标雷")
var roll_read: Dictionary = _armor_roll() # 普通分量掷点(每吃掷点分量逐次掷)
var threshold: int = _defender_ac(hero_target) + int(db.settlement.attack_bonus)
var crit: bool = bool(roll_read.crit)
var discount: int = _roll_discount(int(roll_read.roll), threshold)
var base_val: float = float(_hero_atk) * (100.0 + float(up)) / 100.0
var comp: Dictionary = {}
var d_hero := 0
for atk_type in SettlementTable.ATTACK_TYPE_KEYS:
var c: int = 0
if atk_type == "normal":
c = maxi(0, int(floor(float(_hero_atk) * (100.0 + float(up)) / 100.0))) # 普通分量走攻击力基线:吃增益、不吃克制
c = DamageMath.rolled_component(base_val, discount, crit, int(db.settlement.crit_multiplier), _armor_effectiveness("normal", armor))
else:
c = _charge_component(int(points.get(atk_type, 0)), atk_type, armor)
comp[atk_type] = c
d_hero += c
var payload: Dictionary = {"target": hero_target.display_name}
payload.merge(comp)
if d_hero > 0:
_enemy_system.apply_damage(hero_target, d_hero)
payload["killed"] = not hero_target.alive # 击杀标记在扣血后取(原语义不变)
_emit_row("damage", "hero_attack", Vector2i(-1, -1), ri, "主角攻击", d_hero, payload)
else:
# 零值伤害行(战斗结算 边界表:"合成后为 0 时不调扣血接口,改写零值伤害行——免伤与零输出必须可见";
# 敌方侧同款口径已就绪——照抄。分量字段齐备保 P2 归因不断链)
_emit_row("damage", "hero_attack", Vector2i(-1, -1), ri, "主角攻击(被减伤归零)", 0, payload)
payload["roll"] = int(roll_read.roll)
payload["ac"] = threshold
payload["tier"] = discount
if crit:
payload["crit"] = true
var d_landed: int = maxi(int(db.settlement.damage_floor), d_hero)
_enemy_system.apply_damage(hero_target, d_landed)
payload["killed"] = not hero_target.alive # 击杀标记在扣血后取(原语义不变)
_emit_row("damage", "hero_attack", Vector2i(-1, -1), ri, _attack_label("主角攻击", crit, discount), d_landed, payload)
for u in _enemy_system.query_alive("enemy"):
if int(_enemy_system.hero_unit().hp) <= 0:
break # hero hp=0 立即短路(死尸挨打行是归因噪音——用户拍板)
@@ -436,15 +445,59 @@ func _combat_segment(ri: int) -> void:
if beat == "charge":
_emit_row("charge", "enemy_action", Vector2i(-1, -1), ri, "%s 蓄力(本轮踩雷白喂)" % u.display_name, 0, {"target": "hero"})
else:
var d_tick := _resolve_damage(int(atk.total))
if d_tick <= 0:
_emit_row("damage", "enemy_action", Vector2i(-1, -1), ri, "%s 攻击(被减伤归零)" % u.display_name, 0, {"target": "hero", "baseline": int(atk.baseline), "attach": int(atk.attach)})
else:
_enemy_system.apply_damage(_enemy_system.hero_unit(), d_tick) # 调用序②受击上报
_emit_row("damage", "enemy_action", Vector2i(-1, -1), ri, "%s 攻击" % u.display_name, d_tick, {"target": "hero", "baseline": int(atk.baseline), "attach": int(atk.attach)})
# 基线部分吃掷点判定(含暴击)+ 伏击附加(必中全额)→ 合计 → 增益池乘区 → max(1, ·)(伤害下限)
var roll_read_e: Dictionary = _armor_roll()
var threshold_e: int = _defender_ac(_enemy_system.hero_unit()) + int(db.settlement.attack_bonus)
var crit_e: bool = bool(roll_read_e.crit)
var discount_e: int = _roll_discount(int(roll_read_e.roll), threshold_e)
var rolled_base: int = DamageMath.rolled_component(float(int(atk.baseline)), discount_e, crit_e, int(db.settlement.crit_multiplier), 100)
var d_tick := _resolve_damage(rolled_base + int(atk.attach))
d_tick = maxi(int(db.settlement.damage_floor), d_tick)
_enemy_system.apply_damage(_enemy_system.hero_unit(), d_tick) # 调用序②受击上报
var payload_e: Dictionary = {"target": "hero", "baseline": int(atk.baseline), "attach": int(atk.attach),
"roll": int(roll_read_e.roll), "ac": threshold_e, "tier": discount_e}
if crit_e:
payload_e["crit"] = true
_emit_row("damage", "enemy_action", Vector2i(-1, -1), ri, _attack_label("%s 攻击" % u.display_name, crit_e, discount_e), d_tick, payload_e)
_enemy_system.advance(u) # 调用序③推进——每结算每存活敌恰一次(hero 不在推进域)
# 攻击行显示名(掷点档位随行文本可读——2026-09-16 护甲等级掷点批;零值行语义已退役,重档折扣 = "被压制")
func _attack_label(base: String, crit: bool, discount: int) -> String:
if crit:
return base + "(暴击)"
if discount < 100:
return base + "(被压制)"
return base
# 护甲等级掷点(armor_roll 封闭流——逐次掷逐次取号、逐位可回放):返回 {roll, crit}
func _armor_roll() -> Dictionary:
var stream: RandomNumberGenerator = rng.open_stream(String(db.settlement.armor_roll_stream))
var roll: int = stream.randi_range(1, int(db.settlement.roll_faces))
return {"roll": roll, "crit": roll == int(db.settlement.crit_roll)}
# 掷点折扣读数:nat 最大面恒全额(暴击分支前置),其余查档(缺口 = 阈值 − 掷点)
func _roll_discount(roll: int, threshold: int) -> int:
if roll == int(db.settlement.crit_roll):
return 100
return DamageMath.roll_discount_percent(roll, threshold, db.settlement.roll_tier_bounds, db.settlement.roll_tier_discounts)
# 防守方护甲等级读数(数值轴——与 armor_type 克制属性轴严格分离):敌人 = spawn 期行值快照;
# 主角 = 表值基准 + ac 型效果加算(ac_up 层数×幅度——不入任何池,无 clamp)
func _defender_ac(unit: Dictionary) -> int:
if bool(unit.get("is_hero", false)):
var total: int = int(db.hero.armor_class)
for eff in _status_effects.query_active("hero"):
var row: Dictionary = db.status_effect_row(eff.effect_id)
if not row.is_empty() and String(row.unit) == "ac":
total += int(eff.effective_value)
return total
return int(unit.get("armor_class", 0))
# F2 乘区:池内加算 → clamp [-100,+100] → 池间乘算 → floor(单一取整规则)——算术归 DamageMath(O3)
func _resolve_damage(d_raw: int) -> int:
var p_hero := _clamped_pool("hero_defense")
@@ -516,7 +569,7 @@ func _emit_composition_effects(side: String, ri: int) -> void:
func _emit_settlement_snapshot(verdict: String) -> void:
var enemies: Array = []
for u in _enemy_system.query_alive("enemy"):
enemies.append({"id": String(u.enemy_id), "hp": int(u.hp), "max_hp": int(u.max_hp), "atk": int(u.atk), "beat": query_beat_of(u), "armor": String(u.get("armor_type", "light"))})
enemies.append({"id": String(u.enemy_id), "hp": int(u.hp), "max_hp": int(u.max_hp), "atk": int(u.atk), "beat": query_beat_of(u), "armor": String(u.get("armor_type", "light")), "armor_class": int(u.get("armor_class", 0))})
var buffs: Array = []
for eff in _status_effects.query_active("hero"):
buffs.append({"id": eff.effect_id, "side": "hero", "layers": eff.layers, "left": eff.remaining_settlements})
@@ -543,7 +596,7 @@ func _emit_settlement_snapshot(verdict: String) -> void:
"verdict": verdict,
"unit_count": enemies.size(),
"spawn_count": _enemy_system.all_enemies().size(), # 本战斗实例化总数(含死亡残位)——unit_count=0 时区分「首轮全灭」vs「空场」(playtest-2026-09-12 报告 Bug#1 误读教训)
"hero": {"hp": int(_enemy_system.hero_unit().get("hp", 0)), "max_hp": int(_enemy_system.hero_unit().get("max_hp", 0)), "atk": int(_enemy_system.hero_unit().get("atk", 0))},
"hero": {"hp": int(_enemy_system.hero_unit().get("hp", 0)), "max_hp": int(_enemy_system.hero_unit().get("max_hp", 0)), "atk": int(_enemy_system.hero_unit().get("atk", 0)), "armor_class": _defender_ac(_enemy_system.hero_unit())},
"economy": {"phase": String(action_economy.phase), "ap": int(action_economy.ap), "rap": int(action_economy.rap), "refresh_used": _refresh_used_this_round},
"board": board_stats,
"enemies": enemies,
@@ -615,8 +668,8 @@ func _battle_over(result: String) -> void:
# ---- 呈现状态面(E1 封装收口 2026-09-13):呈现层 / headless 自动玩家的唯一读取出口——
# 协作对象(_enemy_system/_status_effects/_reward_economy)私有化后,跨层读取一律经本快照或下列命名面(diagnostic_snapshot 同款模式,
# 禁再链式穿透 settlement.enemy_system.hero 一类路径)。载荷 schema 成文(code-conventions 2.1):
# round / battle_result / phase / ap / rap / can_request_refresh / hero{hp,max_hp,atk,alive} /
# enemies[{order,display_name,hp,max_hp,enemy_id,armor_type,beat,ring_size,ring_index,ambush_layers,alive}] /
# round / battle_result / phase / ap / rap / can_request_refresh / hero{hp,max_hp,atk,alive,armor_class} /
# enemies[{order,display_name,hp,max_hp,enemy_id,armor_type,armor_class,beat,ring_size,ring_index,ambush_layers,alive}] /
# ambush_layers / hero_effects[](增益状态 query_active 投影)
func presentation_snapshot() -> Dictionary:
var enemies: Array = []
@@ -632,6 +685,7 @@ func presentation_snapshot() -> Dictionary:
"hp": int(u.hp), "max_hp": int(u.max_hp),
"enemy_id": String(u.enemy_id),
"armor_type": String(u.get("armor_type", "light")),
"armor_class": int(u.get("armor_class", 0)),
"beat": String(_enemy_system.query_beat(u)) if alive else "",
"ring_size": ring.size(),
"ring_index": int(u.get("steps", 0)) % maxi(ring.size(), 1),
@@ -641,7 +695,7 @@ func presentation_snapshot() -> Dictionary:
var hero := {}
var h: Dictionary = _enemy_system.hero_unit()
if not h.is_empty():
hero = {"hp": int(h.hp), "max_hp": int(h.max_hp), "atk": int(h.atk), "alive": bool(h.get("alive", false))}
hero = {"hp": int(h.hp), "max_hp": int(h.max_hp), "atk": int(h.atk), "alive": bool(h.get("alive", false)), "armor_class": _defender_ac(h)}
return {
"round": round_index,
"battle_result": battle_result,
@@ -3,6 +3,7 @@
# 实现 GDD:design/archive/gdd-2026-09-14/b1-settlement.md(系统 ID B1·战斗结算——registry 追溯锚,命名规范见 CLAUDE.md)
# 战斗结算·战斗段伤害公式族(零状态纯算术宿主——规范 1.2「工具性纯函数挂明确宿主类」;
# 自 battle_orchestrator 拆出 2026-09-13,封装审查 O3)。池读取与表值读取仍归 战斗结算,本类只做算术。
# 护甲等级掷点批(2026-09-16):掷点查档与吃掷点分量算术收编本类(判定式参数化——表值零字面量纪律不变)。
class_name DamageMath
extends RefCounted
@@ -16,3 +17,27 @@ static func pool_damage(d_raw: int, p_hero: int, p_enemy: int) -> int:
# 充能分量:充能点数(调用方已按奖励定义换算点数、并按源套单轮计数上限)× 克制 percent → floor
static func charge_component(points: int, effectiveness_percent: int) -> int:
return int(floor(float(points) * float(effectiveness_percent) / 100.0))
# ---- 护甲等级掷点批(2026-09-16 用户拍板——判定式与档位参数全在 settlement.tres,本类只做算术)----
# 掷点折扣查档(纯函数):掷点 ≥ 阈值 → 100 全额;低掷按缺口(阈值 − 掷点)查升序边界落档,
# 返回档位值 percent(档数 = 边界数 + 1:缺口 ∈ [1, bounds[0]] → discounts[0],(bounds[i], bounds[i+1]] →
# discounts[i+1],> bounds 末值 → discounts 末位——档位不设零档,非归零)。nat 最大面的"恒全额"由调用方先行分支。
static func roll_discount_percent(roll: int, threshold: int, bounds: Array, discounts: Array) -> int:
if roll >= threshold:
return 100
var tier := 0
for i in bounds.size():
if threshold - roll > int(bounds[i]):
tier = i + 1
return int(discounts[tier])
# 吃掷点分量算术(纯函数):基线值 × 掷点折扣 ×(暴击时)暴击倍率 × 克制 percent → floor(单一取整;
# 乘法交换——折扣/暴击/克制次序无关,取整链保持现登记:每分量恰一次 floor)。暴击恒全额由 discount=100 承载。
static func rolled_component(base_value: float, discount_percent: int, crit: bool, crit_multiplier: int, effectiveness_percent: int) -> int:
var v: float = base_value * float(discount_percent) / 100.0
if crit:
v *= float(crit_multiplier) / 100.0
return int(floor(v * float(effectiveness_percent) / 100.0))
@@ -7,13 +7,14 @@
# 取号语义:全新实例首个 open_stream = scope 0(地图战斗资源 预推进术语的登记口径)。
extends Node
const STREAM_NAMES := ["board_gen", "reward_roll", "encounter"] # 2026-09-12 增 encounter(遭遇随机化批,用户拍板——registry rng_service 条目同步)
const STREAM_NAMES := ["board_gen", "reward_roll", "encounter", "armor_roll"] # 2026-09-12 增 encounter(遭遇随机化批);2026-09-16 增 armor_roll(护甲等级掷点批,用户拍板——回合内随机、逐次掷逐次取号,registry rng_service 条目同步)
# 分段表达式书写(数据配置 R2 实现级规约——禁 ≥2^63 完整字面量)
const GOLDEN_RATIO := (0x9E3779B9 << 32) | 0x7F4A7C15 # 0x9E3779B97F4A7C15
const SALT_BOARD_GEN := (0xA0761D64 << 32) | 0x78BD642F # 0xA0761D6478BD642F
const SALT_REWARD_ROLL := (0xE7037ED1 << 32) | 0xA0B428DB # 0xE7037ED1A0B428DB
const SALT_ENCOUNTER := (0x8EBC6AF0 << 32) | 0x9C88C6E3 # 0x8EBC6AF09C88C6E3(遭遇骰——registry stream_seed_derivation 权威值;旧值 0x5DEECE66D<<32 超 int64 被截断,黄金向量漂移)
const SALT_ARMOR_ROLL := (0xC2B2AE3D << 32) | 0x27D4EB2F # 0xC2B2AE3D27D4EB2F(护甲掷点流——互异奇常数 ≠ 乘数;黄金向量见冒烟/registry 同批登记)
const OUT_MASK := 0x7FFFFFFFFFFFFFFF
const INT64_MIN := -9223372036854775808
@@ -22,7 +23,7 @@ const INT64_MIN := -9223372036854775808
var _master_seed: int = -1 # -1 = 未播种(取数即致命,EC7)
var master_seed: int:
get: return _master_seed
var _counters := {"board_gen": 0, "reward_roll": 0, "encounter": 0}
var _counters := {"board_gen": 0, "reward_roll": 0, "encounter": 0, "armor_roll": 0}
func _salt_for(stream: String) -> int:
@@ -33,6 +34,8 @@ func _salt_for(stream: String) -> int:
return SALT_REWARD_ROLL
"encounter":
return SALT_ENCOUNTER
"armor_roll":
return SALT_ARMOR_ROLL
push_error("[RngService] 未知名流: %s" % stream)
assert(false, "未知名流(流集合封闭,EC12)")
return 0
@@ -54,7 +57,7 @@ func set_master_seed(seed_val: int) -> void:
if seed_val < 0:
seed_val = -seed_val
push_warning("[RngService] master_seed 负输入 abs 归一化: %d" % seed_val)
assert(_counters["board_gen"] == 0 and _counters["reward_roll"] == 0 and _counters["encounter"] == 0, "冒险中途重播种会击穿同冒险无碰撞前提(仅许全部流计数器全零时调用)")
assert(_counters["board_gen"] == 0 and _counters["reward_roll"] == 0 and _counters["encounter"] == 0 and _counters["armor_roll"] == 0, "冒险中途重播种会击穿同冒险无碰撞前提(仅许全部流计数器全零时调用)")
_master_seed = seed_val
_counters["board_gen"] = 0
_counters["reward_roll"] = 0
@@ -65,6 +68,7 @@ func new_adventure(seed_val: int) -> void:
_counters["board_gen"] = 0
_counters["reward_roll"] = 0
_counters["encounter"] = 0
_counters["armor_roll"] = 0
set_master_seed(seed_val)
@@ -68,6 +68,7 @@ func spawn(level_index: int, node_depth: int, hero_hp: int, unit_count_override:
"pattern": Array(row.pattern), # 行值快照(拷贝)
"advantage_per_round_cap": int(row.advantage_per_round_cap),
"armor_type": String(row.get("armor_type", "light")), # 护甲类型快照(克制矩阵消费面——战斗结算·战斗段;2026-09-15 命名统一批:light/ward/heavy)
"armor_class": int(row.get("armor_class", 0)), # 护甲等级快照(掷点判定阈值基数——战斗结算·战斗段;2026-09-16 护甲等级掷点批)
"steps": 0,
"alive": true,
"is_hero": false,
@@ -82,6 +82,10 @@ func validate() -> Array:
var at: String = String(row.get("armor_type", "light"))
if not ARMOR_TYPES.has(at):
errs.append({"category": "enum", "message": "%s armor_type 未知枚举: %s(域 {light, ward, heavy})" % [eid, at]})
# armor_class(可省略——缺省 0 向后兼容;2026-09-16 护甲等级掷点批:防守方掷点判定阈值基数)
var ac: int = int(row.get("armor_class", 0))
if ac < 0 or ac > 999:
errs.append({"category": "domain", "message": "%s armor_class 越域 [0,999]: %d" % [eid, ac]})
# 每行集恰一敌种(行集内 enemy_id 唯一)
var rk := "%d_%d" % [L, D]
if rowset_species.has(rk):
@@ -8,6 +8,7 @@ extends Resource
@export var max_hp: int = 50 # [1,999] knob(provisional,溯源 = 原型锚)
@export var atk: int = 6 # [1,999] knob——基础攻击(溯源 = 概念原型 HERO_BASE_ATK,provisional 归 难度曲线与Boss);消费面 = 战斗结算·战斗段先手攻击
@export var armor_class: int = 10 # [0,999] knob——防守方护甲等级基准(2026-09-16 护甲等级掷点批,暂定 10 归 难度曲线与Boss);消费面 = 战斗结算掷点判定阈值基数(ac_up 效果加算其上)
func validate() -> Array:
@@ -16,4 +17,6 @@ func validate() -> Array:
errs.append({"category": "domain", "message": "max_hp 越域 [1,999]: %d" % max_hp})
if atk < 1 or atk > 999:
errs.append({"category": "domain", "message": "atk 越域 [1,999]: %d" % atk})
if armor_class < 0 or armor_class > 999:
errs.append({"category": "domain", "message": "armor_class 越域 [0,999]: %d" % armor_class})
return errs
@@ -1,7 +1,7 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# 数据配置 SettlementTable schema(战斗结算 回填值;单行全局,Rule 4 豁免)——八字段口径。
# 数据配置 SettlementTable schema(战斗结算 回填值;单行全局,Rule 4 豁免)——八字段口径 + 克制/充能批 + 护甲等级掷点批。
# 三行致命 manifest:segorder_violation / poolmap_dangling / pperfect_oob。
class_name SettlementTable
extends Resource
@@ -21,6 +21,19 @@ extends Resource
# 原 damage_a_per_cell / damage_a_cap / damage_b_per_flag 三字段即"类型×来源"焊死的产物,已由 charge_grants 取代。
@export var charge_grants: Dictionary = {} # 奖励侧定义:触发源 → {attack_type(投哪个桶), per_unit(每单位点数), cap(单轮计数上限,0 = 不封顶)}
@export var armor_effectiveness: Dictionary = {} # 克制矩阵 percent:{攻击类型 {normal|pierce|magic}: {护甲 {light|ward|heavy}: percent}}
# ---- 护甲等级掷点批(2026-09-16 用户拍板,数字即规范——registry settlement_table.ac_roll/ac_discount_tiers 同批定稿)----
# 判定式:掷 1d20,掷点 ≥ 防守方 AC + 攻击加成 → 全额;低掷按缺口(AC−掷点)查档打折,无 miss、无零收益档;
# nat 最大面(crit_roll)= 暴击:该次基础攻击分量 ×2 且恒全额;任何攻击落地伤害全局下限 damage_floor(≥1)。
# 掷点只吃基础攻击分量(主角普通分量 / 敌方攻击拍与重击拍基线),穿刺/魔法充能与棋盘来源伤害不掷不折。
# 微调旋钮 = 档位边界(roll_tier_bounds)与档位值(roll_tier_discounts),不动 AC 值(armor_class 归单位表)。
@export var roll_faces: int = 20 # [2,1000] knob——掷点骰面(d20)
@export var attack_bonus: int = 0 # [0,999] 结构位——攻击者掷点加成,切片恒 0(元进度/武器附魔未来投值)
@export var roll_tier_bounds: Array = [] # 缺口档边界升序:缺口 ≥ bounds[i] 落入第 i 档([3,7] = 缺口1-3/4-7/8+ 三档)
@export var roll_tier_discounts: Array = [] # 档位值 percent 降序(与 bounds 同长;[75,50,25] = ×0.75/×0.5/×0.25)
@export var crit_roll: int = 20 # [1,roll_faces] knob——nat 最大面 = 暴击触发掷点(恒全额)
@export var crit_multiplier: int = 200 # [100,999] percent knob——暴击倍率(200 = ×2,只乘该次基础攻击分量)
@export var damage_floor: int = 1 # [1,999] knob——任何攻击落地伤害全局下限("被减伤归零 v=0"行语义随批退役)
@export var armor_roll_stream: String = "armor_roll" # 掷点封闭流名(rng_service STREAM_NAMES 成员;逐次掷逐次取号,可逐位回放)
const ARMOR_KEYS := ["light", "ward", "heavy"]
const ATTACK_TYPE_KEYS := ["normal", "pierce", "magic"]
@@ -42,6 +55,30 @@ func validate() -> Array:
errs.append({"category": "frozen", "message": "round_direction 结构冻结 floor: %s" % round_direction})
if pool_clamp_lo != -100 or pool_clamp_hi != 100:
errs.append({"category": "frozen", "message": "pool_clamp 域结构冻结 [-100,+100]"})
if roll_faces < 2 or roll_faces > 1000:
errs.append({"category": "domain", "message": "roll_faces 越域 [2,1000]: %d" % roll_faces})
if crit_roll < 1 or crit_roll > roll_faces:
errs.append({"category": "domain", "message": "crit_roll 越域 [1,roll_faces(%d)]: %d" % [roll_faces, crit_roll]})
if crit_multiplier < 100 or crit_multiplier > 999:
errs.append({"category": "domain", "message": "crit_multiplier 越域 [100,999]: %d" % crit_multiplier})
if damage_floor < 1 or damage_floor > 999:
errs.append({"category": "domain", "message": "damage_floor 越域 [1,999]: %d" % damage_floor})
if attack_bonus < 0 or attack_bonus > 999:
errs.append({"category": "domain", "message": "attack_bonus 越域 [0,999]: %d" % attack_bonus})
if String(armor_roll_stream) == "":
errs.append({"category": "domain", "message": "armor_roll_stream 空"})
# 档位表形状:bounds 升序且 ∈ [1, faces-1];discounts 降序且 ∈ [1,99](无零档——非归零);
# 档数 = 边界数 + 1(缺口 [1,b0] → discounts[0],(b_i,b_{i+1}] → discounts[i+1],> b 末值 → 末位档)
if roll_tier_bounds.is_empty() or roll_tier_discounts.size() != roll_tier_bounds.size() + 1:
errs.append({"category": "cross_field", "message": "roll_tier_bounds/discounts 形状违例(档数须 = 边界数 + 1): %d 边界 vs %d 档" % [roll_tier_bounds.size(), roll_tier_discounts.size()]})
else:
for i in roll_tier_bounds.size():
var b: int = int(roll_tier_bounds[i])
if b < 1 or b >= roll_faces or (i > 0 and b <= int(roll_tier_bounds[i - 1])):
errs.append({"category": "cross_field", "message": "roll_tier_bounds[%d] 越域/非升序: %d" % [i, b]})
var d: int = int(roll_tier_discounts[i])
if d < 1 or d > 99 or (i > 0 and d >= int(roll_tier_discounts[i - 1])):
errs.append({"category": "cross_field", "message": "roll_tier_discounts[%d] 越域/非降序(档位不设零档): %d" % [i, d]})
# 充能奖励定义域校验(触发源 → {attack_type, per_unit, cap})
for src in charge_grants.keys():
var g: Dictionary = charge_grants[src]
@@ -9,7 +9,7 @@ extends Resource
@export var rows: Array = [] # Array[Dictionary]
const TARGET_SIDES := ["hero", "enemy"]
const UNITS := ["percent", "flat"]
const UNITS := ["percent", "flat", "ac"] # 2026-09-16 护甲等级掷点批增 ac——AC 阈值轴效果(ac_up),非池百分比非定点值
const STACK_POLICIES := ["stack", "refresh", "independent"]
@@ -37,11 +37,13 @@ func validate() -> Array:
if not STACK_POLICIES.has(row.get("stack_policy", "")):
errs.append({"category": "enum", "message": "%s stack_policy 出域" % eid})
var m: int = int(row.get("magnitude", 0))
# 跨字段:percent ∈ [-100,100] 且 ≠0 / flat ≥ 1
# 跨字段:percent ∈ [-100,100] 且 ≠0 / flat ≥ 1 / ac ∈ [-20,20] 且 ≠0(AC 阈值轴——幅度域独立,防呆上限 20)
if unit == "percent" and (m == 0 or m < -100 or m > 100):
errs.append({"category": "cross_field", "message": "%s percent magnitude 域错: %d" % [eid, m]})
if unit == "flat" and m < 1:
errs.append({"category": "cross_field", "message": "%s flat magnitude 须 ≥1: %d" % [eid, m]})
if unit == "ac" and (m == 0 or m < -20 or m > 20):
errs.append({"category": "cross_field", "message": "%s ac magnitude 域错([-20,20] 且 ≠0): %d" % [eid, m]})
var dur: int = int(row.get("duration_settlements", 0))
if dur < 1 or dur > 99:
errs.append({"category": "domain", "message": "%s duration_settlements 越域 [1,99]: %d" % [eid, dur]})
@@ -18,7 +18,8 @@ rows = [{
"heavy_multiplier": 2,
"pattern": ["attack"],
"advantage_per_round_cap": 4,
"armor_type": "light"
"armor_type": "light",
"armor_class": 0
}, {
"level_index": 1,
"node_depth": 1,
@@ -33,7 +34,8 @@ rows = [{
"heavy_multiplier": 2,
"pattern": ["attack"],
"advantage_per_round_cap": 4,
"armor_type": "light"
"armor_type": "light",
"armor_class": 0
}, {
"level_index": 1,
"node_depth": 2,
@@ -47,7 +49,8 @@ rows = [{
"heavy_multiplier": 2,
"pattern": ["attack", "charge", "heavy"],
"advantage_per_round_cap": 4,
"armor_type": "ward"
"armor_type": "ward",
"armor_class": 0
}, {
"level_index": 1,
"node_depth": 3,
@@ -62,7 +65,8 @@ rows = [{
"heavy_multiplier": 2,
"pattern": ["attack", "charge", "heavy"],
"advantage_per_round_cap": 4,
"armor_type": "ward"
"armor_type": "ward",
"armor_class": 0
}, {
"level_index": 1,
"node_depth": 4,
@@ -76,7 +80,8 @@ rows = [{
"heavy_multiplier": 2,
"pattern": ["attack", "attack", "charge", "heavy"],
"advantage_per_round_cap": 4,
"armor_type": "heavy"
"armor_type": "heavy",
"armor_class": 0
}, {
"level_index": 2,
"node_depth": 0,
@@ -90,7 +95,8 @@ rows = [{
"heavy_multiplier": 2,
"pattern": ["attack", "charge", "heavy"],
"advantage_per_round_cap": 4,
"armor_type": "ward"
"armor_type": "ward",
"armor_class": 0
}]
; 切片调参登记 v3(2026-09-12 试玩批次 1 触发——F7 流程;C3 GDD F5 对照):
@@ -112,3 +118,7 @@ rows = [{
; 敌方攻击区间批(2026-09-14 用户拍板②第二步——"上限掺运气"敌方侧:atk 上阶/下阶,中点恒等行值;
; roll 时机 = 遭遇骰同流〔encounter〕spawn 期整战持久,每拍抖动否决〔数字可读性锚〕;B2 零随机纪律保持
; ——收 override 即确定值。宽度 ±3 provisional 归 C3 测量迭代;验收 = 完美策略档 0 加成通关 ≤40%。
; 护甲等级掷点批(2026-09-16 用户拍板):armor_class 字段入列(缺省 0 向后兼容)——防守方掷点判定阈值基数,
; 与 armor_type(克制属性轴)两概念严格分离、不做映射。首关六行暂定 0(正式行值归怪物/敌人系统设定批——
; 登记待办:第一关怪物 AC 相对较低,值待怪物设定投值;投值所有权归 难度曲线与Boss)。
@@ -6,3 +6,7 @@
script = ExtResource("1")
max_hp = 50
atk = 6
armor_class = 10
; 护甲等级掷点批(2026-09-16 用户拍板):armor_class = 10(暂定基准,归 难度曲线与Boss;
; EV 参考数按判定式复算 = 期望减免 21.25%);ac_up 增益 +1 AC/层(×2 层 = AC+2)在其上加算。
@@ -15,11 +15,11 @@ rows = [{
"pool_tag": "cell",
"tier": 2
}, {
"reward_id": "defense_up",
"reward_id": "ac_up",
"cell_type": "defense",
"polarity": "positive",
"effect_id": "defense_up",
"display_name": "减伤",
"effect_id": "ac_up",
"display_name": "护甲强化",
"power": 0,
"weight": 16,
"pool_tag": "cell",
@@ -130,3 +130,6 @@ rows = [{
; 区间行(伤害量值区间批 2026-09-14):heal_minor [2,6] · clear_blast [25,55]——中点锚 power 恒等期望当量,
; roll 时机 = 身份预分配批内追加取号(既有取号序零扰动);spark_aoe 定值行无区间字段。
; 减伤墙调参批(2026-09-13):defense_up 权重 24→16——攻击成为池内最高行(历史注记)。
; 护甲等级掷点批(2026-09-16 用户拍板):defense 型正向行换装——reward_id/effect_id defense_up → ac_up
; (显示名暂定"护甲强化",AC +1/层 ×2 层);cell_type defense / 权重 16 / tier 2 零改动(类型频次合计 79 不变);
; 百分比减伤行无双轨退役——status_effects.tres defense_up 行同批删除。
@@ -18,7 +18,6 @@ source_label_map = {
}
effect_pool_map = {
"attack_up": "hero_offense",
"defense_up": "hero_defense",
"weaken": "enemy_output",
"ambush": "flat_attach",
"enrage": "enemy_output",
@@ -57,6 +56,14 @@ armor_effectiveness = {
"heavy": 150
}
}
roll_faces = 20
attack_bonus = 0
roll_tier_bounds = [3, 7]
roll_tier_discounts = [75, 50, 25]
crit_roll = 20
crit_multiplier = 200
damage_floor = 1
armor_roll_stream = "armor_roll"
; 攻击类型 × 护甲克制批(2026-09-12 用户拍板结构;2026-09-15 命名统一批 + 抽象分离批,数值 provisional 待用户/C3 调参):
; 主角攻击 = 普通(atk×(1+attack_up),对全部护甲 100%)+ 穿刺 + 魔法(后两者由充能供给),三分量各自 floor 后相加;
@@ -72,3 +79,12 @@ armor_effectiveness = {
; sapped(我方乏力 -25%,hero_offense 池)= attack/defense/weaken 三型负向字典行
; (极性路由兑付:挖雷格 / 标错安全格按该格 cell_type 取负向行;heal 负向 = 主角直伤不经状态、blast 负向 = ambush+2 层)。
; 减伤墙调参批(2026-09-13):pool_clamp 域 [-100,+100] 结构冻结不动;减伤杠杆走 status_effects.tres(批注在该表)。
; 护甲等级掷点批(2026-09-16 用户拍板,数字即规范——战斗结算 GDD 护甲等级掷点节定稿同批):
; roll_faces=20 / roll_tier_bounds=[3,7] / roll_tier_discounts=[75,50,25] / crit_roll=20 / crit_multiplier=200 /
; damage_floor=1 / armor_roll_stream=armor_roll;attack_bonus=0 结构位(元进度/武器附魔未来投值)。
; 判定式:掷点 ≥ 防守方 AC + attack_bonus → 全额;缺口 1-3 → ×0.75、4-7 → ×0.5、8+ → ×0.25;无 miss 无零收益档;
; nat20 = 暴击 ×2 恒全额(双方同规);任何攻击落地伤害 ≥ damage_floor(全局 max(1,·))。
; 掷点只吃基础攻击分量(主角普通分量 / 敌方攻击拍与重击拍基线);穿刺/魔法充能与棋盘来源伤害必中全额。
; EV 参考数(按判定式复算):AC10 期望减免 21.25% / AC11 25% / AC12 28.75%;微调旋钮 = bounds/discounts,不动 AC 值。
; effect_pool_map 同批删 defense_up 键(减伤百分比行整体退役、无双轨)——ac_up 是 AC 阈值轴效果,不入任何池;
; off_guard(hero_defense 池)保留不动,池结构与乘区公式零改动。
@@ -14,11 +14,11 @@ rows = [{
"stack_policy": "stack",
"max_stack": 2
}, {
"effect_id": "defense_up",
"display_name": "减伤",
"effect_id": "ac_up",
"display_name": "护甲强化",
"target_side": "hero",
"magnitude": -30,
"unit": "percent",
"magnitude": 1,
"unit": "ac",
"duration_settlements": 1,
"stack_policy": "stack",
"max_stack": 2
@@ -76,3 +76,9 @@ rows = [{
; 格面资源批(2026-09-15):新增 sapped(乏力——weaken 型负向,hero_offense 池,attack_up 镜像;
; 进 effect_pool_map 同批登记)——状态表六行→七行;enrage/off_guard 语义 = attack/defense 型负向
; (标错安全格 / 踩到雷格经极性路由兑付,不再绑"标错反噬"单一来源)。
; 护甲等级掷点批(2026-09-16 用户拍板——百分比减伤增益整体换装为 AC,无双轨):
; defense_up(减伤 −30 percent ×2 层)整行退役(上方 2026-09-13 减伤墙批注随之留档)——
; "叠满池触底 ×0"免伤墙的结构面随 AC 掷点从根杜绝;新增 ac_up(护甲强化 +1 ac ×2 层)——
; unit=ac 新量纲(AC 阈值轴效果,不入 effect_pool_map 任何池),消费面 = 战斗结算掷点判定
; (防守方 AC = 主角表值 armor_class + Σac 型效果层数×幅度);off_guard(失衡 +25 percent,hero_defense 池)
; 保留不动,其余六行零改动——状态表仍七行(一行换装)。
@@ -48,7 +48,7 @@ static func _refresh_hero(stage: BattleStageView, info: HeroInfoPanelView, s: Di
var mod_text := ""
if mod_total != 0:
mod_text = "(%+d%% %s)" % [mod_total, "·".join(sources)]
stage.hero_card.set_data(hp, max_hp, atk, mod_text, _hero_status_summary(s.hero_effects))
stage.hero_card.set_data(hp, max_hp, atk, mod_text, _hero_status_summary(s.hero_effects), int(hero.get("armor_class", -1)))
info.set_hero(hp, max_hp)
info.set_attack(atk, mod_text)
@@ -80,7 +80,7 @@ static func _refresh_out_of_battle(stage: BattleStageView, info: HeroInfoPanelVi
if runner != null:
info.set_hero(runner.hero_hp, runner.effective_max_hp())
info.set_attack(runner.effective_atk(), "(关卡推进中)")
stage.hero_card.set_data(runner.hero_hp, runner.effective_max_hp(), runner.effective_atk(), "(关卡推进中)", "")
stage.hero_card.set_data(runner.hero_hp, runner.effective_max_hp(), runner.effective_atk(), "(关卡推进中)", "", int(db.hero.armor_class))
stage.clear_enemies() # 节点间隙清残位敌卡(死亡瞬间的灰化呈现已在结算行驱动完成)
info.set_rap(0, int(db.action_economy.rap_refresh_cost)) # battle_over RAP 清零反馈(行动经济 义务)
@@ -122,7 +122,7 @@ func _build_ui() -> void:
_register_modal(m)
_tune_sim = TuneSimController.new(db, tune_panel, int(db.hero.max_hp))
var start: Dictionary = UiAssembler.build_start_overlay(self)
var start: Dictionary = UiAssembler.build_start_overlay(self, db)
_start_overlay = start.overlay
_seed_edit = start.seed_edit
start.lvbtn.pressed.connect(_start_full_level)
@@ -83,7 +83,7 @@ static func build_overlays(host: Control, db, base_atk: int, base_hp: int) -> Di
# 开始覆盖层(返回 {overlay, box, seed_edit, lvbtn, tunebtn, dbgbtn}——按钮接线归组合根;
# 单场遭遇用户入口已删(2026-09-13 用户裁决——单场原语仅探针经 debug_session.start_single 使用))
static func build_start_overlay(host: Control) -> Dictionary:
static func build_start_overlay(host: Control, db = null) -> Dictionary:
var overlay := overlay_bg(host, Color8(28, 22, 18))
var box := overlay_box(overlay)
var title := UiKit.label("Dungeon Sweeper", 34, true)
@@ -109,7 +109,10 @@ static func build_start_overlay(host: Control) -> Dictionary:
var dbgbtn := UiKit.button("调试 · 连续关卡两层(HP 关内延续)")
box.add_child(dbgbtn)
var help := UiKit.label("玩法:每轮 8 AP——挖开(1AP)/插旗(1AP)/移动(免费)/连锁(免费)。\n每个非封锁格都藏一种格面资源(增伤/减伤/回血/爆裂/削弱)。开安全格、标中雷 = 正向兑付;\n开雷格、标错安全格 = 负向兑付——全部在轮末结算逐行兑现。\n停手:剩余 AP 转存 RAP;弃图刷新:RAP−8 当场换图继续挖(每轮剩 2 次)。\n敌人全灭胜、主角倒下负。悬停可看三态光标与可挖域微光。", 13, false)
# 玩法帮助文案(表值驱动——2026-09-16 AP 压速批遗留硬编码 8 随手修正;护甲等级掷点批同步"减伤"→"护甲强化")
var help_text := "玩法:每轮 %d AP——挖开(1AP)/插旗(1AP)/移动(免费)/连锁(免费)。\n每个非封锁格都藏一种格面资源(增伤/护甲强化/回血/爆裂/削弱)。开安全格、标中雷 = 正向兑付;\n开雷格、标错安全格 = 负向兑付——全部在轮末结算逐行兑现;吃掷点的基础攻击按护甲等级判定,无 miss、最低保底 1 点。\n停手:剩余 AP 转存 RAP;弃图刷新:RAP−%d 当场换图继续挖(每轮剩 %d 次)。\n敌人全灭胜、主角倒下负。悬停可看三态光标与可挖域微光。" % [
int(db.action_economy.ap_per_round), int(db.action_economy.rap_refresh_cost), int(db.action_economy.refresh_max_per_round)]
var help := UiKit.label(help_text, 13, false)
help.add_theme_color_override("font_color", Color8(178, 166, 146))
box.add_child(help)
@@ -5,7 +5,7 @@
# highlight 金框〔flag_hit/clear_reward_h2/perfect_broken〕、击杀标记)。
# Date: 2026-09-12(基础 = 2026-09-11 13 类行协议格式化版;2026-09-15 格面资源批 15→10 类:
# 删 item / consumed_ineffective / use_rejected / window_open / window_skip 五类——可选类道具与窗口下线,
# 战斗结算 行协议清单同步)
# 战斗结算 行协议清单同步;2026-09-16 护甲等级掷点批:伤害行补掷点读数/档位随行标注 + nat20 金框高亮)
# 高内聚边界:战斗结算 步骤流 → 玩家文案的全部格式化规则内聚于此;来源文案映射来自 SettlementTable。
class_name BattleLogView
extends PanelContainer
@@ -176,9 +176,20 @@ func _format_row(row: Dictionary, agg_count := -1) -> String:
elif row.get("pierce", null) != null:
# 主角攻击三分量拆行(攻击类型×护甲克制批——P2 归因:这刀里哪段是穿刺/魔法挣的)
extra = "(普通 %d + 穿刺 %d + 魔法 %d)" % [int(row.normal), int(row.pierce), int(row.magic)]
# 护甲等级掷点留档(2026-09-16 掷点批——掷点读数与档位随行可读;吃掷点分量恒带,棋盘来源行不带)
var roll_txt := ""
if row.get("roll", null) != null:
roll_txt = "〔掷 %d vs 护甲 %d" % [int(row.roll), int(row.ac)]
if bool(row.get("crit", false)):
roll_txt += " · 暴击×%d" % int(_db.settlement.crit_multiplier)
elif int(row.tier) < 100:
roll_txt += " · ×%d%%" % int(row.tier)
roll_txt += "〕"
var kill := " ⚔击杀!" if row.get("killed", false) else ""
var body := "%s -%d%s%s%s%s" % [name_, v, extra, tail, kill, suffix]
var body := "%s -%d%s%s%s%s%s" % [name_, v, extra, roll_txt, tail, kill, suffix]
var body_col := "#e07050" if row.get("killed", false) else ("#9a8a72" if v == 0 else "#e0a050")
if bool(row.get("crit", false)):
return prefix + _gold_frame(body) # nat 最大面暴击 = 金框高亮行
return prefix + "[color=%s]%s[/color]" % [body_col, body]
"heal":
return prefix + "[color=#7cc47c]%s +%d%s%s[/color]" % [name_, v, tail, suffix]
@@ -22,6 +22,7 @@ var _intent_badge: Label
var _ring: Label
var _status_label: Label
var _armor := "light" # 护甲类型快照(set_data 写入、set_intent 渲染——环点行常驻标签)
var _armor_class := 0 # 护甲等级快照(2026-09-16 护甲等级掷点批——环点行常驻;与护甲类型两轴分立)
var _dead := false
@@ -72,6 +73,7 @@ func set_data(d: Dictionary) -> void:
_hp_bar.value = int(d.get("hp", 0))
_set_order(int(d.get("order", 1)))
_armor = String(d.get("armor_type", "light"))
_armor_class = int(d.get("armor_class", 0))
set_intent(String(d.get("beat", "")), int(d.get("ring_size", 0)), int(d.get("ring_index", 0)))
var ambush: int = int(d.get("ambush_layers", 0))
_status_label.text = "伏击池 ×%d(全体共享)" % ambush if ambush > 0 else ""
@@ -101,13 +103,14 @@ func set_intent(beat: String, ring_size: int, ring_index: int) -> void:
else:
_intent_badge.text = "意图:" + label + ("▼" if beat == "heavy" else "")
# 环点 = 拍数直觉编码(● 已到拍 / ○ 未到)+ 护甲类型标签(克制对策的可读前提——恒常驻非悬停)
# + 护甲等级 AC(掷点折扣阈值——2026-09-16 护甲等级掷点批;与护甲类型两轴分立可读)
if ring_size > 0:
var dots := ""
for i in ring_size:
dots += "●" if i == ring_index else "○"
_ring.text = "%s · 环 %s(%d 拍)" % [_armor_tag(_armor), dots, ring_size]
_ring.text = "%s · AC %d · 环 %s(%d 拍)" % [_armor_tag(_armor), _armor_class, dots, ring_size]
else:
_ring.text = _armor_tag(_armor)
_ring.text = "%s · AC %d" % [_armor_tag(_armor), _armor_class]
func _armor_tag(armor: String) -> String:
@@ -12,6 +12,7 @@ var _portrait: Label
var _hp_bar: ProgressBar
var _hp_label: Label
var _atk_label: Label
var _ac_label: Label
var _status_label: Label
@@ -49,15 +50,18 @@ func _init() -> void:
col.add_child(_hp_label)
_atk_label = UiKit.label("攻击 —", 13, false)
col.add_child(_atk_label)
_ac_label = UiKit.label("护甲 —", 13, false)
col.add_child(_ac_label)
_status_label = _fixed_label(46) # 缺陷①约束:固定槽高 + clip——状态文案切换零回流
col.add_child(_status_label)
func set_data(hp: int, max_hp: int, atk: int, atk_mod: String, status_summary: String) -> void:
func set_data(hp: int, max_hp: int, atk: int, atk_mod: String, status_summary: String, ac: int = -1) -> void:
_hp_bar.max_value = maxi(1, max_hp)
_hp_bar.value = hp
_hp_label.text = "HP %d / %d" % [hp, max_hp]
_atk_label.text = "攻 %d %s" % [atk, atk_mod]
_ac_label.text = "护甲 %d" % ac if ac >= 0 else "护甲 —"
_status_label.text = status_summary
@@ -65,6 +69,7 @@ func set_idle() -> void:
_hp_bar.value = 0
_hp_label.text = "HP —"
_atk_label.text = "攻击 —"
_ac_label.text = "护甲 —"
_status_label.text = ""
@@ -11,11 +11,12 @@ extends HBoxContainer
const MAX_SLOTS := 4
# 极性/语义映射(数据源 = SettlementTable.effect_pool_map 效果语义 + StatusEffectTable display_name):
# 益 = ▲/◆/▼(金/蓝族);害 = ⚡/⚔/✕/☾(旗朱族)——形状+色相双通道,文字名常驻第三通道。
# 状态表七行全映射(attack_up/defense_up/weaken/ambush/enrage/off_guard/sapped)——HUD框架 图标键契约
# "一键一枚专属纹样,查不到的键立即断言 + ? 占位 + 诊断"(2026-09-15 格面资源批补 sapped 键)。
# 状态表七行全映射(attack_up/ac_up/weaken/ambush/enrage/off_guard/sapped)——HUD框架 图标键契约
# "一键一枚专属纹样,查不到的键立即断言 + ? 占位 + 诊断"(2026-09-15 格面资源批补 sapped 键;
# 2026-09-16 护甲等级掷点批:defense_up → ac_up 换装)。
const EFFECT_ICON := {
"attack_up": ["▲", Color8(176, 122, 20), "益"],
"defense_up": ["◆", Color8(46, 110, 142), "益"],
"ac_up": ["◇", Color8(46, 110, 142), "益"],
"weaken": ["▼", Color8(46, 110, 142), "益"],
"ambush": ["⚡", Color8(198, 61, 34), "害"],
"enrage": ["⚔", Color8(198, 61, 34), "害"],
@@ -24,7 +25,7 @@ const EFFECT_ICON := {
}
const EFFECT_SEMANTIC := {
"attack_up": "主角攻击加成(hero_offense 池加算)",
"defense_up": "受到伤害减免(hero_defense 池加算)",
"ac_up": "护甲等级 +1/层(掷点折扣判定阈值——AC 数值轴,受击低掷按档位打折)",
"weaken": "敌方输出削弱(enemy_output 池乘算)",
"ambush": "敌方全体攻击附加(flat_attach——blast 型负向,战斗段共享不消耗)",
"enrage": "敌方输出加成(enemy_output 池加算——attack 型负向)",
@@ -1,12 +1,12 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-09-13(playtest-2026-09-12 报告 Top1——零承伤根因分割探针)
# 逐结算行打印 beat / atk.total / hero_defense 池值 / d_tick,分割报告悬置的两个嫌疑:
# H1 defense_up 叠满墙(设计语义——hero_defense 池触 clamp 下限 = 全额免伤)
# H2 Boss/敌攻击链缺陷(bug——fresh 池下攻击拍仍零兑现)
# 快照盲区说明:settlement_snapshot 取在衰减段之后,时效 1 的 defense_up 当轮授予当轮衰减——
# 真人局 JSONL buffs[] 恒空 ≠ 池为空,本探针改从结算行(授予/续存 effect 行的 pool_value)重建战斗段时点池值。
# 观测面:全部读战斗结算步骤行 / 表值 / 呈现快照(零核心穿透);池归属/墙深/池界全读表(数据配置 Rule 1——零数值字面量)。
# 模式(-- 后参数):boss [seed](默认 42:Run A 零挖掘 fresh 池 + Run B 自动挖掘墙行为)
# 2026-09-16 改掷点口径(护甲等级掷点批):defense_up 墙已随 AC 换装退役——
# H1 旧 defense_up 叠满墙失义(hero_defense 池现仅 off_guard 正向行,×0 墙结构面消失)
# H2 Boss/敌攻击链健康:fresh 池下非蓄力拍 d_tick = max(下限, 池乘区(掷点档位基线 + 伏击附加))
# 快照盲区说明:settlement_snapshot 取在衰减段之后,时效 1 的 ac_up 当轮授予当轮衰减——
# 真人局 JSONL buffs[] 恒空 ≠ 状态为空,本探针改从结算行(授予/续存 effect 行的 pool_value)重建战斗段时点池值。
# 观测面:全部读战斗结算步骤行 / 表值 / 呈现快照(零核心穿透);池归属/档位/下限全读表(数据配置 Rule 1——零数值字面量)。
# 模式(-- 后参数):boss [seed](默认 42:Run A 零挖掘 fresh 池 + Run B 自动挖掘授予行为)
# level [seed](全关遭遇回放 + 多种子 spawn 扫描——报告 Bug#1 证据面)
# 运行:Godot_console.exe --headless --path . --script res://smoke/damage_probe.gd -- boss 42
# Godot_console.exe --headless --path . --script res://smoke/damage_probe.gd -- level 42
@@ -53,31 +53,35 @@ func _initialize() -> void:
func _probe_boss(db, seed_val: int) -> void:
var math = load("res://core/damage_math.gd")
var def_row: Dictionary = db.status_effect_row("defense_up")
var wall_floor: int = int(def_row.get("magnitude", 0)) * int(def_row.get("max_stack", 1))
var clamp_lo: int = int(db.settlement.pool_clamp_lo)
var clamp_hi: int = int(db.settlement.pool_clamp_hi)
print("表值面:defense_up magnitude=%d × max_stack=%d = 墙最深池 %d · pool_clamp [%d, %d]" % [
int(def_row.get("magnitude", 0)), int(def_row.get("max_stack", 1)), wall_floor, clamp_lo, clamp_hi])
var hero_def_negative := 0
for eff_id in db.settlement.effect_pool_map.keys():
if String(db.settlement.effect_pool_map[eff_id]) != "hero_defense":
continue
var row: Dictionary = db.status_effect_row(eff_id)
if not row.is_empty() and String(row.get("unit", "")) == "percent":
hero_def_negative = mini(hero_def_negative, int(row.get("magnitude", 0)))
print("表值面:hero_defense 池最负 percent 行 %d · pool_clamp [%d, %d] · damage_floor %d · AC 基准 %d(+ac_up×%d 层)" % [
hero_def_negative, clamp_lo, clamp_hi, int(db.settlement.damage_floor), int(db.hero.armor_class), int(db.status_effect_row("ac_up").get("max_stack", 0))])
print("--- Run A:fresh 池(零挖掘停手轮——无授予、无墙、攻击链裸验)---")
print("--- Run A:fresh 池(零挖掘停手轮——无授予,攻击链裸验)---")
var run_a := _run_boss(db, seed_val, false)
_print_rounds(db, math, run_a, true)
var a_ok := _assert_rounds(db, math, run_a, true)
check(a_ok.fresh_and_full, "A1 攻击链健康(fresh 池):非蓄力拍 d_tick = atk.total 全额兑现(×%d 拍)——H2 攻击链缺陷否证" % a_ok.n_attack)
check(a_ok.arith_ok, "A2 池算术完整:全部非蓄力拍 d_tick == DamageMath.pool_damage(×%d 拍)" % a_ok.n_attack)
check(a_ok.fresh_and_full, "A1 攻击链健康(fresh 池·掷点口径):非蓄力拍 d_tick = max(下限, 掷点档位基线+附加)(×%d 拍)——H2 攻击链缺陷否证" % a_ok.n_attack)
check(a_ok.arith_ok, "A2 池算术完整:全部非蓄力拍 d_tick == 掷点口径期望(×%d 拍)" % a_ok.n_attack)
check(String(run_a.result) == "defeat", "A3 攻击链致死可达:fresh 池 Boss 在 %d 轮内击倒英雄(result=%s——伤害确实落地)" % [run_a.snaps.size(), str(run_a.result)])
var run_a2 := _run_boss(db, seed_val, false)
check(JSON.stringify(run_a.rows) == JSON.stringify(run_a2.rows), "A4 同种子回放:Run A 两遍结算行逐行相同")
check(JSON.stringify(run_a.rows) == JSON.stringify(run_a2.rows), "A4 同种子回放:Run A 两遍结算行逐行相同(含 armor_roll 掷点流)")
print("--- Run B:自动挖掘(授予照常发生——当前数据墙行为实测)---")
print("--- Run B:自动挖掘(授予照常发生——当前数据承伤行为实测)---")
var run_b := _run_boss(db, seed_val, true)
_print_rounds(db, math, run_b, true)
var b_ok := _assert_rounds(db, math, run_b, false)
check(b_ok.arith_ok, "B1 池算术完整(含墙轮):全部非蓄力拍 d_tick == pool_damage(raw, hero_def, enemy_out)(×%d 拍)" % b_ok.n_attack)
check(b_ok.wall_tick_positive, "B2 墙轮不归零(当前数据重证):hero_def<0 的轮 d_tick 恒 > 0(墙轮 ×%d,最深实测池 %d)" % [b_ok.n_wall, b_ok.deepest])
var binding_floor: int = maxi(wall_floor, clamp_lo) # 墙深与池界取更浅者 = 实际可达的最深免伤池
check(wall_floor > clamp_lo, "B3 墙归零不可达(表值判定):墙最深池 %d > pool_clamp_lo %d → 最低残余 ×%.2f——旧数据墙(旧 magnitude×2 = clamp_lo 触底 → ×0)为真人局零承伤根因,09-13 调参批已拆(H1 成立=设计语义,非攻击链 bug)" % [wall_floor, clamp_lo, float(100 + binding_floor) / 100.0])
check(b_ok.arith_ok, "B1 池算术完整(含授予轮):全部非蓄力拍 d_tick == 掷点口径期望(×%d 拍)" % b_ok.n_attack)
check(b_ok.wall_tick_positive, "B2 负池轮不归零(伤害下限):hero_def<0 的轮 d_tick 恒 ≥ damage_floor(×%d 轮,最深实测池 %d)" % [b_ok.n_wall, b_ok.deepest])
check(hero_def_negative * 16 > clamp_lo or hero_def_negative >= 0, "B3 墙归零不可达(表值判定):hero_defense 池最负行 %d——叠满不可触池底 %d(×0 免伤墙结构面已随 AC 换装消失)" % [hero_def_negative, clamp_lo])
func _run_boss(db, seed_val: int, dig: bool) -> Dictionary:
@@ -200,12 +204,19 @@ func _analyze_round(db, math, rows: Array, beats: Dictionary) -> Dictionary:
elif t == "damage" and String(row.get("source_class", "")) == "enemy_action":
var baseline: int = int(row.get("baseline", 0))
var attach: int = int(row.get("attach", 0))
var raw: int = baseline + attach
# 掷点口径重建(2026-09-16 护甲等级掷点批):基线按行留档档位打折(暴击 ×倍率),附加必中
var tier: int = int(row.get("tier", 100))
var crit: bool = bool(row.get("crit", false))
var base_factor: float = float(tier) * (float(db.settlement.crit_multiplier) / 100.0 if crit else 1.0)
var rolled_base := int(floor(float(baseline) * base_factor / 100.0))
var raw: int = rolled_base + attach
var tick: int = int(row.get("value", 0))
var expected: int = math.pool_damage(raw, pd, pe)
var expected: int = maxi(int(db.settlement.damage_floor), math.pool_damage(raw, pd, pe))
var roll_txt := "掷%s" % str(int(row.get("roll", -1)))
var zeroed := "✓" if tick == expected else "✗ 算术不符!"
lines.append("拍=%s 原始=%d+%d=%d hero_def=%d enemy_out=%d d_tick=%d 期望=%d %s" % [
String(beats.get(int(row.get("round_index", 0)), "?")), baseline, attach, raw, pd, pe, tick, expected, zeroed])
lines.append("拍=%s %s/档%d%s 基线%d→掷后%d +伏击%d hero_def=%d enemy_out=%d d_tick=%d 期望=%d %s" % [
String(beats.get(int(row.get("round_index", 0)), "?")), roll_txt, tier, "暴击" if crit else "",
baseline, rolled_base, attach, pd, pe, tick, expected, zeroed])
ticks.append({"raw": raw, "tick": tick, "expected": expected, "ok": tick == expected})
elif t == "damage" and String(row.get("source_class", "")) == "hero_attack":
lines.append("主角攻击 %d(普通 %d + 穿刺 %d + 魔法 %d)→ %s" % [
@@ -1,7 +1,8 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-09-13(playtest-2026-09-12 Q1 复核——"安全格减伤是否过量导致满血打完 Boss")
# 逐结算轮统计 defense_up 授予层数(effect 行 value=-50)与该轮敌方实伤,分割"免伤墙轮"
# (≥2 层 = 池 clamp -100 = 100% 免伤)与非墙轮的实际承伤。
# 2026-09-16 改 AC 口径(护甲等级掷点批):defense_up 百分比减伤行已整体退役换装 ac_up
# (+1 护甲等级/层 ×2 层),本探针改逐结算轮统计 ac_up 授予层数(AC10→11→12)与该轮敌方实伤,
# 分割"高 AC 轮"(叠满 = AC12,期望减免 28.75%)与基线轮(AC10,期望减免 21.25%)的承伤读数。
# 运行:Godot_console.exe --headless --path . --script res://smoke/defense_scan.gd -- [N] [seed0]
extends SceneTree
@@ -20,19 +21,19 @@ func _initialize() -> void:
quit(1)
return
var autoplay = load("res://smoke/level_autoplay.gd")
print("=== 减伤叠层扫描:第一关 · N=%d · 种子 %d+%d ===" % [n, seed0, n])
print("%-6s %-6s %-8s %-10s %-12s %-12s" % ["seed", "轮数", "战斗轮", "墙轮(≥2层)", "墙轮承伤", "非墙轮承伤"])
var wall_rounds := 0
print("=== 护甲强化叠层扫描(AC 口径):第一关 · N=%d · 种子 %d+%d ===" % [n, seed0, n])
print("%-6s %-6s %-8s %-12s %-14s %-14s" % ["seed", "轮数", "战斗轮", "满层轮(AC12)", "满层轮承伤", "非满层轮承伤"])
var full_rounds := 0
var open_rounds := 0
var wall_dmg := 0
var full_dmg := 0
var open_dmg := 0
var stack_total := 0
var combat_rounds := 0
for i in range(n):
var st = autoplay.run_level(db, seed0 + i, 1, 0, 0, true)
# 逐轮聚合(按战斗分段——round_index 跨战斗重置,直接用轮号会串桶):
# effect 行 value=-50 计减伤层;enemy_action damage 计承伤
var def_by_round := {}
# effect 行 effect_id=ac_up 计护甲强化层;enemy_action damage 计承伤
var ac_by_round := {}
var dmg_by_round := {}
var battle_seq := 0
var last_round := 0
@@ -42,43 +43,43 @@ func _initialize() -> void:
battle_seq += 1 # 轮号回卷 = 新战斗
last_round = r
var key := battle_seq * 1000 + r
if String(row.get("type", "")) == "effect" and String(row.get("display_name", "")) == "减伤" \
if String(row.get("type", "")) == "effect" and String(row.get("effect_id", "")) == "ac_up" \
and String(row.get("source_class", "")) == "safe_cell":
def_by_round[key] = int(def_by_round.get(key, 0)) + 1
ac_by_round[key] = int(ac_by_round.get(key, 0)) + 1
elif String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "enemy_action":
dmg_by_round[key] = int(dmg_by_round.get(key, 0)) + int(row.get("value", 0))
for k in def_by_round:
for k in ac_by_round:
combat_rounds += 1
stack_total += int(def_by_round[k])
stack_total += int(ac_by_round[k])
var d: int = int(dmg_by_round.get(k, 0))
if int(def_by_round[k]) >= 2:
wall_rounds += 1
wall_dmg += d
if int(ac_by_round[k]) >= 2:
full_rounds += 1
full_dmg += d
else:
open_rounds += 1
open_dmg += d
print("%-6d %-6d %-8d %-10d %-12s %-12s" % [seed0 + i, st.rounds, def_by_round.size(),
_wall_count(def_by_round), str(dmg_in(dmg_by_round, def_by_round, 2)), str(dmg_in(dmg_by_round, def_by_round, 1))])
print("%-6d %-6d %-8d %-12d %-14s %-14s" % [seed0 + i, st.rounds, ac_by_round.size(),
_full_count(ac_by_round), str(dmg_in(dmg_by_round, ac_by_round, 2)), str(dmg_in(dmg_by_round, ac_by_round, 1))])
if combat_rounds > 0:
print("---- 战斗轮 %d:免伤墙轮 %d(%.0f%%,均值 %.2f 层/轮)· 墙轮承伤合计 %d(合法——-30 批墙帽 ×0.40)· 非墙轮承伤合计 %d(均 %.1f/轮)" % [
combat_rounds, wall_rounds, 100.0 * float(wall_rounds) / float(combat_rounds),
float(stack_total) / float(combat_rounds), wall_dmg, open_dmg, float(open_dmg) / float(maxi(1, open_rounds))])
print("---- 战斗轮 %d:满层轮 %d(%.0f%%,均值 %.2f 层/轮)· 满层轮承伤合计 %d(AC12——期望减免 28.75%%)· 非满层轮承伤合计 %d(均 %.1f/轮,AC10/11)" % [
combat_rounds, full_rounds, 100.0 * float(full_rounds) / float(combat_rounds),
float(stack_total) / float(combat_rounds), full_dmg, open_dmg, float(open_dmg) / float(maxi(1, open_rounds))])
quit(0)
func _wall_count(def_by_round: Dictionary) -> int:
func _full_count(ac_by_round: Dictionary) -> int:
var c := 0
for r in def_by_round:
if int(def_by_round[r]) >= 2:
for r in ac_by_round:
if int(ac_by_round[r]) >= 2:
c += 1
return c
func dmg_in(dmg_by_round: Dictionary, def_by_round: Dictionary, mode: int) -> int:
# mode=2 墙轮承伤 / mode=1 非墙轮承伤
func dmg_in(dmg_by_round: Dictionary, ac_by_round: Dictionary, mode: int) -> int:
# mode=2 满层轮承伤 / mode=1 非满层轮承伤
var t := 0
for r in def_by_round:
var walled := int(def_by_round[r]) >= 2
if (mode == 2 and walled) or (mode == 1 and not walled):
for r in ac_by_round:
var full: bool = int(ac_by_round[r]) >= 2
if (mode == 2 and full) or (mode == 1 and not full):
t += int(dmg_by_round.get(r, 0))
return t
@@ -25,7 +25,7 @@ func _run_encounter(db, L: int) -> void:
var dmg_total: Array = []
var safe_opened: Array = []
var clears: Array = [] # 每战斗清图(换新板)次数
var buff_grants: Array = [] # attack_up+defense_up 授予数
var buff_grants: Array = [] # attack_up+ac_up 授予数
var losses := 0
var timeouts := 0
for i in range(N):
@@ -61,7 +61,7 @@ func _run_battle(db, seed_val: int, L: int) -> Dictionary:
settlement.step_row.connect(func(row):
if row.type == "damage" and row.source_class == "enemy_action":
out.hero_dmg += int(row.value)
elif row.type == "effect" and row.source_class == "safe_cell" and (row.effect_id == "attack_up" or row.effect_id == "defense_up"):
elif row.type == "effect" and row.source_class == "safe_cell" and (row.effect_id == "attack_up" or row.effect_id == "ac_up"):
out.buffs += 1)
settlement.minesweeper.cell_revealed.connect(func(evt):
if evt.kind == "safe" and evt.channel != "initial":
@@ -38,6 +38,7 @@ func _initialize() -> void:
check(rng.derive_at("board_gen", 3) == 8849696722413293638, "R2 向量 board_gen m=42 scope=3")
check(rng.derive_at("reward_roll", 0) == 7422916049646135537, "R2 向量 reward_roll m=42 scope=0")
check(rng.derive_at("encounter", 0) == 1061841193803499209, "R2 向量 encounter m=42 scope=0(registry salt_encounter=0x8EBC6AF09C88C6E3 权威值)")
check(rng.derive_at("armor_roll", 0) == 4806095330042243845, "R2 向量 armor_roll m=42 scope=0(护甲等级掷点批 2026-09-16——registry salt_armor_roll=0xC2B2AE3D27D4EB2F 权威值)")
check(rng.derive_at("board_gen", 0) == rng.derive_at("board_gen", 0), "derive_at 纯函数恒等")
# scope 取号语义:首个 open_stream = scope 0;计数器单调
var s0 = rng.open_stream("board_gen")
@@ -60,7 +61,8 @@ var _rng
func _run_core_smoke(db, rng) -> void:
_db = db
_rng = rng
_smoke_wall_zero_guard()
_smoke_ac_swap_guard()
_smoke_armor_roll()
_smoke_board_generator()
_smoke_variation_framework()
_smoke_full_battle(1, 0, "L1 · grub")
@@ -196,18 +198,194 @@ func _smoke_level() -> void:
check(int(_db.hero.atk) == 6 and int(_db.hero.max_hp) == 50, "模拟后 hero 表恢复基线(atk=%d hp=%d)" % [int(_db.hero.atk), int(_db.hero.max_hp)])
# ---- 减伤墙归零护栏(2026-09-13 用户拍板:墙语义维持现状 + 护栏——BUG-0003 事故类永久免疫)----
# 表级不变式:2×defense_up.magnitude > pool_clamp_lo。违反 = 叠满两层触池底 = F2 乘区 ×0 全额免伤
# (playtest-2026-09-12 真人局 12/12 轮零承伤根因)。任何未来 magnitude/clamp 调参重新造出 ×0 墙在此红灯,
# 强制显式重审(含 clamp 收紧等结构变更——本护栏随之需要有意修订)。
# ---- 护甲等级掷点批·换装护栏(2026-09-16 用户拍板:百分比减伤行整体退役换装 AC,无双轨)----
# ① 换装在位:defense_up 状态行/池映射/奖励行三处全无残留,ac_up 三处就位(unit=ac ×2 层);
# ② 免伤墙归零护栏泛化保留(BUG-0003 事故类永久免疫):hero_defense 池任何 percent 行
# magnitude × max_stack > pool_clamp_lo——单效果叠满不可触池底(现行池内仅 off_guard 正向行,
# 墙结构面已随 AC 换装消失,本断言防未来负向行回归时静默造墙);
# ③ 单位表 armor_class 字段:主角 10(暂定)/ 敌方行 0(暂定——正式值归怪物设定批);
# ④ 掷点参数域:档位边界升序/档位值降序无零档/骰面与暴击参数在域(加载校验双保险)。
func _smoke_wall_zero_guard() -> void:
print("--- 增益状态·减伤墙归零护栏 ---")
var def_row: Dictionary = _db.status_effect_row("defense_up")
var wall_floor: int = int(def_row.get("magnitude", 0)) * int(def_row.get("max_stack", 1))
var clamp_lo: int = int(_db.settlement.pool_clamp_lo)
check(wall_floor > clamp_lo, "墙最深池 %d > pool_clamp_lo %d → 叠满不可归零(最低残余 ×%.2f)" % [
wall_floor, clamp_lo, float(100 + wall_floor) / 100.0])
func _smoke_ac_swap_guard() -> void:
print("--- 护甲等级掷点批·换装护栏 ---")
# ① 换装在位(三处无双轨)
check(_db.status_effect_row("defense_up").is_empty(), "状态表 defense_up 行已退役(无双轨)")
var ac_row: Dictionary = _db.status_effect_row("ac_up")
check(not ac_row.is_empty() and String(ac_row.get("target_side", "")) == "hero" \
and String(ac_row.get("unit", "")) == "ac" and int(ac_row.get("magnitude", 0)) == 1 \
and int(ac_row.get("max_stack", 0)) == 2 and String(ac_row.get("stack_policy", "")) == "stack",
"ac_up 行在位(hero · +1 ac · ×2 层 · stack——AC 阈值轴新量纲)")
var ac_reward := {}
for row in _db.reward.rows:
if String(row.get("cell_type", "")) == "defense" and String(row.get("polarity", "")) == "positive":
ac_reward = row
check(String(ac_reward.get("effect_id", "")) == "ac_up" and String(ac_reward.get("display_name", "")) == "护甲强化",
"奖励表 defense 正向行 effect_id = ac_up(显示名暂定护甲强化,权重/tier 不变)")
check(not _db.settlement.effect_pool_map.has("defense_up") and not _db.settlement.effect_pool_map.has("ac_up"),
"effect_pool_map 无 defense_up/ac_up 键(减伤不入池——AC 不经乘区)")
# ② 免伤墙归零护栏(泛化——BUG-0003 免疫)
var wall_ok := true
var wall_detail := ""
for eff_id in _db.settlement.effect_pool_map.keys():
if String(_db.settlement.effect_pool_map[eff_id]) != "hero_defense":
continue
var row: Dictionary = _db.status_effect_row(eff_id)
if row.is_empty() or String(row.get("unit", "")) != "percent":
continue
var floor_v: int = int(row.get("magnitude", 0)) * int(row.get("max_stack", 1))
if floor_v <= int(_db.settlement.pool_clamp_lo):
wall_ok = false
wall_detail += " %s(%d)" % [eff_id, floor_v]
check(wall_ok, "hero_defense 池 percent 行叠满不可触池底(墙最深%s > clamp_lo %d——单效果归零结构性不可达)" % [
wall_detail if wall_detail != "" else "(池内现无负向行)", int(_db.settlement.pool_clamp_lo)])
# ③ 单位表 armor_class
check(int(_db.hero.armor_class) == 10, "主角 armor_class = 10(暂定基准——EV 期望减免 21.25%%)")
var ac_all := true
for row in _db.enemy.rows:
if not row.has("armor_class") or int(row.armor_class) != 0:
ac_all = false
check(ac_all, "敌方全行 armor_class = 0(首关暂定——正式行值归怪物设定批待办)")
# ④ 掷点参数域(加载校验已过——此处钉形状语义)
var bounds: Array = _db.settlement.roll_tier_bounds
var discounts: Array = _db.settlement.roll_tier_discounts
check(int(_db.settlement.roll_faces) == 20 and int(_db.settlement.crit_roll) == 20 \
and int(_db.settlement.crit_multiplier) == 200 and int(_db.settlement.damage_floor) == 1 \
and int(_db.settlement.attack_bonus) == 0 and String(_db.settlement.armor_roll_stream) == "armor_roll",
"掷点参数拍板值在位(d20 / nat20 暴击 ×200%% / 下限 1 / attack_bonus 0 / armor_roll 流)")
check(bounds == [3, 7] and discounts == [75, 50, 25], "档位表拍板值在位(缺口 1-3 → ×0.75 · 4-7 → ×0.5 · 8+ → ×0.25——无零档)")
# ---- 护甲等级掷点批·行为面(2026-09-16 拍板验收:折扣档分布 / nat20 暴击 / 伤害下限 / 棋盘来源必中)----
func _smoke_armor_roll() -> void:
print("--- 护甲等级掷点(查档纯函数 + 暴击 + 伤害下限 + AC12 分布 + 棋盘必中)---")
# ① 查档纯函数:全掷点域逐位对照(AC10 / AC12)+ EV 锚(AC10 期望减免 21.25% —— registry 参考数)
var expect_ac10 := {100: 11, 75: 3, 50: 4, 25: 2} # 掷 10-20 全额 / 7-9 / 3-6 / 1-2
var expect_ac12 := {100: 9, 75: 3, 50: 4, 25: 4} # 掷 12-20 全额 / 9-11 / 5-8 / 1-4
for cfg in [{"ac": 10, "expect": expect_ac10}, {"ac": 12, "expect": expect_ac12}]:
var dist := {100: 0, 75: 0, 50: 0, 25: 0}
var tier_ok := true
for roll in range(1, 21):
var d: int = DamageMath.roll_discount_percent(roll, cfg.ac, _db.settlement.roll_tier_bounds, _db.settlement.roll_tier_discounts)
if not dist.has(d):
tier_ok = false
continue
dist[d] += 1
check(tier_ok and dist == cfg.expect, "查档 AC%d 全掷点域分布(100/75/50/25 = %s)" % [cfg.ac, str([dist[100], dist[75], dist[50], dist[25]])])
var ev_sum := 0
for roll in range(1, 21):
ev_sum += DamageMath.roll_discount_percent(roll, 10, _db.settlement.roll_tier_bounds, _db.settlement.roll_tier_discounts)
check(ev_sum == 1575, "AC10 EV 锚:期望系数合计 %d/2000 = 期望减免 21.25%%(nat20 恒全额含内)" % ev_sum)
# ② nat20 暴击(行为面):种子扫描定位 armor_roll scope0 首掷 = 20 的种子 → 停手轮主角先手必暴击
var crit_seed := -1
for s in range(1, 800):
var rg_probe = load("res://core/rng_service.gd").new()
rg_probe.set_master_seed(s)
if rg_probe.open_stream("armor_roll").randi_range(1, int(_db.settlement.roll_faces)) == int(_db.settlement.crit_roll):
crit_seed = s
break
check(crit_seed > 0, "nat20 种子可达(seed=%d——armor_roll scope0 首掷命中 %d)" % [crit_seed, int(_db.settlement.crit_roll)])
var crit_rows := _first_hero_attack_rows(crit_seed)
check(int(crit_rows.get("roll", -1)) == int(_db.settlement.crit_roll) and bool(crit_rows.get("crit", false)),
"nat20 → 行留档 roll=20 + crit 标记(主角先手普通分量)")
check(int(crit_rows.get("normal", -1)) == 2 * int(_db.hero.atk) and int(crit_rows.get("value", -1)) == 2 * int(_db.hero.atk),
"nat20 → 普通分量 ×2(%d = 2×%d,暴击倍率只乘该分量,穿刺/魔法不吃)" % [int(crit_rows.get("normal", -1)), int(_db.hero.atk)])
# ③ 伤害下限(行为面):削弱 ×2 = 池触底 ×0 → 落地伤害 = damage_floor(零值行已退役)
var floor_rows := _weaken_zero_round_rows()
check(not floor_rows.is_empty() and int(floor_rows[0].get("value", -1)) == int(_db.settlement.damage_floor),
"池触底 ×0 → 落地伤害 = %d(全局 max(1,·)——×%d 拍采样)" % [int(_db.settlement.damage_floor), floor_rows.size()])
var no_zero_label := true
for r in floor_rows:
if String(r.get("display_name", "")).contains("归零") or int(r.get("value", 0)) <= 0:
no_zero_label = false
check(no_zero_label, "零值伤害行语义退役:无\"被减伤归零\"行、无 v=0 伤害行(被压制/保底下限可见)")
# ④ AC12 折扣档分布(行为面):ac_up ×2 逐轮授给(时效 1)→ Boss 行攻击拍逐拍判定
var tiers := _ac12_tier_samples()
var tier_distinct := {}
var tiers_valid := true
for t in tiers:
tier_distinct[int(t)] = true
if not [100, 75, 50, 25].has(int(t)):
tiers_valid = false
check(tiers_valid and tiers.size() >= 3, "AC12 攻击拍逐拍判定(×%d 拍全部落档域 {100,75,50,25})" % tiers.size())
check(tier_distinct.size() >= 2, "折扣档实际流动(AC12 实测档位 %s——≥2 档可见)" % str(tier_distinct.keys()))
# ⑤ 棋盘来源必中 + 吃掷点行留档(全战斗流扫描)
var run = _run_battle(1, 0)
var board_ok := true
var attack_ok := true
var all_positive := true
for row in run.stream:
if String(row.get("type", "")) != "damage":
continue
if int(row.get("value", 0)) <= 0:
all_positive = false
var src := String(row.get("source_class", ""))
if src == "hero_attack" or src == "enemy_action":
if not (row.has("roll") and row.has("ac") and row.has("tier")):
attack_ok = false
elif row.has("roll"):
board_ok = false # 棋盘来源(爆裂/直伤/清障炸药)必中全额——不掷不留档
check(all_positive, "全战斗流零 v=0 伤害行(伤害下限全局生效)")
check(attack_ok, "hero_attack/enemy_action 伤害行掷点读数+档位随行留档(P2 归因)")
check(board_ok, "棋盘来源伤害行零掷点字段(伏击附加/爆裂/直伤/清障炸药必中全额)")
# 定位种子下首场战斗第一行主角攻击伤害行(停手轮——零授予零充能,普通分量 = 攻击基线裸值)
func _first_hero_attack_rows(seed_val: int) -> Dictionary:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}):
return {}
settlement.minesweeper.settle_batch([{"action": "stop"}])
for row in rows:
if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "hero_attack":
return row
return {}
# 削弱 ×2(池 enemy_output 触底 ×0)下的敌方攻击伤害行采样(D2 熊地精——首拍攻击)
func _weaken_zero_round_rows() -> Array:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260916)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)}):
return rows
# 测试通道:直授削弱 ×2(来源格取真实坐标——行来源断言要求;时效 1 当轮结算内有效)
for i in range(2):
settlement._status_effects.apply_effect("weaken", {"source_class": "mine_hit", "origin_cell": Vector2i(0, 0), "round_index": 1})
settlement.minesweeper.settle_batch([{"action": "stop"}])
var out: Array = []
for row in rows:
if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "enemy_action":
out.append(row)
return out
# AC12 折扣档采样:每停手轮前直授 ac_up ×2(时效 1 逐轮衰减)——Boss 行攻击/重击拍逐拍判定留档
func _ac12_tier_samples() -> Array:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260917)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)}):
return []
var guard := 12
while settlement.battle_result == "" and guard > 0:
guard -= 1
for i in range(2):
settlement._status_effects.apply_effect("ac_up", {"source_class": "safe_cell", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index})
settlement.minesweeper.settle_batch([{"action": "stop"}])
var tiers: Array = []
for row in rows:
if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "enemy_action":
tiers.append(int(row.get("tier", -1)))
return tiers
func _smoke_board_generator() -> void:
@@ -768,17 +946,19 @@ func _level_battle_nodes(level_index: int) -> Array:
# ---- 战斗段 fresh 池攻击链回归(playtest-2026-09-12 报告 Bug#2 沉淀——
# 「Boss 攻击拍零兑现」实为旧 defense_up 数据 ×2 叠满 = 池触底 ×0(设计语义);本断言钉死:
# 池为空时攻击链必须全额兑现——若未来出现 fresh 池零兑现(真攻击链缺陷)在此被抓)----
# 池为空时攻击链必须按掷点档位足额兑现——2026-09-16 护甲等级掷点批改口径:
# d_tick = max(damage_floor, pool_damage(floor(基线×档位%), 0, 0)),档位读数随行重建可归因)----
func _smoke_attack_chain_fresh_pool() -> void:
print("--- 战斗结算·fresh 池攻击链回归(Bug#2)---")
print("--- 战斗结算·fresh 池攻击链回归(Bug#2·掷点口径)---")
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260913)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var ticks: Array = [] # {raw, tick}——敌行动伤害行重建
var ticks: Array = [] # {baseline, attach, roll, tier, crit, tick}——敌行动伤害行重建
settlement.step_row.connect(func(row):
if row.type == "damage" and row.source_class == "enemy_action":
ticks.append({"raw": int(row.baseline) + int(row.attach), "tick": int(row.value)}))
ticks.append({"baseline": int(row.baseline), "attach": int(row.attach), "roll": int(row.roll),
"tier": int(row.tier), "crit": bool(row.get("crit", false)), "tick": int(row.value)}))
check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "fresh 池回归战斗启动(Boss 行)")
# 停手轮:零挖掘零旗 → 零授予 → 池恒空;Boss pattern 前 4 拍 = attack/attack/charge/heavy
for i in range(4):
@@ -788,9 +968,12 @@ func _smoke_attack_chain_fresh_pool() -> void:
check(ticks.size() >= 2, "非蓄力攻击拍采样充足(%d 拍——charge 拍零兑付为设计语义不计)" % ticks.size())
var all_full := true
for t in ticks:
if int(t.tick) != int(t.raw) or int(t.raw) <= 0:
var base_factor: float = float(t.tier) * (float(_db.settlement.crit_multiplier) / 100.0 if t.crit else 1.0)
var rolled_base := int(floor(float(t.baseline) * base_factor / 100.0))
var expected: int = maxi(int(_db.settlement.damage_floor), rolled_base + int(t.attach))
if int(t.tick) != expected or int(t.tick) <= 0:
all_full = false
check(all_full, "fresh 池非蓄力拍 d_tick == atk.total 全额兑现(Boss 攻击链健康——Bug#2 回归断言)")
check(all_full, "fresh 池非蓄力拍 d_tick = max(1, 掷点档位基线 + 伏击附加)(Boss 攻击链健康——Bug#2 回归断言)")
func _encounter_replay(seed_val: int) -> Array:
var out: Array = []
@@ -914,9 +1097,10 @@ func _smoke_refresh_provenance_guard() -> void:
# ==================== 格面资源批新增覆盖(极性路由 / 负向字典 / 资源面 / 五段全序) ====================
# 极性路由断言(战斗结算 验收):挖安全格/标中旗只出正向、挖雷格/标错旗只出负向、兑付行指回来源格。
# 正向字典效果集 = {attack_up, defense_up, weaken}(percent)+ spark_aoe/heal_minor(flat);
# 正向字典效果集 = {attack_up, ac_up, weaken}(ac_up = 2026-09-16 护甲等级掷点批换装,原 defense_up)
# + spark_aoe/heal_minor(flat);
# 负向字典效果集 = {enrage, off_guard, sapped}(percent)+ 直伤(heal flat)/ ambush(blast flat)。
const POSITIVE_EFFECTS := ["attack_up", "defense_up", "weaken"]
const POSITIVE_EFFECTS := ["attack_up", "ac_up", "weaken"]
const NEGATIVE_EFFECTS := ["enrage", "off_guard", "sapped", "ambush"]
@@ -1 +0,0 @@
uid://dw3rd4l55w70r
@@ -0,0 +1,612 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""试玩日志分析器(垂直切片 tools/ 专用,纯标准库,零第三方依赖)。
消费 presentation/playtest_logger.gd 产生的 playtest_*.jsonl,
产出 关卡 / 战斗 / 轮次 三级统计,并为护甲等级(AC)掷点批预留验收统计面。
已知测量盲区(本工具的核心补偿,勿绕过):
settlement 快照在衰减段之后发出、且现有状态寿命全部为 1 结算
→ 快照 buffs 字段恒为空。战斗期真实增益只能从 step 行(type=effect,
文本 增伤/削弱/减伤/…)按结算轮窗口复原。本工具从不把快照 buffs
当战斗期状态使用,只将其并列输出以持续暴露盲区。
AC 掷点批预留位:
落批后伤害 step 行会新增掷点字段(读数/档位/暴击,字段名以落批实际为准)。
本工具按 FIELD_CANDIDATES 探测:字段存在 → 激活掷点统计面;
不存在(AC 批前旧日志)→ 降级输出"不适用(AC 批前日志)"。
同时所有 step 行的未知键都会进"数据质量"观测面——落批后先看那里
拿到真实字段名,再在 FIELD_CANDIDATES 里补一行适配即可。
用法:
python log_analysis.py <jsonl 文件 | 目录 | 通配> [--per-run] [--rounds]
目录/通配 = 批量汇总;--per-run 附加逐局明细;--rounds 附加逐轮流水。
"""
import argparse
import glob
import json
import os
import sys
import time
from collections import Counter, defaultdict
# ---------------------------------------------------------------- 常量与映射
# step 行已知键(schema 事实源 = playtest_logger.gd _on_step_row,2026-09-16 版)
STEP_KNOWN_KEYS = {"kind", "type", "source", "cell", "round", "text", "value", "highlight"}
# AC 掷点批候选字段名(落批后按实际字段名在此登记——预留的一行适配点)
ROLL_FIELD_CANDIDATES = ("roll", "d20", "dice", "nat", "nat_roll", "roll_value")
TIER_FIELD_CANDIDATES = ("tier", "discount", "discount_tier", "damage_tier", "bracket")
CRIT_FIELD_CANDIDATES = ("crit", "critical", "is_crit", "nat20")
AC_FIELD_CANDIDATES = ("ac", "target_ac", "defense_ac")
# effect 行显示文本 → 增益语义键(未登记文本原样作键,新增益自动浮现)
TEXT_TO_BUFF = {"增伤": "attack_up", "削弱": "weaken", "减伤": "defend_up"}
# 档位语义:缺口 = AC − 掷点读数(读数达标 = 全额)
TIER_MULTIPLIER = {"全额": 1.0, "×0.75": 0.75, "×0.5": 0.5, "×0.25": 0.25}
THEORY_EV_REDUCTION_AC10 = 0.2125 # AC10 理论 EV 减免(任务口径)
THEORY_CRIT_RATE = 0.05 # nat20 暴击率理论值
CRIT_TOLERANCE = 0.02 # 健康检查宽容度 ±2 个百分点
BAND_NORMAL = 0.20 # 承伤带:普通场 ≤20% max_hp
BAND_BOSS = 0.50 # 承伤带:Boss 场 ≤50% max_hp
BATTLE_TYPES = ("battle", "boss")
def _first(row, candidates):
"""返回 row 中第一个命中的候选字段值。"""
for c in candidates:
if c in row:
return row[c]
return None
# ---------------------------------------------------------------- 逐局状态机
class BattleAcc:
"""一场战斗(battle/boss 节点)的累计器。"""
def __init__(self, node_type, depth):
self.node_type = node_type
self.depth = depth
self.rounds = [] # 每结算一个 dict(见 RunAcc._finalize_round)
self.damage = 0 # Σ 敌方攻击落地伤害(enemy_action × type=damage 的 value)
self.heals = 0 # Σ 主角治疗(type=heal 的 value)
self.attack_rounds = 0 # 有敌方攻击落地的轮数
self.max_hp = None
self.start_hp = None # 首结算快照 HP + 首轮承伤 − 首轮治疗 反推的进场 HP
self.last_hp = None
class RunAcc:
"""一局(run 行切分;无 run 行的旧日志 = 整文件一局,种子取 session 行)。"""
def __init__(self, src_file, session_seed):
self.src_file = src_file
self.session_seed = session_seed
self.seed = session_seed
self.policy = ""
self.level_index = None
self.result = None
self.end_hp = None
self.max_hp = None
self.round_steps = [] # 当前结算轮窗口内的 step 行缓冲
self.battle = None
self.battles = []
self.battle_fifo = [] # battle_end 延迟发出且不带 depth,按 FIFO 对账
self.late_battle_ends = 0 # 未能配对的 battle_end(数据质量)
self.boss_node_seen = False
self.rounds_total = 0
self.rounds_with_enemy = 0
self.damage_total = 0
self.heals_total = 0
self.zero_narrow = 0 # 零承伤轮(窄口径,AC 验收主指标)
self.attack_landed_rounds = 0
self.zero_loss_wide = 0 # 零掉血轮(宽口径,参考)
self.snapshot_buffs_nonempty = 0 # 盲区暴露面:快照 buffs 恒空计数
self.snapshot_buffs_total = 0
self.round_ledger = [] # --rounds 逐轮流水
self.roll_rows = [] # 命中掷点候选字段的 step 行
self._prev_hp = None # 上一结算快照的主角 HP(宽口径逐轮差分用)
self._last_enemy_present = False
# -- 事件入口 ---------------------------------------------------------
def on_run_row(self, e):
self.seed = e.get("seed", self.session_seed)
self.policy = "" if e.get("policy") is None else str(e.get("policy"))
self.level_index = e.get("level_index")
def on_node(self, e):
t = e.get("type", "")
if t in BATTLE_TYPES:
# 先收掉上一场(战斗的 settlement 严格落在相邻 node 行之间)
self._close_battle()
self.battle = BattleAcc(t, e.get("depth"))
if t == "boss":
self.boss_node_seen = True
# rest / reward 节点不切战斗上下文(与 battle 同 index 出现)
def on_step(self, e):
self.round_steps.append(e)
if any(c in e for c in ROLL_FIELD_CANDIDATES):
self.roll_rows.append(e)
def on_settlement(self, e):
b = self.battle
self.rounds_total += 1
hero = e.get("hero", {})
hp = int(hero.get("hp", 0))
if hero.get("max_hp"):
self.max_hp = int(hero.get("max_hp"))
enemy_present = bool(e.get("enemies"))
if enemy_present:
self.rounds_with_enemy += 1
# 盲区暴露面:快照 buffs 只计数对照,从不当作战斗期状态
self.snapshot_buffs_total += 1
if e.get("buffs"):
self.snapshot_buffs_nonempty += 1
self._finalize_round(e, b, hp, enemy_present)
def on_battle_end(self, e):
# 延迟发出、不带 depth:只做 FIFO 对账(数据质量),核心统计不依赖它
if self.battle_fifo:
self.battle_fifo.pop(0)
else:
self.late_battle_ends += 1
def on_level_end(self, e):
self._close_battle()
self.result = e.get("result", "")
self.end_hp = int(e.get("hp", 0))
# -- 轮窗口收口(核心口径都在这里)------------------------------------
def _finalize_round(self, snap, battle, hp, enemy_present):
steps, self.round_steps = self.round_steps, []
dmg = sum(int(s.get("value", 0)) for s in steps
if s.get("type") == "damage" and s.get("source") == "enemy_action")
heal = sum(int(s.get("value", 0)) for s in steps if s.get("type") == "heal")
attacks = sum(1 for s in steps
if s.get("type") == "damage" and s.get("source") == "enemy_action")
charges = sum(1 for s in steps if s.get("type") == "charge")
# 蓄力拍(type=charge,含空拍/白喂)不算"攻击落地"——验收口径硬性排除。
# 战斗期增益复原:本结算轮窗口内的 effect 行(快照 buffs 恒空,禁用)
buffs = Counter()
for s in steps:
if s.get("type") == "effect":
key = TEXT_TO_BUFF.get(s.get("text", ""), s.get("text", ""))
buffs[key or "<未登记effect文本>"] += 1
# 宽口径(整轮掉血 0):首轮用进场反推 HP 差分,之后用相邻快照差
if self._prev_hp is None:
entry_hp = hp + dmg - heal
loss = entry_hp - hp
else:
loss = max(0, self._prev_hp - hp)
self._prev_hp = hp
zero_narrow = attacks > 0 and dmg == 0 # 窄口径:有攻击落地且掉血 0
zero_wide = loss == 0 # 宽口径:整轮 HP 不降(聚合时限敌战轮)
self.damage_total += dmg
self.heals_total += heal
if attacks > 0:
self.attack_landed_rounds += 1
if zero_narrow:
self.zero_narrow += 1
if zero_wide:
self.zero_loss_wide += 1
rec = {
"type": battle.node_type if battle else "?",
"depth": snap.get("depth"), "round": snap.get("round"),
"dmg": dmg, "heal": heal, "hp": hp,
"attacks": attacks, "charges": charges,
"zero_narrow": zero_narrow, "zero_wide": zero_wide,
"buffs": dict(buffs),
"path": (snap.get("round_facts") or {}).get("path", ""),
"enemy_present": enemy_present,
}
self.round_ledger.append(rec)
if battle is not None:
battle.rounds.append(rec)
battle.damage += dmg
battle.heals += heal
battle.attack_rounds += 1 if attacks > 0 else 0
battle.max_hp = self.max_hp
battle.last_hp = hp
if battle.start_hp is None:
battle.start_hp = hp + dmg - heal
def _close_battle(self):
if self.battle is None:
return
b, self.battle = self.battle, None
if b.rounds:
# 有过敌人的战斗(任一轮出现过敌人或攻击落地)——宽口径分母用其全部轮数。
# 不能按单轮快照 enemies 判定:敌人当轮被歼的轮快照为空,但轮仍属于敌战。
b.enemy_flag = any(r["attacks"] > 0 or r["enemy_present"] for r in b.rounds)
for rec in b.rounds:
rec["enemy_battle"] = b.enemy_flag
self.battles.append(b)
self.battle_fifo.append(b)
# -- 派生量 -----------------------------------------------------------
def boss_battle(self):
for b in self.battles:
if b.node_type == "boss":
return b
return None
def boss_enter_hp(self):
"""进 Boss HP = Boss 首结算快照 HP + 首轮承伤 − 首轮治疗(精确反推)。"""
b = self.boss_battle()
if b is None or not b.rounds:
return None
r0 = b.rounds[0]
return r0["hp"] + r0["dmg"] - r0["heal"]
# ---------------------------------------------------------------- 文件解析
class FileResult:
def __init__(self, path):
self.path = path
self.lines = 0
self.bad_lines = 0
self.parse_seconds = 0.0
self.session_seeds = []
self.runs = []
self.unknown_step_keys = Counter() # 未知 step 键 → 次数(AC 落批观察面)
self.unknown_step_samples = {} # 未知 step 键 → 样例值
self.dice_active = False
def parse_file(path):
res = FileResult(path)
t0 = time.perf_counter()
run = None
with open(path, encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
res.lines += 1
try:
e = json.loads(line)
except json.JSONDecodeError:
res.bad_lines += 1
continue
kind = e.get("kind", "")
if kind == "session":
res.session_seeds.append(e.get("seed"))
if run is None:
run = RunAcc(os.path.basename(path), e.get("seed"))
res.runs.append(run)
elif kind == "run":
if run is not None and (run.rounds_total or run.result or run.battles):
run = RunAcc(os.path.basename(path), res.session_seeds[-1] if res.session_seeds else None)
res.runs.append(run)
elif run is None:
run = RunAcc(os.path.basename(path), None)
res.runs.append(run)
run.on_run_row(e)
elif run is None:
# 无 session/run 头的残缺文件:兜底开一局,保证数据不丢
run = RunAcc(os.path.basename(path), None)
res.runs.append(run)
_feed(run, res, kind, e)
else:
_feed(run, res, kind, e)
if run is not None:
run._close_battle()
res.parse_seconds = time.perf_counter() - t0
res.dice_active = any(r.roll_rows for r in res.runs)
return res
def _feed(run, res, kind, e):
if kind == "node":
run.on_node(e)
elif kind == "step":
run.on_step(e)
for k in e:
if k not in STEP_KNOWN_KEYS:
res.unknown_step_keys[k] += 1
res.unknown_step_samples.setdefault(k, repr(e[k])[:60])
elif kind == "settlement":
run.on_settlement(e)
elif kind == "battle_end":
run.on_battle_end(e)
elif kind == "level_end":
run.on_level_end(e)
# action / ux / diagnosis 等其余 kind:本工具当前无消费面,静默跳过
def resolve_inputs(arg):
"""文件 / 目录 / 通配 → 排序后的 playtest_*.jsonl 列表。"""
if os.path.isdir(arg):
return sorted(glob.glob(os.path.join(arg, "playtest_*.jsonl")))
if any(ch in arg for ch in "*?["):
return sorted(glob.glob(arg))
return [arg]
# ---------------------------------------------------------------- 聚合与渲染
def fmt(x, nd=1):
return "—" if x is None else f"{x:.{nd}f}"
def aggregate_levels(runs):
"""关卡级:按(关卡, 档位)聚合。"""
groups = defaultdict(list)
for r in runs:
groups[(r.level_index, r.policy)].append(r)
rows = []
for (lvl, policy), rs in sorted(groups.items(), key=lambda kv: (str(kv[0][0]), str(kv[0][1]))):
n = len(rs)
wins = sum(1 for r in rs if r.result == "victory")
boss_rs = [r for r in rs if r.boss_enter_hp() is not None]
win_rs = [r for r in rs if r.result == "victory"]
rows.append({
"level": lvl, "policy": policy or "—", "runs": n,
"clear_rate": wins / n if n else None,
"avg_rounds": sum(r.rounds_total for r in rs) / n if n else None,
"avg_boss_hp": (sum(r.boss_enter_hp() for r in boss_rs) / len(boss_rs)) if boss_rs else None,
"avg_end_hp": (sum(r.end_hp for r in win_rs) / len(win_rs)) if win_rs else None,
"avg_dmg": sum(r.damage_total for r in rs) / n if n else None,
})
return rows
def aggregate_battles(runs):
"""战斗级:按(关卡, 节点类型, 深度)分组,普通场与 Boss 场分开对照承伤带。"""
groups = defaultdict(list)
for r in runs:
for b in r.battles:
if b.max_hp:
groups[(r.level_index, b.node_type, b.depth)].append(b)
rows = []
for (lvl, ntype, depth), bs in sorted(
groups.items(), key=lambda kv: (str(kv[0][0]), kv[0][1], str(kv[0][2]))):
n = len(bs)
avg_dmg = sum(b.damage for b in bs) / n
avg_ratio = sum(b.damage / b.max_hp for b in bs) / n
band = BAND_BOSS if ntype == "boss" else BAND_NORMAL
rows.append({
"level": lvl, "type": ntype, "depth": depth, "count": n,
"avg_dmg": avg_dmg, "avg_ratio": avg_ratio,
"band": band, "pass": avg_ratio <= band,
"avg_rounds": sum(len(b.rounds) for b in bs) / n,
"avg_attack_rounds": sum(b.attack_rounds for b in bs) / n,
})
return rows
def aggregate_rounds(runs):
t = sum(r.rounds_total for r in runs)
e = sum(r.rounds_with_enemy for r in runs)
a = sum(r.attack_landed_rounds for r in runs)
zn = sum(r.zero_narrow for r in runs)
# 宽口径分母 = 有过敌人的战斗的全部轮数(含敌人当轮被歼的收尾轮)
enemy_battle_rounds = 0
zw = 0
for r in runs:
for b in r.battles:
if getattr(b, "enemy_flag", False):
enemy_battle_rounds += len(b.rounds)
zw += sum(1 for rec in b.rounds if rec["zero_wide"])
return {"rounds": t, "with_enemy": e, "attack_landed": a,
"zero_narrow": zn, "zero_loss_wide": zw,
"enemy_battle_rounds": enemy_battle_rounds}
def derive_tier(roll, ac):
"""缺口 = AC − 掷点读数 → 档位(1-3/4-7/8+ → 75/50/25%,达标=全额)。"""
gap = ac - roll
if gap <= 0:
return "全额"
if gap <= 3:
return "×0.75"
if gap <= 7:
return "×0.5"
return "×0.25"
def aggregate_dice(res_files):
"""掷点面(AC 批落地后激活)。字段名以 FIELD_CANDIDATES 命中为准。"""
rolls = [] # (direction, roll, tier, crit)
for res in res_files:
for run in res.runs:
for row in run.roll_rows:
roll = _first(row, ROLL_FIELD_CANDIDATES)
if not isinstance(roll, int):
continue
direction = "主角→敌" if row.get("source") == "hero_attack" else "敌→主角"
ac = _first(row, AC_FIELD_CANDIDATES)
ac = int(ac) if isinstance(ac, int) else 10 # 基线 AC10
tier_v = _first(row, TIER_FIELD_CANDIDATES)
tier = tier_v if tier_v in TIER_MULTIPLIER else derive_tier(roll, ac)
crit_v = _first(row, CRIT_FIELD_CANDIDATES)
crit = bool(crit_v) if crit_v is not None else (roll == 20)
rolls.append((direction, roll, tier, crit))
if not rolls:
return None
hist = Counter(r for _, r, _, _ in rolls)
tiers = Counter(t for _, _, t, _ in rolls)
crits = sum(1 for *_, c in rolls if c)
ev_red = 1 - sum(TIER_MULTIPLIER[t] for _, _, t, _ in rolls) / len(rolls)
by_dir = defaultdict(list)
for d, r, t, c in rolls:
by_dir[d].append((r, t, c))
return {"hist": hist, "tiers": tiers, "n": len(rolls),
"crit_rate": crits / len(rolls), "ev_reduction": ev_red, "by_dir": by_dir}
def render_dice(dice, out):
print("== 掷点面(AC 批验收统计)==", file=out)
if dice is None:
print(" 不适用(AC 批前日志):本日志无掷点字段。", file=out)
print(" 其余指标照常输出;落批后重跑本工具,先看【数据质量】的未知字段观测面。", file=out)
return
print(f" 样本 {dice['n']} 次掷点", file=out)
print(" 掷点直方图(d20):", file=out)
for v in range(1, 21):
c = dice["hist"].get(v, 0)
if c:
print(f" {v:2d}: {'#' * min(c, 60)}{c if c > 60 else ''}", file=out)
n = dice["n"]
print(" 档位占比:", file=out)
for t in ("全额", "×0.75", "×0.5", "×0.25"):
c = dice["tiers"].get(t, 0)
print(f" {t:>6}: {c:6d} ({c / n * 100:5.1f}%)", file=out)
cr = dice["crit_rate"]
ok = abs(cr - THEORY_CRIT_RATE) <= CRIT_TOLERANCE
print(f" nat20 暴击率: {cr * 100:.2f}%(理论 {THEORY_CRIT_RATE * 100:.0f}% ±{CRIT_TOLERANCE * 100:.0f}pp)"
f" → {'健康' if ok else '超界,需排查'}", file=out)
print(f" 观察 EV 减免: {dice['ev_reduction'] * 100:.2f}%"
f"(理论 AC10 = {THEORY_EV_REDUCTION_AC10 * 100:.2f}%)", file=out)
print(" 按攻击方向:", file=out)
for d, items in sorted(dice["by_dir"].items()):
rr = [r for r, _, _ in items]
cc = sum(1 for *_, c in items if c)
red = 1 - sum(TIER_MULTIPLIER[t] for _, t, _ in items) / len(items)
print(f" {d}: n={len(items)} 均值掷点={sum(rr) / len(rr):.2f} "
f"暴击率={cc / len(items) * 100:.2f}% EV减免={red * 100:.2f}%", file=out)
def render(res_files, args, out):
total_lines = sum(r.lines for r in res_files)
total_secs = sum(r.parse_seconds for r in res_files)
all_runs = [run for res in res_files for run in res.runs]
print("== 输入概况 ==", file=out)
print(f" 文件 {len(res_files)} 个,共 {total_lines} 行,解析 {total_secs:.2f}s"
f"(大文件单文件耗时见下)", file=out)
for res in res_files:
dice_tag = "掷点面:激活" if res.dice_active else "掷点面:本日志无掷点字段(AC 批前日志,已降级)"
print(f" {os.path.basename(res.path)}: {res.lines} 行 / {res.parse_seconds:.2f}s / "
f"{len(res.runs)} 局 / {dice_tag}", file=out)
print(file=out)
render_dice(aggregate_dice(res_files), out)
print(file=out)
print("== 关卡级统计 ==", file=out)
print(" 口径:局=run 行切分(无 run 行的旧日志整文件一局);通关=result=victory;", file=out)
print(" 进BossHP=Boss首结算快照HP+首轮承伤−首轮治疗(精确反推);平均轮数=每局settlement数。", file=out)
hdr = f" {'关卡':>6} {'档位':>8} {'局数':>5} {'通关率':>7} {'平均轮数':>8} {'进BossHP均':>10} {'通关剩余HP均':>12} {'整局承伤均':>10}"
print(hdr, file=out)
for row in aggregate_levels(all_runs):
print(f" {str(row['level']):>6} {row['policy']:>8} {row['runs']:>5} "
f"{fmt(row['clear_rate'] * 100 if row['clear_rate'] is not None else None):>6}% "
f"{fmt(row['avg_rounds']):>8} {fmt(row['avg_boss_hp']):>10} "
f"{fmt(row['avg_end_hp']):>12} {fmt(row['avg_dmg']):>10}", file=out)
print(file=out)
print("== 战斗级统计(按 关卡/节点类型/深度 分组;对照承伤带 普通≤20%、Boss≤50% max_hp)==", file=out)
print(f" {'关卡':>6} {'类型':>6} {'深度':>4} {'场数':>5} {'场均轮数':>8} {'场均承伤':>8} {'承伤/max_hp':>11} {'承伤带':>7} {'判定':>4}", file=out)
for row in aggregate_battles(all_runs):
print(f" {str(row['level']):>6} {row['type']:>6} {str(row['depth']):>4} {row['count']:>5} "
f"{fmt(row['avg_rounds']):>8} {fmt(row['avg_dmg']):>8} {fmt(row['avg_ratio'] * 100):>10}% "
f"{'≤' + format(row['band'] * 100, '.0f') + '%':>7} {'✓' if row['pass'] else '✗':>4}", file=out)
print(file=out)
rr = aggregate_rounds(all_runs)
print("== 轮级统计 ==", file=out)
print(f" 总轮数 {rr['rounds']}|敌战轮(有过敌人的战斗全部轮数){rr['enemy_battle_rounds']}|有攻击落地轮 {rr['attack_landed']}", file=out)
zn_pct = rr['zero_narrow'] / rr['attack_landed'] * 100 if rr['attack_landed'] else 0.0
ebr = rr['enemy_battle_rounds']
zw_pct = rr['zero_loss_wide'] / ebr * 100 if ebr else 0.0
print(f" 零承伤轮(窄口径,AC 验收主指标): {rr['zero_narrow']} / 有攻击落地轮 {rr['attack_landed']}"
f"({zn_pct:.1f}%)——AC 批落地含'伤害下限 1'后该值应趋零", file=out)
print(f" 零掉血轮(宽口径,参考): {rr['zero_loss_wide']} / 敌战轮 {ebr}"
f"({zw_pct:.1f}%)", file=out)
print(file=out)
# 增益战斗期复原
buff_totals = Counter()
buff_settlements = 0
for run in all_runs:
seen_in_run = False
for rec in run.round_ledger:
for k, v in rec["buffs"].items():
buff_totals[k] += v
seen_in_run = True
if seen_in_run:
buff_settlements += sum(1 for rec in run.round_ledger if rec["buffs"])
snap_total = sum(r.snapshot_buffs_total for r in all_runs)
snap_nonempty = sum(r.snapshot_buffs_nonempty for r in all_runs)
print("== 增益战斗期复原(来自 step 行,按结算轮窗口聚合)==", file=out)
print(f" 各增益出现总层数: {dict(buff_totals) if buff_totals else '(无 effect 行)'}", file=out)
print(f" 有增益激活的结算数: {buff_settlements}(每结算层数明细见 --rounds 流水)", file=out)
print(f" 快照 buffs 非空: {snap_nonempty}/{snap_total}"
f"(已知盲区:快照在衰减段后发出且状态寿命=1 结算 → 恒空;此行仅作盲区持续暴露)", file=out)
print(file=out)
if args.per_run:
print("== 逐局明细(--per-run)==", file=out)
print(f" {'文件':<28} {'种子':>6} {'档位':>8} {'结果':>8} {'轮数':>4} {'承伤':>5} {'零承伤轮':>8} {'进BossHP':>8} {'终局HP':>6}", file=out)
for run in all_runs:
print(f" {run.src_file:<28} {str(run.seed):>6} {run.policy or '—':>8} "
f"{run.result or '—':>8} {run.rounds_total:>4} {run.damage_total:>5} "
f"{run.zero_narrow:>8} {fmt(run.boss_enter_hp(), 0):>8} {str(run.end_hp):>6}", file=out)
print(file=out)
if args.rounds:
print("== 逐轮流水(--rounds)==", file=out)
print(" 列: 种子 节点 深度 轮 | 攻击落地 蓄力 承伤 治疗 结算HP | 零承伤 零掉血 | 增益复原 | 出路", file=out)
for run in all_runs:
for rec in run.round_ledger:
buffs = ",".join(f"{k}×{v}" for k, v in sorted(rec["buffs"].items())) or "—"
print(f" {run.seed} {rec['type']} {rec['depth']} r{rec['round']} | "
f"{rec['attacks']} {rec['charges']} {rec['dmg']} {rec['heal']} {rec['hp']:>3} | "
f"{'Y' if rec['zero_narrow'] else '-'} {'Y' if rec['zero_wide'] else '-'} | "
f"{buffs} | {rec['path']}", file=out)
print(file=out)
print("== 数据质量 ==", file=out)
bad = sum(r.bad_lines for r in res_files)
late = sum(run.late_battle_ends for res in res_files for run in res.runs)
print(f" 解析失败行: {bad}|未配对 battle_end(延迟发出越局): {late}", file=out)
if res_files and any(res.unknown_step_keys for res in res_files):
print(" step 行未知字段观测面(AC 批落批后的适配入口——新掷点字段会先出现在这里):", file=out)
for res in res_files:
for k, c in res.unknown_step_keys.most_common():
print(f" {os.path.basename(res.path)}: {k} ×{c} 样例 {res.unknown_step_samples[k]}", file=out)
else:
print(" step 行未知字段: 无(当前日志 = 2026-09-16 schema,无掷点/分量字段)", file=out)
print(" AC 批落批后:新字段先出现在上面观测面 → 在 FIELD_CANDIDATES 登记真实字段名 → 掷点面自动激活。", file=out)
# ---------------------------------------------------------------- 入口
def main(argv=None):
ap = argparse.ArgumentParser(description="试玩 JSONL 日志三级统计分析器(关卡/战斗/轮次 + AC 掷点预留面)")
ap.add_argument("input", help="jsonl 文件 | 目录 | 通配(目录按 playtest_*.jsonl 收取)")
ap.add_argument("--per-run", action="store_true", help="附加逐局明细")
ap.add_argument("--rounds", action="store_true", help="附加逐轮流水")
args = ap.parse_args(argv)
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
files = resolve_inputs(args.input)
files = [f for f in files if os.path.isfile(f)]
if not files:
print(f"未找到输入:{args.input}", file=sys.stderr)
return 2
res_files = [parse_file(f) for f in files]
render(res_files, args, sys.stdout)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,99 @@
# log_analysis.py 指标口径说明
试玩日志分析器(`tools/log_analysis.py`,Python 3 标准库单文件)的指标定义、
自验对账记录与 AC 掷点批适配点。schema 事实源 = `presentation/playtest_logger.gd`。
## 用法
```
python log_analysis.py <jsonl 文件 | 目录 | 通配> [--per-run] [--rounds]
```
- 目录 = 收取其下全部 `playtest_*.jsonl`;通配/目录 = 批量汇总(跨文件聚合)。
- `--per-run` 附加逐局明细表;`--rounds` 附加逐轮流水(含每轮增益复原)。
## 局的切分
- 批量日志一文件多局:按 `run` 行切局(run 行携带该局种子/档位)。
- 旧日志无 `run` 行:整文件一局,种子取 `session` 行。
- 无 `session`/`run` 头的残缺文件:兜底开局,数据不丢。
## 关卡级指标
| 指标 | 定义 |
| --- | --- |
| 局数 | run 行切出的局数(旧日志 = 文件数)。 |
| 通关率 | `level_end.result == "victory"` 的局占比。 |
| 平均轮数/局 | 每局 `settlement` 快照数(=该局回合总数,快照每结算必发)。 |
| 进 Boss HP 均 | **快照反推法**:Boss 首结算快照 HP + 首轮承伤 − 首轮治疗。快照是回合末状态,用首轮回合内净额反推进场 HP,精确且自包含;仅统计到达 Boss 的局。 |
| 通关剩余 HP 均 | 通关局 `level_end.hp` 均值。 |
| 整局承伤均 | 每局全部敌方落地伤害之和的均值(见战斗级"承伤")。 |
## 战斗级指标
- 分组键 =(关卡,节点类型,节点深度);`battle` 与 `boss` 分开输出。
- **承伤** = 该场全部 `step` 行中 `source=enemy_action` 且 `type=damage` 的 `value` 合计
(即敌方攻击实际落地伤害;含"被减伤归零"的 0 值行)。
- **占生效 max_hp 比** = 承伤 ÷ 该场快照 `hero.max_hp`("生效"指取快照实时值,非写死 50)。
- 承伤带判定:普通场 ≤20%、Boss 场 ≤50%,均值超带在"判定"列标 ✗。
- `battle_end` 行由核心延迟发出(落在下一 node 行之后)且不带深度,本工具仅按 FIFO
对账其配对数(数据质量面),核心统计不依赖它——战斗上下文以 node 行开、相邻 node/局末收。
## 轮级指标(AC 批验收硬指标所在)
- **零承伤轮(窄口径,验收主指标)**:本轮**有敌方攻击落地**(≥1 行 `enemy_action`×`damage`)
且主角掉血为 0(该轮敌方落地伤害合计 = 0)。分母 = 有攻击落地的轮数。
- 蓄力拍(`type=charge`,含空拍/踩雷白喂)**不计**为"有攻击落地"——口径硬性排除。
- AC 批落地含"伤害下限 1"后,"被减伤归零"型轮将变为 1 点实伤,该值应趋零。
- **零掉血轮(宽口径,参考)**:本轮主角 HP 不降(首回合按进场反推 HP 差分,
其后按相邻快照差)。分母 = **敌战轮**——"有过敌人的战斗"(任一轮出现敌人或攻击落地)
的全部轮数。不能按单轮快照 enemies 判定:敌人当轮被歼的收尾轮快照 enemies 为空,
但该轮仍属敌战(5678 局按单轮判定会得 4/7,按敌战轮才是 8/11)。
## 战斗期增益复原(盲区补偿面)
- **事实**:`settlement` 快照在衰减段之后发出、且现有状态寿命全部为 1 结算
→ 快照 `buffs` 字段恒为空(本次自验 7,473 个快照 0 非空)。**禁止把快照 buffs
当战斗期状态使用**。
- **复原口径**:每结算的"轮窗口"= 上一 `settlement` 之后至本 `settlement` 之间的全部
`step` 行;窗口内 `type=effect` 的行按显示文本聚为增益键(增伤→attack_up、
削弱→weaken、减伤→defend_up;未登记文本原样作键,如 4200 批浮现的 失衡/暴走/乏力/敌方伏击+2 层),
行数即层数。逐层数明细看 `--rounds` 流水的"增益复原"列。
- 快照 buffs 非空计数仍照常输出(当前恒 0),作为盲区持续暴露面:将来衰减时序改动
若使快照变非空,这里会第一个显示出来。
## 掷点面(AC 批预留,当前降级)
- **激活条件**:step 行出现候选掷点字段(`ROLL/TIER/CRIT/AC_FIELD_CANDIDATES`)。
- 字段存在 → 输出:d20 直方图、档位占比(缺口=AC−读数:达标=全额,1-3→×0.75,
4-7→×0.5,8+→×0.25;优先读落批直供的档位字段)、nat20 暴击率(理论 5% ±2pp 健康检查)、
观察 EV 减免 vs 理论值(AC10 → 21.25%)、按攻击方向(主角→敌 / 敌→主角)分组。
- 字段不存在(AC 批前日志)→ 显示"不适用(AC 批前日志)",其余指标照常。
- **落批适配入口(一行)**:【数据质量】节的"step 行未知字段观测面"会自动列出全部新键及样例;
把真实字段名补进 `FIELD_CANDIDATES` 常量即可,其余零改动。
## 已登记差异(等落批后核对)
1. 任务背景称 step 行含分量字段 `baseline/attach/normal/pierce/magic`——
**现行日志(2026-09-16 schema)没有这些字段**:`_on_step_row` 只落
`kind/type/source/cell/round/text/value/highlight` 八键。本工具的未知键观测面
会在它们出现时自动捕获;届时按落批语义接入,不动 AC 批范围内文件。
2. 掷点字段名以落批实际为准(候选名单只作探测,不作断言)。
## 自验对账记录(2026-09-16,真实日志)
| 局 | 人工对账 | 结果 |
| --- | --- | --- |
| 5678(perfect 通关) | 整关掉血合计 = 深度2 场 1 + 深度3 场 1 + Boss 场 4 = **6** | 工具输出 6 ✓ |
| 5678 | 零掉血轮宽口径:敌战 11 轮 − 掉血 3 轮 = **8/11** | 工具输出 8/11 ✓ |
| 5678 | 零承伤轮窄口径:被减伤归零型 3 轮(深度1 r1 / Boss r1 / Boss r4) | 工具输出 3 ✓ |
| 1234(perfect 通关) | 独立脚本计数:敌方伤害 12、治疗 8、11 轮(HP 守恒 50−12+8=50) | 工具输出 12/11/终局 50 ✓ |
| 4200 批(233,254 行) | run 行 600、level_end 600(500 胜 100 败) | 切 600 局,normal 66.7% + perfect 100% = 500 胜 ✓ |
- 大文件全量解析耗时:**233,254 行 / 1.35–1.53s**(python 3.13,单遍流式,含 json 解析与全部聚合)。
- `battle_end` FIFO 对账:两种子局 + 600 局批均 0 未配对。
## 边界
- 本工具只读日志,不写任何游戏文件;与 `playtest_logger.gd` 的字段集对齐以 2026-09-16 版为准。
- 快照 buffs、`battle_end`、`action`/`ux` 行族的更多消费面留待需要时扩展。
@@ -0,0 +1,37 @@
== 输入概况 ==
文件 1 个,共 233254 行,解析 1.35s(大文件单文件耗时见下)
playtest_4200_20260916T003953.jsonl: 233254 行 / 1.35s / 600 局 / 掷点面:本日志无掷点字段(AC 批前日志,已降级)
== 掷点面(AC 批验收统计)==
不适用(AC 批前日志):本日志无掷点字段。
其余指标照常输出;落批后重跑本工具,先看【数据质量】的未知字段观测面。
== 关卡级统计 ==
口径:局=run 行切分(无 run 行的旧日志整文件一局);通关=result=victory;
进BossHP=Boss首结算快照HP+首轮承伤−首轮治疗(精确反推);平均轮数=每局settlement数。
关卡 档位 局数 通关率 平均轮数 进BossHP均 通关剩余HP均 整局承伤均
1 normal 300 66.7% 13.0 67.7 45.7 65.1
1 perfect 300 100.0% 11.8 75.0 73.7 8.5
== 战斗级统计(按 关卡/节点类型/深度 分组;对照承伤带 普通≤20%、Boss≤50% max_hp)==
关卡 类型 深度 场数 场均轮数 场均承伤 承伤/max_hp 承伤带 判定
1 battle 0 600 1.8 5.1 7.7% ≤20% ✓
1 battle 1 598 2.2 5.8 8.7% ≤20% ✓
1 battle 2 589 1.7 2.2 3.2% ≤20% ✓
1 battle 3 584 2.5 4.4 6.5% ≤20% ✓
1 boss 4 562 4.7 20.8 29.3% ≤50% ✓
== 轮级统计 ==
总轮数 7449|敌战轮(有过敌人的战斗全部轮数)6988|有攻击落地轮 3749
零承伤轮(窄口径,AC 验收主指标): 1334 / 有攻击落地轮 3749(35.6%)——AC 批落地含'伤害下限 1'后该值应趋零
零掉血轮(宽口径,参考): 4259 / 敌战轮 6988(60.9%)
== 增益战斗期复原(来自 step 行,按结算轮窗口聚合)==
各增益出现总层数: {'失衡': 2821, 'defend_up': 8180, 'weaken': 8103, '暴走': 3287, 'attack_up': 9893, '乏力': 2075, '敌方伏击 +2 层': 2459}
有增益激活的结算数: 7396(每结算层数明细见 --rounds 流水)
快照 buffs 非空: 0/7449(已知盲区:快照在衰减段后发出且状态寿命=1 结算 → 恒空;此行仅作盲区持续暴露)
== 数据质量 ==
解析失败行: 0|未配对 battle_end(延迟发出越局): 0
step 行未知字段: 无(当前日志 = 2026-09-16 schema,无掷点/分量字段)
AC 批落批后:新字段先出现在上面观测面 → 在 FIELD_CANDIDATES 登记真实字段名 → 掷点面自动激活。
@@ -0,0 +1,54 @@
== 输入概况 ==
文件 1 个,共 340 行,解析 0.00s(大文件单文件耗时见下)
playtest_1234_20260916T004132.jsonl: 340 行 / 0.00s / 1 局 / 掷点面:本日志无掷点字段(AC 批前日志,已降级)
== 掷点面(AC 批验收统计)==
不适用(AC 批前日志):本日志无掷点字段。
其余指标照常输出;落批后重跑本工具,先看【数据质量】的未知字段观测面。
== 关卡级统计 ==
口径:局=run 行切分(无 run 行的旧日志整文件一局);通关=result=victory;
进BossHP=Boss首结算快照HP+首轮承伤−首轮治疗(精确反推);平均轮数=每局settlement数。
关卡 档位 局数 通关率 平均轮数 进BossHP均 通关剩余HP均 整局承伤均
1 perfect 1 100.0% 11.0 50.0 50.0 12.0
== 战斗级统计(按 关卡/节点类型/深度 分组;对照承伤带 普通≤20%、Boss≤50% max_hp)==
关卡 类型 深度 场数 场均轮数 场均承伤 承伤/max_hp 承伤带 判定
1 battle 0 1 2.0 0.0 0.0% ≤20% ✓
1 battle 1 1 2.0 0.0 0.0% ≤20% ✓
1 battle 2 1 2.0 0.0 0.0% ≤20% ✓
1 battle 3 1 2.0 8.0 16.0% ≤20% ✓
1 boss 4 1 3.0 4.0 8.0% ≤50% ✓
== 轮级统计 ==
总轮数 11|敌战轮(有过敌人的战斗全部轮数)11|有攻击落地轮 6
零承伤轮(窄口径,AC 验收主指标): 4 / 有攻击落地轮 6(66.7%)——AC 批落地含'伤害下限 1'后该值应趋零
零掉血轮(宽口径,参考): 9 / 敌战轮 11(81.8%)
== 增益战斗期复原(来自 step 行,按结算轮窗口聚合)==
各增益出现总层数: {'attack_up': 18, 'defend_up': 15, 'weaken': 14}
有增益激活的结算数: 11(每结算层数明细见 --rounds 流水)
快照 buffs 非空: 0/11(已知盲区:快照在衰减段后发出且状态寿命=1 结算 → 恒空;此行仅作盲区持续暴露)
== 逐局明细(--per-run)==
文件 种子 档位 结果 轮数 承伤 零承伤轮 进BossHP 终局HP
playtest_1234_20260916T004132.jsonl 1234 perfect victory 11 12 4 50 50
== 逐轮流水(--rounds)==
列: 种子 节点 深度 轮 | 攻击落地 蓄力 承伤 治疗 结算HP | 零承伤 零掉血 | 增益复原 | 出路
1234 battle 0 r1 | 1 0 0 0 50 | Y Y | attack_up×2,defend_up×2,weaken×2 | ap_exhausted
1234 battle 0 r2 | 0 0 0 0 50 | - Y | attack_up×1,defend_up×2,weaken×1 | ap_exhausted
1234 battle 1 r1 | 2 0 0 0 50 | Y Y | attack_up×2,weaken×1 | ap_exhausted
1234 battle 1 r2 | 0 0 0 0 50 | - Y | attack_up×2,defend_up×2 | ap_exhausted
1234 battle 2 r1 | 1 0 0 0 50 | Y Y | attack_up×2,defend_up×1,weaken×2 | ap_exhausted
1234 battle 2 r2 | 0 0 0 0 50 | - Y | attack_up×1,defend_up×1,weaken×3 | ap_exhausted
1234 battle 3 r1 | 1 0 8 0 42 | - - | attack_up×2 | ap_exhausted
1234 battle 3 r2 | 0 0 0 4 46 | - Y | attack_up×2,defend_up×2,weaken×2 | ap_exhausted
1234 boss 4 r1 | 1 0 0 0 50 | Y Y | attack_up×1,defend_up×2,weaken×1 | ap_exhausted
1234 boss 4 r2 | 1 0 4 0 46 | - - | attack_up×2,defend_up×1 | ap_exhausted
1234 boss 4 r3 | 0 0 0 4 50 | - Y | attack_up×1,defend_up×2,weaken×2 | cleared
== 数据质量 ==
解析失败行: 0|未配对 battle_end(延迟发出越局): 0
step 行未知字段: 无(当前日志 = 2026-09-16 schema,无掷点/分量字段)
AC 批落批后:新字段先出现在上面观测面 → 在 FIELD_CANDIDATES 登记真实字段名 → 掷点面自动激活。
@@ -0,0 +1,55 @@
== 输入概况 ==
文件 1 个,共 380 行,解析 0.00s(大文件单文件耗时见下)
playtest_5678_20260916T004134.jsonl: 380 行 / 0.00s / 1 局 / 掷点面:本日志无掷点字段(AC 批前日志,已降级)
== 掷点面(AC 批验收统计)==
不适用(AC 批前日志):本日志无掷点字段。
其余指标照常输出;落批后重跑本工具,先看【数据质量】的未知字段观测面。
== 关卡级统计 ==
口径:局=run 行切分(无 run 行的旧日志整文件一局);通关=result=victory;
进BossHP=Boss首结算快照HP+首轮承伤−首轮治疗(精确反推);平均轮数=每局settlement数。
关卡 档位 局数 通关率 平均轮数 进BossHP均 通关剩余HP均 整局承伤均
1 perfect 1 100.0% 12.0 50.0 50.0 6.0
== 战斗级统计(按 关卡/节点类型/深度 分组;对照承伤带 普通≤20%、Boss≤50% max_hp)==
关卡 类型 深度 场数 场均轮数 场均承伤 承伤/max_hp 承伤带 判定
1 battle 0 1 1.0 0.0 0.0% ≤20% ✓
1 battle 1 1 2.0 0.0 0.0% ≤20% ✓
1 battle 2 1 2.0 1.0 2.0% ≤20% ✓
1 battle 3 1 2.0 1.0 2.0% ≤20% ✓
1 boss 4 1 5.0 4.0 8.0% ≤50% ✓
== 轮级统计 ==
总轮数 12|敌战轮(有过敌人的战斗全部轮数)11|有攻击落地轮 6
零承伤轮(窄口径,AC 验收主指标): 3 / 有攻击落地轮 6(50.0%)——AC 批落地含'伤害下限 1'后该值应趋零
零掉血轮(宽口径,参考): 8 / 敌战轮 11(72.7%)
== 增益战斗期复原(来自 step 行,按结算轮窗口聚合)==
各增益出现总层数: {'weaken': 18, 'defend_up': 12, 'attack_up': 21}
有增益激活的结算数: 12(每结算层数明细见 --rounds 流水)
快照 buffs 非空: 0/12(已知盲区:快照在衰减段后发出且状态寿命=1 结算 → 恒空;此行仅作盲区持续暴露)
== 逐局明细(--per-run)==
文件 种子 档位 结果 轮数 承伤 零承伤轮 进BossHP 终局HP
playtest_5678_20260916T004134.jsonl 5678 perfect victory 12 6 3 50 50
== 逐轮流水(--rounds)==
列: 种子 节点 深度 轮 | 攻击落地 蓄力 承伤 治疗 结算HP | 零承伤 零掉血 | 增益复原 | 出路
5678 battle 0 r1 | 0 0 0 0 50 | - Y | attack_up×1,defend_up×2,weaken×1 | ap_exhausted
5678 battle 1 r1 | 1 0 0 0 50 | Y Y | attack_up×2,defend_up×1,weaken×3 | ap_exhausted
5678 battle 1 r2 | 0 0 0 0 50 | - Y | attack_up×1,defend_up×1,weaken×1 | ap_exhausted
5678 battle 2 r1 | 1 0 1 0 49 | - - | attack_up×2,defend_up×2,weaken×1 | ap_exhausted
5678 battle 2 r2 | 0 0 0 1 50 | - Y | attack_up×2,defend_up×1,weaken×3 | ap_exhausted
5678 battle 3 r1 | 1 0 1 0 49 | - - | attack_up×2,weaken×1 | ap_exhausted
5678 battle 3 r2 | 0 0 0 1 50 | - Y | attack_up×1,defend_up×2,weaken×3 | ap_exhausted
5678 boss 4 r1 | 1 0 0 0 50 | Y Y | attack_up×2,weaken×2 | ap_exhausted
5678 boss 4 r2 | 1 0 4 0 46 | - - | attack_up×2,defend_up×2 | ap_exhausted
5678 boss 4 r3 | 0 1 0 4 50 | - Y | attack_up×2,weaken×1 | ap_exhausted
5678 boss 4 r4 | 1 0 0 0 50 | Y Y | attack_up×2,defend_up×1,weaken×2 | ap_exhausted
5678 boss 4 r5 | 0 0 0 0 50 | - Y | attack_up×2 | ap_exhausted
== 数据质量 ==
解析失败行: 0|未配对 battle_end(延迟发出越局): 0
step 行未知字段: 无(当前日志 = 2026-09-16 schema,无掷点/分量字段)
AC 批落批后:新字段先出现在上面观测面 → 在 FIELD_CANDIDATES 登记真实字段名 → 掷点面自动激活。