1251 lines
57 KiB
GDScript
1251 lines
57 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 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")
|