M1c: Gleichschritt im Netz #9
+297
-58
@@ -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
@@ -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])
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user