# VERTICAL SLICE - NOT FOR PRODUCTION # 实现 GDD:design/ux/battle.md(§Events Fired 遥测义务——ux 行族)。 # Date: 2026-09-12(战斗屏改造批增 ux 遥测;基础 = 2026-09-12 日志器首版) # 试玩日志器(呈现层组件——订阅核心信号持久化 JSONL,供数值分析;卡死诊断转储)。 # 职责边界:核心层只发 settlement_snapshot 信号(战斗结算)/既有信号(battle_finished/node_entered/ # level_finished/step_row);本组件负责订阅、缓冲与写文件——核心零日志感知(零 UI 依赖不变)。 # 文件 = user://logs/playtest__<时间戳>.jsonl(每行一次结算/终局/节点的全量状态 JSON)。 # ux 行族(呈现层观测,不改 core):ux_round_input_ratio(原始输入动作数∶AP 消耗)、 # ux_round_exit{path_tag}(快照 round_facts.path——呈现层只订 战斗结算 的 ADR 纪律下唯一合法来源)、 # ux_refresh_attempt / ux_reject / ux_hint_shown(组合根显式上报;ux_window_choice 随道具窗口删除下线 2026-09-15)。 # 诊断转储 = 全量核心状态 + 最近结算/操作环形缓冲 → user://logs/diag_<时间戳>.json(卡死时点按钮)。 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) -> 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 = snap.get("round_facts", {}) log_ux("ux_round_exit", {"path_tag": String(facts.get("path", ""))}) # ux_round_input_ratio:原始输入动作数∶AP 消耗(本轮 AP 配额 − 快照时剩余 = 消耗) var economy: Dictionary = 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()