diff --git a/game/tests/test_two_instances.gd b/game/tests/test_two_instances.gd new file mode 100644 index 0000000..965dd2d --- /dev/null +++ b/game/tests/test_two_instances.gd @@ -0,0 +1,65 @@ +extends TestCase +## What milestone 1 promises: two instances of the game, each in a process of +## its own, stay in lockstep. This process is the host; a second Godot +## process plays a guest with tools/guest_bot.gd. + +## The tick at which the host stops and both compare their worlds. +const UNTIL := 150 +## Milliseconds the test waits for the second instance. +const PATIENCE_MSEC := 30000 + + +func test_a_guest_in_another_process_stays_in_lockstep() -> void: + var directory := TestFiles.temporary_dir("two-instances") + var host: Node = load("res://main.tscn").instantiate() + host.storage_dir = directory.path_join("host") + host.command_line = PackedStringArray() + tree.root.add_child(host) + host.options.port = 0 + host.listen_address = "127.0.0.1" + check(host.play(true), "the world opens") + + var report_path := directory.path_join("guest.json") + var arguments := PackedStringArray([ + "--headless", + "--path", ProjectSettings.globalize_path("res://"), + "--log-file", ProjectSettings.globalize_path(directory.path_join("guest.log")), + "-s", "res://tools/guest_bot.gd", + "--", + "--join=127.0.0.1:%d" % host.link.link.local_port(), + "--storage=" + directory.path_join("guest"), + "--until=%d" % UNTIL, + "--out=" + report_path, + ]) + var process := OS.create_process(OS.get_executable_path(), arguments) + check(process > 0, "the second instance starts") + var started := Time.get_ticks_msec() + while OS.is_process_running(process) and Time.get_ticks_msec() - started < PATIENCE_MSEC: + if host.session.guest_count() > 0: + # Once the guest is there, the test runs the host's ticks itself, + # one per frame, instead of twenty a second, and stops at UNTIL. + # A guest's arrival ends a pause, so the clock is stopped here. + host.clock.paused = true + if host.session.tick() < UNTIL: + host._run_tick() + await tree.process_frame + if OS.is_process_running(process): + OS.kill(process) + check(false, "the second instance did not finish in time") + + var report: Variant = JSON.parse_string(FileAccess.get_file_as_string(report_path)) + check(report is Dictionary and not report.has("error"), "the guest reports: %s" % [report]) + if report is Dictionary and not report.has("error"): + check_eq(int(report["tick"]), UNTIL, "the guest stopped where the host stopped") + check_eq(report["hash"], host.session.state_hash_hex(), "both worlds are the same") + check_eq(int(report["player"]), 1, "the guest is player 1") + check_eq(int(report["stones"]), 1, "the guest broke the rock") + check_eq(int(report["resyncs"]), 0, "without a divergence on the way") + var rock: Vector2i = host.session.spawn_point() + Vector2i(7, -2) + check_eq(host.session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_GROUND, "the rock is gone in the host's world too") + check_eq(host.session.player_name(1), "Bot", "the host knows the guest") + + tree.root.remove_child(host) + host.link.link.close() + host.free() + TestFiles.remove_dir(directory) diff --git a/game/tests/test_two_instances.gd.uid b/game/tests/test_two_instances.gd.uid new file mode 100644 index 0000000..7483170 --- /dev/null +++ b/game/tests/test_two_instances.gd.uid @@ -0,0 +1 @@ +uid://c2vju7aa5pgqm diff --git a/game/tools/guest_bot.gd b/game/tools/guest_bot.gd new file mode 100644 index 0000000..55df730 --- /dev/null +++ b/game/tools/guest_bot.gd @@ -0,0 +1,59 @@ +extends SceneTree +## A second instance of the game for the test of two instances. It starts the +## real main scene as a guest, hits the rock beside the spawn clearing until +## it has a stone, follows the host until the host stops at a tick, and +## writes what its world looks like then as JSON. +## +## The test of two instances starts it; by hand: +## godot --headless --path game -s res://tools/guest_bot.gd -- --join=ADDRESS +## --storage=FOLDER --until=TICK --out=FILE + +## Milliseconds the bot waits for everything before it gives up. +const PATIENCE_MSEC := 20000 + + +func _initialize() -> void: + _run.call_deferred() + + +func _run() -> void: + var settings := {} + for argument in OS.get_cmdline_user_args(): + settings[argument.get_slice("=", 0)] = argument.get_slice("=", 1) + var until := int(settings.get("--until", "0")) + var report := {"error": "the host did not answer"} + + var main: Node = load("res://main.tscn").instantiate() + main.storage_dir = settings.get("--storage", "user://bot") + main.command_line = PackedStringArray(["--join=" + settings.get("--join", ""), "--name=Bot"]) + root.add_child(main) + + var started := Time.get_ticks_msec() + while Time.get_ticks_msec() - started < PATIENCE_MSEC: + await process_frame + var session: GwSession = main.session + if session == null: + report = {"error": "the session ended: %s" % main.menu.status()} + break + if session.local_player() < 0: + continue + var rock := session.spawn_point() + Vector2i(7, -2) + if session.item_count(GwSession.ITEM_STONE) == 0: + main.dig_at(rock) + if session.tick() == until and session.pending_ticks() == 0: + report = { + "tick": session.tick(), + "hash": session.state_hash_hex(), + "player": session.local_player(), + "stones": session.item_count(GwSession.ITEM_STONE), + "resyncs": main.resyncs, + } + break + + var file := FileAccess.open(settings.get("--out", "user://bot.json"), FileAccess.WRITE) + file.store_string(JSON.stringify(report)) + file.close() + main.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST) + root.remove_child(main) + main.free() + quit(0 if not report.has("error") else 1) diff --git a/game/tools/guest_bot.gd.uid b/game/tools/guest_bot.gd.uid new file mode 100644 index 0000000..78344e7 --- /dev/null +++ b/game/tools/guest_bot.gd.uid @@ -0,0 +1 @@ +uid://hvhqt2viwbjx