Spielen allein, als Host oder als Gast

Die Hauptszene beginnt mit dem Startmenü oder mit dem Direktstart aus
der Befehlszeile. Gäste sehen die Welt des Hosts und die Figuren aller
Spieler. Nur der Host speichert, Pause gibt es nur ohne Gäste, Laden
nur beim Spielen allein. Ablehnungen, Abbrüche und Abgleiche erscheinen
als Meldung, und ein Spielstand, der sich gerade nicht lesen lässt,
bleibt unangetastet.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Steven Wroblewski
2026-10-01 22:03:42 +02:00
parent d2da661452
commit 53ef02ae75
3 changed files with 534 additions and 82 deletions
+297 -58
View File
@@ -1,45 +1,83 @@
extends Node2D extends Node2D
## Composition root of a solo session: connects the simulation core with the ## Composition root. Shows the start menu, then runs a session as host or as
## map view, the figure, the HUD and the files on disk. ## 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. ## Seed of a new world until the slice ships its chosen seed.
const NEW_WORLD_SEED := 2026 const NEW_WORLD_SEED := 2026
const PLAYER_NAME := "Spieler"
## How far the pickaxe reaches, in tiles from the figure to the tile centre. ## How far the pickaxe reaches, in tiles from the figure to the tile centre.
const REACH_TILES := 2.5 const REACH_TILES := 2.5
## Ticks between two automatic saves: two minutes. ## Ticks between two automatic saves: two minutes.
const AUTOSAVE_TICKS := 2 * 60 * GwSession.TICKS_PER_SECOND const AUTOSAVE_TICKS := 2 * 60 * GwSession.TICKS_PER_SECOND
const CAMERA_ZOOM := Vector2(3.0, 3.0) 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 ## Folder for the player key, the save and the recordings. If it is empty,
## own folder. ## the command line decides; tests set a folder of their own.
@export var storage_dir := "user://" @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 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 clock: TickClock
var menu: Menu
var map_view: MapView var map_view: MapView
var marker: TileMarker var marker: TileMarker
var avatar: Avatar var avatar: Avatar
var figures: Figures
var hud: Hud 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 _player_key: PackedByteArray
var _next_dig_tick := 0 var _next_dig_tick := 0
## True while an unusable save could not be set aside. Saving is refused ## True while an unusable save could not be set aside. Saving is refused
## then, so that the unusable save is never overwritten. ## then, so that the unusable save is never overwritten.
var _save_blocked := false var _save_blocked := false
var _figure_wait := 0.0
## The address a guest is joining, for messages.
var _joining := ""
func _ready() -> void: func _ready() -> void:
I18n.install() I18n.install()
InputSetup.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) store = SaveStore.new(storage_dir)
_player_key = store.player_key() _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) clock = TickClock.new(GwSession.TICKS_PER_SECOND)
map_view = MapView.new() map_view = MapView.new()
add_child(map_view) add_child(map_view)
marker = TileMarker.new() marker = TileMarker.new()
add_child(marker) add_child(marker)
figures = Figures.new()
add_child(figures)
avatar = Avatar.new() avatar = Avatar.new()
add_child(avatar) add_child(avatar)
var camera := Camera2D.new() var camera := Camera2D.new()
@@ -47,76 +85,268 @@ func _ready() -> void:
avatar.add_child(camera) avatar.add_child(camera)
hud = Hud.new() hud = Hud.new()
add_child(hud) add_child(hud)
if not _start_from_save(): menu = Menu.new()
_start_new_world() 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: 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 return
_update_marker() _update_marker()
if Input.is_action_pressed("dig") and not clock.paused: if Input.is_action_pressed("dig") and not clock.paused:
_try_dig() _try_dig()
if session.is_host():
for i in clock.advance(delta): for i in clock.advance(delta):
_run_tick() _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: func _unhandled_input(event: InputEvent) -> void:
if session == null or not session.is_running():
return
if event.is_action_pressed("save_game"): if event.is_action_pressed("save_game"):
if session.is_host():
hud.flash("SAVED" if save_game() else "SAVE_FAILED") hud.flash("SAVED" if save_game() else "SAVE_FAILED")
else:
hud.flash("HOST_SAVES")
elif event.is_action_pressed("load_game"): elif event.is_action_pressed("load_game"):
_write_recording() _reload()
hud.flash("LOADED" if _start_from_save() else "LOAD_FAILED")
elif event.is_action_pressed("toggle_pause"): elif event.is_action_pressed("toggle_pause"):
clock.paused = not clock.paused if session.is_host() and session.guest_count() == 0:
avatar.set_process(not clock.paused) _set_paused(not clock.paused)
hud.show_paused(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: func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST and session != null: if what != NOTIFICATION_WM_CLOSE_REQUEST or session == null:
if not save_game(): return
if session.is_running():
if session.is_host() and not save_game():
push_warning("the game could not be saved on close") push_warning("the game could not be saved on close")
_write_recording() _write_recording()
if link != null:
link.link.close()
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))
func _run_tick() -> void: func _run_tick() -> void:
@@ -138,6 +368,14 @@ func _apply(events: Array[Dictionary]) -> void:
"inventory_full": "inventory_full":
if event["player"] == local: if event["player"] == local:
hud.flash("INVENTORY_FULL") 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(): if session.is_broken():
hud.flash("SIMULATION_STOPPED") hud.flash("SIMULATION_STOPPED")
push_error(session.last_error()) 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 ## 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: func save_game() -> bool:
if _save_blocked: if _save_blocked or not session.is_host():
return false return false
return store.write_save(session.save()) return store.write_save(session.save())
+232 -21
View File
@@ -1,76 +1,162 @@
extends TestCase extends TestCase
## Starts the real main scene with its own storage folder and runs a few ## Starts the real main scene with its own storage folder. The network tests
## frames. ## start two of them in this process: a host and a guest.
var _started: Array[Node] = []
func _start_main(directory: String) -> Node: func _start_main(directory: String) -> Node:
var main: Node = load("res://main.tscn").instantiate() var main: Node = load("res://main.tscn").instantiate()
main.storage_dir = directory main.storage_dir = directory
tree.root.add_child(main) tree.root.add_child(main)
_started.append(main)
return main return main
func _stop_main(main: Node) -> void: func _stop_main(main: Node) -> void:
_started.erase(main)
tree.root.remove_child(main) tree.root.remove_child(main)
if main.link != null:
main.link.link.close()
main.free() 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: func test_a_new_game_starts_and_the_player_joins() -> void:
var directory := TestFiles.temporary_dir("main") var directory := TestFiles.temporary_dir("main")
var main := _start_main(directory) var main := _start_main(directory)
main.play(false)
for frame in 5: for frame in 5:
await tree.process_frame await tree.process_frame
var session: GwSession = main.session var session: GwSession = main.session
check(session != null and session.is_running(), "a session runs") check(session != null and session.is_running(), "a session runs")
check(not session.is_broken(), "the simulation is healthy") 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(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.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") check_eq(main.hud.stone_text(), tr("STONE_COUNT") % 0, "the HUD shows the stones")
_stop_main(main) _clean_up([directory])
TestFiles.remove_dir(directory)
func test_a_saved_game_is_continued_on_the_next_start() -> void: func test_a_saved_game_is_continued_on_the_next_start() -> void:
var directory := TestFiles.temporary_dir("continue") var directory := TestFiles.temporary_dir("continue")
var first := _start_main(directory) var first := _start_main(directory)
first.play(false)
await tree.process_frame await tree.process_frame
var tick_at_save: int = first.session.tick() var tick_at_save: int = first.session.tick()
check(first.save_game(), "the game was saved") check(first.save_game(), "the game was saved")
_stop_main(first) _stop_main(first)
var second := _start_main(directory) var second := _start_main(directory)
second.play(false)
await tree.process_frame await tree.process_frame
check(second.session.tick() > tick_at_save, "the saved world continues") check(second.session.tick() > tick_at_save, "the saved world continues")
check_eq(second.session.local_player(), 0, "the same player rejoins") check_eq(second.session.local_player(), 0, "the same player rejoins")
_stop_main(second) _clean_up([directory])
TestFiles.remove_dir(directory)
func test_closing_the_window_saves_the_game_and_records_the_session() -> void: func test_closing_the_window_saves_the_game_and_records_the_session() -> void:
var directory := TestFiles.temporary_dir("close") var directory := TestFiles.temporary_dir("close")
var main := _start_main(directory) var main := _start_main(directory)
main.play(false)
await tree.process_frame await tree.process_frame
main.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST) main.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST)
var store := SaveStore.new(directory) var store := SaveStore.new(directory)
check(store.has_save(), "the game was saved") check(store.has_save(), "the game was saved")
check_eq(store.recordings().size(), 1, "the session was recorded") check_eq(store.recordings().size(), 1, "the session was recorded")
_stop_main(main) _clean_up([directory])
TestFiles.remove_dir(directory)
func test_the_game_is_saved_automatically_every_two_minutes() -> void: func test_the_game_is_saved_automatically_every_two_minutes() -> void:
var directory := TestFiles.temporary_dir("autosave") var directory := TestFiles.temporary_dir("autosave")
var main := _start_main(directory) var main := _start_main(directory)
main.play(false)
var store := SaveStore.new(directory) var store := SaveStore.new(directory)
check(not store.has_save(), "there is no save at the start") check(not store.has_save(), "there is no save at the start")
# No frames are awaited, so only these ticks run. # No frames are awaited, so only these ticks run. The loop is counted, so
while main.session.tick() < main.AUTOSAVE_TICKS - 1: # 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() main._run_tick()
check(not store.has_save(), "still no save one tick before the interval ends") check(not store.has_save(), "still no save one tick before the interval ends")
main._run_tick() main._run_tick()
check(store.has_save(), "saved when the interval ends") check(store.has_save(), "saved when the interval ends")
_stop_main(main) _clean_up([directory])
TestFiles.remove_dir(directory)
func test_an_unusable_save_is_set_aside_and_a_new_world_starts() -> void: 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) var store := SaveStore.new(directory)
store.write_save(PackedByteArray([1, 2, 3])) store.write_save(PackedByteArray([1, 2, 3]))
var main := _start_main(directory) var main := _start_main(directory)
await tree.process_frame check(main.play(false), "a new world starts")
check(main.session.is_running(), "a new world runs") check(main.session.tick() <= 1, "it is new, tick %d" % main.session.tick())
check(main.session.tick() <= 5, "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(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") check_eq(main.hud.message(), tr("LOAD_FAILED"), "the player is told")
_stop_main(main) _clean_up([directory])
TestFiles.remove_dir(directory)
func test_an_unusable_save_that_cannot_be_set_aside_is_never_overwritten() -> void: 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. # A folder under the set-aside name keeps the unusable save from moving.
DirAccess.make_dir_absolute(directory.path_join(SaveStore.SAVE_FILE + ".broken")) DirAccess.make_dir_absolute(directory.path_join(SaveStore.SAVE_FILE + ".broken"))
var main := _start_main(directory) var main := _start_main(directory)
await tree.process_frame check(main.play(false), "a new world starts")
check(main.session.is_running(), "a new world runs")
check(not main.save_game(), "saving is refused") check(not main.save_game(), "saving is refused")
check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "the unusable save is untouched") 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") check_eq(main.hud.message(), tr("LOAD_FAILED"), "the player is told")
_stop_main(main) _clean_up([directory])
TestFiles.remove_dir(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) DirAccess.make_dir_recursive_absolute(STORAGE)
var main: Node = load("res://main.tscn").instantiate() var main: Node = load("res://main.tscn").instantiate()
main.storage_dir = STORAGE main.storage_dir = STORAGE
main.command_line = PackedStringArray()
root.add_child(main) root.add_child(main)
main.play(false)
await process_frame await process_frame
# Four tiles to the right, toward the rock outcrop beside the clearing. # Four tiles to the right, toward the rock outcrop beside the clearing.