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
This commit is contained in:
@@ -2,15 +2,54 @@ 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) -> void:
|
||||
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,
|
||||
@@ -19,7 +58,9 @@ func begin(run_id: String, suite: String, config: Dictionary) -> void:
|
||||
"build": _build_info(),
|
||||
"environment": _environment_info(),
|
||||
"cases": [],
|
||||
"events": "events.jsonl",
|
||||
"required_cases": [],
|
||||
"events": events_path.get_file() if not events_path.is_empty() else "",
|
||||
"event_count": 0,
|
||||
"failures": [],
|
||||
"blocked": [],
|
||||
"coverage": {"required": 0, "passed": 0},
|
||||
@@ -28,37 +69,97 @@ func begin(run_id: String, suite: String, config: Dictionary) -> void:
|
||||
# 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": reason, "evidence": _sanitize(evidence)}
|
||||
"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, reason if not reason.is_empty() else "failed"])
|
||||
_fail("%s: %s" % [case_id, item.reason if not String(item.reason).is_empty() else "failed"])
|
||||
elif normalized == "BLOCKED":
|
||||
_report.blocked.append(reason if not reason.is_empty() else case_id)
|
||||
_report.blocked.append("%s: %s" % [case_id, item.reason if not String(item.reason).is_empty() else "blocked"])
|
||||
|
||||
func add_event(event: Dictionary, events_path: String) -> void:
|
||||
var safe: Variant = _sanitize(event)
|
||||
var file := FileAccess.open(events_path, FileAccess.READ_WRITE)
|
||||
## 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)
|
||||
file = FileAccess.open(_events_path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
_fail("cannot open events file")
|
||||
return
|
||||
return event
|
||||
file.seek_end()
|
||||
file.store_line(JSON.stringify(safe))
|
||||
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
|
||||
@@ -68,6 +169,8 @@ func finish() -> Dictionary:
|
||||
_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
|
||||
|
||||
@@ -83,7 +186,7 @@ func write(path: String) -> bool:
|
||||
return true
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
_report.failures.append(message)
|
||||
_report.failures.append(_redact_text(message))
|
||||
|
||||
func _build_info() -> Dictionary:
|
||||
return {
|
||||
@@ -94,19 +197,40 @@ func _build_info() -> Dictionary:
|
||||
}
|
||||
|
||||
func _environment_info() -> Dictionary:
|
||||
var size := DisplayServer.window_get_size() if DisplayServer.get_name() != "headless" else Vector2i.ZERO
|
||||
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 ["password", "passwd", "token", "secret", "account"]:
|
||||
if name in SECRET_KEYS:
|
||||
out[key] = "[redacted]"
|
||||
else:
|
||||
out[key] = _sanitize(value[key])
|
||||
@@ -116,9 +240,11 @@ func _sanitize(value: Variant) -> Variant:
|
||||
for item in value:
|
||||
out_array.append(_sanitize(item))
|
||||
return out_array
|
||||
if value is Vector2:
|
||||
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:
|
||||
if value is Vector3 or value is Vector3i:
|
||||
return [value.x, value.y, value.z]
|
||||
if value is Object:
|
||||
return "[object]"
|
||||
|
||||
Reference in New Issue
Block a user