118 lines
5.4 KiB
GDScript
118 lines
5.4 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",
|
|
]
|
|
|
|
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
|