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 test_probe_releases_old_registries() -> void: var observer := Probe.new() for i in range(30): var registry := FakeFx.new() var reference: WeakRef = weakref(registry) observer.watch_local(registry, "fx_spawned", "fx_spawned") observer.watch_local(registry, "fx_finished", "fx_finished") check(observer.bound_count() == 2, "probe prunes old scene bindings") registry = null check(reference.get_ref() == null, "probe does not retain the old scene's effect registry") observer.disconnect_all() observer.free() func run() -> void: MapCoord.set_base(Vector2.ZERO) test_config_contract() test_report_contract() test_probe_releases_old_registries() 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")