diff --git a/game/scripts/main.gd b/game/scripts/main.gd index c270348..b945d18 100644 --- a/game/scripts/main.gd +++ b/game/scripts/main.gd @@ -57,6 +57,9 @@ var _save_blocked := false var _figure_wait := 0.0 ## The address a guest is joining, for messages. var _joining := "" +## The host's accepted guests when _process last looked. A new one ends a +## pause. +var _known_guests := 0 func _ready() -> void: @@ -233,12 +236,37 @@ func _leave(message: String) -> void: _show_menu(message) +## A broken session runs no more ticks. Instead of leaving the others +## waiting, a host closes its world to guests and a guest leaves. +func _stop_sharing() -> void: + if not session.is_host(): + _leave(tr("SIMULATION_STOPPED")) + return + link.link.close() + link = null + figures.clear() + _update_status() + + +## Ends a pause once the host accepted a guest: the guest joins in the next +## tick, and a paused world would keep it waiting. A connection that has not +## greeted yet is no guest. +func _end_pause_for_new_guests() -> void: + var guests := session.guest_count() + if guests > _known_guests and clock.paused: + _set_paused(false) + _known_guests = guests + + func _process(delta: float) -> void: if link != null: for notice in link.pump(delta): _on_notice(notice) + if session != null and session.is_broken() and link != null: + _stop_sharing() if session == null or not session.is_running() or session.is_broken(): return + _end_pause_for_new_guests() _update_marker() if Input.is_action_pressed("dig") and not clock.paused: _try_dig() @@ -257,11 +285,10 @@ func _process(delta: float) -> void: func _on_notice(notice: Dictionary) -> void: + # An earlier notice of the same batch may have ended the session. + if session == null: + return 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 @@ -304,7 +331,8 @@ func _unhandled_input(event: InputEvent) -> void: elif event.is_action_pressed("load_game"): _reload() elif event.is_action_pressed("toggle_pause"): - if session.is_host() and session.guest_count() == 0: + # Ending a pause is always allowed, starting one only alone. + if clock.paused or (session.is_host() and session.guest_count() == 0): _set_paused(not clock.paused) else: hud.flash("PAUSE_ONLY_ALONE") diff --git a/game/tests/test_main.gd b/game/tests/test_main.gd index 44b0c75..d40fae0 100644 --- a/game/tests/test_main.gd +++ b/game/tests/test_main.gd @@ -268,6 +268,34 @@ func test_only_the_host_saves_and_only_alone_one_may_pause_or_load() -> void: _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") @@ -295,6 +323,36 @@ func test_a_guest_goes_back_to_the_menu_when_the_host_closes() -> void: _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(), "", "and no longer says that it does") + 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) @@ -305,6 +363,17 @@ func test_a_guest_that_is_turned_away_learns_why() -> void: _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)