extends TestCase ## Starts the real main scene with its own storage folder. The network tests ## start two of them in this process: a host and a guest. var _started: Array[Node] = [] func _start_main(directory: String) -> Node: var main: Node = load("res://main.tscn").instantiate() main.storage_dir = directory tree.root.add_child(main) _started.append(main) return main func _stop_main(main: Node) -> void: _started.erase(main) tree.root.remove_child(main) if main.link != null: main.link.link.close() main.free() ## Stops every main scene this test started and removes their folders. func _clean_up(directories: Array[String]) -> void: for main in _started.duplicate(): _stop_main(main) for directory in directories: TestFiles.remove_dir(directory) ## A main scene with an open world that listens on the loopback interface. func _start_host(directory: String) -> Node: var host := _start_main(directory) host.options.port = 0 host.listen_address = "127.0.0.1" host.menu.name_field.text = "Host" check(host.play(true), "the world opens") return host ## A main scene that joins `host` as "Grace". func _start_guest(directory: String, host: Node) -> Node: var guest := _start_main(directory) guest.menu.name_field.text = "Grace" guest.join("127.0.0.1:%d" % host.link.link.local_port()) return guest ## Lets frames pass until `condition` holds, at most `frames` of them. func _wait_until(condition: Callable, frames := 600) -> bool: for frame in frames: if condition.call(): return true await tree.process_frame return condition.call() func _joined(guest: Node) -> bool: return guest.session != null and guest.session.local_player() >= 0 func _press(main: Node, action: String) -> void: var event := InputEventAction.new() event.action = action event.pressed = true main._unhandled_input(event) func test_the_menu_comes_first_and_remembers_the_name() -> void: var directory := TestFiles.temporary_dir("menu") var main := _start_main(directory) await tree.process_frame check(main.menu.visible, "the menu shows") check(main.session == null, "nothing runs yet") main.menu.name_field.text = " Ada " check(main.play(false), "playing alone starts") check(not main.menu.visible, "the menu is gone") check_eq(main.hud.status_text(), "", "no status line when playing alone") check_eq(main.session.player_name(main.session.local_player()), "Ada", "the player has the name") _stop_main(main) var later := _start_main(directory) check_eq(later.menu.name_field.text, "Ada", "the name is remembered") _clean_up([directory]) func test_the_command_line_can_start_without_the_menu() -> void: var directory := TestFiles.temporary_dir("command-line") var main: Node = load("res://main.tscn").instantiate() main.storage_dir = directory main.command_line = PackedStringArray(["--solo", "--name=Linus"]) tree.root.add_child(main) _started.append(main) check(main.session != null and main.session.is_running(), "a game runs at once") check(not main.menu.visible, "without the menu") check_eq(main.session.player_name(main.session.local_player()), "Linus", "with the name from the command line") _clean_up([directory]) func test_a_new_game_starts_and_the_player_joins() -> void: var directory := TestFiles.temporary_dir("main") var main := _start_main(directory) main.play(false) for frame in 5: await tree.process_frame var session: GwSession = main.session check(session != null and session.is_running(), "a session runs") check(not session.is_broken(), "the simulation is healthy") check(session.is_host(), "playing alone is hosting without guests") check_eq(session.local_player(), 0, "the local player has joined") check_eq(main.avatar.position, GridMotion.tile_center(session.spawn_point()), "the figure stands on the spawn") check_eq(main.hud.stone_text(), tr("STONE_COUNT") % 0, "the HUD shows the stones") _clean_up([directory]) func test_a_saved_game_is_continued_on_the_next_start() -> void: var directory := TestFiles.temporary_dir("continue") var first := _start_main(directory) first.play(false) await tree.process_frame var tick_at_save: int = first.session.tick() check(first.save_game(), "the game was saved") _stop_main(first) var second := _start_main(directory) second.play(false) await tree.process_frame check(second.session.tick() > tick_at_save, "the saved world continues") check_eq(second.session.local_player(), 0, "the same player rejoins") _clean_up([directory]) func test_closing_the_window_saves_the_game_and_records_the_session() -> void: var directory := TestFiles.temporary_dir("close") var main := _start_main(directory) main.play(false) await tree.process_frame main.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST) var store := SaveStore.new(directory) check(store.has_save(), "the game was saved") check_eq(store.recordings().size(), 1, "the session was recorded") _clean_up([directory]) func test_the_game_is_saved_automatically_every_two_minutes() -> void: var directory := TestFiles.temporary_dir("autosave") var main := _start_main(directory) main.play(false) var store := SaveStore.new(directory) check(not store.has_save(), "there is no save at the start") # No frames are awaited, so only these ticks run. The loop is counted, so # that a session that stops ticking fails the test instead of hanging it. for i in main.AUTOSAVE_TICKS - 1 - main.session.tick(): main._run_tick() check(not store.has_save(), "still no save one tick before the interval ends") main._run_tick() check(store.has_save(), "saved when the interval ends") _clean_up([directory]) func test_an_unusable_save_is_set_aside_and_a_new_world_starts() -> void: var directory := TestFiles.temporary_dir("broken") var store := SaveStore.new(directory) store.write_save(PackedByteArray([1, 2, 3])) var main := _start_main(directory) check(main.play(false), "a new world starts") check(main.session.tick() <= 1, "it is new, tick %d" % main.session.tick()) check(FileAccess.file_exists(directory.path_join(SaveStore.SAVE_FILE + ".broken")), "the unusable save is kept aside") check_eq(main.hud.message(), tr("LOAD_FAILED"), "the player is told") _clean_up([directory]) func test_an_unusable_save_that_cannot_be_set_aside_is_never_overwritten() -> void: var directory := TestFiles.temporary_dir("stuck") var store := SaveStore.new(directory) store.write_save(PackedByteArray([1, 2, 3])) # A folder under the set-aside name keeps the unusable save from moving. DirAccess.make_dir_absolute(directory.path_join(SaveStore.SAVE_FILE + ".broken")) var main := _start_main(directory) check(main.play(false), "a new world starts") check(not main.save_game(), "saving is refused") check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "the unusable save is untouched") check_eq(main.hud.message(), tr("LOAD_FAILED"), "the player is told") _clean_up([directory]) func test_a_save_that_cannot_be_read_right_now_is_left_alone() -> void: var directory := TestFiles.temporary_dir("held") SaveStore.new(directory).write_save(PackedByteArray([1, 2, 3])) var main := _start_main(directory) var held := HeldFileStore.new(directory) held.unreadable = SaveStore.SAVE_FILE main.store = held check(not main.play(false), "nothing starts") check_eq(main.menu.status(), tr("SAVE_UNREADABLE"), "the menu says why") check(main.session == null, "no session") check(not FileAccess.file_exists(directory.path_join(SaveStore.SAVE_FILE + ".broken")), "the save was not set aside") _clean_up([directory]) func test_without_a_readable_key_the_game_stays_in_the_menu() -> void: var directory := TestFiles.temporary_dir("no-key") var main := _start_main(directory) main._player_key = PackedByteArray() check(not main.play(false), "playing alone does not start") main.join("127.0.0.1") check(main.session == null, "joining does not start either") check_eq(main.menu.status(), tr("KEY_UNREADABLE"), "the menu says why") _clean_up([directory]) func test_a_guest_joins_an_open_world_and_both_have_the_same_world() -> void: var host_dir := TestFiles.temporary_dir("host") var guest_dir := TestFiles.temporary_dir("guest") var host := _start_host(host_dir) var port: int = host.link.link.local_port() check_eq(host.hud.status_text(), tr("STATUS_HOST") % [port, 1], "the host sees the port") var guest := _start_guest(guest_dir, host) check_eq(guest.menu.status(), tr("CONNECTING") % ("127.0.0.1:%d" % host.link.link.local_port()), "the guest waits") check(await _wait_until(_joined.bind(guest)), "the guest is in the world") check(not guest.menu.visible, "the guest sees the world") check_eq(guest.session.local_player(), 1, "the guest is player 1") check(await _wait_until(func() -> bool: return host.hud.message() == tr("PLAYER_JOINED") % "Grace"), "the host is told") check_eq(host.hud.status_text(), tr("STATUS_HOST") % [port, 2], "and counts two players") check_eq(guest.hud.status_text(), tr("STATUS_GUEST") % ["127.0.0.1:%d" % port, 2], "the guest sees whose guest it is") # Stop the host's clock and let the guest catch up. host.clock.paused = true check(await _wait_until(func() -> bool: return guest.session.tick() == host.session.tick()), "the guest catches up") check_eq(guest.session.state_hash_hex(), host.session.state_hash_hex(), "both worlds are the same") _clean_up([host_dir, guest_dir]) func test_the_figures_of_host_and_guest_appear_on_the_other_side() -> void: var host_dir := TestFiles.temporary_dir("host") var guest_dir := TestFiles.temporary_dir("guest") var host := _start_host(host_dir) var guest := _start_guest(guest_dir, host) await _wait_until(_joined.bind(guest)) host.avatar.position += Vector2(16.0, 0.0) var host_figure: Vector2 = host.avatar.position check(await _wait_until(func() -> bool: return guest.figures.shown_at(0).distance_to(host_figure) < 0.5), "the guest sees the host's figure") check(await _wait_until(func() -> bool: return host.figures.shown_at(1) != Vector2.INF), "the host sees the guest's figure") _clean_up([host_dir, guest_dir]) func test_only_the_host_saves_and_only_alone_one_may_pause_or_load() -> void: var host_dir := TestFiles.temporary_dir("host") var guest_dir := TestFiles.temporary_dir("guest") var host := _start_host(host_dir) var guest := _start_guest(guest_dir, host) await _wait_until(_joined.bind(guest)) _press(guest, "save_game") check_eq(guest.hud.message(), tr("HOST_SAVES"), "a guest does not save") _press(guest, "toggle_pause") check_eq(guest.hud.message(), tr("PAUSE_ONLY_ALONE"), "a guest cannot pause") _press(host, "toggle_pause") check_eq(host.hud.message(), tr("PAUSE_ONLY_ALONE"), "nor can a host with a guest") _press(host, "load_game") check_eq(host.hud.message(), tr("LOAD_ONLY_ALONE"), "loading is for playing alone") guest.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST) var guest_store := SaveStore.new(guest_dir) check(not guest_store.has_save(), "the guest wrote no save") check_eq(guest_store.recordings().size(), 1, "but a recording of its session") _clean_up([host_dir, guest_dir]) func test_an_accepted_guest_ends_a_pause_and_a_bare_connection_does_not() -> void: var host_dir := TestFiles.temporary_dir("host") var guest_dir := TestFiles.temporary_dir("guest") var host := _start_host(host_dir) _press(host, "toggle_pause") check(host.clock.paused, "the host pauses while alone") # A connection that never greets is no guest. var stranger := NetLink.new() stranger.join("127.0.0.1", host.link.link.local_port()) var seen := func() -> bool: stranger.poll(1.0 / 60.0) return host.link.link._peers.size() == 1 check(await _wait_until(seen), "the host sees a bare connection") check(host.clock.paused, "which leaves the pause alone") var guest := _start_guest(guest_dir, host) check(await _wait_until(_joined.bind(guest)), "a guest joins the paused world") check(not host.clock.paused, "and ends the pause") # Should a pause ever meet a guest, the host can still end it. host._set_paused(true) _press(host, "toggle_pause") check(not host.clock.paused, "the host can always end a pause") stranger.close() _clean_up([host_dir, guest_dir]) func test_the_host_sees_a_guest_leave() -> void: var host_dir := TestFiles.temporary_dir("host") var guest_dir := TestFiles.temporary_dir("guest") var host := _start_host(host_dir) var guest := _start_guest(guest_dir, host) await _wait_until(_joined.bind(guest)) await _wait_until(func() -> bool: return host.figures.shown_at(1) != Vector2.INF) guest.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST) check(await _wait_until(func() -> bool: return host.hud.message() == tr("PLAYER_LEFT") % "Grace"), "the host is told") check_eq(host.figures.shown_at(1), Vector2.INF, "the guest's figure is gone") check_eq(host.session.online_players(), PackedInt64Array([0]), "only the host is online") _clean_up([host_dir, guest_dir]) func test_a_guest_goes_back_to_the_menu_when_the_host_closes() -> void: var host_dir := TestFiles.temporary_dir("host") var guest_dir := TestFiles.temporary_dir("guest") var host := _start_host(host_dir) var guest := _start_guest(guest_dir, host) await _wait_until(_joined.bind(guest)) host.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST) check(await _wait_until(func() -> bool: return guest.session == null), "the guest's session ends") check(guest.menu.visible, "the menu is back") check_eq(guest.menu.status(), tr("HOST_LEFT"), "and says why") _clean_up([host_dir, guest_dir]) func test_a_host_that_stops_sharing_lets_its_guests_go() -> void: var host_dir := TestFiles.temporary_dir("host") var guest_dir := TestFiles.temporary_dir("guest") var host := _start_host(host_dir) var guest := _start_guest(guest_dir, host) await _wait_until(_joined.bind(guest)) # An internal error of the core cannot be caused from here. For a broken # session, _process calls what this test calls. host._stop_sharing() check(host.link == null, "the host no longer listens") check_eq(host.hud.status_text(), tr("SIMULATION_STOPPED"), "its status line says why") check(await _wait_until(func() -> bool: return guest.session == null), "the guest's session ends") check_eq(guest.menu.status(), tr("HOST_LEFT"), "and the guest learns why") _clean_up([host_dir, guest_dir]) func test_a_guest_that_stops_sharing_leaves() -> void: var host_dir := TestFiles.temporary_dir("host") var guest_dir := TestFiles.temporary_dir("guest") var host := _start_host(host_dir) var guest := _start_guest(guest_dir, host) await _wait_until(_joined.bind(guest)) # As above, for a guest whose own core stopped. guest._stop_sharing() check(guest.session == null, "the guest's session ends") check_eq(guest.menu.status(), tr("SIMULATION_STOPPED"), "the menu says why") check(await _wait_until(func() -> bool: return host.hud.message() == tr("PLAYER_LEFT") % "Grace"), "the host sees the guest go") _clean_up([host_dir, guest_dir]) func test_a_guest_that_is_turned_away_learns_why() -> void: var host_dir := TestFiles.temporary_dir("host") var host := _start_host(host_dir) # A second window on the same profile has the same player key. var twin := _start_guest(host_dir, host) check(await _wait_until(func() -> bool: return twin.session == null), "the twin is turned away") check_eq(twin.menu.status(), tr("REFUSED_ALREADY_PLAYING"), "the menu says why") _clean_up([host_dir]) func test_notices_after_the_end_of_a_session_are_ignored() -> void: var directory := TestFiles.temporary_dir("late") var main := _start_main(directory) # In one batch of notices, an earlier one may have ended the session. main._on_notice({"type": "world_received", "resync": true}) main._on_notice({"type": "protocol_error"}) check(main.session == null, "no session") check(main.menu.visible, "the menu stays") _clean_up([directory]) func test_a_mistyped_address_is_reported() -> void: var directory := TestFiles.temporary_dir("typo") var main := _start_main(directory) main.join("host:port") check(main.session == null, "nothing starts") check_eq(main.menu.status(), tr("ADDRESS_INVALID") % "host:port", "the menu says why") _clean_up([directory]) func test_an_own_hit_shows_its_burst_at_once() -> void: var directory := TestFiles.temporary_dir("burst") var main := _start_main(directory) main.play(false) var rock: Vector2i = main.session.spawn_point() + Vector2i(7, -2) check_eq(main.session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_SOFT_ROCK, "rock beside the clearing") main.dig_at(rock) check_eq(main.map_view.burst_count(), 1, "the burst shows before the hit lands in a tick") _clean_up([directory]) func test_an_own_hit_that_breaks_the_rock_shows_its_burst_only_once() -> void: var directory := TestFiles.temporary_dir("break") var main := _start_main(directory) main.play(false) var rock: Vector2i = main.session.spawn_point() + Vector2i(7, -2) for blow in 3: main.dig_at(rock) for tick in GwSession.DIG_COOLDOWN_TICKS + 1: main._run_tick() check_eq(main.session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_GROUND, "three own hits break the rock") check_eq(main.map_view.burst_count(), 3, "one burst per own hit, none more when the rock breaks") _clean_up([directory]) func test_own_hits_reported_late_show_no_second_burst() -> void: var directory := TestFiles.temporary_dir("late") var main := _start_main(directory) main.play(false) var rock: Vector2i = main.session.spawn_point() + Vector2i(7, -2) main.dig_at(rock) main._next_dig_tick = 0 main.dig_at(rock) # As for a guest: the report of the first hit comes after the second hit, # and the second hit breaks the rock. var reports: Array[Dictionary] = [ {"type": "tile_hit", "player": main.session.local_player(), "x": rock.x, "y": rock.y}, {"type": "tile_changed", "x": rock.x, "y": rock.y}, ] main._apply(reports) check_eq(main.map_view.burst_count(), 2, "two own hits, two bursts") _clean_up([directory]) func test_the_style_samples_open_from_the_menu_and_lead_back_to_it() -> void: var directory := TestFiles.temporary_dir("showcase") var main := _start_main(directory) main.menu.showcase_button.pressed.emit() check(main.showcase.visible, "the samples show") check(not main.menu.visible, "instead of the menu") main.showcase.style_buttons[2].pressed.emit() check_eq(main.art.style.key(), GlowStyle.KEY, "a button there chooses the style") await tree.process_frame main.showcase.back_button.pressed.emit() check(main.menu.visible, "the menu is back") check(not main.showcase.visible, "the samples are gone") _clean_up([directory]) func test_a_world_that_starts_takes_the_place_of_the_style_samples() -> void: var directory := TestFiles.temporary_dir("samples-world") var main := _start_main(directory) main.open_showcase() check(main.showcase.visible, "the samples are open") main.play(false) check(not main.showcase.visible, "the world shows instead of the samples") check_eq(main.showcase.process_mode, Node.PROCESS_MODE_DISABLED, "and the samples take no input") check(not main.menu.visible, "the menu stays hidden") _clean_up([directory]) func test_f2_switches_the_style_and_the_choice_is_kept() -> void: var directory := TestFiles.temporary_dir("style") var main := _start_main(directory) main.play(false) var first: StringName = main.art.style.key() _press(main, "next_style") var second: StringName = main.art.style.key() check(second != first, "another style") check_eq(main.hud.message(), tr("STYLE_SWITCHED") % tr(main.art.style.title_key()), "the player is told") _stop_main(main) var later := _start_main(directory) check_eq(later.art.style.key(), second, "the next start draws in it again") _clean_up([directory]) func test_the_command_line_chooses_the_style() -> void: var directory := TestFiles.temporary_dir("style-option") var main: Node = load("res://main.tscn").instantiate() main.storage_dir = directory main.command_line = PackedStringArray(["--style=pastel"]) tree.root.add_child(main) _started.append(main) check_eq(main.art.style.key(), PastelStyle.KEY, "the style of the command line") _clean_up([directory]) func test_hits_of_other_players_show_their_bursts() -> void: var directory := TestFiles.temporary_dir("other-hits") var main := _start_main(directory) main.play(false) var rock: Vector2i = main.session.spawn_point() + Vector2i(7, -2) var reports: Array[Dictionary] = [ {"type": "tile_hit", "player": main.session.local_player() + 1, "x": rock.x, "y": rock.y}, {"type": "tile_changed", "x": rock.x, "y": rock.y}, ] main._apply(reports) check_eq(main.map_view.burst_count(), 2, "a hit and a breaking blow of another player") _clean_up([directory])