Files
shenleiandClaude Opus 5 f39a55fdd5 feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档
- 新增 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
2026-09-11 22:06:48 +09:00

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