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

479 lines
24 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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])