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", ] const SERVER_KEYS := ["server_index", "channel", "auth_host", "auth_port", "game_host", "game_port"] const SUITES := ["playable", "full", "soak"] const MAP_CELL_CM := 25600.0 const GOLD_VNUM := 1 ## 技能证据分两类:只有接收端分发(M2Client 信号)才算服务端结果。 ## 本地施法开始、特效节点生成只能作为补充证据,单独出现不能让用例 PASS。 const SERVER_EVIDENCE := ["damage", "target_dead", "server_motion", "effect_cue", "fly_cue", "affect_added", "sp_changed"] const LOCAL_EVIDENCE := ["cast_started", "fx_spawned", "fx_finished"] const SKILL_TARGETS := ["enemy", "self", "ground"] 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 not _is_int(config.get("schema_version", null)) or 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_KEYS: if not server.has(key): errors.append("server.%s is missing; copy the address selected from serverinfo" % key) if not _is_int(server.get("server_index", null)) or int(server.get("server_index", -1)) < 0: errors.append("server.server_index must be a non-negative integer") if not _is_int(server.get("channel", null)) or int(server.get("channel", 0)) < 1: errors.append("server.channel must be a channel number (>= 1)") for key in ["auth_host", "game_host"]: if String(server.get(key, "")).strip_edges().is_empty(): errors.append("server.%s must not be empty" % key) for key in ["auth_port", "game_port"]: if not _is_int(server.get(key, null)) or int(server.get(key, 0)) <= 0 or int(server.get(key, 0)) > 65535: errors.append("server.%s must be 1..65535" % key) if not _is_int(config.get("character_slot", null)) or int(config.get("character_slot", -1)) < 0: errors.append("character_slot must be a non-negative integer") var map_key := String(config.get("map_key", "")).strip_edges() if map_key.is_empty(): errors.append("map_key is empty; resolve it from the server/map fixture") elif map_key.begins_with("/") or ".." in map_key.split("/") or "\\" in map_key: errors.append("map_key must be a relative asset directory such as outdoortrent/metin2_map_trent") 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_int(point[0]) or not _is_int(point[1]) \ or int(point[0]) < 0 or int(point[1]) < 0: errors.append("waypoints_cm[%d] must be [x,y] non-negative integer centimeters" % 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) for value in drops: if _is_int(value) and int(value) == GOLD_VNUM: errors.append("allowed_drop_vnums must not contain gold (vnum 1); money is a separate points case") _validate_skill_cases(config.get("skill_cases", null), errors) var resolution: Variant = config.get("resolution", null) if not (resolution is Array) or resolution.size() != 2 or not _is_int(resolution[0]) or not _is_int(resolution[1]) \ or int(resolution[0]) <= 0 or int(resolution[1]) <= 0: errors.append("resolution must be [width,height]") if not _is_int(config.get("loops", null)) or int(config.get("loops", 0)) < 1: errors.append("loops must be at least 1") if not _is_int(config.get("timeout_seconds", null)) or 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 anywhere in the tree. _reject_secrets(config, "", errors) return {"ok": errors.is_empty(), "errors": errors} ## test/playable/skill-cases.json:每职业 × {单体, 范围, 自身增益, 飞行效果} 的实际技能矩阵。 ## 条目只能是 unconfirmed(缺夹具 -> BLOCKED)、confirmed(环境负责人确认的已学技能)或 ## not_applicable(附清单确认记录)。脚本从不补技能 ID;confirmed 的 skill_id 必须属于该职业 ## 且不是被动技,每技能至少成功施放 SKILL_MATRIX_REPEATS 次。 const SKILL_MATRIX_TYPES := ["single_target", "area", "self_buff", "flying"] const SKILL_MATRIX_JOBS := ["WARRIOR", "ASSASSIN", "SURA", "SHAMAN"] const SKILL_MATRIX_REPEATS := 10 static func skill_matrix_cases(matrix: Dictionary, job: String, skill_table: RefCounted = null) -> Dictionary: var errors: Array[String] = [] var blocked: Array[String] = [] var not_applicable: Array[String] = [] var cases: Array = [] var result := {"errors": errors, "blocked": blocked, "not_applicable": not_applicable, "cases": cases} if not _is_int(matrix.get("schema_version", null)) or int(matrix.get("schema_version", -1)) != SCHEMA_VERSION: errors.append("skill matrix schema_version must be %d" % SCHEMA_VERSION) var jobs: Variant = matrix.get("jobs", null) if not (jobs is Dictionary): errors.append("skill matrix jobs must be an object") return result for name in jobs: if not (String(name) in SKILL_MATRIX_JOBS): errors.append("skill matrix has unknown job: %s" % name) if not (job in SKILL_MATRIX_JOBS) or not (jobs.get(job, null) is Dictionary): errors.append("skill matrix has no entry for job %s" % job) return result var row: Dictionary = jobs[job] for type in row: if not (String(type) in SKILL_MATRIX_TYPES): errors.append("%s has unknown skill type: %s" % [job, type]) for type in SKILL_MATRIX_TYPES: var where := "%s/%s" % [job, type] var entry: Variant = row.get(type, null) if not (entry is Dictionary): errors.append("%s is missing" % where) continue match String(entry.get("status", "")): "unconfirmed": if entry.has("skill_id"): errors.append("%s is unconfirmed but carries a skill_id" % where) blocked.append(where) "not_applicable": if String(entry.get("checklist_ref", "")).strip_edges().is_empty(): errors.append("%s not_applicable needs checklist_ref" % where) not_applicable.append(where) "confirmed": var skill_id := int(entry.skill_id) if _is_int(entry.get("skill_id", null)) else 0 if skill_table != null and skill_id > 0: if String(skill_table.category_of(skill_id)) != job: errors.append("%s skill_id %d does not belong to %s" % [where, skill_id, job]) elif skill_table.is_passive(skill_id): errors.append("%s skill_id %d is passive" % [where, skill_id]) if entry.has("repeats") and _is_int(entry.repeats) and int(entry.repeats) < SKILL_MATRIX_REPEATS: errors.append("%s repeats must be at least %d" % [where, SKILL_MATRIX_REPEATS]) cases.append({"case_id": "%s-%s" % [job.to_lower(), type.replace("_", "-")], "skill_id": entry.get("skill_id", null), "target": entry.get("target", "enemy"), "repeats": entry.get("repeats", SKILL_MATRIX_REPEATS), "required_evidence": entry.get("required_evidence", null)}) _: errors.append("%s status must be unconfirmed, confirmed or not_applicable" % where) if not cases.is_empty(): _validate_skill_cases(cases, errors) elif blocked.is_empty(): errors.append("%s has no confirmed skill case" % job) return result ## 必测用例清单由配置推导,客户端与父运行器共用同一份结果, ## 运行器在启动前落盘,校验器据此拒绝“少报一个用例”的 PASS。 static func required_cases(config: Dictionary, suite: String) -> Array[String]: var out: Array[String] = ["CONFIG-01", "NET-LOGIN-01", "NET-SELECT-01", "NET-WORLD-01"] var waypoints: Variant = config.get("waypoints_cm", []) if waypoints is Array: for i in waypoints.size(): out.append("NET-MOVE-%02d" % (i + 1)) out.append_array(["NET-MOVE-CONFIRM-01", "NET-TARGET-01", "NET-ATTACK-01", "NET-DROP-01", "NET-PICKUP-01"]) if suite == "full": var skills: Variant = config.get("skill_cases", []) if skills is Array: for item in skills: if item is Dictionary: out.append(skill_case_id(item)) if suite == "soak": # STB-MEMORY-01 is a runner case (external RSS sampler); exits are counted by the runner. out.append_array(SOAK_CASES) return out ## STB-01 §9.2 soak block. The minimums are the release acceptance numbers, not ## defaults: a shorter or smaller soak is rejected instead of silently reported as PASS. ## Warp routes and real transport faults need environment-owner confirmation; the ## unconfirmed status is valid config but the matching case stays BLOCKED. const SOAK_CASES := ["STB-DURATION-01", "STB-FRAMES-01", "STB-RECONNECT-01", "STB-WARP-01", "STB-DISCONNECT-01", "STB-RESIZE-01"] const SOAK_MIN_DURATION_S := 7200 const SOAK_MIN_REST_S := 30 const SOAK_MIN_ROUNDS := 10 const SOAK_MIN_RECONNECTS := 10 const SOAK_MIN_EXITS := 10 const SOAK_MIN_WARPS := 20 const SOAK_RESOLUTION_COUNT := 3 const SOAK_MIN_SWITCHES := 10 const SOAK_TIMEOUT_MARGIN_S := 900 const SOAK_MAX_UNREACHABLE_S := 20 const SOAK_FAULT_TYPES := ["close", "unreachable"] ## soak_client=false validates a soak block seen by another suite (the playable exit runs ## sharing the scenario): everything except the client timeout margin, which only the ## 2 h soak client needs. static func validate_soak(config: Dictionary, soak_client := true) -> Dictionary: var errors: Array[String] = [] var soak: Variant = config.get("soak", null) if not (soak is Dictionary): errors.append("soak must be an object for the soak suite") return {"ok": false, "errors": errors} var minimums := {"duration_seconds": SOAK_MIN_DURATION_S, "rest_seconds": SOAK_MIN_REST_S, "warmup_rounds": 0, "min_rounds": SOAK_MIN_ROUNDS, "reconnects": SOAK_MIN_RECONNECTS, "exits": SOAK_MIN_EXITS} for key in minimums: if not _is_int(soak.get(key, null)) or int(soak.get(key, -1)) < int(minimums[key]): errors.append("soak.%s must be an integer >= %d" % [key, minimums[key]]) var duration := int(soak.get("duration_seconds", 0)) if _is_int(soak.get("duration_seconds", null)) else 0 if soak_client and _is_int(config.get("timeout_seconds", null)) and int(config.timeout_seconds) < duration + SOAK_TIMEOUT_MARGIN_S: errors.append("timeout_seconds must be at least soak.duration_seconds + %d" % SOAK_TIMEOUT_MARGIN_S) var resolutions: Variant = soak.get("resolutions", null) if not (resolutions is Dictionary): errors.append("soak.resolutions must be an object") else: var sizes: Variant = resolutions.get("sizes", null) var seen := {} if not (sizes is Array) or sizes.size() != SOAK_RESOLUTION_COUNT: errors.append("soak.resolutions.sizes must list exactly %d logical sizes" % SOAK_RESOLUTION_COUNT) else: for i in sizes.size(): var size: Variant = sizes[i] if not (size is Array) or size.size() != 2 or not _is_int(size[0]) or not _is_int(size[1]) \ or int(size[0]) <= 0 or int(size[1]) <= 0: errors.append("soak.resolutions.sizes[%d] must be [width,height] logical points" % i) continue var key := "%dx%d" % [int(size[0]), int(size[1])] if seen.has(key): errors.append("soak.resolutions.sizes[%d] duplicates %s" % [i, key]) seen[key] = true if not _is_int(resolutions.get("switches_per_size", null)) or int(resolutions.get("switches_per_size", 0)) < SOAK_MIN_SWITCHES: errors.append("soak.resolutions.switches_per_size must be >= %d" % SOAK_MIN_SWITCHES) var warp: Variant = soak.get("warp", null) if not (warp is Dictionary): errors.append("soak.warp must be an object with status unconfirmed|confirmed") else: match String(warp.get("status", "")): "unconfirmed": pass "confirmed": if not _is_int(warp.get("required", null)) or int(warp.get("required", 0)) < SOAK_MIN_WARPS or int(warp.get("required", 0)) % 2 != 0: errors.append("soak.warp.required must be an even integer >= %d (out-and-back legs)" % SOAK_MIN_WARPS) for key in ["portal_cm", "return_portal_cm"]: if not _is_cm_point(warp.get(key, null)): errors.append("soak.warp.%s must be [x,y] non-negative integer centimeters" % key) var destination := String(warp.get("destination_map_key", "")).strip_edges() if destination.is_empty() or destination.begins_with("/") or ".." in destination.split("/") or "\\" in destination: errors.append("soak.warp.destination_map_key must be a relative asset directory") elif destination == String(config.get("map_key", "")): errors.append("soak.warp.destination_map_key must differ from map_key") if not (warp.get("cross_server", null) is bool): errors.append("soak.warp.cross_server must be true or false as verified on the test server") _: errors.append("soak.warp.status must be unconfirmed or confirmed") var faults: Variant = soak.get("faults", null) if not (faults is Dictionary): errors.append("soak.faults must be an object with status unconfirmed|confirmed") else: match String(faults.get("status", "")): "unconfirmed": pass "confirmed": # Only a test-connection proxy on 127.0.0.1; never global routes or firewall rules. if String(faults.get("mode", "")) != "local_proxy": errors.append("soak.faults.mode must be local_proxy") if not _is_int(faults.get("per_type", null)) or int(faults.get("per_type", 0)) < 1: errors.append("soak.faults.per_type must be a positive integer") var seconds: Variant = faults.get("unreachable_seconds", null) if not _is_int(seconds) or int(seconds) < 1 or int(seconds) > SOAK_MAX_UNREACHABLE_S: errors.append("soak.faults.unreachable_seconds must be 1..%d" % SOAK_MAX_UNREACHABLE_S) _check_fault_upstream(config, faults.get("upstream", null), errors) _: errors.append("soak.faults.status must be unconfirmed or confirmed") _reject_secrets(soak, "soak.", errors) return {"ok": errors.is_empty(), "errors": errors} ## The proxy (script/playable_fault_proxy.mjs) listens on server.* which must be loopback, ## and forwards to upstream (the real test server). It only carries this client's connection. static func _check_fault_upstream(config: Dictionary, upstream: Variant, errors: Array[String]) -> void: var server: Variant = config.get("server", {}) if not (server is Dictionary): return for key in ["auth_host", "game_host"]: if String(server.get(key, "")) != "127.0.0.1": errors.append("soak.faults local_proxy needs server.%s = 127.0.0.1 (the proxy listen address)" % key) if not (upstream is Dictionary): errors.append("soak.faults.upstream must give auth_host/auth_port/game_host/game_port of the real test server") return for role in ["auth", "game"]: var host := String(upstream.get(role + "_host", "")).strip_edges() var port: Variant = upstream.get(role + "_port", null) if host.is_empty() or " " in host: errors.append("soak.faults.upstream.%s_host is missing" % role) if not _is_int(port) or int(port) < 1 or int(port) > 65535: errors.append("soak.faults.upstream.%s_port must be 1..65535" % role) elif host == String(server.get(role + "_host", "")) and int(port) == int(server.get(role + "_port", -1)): errors.append("soak.faults.upstream.%s points at the proxy itself" % role) static func _is_cm_point(point: Variant) -> bool: return point is Array and point.size() == 2 and _is_int(point[0]) and _is_int(point[1]) \ and int(point[0]) >= 0 and int(point[1]) >= 0 static func skill_case_id(item: Dictionary) -> String: return "CBT-%s" % String(item.get("case_id", "")).strip_edges() ## Resolve the configured address through the same ServerInfo object the ## client uses. The config must copy the resolved address verbatim; any drift ## between the precheck target and the client connect target is an error. static func resolve_server(config: Dictionary, serverinfo: RefCounted) -> Dictionary: var errors: Array[String] = [] var server: Dictionary = config.get("server", {}) if config.get("server", null) is Dictionary else {} var index := int(server.get("server_index", -1)) var channel := int(server.get("channel", 0)) var entry: Dictionary = serverinfo.server(index) if serverinfo != null else {} if entry.is_empty(): errors.append("server_index %d is not present in serverinfo" % index) return {"ok": false, "address": {}, "errors": errors} var channels: Array = entry.get("channels", []) if not (channel in channels): errors.append("channel %d is not listed by serverinfo server %d" % [channel, index]) var address: Dictionary = serverinfo.address(index, channel) for key in ["auth_host", "game_host"]: if String(server.get(key, "")) != String(address.get(key, "")): errors.append("server.%s differs from serverinfo" % key) for key in ["auth_port", "game_port"]: if int(server.get(key, -1)) != int(address.get(key, -2)): errors.append("server.%s differs from serverinfo" % key) return {"ok": errors.is_empty(), "errors": errors, "address": { "server_index": index, "channel": channel, "auth_host": String(address.get("auth_host", "")), "auth_port": int(address.get("auth_port", 0)), "game_host": String(address.get("game_host", "")), "game_port": int(address.get("game_port", 0)), }} ## Resource preconditions: the map must exist in the asset root and every ## waypoint must fall inside the same Setting.txt rectangle GameScene uses to ## resolve a map from the server position. static func check_resources(config: Dictionary, assets_root: String) -> Dictionary: var errors: Array[String] = [] var root := assets_root.trim_suffix("/") var map_key := String(config.get("map_key", "")) var setting := root.path_join(map_key).path_join("Setting.txt") if root.is_empty() or not DirAccess.dir_exists_absolute(root): errors.append("asset root does not exist") return {"ok": false, "errors": errors, "bounds": {}} if not FileAccess.file_exists(setting): errors.append("map_key has no Setting.txt in the asset root: %s" % map_key) return {"ok": false, "errors": errors, "bounds": {}} var bounds := map_bounds(setting) if bounds.is_empty(): errors.append("map Setting.txt lacks BasePosition/MapSize: %s" % map_key) return {"ok": false, "errors": errors, "bounds": {}} var base: Vector2 = bounds.base var size: Vector2i = bounds.size var waypoints: Array = config.get("waypoints_cm", []) for i in waypoints.size(): var point: Array = waypoints[i] var x := float(point[0]) var y := float(point[1]) if x < base.x or y < base.y or x >= base.x + size.x * MAP_CELL_CM or y >= base.y + size.y * MAP_CELL_CM: errors.append("waypoints_cm[%d] is outside %s bounds" % [i, map_key]) return {"ok": errors.is_empty(), "errors": errors, "bounds": {"base_cm": [base.x, base.y], "size_cells": [size.x, size.y]}} static func map_bounds(setting_path: String) -> Dictionary: var f := FileAccess.open(setting_path, FileAccess.READ) if f == null: return {} var base := Vector2.ZERO var size := Vector2i.ZERO var have_base := false var have_size := false while not f.eof_reached(): var fields := f.get_line().replace("\t", " ").strip_edges().split(" ", false) if fields.size() < 3: continue match String(fields[0]).to_lower(): "baseposition": base = Vector2(float(fields[1]), float(fields[2])) have_base = true "mapsize": size = Vector2i(int(fields[1]), int(fields[2])) have_size = size.x > 0 and size.y > 0 if not have_base or not have_size: return {} return {"base": base, "size": size} static func _reject_secrets(value: Variant, prefix: String, errors: Array[String]) -> void: if value is Dictionary: for key in value: var name := String(key).to_lower() for forbidden in ["account", "username", "user_id", "login", "password", "passwd", "token", "secret"]: if name == forbidden or name.ends_with("_" + forbidden): errors.append("credentials must not be stored in config: %s%s" % [prefix, key]) break _reject_secrets(value[key], "%s%s." % [prefix, key], errors) elif value is Array: for i in value.size(): _reject_secrets(value[i], "%s%d." % [prefix, i], errors) static func _validate_positive_int_array(values: Array, name: String, errors: Array[String]) -> void: for i in values.size(): if not _is_int(values[i]) or int(values[i]) <= 0: errors.append("%s[%d] must be a positive integer" % [name, i]) ## JSON numbers arrive as float; accept only integral values. static func _is_int(value: Variant) -> bool: if value is int: return true return value is float and is_finite(value) and float(value) == floorf(value) static func _validate_skill_cases(skills: Variant, errors: Array[String]) -> void: if not (skills is Array) or skills.is_empty(): errors.append("skill_cases must contain at least one confirmed skill case") else: var seen := {} 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]) var case_id := String(item.get("case_id", "")).strip_edges() if case_id.is_empty(): errors.append("skill_cases[%d].case_id is empty" % i) elif seen.has(case_id): errors.append("skill_cases[%d].case_id is duplicated: %s" % [i, case_id]) seen[case_id] = true if not _is_int(item.get("skill_id", null)) or 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 item.has("target") and not (String(item.target) in SKILL_TARGETS): errors.append("skill_cases[%d].target must be one of %s" % [i, SKILL_TARGETS]) if item.has("repeats") and (not _is_int(item.repeats) or int(item.repeats) < 1): errors.append("skill_cases[%d].repeats must be a positive integer" % i) var evidence: Variant = item.get("required_evidence", null) if not (evidence is Array) or evidence.is_empty(): errors.append("skill_cases[%d].required_evidence must be non-empty" % i) continue var has_server := false for kind in evidence: var name := String(kind) if name in SERVER_EVIDENCE: has_server = true elif not (name in LOCAL_EVIDENCE): errors.append("skill_cases[%d].required_evidence has unknown kind: %s" % [i, name]) if not has_server: errors.append("skill_cases[%d].required_evidence needs at least one server-side kind %s" % [i, SERVER_EVIDENCE])