feat: expand progression and multiplayer systems

Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
This commit is contained in:
Alexander Sellite 2026-08-23 20:48:38 -04:00
parent 1db1a5b754
commit 3b84bfe3a0
97 changed files with 7869 additions and 982 deletions

View file

@ -3,6 +3,8 @@ extends SceneTree
const MainScene: PackedScene = preload("res://main/main.tscn")
const TEST_PORT: int = 18194
const LATE_MESSAGE: String = "late join visibility check"
const CLEAR_MESSAGE: String = "late join animation clear check"
const ACTIVE_ACTION: StringName = &"draw"
func _initialize() -> void:
@ -26,8 +28,21 @@ func _run_host() -> void:
var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(bool(main.call(
"_apply_world",
WorldLayout.STARTER_ISLAND,
NetworkProtocol.DEFAULT_WORLD_SEED,
true,
)))
assert(session.set_host_world(
WorldLayout.STARTER_ISLAND,
NetworkProtocol.DEFAULT_WORLD_SEED,
))
assert(session.start_private_host(TEST_PORT))
assert(save_manager.initialize_new_game())
assert(save_manager.initialize_new_game(
NetworkProtocol.DEFAULT_WORLD_SEED,
WorldLayout.STARTER_ISLAND,
))
main.call("_enter_gameplay")
for _frame: int in 4:
await physics_frame
@ -35,7 +50,26 @@ func _run_host() -> void:
assert(await _wait_for_registry_size(session, 3))
var spawn := main.get_node("%PlayerSpawnService") as PlayerSpawnService
assert(spawn.get_peer_ids().size() == 3)
await create_timer(5.0).timeout
var action_peer_id: int = await _wait_for_remote_action(
spawn,
ACTIVE_ACTION,
)
assert(action_peer_id > 1)
assert(await _wait_for_avatar_bool(
spawn,
action_peer_id,
&"_active_item_is_net",
true,
))
assert(await _wait_for_avatar_sitting(spawn, action_peer_id, true))
assert(await _wait_for_avatar_action(
spawn,
action_peer_id,
&"",
))
assert(await _wait_for_avatar_sitting(spawn, action_peer_id, false))
var chat := main.get_node("%NetworkChatService") as NetworkChatService
assert(await _wait_for_history_message(chat, CLEAR_MESSAGE))
print("Late-join multiplayer host validation: PASS")
await _cleanup(main, session)
@ -47,7 +81,23 @@ func _run_first_client() -> void:
var preview: Dictionary = profile.get_persisted_appearance().duplicate(true)
preview["scale"] = 0.9
assert(profile.preview_appearance(preview))
await create_timer(2.0).timeout
var player := main.get("_player") as Player
assert(player != null)
var floor_deadline: int = Time.get_ticks_msec() + 5000
while Time.get_ticks_msec() < floor_deadline and not player.is_on_floor():
await physics_frame
assert(player.is_on_floor())
player.toggle_sitting()
assert(player.is_sitting())
assert(player.bag.add_item(&"crab_net"))
assert(player.hotbar.assign_item(0, &"crab_net"))
assert(player.hotbar.select_slot(0))
for _frame: int in 2:
await process_frame
assert(bool(player.get("_active_item_is_net")))
assert(player.begin_animation_action(ACTIVE_ACTION))
await create_timer(0.5).timeout
assert(StringName(str(player.get("_animation_action_id"))) == ACTIVE_ACTION)
var chat := main.get_node("%NetworkChatService") as NetworkChatService
var deadline: int = Time.get_ticks_msec() + 15000
while Time.get_ticks_msec() < deadline:
@ -61,6 +111,15 @@ func _run_first_client() -> void:
assert(_history_contains(chat, LATE_MESSAGE))
var spawn := main.get_node("%PlayerSpawnService") as PlayerSpawnService
assert(spawn.get_peer_ids().size() == 3)
# Keep the action active until the late peer has joined and announced that
# it has received the lifecycle snapshot. This exercises client -> host ->
# existing client/late client replication, including the join-in-progress
# state carried by the spawn envelope.
await create_timer(1.0).timeout
player.end_animation_action()
player.toggle_sitting()
assert(not player.is_sitting())
assert(await _wait_for_history_message(chat, CLEAR_MESSAGE))
print("Late-join first-client validation: PASS")
await _cleanup(main, session)
@ -77,9 +136,28 @@ func _run_late_client() -> void:
assert(session.get_authenticated_peer_ids().size() == 3)
var spawn := main.get_node("%PlayerSpawnService") as PlayerSpawnService
assert(spawn.get_peer_ids().size() == 3)
var action_peer_id: int = await _wait_for_remote_action(
spawn,
ACTIVE_ACTION,
)
assert(action_peer_id > 1)
assert(await _wait_for_avatar_bool(
spawn,
action_peer_id,
&"_active_item_is_net",
true,
))
assert(await _wait_for_avatar_sitting(spawn, action_peer_id, true))
var chat := main.get_node("%NetworkChatService") as NetworkChatService
assert(chat.send_local_message(LATE_MESSAGE))
await create_timer(1.0).timeout
assert(await _wait_for_avatar_action(
spawn,
action_peer_id,
&"",
))
assert(await _wait_for_avatar_sitting(spawn, action_peer_id, false))
assert(chat.send_local_message(CLEAR_MESSAGE))
await create_timer(0.5).timeout
print("Late-join late-client validation: PASS")
await _cleanup(main, session)
@ -114,6 +192,136 @@ func _history_contains(service: NetworkChatService, body: String) -> bool:
)
func _wait_for_history_message(
service: NetworkChatService,
body: String,
) -> bool:
var deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < deadline:
await process_frame
if _history_contains(service, body):
return true
return false
func _wait_for_remote_action(
spawn_service: PlayerSpawnService,
action_id: StringName,
) -> int:
var deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < deadline:
await process_frame
for peer_id: int in spawn_service.get_peer_ids():
var avatar: Player = spawn_service.get_avatar(peer_id)
if (
avatar != null
and _observed_action_id(avatar) == action_id
):
return peer_id
print(
"animation wait timed out: ",
_action_debug_snapshot(spawn_service),
)
return 0
func _wait_for_avatar_action(
spawn_service: PlayerSpawnService,
peer_id: int,
action_id: StringName,
) -> bool:
var deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < deadline:
await process_frame
var avatar: Player = spawn_service.get_avatar(peer_id)
if (
avatar != null
and _observed_action_id(avatar) == action_id
):
return true
print(
"animation clear wait timed out: ",
_action_debug_snapshot(spawn_service),
)
return false
func _wait_for_avatar_bool(
spawn_service: PlayerSpawnService,
peer_id: int,
property_name: StringName,
expected: bool,
) -> bool:
var deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < deadline:
await process_frame
var avatar: Player = spawn_service.get_avatar(peer_id)
if avatar != null and bool(avatar.get(property_name)) == expected:
return true
return false
func _wait_for_avatar_sitting(
spawn_service: PlayerSpawnService,
peer_id: int,
expected: bool,
) -> bool:
var deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < deadline:
await process_frame
var avatar: Player = spawn_service.get_avatar(peer_id)
if avatar != null and avatar.is_sitting() == expected:
return true
var avatar: Player = spawn_service.get_avatar(peer_id)
if avatar != null:
print(
"sitting wait timed out: ",
{
"peer_id": peer_id,
"expected": expected,
"sitting": avatar.is_sitting(),
"sit_after_landing": bool(avatar.get("_sit_after_landing")),
"on_floor": avatar.is_on_floor(),
"position": avatar.global_position,
"velocity": avatar.velocity,
"authoritative": bool(avatar.get(
"_network_authoritative_simulation"
)),
"last_input": int(avatar.get(
"_last_network_input_sequence"
)),
},
)
return false
func _observed_action_id(avatar: Player) -> StringName:
return StringName(str(avatar.get(
"_animation_action_id"
if bool(avatar.get("_network_authoritative_simulation"))
else "_network_target_animation_action_id"
)))
func _action_debug_snapshot(spawn_service: PlayerSpawnService) -> Dictionary:
var result: Dictionary = {}
for peer_id: int in spawn_service.get_peer_ids():
var avatar: Player = spawn_service.get_avatar(peer_id)
if avatar == null:
continue
result[peer_id] = {
"authoritative": bool(avatar.get(
"_network_authoritative_simulation"
)),
"source": str(avatar.get("_animation_action_id")),
"target": str(avatar.get(
"_network_target_animation_action_id"
)),
"last_input": int(avatar.get("_last_network_input_sequence")),
}
return result
func _create_initialized_main() -> Node:
root.size = Vector2i(1280, 720)
var main: Node = MainScene.instantiate()