Files
Grovewright/game/tests/test_main.gd
T
Steven Wroblewski ab81f66ef0 Stil wählen, merken und fotografieren
F2 wechselt im Spiel den Stil und meldet ihn, --style=NAME wählt ihn
beim Start, und das Spiel merkt sich die Wahl in den Einstellungen.
Das Bildschirmfoto-Werkzeug nimmt Bilder in einem bestimmten Stil und
von der Vitrine auf und zeigt eine zweite Figur mit Namen.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 12:49:40 +02:00

482 lines
20 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])
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])