- 新增 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
479 lines
24 KiB
GDScript
479 lines
24 KiB
GDScript
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])
|