Files
mtgodot-poc/project/playable_harness_test.gd
T
shenleiandClaude Opus 5 f39a55fdd5 feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档
- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay
  只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续
  运行并记 BLOCKED;写本批次 release-manifest.json 后聚合
- run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理);
  soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数)
- 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试
- forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别
- 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求
- .gitignore 排除本地场景配置、凭据文件与运行输出

离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS,
node 夹具测试 PASS。未联网运行,未重建候选包。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-11 22:06:48 +09:00

1237 lines
56 KiB
GDScript

extends SceneTree
## INF-01 / NET-01 / CBT-01 离线门禁:配置契约、报告契约,以及用假客户端驱动
## 真实 PlayableProbe + PlayableFlow 的正反例。时间由注入时钟推进,不等待真实超时。
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 MAIN := 1001
const MOB := 2001
const MOB_RACE := 101
const DROP_VNUM := 19
const SKILL_ID := 5
class FakeClient extends Node:
signal login_failed(reason: String)
signal char_list(chars: Array)
signal entered_game()
signal disconnected(reason: String)
signal entity_main_set(vid: int)
signal entity_spawned(entity: Dictionary)
signal entity_despawned(vid: int)
signal entity_moved(vid: int)
signal entity_dead(vid: int)
signal damage(vid: int, amount: int, flag: int)
signal motion(vid: int, victim_vid: int, motion: int)
signal vitals_changed(vid: int)
signal points_changed(points: Dictionary)
signal target_info(vid: int, hp_percent: int)
signal ground_item_added(item: Dictionary)
signal ground_item_removed(vid: int)
signal item_picked_up(vnum: int, count: int, from: String)
signal inventory_changed(window: int, cell: int)
signal affect_added(affect: Dictionary)
signal effect_cue(vid: int, name: String, special: int)
signal fly_cue(kind: int, start_vid: int, end_vid: int)
var main_vid := 0
var entities := {}
var ground: Array = []
var inventory: Array = []
var reconnect_ok := true
var reconnect_calls := 0
var calls: Array = []
func is_in_game() -> bool: return main_vid > 0
func get_main_vid() -> int: return main_vid
func get_entity(vid: int) -> Dictionary: return entities.get(vid, {})
func get_entities() -> Array: return entities.values()
func get_ground_items() -> Array: return ground.duplicate()
func get_inventory() -> Array: return inventory.duplicate()
func reconnect() -> bool:
reconnect_calls += 1
calls.append("reconnect")
return reconnect_ok
class FakeHud extends Node:
pass
class FakePC extends Node:
var player: Node3D
var client: FakeClient
var walks: Array = []
var move_on_walk := true
func walk_to(world_pos: Vector3) -> void:
walks.append(world_pos)
if move_on_walk:
player.position = world_pos
var cm := MapCoord.to_server_cm(world_pos)
client.entities[MAIN].pos_cm = Vector3(cm.x, cm.y, 0)
class FakeNetPlay extends Node:
var client: FakeClient
var attack_states: Array = []
var selected: Array = []
var picks: Array = []
var _target_vid := 0
var _attack_key_down := false
func select_target(vid: int) -> bool:
selected.append(vid)
client.calls.append("select_target")
var e: Dictionary = client.get_entity(vid)
return not e.is_empty() and not bool(e.get("dead", false))
func set_attack_key(down: bool) -> void:
attack_states.append(down)
client.calls.append("attack:%s" % down)
func pick_ground_item(iid: int) -> void:
picks.append(iid)
client.calls.append("pick")
class FakeQuickbar extends Node:
signal skill_cast_started(skill_id: int, target_vid: int)
signal skill_rejected(skill_id: int, code: String)
var client: FakeClient
var net_play: FakeNetPlay
var assigned: Array = []
var activations := 0
var reject_code := ""
func assign(slot: int, kind: String, id: int, persist := true) -> bool:
assigned.append([slot, kind, id, persist])
return true
func activate(_slot: int) -> void:
activations += 1
var skill: int = int(assigned[-1][2])
if not reject_code.is_empty():
skill_rejected.emit(skill, reject_code)
return
skill_cast_started.emit(skill, int(net_play.selected[-1]) if not net_play.selected.is_empty() else 0)
## Same lifecycle signals as fx/effect_registry.gd.
class FakeFx extends RefCounted:
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)
class FakeAppFlow extends Node:
var address := {}
var scene_ready := false
var map_key := ""
var context := {}
func select_test_server(_index: int, _channel: int) -> Dictionary: return address
func get_playable_snapshot() -> Dictionary:
return {"stage": "GAME", "scene_ready": scene_ready, "map_key": map_key, "character_count": 1, "reconnecting": false}
func get_playable_context() -> Dictionary: return context
## Same registry shape as net_world.gd: entity nodes under `parent` with meta "vid".
class FakeNetWorld extends Node:
var parent := Node3D.new()
var _fading := {}
func _init() -> void:
add_child(parent)
func add_entity_node(vid: int) -> Node3D:
var node := Node3D.new()
node.set_meta("vid", vid)
parent.add_child(node)
return node
## Window adapter contract used by STB-01 resize (testing/playable_window.gd is the live one).
class FakeWindow extends RefCounted:
var supported := true
var reason := ""
var scale := 2.0
var logical := Vector2i(1280, 720)
var max_logical := Vector2i(4000, 4000)
var stuck := false
var requests: Array = []
func availability() -> Dictionary: return {"ok": supported, "reason": reason}
func request_logical_size(size: Vector2i) -> bool:
requests.append(size)
if size.x > max_logical.x or size.y > max_logical.y:
return false
if not stuck:
logical = size
return true
func logical_size() -> Vector2i: return logical
func physical_size() -> Vector2i: return Vector2i(roundi(logical.x * scale), roundi(logical.y * scale))
func viewport_size() -> Vector2i: return logical
func screen_scale() -> float: return scale
class Rig:
var client: FakeClient
var app: FakeAppFlow
var pc: FakePC
var net_play: FakeNetPlay
var quickbar: FakeQuickbar
var fx: FakeFx
var net_world: FakeNetWorld
var window: FakeWindow
var probe: Node
var report: RefCounted
var flow: RefCounted
var now := 1_000_000
var events_path := ""
var status := ""
func advance(us: int) -> void:
now += us
flow.tick()
func step(times := 1) -> void:
for i in times:
flow.tick()
var failures := 0
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 valid_config() -> Dictionary:
return {
"schema_version": 1, "scenario_id": "harness", "protocol": "classic",
"server": {"server_index": 0, "channel": 1, "auth_host": "127.0.0.1", "auth_port": 11000,
"game_host": "127.0.0.1", "game_port": 13002},
"character_slot": 2, "map_key": "outdoortrent/metin2_map_trent",
"waypoints_cm": [[1000, 2000], [1600, 2000]],
"allowed_mob_vnums": [MOB_RACE], "allowed_drop_vnums": [DROP_VNUM],
"skill_cases": [{"case_id": "strike", "skill_id": SKILL_ID, "target": "enemy", "repeats": 2,
"required_evidence": ["cast_started", "damage"]}],
"resolution": [1280, 720], "loops": 1, "timeout_seconds": 900,
}
func run() -> void:
MapCoord.set_base(Vector2.ZERO)
test_config_contract()
test_report_contract()
test_happy_path()
test_gameplay_not_allowed()
test_absent_slot_fails_without_select()
test_disconnect_during_select()
test_address_mismatch()
test_server_correction()
test_reconnect_unavailable_blocks_confirm()
test_stale_epoch_ignored()
test_target_despawn_releases_and_retargets()
test_attack_timeout_releases_key()
test_drop_taken_by_other()
test_inventory_full_timeout()
test_foreign_owner_drop_rejected()
test_local_only_skill_evidence_fails()
test_skill_not_learned_blocked()
test_skill_case_waits_for_effect_cleanup()
test_skill_screenshots_follow_effect_times()
test_effect_outliving_lifetime_fails()
test_effect_finished_late_fails()
test_effect_from_passed_case_fails_run()
test_looping_effect_not_timed()
test_soak_config_contract()
test_soak_rounds_until_wall_clock_duration()
test_soak_duration_is_wall_clock_not_rounds()
test_soak_later_round_failure()
test_soak_later_round_fixture_blocked()
test_soak_faults_confirmed()
test_soak_fault_without_disconnect_fails()
test_soak_warp_confirmed()
test_soak_warp_without_server_reconnect_fails()
test_soak_rebind_checks()
test_soak_resize_contract()
test_soak_frames_gate()
test_soak_gameplay_not_allowed()
for rig in _rigs:
rig.probe.disconnect_all()
for node in [rig.client, rig.app, rig.pc, rig.net_play, rig.quickbar, rig.net_world, rig.probe]:
node.free()
# Break Rig <-> flow lambda cycles so the run exits leak-free.
rig.flow.clock_us = Callable()
rig.flow.capture = Callable()
rig.flow.wall_ms = Callable()
if rig.flow.metrics != null:
rig.flow.metrics.clock_us = Callable()
rig.flow.metrics = null
rig.flow.window = null
rig.window = null
rig.fx = null
rig.flow = null
rig.report = null
_rigs.clear()
print("playable_harness_test: failures=%d" % failures)
quit(1 if failures else 0)
# --- config / report contracts ----------------------------------------------------
func test_config_contract() -> void:
var example := Config.load_file(ProjectSettings.globalize_path("res://../test/playable/scenario.example.json"))
check(not example.ok and example.errors.size() > 0, "empty example configuration is rejected with reasons")
var valid := valid_config()
check(Config.validate(valid).ok, "complete configuration is accepted: %s" % [Config.validate(valid).errors])
var old := valid.duplicate(true)
old.server = {"server_index": 0, "channel_index": 0}
check(not Config.validate(old).ok, "old server {server_index, channel_index} schema is rejected")
for key in ["password", "account", "login_token"]:
var secret := valid.duplicate(true)
secret[key] = "x"
check(not Config.validate(secret).ok, "top-level %s is rejected" % key)
var nested := valid.duplicate(true)
nested.server["user_password"] = "x"
check(not Config.validate(nested).ok, "nested credential key is rejected")
var gold := valid.duplicate(true)
gold.allowed_drop_vnums = [1, DROP_VNUM]
check(not Config.validate(gold).ok, "gold vnum is not a pickup fixture")
var local_only := valid.duplicate(true)
local_only.skill_cases[0].required_evidence = ["cast_started", "fx_spawned"]
check(not Config.validate(local_only).ok, "local-only skill evidence is rejected")
var dup := valid.duplicate(true)
dup.skill_cases.append(dup.skill_cases[0].duplicate(true))
check(not Config.validate(dup).ok, "duplicate skill case_id is rejected")
var fractional := valid.duplicate(true)
fractional.waypoints_cm = [[1000.5, 2000]]
check(not Config.validate(fractional).ok, "fractional waypoint is rejected")
var escape := valid.duplicate(true)
escape.map_key = "../etc"
check(not Config.validate(escape).ok, "map_key path escape is rejected")
var cases := Config.required_cases(valid, "playable")
check(cases.size() == 11 and cases.has("NET-MOVE-02") and not cases.has("CBT-strike"), "playable required cases: %s" % [cases])
check(Config.required_cases(valid, "full").has("CBT-strike"), "full suite adds skill cases")
var info := ServerInfo.new()
check(Config.resolve_server(valid, info).ok == false, "built-in serverinfo address differs from the harness config")
var list := "user://playable-harness-serverlist.txt"
var f := FileAccess.open(list, FileAccess.WRITE)
f.store_string("fixture\t127.0.0.1\t11000\t127.0.0.1\t13002\t1,2\t10\t0\n")
f.close()
check(info.load_file(list), "fixture serverlist loads")
check(Config.resolve_server(valid, info).ok, "matching serverlist resolves")
var ch2 := valid.duplicate(true)
ch2.server.channel = 2
check(not Config.resolve_server(ch2, info).ok, "channel 2 needs game_port + port_step")
ch2.server.game_port = 13012
check(Config.resolve_server(ch2, info).ok, "channel 2 resolves with the stepped port")
var assets := ProjectSettings.globalize_path("user://playable-harness-assets")
DirAccess.make_dir_recursive_absolute(assets.path_join(valid.map_key))
f = FileAccess.open(assets.path_join(valid.map_key).path_join("Setting.txt"), FileAccess.WRITE)
f.store_string("ScriptType\tMapSetting\nBasePosition\t0\t0\nMapSize\t2\t2\n")
f.close()
check(Config.check_resources(valid, assets).ok, "waypoints inside Setting.txt bounds pass")
var outside := valid.duplicate(true)
outside.waypoints_cm = [[60000, 100]]
check(not Config.check_resources(outside, assets).ok, "waypoint outside map bounds is rejected")
var missing := valid.duplicate(true)
missing.map_key = "outdoortrent/missing"
check(not Config.check_resources(missing, assets).ok, "missing Setting.txt is rejected")
func test_report_contract() -> void:
OS.set_environment("MT_ACCOUNT", "harness-acct")
OS.set_environment("MT_PASSWORD", "harness-pass")
var events := ProjectSettings.globalize_path("user://playable-harness-contract.jsonl")
if FileAccess.file_exists(events):
DirAccess.remove_absolute(events)
var report := Report.new()
report.begin("contract-run", "playable", valid_config(), events)
report.set_required_cases(["CONFIG-01", "NET-LOGIN-01"])
report.add_case("CONFIG-01", "PASS", "validated")
var event: Dictionary = report.record("login_failed", "LOGIN", "NET-LOGIN-01", 1, 0, 0,
{"reason": "bad harness-acct / harness-pass", "name": "SomePlayer", "pos_cm": Vector2(1, 2)})
check(not event.payload.has("name"), "non-whitelisted payload key is dropped")
check(String(event.payload.reason).find("harness-acct") < 0 and String(event.payload.reason).find("harness-pass") < 0,
"credential literals are redacted from payload strings")
check(event.payload.pos_cm == [1.0, 2.0], "vectors are serialized as arrays")
for key in Report.EVENT_KEYS:
check(event.has(key), "event has contract key " + key)
report.add_case("CONFIG-01", "PASS", "again")
var finished: Dictionary = report.finish()
check(finished.status == "FAIL", "duplicate case result fails the report")
check("NET-LOGIN-01" in finished.required_cases and str(finished.blocked).find("not_reached") >= 0,
"missing required case becomes BLOCKED not_reached")
check(finished.dropped_payload_keys.has("name"), "dropped payload keys are listed")
var blocked := Report.new()
blocked.begin("blocked-run", "playable", valid_config())
blocked.set_required_cases(["CONFIG-01"])
blocked.add_case("CONFIG-01", "BLOCKED", "fixture_not_ready")
check(blocked.finish().status == "BLOCKED", "blocked fixture cannot become PASS")
var empty := Report.new()
empty.begin("empty-run", "playable", {})
check(empty.finish().status == "BLOCKED", "empty case list is never PASS")
var out := ProjectSettings.globalize_path("user://playable-harness-report.json")
check(report.write(out), "report can be serialized")
var text := FileAccess.get_file_as_string(out)
check(text.find("harness-acct") < 0 and text.find("harness-pass") < 0, "serialized report has no credential literal")
check(FileAccess.get_file_as_string(events).find("harness-pass") < 0, "events file has no credential literal")
var json: Variant = JSON.parse_string(text)
check(json is Dictionary and json.get("run_id", "") == "contract-run", "serialized report is readable")
OS.set_environment("MT_ACCOUNT", "")
OS.set_environment("MT_PASSWORD", "")
# --- state machine rig --------------------------------------------------------------
func make_rig(suite := "playable", allow := true, config := {}) -> Rig:
var rig := Rig.new()
var cfg: Dictionary = config if not config.is_empty() else valid_config()
rig.client = FakeClient.new()
rig.app = FakeAppFlow.new()
rig.app.address = cfg.server.duplicate()
rig.app.map_key = cfg.map_key
var player := Node3D.new()
rig.pc = FakePC.new()
rig.pc.player = player
rig.pc.client = rig.client
rig.pc.add_child(player)
rig.net_play = FakeNetPlay.new()
rig.net_play.client = rig.client
rig.quickbar = FakeQuickbar.new()
rig.quickbar.client = rig.client
rig.quickbar.net_play = rig.net_play
rig.fx = FakeFx.new()
rig.net_world = FakeNetWorld.new()
rig.app.context = {"player": player, "pc": rig.pc, "net_play": rig.net_play, "hud": rig.app, "quickbar": rig.quickbar, "fx": rig.fx,
"net_world": rig.net_world}
rig.probe = Probe.new()
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.watch_local(rig.fx, "fx_spawned", "fx_spawned")
rig.probe.watch_local(rig.fx, "fx_finished", "fx_finished")
rig.events_path = ProjectSettings.globalize_path("user://playable-harness-%d.jsonl" % _rigs.size())
if FileAccess.file_exists(rig.events_path):
DirAccess.remove_absolute(rig.events_path)
rig.report = Report.new()
rig.report.begin("rig-%d" % _rigs.size(), suite, cfg, rig.events_path)
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 = cfg
rig.flow.suite = suite
rig.flow.allow_gameplay = allow
rig.flow.clock_us = func() -> int: return rig.now
rig.flow.capture = func(case_id: String) -> String: return "%s.png" % case_id.to_lower()
rig.flow.finished.connect(func(s: String) -> void: rig.status = s)
rig.probe.observed.connect(rig.flow.on_observed)
_rigs.append(rig)
rig.flow.start()
return rig
func enter_world(rig: Rig) -> void:
rig.client.char_list.emit([{"index": 0}, {"index": 2}])
rig.client.main_vid = MAIN
rig.client.entities[MAIN] = {"vid": MAIN, "ch_type": 0, "name": "Hero", "pos_cm": Vector3(900, 2000, 0), "hp": 100, "dead": false}
rig.client.entered_game.emit()
rig.step()
rig.app.scene_ready = true
rig.step()
func walk_route(rig: Rig) -> void:
rig.step(3)
## Simulate the re-login: fast-path reconnect emits a second entered_game.
func confirm_position(rig: Rig) -> void:
rig.client.entered_game.emit()
rig.step()
func spawn_mob(rig: Rig, vid := MOB) -> void:
rig.client.entities[vid] = {"vid": vid, "ch_type": 2, "race": MOB_RACE, "hp": 100, "max_hp": 100, "dead": false, "pos_cm": Vector3(1700, 2000, 0)}
func kill_mob(rig: Rig, vid := MOB) -> void:
rig.client.damage.emit(vid, 40, 1)
rig.client.entities[vid].dead = true
rig.client.entity_dead.emit(vid)
rig.step()
func drop_item(rig: Rig, iid := 7001, owner := "") -> void:
rig.client.ground.append({"vid": iid, "vnum": DROP_VNUM, "owner": owner, "pos": Vector3(17.0, 0.0, -20.0)})
rig.step()
func case_status(rig: Rig, id: String) -> String:
return rig.report.case_status(id)
func test_happy_path() -> void:
var rig := make_rig("full")
check(case_status(rig, "CONFIG-01") == "PASS", "config/address case passes")
enter_world(rig)
check(case_status(rig, "NET-LOGIN-01") == "PASS", "login passes on char_list with slot")
check(case_status(rig, "NET-SELECT-01") == "PASS", "select passes on entered_game + main entity")
check(case_status(rig, "NET-WORLD-01") == "PASS", "world passes when scene/map/player/HUD ready")
walk_route(rig)
check(rig.pc.walks.size() == 2, "each waypoint uses PlayerController.walk_to")
check(case_status(rig, "NET-MOVE-01") == "PASS" and case_status(rig, "NET-MOVE-02") == "PASS", "waypoints pass")
check(rig.client.reconnect_calls == 1 and rig.flow.stage == "MOVE_CONFIRM", "route end re-logs to read server position")
confirm_position(rig)
check(case_status(rig, "NET-MOVE-CONFIRM-01") == "PASS", "server position after re-login confirms the route")
spawn_mob(rig)
rig.step()
check(case_status(rig, "NET-TARGET-01") == "PASS" and rig.net_play.selected == [MOB], "target selected through NetPlay")
check(rig.net_play.attack_states == [true], "attack key pressed once")
kill_mob(rig)
check(case_status(rig, "NET-ATTACK-01") == "PASS", "damage + death passes attack")
check(rig.net_play.attack_states == [true, false], "attack key released after the kill")
drop_item(rig)
check(case_status(rig, "NET-DROP-01") == "PASS" and rig.net_play.picks == [7001], "new allowed drop is picked")
rig.client.ground.clear()
rig.client.inventory.append({"vnum": DROP_VNUM, "count": 1, "cell": 3})
rig.step()
check(case_status(rig, "NET-PICKUP-01") == "PASS", "ground removal + inventory increase passes pickup")
check(rig.flow.stage == "SKILL" and rig.quickbar.assigned == [[7, "skill", SKILL_ID, false]], "skill assigned without persistence")
spawn_mob(rig, MOB + 10)
rig.step()
check(rig.quickbar.activations == 1, "first cast requested")
rig.client.damage.emit(MOB + 10, 30, 1)
rig.step()
rig.advance(600_000)
check(rig.quickbar.activations == 2, "second cast requested after retry gap")
rig.client.damage.emit(MOB + 10, 30, 1)
rig.step()
check(case_status(rig, "CBT-strike") == "PASS", "every cast with server evidence passes the skill case")
check(rig.status == "PASS" and rig.flow.done, "full closed loop reports PASS (status=%s, failures=%s, blocked=%s)" % [rig.status, rig.report.finish().failures, rig.report.finish().blocked])
var lines := FileAccess.get_file_as_string(rig.events_path).strip_edges().split("\n")
check(lines.size() > 10, "contract events persisted")
var first: Variant = JSON.parse_string(lines[0])
check(first is Dictionary and first.run_id == "rig-0" and first.has("connection_epoch"), "event lines carry run_id and epoch")
check(FileAccess.get_file_as_string(rig.events_path).find("Hero") < 0, "character name never reaches events")
func test_gameplay_not_allowed() -> void:
var rig := make_rig("playable", false)
enter_world(rig)
check(rig.flow.done and rig.status == "BLOCKED", "without allow_gameplay the run is BLOCKED after world")
check(case_status(rig, "NET-MOVE-01") == "BLOCKED" and str(rig.report.finish().blocked).find("gameplay_not_allowed") >= 0,
"remaining cases are BLOCKED gameplay_not_allowed")
check(rig.pc.walks.is_empty() and rig.net_play.attack_states.is_empty(), "no gameplay input without permission")
func test_absent_slot_fails_without_select() -> void:
var rig := make_rig()
rig.client.char_list.emit([{"index": 0}])
check(case_status(rig, "NET-LOGIN-01") == "FAIL" and rig.flow.done, "absent configured slot fails login")
check(rig.status == "FAIL", "absent slot run is FAIL")
func test_disconnect_during_select() -> void:
var rig := make_rig()
rig.client.char_list.emit([{"index": 2}])
rig.client.disconnected.emit("reset")
check(case_status(rig, "NET-SELECT-01") == "FAIL" and rig.flow.done, "disconnect while entering fails select")
rig.client.entered_game.emit()
rig.app.scene_ready = true
rig.step(3)
check(rig.client.calls.is_empty() and rig.pc.walks.is_empty(), "no operations after a finished run")
func test_address_mismatch() -> void:
var cfg := valid_config()
var rig := make_rig("playable", true, cfg)
check(case_status(rig, "CONFIG-01") == "PASS", "matching address control")
var client := FakeClient.new()
var app := FakeAppFlow.new()
app.address = cfg.server.duplicate()
app.address.game_port = 13003
var report := Report.new()
report.begin("mismatch", "playable", cfg)
var flow := Flow.new()
flow.report = report
flow.flow = app
flow.client = client
flow.config = cfg
flow.clock_us = func() -> int: return 0
flow.start()
check(report.case_status("CONFIG-01") == "FAIL" and flow.done, "client address differing from precheck fails CONFIG-01")
client.free()
app.free()
func test_server_correction() -> void:
var rig := make_rig()
rig.pc.move_on_walk = false
enter_world(rig)
rig.step()
rig.client.entities[MAIN].pos_cm = Vector3(9000, 9000, 0)
rig.client.entity_moved.emit(MAIN)
check(case_status(rig, "NET-MOVE-01") == "FAIL", "server correction beyond tolerance fails the waypoint")
var rig2 := make_rig()
rig2.pc.move_on_walk = false
enter_world(rig2)
rig2.advance(21_000_000)
check(case_status(rig2, "NET-MOVE-01") == "FAIL" and rig2.status == "FAIL", "waypoint timeout fails via injected clock")
func test_reconnect_unavailable_blocks_confirm() -> void:
var rig := make_rig()
rig.client.reconnect_ok = false
enter_world(rig)
walk_route(rig)
check(case_status(rig, "NET-MOVE-CONFIRM-01") == "BLOCKED", "no reconnect -> confirm BLOCKED, not PASS")
check(rig.flow.stage == "TARGET", "run continues to targeting after a blocked confirm")
func test_stale_epoch_ignored() -> void:
var rig := make_rig()
enter_world(rig)
walk_route(rig)
# Old-connection damage/death arriving after the reconnect was requested.
spawn_mob(rig)
rig.client.disconnected.emit("relogin")
rig.client.damage.emit(MOB, 50, 1)
confirm_position(rig)
check(case_status(rig, "NET-MOVE-CONFIRM-01") == "PASS", "expected reconnect disconnect does not fail the run")
rig.step()
check(rig.flow.stage == "ATTACK", "target bound in new epoch")
check(int(rig.probe.connection_epoch) == 2, "disconnect bumps the connection epoch")
# Deliver an old-epoch death directly; it must be marked stale and ignored.
rig.flow.on_observed("entity_dead", {"source": "server", "connection_epoch": 1, "actor_vid": 0, "target_vid": MOB})
rig.step()
check(case_status(rig, "NET-ATTACK-01") == "", "old-epoch death does not pass attack")
check(FileAccess.get_file_as_string(rig.events_path).find("stale_epoch") >= 0, "stale event is recorded as stale_epoch")
func test_target_despawn_releases_and_retargets() -> void:
var rig := make_rig()
enter_world(rig)
walk_route(rig)
confirm_position(rig)
spawn_mob(rig)
rig.step()
rig.client.entities.erase(MOB)
rig.client.entity_despawned.emit(MOB)
check(rig.net_play.attack_states == [true, false], "despawn releases the attack key")
spawn_mob(rig, MOB + 1)
rig.client.entities[MOB] = {"vid": MOB, "ch_type": 2, "race": MOB_RACE, "hp": 100, "dead": false, "pos_cm": Vector3(1000, 2000, 0)}
rig.step()
check(rig.net_play.selected == [MOB, MOB + 1], "abandoned VID is not reselected (selected=%s)" % [rig.net_play.selected])
func test_attack_timeout_releases_key() -> void:
var rig := make_rig()
enter_world(rig)
walk_route(rig)
confirm_position(rig)
spawn_mob(rig)
rig.step()
rig.advance(91_000_000)
check(case_status(rig, "NET-ATTACK-01") == "FAIL" and rig.net_play.attack_states == [true, false], "attack timeout fails and releases input")
var rig2 := make_rig()
enter_world(rig2)
walk_route(rig2)
confirm_position(rig2)
spawn_mob(rig2)
rig2.step()
rig2.client.entities[MOB].dead = true
rig2.client.entity_dead.emit(MOB)
rig2.step()
check(case_status(rig2, "NET-ATTACK-01") == "FAIL", "death without damage/HP evidence fails")
func to_pickup(rig: Rig) -> void:
enter_world(rig)
walk_route(rig)
confirm_position(rig)
spawn_mob(rig)
rig.step()
kill_mob(rig)
drop_item(rig)
func test_drop_taken_by_other() -> void:
var rig := make_rig()
to_pickup(rig)
rig.client.ground.clear()
rig.client.ground_item_removed.emit(7001)
rig.step()
rig.advance(2_500_000)
check(case_status(rig, "NET-PICKUP-01") == "FAIL", "item removed without inventory increase fails pickup")
func test_inventory_full_timeout() -> void:
var rig := make_rig()
to_pickup(rig)
rig.advance(16_000_000)
check(case_status(rig, "NET-PICKUP-01") == "FAIL" and rig.status == "FAIL", "pickup never accepted (inventory full) fails")
func test_foreign_owner_drop_rejected() -> void:
var rig := make_rig()
enter_world(rig)
walk_route(rig)
confirm_position(rig)
spawn_mob(rig)
rig.step()
kill_mob(rig)
drop_item(rig, 7002, "SomeoneElse")
check(rig.net_play.picks.is_empty() and case_status(rig, "NET-DROP-01") == "", "foreign-owner drop is not picked")
rig.advance(31_000_000)
check(rig.flow.stage == "TARGET", "no acceptable drop re-targets while kills < 3")
func to_skill(rig: Rig) -> void:
to_pickup(rig)
rig.client.ground.clear()
rig.client.inventory.append({"vnum": DROP_VNUM, "count": 1})
rig.step()
spawn_mob(rig, MOB + 10)
func test_local_only_skill_evidence_fails() -> void:
var rig := make_rig("full")
to_skill(rig)
check(rig.flow.stage == "SKILL", "reached skill stage")
rig.step()
check(rig.quickbar.activations == 1, "skill activated through Quickbar")
rig.advance(11_000_000)
check(case_status(rig, "CBT-strike") == "FAIL", "cast_started without server damage fails")
func test_skill_not_learned_blocked() -> void:
var rig := make_rig("full")
to_skill(rig)
rig.quickbar.reject_code = "NOT_YET_LEARN"
rig.step()
check(case_status(rig, "CBT-strike") == "BLOCKED" and rig.status == "BLOCKED", "unlearned skill is a fixture BLOCKED")
# --- CBT-01: effect lifecycle ------------------------------------------------------
## Two casts with server damage; the first cast spawns effect 41 (lifetime 1500ms).
func cast_twice_with_effect(rig: Rig) -> void:
to_skill(rig)
rig.step()
rig.fx.fx_spawned.emit("d:/ymir work/effect/hit/blow_1.mse", 41, 1500)
rig.client.damage.emit(MOB + 10, 30, 1)
rig.step()
rig.advance(600_000)
rig.client.damage.emit(MOB + 10, 30, 1)
rig.step()
func test_skill_case_waits_for_effect_cleanup() -> void:
var rig := make_rig("full")
cast_twice_with_effect(rig)
check(rig.quickbar.activations == 2 and case_status(rig, "CBT-strike") == "", "skill case is held while its effect is still alive")
rig.fx.fx_finished.emit("d:/ymir work/effect/hit/blow_1.mse", 41, 1500, 1900, "cleanup")
rig.step()
check(case_status(rig, "CBT-strike") == "PASS" and rig.status == "PASS", "effect cleaned inside lifetime+1s lets the skill case pass")
var text := FileAccess.get_file_as_string(rig.events_path)
check(text.find("\"fx_id\":41") >= 0 and text.find("\"elapsed_ms\":1900") >= 0 and text.find("blow_1") >= 0,
"effect spawn/finish boundaries are persisted with id, lifetime and elapsed")
func case_evidence(rig: Rig, id: String) -> Array:
for item in rig.report.finish().cases:
if String(item.id) == id:
return item.evidence
return []
func test_skill_screenshots_follow_effect_times() -> void:
# Effect finishes before its half-life tick: peak falls back to the last live frame.
var rig := make_rig("full")
cast_twice_with_effect(rig)
rig.fx.fx_finished.emit("d:/ymir work/effect/hit/blow_1.mse", 41, 1500, 700, "cleanup")
rig.step()
check(case_evidence(rig, "CBT-strike") == ["cbt-strike-before.png", "cbt-strike-trigger.png", "cbt-strike-peak.png", "cbt-strike-end.png"],
"first cast is captured before / trigger / peak / end: %s" % [case_evidence(rig, "CBT-strike")])
# Peak is taken on the tick the half-life falls due, not after a fixed second.
var timed := make_rig("full")
to_skill(timed)
timed.step()
timed.fx.fx_spawned.emit("blow_1", 41, 400)
timed.advance(150_000)
check(not ("cbt-strike-peak.png" in timed.flow._shots), "no peak before the effect's half-life")
timed.advance(60_000)
check("cbt-strike-peak.png" in timed.flow._shots and not ("cbt-strike-end.png" in timed.flow._shots),
"peak captured at 200ms of a 400ms effect, end still pending")
timed.fx.fx_finished.emit("blow_1", 41, 400, 400, "cleanup")
check(not ("cbt-strike-end.png" in timed.flow._shots), "end frame waits for the tick after tree exit")
timed.step()
check(timed.flow._shots.back() == "cbt-strike-end.png", "end captured one tick after the effect left the tree")
var text := FileAccess.get_file_as_string(timed.events_path)
check(text.find("\"skill_capture\"") >= 0 and text.find("\"phase\":\"peak\"") >= 0, "captures are correlated in events.jsonl")
func test_effect_outliving_lifetime_fails() -> void:
var rig := make_rig("full")
cast_twice_with_effect(rig)
rig.advance(1_000_000)
check(rig.status == "", "effect inside lifetime+1s does not fail yet")
rig.advance(1_000_000)
check(case_status(rig, "CBT-strike") == "FAIL" and rig.status == "FAIL", "effect still in the tree after lifetime+1s fails the skill case")
check(str(rig.report.finish().failures).find("blow_1") >= 0, "failure names the effect")
func test_effect_finished_late_fails() -> void:
var rig := make_rig("full")
cast_twice_with_effect(rig)
rig.fx.fx_finished.emit("blow_1", 41, 1500, 2600, "cleanup")
check(case_status(rig, "CBT-strike") == "FAIL", "effect reporting elapsed > lifetime+1s fails")
func test_effect_from_passed_case_fails_run() -> void:
var rig := make_rig()
enter_world(rig)
walk_route(rig)
confirm_position(rig)
spawn_mob(rig)
rig.step()
check(rig.flow.stage == "ATTACK", "attacking before the hit effect")
rig.fx.fx_spawned.emit("hit_spark", 77, 800)
kill_mob(rig)
check(case_status(rig, "NET-ATTACK-01") == "PASS" and rig.flow.stage == "DROP", "attack case passed while its effect is alive")
rig.advance(1_900_000)
check(rig.status == "FAIL" and str(rig.report.finish().failures).find("NET-ATTACK-01") >= 0,
"effect of an already-passed case outliving lifetime+1s makes the run FAIL")
func test_looping_effect_not_timed() -> void:
var rig := make_rig("full")
to_skill(rig)
rig.fx.fx_spawned.emit("aura_loop", 90, -1)
rig.step()
rig.client.damage.emit(MOB + 10, 30, 1)
rig.step()
rig.advance(600_000)
rig.client.damage.emit(MOB + 10, 30, 1)
rig.step()
check(case_status(rig, "CBT-strike") == "PASS", "looping owner-bound effect does not hold or fail the case")
# --- STB-01: soak rounds -------------------------------------------------------------
# Time is the injected monotonic clock; the soak ends on elapsed wall-clock duration,
# never on a round or frame count. Warp / real faults / resize need a confirmed
# environment or a window, otherwise they stay BLOCKED.
const WALL_ORIGIN_MS := 1_700_000_000_000
const SOAK_REST_US := 600_000_000
const SETTLE_US := 1_100_000
func soak_config() -> Dictionary:
var cfg := valid_config()
cfg.timeout_seconds = 9000
cfg.soak = {"duration_seconds": 7200, "rest_seconds": 30, "warmup_rounds": 1, "min_rounds": 10,
"reconnects": 10, "exits": 10,
"warp": {"status": "unconfirmed"},
"faults": {"status": "unconfirmed"},
"resolutions": {"sizes": [[1280, 720], [1440, 900], [1600, 1000]], "switches_per_size": 10}}
return cfg
func confirmed_warp(cfg: Dictionary) -> Dictionary:
cfg.soak.warp = {"status": "confirmed", "required": 20, "portal_cm": [1500, 2600], "destination_map_key": "outdoortrent/metin2_map_trent02",
"return_portal_cm": [52000, 1200], "cross_server": false}
return cfg
func confirmed_faults(cfg: Dictionary) -> Dictionary:
# The client connects to server.* on 127.0.0.1 (the proxy); upstream is the real test server.
cfg.soak.faults = {"status": "confirmed", "mode": "local_proxy", "per_type": 1, "unreachable_seconds": 10,
"upstream": {"auth_host": "192.0.2.10", "auth_port": 11000, "game_host": "192.0.2.10", "game_port": 13002}}
return cfg
func make_soak_rig(cfg := {}, with_window := true) -> Rig:
var rig := make_rig("soak", true, cfg if not cfg.is_empty() else soak_config())
rig.flow.wall_ms = func() -> int: return WALL_ORIGIN_MS + rig.now / 1000
if with_window:
rig.window = FakeWindow.new()
rig.flow.window = rig.window
return rig
func events_of(rig: Rig, kind: String) -> Array:
var out: Array = []
for line in FileAccess.get_file_as_string(rig.events_path).strip_edges().split("\n"):
var event: Variant = JSON.parse_string(line)
if event is Dictionary and event.kind == kind:
out.append(event)
return out
func finished_case(rig: Rig, id: String) -> Dictionary:
for item in rig.report.finish().cases:
if String(item.id) == id:
return item
return {}
## MOVE x2 -> MOVE_CONFIRM re-login -> settle -> TARGET -> ATTACK.
func round_to_attack(rig: Rig, round: int) -> void:
walk_route(rig)
confirm_position(rig)
rig.advance(SETTLE_US)
spawn_mob(rig, MOB + 100 * (round + 1))
rig.step()
## ATTACK -> DROP -> PICKUP passes; the flow moves on to FAULT / WARP / RESIZE / REST.
func round_attack_to_pickup(rig: Rig, round: int) -> void:
kill_mob(rig, MOB + 100 * (round + 1))
drop_item(rig, 7001 + round)
rig.client.ground.clear()
rig.client.inventory.append({"vnum": DROP_VNUM, "count": 1})
rig.step()
func resize_all(rig: Rig) -> void:
for i in 3:
if rig.flow.stage == "RESIZE":
rig.advance(600_000)
func soak_round(rig: Rig, round: int, rest_us := SOAK_REST_US) -> void:
round_to_attack(rig, round)
round_attack_to_pickup(rig, round)
resize_all(rig)
check(rig.flow.stage == "REST" or rig.flow.done, "round %d reaches REST (stage=%s)" % [round, rig.flow.stage])
rig.advance(rest_us)
func test_soak_config_contract() -> void:
check("soak" in Config.SUITES, "soak is a known suite")
var cfg := soak_config()
check(Config.validate(cfg).ok and Config.validate_soak(cfg).ok, "complete soak config is accepted: %s" % [Config.validate_soak(cfg).errors])
var cases := Config.required_cases(cfg, "soak")
for id in ["NET-PICKUP-01", "STB-DURATION-01", "STB-FRAMES-01", "STB-RECONNECT-01", "STB-WARP-01", "STB-DISCONNECT-01", "STB-RESIZE-01"]:
check(cases.has(id), "soak required cases include %s" % id)
check(not cases.has("CBT-strike") and not cases.has("STB-MEMORY-01"), "soak has no skill cases; memory is a runner case")
check(confirmed_warp(soak_config()).soak.warp.status == "confirmed" and Config.validate_soak(confirmed_warp(confirmed_faults(soak_config()))).ok,
"confirmed warp route and local fault proxy are accepted: %s" % [Config.validate_soak(confirmed_warp(confirmed_faults(soak_config()))).errors])
var bad := {
"no soak block": func(c: Dictionary) -> void: c.erase("soak"),
"rest below 30s": func(c: Dictionary) -> void: c.soak.rest_seconds = 10,
"duration below 2h": func(c: Dictionary) -> void: c.soak.duration_seconds = 600,
"fractional duration": func(c: Dictionary) -> void: c.soak.duration_seconds = 7200.5,
"fewer than 10 rounds": func(c: Dictionary) -> void: c.soak.min_rounds = 5,
"fewer than 10 reconnects": func(c: Dictionary) -> void: c.soak.reconnects = 3,
"fewer than 10 exits": func(c: Dictionary) -> void: c.soak.exits = 1,
"two resolutions": func(c: Dictionary) -> void: c.soak.resolutions.sizes = [[1280, 720], [1440, 900]],
"duplicate resolution": func(c: Dictionary) -> void: c.soak.resolutions.sizes = [[1280, 720], [1280, 720], [1600, 1000]],
"switches below 10": func(c: Dictionary) -> void: c.soak.resolutions.switches_per_size = 2,
"timeout without soak margin": func(c: Dictionary) -> void: c.timeout_seconds = 7300,
"unknown warp status": func(c: Dictionary) -> void: c.soak.warp.status = "maybe",
"confirmed warp without portal": func(c: Dictionary) -> void: c.soak.warp = {"status": "confirmed", "required": 20},
"odd warp count": func(c: Dictionary) -> void: confirmed_warp(c).soak.warp.required = 21,
"warp below 20": func(c: Dictionary) -> void: confirmed_warp(c).soak.warp.required = 10,
"warp to the same map": func(c: Dictionary) -> void: confirmed_warp(c).soak.warp.destination_map_key = c.map_key,
"fault mode other than local proxy": func(c: Dictionary) -> void: confirmed_faults(c).soak.faults.mode = "pf_firewall",
"unreachable longer than reconnect UI retries": func(c: Dictionary) -> void: confirmed_faults(c).soak.faults.unreachable_seconds = 60,
"credential inside soak": func(c: Dictionary) -> void: c.soak["proxy_password"] = "x",
"confirmed faults without upstream": func(c: Dictionary) -> void: confirmed_faults(c).soak.faults.erase("upstream"),
"fault proxy on a non-loopback listen address": func(c: Dictionary) -> void: confirmed_faults(c).server.game_host = "192.0.2.10",
"fault proxy upstream loops back to itself": func(c: Dictionary) -> void: confirmed_faults(c).soak.faults.upstream = c.server.duplicate(),
"fault upstream port out of range": func(c: Dictionary) -> void: confirmed_faults(c).soak.faults.upstream.auth_port = 70000,
}
for name in bad:
var broken := soak_config()
bad[name].call(broken)
check(not (Config.validate(broken).ok and Config.validate_soak(broken).ok), "soak config rejects: %s" % name)
var confirmed := confirmed_faults(confirmed_warp(soak_config()))
var confirmed_check := Config.validate_soak(confirmed)
check(Config.validate(confirmed).ok and confirmed_check.ok, "confirmed warp + fault proxy config is valid: %s" % [confirmed_check.errors])
var exit_run := soak_config()
exit_run.timeout_seconds = 900
check(not Config.validate_soak(exit_run).ok and Config.validate_soak(exit_run, false).ok,
"the soak timeout margin applies to the soak client only, not to exit runs sharing the scenario")
var exit_run_bad := confirmed_faults(soak_config())
exit_run_bad.soak.faults.upstream.auth_port = 70000
check(not Config.validate_soak(exit_run_bad, false).ok, "exit runs still reject an invalid fault proxy upstream")
var live_window = preload("res://testing/playable_window.gd").new(null)
var live_available: Dictionary = live_window.availability()
check(not live_available.ok and "headless" in String(live_available.reason) and live_window.viewport_size() == Vector2i.ZERO,
"live window adapter reports headless as unavailable")
var example := Config.load_file(ProjectSettings.globalize_path("res://../test/playable/scenario.example.json"))
var example_soak: Dictionary = example.config.get("soak", {})
check(example_soak.get("warp", {}).get("status", "") == "unconfirmed" and example_soak.get("faults", {}).get("status", "") == "unconfirmed",
"example scenario ships an unconfirmed soak block")
func test_soak_rounds_until_wall_clock_duration() -> void:
var rig := make_soak_rig()
enter_world(rig)
check(rig.flow.stage == "MOVE" and events_of(rig, "soak_round").size() == 1, "soak starts round 0 after the world is ready")
var rounds := 0
while not rig.flow.done and rounds < 20:
soak_round(rig, rounds)
rounds += 1
var report: Dictionary = rig.report.finish()
check(rig.flow.done and rounds == 12, "soak ends after the 7200s wall-clock budget, not a fixed round count (rounds=%d)" % rounds)
check(report.failures.is_empty(), "clean soak has no failures (no duplicate case results): %s" % [report.failures])
for id in ["NET-MOVE-01", "NET-MOVE-CONFIRM-01", "NET-PICKUP-01", "STB-DURATION-01", "STB-RECONNECT-01", "STB-RESIZE-01", "STB-FRAMES-01"]:
pass
for id in ["NET-MOVE-01", "NET-MOVE-CONFIRM-01", "NET-PICKUP-01", "STB-DURATION-01", "STB-RECONNECT-01", "STB-RESIZE-01"]:
check(case_status(rig, id) == "PASS", "%s passes after 12 rounds (%s)" % [id, finished_case(rig, id)])
check(case_status(rig, "STB-FRAMES-01") == "BLOCKED" and String(finished_case(rig, "STB-FRAMES-01").reason).find("metrics") >= 0,
"no frame metrics attached -> STB-FRAMES-01 BLOCKED")
check(case_status(rig, "STB-WARP-01") == "BLOCKED" and String(finished_case(rig, "STB-WARP-01").reason).find("not confirmed") >= 0,
"unconfirmed warp route -> STB-WARP-01 BLOCKED")
var disconnect_reason := String(finished_case(rig, "STB-DISCONNECT-01").reason)
check(case_status(rig, "STB-DISCONNECT-01") == "BLOCKED" and disconnect_reason.find("active reconnect") >= 0,
"no confirmed fault condition -> STB-DISCONNECT-01 BLOCKED, active reconnect is no substitute: %s" % disconnect_reason)
check(report.status == "BLOCKED", "required BLOCKED soak cases keep the run from PASS")
check(rig.client.reconnect_calls == 12 and rig.pc.walks.size() == 24, "each round re-walks the route and re-logs once")
var soak: Dictionary = report.get("soak", {})
check(soak.get("rounds", -1) == 12 and soak.get("active_reconnects", -1) == 12 and soak.get("real_faults", {}) == {"close": 0, "unreachable": 0},
"soak summary counts active reconnects separately from real faults: %s" % [soak])
check(soak.get("resizes", {}) == {"1280x720": 11, "1440x900": 12, "1600x1000": 12}, "a size already in effect is not counted as a switch: %s" % [soak.get("resizes", {})])
check(soak.get("elapsed_seconds", 0.0) >= 7200.0 and String(soak.get("cold_start", "")).find("未知") >= 0,
"summary records elapsed wall-clock seconds and the unknown cold-start cache state")
var rests := events_of(rig, "soak_rest")
check(rests.size() == 24, "each round has a rest start and end event (%d)" % rests.size())
var ok_rest := true
for i in range(0, rests.size() - 1, 2):
var start: Dictionary = rests[i].payload
var end: Dictionary = rests[i + 1].payload
ok_rest = ok_rest and start.state == "start" and end.state == "end" and start.round == end.round and start.round == i / 2 \
and int(end.wall_ms) - int(start.wall_ms) >= 30000
check(ok_rest, "rest windows carry round and unix wall_ms and last at least 30s")
check(rig.window.requests.size() == 36 and events_of(rig, "window_resized").size() == 36, "three logical sizes are requested every round")
var resized: Dictionary = events_of(rig, "window_resized")[1].payload
check(resized.requested_size == [1440.0, 900.0] and resized.logical_size == [1440.0, 900.0] and resized.physical_size == [2880.0, 1800.0] \
and resized.screen_scale == 2.0, "Retina scale: logical matches the request while physical pixels are recorded: %s" % [resized])
func test_soak_duration_is_wall_clock_not_rounds() -> void:
var rig := make_soak_rig()
enter_world(rig)
for round in 12:
soak_round(rig, round, 31_000_000)
check(not rig.flow.done and rig.flow.stage == "MOVE", "12 short rounds do not end a 2h soak")
soak_round(rig, 12, 7_200_000_000)
check(rig.flow.done and case_status(rig, "STB-DURATION-01") == "PASS", "the round that crosses the wall-clock deadline ends the soak")
check(rig.report.finish().soak.rounds == 13, "13 completed rounds recorded")
var short := make_soak_rig()
enter_world(short)
soak_round(short, 0, 7_300_000_000)
check(short.flow.done and case_status(short, "STB-DURATION-01") == "BLOCKED" \
and String(finished_case(short, "STB-DURATION-01").reason).find("10") >= 0, "duration reached with fewer than min_rounds is BLOCKED")
check(case_status(short, "STB-RECONNECT-01") == "BLOCKED", "one reconnect of ten is BLOCKED, not PASS")
func test_soak_later_round_failure() -> void:
var rig := make_soak_rig()
enter_world(rig)
soak_round(rig, 0)
soak_round(rig, 1)
round_to_attack(rig, 2)
kill_mob(rig, MOB + 300)
drop_item(rig, 7010)
rig.client.ground.clear()
rig.step()
rig.advance(2_500_000)
var report: Dictionary = rig.report.finish()
check(rig.flow.done and report.status == "FAIL", "a pickup failure in round 2 fails the soak")
check(case_status(rig, "NET-PICKUP-01") == "PASS" and str(report.failures).find("soak round 2") >= 0,
"the round-0 case result stays; the later failure is a run failure naming the round: %s" % [report.failures])
check(str(report.failures).find("duplicate") < 0, "later rounds never write duplicate case results")
check(case_status(rig, "STB-DURATION-01") == "FAIL", "soak ended early by a failure -> STB-DURATION-01 FAIL")
func test_soak_later_round_fixture_blocked() -> void:
var rig := make_soak_rig()
enter_world(rig)
soak_round(rig, 0)
walk_route(rig)
confirm_position(rig)
rig.advance(SETTLE_US)
rig.advance(31_000_000)
var report: Dictionary = rig.report.finish()
check(rig.flow.done and report.status == "BLOCKED" and report.failures.is_empty(), "no monster in a later round ends the soak BLOCKED")
check(case_status(rig, "STB-DURATION-01") == "BLOCKED" and String(finished_case(rig, "STB-DURATION-01").reason).find("round 1") >= 0,
"STB-DURATION-01 names the blocked round: %s" % [finished_case(rig, "STB-DURATION-01")])
func test_soak_faults_confirmed() -> void:
var rig := make_soak_rig(confirmed_faults(soak_config()), false)
enter_world(rig)
round_to_attack(rig, 0)
round_attack_to_pickup(rig, 0)
check(rig.flow.stage == "FAULT", "confirmed fault condition runs after the pickup")
var requests := events_of(rig, "fault_request")
check(requests.size() == 1 and requests[0].payload.type == "close" and requests[0].payload.attempt == 1,
"fault request is written to events.jsonl for the local proxy: %s" % [requests])
check(rig.client.reconnect_calls == 1, "a real fault is not produced by calling reconnect()")
rig.client.disconnected.emit("closed by peer")
rig.step()
check(not rig.flow.done and rig.flow.stage == "FAULT", "the requested disconnect is tolerated")
rig.client.entered_game.emit()
rig.step()
rig.advance(SETTLE_US)
check(rig.flow.stage == "REST", "recovered through the production reconnect path, then rests (stage=%s)" % rig.flow.stage)
check(rig.client.reconnect_calls == 1 and events_of(rig, "fault_recovered").size() == 1, "recovery counted without an extra active reconnect")
rig.advance(SOAK_REST_US)
round_to_attack(rig, 1)
round_attack_to_pickup(rig, 1)
check(rig.flow.stage == "FAULT" and events_of(rig, "fault_request")[-1].payload.type == "unreachable", "second fault covers short unreachability")
rig.client.disconnected.emit("connect refused")
rig.client.login_failed.emit("connect refused")
rig.client.disconnected.emit("connect refused")
rig.advance(5_000_000)
rig.client.entered_game.emit()
rig.step()
rig.advance(SETTLE_US)
check(rig.flow.stage == "REST" and not rig.flow.done, "unreachable window with failed retries recovers")
rig.advance(7_300_000_000)
var soak: Dictionary = rig.report.finish().soak
check(soak.real_faults == {"close": 1, "unreachable": 1} and soak.active_reconnects == 2, "real faults and active reconnects are counted apart: %s" % [soak])
check(case_status(rig, "STB-DISCONNECT-01") == "PASS", "both fault types recovered -> STB-DISCONNECT-01 PASS")
func test_soak_fault_without_disconnect_fails() -> void:
var rig := make_soak_rig(confirmed_faults(soak_config()), false)
enter_world(rig)
round_to_attack(rig, 0)
round_attack_to_pickup(rig, 0)
rig.advance(31_000_000)
check(rig.flow.done and case_status(rig, "STB-DISCONNECT-01") == "FAIL", "no disconnect observed after a fault request fails")
var late := make_soak_rig(confirmed_faults(soak_config()), false)
enter_world(late)
round_to_attack(late, 0)
round_attack_to_pickup(late, 0)
late.client.disconnected.emit("closed by peer")
late.advance(131_000_000)
check(late.flow.done and case_status(late, "STB-DISCONNECT-01") == "FAIL", "no recovery within the reconnect budget fails")
func test_soak_warp_confirmed() -> void:
var cfg := confirmed_warp(soak_config())
var rig := make_soak_rig(cfg, false)
enter_world(rig)
round_to_attack(rig, 0)
round_attack_to_pickup(rig, 0)
check(rig.flow.stage == "WARP", "confirmed warp route runs after the pickup")
var portal := MapCoord.to_world(Vector3(15.0, 0.0, -26.0))
check(rig.pc.walks[-1].is_equal_approx(portal), "warp walks into the configured portal through PlayerController.walk_to")
rig.app.map_key = cfg.soak.warp.destination_map_key
rig.client.disconnected.emit("warp")
rig.client.entered_game.emit()
rig.step()
rig.advance(SETTLE_US)
check(rig.flow.stage == "WARP" and events_of(rig, "warp_arrived").size() == 1, "arrival in the destination starts the return leg")
rig.app.map_key = cfg.map_key
rig.client.disconnected.emit("warp")
rig.client.entered_game.emit()
rig.step()
rig.advance(SETTLE_US)
check(rig.flow.stage == "REST" and events_of(rig, "warp_arrived").size() == 2, "out-and-back counts two map changes")
rig.advance(7_300_000_000)
var soak: Dictionary = rig.report.finish().soak
check(soak.warps == 2 and case_status(rig, "STB-WARP-01") == "BLOCKED", "2 of 20 warps is BLOCKED, not PASS (%s)" % [soak])
var wrong := make_soak_rig(confirm_copy(cfg), false)
enter_world(wrong)
round_to_attack(wrong, 0)
round_attack_to_pickup(wrong, 0)
wrong.client.entered_game.emit()
wrong.step()
wrong.advance(SETTLE_US)
check(wrong.flow.done and case_status(wrong, "STB-WARP-01") == "FAIL", "a reconnect that stays on the old map fails the warp")
func confirm_copy(cfg: Dictionary) -> Dictionary:
return cfg.duplicate(true)
func test_soak_warp_without_server_reconnect_fails() -> void:
var cfg := confirmed_warp(soak_config())
var rig := make_soak_rig(cfg, false)
enter_world(rig)
round_to_attack(rig, 0)
round_attack_to_pickup(rig, 0)
rig.app.map_key = cfg.soak.warp.destination_map_key
rig.advance(SETTLE_US)
rig.advance(121_000_000)
check(rig.flow.done and case_status(rig, "STB-WARP-01") == "FAIL", "a map change without a new server connection epoch is not a warp")
func test_soak_rebind_checks() -> void:
var dup := make_soak_rig()
enter_world(dup)
walk_route(dup)
dup.net_world.add_entity_node(3001)
dup.net_world.add_entity_node(3001)
confirm_position(dup)
dup.advance(SETTLE_US)
check(dup.flow.done and case_status(dup, "STB-RECONNECT-01") == "FAIL" and String(finished_case(dup, "STB-RECONNECT-01").reason).find("duplicate entity") >= 0,
"duplicate entity nodes after re-login fail STB-RECONNECT-01")
var queued := make_soak_rig()
enter_world(queued)
walk_route(queued)
queued.net_world.add_entity_node(3002)
var fading := queued.net_world.add_entity_node(3002)
fading.queue_free()
confirm_position(queued)
queued.advance(SETTLE_US)
check(queued.flow.stage == "TARGET", "a node already queued for deletion is not a duplicate")
var residue := make_soak_rig()
enter_world(residue)
walk_route(residue)
residue.net_play._target_vid = 99999
confirm_position(residue)
residue.advance(SETTLE_US)
check(residue.flow.done and String(finished_case(residue, "STB-RECONNECT-01").reason).find("target") >= 0, "old target VID kept after re-login fails")
var twice := make_soak_rig()
enter_world(twice)
walk_route(twice)
twice.client.entered_game.emit()
twice.client.entered_game.emit()
twice.step()
check(twice.flow.done and String(finished_case(twice, "STB-RECONNECT-01").reason).find("duplicate entered_game") >= 0,
"two entered_game signals without a disconnect fail as a duplicate signal")
var stray := make_soak_rig()
enter_world(stray)
round_to_attack(stray, 0)
stray.client.entered_game.emit()
check(stray.flow.done and stray.report.finish().status == "FAIL", "an unrequested entered_game during combat fails the soak")
var settling := make_soak_rig()
enter_world(settling)
walk_route(settling)
confirm_position(settling)
check(settling.flow.stage == "MOVE_CONFIRM", "rebind waits for the scene to settle before checking")
settling.advance(SETTLE_US)
check(settling.flow.stage == "TARGET", "settled rebind continues")
func test_soak_resize_contract() -> void:
var none := make_soak_rig({}, false)
enter_world(none)
soak_round(none, 0, 7_300_000_000)
check(String(finished_case(none, "STB-RESIZE-01").reason).find("window") >= 0 and case_status(none, "STB-RESIZE-01") == "BLOCKED",
"no window adapter -> STB-RESIZE-01 BLOCKED")
var headless := make_soak_rig()
headless.window.supported = false
headless.window.reason = "headless display server has no window"
enter_world(headless)
soak_round(headless, 0, 7_300_000_000)
check(case_status(headless, "STB-RESIZE-01") == "BLOCKED" and String(finished_case(headless, "STB-RESIZE-01").reason).find("headless") >= 0,
"headless window -> BLOCKED with the adapter reason")
var small := make_soak_rig()
small.window.max_logical = Vector2i(1500, 950)
enter_world(small)
soak_round(small, 0, 7_300_000_000)
var reason := String(finished_case(small, "STB-RESIZE-01").reason)
check(case_status(small, "STB-RESIZE-01") == "BLOCKED" and reason.find("1600x1000") >= 0, "a size larger than the usable screen is BLOCKED and named: %s" % reason)
check(events_of(small, "window_size_rejected").size() == 1 and small.report.finish().failures.is_empty(), "screen-limited size is recorded, not a failure")
var stuck := make_soak_rig()
stuck.window.stuck = true
enter_world(stuck)
round_to_attack(stuck, 0)
round_attack_to_pickup(stuck, 0)
stuck.advance(600_000)
stuck.advance(5_100_000)
check(stuck.flow.done and case_status(stuck, "STB-RESIZE-01") == "FAIL", "window that never reaches the requested logical size fails")
## Push one frame interval tagged with a phase, as playable_live_test does each frame.
func push_frame(metrics: RefCounted, rig: Rig, phase: String, ms: int, marker := "") -> void:
metrics.set_tags({"phase": phase, "map": "m", "actor_count": "1-8", "gpu_skin": false})
if not marker.is_empty():
metrics.mark(marker, {})
metrics.add_interval_us(ms * 1000, rig.now)
func metrics_rig() -> Array:
var rig := make_soak_rig()
var metrics: RefCounted = Metrics.new()
metrics.clock_us = func() -> int: return rig.now
metrics.configure({"window_seconds": 3600})
rig.flow.metrics = metrics
return [rig, metrics]
func test_soak_frames_gate() -> void:
var pair := metrics_rig()
var rig: Rig = pair[0]
var metrics: RefCounted = pair[1]
enter_world(rig)
for round in 3:
round_to_attack(rig, round)
check(rig.flow.stage == "ATTACK", "round %d attacking" % round)
if round != 1:
push_frame(metrics, rig, "attack", 150, "model_build")
push_frame(metrics, rig, "loading", 400)
round_attack_to_pickup(rig, round)
resize_all(rig)
rig.advance(SOAK_REST_US if round < 2 else 7_300_000_000)
var frames := finished_case(rig, "STB-FRAMES-01")
check(frames.get("status", "") == "FAIL" and String(frames.reason).find("attack") >= 0,
"attack with >100ms frames in 2 of 3 consecutive rounds fails STB-FRAMES-01: %s" % [frames])
var pair2 := metrics_rig()
var clean: Rig = pair2[0]
var clean_metrics: RefCounted = pair2[1]
enter_world(clean)
for round in 3:
round_to_attack(clean, round)
push_frame(clean_metrics, clean, "loading", 400)
push_frame(clean_metrics, clean, "attack", 150 if round == 0 else 16, "model_build")
round_attack_to_pickup(clean, round)
resize_all(clean)
clean.advance(SOAK_REST_US if round < 2 else 7_300_000_000)
check(case_status(clean, "STB-FRAMES-01") == "PASS", "attributed single severe frame and loading frames pass: %s" % [finished_case(clean, "STB-FRAMES-01")])
var pair3 := metrics_rig()
var loose: Rig = pair3[0]
var loose_metrics: RefCounted = pair3[1]
enter_world(loose)
round_to_attack(loose, 0)
push_frame(loose_metrics, loose, "attack", 60)
round_attack_to_pickup(loose, 0)
resize_all(loose)
loose.advance(7_300_000_000)
var loose_case := finished_case(loose, "STB-FRAMES-01")
check(loose_case.get("status", "") == "BLOCKED" and String(loose_case.reason).find("attribution") >= 0,
"a gameplay frame >50ms without attribution keeps STB-FRAMES-01 BLOCKED: %s" % [loose_case])
func test_soak_gameplay_not_allowed() -> void:
var rig := make_rig("soak", false, soak_config())
enter_world(rig)
check(rig.flow.done and case_status(rig, "STB-DURATION-01") == "BLOCKED" and rig.pc.walks.is_empty(), "soak without allow_gameplay is BLOCKED with no input")