Fix multiplayer presentation and Fishnet synchronization

This commit is contained in:
Alexander Sellite 2026-08-13 21:58:43 -04:00
parent 31d459b3a7
commit 2045d8e288
19 changed files with 748 additions and 41 deletions

View file

@ -150,6 +150,10 @@ func get_plan_id() -> String:
return _active_plan_id
func has_active_board() -> bool:
return validate_board(_active_board)
func get_time_until_refresh_text() -> String:
if _active_plan_id.is_empty() or _world_time == null:
return "daily jobs unavailable"

View file

@ -2,11 +2,13 @@ class_name NetworkJobService
extends Node
const MAX_SESSION_ID_LENGTH: int = 96
const BOARD_REQUEST_INTERVAL_SECONDS: float = 1.5
var _session: NetworkSession
var _jobs: PlayerJobService
var _sequence: int = 0
var _last_received_sequence: int = -1
var _board_request_accumulator: float = 0.0
func setup(session: NetworkSession, jobs: PlayerJobService) -> void:
@ -15,13 +17,36 @@ func setup(session: NetworkSession, jobs: PlayerJobService) -> void:
_session.state_changed.connect(_on_session_state_changed)
_session.peer_authenticated.connect(_on_peer_authenticated)
_jobs.board_changed.connect(_on_board_changed)
set_process(true)
func _process(delta: float) -> void:
if (
_session == null
or _jobs == null
or not _session.is_joined_client()
or not _session.supports_server_capability(
NetworkProtocol.JOBS_CAPABILITY
)
or _jobs.has_active_board()
):
_board_request_accumulator = 0.0
return
_board_request_accumulator += delta
if _board_request_accumulator < BOARD_REQUEST_INTERVAL_SECONDS:
return
_board_request_accumulator = 0.0
_request_remote_board()
func _on_session_state_changed(state: NetworkSession.State) -> void:
_sequence = 0
_last_received_sequence = -1
_board_request_accumulator = 0.0
if state == NetworkSession.State.JOINED_CLIENT:
if not _session.supports_server_capability(NetworkProtocol.JOBS_CAPABILITY):
if _session.supports_server_capability(NetworkProtocol.JOBS_CAPABILITY):
_request_remote_board()
else:
_jobs.clear_remote_board()
elif state in [
NetworkSession.State.INACTIVE,
@ -69,6 +94,33 @@ func _send_board(peer_id: int) -> void:
})
func _request_remote_board() -> void:
if (
_session != null
and _session.is_joined_client()
and _session.supports_server_capability(
NetworkProtocol.JOBS_CAPABILITY
)
):
request_job_board.rpc_id(1)
@rpc("any_peer", "call_remote", "reliable", 0)
func request_job_board() -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if (
_session == null
or not _session.is_host()
or sender_id <= 1
or not _session.is_authenticated_peer(sender_id)
or not _session.peer_supports_capability(
sender_id, NetworkProtocol.JOBS_CAPABILITY
)
):
return
_send_board(sender_id)
@rpc("authority", "call_remote", "reliable", 0)
func receive_job_board(data: Dictionary) -> void:
if (
@ -86,6 +138,7 @@ func receive_job_board(data: Dictionary) -> void:
var board: Dictionary = data.get("board", {})
if _jobs.apply_remote_board(board):
_last_received_sequence = sequence
_board_request_accumulator = 0.0
static func validate_snapshot(value: Variant) -> bool:

View file

@ -0,0 +1,94 @@
class_name NetworkPlayerAnimationProtocol
extends RefCounted
const FORMAT_VERSION: int = 1
const MAX_STATE_ID_LENGTH: int = 64
const MAX_ACTION_SEQUENCE: int = 2147483647
const MAX_ACTION_ELAPSED_SECONDS: float = 86400.0
const LOCOMOTION_IDLE: StringName = &"idle"
const LOCOMOTION_WALKING: StringName = &"walking"
const LOCOMOTION_RUNNING: StringName = &"running"
static func make_state(
locomotion_id: StringName,
grounded: bool,
action_id: StringName = &"",
action_sequence: int = 0,
action_elapsed: float = 0.0,
) -> Dictionary:
return {
"format_version": FORMAT_VERSION,
"locomotion_id": String(locomotion_id),
"grounded": grounded,
"action": make_action_state(
action_id, action_sequence, action_elapsed
),
}
static func make_action_state(
action_id: StringName = &"",
action_sequence: int = 0,
action_elapsed: float = 0.0,
) -> Dictionary:
return {
"id": String(action_id),
"sequence": action_sequence,
"elapsed": action_elapsed,
}
static func validate_state(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY:
return false
var state: Dictionary = value
return (
typeof(state.get("format_version")) == TYPE_INT
and int(state["format_version"]) == FORMAT_VERSION
and valid_state_id(state.get("locomotion_id"), false)
and typeof(state.get("grounded")) == TYPE_BOOL
and validate_action_state(state.get("action"))
)
static func validate_action_state(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY:
return false
var action: Dictionary = value
if (
not valid_state_id(action.get("id"), true)
or typeof(action.get("sequence")) != TYPE_INT
or typeof(action.get("elapsed")) not in [TYPE_FLOAT, TYPE_INT]
):
return false
var sequence: int = int(action["sequence"])
var elapsed: float = float(action["elapsed"])
return (
sequence >= 0
and sequence <= MAX_ACTION_SEQUENCE
and is_finite(elapsed)
and elapsed >= 0.0
and elapsed <= MAX_ACTION_ELAPSED_SECONDS
)
static func valid_state_id(value: Variant, allow_empty: bool) -> bool:
if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]:
return false
var state_id: String = str(value)
if state_id.is_empty():
return allow_empty
if state_id.length() > MAX_STATE_ID_LENGTH:
return false
for index: int in state_id.length():
var codepoint: int = state_id.unicode_at(index)
var valid_character: bool = (
codepoint >= 97 and codepoint <= 122
or codepoint >= 48 and codepoint <= 57
or codepoint == 95
)
if not valid_character:
return false
return true

View file

@ -0,0 +1 @@
uid://cmaaep4n3p2tq

View file

@ -1,7 +1,7 @@
class_name NetworkProtocol
extends RefCounted
const PROTOCOL_VERSION: int = 3
const PROTOCOL_VERSION: int = 4
const GAME_BUILD: String = "prealpha"
const MAX_GAME_VERSION_LENGTH: int = 64
const MAX_DISPLAY_NAME_LENGTH: int = 24

View file

@ -9,6 +9,7 @@ const CONNECTION_TIMEOUT_SECONDS: float = 10.0
const AUTHENTICATION_TIMEOUT_SECONDS: float = 60.0
const INPUT_INTERVAL: float = 1.0 / 30.0
const SNAPSHOT_INTERVAL: float = 1.0 / 30.0
const MAX_MOVEMENT_INPUT_SEQUENCE: int = 2147483647
signal state_changed(state: State)
signal status_message_changed(message: String)
@ -1741,9 +1742,10 @@ func _is_valid_movement_input(data: Dictionary) -> bool:
or typeof(data.get("sprint")) != TYPE_BOOL
or typeof(data.get("sneak")) != TYPE_BOOL
or typeof(data.get("slow_walk")) != TYPE_BOOL
or (
data.has("sitting")
and typeof(data.get("sitting")) != TYPE_BOOL
or typeof(data.get("sitting")) != TYPE_BOOL
or typeof(data.get("casting")) != TYPE_BOOL
or not NetworkPlayerAnimationProtocol.validate_action_state(
data.get("animation_action")
)
or typeof(data.get("camera_yaw")) not in [TYPE_FLOAT, TYPE_INT]
):
@ -1751,11 +1753,18 @@ func _is_valid_movement_input(data: Dictionary) -> bool:
var axis: Array = data["axis"]
if axis.size() != 2:
return false
if (
typeof(axis[0]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(axis[1]) not in [TYPE_FLOAT, TYPE_INT]
):
return false
var x: float = float(axis[0])
var y: float = float(axis[1])
var camera_yaw: float = float(data["camera_yaw"])
return (
is_finite(x)
int(data["sequence"]) > 0
and int(data["sequence"]) <= MAX_MOVEMENT_INPUT_SEQUENCE
and is_finite(x)
and is_finite(y)
and is_finite(camera_yaw)
and absf(x) <= 1.01

View file

@ -68,6 +68,10 @@ const CHARACTER_FISHING_ANIMATION: StringName = &"fishing"
const CHARACTER_FISHING_SIT_ANIMATION: StringName = &"fishing_sit"
const CHARACTER_FIGHTING_ANIMATION: StringName = &"fighting"
const CHARACTER_FIGHTING_SIT_ANIMATION: StringName = &"fighting_sit"
# Add future networked emote animation IDs here. The protocol accepts unknown
# safe IDs so newer clients can extend it, but Player only presents actions
# explicitly approved by this catalog.
const NETWORK_ANIMATION_ACTION_IDS: Array[StringName] = []
enum FishingVisualPhase {
NONE,
@ -84,6 +88,12 @@ enum PocketVisualTarget {
ART_KIT,
CATCH_SHOWCASE,
}
enum LocomotionState {
IDLE,
WALKING,
RUNNING,
}
const FIGHTING_EYES_ID: String = "alligator_eyes"
const BLINK_EYES_ID: String = "closed"
const BLINK_INTERVAL_SECONDS := Vector2(2.8, 7.5)
@ -393,8 +403,18 @@ var _last_network_input_sequence: int = 0
var _network_target_position: Vector3
var _network_target_velocity: Vector3
var _network_target_visual_yaw: float = 0.0
var _network_target_grounded: bool = false
var _network_target_locomotion_state: LocomotionState = LocomotionState.IDLE
var _network_target_animation_action_id: StringName = &""
var _network_target_animation_action_sequence: int = 0
var _network_target_animation_action_elapsed: float = 0.0
var _network_snapshot_ready: bool = false
var _network_snapshot_age: float = 0.0
var _animation_action_id: StringName = &""
var _animation_action_sequence: int = 0
var _animation_action_elapsed: float = 0.0
var _presented_animation_action_id: StringName = &""
var _presented_animation_action_sequence: int = -1
var _character_animation_name: StringName = &""
var _sitting: bool = false
var _sit_after_landing: bool = false
@ -475,6 +495,72 @@ func set_controller_mapping_manager(
_controller_mapping_manager = mapping_manager
func begin_animation_action(
action_id: StringName,
elapsed_seconds: float = 0.0,
) -> bool:
if (
action_id.is_empty()
or not supports_network_animation_action(action_id)
or _character_animation_player == null
or not _character_animation_player.has_animation(action_id)
):
return false
var next_sequence: int = (
1
if _animation_action_sequence
>= NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE
else _animation_action_sequence + 1
)
var action_state: Dictionary = (
NetworkPlayerAnimationProtocol.make_action_state(
action_id, next_sequence, elapsed_seconds
)
)
if not NetworkPlayerAnimationProtocol.validate_action_state(action_state):
return false
_apply_animation_action_state(action_state)
return true
func end_animation_action() -> void:
if _animation_action_id.is_empty():
return
var next_sequence: int = (
1
if _animation_action_sequence
>= NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE
else _animation_action_sequence + 1
)
_apply_animation_action_state(
NetworkPlayerAnimationProtocol.make_action_state(
&"", next_sequence, 0.0
)
)
func _apply_animation_action_state(action_state: Dictionary) -> void:
if not NetworkPlayerAnimationProtocol.validate_action_state(action_state):
return
var action_id := StringName(str(action_state["id"]))
if (
not action_id.is_empty()
and not supports_network_animation_action(action_id)
):
action_id = &""
var action_sequence: int = int(action_state["sequence"])
var action_changed: bool = (
action_id != _animation_action_id
or action_sequence != _animation_action_sequence
)
_animation_action_id = action_id
_animation_action_sequence = action_sequence
_animation_action_elapsed = float(action_state["elapsed"])
if action_changed:
_character_animation_name = &""
_update_character_animation()
func _initialize_fishing_rod() -> void:
var skeleton := get_node_or_null(
"Visuals/CharacterRig/CharacterRig/Skeleton3D"
@ -613,6 +699,11 @@ func _physics_process(delta: float) -> void:
func _process(delta: float) -> void:
if not _animation_action_id.is_empty():
_animation_action_elapsed = minf(
_animation_action_elapsed + delta,
NetworkPlayerAnimationProtocol.MAX_ACTION_ELAPSED_SECONDS,
)
_update_blink(delta)
_update_character_animation()
_update_sprint_dust(delta)
@ -705,7 +796,7 @@ func _update_sprint_dust(delta: float) -> void:
if _sprint_dust == null:
return
var horizontal_velocity := Vector3(velocity.x, 0.0, velocity.z)
var grounded: bool = is_on_floor()
var grounded: bool = _get_presented_grounded()
if _water_recovery_active:
_sprint_dust_airborne = false
_sprint_dust_fall_speed = 0.0
@ -730,18 +821,11 @@ func _update_sprint_dust(delta: float) -> void:
)
_sprint_dust_airborne = false
_sprint_dust_fall_speed = 0.0
var horizontal_speed_squared: float = horizontal_velocity.length_squared()
var fastest_non_sprint_speed := maxf(
walk_speed,
maxf(sneak_speed, slow_walk_speed)
)
var running_threshold := fastest_non_sprint_speed + 0.5
var should_emit: bool = (
grounded
and not _sitting
and not _water_recovery_active
and horizontal_speed_squared
> running_threshold * running_threshold
and _get_presented_locomotion_state() == LocomotionState.RUNNING
)
_sprint_dust.update_trail(
delta,
@ -778,18 +862,17 @@ func _get_controller_zoom_strength() -> float:
func _update_character_animation() -> void:
if _character_animation_player == null:
return
var horizontal_speed_squared: float = (
velocity.x * velocity.x + velocity.z * velocity.z
var locomotion_state: LocomotionState = (
_get_presented_locomotion_state()
)
var is_walking: bool = horizontal_speed_squared > 0.0025
var fastest_non_sprint_speed := maxf(
walk_speed,
maxf(sneak_speed, slow_walk_speed)
)
var running_threshold := fastest_non_sprint_speed + 0.5
var is_running := (
is_walking
and horizontal_speed_squared > running_threshold * running_threshold
var is_walking: bool = locomotion_state != LocomotionState.IDLE
var is_running: bool = locomotion_state == LocomotionState.RUNNING
var animation_action: Dictionary = _get_presented_animation_action()
var animation_action_id := StringName(str(animation_action.get("id", "")))
var animation_action_available: bool = (
not animation_action_id.is_empty()
and supports_network_animation_action(animation_action_id)
and _character_animation_player.has_animation(animation_action_id)
)
var held_show_item_visible: bool = (
_held_fish_visible or _held_art_kit_visible
@ -878,6 +961,8 @@ func _update_character_animation() -> void:
CHARACTER_FISHING_ANIMATION,
CHARACTER_IDLE_ANIMATION,
]
elif animation_action_available:
requested_animation = [animation_action_id]
elif _sitting:
if held_show_item_visible:
requested_animation = [
@ -934,13 +1019,45 @@ func _update_character_animation() -> void:
break
if next_animation.is_empty():
return
if _character_animation_name == next_animation:
var action_selected: bool = (
animation_action_available and next_animation == animation_action_id
)
var action_sequence: int = int(animation_action.get("sequence", 0))
var action_changed: bool = (
action_selected
and (
animation_action_id != _presented_animation_action_id
or action_sequence != _presented_animation_action_sequence
)
)
if not action_selected:
_presented_animation_action_id = &""
_presented_animation_action_sequence = -1
if _character_animation_name == next_animation and not action_changed:
return
_character_animation_player.play(next_animation)
_character_animation_name = next_animation
if action_selected:
_presented_animation_action_id = animation_action_id
_presented_animation_action_sequence = action_sequence
var elapsed: float = float(animation_action.get("elapsed", 0.0))
var animation: Animation = _character_animation_player.get_animation(
next_animation
)
if elapsed > 0.0 and animation != null and animation.length > 0.0:
_character_animation_player.seek(
fposmod(elapsed, animation.length), true
)
func _on_character_animation_finished(animation_name: StringName) -> void:
if (
local_control_enabled
and not _animation_action_id.is_empty()
and animation_name == _animation_action_id
):
end_animation_action()
return
if animation_name in [
CHARACTER_POCKET_IDLE_IDLE_ANIMATION,
CHARACTER_POCKET_IDLE_SHOW_ANIMATION,
@ -1278,6 +1395,11 @@ func configure_network_remote(authoritative_simulation: bool) -> void:
_network_interpolation_enabled = not authoritative_simulation
_network_snapshot_ready = false
_network_snapshot_age = 0.0
_network_target_grounded = false
_network_target_locomotion_state = LocomotionState.IDLE
_network_target_animation_action_id = &""
_network_target_animation_action_sequence = 0
_network_target_animation_action_elapsed = 0.0
_camera.current = false
@ -1301,6 +1423,7 @@ func capture_network_input(sequence: int) -> Dictionary:
"casting": (
_fishing_visual_phase == FishingVisualPhase.CASTING
),
"animation_action": _make_animation_action_state(),
}
var axis: Vector2 = Input.get_vector(
"move_left",
@ -1318,6 +1441,7 @@ func capture_network_input(sequence: int) -> Dictionary:
"slow_walk": Input.is_action_pressed("slow_walk"),
"sitting": _sitting,
"casting": _fishing_visual_phase == FishingVisualPhase.CASTING,
"animation_action": _make_animation_action_state(),
}
@ -1335,6 +1459,7 @@ func apply_authoritative_network_input(data: Dictionary) -> void:
_network_sprint = bool(data.get("sprint", false))
_network_sneak = bool(data.get("sneak", false))
_network_slow_walk = bool(data.get("slow_walk", false))
_apply_animation_action_state(data.get("animation_action", {}))
_apply_network_casting(bool(data.get("casting", false)))
var sitting_requested: bool = bool(data.get("sitting", false))
if sitting_requested and not is_on_floor():
@ -1352,7 +1477,13 @@ func make_network_snapshot(peer_id: int) -> Dictionary:
"position": [global_position.x, global_position.y, global_position.z],
"velocity": [velocity.x, velocity.y, velocity.z],
"visual_yaw": _visuals.rotation.y,
"grounded": is_on_floor(),
"animation_state": NetworkPlayerAnimationProtocol.make_state(
_get_authoritative_locomotion_id(),
is_on_floor(),
_animation_action_id,
_animation_action_sequence,
_animation_action_elapsed,
),
"sitting": _sitting,
"casting": _fishing_visual_phase == FishingVisualPhase.CASTING,
}
@ -1365,6 +1496,7 @@ func push_network_snapshot(snapshot: Dictionary) -> void:
_network_target_position = parsed["position"]
_network_target_velocity = parsed["velocity"]
_network_target_visual_yaw = parsed["visual_yaw"]
_apply_network_target_animation_state(parsed["animation_state"])
_network_snapshot_age = 0.0
_apply_network_casting(bool(parsed["casting"]))
_set_sitting(bool(parsed["sitting"]))
@ -1412,6 +1544,7 @@ func apply_network_teleport(snapshot: Dictionary) -> void:
_network_target_position = global_position
_network_target_velocity = velocity
_network_target_visual_yaw = _visuals.rotation.y
_apply_network_target_animation_state(parsed["animation_state"])
_network_snapshot_age = 0.0
_network_snapshot_ready = true
@ -1437,6 +1570,12 @@ func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary:
float(network_velocity[2])
)
var visual_yaw: float = float(snapshot.get("visual_yaw", 0.0))
var animation_state_value: Variant = snapshot.get("animation_state")
if not NetworkPlayerAnimationProtocol.validate_state(animation_state_value):
return {}
var animation_state: Dictionary = (
animation_state_value as Dictionary
).duplicate(true)
if (
snapshot.has("acknowledged_input")
and (
@ -1464,12 +1603,119 @@ func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary:
"position": parsed_position,
"velocity": parsed_velocity,
"visual_yaw": visual_yaw,
"animation_state": animation_state,
"acknowledged_input": acknowledged_input,
"sitting": sitting,
"casting": casting,
}
func _make_animation_action_state() -> Dictionary:
return NetworkPlayerAnimationProtocol.make_action_state(
_animation_action_id,
_animation_action_sequence,
_animation_action_elapsed,
)
func _apply_network_target_animation_state(state: Dictionary) -> void:
if not NetworkPlayerAnimationProtocol.validate_state(state):
return
_network_target_grounded = bool(state["grounded"])
_network_target_locomotion_state = _locomotion_state_from_id(
StringName(str(state["locomotion_id"]))
)
var action: Dictionary = state["action"]
_network_target_animation_action_id = StringName(str(action["id"]))
_network_target_animation_action_sequence = int(action["sequence"])
_network_target_animation_action_elapsed = float(action["elapsed"])
func _get_authoritative_locomotion_state() -> LocomotionState:
if (
_network_authoritative_simulation
and _is_movement_input_enabled()
and not _water_recovery_active
and not _sitting
and _network_axis.length_squared() > 0.0025
):
return (
LocomotionState.RUNNING
if _network_sprint
else LocomotionState.WALKING
)
return _locomotion_state_from_velocity()
func _get_authoritative_locomotion_id() -> StringName:
return _locomotion_id_from_state(_get_authoritative_locomotion_state())
func _get_presented_locomotion_state() -> LocomotionState:
if _network_interpolation_enabled and _network_snapshot_ready:
return _network_target_locomotion_state
return _locomotion_state_from_velocity()
func _get_presented_animation_action() -> Dictionary:
if _network_interpolation_enabled and _network_snapshot_ready:
return NetworkPlayerAnimationProtocol.make_action_state(
_network_target_animation_action_id,
_network_target_animation_action_sequence,
minf(
_network_target_animation_action_elapsed + _network_snapshot_age,
NetworkPlayerAnimationProtocol.MAX_ACTION_ELAPSED_SECONDS,
),
)
return _make_animation_action_state()
func _locomotion_id_from_state(state: LocomotionState) -> StringName:
match state:
LocomotionState.WALKING:
return NetworkPlayerAnimationProtocol.LOCOMOTION_WALKING
LocomotionState.RUNNING:
return NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING
_:
return NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE
func _locomotion_state_from_id(state_id: StringName) -> LocomotionState:
match state_id:
NetworkPlayerAnimationProtocol.LOCOMOTION_WALKING:
return LocomotionState.WALKING
NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING:
return LocomotionState.RUNNING
_:
return LocomotionState.IDLE
static func supports_network_animation_action(action_id: StringName) -> bool:
return action_id in NETWORK_ANIMATION_ACTION_IDS
func _locomotion_state_from_velocity() -> LocomotionState:
var horizontal_speed_squared: float = (
velocity.x * velocity.x + velocity.z * velocity.z
)
if horizontal_speed_squared <= 0.0025:
return LocomotionState.IDLE
var fastest_non_sprint_speed := maxf(
walk_speed,
maxf(sneak_speed, slow_walk_speed)
)
var running_threshold := fastest_non_sprint_speed + 0.5
if horizontal_speed_squared > running_threshold * running_threshold:
return LocomotionState.RUNNING
return LocomotionState.WALKING
func _get_presented_grounded() -> bool:
if _network_interpolation_enabled and _network_snapshot_ready:
return _network_target_grounded
return is_on_floor()
func _apply_network_casting(casting: bool) -> void:
if casting:
if _fishing_visual_phase == FishingVisualPhase.NONE:

View file

@ -17,6 +17,7 @@ readonly -a QUICK_TESTS=(
"tests/fishing_surface_validation.gd"
"tests/fur_pattern_validation.gd"
"tests/logbook_validation.gd"
"tests/network_player_animation_protocol_validation.gd"
"tests/player_experience_validation.gd"
"tests/shoreline_ambience_validation.gd"
"tests/surface_drawing_validation.gd"
@ -49,6 +50,7 @@ readonly -a NETWORK_TESTS=(
"tests/fish_showcase_multiplayer_validation.gd"
"tests/fishing_multiplayer_validation.gd"
"tests/job_multiplayer_validation.gd"
"tests/movement_multiplayer_validation.gd"
"tests/operator_multiplayer_validation.gd"
"tests/surface_drawing_multiplayer_validation.gd"
"tests/world_time_multiplayer_validation.gd"

View file

@ -181,7 +181,7 @@ func _run() -> void:
var invalid_state: Dictionary = valid_state.duplicate(true)
invalid_state["display_scale"] = 1000.0
assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state))
assert(NetworkProtocol.PROTOCOL_VERSION == 3)
assert(NetworkProtocol.PROTOCOL_VERSION == 4)
assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10)
assert(
NetworkProtocol.FISH_QUALITY_CAPABILITY

View file

@ -25,7 +25,7 @@ func _run() -> void:
_validate_mail_round_trip()
_validate_collection_mastery()
_validate_version_four_migration()
assert(NetworkProtocol.PROTOCOL_VERSION == 3)
assert(NetworkProtocol.PROTOCOL_VERSION == 4)
assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10)
assert(NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1")
print("Fish quality validation: PASS")

View file

@ -147,12 +147,6 @@ func _run_host() -> void:
await process_frame
assert(remote_animation_player.current_animation == &"retract_sit")
var completion_deadline: int = Time.get_ticks_msec() + 12000
while (
Time.get_ticks_msec() < completion_deadline
and session.get_authenticated_peer_ids().size() == 2
):
await process_frame
print("Fishing multiplayer host validation: PASS")
session.disconnect_session("")
main.queue_free()
@ -337,6 +331,13 @@ func _run_client() -> void:
while Time.get_ticks_msec() < host_observation_deadline:
await process_frame
print("Fishing multiplayer client validation: PASS")
var host_completion_deadline: int = Time.get_ticks_msec() + 10000
while (
Time.get_ticks_msec() < host_completion_deadline
and session.is_joined_client()
):
await process_frame
if session.is_joined_client():
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:

View file

@ -87,6 +87,20 @@ func _run_client() -> void:
assert(not jobs.get_plan_id().is_empty())
assert(jobs.get_daily_jobs().size() == JobCatalog.DAILY_JOB_COUNT)
assert(jobs.get_forecast().size() == JobCatalog.WEATHER_SEGMENT_COUNT)
var preserved_plan_id: String = jobs.get_plan_id()
jobs.clear_remote_board()
assert(not jobs.has_active_board())
assert(jobs.get_plan_id() == preserved_plan_id)
var retry_deadline: int = Time.get_ticks_msec() + 5000
while (
Time.get_ticks_msec() < retry_deadline
and not jobs.has_active_board()
):
await process_frame
assert(jobs.has_active_board())
assert(jobs.get_plan_id() == preserved_plan_id)
assert(jobs.get_daily_jobs().size() == JobCatalog.DAILY_JOB_COUNT)
assert(jobs.get_forecast().size() == JobCatalog.WEATHER_SEGMENT_COUNT)
assert(weather.get_daily_plan_id().is_empty())
var game_ui := main.get_node("%GameUI") as GameUI
var player_menu := game_ui.get("_player_menu") as PlayerMenu

View file

@ -85,6 +85,7 @@ func _run() -> void:
(player_menu.get_node("%NavigationCluster") as Control).get_child_count()
== 7
)
await _validate_unavailable_fishnet_layout()
var wallet_before: int = player.wallet.get_balance()
var experience_before: int = player.experience.get_total_experience()
@ -162,6 +163,24 @@ func _run() -> void:
var pause_menu := game_ui.get_pause_menu()
var join_page := pause_menu.get_node("%JoinGamePage") as JoinGamePage
assert(join_page != null)
pause_menu.show()
join_page.set_status("Connection timed out.")
join_page.show()
join_page.call(
"_on_discovery_status_changed", "2 public rooms found", false
)
var join_status := join_page.get_node("%Status") as Label
assert(join_status.text == "Could not reach the server.")
join_page.hide()
join_page.call("_set_mode", JoinGamePage.Mode.DIRECT)
join_page.call("_set_mode", JoinGamePage.Mode.DISCOVER)
join_page.show()
join_page.call(
"_on_discovery_status_changed", "2 public rooms found", false
)
assert(join_status.text == "2 public rooms found")
join_page.close_page()
pause_menu.hide()
join_page.call("_refresh")
var session_summary := (
join_page.get_node("%SessionSummary") as Label
@ -191,10 +210,37 @@ func _run() -> void:
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
print("Job system validation: PASS")
quit()
func _validate_unavailable_fishnet_layout() -> void:
var unavailable_page := TheNetPage.new()
unavailable_page.size = Vector2(1280.0, 720.0)
root.add_child(unavailable_page)
for _frame: int in 3:
await process_frame
unavailable_page.call("_refresh_forecast", true)
await process_frame
var forecast_list := unavailable_page.get("_forecast_list") as HBoxContainer
assert(forecast_list != null)
assert(forecast_list.size.x >= 272.0)
assert(forecast_list.size.y <= 66.0)
assert(forecast_list.get_child_count() == 1)
var placeholder := forecast_list.get_child(0) as Label
assert(placeholder != null)
assert(placeholder.size.x >= 252.0)
assert(placeholder.size.y <= 66.0)
assert(placeholder.autowrap_mode == TextServer.AUTOWRAP_OFF)
var tabs := unavailable_page.get("_tabs") as HBoxContainer
assert(tabs != null)
assert(tabs.position.y + tabs.size.y < UtilityPageStyle.LAPTOP_RECT.end.y)
unavailable_page.queue_free()
for _frame: int in 2:
await process_frame
func _validate_weather_guarantees(board: Dictionary) -> void:
var jobs: Array = board.get("jobs", [])
var schedule: Array = board.get("weather_schedule", [])

View file

@ -0,0 +1,163 @@
extends SceneTree
const MainScene: PackedScene = preload("res://main/main.tscn")
const TEST_PORT: int = 18141
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var arguments: PackedStringArray = OS.get_cmdline_user_args()
if arguments.has("host"):
await _run_host()
return
if arguments.has("client"):
await _run_client()
return
push_error("Movement multiplayer validation needs host or client mode.")
quit(1)
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(session.start_private_host(TEST_PORT))
assert(save_manager.initialize_new_game())
main.call("_enter_gameplay")
for _frame: int in 4:
await physics_frame
assert(session.set_host_open(true))
var remote_peer_id: int = await _wait_for_remote_peer(session)
assert(remote_peer_id > 1)
var player := main.get("_player") as Player
player.configure_network_remote(true)
player.apply_authoritative_network_input(_movement_input(1, true))
await create_timer(2.5).timeout
player.apply_authoritative_network_input(_movement_input(2, false))
await create_timer(2.5).timeout
player.apply_authoritative_network_input(_movement_input(3, false, false))
var disconnect_deadline: int = Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < disconnect_deadline
and session.is_authenticated_peer(remote_peer_id)
):
await process_frame
assert(not session.is_authenticated_peer(remote_peer_id))
print("Movement multiplayer host validation: PASS")
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit()
func _run_client() -> void:
var main: Node = await _create_initialized_main()
main.call(
"_on_title_join_game_requested",
"127.0.0.1:%d" % TEST_PORT,
)
var session := main.get_node("%NetworkSession") as NetworkSession
var join_deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < join_deadline:
await process_frame
if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
main.call("_confirm_server_trust")
if session.is_joined_client() and bool(main.get("_gameplay_started")):
break
assert(session.is_joined_client())
var spawn_service := main.get_node(
"%PlayerSpawnService"
) as PlayerSpawnService
var host_avatar: Player = spawn_service.get_avatar(1)
assert(host_avatar != null)
var animation_player := host_avatar.get_node(
"Visuals/CharacterRig/AnimationPlayer"
) as AnimationPlayer
var sprint_dust := host_avatar.get_node("%SprintDust") as SprintDustTrail
var saw_running: bool = false
var saw_walking: bool = false
var saw_animation_advance: bool = false
var saw_dust: bool = false
var previous_animation: StringName = &""
var previous_animation_position: float = -1.0
var observation_deadline: int = Time.get_ticks_msec() + 7000
while Time.get_ticks_msec() < observation_deadline:
await process_frame
var current_animation: StringName = animation_player.current_animation
saw_running = saw_running or current_animation.begins_with("running")
saw_walking = saw_walking or current_animation.begins_with("walking")
var current_position: float = (
animation_player.current_animation_position
)
if (
current_animation == previous_animation
and previous_animation_position >= 0.0
and absf(current_position - previous_animation_position) > 0.001
):
saw_animation_advance = true
previous_animation = current_animation
previous_animation_position = current_position
saw_dust = saw_dust or sprint_dust.get_active_puff_count() > 0
if saw_running and saw_walking and saw_animation_advance and saw_dust:
break
assert(saw_running)
assert(saw_walking)
assert(saw_animation_advance)
assert(saw_dust)
print("Movement multiplayer client validation: PASS")
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit()
func _movement_input(
sequence: int,
sprinting: bool,
moving: bool = true,
) -> Dictionary:
return {
"sequence": sequence,
"axis": [0.0, -1.0] if moving else [0.0, 0.0],
"camera_yaw": 0.0,
"jump": false,
"sprint": sprinting,
"sneak": false,
"slow_walk": false,
"sitting": false,
"casting": false,
"animation_action": (
NetworkPlayerAnimationProtocol.make_action_state()
),
}
func _create_initialized_main() -> Node:
root.size = Vector2i(1280, 720)
var main: Node = MainScene.instantiate()
root.add_child(main)
for _frame: int in 4:
await process_frame
if not bool(main.get("_application_initialized")):
main.call("_activate_selected_data_path", "", true)
for _frame: int in 8:
await process_frame
assert(bool(main.get("_application_initialized")))
return main
func _wait_for_remote_peer(session: NetworkSession) -> int:
var deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < deadline:
await process_frame
for peer_id: int in session.get_authenticated_peer_ids():
if peer_id != session.get_local_peer_id():
return peer_id
return 0

View file

@ -0,0 +1 @@
uid://dlpu5lwb2pges

View file

@ -0,0 +1,49 @@
extends SceneTree
func _initialize() -> void:
var idle := NetworkPlayerAnimationProtocol.make_state(
NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE,
true,
)
assert(NetworkPlayerAnimationProtocol.validate_state(idle))
var emote := NetworkPlayerAnimationProtocol.make_state(
NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING,
false,
&"wave_hello",
17,
1.25,
)
assert(NetworkPlayerAnimationProtocol.validate_state(emote))
var future_locomotion := NetworkPlayerAnimationProtocol.make_state(
&"swimming_fast",
false,
)
assert(NetworkPlayerAnimationProtocol.validate_state(future_locomotion))
var extra_future_field: Dictionary = emote.duplicate(true)
extra_future_field["future_layer"] = {"id": "umbrella"}
assert(NetworkPlayerAnimationProtocol.validate_state(extra_future_field))
var invalid_action: Dictionary = emote.duplicate(true)
invalid_action["action"] = {
"id": "../unsafe",
"sequence": 17,
"elapsed": 1.25,
}
assert(not NetworkPlayerAnimationProtocol.validate_state(invalid_action))
var invalid_sequence: Dictionary = emote.duplicate(true)
invalid_sequence["action"] = {
"id": "wave_hello",
"sequence": -1,
"elapsed": 1.25,
}
assert(not NetworkPlayerAnimationProtocol.validate_state(invalid_sequence))
var invalid_elapsed: Dictionary = emote.duplicate(true)
invalid_elapsed["action"] = {
"id": "wave_hello",
"sequence": 17,
"elapsed": INF,
}
assert(not NetworkPlayerAnimationProtocol.validate_state(invalid_elapsed))
print("Network player animation protocol validation: PASS")
quit()

View file

@ -0,0 +1 @@
uid://cjgelp62dkykn

View file

@ -62,6 +62,7 @@ var _discovery_refresh_timer: Timer
var _editing_entry_id: String = ""
var _name_entry_active: bool = false
var _delete_armed: bool = false
var _connection_error_latched: bool = false
func _ready() -> void:
@ -181,7 +182,7 @@ func open_page(preserved_endpoint: String = "") -> void:
_address.text = "127.0.0.1:7777"
show()
UtilityPageStyle.animate_in(self)
_set_mode(_mode)
_set_mode(_mode, false)
if _mode == Mode.DIRECT:
_address.grab_focus()
_address.select_all()
@ -201,10 +202,13 @@ func get_endpoint_text() -> String:
func set_status(message: String) -> void:
_connection_error_latched = true
_set_status(_friendly_connection_message(message), true)
func _set_mode(mode: Mode) -> void:
func _set_mode(mode: Mode, clear_connection_error: bool = true) -> void:
if clear_connection_error:
_connection_error_latched = false
_mode = mode
_selected_entry = null
_selected_discovery_index = -1
@ -225,6 +229,7 @@ func _set_mode(mode: Mode) -> void:
func _request_join() -> void:
_connection_error_latched = false
if _network_session.state in [
NetworkSession.State.CONNECTION_FAILED,
NetworkSession.State.SERVER_LOST,
@ -267,6 +272,10 @@ func _on_public_join_prepared(endpoint_text: String) -> void:
func _on_public_join_status_changed(message: String, is_error: bool) -> void:
if _mode == Mode.DISCOVER and is_visible_in_tree():
if is_error:
_connection_error_latched = true
elif _connection_error_latched:
return
_set_status(message, is_error)
_refresh()
@ -708,7 +717,11 @@ func _on_discovery_rooms_updated(rooms: Array[Dictionary]) -> void:
func _on_discovery_status_changed(message: String, is_error: bool) -> void:
if _mode == Mode.DISCOVER and is_visible_in_tree():
if (
_mode == Mode.DISCOVER
and is_visible_in_tree()
and not _connection_error_latched
):
_set_status(message, is_error)
@ -740,6 +753,7 @@ func _friendly_connection_message(message: String) -> String:
return "Connection cancelled."
if (
"timeout" in normalized
or "timed out" in normalized
or "unavailable" in normalized
or "refused" in normalized
or "reach" in normalized
@ -769,6 +783,7 @@ func _clear_edit_state() -> void:
func _on_cancel_pressed() -> void:
_connection_error_latched = false
if _network_session != null:
_network_session.cancel_connection()
_refresh()
@ -795,11 +810,14 @@ func _on_state_changed(_state: NetworkSession.State) -> void:
func _on_status_message_changed(message: String) -> void:
if _connection_error_latched:
return
_set_status(_friendly_connection_message(message))
_refresh()
func _on_connection_error(message: String) -> void:
_connection_error_latched = true
_set_status(_friendly_connection_message(message), true)
_refresh()

View file

@ -158,6 +158,8 @@ func _build_laptop() -> void:
)
forecast_stack.add_child(forecast_heading)
_forecast_list = HBoxContainer.new()
_forecast_list.custom_minimum_size = Vector2(272.0, 66.0)
_forecast_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_forecast_list.alignment = BoxContainer.ALIGNMENT_CENTER
_forecast_list.add_theme_constant_override("separation", 6)
forecast_stack.add_child(_forecast_list)
@ -501,7 +503,10 @@ func _daily_refresh_text() -> String:
func _add_forecast_empty(message: String) -> void:
var label := Label.new()
label.text = message
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.custom_minimum_size = Vector2(252.0, 66.0)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.autowrap_mode = TextServer.AUTOWRAP_OFF
label.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
)