From 96ea2dd1ea171f2e986c2efc5a1611f523b6a9b6 Mon Sep 17 00:00:00 2001 From: algodoogle Date: Tue, 28 Jul 2026 19:41:31 +0100 Subject: [PATCH] mp fix --- Net/network_manager.gd | 11 ++ Net/world_layout.gd | 82 +++++++++------ Prefabs/despawning_item.gd | 28 ++++- Scenes/JonScene.tscn | 15 ++- Scenes/multiPlayer.tscn | 4 + Scenes/multiplayer_world.gd | 38 ++++++- Stations/Hob.tscn | 3 + Stations/hob.gd | 60 ++++++++--- Stations/sink.gd | 50 ++++++--- Stations/sink.tscn | 5 +- UI/progress_bar_3d.gd | 4 + test/mp_test_driver.gd | 176 ++++++++++++++++++++++++++++++-- test/multiPlayerTest.tscn | 6 +- test/play_mp_test.ps1 | 15 +-- test/run_mp_test.ps1 | 86 +++++++++++----- test/run_mp_test_windowed.ps1 | 11 +- test/run_tests_in_editor.gd | 137 +++++++++++++++++++++++++ test/run_tests_in_editor.gd.uid | 1 + 18 files changed, 620 insertions(+), 112 deletions(-) create mode 100644 test/run_tests_in_editor.gd create mode 100644 test/run_tests_in_editor.gd.uid diff --git a/Net/network_manager.gd b/Net/network_manager.gd index 9024d7c..efd08a7 100644 --- a/Net/network_manager.gd +++ b/Net/network_manager.gd @@ -297,6 +297,17 @@ func _release_from_snap_zones(item: Node) -> void: if zone.picked_up_object == item: log_line("releasing %s from %s's snap zone (authority just granted elsewhere)" % [item.name, zone.get_parent().name]) zone.drop_object() + # Make the zone forget the item as well. These zones are snap_mode=RANGE, + # so every frame they re-grab anything still listed in their grab area + # that can be picked up — and Jolt does not emit body_exited when let_go() + # switches the item's collision layer back out of the zone's mask, so the + # entry goes stale and never clears. The station then snatches the item + # straight back off the player who just took it, teleporting it home. + # Bringing it near again re-adds it properly (a held item is on the layer + # the zone watches), and releasing next to a station is handled + # explicitly by _try_snap_into_station. + if zone._object_in_grab_area.has(item): + zone._object_in_grab_area.erase(item) # Snap the item into the nearest empty station snap zone within grab range. diff --git a/Net/world_layout.gd b/Net/world_layout.gd index 067024d..72ee21e 100644 --- a/Net/world_layout.gd +++ b/Net/world_layout.gd @@ -18,51 +18,67 @@ func get_node_data(node): var data = {} data["scene"] = node.scene_file_path data["name"] = node.name - data["xform"] = node.transform + # global, not local: the copies are respawned under WorldContent, so an + # authored node that was nested inside another (JonScene parents Counter5 + # under Counter3) would otherwise land in the wrong place. + data["xform"] = node.global_transform data["props"] = {} var scrip = node.get_script() if scrip: for i in scrip.get_script_property_list(): - if i.usage & PROPERTY_USAGE_STORAGE: - data["props"][i["name"]] = node.get(i["name"]) + # Only authored configuration — i.e. @export vars, which are the ones + # the editor exposes. Plain script variables are live runtime state: + # copying those and replaying them into a fresh instance re-runs their + # setters before the node is in the tree, so any setter touching an + # @onready reference blows up (Table's _state calls into its progress + # bar, which is still null at that point). + if not (i.usage & PROPERTY_USAGE_EDITOR): + continue + if str(i["name"]).begins_with("_"): + continue + data["props"][i["name"]] = node.get(i["name"]) return data +## The authored station nodes sitting in the current scene: anything instanced +## from res://Stations/. Exposed as nodes (not just data) because every peer has +## to remove these originals — the server replaces them with replicated copies, +## and a client that kept its own would end up showing two of everything. +func get_station_nodes() -> Array[Node]: + return _authored_nodes("res://Stations/", "StaticBody3D") + + +## Likewise for authored items. Containers/ counts as items too — plates and +## trays are things the player carries, and a client that never received one +## would have an incomplete world. +func get_item_nodes() -> Array[Node]: + var found := _authored_nodes("res://Items/", "XRToolsPickable") + found.append_array(_authored_nodes("res://Containers/", "XRToolsPickable")) + return found + + +func _authored_nodes(dir: String, type: String) -> Array[Node]: + var scenes := [] + for file in ResourceLoader.list_directory(dir): + scenes.append(dir + file) + var found: Array[Node] = [] + for node in get_tree().root.find_children("*", type, true, false): + if node.scene_file_path in scenes and not found.has(node): + found.append(node) + return found + + func get_stations() -> Array[Dictionary]: - var Stations = ResourceLoader.list_directory("res://Stations/") - - var Stations2 = [] - for i in Stations: - Stations2.append("res://Stations/" + i) - Stations = Stations2 - - var Stations_nodes = [] - for i in get_tree().root.find_children("*", "StaticBody3D", true, false): - if i.scene_file_path in Stations: - Stations_nodes.append(i) - var data: Array[Dictionary] = [] - for i in Stations_nodes: - data.append(get_node_data(i)) + for node in get_station_nodes(): + data.append(get_node_data(node)) return data - - -func get_items() -> Array[Dictionary]: - var Items = ResourceLoader.list_directory("res://Items/") - var Items2 = [] - for i in Items: - Items2.append("res://Items/" + i) - Items = Items2 - - var Items_nodes = [] - for i in get_tree().root.find_children("*", "XRToolsPickable", true, false): - if i.scene_file_path in Items: - Items_nodes.append(i) - + +func get_items() -> Array[Dictionary]: var data: Array[Dictionary] = [] - for i in Items_nodes: - data.append(get_node_data(i)) + for node in get_item_nodes(): + data.append(get_node_data(node)) return data diff --git a/Prefabs/despawning_item.gd b/Prefabs/despawning_item.gd index a5462ed..c24100c 100644 --- a/Prefabs/despawning_item.gd +++ b/Prefabs/despawning_item.gd @@ -23,19 +23,41 @@ func _ready() -> void: func _process(delta: float) -> void: + # Despawning is a world decision, so only the owner of world logic runs the + # clock. Every peer used to run its own copy: the countdown drifted between + # them and the blink below toggled `visible` locally, so the same item was + # shown on one peer and hidden on the other. Non-owners just follow the + # server, which removes the item for everyone when its time is up. + if not NetworkManager.owns_world(): + return + # if we're held or moving, reset timer and return if _pickable.get_picked_up_by() or _rigid.linear_velocity.length_squared() > pow(minimum_speed_square,2): _time_left = time_to_despawn - get_parent().visible = true + _set_visible(true) return - + # Count down to zero and despawn _time_left -= delta if _time_left <= 0: print("DespawningItem despawning!" , get_parent()) NetworkManager.despawn_item(get_parent()) + # Stop counting: despawn_item() only queues the free, so without this we + # keep re-reporting the same item every frame until it actually goes. + set_process(false) + return # Make item blink in and out before despawning if _time_left < time_to_despawn / 2: - get_parent().visible = int(_time_left * 5.0) %2 == 0 + _set_visible(int(_time_left * 5.0) % 2 == 0) + + +# `visible` is not a replicated property, so a blink driven only here would make +# the item flicker on the host and stay solid on clients. Until it is synced, +# only warn when there is nobody else to disagree with. +func _set_visible(value: bool) -> void: + if NetworkManager.is_online(): + get_parent().visible = true + return + get_parent().visible = value diff --git a/Scenes/JonScene.tscn b/Scenes/JonScene.tscn index a7d194d..a2abefe 100644 --- a/Scenes/JonScene.tscn +++ b/Scenes/JonScene.tscn @@ -1,6 +1,6 @@ [gd_scene format=3 uid="uid://do6mrslyqe5qe"] -[ext_resource type="Script" uid="uid://b4ldkd3ngs0vp" path="res://main.gd" id="1_57ppd"] +[ext_resource type="Script" uid="uid://d1cefhe3yyyds" path="res://Scenes/multiplayer_world.gd" id="1_57ppd"] [ext_resource type="PackedScene" uid="uid://j5s5wus7tuhb" path="res://XROrigin.tscn" id="2_o1b3t"] [ext_resource type="Material" uid="uid://cmia50cfqxxo4" path="res://Textures/dev_material_3d.tres" id="3_irf4n"] [ext_resource type="PackedScene" uid="uid://j7caslh27nor" path="res://Stations/Hob.tscn" id="4_ctden"] @@ -45,9 +45,20 @@ script = ExtResource("1_57ppd") [node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1177077250] environment = SubResource("Environment_bvwq1") -[node name="XROrigin3D" parent="." unique_id=2055526621 instance=ExtResource("2_o1b3t")] +[node name="XROrigin3D" parent="." unique_id=2055526621 groups=["local_xr_origin"] instance=ExtResource("2_o1b3t")] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9667189, 0) +[node name="WorldContent" type="Node3D" parent="."] + +[node name="Players" type="Node3D" parent="."] + +[node name="ItemsSpawner" type="MultiplayerSpawner" parent="."] +spawn_path = NodePath("../WorldContent") + +[node name="PlayersSpawner" type="MultiplayerSpawner" parent="."] +_spawnable_scenes = PackedStringArray("res://Player/net_player.tscn") +spawn_path = NodePath("../Players") + [node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1376644384] transform = Transform3D(-0.8660254, -0.43301278, 0.24999999, 0.3022632, -0.05510214, 0.9516306, -0.39829266, 0.8997021, 0.17860365, 0, 0, 0) diff --git a/Scenes/multiPlayer.tscn b/Scenes/multiPlayer.tscn index 9e25add..c3043ae 100644 --- a/Scenes/multiPlayer.tscn +++ b/Scenes/multiPlayer.tscn @@ -4,6 +4,7 @@ [ext_resource type="PackedScene" uid="uid://j5s5wus7tuhb" path="res://XROrigin.tscn" id="2_g30gi"] [ext_resource type="Material" uid="uid://cmia50cfqxxo4" path="res://Textures/dev_material_3d.tres" id="3_75ecy"] [ext_resource type="Sky" uid="uid://c1abtpwhdm2d5" path="res://Textures/sky/NightSkyHDRI009_16K.tres" id="5_c3xgf"] +[ext_resource type="Script" uid="uid://biu4qr3nr1eny" path="res://test/mp_test_driver.gd" id="99_mptst"] [sub_resource type="BoxShape3D" id="BoxShape3D_vlqg6"] size = Vector3(15, 0.1, 15) @@ -74,3 +75,6 @@ shape = SubResource("BoxShape3D_arao0") [node name="CollisionShape3D4" type="CollisionShape3D" parent="StaticBody3D" unique_id=1431367494] transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 7.502467, 9.197384, -0.018813023) shape = SubResource("BoxShape3D_arao0") + +[node name="TestDriver" type="Node" parent="."] +script = ExtResource("99_mptst") diff --git a/Scenes/multiplayer_world.gd b/Scenes/multiplayer_world.gd index 7c81967..4848afc 100644 --- a/Scenes/multiplayer_world.gd +++ b/Scenes/multiplayer_world.gd @@ -72,12 +72,34 @@ func _on_session_started(_is_server: bool) -> void: ## Spawns the world's stations/items exactly once, on the machine that owns ## world logic (server or offline). Safe to call multiple times/entry points. func _populate_world_if_owner() -> void: - if not NetworkManager.owns_world() or _populated or not populate_from_layout: + if _populated or not populate_from_layout: return + # WorldLayout reads the live scene tree to find what to replicate, so it + # needs to be an instance sitting in that tree — its methods can't be called + # on the class itself. + var layout := WorldLayout.new() + add_child(layout) + var authored := layout.get_station_nodes() + authored.append_array(layout.get_item_nodes()) + + # The stations and items authored into the scene file are a *template*, not + # the live world. Only the server turns them into real objects, spawned + # through NetworkManager so they replicate. Every peer therefore drops its + # own authored copies: the client would otherwise show its local originals + # on top of the server's replicated ones, and the two sets would drift apart + # because only the server's are synced. + if not NetworkManager.owns_world(): + NetworkManager.log_line("Clearing %d authored nodes; the server's copies replace them" % authored.size()) + _remove_authored(authored) + layout.queue_free() + return + _populated = true GameManager.meals_in_play = ["hamburger"] - var stations := WorldLayout.get_stations() - var items := WorldLayout.get_items() + var stations := layout.get_stations() + var items := layout.get_items() + layout.queue_free() + _remove_authored(authored) NetworkManager.log_line("Populating world: %d stations, %d items" % [stations.size(), items.size()]) for d in stations: NetworkManager.spawn_item(d["scene"], d["xform"], d["name"], d["props"]) @@ -86,6 +108,16 @@ func _populate_world_if_owner() -> void: NetworkManager.log_line("World populated") +# Free the authored template nodes. Done immediately rather than with +# queue_free() so the names are released before the replicated copies are +# spawned under the same ones. +func _remove_authored(nodes: Array[Node]) -> void: + for node in nodes: + if is_instance_valid(node): + node.get_parent().remove_child(node) + node.free() + + ## Only the server (or the single offline machine) materialises player ## avatars; MultiplayerSpawner replicates the result to everyone else, ## including late joiners. diff --git a/Stations/Hob.tscn b/Stations/Hob.tscn index 283f875..a0a6ad3 100644 --- a/Stations/Hob.tscn +++ b/Stations/Hob.tscn @@ -22,6 +22,9 @@ properties/0/replication_mode = 1 properties/1/path = NodePath(".:cooking_result_time") properties/1/spawn = true properties/1/replication_mode = 1 +properties/2/path = NodePath(".:cooking_result") +properties/2/spawn = true +properties/2/replication_mode = 1 [sub_resource type="Animation" id="Animation_7uuqv"] length = 0.001 diff --git a/Stations/hob.gd b/Stations/hob.gd index c1345c0..cbde497 100644 --- a/Stations/hob.gd +++ b/Stations/hob.gd @@ -6,24 +6,63 @@ const RECIPE_MANAGER = preload("res://RecipeManager.gd") @onready var snap_zone: XRToolsSnapZone = $XRToolsSnapZone @onready var progress_bar: ProgressBar3D = $ProgressBar3D -var time_cooked = 0 -var cooking_result: String -var cooking_result_time = 0 +## Cooking state. All three are replicated (see Hob.tscn's Sync node) and each +## refreshes the display when it changes, which is what makes the hob look alive +## on a client: only the world owner runs the station's _process and its snap +## zone, so a client never reaches the code below by itself and would otherwise +## show a hob that never lights up. +var time_cooked: float = 0.0: set = _set_time_cooked +var cooking_result: String = "": set = _set_cooking_result +var cooking_result_time: float = 0.0: set = _set_cooking_result_time + + +func _set_time_cooked(value: float) -> void: + if is_equal_approx(time_cooked, value): + return + time_cooked = value + _refresh_display() + + +func _set_cooking_result(value: String) -> void: + if cooking_result == value: + return + cooking_result = value + _refresh_display() + # The flames follow whether we're cooking, on every peer. + _play_animation("hob" if not cooking_result.is_empty() else "RESET") + + +func _set_cooking_result_time(value: float) -> void: + if is_equal_approx(cooking_result_time, value): + return + cooking_result_time = value + _refresh_display() + func _ready() -> void: if not progress_bar or progress_bar is not ProgressBar3D: push_error("Hob missing reference to progressbar, or wrong type:", progress_bar) snap_zone.has_picked_up.connect(_on_object_picked_up) snap_zone.has_dropped.connect(_on_object_dropped) - _refresh_progress_bar() + _refresh_display() -func _refresh_progress_bar() -> void: +func _play_animation(name: String) -> void: + # Replicated setters can fire before the node is in the tree (spawn payload), + # when the @onready children don't exist yet. + var player := get_node_or_null("AnimationPlayer") as AnimationPlayer + if player: + player.play(name) + + +func _refresh_display() -> void: + # Guarded for the same reason as _play_animation. + if not progress_bar: + return var progress := 0.0 if cooking_result and cooking_result_time > 0: progress = clampf(float(time_cooked) / cooking_result_time, 0.0, 1.0) - #print("time_cooked: %s / cooking_result_time: %s = progress: %s" % [time_cooked, cooking_result_time, progress]) progress_bar.set_progress(progress) progress_bar.set_bar_visible(not cooking_result.is_empty()) progress_bar.override_fill_color(Color.RED if cooking_result == "charcoal" else Color.GREEN) @@ -44,25 +83,20 @@ func _on_object_picked_up(_item) -> void: cooking_result = result cooking_result_time = RECIPE_MANAGER.get_cooking_time(_food_item.id) print("Hob: set cooking_result ", cooking_result) - _refresh_progress_bar() - $AnimationPlayer.play("hob") + # The setters above already refresh the display and start the flames. func _on_object_dropped() -> void: print("Hob: object drop ") - $AnimationPlayer.play("RESET") time_cooked = 0 cooking_result = "" cooking_result_time = 0 - _refresh_progress_bar() func convert_held_to_item(_item: String) -> void: if not NetworkManager.owns_world(): return print("Hob converting ", _item) - $AnimationPlayer.play("RESET") - var old_pickable = snap_zone.picked_up_object if not old_pickable: @@ -86,7 +120,7 @@ func _process(delta: float) -> void: if cooking_result: #print("Hob cooking_result: ", cooking_result) time_cooked += cook_speed * delta - _refresh_progress_bar() + _refresh_display() if time_cooked >= cooking_result_time: time_cooked = 0 convert_held_to_item(cooking_result) diff --git a/Stations/sink.gd b/Stations/sink.gd index 8f9e6d6..b9ecec0 100644 --- a/Stations/sink.gd +++ b/Stations/sink.gd @@ -6,10 +6,41 @@ const plate_wash_time: float = 3.0 @onready var snap_zone: XRToolsSnapZone = $XRToolsSnapZone @onready var progress_bar: ProgressBar3D = $ProgressBar3D -var time_washed: float = 0.0 -var is_washing: bool = false +## Washing state. Both are replicated (see sink.tscn's Sync node) and refresh the +## display when they change. Only the world owner runs this station's _process +## and snap zone, so without this a client never updates its bar — it stayed +## frozen on screen long after the plate came out clean. +var time_washed: float = 0.0: set = _set_time_washed +var is_washing: bool = false: set = _set_is_washing var plate: PlateController = null + +func _set_time_washed(value: float) -> void: + if is_equal_approx(time_washed, value): + return + time_washed = value + _refresh_progress_bar() + + +func _set_is_washing(value: bool) -> void: + if is_washing == value: + return + is_washing = value + _refresh_progress_bar() + _set_effects_playing(is_washing) + + +# Water, bubbles and the animation follow the washing state on every peer, not +# just the one running the logic. +func _set_effects_playing(playing: bool) -> void: + var player := get_node_or_null("AnimationPlayer") as AnimationPlayer + if player: + player.play("working" if playing else "RESET") + for effect in ["water", "bubbles", "GPUParticles3D"]: + var node := get_node_or_null(effect) as Node3D + if node: + node.visible = playing + func _ready() -> void: if not progress_bar or progress_bar is not ProgressBar3D: push_error("Sink missing reference to progressbar, or wrong type:", progress_bar) @@ -21,6 +52,8 @@ func _ready() -> void: func _refresh_progress_bar() -> void: + if not progress_bar: + return progress_bar.set_progress(clampf(float(time_washed) / plate_wash_time, 0.0, 1.0)) progress_bar.set_bar_visible(is_washing) @@ -29,12 +62,8 @@ func _on_object_picked_up(_item) -> void: print("Sink: object picked up: ", _item) plate = _item.get_node_or_null("PlateController") as PlateController if plate: + # The setter starts the effects and shows the bar, here and on clients. is_washing = plate.is_dirty - if is_washing: - $AnimationPlayer.play("working") - $bubbles.show() - $water.show() - $GPUParticles3D.show() func _on_object_dropped() -> void: @@ -46,13 +75,10 @@ func _reset_sink(): time_washed = 0 is_washing = false plate = null - $AnimationPlayer.play("RESET") - $water.hide() - $bubbles.hide() - $GPUParticles3D.hide() - _refresh_progress_bar() func complete_washing(): + if not plate: + return plate.is_dirty = false _reset_sink() print("Sink washing complete!") diff --git a/Stations/sink.tscn b/Stations/sink.tscn index 5739331..01f2933 100644 --- a/Stations/sink.tscn +++ b/Stations/sink.tscn @@ -16,9 +16,12 @@ stereo = true size = Vector3(0.5, 0.128, 0.5) [sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_sink"] -properties/0/path = NodePath(".:progress") +properties/0/path = NodePath(".:time_washed") properties/0/spawn = true properties/0/replication_mode = 1 +properties/1/path = NodePath(".:is_washing") +properties/1/spawn = true +properties/1/replication_mode = 1 [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_0gbf8"] diff --git a/UI/progress_bar_3d.gd b/UI/progress_bar_3d.gd index 93d99c8..da7e032 100644 --- a/UI/progress_bar_3d.gd +++ b/UI/progress_bar_3d.gd @@ -25,6 +25,10 @@ func set_bar_visible(value: bool) -> void: progress_bar.visible = value +func is_bar_visible() -> bool: + return progress_bar != null and progress_bar.visible + + func override_fill_color(color: Color) -> void: var style_box := StyleBoxFlat.new() style_box.bg_color = color diff --git a/test/mp_test_driver.gd b/test/mp_test_driver.gd index 470a55f..cfca263 100644 --- a/test/mp_test_driver.gd +++ b/test/mp_test_driver.gd @@ -88,6 +88,12 @@ var _frame_index := 0 func _ready() -> void: var args := OS.get_cmdline_user_args() _auto_mode = "--mptest" in args + # Opt-in only. This node also sits in the real multiplayer scene (so a client + # joining a test session has a driver), and must be completely inert during + # an ordinary game — no overlay, no debug camera, no keyboard hooks. + if not _auto_mode and not ("--mptest-manual" in args): + queue_free() + return _frames_enabled = "--mptest-frames" in args _step_pause = _arg_value(args, "--mptest-pause", 0.0) _end_hold = _arg_value(args, "--mptest-hold", 0.0) @@ -98,7 +104,8 @@ func _ready() -> void: _refresh_role() _log("=== mp test driver ready (role=%s, peer=%d, mode=%s) ===" % [_role, multiplayer.get_unique_id(), "automatic" if _auto_mode else "manual"]) - if not _resolve_nodes(): + # await: _resolve_nodes now waits for the server's spawns to arrive. + if not await _resolve_nodes(): if _auto_mode: _finish(false) return @@ -133,14 +140,40 @@ func _resolve_nodes() -> bool: if not _hand: _log("FATAL: could not resolve XROrigin3D/XRControllerRightHand/FunctionPickup") return false + # The kitchen is no longer baked into the live scene: the server harvests the + # authored nodes and respawns them replicated, so on a client nothing exists + # until those spawns arrive. Wait for them rather than failing immediately. for required in ["Hob", "Sink", "DirtStation", "Counter", "Counter2", "Plate"]: - if not _find(required): - _log("FATAL: test scene is missing '%s'" % required) + if not await _wait_until(func(): return _find(required) != null, + "'%s' to arrive from the server" % required, SETUP_TIMEOUT_SEC): + _log("FATAL: '%s' never appeared in the world" % required) return false _log("resolved hand=%s; kitchen has Hob, Sink, DirtStation, Counter, Counter2" % _hand.get_path()) + _disable_despawn_timers() return true +# The test deliberately leaves items sitting still for minutes at a time, which +# DespawningItem would treat as litter and remove (it took the raw burger out +# before the client had even connected). Hold them indefinitely instead, so the +# run tests the kitchen rather than the despawn timer. +func _disable_despawn_timers() -> void: + var stopped := 0 + for node in _world.find_children("*", "DespawningItem", true, false): + if _despawn_timers_seen.has(node.get_instance_id()): + continue + _despawn_timers_seen[node.get_instance_id()] = true + node.set_process(false) + stopped += 1 + if stopped > 0: + _log("disabled %d DespawningItem timer(s) so test items don't vanish mid-run" % stopped) + + +# Items appear as the run goes on (cooking and combining spawn new ones), so +# this is re-checked before every step rather than only at startup. +var _despawn_timers_seen := {} + + # Items live either baked in the scene root or, once spawned at runtime, under # WorldContent. Look in both. func _find(name: String) -> Node3D: @@ -184,6 +217,11 @@ func _run_server() -> void: # A frame of the untouched kitchen, so the GIF opens on the starting state. await _capture_step_frame("start") + # 0. The client opened a different, empty scene, so everything it has must + # have arrived over the network. Check that before touching anything — this + # is the same path a player joining mid-session takes. + await _step("server", "world_replicated_to_client", "verify_world_replicated", []) + # 1-2. Both peers can pick the plate up and put it down. await _step("client", "client_grab_plate", "grab", ["Plate"]) await _step("client", "client_drop_plate", "drop", ["Plate"]) @@ -204,8 +242,10 @@ func _run_server() -> void: # 5. Client takes the dirty plate to the sink, which should wash it clean. await _step("client", "client_plate_to_sink", "place_in_zone", ["Plate", "Sink"]) await _both("plate_snapped_in_sink", "verify_snapped", ["Plate", "Sink"]) + await _both("sink_bar_shown_while_washing", "verify_station_bar", ["Sink", "true"]) await _step("server", "wait_for_wash", "await_clean", ["Plate"]) await _both("plate_washed_clean", "verify_dirty", ["Plate", "false"]) + await _both("sink_bar_hidden_when_done", "verify_station_bar", ["Sink", "false"]) # 6. The cook-and-plate round, once per peer. The first round uses the items # baked into the scene; the second uses freshly spawned ones, so both paths @@ -230,6 +270,7 @@ func _cook_and_plate_round(actor: String, burger: String, buns: String, plate: S # Burger onto the hob; it should cook and the raw one should disappear. await _step(actor, "%s_burger_to_hob" % actor, "place_in_zone", [burger, "Hob"]) await _both("%s_burger_snapped_in_hob" % actor, "verify_snapped", [burger, "Hob"]) + await _both("%s_hob_bar_shown_while_cooking" % actor, "verify_station_bar", ["Hob", "true"]) await _step("server", "%s_wait_for_cook" % actor, "await_food", ["cooked_burger"]) # Get it off the hob before anything else: the hob keeps cooking whatever is @@ -238,6 +279,7 @@ func _cook_and_plate_round(actor: String, burger: String, buns: String, plate: S await _step(actor, "%s_cooked_to_counter" % actor, "place_food_in_zone", ["cooked_burger", "Counter"]) await _both("%s_raw_burger_removed" % actor, "verify_gone", [burger]) await _both("%s_cooked_burger_exists" % actor, "verify_food_exists", ["cooked_burger"]) + await _both("%s_hob_bar_hidden_when_empty" % actor, "verify_station_bar", ["Hob", "false"]) # Now bring the buns to it to combine. await _step(actor, "%s_buns_to_cooked" % actor, "carry_food_to_food", [buns, "cooked_burger"]) @@ -256,6 +298,7 @@ func _cook_and_plate_round(actor: String, burger: String, buns: String, plate: S # nowhere to put its plate. await _both("%s_food_on_plate_before_lift" % actor, "verify_plate_visuals", [plate]) await _step(actor, "%s_plate_off_counter2" % actor, "park", [plate, park_at]) + await _both("%s_counter2_freed" % actor, "verify_zone_empty", ["Counter2"]) # The food must still be on the plate after it has been carried off the # counter and set down again. await _both("%s_food_stayed_on_plate" % actor, "verify_plate_visuals", [plate]) @@ -297,6 +340,7 @@ func _find_client_id() -> int: # and record the verdict. func _step(actor: String, label: String, step: String, args: Array) -> void: _current_step = label + _disable_despawn_timers() _banner("STEP %d: %s (on the %s)" % [_next_step_no(), label, actor.to_upper()]) var res: Dictionary if actor == "server": @@ -313,6 +357,7 @@ func _step(actor: String, label: String, step: String, args: Array) -> void: # agree, which is the whole point of the exercise. func _both(label: String, step: String, args: Array) -> void: _current_step = label + _disable_despawn_timers() _banner("STEP %d: %s (checked on BOTH peers)" % [_next_step_no(), label]) _record(label, "server", await _run_local_step(step, args)) _record(label, "client", await _remote(step, args)) @@ -363,6 +408,7 @@ func _report() -> void: _log("FAILURE %s [%s]: %s" % [r["step"], r["side"], r["detail"]]) await _capture_step_frame("final") _write_frame_index() + _write_report(failed) if not _auto_mode: return _quit_client.rpc_id(_client_id) @@ -374,11 +420,40 @@ func _report() -> void: _finish(failed == 0) +# A standalone report of the run, written next to the logs so it can be read +# without scrolling the console — and so the in-editor runner can print it back. +func _write_report(failed: int) -> void: + var path := "res://logs/mptest_report.txt" + var f := FileAccess.open(path, FileAccess.WRITE) + if not f: + return + var passed := _results.size() - failed + f.store_line("VRyHungry multiplayer test report") + f.store_line("run at %s" % Time.get_datetime_string_from_system()) + f.store_line("") + f.store_line("RESULT: %s (%d passed, %d failed, %d total)" + % ["ALL CHECKS PASSED" if failed == 0 else "FAILED", passed, failed, _results.size()]) + f.store_line("") + if failed > 0: + f.store_line("--- failures ---") + for r in _results: + if not r["ok"]: + f.store_line("FAIL [%s] %s" % [r["side"], r["step"]]) + f.store_line(" %s" % r["detail"]) + f.store_line("") + f.store_line("--- every check, in order ---") + for r in _results: + f.store_line("%-4s %-6s %s" % ["PASS" if r["ok"] else "FAIL", r["side"], r["step"]]) + f.close() + _log("report written to %s" % ProjectSettings.globalize_path(path)) + + # --- Client command handling ---------------------------------------------- @rpc("authority", "reliable") func _cmd(step: String, args: Array) -> void: _current_step = step + _disable_despawn_timers() _log("<- server: %s%s" % [step, args]) _running = true var res := await _run_local_step(step, args) @@ -433,6 +508,12 @@ func _run_local_step(step: String, args: Array) -> Dictionary: return _check_plate_contains(args[0], args[1]) "verify_plate_visuals": return _check_plate_visuals(args[0]) + "verify_station_bar": + return _check_station_bar(args[0], args[1] == "true") + "verify_zone_empty": + return _check_zone_empty(args[0]) + "verify_world_replicated": + return await _check_world_replicated() return {"ok": false, "detail": "unknown step %s" % step} @@ -872,6 +953,10 @@ func _visual_count(item: Node3D, path: String) -> int: return count +## Stations whose on-screen state has to match on every peer. +const WATCHED_STATIONS := ["Hob", "Sink", "DirtStation", "Counter", "Counter2"] + + func _snapshot() -> Dictionary: var out := {} for root in [_world, _world.get_node_or_null("WorldContent")]: @@ -880,9 +965,33 @@ func _snapshot() -> Dictionary: for child in root.get_children(): if child is XRToolsPickable and not child.is_queued_for_deletion(): out[str(child.name)] = _describe(child) + for name in WATCHED_STATIONS: + var station := _find(name) + if station: + out["station:" + name] = _describe_station(station) return out +# What a station shows the player. Only the world owner runs a station's logic +# and snap zone, so its display has to be driven from replicated state — a +# client that never updates it shows a hob that never lights up, or a sink bar +# that stays on screen after the plate came out clean. +# +# Bar *visibility* is compared across peers; the progress value is diagnostic +# only ("_" prefix), because it changes every tick and the two peers are +# legitimately a frame apart. +func _describe_station(station: Node3D) -> Dictionary: + var d := {} + var bar := station.get_node_or_null("ProgressBar3D") as ProgressBar3D + d["bar_visible"] = bar.is_bar_visible() if bar else false + d["_bar_progress"] = snappedf(bar.get_progress(), 1.0) if bar else -1.0 + if "cooking_result" in station: + d["cooking"] = str(station.cooking_result) + if "is_washing" in station: + d["washing"] = bool(station.is_washing) + return d + + @rpc("authority", "reliable") func _request_snapshot() -> void: _snapshot_reply.rpc_id(1, _snapshot()) @@ -918,9 +1027,11 @@ func _compare(server: Dictionary, client: Dictionary) -> Array[String]: continue var s: Dictionary = server[name] var c: Dictionary = client[name] - var dist: float = (s["pos"] as Vector3).distance_to(c["pos"]) - if dist > SYNC_POS_TOLERANCE: - problems.append("%s is %.3fm apart (server %s vs client %s)" % [name, dist, s["pos"], c["pos"]]) + # Stations are compared on their displayed state, not a position. + if s.has("pos") and c.has("pos"): + var dist: float = (s["pos"] as Vector3).distance_to(c["pos"]) + if dist > SYNC_POS_TOLERANCE: + problems.append("%s is %.3fm apart (server %s vs client %s)" % [name, dist, s["pos"], c["pos"]]) for key in s: # "_" keys are per-peer diagnostics, not things that must match. if key == "pos" or key.begins_with("_"): @@ -1031,6 +1142,59 @@ func _check_plate_visuals(plate_name: String) -> Dictionary: % [plate_name, off, _visual_diag(plate)]} +# A station's progress bar must show the same thing to everyone: visible while +# the station is working, gone once it has finished. Only the world owner runs +# station logic, so a client can only get this right if the display is driven +# from replicated state. +func _check_station_bar(station_name: String, want_visible: bool) -> Dictionary: + var station := _find(station_name) + if not station: + return {"ok": false, "detail": "'%s' does not exist on this peer" % station_name} + var bar := station.get_node_or_null("ProgressBar3D") as ProgressBar3D + if not bar: + return {"ok": false, "detail": "%s has no ProgressBar3D" % station_name} + var shown := bar.is_bar_visible() + var detail := "%s bar visible=%s progress=%.0f%%" % [station_name, shown, bar.get_progress()] + if "cooking_result" in station: + detail += " cooking='%s'" % station.cooking_result + if "is_washing" in station: + detail += " washing=%s" % station.is_washing + if shown != want_visible: + return {"ok": false, "detail": "expected %s's bar to be %s, but %s" + % [station_name, "visible" if want_visible else "hidden", detail]} + return {"ok": true, "detail": detail} + + +# The client loaded a bare multiplayer scene with no kitchen in it, so every +# object it can see arrived from the server. This confirms it got the whole +# layout — the same thing that has to work for a player joining mid-session. +# Server-side check: it asks the client for its inventory and compares. +func _check_world_replicated() -> Dictionary: + var mine := _snapshot() + var theirs := await _fetch_client_snapshot() + if theirs.is_empty(): + return {"ok": false, "detail": "the client reported nothing at all"} + var problems := _compare(mine, theirs) + var detail := "server has %d objects, client has %d" % [mine.size(), theirs.size()] + if not problems.is_empty(): + return {"ok": false, "detail": "%s; %s" % [detail, "; ".join(problems)]} + return {"ok": true, "detail": "%s, all matching (client received the world over the network)" % detail} + + +# A station that has had its item taken away must not still be holding it. +func _check_zone_empty(station_name: String) -> Dictionary: + var zone := _zone_of(station_name) + if not zone: + return {"ok": false, "detail": "station '%s' has no snap zone on this peer" % station_name} + if not NetworkManager.owns_world(): + # Client zones are gated off entirely; they never hold anything. + return {"ok": true, "detail": "%s: client zones are gated, nothing to check" % station_name} + if is_instance_valid(zone.picked_up_object): + return {"ok": false, "detail": "%s's zone still holds %s after it was taken away" + % [station_name, zone.picked_up_object]} + return {"ok": true, "detail": "%s's zone is empty" % station_name} + + # --- Diagnostics ----------------------------------------------------------- func _diag(item: Node3D) -> String: diff --git a/test/multiPlayerTest.tscn b/test/multiPlayerTest.tscn index b26aa39..e9e4a38 100644 --- a/test/multiPlayerTest.tscn +++ b/test/multiPlayerTest.tscn @@ -34,7 +34,7 @@ size = Vector3(15, 20, 0.1) [node name="Main" type="Node3D" unique_id=1312265607] script = ExtResource("1_6uucx") -populate_from_layout = false +populate_from_layout = true [node name="XROrigin3D" parent="." unique_id=2055526621 groups=["local_xr_origin"] instance=ExtResource("2_j7vd1")] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.36171648, 0, 0.81835127) @@ -100,10 +100,10 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2, 0.5, 0) script = ExtResource("9_mptst") [node name="raw_burger" parent="." unique_id=1675596942 instance=ExtResource("10_51k0c")] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.28692234, 1.0149999, 0.3505687) +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.28692234, 1.0149999, 1.1) [node name="BurgerBuns" parent="." unique_id=1088240294 instance=ExtResource("11_psgbv")] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.40037438, 1.0094403, 0.34120744) +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.40037438, 1.0094403, 1.1) [node name="Counter" parent="." unique_id=1487893288 instance=ExtResource("12_j5uvh")] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1, 0.5, 0) diff --git a/test/play_mp_test.ps1 b/test/play_mp_test.ps1 index b5eeb00..7a6678d 100644 --- a/test/play_mp_test.ps1 +++ b/test/play_mp_test.ps1 @@ -29,27 +29,28 @@ param( $ErrorActionPreference = "Stop" $proj = Split-Path -Parent $PSScriptRoot -$scene = "res://test/multiPlayerTest.tscn" +$serverScene = "res://test/multiPlayerTest.tscn" +$clientScene = "res://Scenes/multiPlayer.tscn" . (Join-Path $PSScriptRoot "mp_window_layout.ps1") -function Start-Instance($extraArgs) { - $a = "--xr-mode off --resolution 900x600 --path `"$proj`" $scene" - if ($extraArgs) { $a += " -- $($extraArgs -join ' ')" } +function Start-Instance($extraArgs, $sceneFor) { + $a = "--xr-mode off --resolution 900x600 --path `"$proj`" $sceneFor -- --mptest-manual" + if ($extraArgs) { $a += " $($extraArgs -join ' ')" } return Start-Process -FilePath $Godot -ArgumentList $a -PassThru } if ($Solo) { Write-Host "Opening a single offline window (no networking)." - $null = Move-GameWindow (Start-Instance $null) 300 100 + $null = Move-GameWindow (Start-Instance $null $serverScene) 300 100 return } Write-Host "Opening SERVER window (left)..." -$w = Move-GameWindow (Start-Instance @("--server")) 20 60 +$w = Move-GameWindow (Start-Instance @("--server") $serverScene) 20 60 Start-Sleep -Seconds 5 Write-Host "Opening CLIENT window (right)..." -$null = Move-GameWindow (Start-Instance @("--join", "127.0.0.1")) (20 + $w + 12) 60 +$null = Move-GameWindow (Start-Instance @("--join", "127.0.0.1") $clientScene) (20 + $w + 12) 60 Write-Host "" Write-Host "Both windows are up. Click one to focus it, then press:" diff --git a/test/run_mp_test.ps1 b/test/run_mp_test.ps1 index f48b34e..0753ab6 100644 --- a/test/run_mp_test.ps1 +++ b/test/run_mp_test.ps1 @@ -1,41 +1,69 @@ -# Runs the headless two-instance multiplayer test (test/multiPlayerTest.tscn). +# Runs the two-instance multiplayer test and ALWAYS produces a GIF of the run. # # powershell -File test\run_mp_test.ps1 # -# Starts a server instance and a client instance of the game with --xr-mode off -# (SteamVR's OpenXR runtime crashes a headless process), lets test/mp_test_driver.gd -# drive the scripted plate/dirt-station sequence, then prints both logs. +# Starts a server instance and a client instance on test/multiPlayerTest.tscn, +# lets test/mp_test_driver.gd drive the scripted kitchen sequence, then writes: +# logs\mptest_report.txt - every check, pass/fail, plus failure details +# logs\mptest_run.gif - both peers side by side, one frame per step +# logs\mptest_{server,client}.log - full step logs # Exits non-zero if any check failed. +# +# The windows are visible because frame capture needs a real framebuffer: a +# headless Godot renders nothing, so there would be no GIF. Use -Headless when +# you only want the pass/fail result (faster, but no GIF). param( [string]$Godot = "C:\Users\Aqua Aurora\Desktop\Godot_v4.7-stable_win64.exe", - [int]$TimeoutSec = 120 + [int]$TimeoutSec = 400, + # Seconds to pause between steps, and to hold the final state on screen. + [double]$Pause = 0.4, + [double]$Hold = 3, + # Passed through to make_gif.ps1. + [int]$HalfWidth = 900, + [double]$SecondsPerStep = 1.2, + # Skip the windows entirely. No frames are captured, so no GIF is produced. + [switch]$Headless ) $ErrorActionPreference = "Stop" $proj = Split-Path -Parent $PSScriptRoot -$scene = "res://test/multiPlayerTest.tscn" +# The two peers deliberately load DIFFERENT scenes, mirroring the real game: +# the host opens whatever world it is running, the client opens the bare +# multiplayer scene and must receive the entire layout over the network. That is +# also exactly what a client joining mid-session goes through. +$serverScene = "res://test/multiPlayerTest.tscn" +$clientScene = "res://Scenes/multiPlayer.tscn" $serverLog = Join-Path $proj "logs/mptest_server.log" $clientLog = Join-Path $proj "logs/mptest_client.log" +$report = Join-Path $proj "logs/mptest_report.txt" +$gif = Join-Path $proj "logs/mptest_run.gif" -foreach ($f in @($serverLog, $clientLog)) { if (Test-Path $f) { Remove-Item $f -Force } } +foreach ($f in @($serverLog, $clientLog, $report, $gif)) { if (Test-Path $f) { Remove-Item $f -Force } } -function Start-Instance($extraArgs) { - # Single argument string with the project path quoted: Start-Process does - # not quote array elements, so the space in the path would split it. - $a = "--headless --xr-mode off --path `"$proj`" $scene -- $($extraArgs -join ' ')" - $p = Start-Process -FilePath $Godot -ArgumentList $a -PassThru -NoNewWindow - # Touching .Handle caches it, which is what makes .ExitCode readable later; - # without this it comes back empty even after the process has exited. +. (Join-Path $PSScriptRoot "mp_window_layout.ps1") + +function Start-Instance($extraArgs, $sceneFor) { + # --xr-mode off keeps it on the desktop (SteamVR's OpenXR runtime otherwise + # takes over, and two instances can't share a headset anyway). + $a = "--xr-mode off" + if ($Headless) { $a = "--headless " + $a } + $a += " --resolution 900x600 --path `"$proj`" $sceneFor -- $($extraArgs -join ' ') --mptest" + if (-not $Headless) { $a += " --mptest-frames" } + $a += " --mptest-pause $Pause --mptest-hold $Hold" + $p = Start-Process -FilePath $Godot -ArgumentList $a -PassThru -NoNewWindow:$Headless + # Touching .Handle caches it, which is what makes .ExitCode readable later. $null = $p.Handle return $p } -Write-Host "Starting server..." -$server = Start-Instance @("--server", "--mptest") -Start-Sleep -Seconds 4 -Write-Host "Starting client..." -$client = Start-Instance @("--join", "127.0.0.1", "--mptest") +Write-Host "Starting SERVER..." +$server = Start-Instance @("--server") $serverScene +if (-not $Headless) { $w = Move-GameWindow $server 20 60 } +Start-Sleep -Seconds 5 +Write-Host "Starting CLIENT..." +$client = Start-Instance @("--join", "127.0.0.1") $clientScene +if (-not $Headless) { $null = Move-GameWindow $client (20 + $w + 12) 60 } $deadline = (Get-Date).AddSeconds($TimeoutSec) while ((Get-Date) -lt $deadline -and -not $server.HasExited) { Start-Sleep -Milliseconds 500 } @@ -45,16 +73,26 @@ foreach ($p in @($server, $client)) { } Start-Sleep -Milliseconds 500 -foreach ($pair in @(@("SERVER", $serverLog), @("CLIENT", $clientLog))) { +if (-not $Headless) { Write-Host "" - Write-Host "======================== $($pair[0]) ========================" - if (Test-Path $pair[1]) { Get-Content $pair[1] -Encoding UTF8 } else { Write-Host "(no log written)" } + Write-Host "======================== GIF ========================" + & powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "make_gif.ps1") ` + -HalfWidth $HalfWidth -SecondsPerStep $SecondsPerStep +} else { + Write-Host "" + Write-Host "(headless run: no frames captured, so no GIF - drop -Headless to get one)" } -# ExitCode is only populated on the process object after a WaitForExit() call, -# even when HasExited is already true - without this it reads back empty. +Write-Host "" +Write-Host "======================== REPORT ========================" +if (Test-Path $report) { Get-Content $report -Encoding UTF8 } else { Write-Host "(no report written - the run did not finish)" } + $server.WaitForExit(2000) | Out-Null $code = if ($server.HasExited) { $server.ExitCode } else { 1 } Write-Host "" +Write-Host "report: $report" +if (-not $Headless -and (Test-Path $gif)) { Write-Host "gif: $gif" } +Write-Host "logs: $serverLog" +Write-Host " $clientLog" Write-Host "server exit code: $code" exit $code diff --git a/test/run_mp_test_windowed.ps1 b/test/run_mp_test_windowed.ps1 index 05280fb..dac7ec0 100644 --- a/test/run_mp_test_windowed.ps1 +++ b/test/run_mp_test_windowed.ps1 @@ -24,7 +24,8 @@ param( $ErrorActionPreference = "Stop" $proj = Split-Path -Parent $PSScriptRoot -$scene = "res://test/multiPlayerTest.tscn" +$serverScene = "res://test/multiPlayerTest.tscn" +$clientScene = "res://Scenes/multiPlayer.tscn" $serverLog = Join-Path $proj "logs/mptest_server.log" $clientLog = Join-Path $proj "logs/mptest_client.log" @@ -32,10 +33,10 @@ foreach ($f in @($serverLog, $clientLog)) { if (Test-Path $f) { Remove-Item $f - . (Join-Path $PSScriptRoot "mp_window_layout.ps1") -function Start-Instance($extraArgs) { +function Start-Instance($extraArgs, $sceneFor) { # --xr-mode off keeps it on the desktop (SteamVR's OpenXR runtime otherwise # takes over, and two instances can't share a headset anyway). - $a = "--xr-mode off --resolution 900x600 --path `"$proj`" $scene -- " + + $a = "--xr-mode off --resolution 900x600 --path `"$proj`" $sceneFor -- " + "$($extraArgs -join ' ') --mptest --mptest-frames --mptest-pause $Pause --mptest-hold $Hold" $p = Start-Process -FilePath $Godot -ArgumentList $a -PassThru # Touching .Handle caches it, which is what makes .ExitCode readable later. @@ -44,11 +45,11 @@ function Start-Instance($extraArgs) { } Write-Host "Starting SERVER window (left)..." -$server = Start-Instance @("--server") +$server = Start-Instance @("--server") $serverScene $w = Move-GameWindow $server 20 60 Start-Sleep -Seconds 5 Write-Host "Starting CLIENT window (right)..." -$client = Start-Instance @("--join", "127.0.0.1") +$client = Start-Instance @("--join", "127.0.0.1") $clientScene $null = Move-GameWindow $client (20 + $w + 12) 60 Write-Host "Watch the two windows (each has a fixed camera on the test area)." diff --git a/test/run_tests_in_editor.gd b/test/run_tests_in_editor.gd new file mode 100644 index 0000000..61bf433 --- /dev/null +++ b/test/run_tests_in_editor.gd @@ -0,0 +1,137 @@ +@tool +extends EditorScript + +## Runs the whole multiplayer test suite from inside the Godot editor. +## +## HOW TO USE +## 1. Open this file in the editor's script panel. +## 2. File > Run (Ctrl+Shift+X). +## +## It launches two copies of the game (server + client) as separate processes, +## waits for them to finish, then prints the full pass/fail report into the +## Output panel and builds the side-by-side GIF. +## +## Everything it produces lands in logs/: +## mptest_report.txt every check, pass/fail, plus details for failures +## mptest_run.gif both peers side by side, one frame per step +## mptest_{server,client}.log full step logs +## +## NOTE: the editor is blocked while the run is in progress (a couple of +## minutes). Watch the two game windows to follow along. + +## Give up if the server hasn't finished in this long. +const TIMEOUT_SEC := 400 +## Seconds each step is held, so the run is watchable and the GIF is readable. +const STEP_PAUSE := 0.4 +## Seconds the final state stays on screen before the instances close. +const END_HOLD := 3.0 + +## The peers load different scenes on purpose, mirroring the real game: the host +## opens whatever world it is running, the client opens the bare multiplayer +## scene and has to receive the whole layout over the network — the same path a +## client joining mid-session takes. +const SERVER_SCENE := "res://test/multiPlayerTest.tscn" +const CLIENT_SCENE := "res://Scenes/multiPlayer.tscn" + + +func _run() -> void: + var exe := OS.get_executable_path() + var project_dir := ProjectSettings.globalize_path("res://").rstrip("/") + var logs_dir := ProjectSettings.globalize_path("res://logs") + DirAccess.make_dir_recursive_absolute(logs_dir) + _clear_previous_results(logs_dir) + + print_rich("[b]Running the multiplayer test suite...[/b]") + print(" the editor will be unresponsive until it finishes (~2 minutes)") + + var server_pid := _launch(exe, project_dir, ["--server"], SERVER_SCENE) + if server_pid <= 0: + push_error("Could not start the server instance") + return + # Give the host time to come up and open its port before the client dials in. + OS.delay_msec(5000) + var client_pid := _launch(exe, project_dir, ["--join", "127.0.0.1"], CLIENT_SCENE) + if client_pid <= 0: + push_error("Could not start the client instance") + OS.kill(server_pid) + return + + var waited := 0.0 + while OS.is_process_running(server_pid) and waited < TIMEOUT_SEC: + OS.delay_msec(500) + waited += 0.5 + if OS.is_process_running(server_pid): + print(" timed out after %ds, stopping the instances" % TIMEOUT_SEC) + OS.kill(server_pid) + if OS.is_process_running(client_pid): + OS.kill(client_pid) + OS.delay_msec(500) + + _print_report(logs_dir) + _build_gif(project_dir, logs_dir) + + +func _launch(exe: String, project_dir: String, extra: Array, scene: String) -> int: + var args := PackedStringArray([ + "--xr-mode", "off", "--resolution", "900x600", + "--path", project_dir, scene, "--", + ]) + for a in extra: + args.append(a) + # --mptest runs the scripted sequence; --mptest-frames captures the frame per + # step that the GIF is stitched from (needs a real window, hence no + # --headless here). + args.append_array(PackedStringArray([ + "--mptest", "--mptest-frames", + "--mptest-pause", str(STEP_PAUSE), "--mptest-hold", str(END_HOLD), + ])) + return OS.create_process(exe, args) + + +# Remove the previous run's output so a failed launch can't leave stale results +# looking like this run's. +func _clear_previous_results(logs_dir: String) -> void: + for name in ["mptest_report.txt", "mptest_run.gif", "mptest_server.log", "mptest_client.log"]: + DirAccess.remove_absolute(logs_dir.path_join(name)) + for role in ["server", "client"]: + var frames := logs_dir.path_join("mptest_frames_%s" % role) + var dir := DirAccess.open(frames) + if dir: + for f in dir.get_files(): + if f.ends_with(".png"): + dir.remove(f) + + +func _print_report(logs_dir: String) -> void: + var path := logs_dir.path_join("mptest_report.txt") + if not FileAccess.file_exists(path): + push_error("No report at %s — the run did not finish. Check logs/mptest_server.log" % path) + return + var text := FileAccess.get_file_as_string(path) + print("") + # Colour the summary so a failure is obvious in the Output panel. + for line in text.split("\n"): + if line.begins_with("FAIL") or line.contains("RESULT: FAILED"): + print_rich("[color=red]%s[/color]" % line) + elif line.contains("ALL CHECKS PASSED"): + print_rich("[color=green][b]%s[/b][/color]" % line) + elif line.begins_with("PASS"): + print_rich("[color=gray]%s[/color]" % line) + else: + print(line) + print("report: %s" % path) + + +func _build_gif(project_dir: String, logs_dir: String) -> void: + var script := project_dir.path_join("test/make_gif.ps1") + var out := [] + var code := OS.execute("powershell", [ + "-ExecutionPolicy", "Bypass", "-File", script, + ], out, true) + for line in out: + print(line) + var gif := logs_dir.path_join("mptest_run.gif") + if code == 0 and FileAccess.file_exists(gif): + print_rich("[b]gif:[/b] %s" % gif) + else: + push_warning("GIF was not produced (is ffmpeg on PATH?). See the output above.") diff --git a/test/run_tests_in_editor.gd.uid b/test/run_tests_in_editor.gd.uid new file mode 100644 index 0000000..fe08836 --- /dev/null +++ b/test/run_tests_in_editor.gd.uid @@ -0,0 +1 @@ +uid://crcuhy3n8goep