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

196 lines
8.5 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.
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)))