Implement playable Mac client and rendering validation
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
class_name PlayableConfig
|
||||
|
||||
## 首个联网内测的配置解析器。
|
||||
##
|
||||
## 这是纯数据校验层:不读取账号密码、不创建网络连接、不修改游戏状态。
|
||||
## 生产测试入口必须先通过 validate(),任何缺少的必测字段都只能得到 BLOCKED,
|
||||
## 不能由测试脚本临时补默认值后继续发包。
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
const REQUIRED_KEYS := [
|
||||
"scenario_id", "protocol", "server", "character_slot", "map_key",
|
||||
"waypoints_cm", "allowed_mob_vnums", "allowed_drop_vnums", "skill_cases",
|
||||
"resolution", "loops", "timeout_seconds",
|
||||
]
|
||||
|
||||
static func load_file(path: String) -> Dictionary:
|
||||
var result := {"ok": false, "config": {}, "errors": []}
|
||||
if path.strip_edges().is_empty():
|
||||
result.errors.append("config path is empty")
|
||||
return result
|
||||
if not FileAccess.file_exists(path):
|
||||
result.errors.append("config file does not exist: %s" % path)
|
||||
return result
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
result.errors.append("config file cannot be opened")
|
||||
return result
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
result.errors.append("config root must be a JSON object")
|
||||
return result
|
||||
var checked := validate(parsed)
|
||||
result.config = parsed
|
||||
result.errors = checked.errors
|
||||
result.ok = bool(checked.ok)
|
||||
return result
|
||||
|
||||
static func validate(config: Dictionary) -> Dictionary:
|
||||
var errors: Array[String] = []
|
||||
for key in REQUIRED_KEYS:
|
||||
if not config.has(key):
|
||||
errors.append("missing required key: %s" % key)
|
||||
if int(config.get("schema_version", -1)) != SCHEMA_VERSION:
|
||||
errors.append("schema_version must be %d" % SCHEMA_VERSION)
|
||||
if String(config.get("scenario_id", "")).strip_edges().is_empty():
|
||||
errors.append("scenario_id must not be empty")
|
||||
var protocol := String(config.get("protocol", "")).strip_edges().to_lower()
|
||||
if protocol != "classic":
|
||||
errors.append("protocol must be classic")
|
||||
var server: Variant = config.get("server", null)
|
||||
if not (server is Dictionary):
|
||||
errors.append("server must be an object selected from serverinfo")
|
||||
else:
|
||||
for key in ["server_index", "channel_index"]:
|
||||
if not server.has(key) or int(server[key]) < 0:
|
||||
errors.append("server.%s must be a non-negative integer" % key)
|
||||
if not config.has("character_slot") or int(config.get("character_slot", -1)) < 0:
|
||||
errors.append("character_slot must be a non-negative integer")
|
||||
if String(config.get("map_key", "")).strip_edges().is_empty():
|
||||
errors.append("map_key is empty; resolve it from the server/map fixture")
|
||||
var waypoints: Variant = config.get("waypoints_cm", null)
|
||||
if not (waypoints is Array) or waypoints.is_empty():
|
||||
errors.append("waypoints_cm must contain at least one [x,y] centimeter point")
|
||||
else:
|
||||
for i in waypoints.size():
|
||||
var point: Variant = waypoints[i]
|
||||
if not (point is Array) or point.size() != 2 or not _is_number(point[0]) or not _is_number(point[1]):
|
||||
errors.append("waypoints_cm[%d] must be [x,y] numbers" % i)
|
||||
var mobs: Variant = config.get("allowed_mob_vnums", null)
|
||||
if not (mobs is Array) or mobs.is_empty():
|
||||
errors.append("allowed_mob_vnums must contain confirmed server mob vnums")
|
||||
else:
|
||||
_validate_positive_int_array(mobs, "allowed_mob_vnums", errors)
|
||||
var drops: Variant = config.get("allowed_drop_vnums", null)
|
||||
if not (drops is Array) or drops.is_empty():
|
||||
errors.append("allowed_drop_vnums must contain a deterministic pickup fixture")
|
||||
else:
|
||||
_validate_positive_int_array(drops, "allowed_drop_vnums", errors)
|
||||
var skills: Variant = config.get("skill_cases", null)
|
||||
if not (skills is Array) or skills.is_empty():
|
||||
errors.append("skill_cases must contain at least one confirmed skill case")
|
||||
else:
|
||||
for i in skills.size():
|
||||
if not (skills[i] is Dictionary):
|
||||
errors.append("skill_cases[%d] must be an object" % i)
|
||||
continue
|
||||
var item: Dictionary = skills[i]
|
||||
for key in ["case_id", "skill_id", "required_evidence"]:
|
||||
if not item.has(key):
|
||||
errors.append("skill_cases[%d] missing %s" % [i, key])
|
||||
if String(item.get("case_id", "")).strip_edges().is_empty():
|
||||
errors.append("skill_cases[%d].case_id is empty" % i)
|
||||
if int(item.get("skill_id", -1)) <= 0 or int(item.get("skill_id", -1)) >= 255:
|
||||
errors.append("skill_cases[%d].skill_id must be 1..254" % i)
|
||||
if not (item.get("required_evidence", null) is Array) or item.required_evidence.is_empty():
|
||||
errors.append("skill_cases[%d].required_evidence must be non-empty" % i)
|
||||
var resolution: Variant = config.get("resolution", null)
|
||||
if not (resolution is Array) or resolution.size() != 2 or int(resolution[0]) <= 0 or int(resolution[1]) <= 0:
|
||||
errors.append("resolution must be [width,height]")
|
||||
if int(config.get("loops", 0)) < 1:
|
||||
errors.append("loops must be at least 1")
|
||||
if int(config.get("timeout_seconds", 0)) < 1:
|
||||
errors.append("timeout_seconds must be positive")
|
||||
# This config is deliberately data-only. Reject the common accidental secret paths.
|
||||
for forbidden in ["account", "username", "password", "token", "secret"]:
|
||||
if config.has(forbidden):
|
||||
errors.append("credentials must not be stored in config: %s" % forbidden)
|
||||
return {"ok": errors.is_empty(), "errors": errors}
|
||||
|
||||
static func _validate_positive_int_array(values: Array, name: String, errors: Array[String]) -> void:
|
||||
for i in values.size():
|
||||
if not _is_number(values[i]) or int(values[i]) <= 0 or float(values[i]) != float(int(values[i])):
|
||||
errors.append("%s[%d] must be a positive integer" % [name, i])
|
||||
|
||||
static func _is_number(value: Variant) -> bool:
|
||||
return value is int or value is float
|
||||
@@ -0,0 +1,115 @@
|
||||
class_name PlayableProbe
|
||||
extends Node
|
||||
|
||||
## 只读网络/场景观察器。所有服务端证据必须从 M2Client 信号或状态快照进入,
|
||||
## 本节点不调用移动、攻击、拾取、施法或其它会改变服务器状态的方法。
|
||||
|
||||
signal observed(kind: String, payload: Dictionary)
|
||||
|
||||
var client: Node
|
||||
var flow: Node
|
||||
var connection_epoch := 0
|
||||
var _connections: Array[Dictionary] = []
|
||||
|
||||
func setup(app_flow: Node, m2client: Node) -> void:
|
||||
flow = app_flow
|
||||
client = m2client
|
||||
if client == null:
|
||||
return
|
||||
_bind("entered_game", Callable(self, "_on_entered_game"))
|
||||
_bind("disconnected", Callable(self, "_on_disconnected"))
|
||||
_bind("char_list", Callable(self, "_on_char_list"))
|
||||
_bind("entity_spawned", Callable(self, "_on_entity_spawned"))
|
||||
_bind("entity_despawned", Callable(self, "_on_entity_despawned"))
|
||||
_bind("entity_moved", Callable(self, "_on_entity_moved"))
|
||||
_bind("entity_dead", Callable(self, "_on_entity_dead"))
|
||||
_bind("damage", Callable(self, "_on_damage"))
|
||||
_bind("vitals_changed", Callable(self, "_on_vitals"))
|
||||
_bind("ground_item_added", Callable(self, "_on_ground_item_added"))
|
||||
_bind("ground_item_removed", Callable(self, "_on_ground_item_removed"))
|
||||
_bind("item_picked_up", Callable(self, "_on_item_picked_up"))
|
||||
_bind("inventory_changed", Callable(self, "_on_inventory_changed"))
|
||||
_bind("effect_cue", Callable(self, "_on_effect_cue"))
|
||||
_bind("fly_cue", Callable(self, "_on_fly_cue"))
|
||||
_bind("fly_targeting", Callable(self, "_on_fly_targeting"))
|
||||
|
||||
func snapshot() -> Dictionary:
|
||||
var in_game: bool = client != null and client.has_method("is_in_game") and client.is_in_game()
|
||||
var main_vid := int(client.get_main_vid()) if client != null and client.has_method("get_main_vid") else 0
|
||||
var entity_count: int = client.get_entities().size() if client != null and client.has_method("get_entities") else 0
|
||||
var ground_count: int = client.get_ground_items().size() if client != null and client.has_method("get_ground_items") else 0
|
||||
var state := int(flow.state()) if flow != null and flow.has_method("state") else -1
|
||||
return {"in_game": in_game, "main_vid": main_vid, "entity_count": entity_count,
|
||||
"ground_item_count": ground_count, "app_state": state, "connection_epoch": connection_epoch}
|
||||
|
||||
func disconnect_all() -> void:
|
||||
for entry in _connections:
|
||||
var signal_name: String = entry.signal
|
||||
var callback: Callable = entry.callback
|
||||
if client != null and client.has_signal(signal_name) and client.is_connected(signal_name, callback):
|
||||
client.disconnect(signal_name, callback)
|
||||
_connections.clear()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
disconnect_all()
|
||||
|
||||
func _bind(signal_name: String, callback: Callable) -> void:
|
||||
if client.has_signal(signal_name) and not client.is_connected(signal_name, callback):
|
||||
client.connect(signal_name, callback)
|
||||
_connections.append({"signal": signal_name, "callback": callback})
|
||||
|
||||
func _emit(kind: String, payload := {}) -> void:
|
||||
var data: Dictionary = payload.duplicate(true)
|
||||
data["connection_epoch"] = connection_epoch
|
||||
data["received_us"] = Time.get_ticks_usec()
|
||||
observed.emit(kind, data)
|
||||
|
||||
func _on_entered_game() -> void:
|
||||
connection_epoch += 1
|
||||
_emit("entered_game")
|
||||
|
||||
func _on_disconnected(reason: String) -> void:
|
||||
_emit("disconnected", {"reason": reason})
|
||||
|
||||
func _on_char_list(chars: Array) -> void:
|
||||
_emit("char_list", {"count": chars.size()})
|
||||
|
||||
func _on_entity_spawned(entity: Dictionary) -> void:
|
||||
_emit("entity_spawned", {"vid": int(entity.get("vid", 0)), "race": int(entity.get("race", 0)), "kind": int(entity.get("kind", -1))})
|
||||
|
||||
func _on_entity_despawned(vid: int) -> void:
|
||||
_emit("entity_despawned", {"vid": vid})
|
||||
|
||||
func _on_entity_moved(vid: int) -> void:
|
||||
_emit("entity_moved", {"vid": vid})
|
||||
|
||||
func _on_entity_dead(vid: int) -> void:
|
||||
_emit("entity_dead", {"vid": vid})
|
||||
|
||||
func _on_damage(vid: int, amount: int, flag: int) -> void:
|
||||
_emit("damage", {"vid": vid, "amount": amount, "flag": flag})
|
||||
|
||||
func _on_vitals(vid: int) -> void:
|
||||
_emit("vitals_changed", {"vid": vid})
|
||||
|
||||
func _on_ground_item_added(item: Dictionary) -> void:
|
||||
_emit("ground_item_added", {"vid": int(item.get("vid", 0)), "vnum": int(item.get("vnum", 0)), "owner": String(item.get("owner", ""))})
|
||||
|
||||
func _on_ground_item_removed(vid: int) -> void:
|
||||
_emit("ground_item_removed", {"vid": vid})
|
||||
|
||||
func _on_item_picked_up(vnum: int, count: int, source: String) -> void:
|
||||
_emit("item_picked_up", {"vnum": vnum, "count": count, "source": source})
|
||||
|
||||
func _on_inventory_changed(window: int, cell: int) -> void:
|
||||
_emit("inventory_changed", {"window": window, "cell": cell})
|
||||
|
||||
func _on_effect_cue(vid: int, name: String, special: int) -> void:
|
||||
_emit("effect_cue", {"vid": vid, "name": name, "special": special})
|
||||
|
||||
func _on_fly_cue(kind: int, start_vid: int, end_vid: int) -> void:
|
||||
_emit("fly_cue", {"type": kind, "start_vid": start_vid, "end_vid": end_vid})
|
||||
|
||||
func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool) -> void:
|
||||
_emit("fly_targeting", {"shooter_vid": shooter_vid, "target_vid": target_vid,
|
||||
"target_cm": [target_cm.x, target_cm.y], "append": append})
|
||||
@@ -0,0 +1,125 @@
|
||||
class_name PlayableReport
|
||||
|
||||
## 联网测试进程内的报告构建器。
|
||||
## 只写非敏感的 client-report.json;最终 process/退出日志门禁由父脚本补齐。
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
const VALID_STATUSES := ["PASS", "FAIL", "BLOCKED", "SKIP"]
|
||||
|
||||
var _report: Dictionary = {}
|
||||
var _started_us := 0
|
||||
|
||||
func begin(run_id: String, suite: String, config: Dictionary) -> void:
|
||||
_started_us = Time.get_ticks_usec()
|
||||
_report = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"run_id": run_id,
|
||||
"suite": suite,
|
||||
"status": "BLOCKED",
|
||||
"build": _build_info(),
|
||||
"environment": _environment_info(),
|
||||
"cases": [],
|
||||
"events": "events.jsonl",
|
||||
"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", ""))
|
||||
|
||||
func add_case(case_id: String, status: String, reason := "", evidence: Array = [], duration_ms := 0.0) -> void:
|
||||
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)}
|
||||
_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"])
|
||||
elif normalized == "BLOCKED":
|
||||
_report.blocked.append(reason if not reason.is_empty() else case_id)
|
||||
|
||||
func add_event(event: Dictionary, events_path: String) -> void:
|
||||
var safe: Variant = _sanitize(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
|
||||
file.seek_end()
|
||||
file.store_line(JSON.stringify(safe))
|
||||
file.close()
|
||||
|
||||
func finish() -> Dictionary:
|
||||
if _report.is_empty():
|
||||
return {}
|
||||
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"
|
||||
_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(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 size := DisplayServer.window_get_size() if DisplayServer.get_name() != "headless" else Vector2i.ZERO
|
||||
return {
|
||||
"os": OS.get_name(),
|
||||
"renderer": RenderingServer.get_video_adapter_name(),
|
||||
"resolution": [size.x, size.y],
|
||||
}
|
||||
|
||||
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"]:
|
||||
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 Vector2:
|
||||
return [value.x, value.y]
|
||||
if value is Vector3:
|
||||
return [value.x, value.y, value.z]
|
||||
if value is Object:
|
||||
return "[object]"
|
||||
return value
|
||||
Reference in New Issue
Block a user