按 code-conventions §1.4.1 第三轮定稿执行:core 22+schemas 18+presentation 根 14+views 28,全部头注 #→##、删日期/变更叙事/GDD 与架构反向链接/字段与域清单/机制描述,保留职责摘要+真实使用约束(防事故禁令/单一入口/信号契约每信号一行)。main.gd 31→5 行、save_manager 45→5 行、adventure_runner 37→5 行为最大单件。子代理分模块执行(波一 core+schemas、波二 presentation+views,并发≤2)。验收=tools/verify_comment_only_change.py 逐字节校验(82 文件头注区外逐行一致=零行为变更)+audit_file_headers 全绿;纯注释批免冒烟(先例 5843270)
178 lines
7.1 KiB
GDScript
178 lines
7.1 KiB
GDScript
## VERTICAL SLICE - NOT FOR PRODUCTION
|
||
## 试玩日志器:订阅核心信号逐行落 JSONL(user://logs/playtest_<seed>_<时间戳>.jsonl)供数值分析,卡死诊断转储 diag_*.json。
|
||
## ux_reject/ux_refresh_attempt/ux_hint_shown 无信号源——依赖组合根显式上报,漏调即缺行。
|
||
## ap_per_round 须由组合根注入——ux_round_input_ratio 的 AP 消耗口径分母。
|
||
class_name PlaytestLogger
|
||
extends RefCounted
|
||
|
||
const RING_SIZE := 200
|
||
|
||
var _file: FileAccess = null
|
||
var _path := ""
|
||
var _seed := 0
|
||
var _recent_steps: Array[Dictionary] = [] # 环形缓冲:最近结算行
|
||
var _recent_actions: Array[Dictionary] = [] # 环形缓冲:最近玩家动作
|
||
var _snap_count := 0
|
||
var ap_per_round := 8 # 组合根注入(AP 消耗口径分母——行动经济 无推送信号)
|
||
var _ux_inputs := 0 # 本结算轮原始输入动作数(log_action 计数,快照后归零)
|
||
|
||
|
||
# 开启会话(每次 start_level/start_battle 调用——按种子分文件)。返回日志路径。
|
||
func start_session(seed_val: int) -> String:
|
||
_seed = seed_val
|
||
_recent_steps.clear()
|
||
_recent_actions.clear()
|
||
_snap_count = 0
|
||
DirAccess.make_dir_recursive_absolute("user://logs")
|
||
var stamp: String = Time.get_datetime_string_from_system().replace(":", "").replace("-", "").replace(" ", "_")
|
||
_path = "user://logs/playtest_%d_%s.jsonl" % [seed_val, stamp]
|
||
if _file != null:
|
||
_file.close()
|
||
_file = FileAccess.open(_path, FileAccess.WRITE)
|
||
_append({"kind": "session", "seed": seed_val, "ts": Time.get_datetime_string_from_system()})
|
||
return _path
|
||
|
||
|
||
func close_session() -> void:
|
||
if _file != null:
|
||
_file.close()
|
||
_file = null
|
||
|
||
|
||
func log_path() -> String:
|
||
return _path
|
||
|
||
|
||
func attach_battle(settlement: BattleOrchestrator) -> void:
|
||
settlement.step_row.connect(_on_step_row)
|
||
settlement.settlement_snapshot.connect(_on_settlement_snapshot)
|
||
settlement.battle_finished.connect(_on_battle_finished_log)
|
||
|
||
|
||
func _on_battle_finished_log(p: Dictionary) -> void:
|
||
_append({
|
||
"kind": "battle_end", "result": String(p.get("result", "")),
|
||
"hero_hp": int(p.get("hero_hp", 0)),
|
||
"gp_dropped": int(p.get("gp_dropped", 0))}) # 战斗掉落列(GP+商店房实施批 2026-09-21——验收取数面)
|
||
|
||
|
||
# ---- GP 钱包行(GP+商店房实施批——到账/购买/清零事件;runner wallet_changed 投影经组合根转发)----
|
||
|
||
func log_wallet(event: String, gp: int, extra: Dictionary = {}) -> void:
|
||
var line := {"kind": "wallet", "event": event, "gp": gp}
|
||
for k in extra.keys():
|
||
line[k] = extra[k]
|
||
_append(line)
|
||
|
||
|
||
# ---- ux 遥测(battle.md §Events Fired——呈现层观测行族,不改 core) ----
|
||
|
||
func log_ux(ux_kind: String, payload: Dictionary) -> void:
|
||
var line := {"kind": "ux", "ux": ux_kind}
|
||
for k in payload.keys():
|
||
line[k] = payload[k]
|
||
_append(line)
|
||
|
||
|
||
func log_action(kind: String, cell: Vector2i, accepted: bool, extra: Dictionary = {}) -> void:
|
||
_ux_inputs += 1 # 原始输入动作数(含被拒——P4 ≤2 护栏观测口径 = 原始输入)
|
||
# 逐操作落盘(2026-09-12 用户要求:操作记录完整持久化——此前仅入环形缓冲、卡死诊断才可见)
|
||
var line := {"kind": "action", "op": kind, "cell": [cell.x, cell.y], "accepted": accepted}
|
||
for k in extra.keys():
|
||
line[k] = extra[k]
|
||
_append(line)
|
||
_ring(_recent_actions, {"kind": kind, "cell": [cell.x, cell.y], "accepted": accepted}.merged(extra))
|
||
|
||
|
||
func _on_settlement_snapshot(snap: Dictionary) -> void:
|
||
_append(snap)
|
||
# ux_round_exit{path_tag}:对齐上游封闭枚举 {stopped, ap_exhausted, cleared}
|
||
var facts: Dictionary[Variant, Variant] = snap.get("round_facts", {})
|
||
log_ux("ux_round_exit", {"path_tag": String(facts.get("path", ""))})
|
||
# ux_round_input_ratio:原始输入动作数∶AP 消耗(本轮 AP 配额 − 快照时剩余 = 消耗)
|
||
var economy: Dictionary[Variant, Variant] = snap.get("economy", {})
|
||
var ap_left: int = int(economy.get("ap", 0))
|
||
var consumed: int = clampi(ap_per_round - ap_left, 0, ap_per_round)
|
||
log_ux("ux_round_input_ratio", {"inputs": _ux_inputs, "ap_consumed": consumed,
|
||
"ratio": float(_ux_inputs) / float(maxi(consumed, 1))})
|
||
_ux_inputs = 0
|
||
|
||
|
||
func log_node(node: Dictionary, info: Dictionary, node_index: int) -> void:
|
||
var t := String(node.get("type", ""))
|
||
var line := {"kind": "node", "type": t, "index": node_index}
|
||
if t == "battle" or t == "boss":
|
||
line["depth"] = int(node.get("node_depth", 0))
|
||
elif t == "rest":
|
||
line["healed"] = int(info.get("healed", 0))
|
||
elif t == "shop":
|
||
line["gp"] = int(info.get("gp", 0)) # 进店时点余额(GP+商店房实施批)
|
||
# reward 节点:内容待拍板(格面资源批 2026-09-15 道具删除)——零载荷
|
||
_append(line)
|
||
|
||
|
||
# 批量模拟会话的局次标记(一文件多局——run_start 行携带该局种子/档位,供逐局切分)
|
||
func log_run(meta: Dictionary) -> void:
|
||
var line := {"kind": "run"}
|
||
for k in meta.keys():
|
||
line[k] = meta[k]
|
||
_append(line)
|
||
|
||
|
||
func log_level_end(payload: Dictionary) -> void:
|
||
_append({"kind": "level_end", "result": String(payload.get("result", "")),
|
||
"node_index": int(payload.get("node_index", -1)), "hp": int(payload.get("hp", 0))})
|
||
|
||
|
||
func _on_step_row(row: Dictionary) -> void:
|
||
_ring(_recent_steps, {"t": row.type, "src": row.source_class, "v": int(row.value), "r": int(row.round_index)})
|
||
# 结算明细行逐行落盘(2026-09-12 用户要求——五段结算完整回放数据面);
|
||
# 公共七字段之外的全部行字段(payload:roll/ac/tier/crit/normal/pierce/magic/baseline/rider/target/
|
||
# effect_id/pool_value 等)随行平铺落盘(2026-09-17 数值分析工具批——无 payload 的旧日志反事实只能
|
||
# 比例近似,新日志可精确重算)。
|
||
var line := {"kind": "step", "type": row.type, "source": row.source_class,
|
||
"cell": row.origin_cell, "round": int(row.round_index),
|
||
"text": String(row.display_name), "value": int(row.value),
|
||
"highlight": String(row.get("highlight", ""))}
|
||
for k in row.keys():
|
||
if not line.has(k):
|
||
line[k] = row[k]
|
||
_append(line)
|
||
|
||
|
||
# ---- 诊断转储(卡死时点击——全量状态 + 环形缓冲)----
|
||
|
||
func dump_diagnosis(context: Dictionary) -> String:
|
||
DirAccess.make_dir_recursive_absolute("user://logs")
|
||
var stamp: String = Time.get_datetime_string_from_system().replace(":", "").replace("-", "").replace(" ", "_")
|
||
var path := "user://logs/diag_%s.json" % stamp
|
||
var dump := {
|
||
"kind": "diagnosis", "ts": Time.get_datetime_string_from_system(), "seed": _seed,
|
||
"session_log": _path, "snap_count": _snap_count,
|
||
"recent_actions": _recent_actions.duplicate(),
|
||
"recent_steps": _recent_steps.duplicate(),
|
||
"core": context,
|
||
}
|
||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||
if f != null:
|
||
f.store_string(JSON.stringify(dump, " "))
|
||
f.close()
|
||
print("[playtest_logger] 诊断转储已写入: %s(项目日志目录见输出)" % path)
|
||
print(" [diag] %s" % JSON.stringify(context))
|
||
return path
|
||
|
||
|
||
func _ring(buf: Array, item: Dictionary) -> void:
|
||
buf.append(item)
|
||
while buf.size() > RING_SIZE:
|
||
buf.pop_front()
|
||
|
||
|
||
func _append(dict: Dictionary) -> void:
|
||
if dict.get("kind", "") == "settlement":
|
||
_snap_count += 1
|
||
if _file == null:
|
||
return
|
||
_file.store_line(JSON.stringify(dict))
|
||
_file.flush()
|