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
This commit is contained in:
+24
-5
@@ -51,7 +51,13 @@ func _init() -> void:
|
||||
func start(assets_root: String = "", injected_client: Node = null) -> void:
|
||||
_assets = assets_root
|
||||
serverinfo = ServerInfoRes.new()
|
||||
serverinfo.load_file("res://serverlist.txt") # 有就用,没有用内置
|
||||
# 联网内测的地址覆盖只在 MT_TEST_MODE=playable 生效;父运行器把同一路径交给
|
||||
# 预检,保证预检目标与客户端真实连接目标来自同一个 ServerInfo 解析。
|
||||
var test_serverlist := OS.get_environment("MT_PLAYABLE_SERVERLIST").strip_edges()
|
||||
if OS.get_environment("MT_TEST_MODE") == "playable" and not test_serverlist.is_empty():
|
||||
serverinfo.load_file(test_serverlist)
|
||||
else:
|
||||
serverinfo.load_file("res://serverlist.txt") # 有就用,没有用内置
|
||||
if injected_client != null:
|
||||
client = injected_client
|
||||
elif ClassDB.class_exists("M2Client"):
|
||||
@@ -84,6 +90,17 @@ func start(assets_root: String = "", injected_client: Node = null) -> void:
|
||||
func state() -> int:
|
||||
return _state
|
||||
|
||||
# Playable-test only: choose the server/channel before the deferred autologin
|
||||
# connect runs, and return the exact address _do_connect() will use.
|
||||
func select_test_server(server_index: int, channel: int) -> Dictionary:
|
||||
if OS.get_environment("MT_TEST_MODE") != "playable" or serverinfo == null:
|
||||
return {}
|
||||
if serverinfo.server(server_index).is_empty():
|
||||
return {}
|
||||
_sel_server = server_index
|
||||
_sel_channel = channel
|
||||
return serverinfo.address(_sel_server, _sel_channel)
|
||||
|
||||
# Read-only adapter for the packaged playable test. It deliberately returns
|
||||
# summaries rather than the private UI/game nodes so a report cannot retain a
|
||||
# stale scene reference across reconnect or map reload.
|
||||
@@ -147,14 +164,16 @@ func _on_char_list(list: Array) -> void:
|
||||
if auto_login and not _chars.is_empty():
|
||||
var idx := int(_chars[0].get("index", 0))
|
||||
if _auto_char_slot >= 0:
|
||||
# An explicit MT_CHAR_SLOT that the account lacks must not silently enter
|
||||
# another character; stay on the select screen instead.
|
||||
idx = -1
|
||||
for character in _chars:
|
||||
if int(character.get("index", -1)) == _auto_char_slot:
|
||||
idx = _auto_char_slot
|
||||
break
|
||||
if client.has_method("enter_game"):
|
||||
client.enter_game(idx)
|
||||
elif client.has_method("select_character"):
|
||||
client.select_character(idx)
|
||||
if idx >= 0:
|
||||
# Remember the slot so a later in-game reconnect re-enters the same character.
|
||||
_enter_character(idx)
|
||||
|
||||
func _on_char_name_changed(pid: int, name: String) -> void:
|
||||
var sc: Node = _ui.get_node_or_null("CharSelect") if _ui and _state == SELECT else null
|
||||
|
||||
@@ -74,6 +74,20 @@ func _init() -> void:
|
||||
_ck(game_snapshot.get("stage", "") == "GAME" and not game_snapshot.get("reconnecting", true),
|
||||
"playable snapshot reports GAME after reconnect")
|
||||
|
||||
# Auto-login with an explicit MT_CHAR_SLOT must never fall back to another
|
||||
# character, and the chosen slot must be remembered for a later reconnect.
|
||||
flow._state = AppFlow.LOGIN
|
||||
flow.auto_login = true
|
||||
flow._auto_char_slot = 5
|
||||
flow._selected_char_slot = -1
|
||||
client.selects.clear()
|
||||
client.char_list.emit([{"index": 0, "name": "Other"}])
|
||||
_ck(client.selects.is_empty(), "absent explicit slot does not enter chars[0]")
|
||||
flow._state = AppFlow.LOGIN
|
||||
client.char_list.emit([{"index": 0, "name": "Other"}, {"index": 5, "name": "Fixture"}])
|
||||
_ck(client.selects == [5] and flow._selected_char_slot == 5,
|
||||
"explicit slot is entered and remembered for reconnect")
|
||||
|
||||
flow.queue_free()
|
||||
await process_frame
|
||||
if _fail == 0:
|
||||
|
||||
@@ -267,6 +267,14 @@ func _run() -> void:
|
||||
np._swing_start_t = np._now() - 2.0 # 动作越过所有命中窗
|
||||
np._process(0.016)
|
||||
_ck(np._hit_windows.is_empty(), "motion past all windows -> hit windows cleared")
|
||||
# 目标已死:挥击命中窗内也不发 CG_ATTACK(死亡目标不是有效命中)
|
||||
fc.ents[2000]["dead"] = true
|
||||
np._do_attack_swing(pcn, {})
|
||||
np._hit_dedup.clear()
|
||||
np._swing_start_t = np._now() - 0.1
|
||||
np._process(0.016)
|
||||
_ck(fc.attacks.size() == 1, "dead victim inside the hit window -> no CG_ATTACK")
|
||||
fc.ents[2000]["dead"] = false
|
||||
np.net_world = null
|
||||
|
||||
# --- §3.5 修改 6:武器种类 -> combo_motion_mode(RefreshState 的 SetMotionMode 分支)---
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
extends SceneTree
|
||||
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
const EffectRegistry = preload("res://fx/effect_registry.gd")
|
||||
var failures := 0
|
||||
|
||||
func check(ok: bool, label: String) -> void:
|
||||
@@ -84,5 +85,45 @@ func run() -> void:
|
||||
check(is_equal_approx(live._particle_states[0].clock, stopped_clock),
|
||||
"SceneTree leaves stopped clock unchanged")
|
||||
live.free()
|
||||
await registry_lifecycle()
|
||||
print("effect_playback_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
|
||||
# CBT-01 evidence: EffectRegistry reports the real spawn and tree-exit boundaries.
|
||||
func registry_lifecycle() -> void:
|
||||
var registry := EffectRegistry.new()
|
||||
var spec := {"lights": [{"duration": 0.8, "diffuse": [1.0, 0.5, 0.2, 1.0]}]}
|
||||
registry._path_cache["synthetic"] = "synthetic.mse"
|
||||
registry._spec_cache["synthetic.mse"] = spec
|
||||
var spawned: Array = []
|
||||
var finished: Array = []
|
||||
registry.fx_spawned.connect(func(effect: String, id: int, lifetime: int) -> void: spawned.append([effect, id, lifetime]))
|
||||
registry.fx_finished.connect(func(effect: String, id: int, lifetime: int, elapsed: int, reason: String) -> void:
|
||||
finished.append([effect, id, lifetime, elapsed, reason]))
|
||||
var parent := Node3D.new()
|
||||
root.add_child(parent)
|
||||
var once := registry.spawn("synthetic", parent, true)
|
||||
check(spawned.size() == 1 and spawned[0][0] == "synthetic" and spawned[0][2] == 1000,
|
||||
"one-shot spawn reports its MSE-defined lifetime: %s" % [spawned])
|
||||
check(finished.is_empty(), "spawn alone is not a finish")
|
||||
await create_timer(1.8).timeout
|
||||
check(not is_instance_valid(once), "one-shot effect left the tree on its own clock")
|
||||
check(finished.size() == 1 and finished[0][1] == spawned[0][1] and finished[0][4] == "cleanup",
|
||||
"tree exit reports the same id with reason cleanup: %s" % [finished])
|
||||
if finished.size() == 1:
|
||||
check(int(finished[0][3]) >= 1000 and int(finished[0][3]) <= 1000 + 1000,
|
||||
"cleanup happened inside lifetime + 1s (elapsed %dms)" % int(finished[0][3]))
|
||||
var looping := registry.spawn("synthetic", parent, false)
|
||||
check(spawned.size() == 2 and spawned[1][2] == -1 and spawned[1][1] != spawned[0][1],
|
||||
"looping spawn has a fresh id and no defined lifetime")
|
||||
var early := registry.spawn("synthetic", parent, true)
|
||||
parent.remove_child(early)
|
||||
early.free()
|
||||
check(finished.size() == 2 and finished[1][4] == "removed", "owner removal before cleanup reports reason removed")
|
||||
parent.free()
|
||||
check(finished.size() == 3 and finished[2][1] == spawned[1][1], "scene teardown closes the looping effect")
|
||||
var orphan := Node3D.new()
|
||||
check(registry.spawn("missing", orphan, true) == null and spawned.size() == 3,
|
||||
"unresolvable effect emits no spawn event")
|
||||
orphan.free()
|
||||
await process_frame
|
||||
|
||||
@@ -1,96 +1,596 @@
|
||||
extends Node
|
||||
|
||||
## 离线加载实际鬼木林/赤鬼木林地图,并把树怪放进生产 GameScene 的实体挂载点。
|
||||
## 服务器刷新、选中、战斗和掉落仍由 playable_live_test 负责。
|
||||
## MAP-02 地图内离线验收:MT_TEST_MODE=forest_render(包内与编辑器都经 client_main 进入)。
|
||||
##
|
||||
## MT_TEST_MODE=forest_render MT_FOREST_MAPS=<map_key>[,<map_key>] godot --path project
|
||||
## bash script/forest_map_render_test.sh --help # 包内运行、墙钟超时、最终报告
|
||||
##
|
||||
## 地图只来自 MT_FOREST_MAPS 或 MT_PLAYABLE_CONFIG 的 map_key(由 audit_playable_maps 的结果填写),
|
||||
## 不在脚本里猜地图。每张图:生产 GameScene 装图 -> 机位(夹具确认 / 自动候选)-> 树怪经
|
||||
## net_world 的真实生成路径(entity_spawned -> MobView 工厂 -> 贴地)-> 记录 sample_height、
|
||||
## 怪物世界位置、ground offset、材质、实际动作路径 -> 固定机位截图。
|
||||
##
|
||||
## 判定边界:
|
||||
## - 平地(足迹内地形高差 <= FLAT_FOOTPRINT_M)上 wait 各采样帧都悬空/埋地超过 GROUND_GAP_M 才算失败;
|
||||
## AABB 底部不是足部,坡地只记录数据交人工看脚/根部接触,不据此宣布足部 IK 完成。
|
||||
## - 自动候选机位、未确认机位、缺失的传送点、headless(无 Metal 截图)一律 BLOCKED,不当 PASS。
|
||||
## - 这里没有服务器刷新:选中/攻击/死亡/拾取的实网证据由 playable_live_test 单独记录。
|
||||
##
|
||||
## 必测用例由 required_case_ids(maps) 决定(script/forest_map_render_test.sh 在启动前写同一份列表),
|
||||
## Metal 截图的人工签核是发布清单里单独的 manual 项,不由本进程判定。
|
||||
##
|
||||
## 退出码:单独运行 0 PASS / 1 FAIL / 2 BLOCKED;由 runner 启动(有 MT_PLAYABLE_RUN_ID)时写出报告即 0,
|
||||
## 判定交给 runner。客户端只写 client-report.json、events 与 forest-map-evidence.json。
|
||||
|
||||
const GameScene = preload("res://game_scene.gd")
|
||||
const Fixtures = preload("res://gamescene_test.gd")
|
||||
const MobView = preload("res://ui/mob_view.gd")
|
||||
const Config = preload("res://testing/playable_config.gd")
|
||||
const Viewpoints = preload("res://testing/forest_viewpoints.gd")
|
||||
const PlayableReport = preload("res://testing/playable_report.gd")
|
||||
|
||||
var failures := 0
|
||||
## root/npclist.txt 2301–2307 / 2311–2315;未由服务器确认前只是候选。
|
||||
const CANDIDATE_RACES := [2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315]
|
||||
const WAIT_SAMPLES := [0.0, 0.25, 0.5, 0.75]
|
||||
const GRID_STEP_M := 4.0
|
||||
const FOOTPRINT_HALF_M := 1.5
|
||||
## 平地判定:怪物包围盒足迹内地形最高/最低差。超过即交人工接触检查,不做自动悬空判定。
|
||||
const FLAT_FOOTPRINT_M := 0.10
|
||||
## 平地 wait 姿态 AABB 底部与地形高度的允许差(米)。校准记录见 CALIBRATION。
|
||||
const GROUND_GAP_M := 0.10
|
||||
const CALIBRATION := "GROUND_GAP_M=0.10 m (2026-09-11, Metal, metin2_map_trent auto viewpoints): MobView grounds the bind-pose bottom via Metin2Model ground_offset; over 16 flat placements of the 12 candidate races the largest wait-loop AABB-bottom gap was 0.026 m (ent_trent, sinks slightly). 0.10 m keeps ~4x margin while still catching a missing/doubled ground offset (0.04-0.15 m per race). Changes need a recorded reason."
|
||||
const ROOT_GAP_M := 0.001
|
||||
const MAIN_VID := 1000
|
||||
const MOB_VID_BASE := 60000
|
||||
const CHAR_TYPE_MONSTER := 2
|
||||
const CAMERA_PITCH_DEG := 28.0
|
||||
const DEFAULT_TIMEOUT_SECONDS := 900
|
||||
|
||||
var _out := ""
|
||||
var _report := PlayableReport.new()
|
||||
var _evidence := {}
|
||||
var _finished := false
|
||||
|
||||
func _ready() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
failures += 1
|
||||
printerr("FOREST MAP FAIL: " + message)
|
||||
|
||||
func run() -> void:
|
||||
var map_key := OS.get_environment("MT_FOREST_MAP")
|
||||
_out = OS.get_environment("MT_TEST_OUTPUT")
|
||||
if _out.is_empty():
|
||||
_out = OS.get_environment("MT_RENDER_OUTPUT")
|
||||
if _out.is_empty():
|
||||
_out = ProjectSettings.globalize_path("user://forest-map-%d" % Time.get_unix_time_from_system())
|
||||
DirAccess.make_dir_recursive_absolute(_out)
|
||||
var timeout := int(OS.get_environment("MT_FOREST_TIMEOUT_SECONDS")) if OS.get_environment("MT_FOREST_TIMEOUT_SECONDS").is_valid_int() else DEFAULT_TIMEOUT_SECONDS
|
||||
get_tree().create_timer(timeout).timeout.connect(_on_watchdog)
|
||||
var headless := DisplayServer.get_name() == "headless"
|
||||
_evidence = {"schema_version": 1, "scope": "offline production GameScene + MobView placement; no server spawn, no live target/attack/pickup evidence",
|
||||
"headless": headless, "assets": AssetRoot.path(), "calibration": CALIBRATION,
|
||||
"thresholds": {"flat_footprint_m": FLAT_FOOTPRINT_M, "ground_gap_m": GROUND_GAP_M, "root_gap_m": ROOT_GAP_M},
|
||||
"maps": [], "manual_review": ["Metal screenshots: tree monster silhouettes, materials, foot/root contact on slopes"],
|
||||
"live_evidence": "separate: playable_live_test per-map enter/select/attack/dead/pickup/leave"}
|
||||
|
||||
var config := {}
|
||||
var config_path := OS.get_environment("MT_PLAYABLE_CONFIG")
|
||||
var loaded_config := Config.load_file(config_path) if not config_path.is_empty() else {"ok": false, "config": {}}
|
||||
if map_key.is_empty() and loaded_config.ok:
|
||||
map_key = String(loaded_config.config.get("map_key", ""))
|
||||
if map_key.is_empty():
|
||||
map_key = "outdoortrent/metin2_map_trent"
|
||||
var race := int(OS.get_environment("MT_FOREST_MOB_RACE")) if not OS.get_environment("MT_FOREST_MOB_RACE").is_empty() else 2301
|
||||
var output := OS.get_environment("MT_TEST_OUTPUT")
|
||||
if output.is_empty():
|
||||
output = ProjectSettings.globalize_path("res://../build/rendering/forest-map-%d" % Time.get_unix_time_from_system())
|
||||
DirAccess.make_dir_recursive_absolute(output)
|
||||
var playable_report := PlayableReport.new()
|
||||
playable_report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "playable", loaded_config.config if loaded_config.ok else {})
|
||||
get_tree().root.size = Vector2i(1280, 720)
|
||||
print("FOREST MAP: loading %s" % map_key)
|
||||
var client := Fixtures.FakeClient.new()
|
||||
# Server-space values chosen inside the corresponding local map rectangle.
|
||||
# GameScene/MapCoord performs the actual base-position conversion.
|
||||
var base_x := 0.0 if map_key == "outdoortrent/metin2_map_trent" else 1049600.0
|
||||
var server_x := base_x + 12800.0
|
||||
var server_y := 12800.0
|
||||
client.spawn(1000, "ForestRender", Vector3(server_x * 0.01, 0.0, -server_y * 0.01), true)
|
||||
var scene := GameScene.new()
|
||||
get_tree().root.add_child(scene)
|
||||
await scene.setup(client, AssetRoot.path(), map_key)
|
||||
print("FOREST MAP: GameScene setup complete")
|
||||
if not scene._map_loaded():
|
||||
_fail("map did not load: %s" % map_key)
|
||||
if not scene._model_built or scene.player == null:
|
||||
_fail("local player model is not ready")
|
||||
var entities := scene.get_node_or_null("Entities")
|
||||
if entities == null:
|
||||
_fail("GameScene entity mount is missing")
|
||||
var positions := [Vector3(12.0, 0.0, 18.0), Vector3(15.0, 0.0, 21.0)]
|
||||
var monster := MobView.new()
|
||||
monster.name = "ForestTreeMonster_%d" % race
|
||||
if not monster.build(AssetRoot.path(), null, race):
|
||||
_fail("tree monster build failed race=%d" % race)
|
||||
if not config_path.is_empty() and FileAccess.file_exists(config_path):
|
||||
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(config_path))
|
||||
config = parsed if parsed is Dictionary else {}
|
||||
_report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "forest_render", config, OS.get_environment("MT_TEST_EVENTS"))
|
||||
var maps: Array[String] = []
|
||||
for key in OS.get_environment("MT_FOREST_MAPS").split(",", false):
|
||||
if not key.strip_edges().is_empty():
|
||||
maps.append(key.strip_edges())
|
||||
if maps.is_empty() and not String(config.get("map_key", "")).strip_edges().is_empty():
|
||||
maps.append(String(config.map_key).strip_edges())
|
||||
_report.set_required_cases(required_case_ids(maps))
|
||||
var vp_file := Viewpoints.load_file(OS.get_environment("MT_FOREST_VIEWPOINTS"))
|
||||
var races := parse_races(OS.get_environment("MT_FOREST_RACES"))
|
||||
var config_errors: Array[String] = []
|
||||
if maps.is_empty():
|
||||
config_errors.append("no map: set MT_FOREST_MAPS or config map_key from map-assets.json")
|
||||
var ids := {}
|
||||
for map_key in maps:
|
||||
if not is_safe_map_key(map_key):
|
||||
config_errors.append("map_key %s must be a relative asset path without '..'" % map_key)
|
||||
elif ids.has(map_key.get_file()):
|
||||
config_errors.append("map_key %s repeats map id %s" % [map_key, map_key.get_file()])
|
||||
ids[map_key.get_file()] = true
|
||||
if not vp_file.ok:
|
||||
config_errors.append_array(vp_file.errors)
|
||||
if not races.ok:
|
||||
config_errors.append("MT_FOREST_RACES must be a comma list of positive mob vnums")
|
||||
if not config_errors.is_empty():
|
||||
_report.add_case("MAP-FOREST-CONFIG", "BLOCKED", "; ".join(config_errors))
|
||||
else:
|
||||
entities.add_child(monster)
|
||||
monster.position = positions[0]
|
||||
monster.set_anim_state("wait")
|
||||
client.spawn(23001, "TreeMonster", Vector3(positions[0].x * 100.0, 0.0, -positions[0].z * 100.0))
|
||||
_report.add_case("MAP-FOREST-CONFIG", "PASS", "maps=%s viewpoint_fixture=%s" % [",".join(maps),
|
||||
"yes" if not OS.get_environment("MT_FOREST_VIEWPOINTS").is_empty() else "none"])
|
||||
get_tree().root.size = Vector2i(1280, 720)
|
||||
for map_key in maps:
|
||||
await _run_map(map_key, vp_file.data, races.races, headless)
|
||||
_finish()
|
||||
|
||||
static func required_case_ids(maps: Array) -> Array[String]:
|
||||
var ids: Array[String] = ["MAP-FOREST-CONFIG"]
|
||||
for map_key in maps:
|
||||
var map_id := String(map_key).get_file()
|
||||
ids.append("MAP-FOREST-LOAD-" + map_id)
|
||||
for kind in Viewpoints.KINDS:
|
||||
ids.append("MAP-FOREST-VP-%s-%s" % [map_id, kind])
|
||||
ids.append("MAP-FOREST-MOTION-" + map_id)
|
||||
return ids
|
||||
|
||||
static func is_safe_map_key(map_key: String) -> bool:
|
||||
return not map_key.is_empty() and not map_key.begins_with("/") and not ("\\" in map_key) \
|
||||
and not (".." in map_key.split("/"))
|
||||
|
||||
static func parse_races(text: String) -> Dictionary:
|
||||
var out := {"ok": true, "races": []}
|
||||
for part in text.split(",", false):
|
||||
var value := part.strip_edges()
|
||||
if not value.is_valid_int() or int(value) <= 0:
|
||||
out.ok = false
|
||||
continue
|
||||
out.races.append(int(value))
|
||||
return out
|
||||
|
||||
func _on_watchdog() -> void:
|
||||
if _finished:
|
||||
return
|
||||
_report.add_failure("forest render watchdog: run did not finish (script error or hang)")
|
||||
_finish()
|
||||
|
||||
func _finish() -> void:
|
||||
if _finished:
|
||||
return
|
||||
_finished = true
|
||||
var report_path := OS.get_environment("MT_TEST_REPORT")
|
||||
if report_path.is_empty():
|
||||
report_path = _out.path_join("client-report.json")
|
||||
var final := _report.finish()
|
||||
_evidence["status"] = final.get("status", "FAIL")
|
||||
# 自动判定之外仍需人工看图;写进报告,避免把自动 PASS 读成视觉签核完成。
|
||||
final["manual_signoff"] = {"required": true, "status": "PENDING", "evidence": "forest-*.png + forest-map-evidence.json",
|
||||
"scope": "tree monster silhouettes/materials, slope foot-root contact, dense-forest occlusion, dead poses"}
|
||||
_evidence["manual_signoff"] = final.manual_signoff
|
||||
var f := FileAccess.open(_out.path_join("forest-map-evidence.json"), FileAccess.WRITE)
|
||||
if f:
|
||||
f.store_string(JSON.stringify(_evidence, "\t"))
|
||||
f.close()
|
||||
var written := _report.write(report_path)
|
||||
var status := String(final.get("status", "FAIL"))
|
||||
print("FOREST MAP RENDER: status=%s failures=%d blocked=%d output=%s" % [status,
|
||||
final.get("failures", []).size(), final.get("blocked", []).size(), _out])
|
||||
for line in final.get("failures", []):
|
||||
printerr("FOREST MAP FAIL: " + String(line))
|
||||
var code := 0 if status == "PASS" else (1 if status == "FAIL" else 2)
|
||||
if not written:
|
||||
printerr("FOREST MAP RENDER: cannot write client report")
|
||||
code = 1
|
||||
elif not OS.get_environment("MT_PLAYABLE_RUN_ID").is_empty():
|
||||
code = 0 # 由父 runner 在真实退出后判定;非零只表示客户端没能写出报告。
|
||||
# 被释放的 AudioStreamPlayer3D 的 playback 由音频线程异步移除;立即 quit 会报 ObjectDB 泄漏。
|
||||
get_tree().create_timer(0.3, true, false, true).timeout.connect(func() -> void: get_tree().quit(code))
|
||||
|
||||
func _run_map(map_key: String, vp_data: Dictionary, races_override: Array, headless: bool) -> void:
|
||||
var map_id := map_key.get_file()
|
||||
var map_ev := {"map_key": map_key, "viewpoints": []}
|
||||
_evidence.maps.append(map_ev)
|
||||
var bounds := {}
|
||||
var probe := GameScene.new()
|
||||
for name in ["setting.txt", "Setting.txt"]:
|
||||
var p := AssetRoot.path().path_join(map_key).path_join(name)
|
||||
if FileAccess.file_exists(p):
|
||||
bounds = probe._map_bounds(p)
|
||||
break
|
||||
probe.free()
|
||||
if bounds.is_empty():
|
||||
_report.add_case("MAP-FOREST-LOAD-" + map_id, "BLOCKED", "Setting.txt with BasePosition/MapSize not found for %s" % map_key)
|
||||
return
|
||||
var plan := Viewpoints.plan(vp_data, map_key, bounds)
|
||||
if not plan.errors.is_empty():
|
||||
_report.add_case("MAP-FOREST-LOAD-" + map_id, "BLOCKED", "viewpoint fixture invalid: " + "; ".join(plan.errors))
|
||||
return
|
||||
var size_m := Vector2(bounds.size) * Viewpoints.MAP_CELL_CM * 0.01
|
||||
var races: Array = plan.races.duplicate()
|
||||
var race_source := "fixture"
|
||||
if not races_override.is_empty():
|
||||
races = races_override.duplicate()
|
||||
race_source = "MT_FOREST_RACES"
|
||||
if races.is_empty():
|
||||
races = CANDIDATE_RACES.duplicate()
|
||||
race_source = "candidate_only"
|
||||
map_ev["bounds"] = {"base_cm": [bounds.base.x, bounds.base.y], "size_tiles": [bounds.size.x, bounds.size.y]}
|
||||
map_ev["races"] = races
|
||||
map_ev["race_source"] = race_source
|
||||
|
||||
var client := Fixtures.FakeClient.new()
|
||||
client.name = "ForestFakeClient"
|
||||
get_tree().root.add_child(client)
|
||||
var centre_cm: Vector2 = bounds.base + size_m * 50.0
|
||||
client.spawn(MAIN_VID, "ForestRender", _net_pos(centre_cm), true)
|
||||
var scene := GameScene.new()
|
||||
scene.name = "ForestGameScene"
|
||||
get_tree().root.add_child(scene)
|
||||
var started := Time.get_ticks_msec()
|
||||
await scene.setup(client, AssetRoot.path(), map_key)
|
||||
var world: Node = scene.world
|
||||
while world and int(world.call("get_load_report").get("stream_queue", 0)) > 0:
|
||||
await get_tree().process_frame
|
||||
if monster.model == null or monster.model.call("get_visual_aabb").size.length() <= 0.0:
|
||||
_fail("tree monster has no visible geometry")
|
||||
if scene.cam and scene.cam.has_method("snap_to_target"):
|
||||
scene.cam.snap_to_target()
|
||||
var load_report: Dictionary = world.call("get_load_report") if world else {}
|
||||
map_ev["load"] = {"ms": Time.get_ticks_msec() - started, "report": load_report, "scene_map_path": scene.map_path}
|
||||
var load_errors: Array[String] = []
|
||||
if not scene._map_loaded():
|
||||
load_errors.append("map did not load")
|
||||
if scene.map_path != map_key:
|
||||
load_errors.append("GameScene resolved %s instead of %s" % [scene.map_path, map_key])
|
||||
for key in ["chunks_failed", "objects_missing_model"]:
|
||||
if int(load_report.get(key, 0)) > 0:
|
||||
load_errors.append("%s=%d" % [key, int(load_report.get(key, 0))])
|
||||
if scene.net_world == null or scene.proto == null:
|
||||
load_errors.append("net_world/mob_proto not ready; monsters would fall back to capsules")
|
||||
_report.record("map_loaded", "forest_render", "MAP-FOREST-LOAD-" + map_id, 0, MAIN_VID, 0,
|
||||
{"map_key": map_key, "elapsed_ms": map_ev.load.ms, "count": int(load_report.get("chunks_built", 0)),
|
||||
"reason_code": "ok" if load_errors.is_empty() else "load_failed"})
|
||||
_report.add_case("MAP-FOREST-LOAD-" + map_id, "FAIL" if not load_errors.is_empty() else "PASS",
|
||||
"; ".join(load_errors) if not load_errors.is_empty() else "chunks=%d objects=%d trees=%d" % [
|
||||
int(load_report.get("chunks_built", 0)), int(load_report.get("objects_placed", 0)), int(load_report.get("trees_placed", 0))])
|
||||
if not load_errors.is_empty():
|
||||
await _teardown(scene, client)
|
||||
return
|
||||
|
||||
var viewpoints: Array = plan.viewpoints.duplicate()
|
||||
var auto_kinds: Array = plan.missing_kinds.filter(func(k: String) -> bool: return k in Viewpoints.AUTO_KINDS)
|
||||
if not auto_kinds.is_empty():
|
||||
var trees := _tree_positions(world)
|
||||
map_ev["tree_trunks_found"] = trees.size()
|
||||
if not trees.is_empty():
|
||||
var r := Rect2(trees[0], Vector2.ZERO)
|
||||
for t in trees:
|
||||
r = r.expand(t)
|
||||
map_ev["tree_trunk_bounds_m"] = [r.position.x, r.position.y, r.end.x, r.end.y]
|
||||
# headless 的 RenderingServerDummy 不保存 MultiMesh 实例数据,读回全是单位变换。
|
||||
if trees.size() > 1 and r.size.is_zero_approx():
|
||||
map_ev["tree_positions"] = "unavailable: MultiMesh instance transforms are not readable from the headless rendering server"
|
||||
trees = PackedVector2Array()
|
||||
var candidates := Viewpoints.pick_candidates(_terrain_samples(world, size_m), trees, size_m, auto_kinds)
|
||||
for c: Dictionary in candidates:
|
||||
viewpoints.append({"id": "auto-" + String(c.kind), "kind": c.kind, "status": "auto_candidate", "source": "auto",
|
||||
"confirmed_by": "", "camera_yaw_deg": Viewpoints.DEFAULT_YAW_DEG, "local_m": c.p,
|
||||
"server_cm": Viewpoints.to_server_cm(bounds, c.p),
|
||||
"selection": {"range_m": c.range_m, "slope_ratio": c.slope_ratio, "tree_density": c.density}})
|
||||
var motion_errors: Array[String] = []
|
||||
var fallbacks: Array = []
|
||||
var by_kind := {}
|
||||
for vp: Dictionary in viewpoints:
|
||||
var vp_ev := await _run_viewpoint(scene, client, world, vp, races, map_id, headless)
|
||||
map_ev.viewpoints.append(vp_ev)
|
||||
for line: String in vp_ev.motion_errors:
|
||||
if not (line in motion_errors):
|
||||
motion_errors.append(line)
|
||||
fallbacks.append_array(vp_ev.fallbacks)
|
||||
if not by_kind.has(vp.kind):
|
||||
by_kind[vp.kind] = []
|
||||
by_kind[vp.kind].append(vp_ev)
|
||||
_report.record("viewpoint", "forest_render", "MAP-FOREST-VP-%s-%s" % [map_id, vp.kind], 0, 0, 0,
|
||||
{"map_key": map_key, "state": vp.kind, "reason_code": vp.status, "count": vp_ev.mobs.size(),
|
||||
"evidence": vp_ev.screenshots, "selected": vp.id})
|
||||
for kind in Viewpoints.KINDS:
|
||||
_add_kind_case(map_id, kind, by_kind.get(kind, []), map_ev, headless)
|
||||
map_ev["reference_fallbacks"] = fallbacks
|
||||
if viewpoints.is_empty():
|
||||
_report.add_case("MAP-FOREST-MOTION-" + map_id, "BLOCKED", "no viewpoint to place tree monsters")
|
||||
else:
|
||||
_report.add_case("MAP-FOREST-MOTION-" + map_id, "FAIL" if not motion_errors.is_empty() else "PASS",
|
||||
"; ".join(motion_errors) if not motion_errors.is_empty() else "wait/run/dead via net_world; %d reference keep-current-motion fallbacks need live confirmation" % fallbacks.size())
|
||||
await _teardown(scene, client)
|
||||
|
||||
## 每种机位一个必测用例:任一机位断言失败即 FAIL;只有“经确认 + Metal 截图 + 全部断言通过”才 PASS。
|
||||
func _add_kind_case(map_id: String, kind: String, results: Array, map_ev: Dictionary, headless: bool) -> void:
|
||||
var case_id := "MAP-FOREST-VP-%s-%s" % [map_id, kind]
|
||||
var errors: Array[String] = []
|
||||
var screenshots: Array = []
|
||||
var confirmed: Array[String] = []
|
||||
var unconfirmed: Array[String] = []
|
||||
for r: Dictionary in results:
|
||||
screenshots.append_array(r.screenshots)
|
||||
for e: String in r.errors:
|
||||
errors.append("%s: %s" % [r.id, e])
|
||||
if r.status == "confirmed":
|
||||
confirmed.append(String(r.id))
|
||||
else:
|
||||
unconfirmed.append("%s(%s @ server_cm %s)" % [r.id, r.status, r.server_cm])
|
||||
if not errors.is_empty():
|
||||
_report.add_case(case_id, "FAIL", "; ".join(errors), screenshots)
|
||||
elif results.is_empty():
|
||||
var why := "no confirmed viewpoint and no usable terrain candidate"
|
||||
if kind == "warp":
|
||||
why = "warp points cannot be derived offline; add a confirmed viewpoint"
|
||||
elif kind == "dense" and map_ev.has("tree_positions"):
|
||||
why = "no confirmed viewpoint; tree density needs a Metal run (%s)" % map_ev.tree_positions
|
||||
_report.add_case(case_id, "BLOCKED", why)
|
||||
elif headless:
|
||||
_report.add_case(case_id, "BLOCKED", "headless run has no Metal screenshot", screenshots)
|
||||
elif confirmed.is_empty() or not unconfirmed.is_empty():
|
||||
_report.add_case(case_id, "BLOCKED", "viewpoints not confirmed by the test environment owner: %s" % ", ".join(unconfirmed), screenshots)
|
||||
else:
|
||||
_report.add_case(case_id, "PASS", "confirmed %s: %s" % [kind, ", ".join(confirmed)], screenshots)
|
||||
|
||||
func _run_viewpoint(scene: Node, client: Node, world: Node, vp: Dictionary, races: Array, map_id: String, headless: bool) -> Dictionary:
|
||||
var ev := {"id": vp.id, "kind": vp.kind, "status": vp.status, "source": vp.source,
|
||||
"server_cm": [vp.server_cm.x, vp.server_cm.y], "local_m": [vp.local_m.x, vp.local_m.y],
|
||||
"camera_yaw_deg": vp.camera_yaw_deg, "selection": vp.get("selection", {}),
|
||||
"mobs": [], "screenshots": [], "errors": [], "motion_errors": [], "fallbacks": []}
|
||||
var centre: Vector2 = vp.local_m
|
||||
var yaw := deg_to_rad(float(vp.camera_yaw_deg))
|
||||
var to_camera := Vector3(sin(yaw), 0.0, cos(yaw))
|
||||
var right := Vector3.UP.cross(to_camera).normalized()
|
||||
var base: Vector2 = vp.server_cm - vp.local_m * 100.0
|
||||
# 主角放到怪群侧面,避免挡住机位。
|
||||
var player_local := centre + Vector2(right.x, right.z) * 12.0
|
||||
client.ents[MAIN_VID].pos = _net_pos(base + player_local * 100.0)
|
||||
scene._place_player_at_net_pos(client.ents[MAIN_VID].pos)
|
||||
|
||||
var vids: Array[int] = []
|
||||
for i in races.size():
|
||||
var vid := MOB_VID_BASE + i
|
||||
vids.append(vid)
|
||||
client.ents[vid] = {"vid": vid, "name": "Race%d" % races[i], "race": int(races[i]), "ch_type": CHAR_TYPE_MONSTER,
|
||||
"pos": _net_pos(vp.server_cm), "is_main": false, "func": 0, "moving": false, "angle_deg": 0.0,
|
||||
"hp": 100, "max_hp": 100, "dead": false}
|
||||
client.entity_spawned.emit(client.ents[vid])
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
var image_name := "forest-map-%s.png" % map_key.get_file()
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await RenderingServer.frame_post_draw
|
||||
if get_tree().root.get_texture().get_image().save_png(output.path_join(image_name)) != OK:
|
||||
_fail("map screenshot could not be saved")
|
||||
var report := {"passed": failures == 0, "failures": failures, "map": map_key,
|
||||
"tree_monster_race": race, "tree_monster_model": monster.model != null,
|
||||
"map_loaded": scene._map_loaded(), "screenshot": image_name if DisplayServer.get_name() != "headless" else "",
|
||||
"assets": AssetRoot.path(), "headless": DisplayServer.get_name() == "headless",
|
||||
"scope": "production GameScene map load plus offline tree-monster placement; no server spawn"}
|
||||
var file := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE)
|
||||
if file:
|
||||
file.store_string(JSON.stringify(report, "\t"))
|
||||
file.close()
|
||||
var client_report_path := OS.get_environment("MT_TEST_REPORT")
|
||||
if not client_report_path.is_empty():
|
||||
playable_report.add_case("MAP-FOREST-01", "PASS" if failures == 0 else "FAIL",
|
||||
"map=%s tree_monster_race=%d" % [map_key, race])
|
||||
playable_report.write(client_report_path)
|
||||
print("FOREST MAP RENDER: ", JSON.stringify(report))
|
||||
# 按最大足迹排成面向相机的网格,再让 net_world 按新 pos 贴地。
|
||||
var spacing := 3.5
|
||||
for vid in vids:
|
||||
var node: Node3D = scene.net_world.node_for(vid)
|
||||
if node != null and node.has_method("resolve_motion") and node.model:
|
||||
# get_visual_aabb 是模型本地单位(未乘 unit_scale),先变到世界米。
|
||||
var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB)
|
||||
spacing = maxf(spacing, maxf(box.size.x, box.size.z) + 1.0)
|
||||
var cols := int(ceil(sqrt(float(races.size()))))
|
||||
var rows := int(ceil(float(races.size()) / float(cols)))
|
||||
for i in vids.size():
|
||||
var offset := right * (float(i % cols) - (cols - 1) * 0.5) * spacing \
|
||||
+ to_camera * (float(i / cols) - (rows - 1) * 0.5) * spacing
|
||||
client.ents[vids[i]].pos = _net_pos(base + (centre + Vector2(offset.x, offset.z)) * 100.0)
|
||||
for _i in 3:
|
||||
await get_tree().process_frame
|
||||
|
||||
var cam := Camera3D.new()
|
||||
cam.name = "ForestFixedCamera"
|
||||
cam.fov = 50.0
|
||||
scene.add_child(cam)
|
||||
cam.current = true
|
||||
var top := 0.0
|
||||
for i in vids.size():
|
||||
var node: Node3D = scene.net_world.node_for(vids[i])
|
||||
var mob := await _measure_mob(node, int(races[i]), vids[i], world, ev)
|
||||
ev.mobs.append(mob)
|
||||
top = maxf(top, float(mob.get("height_m", 0.0)))
|
||||
var ground := float(world.call("sample_height", centre.x, centre.y))
|
||||
var look := Vector3(centre.x, ground + maxf(1.0, top * 0.5), centre.y)
|
||||
var distance := maxf(12.0, spacing * float(maxi(cols, rows)) * 1.15)
|
||||
var pitch := deg_to_rad(CAMERA_PITCH_DEG)
|
||||
cam.position = look + (to_camera * cos(pitch) + Vector3.UP * sin(pitch)) * distance
|
||||
cam.position.y = maxf(cam.position.y, float(world.call("sample_height", cam.position.x, cam.position.z)) + 1.5)
|
||||
cam.look_at(look, Vector3.UP)
|
||||
ev["camera"] = {"position": [cam.position.x, cam.position.y, cam.position.z], "look_at": [look.x, look.y, look.z], "fov": cam.fov,
|
||||
"distance_m": distance, "grid_spacing_m": spacing}
|
||||
for i in vids.size():
|
||||
var node: Node3D = scene.net_world.node_for(vids[i])
|
||||
if node == null:
|
||||
continue
|
||||
var anchor := node.global_position + Vector3.UP * 0.5
|
||||
var screen := get_viewport().get_visible_rect().grow(-8.0)
|
||||
if cam.is_position_behind(anchor) or not screen.has_point(cam.unproject_position(anchor)):
|
||||
ev.errors.append("race %d is outside the fixed camera frame" % races[i])
|
||||
if not headless:
|
||||
await _screenshot(cam, "forest-%s-%s-wait.png" % [map_id, vp.id], ev)
|
||||
|
||||
# 生产路径动作:移动(资源无 RUN/WALK 时按旧客户端保留当前动作)与死亡。
|
||||
for i in vids.size():
|
||||
var node: Node3D = scene.net_world.node_for(vids[i])
|
||||
if node != null and node.has_method("resolve_motion"):
|
||||
node.anim.set_process(true)
|
||||
client.ents[vids[i]].moving = true
|
||||
for _i in 2:
|
||||
await get_tree().process_frame
|
||||
for i in vids.size():
|
||||
_check_motion(scene.net_world.node_for(vids[i]), int(races[i]), "run", ev)
|
||||
client.ents[vids[i]].moving = false
|
||||
client.ents[vids[i]].dead = true
|
||||
client.entity_dead.emit(vids[i])
|
||||
await get_tree().create_timer(0.4).timeout
|
||||
for i in vids.size():
|
||||
var node: Node3D = scene.net_world.node_for(vids[i])
|
||||
_check_motion(node, int(races[i]), "dead", ev)
|
||||
if node != null and node.has_method("resolve_motion") and String(node.last_motion_resolution().path) != "":
|
||||
if absf(node.rotation.x) > 0.01 or absf(node.rotation.z) > 0.01:
|
||||
ev.motion_errors.append("race %d dead motion is tilted by the scene (rotation.x=%.2f); reference Die() only plays DEAD" % [races[i], node.rotation.x])
|
||||
node.anim.set_process(false)
|
||||
node.anim.call("set_time", maxf(0.0, float(node.anim.call("get_duration")) - 1.0 / 60.0))
|
||||
var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB)
|
||||
var h := float(world.call("sample_height", node.global_position.x, node.global_position.z))
|
||||
for mob: Dictionary in ev.mobs:
|
||||
if int(mob.vid) == vids[i]:
|
||||
mob["dead_end_bottom_gap_m"] = box.position.y - h
|
||||
if not headless:
|
||||
await _screenshot(cam, "forest-%s-%s-dead.png" % [map_id, vp.id], ev)
|
||||
for vid in vids:
|
||||
client.ents.erase(vid)
|
||||
client.entity_despawned.emit(vid)
|
||||
cam.queue_free()
|
||||
for _i in 2:
|
||||
await get_tree().process_frame
|
||||
return ev
|
||||
|
||||
func _measure_mob(node: Node3D, race: int, vid: int, world: Node, ev: Dictionary) -> Dictionary:
|
||||
var out := {"race": race, "vid": vid}
|
||||
if node == null or not node.has_method("resolve_motion") or node.model == null or node.anim == null:
|
||||
ev.errors.append("race %d spawned without a MobView model (placeholder capsule)" % race)
|
||||
out["model"] = false
|
||||
return out
|
||||
out["model"] = true
|
||||
var anim: Node = node.anim
|
||||
anim.set("blend_time", 0.0)
|
||||
anim.set_process(false)
|
||||
var root := node.global_position
|
||||
var h_root := float(world.call("sample_height", root.x, root.z))
|
||||
out["world_position"] = [root.x, root.y, root.z]
|
||||
out["sample_height_m"] = h_root
|
||||
out["root_gap_m"] = root.y - h_root
|
||||
out["ground_offset_m"] = float(node.model.call("get_ground_offset"))
|
||||
out["model_local_y_m"] = node.model.position.y
|
||||
out["wait_resolution"] = node.last_motion_resolution()
|
||||
out["wait_motion_path"] = String(anim.get("anim_path"))
|
||||
out["materials"] = _material_report(node.model)
|
||||
if absf(root.y - h_root) > ROOT_GAP_M:
|
||||
ev.errors.append("race %d root is %.3fm off terrain; net_world grounding broken" % [race, root.y - h_root])
|
||||
if String(out.wait_motion_path).is_empty():
|
||||
ev.motion_errors.append("race %d has no wait motion after spawn" % race)
|
||||
if int(out.materials.surfaces) == 0 or int(out.materials.textured) < int(out.materials.surfaces) or int(out.materials.invalid) > 0:
|
||||
ev.errors.append("race %d materials: %s" % [race, out.materials])
|
||||
var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB)
|
||||
var half := Vector2(maxf(0.3, box.size.x * 0.5), maxf(0.3, box.size.z * 0.5))
|
||||
var c := Vector2(box.get_center().x, box.get_center().z)
|
||||
var heights: Array[float] = []
|
||||
for d in [Vector2.ZERO, Vector2(half.x, half.y), Vector2(-half.x, half.y), Vector2(half.x, -half.y), Vector2(-half.x, -half.y)]:
|
||||
heights.append(float(world.call("sample_height", c.x + d.x, c.y + d.y)))
|
||||
var footprint_range: float = heights.max() - heights.min()
|
||||
out["footprint_range_m"] = footprint_range
|
||||
out["height_m"] = box.size.y
|
||||
var samples: Array = []
|
||||
var off := 0
|
||||
var duration := float(anim.call("get_duration"))
|
||||
for f in WAIT_SAMPLES:
|
||||
anim.call("set_time", clampf(duration * f, 0.0, maxf(0.0, duration - 1.0 / 60.0)))
|
||||
var b: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB)
|
||||
var gap := b.position.y - h_root
|
||||
samples.append({"fraction": f, "aabb_bottom_gap_m": gap})
|
||||
if not b.position.is_finite() or not b.size.is_finite():
|
||||
ev.errors.append("race %d wait sample %.2f has non-finite bounds" % [race, f])
|
||||
if absf(gap) > GROUND_GAP_M:
|
||||
off += 1
|
||||
out["wait_samples"] = samples
|
||||
anim.call("set_time", clampf(duration * 0.5, 0.0, maxf(0.0, duration - 1.0 / 60.0)))
|
||||
if footprint_range <= FLAT_FOOTPRINT_M:
|
||||
out["contact_check"] = "flat_auto"
|
||||
if off == WAIT_SAMPLES.size():
|
||||
ev.errors.append("race %d floats/buries on flat ground: every wait sample exceeds %.2fm (%s)" % [race, GROUND_GAP_M, samples])
|
||||
else:
|
||||
out["contact_check"] = "slope_manual"
|
||||
return out
|
||||
|
||||
func _check_motion(node: Node3D, race: int, state: String, ev: Dictionary) -> void:
|
||||
if node == null or not node.has_method("resolve_motion"):
|
||||
return
|
||||
var res: Dictionary = node.last_motion_resolution()
|
||||
var path := String(node.anim.get("anim_path"))
|
||||
if String(res.get("requested_state", "")) != state:
|
||||
ev.motion_errors.append("race %d: net_world did not request %s (last=%s)" % [race, state, res.get("requested_state", "")])
|
||||
return
|
||||
if String(res.path).is_empty():
|
||||
if state == "run" and String(res.fallback_reason) == "reference_keep_current_motion" and not path.is_empty():
|
||||
ev.fallbacks.append({"race": race, "state": state, "kept_motion_path": path, "needs_live_confirmation": true})
|
||||
else:
|
||||
ev.motion_errors.append("race %d %s has no motion and is not a reference keep-current fallback" % [race, state])
|
||||
return
|
||||
if path != String(res.path):
|
||||
ev.motion_errors.append("race %d %s resolved %s but plays %s" % [race, state, res.path, path])
|
||||
if state == "dead" and (bool(node.anim.get("loop")) or not String(res.motion).contains("DEAD")):
|
||||
ev.motion_errors.append("race %d dead plays %s loop=%s" % [race, res.motion, node.anim.get("loop")])
|
||||
|
||||
func _screenshot(cam: Camera3D, file_name: String, ev: Dictionary) -> void:
|
||||
cam.current = true
|
||||
# 证据图只拍 3D 场景:HUD 与血条层会让“非均匀”检查在场景全坏时仍然通过。
|
||||
var hidden: Array[CanvasLayer] = []
|
||||
for layer in get_tree().root.find_children("*", "CanvasLayer", true, false):
|
||||
if (layer as CanvasLayer).visible:
|
||||
(layer as CanvasLayer).visible = false
|
||||
hidden.append(layer)
|
||||
for _i in 3:
|
||||
await get_tree().process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
var image := get_tree().root.get_texture().get_image()
|
||||
for layer in hidden:
|
||||
if is_instance_valid(layer):
|
||||
layer.visible = true
|
||||
if image == null or image.is_empty() or not _has_detail(image):
|
||||
ev.errors.append("screenshot %s is empty or uniform" % file_name)
|
||||
return
|
||||
if image.save_png(_out.path_join(file_name)) != OK:
|
||||
ev.errors.append("screenshot %s could not be saved" % file_name)
|
||||
return
|
||||
ev.screenshots.append(file_name)
|
||||
|
||||
func _has_detail(image: Image) -> bool:
|
||||
var first := image.get_pixel(0, 0)
|
||||
var differing := 0
|
||||
var total := 0
|
||||
for y in range(0, image.get_height(), 16):
|
||||
for x in range(0, image.get_width(), 16):
|
||||
total += 1
|
||||
var p := image.get_pixel(x, y)
|
||||
if absf(p.r - first.r) + absf(p.g - first.g) + absf(p.b - first.b) > 0.05:
|
||||
differing += 1
|
||||
return total > 0 and float(differing) / float(total) > 0.1
|
||||
|
||||
func _material_report(model: Node) -> Dictionary:
|
||||
var out := {"surfaces": 0, "textured": 0, "two_sided": 0, "invalid": 0}
|
||||
for node in model.find_children("*", "MeshInstance3D", true, false):
|
||||
var instance := node as MeshInstance3D
|
||||
if instance.mesh == null:
|
||||
continue
|
||||
for surface in instance.mesh.get_surface_count():
|
||||
out.surfaces += 1
|
||||
var material := instance.get_active_material(surface)
|
||||
if material is ShaderMaterial and (material as ShaderMaterial).shader != null:
|
||||
var sm := material as ShaderMaterial
|
||||
if bool(sm.get_shader_parameter("use_texture")) and sm.get_shader_parameter("albedo_tex") != null:
|
||||
out.textured += 1
|
||||
if sm.shader.code.contains("cull_disabled"):
|
||||
out.two_sided += 1
|
||||
elif material is BaseMaterial3D:
|
||||
if (material as BaseMaterial3D).get_texture(BaseMaterial3D.TEXTURE_ALBEDO) != null:
|
||||
out.textured += 1
|
||||
if (material as BaseMaterial3D).cull_mode == BaseMaterial3D.CULL_DISABLED:
|
||||
out.two_sided += 1
|
||||
else:
|
||||
out.invalid += 1
|
||||
return out
|
||||
|
||||
func _terrain_samples(world: Node, size_m: Vector2) -> Array:
|
||||
var out: Array = []
|
||||
var x := GRID_STEP_M * 0.5
|
||||
while x < size_m.x:
|
||||
var z := GRID_STEP_M * 0.5
|
||||
while z < size_m.y:
|
||||
var lo := INF
|
||||
var hi := -INF
|
||||
for d in [Vector2.ZERO, Vector2(FOOTPRINT_HALF_M, 0), Vector2(-FOOTPRINT_HALF_M, 0), Vector2(0, FOOTPRINT_HALF_M), Vector2(0, -FOOTPRINT_HALF_M)]:
|
||||
var h := float(world.call("sample_height", x + d.x, z + d.y))
|
||||
lo = minf(lo, h)
|
||||
hi = maxf(hi, h)
|
||||
out.append({"p": Vector2(x, z), "range_m": hi - lo, "span_m": FOOTPRINT_HALF_M * 2.0,
|
||||
"blocked": bool(world.call("is_blocked", x, z))})
|
||||
z += GRID_STEP_M
|
||||
x += GRID_STEP_M
|
||||
return out
|
||||
|
||||
func _tree_positions(world: Node) -> PackedVector2Array:
|
||||
var out := PackedVector2Array()
|
||||
for node in world.find_children("Trees_*", "MultiMeshInstance3D", true, false):
|
||||
var mm := (node as MultiMeshInstance3D).multimesh
|
||||
if mm == null:
|
||||
continue
|
||||
for i in mm.instance_count:
|
||||
var origin := ((node as MultiMeshInstance3D).global_transform * mm.get_instance_transform(i)).origin
|
||||
out.append(Vector2(origin.x, origin.z))
|
||||
return out
|
||||
|
||||
func _net_pos(server_cm: Vector2) -> Vector3:
|
||||
return Vector3(server_cm.x * 0.01, 0.0, -server_cm.y * 0.01)
|
||||
|
||||
func _teardown(scene: Node, client: Node) -> void:
|
||||
scene.queue_free()
|
||||
client.queue_free()
|
||||
await get_tree().process_frame
|
||||
get_tree().quit(1 if failures else 0)
|
||||
for _i in 3:
|
||||
await get_tree().process_frame
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
extends SceneTree
|
||||
|
||||
## 鬼木林 / 赤鬼木林树怪的离线资源和 CPU/GPU 动作验收。
|
||||
## 鬼木林 / 赤鬼木林树怪的离线资源和 CPU/GPU 动作验收(FIRST-MAC-PLAYABLE §8.2)。
|
||||
## 这不是服务器刷新证明;真实地图中的选中、战斗和掉落由 playable_live_test 承担。
|
||||
##
|
||||
## 每个 race × CPU/GPU × wait/run/attack/damage/dead,在动作起点/25%/50%/75%/末段取样:
|
||||
## - 报告 requested_state / resolved_motion / fallback_reason,实际播放路径必须等于解析结果;
|
||||
## - 资源确无动作时按旧客户端规则(GetMotionKey 失败 -> 保留当前动作)单独判定,
|
||||
## 只允许移动循环动作走这条回退,并写进 report.fallbacks;
|
||||
## - 贴图实际加载、材质有 shader、包围盒有限;
|
||||
## - 同一姿态 CPU/GPU 包围盒差 <= max(1cm, 高度0.5%);
|
||||
## - 非 headless 时输出正面、侧面和每 race 动作 contact sheet,截图不能是纯背景。
|
||||
## env: MT_RENDER_OUTPUT 输出目录;MT_FOREST_RACES 逗号分隔的 race 子集(调试用)。
|
||||
|
||||
const MobView = preload("res://ui/mob_view.gd")
|
||||
const RACES := [2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315]
|
||||
const STATES := ["wait", "run", "attack", "damage", "dead"]
|
||||
## 旧客户端 CActorInstance::Move -> SetLoopMotion(RUN/WALK):缺动作时直接返回保留当前循环。
|
||||
const MOVE_STATES := ["run"]
|
||||
const SAMPLES := [0.0, 0.25, 0.5, 0.75, 1.0]
|
||||
const SIZE := 640
|
||||
const THUMB := 128
|
||||
const BACKGROUND := Color(0.08, 0.1, 0.14)
|
||||
const TOLERANCE_FLOOR_M := 0.01
|
||||
const TOLERANCE_HEIGHT_RATIO := 0.005
|
||||
const MIN_FOREGROUND_RATIO := 0.002
|
||||
|
||||
var _output := ""
|
||||
var _failures: Array[String] = []
|
||||
var _fallbacks: Array[Dictionary] = []
|
||||
var _pose_bounds := {}
|
||||
var _results: Array[Dictionary] = []
|
||||
var _races: Array = RACES
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
@@ -25,6 +44,11 @@ func run() -> void:
|
||||
if _output.is_empty():
|
||||
_output = ProjectSettings.globalize_path("res://../build/rendering/forest-mobs-%d" % Time.get_unix_time_from_system())
|
||||
DirAccess.make_dir_recursive_absolute(_output)
|
||||
var subset := OS.get_environment("MT_FOREST_RACES").strip_edges()
|
||||
if not subset.is_empty():
|
||||
_races = []
|
||||
for part in subset.split(",", false):
|
||||
_races.append(int(part))
|
||||
root.size = Vector2i(SIZE, SIZE)
|
||||
_build_lighting()
|
||||
var cam := Camera3D.new()
|
||||
@@ -34,80 +58,226 @@ func run() -> void:
|
||||
cam.make_current()
|
||||
for gpu in ["0", "1"]:
|
||||
OS.set_environment("MTGODOT_GPUSKIN", gpu)
|
||||
for race in RACES:
|
||||
await _check_race(race, gpu, cam)
|
||||
for race in _races:
|
||||
await _check_race(int(race), gpu, cam)
|
||||
OS.set_environment("MTGODOT_GPUSKIN", "0")
|
||||
var report := {"passed": _failures.is_empty(), "failures": _failures, "races": RACES,
|
||||
"states": STATES, "gpu_modes": ["0", "1"], "results": _results,
|
||||
"assets": AssetRoot.path(), "viewport": str(root.size),
|
||||
"headless": DisplayServer.get_name() == "headless",
|
||||
var headless := DisplayServer.get_name() == "headless"
|
||||
var report := {"passed": _failures.is_empty(), "failures": _failures, "races": _races,
|
||||
"states": STATES, "gpu_modes": ["0", "1"], "sample_fractions": SAMPLES,
|
||||
"tolerance_rule": "max(%.2fm, %.1f%% of posed height)" % [TOLERANCE_FLOOR_M, TOLERANCE_HEIGHT_RATIO * 100.0],
|
||||
"fallback_rule": "reference CRaceData::GetMotionKey miss -> SetLoopMotion returns and the current loop keeps playing",
|
||||
"fallbacks": _fallbacks, "results": _results,
|
||||
"assets": AssetRoot.path(), "viewport": str(root.size), "headless": headless,
|
||||
"screenshots": not headless,
|
||||
"manual_review": "contact sheets and front/side views need one human sign-off; AABB bottom is not foot contact",
|
||||
"scope": "offline tree-monster assembly; no server spawn or 40250 pixel parity"}
|
||||
var file := FileAccess.open(_output.path_join("report.json"), FileAccess.WRITE)
|
||||
if file:
|
||||
file.store_string(JSON.stringify(report, "\t"))
|
||||
file.close()
|
||||
print("FOREST MOB RENDER: ", JSON.stringify({"passed": _failures.is_empty(), "failures": _failures.size(), "results": _results.size(), "output": _output}))
|
||||
else:
|
||||
_fail("cannot write report.json")
|
||||
print("FOREST MOB RENDER: ", JSON.stringify({"passed": _failures.is_empty(), "failures": _failures.size(),
|
||||
"fallbacks": _fallbacks.size(), "results": _results.size(), "output": _output}))
|
||||
# Batch marker (rendering_batch_test.sh); the JSON line above keeps the details.
|
||||
print("PASS: forest_mob_render_test (manual sign-off pending)" if _failures.is_empty() else "FAIL: forest_mob_render_test")
|
||||
quit(0 if _failures.is_empty() else 1)
|
||||
|
||||
func _check_race(race: int, gpu: String, cam: Camera3D) -> void:
|
||||
var tag := "race=%d GPU=%s" % [race, gpu]
|
||||
var view := MobView.new()
|
||||
view.name = "ForestMob_%d_%s" % [race, gpu]
|
||||
root.add_child(view)
|
||||
if not view.build(AssetRoot.path(), null, race):
|
||||
_fail("race=%d GPU=%s build returned false" % [race, gpu])
|
||||
_fail("%s build returned false" % tag)
|
||||
view.queue_free()
|
||||
await process_frame
|
||||
return
|
||||
var row := {"race": race, "gpu_skin": gpu == "1", "states": {}}
|
||||
for state in STATES:
|
||||
var requested := view.get_motion_path(state)
|
||||
if requested.is_empty() or not FileAccess.file_exists(requested):
|
||||
_fail("race=%d GPU=%s state=%s has no resolved .msa" % [race, gpu, state])
|
||||
continue
|
||||
# 交叉淡入只在 _process 里推进;取样要看动作本身的姿态,不看上一动作的残留混合。
|
||||
if view.anim:
|
||||
view.anim.set("blend_time", 0.0)
|
||||
var row := {"race": race, "gpu_skin": gpu == "1", "directory": view._dir,
|
||||
"materials": _material_report(view.model, tag), "states": {}, "views": {}, "contact_sheet": ""}
|
||||
var shoot := DisplayServer.get_name() != "headless"
|
||||
var sheet: Image = null
|
||||
if shoot:
|
||||
sheet = Image.create(THUMB * SAMPLES.size(), THUMB * STATES.size(), false, Image.FORMAT_RGBA8)
|
||||
sheet.fill(BACKGROUND)
|
||||
var frame := {}
|
||||
for state_index in STATES.size():
|
||||
var state: String = STATES[state_index]
|
||||
var resolution: Dictionary = view.resolve_motion(state)
|
||||
var before := String(view.anim.get("anim_path")) if view.anim else ""
|
||||
view.set_anim_state(state)
|
||||
if view.anim == null:
|
||||
_fail("race=%d GPU=%s state=%s has no animation player" % [race, gpu, state])
|
||||
_fail("%s state=%s has no animation player" % [tag, state])
|
||||
continue
|
||||
var entry := {"requested_state": state, "resolved_motion": String(resolution.motion),
|
||||
"motion_path": String(resolution.path), "fallback_reason": String(resolution.fallback_reason),
|
||||
"status": "PASS", "samples": []}
|
||||
row.states[state] = entry
|
||||
var actual := String(view.anim.get("anim_path"))
|
||||
if String(resolution.path).is_empty():
|
||||
if state in MOVE_STATES and resolution.fallback_reason == "reference_keep_current_motion" and actual == before and not before.is_empty():
|
||||
entry.status = "REFERENCE_FALLBACK"
|
||||
entry["kept_motion_path"] = actual
|
||||
_fallbacks.append({"race": race, "gpu_skin": gpu == "1", "requested_state": state,
|
||||
"kept_motion_path": actual, "fallback_reason": resolution.fallback_reason,
|
||||
"needs_live_confirmation": "server movement of this race shows the kept loop while its position changes"})
|
||||
else:
|
||||
entry.status = "FAIL"
|
||||
_fail("%s state=%s has no motion (%s; still playing %s)" % [tag, state, resolution.fallback_reason, actual])
|
||||
continue
|
||||
if not FileAccess.file_exists(String(resolution.path)):
|
||||
entry.status = "FAIL"
|
||||
_fail("%s state=%s resolved a missing file %s" % [tag, state, resolution.path])
|
||||
continue
|
||||
if actual != String(resolution.path):
|
||||
entry.status = "FAIL"
|
||||
_fail("%s state=%s resolved %s but played %s" % [tag, state, resolution.path, actual])
|
||||
continue
|
||||
if state == "dead" and bool(view.anim.get("loop")):
|
||||
_fail("%s dead motion is looping" % tag)
|
||||
view.anim.set_process(true)
|
||||
await process_frame
|
||||
var actual := String(view.anim.get("anim_path"))
|
||||
if actual != requested:
|
||||
_fail("race=%d GPU=%s state=%s resolved %s but played %s" % [race, gpu, state, requested, actual])
|
||||
var duration := float(view.anim.call("get_duration")) if view.anim.has_method("get_duration") else 1.0
|
||||
var fraction := 0.5
|
||||
if state == "dead":
|
||||
fraction = 0.95
|
||||
view.anim.call("set_time", maxf(0.0, duration * fraction))
|
||||
view.anim.set_process(false)
|
||||
await process_frame
|
||||
var bounds: AABB = view.model.call("get_visual_aabb")
|
||||
if not bounds.position.is_finite() or not bounds.size.is_finite() or bounds.size.length() <= 0.0:
|
||||
_fail("race=%d GPU=%s state=%s has invalid visual AABB" % [race, gpu, state])
|
||||
_pose_bounds["%d/%s" % [race, state]] = bounds if gpu == "0" else _pose_bounds.get("%d/%s" % [race, state], AABB())
|
||||
if gpu == "1":
|
||||
var cpu_bounds: AABB = _pose_bounds.get("%d/%s" % [race, state], AABB())
|
||||
var tolerance := maxf(0.01, maxf(cpu_bounds.size.length(), bounds.size.length()) * 0.005)
|
||||
if cpu_bounds.size.length() <= 0.0 or cpu_bounds.position.distance_to(bounds.position) > tolerance or cpu_bounds.size.distance_to(bounds.size) > tolerance:
|
||||
_fail("race=%d state=%s CPU/GPU AABB differs beyond %.4fm" % [race, state, tolerance])
|
||||
var image_name := ""
|
||||
if DisplayServer.get_name() != "headless":
|
||||
var centre: Vector3 = view.model.global_transform * bounds.get_center()
|
||||
var visual_size: float = bounds.size.length() * view.model.scale.length() / sqrt(3.0)
|
||||
var distance: float = maxf(3.0, visual_size * 1.8)
|
||||
cam.position = centre + Vector3(distance * 0.35, distance * 0.22, distance)
|
||||
cam.look_at(centre, Vector3.UP)
|
||||
await RenderingServer.frame_post_draw
|
||||
image_name = "mob-%d-%s.png" % [race, state] if gpu == "1" else "mob-%d-%s-cpu.png" % [race, state]
|
||||
if root.get_texture().get_image().save_png(_output.path_join(image_name)) != OK:
|
||||
_fail("race=%d state=%s screenshot save failed" % [race, state])
|
||||
row.states[state] = {"requested_motion": requested, "resolved_motion": actual,
|
||||
"duration": duration, "sample_fraction": fraction,
|
||||
"aabb_position": [bounds.position.x, bounds.position.y, bounds.position.z],
|
||||
"aabb_size": [bounds.size.x, bounds.size.y, bounds.size.z], "screenshot": image_name}
|
||||
var duration := float(view.anim.call("get_duration")) if view.anim.has_method("get_duration") else 0.0
|
||||
entry["duration"] = duration
|
||||
if duration <= 0.0:
|
||||
entry.status = "FAIL"
|
||||
_fail("%s state=%s has zero duration" % [tag, state])
|
||||
continue
|
||||
for sample_index in SAMPLES.size():
|
||||
var fraction: float = SAMPLES[sample_index]
|
||||
# 末段取最后一帧之前,避免非循环动作 set_time(duration) 被播放器夹回/结束。
|
||||
var at := clampf(duration * fraction, 0.0, maxf(0.0, duration - 1.0 / 60.0))
|
||||
view.anim.call("set_time", at)
|
||||
await process_frame
|
||||
var sample := _sample_pose(view, race, gpu, state, fraction, tag)
|
||||
sample["time"] = at
|
||||
entry.samples.append(sample)
|
||||
if not bool(sample.valid):
|
||||
entry.status = "FAIL"
|
||||
if shoot:
|
||||
if frame.is_empty():
|
||||
frame = _frame_for(view)
|
||||
var image := await _capture(cam, frame, Vector3(0.35, 0.22, 1.0))
|
||||
if not _has_foreground(image):
|
||||
entry.status = "FAIL"
|
||||
_fail("%s state=%s sample=%.2f screenshot is empty" % [tag, state, fraction])
|
||||
image.convert(Image.FORMAT_RGBA8)
|
||||
image.resize(THUMB, THUMB, Image.INTERPOLATE_BILINEAR)
|
||||
sheet.blit_rect(image, Rect2i(0, 0, THUMB, THUMB), Vector2i(sample_index * THUMB, state_index * THUMB))
|
||||
if state == "wait" and is_equal_approx(fraction, 0.5):
|
||||
for view_name in ["front", "side"]:
|
||||
var direction := Vector3(0.0, 0.18, 1.0) if view_name == "front" else Vector3(1.0, 0.18, 0.0)
|
||||
var shot := await _capture(cam, frame, direction)
|
||||
var file_name := "mob-%d-%s-%s.png" % [race, view_name, "gpu" if gpu == "1" else "cpu"]
|
||||
if not _has_foreground(shot) or shot.save_png(_output.path_join(file_name)) != OK:
|
||||
_fail("%s %s view screenshot failed" % [tag, view_name])
|
||||
row.views[view_name] = file_name
|
||||
if shoot:
|
||||
var sheet_name := "mob-%d-sheet-%s.png" % [race, "gpu" if gpu == "1" else "cpu"]
|
||||
if not _has_foreground(sheet) or sheet.save_png(_output.path_join(sheet_name)) != OK:
|
||||
_fail("%s contact sheet save failed" % tag)
|
||||
row.contact_sheet = sheet_name
|
||||
_results.append(row)
|
||||
view.queue_free()
|
||||
await process_frame
|
||||
|
||||
## 同一 race/state/fraction:CPU 先记录,GPU 比较。容差 max(1cm, 高度0.5%),米制世界空间。
|
||||
func _sample_pose(view: Node3D, race: int, gpu: String, state: String, fraction: float, tag: String) -> Dictionary:
|
||||
var local: AABB = view.model.call("get_visual_aabb")
|
||||
var bounds: AABB = view.model.global_transform * local
|
||||
var out := {"fraction": fraction, "valid": true,
|
||||
"aabb_position": [bounds.position.x, bounds.position.y, bounds.position.z],
|
||||
"aabb_size": [bounds.size.x, bounds.size.y, bounds.size.z]}
|
||||
if not bounds.position.is_finite() or not bounds.size.is_finite() or bounds.size.length() <= 0.0:
|
||||
out.valid = false
|
||||
_fail("%s state=%s sample=%.2f has invalid visual AABB" % [tag, state, fraction])
|
||||
return out
|
||||
var key := "%d/%s/%.2f" % [race, state, fraction]
|
||||
if gpu == "0":
|
||||
_pose_bounds[key] = bounds
|
||||
return out
|
||||
if not _pose_bounds.has(key):
|
||||
out.valid = false
|
||||
_fail("%s state=%s sample=%.2f has no CPU pose to compare" % [tag, state, fraction])
|
||||
return out
|
||||
var cpu: AABB = _pose_bounds[key]
|
||||
var tolerance := maxf(TOLERANCE_FLOOR_M, maxf(cpu.size.y, bounds.size.y) * TOLERANCE_HEIGHT_RATIO)
|
||||
var delta := maxf(cpu.position.distance_to(bounds.position), cpu.end.distance_to(bounds.end))
|
||||
out["cpu_gpu_delta_m"] = delta
|
||||
out["tolerance_m"] = tolerance
|
||||
if delta > tolerance:
|
||||
out.valid = false
|
||||
_fail("%s state=%s sample=%.2f CPU/GPU AABB differs %.4fm > %.4fm" % [tag, state, fraction, delta, tolerance])
|
||||
return out
|
||||
|
||||
## 贴图实际绑定到材质、材质有 shader。双面/混合按资源记录,不做统一强制。
|
||||
func _material_report(model: Node, tag: String) -> Dictionary:
|
||||
var surfaces := 0
|
||||
var textured := 0
|
||||
var two_sided := 0
|
||||
var invalid := 0
|
||||
for node in model.find_children("*", "MeshInstance3D", true, false):
|
||||
var instance := node as MeshInstance3D
|
||||
if instance.mesh == null:
|
||||
continue
|
||||
for surface in instance.mesh.get_surface_count():
|
||||
surfaces += 1
|
||||
var material := instance.get_active_material(surface)
|
||||
var shader_material := material as ShaderMaterial
|
||||
if shader_material != null:
|
||||
if shader_material.shader == null:
|
||||
invalid += 1
|
||||
continue
|
||||
if bool(shader_material.get_shader_parameter("use_texture")) and shader_material.get_shader_parameter("albedo_tex") != null:
|
||||
textured += 1
|
||||
if shader_material.shader.code.contains("cull_disabled"):
|
||||
two_sided += 1
|
||||
elif material is BaseMaterial3D:
|
||||
if (material as BaseMaterial3D).get_texture(BaseMaterial3D.TEXTURE_ALBEDO) != null:
|
||||
textured += 1
|
||||
if (material as BaseMaterial3D).cull_mode == BaseMaterial3D.CULL_DISABLED:
|
||||
two_sided += 1
|
||||
else:
|
||||
invalid += 1
|
||||
if surfaces == 0:
|
||||
_fail("%s model has no mesh surfaces" % tag)
|
||||
if invalid > 0:
|
||||
_fail("%s has %d surfaces without a valid material" % [tag, invalid])
|
||||
if textured < surfaces:
|
||||
_fail("%s only %d/%d surfaces have a loaded texture" % [tag, textured, surfaces])
|
||||
return {"surfaces": surfaces, "textured": textured, "two_sided": two_sided, "invalid": invalid}
|
||||
|
||||
## 按首个采样姿态固定机位,同一 race 的所有截图可直接对比。
|
||||
func _frame_for(view: Node3D) -> Dictionary:
|
||||
var bounds: AABB = view.model.global_transform * (view.model.call("get_visual_aabb") as AABB)
|
||||
var radius := maxf(0.5, bounds.size.length() * 0.5)
|
||||
return {"centre": bounds.get_center(), "distance": maxf(3.0, radius * 3.6)}
|
||||
|
||||
func _capture(cam: Camera3D, frame: Dictionary, direction: Vector3) -> Image:
|
||||
var centre: Vector3 = frame.centre
|
||||
cam.position = centre + direction.normalized() * float(frame.distance)
|
||||
cam.look_at(centre, Vector3.UP)
|
||||
await RenderingServer.frame_post_draw
|
||||
return root.get_texture().get_image()
|
||||
|
||||
func _has_foreground(image: Image) -> bool:
|
||||
if image == null or image.is_empty():
|
||||
return false
|
||||
var hits := 0
|
||||
var step := 4
|
||||
var total := 0
|
||||
for y in range(0, image.get_height(), step):
|
||||
for x in range(0, image.get_width(), step):
|
||||
total += 1
|
||||
var c := image.get_pixel(x, y)
|
||||
if absf(c.r - BACKGROUND.r) + absf(c.g - BACKGROUND.g) + absf(c.b - BACKGROUND.b) > 0.06:
|
||||
hits += 1
|
||||
return total > 0 and float(hits) / float(total) >= MIN_FOREGROUND_RATIO
|
||||
|
||||
func _build_lighting() -> void:
|
||||
var sun := DirectionalLight3D.new()
|
||||
sun.rotation_degrees = Vector3(-42, 155, 0)
|
||||
@@ -116,7 +286,7 @@ func _build_lighting() -> void:
|
||||
var env := WorldEnvironment.new()
|
||||
var settings := Environment.new()
|
||||
settings.background_mode = Environment.BG_COLOR
|
||||
settings.background_color = Color(0.08, 0.1, 0.14)
|
||||
settings.background_color = BACKGROUND
|
||||
settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
settings.ambient_light_color = Color(0.55, 0.58, 0.65)
|
||||
settings.ambient_light_energy = 0.8
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# forest_viewpoints_test —— MAP-02 机位夹具校验与自动候选挑选(不加载地图)。
|
||||
# godot --headless --path project --script forest_viewpoints_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const Viewpoints = preload("res://testing/forest_viewpoints.gd")
|
||||
|
||||
const BOUNDS := {"base": Vector2(1049600, 0), "size": Vector2i(2, 2)}
|
||||
var _fail := 0
|
||||
var _done := false
|
||||
|
||||
func _ck(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
_ck(_done, "all viewpoint assertions executed")
|
||||
print("forest_viewpoints_test: failures=%d" % _fail)
|
||||
if _fail == 0:
|
||||
print("PASS: forest_viewpoints_test")
|
||||
quit(1 if _fail else 0)
|
||||
|
||||
func _has_error(result: Dictionary, needle: String) -> bool:
|
||||
return result.errors.any(func(e: String) -> bool: return needle in e)
|
||||
|
||||
func _run() -> void:
|
||||
# No fixture file: nothing is confirmed, every kind is missing, nothing fails.
|
||||
var empty := Viewpoints.plan({}, "m", BOUNDS)
|
||||
_ck(empty.errors.is_empty() and empty.viewpoints.is_empty(), "missing fixture is not an error")
|
||||
_ck(empty.missing_kinds == Viewpoints.KINDS, "missing fixture leaves all four kinds unconfirmed")
|
||||
_ck(Viewpoints.load_file("").ok and not Viewpoints.load_file("/nonexistent/vp.json").ok,
|
||||
"empty path means no fixture; a named but absent file is an error")
|
||||
|
||||
var good := {"schema_version": 1, "maps": {"m": {"races": [2301, 2306], "viewpoints": [
|
||||
{"id": "flat-01", "kind": "flat", "status": "confirmed", "confirmed_by": "test-env-owner",
|
||||
"server_cm": [1062400, 12800], "camera_yaw_deg": 90},
|
||||
{"id": "warp-01", "kind": "warp", "status": "unconfirmed", "server_cm": [1100000, 40000]},
|
||||
]}}}
|
||||
var planned := Viewpoints.plan(good, "m", BOUNDS)
|
||||
_ck(planned.errors.is_empty(), "valid fixture accepted: %s" % [planned.errors])
|
||||
_ck(planned.missing_kinds == ["slope", "dense"], "configured kinds are not reported missing")
|
||||
_ck(planned.races == [2301, 2306], "races come from the fixture")
|
||||
_ck(planned.viewpoints[0].local_m.is_equal_approx(Vector2(128, 128)), "server cm converts through BasePosition")
|
||||
_ck(Viewpoints.to_server_cm(BOUNDS, Vector2(128, 128)) == Vector2(1062400, 12800), "local metres convert back to server cm")
|
||||
|
||||
var bad := {"schema_version": 2, "maps": {"m": {"races": [0], "viewpoints": [
|
||||
{"id": "a", "kind": "flat", "status": "confirmed", "server_cm": [1062400, 12800]},
|
||||
{"id": "a", "kind": "cave", "status": "unconfirmed", "confirmed_by": "x", "server_cm": [1062400, 12800]},
|
||||
{"id": "b", "kind": "slope", "status": "maybe", "server_cm": [10, 10]},
|
||||
{"id": "c", "kind": "dense", "status": "unconfirmed", "server_cm": [1.5, 2]},
|
||||
]}, "password": "x"}}
|
||||
var rejected := Viewpoints.plan(bad, "m", BOUNDS)
|
||||
for needle in ["schema_version", "confirmed_by (role) is empty", "duplicates a", "kind must be one of",
|
||||
"unconfirmed but carries confirmed_by", "status must be", "outside m", "server_cm must be",
|
||||
"races must contain positive", "credentials must not be stored"]:
|
||||
_ck(_has_error(rejected, needle), "invalid fixture reports: %s (got %s)" % [needle, rejected.errors])
|
||||
|
||||
# Candidate picking is deterministic over synthetic terrain samples.
|
||||
var samples: Array = []
|
||||
for x in range(0, 101, 4):
|
||||
for z in range(0, 101, 4):
|
||||
var p := Vector2(x, z)
|
||||
var range_m := 0.02 if p.distance_to(Vector2(40, 40)) < 3.0 else 0.3
|
||||
if p.distance_to(Vector2(80, 30)) < 3.0:
|
||||
range_m = 1.2 # steep but walkable (0.4)
|
||||
if p.distance_to(Vector2(20, 80)) < 3.0:
|
||||
range_m = 6.0 # cliff (2.0) must lose to a walkable slope
|
||||
samples.append({"p": p, "range_m": range_m, "span_m": 3.0, "blocked": p == Vector2(44, 40)})
|
||||
var trees := PackedVector2Array()
|
||||
for i in 8:
|
||||
trees.append(Vector2(60, 72) + Vector2(4, 0).rotated(TAU * i / 8.0))
|
||||
trees.append(Vector2(41, 41)) # tree trunk right next to the flattest point
|
||||
var picked := Viewpoints.pick_candidates(samples, trees, Vector2(100, 100), Viewpoints.AUTO_KINDS)
|
||||
var by_kind := {}
|
||||
for c: Dictionary in picked:
|
||||
by_kind[c.kind] = c
|
||||
_ck(by_kind.has("flat") and by_kind.flat.range_m <= 0.3 and by_kind.flat.p.distance_to(Vector2(41, 41)) >= Viewpoints.AUTO_TREE_CLEARANCE_M,
|
||||
"flat candidate keeps clear of tree trunks: %s" % [by_kind.get("flat", {})])
|
||||
_ck(by_kind.has("slope") and (by_kind.slope.p as Vector2).distance_to(Vector2(80, 30)) < 3.0,
|
||||
"slope candidate is the steepest walkable point, not the cliff: %s" % [by_kind.get("slope", {})])
|
||||
_ck(by_kind.has("dense") and by_kind.dense.p.distance_to(Vector2(60, 72)) < Viewpoints.AUTO_DENSITY_RADIUS_M,
|
||||
"dense candidate sits inside the tree cluster: %s" % [by_kind.get("dense", {})])
|
||||
for a: Dictionary in picked:
|
||||
for b: Dictionary in picked:
|
||||
_ck(a == b or (a.p as Vector2).distance_to(b.p) >= Viewpoints.AUTO_SEPARATION_M, "candidates are separated")
|
||||
_ck(a.p.x >= Viewpoints.AUTO_EDGE_MARGIN_M and a.p.y >= Viewpoints.AUTO_EDGE_MARGIN_M, "candidates avoid map edges")
|
||||
_ck(not picked.any(func(c: Dictionary) -> bool: return c.kind == "warp"), "warp points are never guessed offline")
|
||||
var flat_only := Viewpoints.pick_candidates([{"p": Vector2(50, 50), "range_m": 0.0, "span_m": 3.0, "blocked": false}],
|
||||
PackedVector2Array(), Vector2(100, 100), Viewpoints.AUTO_KINDS)
|
||||
_ck(flat_only.size() == 1 and flat_only[0].kind == "flat", "flat terrain yields no fake slope or dense candidate")
|
||||
_done = true
|
||||
@@ -29,6 +29,10 @@ var _light_states: Array[Dictionary] = []
|
||||
var _bsphere_r := 0.0
|
||||
var _playing := false
|
||||
var _cleanup_remaining := -1.0
|
||||
## MSE-defined playback length of the last play(); -1 while looping (owner-bound).
|
||||
var defined_lifetime_ms := -1
|
||||
## Why the node left the tree: "cleanup" when its own playback clock freed it.
|
||||
var finish_reason := ""
|
||||
|
||||
static var _glow_tex: Texture2D
|
||||
|
||||
@@ -64,6 +68,7 @@ func _process(delta: float) -> void:
|
||||
if _cleanup_remaining >= 0.0:
|
||||
_cleanup_remaining -= maxf(delta, 0.0)
|
||||
if _cleanup_remaining <= 0.0:
|
||||
finish_reason = "cleanup"
|
||||
queue_free()
|
||||
|
||||
func play(force_one_shot := false) -> void:
|
||||
@@ -71,6 +76,7 @@ func play(force_one_shot := false) -> void:
|
||||
# may start or delete an effect after stop/replay, or while it is paused.
|
||||
_playing = true
|
||||
_cleanup_remaining = maxf(_longest_life() + 0.5, 1.5) if (force_one_shot or one_shot) else -1.0
|
||||
defined_lifetime_ms = roundi(_longest_life() * 1000.0) if (force_one_shot or one_shot) else -1
|
||||
for state in _particle_states:
|
||||
state["clock"] = 0.0
|
||||
state["emission_stopped"] = false
|
||||
@@ -97,6 +103,7 @@ func stop() -> void:
|
||||
# Existing particles may finish their lifetime; mesh/light emission stops now.
|
||||
_playing = false
|
||||
_cleanup_remaining = -1.0
|
||||
defined_lifetime_ms = -1
|
||||
for e in _emitters:
|
||||
e.emitting = false
|
||||
for mesh in _mesh_nodes:
|
||||
|
||||
@@ -6,12 +6,21 @@
|
||||
# var pv := fxr.spawn_at("hit_spark", world, global_pos)
|
||||
extends RefCounted
|
||||
|
||||
## Lifecycle boundaries of every effect this registry creates. lifetime_ms is the
|
||||
## MSE-defined length (-1 = looping, lives as long as its owner). fx_finished is
|
||||
## emitted when the node actually leaves the tree; reason "cleanup" means its own
|
||||
## playback clock freed it, "removed" means the owner/scene removed it earlier.
|
||||
signal fx_spawned(effect: String, fx_id: int, lifetime_ms: int)
|
||||
signal fx_finished(effect: String, fx_id: int, lifetime_ms: int, elapsed_ms: int, reason: String)
|
||||
|
||||
const Mse = preload("res://fx/mse.gd")
|
||||
const EffectPlayer = preload("res://fx/effect_player.gd")
|
||||
|
||||
var assets_root := ""
|
||||
var _path_cache := {} # name -> abs .mse path ("" = not found)
|
||||
var _spec_cache := {} # abs path -> parsed spec
|
||||
# Process-wide so ids stay unique when GameScene rebuilds its registry.
|
||||
static var _next_fx_id := 0
|
||||
|
||||
func setup(assets: String) -> void:
|
||||
assets_root = assets
|
||||
@@ -89,8 +98,23 @@ func spawn(name: String, parent: Node3D, one_shot := true) -> Node3D:
|
||||
fx.build(s, assets_root)
|
||||
parent.add_child(fx)
|
||||
fx.play(one_shot)
|
||||
_track(fx, name)
|
||||
return fx
|
||||
|
||||
func _track(fx: Node3D, name: String) -> void:
|
||||
_next_fx_id += 1
|
||||
var fx_id := _next_fx_id
|
||||
var lifetime_ms: int = fx.defined_lifetime_ms
|
||||
fx.tree_exiting.connect(_on_fx_exiting.bind(fx, name, fx_id, lifetime_ms, Time.get_ticks_msec()),
|
||||
CONNECT_ONE_SHOT)
|
||||
fx_spawned.emit(name, fx_id, lifetime_ms)
|
||||
|
||||
func _on_fx_exiting(fx: Node3D, name: String, fx_id: int, lifetime_ms: int, spawned_ms: int) -> void:
|
||||
var reason := "removed"
|
||||
if is_instance_valid(fx) and not String(fx.finish_reason).is_empty():
|
||||
reason = fx.finish_reason
|
||||
fx_finished.emit(name, fx_id, lifetime_ms, Time.get_ticks_msec() - spawned_ms, reason)
|
||||
|
||||
# 在世界某点播一次性
|
||||
func spawn_at(name: String, world_parent: Node3D, global_pos: Vector3, one_shot := true) -> Node3D:
|
||||
var fx := spawn(name, world_parent, one_shot)
|
||||
|
||||
@@ -1287,7 +1287,7 @@ func get_playable_context() -> Dictionary:
|
||||
return {"scene_ready": _model_built and _map_loaded(), "map_path": map_path,
|
||||
"net_play": net_play, "net_world": net_world, "world": world,
|
||||
"player": player, "pc": pc, "hud": hud, "quickbar": quickbar,
|
||||
"ground_items": ground_items, "skill_fx": skill_fx}
|
||||
"ground_items": ground_items, "skill_fx": skill_fx, "fx": fx}
|
||||
|
||||
var _mob_view_cache := {} # race -> bool(该 race 是否有可用模型;失败就别再试)
|
||||
var _remote_player_view_cache := {} # race -> bool(远端 PC 真模型可用性)
|
||||
|
||||
@@ -7,6 +7,7 @@ const MobView = preload("res://ui/mob_view.gd")
|
||||
const Audio = preload("res://audio.gd")
|
||||
|
||||
var _fail := 0
|
||||
var _resolution_done := false
|
||||
func _ck(c: bool, m: String) -> void:
|
||||
if not c:
|
||||
_fail += 1
|
||||
@@ -21,7 +22,45 @@ func _init() -> void:
|
||||
printerr("%d check(s) failed" % _fail)
|
||||
quit(1)
|
||||
|
||||
class FakeAnim:
|
||||
extends Node
|
||||
var anim_path := ""
|
||||
var loop := false
|
||||
|
||||
# 纯解析:不需要扩展和资产。旧客户端 CRaceData::GetMotionKey 找不到动作时
|
||||
# SetLoopMotion/InterceptMotion 直接返回、保留当前动作;不能静默换成 WAIT。
|
||||
func _check_motion_resolution() -> void:
|
||||
var mv: Node3D = MobView.new()
|
||||
mv._dir = "res://__no_such_mob_dir__"
|
||||
mv._motions = {"WAIT": "wait.msa", "NORMAL_ATTACK": "attack.msa", "FRONT_DEAD": "dead.msa"}
|
||||
var dead: Dictionary = mv.resolve_motion("dead")
|
||||
_ck(dead.get("requested_state") == "dead" and dead.get("motion") == "FRONT_DEAD" \
|
||||
and dead.get("path") == "dead.msa" and dead.get("fallback_reason") == "", "dead -> FRONT_DEAD 无回退")
|
||||
var run: Dictionary = mv.resolve_motion("run")
|
||||
_ck(run.get("path") == "" and run.get("motion") == "" \
|
||||
and run.get("fallback_reason") == "reference_keep_current_motion", "缺 RUN/WALK -> 保留当前动作(%s)" % run)
|
||||
_ck(mv.get_motion_path("run") == "", "缺 RUN/WALK 时 get_motion_path 不返回 WAIT")
|
||||
mv._motions = {"WAIT": "wait.msa"}
|
||||
_ck(mv.get_motion_path("dead") == "", "缺死亡动作时不能解析成 WAIT")
|
||||
mv._motions = {"WAIT": "wait.msa", "WALK": "walk.msa"}
|
||||
var alias: Dictionary = mv.resolve_motion("run")
|
||||
_ck(alias.get("path") == "walk.msa" and alias.get("fallback_reason") == "client_alias:WALK", "run -> WALK 标注别名回退")
|
||||
mv._motions = {"WAIT": "wait.msa"}
|
||||
var anim := FakeAnim.new()
|
||||
mv.anim = anim
|
||||
mv.set_anim_state("wait")
|
||||
mv.set_anim_state("run")
|
||||
_ck(anim.anim_path == "wait.msa" and anim.loop, "缺 run 时保留当前 WAIT 播放")
|
||||
_ck(mv.last_motion_resolution().get("fallback_reason") == "reference_keep_current_motion", "记录最后一次解析的回退原因")
|
||||
mv.anim = null
|
||||
anim.free()
|
||||
mv.free()
|
||||
_resolution_done = true
|
||||
|
||||
func _run() -> void:
|
||||
_check_motion_resolution()
|
||||
# 脚本运行时错误只会中止函数、不计失败;用哨兵保证整段断言都执行过。
|
||||
_ck(_resolution_done, "动作解析断言全部执行")
|
||||
if not ClassDB.class_exists("Metin2Model") or not ClassDB.class_exists("Metin2Proto"):
|
||||
print(" (skip: 扩展未注册)")
|
||||
return
|
||||
|
||||
@@ -6,7 +6,8 @@ func _init() -> void:
|
||||
func run() -> void:
|
||||
for mode in ["0", "1"]:
|
||||
OS.set_environment("MTGODOT_GPUSKIN", mode)
|
||||
for race in [101, 110, 20001]:
|
||||
# 101/110/20001 是原全怪物回归基线;2301-2315 是首测鬼木林树怪(MAP-01)。
|
||||
for race in [101, 110, 20001, 2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315]:
|
||||
var view := MobView.new()
|
||||
if not view.build(AssetRoot.path(), null, race):
|
||||
failures += 1
|
||||
|
||||
+20
-3
@@ -894,8 +894,27 @@ func skill_context() -> Dictionary:
|
||||
"arrow_count": _arrow_count,
|
||||
"cur_hp": cur_hp,
|
||||
"cur_sp": cur_sp,
|
||||
"bow_distance": int(_bow_distance_bonus),
|
||||
"target_distance": Callable(self, "skill_target_distance_cm"),
|
||||
}
|
||||
|
||||
# rkInstMain.GetDistance(&rkInstTarget)(含 IS_HUGE_RACE −200)——施法射程判定与
|
||||
# MODE_USE_SKILL 预约趋近共用同一量度,保证预约触发时 __UseSkill 必然在射程内。
|
||||
func skill_target_distance_cm(vid: int) -> float:
|
||||
if pc == null or pc.player == null or client == null:
|
||||
return -1.0
|
||||
var tnode: Node3D = net_world.node_for(vid) if net_world else null
|
||||
if tnode == null:
|
||||
return -1.0
|
||||
var dist_cm: float = pc.player.global_position.distance_to(tnode.global_position) * CM
|
||||
if _is_huge_race(int(client.get_entity(vid).get("race", 0))):
|
||||
dist_cm -= HUGE_RACE_DIST_FIX_CM
|
||||
return dist_cm
|
||||
|
||||
# __IsReservedUseSkill(dwSkillSlotIndex)
|
||||
func is_use_skill_reserved(slot: int) -> bool:
|
||||
return _reserved_mode == ReservedMode.USE_SKILL and _skill_slot_reserved == slot
|
||||
|
||||
func _process(dt: float) -> void:
|
||||
# 击退动作播完 -> 解除击退闸门(动作时长驱动,非计时器)。
|
||||
if _knock_down and player_view and player_view.has_method("is_in_hit_reaction") \
|
||||
@@ -1160,9 +1179,7 @@ func _reserve_process_use_skill() -> void:
|
||||
if rv.is_empty() or tnode == null:
|
||||
_clear_reserved()
|
||||
return
|
||||
var dist_cm: float = pc.player.global_position.distance_to(tnode.global_position) * CM
|
||||
if _is_huge_race(int(rv.get("race", 0))):
|
||||
dist_cm -= HUGE_RACE_DIST_FIX_CM
|
||||
var dist_cm := skill_target_distance_cm(_vid_reserved)
|
||||
if dist_cm < _skill_range_reserved:
|
||||
if _target_vid != _vid_reserved:
|
||||
_target_vid = _vid_reserved
|
||||
|
||||
+15
-7
@@ -1072,11 +1072,14 @@ func _on_dead(vid: int) -> void:
|
||||
var n: Node3D = _by_vid.get(vid, null)
|
||||
if n == null:
|
||||
return
|
||||
# 倒地 + 变灰(服务端随后会发 GC_CHARACTER_DEL 再真正移除)
|
||||
var t := create_tween()
|
||||
t.tween_property(n, "rotation:x", deg_to_rad(-80.0), 0.3)
|
||||
# CActorInstance::Die 只 InterceptOnceMotion(NAME_DEAD),不倾倒模型;
|
||||
# 只有没有动作的占位胶囊才用倒地作为唯一的死亡表现。
|
||||
if n.has_method("set_anim_state"):
|
||||
n.set_meta("dead", true)
|
||||
n.call("set_anim_state", "dead")
|
||||
else:
|
||||
var t := create_tween()
|
||||
t.tween_property(n, "rotation:x", deg_to_rad(-80.0), 0.3)
|
||||
|
||||
# P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体 / 坐标。原客户端在
|
||||
# GC_CREATE_FLY 之前可能只收到 FLY_TARGETING(target VID=0 时是坐标),所以这里也消费
|
||||
@@ -1284,17 +1287,22 @@ func _process(dt: float) -> void:
|
||||
var f := int(e.get("func", FUNC_WAIT))
|
||||
var moving := bool(e.get("moving", false))
|
||||
var walk_mode := int(e.get("walk_mode", 1))
|
||||
var dead := bool(e.get("dead", false))
|
||||
if f != int(n.get_meta("func", -1)) or moving != bool(n.get_meta("moving", false)) \
|
||||
or walk_mode != int(n.get_meta("walk_mode", -1)):
|
||||
or walk_mode != int(n.get_meta("walk_mode", -1)) or dead != bool(n.get_meta("dead", false)):
|
||||
n.set_meta("func", f)
|
||||
n.set_meta("moving", moving)
|
||||
n.set_meta("walk_mode", walk_mode)
|
||||
_apply_anim(n, f, moving, walk_mode)
|
||||
n.set_meta("dead", dead)
|
||||
_apply_anim(n, f, moving, walk_mode, dead)
|
||||
_update_hp_bars()
|
||||
|
||||
func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1) -> void:
|
||||
func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1, dead := false) -> void:
|
||||
var state := "wait"
|
||||
if f == FUNC_MOVE or (f == FUNC_WAIT and moving):
|
||||
if dead:
|
||||
# CActorInstance::__SetMotion:IsDead() 时拒绝 DEAD/DAMAGE_FLYING 以外的动作。
|
||||
state = "dead"
|
||||
elif f == FUNC_MOVE or (f == FUNC_WAIT and moving):
|
||||
state = "walk" if walk_mode == 0 else "run"
|
||||
elif f == FUNC_ATTACK or f == FUNC_COMBO:
|
||||
state = "attack"
|
||||
|
||||
@@ -124,3 +124,38 @@ func _run() -> void:
|
||||
await process_frame
|
||||
_ck(nw.node_for(2) != null, "main back in range -> culled entity rebuilt after its fade")
|
||||
_ck(int(added.get(2, 0)) == 2, "entity 2 rebuilt exactly once on re-entry")
|
||||
|
||||
await _dead_motion(fc, nw)
|
||||
|
||||
class AnimView extends Node3D:
|
||||
var states: Array[String] = []
|
||||
func set_anim_state(s: String) -> void:
|
||||
states.append(s)
|
||||
|
||||
# MAP-02 / CActorInstance::Die: a model plays NAME_DEAD only (no scene tilt), and
|
||||
# __SetMotion rejects every non-dead motion while the actor is dead — the
|
||||
# func/moving edge that arrives with the death must not restart WAIT.
|
||||
func _dead_motion(fc: FakeClient, nw: Node) -> void:
|
||||
nw.set_model_factory(func(d: Dictionary) -> Node3D:
|
||||
return AnimView.new() if int(d.get("race", 0)) == 2301 else null)
|
||||
var ent := fc.add(6, NEAR_CM + Vector2(300, 0), {"race": 2301, "ch_type": 2, "moving": true})
|
||||
fc.entity_spawned.emit(ent)
|
||||
await process_frame
|
||||
var view := nw.node_for(6) as AnimView
|
||||
_ck(view != null and view.states.back() == "run", "moving tree monster -> run requested")
|
||||
if view == null:
|
||||
return
|
||||
ent["moving"] = false
|
||||
ent["dead"] = true
|
||||
fc.entity_dead.emit(6)
|
||||
await create_tween().tween_interval(0.4).finished
|
||||
await process_frame
|
||||
_ck(view.states.back() == "dead", "death while moving keeps DEAD (reference __SetMotion ignores WAIT when dead): %s" % [view.states])
|
||||
_ck(is_zero_approx(view.rotation.x) and is_zero_approx(view.rotation.z), "model with motions is not tilted on death (rotation.x=%.2f)" % view.rotation.x)
|
||||
var capsule := fc.add(7, NEAR_CM + Vector2(600, 0), {"ch_type": 2})
|
||||
fc.entity_spawned.emit(capsule)
|
||||
var placeholder: Node3D = nw.node_for(7)
|
||||
capsule["dead"] = true
|
||||
fc.entity_dead.emit(7)
|
||||
await create_tween().tween_interval(0.4).finished
|
||||
_ck(placeholder != null and placeholder.rotation.x < -1.0, "placeholder capsule (no motions) still falls over as its only death cue")
|
||||
|
||||
@@ -289,8 +289,15 @@ func _run() -> void:
|
||||
var hook_calls: Array = []
|
||||
np.use_skill_hook = func(slot: int) -> bool: hook_calls.append(slot); return true
|
||||
pc.player.position = Vector3.ZERO
|
||||
_ck(is_equal_approx(np.skill_target_distance_cm(4100), 3000.0), "skill_target_distance_cm = node distance in cm")
|
||||
_ck(np.skill_target_distance_cm(424242) < 0.0, "skill_target_distance_cm unknown vid -> -1")
|
||||
var ctx_distance: Callable = np.skill_context().get("target_distance", Callable())
|
||||
_ck(ctx_distance.is_valid() and is_equal_approx(float(ctx_distance.call(4100)), 3000.0),
|
||||
"skill_context exposes the same distance to the PlayerSkill gate")
|
||||
_ck(not np.is_use_skill_reserved(5), "no reservation yet")
|
||||
np.reserve_use_skill(4100, 5, 800.0)
|
||||
_ck(is_equal_approx(np._skill_range_reserved, 790.0), "reserve_use_skill trims range >100 by 10")
|
||||
_ck(np.is_use_skill_reserved(5) and not np.is_use_skill_reserved(6), "__IsReservedUseSkill matches the reserved slot")
|
||||
pc.walk_calls.clear()
|
||||
fc.calls.set_target.clear()
|
||||
await process_frame
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
extends SceneTree
|
||||
|
||||
## CBT-01 离线事件与输入断言:真实 Quickbar + PlayerSkill + SkillTable 发出的请求,
|
||||
## 经真实 PlayableProbe / PlayableFlow 归档。只有假客户端记录请求,不连服务器。
|
||||
##
|
||||
## godot --headless --path project --script playable_combat_test.gd
|
||||
##
|
||||
## 覆盖 §7.2:拒绝不发有效施法请求、正常请求不重复、无目标、超距、冷却、资源不足、
|
||||
## 目标死亡、自身死亡、断线/切图、施法中换目标。
|
||||
|
||||
const SkillTable = preload("res://ui/skill_table.gd")
|
||||
const Quickbar = preload("res://ui/quickbar.gd")
|
||||
const Probe = preload("res://testing/playable_probe.gd")
|
||||
const Flow = preload("res://testing/playable_flow.gd")
|
||||
const Report = preload("res://testing/playable_report.gd")
|
||||
const Config = preload("res://testing/playable_config.gd")
|
||||
const Matrix = preload("res://playable_skill_matrix.gd")
|
||||
|
||||
const MAIN := 1000
|
||||
const MOB := 2000
|
||||
const MOB_B := 2001
|
||||
const MOB_RACE := 101
|
||||
## Warrior "Three-Way Cut": attack skill, needs a target, TargetRange 400cm.
|
||||
const SKILL := 1
|
||||
const SLOT := 7
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal skill_cooldown_end(skill: int)
|
||||
signal quickslots_changed()
|
||||
signal skills_changed()
|
||||
signal entity_dead(vid: int)
|
||||
signal damage(vid: int, amount: int, flag: int)
|
||||
signal disconnected(reason: String)
|
||||
signal entered_game()
|
||||
var target := 0
|
||||
var in_game := true
|
||||
var entities := {
|
||||
MAIN: {"vid": MAIN, "kind": 0, "ch_type": 0, "pos_cm": Vector3.ZERO, "dead": false},
|
||||
MOB: {"vid": MOB, "kind": 2, "ch_type": 2, "race": MOB_RACE, "pos_cm": Vector3(300, 0, 0), "dead": false, "hp": 100},
|
||||
MOB_B: {"vid": MOB_B, "kind": 2, "ch_type": 2, "race": MOB_RACE, "pos_cm": Vector3(350, 0, 0), "dead": false, "hp": 100},
|
||||
}
|
||||
var skills := [{"id": SKILL, "level": 5, "master": 0}]
|
||||
## Every outgoing request, in order: [name, args...].
|
||||
var requests: Array = []
|
||||
func is_in_game() -> bool: return in_game
|
||||
func get_main_vid() -> int: return MAIN
|
||||
func get_entity(vid) -> Dictionary: return entities.get(int(vid), {})
|
||||
func get_entities() -> Array: return entities.values()
|
||||
func get_ground_items() -> Array: return []
|
||||
func get_target() -> Dictionary: return {"vid": target}
|
||||
func get_skills() -> Array: return skills
|
||||
func get_inventory() -> Array: return []
|
||||
func get_quickslots() -> Array: return []
|
||||
func use_skill(id, vid) -> bool:
|
||||
requests.append(["use_skill", int(id), int(vid)])
|
||||
return in_game
|
||||
func cast_skill(motion, rot, x, y) -> bool:
|
||||
requests.append(["cast_skill", int(motion)])
|
||||
return in_game
|
||||
func quickslot_add(pos, type, ref) -> bool:
|
||||
requests.append(["quickslot_add", pos, type, ref])
|
||||
return true
|
||||
|
||||
## Only the NetPlay surface Quickbar/PlayableFlow touch. Range reservation itself
|
||||
## (walk in, then use_skill_hook) is covered by netplay_test.
|
||||
class FakeNetPlay extends Node:
|
||||
var client: FakeClient
|
||||
var context := {}
|
||||
var reservations: Array = []
|
||||
func select_target(vid: int) -> bool:
|
||||
client.target = vid
|
||||
return not client.get_entity(vid).is_empty()
|
||||
func skill_context() -> Dictionary:
|
||||
return context.merged({"target_distance": Callable(self, "skill_target_distance_cm")})
|
||||
func skill_target_distance_cm(vid: int) -> float:
|
||||
var e: Dictionary = client.get_entity(vid)
|
||||
var me: Dictionary = client.get_entity(MAIN)
|
||||
if e.is_empty() or me.is_empty():
|
||||
return -1.0
|
||||
return Vector3(e.pos_cm).distance_to(Vector3(me.pos_cm))
|
||||
func reserve_use_skill(vid: int, slot: int, range_cm: float) -> void:
|
||||
reservations.append([vid, slot, int(range_cm)])
|
||||
func is_use_skill_reserved(slot: int) -> bool:
|
||||
return not reservations.is_empty() and int(reservations[-1][1]) == slot
|
||||
func set_attack_key(_down: bool) -> void:
|
||||
pass
|
||||
|
||||
class FakeApp extends Node:
|
||||
var context := {}
|
||||
func get_playable_context() -> Dictionary: return context
|
||||
func get_playable_snapshot() -> Dictionary: return {"scene_ready": true}
|
||||
|
||||
class Rig:
|
||||
var client: FakeClient
|
||||
var net_play: FakeNetPlay
|
||||
var app: FakeApp
|
||||
var quickbar: Node
|
||||
var ui: Control
|
||||
var player: Node3D
|
||||
var probe: Node
|
||||
var report: RefCounted
|
||||
var flow: RefCounted
|
||||
var events: Array = []
|
||||
var now := 1_000_000
|
||||
func observed(kind: String) -> Array:
|
||||
return events.filter(func(e: Array) -> bool: return e[0] == kind)
|
||||
func count(name: String) -> int:
|
||||
return client.requests.filter(func(r: Array) -> bool: return r[0] == name).size()
|
||||
|
||||
var failures := 0
|
||||
var table: RefCounted
|
||||
var _rigs: Array = []
|
||||
|
||||
func _init() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func check(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func run() -> void:
|
||||
table = SkillTable.new()
|
||||
var loaded := false
|
||||
for lang in ["en", "common"]:
|
||||
if table.load_file(AssetRoot.path().path_join("locale/locale/%s/skilldesc.txt" % lang)):
|
||||
loaded = true
|
||||
break
|
||||
if not loaded:
|
||||
# Missing skill data makes every assertion meaningless; never report a silent pass.
|
||||
check(false, "skilldesc.txt is required for playable_combat_test")
|
||||
else:
|
||||
check(table.target_range(SKILL) == 400 and table.is_need_target(SKILL), "fixture skill 1 is a 400cm targeted attack")
|
||||
test_single_request_per_activation()
|
||||
test_cooldown_holds_without_request()
|
||||
test_no_target_rejected_without_request()
|
||||
test_out_of_range_reserves_without_request()
|
||||
test_not_enough_sp_rejected_without_request()
|
||||
test_dead_target_rejected_without_request()
|
||||
test_dead_self_holds_without_request()
|
||||
test_disconnect_fails_case_and_stops_requests()
|
||||
test_target_switch_keeps_cast_target()
|
||||
test_skill_matrix()
|
||||
for rig in _rigs:
|
||||
rig.probe.disconnect_all()
|
||||
rig.flow.clock_us = Callable()
|
||||
rig.flow = null
|
||||
for node in [rig.quickbar, rig.ui, rig.player, rig.probe, rig.net_play, rig.app, rig.client]:
|
||||
node.free()
|
||||
_rigs.clear()
|
||||
print("playable_combat_test: failures=%d" % failures)
|
||||
quit(1 if failures else 0)
|
||||
|
||||
func make_rig(repeats := 2) -> Rig:
|
||||
var rig := Rig.new()
|
||||
rig.client = FakeClient.new()
|
||||
root.add_child(rig.client)
|
||||
rig.net_play = FakeNetPlay.new()
|
||||
rig.net_play.client = rig.client
|
||||
rig.net_play.context = {"cur_sp": 500, "cur_hp": 500}
|
||||
rig.player = Node3D.new()
|
||||
root.add_child(rig.player)
|
||||
rig.ui = Control.new()
|
||||
root.add_child(rig.ui)
|
||||
rig.quickbar = Quickbar.new()
|
||||
rig.quickbar.net_play = rig.net_play
|
||||
root.add_child(rig.quickbar)
|
||||
rig.quickbar.setup(rig.client, table, rig.ui, func() -> Node: return rig.player)
|
||||
rig.app = FakeApp.new()
|
||||
rig.app.context = {"quickbar": rig.quickbar, "net_play": rig.net_play}
|
||||
rig.probe = Probe.new()
|
||||
root.add_child(rig.probe)
|
||||
rig.probe.setup(rig.app, rig.client)
|
||||
rig.probe.watch_local(rig.quickbar, "skill_cast_started", "cast_started")
|
||||
rig.probe.watch_local(rig.quickbar, "skill_rejected", "skill_rejected")
|
||||
rig.probe.observed.connect(func(kind: String, data: Dictionary) -> void: rig.events.append([kind, data]))
|
||||
var config := {"allowed_mob_vnums": [MOB_RACE], "timeout_seconds": 900,
|
||||
"skill_cases": [{"case_id": "three-way-cut", "skill_id": SKILL, "target": "enemy",
|
||||
"repeats": repeats, "required_evidence": ["cast_started", "damage"]}]}
|
||||
rig.report = Report.new()
|
||||
rig.report.begin("combat-%d" % _rigs.size(), "full", {})
|
||||
rig.report.set_required_cases(["CBT-three-way-cut"])
|
||||
rig.flow = Flow.new()
|
||||
rig.flow.report = rig.report
|
||||
rig.flow.probe = rig.probe
|
||||
rig.flow.flow = rig.app
|
||||
rig.flow.client = rig.client
|
||||
rig.flow.config = config
|
||||
rig.flow.suite = "full"
|
||||
rig.flow.allow_gameplay = true
|
||||
rig.flow.clock_us = func() -> int: return rig.now
|
||||
rig.probe.observed.connect(rig.flow.on_observed)
|
||||
# Enter the skill stage the way the closed loop leaves PICKUP: bound main + epoch.
|
||||
rig.flow._start_us = rig.now
|
||||
rig.flow._main_vid = MAIN
|
||||
rig.flow._bound_epoch = rig.probe.connection_epoch
|
||||
rig.flow._begin_skill_case()
|
||||
_rigs.append(rig)
|
||||
return rig
|
||||
|
||||
func tick(rig: Rig, advance_us := 0) -> void:
|
||||
rig.now += advance_us
|
||||
rig.flow.tick()
|
||||
|
||||
# --- input assertions ----------------------------------------------------------
|
||||
|
||||
func test_single_request_per_activation() -> void:
|
||||
var rig := make_rig()
|
||||
check(rig.client.requests.is_empty(), "assigning the test slot sends no CG_QUICKSLOT_ADD")
|
||||
tick(rig)
|
||||
check(rig.count("use_skill") == 1 and rig.count("cast_skill") == 1,
|
||||
"one activation sends exactly one use_skill and one cast_skill: %s" % [rig.client.requests])
|
||||
check(rig.client.requests[0] == ["use_skill", SKILL, MOB], "use_skill carries the resolved target")
|
||||
var casts := rig.observed("cast_started")
|
||||
check(casts.size() == 1 and int(casts[0][1].target_vid) == MOB and casts[0][1].source == "local",
|
||||
"cast_started is a local event bound to the cast target")
|
||||
tick(rig, 100_000)
|
||||
check(rig.count("use_skill") == 1, "a pending cast is not re-requested while waiting for server evidence")
|
||||
|
||||
func test_cooldown_holds_without_request() -> void:
|
||||
var rig := make_rig()
|
||||
tick(rig)
|
||||
rig.client.damage.emit(MOB, 30, 1)
|
||||
tick(rig)
|
||||
check(rig.report.case_status("CBT-three-way-cut") == "" and rig.flow._skill_successes == 1, "first cast verified by server damage")
|
||||
tick(rig, 600_000)
|
||||
check(rig.count("use_skill") == 1 and rig.observed("skill_rejected").is_empty(),
|
||||
"local cooldown holds the retry without a request or a rejection event")
|
||||
rig.client.skill_cooldown_end.emit(SKILL)
|
||||
tick(rig, 600_000)
|
||||
check(rig.count("use_skill") == 2 and rig.count("cast_skill") == 2, "GC_SKILL_COOLTIME_END unlocks exactly one new request")
|
||||
|
||||
func test_no_target_rejected_without_request() -> void:
|
||||
var rig := make_rig()
|
||||
rig.client.target = 0
|
||||
rig.quickbar.activate(SLOT)
|
||||
var rejected := rig.observed("skill_rejected")
|
||||
check(rejected.size() == 1 and rejected[0][1].code == "NEED_TARGET", "no target -> NEED_TARGET")
|
||||
check(rig.client.requests.is_empty(), "NEED_TARGET sends no skill request")
|
||||
|
||||
func test_out_of_range_reserves_without_request() -> void:
|
||||
var rig := make_rig()
|
||||
rig.client.entities[MOB].pos_cm = Vector3(900, 0, 0)
|
||||
rig.client.entities[MOB_B].pos_cm = Vector3(950, 0, 0)
|
||||
tick(rig)
|
||||
check(rig.count("use_skill") == 0 and rig.count("cast_skill") == 0, "target beyond TargetRange sends no skill request")
|
||||
check(rig.net_play.reservations.size() == 1 and rig.net_play.reservations[0][0] == MOB,
|
||||
"out-of-range cast becomes a MODE_USE_SKILL reservation: %s" % [rig.net_play.reservations])
|
||||
check(rig.observed("skill_rejected").is_empty() and rig.observed("cast_started").is_empty(),
|
||||
"reservation is neither a rejection nor a cast")
|
||||
# NetPlay walked into range and fired use_skill_hook -> activate_reserved.
|
||||
rig.client.entities[MOB].pos_cm = Vector3(300, 0, 0)
|
||||
var global_slot: int = rig.net_play.reservations[0][1] if not rig.net_play.reservations.is_empty() else SLOT
|
||||
check(bool(rig.quickbar.activate_reserved(global_slot)), "reserved skill fires once in range")
|
||||
check(rig.count("use_skill") == 1 and rig.observed("cast_started").size() == 1, "in-range reserved cast sends exactly one request")
|
||||
rig.client.damage.emit(MOB, 30, 1)
|
||||
tick(rig)
|
||||
check(rig.flow._skill_successes == 1, "the asynchronous reserved cast is credited to the case")
|
||||
|
||||
func test_not_enough_sp_rejected_without_request() -> void:
|
||||
var rig := make_rig()
|
||||
rig.net_play.context = {"cur_sp": 10, "cur_hp": 500}
|
||||
tick(rig)
|
||||
var rejected := rig.observed("skill_rejected")
|
||||
check(rejected.size() == 1 and rejected[0][1].code == "NOT_ENOUGH_SP", "SP below GetNeedSP -> NOT_ENOUGH_SP")
|
||||
check(rig.client.requests.is_empty(), "NOT_ENOUGH_SP sends no skill request")
|
||||
check(rig.report.case_status("CBT-three-way-cut") == "", "resource shortage is retried, not a fixture BLOCKED")
|
||||
|
||||
func test_dead_target_rejected_without_request() -> void:
|
||||
var rig := make_rig()
|
||||
rig.client.entities[MOB].dead = true
|
||||
rig.client.target = MOB
|
||||
rig.quickbar.activate(SLOT)
|
||||
var rejected := rig.observed("skill_rejected")
|
||||
check(rejected.size() == 1 and rejected[0][1].code == "CANNOT_ATTACK", "dead target -> CANNOT_ATTACK")
|
||||
check(rig.client.requests.is_empty(), "dead target sends no skill request")
|
||||
tick(rig, 600_000)
|
||||
check(rig.client.requests.size() == 2 and rig.client.requests[0] == ["use_skill", SKILL, MOB_B],
|
||||
"flow re-targets a living monster instead of the corpse: %s" % [rig.client.requests])
|
||||
|
||||
func test_dead_self_holds_without_request() -> void:
|
||||
var rig := make_rig()
|
||||
rig.client.entities[MAIN].dead = true
|
||||
tick(rig)
|
||||
check(rig.client.requests.is_empty() and rig.observed("skill_rejected").is_empty(), "dead main character sends nothing")
|
||||
rig.client.entities[MAIN].dead = false
|
||||
rig.net_play.context = {"cur_sp": 500, "can_act": false}
|
||||
tick(rig, 600_000)
|
||||
check(rig.client.requests.is_empty(), "stunned/knocked-down (CANNOT_ACT) sends nothing")
|
||||
|
||||
func test_disconnect_fails_case_and_stops_requests() -> void:
|
||||
var rig := make_rig()
|
||||
tick(rig)
|
||||
check(rig.count("use_skill") == 1, "cast requested before the disconnect")
|
||||
rig.client.in_game = false
|
||||
rig.client.disconnected.emit("socket closed")
|
||||
check(rig.report.case_status("CBT-three-way-cut") == "FAIL" and rig.flow.done, "disconnect during a skill case fails that case")
|
||||
# Server damage after reconnect belongs to the new epoch and the finished case.
|
||||
rig.client.in_game = true
|
||||
rig.client.entered_game.emit()
|
||||
rig.client.damage.emit(MOB, 30, 1)
|
||||
tick(rig, 600_000)
|
||||
check(rig.count("use_skill") == 1 and rig.flow._skill_successes == 0, "no request or credit after the disconnect")
|
||||
|
||||
func test_target_switch_keeps_cast_target() -> void:
|
||||
var rig := make_rig(1)
|
||||
tick(rig)
|
||||
check(rig.observed("cast_started").size() == 1 and int(rig.observed("cast_started")[0][1].target_vid) == MOB, "cast bound to target A")
|
||||
rig.net_play.select_target(MOB_B)
|
||||
rig.client.damage.emit(MOB_B, 30, 1)
|
||||
tick(rig)
|
||||
check(rig.flow._skill_successes == 0, "damage on the newly selected target B is not evidence for the cast on A")
|
||||
rig.client.damage.emit(MOB, 30, 1)
|
||||
tick(rig)
|
||||
check(rig.flow._skill_successes == 1 and rig.report.case_status("CBT-three-way-cut") == "PASS",
|
||||
"damage on the cast-time target A completes the case")
|
||||
check(rig.count("use_skill") == 1, "switching the selection never re-sends the cast")
|
||||
|
||||
# --- test/playable/skill-cases.json ---------------------------------------------
|
||||
|
||||
func test_skill_matrix() -> void:
|
||||
var committed := ProjectSettings.globalize_path("res://").path_join("../test/playable/skill-cases.json").simplify_path()
|
||||
for job in Config.SKILL_MATRIX_JOBS:
|
||||
var r: Dictionary = Matrix.check(committed, job)
|
||||
check(r.status == "BLOCKED" and r.errors.is_empty() and r.blocked.size() == 4 and r.skill_cases.is_empty(),
|
||||
"committed matrix carries no invented skill ids and blocks %s: %s" % [job, r])
|
||||
var evidence := ["cast_started", "damage"]
|
||||
var matrix := {"schema_version": 1, "jobs": {"WARRIOR": {
|
||||
"single_target": {"status": "confirmed", "skill_id": SKILL, "target": "enemy", "required_evidence": evidence},
|
||||
"area": {"status": "not_applicable", "checklist_ref": "REL-01 checklist row"},
|
||||
"self_buff": {"status": "not_applicable", "checklist_ref": "REL-01 checklist row"},
|
||||
"flying": {"status": "not_applicable", "checklist_ref": "REL-01 checklist row"}}}}
|
||||
var ok: Dictionary = Config.skill_matrix_cases(matrix, "WARRIOR", table)
|
||||
check(ok.errors.is_empty() and ok.blocked.is_empty() and ok.cases.size() == 1, "confirmed matrix resolves: %s" % [ok])
|
||||
if ok.cases.size() == 1:
|
||||
check(ok.cases[0].case_id == "warrior-single-target" and int(ok.cases[0].repeats) == Config.SKILL_MATRIX_REPEATS,
|
||||
"matrix case defaults to 10 successful casts")
|
||||
check("CBT-warrior-single-target" in Config.required_cases({"skill_cases": ok.cases}, "full"),
|
||||
"matrix case becomes a required full-suite case")
|
||||
var bad := [
|
||||
["unconfirmed entry with a skill id", "area", {"status": "unconfirmed", "skill_id": 3}],
|
||||
["skill from another job", "single_target", {"status": "confirmed", "skill_id": 32, "required_evidence": evidence}],
|
||||
["fewer than 10 casts", "single_target", {"status": "confirmed", "skill_id": SKILL, "repeats": 3, "required_evidence": evidence}],
|
||||
["local-only evidence", "single_target", {"status": "confirmed", "skill_id": SKILL, "required_evidence": ["cast_started", "fx_spawned"]}],
|
||||
["not applicable without checklist record", "area", {"status": "not_applicable"}],
|
||||
["unknown status", "flying", {"status": "skip"}],
|
||||
]
|
||||
for row in bad:
|
||||
var mutated: Dictionary = matrix.duplicate(true)
|
||||
mutated.jobs.WARRIOR[row[1]] = row[2]
|
||||
check(not Config.skill_matrix_cases(mutated, "WARRIOR", table).errors.is_empty(), "matrix rejects %s" % row[0])
|
||||
var none: Dictionary = matrix.duplicate(true)
|
||||
none.jobs.WARRIOR.single_target = {"status": "not_applicable", "checklist_ref": "x"}
|
||||
check(not Config.skill_matrix_cases(none, "WARRIOR", table).errors.is_empty(), "a job with nothing confirmed is not READY")
|
||||
check(not Config.skill_matrix_cases(matrix, "SURA", table).errors.is_empty(), "a job absent from the matrix is an error")
|
||||
@@ -1,14 +1,75 @@
|
||||
extends SceneTree
|
||||
|
||||
## Parent-runner precondition gate. Runs headless before the packaged client
|
||||
## starts; it never opens a network connection or reads credentials.
|
||||
##
|
||||
## env:
|
||||
## MT_PLAYABLE_VALIDATE_CONFIG scenario JSON (required)
|
||||
## MT_PLAYABLE_SUITE playable|full|soak (default playable)
|
||||
## MT_PLAYABLE_ASSETS asset root used for map/waypoint checks
|
||||
## MT_PLAYABLE_SERVERLIST test-mode serverlist override (same value is
|
||||
## handed to the client, so both resolve alike)
|
||||
## MT_PLAYABLE_GATE_OUTPUT directory for required-cases.json / server-address.json
|
||||
## exit: 0 = ready, 2 = configuration/precondition blocked.
|
||||
|
||||
const Config = preload("res://testing/playable_config.gd")
|
||||
const ServerInfoRes = preload("res://net/serverinfo.gd")
|
||||
|
||||
func _init() -> void:
|
||||
var path := OS.get_environment("MT_PLAYABLE_VALIDATE_CONFIG")
|
||||
var suite := OS.get_environment("MT_PLAYABLE_SUITE").strip_edges()
|
||||
if suite.is_empty():
|
||||
suite = "playable"
|
||||
var errors: Array = []
|
||||
if not (suite in Config.SUITES):
|
||||
errors.append("unknown suite: %s" % suite)
|
||||
var result := Config.load_file(path)
|
||||
if not result.ok:
|
||||
for error in result.errors:
|
||||
errors.append_array(result.errors)
|
||||
# A present soak block is validated for every suite: its confirmed fault proxy also
|
||||
# fronts the playable exit runs that share the scenario.
|
||||
if result.ok and (suite == "soak" or result.config.has("soak")):
|
||||
errors.append_array(Config.validate_soak(result.config, suite == "soak").errors)
|
||||
var address := {}
|
||||
var bounds := {}
|
||||
if result.ok:
|
||||
var serverinfo := ServerInfoRes.new()
|
||||
var override := OS.get_environment("MT_PLAYABLE_SERVERLIST").strip_edges()
|
||||
if not override.is_empty():
|
||||
if not serverinfo.load_file(override):
|
||||
errors.append("MT_PLAYABLE_SERVERLIST cannot be loaded")
|
||||
else:
|
||||
serverinfo.load_file("res://serverlist.txt")
|
||||
var resolved := Config.resolve_server(result.config, serverinfo)
|
||||
errors.append_array(resolved.errors)
|
||||
address = resolved.address
|
||||
var assets := OS.get_environment("MT_PLAYABLE_ASSETS").strip_edges()
|
||||
if assets.is_empty():
|
||||
errors.append("MT_PLAYABLE_ASSETS is not set; resource preconditions cannot be checked")
|
||||
else:
|
||||
var resources := Config.check_resources(result.config, assets)
|
||||
errors.append_array(resources.errors)
|
||||
bounds = resources.bounds
|
||||
if not errors.is_empty():
|
||||
for error in errors:
|
||||
printerr("CONFIG: " + String(error))
|
||||
quit(2)
|
||||
return
|
||||
print("PLAYABLE CONFIG: PASS " + path)
|
||||
var out_dir := OS.get_environment("MT_PLAYABLE_GATE_OUTPUT").strip_edges()
|
||||
if not out_dir.is_empty():
|
||||
var cases := Config.required_cases(result.config, suite)
|
||||
if not _write_json(out_dir.path_join("required-cases.json"), {"schema_version": 1, "suite": suite, "cases": cases}) \
|
||||
or not _write_json(out_dir.path_join("server-address.json"), address) \
|
||||
or not _write_json(out_dir.path_join("map-bounds.json"), bounds):
|
||||
printerr("CONFIG: cannot write gate output")
|
||||
quit(2)
|
||||
return
|
||||
print("PLAYABLE CONFIG: PASS")
|
||||
quit(0)
|
||||
|
||||
func _write_json(path: String, value: Variant) -> bool:
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return false
|
||||
file.store_string(JSON.stringify(value, " ") + "\n")
|
||||
file.close()
|
||||
return true
|
||||
|
||||
+1216
-30
File diff suppressed because it is too large
Load Diff
+139
-331
@@ -1,373 +1,181 @@
|
||||
extends Node
|
||||
|
||||
## 首个 Mac 内测的真实联网闭环。
|
||||
## 首个 Mac 内测的真实联网闭环(NET-01 / CBT-01)。
|
||||
##
|
||||
## 这个脚本只负责调度生产入口并观察服务端结果:移动仍经 PlayerController,
|
||||
## 目标仍经 NetPlay,拾取仍经 NetPlay.pick_ground_item,技能入口由后续 CBT
|
||||
## 用例接入 Quickbar。没有 `MT_PLAYABLE_ALLOW_GAMEPLAY=1` 时只做登录/进场
|
||||
## 观察并把后续必测项标记为 BLOCKED,不会误发游戏操作。
|
||||
## 本节点只做装配:加载配置、挂 PlayableProbe、把观察转给 PlayableFlow 状态机、
|
||||
## 截图、写 client-report.json 后正常退出。所有游戏操作都在 PlayableFlow 里经
|
||||
## 生产入口发出;没有 MT_PLAYABLE_ALLOW_GAMEPLAY=1 时只验证登录/选角/进场。
|
||||
##
|
||||
## env:
|
||||
## MT_PLAYABLE_CONFIG 场景 JSON(不含凭据)
|
||||
## MT_PLAYABLE_SUITE playable|full|soak(soak = STB-01 墙钟长跑,挂帧指标与窗口适配器)
|
||||
## MT_PLAYABLE_RUN_ID 父脚本生成的 run_id
|
||||
## MT_PLAYABLE_ALLOW_GAMEPLAY 1 = 允许移动/攻击/拾取/施法
|
||||
## MT_TEST_REPORT client-report.json 路径
|
||||
## MT_TEST_EVENTS events.jsonl 路径(默认与报告同目录)
|
||||
## MT_TEST_OUTPUT 失败截图目录(默认与报告同目录)
|
||||
## MTGODOT_GPUSKIN 仅作为帧指标分段标签 gpu_skin 记录
|
||||
|
||||
const Config = preload("res://testing/playable_config.gd")
|
||||
const Report = preload("res://testing/playable_report.gd")
|
||||
const Probe = preload("res://testing/playable_probe.gd")
|
||||
const Flow = preload("res://testing/playable_flow.gd")
|
||||
const Metrics = preload("res://testing/playable_metrics.gd")
|
||||
const PlayableWindow = preload("res://testing/playable_window.gd")
|
||||
## Kinds that do heavy work on the main thread; a long frame right after one is attributed to it.
|
||||
const MARKED_KINDS := ["entered_game", "disconnected", "entity_spawned", "ground_item_added"]
|
||||
|
||||
const WAIT_WORLD_SECONDS := 60.0
|
||||
const MOVE_SECONDS := 20.0
|
||||
const TARGET_SECONDS := 30.0
|
||||
const ATTACK_SECONDS := 90.0
|
||||
const DROP_SECONDS := 30.0
|
||||
const PICKUP_SECONDS := 15.0
|
||||
|
||||
var _flow: Node
|
||||
var _client: Node
|
||||
var _config: Dictionary
|
||||
var _app_flow: Node
|
||||
var _client: Object
|
||||
var _report: RefCounted
|
||||
var _probe: Node
|
||||
var _machine: RefCounted
|
||||
var _report_path := ""
|
||||
var _events_path := ""
|
||||
var _state := "boot"
|
||||
var _state_since := 0.0
|
||||
var _started := 0.0
|
||||
var _output_dir := ""
|
||||
var _watched_quickbar: Object
|
||||
var _watched_fx: Object
|
||||
var _finished := false
|
||||
var _allow_gameplay := false
|
||||
var _entered_count := 0
|
||||
var _main_vid := 0
|
||||
var _server_move_seen := false
|
||||
var _waypoint_index := 0
|
||||
var _expected_position := Vector3.ZERO
|
||||
var _target_vid := 0
|
||||
var _target_initial_hp := -1
|
||||
var _target_damaged := false
|
||||
var _target_dead := false
|
||||
var _drop_vid := 0
|
||||
var _drop_vnum := 0
|
||||
var _pickup_before := 0
|
||||
var _cases := {}
|
||||
var _metrics: RefCounted
|
||||
var _tick_us := 0
|
||||
var _tick_max_us := 0
|
||||
var _ticks := 0
|
||||
var _memory_start := {}
|
||||
|
||||
func setup(flow: Node, m2client: Node, config_path := "") -> void:
|
||||
_flow = flow
|
||||
func setup(app_flow: Node, m2client: Object, config: Dictionary = {}) -> void:
|
||||
_app_flow = app_flow
|
||||
_client = m2client
|
||||
_started = Time.get_ticks_msec() / 1000.0
|
||||
_state_since = _started
|
||||
_allow_gameplay = OS.get_environment("MT_PLAYABLE_ALLOW_GAMEPLAY") == "1"
|
||||
_report_path = OS.get_environment("MT_TEST_REPORT").strip_edges()
|
||||
if _report_path.is_empty():
|
||||
_report_path = "user://playable-client-report.json"
|
||||
_events_path = OS.get_environment("MT_TEST_EVENTS").strip_edges()
|
||||
if _events_path.is_empty():
|
||||
_events_path = _report_path.get_base_dir().path_join("events.jsonl")
|
||||
var path := config_path if not config_path.is_empty() else OS.get_environment("MT_PLAYABLE_CONFIG")
|
||||
var loaded := Config.load_file(path)
|
||||
var events_path := OS.get_environment("MT_TEST_EVENTS").strip_edges()
|
||||
if events_path.is_empty():
|
||||
events_path = _report_path.get_base_dir().path_join("events.jsonl")
|
||||
_output_dir = OS.get_environment("MT_TEST_OUTPUT").strip_edges()
|
||||
if _output_dir.is_empty():
|
||||
_output_dir = _report_path.get_base_dir()
|
||||
var suite := OS.get_environment("MT_PLAYABLE_SUITE").strip_edges()
|
||||
if suite.is_empty():
|
||||
suite = "playable"
|
||||
var errors: Array = []
|
||||
if config.is_empty():
|
||||
var loaded := Config.load_file(OS.get_environment("MT_PLAYABLE_CONFIG"))
|
||||
config = loaded.config
|
||||
errors = loaded.errors
|
||||
else:
|
||||
errors = Config.validate(config).errors
|
||||
if not (suite in Config.SUITES):
|
||||
errors.append("unknown suite")
|
||||
_report = Report.new()
|
||||
_report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "playable", loaded.config if loaded.ok else {})
|
||||
if not loaded.ok:
|
||||
_case("CONFIG-01", "FAIL", "invalid configuration: %s" % "; ".join(loaded.errors))
|
||||
_report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), suite, config if errors.is_empty() else {}, events_path)
|
||||
if not errors.is_empty():
|
||||
_report.set_required_cases(["CONFIG-01"])
|
||||
_report.add_case("CONFIG-01", "FAIL", "invalid configuration: %s" % "; ".join(errors))
|
||||
_finish_later()
|
||||
return
|
||||
_config = loaded.config
|
||||
_case("CONFIG-01", "PASS", "validated")
|
||||
if _client == null:
|
||||
_case("NET-BOOT-01", "FAIL", "M2Client was not created")
|
||||
_report.set_required_cases(Config.required_cases(config, suite))
|
||||
_report.add_case("CONFIG-01", "FAIL", "M2Client was not created")
|
||||
_finish_later()
|
||||
return
|
||||
_probe = Probe.new()
|
||||
_probe.name = "PlayableProbe"
|
||||
add_child(_probe)
|
||||
_probe.setup(_flow, _client)
|
||||
_probe.observed.connect(_on_observed)
|
||||
_bind("char_list", Callable(self, "_on_char_list"))
|
||||
_bind("entered_game", Callable(self, "_on_entered_game"))
|
||||
_bind("disconnected", Callable(self, "_on_disconnected"))
|
||||
_bind("entity_moved", Callable(self, "_on_entity_moved"))
|
||||
_bind("damage", Callable(self, "_on_damage"))
|
||||
_bind("entity_dead", Callable(self, "_on_entity_dead"))
|
||||
_bind("ground_item_added", Callable(self, "_on_ground_item_added"))
|
||||
_bind("ground_item_removed", Callable(self, "_on_ground_item_removed"))
|
||||
_bind("item_picked_up", Callable(self, "_on_item_picked_up"))
|
||||
_bind("inventory_changed", Callable(self, "_on_inventory_changed"))
|
||||
_set_state("WAIT_LOGIN")
|
||||
_record("harness_started", {"allow_gameplay": _allow_gameplay})
|
||||
_probe.setup(_app_flow, _client)
|
||||
_machine = Flow.new()
|
||||
_machine.report = _report
|
||||
_machine.probe = _probe
|
||||
_machine.flow = _app_flow
|
||||
_machine.client = _client
|
||||
_machine.config = config
|
||||
_machine.suite = suite
|
||||
_machine.allow_gameplay = OS.get_environment("MT_PLAYABLE_ALLOW_GAMEPLAY") == "1"
|
||||
_machine.capture = Callable(self, "_capture")
|
||||
_machine.finished.connect(_on_machine_finished)
|
||||
_probe.observed.connect(_machine.on_observed)
|
||||
if suite == "soak":
|
||||
_metrics = Metrics.new()
|
||||
_metrics.configure({"window_seconds": 60})
|
||||
_machine.metrics = _metrics
|
||||
_machine.window = PlayableWindow.new(get_viewport())
|
||||
_memory_start = Metrics.memory_snapshot()
|
||||
_probe.observed.connect(_mark_observed)
|
||||
_machine.start()
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _finished:
|
||||
if _machine == null or _finished:
|
||||
return
|
||||
var now := Time.get_ticks_msec() / 1000.0
|
||||
if now - _started > float(_config.get("timeout_seconds", 120)):
|
||||
_fail_current("overall timeout in state %s" % _state)
|
||||
_finish_later()
|
||||
_watch_scene_signals()
|
||||
if _metrics != null:
|
||||
var tags: Dictionary = _machine.frame_tags()
|
||||
tags["gpu_skin"] = OS.get_environment("MTGODOT_GPUSKIN").strip_edges()
|
||||
_metrics.set_tags(tags)
|
||||
_metrics.frame()
|
||||
var started := Time.get_ticks_usec()
|
||||
_machine.tick()
|
||||
var spent := Time.get_ticks_usec() - started
|
||||
_tick_us += spent
|
||||
_tick_max_us = maxi(_tick_max_us, spent)
|
||||
_ticks += 1
|
||||
return
|
||||
match _state:
|
||||
"WAIT_LOGIN":
|
||||
if _entered_count > 0:
|
||||
_set_state("WAIT_WORLD")
|
||||
elif now - _state_since > 30.0:
|
||||
_fail_case("NET-LOGIN-01", "login/character selection timed out")
|
||||
_finish_later()
|
||||
"WAIT_WORLD":
|
||||
var snap: Dictionary = _flow.get_playable_snapshot() if _flow and _flow.has_method("get_playable_snapshot") else {}
|
||||
if bool(snap.get("scene_ready", false)):
|
||||
_case("NET-LOGIN-01", "PASS", "character list received")
|
||||
_case("NET-SELECT-01", "PASS", "entered_game received")
|
||||
_case("NET-WORLD-01", "PASS", "game scene and map are ready")
|
||||
_main_vid = int(_client.get_main_vid()) if _client.has_method("get_main_vid") else 0
|
||||
if _main_vid <= 0:
|
||||
_fail_case("NET-WORLD-02", "main VID is invalid")
|
||||
_finish_later()
|
||||
elif not _allow_gameplay:
|
||||
_case("GAMEPLAY-ALLOW-01", "BLOCKED", "--allow-gameplay was not supplied")
|
||||
_finish_later()
|
||||
else:
|
||||
_begin_move()
|
||||
elif now - _state_since > WAIT_WORLD_SECONDS:
|
||||
_fail_case("NET-WORLD-01", "scene did not become ready")
|
||||
_finish_later()
|
||||
"MOVE":
|
||||
if _move_reached():
|
||||
if _server_move_seen:
|
||||
_case("NET-MOVE-%02d" % (_waypoint_index + 1), "PASS", "server entity_moved observed")
|
||||
_waypoint_index += 1
|
||||
if _waypoint_index >= _config.waypoints_cm.size():
|
||||
_begin_target()
|
||||
else:
|
||||
_send_waypoint()
|
||||
else:
|
||||
_block_current("local arrival without server movement evidence")
|
||||
_finish_later()
|
||||
elif now - _state_since > MOVE_SECONDS:
|
||||
_fail_current("waypoint not reached")
|
||||
_finish_later()
|
||||
"TARGET":
|
||||
if _target_vid > 0:
|
||||
_case("NET-TARGET-01", "PASS", "allowed live monster selected")
|
||||
_begin_attack()
|
||||
elif now - _state_since > TARGET_SECONDS:
|
||||
_fail_case("NET-TARGET-01", "no allowed live monster found")
|
||||
_finish_later()
|
||||
"ATTACK":
|
||||
if _target_dead or (_target_damaged and _target_hp_decreased()):
|
||||
if _client.has_method("get_entity") and not _client.get_entity(_target_vid).is_empty():
|
||||
_case("NET-ATTACK-01", "PASS", "server damage/death evidence observed")
|
||||
else:
|
||||
_block_current("target result arrived after entity removal")
|
||||
_stop_attack()
|
||||
_set_state("DROP")
|
||||
elif now - _state_since > ATTACK_SECONDS:
|
||||
_stop_attack()
|
||||
_fail_case("NET-ATTACK-01", "no server damage or death result")
|
||||
_finish_later()
|
||||
"DROP":
|
||||
if _drop_vid > 0:
|
||||
_case("NET-DROP-01", "PASS", "allowed ground item added by server")
|
||||
_begin_pickup()
|
||||
elif now - _state_since > DROP_SECONDS:
|
||||
_block_current("deterministic drop fixture was not observed")
|
||||
_finish_later()
|
||||
"PICKUP":
|
||||
if _pickup_succeeded():
|
||||
_case("NET-PICKUP-01", "PASS", "item_picked_up and inventory delta observed")
|
||||
_case("NET-EXIT-01", "PASS", "client test state completed")
|
||||
_finish_later()
|
||||
elif now - _state_since > PICKUP_SECONDS:
|
||||
_fail_case("NET-PICKUP-01", "pickup result did not include inventory delta")
|
||||
_finish_later()
|
||||
_machine.tick()
|
||||
|
||||
func _bind(signal_name: String, callback: Callable) -> void:
|
||||
if _client.has_signal(signal_name) and not _client.is_connected(signal_name, callback):
|
||||
_client.connect(signal_name, callback)
|
||||
func _mark_observed(kind: String, _data: Dictionary) -> void:
|
||||
if kind in MARKED_KINDS:
|
||||
_metrics.mark(kind)
|
||||
|
||||
func _set_state(next: String) -> void:
|
||||
_state = next
|
||||
_state_since = Time.get_ticks_msec() / 1000.0
|
||||
_record("state", {"name": next})
|
||||
|
||||
func _on_observed(kind: String, payload: Dictionary) -> void:
|
||||
_record(kind, payload)
|
||||
|
||||
func _record(kind: String, payload := {}) -> void:
|
||||
if _report == null:
|
||||
## Quickbar / skill fx live in GameScene and are rebuilt with it; re-bind lazily.
|
||||
func _watch_scene_signals() -> void:
|
||||
if _app_flow == null or not _app_flow.has_method("get_playable_context"):
|
||||
return
|
||||
var data: Dictionary = payload.duplicate(true)
|
||||
data["monotonic_us"] = Time.get_ticks_usec()
|
||||
data["run_id"] = OS.get_environment("MT_PLAYABLE_RUN_ID")
|
||||
data["case_id"] = _state
|
||||
_report.add_event(data, _events_path)
|
||||
var context: Dictionary = _app_flow.get_playable_context()
|
||||
var quickbar: Object = context.get("quickbar", null)
|
||||
if quickbar != null and is_instance_valid(quickbar) and quickbar != _watched_quickbar:
|
||||
_watched_quickbar = quickbar
|
||||
_probe.watch_local(quickbar, "skill_cast_started", "cast_started")
|
||||
_probe.watch_local(quickbar, "skill_rejected", "skill_rejected")
|
||||
# EffectRegistry spawn / tree-exit boundaries (CBT-01 effect lifetime evidence).
|
||||
var registry: Object = context.get("fx", null)
|
||||
if registry != null and registry != _watched_fx:
|
||||
_watched_fx = registry
|
||||
_probe.watch_local(registry, "fx_spawned", "fx_spawned")
|
||||
_probe.watch_local(registry, "fx_finished", "fx_finished")
|
||||
|
||||
func _on_char_list(characters: Array) -> void:
|
||||
_record("char_list", {"count": characters.size()})
|
||||
var slot := int(_config.get("character_slot", -1))
|
||||
var found := false
|
||||
for character in characters:
|
||||
if int(character.get("index", -1)) == slot:
|
||||
found = true
|
||||
break
|
||||
if not found:
|
||||
_fail_case("NET-LOGIN-01", "configured character slot is absent")
|
||||
_finish_later()
|
||||
func _capture(case_id: String) -> String:
|
||||
if DisplayServer.get_name() == "headless" or _output_dir.is_empty():
|
||||
return ""
|
||||
var image := get_viewport().get_texture().get_image()
|
||||
if image == null or image.is_empty():
|
||||
return ""
|
||||
var file_name := "%s.png" % case_id.to_lower()
|
||||
if image.save_png(_output_dir.path_join(file_name)) != OK:
|
||||
return ""
|
||||
return file_name
|
||||
|
||||
func _on_entered_game() -> void:
|
||||
_entered_count += 1
|
||||
_record("entered_game", {"count": _entered_count})
|
||||
|
||||
func _on_disconnected(reason: String) -> void:
|
||||
_record("disconnected", {"reason": reason})
|
||||
if _state != "WAIT_LOGIN" and _state != "WAIT_WORLD":
|
||||
_fail_current("unexpected disconnect: %s" % reason)
|
||||
_finish_later()
|
||||
|
||||
func _on_entity_moved(vid: int) -> void:
|
||||
if vid == _main_vid:
|
||||
_server_move_seen = true
|
||||
_record("main_entity_moved", {"vid": vid})
|
||||
|
||||
func _on_damage(vid: int, amount: int, flag: int) -> void:
|
||||
if vid == _target_vid and amount > 0:
|
||||
_target_damaged = true
|
||||
_record("target_damage", {"vid": vid, "amount": amount, "flag": flag})
|
||||
|
||||
func _on_entity_dead(vid: int) -> void:
|
||||
if vid == _target_vid:
|
||||
_target_dead = true
|
||||
_record("target_dead", {"vid": vid})
|
||||
|
||||
func _on_ground_item_added(item: Dictionary) -> void:
|
||||
var vnum := int(item.get("vnum", 0))
|
||||
if _state == "DROP" and vnum in _config.get("allowed_drop_vnums", []):
|
||||
_drop_vid = int(item.get("vid", 0))
|
||||
_drop_vnum = vnum
|
||||
_record("allowed_drop", {"vid": _drop_vid, "vnum": vnum})
|
||||
|
||||
func _on_ground_item_removed(vid: int) -> void:
|
||||
_record("ground_item_removed", {"vid": vid})
|
||||
|
||||
func _on_item_picked_up(vnum: int, count: int, source: String) -> void:
|
||||
if _state == "PICKUP" and vnum == _drop_vnum:
|
||||
_record("item_picked_up", {"vnum": vnum, "count": count, "source": source})
|
||||
|
||||
func _on_inventory_changed(window: int, cell: int) -> void:
|
||||
_record("inventory_changed", {"window": window, "cell": cell})
|
||||
|
||||
func _begin_move() -> void:
|
||||
_waypoint_index = 0
|
||||
_send_waypoint()
|
||||
|
||||
func _send_waypoint() -> void:
|
||||
var point: Array = _config.waypoints_cm[_waypoint_index]
|
||||
var net := Vector3(float(point[0]) * 0.01, 0.0, -float(point[1]) * 0.01)
|
||||
_expected_position = MapCoord.to_world(net)
|
||||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||||
var controller: Node = context.get("pc", null)
|
||||
_server_move_seen = false
|
||||
if controller == null or not controller.has_method("walk_to"):
|
||||
_fail_case("NET-MOVE-%02d" % (_waypoint_index + 1), "PlayerController.walk_to is unavailable")
|
||||
_finish_later()
|
||||
return
|
||||
controller.walk_to(_expected_position)
|
||||
_set_state("MOVE")
|
||||
_record("move_requested", {"waypoint_index": _waypoint_index, "x_cm": point[0], "y_cm": point[1]})
|
||||
|
||||
func _move_reached() -> bool:
|
||||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||||
var player: Node3D = context.get("player", null)
|
||||
return is_instance_valid(player) and player.global_position.distance_to(_expected_position) <= 1.0
|
||||
|
||||
func _begin_target() -> void:
|
||||
_target_vid = 0
|
||||
var allowed: Array = _config.get("allowed_mob_vnums", [])
|
||||
var best_distance := INF
|
||||
var player_pos := Vector3.ZERO
|
||||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||||
var player: Node3D = context.get("player", null)
|
||||
if is_instance_valid(player):
|
||||
player_pos = player.global_position
|
||||
for entity in _client.get_entities():
|
||||
var race := int(entity.get("race", 0))
|
||||
var kind := int(entity.get("kind", entity.get("ch_type", -1)))
|
||||
if race not in allowed or bool(entity.get("dead", false)) or kind != 2:
|
||||
continue
|
||||
var pos: Variant = entity.get("pos", null)
|
||||
var distance := 0.0
|
||||
if pos is Vector3:
|
||||
distance = player_pos.distance_to(MapCoord.to_world(pos))
|
||||
if distance < best_distance:
|
||||
best_distance = distance
|
||||
_target_vid = int(entity.get("vid", 0))
|
||||
if _target_vid > 0:
|
||||
var net_play: Node = context.get("net_play", null)
|
||||
if net_play == null or not net_play.has_method("select_target") or not net_play.select_target(_target_vid):
|
||||
_target_vid = 0
|
||||
_set_state("TARGET")
|
||||
|
||||
func _begin_attack() -> void:
|
||||
var entity: Dictionary = _client.get_entity(_target_vid)
|
||||
_target_initial_hp = int(entity.get("hp", -1))
|
||||
_target_damaged = false
|
||||
_target_dead = false
|
||||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||||
var net_play: Node = context.get("net_play", null)
|
||||
if net_play == null or not net_play.has_method("set_attack_key"):
|
||||
_fail_case("NET-ATTACK-01", "NetPlay.set_attack_key is unavailable")
|
||||
_finish_later()
|
||||
return
|
||||
net_play.set_attack_key(true)
|
||||
_set_state("ATTACK")
|
||||
|
||||
func _stop_attack() -> void:
|
||||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||||
var net_play: Node = context.get("net_play", null)
|
||||
if net_play and net_play.has_method("set_attack_key"):
|
||||
net_play.set_attack_key(false)
|
||||
|
||||
func _target_hp_decreased() -> bool:
|
||||
if not _client.has_method("get_entity"):
|
||||
return false
|
||||
var e: Dictionary = _client.get_entity(_target_vid)
|
||||
return not e.is_empty() and _target_initial_hp > 0 and int(e.get("hp", _target_initial_hp)) < _target_initial_hp
|
||||
|
||||
func _begin_pickup() -> void:
|
||||
_pickup_before = _inventory_count(_drop_vnum)
|
||||
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
|
||||
var net_play: Node = context.get("net_play", null)
|
||||
if net_play == null or not net_play.has_method("pick_ground_item"):
|
||||
_fail_case("NET-PICKUP-01", "NetPlay.pick_ground_item is unavailable")
|
||||
_finish_later()
|
||||
return
|
||||
net_play.pick_ground_item(_drop_vid)
|
||||
_set_state("PICKUP")
|
||||
|
||||
func _inventory_count(vnum: int) -> int:
|
||||
if not _client.has_method("get_inventory"):
|
||||
return -1
|
||||
var total := 0
|
||||
for item in _client.get_inventory():
|
||||
if int(item.get("vnum", 0)) == vnum:
|
||||
total += int(item.get("count", 0))
|
||||
return total
|
||||
|
||||
func _pickup_succeeded() -> bool:
|
||||
return _drop_vnum > 0 and _inventory_count(_drop_vnum) > _pickup_before
|
||||
|
||||
func _case(case_id: String, status: String, reason: String) -> void:
|
||||
if _cases.has(case_id):
|
||||
return
|
||||
_cases[case_id] = true
|
||||
_report.add_case(case_id, status, reason)
|
||||
|
||||
func _fail_case(case_id: String, reason: String) -> void:
|
||||
_case(case_id, "FAIL", reason)
|
||||
|
||||
func _block_current(reason: String) -> void:
|
||||
_case("BLOCK-%s" % _state, "BLOCKED", reason)
|
||||
|
||||
func _fail_current(reason: String) -> void:
|
||||
_fail_case("FAIL-%s" % _state, reason)
|
||||
func _on_machine_finished(_status: String) -> void:
|
||||
_finish_later()
|
||||
|
||||
func _finish_later() -> void:
|
||||
if _finished:
|
||||
return
|
||||
_finished = true
|
||||
_stop_attack()
|
||||
_report.write(_report_path)
|
||||
call_deferred("_quit")
|
||||
call_deferred("_write_and_quit")
|
||||
|
||||
func _quit() -> void:
|
||||
func _write_and_quit() -> void:
|
||||
if _machine != null:
|
||||
_machine.cancel_inputs()
|
||||
if _metrics != null:
|
||||
_report.set_section("frame_metrics", _metrics.summary())
|
||||
_report.set_section("client_memory", {"start": _memory_start, "end": Metrics.memory_snapshot(),
|
||||
"note": "MEMORY_STATIC 是 Godot 分配器计数,不是进程 RSS;RSS 由父运行器按 PID 外部采样"})
|
||||
_report.set_section("probe_overhead", {"sampler": Metrics.measure_overhead(),
|
||||
"flow_tick": {"ticks": _ticks, "total_us": _tick_us, "max_us": _tick_max_us,
|
||||
"mean_us": float(_tick_us) / maxf(1.0, _ticks)}})
|
||||
if _probe != null:
|
||||
_probe.disconnect_all()
|
||||
if not _report.write(_report_path):
|
||||
printerr("PLAYABLE: cannot write client report")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
print("PLAYABLE CLIENT: %s" % _report.finish().status)
|
||||
# Normal quit path: the parent runner owns the final exit gate.
|
||||
get_tree().quit(0)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# playable_metrics_test —— STB-01 §9.1 帧间隔/长帧指标的离线自检(注入时钟,不等真实时间)。
|
||||
# godot --headless --path project --script playable_metrics_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const Metrics = preload("res://testing/playable_metrics.gd")
|
||||
|
||||
var _fail := 0
|
||||
var _done := false
|
||||
var _now := 0
|
||||
|
||||
func _ck(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
_fail += 1
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func _init() -> void:
|
||||
_run()
|
||||
_ck(_done, "all metrics assertions executed")
|
||||
print("playable_metrics_test: failures=%d" % _fail)
|
||||
if _fail == 0:
|
||||
print("PASS: playable_metrics_test")
|
||||
quit(1 if _fail else 0)
|
||||
|
||||
func _metrics(options := {}) -> RefCounted:
|
||||
var m: RefCounted = Metrics.new()
|
||||
m.clock_us = func() -> int: return _now
|
||||
m.configure(options)
|
||||
return m
|
||||
|
||||
## Advance the injected clock by `ms` and close one frame.
|
||||
func _frame(m: RefCounted, ms: float) -> void:
|
||||
_now += int(ms * 1000.0)
|
||||
m.frame()
|
||||
|
||||
func _run() -> void:
|
||||
# Percentiles are nearest-rank over the window samples; max and counts are exact.
|
||||
var p: RefCounted = _metrics({"window_seconds": 3600})
|
||||
p.set_tags({"map": "m", "phase": "play", "actor_count": 8, "gpu_skin": true})
|
||||
p.frame()
|
||||
for i in range(1, 101):
|
||||
_frame(p, float(i))
|
||||
var s: Dictionary = p.summary()
|
||||
var w: Dictionary = s.windows[0]
|
||||
_ck(w.count == 100 and w.sampled == 100 and not w.truncated, "one window with 100 samples: %s" % [w])
|
||||
_ck(w.p50_ms == 50.0 and w.p95_ms == 95.0 and w.p99_ms == 99.0 and w.max_ms == 100.0,
|
||||
"nearest-rank p50/p95/p99/max: %s" % [w])
|
||||
_ck(w.over_50ms == 50 and w.over_100ms == 0, ">50ms and >100ms are strict thresholds: %s" % [w])
|
||||
_ck(s.print_frames == false and p.print_frames == false, "per-frame printing is off by default")
|
||||
_ck(s.long_frames.size() == 50 and s.long_frames[0].ms == 51.0, "only samples above 50ms become long-frame events")
|
||||
_ck(s.long_frames[0].tags == {"map": "m", "phase": "play", "actor_count": "8", "gpu_skin": "true"},
|
||||
"long frames carry map/phase/actor_count/gpu_skin tags: %s" % [s.long_frames[0].tags])
|
||||
_ck(s.frames == 100, "first frame() only arms the clock")
|
||||
|
||||
# Fixed-size ring: a window longer than the capacity keeps exact counters but
|
||||
# bounded sample storage, and says so.
|
||||
var ring: RefCounted = _metrics({"capacity": 64, "window_seconds": 3600, "max_long_frames": 8, "max_markers": 4})
|
||||
ring.set_tags({"phase": "play"})
|
||||
ring.frame()
|
||||
for i in 10000:
|
||||
_frame(ring, 120.0 if i == 17 else (60.0 if i % 1000 == 0 else 16.0))
|
||||
if i % 100 == 0:
|
||||
ring.mark("entity_batch", {"count": i})
|
||||
_ck(ring.stored_sample_count() <= 64, "ring buffer never grows past capacity (%d)" % ring.stored_sample_count())
|
||||
var rs: Dictionary = ring.summary()
|
||||
var rw: Dictionary = rs.windows[0]
|
||||
_ck(rw.count == 10000 and rw.sampled == 64 and rw.truncated, "overlong window is marked truncated: %s" % [rw])
|
||||
_ck(rw.max_ms == 120.0 and rw.over_100ms == 1 and rw.over_50ms == 11, "counters stay exact beyond capacity: %s" % [rw])
|
||||
_ck(rs.long_frames.size() == 8 and rs.long_frames_dropped == 3, "long-frame events are bounded with a drop count")
|
||||
_ck(rs.markers.size() == 4 and rs.markers_dropped == 96, "markers are bounded with a drop count")
|
||||
_ck(ring.long_unattributed_by_phase() == {"play": 11} and rs.long_frames.size() == 8,
|
||||
"unattributed counts stay exact while the long-frame list is bounded: %s" % [ring.long_unattributed_by_phase()])
|
||||
|
||||
# Wall-clock windows close on the injected clock, not on a frame count.
|
||||
var timed: RefCounted = _metrics({"window_seconds": 1, "max_windows": 3})
|
||||
timed.set_tags({"phase": "play"})
|
||||
timed.frame()
|
||||
for i in 400:
|
||||
_frame(timed, 16.0)
|
||||
var ts: Dictionary = timed.summary()
|
||||
_ck(ts.windows.size() == 3 and ts.windows_dropped >= 3, "closed windows are bounded (%d kept, %d dropped)" % [ts.windows.size(), ts.windows_dropped])
|
||||
_ck(ts.windows.all(func(x: Dictionary) -> bool: return x.end_ms - x.start_ms <= 1016), "each window spans about one wall-clock second")
|
||||
|
||||
# A tag change closes the window and opens a separate segment; segments are capped.
|
||||
var seg: RefCounted = _metrics({"max_segments": 2, "window_seconds": 3600})
|
||||
seg.set_tags({"phase": "loading", "map": "a"})
|
||||
seg.frame()
|
||||
_frame(seg, 300.0)
|
||||
seg.set_tags({"phase": "play", "map": "a"})
|
||||
_frame(seg, 16.0)
|
||||
seg.set_tags({"phase": "play", "map": "b"})
|
||||
_frame(seg, 16.0)
|
||||
var ss: Dictionary = seg.summary()
|
||||
_ck(ss.windows.size() == 3 and ss.windows[0].tags.phase == "loading", "tag changes split windows: %s" % [ss.windows])
|
||||
_ck(ss.segments.size() == 2 and ss.unsegmented_frames == 1, "segment table is capped: %s" % [ss.segments])
|
||||
_ck(ss.segments[0].max_ms == 300.0 and ss.segments[0].over_100ms == 1, "loading segment is reported separately")
|
||||
_ck(seg.severe_by_phase() == {"loading": 1}, "severe counts by phase: %s" % [seg.severe_by_phase()])
|
||||
|
||||
# Markers from the frame that was measured (and the one before it) attribute a long frame.
|
||||
var att: RefCounted = _metrics({"window_seconds": 3600})
|
||||
att.set_tags({"phase": "play"})
|
||||
att.frame()
|
||||
_frame(att, 16.0)
|
||||
att.mark("model_build", {"race": 2301, "ms": 70.0})
|
||||
_frame(att, 16.0)
|
||||
_frame(att, 80.0)
|
||||
_frame(att, 16.0)
|
||||
_frame(att, 16.0)
|
||||
_frame(att, 75.0)
|
||||
var at: Dictionary = att.summary()
|
||||
_ck(at.long_frames.size() == 2 and at.long_frames[0].markers == ["model_build"], "long frame attributed to the recent marker: %s" % [at.long_frames])
|
||||
_ck(at.long_frames[1].markers.is_empty(), "later long frame without a marker stays unattributed")
|
||||
_ck(att.unattributed_long_frames().size() == 1, "unattributed long frames are listed for analysis")
|
||||
_ck(att.long_unattributed_by_phase() == {"play": 1} and at.long_unattributed_by_phase == {"play": 1},
|
||||
"unattributed long frames are counted per phase: %s" % [att.long_unattributed_by_phase()])
|
||||
_ck(at.markers[0].detail == {"race": 2301, "ms": 70.0}, "marker detail keeps only whitelisted keys")
|
||||
att.mark("model_build", {"name": "Hero", "count": 2})
|
||||
_ck(not att.summary().markers[-1].detail.has("name"), "non-whitelisted marker detail is dropped")
|
||||
|
||||
_ck([0, 1, 8, 9, 16, 17, 64, 65].map(func(n: int) -> String: return Metrics.actor_bucket(n))
|
||||
== ["0", "1-8", "1-8", "9-16", "9-16", "17-32", "33-64", "65+"], "actor counts are bucketed")
|
||||
|
||||
# §9.3: the same interaction repeated 3 times with >100ms in at least 2 blocks.
|
||||
_ck(Metrics.repeated_severe({"ATTACK": [true, false, true], "MOVE": [true, false, false, true], "DROP": [false, true, true]})
|
||||
== ["ATTACK", "DROP"], "2-of-3 consecutive repetitions with a >100ms frame are flagged")
|
||||
_ck(Metrics.repeated_severe({"ATTACK": [true, true]}).is_empty(), "fewer than 3 repetitions cannot be judged yet")
|
||||
|
||||
# Memory: MEMORY_STATIC is recorded; GPU memory is never reported as 0.
|
||||
var mem: Dictionary = Metrics.memory_snapshot()
|
||||
_ck(mem.memory_static_kib is int and mem.memory_static_kib > 0, "MEMORY_STATIC in KiB: %s" % [mem])
|
||||
_ck(mem.gpu_memory_kib == null and mem.gpu_memory_status == "unavailable", "GPU memory is null/unavailable")
|
||||
_ck(mem.godot_video_mem_kib == null or int(mem.godot_video_mem_kib) > 0, "Godot video allocation is null when unknown, not 0")
|
||||
|
||||
# Probe cost is measured, not assumed.
|
||||
var cost: Dictionary = Metrics.measure_overhead(2000)
|
||||
_ck(cost.iterations == 2000 and cost.per_sample_us > 0.0 and cost.per_sample_us < 1000.0, "per-sample probe cost measured: %s" % [cost])
|
||||
_done = true
|
||||
@@ -0,0 +1,64 @@
|
||||
extends SceneTree
|
||||
|
||||
## CBT-01 技能矩阵检查:读取 test/playable/skill-cases.json(或 --matrix 指定的本地副本),
|
||||
## 按职业校验并输出可直接放入场景配置的 skill_cases。只读数据,不连网络、不读凭据。
|
||||
##
|
||||
## godot --headless --path project --script playable_skill_matrix.gd -- --job WARRIOR [--matrix PATH]
|
||||
##
|
||||
## stdout: 一行 "SKILL_MATRIX {json}"。exit 0 = READY,1 = 矩阵错误,2 = 存在未确认条目(BLOCKED)。
|
||||
|
||||
const Config = preload("res://testing/playable_config.gd")
|
||||
const SkillTable = preload("res://ui/skill_table.gd")
|
||||
|
||||
func _init() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var job := ""
|
||||
var matrix_path := ProjectSettings.globalize_path("res://").path_join("../test/playable/skill-cases.json").simplify_path()
|
||||
var i := 0
|
||||
while i < args.size():
|
||||
match args[i]:
|
||||
"--job":
|
||||
i += 1
|
||||
job = String(args[i]).to_upper() if i < args.size() else ""
|
||||
"--matrix":
|
||||
i += 1
|
||||
matrix_path = args[i] if i < args.size() else ""
|
||||
"--help":
|
||||
print("usage: playable_skill_matrix.gd -- --job WARRIOR|ASSASSIN|SURA|SHAMAN [--matrix PATH]")
|
||||
quit(0)
|
||||
return
|
||||
_:
|
||||
printerr("SKILL_MATRIX: unknown argument %s" % args[i])
|
||||
quit(1)
|
||||
return
|
||||
i += 1
|
||||
var result := check(matrix_path, job)
|
||||
print("SKILL_MATRIX " + JSON.stringify(result))
|
||||
quit({"READY": 0, "BLOCKED": 2}.get(result.status, 1))
|
||||
|
||||
static func check(matrix_path: String, job: String) -> Dictionary:
|
||||
var out := {"status": "FAIL", "job": job, "errors": [], "blocked": [], "not_applicable": [], "skill_cases": []}
|
||||
var file := FileAccess.open(matrix_path, FileAccess.READ)
|
||||
if file == null:
|
||||
out.errors.append("skill matrix cannot be opened")
|
||||
return out
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
if not (parsed is Dictionary):
|
||||
out.errors.append("skill matrix is not a JSON object")
|
||||
return out
|
||||
var table := SkillTable.new()
|
||||
var loaded := false
|
||||
for lang in ["en", "common"]:
|
||||
if table.load_file(AssetRoot.path().path_join("locale/locale/%s/skilldesc.txt" % lang)):
|
||||
loaded = true
|
||||
break
|
||||
var resolved := Config.skill_matrix_cases(parsed, job, table if loaded else null)
|
||||
out.errors = resolved.errors
|
||||
if not loaded:
|
||||
out.errors.append("skilldesc.txt is unavailable; skill ownership cannot be checked")
|
||||
out.blocked = resolved.blocked
|
||||
out.not_applicable = resolved.not_applicable
|
||||
out.skill_cases = resolved.cases
|
||||
if out.errors.is_empty():
|
||||
out.status = "BLOCKED" if not out.blocked.is_empty() else "READY"
|
||||
return out
|
||||
+32
-2
@@ -56,6 +56,9 @@ var arrow_count := -1 # 装备箭袋数量;-1 未知/无限,
|
||||
var slot_cd_end := 0.0 # 该槽本地冷却结束时刻(Time.get_ticks_msec()/1000)
|
||||
var cur_sp := -1 # GetStatus(POINT_SP);-1 = 未知(不判 NOT_ENOUGH_SP)
|
||||
var cur_hp := -1 # GetStatus(POINT_HP);-1 = 未知(不判 NOT_ENOUGH_HP)
|
||||
var bow_distance := 0 # GetStatus(POINT_BOW_DISTANCE) —— __GetSkillTargetRange 加成
|
||||
## vid -> 主角到目标的距离 cm(已含 IS_HUGE_RACE −200 修正);<0 = 未知。未注入则不判射程。
|
||||
var target_distance := Callable()
|
||||
|
||||
func setup(m2client: Object, skill_table: RefCounted) -> void:
|
||||
client = m2client
|
||||
@@ -69,6 +72,24 @@ func _now() -> float:
|
||||
func _ok(target_vid := 0) -> Dictionary:
|
||||
return {"ok": true, "code": "OK", "target_vid": target_vid}
|
||||
|
||||
# __GetSkillTargetRange —— PythonPlayerSkill.cpp:390
|
||||
func _skill_target_range(skill_id: int) -> int:
|
||||
return int(table.target_range(skill_id)) + bow_distance * 100
|
||||
|
||||
# 射程外:不施法,交给调用方 __ReserveUseSkill(走近后再 __UseSkill)。
|
||||
# charge = 参考端「蓄力技且未预约时先 __SendUseSkill(slot, 0)」。
|
||||
func _out_of_range(skill_id: int, tvid: int) -> Dictionary:
|
||||
var range_cm := _skill_target_range(skill_id)
|
||||
if range_cm <= 0 or not target_distance.is_valid():
|
||||
return {}
|
||||
var distance := float(target_distance.call(tvid))
|
||||
if distance < 0.0 or distance < float(range_cm):
|
||||
return {}
|
||||
var r := _no("RESERVED", tvid)
|
||||
r["range_cm"] = range_cm
|
||||
r["charge"] = bool(table.is_charge_skill(skill_id))
|
||||
return r
|
||||
|
||||
func _no(code: String, target_vid := 0) -> Dictionary:
|
||||
return {"ok": false, "code": code, "target_vid": target_vid}
|
||||
|
||||
@@ -358,8 +379,8 @@ func use_skill(skill_id: int, is_active := false) -> Dictionary:
|
||||
return _resolve_target(skill_id)
|
||||
|
||||
# __UseSkill 里的目标分支(PythonPlayerSkill.cpp:520-650)。
|
||||
# 距离 / __ReserveUseSkill / 扇形·圆形多目标 SendAddFlyTargetingPacket 归 §3.4/§3.9,
|
||||
# 这里只判目标「类型」是否合法并回传解析到的 vid。
|
||||
# 判目标「类型」是否合法并回传解析到的 vid;射程外回 RESERVED(带 range_cm / charge),
|
||||
# 由调用方 __ReserveUseSkill。扇形·圆形多目标 SendAddFlyTargetingPacket 归 §3.4/§3.9。
|
||||
func _resolve_target(skill_id: int) -> Dictionary:
|
||||
var main_vid := _main_vid()
|
||||
var tvid := 0
|
||||
@@ -389,6 +410,11 @@ func _resolve_target(skill_id: int) -> Dictionary:
|
||||
return _ok(main_vid)
|
||||
# 非自己:结盟技打友方(不可攻击的 PC)合法;打敌人不合法
|
||||
if _entity_kind(te) == 0 and not _is_attackable(te):
|
||||
# PythonPlayerSkill.cpp:560:友方 PC 在射程外 -> __ReserveUseSkill(无蓄力预发)。
|
||||
var far := _out_of_range(skill_id, tvid)
|
||||
if not far.is_empty():
|
||||
far["charge"] = false
|
||||
return far
|
||||
return _ok(tvid)
|
||||
if table.can_use_for_me(skill_id):
|
||||
return _ok(main_vid) # 目标非法 -> 回落自身
|
||||
@@ -398,6 +424,10 @@ func _resolve_target(skill_id: int) -> Dictionary:
|
||||
if bool(te.get("in_safe", false)):
|
||||
return _no("CANNOT_ATTACK_ENEMY_IN_SAFE_AREA")
|
||||
if _is_attackable(te):
|
||||
# __ProcessEnemySkillTargetRange(PythonPlayerSkill.cpp:395)
|
||||
var far := _out_of_range(skill_id, tvid)
|
||||
if not far.is_empty():
|
||||
return far
|
||||
return _ok(tvid)
|
||||
return _no("CANNOT_ATTACK")
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ func _reset(g: RefCounted) -> void:
|
||||
g.slot_cd_end = 0.0
|
||||
g.cur_hp = -1
|
||||
g.cur_sp = -1
|
||||
g.bow_distance = 0
|
||||
g.target_distance = Callable()
|
||||
|
||||
func _run() -> void:
|
||||
var assets := AssetRoot.path()
|
||||
@@ -227,6 +229,33 @@ func _run() -> void:
|
||||
fc.target = {"vid": 2000}; fc.entities[2000] = {"kind": 1} # NPC
|
||||
_ck(g.use_skill(1).code == "CANNOT_ATTACK", "(d) 目标不可攻击 -> CANNOT_ATTACK")
|
||||
fc.entities[2000] = {"kind": 2}
|
||||
# __ProcessEnemySkillTargetRange:distance >= TargetRange(+弓距) -> 预约,不施法
|
||||
var range1: int = st.target_range(1)
|
||||
if range1 > 0:
|
||||
var dist := {"cm": float(range1)}
|
||||
g.target_distance = func(_vid: int) -> float: return float(dist.cm)
|
||||
var far := g.use_skill(1)
|
||||
_ck(not far.ok and far.code == "RESERVED" and far.target_vid == 2000 and far.range_cm == range1,
|
||||
"(d) 目标恰在射程边界 -> RESERVED(range=%d): %s" % [range1, far])
|
||||
_ck(PlayerSkill.is_silent_code("RESERVED"), "RESERVED 不弹 OnCannotUseSkill")
|
||||
dist.cm = range1 - 1.0
|
||||
_ck(g.use_skill(1).ok, "(d) 射程内 1cm -> ok")
|
||||
g.bow_distance = 2
|
||||
dist.cm = range1 + 150.0
|
||||
_ck(g.use_skill(1).ok, "(d) POINT_BOW_DISTANCE*100 计入射程")
|
||||
dist.cm = -1.0
|
||||
_ck(g.use_skill(1).ok, "(d) 距离未知 -> 不预约")
|
||||
_reset(g)
|
||||
# 蓄力技在射程外要求调用方先发 use_skill(slot, 0)
|
||||
for id in st._by_id.keys():
|
||||
if st.is_charge_skill(int(id)) and st.target_range(int(id)) > 0 and st.is_need_target(int(id)):
|
||||
fc.skills.append({"id": int(id), "level": 1, "master": 0})
|
||||
g.target_distance = func(_vid: int) -> float: return 100000.0
|
||||
var rc := g.use_skill(int(id))
|
||||
_ck(rc.code != "RESERVED" or rc.charge, "(d) 蓄力技 %d 射程外 -> RESERVED charge=true: %s" % [id, rc])
|
||||
fc.skills.pop_back()
|
||||
_reset(g)
|
||||
break
|
||||
# 结盟技 109 Cure:CAN_USE_FOR_ME|ONLY_FOR_ALLIANCE
|
||||
if st.has(109) and st.is_only_for_alliance(109) and st.can_use_for_me(109):
|
||||
_reset(g)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
extends SceneTree
|
||||
|
||||
## Prints the address AppFlow would connect to, resolved through the same
|
||||
## ServerInfo (built-in default + res://serverlist.txt). Used by
|
||||
## script/live_smoke_test.sh so the port precheck can never drift from the
|
||||
## client connect target. Never opens a connection or reads credentials.
|
||||
##
|
||||
## env: MT_SERVER_INDEX (default 0), MT_SERVER_CHANNEL (default: the first
|
||||
## channel listed for that server, matching the login screen default).
|
||||
## stdout: one line "SERVER_ADDRESS {json}". exit 2 when unresolvable.
|
||||
|
||||
const ServerInfoRes = preload("res://net/serverinfo.gd")
|
||||
|
||||
func _init() -> void:
|
||||
var serverinfo := ServerInfoRes.new()
|
||||
serverinfo.load_file("res://serverlist.txt")
|
||||
var index := int(OS.get_environment("MT_SERVER_INDEX")) if OS.has_environment("MT_SERVER_INDEX") else 0
|
||||
var server: Dictionary = serverinfo.server(index)
|
||||
if server.is_empty():
|
||||
printerr("SERVER_ADDRESS: server index %d is not in serverinfo" % index)
|
||||
quit(2)
|
||||
return
|
||||
var channels: Array = server.get("channels", [1])
|
||||
var channel := int(channels[0]) if not channels.is_empty() else 1
|
||||
if OS.has_environment("MT_SERVER_CHANNEL"):
|
||||
channel = int(OS.get_environment("MT_SERVER_CHANNEL"))
|
||||
if not channels.has(channel):
|
||||
printerr("SERVER_ADDRESS: channel %d is not listed for server %d" % [channel, index])
|
||||
quit(2)
|
||||
return
|
||||
var address: Dictionary = serverinfo.address(index, channel)
|
||||
address["server_index"] = index
|
||||
address["channel"] = channel
|
||||
print("SERVER_ADDRESS " + JSON.stringify(address))
|
||||
quit(0)
|
||||
@@ -1,15 +1,19 @@
|
||||
# Offline real-resource stress/viewport report; NOT 40250 visual parity or
|
||||
# server reconnect acceptance. Run with a renderer, --quit-after 3600.
|
||||
# suite=offline_synthetic: FakeClient + real assets. STB-01 live numbers only come
|
||||
# from the networked soak suite (script/playable_soak.sh); never merge the two.
|
||||
extends SceneTree
|
||||
const GameScene = preload("res://game_scene.gd")
|
||||
const Fixtures = preload("res://gamescene_test.gd")
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
const MobView = preload("res://ui/mob_view.gd")
|
||||
const Metrics = preload("res://testing/playable_metrics.gd")
|
||||
class ScenarioClient extends Fixtures.FakeClient:
|
||||
signal warp(pos: Vector3, same_server: bool)
|
||||
signal world_reset()
|
||||
var failures: Array[String] = []
|
||||
var output := ""
|
||||
var metrics: RefCounted
|
||||
func check(ok: bool, label: String) -> void:
|
||||
if not ok:
|
||||
failures.append(label)
|
||||
@@ -27,8 +31,13 @@ func run() -> void:
|
||||
DirAccess.make_dir_recursive_absolute(output)
|
||||
root.size = Vector2i(1280, 720)
|
||||
var report := {"scope": "offline real maps, CPU/GPU animated crowd and viewport layout; no server writes or 40250 parity",
|
||||
"suite": "offline_synthetic", "suite_note": "离线合成:FakeClient + 真实资源,无服务器;不能代替 soak 实网 suite",
|
||||
"engine": Engine.get_version_info(), "backend": DisplayServer.get_name(), "samples": [],
|
||||
"sample_frames": sample_frames, "model_builds": []}
|
||||
"sample_frames": sample_frames, "model_builds": [], "viewports": [],
|
||||
"percentile": "nearest-rank over window-frame intervals",
|
||||
"cold_start_note": "进程冷启动/系统缓存状态未知;测试不清空系统缓存"}
|
||||
metrics = Metrics.new()
|
||||
metrics.configure({"window_seconds": 3600})
|
||||
var client := ScenarioClient.new()
|
||||
root.add_child(client)
|
||||
var scene := GameScene.new()
|
||||
@@ -41,24 +50,32 @@ func run() -> void:
|
||||
for i in 30: await process_frame
|
||||
check(scene._model_built and scene._map_loaded(), "A1 main character and world loaded")
|
||||
var centre: Vector3 = scene.player.position
|
||||
report["probe_overhead"] = await _probe_overhead(sample_frames)
|
||||
metrics.reset_frame_clock()
|
||||
metrics.frame()
|
||||
for mode in ["0", "1"]:
|
||||
OS.set_environment("MTGODOT_GPUSKIN", mode)
|
||||
var crowd := Node3D.new()
|
||||
scene.add_child(crowd)
|
||||
for count in [1, 8, 16]:
|
||||
_tags("OutdoorA1", "loading", count, mode == "1")
|
||||
while crowd.get_child_count() < count:
|
||||
var index := crowd.get_child_count()
|
||||
var view := PlayerView.new()
|
||||
var build_start := Time.get_ticks_usec()
|
||||
check(view.build(AssetRoot.path(), index % 8), "crowd race %d build" % (index % 8))
|
||||
report.model_builds.append({"gpu_skin": mode == "1", "race": index % 8,
|
||||
"build_ms": (Time.get_ticks_usec() - build_start) / 1000.0,
|
||||
var build_ms := (Time.get_ticks_usec() - build_start) / 1000.0
|
||||
metrics.mark("model_build", {"race": index % 8, "ms": build_ms})
|
||||
report.model_builds.append({"gpu_skin": mode == "1", "race": index % 8, "build_ms": build_ms,
|
||||
"note": "synchronous build only; OS/shader caches may already be warm"})
|
||||
crowd.add_child(view)
|
||||
view.position = centre + Vector3((index % 4 - 1.5) * 2, 0, (index / 4 + 1) * 2)
|
||||
view.position.y = scene.world.sample_height(view.position.x, view.position.z)
|
||||
view.set_anim_state("run")
|
||||
for i in 12: await process_frame
|
||||
for i in 12:
|
||||
await process_frame
|
||||
metrics.frame()
|
||||
_tags("OutdoorA1", "gameplay", count, mode == "1")
|
||||
var times: Array[float] = []
|
||||
var switches: Array[float] = []
|
||||
var memory_start := Performance.get_monitor(Performance.MEMORY_STATIC)
|
||||
@@ -69,7 +86,9 @@ func run() -> void:
|
||||
var state: String = ["run", "attack", "wait"][(i / 30) % 3]
|
||||
for view in crowd.get_children(): view.set_anim_state(state)
|
||||
switches.append((Time.get_ticks_usec() - switch_start) / 1000.0)
|
||||
metrics.mark("motion_switch", {"state": state, "count": count, "ms": switches[-1]})
|
||||
await process_frame
|
||||
metrics.frame()
|
||||
var now := Time.get_ticks_usec()
|
||||
times.append((now - previous) / 1000.0)
|
||||
previous = now
|
||||
@@ -79,21 +98,32 @@ func run() -> void:
|
||||
"finite posed bounds after run/attack/wait blend")
|
||||
times.sort()
|
||||
switches.sort()
|
||||
var sorted := PackedFloat32Array(times)
|
||||
report.samples.append({"gpu_skin": mode == "1", "extra_players": count,
|
||||
"median_ms": times[sample_frames / 2], "p95_ms": times[int((sample_frames - 1) * 0.95)], "max_ms": times[-1],
|
||||
"median_ms": Metrics.percentile(sorted, 0.5), "p95_ms": Metrics.percentile(sorted, 0.95),
|
||||
"p99_ms": Metrics.percentile(sorted, 0.99), "max_ms": sorted[-1],
|
||||
"p99_note": "nearest-rank; below 100 samples p99 equals max",
|
||||
"over_50ms": times.filter(func(ms: float) -> bool: return ms > Metrics.LONG_MS).size(),
|
||||
"over_100ms": times.filter(func(ms: float) -> bool: return ms > Metrics.SEVERE_MS).size(),
|
||||
"motion_switches": switches.size(), "motion_switch_max_ms": switches[-1],
|
||||
"memory_static_start": memory_start, "memory_static_end": Performance.get_monitor(Performance.MEMORY_STATIC),
|
||||
"clip_cache": Metin2AnimPlayer.get_clip_cache_stats(),
|
||||
"note": "%d window-frame intervals with repeated motion transitions; not GPU timestamp, RSS or FPS guarantee" % sample_frames})
|
||||
print("SCENARIO_SAMPLE gpu=%s count=%d frames=%d max_ms=%.3f" % [mode, count, sample_frames, times[-1]])
|
||||
check(report.samples[-1].p99_ms <= report.samples[-1].max_ms and report.samples[-1].p99_ms > 0.0, "p99 recorded for gpu=%s count=%d" % [mode, count])
|
||||
print("SCENARIO_SAMPLE gpu=%s count=%d frames=%d p99_ms=%.3f max_ms=%.3f" % [mode, count, sample_frames, report.samples[-1].p99_ms, times[-1]])
|
||||
crowd.free()
|
||||
await process_frame
|
||||
metrics.frame()
|
||||
OS.set_environment("MTGODOT_GPUSKIN", "0")
|
||||
var mobs := Node3D.new()
|
||||
scene.add_child(mobs)
|
||||
_tags("OutdoorA1", "loading", 3, false)
|
||||
for race in [101, 110, 20001]:
|
||||
var view := MobView.new()
|
||||
if not view.build(AssetRoot.path(), null, race):
|
||||
var build_start := Time.get_ticks_usec()
|
||||
var built: bool = view.build(AssetRoot.path(), null, race)
|
||||
metrics.mark("model_build", {"race": race, "ms": (Time.get_ticks_usec() - build_start) / 1000.0})
|
||||
if not built:
|
||||
check(false, "NPC/monster build race=%d" % race)
|
||||
view.free()
|
||||
continue
|
||||
@@ -102,9 +132,17 @@ func run() -> void:
|
||||
view.position.y = scene.world.sample_height(view.position.x, view.position.z)
|
||||
view.set_anim_state("wait")
|
||||
check(not view.model.get_visual_aabb().size.is_zero_approx(), "NPC/monster real geometry")
|
||||
_tags("OutdoorA1", "gameplay", 3, false)
|
||||
for size in [Vector2i(1280, 720), Vector2i(1920, 1080), Vector2i(2560, 1440)]:
|
||||
root.size = size
|
||||
for i in 5: await process_frame
|
||||
metrics.mark("viewport_resize", {"size": "%dx%d" % [size.x, size.y]})
|
||||
for i in 5:
|
||||
await process_frame
|
||||
metrics.frame()
|
||||
# Logical viewport and physical window pixels are both recorded; Retina scale is not a size error.
|
||||
report.viewports.append({"logical": [size.x, size.y], "visible": [int(root.get_visible_rect().size.x), int(root.get_visible_rect().size.y)],
|
||||
"window_physical": [DisplayServer.window_get_size().x, DisplayServer.window_get_size().y],
|
||||
"screen_scale": DisplayServer.screen_get_scale()})
|
||||
if scene.quickbar and scene.quickbar._root:
|
||||
var rect: Rect2 = scene.quickbar._root.get_global_rect()
|
||||
check(Rect2(Vector2.ZERO, root.get_visible_rect().size).encloses(rect), "quickbar inside viewport %s" % size)
|
||||
@@ -114,11 +152,26 @@ func run() -> void:
|
||||
# Late server location update exercises the production map-correction path.
|
||||
client.ents[1000].pos_cm = Vector2(959109, 269267)
|
||||
client.ents[1000].pos = Vector3(9591.09, 0, -2692.67)
|
||||
_tags("OutdoorC1", "loading", 3, false)
|
||||
metrics.mark("map_switch", {"map": "OutdoorC1"})
|
||||
client.world_reset.emit()
|
||||
client.warp.emit(client.ents[1000].pos, false)
|
||||
for i in 60: await process_frame
|
||||
for i in 60:
|
||||
await process_frame
|
||||
metrics.frame()
|
||||
check(scene.map_path == "OutdoorC1/metin2_map_c1" and scene._map_loaded(), "A1 to C1 map correction")
|
||||
report["final_map"] = scene.map_path
|
||||
var summary: Dictionary = metrics.summary()
|
||||
report["frame_metrics"] = summary
|
||||
report["unattributed_long_frames"] = metrics.unattributed_long_frames().size()
|
||||
report["memory"] = Metrics.memory_snapshot()
|
||||
check(int(summary.frames) > sample_frames * 6 and not summary.segments.is_empty() and not summary.markers.is_empty(), "frame metrics recorded with markers")
|
||||
var phases := {}
|
||||
for row in summary.segments: phases[row.tags.phase] = true
|
||||
check(phases.has("loading") and phases.has("gameplay"), "loading and gameplay are separate segments: %s" % [phases.keys()])
|
||||
check(report.memory.gpu_memory_kib == null, "GPU memory is reported unavailable, never 0")
|
||||
print("SCENARIO_METRICS frames=%d long=%d unattributed=%d severe=%s" % [summary.frames, summary.long_frames.size(),
|
||||
report.unattributed_long_frames, summary.severe_by_phase])
|
||||
scene.free()
|
||||
client.free()
|
||||
await process_frame
|
||||
@@ -127,3 +180,31 @@ func run() -> void:
|
||||
FileAccess.open(output.path_join("report.json"), FileAccess.WRITE).store_string(JSON.stringify(report, "\t"))
|
||||
print("rendering_scenario_test: failures=%d report=%s" % [failures.size(), output])
|
||||
quit(1 if not failures.is_empty() else 0)
|
||||
func _tags(map: String, phase: String, actors: int, gpu_skin: bool) -> void:
|
||||
metrics.set_tags({"map": map, "phase": phase, "actor_count": Metrics.actor_bucket(actors), "gpu_skin": gpu_skin})
|
||||
## Short alternating baselines with the probe off and on (same scene, same frames),
|
||||
## plus the per-sample CPU cost; the difference is reported, never assumed zero.
|
||||
func _probe_overhead(frames: int) -> Dictionary:
|
||||
var rounds: Array = []
|
||||
var probe: RefCounted = Metrics.new()
|
||||
probe.configure({"window_seconds": 3600})
|
||||
probe.set_tags({"map": "OutdoorA1", "phase": "probe", "actor_count": "0", "gpu_skin": false})
|
||||
for i in 4:
|
||||
var enabled := i % 2 == 1
|
||||
var times := PackedFloat32Array()
|
||||
var previous := Time.get_ticks_usec()
|
||||
for f in frames:
|
||||
await process_frame
|
||||
if enabled:
|
||||
probe.frame()
|
||||
var now := Time.get_ticks_usec()
|
||||
times.append((now - previous) / 1000.0)
|
||||
previous = now
|
||||
times.sort()
|
||||
rounds.append({"probe": enabled, "frames": frames, "p50_ms": Metrics.percentile(times, 0.5),
|
||||
"p99_ms": Metrics.percentile(times, 0.99), "max_ms": times[-1]})
|
||||
var off: Array = rounds.filter(func(r: Dictionary) -> bool: return not r.probe)
|
||||
var on: Array = rounds.filter(func(r: Dictionary) -> bool: return r.probe)
|
||||
return {"rounds": rounds, "cpu": Metrics.measure_overhead(20000),
|
||||
"p50_delta_ms": (on[0].p50_ms + on[1].p50_ms - off[0].p50_ms - off[1].p50_ms) / 2.0,
|
||||
"note": "frame pacing noise can exceed the probe cost; per_sample_us is the direct CPU cost"}
|
||||
|
||||
@@ -8,6 +8,17 @@ const Quickbar = preload("res://ui/quickbar.gd")
|
||||
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||
const UiManager = preload("res://ui/ui_manager.gd")
|
||||
|
||||
# __ReserveUseSkill 的最小 NetPlay 面:射程上下文 + 预约记录。
|
||||
class FakeRangeNetPlay extends Node:
|
||||
var distance_cm := 100000.0
|
||||
var reservations := []
|
||||
func skill_context() -> Dictionary:
|
||||
return {"bow_distance": 2, "target_distance": func(_vid: int) -> float: return distance_cm}
|
||||
func reserve_use_skill(vid: int, slot: int, range_cm: float) -> void:
|
||||
reservations.append([vid, slot, int(range_cm)])
|
||||
func is_use_skill_reserved(slot: int) -> bool:
|
||||
return not reservations.is_empty() and int(reservations[-1][1]) == slot
|
||||
|
||||
class FakeClient extends Node:
|
||||
signal skills_changed()
|
||||
signal skill_cooldown_end(skill: int)
|
||||
@@ -327,6 +338,37 @@ func _run() -> void:
|
||||
"mobile skill action assigns to first free quickbar slot")
|
||||
sk.close()
|
||||
|
||||
# 射程外(__ProcessEnemySkillTargetRange):不 cast、预约;蓄力技仅首次预约前发 use_skill(id, 0)
|
||||
if st.has(5) and st.is_charge_skill(5):
|
||||
var np := FakeRangeNetPlay.new()
|
||||
get_root().add_child(np)
|
||||
fc.skills.append({"id": 5, "level": 1, "master": 0})
|
||||
var qb3: Node = Quickbar.new()
|
||||
qb3.net_play = np
|
||||
get_root().add_child(qb3)
|
||||
qb3.setup(fc, st, ui, func() -> Node: return pl)
|
||||
qb3.assign(5, "skill", 5, false)
|
||||
var uses0: int = fc.skill_uses.size()
|
||||
var casts0: int = fc.casts.size()
|
||||
var rejected := []
|
||||
qb3.skill_rejected.connect(func(sid: int, code: String): rejected.append([sid, code]))
|
||||
qb3.activate(5)
|
||||
var range5: int = st.target_range(5) + 200
|
||||
_ck(fc.casts.size() == casts0 and fc.skill_uses.slice(uses0) == [[5, 0]],
|
||||
"charge skill out of range -> only use_skill(5, 0), no cast: %s" % [fc.skill_uses.slice(uses0)])
|
||||
_ck(np.reservations == [[2000, 5, range5]], "out of range -> reserve_use_skill(target, slot, TargetRange+bow*100)")
|
||||
_ck(rejected.is_empty(), "reservation is silent (no OnCannotUseSkill)")
|
||||
qb3.activate(5)
|
||||
_ck(fc.skill_uses.size() == uses0 + 1 and np.reservations.size() == 2,
|
||||
"already reserved charge skill -> no second use_skill(5, 0)")
|
||||
_ck(not qb3.activate_reserved(5), "reserved fire still out of range keeps the new reservation")
|
||||
np.distance_cm = 10.0
|
||||
_ck(qb3.activate_reserved(5), "reserved fire in range consumes the reservation")
|
||||
_ck(fc.casts.size() == casts0 + 1 and fc.skill_uses.back() == [5, 2000], "in range -> use_skill(5, target) + one cast")
|
||||
fc.skills.pop_back()
|
||||
qb3.free()
|
||||
np.free()
|
||||
|
||||
# 服务器 GC_QUICKSLOT_* 恢复:type 2 技能 / type 1 道具 / type 3 表情 -> 填格
|
||||
var qb2: Node = Quickbar.new()
|
||||
get_root().add_child(qb2)
|
||||
|
||||
@@ -54,6 +54,27 @@ func run() -> void:
|
||||
target.free()
|
||||
game._on_local_motion_event_detailed({"type": 10, "effect": "full/path.mse", "sound": "", "pos": Vector3.ZERO})
|
||||
check(registry.seen.size() == 1, "despawned target not replaced by caster")
|
||||
game._on_skill_cast_started(1, 0)
|
||||
game._on_local_motion_event_detailed({"type": 10, "effect": "full/path.mse", "sound": "", "pos": Vector3.ZERO})
|
||||
check(registry.seen.size() == 1, "cast without a target spawns no target effect")
|
||||
# A following effect whose target dies/despawns mid-life is freed, never left at a stale position.
|
||||
var victim := Node3D.new()
|
||||
root.add_child(victim)
|
||||
var outside := Node3D.new()
|
||||
var outside_anchor := Anchor.new()
|
||||
check(not outside_anchor.configure(outside, Vector3.ZERO, true), "target outside the tree is rejected")
|
||||
outside_anchor.free()
|
||||
outside.free()
|
||||
var follower := Node3D.new()
|
||||
root.add_child(follower)
|
||||
var follow_anchor := Anchor.new()
|
||||
follower.add_child(follow_anchor)
|
||||
check(follow_anchor.configure(victim, Vector3.ZERO, true), "follower configured")
|
||||
var follower_ref: WeakRef = weakref(follower)
|
||||
victim.free()
|
||||
await process_frame
|
||||
await process_frame
|
||||
check(follower_ref.get_ref() == null, "follower freed after its target left the tree")
|
||||
game.free()
|
||||
effect.free()
|
||||
await process_frame
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
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)))
|
||||
@@ -12,6 +12,17 @@ const REQUIRED_KEYS := [
|
||||
"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": []}
|
||||
@@ -41,7 +52,7 @@ static func validate(config: Dictionary) -> Dictionary:
|
||||
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:
|
||||
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")
|
||||
@@ -52,21 +63,35 @@ static func validate(config: Dictionary) -> Dictionary:
|
||||
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:
|
||||
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")
|
||||
if String(config.get("map_key", "")).strip_edges().is_empty():
|
||||
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_number(point[0]) or not _is_number(point[1]):
|
||||
errors.append("waypoints_cm[%d] must be [x,y] numbers" % 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")
|
||||
@@ -77,10 +102,347 @@ static func validate(config: Dictionary) -> Dictionary:
|
||||
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)
|
||||
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)
|
||||
@@ -89,29 +451,28 @@ static func validate(config: Dictionary) -> Dictionary:
|
||||
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():
|
||||
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)
|
||||
if int(item.get("skill_id", -1)) <= 0 or int(item.get("skill_id", -1)) >= 255:
|
||||
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 not (item.get("required_evidence", null) is Array) or item.required_evidence.is_empty():
|
||||
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)
|
||||
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
|
||||
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])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,292 @@
|
||||
class_name PlayableMetrics
|
||||
extends RefCounted
|
||||
|
||||
## STB-01 §9.1 帧间隔与长帧指标(纯数据,时钟可注入)。
|
||||
##
|
||||
## - frame() 每帧调用一次,记录与上一帧的 monotonic 间隔;首个调用只起表。
|
||||
## - 所有存储都有固定上限:当前窗口的样本在 capacity 大小的环形缓冲里,
|
||||
## 已关闭窗口 / 长帧事件 / 标记 / 分段表各有上限,超出只计丢弃数,
|
||||
## 测试自身不会因为长跑而无限增长内存。
|
||||
## - 窗口按注入时钟(墙钟期限)关闭,或在分段标签变化时关闭;不按帧数。
|
||||
## 窗口样本超过 capacity 时分位数只来自最近 capacity 个样本并标 truncated,
|
||||
## max 与 >50ms / >100ms 计数始终精确。
|
||||
## - 分段标签只取 map / phase / actor_count / gpu_skin;actor_count 请用 actor_bucket() 分桶。
|
||||
## - 默认不逐帧打印,只保留长帧事件(>50ms),附带本帧和上一帧内的标记用于归因。
|
||||
## - GPU 内存拿不到时写 null + "unavailable",从不写 0。
|
||||
|
||||
const TAG_KEYS := ["map", "phase", "actor_count", "gpu_skin"]
|
||||
const LONG_MS := 50.0
|
||||
const SEVERE_MS := 100.0
|
||||
## 9.3 标记:同步模型 build、动作 reload、GR2 解析、材质初次使用、实体批量生成、缓存命中等。
|
||||
const MARKER_DETAIL_KEYS := ["race", "count", "ms", "state", "map", "hits", "misses", "size", "round", "kind"]
|
||||
const MAX_PENDING_MARKERS := 16
|
||||
const MAX_MARKER_KIND_LENGTH := 32
|
||||
|
||||
var capacity := 4096
|
||||
var window_us := 60_000_000
|
||||
var max_windows := 512
|
||||
var max_long_frames := 512
|
||||
var max_markers := 1024
|
||||
var max_segments := 64
|
||||
var print_frames := false
|
||||
var clock_us: Callable = func() -> int: return Time.get_ticks_usec()
|
||||
|
||||
var _ring := PackedFloat32Array()
|
||||
var _ring_head := 0
|
||||
var _ring_len := 0
|
||||
var _window_count := 0
|
||||
var _window_over_long := 0
|
||||
var _window_over_severe := 0
|
||||
var _window_max := 0.0
|
||||
var _window_start_us := -1
|
||||
var _origin_us := -1
|
||||
var _last_frame_us := -1
|
||||
var _tags := {}
|
||||
var _tag_key := ""
|
||||
var _frames := 0
|
||||
var _windows: Array = []
|
||||
var _windows_dropped := 0
|
||||
var _segments := {}
|
||||
var _unsegmented_frames := 0
|
||||
var _severe_by_phase := {}
|
||||
var _long_unattributed_by_phase := {}
|
||||
var _long_frames: Array = []
|
||||
var _long_frames_dropped := 0
|
||||
var _markers: Array = []
|
||||
var _markers_dropped := 0
|
||||
var _pending_markers: Array[String] = []
|
||||
var _previous_markers: Array[String] = []
|
||||
|
||||
func _init() -> void:
|
||||
_ring.resize(capacity)
|
||||
|
||||
func configure(options: Dictionary) -> void:
|
||||
capacity = maxi(1, int(options.get("capacity", capacity)))
|
||||
window_us = maxi(1, int(float(options.get("window_seconds", window_us / 1_000_000.0)) * 1_000_000.0))
|
||||
max_windows = maxi(1, int(options.get("max_windows", max_windows)))
|
||||
max_long_frames = maxi(1, int(options.get("max_long_frames", max_long_frames)))
|
||||
max_markers = maxi(1, int(options.get("max_markers", max_markers)))
|
||||
max_segments = maxi(1, int(options.get("max_segments", max_segments)))
|
||||
print_frames = bool(options.get("print_frames", false))
|
||||
_ring.resize(capacity)
|
||||
_ring_head = 0
|
||||
_ring_len = 0
|
||||
|
||||
## Switches the segment. A different tag set closes the current window first.
|
||||
func set_tags(tags: Dictionary) -> void:
|
||||
var normalized := {}
|
||||
for key in TAG_KEYS:
|
||||
if tags.has(key):
|
||||
normalized[key] = String(str(tags[key])).left(80)
|
||||
var key := JSON.stringify(normalized)
|
||||
if key == _tag_key:
|
||||
return
|
||||
if _window_count > 0:
|
||||
close_window()
|
||||
_tags = normalized
|
||||
_tag_key = key
|
||||
|
||||
func tags() -> Dictionary:
|
||||
return _tags.duplicate()
|
||||
|
||||
## Call once per rendered frame. The first call only arms the clock.
|
||||
func frame(now_us := -1) -> void:
|
||||
var now := now_us if now_us >= 0 else int(clock_us.call())
|
||||
if _last_frame_us >= 0:
|
||||
add_interval_us(now - _last_frame_us, now)
|
||||
else:
|
||||
_arm(now)
|
||||
_last_frame_us = now
|
||||
|
||||
## Forget the previous frame time (e.g. before a deliberately unmeasured pause).
|
||||
func reset_frame_clock() -> void:
|
||||
_last_frame_us = -1
|
||||
|
||||
func add_interval_us(interval_us: int, now_us: int) -> void:
|
||||
if _origin_us < 0:
|
||||
_arm(now_us - interval_us)
|
||||
if _window_start_us < 0:
|
||||
_window_start_us = now_us - interval_us
|
||||
var ms := float(interval_us) / 1000.0
|
||||
_ring[_ring_head] = ms
|
||||
_ring_head = (_ring_head + 1) % capacity
|
||||
_ring_len = mini(_ring_len + 1, capacity)
|
||||
_window_count += 1
|
||||
_frames += 1
|
||||
_window_max = maxf(_window_max, ms)
|
||||
var segment := _segment()
|
||||
if not segment.is_empty():
|
||||
segment.frames = int(segment.frames) + 1
|
||||
segment.max_ms = maxf(float(segment.max_ms), ms)
|
||||
var attribution: Array[String] = []
|
||||
for kind in _previous_markers + _pending_markers:
|
||||
if not (kind in attribution):
|
||||
attribution.append(kind)
|
||||
_previous_markers = _pending_markers
|
||||
_pending_markers = []
|
||||
if ms > LONG_MS:
|
||||
_window_over_long += 1
|
||||
if not segment.is_empty():
|
||||
segment.over_50ms = int(segment.over_50ms) + 1
|
||||
_push_bounded(_long_frames, {"t_ms": _t_ms(now_us), "ms": ms, "tags": _tags.duplicate(),
|
||||
"markers": attribution}, max_long_frames, "_long_frames_dropped")
|
||||
if attribution.is_empty():
|
||||
var long_phase := String(_tags.get("phase", ""))
|
||||
if _long_unattributed_by_phase.has(long_phase) or _long_unattributed_by_phase.size() < max_segments:
|
||||
_long_unattributed_by_phase[long_phase] = int(_long_unattributed_by_phase.get(long_phase, 0)) + 1
|
||||
if ms > SEVERE_MS:
|
||||
_window_over_severe += 1
|
||||
if not segment.is_empty():
|
||||
segment.over_100ms = int(segment.over_100ms) + 1
|
||||
var phase := String(_tags.get("phase", ""))
|
||||
if _severe_by_phase.has(phase) or _severe_by_phase.size() < max_segments:
|
||||
_severe_by_phase[phase] = int(_severe_by_phase.get(phase, 0)) + 1
|
||||
if print_frames:
|
||||
print("FRAME ms=%.3f tags=%s" % [ms, _tag_key])
|
||||
if now_us - _window_start_us >= window_us:
|
||||
close_window(now_us)
|
||||
|
||||
## Attribution marker (model build, motion reload, GR2 parse, material first use, entity batch, ...).
|
||||
func mark(kind: String, detail := {}) -> void:
|
||||
var name := kind.left(MAX_MARKER_KIND_LENGTH)
|
||||
var clean := {}
|
||||
for key in detail:
|
||||
if String(key) in MARKER_DETAIL_KEYS:
|
||||
var value: Variant = detail[key]
|
||||
clean[String(key)] = value if (value is int or value is float or value is bool) else String(str(value)).left(80)
|
||||
var now := int(clock_us.call())
|
||||
_push_bounded(_markers, {"t_ms": _t_ms(now), "kind": name, "tags": _tags.duplicate(), "detail": clean},
|
||||
max_markers, "_markers_dropped")
|
||||
if _pending_markers.size() < MAX_PENDING_MARKERS and not (name in _pending_markers):
|
||||
_pending_markers.append(name)
|
||||
|
||||
func close_window(now_us := -1) -> void:
|
||||
if _window_count == 0:
|
||||
return
|
||||
var end := now_us if now_us >= 0 else maxi(_last_frame_us, _window_start_us)
|
||||
var sorted := PackedFloat32Array()
|
||||
sorted.resize(_ring_len)
|
||||
for i in _ring_len:
|
||||
sorted[i] = _ring[(_ring_head - _ring_len + i + capacity) % capacity]
|
||||
sorted.sort()
|
||||
var window := {"tags": _tags.duplicate(), "start_ms": _t_ms(_window_start_us), "end_ms": _t_ms(end),
|
||||
"count": _window_count, "sampled": _ring_len, "truncated": _window_count > _ring_len,
|
||||
"p50_ms": percentile(sorted, 0.50), "p95_ms": percentile(sorted, 0.95), "p99_ms": percentile(sorted, 0.99),
|
||||
"max_ms": _window_max, "over_50ms": _window_over_long, "over_100ms": _window_over_severe}
|
||||
_push_bounded(_windows, window, max_windows, "_windows_dropped")
|
||||
var segment: Dictionary = _segments.get(_tag_key, {})
|
||||
if not segment.is_empty():
|
||||
segment.windows = int(segment.windows) + 1
|
||||
segment.worst_p99_ms = maxf(float(segment.worst_p99_ms), float(window.p99_ms))
|
||||
_ring_head = 0
|
||||
_ring_len = 0
|
||||
_window_count = 0
|
||||
_window_over_long = 0
|
||||
_window_over_severe = 0
|
||||
_window_max = 0.0
|
||||
_window_start_us = end
|
||||
|
||||
func summary() -> Dictionary:
|
||||
close_window()
|
||||
var segments: Array = []
|
||||
for key in _segments:
|
||||
segments.append(_segments[key].duplicate(true))
|
||||
return {"schema_version": 1, "capacity": capacity, "window_seconds": window_us / 1_000_000.0,
|
||||
"thresholds_ms": [LONG_MS, SEVERE_MS], "print_frames": print_frames, "frames": _frames,
|
||||
"segments": segments, "unsegmented_frames": _unsegmented_frames,
|
||||
"windows": _windows.duplicate(true), "windows_dropped": _windows_dropped,
|
||||
"long_frames": _long_frames.duplicate(true), "long_frames_dropped": _long_frames_dropped,
|
||||
"markers": _markers.duplicate(true), "markers_dropped": _markers_dropped,
|
||||
"severe_by_phase": _severe_by_phase.duplicate(),
|
||||
"long_unattributed_by_phase": _long_unattributed_by_phase.duplicate()}
|
||||
|
||||
func stored_sample_count() -> int:
|
||||
return _ring_len
|
||||
|
||||
## Cumulative >100ms counts per phase tag; diff two snapshots to judge one repetition.
|
||||
func severe_by_phase() -> Dictionary:
|
||||
return _severe_by_phase.duplicate()
|
||||
|
||||
## Exact per-phase count of >50ms frames without a marker (not limited by max_long_frames).
|
||||
func long_unattributed_by_phase() -> Dictionary:
|
||||
return _long_unattributed_by_phase.duplicate()
|
||||
|
||||
## Retained long frames that have no marker in their frame or the one before. They
|
||||
## need manual attribution (§9.3); the automated gate never explains them away.
|
||||
func unattributed_long_frames() -> Array:
|
||||
return _long_frames.filter(func(item: Dictionary) -> bool: return item.markers.is_empty())
|
||||
|
||||
## Nearest-rank percentile of an ascending array.
|
||||
static func percentile(sorted: PackedFloat32Array, q: float) -> float:
|
||||
if sorted.is_empty():
|
||||
return 0.0
|
||||
var index := clampi(int(ceil(q * sorted.size())) - 1, 0, sorted.size() - 1)
|
||||
return sorted[index]
|
||||
|
||||
static func actor_bucket(count: int) -> String:
|
||||
if count <= 0:
|
||||
return "0"
|
||||
for limit in [8, 16, 32, 64]:
|
||||
if count <= limit:
|
||||
return "%d-%d" % [limit / 2 + 1 if limit > 8 else 1, limit]
|
||||
return "65+"
|
||||
|
||||
## §9.3: an interaction repeated at least 3 times where 2 of any 3 consecutive
|
||||
## repetitions contained a >100ms frame. history: interaction -> Array[bool].
|
||||
static func repeated_severe(history: Dictionary) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
for key in history:
|
||||
var runs: Array = history[key]
|
||||
for i in range(0, runs.size() - 2):
|
||||
var hits := int(bool(runs[i])) + int(bool(runs[i + 1])) + int(bool(runs[i + 2]))
|
||||
if hits >= 2:
|
||||
out.append(String(key))
|
||||
break
|
||||
return out
|
||||
|
||||
## MEMORY_STATIC is Godot's own allocator counter, not process RSS (the runner
|
||||
## samples RSS externally). GPU memory has no reliable source here.
|
||||
static func memory_snapshot() -> Dictionary:
|
||||
var video := int(Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED))
|
||||
return {"memory_static_kib": int(Performance.get_monitor(Performance.MEMORY_STATIC)) / 1024,
|
||||
"memory_static_max_kib": int(Performance.get_monitor(Performance.MEMORY_STATIC_MAX)) / 1024,
|
||||
"gpu_memory_kib": null, "gpu_memory_status": "unavailable",
|
||||
"godot_video_mem_kib": video / 1024 if video > 0 else null,
|
||||
"godot_video_mem_note": "Godot RenderingDevice allocation counter; not system GPU memory"}
|
||||
|
||||
## Cost of one sample on this machine; compare with the frame budget in reports.
|
||||
static func measure_overhead(iterations := 20000) -> Dictionary:
|
||||
var probe := new()
|
||||
probe.configure({"capacity": 4096, "window_seconds": 3600})
|
||||
probe.set_tags({"map": "overhead", "phase": "probe", "actor_count": "1-8", "gpu_skin": false})
|
||||
var started := Time.get_ticks_usec()
|
||||
for i in iterations:
|
||||
probe.add_interval_us(16_000 + (i % 7) * 1000, (i + 1) * 16_000)
|
||||
var elapsed := Time.get_ticks_usec() - started
|
||||
return {"iterations": iterations, "total_us": elapsed, "per_sample_us": float(elapsed) / maxf(1.0, iterations)}
|
||||
|
||||
func _arm(now_us: int) -> void:
|
||||
if _origin_us < 0:
|
||||
_origin_us = now_us
|
||||
if _window_start_us < 0:
|
||||
_window_start_us = now_us
|
||||
|
||||
func _t_ms(us: int) -> float:
|
||||
return float(us - maxi(_origin_us, 0)) / 1000.0
|
||||
|
||||
func _segment() -> Dictionary:
|
||||
if _segments.has(_tag_key):
|
||||
return _segments[_tag_key]
|
||||
if _segments.size() >= max_segments:
|
||||
_unsegmented_frames += 1
|
||||
return {}
|
||||
var row := {"tags": _tags.duplicate(), "frames": 0, "windows": 0, "max_ms": 0.0, "worst_p99_ms": 0.0,
|
||||
"over_50ms": 0, "over_100ms": 0}
|
||||
_segments[_tag_key] = row
|
||||
return row
|
||||
|
||||
func _push_bounded(list: Array, item: Dictionary, limit: int, dropped_field: String) -> void:
|
||||
if list.size() >= limit:
|
||||
list.pop_front()
|
||||
set(dropped_field, int(get(dropped_field)) + 1)
|
||||
list.append(item)
|
||||
@@ -3,113 +3,236 @@ extends Node
|
||||
|
||||
## 只读网络/场景观察器。所有服务端证据必须从 M2Client 信号或状态快照进入,
|
||||
## 本节点不调用移动、攻击、拾取、施法或其它会改变服务器状态的方法。
|
||||
##
|
||||
## 每条观察都带 source:
|
||||
## "server" —— M2Client 接收端派发(EntityStore 变化 / 收包回调)
|
||||
## "local" —— 本地 UI/表现层信号(施法开始、特效节点),不能单独作为服务端证据
|
||||
##
|
||||
## connection_epoch:首个连接为 1;收到 disconnected 或在同一 epoch 内再次
|
||||
## entered_game(快速重连 / 换服不一定发 disconnected)时加一。VID 只在其 epoch
|
||||
## 内有意义,状态机必须按新 epoch 重新绑定主角与目标。
|
||||
|
||||
signal observed(kind: String, payload: Dictionary)
|
||||
|
||||
var client: Node
|
||||
var flow: Node
|
||||
var connection_epoch := 0
|
||||
var _connections: Array[Dictionary] = []
|
||||
const SERVER_SIGNALS := {
|
||||
"entered_game": "_on_entered_game",
|
||||
"disconnected": "_on_disconnected",
|
||||
"login_failed": "_on_login_failed",
|
||||
"char_list": "_on_char_list",
|
||||
"entity_main_set": "_on_entity_main_set",
|
||||
"entity_spawned": "_on_entity_spawned",
|
||||
"entity_despawned": "_on_entity_despawned",
|
||||
"entity_moved": "_on_entity_moved",
|
||||
"entity_dead": "_on_entity_dead",
|
||||
"damage": "_on_damage",
|
||||
"motion": "_on_motion",
|
||||
"vitals_changed": "_on_vitals",
|
||||
"points_changed": "_on_points_changed",
|
||||
"target_info": "_on_target_info",
|
||||
"ground_item_added": "_on_ground_item_added",
|
||||
"ground_item_removed": "_on_ground_item_removed",
|
||||
"item_picked_up": "_on_item_picked_up",
|
||||
"inventory_changed": "_on_inventory_changed",
|
||||
"affect_added": "_on_affect_added",
|
||||
"effect_cue": "_on_effect_cue",
|
||||
"fly_cue": "_on_fly_cue",
|
||||
"fly_targeting": "_on_fly_targeting",
|
||||
}
|
||||
|
||||
func setup(app_flow: Node, m2client: Node) -> void:
|
||||
var client: Object
|
||||
var flow: Object
|
||||
var connection_epoch := 1
|
||||
var _entered_in_epoch := false
|
||||
var _connections: Array[Dictionary] = []
|
||||
var _last_sp := -1
|
||||
|
||||
func setup(app_flow: Object, m2client: Object) -> void:
|
||||
flow = app_flow
|
||||
client = m2client
|
||||
if client == null:
|
||||
return
|
||||
_bind("entered_game", Callable(self, "_on_entered_game"))
|
||||
_bind("disconnected", Callable(self, "_on_disconnected"))
|
||||
_bind("char_list", Callable(self, "_on_char_list"))
|
||||
_bind("entity_spawned", Callable(self, "_on_entity_spawned"))
|
||||
_bind("entity_despawned", Callable(self, "_on_entity_despawned"))
|
||||
_bind("entity_moved", Callable(self, "_on_entity_moved"))
|
||||
_bind("entity_dead", Callable(self, "_on_entity_dead"))
|
||||
_bind("damage", Callable(self, "_on_damage"))
|
||||
_bind("vitals_changed", Callable(self, "_on_vitals"))
|
||||
_bind("ground_item_added", Callable(self, "_on_ground_item_added"))
|
||||
_bind("ground_item_removed", Callable(self, "_on_ground_item_removed"))
|
||||
_bind("item_picked_up", Callable(self, "_on_item_picked_up"))
|
||||
_bind("inventory_changed", Callable(self, "_on_inventory_changed"))
|
||||
_bind("effect_cue", Callable(self, "_on_effect_cue"))
|
||||
_bind("fly_cue", Callable(self, "_on_fly_cue"))
|
||||
_bind("fly_targeting", Callable(self, "_on_fly_targeting"))
|
||||
for signal_name in SERVER_SIGNALS:
|
||||
_bind(client, signal_name, Callable(self, SERVER_SIGNALS[signal_name]))
|
||||
|
||||
## Subscribe to a local presentation object (quickbar / skill fx). Its events
|
||||
## are tagged source=local and never satisfy a server-evidence requirement.
|
||||
func watch_local(source: Object, signal_name: String, kind: String) -> void:
|
||||
if source == null or not source.has_signal(signal_name):
|
||||
return
|
||||
var callback := func(a: Variant = null, b: Variant = null, c: Variant = null,
|
||||
d: Variant = null, e: Variant = null) -> void:
|
||||
_emit_local(kind, [a, b, c, d, e])
|
||||
_bind(source, signal_name, callback)
|
||||
|
||||
func snapshot() -> Dictionary:
|
||||
var in_game: bool = client != null and client.has_method("is_in_game") and client.is_in_game()
|
||||
var main_vid := int(client.get_main_vid()) if client != null and client.has_method("get_main_vid") else 0
|
||||
var entity_count: int = client.get_entities().size() if client != null and client.has_method("get_entities") else 0
|
||||
var ground_count: int = client.get_ground_items().size() if client != null and client.has_method("get_ground_items") else 0
|
||||
var state := int(flow.state()) if flow != null and flow.has_method("state") else -1
|
||||
var alive := client != null and is_instance_valid(client)
|
||||
var in_game: bool = alive and client.has_method("is_in_game") and client.is_in_game()
|
||||
var main_vid := int(client.get_main_vid()) if alive and client.has_method("get_main_vid") else 0
|
||||
var entity_count: int = client.get_entities().size() if alive and client.has_method("get_entities") else 0
|
||||
var ground_count: int = client.get_ground_items().size() if alive and client.has_method("get_ground_items") else 0
|
||||
var flow_alive := flow != null and is_instance_valid(flow)
|
||||
var state := int(flow.state()) if flow_alive and flow.has_method("state") else -1
|
||||
return {"in_game": in_game, "main_vid": main_vid, "entity_count": entity_count,
|
||||
"ground_item_count": ground_count, "app_state": state, "connection_epoch": connection_epoch}
|
||||
"ground_item_count": ground_count, "app_state": state}
|
||||
|
||||
func disconnect_all() -> void:
|
||||
for entry in _connections:
|
||||
var source: Object = entry.source
|
||||
var signal_name: String = entry.signal
|
||||
var callback: Callable = entry.callback
|
||||
if client != null and client.has_signal(signal_name) and client.is_connected(signal_name, callback):
|
||||
client.disconnect(signal_name, callback)
|
||||
if source != null and is_instance_valid(source) and source.has_signal(signal_name) \
|
||||
and source.is_connected(signal_name, callback):
|
||||
source.disconnect(signal_name, callback)
|
||||
_connections.clear()
|
||||
|
||||
func bound_count() -> int:
|
||||
return _connections.size()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
disconnect_all()
|
||||
|
||||
func _bind(signal_name: String, callback: Callable) -> void:
|
||||
if client.has_signal(signal_name) and not client.is_connected(signal_name, callback):
|
||||
client.connect(signal_name, callback)
|
||||
_connections.append({"signal": signal_name, "callback": callback})
|
||||
func _bind(source: Object, signal_name: String, callback: Callable) -> void:
|
||||
if source.has_signal(signal_name) and not source.is_connected(signal_name, callback):
|
||||
source.connect(signal_name, callback)
|
||||
_connections.append({"source": source, "signal": signal_name, "callback": callback})
|
||||
|
||||
func _emit(kind: String, payload := {}) -> void:
|
||||
func _emit(kind: String, payload := {}, actor_vid := 0, target_vid := 0) -> void:
|
||||
var data: Dictionary = payload.duplicate(true)
|
||||
data["source"] = "server"
|
||||
data["connection_epoch"] = connection_epoch
|
||||
data["actor_vid"] = actor_vid
|
||||
data["target_vid"] = target_vid
|
||||
data["received_us"] = Time.get_ticks_usec()
|
||||
observed.emit(kind, data)
|
||||
|
||||
func _emit_local(kind: String, args: Array) -> void:
|
||||
var data := {"source": "local", "connection_epoch": connection_epoch,
|
||||
"actor_vid": 0, "target_vid": 0, "received_us": Time.get_ticks_usec()}
|
||||
match kind:
|
||||
"cast_started":
|
||||
data["skill_id"] = int(args[0]) if args[0] != null else 0
|
||||
data["target_vid"] = int(args[1]) if args[1] != null else 0
|
||||
"skill_rejected":
|
||||
data["skill_id"] = int(args[0]) if args[0] != null else 0
|
||||
data["code"] = String(args[1]) if args[1] != null else ""
|
||||
"fx_spawned", "fx_finished":
|
||||
# EffectRegistry lifecycle: (effect, fx_id, lifetime_ms[, elapsed_ms, reason]).
|
||||
data["effect"] = String(args[0]).get_file().get_basename().left(80) if args[0] != null else ""
|
||||
data["fx_id"] = int(args[1]) if args[1] != null else 0
|
||||
data["lifetime_ms"] = int(args[2]) if args[2] != null else -1
|
||||
if kind == "fx_finished":
|
||||
data["elapsed_ms"] = int(args[3]) if args[3] != null else -1
|
||||
data["reason"] = String(args[4]) if args[4] != null else ""
|
||||
_:
|
||||
if args[0] is int:
|
||||
data["skill_id"] = args[0]
|
||||
observed.emit(kind, data)
|
||||
|
||||
func _on_entered_game() -> void:
|
||||
connection_epoch += 1
|
||||
if _entered_in_epoch:
|
||||
connection_epoch += 1
|
||||
_entered_in_epoch = true
|
||||
_last_sp = -1
|
||||
_emit("entered_game")
|
||||
|
||||
func _on_disconnected(reason: String) -> void:
|
||||
_emit("disconnected", {"reason": reason})
|
||||
_emit("disconnected", {"reason": reason.left(120)})
|
||||
connection_epoch += 1
|
||||
_entered_in_epoch = false
|
||||
_last_sp = -1
|
||||
|
||||
func _on_login_failed(reason: String) -> void:
|
||||
_emit("login_failed", {"reason": reason.left(120)})
|
||||
|
||||
func _on_char_list(chars: Array) -> void:
|
||||
_emit("char_list", {"count": chars.size()})
|
||||
var slots: Array = []
|
||||
for character in chars:
|
||||
if character is Dictionary:
|
||||
slots.append(int(character.get("index", -1)))
|
||||
_emit("char_list", {"count": chars.size(), "slots": slots})
|
||||
|
||||
func _on_entity_main_set(vid: int) -> void:
|
||||
_emit("entity_main_set", {}, vid)
|
||||
|
||||
func _on_entity_spawned(entity: Dictionary) -> void:
|
||||
_emit("entity_spawned", {"vid": int(entity.get("vid", 0)), "race": int(entity.get("race", 0)), "kind": int(entity.get("kind", -1))})
|
||||
_emit("entity_spawned", {"race": int(entity.get("race", 0)), "ch_type": int(entity.get("ch_type", -1)),
|
||||
"dead": bool(entity.get("dead", false)), "pos_cm": entity.get("pos_cm", Vector3.ZERO)},
|
||||
int(entity.get("vid", 0)))
|
||||
|
||||
func _on_entity_despawned(vid: int) -> void:
|
||||
_emit("entity_despawned", {"vid": vid})
|
||||
_emit("entity_despawned", {}, vid)
|
||||
|
||||
func _on_entity_moved(vid: int) -> void:
|
||||
_emit("entity_moved", {"vid": vid})
|
||||
var pos: Variant = Vector3.ZERO
|
||||
if client != null and client.has_method("get_entity"):
|
||||
pos = client.get_entity(vid).get("pos_cm", Vector3.ZERO)
|
||||
_emit("entity_moved", {"pos_cm": pos}, vid)
|
||||
|
||||
func _on_entity_dead(vid: int) -> void:
|
||||
_emit("entity_dead", {"vid": vid})
|
||||
_emit("entity_dead", {}, 0, vid)
|
||||
|
||||
func _on_damage(vid: int, amount: int, flag: int) -> void:
|
||||
_emit("damage", {"vid": vid, "amount": amount, "flag": flag})
|
||||
_emit("damage", {"amount": amount, "flag": flag}, 0, vid)
|
||||
|
||||
func _on_motion(vid: int, victim_vid: int, motion: int) -> void:
|
||||
_emit("server_motion", {"motion": motion}, vid, victim_vid)
|
||||
|
||||
func _on_vitals(vid: int) -> void:
|
||||
_emit("vitals_changed", {"vid": vid})
|
||||
var entity: Dictionary = client.get_entity(vid) if client != null and client.has_method("get_entity") else {}
|
||||
_emit("vitals_changed", {"hp": int(entity.get("hp", -1)), "max_hp": int(entity.get("max_hp", -1)),
|
||||
"dead": bool(entity.get("dead", false))}, vid)
|
||||
|
||||
func _on_points_changed(points: Dictionary) -> void:
|
||||
# points_changed carries the main character's point table. Only a change in
|
||||
# SP is interesting as skill-resource evidence.
|
||||
var sp := int(points.get("sp", points.get(8, -1))) if points.has("sp") or points.has(8) else -1
|
||||
if sp >= 0 and _last_sp >= 0 and sp != _last_sp:
|
||||
_emit("sp_changed", {"before": _last_sp, "after": sp, "delta": sp - _last_sp})
|
||||
if sp >= 0:
|
||||
_last_sp = sp
|
||||
|
||||
func _on_target_info(vid: int, hp_percent: int) -> void:
|
||||
_emit("target_info", {"hp_percent": hp_percent}, 0, vid)
|
||||
|
||||
func _on_ground_item_added(item: Dictionary) -> void:
|
||||
_emit("ground_item_added", {"vid": int(item.get("vid", 0)), "vnum": int(item.get("vnum", 0)), "owner": String(item.get("owner", ""))})
|
||||
_emit("ground_item_added", {"iid": int(item.get("vid", 0)), "vnum": int(item.get("vnum", 0)),
|
||||
"owner_match": _owner_matches(String(item.get("owner", ""))), "pos_cm": _ground_pos_cm(item)})
|
||||
|
||||
func _on_ground_item_removed(vid: int) -> void:
|
||||
_emit("ground_item_removed", {"vid": vid})
|
||||
_emit("ground_item_removed", {"iid": vid})
|
||||
|
||||
func _on_item_picked_up(vnum: int, count: int, source: String) -> void:
|
||||
_emit("item_picked_up", {"vnum": vnum, "count": count, "source": source})
|
||||
# `source` may be a player name; keep only whether it was attributed at all.
|
||||
_emit("item_picked_up", {"vnum": vnum, "count": count, "selected": not source.is_empty()})
|
||||
|
||||
func _on_inventory_changed(window: int, cell: int) -> void:
|
||||
_emit("inventory_changed", {"window": window, "cell": cell})
|
||||
|
||||
func _on_affect_added(affect: Dictionary) -> void:
|
||||
_emit("affect_added", {"type": int(affect.get("type", 0))})
|
||||
|
||||
func _on_effect_cue(vid: int, name: String, special: int) -> void:
|
||||
_emit("effect_cue", {"vid": vid, "name": name, "special": special})
|
||||
_emit("effect_cue", {"effect": name.get_file(), "special": special}, vid)
|
||||
|
||||
func _on_fly_cue(kind: int, start_vid: int, end_vid: int) -> void:
|
||||
_emit("fly_cue", {"type": kind, "start_vid": start_vid, "end_vid": end_vid})
|
||||
_emit("fly_cue", {"type": kind}, start_vid, end_vid)
|
||||
|
||||
func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool) -> void:
|
||||
_emit("fly_targeting", {"shooter_vid": shooter_vid, "target_vid": target_vid,
|
||||
"target_cm": [target_cm.x, target_cm.y], "append": append})
|
||||
func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, _append: bool) -> void:
|
||||
_emit("fly_targeting", {"pos_cm": target_cm}, shooter_vid, target_vid)
|
||||
|
||||
## Ownership is evaluated in-process so the owner's name never reaches a report:
|
||||
## an empty owner is free-for-all, otherwise it must equal the main character.
|
||||
func _owner_matches(owner: String) -> bool:
|
||||
if owner.is_empty():
|
||||
return true
|
||||
if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"):
|
||||
return false
|
||||
var main: Dictionary = client.get_entity(int(client.get_main_vid()))
|
||||
return String(main.get("name", "")) == owner
|
||||
|
||||
func _ground_pos_cm(item: Dictionary) -> Vector2:
|
||||
var pos: Variant = item.get("pos", null)
|
||||
if pos is Vector3:
|
||||
# M2Client ground pos is Godot metres (x, y-up, -y_cm); convert back to server cm.
|
||||
return Vector2(roundf(pos.x * 100.0), roundf(-pos.z * 100.0))
|
||||
return Vector2(-1, -1)
|
||||
|
||||
@@ -2,15 +2,54 @@ class_name PlayableReport
|
||||
|
||||
## 联网测试进程内的报告构建器。
|
||||
## 只写非敏感的 client-report.json;最终 process/退出日志门禁由父脚本补齐。
|
||||
##
|
||||
## 事件契约(events.jsonl 每行):monotonic_us、run_id、case_id、connection_epoch、
|
||||
## stage、kind、actor_vid、target_vid、payload。payload 只保留 PAYLOAD_KEYS
|
||||
## 白名单字段;其它字段丢弃并计数,避免把角色名、聊天内容或环境变量写进报告。
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
const VALID_STATUSES := ["PASS", "FAIL", "BLOCKED", "SKIP"]
|
||||
const EVENT_KEYS := ["monotonic_us", "run_id", "case_id", "connection_epoch", "stage",
|
||||
"kind", "actor_vid", "target_vid", "payload"]
|
||||
const PAYLOAD_KEYS := [
|
||||
"source", "count", "race", "ch_type", "amount", "flag", "vnum", "window", "cell",
|
||||
"special", "type", "motion", "reason_code", "hp", "max_hp", "hp_percent", "dead",
|
||||
"pos_cm", "local_pos_cm", "distance_cm", "tolerance_cm", "waypoint_index", "skill_id",
|
||||
"code", "state", "map_key", "expected_map_key", "iid", "before", "after", "delta",
|
||||
"attempt", "owner_match", "allow_gameplay", "main_vid", "entity_count",
|
||||
"ground_item_count", "app_state", "scene_ready", "hud_ready", "player_ready",
|
||||
"in_game", "reconnecting", "character_count", "slot_present", "stale_epoch",
|
||||
"elapsed_ms", "timeout_ms", "server_index", "channel", "address_match", "evidence",
|
||||
"screenshot", "suite", "effect", "lifetime_ms", "selected", "accepted", "reason", "slots",
|
||||
"fx_id", "open_effects", "phase",
|
||||
# STB-01 soak
|
||||
"round", "wall_ms", "requested_size", "logical_size", "physical_size", "viewport_size",
|
||||
"screen_scale", "duplicate_vids", "target_residue", "portal_cm", "unreachable_seconds",
|
||||
"cross_server", "interaction", "severe", "elapsed_s", "leg", "required", "size",
|
||||
]
|
||||
## Top-level report sections a suite may attach; anything else is rejected.
|
||||
const SECTIONS := ["soak", "frame_metrics", "client_memory", "probe_overhead"]
|
||||
## Values that look like credentials are masked even inside whitelisted keys.
|
||||
const SECRET_KEYS := ["password", "passwd", "token", "secret", "account", "login", "username"]
|
||||
|
||||
var _report: Dictionary = {}
|
||||
var _started_us := 0
|
||||
var _events_path := ""
|
||||
var _event_count := 0
|
||||
var _dropped_payload_keys := {}
|
||||
var _redact_literals: Array[String] = []
|
||||
|
||||
func begin(run_id: String, suite: String, config: Dictionary) -> void:
|
||||
func begin(run_id: String, suite: String, config: Dictionary, events_path := "") -> void:
|
||||
_started_us = Time.get_ticks_usec()
|
||||
_events_path = events_path
|
||||
_event_count = 0
|
||||
_dropped_payload_keys = {}
|
||||
# Account/password literals may appear inside server messages; never persist them.
|
||||
_redact_literals.clear()
|
||||
for name in ["MT_ACCOUNT", "MT_PASSWORD"]:
|
||||
var literal := OS.get_environment(name)
|
||||
if literal.length() >= 2:
|
||||
_redact_literals.append(literal)
|
||||
_report = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"run_id": run_id,
|
||||
@@ -19,7 +58,9 @@ func begin(run_id: String, suite: String, config: Dictionary) -> void:
|
||||
"build": _build_info(),
|
||||
"environment": _environment_info(),
|
||||
"cases": [],
|
||||
"events": "events.jsonl",
|
||||
"required_cases": [],
|
||||
"events": events_path.get_file() if not events_path.is_empty() else "",
|
||||
"event_count": 0,
|
||||
"failures": [],
|
||||
"blocked": [],
|
||||
"coverage": {"required": 0, "passed": 0},
|
||||
@@ -28,37 +69,97 @@ func begin(run_id: String, suite: String, config: Dictionary) -> void:
|
||||
# Keep configuration useful for reproducing a run without copying any secret.
|
||||
_report["scenario_id"] = String(config.get("scenario_id", ""))
|
||||
|
||||
## Declares the case IDs that must appear; missing ones become BLOCKED on finish().
|
||||
func set_required_cases(ids: Array) -> void:
|
||||
var out: Array = []
|
||||
for id in ids:
|
||||
out.append(String(id))
|
||||
_report.required_cases = out
|
||||
|
||||
func required_cases() -> Array:
|
||||
return _report.get("required_cases", []).duplicate()
|
||||
|
||||
func has_case(case_id: String) -> bool:
|
||||
for item in _report.get("cases", []):
|
||||
if String(item.id) == case_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
func case_status(case_id: String) -> String:
|
||||
for item in _report.get("cases", []):
|
||||
if String(item.id) == case_id:
|
||||
return String(item.status)
|
||||
return ""
|
||||
|
||||
func add_case(case_id: String, status: String, reason := "", evidence: Array = [], duration_ms := 0.0) -> void:
|
||||
if has_case(case_id):
|
||||
_fail("duplicate case result for %s" % case_id)
|
||||
return
|
||||
var normalized := status.to_upper()
|
||||
if not VALID_STATUSES.has(normalized):
|
||||
normalized = "FAIL"
|
||||
_fail("invalid case status for %s" % case_id)
|
||||
var item := {"id": case_id, "status": normalized, "duration_ms": duration_ms,
|
||||
"reason": reason, "evidence": _sanitize(evidence)}
|
||||
"reason": _redact_text(reason), "evidence": _sanitize(evidence)}
|
||||
_report.cases.append(item)
|
||||
_report.coverage.required = int(_report.coverage.required) + 1
|
||||
if normalized == "PASS":
|
||||
_report.coverage.passed = int(_report.coverage.passed) + 1
|
||||
elif normalized == "FAIL":
|
||||
_fail("%s: %s" % [case_id, reason if not reason.is_empty() else "failed"])
|
||||
_fail("%s: %s" % [case_id, item.reason if not String(item.reason).is_empty() else "failed"])
|
||||
elif normalized == "BLOCKED":
|
||||
_report.blocked.append(reason if not reason.is_empty() else case_id)
|
||||
_report.blocked.append("%s: %s" % [case_id, item.reason if not String(item.reason).is_empty() else "blocked"])
|
||||
|
||||
func add_event(event: Dictionary, events_path: String) -> void:
|
||||
var safe: Variant = _sanitize(event)
|
||||
var file := FileAccess.open(events_path, FileAccess.READ_WRITE)
|
||||
## A run-level failure that belongs to no open case (e.g. an effect from an
|
||||
## already-passed case outliving its lifetime). Any failure makes the run FAIL.
|
||||
func add_failure(message: String) -> void:
|
||||
_fail(message)
|
||||
|
||||
## Attach a whitelisted top-level section (sanitized like payloads). Returns false for unknown names.
|
||||
func set_section(name: String, value: Dictionary) -> bool:
|
||||
if _report.is_empty() or not (name in SECTIONS):
|
||||
return false
|
||||
_report[name] = _sanitize(value)
|
||||
return true
|
||||
|
||||
func failures() -> Array:
|
||||
return _report.get("failures", []).duplicate()
|
||||
|
||||
## Append one contract event. Returns the event actually written (for tests).
|
||||
func record(kind: String, stage: String, case_id: String, connection_epoch: int,
|
||||
actor_vid := 0, target_vid := 0, payload := {}) -> Dictionary:
|
||||
var event := {
|
||||
"monotonic_us": Time.get_ticks_usec(),
|
||||
"run_id": String(_report.get("run_id", "")),
|
||||
"case_id": case_id,
|
||||
"connection_epoch": connection_epoch,
|
||||
"stage": stage,
|
||||
"kind": kind,
|
||||
"actor_vid": actor_vid,
|
||||
"target_vid": target_vid,
|
||||
"payload": _whitelist(payload),
|
||||
}
|
||||
_event_count += 1
|
||||
_report.event_count = _event_count
|
||||
if _events_path.is_empty():
|
||||
return event
|
||||
var file := FileAccess.open(_events_path, FileAccess.READ_WRITE)
|
||||
if file == null:
|
||||
file = FileAccess.open(events_path, FileAccess.WRITE)
|
||||
file = FileAccess.open(_events_path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
_fail("cannot open events file")
|
||||
return
|
||||
return event
|
||||
file.seek_end()
|
||||
file.store_line(JSON.stringify(safe))
|
||||
file.store_line(JSON.stringify(event))
|
||||
file.close()
|
||||
return event
|
||||
|
||||
func finish() -> Dictionary:
|
||||
if _report.is_empty():
|
||||
return {}
|
||||
for required in _report.required_cases:
|
||||
if not has_case(String(required)):
|
||||
add_case(String(required), "BLOCKED", "not_reached")
|
||||
var required := int(_report.coverage.required)
|
||||
var passed := int(_report.coverage.passed)
|
||||
var failures: Array = _report.failures
|
||||
@@ -68,6 +169,8 @@ func finish() -> Dictionary:
|
||||
_report.status = "BLOCKED"
|
||||
else:
|
||||
_report.status = "PASS"
|
||||
if not _dropped_payload_keys.is_empty():
|
||||
_report["dropped_payload_keys"] = _dropped_payload_keys.keys()
|
||||
_report.duration_seconds = float(Time.get_ticks_usec() - _started_us) / 1000000.0
|
||||
return _report
|
||||
|
||||
@@ -83,7 +186,7 @@ func write(path: String) -> bool:
|
||||
return true
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
_report.failures.append(message)
|
||||
_report.failures.append(_redact_text(message))
|
||||
|
||||
func _build_info() -> Dictionary:
|
||||
return {
|
||||
@@ -94,19 +197,40 @@ func _build_info() -> Dictionary:
|
||||
}
|
||||
|
||||
func _environment_info() -> Dictionary:
|
||||
var size := DisplayServer.window_get_size() if DisplayServer.get_name() != "headless" else Vector2i.ZERO
|
||||
var headless := DisplayServer.get_name() == "headless"
|
||||
var size := DisplayServer.window_get_size() if not headless else Vector2i.ZERO
|
||||
var logical := size
|
||||
if not headless and DisplayServer.screen_get_scale() > 0.0:
|
||||
logical = Vector2i(roundi(size.x / DisplayServer.screen_get_scale()), roundi(size.y / DisplayServer.screen_get_scale()))
|
||||
return {
|
||||
"os": OS.get_name(),
|
||||
"renderer": RenderingServer.get_video_adapter_name(),
|
||||
"resolution": [size.x, size.y],
|
||||
"logical_resolution": [logical.x, logical.y],
|
||||
}
|
||||
|
||||
func _whitelist(payload: Dictionary) -> Dictionary:
|
||||
var out := {}
|
||||
for key in payload:
|
||||
var name := String(key)
|
||||
if name in PAYLOAD_KEYS:
|
||||
out[name] = _sanitize(payload[key])
|
||||
else:
|
||||
_dropped_payload_keys[name] = true
|
||||
return out
|
||||
|
||||
func _redact_text(text: String) -> String:
|
||||
var out := text
|
||||
for literal in _redact_literals:
|
||||
out = out.replace(literal, "[redacted]")
|
||||
return out
|
||||
|
||||
func _sanitize(value: Variant) -> Variant:
|
||||
if value is Dictionary:
|
||||
var out := {}
|
||||
for key in value:
|
||||
var name := String(key).to_lower()
|
||||
if name in ["password", "passwd", "token", "secret", "account"]:
|
||||
if name in SECRET_KEYS:
|
||||
out[key] = "[redacted]"
|
||||
else:
|
||||
out[key] = _sanitize(value[key])
|
||||
@@ -116,9 +240,11 @@ func _sanitize(value: Variant) -> Variant:
|
||||
for item in value:
|
||||
out_array.append(_sanitize(item))
|
||||
return out_array
|
||||
if value is Vector2:
|
||||
if value is String or value is StringName:
|
||||
return _redact_text(String(value))
|
||||
if value is Vector2 or value is Vector2i:
|
||||
return [value.x, value.y]
|
||||
if value is Vector3:
|
||||
if value is Vector3 or value is Vector3i:
|
||||
return [value.x, value.y, value.z]
|
||||
if value is Object:
|
||||
return "[object]"
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
extends RefCounted
|
||||
|
||||
## STB-RESIZE-01 的真实窗口适配器(PlayableFlow.window)。
|
||||
##
|
||||
## macOS Retina 下 DisplayServer 的窗口尺寸是物理像素,逻辑尺寸 = 物理像素 / 屏幕缩放。
|
||||
## 流程只按逻辑尺寸判定;物理像素、缩放和视口尺寸一并写进事件,Retina 的 2x 不是分辨率错误。
|
||||
## 请求尺寸超出当前屏幕可用区域(含标题栏)时如实拒绝(request_logical_size 返回 false),
|
||||
## 由流程记为 BLOCKED,不缩小目标冒充通过。headless / 全屏 / 最大化窗口不可调整。
|
||||
|
||||
var _viewport: Viewport
|
||||
|
||||
func _init(viewport: Viewport = null) -> void:
|
||||
_viewport = viewport
|
||||
|
||||
func availability() -> Dictionary:
|
||||
if DisplayServer.get_name() == "headless":
|
||||
return {"ok": false, "reason": "headless display server has no window"}
|
||||
match DisplayServer.window_get_mode():
|
||||
DisplayServer.WINDOW_MODE_FULLSCREEN, DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN:
|
||||
return {"ok": false, "reason": "window is fullscreen; logical size switching needs windowed mode"}
|
||||
DisplayServer.WINDOW_MODE_MAXIMIZED:
|
||||
return {"ok": false, "reason": "window is maximized; logical size switching needs windowed mode"}
|
||||
DisplayServer.WINDOW_MODE_MINIMIZED:
|
||||
return {"ok": false, "reason": "window is minimized"}
|
||||
if screen_scale() <= 0.0:
|
||||
return {"ok": false, "reason": "screen scale unavailable"}
|
||||
return {"ok": true, "reason": ""}
|
||||
|
||||
func screen_scale() -> float:
|
||||
return DisplayServer.screen_get_scale(DisplayServer.window_get_current_screen())
|
||||
|
||||
func physical_size() -> Vector2i:
|
||||
return DisplayServer.window_get_size()
|
||||
|
||||
func logical_size() -> Vector2i:
|
||||
var scale := screen_scale()
|
||||
var size := physical_size()
|
||||
if scale <= 0.0:
|
||||
return size
|
||||
return Vector2i(roundi(size.x / scale), roundi(size.y / scale))
|
||||
|
||||
func viewport_size() -> Vector2i:
|
||||
if _viewport == null or not is_instance_valid(_viewport):
|
||||
return Vector2i.ZERO
|
||||
return Vector2i(_viewport.get_visible_rect().size)
|
||||
|
||||
## Returns false (no request sent) when the size cannot fit the usable screen area.
|
||||
func request_logical_size(size: Vector2i) -> bool:
|
||||
var scale := screen_scale()
|
||||
if scale <= 0.0 or size.x <= 0 or size.y <= 0:
|
||||
return false
|
||||
var target := Vector2i(roundi(size.x * scale), roundi(size.y * scale))
|
||||
var screen := DisplayServer.window_get_current_screen()
|
||||
var usable := DisplayServer.screen_get_usable_rect(screen)
|
||||
var decorations := DisplayServer.window_get_size_with_decorations() - DisplayServer.window_get_size()
|
||||
if target.x + decorations.x > usable.size.x or target.y + decorations.y > usable.size.y:
|
||||
return false
|
||||
DisplayServer.window_set_size(target)
|
||||
# Keep the whole window on the usable area so the OS does not clamp the size back.
|
||||
var position := DisplayServer.window_get_position()
|
||||
var clamped := Vector2i(clampi(position.x, usable.position.x, usable.end.x - target.x - decorations.x),
|
||||
clampi(position.y, usable.position.y, usable.end.y - target.y - decorations.y))
|
||||
if clamped != position:
|
||||
DisplayServer.window_set_position(clamped)
|
||||
return true
|
||||
+33
-13
@@ -28,6 +28,7 @@ var anim: Node # Metin2AnimPlayer
|
||||
var _dir := ""
|
||||
var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径
|
||||
var _state := ""
|
||||
var _last_resolution := {}
|
||||
var _audio: Node
|
||||
var _sound_instances: Array = []
|
||||
var _sound_frame := -1
|
||||
@@ -125,8 +126,10 @@ func set_anim_state(s: String) -> void:
|
||||
if s == _state or anim == null:
|
||||
return
|
||||
_state = s
|
||||
var msa := _motion_for(s)
|
||||
_last_resolution = resolve_motion(s)
|
||||
var msa := String(_last_resolution.path)
|
||||
if msa == "":
|
||||
# 旧客户端 GetMotionKey 失败时 SetLoopMotion/InterceptMotion 直接返回:保留当前动作。
|
||||
return
|
||||
anim.set("loop", s in ["wait", "walk", "run"])
|
||||
anim.set("anim_path", msa)
|
||||
@@ -136,7 +139,35 @@ func set_anim_state(s: String) -> void:
|
||||
# the selected .msa before playback lets the test distinguish a real action
|
||||
# from a silent "keep the previous animation" fallback.
|
||||
func get_motion_path(state: String) -> String:
|
||||
return _motion_for(state)
|
||||
return String(resolve_motion(state).path)
|
||||
|
||||
## requested_state / motion / path / fallback_reason。fallback_reason:
|
||||
## "" 首选动作名命中
|
||||
## "client_alias:<NAME>" 本客户端的同类动作别名(如 run -> WALK)
|
||||
## "motlist_missing:<file>" motlist 未列出,按目录里的 <name>.msa 兜底
|
||||
## "reference_keep_current_motion" 资源没有任何候选动作;与旧客户端一致保留当前动作
|
||||
func resolve_motion(state: String) -> Dictionary:
|
||||
var names: Array = STATE_MOTIONS.get(state, [])
|
||||
var out := {"requested_state": state, "motion": "", "path": "", "fallback_reason": ""}
|
||||
for mo: String in names:
|
||||
if _motions.has(mo):
|
||||
out.motion = mo
|
||||
out.path = String(_motions[mo])
|
||||
if mo != String(names[0]):
|
||||
out.fallback_reason = "client_alias:" + mo
|
||||
return out
|
||||
for mo: String in names:
|
||||
var p := _dir.path_join(mo.to_lower() + ".msa")
|
||||
if FileAccess.file_exists(p):
|
||||
out.motion = mo
|
||||
out.path = p
|
||||
out.fallback_reason = "motlist_missing:" + p.get_file()
|
||||
return out
|
||||
out.fallback_reason = "reference_keep_current_motion"
|
||||
return out
|
||||
|
||||
func last_motion_resolution() -> Dictionary:
|
||||
return _last_resolution.duplicate()
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \
|
||||
@@ -194,17 +225,6 @@ func _folder_candidates(proto: Node, race: int) -> Array:
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
func _motion_for(state: String) -> String:
|
||||
for mo: String in STATE_MOTIONS.get(state, []):
|
||||
if _motions.has(mo):
|
||||
return _motions[mo]
|
||||
# 兜底:目录里有 <mo>.msa 直接用
|
||||
for mo: String in STATE_MOTIONS.get(state, []):
|
||||
var p := _dir.path_join(mo.to_lower() + ".msa")
|
||||
if FileAccess.file_exists(p):
|
||||
return p
|
||||
return _motions.get("WAIT", "")
|
||||
|
||||
func _load_motlist() -> void:
|
||||
var ml := _dir.path_join("motlist.txt")
|
||||
if not FileAccess.file_exists(ml):
|
||||
|
||||
+15
-1
@@ -47,6 +47,7 @@ var _slots := [] # 当前页 UI:[{btn, cd, lbl, icon, grade}]
|
||||
var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}]
|
||||
var _skill_cooldowns := {} # skill identity survives slot moves/restores within this UI session
|
||||
var _page := 0
|
||||
var _last_activation_reserved := false # 最近一次激活落到 __ReserveUseSkill(射程外)
|
||||
var _move_from := -1
|
||||
var _mobile_mode := false
|
||||
|
||||
@@ -194,6 +195,17 @@ func _activate_slot(slot: int, screen_direction: Vector2) -> void:
|
||||
elif not PlayerSkill.is_silent_code(fishing_result):
|
||||
skill_rejected.emit(int(s.id), fishing_result)
|
||||
return
|
||||
if code == "RESERVED":
|
||||
# __ProcessEnemySkillTargetRange:射程外不发施法,蓄力技未预约时先
|
||||
# __SendUseSkill(slot, 0),再 __ReserveUseSkill 交给 NetPlay 趋近。
|
||||
_last_activation_reserved = true
|
||||
if net_play and net_play.has_method("reserve_use_skill"):
|
||||
var already: bool = net_play.has_method("is_use_skill_reserved") \
|
||||
and bool(net_play.is_use_skill_reserved(global))
|
||||
if bool(gate.get("charge", false)) and not already and client.has_method("use_skill"):
|
||||
client.use_skill(int(s.id), 0)
|
||||
net_play.reserve_use_skill(int(gate.get("target_vid", 0)), global, float(gate.get("range_cm", 0)))
|
||||
return
|
||||
if code == "TOGGLE_OFF":
|
||||
if client.has_method("use_skill"):
|
||||
client.use_skill(int(s.id), 0)
|
||||
@@ -284,8 +296,10 @@ func activate_reserved(global_slot: int) -> bool:
|
||||
var s: Dictionary = _state[global_slot]
|
||||
if s.kind != "skill" or s.id == 0 or _now() < s.cd_end:
|
||||
return false
|
||||
_last_activation_reserved = false
|
||||
activate(local)
|
||||
return true
|
||||
# 再次落到射程外时新预约已建立,不能让 NetPlay 把它当作已消费清掉。
|
||||
return not _last_activation_reserved
|
||||
|
||||
# client.get_skills() 里该技能的当前等级(quickbar 施法时算 target_count 用)。
|
||||
func _skill_level(sid: int) -> int:
|
||||
|
||||
Reference in New Issue
Block a user