fix multiplayer commands and disconnect recovery

This commit is contained in:
Alexander Sellite 2026-08-13 23:57:15 -04:00
parent 5d7c3d7459
commit 65967816f5
8 changed files with 292 additions and 61 deletions

View file

@ -334,6 +334,7 @@ func _start_dedicated_server() -> void:
if not _network_session.set_host_open(true):
_fail_dedicated_server("Could not open the dedicated server.")
return
_player_jobs.begin_progression_session()
if config.public_listing and not _discovery.set_discoverable(true):
_network_session.disconnect_session("Discovery setup failed.")
_fail_dedicated_server(_discovery.get_host_status_message())
@ -560,7 +561,7 @@ func _initialize_application(dedicated: bool) -> void:
_network_player_list.set_surface_drawing_service(
_network_surface_drawing
)
_network_chat.setup(_network_session)
_network_chat.setup(_network_session, _world_time, _world_weather)
_network_fishing.setup(
_network_session,
_player_spawn_service,
@ -1646,14 +1647,16 @@ func _handle_failed_session_switch(message: String) -> void:
_show_title_music(true)
func _on_network_server_lost() -> void:
func _on_network_server_lost(message: String) -> void:
if not _gameplay_started:
return
_join_requested_from_title = false
_join_requested_from_pause = false
_pending_join_endpoint = ""
_set_gameplay_active(false)
var title_screen: TitleScreenType = _game_ui.get_title_screen()
title_screen.reopen()
title_screen.open_join_game_page(_pending_join_endpoint)
title_screen.report_network_error("The server connection was lost.")
title_screen.reopen_to_menu()
title_screen.report_network_error(message)
_show_title_music(true)

View file

@ -5,6 +5,7 @@ const BURST_COUNT: int = 3
const WINDOW_COUNT: int = 5
const WINDOW_SECONDS: float = 10.0
const CALL_COOLDOWN_MILLISECONDS: int = 180
const WORLD_COMMAND_COOLDOWN_MILLISECONDS: int = 500
const CALL_PITCH_VARIANTS: Array[float] = [
0.96,
1.03,
@ -22,6 +23,7 @@ signal message_received(message: Dictionary)
signal local_message_confirmed(message: Dictionary)
signal send_rejected(message: String)
signal history_replaced(messages: Array[Dictionary])
signal world_command_finished(success: bool, message: String)
signal character_call_received(
peer_id: int,
call_id: String,
@ -35,13 +37,22 @@ var _request_ledgers: Dictionary[int, Dictionary] = {}
var _rate_times: Dictionary[int, Array] = {}
var _last_call_msec: Dictionary[int, int] = {}
var _call_variant_indices: Dictionary[int, int] = {}
var _last_world_command_msec: Dictionary[int, int] = {}
var _sequence: int = 0
var _peer_names: Dictionary[int, String] = {}
var _relationships: PlayerRelationshipStore
var _world_time: WorldTimeService
var _world_weather: WorldWeatherService
func setup(session: NetworkSession) -> void:
func setup(
session: NetworkSession,
world_time: WorldTimeService,
world_weather: WorldWeatherService,
) -> void:
_session = session
_world_time = world_time
_world_weather = world_weather
_session.peer_authenticated.connect(_on_peer_authenticated)
_session.peer_removed.connect(_on_peer_removed)
_session.state_changed.connect(_on_session_state_changed)
@ -242,6 +253,188 @@ func broadcast_system_message(body: String) -> bool:
return true
func request_world_time_change(phase_name: String) -> bool:
var normalized: String = phase_name.strip_edges().to_lower()
if not _valid_time_phase(normalized) or _session == null:
return false
if _session.is_host():
return _apply_world_time_change(normalized)
if (
not _session.is_joined_client()
or not _session.is_local_operator()
or not _session.supports_server_capability(
NetworkProtocol.WORLD_TIME_CAPABILITY
)
):
return false
submit_world_time_command.rpc_id(1, normalized)
return true
func request_world_weather_change(weather_name: String) -> bool:
var normalized: String = _normalized_weather_name(weather_name)
if normalized.is_empty() or _session == null:
return false
if _session.is_host():
return _apply_world_weather_change(normalized)
if (
not _session.is_joined_client()
or not _session.is_local_operator()
or not _session.supports_server_capability(
NetworkProtocol.WORLD_WEATHER_CAPABILITY
)
):
return false
submit_world_weather_command.rpc_id(1, normalized)
return true
@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
func submit_world_time_command(phase_name: String) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not _valid_world_command_sender(sender_id):
_send_world_command_result(
sender_id, false, "Only the host or an operator can change world time."
)
return
if not _consume_world_command_rate(sender_id):
_send_world_command_result(sender_id, false, "Slow down.")
return
var success: bool = _apply_world_time_change(
phase_name.strip_edges().to_lower()
)
_send_world_command_result(
sender_id,
success,
"" if success else "World time could not be changed.",
)
@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
func submit_world_weather_command(weather_name: String) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not _valid_world_command_sender(sender_id):
_send_world_command_result(
sender_id,
false,
"Only the host or an operator can change world weather.",
)
return
if not _consume_world_command_rate(sender_id):
_send_world_command_result(sender_id, false, "Slow down.")
return
var success: bool = _apply_world_weather_change(
_normalized_weather_name(weather_name)
)
_send_world_command_result(
sender_id,
success,
"" if success else "World weather could not be changed.",
)
@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
func receive_world_command_result(success: bool, message: String) -> void:
if _session == null or not _session.is_joined_client():
return
world_command_finished.emit(success, message.left(120))
func _valid_world_command_sender(peer_id: int) -> bool:
return (
_session != null
and _session.is_host()
and peer_id > 1
and _session.is_authenticated_peer(peer_id)
and _session.is_peer_operator(peer_id)
)
func _apply_world_time_change(phase_name: String) -> bool:
if _world_time == null or not _valid_time_phase(phase_name):
return false
var target_hour: float = _time_phase_hour(phase_name)
if not _world_time.set_authoritative_time(target_hour):
return false
broadcast_system_message(
"World time set to %s (%s)."
% [phase_name, _world_time.get_clock_text()]
)
return true
func _apply_world_weather_change(weather_name: String) -> bool:
if _world_weather == null:
return false
var normalized: String = _normalized_weather_name(weather_name)
if normalized.is_empty():
return false
var target_weather: WorldWeatherService.Weather = (
_weather_for_name(normalized)
)
if not _world_weather.set_authoritative_weather(target_weather):
return false
broadcast_system_message("World weather set to %s." % normalized)
return true
func _send_world_command_result(
peer_id: int,
success: bool,
message: String,
) -> void:
if peer_id > 1 and _session.is_authenticated_peer(peer_id):
receive_world_command_result.rpc_id(peer_id, success, message.left(120))
func _consume_world_command_rate(peer_id: int) -> bool:
var now_msec: int = Time.get_ticks_msec()
var last_msec: int = _last_world_command_msec.get(
peer_id, now_msec - WORLD_COMMAND_COOLDOWN_MILLISECONDS
)
if now_msec - last_msec < WORLD_COMMAND_COOLDOWN_MILLISECONDS:
return false
_last_world_command_msec[peer_id] = now_msec
return true
static func _valid_time_phase(phase_name: String) -> bool:
return phase_name in ["dawn", "day", "dusk", "night"]
static func _time_phase_hour(phase_name: String) -> float:
match phase_name:
"dawn":
return WorldTimeService.DAWN_START_HOUR
"day":
return WorldTimeService.DAWN_END_HOUR
"dusk":
return WorldTimeService.DUSK_START_HOUR
"night":
return WorldTimeService.DUSK_END_HOUR
return -1.0
static func _normalized_weather_name(weather_name: String) -> String:
var normalized: String = weather_name.strip_edges().to_lower()
return "clear" if normalized == "sunny" else normalized if normalized in [
"clear", "cloudy", "rainy", "foggy"
] else ""
static func _weather_for_name(
weather_name: String,
) -> WorldWeatherService.Weather:
match weather_name:
"cloudy":
return WorldWeatherService.Weather.CLOUDY
"rainy":
return WorldWeatherService.Weather.RAINY
"foggy":
return WorldWeatherService.Weather.FOGGY
return WorldWeatherService.Weather.SUNNY
func get_history() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for message: Dictionary in _history:
@ -422,6 +615,7 @@ func _on_peer_removed(peer_id: int) -> void:
_rate_times.erase(peer_id)
_last_call_msec.erase(peer_id)
_call_variant_indices.erase(peer_id)
_last_world_command_msec.erase(peer_id)
if not _session.is_host():
return
var display_name: String = _peer_names.get(peer_id, "Player")
@ -481,6 +675,7 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_rate_times.clear()
_last_call_msec.clear()
_call_variant_indices.clear()
_last_world_command_msec.clear()
_peer_names.clear()
_sequence = 0
history_replaced.emit([])

View file

@ -34,7 +34,7 @@ signal server_trust_required(
)
signal peer_identity_observed(peer_id: int, status: String)
signal operator_status_changed(peer_id: int, is_operator: bool)
signal server_lost
signal server_lost(message: String)
signal remote_recovery_requested(peer_id: int, entry_position: Vector3)
signal remote_recovery_presentation_changed(
peer_id: int,
@ -875,7 +875,7 @@ func _on_server_disconnected() -> void:
)
_moderation_disconnect_message = ""
_set_state(State.SERVER_LOST, message)
server_lost.emit()
server_lost.emit(message)
connection_error.emit(message)
@ -1108,7 +1108,6 @@ func submit_client_identity_proof(data: Dictionary) -> void:
@rpc("authority", "call_remote", "reliable", 0)
func receive_moderation_disconnect(message: String) -> void:
_moderation_disconnect_message = message.left(120)
connection_error.emit(_moderation_disconnect_message)
@rpc("authority", "call_remote", "reliable", 0)

View file

@ -23,13 +23,12 @@ func _run() -> void:
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
var jobs := main.get_node("%PlayerJobService") as PlayerJobService
assert(session.start_private_host(TEST_PORT))
assert(save_manager.initialize_new_game())
main.call("_enter_gameplay")
assert(session.start_dedicated_host(TEST_PORT, 8, "127.0.0.1"))
jobs.begin_progression_session()
await process_frame
assert(session.set_host_open(true))
assert(session.is_dedicated_host())
var host_board: Dictionary = jobs.get_host_board_network_data()
assert(PlayerJobService.validate_board(host_board))

View file

@ -88,6 +88,11 @@ func _run_host() -> void:
entry.revision,
))
assert(not session.is_peer_operator(remote_peer_id))
await create_timer(2.0).timeout
assert(session.kick_authenticated_peer(
remote_peer_id,
remote_record.identity_fingerprint,
))
var disconnect_deadline: int = Time.get_ticks_msec() + 8000
while (
@ -173,9 +178,25 @@ func _run_client() -> void:
assert(not (tabs.get_child(2) as Button).visible)
assert(int(players_page.get("_current_tab")) == 0)
service.request_unban.rpc_id(1, SECOND_BANNED_FINGERPRINT)
await create_timer(1.0).timeout
var disconnect_deadline: int = Time.get_ticks_msec() + 10000
while (
Time.get_ticks_msec() < disconnect_deadline
and session.state != NetworkSession.State.SERVER_LOST
):
await process_frame
assert(session.state == NetworkSession.State.SERVER_LOST)
assert(not bool(main.get("_gameplay_started")))
var game_ui := main.get_node("%GameUI") as GameUI
var title_screen := game_ui.get_title_screen()
assert(title_screen.visible)
assert(not title_screen.is_awaiting_start_input())
assert((title_screen.get_node("%Center") as Control).visible)
assert((title_screen.get_node("%ButtonCenter") as Control).visible)
assert(not (title_screen.get_node("%JoinGamePage") as Control).visible)
var feedback := title_screen.get_node("%FeedbackLabel") as RichTextLabel
assert(feedback.visible)
assert("removed by the host" in feedback.text.to_lower())
print("Operator multiplayer client validation: PASS")
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame

View file

@ -3,7 +3,7 @@ extends SceneTree
const MainScene = preload("res://main/main.tscn")
const TEST_PORT: int = 17983
const INITIAL_HOST_TIME: float = 19.75
const UPDATED_HOST_TIME: float = 20.75
const UPDATED_HOST_TIME: float = WorldTimeService.DUSK_END_HOUR
const TIME_TOLERANCE_HOURS: float = 0.05
@ -61,14 +61,31 @@ func _run_host() -> void:
assert(session.peer_supports_capability(
remote_peer_id, NetworkProtocol.WORLD_WEATHER_CAPABILITY
))
var remote_record: PeerRegistry.PeerRecord = session.get_peer_record(
remote_peer_id
)
assert(remote_record != null and remote_record.identity_authenticated)
assert(session.set_peer_operator(
remote_peer_id,
remote_record.identity_fingerprint,
true,
))
assert(world_time.get_phase() == WorldTimeService.Phase.DUSK)
await create_timer(1.0).timeout
world_time.synchronize_time(UPDATED_HOST_TIME)
var command_deadline: int = Time.get_ticks_msec() + 10000
while (
Time.get_ticks_msec() < command_deadline
and _wrapped_time_difference(
world_time.get_time_hours(), UPDATED_HOST_TIME
) > TIME_TOLERANCE_HOURS
):
await process_frame
assert(is_equal_approx(
world_time.get_persistent_time_hours(), UPDATED_HOST_TIME
))
assert(chat_ui.call("_handle_chat_command", "/weather foggy"))
var fog_deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < fog_deadline and not world_weather.is_foggy():
await process_frame
assert(
world_weather.get_persistent_weather()
== WorldWeatherService.Weather.FOGGY
@ -153,6 +170,11 @@ func _run_client() -> void:
assert(is_equal_approx(clock_panel.position.y, 10.0))
assert(is_equal_approx(weather_icon.position.y, 10.0))
assert(clock_panel.position.y + clock_panel.size.y < chat_panel.position.y)
var operator_deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < operator_deadline and not session.is_local_operator():
await process_frame
assert(session.is_local_operator())
assert(chat_ui.call("_handle_chat_command", "/time night"))
var update_deadline: int = Time.get_ticks_msec() + 10000
while (
@ -168,6 +190,8 @@ func _run_client() -> void:
assert(world_time.get_phase() == WorldTimeService.Phase.NIGHT)
assert(is_equal_approx(world_time.get_persistent_time_hours(), 15.25))
assert(clock_label.text == world_time.get_clock_text())
await create_timer(0.6).timeout
assert(chat_ui.call("_handle_chat_command", "/weather foggy"))
var fog_deadline: int = Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < fog_deadline

View file

@ -181,6 +181,7 @@ func setup(
_service.local_message_confirmed.connect(_on_local_message_confirmed)
_service.history_replaced.connect(_on_history)
_service.send_rejected.connect(_on_rejected)
_service.world_command_finished.connect(_on_world_command_finished)
_session.peer_removed.connect(_on_peer_removed)
_entry.text = _settings.current_settings.chat_draft
_entry.caret_column = _entry.text.length()
@ -789,34 +790,16 @@ func _handle_time_command(parts: PackedStringArray) -> void:
if parts.size() != 2:
_set_status("Usage: /time [dawn, day, dusk, night]")
return
if _session == null or not _session.is_host():
_set_status("Only the host can change world time.")
return
if _world_time == null:
if _service == null or _world_time == null:
_set_status("World time is unavailable.")
return
var phase_name := String(parts[1]).to_lower()
var target_hour: float
match phase_name:
"dawn":
target_hour = WorldTimeServiceType.DAWN_START_HOUR
"day":
target_hour = WorldTimeServiceType.DAWN_END_HOUR
"dusk":
target_hour = WorldTimeServiceType.DUSK_START_HOUR
"night":
target_hour = WorldTimeServiceType.DUSK_END_HOUR
_:
_set_status("Usage: /time [dawn, day, dusk, night]")
return
if not _world_time.set_authoritative_time(target_hour):
_set_status("World time could not be changed.")
if phase_name not in ["dawn", "day", "dusk", "night"]:
_set_status("Usage: /time [dawn, day, dusk, night]")
return
if not _service.request_world_time_change(phase_name):
_set_status("Only the host or an operator can change world time.")
return
_set_status("")
_service.broadcast_system_message(
"World time set to %s (%s)."
% [phase_name, _world_time.get_clock_text()]
)
close_chat()
@ -824,37 +807,31 @@ func _handle_weather_command(parts: PackedStringArray) -> void:
if parts.size() != 2:
_set_status("Usage: /weather [clear, cloudy, rainy, foggy]")
return
if _session == null or not _session.is_host():
_set_status("Only the host can change world weather.")
return
if _world_weather == null:
if _service == null or _world_weather == null:
_set_status("World weather is unavailable.")
return
var weather_name := String(parts[1]).to_lower()
var target_weather: WorldWeatherServiceType.Weather
match weather_name:
"clear", "sunny":
target_weather = WorldWeatherServiceType.Weather.SUNNY
weather_name = "clear"
"cloudy":
target_weather = WorldWeatherServiceType.Weather.CLOUDY
"rainy":
target_weather = WorldWeatherServiceType.Weather.RAINY
"foggy":
target_weather = WorldWeatherServiceType.Weather.FOGGY
"cloudy", "rainy", "foggy":
pass
_:
_set_status("Usage: /weather [clear, cloudy, rainy, foggy]")
return
if not _world_weather.set_authoritative_weather(target_weather):
_set_status("World weather could not be changed.")
if not _service.request_world_weather_change(weather_name):
_set_status("Only the host or an operator can change world weather.")
return
_set_status("")
_service.broadcast_system_message(
"World weather set to %s." % weather_name
)
close_chat()
func _on_world_command_finished(success: bool, message: String) -> void:
if success:
_set_status("")
elif not message.is_empty():
_set_status(message)
func _on_local_message_confirmed(message: Dictionary) -> void:
if (
not _send_pending

View file

@ -276,6 +276,19 @@ func reopen() -> void:
_start_entry_prompt_animation()
func reopen_to_menu() -> void:
reopen()
_awaiting_start_input = false
_stop_entry_prompt_animation()
_start_prompt_center.hide()
_presentation_center.show()
_button_center.show()
var bubble_field := get_node_or_null("%BubbleField") as Control
if bubble_field != null:
bubble_field.show()
_set_title_bubbles_interactive(true)
func open_join_game_page(endpoint: String = "") -> void:
_cancel_title_entry_transition()
_awaiting_start_input = false