- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay 只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续 运行并记 BLOCKED;写本批次 release-manifest.json 后聚合 - run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理); soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数) - 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试 - forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别 - 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求 - .gitignore 排除本地场景配置、凭据文件与运行输出 离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS, node 夹具测试 PASS。未联网运行,未重建候选包。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
252 lines
9.1 KiB
GDScript
252 lines
9.1 KiB
GDScript
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
|