M1c: Gleichschritt im Netz #9

Merged
steven merged 20 commits from m1c-gleichschritt-im-netz into main 2026-10-02 06:41:02 +00:00
3 changed files with 534 additions and 82 deletions
Showing only changes of commit 53ef02ae75 - Show all commits
+297 -58
View File
@@ -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()
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"):
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())
+232 -21
View File
@@ -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])
+2
View File
@@ -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.