## VERTICAL SLICE - NOT FOR PRODUCTION ## 试玩日志器:订阅核心信号逐行落 JSONL(user://logs/playtest__<时间戳>.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: int = 0 # 必填注入(零缺省——组合根 start_session 前显式设定,守卫拒未注入) var _ux_inputs := 0 # 本结算轮原始输入动作数(log_action 计数,快照后归零) # 开启会话(每次 start_level/start_battle 调用——按种子分文件)。返回日志路径。 func start_session(seed_val: int) -> String: if ap_per_round < 1 or ap_per_round > 32: push_error("[试玩日志器] ap_per_round 未注入或越域: %d——组合根必填注入" % ap_per_round) return "" _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 钱包行(到账/购买/清零事件;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 护栏观测口径 = 原始输入) # 逐操作落盘(操作记录须完整持久化,禁只入环形缓冲) 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)) # 进店时点余额 # reward 节点:零载荷(节点内容未在呈现面定义) _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)}) # 结算明细行逐行落盘(五段结算完整回放数据面); # 公共七字段之外的全部行字段(payload:roll/ac/tier/crit/normal/pierce/magic/baseline/rider/target/ # effect_id/pool_value 等)随行平铺落盘(无 payload 时反事实只能 # 比例近似,带 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() 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()