Files
Grovewright/game/tests/test_main.gd
T
Steven Wroblewski be22e9f358 Abschlusskorrekturen M1c: Schläge der Gäste, Figuren und Fragebogen
- Der Host hält einen Schlag eines Gastes, der während der Abklingzeit
  ankommt, bis zum ersten Takt danach. Übers Internet ging sonst etwa
  jeder fünfte Schlag eines Gastes verloren.
- ENet drosselt die Figuren nie mehr; sonst konnten die Figuren eines
  von zwei Gästen dauerhaft stehen bleiben. Figuren gehen geordnet, und
  eine stille Verbindung gilt nach höchstens acht Sekunden als verloren.
- Positionen, die keine Zahlen sind, und Figuren von Spielern, die
  nicht online sind, werden verworfen.
- Ein Host mit angehaltener Simulation sagt das in der Statuszeile.
- Neue Tests: Schlag während der Abklingzeit, Zielauswahl von NetLink,
  verworfene Figuren, Neuabgleich über die Brücke.
- Der Fragebogen sagt, was jeder Rechner braucht, und die Freigabe in
  der Firewall gehört zum Host. CLAUDE.md beschreibt das Halten der
  Schläge, die Drosselung und die Zeitüberschreitung und ergänzt die
  Vertrauensregel um die Schlüssel.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 01:22:36 +02:00

384 lines
16 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])