714d604c58
Ein Gast kann die Stilproben öffnen, während er sich verbindet. Kam die Welt dann an, lag sie unter der Vitrine, und „Zurück" legte das Menü über das laufende Spiel. Jetzt schließt die Hauptszene die Vitrine, sobald eine Welt erscheint. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
455 lines
19 KiB
GDScript
455 lines
19 KiB
GDScript
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])
|