class_name PlayableReport ## 联网测试进程内的报告构建器。 ## 只写非敏感的 client-report.json;最终 process/退出日志门禁由父脚本补齐。 ## ## 事件契约(events.jsonl 每行):monotonic_us、run_id、case_id、connection_epoch、 ## stage、kind、actor_vid、target_vid、payload。payload 只保留 PAYLOAD_KEYS ## 白名单字段;其它字段丢弃并计数,避免把角色名、聊天内容或环境变量写进报告。 const SCHEMA_VERSION := 1 const VALID_STATUSES := ["PASS", "FAIL", "BLOCKED", "SKIP"] const EVENT_KEYS := ["monotonic_us", "run_id", "case_id", "connection_epoch", "stage", "kind", "actor_vid", "target_vid", "payload"] const PAYLOAD_KEYS := [ "source", "count", "race", "ch_type", "amount", "flag", "vnum", "window", "cell", "special", "type", "motion", "reason_code", "hp", "max_hp", "hp_percent", "dead", "pos_cm", "local_pos_cm", "distance_cm", "tolerance_cm", "waypoint_index", "skill_id", "code", "state", "map_key", "expected_map_key", "iid", "before", "after", "delta", "attempt", "owner_match", "allow_gameplay", "main_vid", "entity_count", "ground_item_count", "app_state", "scene_ready", "hud_ready", "player_ready", "in_game", "reconnecting", "character_count", "slot_present", "stale_epoch", "elapsed_ms", "timeout_ms", "server_index", "channel", "address_match", "evidence", "screenshot", "suite", "effect", "lifetime_ms", "selected", "accepted", "reason", "slots", "fx_id", "open_effects", "phase", # STB-01 soak "round", "wall_ms", "requested_size", "logical_size", "physical_size", "viewport_size", "screen_scale", "duplicate_vids", "target_residue", "portal_cm", "unreachable_seconds", "cross_server", "interaction", "severe", "elapsed_s", "leg", "required", "size", ] ## Top-level report sections a suite may attach; anything else is rejected. const SECTIONS := ["soak", "frame_metrics", "client_memory", "probe_overhead"] ## Values that look like credentials are masked even inside whitelisted keys. const SECRET_KEYS := ["password", "passwd", "token", "secret", "account", "login", "username"] var _report: Dictionary = {} var _started_us := 0 var _events_path := "" var _event_count := 0 var _dropped_payload_keys := {} var _redact_literals: Array[String] = [] func begin(run_id: String, suite: String, config: Dictionary, events_path := "") -> void: _started_us = Time.get_ticks_usec() _events_path = events_path _event_count = 0 _dropped_payload_keys = {} # Account/password literals may appear inside server messages; never persist them. _redact_literals.clear() for name in ["MT_ACCOUNT", "MT_PASSWORD"]: var literal := OS.get_environment(name) if literal.length() >= 2: _redact_literals.append(literal) _report = { "schema_version": SCHEMA_VERSION, "run_id": run_id, "suite": suite, "status": "BLOCKED", "build": _build_info(), "environment": _environment_info(), "cases": [], "required_cases": [], "events": events_path.get_file() if not events_path.is_empty() else "", "event_count": 0, "failures": [], "blocked": [], "coverage": {"required": 0, "passed": 0}, "exit_gate": {"checked": false, "process_code": null, "errors": []}, } # Keep configuration useful for reproducing a run without copying any secret. _report["scenario_id"] = String(config.get("scenario_id", "")) ## Declares the case IDs that must appear; missing ones become BLOCKED on finish(). func set_required_cases(ids: Array) -> void: var out: Array = [] for id in ids: out.append(String(id)) _report.required_cases = out func required_cases() -> Array: return _report.get("required_cases", []).duplicate() func has_case(case_id: String) -> bool: for item in _report.get("cases", []): if String(item.id) == case_id: return true return false func case_status(case_id: String) -> String: for item in _report.get("cases", []): if String(item.id) == case_id: return String(item.status) return "" func add_case(case_id: String, status: String, reason := "", evidence: Array = [], duration_ms := 0.0) -> void: if has_case(case_id): _fail("duplicate case result for %s" % case_id) return var normalized := status.to_upper() if not VALID_STATUSES.has(normalized): normalized = "FAIL" _fail("invalid case status for %s" % case_id) var item := {"id": case_id, "status": normalized, "duration_ms": duration_ms, "reason": _redact_text(reason), "evidence": _sanitize(evidence)} _report.cases.append(item) _report.coverage.required = int(_report.coverage.required) + 1 if normalized == "PASS": _report.coverage.passed = int(_report.coverage.passed) + 1 elif normalized == "FAIL": _fail("%s: %s" % [case_id, item.reason if not String(item.reason).is_empty() else "failed"]) elif normalized == "BLOCKED": _report.blocked.append("%s: %s" % [case_id, item.reason if not String(item.reason).is_empty() else "blocked"]) ## A run-level failure that belongs to no open case (e.g. an effect from an ## already-passed case outliving its lifetime). Any failure makes the run FAIL. func add_failure(message: String) -> void: _fail(message) ## Attach a whitelisted top-level section (sanitized like payloads). Returns false for unknown names. func set_section(name: String, value: Dictionary) -> bool: if _report.is_empty() or not (name in SECTIONS): return false _report[name] = _sanitize(value) return true func failures() -> Array: return _report.get("failures", []).duplicate() ## Append one contract event. Returns the event actually written (for tests). func record(kind: String, stage: String, case_id: String, connection_epoch: int, actor_vid := 0, target_vid := 0, payload := {}) -> Dictionary: var event := { "monotonic_us": Time.get_ticks_usec(), "run_id": String(_report.get("run_id", "")), "case_id": case_id, "connection_epoch": connection_epoch, "stage": stage, "kind": kind, "actor_vid": actor_vid, "target_vid": target_vid, "payload": _whitelist(payload), } _event_count += 1 _report.event_count = _event_count if _events_path.is_empty(): return event var file := FileAccess.open(_events_path, FileAccess.READ_WRITE) if file == null: file = FileAccess.open(_events_path, FileAccess.WRITE) if file == null: _fail("cannot open events file") return event file.seek_end() file.store_line(JSON.stringify(event)) file.close() return event func finish() -> Dictionary: if _report.is_empty(): return {} for required in _report.required_cases: if not has_case(String(required)): add_case(String(required), "BLOCKED", "not_reached") var required := int(_report.coverage.required) var passed := int(_report.coverage.passed) var failures: Array = _report.failures if not failures.is_empty(): _report.status = "FAIL" elif required == 0 or passed != required or not _report.blocked.is_empty(): _report.status = "BLOCKED" else: _report.status = "PASS" if not _dropped_payload_keys.is_empty(): _report["dropped_payload_keys"] = _dropped_payload_keys.keys() _report.duration_seconds = float(Time.get_ticks_usec() - _started_us) / 1000000.0 return _report func write(path: String) -> bool: var report := finish() if report.is_empty(): return false var file := FileAccess.open(path, FileAccess.WRITE) if file == null: return false file.store_string(JSON.stringify(report, " ")) file.close() return true func _fail(message: String) -> void: _report.failures.append(_redact_text(message)) func _build_info() -> Dictionary: return { "engine_sha256": OS.get_environment("MT_BUILD_ENGINE_SHA256"), "extension_sha256": OS.get_environment("MT_BUILD_EXTENSION_SHA256"), "pck_sha256": OS.get_environment("MT_BUILD_PCK_SHA256"), "arch": OS.get_environment("MT_BUILD_ARCH"), } func _environment_info() -> Dictionary: var headless := DisplayServer.get_name() == "headless" var size := DisplayServer.window_get_size() if not headless else Vector2i.ZERO var logical := size if not headless and DisplayServer.screen_get_scale() > 0.0: logical = Vector2i(roundi(size.x / DisplayServer.screen_get_scale()), roundi(size.y / DisplayServer.screen_get_scale())) return { "os": OS.get_name(), "renderer": RenderingServer.get_video_adapter_name(), "resolution": [size.x, size.y], "logical_resolution": [logical.x, logical.y], } func _whitelist(payload: Dictionary) -> Dictionary: var out := {} for key in payload: var name := String(key) if name in PAYLOAD_KEYS: out[name] = _sanitize(payload[key]) else: _dropped_payload_keys[name] = true return out func _redact_text(text: String) -> String: var out := text for literal in _redact_literals: out = out.replace(literal, "[redacted]") return out func _sanitize(value: Variant) -> Variant: if value is Dictionary: var out := {} for key in value: var name := String(key).to_lower() if name in SECRET_KEYS: out[key] = "[redacted]" else: out[key] = _sanitize(value[key]) return out if value is Array: var out_array: Array = [] for item in value: out_array.append(_sanitize(item)) return out_array if value is String or value is StringName: return _redact_text(String(value)) if value is Vector2 or value is Vector2i: return [value.x, value.y] if value is Vector3 or value is Vector3i: return [value.x, value.y, value.z] if value is Object: return "[object]" return value