- 新增 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
196 lines
8.5 KiB
GDScript
196 lines
8.5 KiB
GDScript
extends RefCounted
|
||
|
||
## MAP-02 地图内验收的机位夹具(test/playable/forest-viewpoints.<name>.local.json)。
|
||
##
|
||
## 机位必须由测试环境负责人在实服上确认(服务器全局 cm)。未配置的 flat/slope/dense
|
||
## 只能由地形/树木密度自动挑“候选”机位:照样截图和测量,但用例保持 BLOCKED,
|
||
## 不能冒充“经确认的机位”。传送点无法离线推导,缺配置即 BLOCKED。
|
||
|
||
const Config = preload("res://testing/playable_config.gd")
|
||
|
||
const SCHEMA_VERSION := 1
|
||
const KINDS := ["flat", "slope", "dense", "warp"]
|
||
const AUTO_KINDS := ["flat", "slope", "dense"]
|
||
const STATUSES := ["confirmed", "unconfirmed"]
|
||
const MAP_CELL_CM := 25600.0
|
||
const DEFAULT_YAW_DEG := 45.0
|
||
## 自动候选:点位离地图边缘、彼此之间和最近树干的最小距离(米),以及坡地的可行走上限。
|
||
const AUTO_EDGE_MARGIN_M := 12.0
|
||
const AUTO_SEPARATION_M := 20.0
|
||
const AUTO_TREE_CLEARANCE_M := 3.0
|
||
const AUTO_DENSITY_RADIUS_M := 12.0
|
||
const AUTO_MAX_SLOPE_RATIO := 0.6
|
||
|
||
static func load_file(path: String) -> Dictionary:
|
||
var result := {"ok": false, "data": {}, "errors": []}
|
||
if path.strip_edges().is_empty():
|
||
result.ok = true # 没有夹具文件:全部机位走候选/BLOCKED,而不是失败
|
||
return result
|
||
if not FileAccess.file_exists(path):
|
||
result.errors.append("viewpoint file does not exist: %s" % path)
|
||
return result
|
||
var file := FileAccess.open(path, FileAccess.READ)
|
||
var parsed: Variant = JSON.parse_string(file.get_as_text()) if file else null
|
||
if not (parsed is Dictionary):
|
||
result.errors.append("viewpoint file root must be a JSON object")
|
||
return result
|
||
result.data = parsed
|
||
result.ok = true
|
||
return result
|
||
|
||
## bounds = {"base": Vector2 cm, "size": Vector2i tiles}(GameScene._map_bounds 的结果)。
|
||
## 返回 {errors, viewpoints, missing_kinds, races};viewpoints 带 local_m(地图本地米)。
|
||
static func plan(data: Dictionary, map_key: String, bounds: Dictionary) -> Dictionary:
|
||
var errors: Array[String] = []
|
||
var viewpoints: Array = []
|
||
var races: Array = []
|
||
var out := {"errors": errors, "viewpoints": viewpoints, "missing_kinds": [], "races": races}
|
||
if not data.is_empty():
|
||
if not _is_int(data.get("schema_version", null)) or int(data.schema_version) != SCHEMA_VERSION:
|
||
errors.append("viewpoint schema_version must be %d" % SCHEMA_VERSION)
|
||
if not (data.get("maps", null) is Dictionary):
|
||
errors.append("viewpoint maps must be an object keyed by map_key")
|
||
Config._reject_secrets(data, "", errors)
|
||
var maps: Dictionary = data.get("maps", {}) if data.get("maps", {}) is Dictionary else {}
|
||
var entry: Variant = maps.get(map_key, {})
|
||
if not (entry is Dictionary):
|
||
errors.append("maps.%s must be an object" % map_key)
|
||
entry = {}
|
||
var seen := {}
|
||
var listed: Variant = entry.get("viewpoints", [])
|
||
if not (listed is Array):
|
||
errors.append("maps.%s.viewpoints must be an array" % map_key)
|
||
listed = []
|
||
for i in listed.size():
|
||
var where := "maps.%s.viewpoints[%d]" % [map_key, i]
|
||
var item: Variant = listed[i]
|
||
if not (item is Dictionary):
|
||
errors.append("%s must be an object" % where)
|
||
continue
|
||
var id := String(item.get("id", "")).strip_edges()
|
||
if id.is_empty() or not id.is_valid_filename() or " " in id:
|
||
errors.append("%s.id must be a non-empty file-safe name" % where)
|
||
elif seen.has(id):
|
||
errors.append("%s.id duplicates %s" % [where, id])
|
||
seen[id] = true
|
||
var kind := String(item.get("kind", ""))
|
||
if not (kind in KINDS):
|
||
errors.append("%s.kind must be one of %s" % [where, KINDS])
|
||
var status := String(item.get("status", ""))
|
||
var confirmed_by := String(item.get("confirmed_by", "")).strip_edges()
|
||
if not (status in STATUSES):
|
||
errors.append("%s.status must be confirmed or unconfirmed" % where)
|
||
elif status == "confirmed" and confirmed_by.is_empty():
|
||
errors.append("%s is confirmed but confirmed_by (role) is empty" % where)
|
||
elif status == "unconfirmed" and not confirmed_by.is_empty():
|
||
errors.append("%s is unconfirmed but carries confirmed_by" % where)
|
||
var cm: Variant = item.get("server_cm", null)
|
||
var server_cm := Vector2(-1, -1)
|
||
if not (cm is Array) or cm.size() != 2 or not _is_int(cm[0]) or not _is_int(cm[1]) \
|
||
or int(cm[0]) < 0 or int(cm[1]) < 0:
|
||
errors.append("%s.server_cm must be [x,y] non-negative integer centimeters" % where)
|
||
else:
|
||
server_cm = Vector2(int(cm[0]), int(cm[1]))
|
||
if not contains_server_cm(bounds, server_cm):
|
||
errors.append("%s.server_cm %s is outside %s" % [where, cm, map_key])
|
||
var yaw: Variant = item.get("camera_yaw_deg", DEFAULT_YAW_DEG)
|
||
if not (yaw is float or yaw is int):
|
||
errors.append("%s.camera_yaw_deg must be a number" % where)
|
||
yaw = DEFAULT_YAW_DEG
|
||
viewpoints.append({"id": id, "kind": kind, "status": status, "source": "config",
|
||
"confirmed_by": confirmed_by, "camera_yaw_deg": float(yaw), "server_cm": server_cm,
|
||
"local_m": to_local_m(bounds, server_cm)})
|
||
var race_list: Variant = entry.get("races", [])
|
||
if not (race_list is Array):
|
||
errors.append("maps.%s.races must be an array of mob vnums" % map_key)
|
||
else:
|
||
for value in race_list:
|
||
if not _is_int(value) or int(value) <= 0:
|
||
errors.append("maps.%s.races must contain positive integers" % map_key)
|
||
break
|
||
races.append(int(value))
|
||
for kind in KINDS:
|
||
if not viewpoints.any(func(v: Dictionary) -> bool: return v.kind == kind):
|
||
out.missing_kinds.append(kind)
|
||
return out
|
||
|
||
static func contains_server_cm(bounds: Dictionary, server_cm: Vector2) -> bool:
|
||
if bounds.is_empty():
|
||
return false
|
||
var base: Vector2 = bounds.base
|
||
var size: Vector2i = bounds.size
|
||
return server_cm.x >= base.x and server_cm.y >= base.y \
|
||
and server_cm.x < base.x + size.x * MAP_CELL_CM and server_cm.y < base.y + size.y * MAP_CELL_CM
|
||
|
||
## 服务器全局 cm -> Metin2World 地图本地米(与 MapCoord.to_world 一致:+Z 朝南)。
|
||
static func to_local_m(bounds: Dictionary, server_cm: Vector2) -> Vector2:
|
||
var base: Vector2 = bounds.get("base", Vector2.ZERO)
|
||
return (server_cm - base) * 0.01
|
||
|
||
static func to_server_cm(bounds: Dictionary, local_m: Vector2) -> Vector2:
|
||
var base: Vector2 = bounds.get("base", Vector2.ZERO)
|
||
return Vector2(roundf(local_m.x * 100.0 + base.x), roundf(local_m.y * 100.0 + base.y))
|
||
|
||
## samples: [{p: Vector2 本地米, range_m: 足迹内地形高差, span_m: 足迹宽度, blocked: bool}]
|
||
## trees: 树干本地米坐标。只挑 kinds 里请求的种类;挑不出来的种类不返回(调用方记 BLOCKED)。
|
||
static func pick_candidates(samples: Array, trees: PackedVector2Array, size_m: Vector2, kinds: Array) -> Array:
|
||
var usable: Array = []
|
||
for s: Dictionary in samples:
|
||
var p: Vector2 = s.p
|
||
if bool(s.blocked) or p.x < AUTO_EDGE_MARGIN_M or p.y < AUTO_EDGE_MARGIN_M \
|
||
or p.x > size_m.x - AUTO_EDGE_MARGIN_M or p.y > size_m.y - AUTO_EDGE_MARGIN_M:
|
||
continue
|
||
var nearest := INF
|
||
var density := 0
|
||
for t in trees:
|
||
var d := t.distance_to(p)
|
||
nearest = minf(nearest, d)
|
||
if d <= AUTO_DENSITY_RADIUS_M:
|
||
density += 1
|
||
if nearest < AUTO_TREE_CLEARANCE_M:
|
||
continue
|
||
var slope := float(s.range_m) / maxf(float(s.span_m), 0.001)
|
||
usable.append({"p": p, "range_m": float(s.range_m), "slope_ratio": slope, "density": density})
|
||
var chosen: Array = []
|
||
var centre := size_m * 0.5
|
||
for kind in ["flat", "dense", "slope"]:
|
||
if not (kind in kinds):
|
||
continue
|
||
var best: Dictionary = {}
|
||
for u: Dictionary in usable:
|
||
if chosen.any(func(c: Dictionary) -> bool: return (c.p as Vector2).distance_to(u.p) < AUTO_SEPARATION_M):
|
||
continue
|
||
if best.is_empty() or _better(kind, u, best, centre):
|
||
best = u
|
||
if best.is_empty():
|
||
continue
|
||
if kind == "slope" and float(best.slope_ratio) <= 0.0:
|
||
continue
|
||
if kind == "dense" and int(best.density) == 0:
|
||
continue
|
||
var c := best.duplicate()
|
||
c["kind"] = kind
|
||
chosen.append(c)
|
||
return chosen
|
||
|
||
static func _better(kind: String, a: Dictionary, b: Dictionary, centre: Vector2) -> bool:
|
||
match kind:
|
||
"flat":
|
||
if not is_equal_approx(float(a.range_m), float(b.range_m)):
|
||
return float(a.range_m) < float(b.range_m)
|
||
return (a.p as Vector2).distance_to(centre) < (b.p as Vector2).distance_to(centre)
|
||
"slope":
|
||
var a_ok := float(a.slope_ratio) <= AUTO_MAX_SLOPE_RATIO
|
||
var b_ok := float(b.slope_ratio) <= AUTO_MAX_SLOPE_RATIO
|
||
if a_ok != b_ok:
|
||
return a_ok
|
||
return float(a.slope_ratio) > float(b.slope_ratio) if a_ok else float(a.slope_ratio) < float(b.slope_ratio)
|
||
"dense":
|
||
if int(a.density) != int(b.density):
|
||
return int(a.density) > int(b.density)
|
||
return float(a.range_m) < float(b.range_m)
|
||
return false
|
||
|
||
static func _is_int(value: Variant) -> bool:
|
||
return value is int or (value is float and is_equal_approx(value, roundf(value)))
|