diff --git a/game/scripts/main.gd b/game/scripts/main.gd index ea5dbb1..c270348 100644 --- a/game/scripts/main.gd +++ b/game/scripts/main.gd @@ -1,45 +1,83 @@ extends Node2D -## Composition root of a solo session: connects the simulation core with the -## map view, the figure, the HUD and the files on disk. +## Composition root. Shows the start menu, then runs a session as host or as +## guest with the map view, the figures, the HUD and the files on disk. ## Seed of a new world until the slice ships its chosen seed. const NEW_WORLD_SEED := 2026 -const PLAYER_NAME := "Spieler" ## How far the pickaxe reaches, in tiles from the figure to the tile centre. const REACH_TILES := 2.5 ## Ticks between two automatic saves: two minutes. const AUTOSAVE_TICKS := 2 * 60 * GwSession.TICKS_PER_SECOND const CAMERA_ZOOM := Vector2(3.0, 3.0) +## Most ticks a guest runs in one frame. A guest that fell behind catches up +## within a few frames without freezing. +const MAX_GUEST_TICKS_PER_FRAME := 20 +## Seconds between two position updates of the own figure. +const FIGURE_SECONDS := 0.1 +## The text for every reason a host gives for turning a guest away. +const REFUSAL_TEXTS := { + GwSession.REFUSED_OTHER_PROTOCOL: "REFUSED_OTHER_PROTOCOL", + GwSession.REFUSED_OTHER_RULES: "REFUSED_OTHER_RULES", + GwSession.REFUSED_WORLD_FULL: "REFUSED_WORLD_FULL", + GwSession.REFUSED_TOO_MANY_PLAYERS: "REFUSED_TOO_MANY_PLAYERS", + GwSession.REFUSED_ALREADY_PLAYING: "REFUSED_ALREADY_PLAYING", +} -## Folder for the player key, the save and the recordings. Tests use their -## own folder. -@export var storage_dir := "user://" +## Folder for the player key, the save and the recordings. If it is empty, +## the command line decides; tests set a folder of their own. +@export var storage_dir := "" +## Network interface an open world listens on. Tests use the loopback one. +var listen_address := "*" +## The command line arguments after "--". Tools and tests may set their own +## before the scene enters the tree. +var command_line := OS.get_cmdline_user_args() -var session: GwSession +var options: LaunchOptions var store: SaveStore +## The running session, or null in the menu. A guest's session has no world +## until the host sent it. +var session: GwSession +## The network connection of an open world or a guest, or null. +var link: SessionLink var clock: TickClock +var menu: Menu var map_view: MapView var marker: TileMarker var avatar: Avatar +var figures: Figures var hud: Hud +## How often a guest's world diverged and was replaced by the host's. +var resyncs := 0 var _player_key: PackedByteArray var _next_dig_tick := 0 ## True while an unusable save could not be set aside. Saving is refused ## then, so that the unusable save is never overwritten. var _save_blocked := false +var _figure_wait := 0.0 +## The address a guest is joining, for messages. +var _joining := "" func _ready() -> void: I18n.install() InputSetup.install() + options = LaunchOptions.parse(command_line) + for argument in options.ignored: + push_warning("ignored command line argument: %s" % argument) + if storage_dir.is_empty(): + storage_dir = options.storage_dir() store = SaveStore.new(storage_dir) _player_key = store.player_key() + if _player_key.is_empty(): + push_warning("the player key cannot be read: %s" % error_string(store.last_read_error)) clock = TickClock.new(GwSession.TICKS_PER_SECOND) map_view = MapView.new() add_child(map_view) marker = TileMarker.new() add_child(marker) + figures = Figures.new() + add_child(figures) avatar = Avatar.new() add_child(avatar) var camera := Camera2D.new() @@ -47,76 +85,268 @@ func _ready() -> void: avatar.add_child(camera) hud = Hud.new() add_child(hud) - if not _start_from_save(): - _start_new_world() + menu = Menu.new() + add_child(menu) + menu.name_field.text = options.player_name if not options.player_name.is_empty() else store.player_name() + menu.address_field.text = options.address if not options.address.is_empty() else store.last_address() + menu.play_chosen.connect(play) + menu.join_chosen.connect(join) + _show_menu("") + match options.start: + "solo": + play(false) + "host": + play(true) + "join": + join(options.address) + + +## Plays the saved world, or a new one, as its host. With `open`, guests may +## join. Returns false if it could not start; the menu then says why. +func play(open: bool) -> bool: + if not _can_start(): + return false + var next := GwSession.new() + var set_aside := false + if store.has_save(): + var save := store.read_save() + if store.last_read_error != OK: + _show_menu(tr("SAVE_UNREADABLE")) + return false + set_aside = not _load(next, save) + if not next.is_running(): + next.start_new(NEW_WORLD_SEED) + if open: + var net := NetLink.new() + if net.host(options.port, listen_address) != OK: + _show_menu(tr("OPEN_FAILED") % options.port) + return false + link = SessionLink.new(next, net) + session = next + session.join(_player_key, _player_name()) + _run_tick() + _show_world() + if set_aside: + hud.flash("LOAD_FAILED") + return true + + +## Starts joining the host at `address`, "host" or "host:port". The world +## arrives later; until then the menu says that it is connecting. +func join(address: String) -> void: + if not _can_start(): + return + var target := NetLink.split_address(address) + if target.is_empty(): + _show_menu(tr("ADDRESS_INVALID") % address) + return + var net := NetLink.new() + if net.join(target[0], target[1]) != OK: + _show_menu(tr("CONNECT_FAILED") % address) + return + session = GwSession.new() + session.start_guest(_player_key, _player_name()) + link = SessionLink.new(session, net) + _joining = address + store.set_last_address(address) + _show_menu(tr("CONNECTING") % address) + + +func _can_start() -> bool: + if session != null: + return false + if _player_key.is_empty(): + _show_menu(tr("KEY_UNREADABLE")) + return false + store.set_player_name(menu.player_name()) + return true + + +func _player_name() -> String: + var typed := menu.player_name() + return typed if not typed.is_empty() else tr("DEFAULT_NAME") + + +## Starts `next` from `save`. If the save is unusable, it is set aside so +## that the next save does not overwrite it, and false is returned. +func _load(next: GwSession, save: PackedByteArray) -> bool: + if next.start_loaded(save): + _save_blocked = false + return true + var aside := store.set_aside_save() + if aside.is_empty(): + _save_blocked = true + push_warning("save not usable (%s) and could not be set aside; saving is off" % next.last_error()) + else: + push_warning("save not usable (%s), kept as %s" % [next.last_error(), aside]) + return false + + +func _show_menu(status: String) -> void: + menu.show_status(status) + menu.show() + hud.hide() + for node: Node2D in [avatar, marker, figures]: + node.hide() + + +## Shows the world of the session and puts the figure on the spawn. +func _show_world() -> void: + menu.hide() + hud.show() + for node: Node2D in [avatar, marker, figures]: + node.show() + map_view.session = session + map_view.queue_redraw() + figures.session = session + figures.clear() + avatar.is_walkable = session.is_walkable + avatar.position = GridMotion.tile_center(session.spawn_point()) + hud.show_stone(session.item_count(GwSession.ITEM_STONE)) + _update_status() + _next_dig_tick = 0 + + +## Says in the status line how the game is shared, and with how many. +func _update_status() -> void: + if link == null: + hud.show_status("") + elif session.is_host(): + hud.show_status(tr("STATUS_HOST") % [link.link.local_port(), session.online_players().size()]) + else: + hud.show_status(tr("STATUS_GUEST") % [_joining, session.online_players().size()]) + + +## Ends a guest's session and goes back to the menu with `message`. +func _leave(message: String) -> void: + if session == null: + return + _write_recording() + if link != null: + link.link.close() + link = null + session = null + map_view.session = null + map_view.queue_redraw() + avatar.is_walkable = Callable() + figures.clear() + _show_menu(message) func _process(delta: float) -> void: - if session == null or session.is_broken(): + if link != null: + for notice in link.pump(delta): + _on_notice(notice) + if session == null or not session.is_running() or session.is_broken(): return _update_marker() if Input.is_action_pressed("dig") and not clock.paused: _try_dig() - for i in clock.advance(delta): - _run_tick() + if session.is_host(): + for i in clock.advance(delta): + _run_tick() + else: + for i in mini(session.pending_ticks(), MAX_GUEST_TICKS_PER_FRAME): + _apply(session.step()) + if link != null: + link.flush() + _figure_wait -= delta + if _figure_wait <= 0.0: + _figure_wait = FIGURE_SECONDS + link.send_figure(avatar.position) + + +func _on_notice(notice: Dictionary) -> void: + match notice["type"]: + "guest_arrived": + # Pausing is for playing alone; a guest ends the pause. + if clock.paused: + _set_paused(false) + "world_received": + if notice["resync"]: + resyncs += 1 + _keep_diverged_recording() + map_view.queue_redraw() + hud.show_stone(session.item_count(GwSession.ITEM_STONE)) + hud.flash("RESYNCED") + else: + _show_world() + "refused": + _leave(tr(REFUSAL_TEXTS.get(notice["reason"], "PROTOCOL_ERROR"))) + "protocol_error": + push_warning("broken message from the host: %s" % session.last_error()) + _leave(tr("PROTOCOL_ERROR")) + "host_lost": + _leave(tr("HOST_LEFT")) + "failed": + _leave(tr("CONNECT_FAILED") % _joining) + "divergence": + push_warning("guest %d diverged at tick %d with state %s" % [notice["peer"], notice["tick"], notice["hash"]]) + "figure": + figures.place(notice["player"], notice["position"]) + + +## Keeps the recording of a guest's world that diverged from the host's, so +## that the divergence can be replayed. +func _keep_diverged_recording() -> void: + var path := store.write_recording(session.take_diverged_recording()) + push_warning("the world diverged from the host's; its recording is %s" % path) func _unhandled_input(event: InputEvent) -> void: + if session == null or not session.is_running(): + return if event.is_action_pressed("save_game"): - hud.flash("SAVED" if save_game() else "SAVE_FAILED") + if session.is_host(): + hud.flash("SAVED" if save_game() else "SAVE_FAILED") + else: + hud.flash("HOST_SAVES") elif event.is_action_pressed("load_game"): - _write_recording() - hud.flash("LOADED" if _start_from_save() else "LOAD_FAILED") + _reload() elif event.is_action_pressed("toggle_pause"): - clock.paused = not clock.paused - avatar.set_process(not clock.paused) - hud.show_paused(clock.paused) + if session.is_host() and session.guest_count() == 0: + _set_paused(not clock.paused) + else: + hud.flash("PAUSE_ONLY_ALONE") + + +## Goes back to the saved world. Only when playing alone: guests would no +## longer have the host's world. +func _reload() -> void: + if link != null: + hud.flash("LOAD_ONLY_ALONE") + return + var save := store.read_save() + if store.last_read_error != OK: + hud.flash("LOAD_FAILED") + return + var next := GwSession.new() + if not _load(next, save): + hud.flash("LOAD_FAILED") + return + _write_recording() + session = next + session.join(_player_key, _player_name()) + _run_tick() + _show_world() + hud.flash("LOADED") + + +func _set_paused(paused: bool) -> void: + clock.paused = paused + avatar.set_process(not paused) + hud.show_paused(paused) func _notification(what: int) -> void: - if what == NOTIFICATION_WM_CLOSE_REQUEST and session != null: - if not save_game(): + if what != NOTIFICATION_WM_CLOSE_REQUEST or session == null: + return + if session.is_running(): + if session.is_host() and not save_game(): push_warning("the game could not be saved on close") _write_recording() - - -func _start_new_world() -> void: - var fresh := GwSession.new() - fresh.start_new(NEW_WORLD_SEED) - _enter(fresh) - - -## Continues the saved world. Returns false if there is no usable save. An -## unusable save is set aside, so that the next save does not overwrite it. -func _start_from_save() -> bool: - if not store.has_save(): - return false - var loaded := GwSession.new() - if not loaded.start_loaded(store.read_save()): - var aside := store.set_aside_save() - if aside.is_empty(): - _save_blocked = true - push_warning("save not usable (%s) and could not be set aside; saving is off" % loaded.last_error()) - else: - push_warning("save not usable (%s), kept as %s" % [loaded.last_error(), aside]) - hud.flash("LOAD_FAILED") - return false - _save_blocked = false - _enter(loaded) - return true - - -## Makes `next` the running session and lets the local player join it. -func _enter(next: GwSession) -> void: - session = next - session.join(_player_key, PLAYER_NAME) - _run_tick() - _next_dig_tick = 0 - map_view.session = session - map_view.queue_redraw() - avatar.is_walkable = session.is_walkable - avatar.position = GridMotion.tile_center(session.spawn_point()) - hud.show_stone(session.item_count(GwSession.ITEM_STONE)) + if link != null: + link.link.close() func _run_tick() -> void: @@ -138,6 +368,14 @@ func _apply(events: Array[Dictionary]) -> void: "inventory_full": if event["player"] == local: hud.flash("INVENTORY_FULL") + "player_joined": + if event["player"] != local: + hud.flash("PLAYER_JOINED", [session.player_name(event["player"])]) + _update_status() + "player_left": + figures.forget(event["player"]) + hud.flash("PLAYER_LEFT", [session.player_name(event["player"])]) + _update_status() if session.is_broken(): hud.flash("SIMULATION_STOPPED") push_error(session.last_error()) @@ -166,9 +404,10 @@ func dig_at(tile: Vector2i) -> void: ## Writes the running world as the save. Returns false if nothing was -## written, also while an unusable save could not be set aside. +## written: for a guest, whose world belongs to the host, and while an +## unusable save could not be set aside. func save_game() -> bool: - if _save_blocked: + if _save_blocked or not session.is_host(): return false return store.write_save(session.save()) diff --git a/game/tests/test_main.gd b/game/tests/test_main.gd index ff63a94..44b0c75 100644 --- a/game/tests/test_main.gd +++ b/game/tests/test_main.gd @@ -1,76 +1,162 @@ extends TestCase -## Starts the real main scene with its own storage folder and runs a few -## frames. +## 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") - _stop_main(main) - TestFiles.remove_dir(directory) + _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") - _stop_main(second) - TestFiles.remove_dir(directory) + _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") - _stop_main(main) - TestFiles.remove_dir(directory) + _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. - while main.session.tick() < main.AUTOSAVE_TICKS - 1: + # 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") - _stop_main(main) - TestFiles.remove_dir(directory) + _clean_up([directory]) func test_an_unusable_save_is_set_aside_and_a_new_world_starts() -> void: @@ -78,13 +164,11 @@ func test_an_unusable_save_is_set_aside_and_a_new_world_starts() -> void: var store := SaveStore.new(directory) store.write_save(PackedByteArray([1, 2, 3])) var main := _start_main(directory) - await tree.process_frame - check(main.session.is_running(), "a new world runs") - check(main.session.tick() <= 5, "it is new, tick %d" % main.session.tick()) + 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") - _stop_main(main) - TestFiles.remove_dir(directory) + _clean_up([directory]) func test_an_unusable_save_that_cannot_be_set_aside_is_never_overwritten() -> void: @@ -94,10 +178,137 @@ func test_an_unusable_save_that_cannot_be_set_aside_is_never_overwritten() -> vo # 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) - await tree.process_frame - check(main.session.is_running(), "a new world runs") + 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") - _stop_main(main) - TestFiles.remove_dir(directory) + _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_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_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_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]) diff --git a/game/tools/screenshot.gd b/game/tools/screenshot.gd index b417a71..85e012f 100644 --- a/game/tools/screenshot.gd +++ b/game/tools/screenshot.gd @@ -21,7 +21,9 @@ func _run() -> void: DirAccess.make_dir_recursive_absolute(STORAGE) var main: Node = load("res://main.tscn").instantiate() main.storage_dir = STORAGE + main.command_line = PackedStringArray() root.add_child(main) + main.play(false) await process_frame # Four tiles to the right, toward the rock outcrop beside the clearing.