Integrate per-save identity and interaction UI

This commit is contained in:
Alexander Sellite 2026-09-01 17:30:50 -04:00
parent ac82a28f3b
commit 4fda6e97f6
58 changed files with 3471 additions and 461 deletions

View file

@ -14,4 +14,5 @@ surface_water_relation = 2
population = 4
surface_population_per_100_square_meters = 0.4
maximum_surface_population = 16
sprite_pixel_size = 0.0005
sprite_pixel_size = 0.01
sprite_surface_offset = 0.11

View file

@ -20,7 +20,8 @@ scare_radius = 2.8
capture_radius = 0.7
interaction_range = 2.6
charge_duration = 2.0
sprite_pixel_size = 0.0005
sprite_pixel_size = 0.01
sprite_surface_offset = 0.11
sprite_tilt_degrees = -45.0
capture_respawn_min_seconds = 480.0
capture_respawn_max_seconds = 720.0

View file

@ -11,4 +11,5 @@ required_tool_id = &"crab_net"
surface_materials = Array[StringName]([&"sand"])
minimum_surface_y = -0.5
population = 4
sprite_pixel_size = 0.0005
sprite_pixel_size = 0.01
sprite_surface_offset = 0.11

View file

@ -11,4 +11,5 @@ required_tool_id = &"crab_net"
surface_materials = Array[StringName]([&"sand"])
minimum_surface_y = 0.08
population = 4
sprite_pixel_size = 0.0005
sprite_pixel_size = 0.01
sprite_surface_offset = 0.11

View file

@ -61,6 +61,7 @@ var surface_population_per_100_square_meters := 0.0
@export_range(0.1, 10.0, 0.05) var interaction_range: float = 2.6
@export_range(0.1, 10.0, 0.05) var charge_duration: float = 2.0
@export_range(0.0001, 0.01, 0.00005) var sprite_pixel_size: float = 0.001
@export_range(-2.0, 2.0, 0.01) var sprite_surface_offset: float = 0.11
@export_range(-90.0, 90.0, 1.0) var sprite_tilt_degrees: float = -45.0
@export_category("Respawn Budget")
@export_range(0.0, 3600.0, 1.0) var capture_respawn_min_seconds: float = 480.0
@ -117,6 +118,7 @@ func is_valid() -> bool:
and interaction_range > 0.0
and charge_duration > 0.0
and sprite_pixel_size > 0.0
and is_finite(sprite_surface_offset)
and capture_respawn_max_seconds >= capture_respawn_min_seconds
and scare_respawn_max_seconds >= scare_respawn_min_seconds
)

View file

@ -2,8 +2,6 @@ class_name WorldGatherable
extends Node3D
const GatherableDataType = preload("res://gathering/gatherable_data.gd")
const REFERENCE_SPRITE_PIXEL_SIZE: float = 0.001
const REFERENCE_SPRITE_HEIGHT: float = 0.22
const WATER_SPURT_INTERVAL_SECONDS: float = 1.15
const WATER_SPURT_COLOR := Color(0.48, 0.82, 0.84, 1.0)
const WATER_SPURT_HOLE_COLOR := Color(0.23, 0.27, 0.24, 1.0)
@ -47,11 +45,7 @@ func configure(
_sprite.position.y = (
0.0
if not data.spawn_anchor_set_id.is_empty()
else (
REFERENCE_SPRITE_HEIGHT
* data.sprite_pixel_size
/ REFERENCE_SPRITE_PIXEL_SIZE
)
else data.sprite_surface_offset
)
_sprite.rotation_degrees.x = data.sprite_tilt_degrees
@ -118,7 +112,6 @@ func _ensure_visual() -> void:
return
_sprite = Sprite3D.new()
_sprite.name = "GatherableSprite"
_sprite.position.y = REFERENCE_SPRITE_HEIGHT
_sprite.shaded = false
_sprite.double_sided = true
_sprite.billboard = BaseMaterial3D.BILLBOARD_ENABLED

View file

@ -146,9 +146,6 @@ const TITLE_MUSIC_PATH: String = (
const NEW_GAME_MUSIC_PATH: String = "res://audio/music/world/tulips.wav"
const DUSK_MUSIC_PATH: String = "res://audio/music/world/craft.mp3"
const TIME_CROSSING_EPSILON_HOURS: float = 0.000001
const PLAYER_MENU_PATTERN_SCALE: float = 0.85
const PLAYER_MENU_PATTERN_SCROLL_VELOCITY := Vector2(-7.0, -5.0)
const SHOP_PATTERN_SCALE: float = 1.75
const DEDICATED_IDLE_PHYSICS_TICKS_PER_SECOND: int = 10
const DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND: int = 30
const HOME_ENTRY_INPUT_OWNER: StringName = &"home_entry_transition"
@ -158,6 +155,8 @@ const HOME_ENTRY_WALK_DISTANCE: float = 1.05
const HOME_ENTRY_WALK_DURATION: float = 0.55
const HOME_EXIT_WALK_DISTANCE: float = 1.25
const HOME_EXIT_WALK_DURATION: float = 0.62
const SHOP_NPC_HANDOFF_HYSTERESIS_METERS: float = 0.1
const SHOP_NPC_CONVERSATION_INPUT_OWNER: StringName = &"shop_npc_conversation"
@export var fish_catalog: FishPoolType
@export var pelican_buyer_profile: FishBuyerProfileType
@ -263,14 +262,16 @@ const HOME_EXIT_WALK_DURATION: float = 0.62
@onready var _world_gatherables_root: Node3D = $WorldGatherables
@onready var _home_world: HomeWorldServiceType = %HomeWorldService
@onready var _title_background: ColorRect = %TitleBackground
@onready var _player_menu_backdrop: ColorRect = %PlayerMenuBackdrop
@onready var _shop_backdrop: ColorRect = %ShopBackdrop
var _gameplay_started: bool = false
var _save_slots := PlayerSaveSlotCatalogType.new()
var _shop_interaction: FishingShopInteractionType
var _decor_shop_interaction: DecorShopInteractionType
var _rv_upgrade_shop_interaction: RVUpgradeInteractionType
var _active_shop_npc_interaction: FishingShopInteractionType
var _engaged_shop_npc_interaction: FishingShopInteractionType
var _shop_conversation_shop_opened: bool = false
var _shop_conversation_camera_restoring: bool = false
var _storage_interaction: PlayerStorageInteractionType
var _title_music_tween: Tween
var _title_music_transition_generation: int = 0
@ -279,8 +280,6 @@ var _dusk_music_played_for_natural_day: bool = false
var _quit_in_progress: bool = false
var _join_requested_from_title: bool = false
var _join_requested_from_pause: bool = false
var _player_menu_backdrop_tween: Tween
var _shop_backdrop_tween: Tween
var _pending_join_endpoint: String = ""
var _social_prompt_queue: Array[Dictionary] = []
var _active_social_prompt: Dictionary = {}
@ -307,6 +306,7 @@ var _home_exit_start_position: Vector3 = Vector3.ZERO
var _home_exit_walk_tween: Tween
var _home_entry_transition_active: bool = false
var _home_entry_walk_tween: Tween
var _observed_cancel_input_frame: int = -1
@onready var _shoreline_ambience: ShorelineAmbience = %ShorelineAmbience
var _rain_ambience: RainAmbienceType
@ -341,8 +341,6 @@ func _ready() -> void:
_player,
_test_world.get_saltwater_shoreline_mesh(),
)
get_window().size_changed.connect(_resize_native_overlays)
_resize_native_overlays()
if not _settings_manager.settings_changed.is_connected(
_apply_runtime_settings
):
@ -370,44 +368,6 @@ func _configure_presentation_performance_profile(light_profile: bool) -> void:
"animation_enabled",
not light_profile,
)
var menu_pattern_material := (
_player_menu_backdrop.material as ShaderMaterial
)
if menu_pattern_material != null:
menu_pattern_material.set_shader_parameter(
"animation_enabled",
not light_profile,
)
menu_pattern_material.set_shader_parameter(
"display_scale",
PLAYER_MENU_PATTERN_SCALE,
)
menu_pattern_material.set_shader_parameter(
"scroll_velocity_pixels",
(
Vector2.ZERO
if light_profile
else PLAYER_MENU_PATTERN_SCROLL_VELOCITY
),
)
var shop_pattern_material := _shop_backdrop.material as ShaderMaterial
if shop_pattern_material != null:
shop_pattern_material.set_shader_parameter(
"animation_enabled",
not light_profile,
)
shop_pattern_material.set_shader_parameter(
"display_scale",
SHOP_PATTERN_SCALE,
)
shop_pattern_material.set_shader_parameter(
"scroll_velocity_pixels",
(
Vector2.ZERO
if light_profile
else PLAYER_MENU_PATTERN_SCROLL_VELOCITY
),
)
func _configure_audio_performance_profile(light_profile: bool) -> void:
@ -437,8 +397,6 @@ func _is_dedicated_server_runtime() -> bool:
func _disable_dedicated_presentation_tree() -> void:
for path: NodePath in [
NodePath("TitleBackgroundLayer"),
NodePath("PlayerMenuBackdrop"),
NodePath("ShopBackdrop"),
NodePath("WorldPixelationPostprocess"),
NodePath("UIPresentation"),
NodePath("PixelationResetOverlay"),
@ -779,6 +737,7 @@ func _initialize_application(dedicated: bool) -> void:
_shop_interaction = _test_world.get_fishing_shop()
_decor_shop_interaction = _test_world.get_decor_shop()
_rv_upgrade_shop_interaction = _test_world.get_rv_upgrade_shop()
_active_shop_npc_interaction = null
_storage_interaction = _test_world.get_player_storage()
if not dedicated:
if not _home_state.changed.is_connected(_on_home_state_changed):
@ -818,6 +777,8 @@ func _initialize_application(dedicated: bool) -> void:
_player_jobs,
_player,
_home_state,
_network_profile if not dedicated else null,
_appearance_store if not dedicated else null,
)
_home_world.setup(
_test_world,
@ -1045,6 +1006,9 @@ func _initialize_application(dedicated: bool) -> void:
_fishing_spot,
_game_ui.get_home_decor_toolbar(),
)
_home_decor_controller.exit_requested.connect(
_on_home_toolbar_exit_requested
)
_game_ui.get_player_storage().decorate_requested.connect(
_home_decor_controller.begin_decorating
)
@ -1117,12 +1081,19 @@ func _initialize_application(dedicated: bool) -> void:
_game_ui.passive_pointer_ui_changed.connect(
_ui_pixelation.set_passive_pointer_ui_enabled
)
_game_ui.player_menu_backdrop_visibility_changed.connect(
_set_player_menu_backdrop_visible
)
_game_ui.shop_backdrop_visibility_changed.connect(
_set_shop_backdrop_visible
_game_ui.get_node("%PlayerMenu").connect(
&"menu_visibility_changed",
_on_player_menu_conversation_visibility_changed
)
for shop_menu: Node in [
_game_ui.get_fishing_shop(),
_game_ui.get_decor_shop(),
_game_ui.get_rv_upgrade_shop(),
]:
shop_menu.connect(
&"menu_visibility_changed",
_on_shop_conversation_menu_visibility_changed
)
_pixelation_reset.return_to_settings_requested.connect(
_game_ui.focus_open_settings_back_button
)
@ -1167,6 +1138,9 @@ func _initialize_application(dedicated: bool) -> void:
pause_menu.menu_visibility_changed.connect(
_game_ui.set_system_menu_open
)
pause_menu.menu_visibility_changed.connect(
_on_pause_menu_conversation_visibility_changed
)
_player.hotbar.selected_assignment_changed.connect(
_on_active_hotbar_item_changed
)
@ -1441,8 +1415,15 @@ func _on_data_root_changed() -> void:
_save_manager.set_autosave_enabled(false)
_save_manager.cancel_pending_autosave()
_configure_portable_stores()
if not _network_profile_service.reload_persisted_profile():
var profile_reloaded: bool = (
_network_profile_service.reload_persisted_profile()
)
if not profile_reloaded:
push_warning("The selected data folder's player profile could not be loaded.")
else:
_save_manager.capture_legacy_character_fallback()
if not _save_manager.load_character_profile():
push_warning("The active save's character profile could not be loaded.")
_network_session.set_local_appearance_snapshot(
_appearance_store.get_snapshot()
)
@ -1547,6 +1528,25 @@ func _input(event: InputEvent) -> void:
if _home_exit_transition_active:
get_viewport().set_input_as_handled()
return
# While dialogue is active the conversation owns the complete input stream.
# The second interaction press advances into the shop; cancel safely leaves
# the conversation without opening another menu behind it.
if (
is_instance_valid(_engaged_shop_npc_interaction)
and not _shop_conversation_shop_opened
):
if (
event.is_action_pressed("interact")
and not (event is InputEventKey and event.echo)
):
_open_engaged_shop()
elif (
event.is_action_pressed("ui_cancel")
or event.is_action_pressed("open_system_menu")
):
_end_shop_npc_conversation(false)
get_viewport().set_input_as_handled()
return
# Do not let controller aliases close or manipulate the world/menu behind
# the modal on-screen keyboard. The keyboard consumes the event itself.
if _game_ui.is_controller_text_entry_open():
@ -1564,6 +1564,8 @@ func _input(event: InputEvent) -> void:
or (event is InputEventKey and event.echo)
):
return
if event.is_action_pressed("ui_cancel"):
_observed_cancel_input_frame = Engine.get_process_frames()
var pause_open_requested: bool = _is_pause_open_request(event)
var title_screen: TitleScreenType = _game_ui.get_title_screen()
if title_screen.visible or not _gameplay_started:
@ -1712,31 +1714,16 @@ func _unhandled_input(event: InputEvent) -> void:
_refresh_home_door_prompt({})
get_viewport().set_input_as_handled()
return
# All shop prompts and interactions resolve through the same nearest-NPC
# selection. This prevents overlapping trigger margins from making a fixed
# shop priority open (or speak for) someone the player is not facing.
_refresh_shop_npc_speech_state()
var active_shop_interaction := _get_active_shop_npc_interaction()
if (
_rv_upgrade_shop_interaction != null
and _rv_upgrade_shop_interaction.is_local_player_in_range()
active_shop_interaction != null
and _fishing_spot.can_open_fishing_shop()
and _game_ui.get_rv_upgrade_shop().open_shop()
):
_game_ui.set_shop_prompt_visible(false)
get_viewport().set_input_as_handled()
return
if (
_decor_shop_interaction != null
and _decor_shop_interaction.is_local_player_in_range()
and _fishing_spot.can_open_fishing_shop()
and _game_ui.get_decor_shop().open_shop()
):
_game_ui.set_shop_prompt_visible(false)
get_viewport().set_input_as_handled()
return
if (
_shop_interaction != null
and _shop_interaction.is_local_player_in_range()
and _fishing_spot.can_open_fishing_shop()
and _game_ui.get_fishing_shop().open_shop()
):
_game_ui.set_shop_prompt_visible(false)
_begin_shop_npc_conversation(active_shop_interaction)
get_viewport().set_input_as_handled()
@ -1750,25 +1737,33 @@ func _process(delta: float) -> void:
)
_print_dedicated_metrics()
return
# Native Godot tooltip popups can take ownership of Escape before this
# scene's _input callback sees it. Poll the action once as a fallback so a
# visible tooltip never forces a second Escape press to close its menu.
if (
Input.is_action_just_pressed("ui_cancel")
and _observed_cancel_input_frame != Engine.get_process_frames()
):
_consume_tooltip_captured_escape()
_maybe_begin_home_exit_transition()
var show_rv_upgrade_prompt := _can_show_rv_upgrade_shop_prompt()
var show_decor_shop_prompt := _can_show_decor_shop_prompt()
var show_shop_prompt := (
show_rv_upgrade_prompt
or show_decor_shop_prompt
or _can_show_shop_prompt()
# Shop close animations/signals may finish after the input frame that asked
# for them. Keep the cinematic held until the UI is actually gone.
if (
_shop_conversation_shop_opened
and is_instance_valid(_engaged_shop_npc_interaction)
and not _is_any_shop_menu_visible()
):
_end_shop_npc_conversation(false)
# Re-evaluate while moving, not only when an Area3D emits enter/exit. This
# gives overlapping edge cases an immediate nearest-character handoff.
_refresh_shop_npc_speech_state()
var active_shop_interaction := _get_active_shop_npc_interaction()
var show_shop_prompt := _can_show_active_shop_prompt(
active_shop_interaction
)
var shop_prompt_anchor := (
_rv_upgrade_shop_interaction.get_prompt_anchor_position()
if show_rv_upgrade_prompt
else (
_decor_shop_interaction.get_prompt_anchor_position()
if show_decor_shop_prompt
else (
_shop_interaction.get_prompt_anchor_position()
if _shop_interaction != null else Vector3.ZERO
)
)
active_shop_interaction.get_prompt_anchor_position()
if active_shop_interaction != null else Vector3.ZERO
)
_game_ui.set_shop_prompt_visible(
show_shop_prompt,
@ -1781,6 +1776,28 @@ func _process(delta: float) -> void:
)
func _consume_tooltip_captured_escape() -> bool:
if (
not _gameplay_started
or _game_ui.get_title_screen().visible
or _home_exit_transition_active
or _game_ui.is_controller_text_entry_open()
or _game_ui.is_input_mapping_capturing()
):
return false
if _game_ui.get_player_storage().consume_escape():
return true
if _game_ui.get_decor_shop().consume_escape():
return true
if _game_ui.get_rv_upgrade_shop().consume_escape():
return true
if _game_ui.get_fishing_shop().consume_escape():
return true
if _game_ui.get_pause_menu().handle_escape():
return true
return _game_ui.consume_player_menu_escape()
func _apply_runtime_settings(settings: PlayerSettingsType) -> void:
if settings == null:
return
@ -1866,11 +1883,22 @@ func _reset_pixelation() -> void:
func _set_gameplay_active(active: bool) -> void:
var was_gameplay_started: bool = _gameplay_started
if not active and was_gameplay_started and _save_manager.is_dirty():
# Session teardown can mutate transient multiplayer state immediately
# before this method runs. Preserve any real progression first, then
# discard teardown-only dirty notifications once gameplay is inactive.
_save_manager.save_if_dirty()
_gameplay_started = active
# Session loss, a world switch, or a return to title can interrupt the home
# handoff before its fade callback runs. Do not carry its input suppressors
# or black screen into the next gameplay session.
if not active:
if (
is_instance_valid(_engaged_shop_npc_interaction)
or _shop_conversation_camera_restoring
or _player.is_shop_conversation_camera_active()
):
_end_shop_npc_conversation(true)
_cancel_home_entry_transition()
_cancel_home_exit_transition(true)
_shoreline_ambience.set_active(active)
@ -1901,8 +1929,7 @@ func _set_gameplay_active(active: bool) -> void:
_refresh_active_hotbar_item()
else:
_player_jobs.end_progression_session()
_set_player_menu_backdrop_visible(false)
_set_shop_backdrop_visible(false)
_save_manager.cancel_pending_autosave()
func _on_natural_time_advanced(advanced_hours: float) -> void:
@ -1953,85 +1980,6 @@ static func _natural_interval_crosses_hour(
)
func _set_player_menu_backdrop_visible(requested_visible: bool) -> void:
if _player_menu_backdrop_tween != null:
_player_menu_backdrop_tween.kill()
_player_menu_backdrop_tween = null
var should_show: bool = requested_visible and _gameplay_started
if should_show:
var was_visible: bool = _player_menu_backdrop.visible
_player_menu_backdrop.visible = true
if not was_visible:
_player_menu_backdrop.modulate.a = 0.0
_player_menu_backdrop_tween = create_tween()
_player_menu_backdrop_tween.tween_property(
_player_menu_backdrop,
"modulate:a",
1.0,
UIMotion.PLAYER_MENU_ENTER_DURATION,
).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
return
if not _player_menu_backdrop.visible:
return
_player_menu_backdrop_tween = create_tween()
_player_menu_backdrop_tween.tween_property(
_player_menu_backdrop,
"modulate:a",
0.0,
UIMotion.PLAYER_MENU_EXIT_DURATION,
).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
_player_menu_backdrop_tween.finished.connect(
func() -> void:
_player_menu_backdrop.visible = false
_player_menu_backdrop.modulate.a = 1.0
_player_menu_backdrop_tween = null
)
func _set_shop_backdrop_visible(requested_visible: bool) -> void:
if _shop_backdrop_tween != null:
_shop_backdrop_tween.kill()
_shop_backdrop_tween = null
var should_show: bool = requested_visible and _gameplay_started
if should_show:
var was_visible: bool = _shop_backdrop.visible
_shop_backdrop.visible = true
if not was_visible:
_shop_backdrop.modulate.a = 0.0
_shop_backdrop_tween = create_tween()
_shop_backdrop_tween.tween_property(
_shop_backdrop,
"modulate:a",
1.0,
UIMotion.PLAYER_MENU_ENTER_DURATION,
).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
return
if not _shop_backdrop.visible:
return
_shop_backdrop_tween = create_tween()
_shop_backdrop_tween.tween_property(
_shop_backdrop,
"modulate:a",
0.0,
UIMotion.PLAYER_MENU_EXIT_DURATION,
).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
_shop_backdrop_tween.finished.connect(
func() -> void:
_shop_backdrop.visible = false
_shop_backdrop.modulate.a = 1.0
_shop_backdrop_tween = null
)
func _resize_native_overlays() -> void:
if not is_node_ready():
return
_player_menu_backdrop.position = Vector2.ZERO
_player_menu_backdrop.size = Vector2(get_window().size)
_shop_backdrop.position = Vector2.ZERO
_shop_backdrop.size = Vector2(get_window().size)
func _on_new_game_requested(
world_layout: StringName,
world_seed: int,
@ -2039,6 +1987,15 @@ func _on_new_game_requested(
if _gameplay_started or _quit_in_progress:
return
_start_new_game_music()
if (
not _save_manager.delete_progression_save()
or not _save_manager.initialize_new_game(world_seed, world_layout)
):
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not initialize local progression. Existing data was preserved where possible."
)
return
if not _apply_world(world_layout, world_seed, true):
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
@ -2054,16 +2011,6 @@ func _on_new_game_requested(
if not _prepare_private_host():
_show_title_music(true)
return
if (
not _save_manager.delete_progression_save()
or not _save_manager.initialize_new_game(world_seed, world_layout)
):
_network_session.disconnect_session("New Game setup failed.")
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not initialize local progression. Existing data was preserved where possible."
)
return
_enter_gameplay()
@ -2152,6 +2099,18 @@ func _on_new_slot_requested(
_on_slot_play_requested(slot_id)
return
_start_new_game_music()
# Reset progression before the private session publishes its first home
# manifest. Doing this after hosting allowed the previous slot's RV tier to
# be announced and spawned into an otherwise brand-new save.
if (
not _save_manager.delete_progression_save()
or not _save_manager.initialize_new_game(world_seed, world_layout)
):
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not initialize local progression."
)
return
if not _apply_world(world_layout, world_seed, true):
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
@ -2167,16 +2126,6 @@ func _on_new_slot_requested(
if not _prepare_private_host():
_show_title_music(true)
return
if (
not _save_manager.delete_progression_save()
or not _save_manager.initialize_new_game(world_seed, world_layout)
):
_network_session.disconnect_session("New Game setup failed.")
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not initialize local progression."
)
return
_save_slots.mark_played(slot_id)
_enter_gameplay()
@ -2243,10 +2192,17 @@ func _on_title_join_game_requested(requested_route: Variant) -> void:
"That connection address is invalid."
)
return
var active_slot: Dictionary = _save_slots.ensure_active_slot()
if not bool(active_slot.get("ok", false)):
var online_slot: Dictionary = _save_slots.ensure_online_slot()
if not bool(online_slot.get("ok", false)):
_game_ui.get_title_screen().report_network_error(
str(active_slot.get("message", "Local progression is unavailable."))
str(online_slot.get(
"message", "Online progression is unavailable."
))
)
return
if not _save_manager.load_character_profile():
_game_ui.get_title_screen().report_network_error(
"The selected online character could not be loaded."
)
return
if _network_session.state != NetworkSessionType.State.INACTIVE:
@ -2356,7 +2312,9 @@ func _show_next_social_prompt() -> void:
_active_social_prompt = _social_prompt_queue.pop_front()
var kind := str(_active_social_prompt.get("kind", ""))
var display_name := str(
_active_social_prompt.get("display_name", "Player")
_active_social_prompt.get(
"display_name", NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
)
)
var shown: bool = false
if kind == "friend_request":
@ -2468,7 +2426,11 @@ func _on_network_join_authenticated() -> void:
var progression_ready: bool = (
_save_manager.load_player_data()
if inspection.can_continue()
else _save_manager.initialize_new_game()
else _save_manager.initialize_new_game(
PlayerSaveManager.DEFAULT_WORLD_SEED,
WorldLayout.GENERATED,
false,
)
)
if not progression_ready:
_network_session.disconnect_session(
@ -2662,6 +2624,12 @@ func _apply_world(
func _refresh_active_world_bindings() -> void:
if (
is_instance_valid(_engaged_shop_npc_interaction)
or _shop_conversation_camera_restoring
or _player.is_shop_conversation_camera_active()
):
_end_shop_npc_conversation(true)
if is_instance_valid(_shop_interaction):
if _shop_interaction.local_player_range_changed.is_connected(
_on_shop_range_changed
@ -2693,6 +2661,7 @@ func _refresh_active_world_bindings() -> void:
_shop_interaction = _test_world.get_fishing_shop()
_decor_shop_interaction = _test_world.get_decor_shop()
_rv_upgrade_shop_interaction = _test_world.get_rv_upgrade_shop()
_active_shop_npc_interaction = null
_storage_interaction = _test_world.get_player_storage()
_network_sale.set_shop_interaction(_shop_interaction)
_network_shop.set_shop_interaction(_shop_interaction)
@ -2747,6 +2716,12 @@ func _refresh_active_world_bindings() -> void:
func _on_water_recovery_starting() -> void:
if (
is_instance_valid(_engaged_shop_npc_interaction)
or _shop_conversation_camera_restoring
or _player.is_shop_conversation_camera_active()
):
_end_shop_npc_conversation(true)
_local_recovery_attempt_id = (
"recovery:%s"
% Crypto.new().generate_random_bytes(16).hex_encode()
@ -2957,6 +2932,15 @@ func _finish_quit() -> void:
func _exit_tree() -> void:
if (
is_instance_valid(_player)
and (
is_instance_valid(_engaged_shop_npc_interaction)
or _shop_conversation_camera_restoring
or _player.is_shop_conversation_camera_active()
)
):
_end_shop_npc_conversation(true)
_cancel_home_entry_transition()
_cancel_home_exit_transition(true)
if _dedicated_runtime and _network_session != null:
@ -2976,10 +2960,15 @@ func _exit_tree() -> void:
func _on_shop_range_changed(in_range: bool) -> void:
_refresh_shop_npc_speech_state()
# Speech ownership is resolved once per frame after all Area3D enter/exit
# signals have settled, avoiding a one-frame clear during zone handoffs.
if not in_range:
_game_ui.get_fishing_shop().close_for_range_exit()
_game_ui.set_shop_prompt_visible(_can_show_any_shop_prompt())
if (
_engaged_shop_npc_interaction == _shop_interaction
and not _shop_conversation_shop_opened
):
_end_shop_npc_conversation(false)
func _on_storage_range_changed(in_range: bool) -> void:
@ -2993,17 +2982,23 @@ func _on_storage_range_changed(in_range: bool) -> void:
func _on_decor_shop_range_changed(in_range: bool) -> void:
_refresh_shop_npc_speech_state()
if not in_range:
_game_ui.get_decor_shop().close_for_range_exit()
_game_ui.set_shop_prompt_visible(_can_show_any_shop_prompt())
if (
_engaged_shop_npc_interaction == _decor_shop_interaction
and not _shop_conversation_shop_opened
):
_end_shop_npc_conversation(false)
func _on_rv_upgrade_shop_range_changed(in_range: bool) -> void:
_refresh_shop_npc_speech_state()
if not in_range:
_game_ui.get_rv_upgrade_shop().close_for_range_exit()
_game_ui.set_shop_prompt_visible(_can_show_any_shop_prompt())
if (
_engaged_shop_npc_interaction == _rv_upgrade_shop_interaction
and not _shop_conversation_shop_opened
):
_end_shop_npc_conversation(false)
func _on_home_state_changed() -> void:
@ -3015,6 +3010,11 @@ func _on_local_home_space_changed(
owner_fingerprint: String,
) -> void:
var inside_rv: bool = String(space_id).begins_with("rv:")
_world_time_visuals.set_local_home_interior_active(inside_rv)
_rain_ambience.set_suppressed(inside_rv)
_shoreline_ambience.set_suppressed(inside_rv)
_surface_drawings_root.visible = not inside_rv
_world_gatherables_root.visible = not inside_rv
_refresh_active_hotbar_item()
_fishing_spot.set_fishing_environment_enabled(not inside_rv)
_game_ui.set_rv_interior_camera_mode(
@ -3094,7 +3094,12 @@ func _maybe_begin_home_exit_transition() -> void:
_begin_home_exit_transition(owner_fingerprint)
func _begin_home_exit_transition(owner_fingerprint: String) -> void:
func _begin_home_exit_transition(
owner_fingerprint: String,
skip_walk: bool = false,
) -> void:
if owner_fingerprint.is_empty() or _home_exit_transition_active:
return
_home_exit_transition_active = true
_home_exit_waiting_for_network = false
_home_exit_start_position = _player.global_position
@ -3107,8 +3112,11 @@ func _begin_home_exit_transition(owner_fingerprint: String) -> void:
_game_ui.get_player_storage().close_storage()
_game_ui.set_rv_storage_button_visible(false)
_game_ui.set_gameplay_input_locked(true)
var exit_direction: Vector3 = _home_world.get_interior_exit_direction(
owner_fingerprint
if skip_walk:
_on_home_exit_walk_finished()
return
var exit_direction: Vector3 = _home_world.get_interior_exit_direction_near(
owner_fingerprint, _player.global_position
)
_player.face_world_direction(exit_direction)
_player.begin_animation_action(PlayerType.CHARACTER_WALKING_ANIMATION)
@ -3127,6 +3135,19 @@ func _begin_home_exit_transition(owner_fingerprint: String) -> void:
)
func _on_home_toolbar_exit_requested() -> void:
if _home_exit_transition_active:
return
var owner_fingerprint: String = (
_network_home.get_local_home_owner_fingerprint()
)
if owner_fingerprint.is_empty():
return
if _home_entry_transition_active:
_cancel_home_entry_transition()
_begin_home_exit_transition(owner_fingerprint, true)
func _on_home_exit_walk_finished() -> void:
_home_exit_walk_tween = null
if not _home_exit_transition_active:
@ -3209,35 +3230,33 @@ func _on_rv_storage_requested() -> void:
func _can_show_shop_prompt() -> bool:
return (
_gameplay_started
and _shop_interaction != null
and _shop_interaction.is_local_player_in_range()
and not _game_ui.get_fishing_shop().visible
and not _game_ui.get_decor_shop().visible
and not _game_ui.get_rv_upgrade_shop().visible
and not _water_recovery.is_recovery_active()
and _fishing_spot.can_open_fishing_shop()
_get_active_shop_npc_interaction() == _shop_interaction
and _can_show_active_shop_prompt(_shop_interaction)
)
func _can_show_decor_shop_prompt() -> bool:
return (
_gameplay_started
and _decor_shop_interaction != null
and _decor_shop_interaction.is_local_player_in_range()
and not _game_ui.get_decor_shop().visible
and not _game_ui.get_fishing_shop().visible
and not _game_ui.get_rv_upgrade_shop().visible
and not _water_recovery.is_recovery_active()
and _fishing_spot.can_open_fishing_shop()
_get_active_shop_npc_interaction() == _decor_shop_interaction
and _can_show_active_shop_prompt(_decor_shop_interaction)
)
func _can_show_rv_upgrade_shop_prompt() -> bool:
return (
_get_active_shop_npc_interaction() == _rv_upgrade_shop_interaction
and _can_show_active_shop_prompt(_rv_upgrade_shop_interaction)
)
func _can_show_active_shop_prompt(
interaction: FishingShopInteractionType,
) -> bool:
return (
_gameplay_started
and _rv_upgrade_shop_interaction != null
and _rv_upgrade_shop_interaction.is_local_player_in_range()
and not _shop_conversation_camera_restoring
and interaction != null
and interaction.is_local_player_in_range()
and not _game_ui.get_rv_upgrade_shop().visible
and not _game_ui.get_fishing_shop().visible
and not _game_ui.get_decor_shop().visible
@ -3247,10 +3266,8 @@ func _can_show_rv_upgrade_shop_prompt() -> bool:
func _can_show_any_shop_prompt() -> bool:
return (
_can_show_shop_prompt()
or _can_show_decor_shop_prompt()
or _can_show_rv_upgrade_shop_prompt()
return _can_show_active_shop_prompt(
_get_active_shop_npc_interaction()
)
@ -3272,34 +3289,181 @@ func _is_any_shop_npc_in_range() -> bool:
func _refresh_shop_npc_speech_state() -> void:
if (
_engaged_shop_npc_interaction != null
and (
not is_instance_valid(_engaged_shop_npc_interaction)
or not _engaged_shop_npc_interaction.is_local_player_in_range()
)
):
if _shop_conversation_shop_opened and _is_any_shop_menu_visible():
_game_ui.set_shop_npc_player_in_range(false)
return
_end_shop_npc_conversation(false)
var interaction := _get_active_shop_npc_interaction()
var dialogue_text: String = ""
if interaction != null:
dialogue_text = interaction.get_dialogue_text(_home_state.get_tier())
_game_ui.set_shop_npc_player_in_range(
interaction != null,
interaction != null and not _shop_conversation_shop_opened,
dialogue_text,
interaction.get_instance_id() if interaction != null else 0,
interaction == _engaged_shop_npc_interaction
and not _shop_conversation_shop_opened,
)
func _get_active_shop_npc_interaction() -> FishingShopInteractionType:
# Match the interaction and prompt priority used above: RV, decor, fishing.
if (
_rv_upgrade_shop_interaction != null
and _rv_upgrade_shop_interaction.is_local_player_in_range()
is_instance_valid(_engaged_shop_npc_interaction)
and _engaged_shop_npc_interaction.is_local_player_in_range()
):
return _rv_upgrade_shop_interaction
_active_shop_npc_interaction = _engaged_shop_npc_interaction
return _active_shop_npc_interaction
var candidates: Array[FishingShopInteractionType] = []
if _shop_interaction != null:
candidates.append(_shop_interaction)
if _decor_shop_interaction != null:
candidates.append(_decor_shop_interaction)
if _rv_upgrade_shop_interaction != null:
candidates.append(_rv_upgrade_shop_interaction)
var nearest: FishingShopInteractionType
var nearest_distance_squared: float = INF
for interaction: FishingShopInteractionType in candidates:
if not interaction.is_local_player_in_range():
continue
var distance_squared := interaction.get_local_player_distance_squared()
if distance_squared < nearest_distance_squared:
nearest = interaction
nearest_distance_squared = distance_squared
if (
_decor_shop_interaction != null
and _decor_shop_interaction.is_local_player_in_range()
nearest != null
and nearest != _active_shop_npc_interaction
and is_instance_valid(_active_shop_npc_interaction)
and _active_shop_npc_interaction.is_local_player_in_range()
):
return _decor_shop_interaction
var active_distance_squared := (
_active_shop_npc_interaction.get_local_player_distance_squared()
)
# Require a small, visible distance advantage before ownership switches.
# This keeps a player standing on the zone seam from chattering between
# two NPCs while still handing off well before the old compact zone ends.
if (
sqrt(nearest_distance_squared)
+ SHOP_NPC_HANDOFF_HYSTERESIS_METERS
>= sqrt(active_distance_squared)
):
nearest = _active_shop_npc_interaction
_active_shop_npc_interaction = nearest
return _active_shop_npc_interaction
func _begin_shop_npc_conversation(
interaction: FishingShopInteractionType,
) -> bool:
if (
_shop_interaction != null
and _shop_interaction.is_local_player_in_range()
_shop_conversation_camera_restoring
or is_instance_valid(_engaged_shop_npc_interaction)
or interaction == null
or not interaction.is_local_player_in_range()
or not _fishing_spot.can_open_fishing_shop()
):
return _shop_interaction
return null
return false
_engaged_shop_npc_interaction = interaction
_active_shop_npc_interaction = interaction
_shop_conversation_shop_opened = false
interaction.set_engaged(true)
_player.begin_shop_conversation_camera(
interaction.get_shopkeeper_position()
)
_player.set_local_input_suppressed(
SHOP_NPC_CONVERSATION_INPUT_OWNER, true
)
_player.set_camera_input_suppressed(
SHOP_NPC_CONVERSATION_INPUT_OWNER, true
)
_refresh_shop_npc_speech_state()
return true
func _open_engaged_shop() -> bool:
var interaction := _engaged_shop_npc_interaction
if (
interaction == null
or not is_instance_valid(interaction)
or not interaction.is_local_player_in_range()
or _shop_conversation_shop_opened
):
return false
var opened: bool = false
if interaction == _rv_upgrade_shop_interaction:
opened = _game_ui.get_rv_upgrade_shop().open_shop()
elif interaction == _decor_shop_interaction:
opened = _game_ui.get_decor_shop().open_shop()
elif interaction == _shop_interaction:
opened = _game_ui.get_fishing_shop().open_shop()
if not opened:
return false
_shop_conversation_shop_opened = true
_game_ui.set_shop_prompt_visible(false)
_refresh_shop_npc_speech_state()
return true
func _end_shop_npc_conversation(immediate_camera: bool = false) -> void:
if _shop_conversation_camera_restoring and not immediate_camera:
return
var interaction := _engaged_shop_npc_interaction
if is_instance_valid(interaction):
interaction.set_engaged(false)
_engaged_shop_npc_interaction = null
_shop_conversation_shop_opened = false
_game_ui.set_shop_npc_player_in_range(false)
var camera_was_active := _player.is_shop_conversation_camera_active()
_shop_conversation_camera_restoring = camera_was_active and not immediate_camera
_player.restore_shop_conversation_camera(
immediate_camera,
_finish_shop_npc_conversation_restore,
)
func _finish_shop_npc_conversation_restore() -> void:
_player.set_local_input_suppressed(
SHOP_NPC_CONVERSATION_INPUT_OWNER, false
)
_player.set_camera_input_suppressed(
SHOP_NPC_CONVERSATION_INPUT_OWNER, false
)
_shop_conversation_camera_restoring = false
if _gameplay_started and is_instance_valid(_game_ui):
_refresh_shop_npc_speech_state()
func _is_any_shop_menu_visible() -> bool:
return (
_game_ui.get_fishing_shop().visible
or _game_ui.get_decor_shop().visible
or _game_ui.get_rv_upgrade_shop().visible
)
func _on_shop_conversation_menu_visibility_changed(is_open: bool) -> void:
if (
not is_open
and _shop_conversation_shop_opened
and not _is_any_shop_menu_visible()
):
_end_shop_npc_conversation(false)
func _on_pause_menu_conversation_visibility_changed(is_open: bool) -> void:
if is_open and is_instance_valid(_engaged_shop_npc_interaction):
_end_shop_npc_conversation(false)
func _on_player_menu_conversation_visibility_changed(is_open: bool) -> void:
if is_open and is_instance_valid(_engaged_shop_npc_interaction):
_end_shop_npc_conversation(false)
func _can_show_storage_prompt() -> bool:

File diff suppressed because one or more lines are too long

View file

@ -278,7 +278,10 @@ func get_friend_presence() -> Array[Dictionary]:
result.append({
"fingerprint": fingerprint,
"display_name": str(
friend.get("last_known_display_name", "Player")
friend.get(
"last_known_display_name",
NetworkProfilePreferences.DEFAULT_DISPLAY_NAME,
)
),
"online": bool(live.get("online", false)),
"room": (
@ -1444,7 +1447,10 @@ func _on_invitation_poll_completed(
continue
friend_invite_received.emit(
fingerprint,
str(friend.get("last_known_display_name", "Player")),
str(friend.get(
"last_known_display_name",
NetworkProfilePreferences.DEFAULT_DISPLAY_NAME,
)),
room.duplicate(true),
)

View file

@ -15,6 +15,9 @@ static var _invisible_edge_pattern: RegEx = RegEx.create_from_string(
static var _visible_content_pattern: RegEx = RegEx.create_from_string(
"[^\\s\\p{Z}\\p{Cf}\\x{2800}]"
)
static var _roleplay_separator_pattern: RegEx = RegEx.create_from_string(
"^[\\s\\p{Z}\\p{Cf}\\x{2800}]"
)
enum Kind { PLAYER, SYSTEM }
@ -40,12 +43,40 @@ static func sanitize_body(value: Variant) -> String:
return result
static func is_roleplay_body(value: Variant) -> bool:
var body: String = sanitize_body(value)
if body.length() < 3 or body.left(3).to_lower() != "/me":
return false
return (
body.length() == 3
or _roleplay_separator_pattern.search(body.substr(3, 1)) != null
)
static func roleplay_action(value: Variant) -> String:
var body: String = sanitize_body(value)
if not is_roleplay_body(body):
return ""
return sanitize_body(body.substr(3))
static func valid_user_body(value: Variant) -> bool:
var body: String = sanitize_body(value)
return (
not body.is_empty()
and (
not is_roleplay_body(body)
or not roleplay_action(body).is_empty()
)
)
static func validate_request(data: Variant) -> bool:
return (
typeof(data) == TYPE_DICTIONARY
and _valid_id(data.get("request_id"))
and _valid_id(data.get("session_id"))
and not sanitize_body(data.get("body")).is_empty()
and valid_user_body(data.get("body"))
and NetworkIdentityCrypto.valid_fingerprint(
data.get("sender_fingerprint")
)
@ -67,7 +98,16 @@ static func validate_message(data: Variant) -> bool:
and typeof(data.get("sender_peer_id")) == TYPE_INT
and typeof(data.get("sender_display_name")) == TYPE_STRING
and str(data["sender_display_name"]).length() <= 24
and not sanitize_body(data.get("body")).is_empty()
and (
(
int(data["kind"]) == Kind.SYSTEM
and not sanitize_body(data.get("body")).is_empty()
)
or (
int(data["kind"]) == Kind.PLAYER
and valid_user_body(data.get("body"))
)
)
and NetworkIdentityCrypto.valid_fingerprint(
data.get("sender_fingerprint")
)

View file

@ -115,7 +115,10 @@ func send_local_message(
send_rejected.emit("Chat is unavailable.")
return false
var clean := NetworkChatProtocol.sanitize_body(body)
if clean.is_empty():
if not NetworkChatProtocol.valid_user_body(clean):
if NetworkChatProtocol.is_roleplay_body(clean):
send_rejected.emit("Usage: /me [action].")
return false
send_rejected.emit("Message is empty or too long.")
return false
var request := {
@ -412,6 +415,7 @@ func _apply_message(
return
var kind := int(data["kind"])
var valid_signature := false
var canonical_sender_name: String = ""
if kind == NetworkChatProtocol.Kind.SYSTEM:
valid_signature = _session.verify_host_action(
"chat_system",
@ -424,6 +428,7 @@ func _apply_message(
if record == null:
_queue_pending_peer_message(sender_id, data, emit_live_signals)
return
canonical_sender_name = record.display_name.left(24)
valid_signature = (
record.identity_fingerprint == str(data["sender_fingerprint"])
and _session.verify_peer_action(
@ -437,6 +442,10 @@ func _apply_message(
return
_seen_messages[data["message_id"]] = true
var stored_message := data.duplicate(true)
if kind == NetworkChatProtocol.Kind.PLAYER:
# The authenticated peer registry, not sender-controlled message data,
# owns the name shown beside chat and roleplay actions.
stored_message["sender_display_name"] = canonical_sender_name
# Suppression is immutable for this received copy. Relationship changes
# later must never resurrect text that was hidden on arrival.
stored_message["locally_suppressed"] = (
@ -548,7 +557,9 @@ func _on_peer_removed(peer_id: int) -> void:
_pending_peer_messages.erase(peer_id)
if not _session.is_host():
return
var display_name: String = _peer_names.get(peer_id, "Player")
var display_name: String = _peer_names.get(
peer_id, NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
)
_peer_names.erase(peer_id)
_broadcast(_make_message(
NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s left." % display_name

View file

@ -152,7 +152,10 @@ func is_local_recipient(letter: Dictionary) -> bool:
func get_local_display_name() -> String:
var record := _session.get_peer_record(_session.get_local_peer_id())
return record.display_name if record != null else "Player"
return (
record.display_name
if record != null else NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
)
func get_recipient_choices() -> Array[Dictionary]:

View file

@ -477,7 +477,10 @@ func request_ban(peer_id: int, fingerprint: String) -> void:
if not _valid_operator_sender(sender_id):
return
var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id)
var display_name: String = record.display_name if record != null else "Player"
var display_name: String = (
record.display_name
if record != null else NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
)
var ok: bool = _ban_on_host(
peer_id, fingerprint, display_name, -1, false
)
@ -544,7 +547,10 @@ func receive_ban_snapshot(records: Array) -> void:
var record: Dictionary = value
var fingerprint: String = str(record.get("target_fingerprint", ""))
var display_name: String = str(
record.get("last_known_display_name", "Player")
record.get(
"last_known_display_name",
NetworkProfilePreferences.DEFAULT_DISPLAY_NAME,
)
).strip_edges().left(NetworkProtocol.MAX_DISPLAY_NAME_LENGTH)
if (
not NetworkIdentityCrypto.valid_fingerprint(fingerprint)

View file

@ -1,12 +1,17 @@
class_name NetworkProfilePreferences
extends Node
signal profile_changed
const FORMAT_VERSION: int = 1
const DEFAULT_DISPLAY_NAME: String = "stray"
const DEFAULT_TITLE: String = "newbie"
const VoiceProfilesType = preload(
"res://player/animalese_voice_profiles.gd"
)
var profile_id: String = ""
var display_name: String = "Player"
var display_name: String = DEFAULT_DISPLAY_NAME
var title: String = DEFAULT_TITLE
var voice_id: String = VoiceProfilesType.DEFAULT_ID
var speech_speed_id: String = VoiceProfilesType.DEFAULT_SPEED_ID
var call_id: String = VoiceProfilesType.DEFAULT_CALL_ID
@ -56,7 +61,8 @@ func load_or_create() -> bool:
int(Time.get_unix_time_from_system()),
Time.get_ticks_usec(),
]
display_name = "Player"
display_name = DEFAULT_DISPLAY_NAME
title = DEFAULT_TITLE
voice_id = VoiceProfilesType.DEFAULT_ID
speech_speed_id = VoiceProfilesType.DEFAULT_SPEED_ID
call_id = VoiceProfilesType.DEFAULT_CALL_ID
@ -65,6 +71,113 @@ func load_or_create() -> bool:
return _save_atomic()
func to_save_data() -> Dictionary:
return {
"profile_id": profile_id,
"display_name": display_name,
"title": title,
"voice_id": voice_id,
"speech_speed_id": speech_speed_id,
"call_id": call_id,
"sample_set_id": sample_set_id,
"created_at_unix": created_at_unix,
}
static func validate_save_data(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY:
return false
var data: Dictionary = value
if (
typeof(data.get("profile_id")) != TYPE_STRING
or typeof(data.get("display_name")) != TYPE_STRING
or (
data.has("title")
and typeof(data.get("title")) != TYPE_STRING
)
or typeof(data.get("created_at_unix")) not in [TYPE_INT, TYPE_FLOAT]
):
return false
var saved_profile_id := str(data.get("profile_id", ""))
var saved_name := str(data.get("display_name", ""))
var saved_title := str(data.get("title", DEFAULT_TITLE))
return (
not saved_profile_id.is_empty()
and saved_profile_id.length() <= NetworkProtocol.MAX_PROFILE_ID_LENGTH
and is_valid_display_name(saved_name)
and is_valid_title(saved_title)
and VoiceProfilesType.is_valid(str(data.get(
"voice_id",
VoiceProfilesType.DEFAULT_ID,
)))
and VoiceProfilesType.is_valid_speed(str(data.get(
"speech_speed_id",
VoiceProfilesType.DEFAULT_SPEED_ID,
)))
and VoiceProfilesType.is_valid_call(str(data.get(
"call_id",
VoiceProfilesType.DEFAULT_CALL_ID,
)))
and VoiceProfilesType.is_valid_sample_set(str(data.get(
"sample_set_id",
VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
)))
and int(data.get("created_at_unix", -1)) >= 0
)
func restore_from_save_data(
value: Variant,
persist_to_profile_file: bool = true,
) -> bool:
if not validate_save_data(value):
return false
var data: Dictionary = value
var previous: Dictionary = to_save_data()
profile_id = str(data["profile_id"])
display_name = str(data["display_name"]).strip_edges()
title = str(data.get("title", DEFAULT_TITLE)).strip_edges()
voice_id = str(data.get("voice_id", VoiceProfilesType.DEFAULT_ID))
speech_speed_id = str(data.get(
"speech_speed_id",
VoiceProfilesType.DEFAULT_SPEED_ID,
))
call_id = str(data.get("call_id", VoiceProfilesType.DEFAULT_CALL_ID))
sample_set_id = str(data.get(
"sample_set_id",
VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
))
created_at_unix = int(data["created_at_unix"])
if persist_to_profile_file and not _save_atomic():
_assign_save_data(previous)
return false
if to_save_data() != previous:
profile_changed.emit()
return true
func reset_to_defaults(persist_to_profile_file: bool = true) -> bool:
var previous: Dictionary = to_save_data()
profile_id = Crypto.new().generate_random_bytes(16).hex_encode()
if profile_id.is_empty():
profile_id = "%d-%d" % [
int(Time.get_unix_time_from_system()),
Time.get_ticks_usec(),
]
display_name = DEFAULT_DISPLAY_NAME
title = DEFAULT_TITLE
voice_id = VoiceProfilesType.DEFAULT_ID
speech_speed_id = VoiceProfilesType.DEFAULT_SPEED_ID
call_id = VoiceProfilesType.DEFAULT_CALL_ID
sample_set_id = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID
created_at_unix = int(Time.get_unix_time_from_system())
if persist_to_profile_file and not _save_atomic():
_assign_save_data(previous)
return false
profile_changed.emit()
return true
func set_display_name(value: String) -> bool:
return set_profile_identity(
value,
@ -72,6 +185,7 @@ func set_display_name(value: String) -> bool:
speech_speed_id,
call_id,
sample_set_id,
title,
)
@ -81,10 +195,15 @@ func set_profile_identity(
selected_speech_speed_id: String,
selected_call_id: String,
selected_sample_set_id: String = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
selected_title: String = "",
) -> bool:
var clean_name: String = value.strip_edges()
var clean_title: String = (
title if selected_title.is_empty() else selected_title.strip_edges()
)
if (
not is_valid_display_name(clean_name)
or not is_valid_title(clean_title)
or not VoiceProfilesType.is_valid(selected_voice_id)
or not VoiceProfilesType.is_valid_speed(selected_speech_speed_id)
or not VoiceProfilesType.is_valid_call(selected_call_id)
@ -92,18 +211,30 @@ func set_profile_identity(
):
return false
var previous_name: String = display_name
var previous_title: String = title
var previous_voice_id: String = voice_id
var previous_speech_speed_id: String = speech_speed_id
var previous_call_id: String = call_id
var previous_sample_set_id: String = sample_set_id
display_name = clean_name
title = clean_title
voice_id = selected_voice_id
speech_speed_id = selected_speech_speed_id
call_id = selected_call_id
sample_set_id = selected_sample_set_id
if _save_atomic():
if (
display_name != previous_name
or voice_id != previous_voice_id
or title != previous_title
or speech_speed_id != previous_speech_speed_id
or call_id != previous_call_id
or sample_set_id != previous_sample_set_id
):
profile_changed.emit()
return true
display_name = previous_name
title = previous_title
voice_id = previous_voice_id
speech_speed_id = previous_speech_speed_id
call_id = previous_call_id
@ -111,6 +242,23 @@ func set_profile_identity(
return false
func _assign_save_data(data: Dictionary) -> void:
profile_id = str(data.get("profile_id", ""))
display_name = str(data.get("display_name", DEFAULT_DISPLAY_NAME))
title = str(data.get("title", DEFAULT_TITLE))
voice_id = str(data.get("voice_id", VoiceProfilesType.DEFAULT_ID))
speech_speed_id = str(data.get(
"speech_speed_id",
VoiceProfilesType.DEFAULT_SPEED_ID,
))
call_id = str(data.get("call_id", VoiceProfilesType.DEFAULT_CALL_ID))
sample_set_id = str(data.get(
"sample_set_id",
VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
))
created_at_unix = int(data.get("created_at_unix", 0))
static func is_valid_display_name(value: String) -> bool:
var clean_name := value.strip_edges()
if clean_name.is_empty() or clean_name.length() > 24:
@ -122,6 +270,17 @@ static func is_valid_display_name(value: String) -> bool:
return true
static func is_valid_title(value: String) -> bool:
var clean_title := value.strip_edges()
if clean_title.is_empty() or clean_title.length() > NetworkProtocol.MAX_PLAYER_TITLE_LENGTH:
return false
for index: int in clean_title.length():
var codepoint: int = clean_title.unicode_at(index)
if codepoint < 32 or codepoint == 127:
return false
return true
func _load_existing() -> bool:
var file := FileAccess.open(_profile_path, FileAccess.READ)
if file == null:
@ -142,20 +301,27 @@ func _load_existing() -> bool:
data.get("format_version") != FORMAT_VERSION
or typeof(data.get("profile_id")) != TYPE_STRING
or typeof(data.get("display_name")) != TYPE_STRING
or (
data.has("title")
and typeof(data.get("title")) != TYPE_STRING
)
or typeof(data.get("created_at_unix")) not in [TYPE_INT, TYPE_FLOAT]
):
return false
var loaded_id: String = data["profile_id"]
var loaded_name: String = data["display_name"]
var loaded_title := str(data.get("title", DEFAULT_TITLE))
if (
loaded_id.is_empty()
or loaded_id.length() > NetworkProtocol.MAX_PROFILE_ID_LENGTH
or loaded_name.is_empty()
or loaded_name.length() > NetworkProtocol.MAX_DISPLAY_NAME_LENGTH
or not is_valid_title(loaded_title)
):
return false
profile_id = loaded_id
display_name = loaded_name
title = loaded_title.strip_edges()
voice_id = VoiceProfilesType.sanitized_id(
str(data.get("voice_id", VoiceProfilesType.DEFAULT_ID))
)
@ -180,6 +346,7 @@ func _save_atomic() -> bool:
"format_version": FORMAT_VERSION,
"profile_id": profile_id,
"display_name": display_name,
"title": title,
"voice_id": voice_id,
"speech_speed_id": speech_speed_id,
"call_id": call_id,

View file

@ -31,6 +31,8 @@ static func valid_check_request(data: Variant) -> bool:
static func valid_apply_request(data: Variant) -> bool:
return (
valid_check_request(data)
and typeof(data.get("player_title")) == TYPE_STRING
and NetworkProfilePreferences.is_valid_title(data["player_title"])
and typeof(data.get("appearance")) == TYPE_DICTIONARY
and valid_snapshot(data["appearance"])
and typeof(data.get("use_anyway")) == TYPE_BOOL
@ -63,6 +65,7 @@ static func signature_fields(data: Dictionary) -> Array:
str(data.get("request_id", "")),
str(data.get("sender_fingerprint", "")),
str(data.get("display_name", "")),
str(data.get("player_title", "")),
]
result.append_array(
CharacterCustomizationCatalog.appearance_signature_values(appearance)

View file

@ -16,6 +16,7 @@ signal profile_snapshot_changed(
display_name: String,
appearance: Dictionary,
)
signal profile_title_changed(peer_id: int, title: String)
var _session: NetworkSession
var _preferences: NetworkProfilePreferences
@ -26,6 +27,7 @@ var _pending_voice_ids: Dictionary[String, String] = {}
var _pending_speech_speed_ids: Dictionary[String, String] = {}
var _pending_call_ids: Dictionary[String, String] = {}
var _pending_sample_set_ids: Dictionary[String, String] = {}
var _pending_titles: Dictionary[String, String] = {}
var _host_pending_apply: Dictionary[String, Dictionary] = {}
var _latest_check_id: String = ""
var _latest_check_name: String = ""
@ -42,6 +44,16 @@ func setup(
_appearance_store = appearance_store
_spawn_service = spawn_service
_appearance_store.load_preferences()
if not _preferences.profile_changed.is_connected(
_on_local_profile_store_changed
):
_preferences.profile_changed.connect(_on_local_profile_store_changed)
if not _appearance_store.appearance_changed.is_connected(
_on_local_appearance_store_changed
):
_appearance_store.appearance_changed.connect(
_on_local_appearance_store_changed
)
_session.peer_authenticated.connect(_on_peer_authenticated)
_session.peer_removed.connect(_on_peer_removed)
_session.peer_display_name_changed.connect(_on_peer_display_name_changed)
@ -51,6 +63,20 @@ func setup(
_apply_local_voice_to_avatar()
func _on_local_profile_store_changed() -> void:
_apply_local_voice_to_avatar()
func _on_local_appearance_store_changed() -> void:
var snapshot: Dictionary = _appearance_store.get_snapshot()
var local_peer_id: int = (
_session.get_local_peer_id() if _session != null else 1
)
_apply_to_avatar(local_peer_id, snapshot)
if _session != null:
_session.set_local_appearance_snapshot(snapshot)
func reload_persisted_profile() -> bool:
if (
not _preferences.load_or_create()
@ -66,6 +92,10 @@ func get_persisted_name() -> String:
return _preferences.display_name
func get_persisted_title() -> String:
return _preferences.title
func get_persisted_appearance() -> Dictionary:
return _appearance_store.get_snapshot()
@ -107,6 +137,7 @@ func preview_appearance(appearance: Dictionary) -> bool:
local_peer_id,
_preferences.display_name,
snapshot,
_preferences.title,
)
var authorization := _make_appearance_preview_authorization(snapshot)
var local_record := _session.get_peer_record(local_peer_id)
@ -179,10 +210,16 @@ func apply_profile(
speech_speed_id: String = VoiceProfilesType.DEFAULT_SPEED_ID,
call_id: String = VoiceProfilesType.DEFAULT_CALL_ID,
sample_set_id: String = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
player_title: String = "",
) -> bool:
var clean_name := display_name.strip_edges()
var clean_title := (
_preferences.title
if player_title.is_empty() else player_title.strip_edges()
)
if (
not NetworkProfilePreferences.is_valid_display_name(clean_name)
or not NetworkProfilePreferences.is_valid_title(clean_title)
or not CharacterCustomizationCatalog.validate_snapshot(appearance)
or not VoiceProfilesType.is_valid(voice_id)
or not VoiceProfilesType.is_valid_speed(speech_speed_id)
@ -196,6 +233,7 @@ func apply_profile(
"request_id": request_id,
"session_id": _session.get_session_id() if _session != null else "",
"display_name": clean_name,
"player_title": clean_title,
"appearance": appearance.duplicate(true),
"use_anyway": use_anyway,
"sender_fingerprint": _session.get_local_identity_fingerprint(),
@ -208,6 +246,7 @@ func apply_profile(
_pending_speech_speed_ids[request_id] = speech_speed_id
_pending_call_ids[request_id] = call_id
_pending_sample_set_ids[request_id] = sample_set_id
_pending_titles[request_id] = clean_title
if _session == null or not _session.is_gameplay_session_active():
_apply_local_result(request_id, true, "", false, PackedStringArray())
elif _session.is_host():
@ -303,6 +342,7 @@ func submit_appearance_preview(data: Dictionary) -> void:
sender_id,
record.display_name,
appearance,
record.title,
)
_apply_to_avatar(sender_id, appearance)
_broadcast_appearance_preview_to_supported_peers(sender_id, appearance)
@ -310,6 +350,7 @@ func submit_appearance_preview(data: Dictionary) -> void:
func _process_apply_request(peer_id: int, data: Dictionary) -> void:
var display_name: String = str(data["display_name"]).strip_edges()
var player_title: String = str(data["player_title"]).strip_edges()
var conflict: bool = _name_conflicts(peer_id, display_name)
var accepted: bool = not conflict or bool(data["use_anyway"])
var suggestions: PackedStringArray = (
@ -325,7 +366,8 @@ func _process_apply_request(peer_id: int, data: Dictionary) -> void:
if accepted:
_host_pending_apply[str(data["request_id"])] = {
"peer_id": peer_id,
"display_name": display_name,
"display_name": display_name,
"player_title": player_title,
"appearance": Dictionary(data["appearance"]).duplicate(true),
"authorization": {
"request_id": data["request_id"],
@ -356,8 +398,11 @@ func confirm_profile_saved(request_id: String) -> void:
return
_host_pending_apply.erase(request_id)
var display_name: String = str(pending["display_name"])
var player_title: String = str(pending["player_title"])
var appearance: Dictionary = Dictionary(pending["appearance"])
_session.apply_canonical_profile(sender_id, display_name, appearance)
_session.apply_canonical_profile(
sender_id, display_name, appearance, player_title
)
var record: PeerRegistry.PeerRecord = _session.get_peer_record(sender_id)
if record != null:
record.profile_authorization = pending.get("authorization", {}).duplicate(true)
@ -365,6 +410,7 @@ func confirm_profile_saved(request_id: String) -> void:
broadcast_profile_snapshot.rpc(
sender_id,
display_name,
player_title,
appearance,
pending.get("authorization", {}),
)
@ -405,10 +451,12 @@ func _apply_local_result(
_pending_speech_speed_ids.erase(request_id)
_pending_call_ids.erase(request_id)
_pending_sample_set_ids.erase(request_id)
_pending_titles.erase(request_id)
conflict_result.emit(request_id, conflict, suggestions)
apply_finished.emit(false, message)
return
var previous_name := _preferences.display_name
var previous_title := _preferences.title
var previous_voice_id := _preferences.voice_id
var previous_speech_speed_id := _preferences.speech_speed_id
var previous_call_id := _preferences.call_id
@ -429,18 +477,24 @@ func _apply_local_result(
request_id,
VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
)
var requested_title: String = _pending_titles.get(
request_id,
NetworkProfilePreferences.DEFAULT_TITLE,
)
if not _preferences.set_profile_identity(
str(request["display_name"]),
requested_voice_id,
requested_speech_speed_id,
requested_call_id,
requested_sample_set_id,
requested_title,
):
_pending_apply.erase(request_id)
_pending_voice_ids.erase(request_id)
_pending_speech_speed_ids.erase(request_id)
_pending_call_ids.erase(request_id)
_pending_sample_set_ids.erase(request_id)
_pending_titles.erase(request_id)
apply_finished.emit(false, "Profile could not be saved.")
return
if not _appearance_store.save_snapshot(request["appearance"]):
@ -450,12 +504,14 @@ func _apply_local_result(
previous_speech_speed_id,
previous_call_id,
previous_sample_set_id,
previous_title,
)
_pending_apply.erase(request_id)
_pending_voice_ids.erase(request_id)
_pending_speech_speed_ids.erase(request_id)
_pending_call_ids.erase(request_id)
_pending_sample_set_ids.erase(request_id)
_pending_titles.erase(request_id)
apply_finished.emit(false, "Profile could not be saved.")
return
_pending_apply.erase(request_id)
@ -463,6 +519,7 @@ func _apply_local_result(
_pending_speech_speed_ids.erase(request_id)
_pending_call_ids.erase(request_id)
_pending_sample_set_ids.erase(request_id)
_pending_titles.erase(request_id)
if _session != null:
_session.set_local_appearance_snapshot(_appearance_store.get_snapshot())
if _session != null and _session.is_gameplay_session_active():
@ -471,6 +528,7 @@ func _apply_local_result(
_session.get_local_peer_id(),
_preferences.display_name,
_appearance_store.get_snapshot(),
_preferences.title,
)
var record := _session.get_peer_record(
_session.get_local_peer_id()
@ -480,6 +538,7 @@ func _apply_local_result(
broadcast_profile_snapshot.rpc(
_session.get_local_peer_id(),
_preferences.display_name,
_preferences.title,
_appearance_store.get_snapshot(),
request,
)
@ -499,11 +558,13 @@ func _apply_local_result(
func broadcast_profile_snapshot(
peer_id: int,
display_name: String,
player_title: String,
appearance: Dictionary,
authorization: Dictionary = {},
) -> void:
if (
not NetworkProfilePreferences.is_valid_display_name(display_name)
or not NetworkProfilePreferences.is_valid_title(player_title)
or not CharacterCustomizationCatalog.validate_snapshot(appearance)
):
return
@ -514,12 +575,16 @@ func broadcast_profile_snapshot(
display_name,
appearance,
authorization,
player_title,
)
):
return
_session.apply_canonical_profile(peer_id, display_name, appearance)
_session.apply_canonical_profile(
peer_id, display_name, appearance, player_title
)
_apply_to_avatar(peer_id, appearance)
profile_snapshot_changed.emit(peer_id, display_name, appearance.duplicate(true))
profile_title_changed.emit(peer_id, player_title)
@rpc("authority", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL)
@ -533,7 +598,9 @@ func broadcast_appearance_preview(
if record == null:
return
var snapshot := CharacterCustomizationCatalog.sanitized_snapshot(appearance)
_session.apply_canonical_profile(peer_id, record.display_name, snapshot)
_session.apply_canonical_profile(
peer_id, record.display_name, snapshot, record.title
)
_apply_to_avatar(peer_id, snapshot)
profile_snapshot_changed.emit(
peer_id,
@ -575,6 +642,7 @@ func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
peer_id,
existing_id,
record.display_name,
record.title,
record.appearance_snapshot,
record.profile_authorization,
)
@ -638,6 +706,7 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_pending_speech_speed_ids.clear()
_pending_call_ids.clear()
_pending_sample_set_ids.clear()
_pending_titles.clear()
_host_pending_apply.clear()
_latest_check_id = ""
_latest_check_name = ""

View file

@ -3,10 +3,11 @@ extends RefCounted
const WorldLayoutType = preload("res://world/world_layout.gd")
const PROTOCOL_VERSION: int = 12
const PROTOCOL_VERSION: int = 13
const GAME_BUILD: String = "prealpha"
const MAX_GAME_VERSION_LENGTH: int = 64
const MAX_DISPLAY_NAME_LENGTH: int = 24
const MAX_PLAYER_TITLE_LENGTH: int = 24
const MAX_PROFILE_ID_LENGTH: int = 96
const MAX_NONCE_LENGTH: int = 96
const MAX_PUBLIC_KEY_LENGTH: int = 8192
@ -183,6 +184,7 @@ static func make_client_hello(
cosmetic_snapshot: Dictionary = {},
identity_fingerprint: String = "",
identity_signature: PackedByteArray = PackedByteArray(),
player_title: String = NetworkProfilePreferences.DEFAULT_TITLE,
) -> Dictionary:
return {
"protocol_version": PROTOCOL_VERSION,
@ -190,6 +192,7 @@ static func make_client_hello(
"game_build": GAME_BUILD,
"local_profile_id": profile_id,
"display_name": display_name,
"player_title": player_title,
"client_nonce": client_nonce,
"capability_flags": PackedStringArray([
FISH_QUALITY_CAPABILITY,
@ -224,6 +227,7 @@ static func validate_client_hello(data: Variant) -> String:
"game_build",
"local_profile_id",
"display_name",
"player_title",
"client_nonce",
"capability_flags",
"cosmetic_snapshot",
@ -246,6 +250,11 @@ static func validate_client_hello(data: Variant) -> String:
return "Profile ID is invalid."
if typeof(payload["display_name"]) != TYPE_STRING:
return "Display name is invalid."
if (
typeof(payload["player_title"]) != TYPE_STRING
or not NetworkProfilePreferences.is_valid_title(payload["player_title"])
):
return "Player title is invalid."
if typeof(payload["client_nonce"]) != TYPE_STRING:
return "Client nonce is invalid."
if typeof(payload["capability_flags"]) not in [
@ -295,6 +304,7 @@ static func client_profile_fields(data: Dictionary) -> Array:
str(data.get("identity_fingerprint", "")),
str(data.get("local_profile_id", "")),
str(data.get("display_name", "")),
str(data.get("player_title", "")),
]
result.append_array(
CharacterCustomizationCatalog.appearance_signature_values(appearance)

View file

@ -67,6 +67,7 @@ signal host_openness_changed(is_open: bool)
signal session_display_name_changed(display_name: String)
signal peer_count_changed(player_count: int, max_players: int)
signal peer_display_name_changed(peer_id: int, display_name: String)
signal peer_title_changed(peer_id: int, title: String)
signal peer_profile_changed(
peer_id: int,
display_name: String,
@ -354,6 +355,7 @@ func _register_player_host() -> void:
NetworkProtocol.HOME_CAPABILITY,
NetworkProtocol.HOME_UPGRADE_CAPABILITY,
]),
_profile.title,
)
_registry.update_appearance(1, _local_appearance_snapshot)
var host_profile_hello := NetworkProtocol.make_client_hello(
@ -362,6 +364,8 @@ func _register_player_host() -> void:
NetworkIdentityCrypto.secure_id(32),
_local_appearance_snapshot,
_player_identity.fingerprint,
PackedByteArray(),
_profile.title,
)
host_profile_hello["identity_signature"] = _player_identity.sign(
"handshake_client_profile",
@ -931,13 +935,16 @@ func apply_canonical_profile(
peer_id: int,
display_name: String,
appearance: Dictionary,
player_title: String = NetworkProfilePreferences.DEFAULT_TITLE,
) -> bool:
if (
not NetworkProfilePreferences.is_valid_display_name(display_name)
or not NetworkProfilePreferences.is_valid_title(player_title)
or not CharacterCustomizationCatalog.validate_snapshot(appearance)
):
return false
var name_changed := _registry.update_display_name(peer_id, display_name)
var title_changed: bool = _registry.update_title(peer_id, player_title)
var appearance_changed := _registry.update_appearance(peer_id, appearance)
if name_changed:
peer_display_name_changed.emit(peer_id, display_name)
@ -945,9 +952,11 @@ func apply_canonical_profile(
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar != null:
avatar.apply_appearance_snapshot(appearance)
if title_changed:
peer_title_changed.emit(peer_id, player_title)
if name_changed or appearance_changed:
peer_profile_changed.emit(peer_id, display_name, appearance.duplicate(true))
return name_changed or appearance_changed
return name_changed or title_changed or appearance_changed
@rpc("any_peer", "call_remote", "reliable", 0)
@ -1376,6 +1385,8 @@ func request_client_profile() -> void:
_client_nonce,
_local_appearance_snapshot,
_player_identity.fingerprint,
PackedByteArray(),
_profile.title,
)
hello["identity_signature"] = _player_identity.sign(
"handshake_client_profile",
@ -1459,6 +1470,7 @@ func submit_client_hello(data: Dictionary) -> void:
return
var profile_id: String = data["local_profile_id"]
var display_name: String = data["display_name"]
var player_title: String = data["player_title"]
if not NetworkProfilePreferences.is_valid_display_name(display_name):
_reject_peer(
sender_id,
@ -1473,6 +1485,7 @@ func submit_client_hello(data: Dictionary) -> void:
identity["fingerprint"],
identity["public_key"],
client_capabilities,
player_title,
):
_reject_peer(
sender_id,
@ -1515,6 +1528,7 @@ func submit_client_hello(data: Dictionary) -> void:
display_name,
submitted_appearance.duplicate(true),
)
peer_title_changed.emit(sender_id, player_title)
receive_server_hello.rpc_id(
sender_id,
NetworkProtocol.make_server_hello(
@ -1691,6 +1705,7 @@ func receive_server_hello(data: Dictionary) -> void:
NetworkProtocol.HOME_CAPABILITY,
NetworkProtocol.HOME_UPGRADE_CAPABILITY,
]),
_profile.title,
)
_registry.update_appearance(local_peer_id, _local_appearance_snapshot)
var local_record := _registry.get_peer(local_peer_id)
@ -1777,6 +1792,10 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
typeof(entry.get("peer_id")) != TYPE_INT
or typeof(entry.get("profile_id")) != TYPE_STRING
or typeof(entry.get("display_name")) != TYPE_STRING
or typeof(entry.get("player_title")) != TYPE_STRING
or not NetworkProfilePreferences.is_valid_title(
str(entry["player_title"])
)
or typeof(entry.get("position")) != TYPE_ARRAY
or typeof(entry.get("yaw")) not in [TYPE_FLOAT, TYPE_INT]
or typeof(entry.get("sitting")) != TYPE_BOOL
@ -1811,6 +1830,7 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
str(entry.get("identity_fingerprint", "")),
str(entry.get("identity_public_key", "")),
_sanitized_capabilities(entry.get("capability_flags", [])),
str(entry["player_title"]),
):
return
peer_was_added = true
@ -1894,6 +1914,10 @@ func _make_spawn_entry(
"peer_id": peer_id,
"profile_id": record.profile_id if record != null else "",
"display_name": display_name,
"player_title": (
record.title
if record != null else NetworkProfilePreferences.DEFAULT_TITLE
),
"position": [
transform.origin.x,
transform.origin.y,
@ -1978,6 +2002,7 @@ func verify_profile_authorization(
display_name: String,
appearance: Dictionary,
authorization: Dictionary,
player_title: String = NetworkProfilePreferences.DEFAULT_TITLE,
) -> bool:
var record := _registry.get_peer(peer_id)
if record == null:
@ -1986,6 +2011,7 @@ func verify_profile_authorization(
peer_id,
record.profile_id,
display_name,
player_title,
appearance,
record.identity_fingerprint,
record.identity_public_key,
@ -2000,6 +2026,7 @@ func _verify_spawn_identity(entry: Dictionary) -> bool:
int(entry.get("peer_id", 0)),
str(entry.get("profile_id", "")),
str(entry.get("display_name", "")),
str(entry.get("player_title", NetworkProfilePreferences.DEFAULT_TITLE)),
Dictionary(entry.get("appearance", {})),
str(entry.get("identity_fingerprint", "")),
str(entry.get("identity_public_key", "")),
@ -2011,6 +2038,7 @@ func _verify_profile_authorization_fields(
peer_id: int,
profile_id: String,
display_name: String,
player_title: String,
appearance: Dictionary,
fingerprint: String,
identity_public_key: String,
@ -2021,6 +2049,8 @@ func _verify_profile_authorization_fields(
)
if NetworkIdentityCrypto.fingerprint_public_pem(public_pem) != fingerprint:
return false
if not NetworkProfilePreferences.is_valid_title(player_title):
return false
if authorization.is_empty():
return peer_id == 1
if authorization.get("domain") == "handshake_client_profile":
@ -2031,6 +2061,7 @@ func _verify_profile_authorization_fields(
appearance,
fingerprint,
authorization.get("signature", PackedByteArray()),
player_title,
)
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
@ -2054,6 +2085,7 @@ func _verify_profile_authorization_fields(
return false
var signed := authorization.duplicate(true)
signed["display_name"] = display_name
signed["player_title"] = player_title
signed["appearance"] = appearance
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
@ -2182,7 +2214,6 @@ func _maybe_send_local_animation_action() -> void:
str(action["id"]),
int(action["sequence"]),
bool(action.get("paused", false)),
avatar.get_network_sitting_intent(),
]
if signature == _last_local_animation_action_signature:
return
@ -2237,9 +2268,9 @@ func submit_movement_animation_action(encoded: Array) -> void:
avatar.apply_authoritative_network_animation_action(
animation_state["action"]
)
avatar.apply_authoritative_network_sitting_state(
bool(animation_state["sitting"])
)
# Sitting is authoritative only on sequenced movement input. The legacy bit
# remains in this packet's wire shape for protocol compatibility, but acting
# on it here would race jump input across two independent ENet channels.
static func _encode_movement_input(data: Dictionary) -> Array:

View file

@ -7,6 +7,7 @@ class PeerRecord:
var peer_id: int = 0
var profile_id: String = ""
var display_name: String = ""
var title: String = NetworkProfilePreferences.DEFAULT_TITLE
var protocol_version: int = 0
var joined_at_unix: int = 0
var appearance_snapshot: Dictionary = (
@ -30,10 +31,12 @@ func add_peer(
identity_fingerprint: String = "",
identity_public_key: String = "",
capability_flags: PackedStringArray = PackedStringArray(),
player_title: String = NetworkProfilePreferences.DEFAULT_TITLE,
) -> bool:
if (
peer_id <= 0
or profile_id.is_empty()
or not NetworkProfilePreferences.is_valid_title(player_title)
or (
not identity_fingerprint.is_empty()
and has_fingerprint(identity_fingerprint)
@ -44,6 +47,7 @@ func add_peer(
record.peer_id = peer_id
record.profile_id = profile_id
record.display_name = display_name
record.title = player_title
record.protocol_version = protocol_version
record.joined_at_unix = int(Time.get_unix_time_from_system())
record.identity_fingerprint = identity_fingerprint
@ -74,6 +78,14 @@ func update_display_name(peer_id: int, display_name: String) -> bool:
return true
func update_title(peer_id: int, player_title: String) -> bool:
var record: PeerRecord = _records.get(peer_id)
if record == null or not NetworkProfilePreferences.is_valid_title(player_title):
return false
record.title = player_title.strip_edges()
return true
func update_appearance(peer_id: int, snapshot: Dictionary) -> bool:
var record: PeerRecord = _records.get(peer_id)
if record == null or not CharacterCustomizationCatalog.validate_snapshot(snapshot):

View file

@ -16,6 +16,10 @@ const ENVIRONMENT_VARIABLE := "straywild_DATA_DIR"
const CREATE_ENVIRONMENT_VARIABLE := "straywild_CREATE_DATA_DIR"
const LEGACY_ENVIRONMENT_VARIABLE := "NETFISHING_DATA_DIR"
const LEGACY_CREATE_ENVIRONMENT_VARIABLE := "NETFISHING_CREATE_DATA_DIR"
const ISOLATED_VALIDATION_ENVIRONMENT := (
"straywild_ISOLATED_VALIDATION_STORAGE"
)
const ISOLATED_VALIDATION_DIRECTORY := "isolated-validation-data"
const APPLICATION_ID := "straywild"
const LEGACY_APPLICATION_ID := "netfishing"
const APP_DATA_PORTABLE_PATH := "user://portable-data"
@ -57,6 +61,9 @@ enum Mode {
COMMAND_LINE_OVERRIDE,
}
static var _process_validation_root := ""
static var _process_validation_device_id := ""
var mode := Mode.UNRESOLVED
var root_path := ""
var root_id := ""
@ -67,6 +74,11 @@ var override_active := false
func resolve() -> bool:
# Validation scripts may be launched directly instead of through the
# repository runner. Keep the opt-in flag fail-safe: it must never follow a
# real user's bootstrap pointer merely because XDG isolation was omitted.
if _isolated_validation_requested():
return _resolve_isolated_validation_root()
prepare_legacy_user_data()
_load_bootstrap_identity()
var command_line: String = _command_line_override()
@ -116,6 +128,44 @@ func resolve() -> bool:
return false
func _resolve_isolated_validation_root() -> bool:
# Behave like app-data storage after choosing the isolated root so migration
# tests can still exercise live root switching within their sandbox.
override_active = false
mode = Mode.APP_DATA
if _process_validation_device_id.is_empty():
_process_validation_device_id = (
Crypto.new().generate_random_bytes(16).hex_encode()
)
device_id = _process_validation_device_id
if _process_validation_root.is_empty():
var parent: String
if OS.has_environment("XDG_DATA_HOME"):
parent = ProjectSettings.globalize_path("user://")
else:
parent = OS.get_temp_dir().path_join(
"straywild-validation-%d-%d"
% [OS.get_process_id(), Time.get_ticks_usec()]
)
_process_validation_root = parent.path_join(
ISOLATED_VALIDATION_DIRECTORY
)
if not has_data_manifest(_process_validation_root):
var created: Dictionary = create_unbound_root(
_process_validation_root
)
if not bool(created.get("ok", false)):
return false
return _activate_existing(_process_validation_root, "", false)
static func _isolated_validation_requested() -> bool:
return (
DisplayServer.get_name() == "headless"
and OS.get_environment(ISOLATED_VALIDATION_ENVIRONMENT) == "1"
)
func prepare_legacy_user_data() -> void:
var current_root: String = _normalize(
ProjectSettings.globalize_path("user://")

View file

@ -388,6 +388,17 @@ class ShowcaseCameraSnapshot:
var camera_dragging: bool
class ConversationCameraSnapshot:
extends RefCounted
var yaw_transform: Transform3D
var yaw_rotation: Vector3
var pitch_rotation: Vector3
var spring_length: float
var target_zoom: float
var camera_dragging: bool
@export_category("Movement")
@export var walk_speed: float = 2.25
@export var sprint_speed: float = 4.5
@ -446,6 +457,11 @@ var _rv_previous_camera_environment: Environment
@export_range(0.0, 1.0, 0.01) var controller_trigger_threshold: float = 0.55
@export_range(1.0, 40.0, 0.5) var free_camera_speed: float = 10.0
@export_range(1.0, 10.0, 0.5) var free_camera_sprint_multiplier: float = 3.0
@export_category("Conversation Camera")
@export_range(0.05, 1.0, 0.01) var conversation_camera_transition_seconds: float = 0.42
@export_range(-45.0, 20.0, 1.0) var conversation_camera_pitch_degrees: float = -9.0
@export_range(1.5, 8.0, 0.1, "suffix:m") var conversation_camera_distance: float = 3.65
@export_range(0.5, 2.5, 0.05, "suffix:m") var conversation_camera_target_height: float = 1.3
@onready var _visuals: Node3D = %Visuals
@onready var _character_rig: Node3D = get_node_or_null(
@ -521,6 +537,9 @@ var _showcase_restore_tween: Tween
var _showcase_camera_tween: Tween
var _showcase_camera_snapshot: ShowcaseCameraSnapshot
var _showcase_restore_generation: int = 0
var _conversation_camera_tween: Tween
var _conversation_camera_snapshot: ConversationCameraSnapshot
var _conversation_camera_generation: int = 0
var _network_peer_id: int = 0
var _network_authoritative_simulation: bool = false
var _network_interpolation_enabled: bool = false
@ -1070,7 +1089,18 @@ func _simulate_movement_physics(delta: float) -> void:
if jump_requested:
_set_sitting(false, local_control_enabled)
else:
velocity = Vector3.ZERO
# Keep the body participating in physics while seated. In particular,
# move_and_slide() refreshes Godot's floor state, so a stale grounded
# flag from prediction or a delayed packet cannot trap this early-return
# path forever after the capsule has moved into the air.
velocity.x = 0.0
velocity.z = 0.0
if not is_on_floor():
velocity.y -= _gravity * fall_gravity_multiplier * delta
move_and_slide()
if not is_on_floor():
_sit_after_landing = true
_set_sitting(false)
_network_jump_pending = false
return
if _water_recovery_active:
@ -1726,6 +1756,10 @@ func toggle_sitting() -> void:
return
if not _can_begin_sitting():
_sit_after_landing = true
# Queue and repeat the intent until an owner snapshot acknowledges it.
# Previously this branch returned before marking a local sitting intent,
# so a delayed authoritative audit could race the deferred sit.
_set_sitting(false, local_control_enabled)
return
_set_sitting(should_sit, local_control_enabled)
@ -1750,7 +1784,11 @@ func _set_sitting(
return
_sitting = should_sit
if _sitting:
velocity = Vector3.ZERO
# Sitting stops horizontal locomotion, but vertical motion remains owned by
# physics. Clearing Y here allowed a delayed seated state to repeatedly
# cancel gravity in a high-latency session.
velocity.x = 0.0
velocity.z = 0.0
_character_animation_name = &""
_update_character_animation()
@ -1803,6 +1841,24 @@ func apply_authoritative_network_sitting_state(should_sit: bool) -> void:
_set_sitting(should_sit)
func _apply_local_prediction_sitting_state(should_sit: bool) -> void:
# Owner audits can arrive several physics frames behind the locally predicted
# jump. Treat an authoritative seated bit as an intent until this capsule is
# actually grounded; applying it directly zeros vertical velocity and lets a
# stream of high-latency snapshots hold the local player in mid-air.
if _local_network_jump_intent_pending:
_sit_after_landing = false
_set_sitting(false)
return
if should_sit and not _can_begin_sitting():
_sit_after_landing = true
_set_sitting(false)
return
if should_sit:
_sit_after_landing = false
_set_sitting(should_sit)
func _input(event: InputEvent) -> void:
# Camera dragging must observe the complete mouse gesture before GUI controls
# get a chance to consume one part of it. Do not claim the event here: the
@ -2048,6 +2104,162 @@ func get_active_gameplay_camera() -> Camera3D:
return _camera
func begin_shop_conversation_camera(npc_world_position: Vector3) -> bool:
if (
not local_control_enabled
or _camera_yaw == null
or _camera_pitch == null
or _spring_arm == null
):
return false
if _free_camera_active:
_set_free_camera_active(false)
_kill_conversation_camera_tween()
if _conversation_camera_snapshot == null:
_conversation_camera_snapshot = ConversationCameraSnapshot.new()
_conversation_camera_snapshot.yaw_transform = _camera_yaw.transform
_conversation_camera_snapshot.yaw_rotation = _camera_yaw.rotation
_conversation_camera_snapshot.pitch_rotation = _camera_pitch.rotation
_conversation_camera_snapshot.spring_length = _spring_arm.spring_length
_conversation_camera_snapshot.target_zoom = _target_zoom
_conversation_camera_snapshot.camera_dragging = _camera_dragging
_set_camera_dragging(false)
var pair_direction := npc_world_position - global_position
pair_direction.y = 0.0
if pair_direction.length_squared() <= 0.000001:
pair_direction = Vector3.RIGHT
else:
pair_direction = pair_direction.normalized()
# Frame the midpoint, with the camera's right axis running from the player
# toward the NPC. This naturally places the player left and shopkeeper right.
var target_world_yaw := atan2(-pair_direction.z, pair_direction.x)
var target_local_yaw := target_world_yaw - global_rotation.y
var shortest_target_yaw := (
_camera_yaw.rotation.y
+ wrapf(target_local_yaw - _camera_yaw.rotation.y, -PI, PI)
)
var pair_midpoint := (global_position + npc_world_position) * 0.5
pair_midpoint.y = global_position.y + conversation_camera_target_height
var target_position := to_local(pair_midpoint)
var duration := maxf(conversation_camera_transition_seconds, 0.05)
_conversation_camera_tween = create_tween()
_conversation_camera_tween.set_parallel(true)
_conversation_camera_tween.set_trans(Tween.TRANS_QUAD)
_conversation_camera_tween.set_ease(Tween.EASE_IN_OUT)
_conversation_camera_tween.tween_property(
_camera_yaw, "rotation:y", shortest_target_yaw, duration
)
_conversation_camera_tween.tween_property(
_camera_yaw, "position", target_position, duration
)
_conversation_camera_tween.tween_property(
_camera_pitch,
"rotation:x",
deg_to_rad(conversation_camera_pitch_degrees),
duration,
)
_conversation_camera_tween.tween_property(
_spring_arm,
"spring_length",
maxf(conversation_camera_distance, 0.1),
duration,
)
return true
func restore_shop_conversation_camera(
immediate: bool = false,
restored_callback: Callable = Callable(),
) -> void:
_kill_conversation_camera_tween()
if _conversation_camera_snapshot == null:
if restored_callback.is_valid():
restored_callback.call()
return
if immediate:
_complete_conversation_camera_restore(restored_callback)
return
var yaw_target := _conversation_camera_snapshot.yaw_rotation
yaw_target.y = (
_camera_yaw.rotation.y
+ wrapf(yaw_target.y - _camera_yaw.rotation.y, -PI, PI)
)
var generation := _conversation_camera_generation
var duration := maxf(conversation_camera_transition_seconds, 0.05)
_conversation_camera_tween = create_tween()
_conversation_camera_tween.set_parallel(true)
_conversation_camera_tween.set_trans(Tween.TRANS_QUAD)
_conversation_camera_tween.set_ease(Tween.EASE_IN_OUT)
_conversation_camera_tween.tween_property(
_camera_yaw, "rotation", yaw_target, duration
)
_conversation_camera_tween.tween_property(
_camera_yaw,
"position",
_conversation_camera_snapshot.yaw_transform.origin,
duration,
)
_conversation_camera_tween.tween_property(
_camera_pitch,
"rotation",
_conversation_camera_snapshot.pitch_rotation,
duration,
)
_conversation_camera_tween.tween_property(
_spring_arm,
"spring_length",
_conversation_camera_snapshot.spring_length,
duration,
)
_conversation_camera_tween.finished.connect(
_on_conversation_camera_restore_finished.bind(
generation, restored_callback
)
)
func is_shop_conversation_camera_active() -> bool:
return _conversation_camera_snapshot != null
func _kill_conversation_camera_tween() -> void:
if (
_conversation_camera_tween != null
and _conversation_camera_tween.is_valid()
):
_conversation_camera_tween.kill()
_conversation_camera_tween = null
_conversation_camera_generation += 1
func _on_conversation_camera_restore_finished(
generation: int,
restored_callback: Callable,
) -> void:
if generation != _conversation_camera_generation:
return
_conversation_camera_tween = null
_complete_conversation_camera_restore(restored_callback)
func _complete_conversation_camera_restore(
restored_callback: Callable,
) -> void:
if _conversation_camera_snapshot != null:
_camera_yaw.transform = _conversation_camera_snapshot.yaw_transform
_camera_pitch.rotation = _conversation_camera_snapshot.pitch_rotation
_spring_arm.spring_length = _conversation_camera_snapshot.spring_length
_target_zoom = _conversation_camera_snapshot.target_zoom
_set_camera_dragging(
_conversation_camera_snapshot.camera_dragging
and Input.is_action_pressed("camera_drag")
)
_conversation_camera_snapshot = null
if restored_callback.is_valid():
restored_callback.call()
func _set_target_zoom(value: float) -> void:
_target_zoom = clampf(
value,
@ -2526,7 +2738,7 @@ func apply_local_prediction_correction(
):
_clear_local_network_jump_intent()
if not _sitting_intent_pending:
_set_sitting(bool(parsed["sitting"]))
_apply_local_prediction_sitting_state(bool(parsed["sitting"]))
var authoritative_position: Vector3 = parsed["position"]
var transit_seconds: float = resolve_local_prediction_transit_seconds(
acknowledged_input,
@ -3322,9 +3534,23 @@ func get_body_center_position() -> Vector3:
func get_chat_anchor_position() -> Vector3:
return get_nameplate_anchor_position()
func get_nameplate_anchor_position() -> Vector3:
# Character head meshes keep stable authored bounds while skeletal animation
# plays. Restricting the anchor to them prevents rods, nets, held catches,
# and showcase props from making overhead UI bounce during an action.
var highest_y := -INF
for node: Node in _visuals.find_children("*", "MeshInstance3D", true, false):
var mesh_instance := node as MeshInstance3D
var skeleton := get_node_or_null(
"Visuals/CharacterRig/CharacterRig/Skeleton3D"
) as Skeleton3D
for head_name: String in ["head_round", "head_pointy"]:
var mesh_instance := (
skeleton.get_node_or_null(head_name) as MeshInstance3D
if skeleton != null
else null
)
if mesh_instance == null or not mesh_instance.is_visible_in_tree():
continue
var bounds := mesh_instance.get_aabb()

View file

@ -1,6 +1,8 @@
class_name PlayerAppearanceStore
extends Node
signal appearance_changed
const FORMAT_VERSION: int = 1
var _snapshot: Dictionary = CharacterCustomizationCatalog.default_snapshot()
var _loaded: bool = false
@ -70,11 +72,42 @@ func save_snapshot(value: Dictionary) -> bool:
_snapshot = value.duplicate(true)
if _save_atomic():
_loaded = true
if _snapshot != previous:
appearance_changed.emit()
return true
_snapshot = previous
return false
func restore_from_save_data(
value: Variant,
persist_to_profile_file: bool = true,
) -> bool:
if (
typeof(value) != TYPE_DICTIONARY
or not CharacterCustomizationCatalog.validate_snapshot(value)
):
return false
var sanitized: Dictionary = (
CharacterCustomizationCatalog.sanitized_snapshot(value)
)
if persist_to_profile_file:
return save_snapshot(sanitized)
var previous: Dictionary = _snapshot.duplicate(true)
_snapshot = sanitized
_loaded = true
if _snapshot != previous:
appearance_changed.emit()
return true
func reset_to_defaults(persist_to_profile_file: bool = true) -> bool:
return restore_from_save_data(
CharacterCustomizationCatalog.default_snapshot(),
persist_to_profile_file,
)
func is_loaded() -> bool:
return _loaded

View file

@ -35,6 +35,13 @@ const WorldLayoutType = preload("res://world/world_layout.gd")
const PlayerJobServiceType = preload("res://jobs/player_job_service.gd")
const PlayerType = preload("res://player/player.gd")
const PlayerHomeStateType = preload("res://homes/player_home_state.gd")
const DecorCatalogType = preload("res://homes/decor_catalog.gd")
const NetworkProfilePreferencesType = preload(
"res://network/network_profile_preferences.gd"
)
const PlayerAppearanceStoreType = preload(
"res://progression/player_appearance_store.gd"
)
const SAVE_VERSION: int = 11
const LEGACY_SAVE_FILENAME := "player_save.json"
@ -78,6 +85,9 @@ class LoadSnapshot:
)
var jobs_data: Dictionary = PlayerJobServiceType.default_save_data()
var home_data: Dictionary = PlayerHomeStateType.default_save_data()
var has_character_data: bool = false
var character_profile_data: Dictionary = {}
var appearance_data: Dictionary = {}
@export_range(0.05, 5.0, 0.05) var autosave_delay: float = 0.5
@ -99,6 +109,9 @@ var _world_weather: WorldWeatherServiceType
var _jobs: PlayerJobServiceType
var _player: PlayerType
var _home_state: PlayerHomeStateType
var _network_profile: NetworkProfilePreferencesType
var _appearance_store: PlayerAppearanceStoreType
var _legacy_character_data: Dictionary = {}
var _autosave_timer: Timer
var _is_configured: bool = false
var _is_restoring: bool = false
@ -179,6 +192,8 @@ func setup(
jobs: PlayerJobServiceType,
player: PlayerType,
home_state: PlayerHomeStateType,
network_profile: NetworkProfilePreferencesType = null,
appearance_store: PlayerAppearanceStoreType = null,
) -> void:
_inventory = inventory
_collection_log = collection_log
@ -197,6 +212,9 @@ func setup(
_jobs = jobs
_player = player
_home_state = home_state
_network_profile = network_profile
_appearance_store = appearance_store
capture_legacy_character_fallback()
_is_configured = (
_inventory != null
and _collection_log != null
@ -263,6 +281,132 @@ func setup(
_player.active_lure_changed.connect(_on_active_tackle_changed)
if not _home_state.changed.is_connected(_mark_dirty):
_home_state.changed.connect(_mark_dirty)
if (
_network_profile != null
and not _network_profile.profile_changed.is_connected(_mark_dirty)
):
_network_profile.profile_changed.connect(_mark_dirty)
if (
_appearance_store != null
and not _appearance_store.appearance_changed.is_connected(_mark_dirty)
):
_appearance_store.appearance_changed.connect(_mark_dirty)
func capture_legacy_character_fallback() -> void:
_legacy_character_data = _current_character_data()
func load_character_profile() -> bool:
if _network_profile == null or _appearance_store == null:
return true
_recover_interrupted_write()
var read_path: String = _read_path()
var previous_restoring: bool = _is_restoring
_is_restoring = true
if read_path.is_empty():
var reset_ok: bool = _reset_character_profile()
_is_restoring = previous_restoring
return reset_ok
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
read_path,
read_path == _legacy_save_path(),
)
if not bool(decoded.get("ok", false)):
_is_restoring = previous_restoring
return false
var save_data: Dictionary = decoded["data"]
var version: int = _read_integer(save_data.get("save_version"), -1)
if version < 1 or version > SAVE_VERSION:
_is_restoring = previous_restoring
return false
if version != SAVE_VERSION:
save_data = _migrate_save(save_data, version)
if save_data.is_empty():
_is_restoring = previous_restoring
return false
var character: Dictionary = _character_data_from_save(save_data)
var restored: bool = (
not character.is_empty()
and _restore_character_data(character)
)
_is_restoring = previous_restoring
return restored
func _current_character_data() -> Dictionary:
if _network_profile == null or _appearance_store == null:
return {}
var profile: Dictionary = _network_profile.to_save_data()
var appearance: Dictionary = _appearance_store.get_snapshot()
if (
not NetworkProfilePreferencesType.validate_save_data(profile)
or not CharacterCustomizationCatalog.validate_snapshot(appearance)
):
return {}
return {
"profile": profile,
"appearance": appearance,
}
func _character_data_from_save(save_data: Dictionary) -> Dictionary:
if not save_data.has("character"):
return _legacy_character_data.duplicate(true)
if typeof(save_data.get("character")) != TYPE_DICTIONARY:
return {}
var character: Dictionary = save_data["character"]
if (
not NetworkProfilePreferencesType.validate_save_data(
character.get("profile")
)
or typeof(character.get("appearance")) != TYPE_DICTIONARY
or not CharacterCustomizationCatalog.validate_snapshot(
character.get("appearance")
)
):
return {}
return {
"profile": (character["profile"] as Dictionary).duplicate(true),
"appearance": CharacterCustomizationCatalog.sanitized_snapshot(
character["appearance"]
),
}
func _restore_character_data(character: Dictionary) -> bool:
if _network_profile == null or _appearance_store == null:
return true
# The selected progression save is authoritative here. Applying it must not
# rewrite the compatibility profile mirrors: their guarded writes may be
# stale after a Syncthing edit, and that must not make a valid slot unloadable.
var previous_profile: Dictionary = _network_profile.to_save_data()
var previous_appearance: Dictionary = _appearance_store.get_snapshot()
if not _network_profile.restore_from_save_data(
character.get("profile"), false
):
return false
if _appearance_store.restore_from_save_data(
character.get("appearance"), false
):
return true
_network_profile.restore_from_save_data(previous_profile, false)
_appearance_store.restore_from_save_data(previous_appearance, false)
return false
func _reset_character_profile() -> bool:
if _network_profile == null or _appearance_store == null:
return true
var previous_profile: Dictionary = _network_profile.to_save_data()
var previous_appearance: Dictionary = _appearance_store.get_snapshot()
if not _network_profile.reset_to_defaults(false):
return false
if _appearance_store.reset_to_defaults(false):
return true
_network_profile.restore_from_save_data(previous_profile, false)
_appearance_store.restore_from_save_data(previous_appearance, false)
return false
func load_player_data() -> bool:
@ -315,6 +459,12 @@ func load_player_data() -> bool:
return false
_is_restoring = true
var character_restored: bool = true
if snapshot.has_character_data:
character_restored = _restore_character_data({
"profile": snapshot.character_profile_data,
"appearance": snapshot.appearance_data,
})
var inventory_restored: bool = _inventory.replace_all_catches(
snapshot.catches,
snapshot.next_catch_sequence
@ -390,13 +540,14 @@ func load_player_data() -> bool:
or not world_weather_restored
or not jobs_restored
or not home_restored
or not character_restored
):
push_error(
(
"Validated player save could not be restored: "
+ "inventory=%s collection=%s wallet=%s bag=%s tackle=%s hotbar=%s "
+ "upgrades=%s cooler=%s layout=%s art=%s experience=%s "
+ "time=%s weather=%s jobs=%s home=%s"
+ "time=%s weather=%s jobs=%s home=%s character=%s"
)
% [
inventory_restored,
@ -414,6 +565,7 @@ func load_player_data() -> bool:
world_weather_restored,
jobs_restored,
home_restored,
character_restored,
]
)
return false
@ -669,6 +821,7 @@ func copy_progression_to_path(
func initialize_new_game(
world_seed: int = DEFAULT_WORLD_SEED,
world_layout: StringName = WorldLayoutType.GENERATED,
reset_character_profile: bool = true,
) -> bool:
if not _is_configured:
return false
@ -679,6 +832,12 @@ func initialize_new_game(
):
return false
_automatic_saving_blocked = false
if reset_character_profile:
_is_restoring = true
var character_reset: bool = _reset_character_profile()
_is_restoring = false
if not character_reset:
return false
_restore_defaults()
_world_layout = world_layout
_world_seed = world_seed
@ -872,7 +1031,7 @@ func _build_save_dictionary() -> Dictionary:
):
return {}
return {
var save_data: Dictionary = {
"save_version": SAVE_VERSION,
"wallet": {
"balance": _wallet.get_balance(),
@ -916,6 +1075,12 @@ func _build_save_dictionary() -> Dictionary:
"jobs": _jobs.to_save_data(),
"home": _home_state.to_save_data(),
}
var character: Dictionary = _current_character_data()
if _network_profile != null and _appearance_store != null:
if character.is_empty():
return {}
save_data["character"] = character
return save_data
func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
@ -998,6 +1163,17 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
return null
var snapshot := LoadSnapshot.new()
var character: Dictionary = _character_data_from_save(save_data)
if save_data.has("character") and character.is_empty():
return null
if not character.is_empty():
snapshot.has_character_data = true
snapshot.character_profile_data = (
character["profile"] as Dictionary
).duplicate(true)
snapshot.appearance_data = (
character["appearance"] as Dictionary
).duplicate(true)
if world_data.has("layout"):
if not WorldLayoutType.is_valid(world_data["layout"]):
return null
@ -1208,7 +1384,12 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
)
continue
var maximum: int = (
item_data.max_stack if item_data.stackable else 1
mini(
item_data.max_stack,
DecorCatalogType.MAX_OWNED_PER_PRODUCT,
)
if DecorCatalogType.has_product(item_id)
else item_data.max_stack if item_data.stackable else 1
)
if quantity > maximum:
push_warning(

View file

@ -14,6 +14,7 @@ var _manifest_path := ""
var _slots_directory := ""
var _slots: Array[Dictionary] = []
var _active_slot_id := ""
var _online_slot_id := ""
var _expected_hash := ""
var _error_message := ""
@ -30,6 +31,7 @@ func configure(
_slots_directory = data_root.root_path.path_join("player/saves")
_slots.clear()
_active_slot_id = ""
_online_slot_id = ""
_expected_hash = ""
_error_message = ""
if DirAccess.make_dir_recursive_absolute(_slots_directory) != OK:
@ -43,13 +45,17 @@ func configure(
return false
var slot_count_before_discovery: int = _slots.size()
var active_before_discovery: String = _active_slot_id
var online_before_discovery: String = _online_slot_id
_discover_untracked_saves()
if _active_slot_id.is_empty() and not _slots.is_empty():
_active_slot_id = str(_slots.front().get("slot_id", ""))
if _online_slot_id.is_empty() and not _slots.is_empty():
_online_slot_id = _active_slot_id
if (
not loaded
or _slots.size() != slot_count_before_discovery
or _active_slot_id != active_before_discovery
or _online_slot_id != online_before_discovery
):
if not _save_manifest():
return false
@ -86,6 +92,24 @@ func get_active_slot_id() -> String:
return _active_slot_id
func get_online_slot_id() -> String:
return _online_slot_id
func mark_slot_for_online(slot_id: String) -> bool:
if _find_slot(slot_id).is_empty():
return false
if _online_slot_id == slot_id:
return true
var previous: String = _online_slot_id
_online_slot_id = slot_id
if not _save_manifest():
_online_slot_id = previous
return false
slots_changed.emit()
return true
func has_slots() -> bool:
return not _slots.is_empty()
@ -102,6 +126,26 @@ func ensure_active_slot() -> Dictionary:
return {"ok": true, "slot_id": slot_id}
func ensure_online_slot() -> Dictionary:
var slot_id: String = _online_slot_id
if slot_id.is_empty() or _find_slot(slot_id).is_empty():
var active: Dictionary = ensure_active_slot()
if not bool(active.get("ok", false)):
return active
slot_id = str(active.get("slot_id", ""))
if not mark_slot_for_online(slot_id):
return {
"ok": false,
"message": "the online progression could not be selected.",
}
if _active_slot_id != slot_id and not activate_slot(slot_id):
return {
"ok": false,
"message": "the online progression could not be opened.",
}
return {"ok": true, "slot_id": slot_id}
func create_empty_slot(display_name: String) -> Dictionary:
if _slots.size() >= MAX_SLOTS:
return {"ok": false, "message": "the maximum number of save slots has been reached."}
@ -117,8 +161,12 @@ func create_empty_slot(display_name: String) -> Dictionary:
"last_played_at_unix": 0,
"legacy": false,
})
var previous_online: String = _online_slot_id
if _online_slot_id.is_empty():
_online_slot_id = slot_id
if not _save_manifest():
_slots.pop_back()
_online_slot_id = previous_online
return {"ok": false, "message": "the save-slot catalog could not be updated."}
slots_changed.emit()
return {"ok": true, "slot_id": slot_id}
@ -267,6 +315,7 @@ func delete_slot(slot_id: String) -> bool:
for previous_entry: Dictionary in _slots:
previous_slots.append(previous_entry.duplicate(true))
var previous_active: String = _active_slot_id
var previous_online: String = _online_slot_id
var entry: Dictionary = _slots[index]
var source_path: String = _existing_slot_path(entry)
var preserved_path := ""
@ -281,9 +330,18 @@ func delete_slot(slot_id: String) -> bool:
if not _slots.is_empty()
else ""
)
if _online_slot_id == slot_id:
_online_slot_id = (
_active_slot_id
if not _active_slot_id.is_empty()
else str(_slots.front().get("slot_id", ""))
if not _slots.is_empty()
else ""
)
if not _select_configured_storage() or not _save_manifest():
_slots = previous_slots
_active_slot_id = previous_active
_online_slot_id = previous_online
_select_configured_storage()
if not preserved_path.is_empty():
_rename_file(preserved_path, source_path)
@ -315,6 +373,7 @@ func _build_slot_summary(entry: Dictionary) -> Dictionary:
path.ends_with(".json"),
)
summary["active"] = str(entry.get("slot_id", "")) == _active_slot_id
summary["online"] = str(entry.get("slot_id", "")) == _online_slot_id
summary["has_save"] = inspection.can_continue()
summary["save_status"] = inspection.status
summary["status_message"] = inspection.message
@ -360,6 +419,9 @@ func _load_manifest() -> bool:
_active_slot_id = str(data.get("active_slot_id", ""))
if not _active_slot_id.is_empty() and not seen_ids.has(_active_slot_id):
_active_slot_id = ""
_online_slot_id = str(data.get("online_slot_id", ""))
if not _online_slot_id.is_empty() and not seen_ids.has(_online_slot_id):
_online_slot_id = ""
_expected_hash = PortableFileGuard.hash_file(_manifest_path)
return true
@ -368,6 +430,7 @@ func _save_manifest() -> bool:
var data: Dictionary = {
"format_version": FORMAT_VERSION,
"active_slot_id": _active_slot_id,
"online_slot_id": _online_slot_id,
"slots": _slots,
}
var result: Dictionary = PortableFileGuard.write_guarded(
@ -576,6 +639,7 @@ func _preserve_invalid_manifest() -> bool:
_expected_hash = ""
_slots.clear()
_active_slot_id = ""
_online_slot_id = ""
return true

View file

@ -44,6 +44,7 @@ readonly -a QUICK_TESTS=(
"tests/on_screen_keyboard_validation.gd"
"tests/player_gathering_animation_validation.gd"
"tests/player_experience_validation.gd"
"tests/profile_title_validation.gd"
"tests/shoreline_ambience_validation.gd"
"tests/surface_drawing_validation.gd"
"tests/surface_drawing_performance_validation.gd"
@ -54,6 +55,7 @@ readonly -a QUICK_TESTS=(
"tests/terrain_chunk_generator_validation.gd"
"tests/texture_sampling_validation.gd"
"tests/tree_gathering_prototype_validation.gd"
"tests/tooltip_escape_validation.gd"
"tests/unified_inventory_validation.gd"
"tests/world_time_validation.gd"
"tests/world_layout_validation.gd"
@ -80,6 +82,7 @@ readonly -a HOST_TESTS=(
"tests/inventory_storage_persistence_validation.gd"
"tests/job_system_validation.gd"
"tests/progression_archive_validation.gd"
"tests/save_reload_conflict_validation.gd"
"tests/surface_drawing_runtime_validation.gd"
)

View file

@ -409,7 +409,17 @@ func _run() -> void:
# not the inactive normal gameplay camera.
var normal_camera := player.get_gameplay_camera()
var normal_camera_transform: Transform3D = normal_camera.global_transform
var chat_anchor: Vector3 = player.get_chat_anchor_position()
var chat_anchor: Vector3 = player.get_nameplate_anchor_position()
assert(player.get_chat_anchor_position().is_equal_approx(chat_anchor))
var held_gear_probe := MeshInstance3D.new()
var held_gear_mesh := BoxMesh.new()
held_gear_mesh.size = Vector3(0.5, 4.0, 0.5)
held_gear_probe.mesh = held_gear_mesh
held_gear_probe.position = Vector3(0.0, 6.0, 0.0)
player.get_node("%Visuals").add_child(held_gear_probe)
assert(player.get_nameplate_anchor_position().is_equal_approx(chat_anchor))
assert(player.get_chat_anchor_position().is_equal_approx(chat_anchor))
held_gear_probe.free()
normal_camera.look_at(
normal_camera.global_position
- (chat_anchor - normal_camera.global_position),
@ -417,6 +427,120 @@ func _run() -> void:
)
assert(normal_camera.is_position_behind(chat_anchor))
assert(not free_camera.is_position_behind(chat_anchor))
free_camera.look_at(chat_anchor, Vector3.UP)
chat_ui.call("_update_nameplates")
var nameplate_layer := chat_ui.get("_nameplate_layer") as Control
var nameplates: Dictionary = chat_ui.get("_nameplates")
var local_nameplate: Dictionary = nameplates.get(
session.get_local_peer_id(), {}
)
var nameplate_control := local_nameplate.get("control") as Control
var nameplate_name := local_nameplate.get("name") as Label
var nameplate_title := local_nameplate.get("title") as Label
var local_record := session.get_peer_record(session.get_local_peer_id())
assert(
nameplate_control != null and nameplate_control.visible,
(
"local nameplate must be visible (available=%s world=%s layer=%s "
+ "shared=%s remote_visible=%s behind=%s occluded=%s screen=%s viewport=%s)"
)
% [
bool(chat_ui.get("_available")),
chat_ui.is_world_speech_visible(),
nameplate_layer.visible,
bool(chat_ui.call(
"_speaker_shares_local_space", session.get_local_peer_id()
)),
player.is_remote_presentation_visible(),
free_camera.is_position_behind(chat_anchor),
bool(chat_ui.call(
"_is_speech_world_occluded", free_camera, player, chat_anchor
)),
free_camera.unproject_position(chat_anchor)
/ float(chat_ui.get("_output_scale")),
chat_ui.get_viewport_rect().size,
],
)
assert(nameplate_name != null and nameplate_title != null)
assert(nameplate_name.text == local_record.display_name)
assert(nameplate_title.text == local_record.title)
assert(
nameplate_name.get_theme_font_size("font_size")
== ChatUI.NAMEPLATE_NAME_FONT_SIZE
)
assert(
nameplate_title.get_theme_font_size("font_size")
== ChatUI.NAMEPLATE_TITLE_FONT_SIZE
)
assert(
nameplate_name.get_theme_font_size("font_size")
> nameplate_title.get_theme_font_size("font_size")
)
assert(ChatUI.NAMEPLATE_NAME_FONT_SIZE == 20)
assert(ChatUI.NAMEPLATE_TITLE_TOP < ChatUI.NAMEPLATE_NAME_HEIGHT)
assert(ChatUI.NAMEPLATE_HEIGHT < 42.0)
assert(nameplate_title.position.y == ChatUI.NAMEPLATE_TITLE_TOP)
for flat_label: Label in [nameplate_name, nameplate_title]:
assert(flat_label.get_theme_color("font_color") == Color.WHITE)
assert(flat_label.get_theme_constant("outline_size") == 0)
assert(flat_label.get_theme_color("font_outline_color").a == 0.0)
assert(flat_label.get_theme_color("font_shadow_color").a == 0.0)
# Persistent remote labels obey both the authoritative home-space boundary
# and the avatar presentation mask. They return only when the avatar shares
# the local space and its world presentation is visible.
var spawn_service := main.get_node(
"%PlayerSpawnService"
) as PlayerSpawnService
var registry := session.get("_registry") as PeerRegistry
const NAMEPLATE_TEST_PEER_ID: int = 9191
assert(registry.add_peer(
NAMEPLATE_TEST_PEER_ID,
"nameplate-test-profile",
"remote stray",
NetworkProtocol.PROTOCOL_VERSION,
"",
"",
PackedStringArray(),
"trailblazer",
))
var remote_transform: Transform3D = player.global_transform
remote_transform.origin += Vector3(0.8, 0.0, 0.0)
var remote_avatar := spawn_service.spawn_remote_player(
NAMEPLATE_TEST_PEER_ID,
remote_transform,
)
assert(remote_avatar != null)
assert(session.set_peer_space(NAMEPLATE_TEST_PEER_ID, &"rv:privacy-test"))
remote_avatar.set_remote_presentation_visible(true)
chat_ui.call("_update_nameplates")
var remote_nameplate: Dictionary = nameplates.get(
NAMEPLATE_TEST_PEER_ID, {}
)
var remote_plate := remote_nameplate.get("control") as Control
assert(remote_plate != null and not remote_plate.visible)
assert(session.set_peer_space(
NAMEPLATE_TEST_PEER_ID,
session.get_peer_space(session.get_local_peer_id()),
))
remote_avatar.set_remote_presentation_visible(false)
chat_ui.call("_update_nameplates")
assert(not remote_plate.visible)
remote_avatar.set_remote_presentation_visible(true)
free_camera.look_at(
remote_avatar.get_nameplate_anchor_position(), Vector3.UP
)
chat_ui.call("_update_nameplates")
assert(remote_plate.visible)
assert((remote_nameplate.get("name") as Label).text == "remote stray")
assert((remote_nameplate.get("title") as Label).text == "trailblazer")
spawn_service.remove_peer(NAMEPLATE_TEST_PEER_ID)
registry.remove_peer(NAMEPLATE_TEST_PEER_ID)
chat_ui.call("_update_nameplates")
assert(not nameplates.has(NAMEPLATE_TEST_PEER_ID))
free_camera.look_at(chat_anchor, Vector3.UP)
chat_ui.call("_update_nameplates")
assert(nameplate_control.visible)
var shop_prompt := game_ui.get_node("%ShopPrompt") as Control
var storage_prompt := game_ui.get_node("%StoragePrompt") as Control
game_ui.set_shop_prompt_visible(true, chat_anchor)
@ -432,11 +556,19 @@ func _run() -> void:
assert(not first_speech_mouth.is_empty())
assert(not second_speech_mouth.is_empty())
assert(first_speech_mouth != second_speech_mouth)
free_camera.look_at(chat_anchor, Vector3.UP)
chat_ui.call("_update_nameplates")
chat_ui.call("_update_speech")
var speech: Dictionary = chat_ui.get("_speech")
var local_speech: Dictionary = speech.get(session.get_local_peer_id(), {})
var speech_bubble := local_speech.get("bubble") as PanelContainer
assert(speech_bubble != null and speech_bubble.visible)
assert(
speech_bubble.position.y
+ speech_bubble.size.y
+ ChatUI.SPEECH_POINTER_HEIGHT
<= nameplate_control.position.y
)
normal_camera.global_transform = normal_camera_transform
await create_timer(0.6).timeout
assert(not bool(player.get("_speech_mouth_active")))
@ -448,6 +580,7 @@ func _run() -> void:
await process_frame
assert(game_ui.is_gameplay_hud_hidden())
assert(chat_ui.is_hud_hidden())
assert(not nameplate_layer.visible)
assert(not (
game_ui.get_node("%GameplayTransientHUD") as Control
).visible)
@ -479,6 +612,8 @@ func _run() -> void:
chat_ui.open_chat()
await process_frame
assert(chat_ui.is_open() and chat_panel.visible)
assert(not chat_ui.is_hud_hidden())
assert(not nameplate_layer.visible)
chat_ui.refocus_gameplay()
assert(chat_ui.is_hud_hidden())
var cancel_button := InputEventJoypadButton.new()
@ -503,6 +638,7 @@ func _run() -> void:
await process_frame
assert(not game_ui.is_gameplay_hud_hidden())
assert(not chat_ui.is_hud_hidden())
assert(nameplate_layer.visible)
assert(not service.can_activate())
assert(not service.is_active() and not toolbar.visible)
assert(player.bag.add_item(ArtShopStock.ART_KIT_ITEM_ID, 1))

View file

@ -4,6 +4,8 @@ const MainScene: PackedScene = preload("res://main/main.tscn")
const TEST_PORT: int = 18152
const PREJOIN_MESSAGE: String = "private prejoin message"
const LIVE_MESSAGE: String = "live message after join"
const HOST_ROLEPLAY: String = "/me grabs [b]his fishing rod[/b]"
const CLIENT_ROLEPLAY: String = "/me waves [color=red]hello[/color]"
func _initialize() -> void:
@ -39,11 +41,34 @@ func _run_host() -> void:
var chat_service := main.get_node(
"%NetworkChatService"
) as NetworkChatService
assert(not chat_service.send_local_message("/me"))
assert(chat_service.send_local_message(PREJOIN_MESSAGE))
var remote_peer_id: int = await _wait_for_remote_peer(session)
assert(remote_peer_id > 1)
await create_timer(1.0).timeout
assert(chat_service.send_local_message(LIVE_MESSAGE))
assert(chat_service.send_local_message(HOST_ROLEPLAY))
var client_roleplay_deadline: int = Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < client_roleplay_deadline
and not _history_contains(chat_service, CLIENT_ROLEPLAY)
):
await process_frame
assert(_history_contains(chat_service, CLIENT_ROLEPLAY))
var client_roleplay: Dictionary = _history_message(
chat_service, CLIENT_ROLEPLAY
)
assert(int(client_roleplay.get("sender_peer_id", 0)) == remote_peer_id)
assert(
str(client_roleplay.get("sender_display_name", ""))
== session.get_peer_record(remote_peer_id).display_name
)
_assert_roleplay_rendered(
main,
str(client_roleplay["sender_display_name"]),
"waves [color=red]hello[/color]",
)
_assert_no_world_speech(main, remote_peer_id)
var disconnect_deadline: int = Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < disconnect_deadline
@ -75,11 +100,39 @@ func _run_client() -> void:
var live_deadline: int = Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < live_deadline
and not _history_contains(chat_service, LIVE_MESSAGE)
and (
not _history_contains(chat_service, LIVE_MESSAGE)
or not _history_contains(chat_service, HOST_ROLEPLAY)
)
):
await process_frame
assert(_history_contains(chat_service, LIVE_MESSAGE))
assert(_history_contains(chat_service, HOST_ROLEPLAY))
assert(not _history_contains(chat_service, PREJOIN_MESSAGE))
var host_roleplay: Dictionary = _history_message(
chat_service, HOST_ROLEPLAY
)
var host_peer_id: int = int(host_roleplay.get("sender_peer_id", 0))
assert(host_peer_id > 0)
assert(
str(host_roleplay.get("sender_display_name", ""))
== session.get_peer_record(host_peer_id).display_name
)
_assert_roleplay_rendered(
main,
str(host_roleplay["sender_display_name"]),
"grabs [b]his fishing rod[/b]",
)
_assert_world_speech_body(main, host_peer_id, LIVE_MESSAGE)
assert(chat_service.send_local_message(CLIENT_ROLEPLAY))
var confirmation_deadline: int = Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < confirmation_deadline
and not _history_contains(chat_service, CLIENT_ROLEPLAY)
):
await process_frame
assert(_history_contains(chat_service, CLIENT_ROLEPLAY))
_assert_no_world_speech(main, session.get_local_peer_id())
print("Chat privacy multiplayer client validation: PASS")
await _session_cleanup(main, session)
@ -91,6 +144,59 @@ func _history_contains(service: NetworkChatService, body: String) -> bool:
)
func _history_message(
service: NetworkChatService,
body: String,
) -> Dictionary:
for message: Dictionary in service.get_history():
if str(message.get("body", "")) == body:
return message
return {}
func _assert_roleplay_rendered(
main: Node,
display_name: String,
action: String,
) -> void:
var chat_ui := main.get_node("%GameUI").get_node("%ChatUI") as ChatUI
var history := chat_ui.get("_history") as RichTextLabel
assert(history != null)
var expected: String = "%s %s" % [display_name, action]
assert(history.get_parsed_text().contains(expected))
assert(not history.get_parsed_text().contains(
"%s: /me" % display_name
))
assert(not history.bbcode_enabled)
var italic_font := history.get_theme_font("italics_font") as FontVariation
assert(italic_font != null)
assert(not is_zero_approx(italic_font.variation_transform.y.x))
func _assert_no_world_speech(main: Node, peer_id: int) -> void:
var chat_ui := main.get_node("%GameUI").get_node("%ChatUI") as ChatUI
var speech: Dictionary = chat_ui.get("_speech")
assert(not speech.has(peer_id))
func _assert_world_speech_body(
main: Node,
peer_id: int,
expected_body: String,
) -> void:
var chat_ui := main.get_node("%GameUI").get_node("%ChatUI") as ChatUI
var speech: Dictionary = chat_ui.get("_speech")
assert(speech.has(peer_id))
var state: Dictionary = speech[peer_id]
var bubble := state.get("bubble") as PanelContainer
assert(bubble != null)
for child: Node in bubble.get_children():
if child is Label:
assert((child as Label).text == expected_body)
return
assert(false, "Speech bubble label is missing.")
func _create_initialized_main() -> Node:
root.size = Vector2i(1280, 720)
var main: Node = MainScene.instantiate()

View file

@ -187,6 +187,19 @@ func _validate_primary_menu_navigation() -> void:
)
for title_control: Control in title_controls:
_assert_directionally_reachable(title_control, title_controls)
var version_row := title.get_node("%VersionRow") as Control
title.call("_on_continue_pressed")
await process_frame
_expect(
not version_row.visible,
"The title version remains visible behind the Save Slots panel.",
)
title.call("_close_save_slots")
await process_frame
_expect(
version_row.visible,
"The title version was not restored after closing Save Slots.",
)
title.queue_free()
await process_frame
@ -221,7 +234,17 @@ func _validate_save_slots_navigation() -> void:
await create_timer(0.25).timeout
var saves_tab := page.get_node("%SavesTab") as Button
var new_tab := page.get_node("%NewSlotTab") as Button
var main_panel := page.get_node("%MainPanel") as PanelContainer
var content_panel := page.get_node("%ContentPanel") as PanelContainer
var back_button := page.get_node("%BackButton") as Button
_expect(
main_panel.size.is_equal_approx(Vector2(880.0, 660.0)),
"Save Slots changed the fixed 880 by 660 outer panel size.",
)
_expect(
main_panel.get_global_rect().encloses(back_button.get_global_rect()),
"The Save Slots Back button extends beyond the outer panel.",
)
var tab_overlap: float = (
saves_tab.get_global_rect().end.y
- content_panel.get_global_rect().position.y
@ -258,6 +281,40 @@ func _validate_save_slots_navigation() -> void:
var duplicate_slot := page.get_node("%DuplicateSlotButton") as Button
var export_slot := page.get_node("%ExportSlotButton") as Button
var delete_slot := page.get_node("%DeleteSlotButton") as Button
var slot_summary := page.get_node("%SlotSummary") as RichTextLabel
slot_summary.text = str(page.call("_format_slot_summary", {
"has_save": true,
"player_level": 100,
"catch_count": 9999,
"discovered_species_count": 999,
"wallet_balance": 999999,
"world_layout": &"generated",
"world_seed": 2147483647,
"last_played_at_unix": 1788282000,
}))
for _frame: int in 2:
await process_frame
var content_rect: Rect2 = content_panel.get_global_rect()
for control: Control in [
import_button,
page.get_node("%SelectedSlotName") as Control,
slot_summary,
page.get_node("%OnlineSlotButton") as Control,
play_slot,
rename_slot,
duplicate_slot,
export_slot,
delete_slot,
]:
_expect(
content_rect.encloses(control.get_global_rect()),
"Save-slot control %s extends beyond the content panel."
% control.name,
)
_expect(
content_rect.end.y <= back_button.get_global_rect().position.y,
"Save-slot content overlaps the Back button.",
)
_expect(
saves_tab.find_valid_focus_neighbor(SIDE_BOTTOM) == import_button,
"An empty Save Slots page does not lead from its tab to Import Save.",
@ -960,6 +1017,18 @@ func _validate_profile_confirmation_focus() -> void:
page.call("set_interactive", true)
page.call("reset_controller_zone")
var name_edit := page.get("_name_edit") as LineEdit
var customize := page.get("_customize_button") as Button
for _frame: int in 2:
await process_frame
_expect(
root.gui_get_focus_owner() == customize,
"Opening Profile activated the player-name field instead of its "
+ "non-text customization action.",
)
_expect(
not name_edit.has_focus(),
"Opening Profile activated the player-name field without user input.",
)
name_edit.grab_focus()
page.call("_on_name_changed", "V")
_expect(

View file

@ -49,12 +49,16 @@ func _run() -> void:
var player := main.get("_player") as Player
var interaction := main.get("_shop_interaction") as FishingShopInteraction
var decor_interaction := main.get(
"_decor_shop_interaction"
) as DecorShopInteraction
var game_ui := main.get("_game_ui") as GameUI
var chat_service := main.get_node(
"%NetworkChatService"
) as NetworkChatService
_expect(player != null, "local player is unavailable")
_expect(interaction != null, "shop interaction is unavailable")
_expect(decor_interaction != null, "decor interaction is unavailable")
_expect(game_ui != null, "game UI is unavailable")
_expect(chat_service != null, "network chat service is unavailable")
if (
@ -70,6 +74,136 @@ func _run() -> void:
interaction.is_local_player_in_range(),
"local player did not enter the shop interaction range",
)
var shop_prompt := game_ui.get_node("%ShopPrompt") as Control
var shop_message := game_ui.get_node("%ShopPromptMessage") as Label
_expect(
not bool(game_ui.get("_shop_npc_dialogue_active"))
and not shop_message.visible,
"shopkeeper spoke before the player initiated conversation",
)
_expect(
shop_prompt.size == Vector2(30.0, 30.0),
"nearby shop prompt was not reduced to the compact interaction glyph",
)
var fixed_area_transform := interaction.global_transform
var shopkeeper := interaction.get_node("../Shopkeeper") as WorldCharacterDisplay
var shopkeeper_start := shopkeeper.position
var camera_yaw := player.get_node("CameraYaw") as Node3D
var camera_pitch := player.get_node("CameraYaw/CameraPitch") as Node3D
var spring_arm := player.get_node(
"CameraYaw/CameraPitch/SpringArm3D"
) as SpringArm3D
var initial_camera_yaw_transform := camera_yaw.transform
var initial_camera_pitch := camera_pitch.rotation
var initial_spring_length := spring_arm.spring_length
# Route planning treats the player as a live obstacle. Move the validation
# avatar clear before forcing an ambient patrol leg.
player.global_position = interaction.global_position + Vector3(20.0, 0.0, 0.0)
interaction.call("_refresh_local_player_proximity")
interaction.call("_choose_roaming_destination")
var roaming_destination: Vector3 = interaction.get(
"_roaming_destination"
)
var safe_route: Dictionary = interaction.call(
"_resolve_safe_route_destination",
roaming_destination,
)
_expect(
not safe_route.is_empty(),
"shopkeeper accepted a destination that failed its terrain route probe",
)
var initial_route_offset := roaming_destination - shopkeeper.position
shopkeeper.rotation.y = (
atan2(initial_route_offset.x, initial_route_offset.z) + PI
)
interaction.call("_process", 0.05)
_expect(
shopkeeper.position.is_equal_approx(shopkeeper_start)
and not shopkeeper.is_locomotion_walking(),
"shopkeeper translated before finishing its turn-in-place",
)
# A patrol leg deliberately starts by turning in place. Advance in small
# slices until the body is aligned and translation begins.
for _turn_frame: int in 20:
interaction.call("_process", 0.05)
if shopkeeper.position.distance_to(shopkeeper_start) > 0.001:
break
_expect(
interaction.global_transform.is_equal_approx(fixed_area_transform),
"ambient shopkeeper roaming moved the authoritative interaction area",
)
_expect(
shopkeeper.position.distance_to(shopkeeper_start) > 0.001
and shopkeeper.is_locomotion_walking(),
"shopkeeper did not walk toward an ambient roaming destination",
)
var roaming_direction := (
shopkeeper.position - shopkeeper_start
).normalized()
var roaming_origin: Vector3 = interaction.get("_roaming_origin")
_expect(
roaming_destination.distance_to(shopkeeper_start)
>= interaction.minimum_route_distance
and roaming_destination.distance_to(shopkeeper_start)
<= interaction.maximum_route_distance + 0.001
and roaming_destination.distance_to(roaming_origin)
<= interaction.roaming_radius + 0.001
and shopkeeper.basis.z.normalized().dot(roaming_direction) > 0.95,
(
"shopkeeper patrol leg was unsafe, too short, or did not face "
+ "its travel direction"
),
)
# Ambient visuals may roam well beyond their original compact trigger, but
# the trigger itself must remain fixed for multiplayer authority. Local UI
# eligibility follows the moving character, while the host validates a
# conservative authored-origin envelope that covers the full patrol.
var captured_roaming_origin: Vector3 = interaction.get(
"_roaming_origin"
)
interaction.roaming_enabled = false
shopkeeper.position = captured_roaming_origin + Vector3(2.6, 0.0, 0.0)
interaction.setup_local_player(player)
_expect(
(interaction.get("_roaming_origin") as Vector3).is_equal_approx(
captured_roaming_origin
),
"rebinding the player recaptured a wandered position as the patrol origin",
)
player.global_position = shopkeeper.global_position + Vector3(1.4, 0.0, 0.0)
interaction.call("_refresh_local_player_proximity")
_expect(
interaction.is_local_player_in_range()
and interaction.is_avatar_in_range(player),
"moving-shopkeeper reach was not covered locally and authoritatively",
)
player.global_position = interaction.call("_get_roaming_origin_global")
interaction.call("_refresh_local_player_proximity")
_expect(
not interaction.is_local_player_in_range()
and interaction.is_avatar_in_range(player),
"local proximity still followed the fixed authored Area3D",
)
var authority_radius := interaction.get_authority_interaction_radius()
player.global_position = (
interaction.call("_get_roaming_origin_global")
+ Vector3(authority_radius + 0.1, 0.0, 0.0)
)
_expect(
not interaction.is_avatar_in_range(player),
"shop authority accepted a player beyond the fixed patrol envelope",
)
shopkeeper.position = shopkeeper_start
interaction.roaming_enabled = true
player.global_position = (
shopkeeper.global_position + Vector3(0.0, 0.0, -1.0)
)
interaction.call("_refresh_local_player_proximity")
_expect(
interaction.global_transform.is_equal_approx(fixed_area_transform),
"proximity validation moved the authoritative interaction area",
)
var character_calls: Array[String] = []
var capture_call := func(
_peer_id: int,
@ -94,12 +228,57 @@ func _run() -> void:
main.call("_unhandled_input", press)
await process_frame
_expect(
game_ui.get_fishing_shop().visible,
"Y did not open the shop while the player was in range",
not game_ui.get_fishing_shop().visible
and main.get("_engaged_shop_npc_interaction") == interaction
and interaction.is_engaged(),
"first Y press did not enter shopkeeper dialogue without opening",
)
_expect(
bool(game_ui.get("_shop_npc_dialogue_active"))
and shop_message.visible
and shop_message.text == FishingShopInteraction.DIALOGUE_TEXT,
"engaged shopkeeper dialogue was not presented",
)
_expect(
not bool(player.call("_is_movement_input_enabled"))
and not bool(player.call("_is_camera_input_enabled"))
and player.is_shop_conversation_camera_active(),
"shopkeeper dialogue did not lock movement and hold its camera frame",
)
_expect(
character_calls.is_empty(),
"Y played a character call instead of opening the shop",
"Y played a character call instead of starting shopkeeper dialogue",
)
await create_timer(0.5).timeout
var player_to_shopkeeper := (
shopkeeper.global_position - player.global_position
)
player_to_shopkeeper.y = 0.0
player_to_shopkeeper = player_to_shopkeeper.normalized()
_expect(
camera_yaw.global_basis.x.normalized().dot(player_to_shopkeeper)
> 0.9
and spring_arm.spring_length < initial_spring_length,
"conversation camera did not frame player-left and NPC-right",
)
_expect(
shopkeeper.global_basis.z.normalized().dot(
(player.global_position - shopkeeper.global_position).normalized()
) > 0.9
and not shopkeeper.is_locomotion_walking(),
"engaged shopkeeper did not stop and face the player",
)
main.call("_input", press)
await process_frame
_expect(
game_ui.get_fishing_shop().visible
and bool(main.get("_shop_conversation_shop_opened")),
"second Y press did not dismiss dialogue and open the shop",
)
_expect(
main.get("_engaged_shop_npc_interaction") == interaction
and not bool(player.call("_is_camera_input_enabled")),
"shop opening released the conversation frame before close",
)
player.global_position = interaction.global_position + Vector3(20.0, 0.0, 0.0)
for _frame: int in 6:
@ -108,6 +287,12 @@ func _run() -> void:
if not game_ui.get_fishing_shop().visible:
break
await process_frame
for _frame: int in 40:
if not player.is_shop_conversation_camera_active():
break
await process_frame
if player.is_shop_conversation_camera_active():
await create_timer(0.55).timeout
_expect(
not interaction.is_local_player_in_range(),
"local player did not leave the shop interaction range",
@ -116,12 +301,219 @@ func _run() -> void:
not game_ui.get_fishing_shop().visible,
"shop did not close after leaving its interaction range",
)
_expect(
main.get("_engaged_shop_npc_interaction") == null
and bool(player.call("_is_movement_input_enabled"))
and bool(player.call("_is_camera_input_enabled")),
(
"shop close did not restore conversation controls and camera "
+ "(engaged=%s movement=%s camera=%s restoring=%s)"
) % [
main.get("_engaged_shop_npc_interaction"),
player.call("_is_movement_input_enabled"),
player.call("_is_camera_input_enabled"),
main.get("_shop_conversation_camera_restoring"),
],
)
_expect(
camera_yaw.transform.is_equal_approx(
initial_camera_yaw_transform
)
and camera_pitch.rotation.is_equal_approx(initial_camera_pitch)
and is_equal_approx(spring_arm.spring_length, initial_spring_length),
"conversation camera did not restore its exact pre-dialogue frame",
)
game_ui.call("_input", press)
await process_frame
_expect(
character_calls.size() == 1,
"Y did not play a character call away from an interaction",
)
# Deliberately overlap two shop margins to validate the transition case.
# Whichever character the player is actually closest to must own both the
# dialogue/prompt and the interaction button, regardless of shop type.
if decor_interaction != null:
var fishing_root := interaction.get_parent() as Node3D
var decor_root := decor_interaction.get_parent() as Node3D
var original_decor_transform := decor_root.global_transform
interaction.roaming_enabled = false
decor_interaction.roaming_enabled = false
(interaction.get_node("../Shopkeeper") as Node3D).position = Vector3.ZERO
(decor_interaction.get_node("../Shopkeeper") as Node3D).position = Vector3.ZERO
decor_root.global_position = (
fishing_root.global_position + Vector3(1.1, 0.0, 0.0)
)
player.global_position = decor_root.global_position
for _frame: int in 6:
await physics_frame
_expect(
interaction.is_local_player_in_range()
and decor_interaction.is_local_player_in_range(),
"overlapping shop transition margins were not established",
)
_expect(
main.call("_get_active_shop_npc_interaction")
== decor_interaction,
"nearest decor NPC did not own the overlapping interaction",
)
_expect(
int(game_ui.get("_shop_npc_source_id"))
== decor_interaction.get_instance_id()
and shop_message.text
== DecorShopInteraction.DECOR_DIALOGUE_TEXT,
"decor dialogue did not follow the active NPC handoff",
)
player.global_position = (
fishing_root.global_position + Vector3(0.57, 0.0, 0.0)
)
for _frame: int in 3:
await physics_frame
_expect(
main.call("_get_active_shop_npc_interaction")
== decor_interaction,
"shop handoff hysteresis did not stabilize a near-tie",
)
player.global_position = (
fishing_root.global_position + Vector3(0.45, 0.0, 0.0)
)
for _frame: int in 3:
await physics_frame
_expect(
main.call("_get_active_shop_npc_interaction")
== interaction,
"shop ownership did not switch after a clear distance lead",
)
_expect(
int(game_ui.get("_shop_npc_source_id"))
== interaction.get_instance_id()
and shop_message.text == FishingShopInteraction.DIALOGUE_TEXT,
"fishing dialogue did not replace the prior NPC cleanly",
)
player.global_position = decor_root.global_position
for _frame: int in 3:
await physics_frame
game_ui.call("_input", press)
main.call("_unhandled_input", press)
await process_frame
_expect(
not game_ui.get_decor_shop().visible
and main.get("_engaged_shop_npc_interaction")
== decor_interaction,
"first Y did not engage the nearest decor NPC in an overlap",
)
main.call("_input", press)
await process_frame
_expect(
game_ui.get_decor_shop().visible
and main.get("_engaged_shop_npc_interaction")
== decor_interaction,
"second Y did not open the engaged decor shop",
)
_expect(
character_calls.size() == 1,
"Y emitted a character call instead of opening the decor shop",
)
game_ui.get_decor_shop().close_shop()
for _frame: int in 40:
if not player.is_shop_conversation_camera_active():
break
await process_frame
if player.is_shop_conversation_camera_active():
await create_timer(0.55).timeout
player.global_position = fishing_root.global_position
for _frame: int in 6:
await physics_frame
_expect(
main.call("_get_active_shop_npc_interaction")
== interaction,
"shop ownership did not hand back to the nearer fishing NPC",
)
decor_root.global_transform = original_decor_transform
# Idle visuals can still begin a frame overlapped (for example after two
# independently planned routes converge). They must separate without
# moving either authoritative Area3D, deadlocking on their reservations,
# or waiting for one NPC to already be in the walking state.
var fishing_visual := interaction.get_node(
"../Shopkeeper"
) as WorldCharacterDisplay
var decor_visual := decor_interaction.get_node(
"../Shopkeeper"
) as WorldCharacterDisplay
var fishing_visual_transform := fishing_visual.global_transform
var decor_visual_transform := decor_visual.global_transform
var fishing_area_transform := interaction.global_transform
var decor_area_transform := decor_interaction.global_transform
player.global_position = fishing_visual.global_position + Vector3(20.0, 0.0, 0.0)
interaction.roaming_enabled = true
decor_interaction.roaming_enabled = true
interaction.call("_schedule_idle")
decor_interaction.call("_schedule_idle")
var crossing_center := fishing_visual.global_position
decor_visual.global_position = (
crossing_center + Vector3(1.0, 0.0, -1.0)
)
decor_interaction.set(
"_roaming_destination",
decor_visual.get_parent_node_3d().to_local(
crossing_center + Vector3(1.0, 0.0, 1.0)
),
)
decor_interaction.set("_roaming_walking", true)
_expect(
bool(interaction.call(
"_route_conflicts_with_reserved_corridors",
crossing_center,
crossing_center + Vector3(2.0, 0.0, 0.0),
)),
"crossing shopkeeper route corridors were not reserved",
)
decor_interaction.call("_schedule_idle")
var fishing_priority: String = interaction.call(
"_traffic_priority_key"
)
var decor_priority: String = decor_interaction.call(
"_traffic_priority_key"
)
var blocked_outward_direction := (
Vector3.RIGHT
if fishing_priority < decor_priority
else Vector3.LEFT
)
fishing_visual.global_position = (
interaction.call("_get_roaming_origin_global")
+ blocked_outward_direction * interaction.roaming_radius
)
decor_visual.global_position = fishing_visual.global_position
var overlapped_position := fishing_visual.global_position
for _separation_frame: int in 80:
interaction.call("_process", 0.05)
decor_interaction.call("_process", 0.05)
var separation := fishing_visual.global_position - decor_visual.global_position
separation.y = 0.0
if separation.length() >= interaction.other_shopkeeper_clearance:
break
var resolved_separation := (
fishing_visual.global_position - decor_visual.global_position
)
resolved_separation.y = 0.0
_expect(
resolved_separation.length()
>= interaction.other_shopkeeper_clearance - 0.02
and fishing_visual.global_position.distance_to(overlapped_position)
> 0.1,
"idle-overlapped shopkeepers did not separate and resume safely",
)
_expect(
interaction.global_transform.is_equal_approx(fishing_area_transform)
and decor_interaction.global_transform.is_equal_approx(
decor_area_transform
),
"live shopkeeper separation moved an authoritative Area3D",
)
fishing_visual.global_transform = fishing_visual_transform
decor_visual.global_transform = decor_visual_transform
chat_service.character_call_received.disconnect(capture_call)
var session := main.get_node("%NetworkSession") as NetworkSession

View file

@ -622,7 +622,6 @@ func _test_fishing_shop_sale_ui(
var game_ui := main.get_node("%GameUI") as GameUI
var shop := game_ui.get_node("%FishingShop") as FishingShop
var player_menu := game_ui.get_node("%PlayerMenu") as PlayerMenu
var shop_backdrop := main.get_node("%ShopBackdrop") as ColorRect
var ui_viewport := main.get_node(
"UIPresentation/UIViewport"
) as SubViewport
@ -630,7 +629,6 @@ func _test_fishing_shop_sale_ui(
interaction != null
and shop != null
and player_menu != null
and shop_backdrop != null
and ui_viewport != null
)
var item_catalog := main.get("item_catalog") as ItemCatalog
@ -701,8 +699,7 @@ func _test_fishing_shop_sale_ui(
assert(shop.open_shop())
for _frame: int in 2:
await process_frame
assert(shop_backdrop.visible)
assert(shop_backdrop.material is ShaderMaterial)
assert(main.get_node_or_null("%ShopBackdrop") == null)
assert(shop.has_node("InputBlocker") and not shop.has_node("Dimmer"))
var shop_panel := shop.get_node("%ShopPanel") as PanelContainer
assert(shop_panel != null and shop_panel.size == Vector2(980.0, 600.0))

View file

@ -204,7 +204,7 @@ func _run() -> void:
var enet_channel_count: int = NetworkProtocol.ENET_CHANNEL_COUNT
var fish_quality_capability: String = NetworkProtocol.FISH_QUALITY_CAPABILITY
var showcase_capability: StringName = NetworkFishShowcaseProtocol.CAPABILITY
assert(protocol_version == 12)
assert(protocol_version == 13)
assert(enet_channel_count == 18)
assert(
fish_quality_capability == "fish_quality_v1"

View file

@ -38,7 +38,7 @@ func _run() -> void:
NetworkProtocol.FISHING_REPLICATION_CAPABILITY
)
var fish_quality_capability: String = NetworkProtocol.FISH_QUALITY_CAPABILITY
assert(protocol_version == 12)
assert(protocol_version == 13)
assert(enet_channel_count == 18)
assert(movement_animation_channel == 11)
assert(

View file

@ -31,6 +31,7 @@ func _run_host() -> void:
"%PlayerSpawnService"
) as PlayerSpawnService
var save_manager := main.get("_save_manager") as PlayerSaveManager
var home_state := main.get_node("%PlayerHomeState") as PlayerHomeState
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
@ -59,6 +60,60 @@ func _run_host() -> void:
await physics_frame
assert(_same_door_position(host_avatar.global_position, host_spawn.origin))
assert(_faces_away_from_home(host_avatar, home_world, host_fingerprint))
var upgrade_result_id := "home-test-upgrade"
network_home.authorize_home_upgrade(
session.get_local_peer_id(),
PlayerHomeState.STARTER_RV_TIER,
upgrade_result_id,
)
assert(home_state.upgrade_to_next_tier())
var rv_deadline: int = Time.get_ticks_msec() + 5000
while (
Time.get_ticks_msec() < rv_deadline
and home_world.get_interior_exit_positions(host_fingerprint).size() < 2
):
await process_frame
var rv_exits: Array[Vector3] = home_world.get_interior_exit_positions(
host_fingerprint
)
assert(rv_exits.size() == 2)
network_home.finalize_home_upgrade(
session.get_local_peer_id(), upgrade_result_id, true
)
assert(rv_exits[0].x < rv_exits[1].x)
for exit_position: Vector3 in rv_exits:
host_avatar.global_position = (
exit_position
+ home_world.get_interior_exit_direction_near(
host_fingerprint, exit_position
) * 0.05
)
assert(home_world.has_avatar_crossed_interior_exit_threshold(
host_avatar, host_fingerprint
))
host_avatar.global_position = rv_exits[0] + Vector3(0.0, 0.0, 1.1)
assert(not home_world.has_avatar_crossed_interior_exit_threshold(
host_avatar, host_fingerprint
))
var presentations: Dictionary = home_world.get(
"_presentations_by_fingerprint"
)
var host_presentation: Dictionary = presentations.get(host_fingerprint, {})
var host_interior := host_presentation.get("interior") as Node3D
assert(host_interior != null)
var wall_collision := host_interior.find_child(
"WallWestCollision", true, false
) as StaticBody3D
assert(wall_collision != null)
assert(bool(wall_collision.get_meta(
HomeWorldService.INTERIOR_SHELL_COLLISION_META, false
)))
var wall_shape := wall_collision.get_child(0) as CollisionShape3D
assert(wall_shape != null)
assert((wall_shape.shape as ConcavePolygonShape3D).backface_collision)
host_avatar.global_position = host_spawn.origin
assert(network_home.request_enter(host_fingerprint))
await physics_frame
var remote_peer_id: int = await _wait_for_remote_peer(session)
assert(remote_peer_id > 1)
@ -66,6 +121,53 @@ func _run_host() -> void:
var remote_fingerprint: String = remote_record.identity_fingerprint
assert(await _wait_for_home(network_home, remote_fingerprint))
var remote_avatar: Player = spawn_service.get_avatar(remote_peer_id)
assert(not remote_avatar.is_remote_presentation_visible())
var chat_ui := main.get_node("%GameUI").get_node("%ChatUI") as ChatUI
chat_ui.call(
"_show_speech_bubble", remote_peer_id, "outside", remote_fingerprint
)
var speech_states: Dictionary = chat_ui.get("_speech")
assert(not speech_states.has(remote_peer_id))
var toolbar := main.get_node("%GameUI").get_node(
"%HomeDecorToolbar"
) as HomeDecorToolbar
assert(toolbar.visible)
var exit_button := toolbar.find_child(
"ExitRVButton", true, false
) as Button
assert(exit_button != null and exit_button.text == "Exit RV")
var storage := main.get_node("%GameUI").get_node(
"%PlayerStorage"
) as PlayerStorage
assert(storage.open_storage_from_home(true))
var close_storage_event := InputEventAction.new()
close_storage_event.action = &"interact"
close_storage_event.pressed = true
storage._unhandled_input(close_storage_event)
assert(not storage.visible)
exit_button.pressed.emit()
var exit_deadline: int = Time.get_ticks_msec() + 5000
while (
Time.get_ticks_msec() < exit_deadline
and not network_home.get_local_home_owner_fingerprint().is_empty()
):
await process_frame
assert(network_home.get_local_home_owner_fingerprint().is_empty())
var visibility_deadline: int = Time.get_ticks_msec() + 1000
while (
Time.get_ticks_msec() < visibility_deadline
and is_instance_valid(remote_avatar)
and not remote_avatar.is_remote_presentation_visible()
):
await process_frame
assert(is_instance_valid(remote_avatar))
assert(remote_avatar.is_remote_presentation_visible())
chat_ui.call(
"_show_speech_bubble", remote_peer_id, "inside", remote_fingerprint
)
speech_states = chat_ui.get("_speech")
assert(speech_states.has(remote_peer_id))
chat_ui.call("_on_peer_removed", remote_peer_id)
var remote_spawn: Transform3D = home_world.get_exterior_spawn_transform(
remote_fingerprint
)
@ -124,6 +226,10 @@ func _run_client() -> void:
player.global_position, exterior_spawn.origin
))
assert(_faces_away_from_home(player, home_world, fingerprint))
# Keep the peer present long enough for the host to rebuild the upgraded RV,
# exercise late-spawn visibility, and use the toolbar transition back to the
# shared world. A two-second grace period raced the host on slower machines.
await create_timer(10.0).timeout
print("Home spawn multiplayer client validation: PASS")
await _cleanup(main, session)

View file

@ -105,21 +105,7 @@ func _validate_main_profile(main: Node) -> void:
assert(not bool(title_material.get_shader_parameter(
"animation_enabled"
)))
for backdrop_name: StringName in [
&"PlayerMenuBackdrop",
&"ShopBackdrop",
]:
var backdrop := main.get_node("%%%s" % backdrop_name) as ColorRect
var backdrop_material := backdrop.material as ShaderMaterial
assert(backdrop_material != null)
assert(not bool(backdrop_material.get_shader_parameter(
"animation_enabled"
)))
assert(
backdrop_material.get_shader_parameter(
"scroll_velocity_pixels"
) == Vector2.ZERO
)
_validate_no_full_screen_menu_backdrops(main)
var game_ui := main.get_node("%GameUI") as GameUI
var title_screen := game_ui.get_title_screen()
assert(title_screen != null)
@ -166,21 +152,7 @@ func _validate_normal_profile(main: Node) -> void:
var title_material := title_background.material as ShaderMaterial
assert(title_material != null)
assert(bool(title_material.get_shader_parameter("animation_enabled")))
for backdrop_name: StringName in [
&"PlayerMenuBackdrop",
&"ShopBackdrop",
]:
var backdrop := main.get_node("%%%s" % backdrop_name) as ColorRect
var backdrop_material := backdrop.material as ShaderMaterial
assert(backdrop_material != null)
assert(bool(backdrop_material.get_shader_parameter(
"animation_enabled"
)))
assert(
backdrop_material.get_shader_parameter(
"scroll_velocity_pixels"
) != Vector2.ZERO
)
_validate_no_full_screen_menu_backdrops(main)
var visuals: WorldTimeVisualController = main.get_node(
"%WorldTimeVisualController"
)
@ -216,6 +188,11 @@ func _validate_new_game_music_transition(main: Node) -> void:
assert(title_music.playing)
func _validate_no_full_screen_menu_backdrops(main: Node) -> void:
assert(main.get_node_or_null("%PlayerMenuBackdrop") == null)
assert(main.get_node_or_null("%ShopBackdrop") == null)
func _first_fresh_water_visual(main: Node) -> MeshInstance3D:
var fresh_water_root := main.get_node(
"TestWorld/Regions/GeneratedWorldRegion/WaterBodies/FreshWaterBodies"

View file

@ -35,7 +35,6 @@ func _run() -> void:
var game_ui := main.get_node("%GameUI") as GameUI
var player_menu := game_ui.get_node("%PlayerMenu") as PlayerMenu
var logbook := player_menu.get_node("%CatalogLogbook") as LogbookPage
var backdrop := main.get_node("%PlayerMenuBackdrop") as ColorRect
var hotbar := game_ui.get_node("%Hotbar") as Control
var fishing_spot := main.get("_fishing_spot") as FishingSpot
assert(logbook.get("_fishing_spot") == fishing_spot)
@ -49,7 +48,7 @@ func _run() -> void:
await create_timer(MENU_TRANSITION_SETTLE_SECONDS).timeout
assert(player_menu.visible)
assert(logbook.visible)
assert(backdrop.visible)
assert(main.get_node_or_null("%PlayerMenuBackdrop") == null)
assert(not bool(game_ui.get("_player_menu_hotbar_visible")))
assert(not hotbar.visible)

View file

@ -195,11 +195,56 @@ func _validate_compact_animation_encoding() -> void:
func _validate_airborne_sitting(avatar: Player) -> void:
avatar.reset_network_movement_state()
avatar.set_local_control(true)
avatar.set("_sit_after_landing", true)
avatar.global_position = Vector3(0.0, 2.0, 0.0)
avatar.velocity = Vector3(0.0, -1.0, 0.0)
avatar.toggle_sitting()
assert(avatar.get_network_sitting_intent())
assert(not avatar.get_network_sitting_state())
assert(bool(avatar.get("_sitting_intent_pending")))
assert(not bool(avatar.make_network_snapshot(2)["sitting"]))
assert(bool(avatar.capture_network_input(1)["sitting"]))
# Reproduce the high-RTT owner audit race: the host has reached the ground
# and reports the deferred sit while this client's predicted capsule is still
# airborne. Repeated seated snapshots must not zero its falling velocity.
var delayed_sit_snapshot: Dictionary = _network_snapshot(
avatar.global_position,
Vector3.ZERO,
1,
)
delayed_sit_snapshot["sitting"] = true
avatar.apply_local_prediction_correction(
delayed_sit_snapshot,
1,
1.0 / 30.0,
0.3,
0.1,
)
assert(not avatar.is_sitting())
assert(bool(avatar.get("_sit_after_landing")))
assert(avatar.velocity.y < 0.0)
var airborne_height: float = avatar.global_position.y
avatar.call("_simulate_movement_physics", 0.1)
assert(avatar.global_position.y < airborne_height)
assert(avatar.velocity.y < -1.0)
for _delayed_audit: int in 3:
delayed_sit_snapshot["position"] = [
avatar.global_position.x,
avatar.global_position.y,
avatar.global_position.z,
]
var falling_velocity: float = avatar.velocity.y
avatar.apply_local_prediction_correction(
delayed_sit_snapshot,
1,
1.0 / 30.0,
0.3,
0.1,
)
assert(not avatar.is_sitting())
assert(is_equal_approx(avatar.velocity.y, falling_velocity))
avatar.call("_simulate_movement_physics", 0.1)
assert(avatar.global_position.y < airborne_height)
avatar.call("_queue_local_network_jump_intent")
var jumping_input: Dictionary = avatar.capture_network_input(2)
assert(bool(jumping_input["jump"]))

View file

@ -10,6 +10,7 @@ func _initialize() -> void:
func _validate_protocol() -> void:
_validate_type_and_empty_handling()
_validate_message_cleanup()
_validate_roleplay_commands()
_validate_protocol_limits()
print("Network chat protocol validation: PASS")
quit()
@ -34,6 +35,28 @@ func _validate_message_cleanup() -> void:
)
func _validate_roleplay_commands() -> void:
assert(NetworkChatProtocol.is_roleplay_body("/me waves"))
assert(NetworkChatProtocol.is_roleplay_body("/ME waves"))
assert(NetworkChatProtocol.is_roleplay_body("/me"))
assert(not NetworkChatProtocol.is_roleplay_body("/meow"))
assert(NetworkChatProtocol.roleplay_action("/me waves") == "waves")
assert(NetworkChatProtocol.roleplay_action(
"/me [color=red]waves[/color]"
) == "[color=red]waves[/color]")
assert(NetworkChatProtocol.valid_user_body("/me waves"))
assert(not NetworkChatProtocol.valid_user_body("/me"))
assert(not NetworkChatProtocol.valid_user_body("/me "))
assert(NetworkChatProtocol.valid_user_body("/meow"))
var signed_fields: Array = NetworkChatProtocol.signature_fields({
"session_id": "session",
"request_id": "request",
"sender_fingerprint": "fingerprint",
"body": "/me waves",
})
assert(signed_fields.back() == "/me waves")
func _validate_protocol_limits() -> void:
var maximum_body: String = "a".repeat(
NetworkChatProtocol.MAX_VISIBLE_CHARACTERS

View file

@ -48,6 +48,8 @@ func _run_host() -> void:
var profile := main.get_node(
"%NetworkProfilePreferences"
) as NetworkProfilePreferences
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(save_manager.initialize_new_game())
assert(profile.set_profile_identity(
profile.display_name,
"deep",
@ -55,7 +57,6 @@ func _run_host() -> void:
profile.call_id,
"kim",
))
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
@ -67,7 +68,6 @@ func _run_host() -> void:
assert(local_avatar != null)
assert(local_avatar.get_animalese_voice_id() == "deep")
assert(local_avatar.get_animalese_sample_set_id() == "kim")
assert(save_manager.initialize_new_game())
main.call("_enter_gameplay")
for _frame: int in 4:
await physics_frame
@ -99,6 +99,7 @@ func _run_host() -> void:
await _wait_for_remote_appearance(session, remote_peer_id, changed)
await _wait_for_remote_appearance(session, remote_peer_id, original)
await _wait_for_remote_appearance(session, remote_peer_id, changed)
await _wait_for_remote_title(session, remote_peer_id, "trail guide")
var avatar := main.get_node("%PlayerSpawnService").get_avatar(
remote_peer_id
) as Player
@ -187,6 +188,7 @@ func _run_client() -> void:
service.get_persisted_speech_speed_id(),
service.get_persisted_call_id(),
"kim",
"trail guide",
))
var apply_deadline: int = Time.get_ticks_msec() + 8000
while Time.get_ticks_msec() < apply_deadline and apply_results.is_empty():
@ -196,6 +198,11 @@ func _run_client() -> void:
assert(service.get_persisted_appearance() == changed)
assert(service.get_persisted_voice_id() == "deep")
assert(service.get_persisted_sample_set_id() == "kim")
assert(service.get_persisted_title() == "trail guide")
assert(
session.get_peer_record(session.get_local_peer_id()).title
== "trail guide"
)
assert(Dictionary(session.get("_local_appearance_snapshot")) == changed)
var player := main.get_node("%PlayerSpawnService").get_local_player() as Player
assert(player.get_animalese_voice_id() == "deep")
@ -278,6 +285,20 @@ func _wait_for_remote_appearance(
assert(false, "Timed out waiting for a remote appearance update.")
func _wait_for_remote_title(
session: NetworkSession,
peer_id: int,
expected: String,
) -> void:
var deadline: int = Time.get_ticks_msec() + 8000
while Time.get_ticks_msec() < deadline:
await process_frame
var record := session.get_peer_record(peer_id)
if record != null and record.title == expected:
return
assert(false, "Timed out waiting for a remote player title update.")
func _create_initialized_main() -> Node:
root.size = Vector2i(1280, 720)
var main: Node = MainScene.instantiate()

View file

@ -0,0 +1,151 @@
extends SceneTree
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
_validate_profile_defaults_and_legacy_data()
_validate_network_payloads()
_validate_peer_registry_title()
await _validate_profile_layout()
print("Profile title validation: PASS")
quit(0)
func _validate_profile_defaults_and_legacy_data() -> void:
var preferences := NetworkProfilePreferences.new()
assert(preferences.display_name == "stray")
assert(preferences.title == "newbie")
assert(NetworkProfilePreferences.is_valid_title("trail guide"))
assert(not NetworkProfilePreferences.is_valid_title(""))
assert(not NetworkProfilePreferences.is_valid_title("x".repeat(
NetworkProtocol.MAX_PLAYER_TITLE_LENGTH + 1
)))
preferences.profile_id = "profile-id"
preferences.created_at_unix = 1
var save_data := preferences.to_save_data()
assert(str(save_data.get("title", "")) == "newbie")
assert(NetworkProfilePreferences.validate_save_data(save_data))
# Title was added without invalidating existing per-save character records.
var legacy_save := save_data.duplicate(true)
legacy_save.erase("title")
assert(NetworkProfilePreferences.validate_save_data(legacy_save))
legacy_save["title"] = 7
assert(not NetworkProfilePreferences.validate_save_data(legacy_save))
preferences.free()
func _validate_network_payloads() -> void:
var appearance := CharacterCustomizationCatalog.default_snapshot()
var fingerprint := "a".repeat(64)
var hello := NetworkProtocol.make_client_hello(
"profile-id",
"stray",
"client-nonce",
appearance,
fingerprint,
PackedByteArray(),
"trail guide",
)
assert(NetworkProtocol.validate_client_hello(hello).is_empty())
assert(str(hello.get("player_title", "")) == "trail guide")
var invalid_hello := hello.duplicate(true)
invalid_hello["player_title"] = ""
assert(not NetworkProtocol.validate_client_hello(invalid_hello).is_empty())
var check_request := {
"request_id": "check",
"session_id": "session",
"display_name": "stray",
}
assert(NetworkProfileProtocol.valid_check_request(check_request))
var apply_request := check_request.duplicate(true)
apply_request.merge({
"player_title": "trail guide",
"appearance": appearance,
"use_anyway": false,
"sender_fingerprint": fingerprint,
"sender_signature": PackedByteArray(),
})
assert(NetworkProfileProtocol.valid_apply_request(apply_request))
var signed_fields := NetworkProfileProtocol.signature_fields(apply_request)
assert(signed_fields.has("trail guide"))
apply_request.erase("player_title")
assert(not NetworkProfileProtocol.valid_apply_request(apply_request))
func _validate_peer_registry_title() -> void:
var registry := PeerRegistry.new()
assert(registry.add_peer(
2,
"profile-id",
"stray",
NetworkProtocol.PROTOCOL_VERSION,
"",
"",
PackedStringArray(),
"trail guide",
))
assert(registry.get_peer(2).title == "trail guide")
assert(registry.update_title(2, "river scout"))
assert(registry.get_peer(2).title == "river scout")
assert(not registry.update_title(2, ""))
assert(not registry.add_peer(
3,
"invalid-profile",
"stray",
NetworkProtocol.PROTOCOL_VERSION,
"",
"",
PackedStringArray(),
"",
))
func _validate_profile_layout() -> void:
root.size = Vector2i(1280, 720)
var page := ProfilePage.new()
page.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(page)
await process_frame
await process_frame
var name_label := page.get("_name_label") as Label
var name_edit := page.get("_name_edit") as LineEdit
var title_edit := page.get("_title_edit") as LineEdit
var identity := page.find_child("IdentityFingerprint", true, false) as Label
var identity_stack := page.find_child(
"ProfileIdentityStack", true, false
) as VBoxContainer
var experience_row := page.find_child(
"ProfileExperienceRow", true, false
) as HBoxContainer
assert(name_label != null and name_edit != null and title_edit != null)
assert(name_label.text == "name")
assert(identity != null and identity_stack != null and experience_row != null)
assert(name_edit.placeholder_text == "stray")
assert(title_edit.placeholder_text == "newbie")
assert(title_edit.global_position.y > name_edit.global_position.y)
assert(is_equal_approx(title_edit.position.x, name_edit.position.x))
assert(identity.get_parent() == identity_stack)
assert(experience_row.get_parent() == identity_stack)
assert(identity.horizontal_alignment == HORIZONTAL_ALIGNMENT_RIGHT)
assert(identity_stack.global_position.x > name_edit.global_position.x)
assert(name_label.tooltip_text.contains(
"Shown to other players in multiplayer."
))
page.call("_set_name_status", "Name is available in this game.")
assert(name_label.tooltip_text.contains(
"Name is available in this game."
))
for visible_label: Label in page.find_children("*", "Label", true, false):
if visible_label.visible:
assert(visible_label.text != "Shown to other players in multiplayer.")
assert(visible_label.text != "Name is available in this game.")
var account_controls: Array = page.call("_account_controller_controls")
assert(name_edit in account_controls)
assert(title_edit in account_controls)
page.queue_free()
for _frame: int in 4:
await process_frame

View file

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

View file

@ -23,14 +23,35 @@ func _run() -> void:
var player := main.get("_player") as Player
var data_root := main.get("_data_root") as PlayerDataRoot
var home_state := main.get("_home_state") as PlayerHomeState
var network_profile := main.get_node(
"%NetworkProfilePreferences"
) as NetworkProfilePreferences
var appearance_store := main.get_node(
"%PlayerAppearanceStore"
) as PlayerAppearanceStore
assert(
save_manager != null
and player != null
and data_root != null
and home_state != null
and network_profile != null
and appearance_store != null
)
assert(save_manager.initialize_new_game(864209))
save_manager.set_autosave_enabled(true)
var first_profile_id: String = network_profile.profile_id
var first_appearance: Dictionary = appearance_store.get_snapshot()
first_appearance["scale"] = 0.8
assert(CharacterCustomizationCatalog.validate_snapshot(first_appearance))
assert(network_profile.set_profile_identity(
"first save character",
"deep",
network_profile.speech_speed_id,
network_profile.call_id,
network_profile.sample_set_id,
"trailblazer",
))
assert(appearance_store.save_snapshot(first_appearance))
assert(player.wallet.restore_balance(4321))
assert(player.bag.add_item(&"coffee"))
assert(home_state.add_owned_decor(&"basic_cube"))
@ -52,6 +73,39 @@ func _run() -> void:
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path)
assert(bool(decoded.get("ok", false)))
assert(int((decoded["data"] as Dictionary)["save_version"]) == 11)
var saved_character: Dictionary = (
(decoded["data"] as Dictionary).get("character", {})
)
assert(NetworkProfilePreferences.validate_save_data(
(saved_character.get("profile", {}) as Dictionary)
))
assert(str((saved_character["profile"] as Dictionary)["profile_id"])
== first_profile_id)
assert(str((saved_character["profile"] as Dictionary)["display_name"])
== "first save character")
assert(str((saved_character["profile"] as Dictionary)["title"])
== "trailblazer")
assert(
(saved_character["appearance"] as Dictionary)
== first_appearance
)
# Starting another save must feel like a new character, then loading this
# archive must restore only this slot's name, voice, and appearance.
assert(save_manager.initialize_new_game(97531))
assert(network_profile.profile_id != first_profile_id)
assert(network_profile.display_name == "stray")
assert(network_profile.title == "newbie")
assert(network_profile.voice_id != "deep")
assert(
appearance_store.get_snapshot()
== CharacterCustomizationCatalog.default_snapshot()
)
assert(save_manager.load_player_data())
assert(network_profile.profile_id == first_profile_id)
assert(network_profile.display_name == "first save character")
assert(network_profile.title == "trailblazer")
assert(network_profile.voice_id == "deep")
assert(appearance_store.get_snapshot() == first_appearance)
assert(not PlayerHomeState.sanitize_save_data(
(decoded["data"] as Dictionary).get("home", {})
).is_empty())
@ -122,6 +176,8 @@ func _run() -> void:
var adopted_slot_id: String = str(adopted_slots.front().get("slot_id", ""))
assert(bool(adopted_slots.front().get("legacy", false)))
assert(bool(adopted_slots.front().get("has_save", false)))
assert(save_slots.get_online_slot_id() == adopted_slot_id)
assert(bool(adopted_slots.front().get("online", false)))
var duplicated: Dictionary = save_slots.duplicate_slot(
adopted_slot_id,
@ -135,6 +191,9 @@ func _run() -> void:
assert(int(duplicate_summary.get("wallet_balance", -1)) == 4321)
assert(int(duplicate_summary.get("world_seed", -1)) == 97531)
assert(save_slots.activate_slot(duplicated_slot_id))
assert(save_slots.mark_slot_for_online(duplicated_slot_id))
assert(save_slots.get_online_slot_id() == duplicated_slot_id)
assert(bool(save_slots.get_slot(duplicated_slot_id).get("online", false)))
assert(save_manager.load_player_data())
assert(player.wallet.get_balance() == 4321)
assert(save_manager.get_world_seed() == 97531)
@ -157,6 +216,7 @@ func _run() -> void:
assert(save_slots.delete_slot(duplicated_slot_id))
assert(save_slots.get_slot(duplicated_slot_id).is_empty())
assert(save_slots.list_slots().size() == 2)
assert(save_slots.get_online_slot_id() == save_slots.get_active_slot_id())
var settings_panels: Array[Node] = main.find_children(
"*", "SettingsPanel", true, false

View file

@ -0,0 +1,166 @@
extends SceneTree
const MainScene: PackedScene = preload("res://main/main.tscn")
const WAIT_MILLISECONDS: int = 20_000
var _conflicts: Array[Dictionary] = []
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
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")))
var data_root := main.get("_data_root") as PlayerDataRoot
var save_manager := main.get("_save_manager") as PlayerSaveManager
var save_slots := main.get("_save_slots") as PlayerSaveSlotCatalog
var player := main.get("_player") as Player
var session := main.get_node("%NetworkSession") as NetworkSession
var network_profile := main.get_node(
"%NetworkProfilePreferences"
) as NetworkProfilePreferences
var appearance_store := main.get_node(
"%PlayerAppearanceStore"
) as PlayerAppearanceStore
assert(
data_root != null
and save_manager != null
and save_slots != null
and player != null
and session != null
and network_profile != null
and appearance_store != null
)
var conflict_dialog_callback := Callable(main, "_on_portable_conflict")
if data_root.conflict_detected.is_connected(conflict_dialog_callback):
data_root.conflict_detected.disconnect(conflict_dialog_callback)
data_root.conflict_detected.connect(_on_conflict_detected)
if OS.get_environment(
PlayerDataRoot.ISOLATED_VALIDATION_ENVIRONMENT
) == "1":
assert(
PlayerDataRoot.ISOLATED_VALIDATION_DIRECTORY
in data_root.root_path
)
main.call(
"_on_new_slot_requested",
"reload regression",
WorldLayout.STARTER_ISLAND,
424242,
"",
)
assert(await _wait_for_gameplay(main, true))
var slot_id: String = save_slots.get_active_slot_id()
assert(not slot_id.is_empty())
assert(player.wallet.restore_balance(321))
assert(network_profile.set_profile_identity(
"reload stray",
"deep",
network_profile.speech_speed_id,
network_profile.call_id,
network_profile.sample_set_id,
"returner",
))
var custom_appearance: Dictionary = appearance_store.get_snapshot()
custom_appearance["scale"] = 0.8
assert(appearance_store.save_snapshot(custom_appearance))
main.call("_on_return_to_title_requested")
assert(await _wait_for_gameplay(main, false))
assert(not save_manager.is_dirty())
# Establish the exact reported lifecycle once: load a local slot, return to
# title, then immediately select that same slot again in this process.
main.call("_on_slot_play_requested", slot_id)
assert(await _wait_for_gameplay(main, true))
main.call("_on_return_to_title_requested")
assert(await _wait_for_gameplay(main, false))
assert(not save_manager.is_dirty())
# A selected save owns its character state. An out-of-band edit to the
# compatibility profile mirror must not make that valid save unloadable or
# be overwritten merely by loading it. A later explicit profile edit must
# still detect the external mutation.
var profile_path: String = data_root.path_for(&"network_profile")
var external_profile_bytes := PortableFileGuard.read_bytes(profile_path)
assert(not external_profile_bytes.is_empty())
external_profile_bytes.append(0x0a)
assert(_write_external_bytes(profile_path, external_profile_bytes))
_conflicts.clear()
main.call("_on_slot_play_requested", slot_id)
assert(await _wait_for_gameplay(main, true))
assert(
_conflicts.is_empty(),
"same-process same-slot reload reported false conflicts: %s"
% [_conflicts],
)
assert(player.wallet.get_balance() == 321)
assert(network_profile.display_name == "reload stray")
assert(network_profile.title == "returner")
assert(appearance_store.get_snapshot() == custom_appearance)
assert(
PortableFileGuard.read_bytes(profile_path)
== external_profile_bytes
)
_conflicts.clear()
assert(not network_profile.set_display_name("explicit overwrite"))
assert(_conflicts.size() == 1)
assert(FileAccess.file_exists(str(_conflicts.front().get("path", ""))))
# A real out-of-band edit must still be caught. Mutating the canonical bytes
# after load simulates a Syncthing/device replacement while this process is
# holding an older expected hash.
var save_path: String = str(save_manager.get("_save_path"))
var external_bytes := PortableFileGuard.read_bytes(save_path)
assert(not external_bytes.is_empty())
external_bytes.append(0x7f)
assert(_write_external_bytes(save_path, external_bytes))
assert(player.wallet.restore_balance(654))
_conflicts.clear()
assert(not save_manager.save_now())
assert(_conflicts.size() == 1)
assert(FileAccess.file_exists(str(_conflicts.front().get("path", ""))))
save_manager.set_autosave_enabled(false)
save_manager.cancel_pending_autosave()
session.disconnect_session("Save reload conflict validation complete.")
main.queue_free()
for _frame: int in 4:
await process_frame
print("Save reload conflict validation: PASS")
quit(0)
func _wait_for_gameplay(main: Node, expected: bool) -> bool:
var deadline: int = Time.get_ticks_msec() + WAIT_MILLISECONDS
while Time.get_ticks_msec() < deadline:
if bool(main.get("_gameplay_started")) == expected:
return true
await process_frame
return false
func _on_conflict_detected(message: String, path: String) -> void:
_conflicts.append({"message": message, "path": path})
func _write_external_bytes(path: String, bytes: PackedByteArray) -> bool:
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return false
file.store_buffer(bytes)
file.flush()
var succeeded: bool = file.get_error() == OK
file.close()
return succeeded

View file

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

View file

@ -123,6 +123,32 @@ func _run_client() -> void:
)
await _wait_for_join(main, session, SECOND_HOST_PORT)
assert(bool(main.get("_gameplay_started")))
var save_slots := main.get("_save_slots") as PlayerSaveSlotCatalog
var save_manager := main.get("_save_manager") as PlayerSaveManager
var local_slot_id: String = save_slots.get_active_slot_id()
assert(not local_slot_id.is_empty())
main.call("_on_return_to_title_requested")
var title_deadline: int = Time.get_ticks_msec() + WAIT_SECONDS * 1000
while (
Time.get_ticks_msec() < title_deadline
and session.state != NetworkSession.State.INACTIVE
):
await process_frame
assert(session.state == NetworkSession.State.INACTIVE)
assert(not bool(main.get("_gameplay_started")))
assert(not save_manager.is_dirty())
main.call("_on_slot_play_requested", local_slot_id)
var local_deadline: int = Time.get_ticks_msec() + WAIT_SECONDS * 1000
while (
Time.get_ticks_msec() < local_deadline
and (
not session.is_host()
or not bool(main.get("_gameplay_started"))
)
):
await process_frame
assert(session.is_host())
assert(bool(main.get("_gameplay_started")))
print("Session switch multiplayer client validation: PASS")
session.disconnect_session("")
main.queue_free()

View file

@ -0,0 +1,43 @@
extends SceneTree
const MainScene: PackedScene = preload("res://main/main.tscn")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
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")))
var game_ui: GameUI = main.get("_game_ui") as GameUI
var player_menu: PlayerMenu = game_ui.get_node("%PlayerMenu") as PlayerMenu
assert(game_ui != null and player_menu != null)
game_ui.get_title_screen().hide()
main.set("_gameplay_started", true)
player_menu.show()
player_menu.set("_transitioning", false)
# Input.action_press updates global action state without delivering an
# InputEvent to Main._input. That reproduces the path used when a native
# tooltip window consumes Escape before the gameplay viewport receives it.
Input.action_press("ui_cancel")
await process_frame
Input.action_release("ui_cancel")
for _frame: int in 40:
await process_frame
assert(not player_menu.visible)
main.queue_free()
await process_frame
print("Tooltip Escape validation: PASS")
quit(0)

View file

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

View file

@ -39,6 +39,15 @@ func _run() -> void:
var canonical_stage := game_ui.get_node(
"UIRoot/CanonicalStage"
) as Control
var gameplay_screen_effects := game_ui.get_node(
"UIRoot/GameplayScreenEffects"
) as Control
var fishing_danger_vignette := game_ui.get_node(
"UIRoot/GameplayScreenEffects/FishingDangerVignette"
) as ColorRect
var gameplay_transient_hud := game_ui.get_node(
"UIRoot/CanonicalStage/GameplayTransientHUD"
) as Control
var chat_ui := game_ui.get_node("UIRoot/ChatUI") as ChatUI
var player_menu := game_ui.get_node(
"UIRoot/CanonicalStage/PlayerMenu"
@ -49,9 +58,15 @@ func _run() -> void:
var hotbar := game_ui.get_node(
"UIRoot/CanonicalStage/Hotbar"
) as HotbarUI
var home_decor_toolbar := game_ui.get_node(
"UIRoot/CanonicalStage/HomeDecorToolbar"
) as HomeDecorToolbar
assert(game_ui.get_node_or_null(
"UIRoot/CanonicalStage/GameplayTransientHUD/EffectStatus"
) == null)
assert(game_ui.get_node_or_null(
"UIRoot/CanonicalStage/GameplayTransientHUD/FishingDangerVignette"
) == null)
var screen_fade := game_ui.get_node("UIRoot/ScreenFade") as Control
var title_screen := game_ui.get_node("UIRoot/TitleScreen") as TitleScreen
var title_content_stage := title_screen.get_node(
@ -71,15 +86,63 @@ func _run() -> void:
root.add_child(presenter)
await process_frame
chat_ui.set_available(true)
var nameplate_layer := chat_ui.get("_nameplate_layer") as Control
var speech_layer := chat_ui.get("_speech_layer") as Control
assert(nameplate_layer != null and nameplate_layer.visible)
assert(speech_layer != null and speech_layer.visible)
home_decor_toolbar.set_active(true)
assert(home_decor_toolbar.visible)
game_ui.call("_on_player_menu_visibility_changed", true)
assert(not chat_ui.is_world_speech_visible())
assert(not nameplate_layer.visible)
assert(not speech_layer.visible)
assert(not home_decor_toolbar.visible)
game_ui.call("_on_player_menu_visibility_changed", false)
assert(chat_ui.is_world_speech_visible())
assert(not nameplate_layer.visible)
assert(speech_layer.visible)
chat_ui.set_available(true)
assert(nameplate_layer.visible)
chat_ui.set_available(false)
assert(not nameplate_layer.visible)
assert(speech_layer.visible)
chat_ui.set_available(true)
assert(nameplate_layer.visible)
chat_ui.set_hud_hidden(true)
assert(not nameplate_layer.visible)
assert(speech_layer.visible)
chat_ui.set_hud_hidden(false)
assert(nameplate_layer.visible)
chat_ui.set_world_nameplates_hud_hidden(true)
assert(not chat_ui.is_hud_hidden())
assert(not nameplate_layer.visible)
assert(speech_layer.visible)
chat_ui.set_world_nameplates_hud_hidden(false)
assert(nameplate_layer.visible)
assert(home_decor_toolbar.visible)
game_ui.set("_storage_open", true)
game_ui.call("_emit_interactive_pointer_ui_changed")
assert(not home_decor_toolbar.visible)
game_ui.call("_on_player_menu_visibility_changed", true)
game_ui.set("_storage_open", false)
game_ui.call("_emit_interactive_pointer_ui_changed")
assert(not home_decor_toolbar.visible)
game_ui.call("_on_player_menu_visibility_changed", false)
assert(home_decor_toolbar.visible)
home_decor_toolbar.set_active(false)
game_ui.set("_gameplay_ui_enabled", true)
game_ui.set("_gameplay_hud_hidden", false)
game_ui.call("_refresh_gameplay_hud_visibility")
assert(gameplay_screen_effects.visible)
assert(gameplay_transient_hud.visible)
game_ui.set("_system_menu_open", true)
game_ui.call("_refresh_gameplay_hud_visibility")
assert(not gameplay_screen_effects.visible)
assert(not gameplay_transient_hud.visible)
game_ui.set("_system_menu_open", false)
game_ui.set("_gameplay_ui_enabled", false)
game_ui.call("_refresh_gameplay_hud_visibility")
chat_ui.set_available(true)
chat_ui.call(
"_set_presentation_state",
ChatUI.PresentationState.EXPANDED,
@ -121,7 +184,16 @@ func _run() -> void:
assert(canonical_stage.position.is_equal_approx(
UIReferencePresentationType.get_stage_position(display_size)
))
assert(gameplay_screen_effects.position.is_equal_approx(Vector2.ZERO))
assert(gameplay_screen_effects.size.is_equal_approx(
expected_visible_size
))
assert(fishing_danger_vignette.position.is_equal_approx(Vector2.ZERO))
assert(fishing_danger_vignette.size.is_equal_approx(
expected_visible_size
))
assert(chat_ui.size.is_equal_approx(expected_visible_size))
assert(nameplate_layer.size.is_equal_approx(expected_visible_size))
assert(screen_fade.size.is_equal_approx(expected_visible_size))
assert(player_menu.position.is_equal_approx(Vector2.ZERO))
assert(player_menu.size.is_equal_approx(

View file

@ -9,7 +9,9 @@ const GatheringControllerType = preload(
)
const WorldGatherableType = preload("res://gathering/world_gatherable.gd")
const CRAB_PIXEL_SIZE: float = 0.0005
const CRAB_PIXEL_SIZE: float = 0.01
const CRAB_SURFACE_OFFSET: float = 0.11
const CRAB_SPRITE_EXTENT: float = 0.64
const REDUCED_CATCH_RING_RADIUS: float = 0.175
const NET_STRIKE_MARKER_DISTANCE: float = 0.85
const CalendarSeasonType = preload("res://world/calendar_season.gd")
@ -30,7 +32,7 @@ func _run() -> void:
var snapshot_entities_per_envelope: int = (
NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE
)
assert(protocol_version == 12)
assert(protocol_version == 13)
assert(
world_spawn_capability == protocol_world_spawn_capability
)
@ -56,6 +58,10 @@ func _validate_catalog_statuses() -> void:
CRAB_PIXEL_SIZE,
)
)
assert(is_equal_approx(
brown.sprite_surface_offset,
CRAB_SURFACE_OFFSET,
))
_validate_quality_behavior(brown)
assert(is_equal_approx(brown.capture_respawn_min_seconds, 480.0))
assert(is_equal_approx(brown.capture_respawn_max_seconds, 720.0))
@ -121,6 +127,11 @@ func _validate_catalog_statuses() -> void:
0.4,
))
assert(blue.maximum_surface_population == 16)
assert(is_equal_approx(blue.sprite_pixel_size, CRAB_PIXEL_SIZE))
assert(is_equal_approx(
blue.sprite_surface_offset,
CRAB_SURFACE_OFFSET,
))
assert(blue.target_population_for_surface_area(100.0) == 4)
assert(blue.target_population_for_surface_area(2250.0) == 9)
assert(FishCatalog.get_fish_by_id(&"crab_blue") == blue.catch_data)
@ -138,6 +149,10 @@ func _validate_catalog_statuses() -> void:
CRAB_PIXEL_SIZE,
)
)
assert(is_equal_approx(
entry.sprite_surface_offset,
CRAB_SURFACE_OFFSET,
))
assert(entry.catch_data.collection_method == FishData.CollectionMethod.NET)
assert(
entry.catch_data.logbook_section
@ -182,13 +197,22 @@ func _validate_catalog_statuses() -> void:
func _validate_billboard_presentation() -> void:
var gatherable := WorldGatherableType.new()
gatherable.call("_ensure_visual")
root.add_child(gatherable)
var brown: GatherableData = Gatherables.get_entry(&"crab_brown")
gatherable.configure("crab-size-validation", brown, Vector3.ZERO, 0.0)
var sprite := gatherable.get_node("GatherableSprite") as Sprite3D
assert(sprite != null)
assert(sprite.billboard == BaseMaterial3D.BILLBOARD_ENABLED)
assert(sprite.texture_filter == BaseMaterial3D.TEXTURE_FILTER_NEAREST)
assert(not sprite.shaded)
gatherable.free()
assert(is_equal_approx(sprite.pixel_size, CRAB_PIXEL_SIZE))
assert(is_equal_approx(sprite.position.y, CRAB_SURFACE_OFFSET))
assert(is_equal_approx(
float(sprite.texture.get_width()) * sprite.pixel_size,
CRAB_SPRITE_EXTENT,
))
gatherable.queue_free()
await process_frame
var unconfigured_hotspot := WorldGatherableType.new()
unconfigured_hotspot.call("_ensure_water_spurt_visual")

View file

@ -25,9 +25,21 @@ const SPEECH_BUBBLE_WIDTH: float = 320.0
const SPEECH_POINTER_HALF_WIDTH: float = 12.0
const SPEECH_POINTER_HEIGHT: float = 14.0
const SPEECH_POINTER_OVERLAP: float = 3.0
const NAMEPLATE_WIDTH: float = 300.0
const NAMEPLATE_NAME_HEIGHT: float = 24.0
const NAMEPLATE_TITLE_TOP: float = 21.0
const NAMEPLATE_TITLE_HEIGHT: float = 18.0
const NAMEPLATE_HEIGHT: float = (
NAMEPLATE_TITLE_TOP + NAMEPLATE_TITLE_HEIGHT
)
const NAMEPLATE_NAME_FONT_SIZE: int = 20
const NAMEPLATE_TITLE_FONT_SIZE: int = 14
const NAMEPLATE_HEAD_GAP: float = 3.0
const NAMEPLATE_SPEECH_GAP: float = 5.0
const ANIMALESE_FULL_VOLUME_DISTANCE: float = 4.0
const ANIMALESE_SILENT_DISTANCE: float = 24.0
const ANIMALESE_SILENT_VOLUME_DB: float = -80.0
const ROLEPLAY_FONT_SLANT: float = -0.18
const MAX_EDITOR_GIVE_BALANCE: int = 1_000_000_000_000
const MOBILE_COMPACT_WIDTH: float = 620.0
const MOBILE_EXPANDED_WIDTH: float = 820.0
@ -107,6 +119,7 @@ var _player: Player
var _fishing_spot: FishingSpot
var _settings: PlayerSettingsManager
var _history: RichTextLabel
var _roleplay_font: FontVariation
var _entry: LineEdit
var _status: Label
var _panel: PanelContainer
@ -114,6 +127,7 @@ var _collapse_button: Button
var _height_button: Button
var _unread_indicator: Label
var _hint: Label
var _nameplate_layer: Control
var _speech_layer: Control
var _animalese_voice: AnimaleseVoiceType
var _clock_panel: PanelContainer
@ -125,6 +139,7 @@ var _calendar_balance: CurrencyAmount
var _weather_icon: WeatherIconType
var _status_effect_column: VBoxContainer
var _status_effect_icons: Dictionary[StringName, TextureRect] = {}
var _nameplates: Dictionary[int, Dictionary] = {}
var _speech: Dictionary[int, Dictionary] = {}
var _draft_save_timer: Timer
var _opacity_tween: Tween
@ -132,6 +147,7 @@ var _height_tween: Tween
var _opened: bool = false
var _available: bool = false
var _hud_hidden: bool = false
var _nameplates_hud_hidden: bool = false
var _world_speech_visible: bool = true
var _presentation_state := PresentationState.COMPACT
var _visible_state_before_collapse := PresentationState.COMPACT
@ -247,7 +263,12 @@ func setup(
_service.local_message_confirmed.connect(_on_local_message_confirmed)
_service.history_replaced.connect(_on_history)
_service.send_rejected.connect(_on_rejected)
_session.peer_removed.connect(_on_peer_removed)
_session.peer_authenticated.connect(_on_peer_authenticated)
_session.peer_display_name_changed.connect(_on_peer_display_name_changed)
_session.peer_title_changed.connect(_on_peer_title_changed)
_session.peer_removed.connect(_on_session_peer_removed)
_spawn.avatar_spawned.connect(_on_avatar_spawned)
_spawn.avatar_removed.connect(_on_avatar_removed)
_entry.text = _settings.current_settings.chat_draft
_entry.caret_column = _entry.text.length()
_set_presentation_state(
@ -258,6 +279,7 @@ func setup(
false,
)
_refresh_history()
_reconcile_nameplates()
func _rebuild_status_effect_icons() -> void:
@ -370,6 +392,7 @@ func open_command_chat() -> void:
func set_available(value: bool) -> void:
_available = value
_refresh_nameplate_layer_visibility()
if not value:
_clear_suspended_chat_state()
_send_pending = false
@ -601,6 +624,7 @@ func _process(_delta: float) -> void:
_refresh_visibility()
_update_status_effect_icons()
_update_panel_opacity()
_update_nameplates()
_update_speech()
@ -612,6 +636,11 @@ func _exit_tree() -> void:
func _build_ui() -> void:
_roleplay_font = FontVariation.new()
_roleplay_font.base_font = UtilityPageStyle.TuffyFont
var roleplay_transform := Transform2D.IDENTITY
roleplay_transform.y.x = ROLEPLAY_FONT_SLANT
_roleplay_font.variation_transform = roleplay_transform
_clock_panel = PanelContainer.new()
_clock_panel.name = "WorldClockPanel"
_clock_panel.size = CLOCK_SIZE
@ -720,7 +749,9 @@ func _build_ui() -> void:
_history.scroll_following = true
_history.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
_history.mouse_filter = Control.MOUSE_FILTER_STOP
_history.bbcode_enabled = false
_history.add_theme_font_override("normal_font", UtilityPageStyle.TuffyFont)
_history.add_theme_font_override("italics_font", _roleplay_font)
_history.add_theme_font_size_override("normal_font_size", HISTORY_FONT_SIZE)
_history.add_theme_color_override("default_color", UtilityPageStyle.LIGHT_TEXT)
_history.add_theme_stylebox_override("normal", _borderless_style(Color.TRANSPARENT))
@ -846,7 +877,16 @@ func _build_ui() -> void:
_hint.add_theme_constant_override("shadow_offset_x", 1)
_hint.add_theme_constant_override("shadow_offset_y", 2)
add_child(_hint)
_nameplate_layer = Control.new()
_nameplate_layer.name = "PlayerNameplateLayer"
_nameplate_layer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_nameplate_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
_nameplate_layer.visible = (
_available and _world_speech_visible and not _nameplates_hud_hidden
)
add_child(_nameplate_layer)
_speech_layer = Control.new()
_speech_layer.name = "PlayerSpeechLayer"
_speech_layer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_speech_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_speech_layer)
@ -1193,10 +1233,15 @@ func _on_message(message: Dictionary) -> void:
_update_panel_opacity(true)
if int(message["kind"]) != NetworkChatProtocol.Kind.PLAYER:
return
var body: String = str(message["body"])
# Roleplay actions belong only in chat history. They deliberately do not
# create world speech, animalese, or a talking animation.
if NetworkChatProtocol.is_roleplay_body(body):
return
var peer_id: int = message["sender_peer_id"]
_show_speech_bubble(
peer_id,
str(message["body"]),
body,
str(message.get("sender_fingerprint", "")),
VoiceProfilesType.sanitized_id(str(message.get(
"voice_id", VoiceProfilesType.DEFAULT_ID
@ -1221,6 +1266,8 @@ func _show_speech_bubble(
requested_sample_set_id: String = "",
) -> void:
_on_peer_removed(peer_id)
if not _speaker_shares_local_space(peer_id):
return
var bubble := PanelContainer.new()
bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE
bubble.custom_minimum_size = Vector2(SPEECH_BUBBLE_WIDTH, 0.0)
@ -1342,16 +1389,27 @@ func _on_rejected(message: String) -> void:
func _refresh_history() -> void:
if _service == null:
return
var lines: Array[String] = []
var messages := _service.get_history()
for message: Dictionary in messages:
_history.clear()
for message_index: int in messages.size():
var message: Dictionary = messages[message_index]
if message_index > 0:
_history.add_text("\n")
if int(message["kind"]) == NetworkChatProtocol.Kind.SYSTEM:
lines.append("%s" % str(message["body"]))
else:
lines.append("%s: %s" % [
str(message["sender_display_name"]), str(message["body"]),
_history.add_text("%s" % str(message["body"]))
continue
var body: String = str(message["body"])
if NetworkChatProtocol.is_roleplay_body(body):
_history.push_italics()
_history.add_text("%s %s" % [
str(message["sender_display_name"]),
NetworkChatProtocol.roleplay_action(body),
])
_history.text = "\n".join(lines)
_history.pop()
continue
_history.add_text("%s: %s" % [
str(message["sender_display_name"]), body,
])
_scroll_history_to_bottom.call_deferred()
@ -1465,6 +1523,8 @@ func is_mobile_mode() -> bool:
func set_hud_hidden(is_hidden: bool) -> void:
_hud_hidden = is_hidden
_nameplates_hud_hidden = is_hidden
_refresh_nameplate_layer_visibility()
_refresh_visibility()
@ -1472,8 +1532,14 @@ func is_hud_hidden() -> bool:
return _hud_hidden
func set_world_nameplates_hud_hidden(is_hidden: bool) -> void:
_nameplates_hud_hidden = is_hidden
_refresh_nameplate_layer_visibility()
func set_world_speech_visible(should_be_visible: bool) -> void:
_world_speech_visible = should_be_visible
_refresh_nameplate_layer_visibility()
if _speech_layer != null:
_speech_layer.visible = should_be_visible
@ -1482,6 +1548,14 @@ func is_world_speech_visible() -> bool:
return _world_speech_visible
func _refresh_nameplate_layer_visibility() -> void:
if _nameplate_layer == null:
return
_nameplate_layer.visible = (
_available and _world_speech_visible and not _nameplates_hud_hidden
)
func _refresh_handle_labels(state: PresentationState) -> void:
var collapsed := state == PresentationState.COLLAPSED
var height_state := _visible_state_before_collapse if collapsed else state
@ -1538,11 +1612,217 @@ func _update_panel_opacity(immediate_recheck: bool = false) -> void:
).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
func _update_nameplates() -> void:
_reconcile_nameplates()
if _spawn == null or _player == null:
_hide_all_nameplates()
return
var camera := _player.get_active_gameplay_camera()
if camera == null:
_hide_all_nameplates()
return
var viewport_size := get_viewport_rect().size
for peer_id: int in _nameplates.keys():
var state: Dictionary = _nameplates[peer_id]
var plate := state.get("control") as Control
var avatar := _spawn.get_avatar(peer_id)
if plate == null:
continue
if (
avatar == null
or not _available
or _nameplates_hud_hidden
or not _world_speech_visible
or not _speaker_shares_local_space(peer_id)
or not _avatar_presentation_visible(peer_id, avatar)
):
plate.hide()
continue
var world_position := avatar.get_nameplate_anchor_position()
if (
camera.is_position_behind(world_position)
or _is_speech_world_occluded(camera, avatar, world_position)
):
plate.hide()
continue
var screen_position := (
camera.unproject_position(world_position) / _output_scale
)
if (
screen_position.x < -NAMEPLATE_WIDTH * 0.5
or screen_position.x > viewport_size.x + NAMEPLATE_WIDTH * 0.5
or screen_position.y < -NAMEPLATE_HEIGHT
or screen_position.y > viewport_size.y + NAMEPLATE_HEIGHT
):
plate.hide()
continue
plate.position = Vector2(
screen_position.x - NAMEPLATE_WIDTH * 0.5,
screen_position.y - NAMEPLATE_HEIGHT - NAMEPLATE_HEAD_GAP,
)
plate.show()
func _reconcile_nameplates() -> void:
if _spawn == null or _session == null or _nameplate_layer == null:
return
var live_peer_ids: Dictionary[int, bool] = {}
for peer_id: int in _spawn.get_peer_ids():
if (
_spawn.get_avatar(peer_id) == null
or _session.get_peer_record(peer_id) == null
):
continue
live_peer_ids[peer_id] = true
_ensure_nameplate(peer_id)
for peer_id: int in _nameplates.keys():
if not live_peer_ids.has(peer_id):
_remove_nameplate(peer_id)
func _ensure_nameplate(peer_id: int) -> void:
if _nameplates.has(peer_id):
_refresh_nameplate_text(peer_id)
return
var plate := Control.new()
plate.name = "PlayerNameplate_%d" % peer_id
plate.size = Vector2(NAMEPLATE_WIDTH, NAMEPLATE_HEIGHT)
plate.mouse_filter = Control.MOUSE_FILTER_IGNORE
plate.hide()
var name_label := _create_nameplate_label(
"PlayerName",
NAMEPLATE_NAME_FONT_SIZE,
0.0,
NAMEPLATE_NAME_HEIGHT,
)
var title_label := _create_nameplate_label(
"PlayerTitle",
NAMEPLATE_TITLE_FONT_SIZE,
NAMEPLATE_TITLE_TOP,
NAMEPLATE_TITLE_HEIGHT,
)
plate.add_child(name_label)
plate.add_child(title_label)
_nameplate_layer.add_child(plate)
_nameplates[peer_id] = {
"control": plate,
"name": name_label,
"title": title_label,
}
_refresh_nameplate_text(peer_id)
func _create_nameplate_label(
node_name: String,
font_size: int,
vertical_position: float,
height: float,
) -> Label:
var label := Label.new()
label.name = node_name
label.position = Vector2(0.0, vertical_position)
label.size = Vector2(NAMEPLATE_WIDTH, height)
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.clip_text = true
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", Color.WHITE)
label.add_theme_color_override("font_outline_color", Color.TRANSPARENT)
label.add_theme_color_override("font_shadow_color", Color.TRANSPARENT)
label.add_theme_constant_override("outline_size", 0)
label.add_theme_constant_override("shadow_offset_x", 0)
label.add_theme_constant_override("shadow_offset_y", 0)
return label
func _refresh_nameplate_text(peer_id: int) -> void:
if _session == null or not _nameplates.has(peer_id):
return
var record := _session.get_peer_record(peer_id)
if record == null:
return
var state: Dictionary = _nameplates[peer_id]
var name_label := state.get("name") as Label
var title_label := state.get("title") as Label
if name_label != null:
name_label.text = (
record.display_name
if not record.display_name.is_empty()
else NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
)
if title_label != null:
title_label.text = (
record.title
if not record.title.is_empty()
else NetworkProfilePreferences.DEFAULT_TITLE
)
func _remove_nameplate(peer_id: int) -> void:
var state: Dictionary = _nameplates.get(peer_id, {})
var plate := state.get("control") as Control
if plate != null:
plate.queue_free()
_nameplates.erase(peer_id)
func _hide_all_nameplates() -> void:
for state: Dictionary in _nameplates.values():
var plate := state.get("control") as Control
if plate != null:
plate.hide()
func _avatar_presentation_visible(peer_id: int, avatar: Player) -> bool:
if _session == null or peer_id == _session.get_local_peer_id():
return true
return avatar.is_remote_presentation_visible()
func _nameplate_speech_clearance(peer_id: int) -> float:
var state: Dictionary = _nameplates.get(peer_id, {})
var plate := state.get("control") as Control
if plate == null or not plate.is_visible_in_tree():
return 0.0
return NAMEPLATE_HEIGHT + NAMEPLATE_HEAD_GAP + NAMEPLATE_SPEECH_GAP
func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
_ensure_nameplate(peer_id)
func _on_peer_display_name_changed(peer_id: int, _display_name: String) -> void:
_ensure_nameplate(peer_id)
func _on_peer_title_changed(peer_id: int, _title: String) -> void:
_ensure_nameplate(peer_id)
func _on_avatar_spawned(peer_id: int, _avatar: Player) -> void:
_ensure_nameplate(peer_id)
func _on_avatar_removed(peer_id: int) -> void:
_remove_nameplate(peer_id)
func _on_session_peer_removed(peer_id: int) -> void:
_on_peer_removed(peer_id)
_remove_nameplate(peer_id)
func _update_speech() -> void:
var now := Time.get_ticks_msec() / 1000.0
for peer_id: int in _speech.keys():
var state: Dictionary = _speech[peer_id]
var bubble := state.get("bubble") as PanelContainer
if not _speaker_shares_local_space(peer_id):
_on_peer_removed(peer_id)
continue
if bubble == null or now >= float(state.get("expires", 0.0)):
if bubble != null:
bubble.queue_free()
@ -1553,10 +1833,14 @@ func _update_speech() -> void:
continue
var avatar := _spawn.get_avatar(peer_id)
var camera := _player.get_active_gameplay_camera()
if avatar == null or camera == null:
if (
avatar == null
or camera == null
or not _avatar_presentation_visible(peer_id, avatar)
):
bubble.hide()
continue
var world_position := avatar.get_chat_anchor_position()
var world_position := avatar.get_nameplate_anchor_position()
if camera.is_position_behind(world_position):
bubble.hide()
continue
@ -1566,6 +1850,7 @@ func _update_speech() -> void:
var screen_position := (
camera.unproject_position(world_position) / _output_scale
)
screen_position.y -= _nameplate_speech_clearance(peer_id)
var viewport_size := get_viewport_rect().size
if (
screen_position.x < -80.0
@ -1631,10 +1916,27 @@ func _is_speech_world_occluded(
if collider is Player:
excluded.append(collider.get_rid())
continue
if (
collider != null
and bool(collider.get_meta(
HomeWorldService.INTERIOR_SHELL_COLLISION_META, false
))
):
excluded.append(collider.get_rid())
continue
return true
return false
func _speaker_shares_local_space(peer_id: int) -> bool:
if _session == null:
return true
var local_peer_id: int = _session.get_local_peer_id()
if local_peer_id <= 0:
return true
return _session.peers_share_space(local_peer_id, peer_id)
func _on_draft_changed(_value: String) -> void:
if _settings == null:
return

View file

@ -88,8 +88,6 @@ signal pixelation_settings_visibility_changed(is_visible: bool)
signal crisp_reset_focus_requested
signal interactive_pointer_ui_changed(is_open: bool)
signal passive_pointer_ui_changed(is_enabled: bool)
signal player_menu_backdrop_visibility_changed(is_visible: bool)
signal shop_backdrop_visibility_changed(is_visible: bool)
signal virtual_pointer_mode_changed(is_active: bool)
signal social_prompt_accepted
signal social_prompt_declined
@ -119,6 +117,7 @@ const FISHING_PANEL_SHOWCASE_BOTTOM_OFFSET: float = -104.0
@onready var _status_label: Label = %StatusLabel
@onready var _bite_prompt_button: Button = %BitePromptButton
@onready var _gameplay_transient_hud: Control = %GameplayTransientHUD
@onready var _gameplay_screen_effects: Control = %GameplayScreenEffects
@onready var _active_bait_indicator: Control = %ActiveBaitIndicator
@onready var _active_bait_button: Button = %ActiveBaitButton
@onready var _active_bait_quantity_badge: Panel = %ActiveBaitQuantityBadge
@ -148,7 +147,8 @@ const VoiceProfilesType = preload(
const SHOP_ANIMALESE_VOICE_ID: String = "natural"
const SHOP_ANIMALESE_BASE_PITCH: float = 1.08
const SHOP_SPEECH_CHARACTERS_PER_SECOND: float = 28.0
const SHOP_NPC_SPEECH_COOLDOWN_MILLISECONDS: int = 5000
const SHOP_PROMPT_DIALOGUE_SIZE := Vector2(300.0, 72.0)
const SHOP_PROMPT_COMPACT_SIZE := Vector2(30.0, 30.0)
const FISHING_DANGER_START_GAP: float = 0.15
const FISHING_DANGER_FULL_GAP: float = 0.025
@ -175,9 +175,11 @@ const FISHING_DANGER_FULL_GAP: float = 0.025
@onready var _shop_prompt_key: Label = %ShopPromptKey
@onready var _shop_prompt_pointer: Polygon2D = %ShopPromptPointer
var _shop_animalese_voice: AnimaleseVoiceType
var _shop_speech_tween: Tween
var _shop_npc_player_in_range: bool = false
var _shop_npc_dialogue_active: bool = false
var _shop_npc_spoken_for_current_visit: bool = false
var _shop_npc_next_speech_msec: int = 0
var _shop_npc_source_id: int = 0
@onready var _chat_ui: ChatUIType = %ChatUI
@onready var _emote_radial_menu: EmoteRadialMenuType = %EmoteRadialMenu
@onready var _quick_radial_menu: QuickRadialMenuType = %QuickRadialMenu
@ -559,11 +561,8 @@ func setup(
_on_storage_visibility_changed
)
_fishing_shop.menu_visibility_changed.connect(_on_shop_visibility_changed)
_fishing_shop.menu_exit_started.connect(_on_shop_exit_started)
_decor_shop.menu_visibility_changed.connect(_on_shop_visibility_changed)
_decor_shop.menu_exit_started.connect(_on_shop_exit_started)
_rv_upgrade_shop.menu_visibility_changed.connect(_on_shop_visibility_changed)
_rv_upgrade_shop.menu_exit_started.connect(_on_shop_exit_started)
_fishing_shop.sell_fish_requested.connect(_on_shop_sell_fish_requested)
_fishing_shop.shop_cooler_return_requested.connect(
_on_shop_cooler_return_requested
@ -700,6 +699,7 @@ func _input(event: InputEvent) -> void:
can_open and not _quick_radial_menu.is_open(),
):
var emote_is_open: bool = _emote_radial_menu.is_open()
_refresh_home_decor_toolbar_obscuring()
if emote_is_open != emote_was_open and _player != null:
_player.set_camera_input_suppressed(
EMOTE_RADIAL_CAMERA_OWNER,
@ -713,6 +713,7 @@ func _input(event: InputEvent) -> void:
can_open and not _emote_radial_menu.is_open(),
):
var quick_is_open: bool = _quick_radial_menu.is_open()
_refresh_home_decor_toolbar_obscuring()
if quick_is_open != quick_was_open and _player != null:
_player.set_camera_input_suppressed(
QUICK_RADIAL_CAMERA_OWNER,
@ -755,6 +756,7 @@ func _close_radial_menus() -> void:
func _close_emote_radial_menu() -> void:
if _emote_radial_menu != null and _emote_radial_menu.is_open():
_emote_radial_menu.close_menu()
_refresh_home_decor_toolbar_obscuring()
if _player != null and is_instance_valid(_player):
_player.set_camera_input_suppressed(
EMOTE_RADIAL_CAMERA_OWNER,
@ -765,6 +767,7 @@ func _close_emote_radial_menu() -> void:
func _close_quick_radial_menu() -> void:
if _quick_radial_menu != null and _quick_radial_menu.is_open():
_quick_radial_menu.close_menu()
_refresh_home_decor_toolbar_obscuring()
if _player != null and is_instance_valid(_player):
_player.set_camera_input_suppressed(
QUICK_RADIAL_CAMERA_OWNER,
@ -798,6 +801,14 @@ func _character_call_yields_to_world_interaction(
_shop_interaction != null
and _shop_interaction.is_local_player_in_range()
)
or (
_decor_shop_interaction != null
and _decor_shop_interaction.is_local_player_in_range()
)
or (
_rv_upgrade_shop_interaction != null
and _rv_upgrade_shop_interaction.is_local_player_in_range()
)
or (
_storage_interaction != null
and _storage_interaction.is_local_player_in_range()
@ -806,6 +817,8 @@ func _character_call_yields_to_world_interaction(
and _fishing_spot != null
and _fishing_spot.can_open_fishing_shop()
and not _fishing_shop.visible
and not _decor_shop.visible
and not _rv_upgrade_shop.visible
)
@ -1544,12 +1557,19 @@ func _refresh_interaction_prompt_bindings() -> void:
func _resize_interaction_prompts() -> void:
var badge_width: float = maxf(
24.0,
ceilf(_shop_prompt_key.get_combined_minimum_size().x) + 12.0,
)
_shop_prompt_key_badge.position.x = _shop_prompt.size.x - badge_width + 6.0
_shop_prompt_key_badge.size.x = badge_width
if not _shop_npc_dialogue_active:
_shop_prompt_key_badge.position = Vector2.ZERO
_shop_prompt_key_badge.size = SHOP_PROMPT_COMPACT_SIZE
else:
var badge_width: float = maxf(
24.0,
ceilf(_shop_prompt_key.get_combined_minimum_size().x) + 12.0,
)
_shop_prompt_key_badge.position = Vector2(
_shop_prompt.size.x - badge_width + 6.0,
-8.0,
)
_shop_prompt_key_badge.size = Vector2(badge_width, 24.0)
var storage_width: float = maxf(
220.0,
ceilf(_storage_prompt_message.get_combined_minimum_size().x) + 24.0,
@ -1925,6 +1945,7 @@ func _refresh_gameplay_hud_visibility() -> void:
and not _storage_open
)
_gameplay_transient_hud.visible = show_world_hud
_gameplay_screen_effects.visible = show_world_hud
_experience_presentation.visible = (
_gameplay_ui_enabled and not _gameplay_hud_hidden
)
@ -2110,19 +2131,39 @@ func set_shop_prompt_visible(
func set_shop_npc_player_in_range(
in_range: bool,
dialogue_text: String = "",
source_id: int = 0,
dialogue_active: bool = false,
) -> void:
var source_changed: bool = source_id != _shop_npc_source_id
var next_dialogue_active: bool = in_range and dialogue_active
var dialogue_stage_changed: bool = (
next_dialogue_active != _shop_npc_dialogue_active
)
var dialogue_changed: bool = (
not dialogue_text.is_empty()
and _shop_prompt_message.text != dialogue_text
)
if source_changed or dialogue_changed or dialogue_stage_changed:
_cancel_shop_npc_speech()
_shop_npc_source_id = source_id
if dialogue_changed:
_shop_prompt_message.text = dialogue_text
_shop_prompt_message.visible_characters = -1
_shop_npc_spoken_for_current_visit = false
if _shop_npc_player_in_range == in_range and not dialogue_changed:
if source_changed or dialogue_stage_changed:
_shop_npc_spoken_for_current_visit = false
if (
_shop_npc_player_in_range == in_range
and not dialogue_changed
and not source_changed
and not dialogue_stage_changed
):
return
_shop_npc_player_in_range = in_range
if not in_range:
_shop_npc_dialogue_active = next_dialogue_active
_apply_shop_prompt_mode()
if not in_range or not _shop_npc_dialogue_active:
_cancel_shop_npc_speech()
_shop_npc_spoken_for_current_visit = false
return
_try_start_shop_npc_speech()
@ -2131,31 +2172,33 @@ func set_shop_npc_player_in_range(
func _try_start_shop_npc_speech() -> void:
if (
not _shop_npc_player_in_range
or not _shop_npc_dialogue_active
or _shop_npc_spoken_for_current_visit
or not _gameplay_ui_enabled
):
return
var now_msec: int = Time.get_ticks_msec()
if now_msec < _shop_npc_next_speech_msec:
return
_shop_npc_spoken_for_current_visit = true
_shop_npc_next_speech_msec = (
now_msec + SHOP_NPC_SPEECH_COOLDOWN_MILLISECONDS
)
_ensure_shop_animalese_voice()
TypewriterRevealType.start(
_shop_prompt_message,
SHOP_SPEECH_CHARACTERS_PER_SECOND,
)
_shop_animalese_voice.speak_text(
_shop_speech_tween = _shop_animalese_voice.speak_text(
_shop_prompt_message,
_shop_prompt_message.text,
"shopkeeper",
"shopkeeper:%d" % _shop_npc_source_id,
SHOP_ANIMALESE_VOICE_ID,
SHOP_SPEECH_CHARACTERS_PER_SECOND,
)
func _cancel_shop_npc_speech() -> void:
TypewriterRevealType.cancel(_shop_prompt_message)
if _shop_speech_tween != null and _shop_speech_tween.is_valid():
_shop_speech_tween.kill()
_shop_speech_tween = null
func _ensure_shop_animalese_voice() -> void:
if _shop_animalese_voice != null:
return
@ -2191,6 +2234,29 @@ func _apply_shop_prompt_style() -> void:
UtilityPageStyle.OCEAN_PANEL_MID,
0.96,
)
_apply_shop_prompt_mode()
func _apply_shop_prompt_mode() -> void:
if not is_node_ready():
return
var dialogue_mode: bool = _shop_npc_dialogue_active
var requested_size := (
SHOP_PROMPT_DIALOGUE_SIZE
if dialogue_mode
else SHOP_PROMPT_COMPACT_SIZE
)
_shop_prompt.custom_minimum_size = requested_size
_shop_prompt.size = requested_size
_shop_prompt_bubble.visible = dialogue_mode
_shop_prompt_message.visible = dialogue_mode
_shop_prompt_pointer.visible = dialogue_mode
var badge_style := (
_shop_prompt_key_badge.get_theme_stylebox("panel") as StyleBoxFlat
)
if badge_style != null:
badge_style.set_corner_radius_all(12 if dialogue_mode else 15)
_resize_interaction_prompts()
func _position_shop_prompt(world_anchor: Vector3) -> void:
@ -2219,7 +2285,7 @@ func _position_shop_prompt(world_anchor: Vector3) -> void:
):
_shop_prompt.hide()
return
var pointer_height := 10.0
var pointer_height := 10.0 if _shop_npc_dialogue_active else 6.0
var desired := screen_position - Vector2(
_shop_prompt.size.x * 0.5,
_shop_prompt.size.y + pointer_height,
@ -2236,14 +2302,15 @@ func _position_shop_prompt(world_anchor: Vector3) -> void:
ui_viewport_size.y - _shop_prompt.size.y - pointer_height - 8.0,
),
)
_shop_prompt_pointer.position = Vector2(
clampf(
screen_position.x - _shop_prompt.position.x,
20.0,
_shop_prompt.size.x - 20.0,
),
_shop_prompt.size.y - 1.0,
)
if _shop_npc_dialogue_active:
_shop_prompt_pointer.position = Vector2(
clampf(
screen_position.x - _shop_prompt.position.x,
20.0,
_shop_prompt.size.x - 20.0,
),
_shop_prompt.size.y - 1.0,
)
func get_screen_fade() -> ScreenFade:
@ -2831,7 +2898,6 @@ func _on_player_menu_visibility_changed(is_open: bool) -> void:
true,
UIMotion.PLAYER_MENU_ENTER_DURATION,
)
player_menu_backdrop_visibility_changed.emit(true)
else:
_player_menu_hotbar_visible = false
_hotbar_ui.set_presentation_visible(false, false)
@ -2862,13 +2928,6 @@ func _on_player_menu_exit_started() -> void:
true,
UIMotion.PLAYER_MENU_EXIT_DURATION,
)
player_menu_backdrop_visibility_changed.emit(false)
func _on_shop_exit_started() -> void:
if not _shop_open:
return
shop_backdrop_visibility_changed.emit(false)
func _on_shop_visibility_changed(is_open: bool) -> void:
@ -2876,8 +2935,6 @@ func _on_shop_visibility_changed(is_open: bool) -> void:
if is_open:
_close_radial_menus()
_refresh_surface_drawing_activation()
if is_open:
shop_backdrop_visibility_changed.emit(true)
if not is_open and _player_menu.is_shop_cooler_mounted():
_player_menu.unmount_shop_cooler()
_refresh_gameplay_hud_visibility()
@ -3049,6 +3106,7 @@ func _refresh_hotbar_visibility() -> void:
func _emit_interactive_pointer_ui_changed() -> void:
_refresh_active_bait_indicator_visibility()
_refresh_rv_storage_button_visibility()
_refresh_home_decor_toolbar_obscuring()
interactive_pointer_ui_changed.emit(
_system_menu_open
or _player_menu_open
@ -3058,6 +3116,26 @@ func _emit_interactive_pointer_ui_changed() -> void:
)
func _refresh_home_decor_toolbar_obscuring() -> void:
if _home_decor_toolbar == null:
return
_home_decor_toolbar.set_menu_obscured(
_system_menu_open
or _player_menu_open
or _shop_open
or _storage_open
or _chat_input_open
or (
_emote_radial_menu != null
and _emote_radial_menu.is_open()
)
or (
_quick_radial_menu != null
and _quick_radial_menu.is_open()
)
)
func _on_chat_text_entry_ownership_changed(active: bool) -> void:
_chat_input_open = active
if active:
@ -3080,4 +3158,5 @@ func _refresh_chat_availability() -> void:
)
_chat_ui.set_available(chat_available)
_chat_ui.set_hud_hidden(_gameplay_hud_hidden and not _chat_input_open)
_chat_ui.set_world_nameplates_hud_hidden(_gameplay_hud_hidden)
passive_pointer_ui_changed.emit(chat_available)

View file

@ -81,7 +81,7 @@ mouse_filter = 2
[node name="SurfaceDrawingToolbar" parent="UIRoot" instance=ExtResource("11_drawing_toolbar")]
unique_name_in_owner = true
[node name="GameplayTransientHUD" type="Control" parent="UIRoot/CanonicalStage"]
[node name="GameplayScreenEffects" type="Control" parent="UIRoot"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
@ -91,7 +91,7 @@ grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="FishingDangerVignette" type="ColorRect" parent="UIRoot/CanonicalStage/GameplayTransientHUD"]
[node name="FishingDangerVignette" type="ColorRect" parent="UIRoot/GameplayScreenEffects"]
unique_name_in_owner = true
visible = false
z_index = 50
@ -105,6 +105,16 @@ mouse_filter = 2
material = SubResource("ShaderMaterial_fishing_vignette")
color = Color(1, 1, 1, 1)
[node name="GameplayTransientHUD" type="Control" parent="UIRoot/CanonicalStage"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="ActiveBaitIndicator" type="Control" parent="UIRoot/CanonicalStage/GameplayTransientHUD"]
unique_name_in_owner = true
z_index = 54

View file

@ -801,7 +801,10 @@ func _refresh_entries() -> void:
else "online"
)
_server_list.add_item("%s%s" % [
str(friend.get("display_name", "Player")), state,
str(friend.get(
"display_name",
NetworkProfilePreferences.DEFAULT_DISPLAY_NAME,
)), state,
])
return
if _saved_servers == null or _mode == Mode.DIRECT:
@ -1451,7 +1454,9 @@ func _format_friend_details(friend: Dictionary) -> String:
return "Select a friend."
var room := _selected_friend_room()
var lines: Array[String] = [
"Friend: %s" % str(friend.get("display_name", "Player")),
"Friend: %s" % str(friend.get(
"display_name", NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
)),
"Status: %s" % (
"online" if bool(friend.get("online", false)) else "offline"
),

View file

@ -689,7 +689,10 @@ func _build_relationship_rows() -> void:
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
var fingerprint := str(record["fingerprint"])
label.text = "%s %s" % [
str(record.get("last_known_display_name", "Player")),
str(record.get(
"last_known_display_name",
NetworkProfilePreferences.DEFAULT_DISPLAY_NAME,
)),
"Blocked" if bool(record.get("blocked", false)) else "Muted",
]
_configure_identity_tooltip(
@ -733,7 +736,9 @@ func _build_friend_rows() -> void:
for friend: Dictionary in friends:
var row := _make_row()
var fingerprint := str(friend.get("fingerprint", ""))
var display_name := str(friend.get("display_name", "Player"))
var display_name := str(friend.get(
"display_name", NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
))
var room: Dictionary = (
friend.get("room", {})
if typeof(friend.get("room", {})) == TYPE_DICTIONARY
@ -835,7 +840,10 @@ func _build_ban_rows() -> void:
label.clip_text = true
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
label.text = "%s banned %s" % [
str(record.get("last_known_display_name", "Player")),
str(record.get(
"last_known_display_name",
NetworkProfilePreferences.DEFAULT_DISPLAY_NAME,
)),
Time.get_date_string_from_unix_time(int(record.get("banned_unix", 0))),
]
_configure_identity_tooltip(

View file

@ -34,6 +34,7 @@ const VoiceProfilesType = preload(
"res://player/animalese_voice_profiles.gd"
)
const VOICE_CATEGORY_ID: String = "voice"
const NAME_TOOLTIP_BASE: String = "Shown to other players in multiplayer."
enum ControllerZone {
ACCOUNT,
@ -45,8 +46,10 @@ enum ControllerZone {
var _service: NetworkProfileService
var _experience: PlayerExperience
var _draft_name: String = ""
var _draft_title: String = NetworkProfilePreferences.DEFAULT_TITLE
var _draft_appearance: Dictionary = {}
var _persisted_name: String = ""
var _persisted_title: String = NetworkProfilePreferences.DEFAULT_TITLE
var _persisted_appearance: Dictionary = {}
var _draft_voice_id: String = VoiceProfilesType.DEFAULT_ID
var _persisted_voice_id: String = VoiceProfilesType.DEFAULT_ID
@ -61,6 +64,8 @@ var _dirty: bool = false
var _allow_duplicate: bool = false
var _name_edit: LineEdit
var _title_edit: LineEdit
var _name_label: Label
var _name_status: Label
var _suggestions: HBoxContainer
var _category_list: VBoxContainer
@ -215,8 +220,9 @@ func consume_escape() -> bool:
if _discard_confirmation.visible:
_close_discard_confirmation()
return true
if _name_edit.has_focus():
if _name_edit.has_focus() or _title_edit.has_focus():
_name_edit.release_focus()
_title_edit.release_focus()
return true
if _dirty:
_show_confirmation("discard")
@ -426,6 +432,18 @@ func _focus_controller_zone() -> void:
if not _profile_interactive:
return
var controls: Array[Control] = _active_controller_zone_controls()
# The account zone starts with a LineEdit in layout order. Do not activate
# text entry merely because the Profile page opened or controller focus
# returned from customization; keep the name field opt-in.
if (
_controller_zone == ControllerZone.ACCOUNT
and _customize_button in controls
and _customize_button.focus_mode != Control.FOCUS_NONE
and _customize_button.is_visible_in_tree()
and not _customize_button.disabled
):
_customize_button.grab_focus()
return
for control: Control in controls:
var button := control as BaseButton
if (
@ -496,6 +514,7 @@ func _account_controller_controls() -> Array[Control]:
var controls: Array[Control] = []
for control: Control in [
_name_edit,
_title_edit,
_customize_button,
_apply_button,
_revert_button,
@ -604,22 +623,26 @@ func _build_ui() -> void:
layout.add_child(account_row)
var account_stack := VBoxContainer.new()
account_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
account_stack.add_theme_constant_override("separation", 2)
account_stack.add_theme_constant_override("separation", 7)
account_row.add_child(account_stack)
var name_line := HBoxContainer.new()
name_line.name = "PlayerNameRow"
name_line.add_theme_constant_override("separation", 10)
account_stack.add_child(name_line)
var name_label := Label.new()
name_label.text = "player name"
name_label.custom_minimum_size.x = 98.0
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
name_label.add_theme_color_override(
_name_label = Label.new()
_name_label.name = "PlayerNameLabel"
_name_label.text = "name"
_name_label.tooltip_text = NAME_TOOLTIP_BASE
_name_label.custom_minimum_size.x = 128.0
_name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_name_label.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
)
name_line.add_child(name_label)
name_line.add_child(_name_label)
_name_edit = LineEdit.new()
_name_edit.name = "PlayerNameEdit"
_name_edit.max_length = NetworkProtocol.MAX_DISPLAY_NAME_LENGTH
_name_edit.placeholder_text = "Player"
_name_edit.placeholder_text = NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
_name_edit.custom_minimum_size = Vector2(280, 34)
UtilityPageStyle.apply_ocean_line_edit(_name_edit)
_name_edit.add_theme_stylebox_override(
@ -629,23 +652,46 @@ func _build_ui() -> void:
)
_name_edit.text_changed.connect(_on_name_changed)
name_line.add_child(_name_edit)
var helper := Label.new()
helper.text = "Shown to other players in multiplayer."
helper.add_theme_font_size_override("font_size", 12)
helper.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
var title_line := HBoxContainer.new()
title_line.name = "PlayerTitleRow"
title_line.add_theme_constant_override("separation", 10)
account_stack.add_child(title_line)
var title_label := Label.new()
title_label.name = "PlayerTitleLabel"
title_label.text = "title"
title_label.tooltip_text = "Shown below your name to other players."
title_label.custom_minimum_size.x = 128.0
title_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
title_label.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
)
account_stack.add_child(helper)
title_line.add_child(title_label)
_title_edit = LineEdit.new()
_title_edit.name = "PlayerTitleEdit"
_title_edit.max_length = NetworkProtocol.MAX_PLAYER_TITLE_LENGTH
_title_edit.placeholder_text = NetworkProfilePreferences.DEFAULT_TITLE
_title_edit.custom_minimum_size = Vector2(280, 34)
UtilityPageStyle.apply_ocean_line_edit(_title_edit)
_title_edit.add_theme_stylebox_override(
"normal", UtilityPageStyle.ocean_button_style(
UtilityPageStyle.OCEAN_PANEL_MID
)
)
_title_edit.text_changed.connect(_on_title_changed)
title_line.add_child(_title_edit)
_name_status = Label.new()
_name_status.add_theme_font_size_override("font_size", 12)
_name_status.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
)
_name_status.name = "PlayerNameStatus"
_name_status.visible = false
account_stack.add_child(_name_status)
_suggestions = HBoxContainer.new()
_suggestions.add_theme_constant_override("separation", 6)
account_stack.add_child(_suggestions)
var account_info_stack := VBoxContainer.new()
account_info_stack.name = "ProfileIdentityStack"
account_info_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
account_info_stack.add_theme_constant_override("separation", 7)
account_row.add_child(account_info_stack)
var identity_value := Label.new()
identity_value.name = "IdentityFingerprint"
identity_value.text = "identity • stored on this device"
@ -656,11 +702,14 @@ func _build_ui() -> void:
identity_value.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
)
account_stack.add_child(identity_value)
identity_value.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
account_info_stack.add_child(identity_value)
var experience_row := HBoxContainer.new()
experience_row.name = "ProfileExperienceRow"
experience_row.alignment = BoxContainer.ALIGNMENT_END
experience_row.add_theme_constant_override("separation", 9)
account_stack.add_child(experience_row)
account_info_stack.add_child(experience_row)
_experience_level = Label.new()
_experience_level.custom_minimum_size.x = 64.0
_experience_level.add_theme_font_size_override("font_size", 14)
@ -695,7 +744,7 @@ func _build_ui() -> void:
actions.alignment = BoxContainer.ALIGNMENT_END
actions.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
actions.add_theme_constant_override("separation", 7)
account_row.add_child(actions)
account_info_stack.add_child(actions)
_customize_button = Button.new()
_customize_button.text = "customize"
_customize_button.custom_minimum_size.x = 96.0
@ -2127,10 +2176,16 @@ func _on_name_changed(value: String) -> void:
_refresh_actions()
_clear_suggestions()
if NetworkProfilePreferences.is_valid_display_name(value):
_name_status.text = "Checking this game…"
_set_name_status("Checking this game…")
_debounce.start()
else:
_name_status.text = "Use 124 plain-text characters."
_set_name_status("Use 124 plain-text characters.")
func _on_title_changed(value: String) -> void:
_draft_title = value
_dirty = _draft_differs()
_refresh_actions()
func _request_conflict_check() -> void:
@ -2145,9 +2200,9 @@ func _on_conflict_result(
) -> void:
_clear_suggestions()
if not has_conflict:
_name_status.text = "Name is available in this game."
_set_name_status("Name is available in this game.")
return
_name_status.text = "That name is already in use in this game."
_set_name_status("That name is already in use in this game.")
for suggestion: String in suggestion_names:
var button := Button.new()
button.text = suggestion
@ -2158,7 +2213,7 @@ func _on_conflict_result(
anyway.text = "use anyway"
anyway.pressed.connect(func() -> void:
_allow_duplicate = true
_name_status.text = "Duplicate name allowed for this apply."
_set_name_status("Duplicate name allowed for this apply.")
)
UtilityPageStyle.apply_compact_ocean_button(anyway)
_suggestions.add_child(anyway)
@ -2209,12 +2264,13 @@ func _apply() -> void:
_draft_speech_speed_id,
_draft_call_id,
_draft_sample_set_id,
_draft_title,
)
func _on_apply_finished(accepted: bool, message: String) -> void:
_apply_button.disabled = false
_name_status.text = message
_set_name_status(message)
if accepted:
_load_persisted()
@ -2223,6 +2279,7 @@ func _load_persisted() -> void:
if _service == null:
return
_persisted_name = _service.get_persisted_name()
_persisted_title = _service.get_persisted_title()
_persisted_appearance = _service.get_persisted_appearance()
_persisted_voice_id = _service.get_persisted_voice_id()
_persisted_speech_speed_id = (
@ -2231,6 +2288,7 @@ func _load_persisted() -> void:
_persisted_call_id = _service.get_persisted_call_id()
_persisted_sample_set_id = _service.get_persisted_sample_set_id()
_draft_name = _persisted_name
_draft_title = _persisted_title
_draft_appearance = _persisted_appearance.duplicate(true)
_draft_voice_id = _persisted_voice_id
_draft_speech_speed_id = _persisted_speech_speed_id
@ -2240,12 +2298,13 @@ func _load_persisted() -> void:
VoiceProfilesType.speed_for(_persisted_speech_speed_id)
)
_name_edit.text = _draft_name
_title_edit.text = _draft_title
_preview.apply_appearance_profile(_draft_appearance)
_appearance_preview_timer.stop()
_service.preview_appearance(_persisted_appearance)
_dirty = false
_allow_duplicate = false
_name_status.text = ""
_set_name_status("")
_clear_suggestions()
_refresh_options()
_refresh_actions()
@ -2288,6 +2347,8 @@ func _confirm_pending_action() -> void:
_draft_speech_speed_id = VoiceProfilesType.DEFAULT_SPEED_ID
_draft_call_id = VoiceProfilesType.DEFAULT_CALL_ID
_draft_sample_set_id = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID
_draft_title = NetworkProfilePreferences.DEFAULT_TITLE
_title_edit.text = _draft_title
_preview.apply_appearance_profile(_draft_appearance)
_queue_draft_appearance_preview()
_dirty = _draft_differs()
@ -2318,6 +2379,7 @@ func _publish_draft_appearance() -> void:
func _draft_differs() -> bool:
return (
_draft_name.strip_edges() != _persisted_name
or _draft_title.strip_edges() != _persisted_title
or _draft_appearance != _persisted_appearance
or _draft_voice_id != _persisted_voice_id
or _draft_speech_speed_id != _persisted_speech_speed_id
@ -2330,6 +2392,7 @@ func _refresh_actions() -> void:
_apply_button.disabled = (
not _dirty
or not NetworkProfilePreferences.is_valid_display_name(_draft_name)
or not NetworkProfilePreferences.is_valid_title(_draft_title)
)
_revert_button.disabled = not _dirty
@ -2339,3 +2402,12 @@ func _clear_suggestions() -> void:
_suggestions.remove_child(child)
child.queue_free()
_apply_controller_zone_focus()
func _set_name_status(message: String) -> void:
_name_status.text = message
_name_label.tooltip_text = (
NAME_TOOLTIP_BASE
if message.is_empty()
else "%s\n%s" % [NAME_TOOLTIP_BASE, message]
)

View file

@ -34,6 +34,7 @@ enum SeedMode {
@onready var _empty_slots_label: Label = %EmptySlotsLabel
@onready var _slot_name_edit: LineEdit = %SelectedSlotName
@onready var _slot_summary: RichTextLabel = %SlotSummary
@onready var _online_slot_button: CheckButton = %OnlineSlotButton
@onready var _play_button: Button = %PlaySlotButton
@onready var _rename_button: Button = %RenameSlotButton
@onready var _duplicate_button: Button = %DuplicateSlotButton
@ -75,6 +76,7 @@ func _ready() -> void:
_saves_tab.pressed.connect(_select_page.bind(PAGE_SAVES, true))
_new_slot_tab.pressed.connect(_open_fresh_slot_page)
_play_button.pressed.connect(_request_play)
_online_slot_button.toggled.connect(_on_online_slot_toggled)
_rename_button.pressed.connect(_rename_selected_slot)
_duplicate_button.pressed.connect(_prepare_duplicate)
_export_button.pressed.connect(_choose_export_path)
@ -285,9 +287,14 @@ func _refresh_slots() -> void:
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.focus_mode = Control.FOCUS_ALL
button.toggle_mode = true
button.text = str(slot.get("display_name", "save")) + (
" · active" if bool(slot.get("active", false)) else ""
)
var markers: Array[String] = []
if bool(slot.get("active", false)):
markers.append("loaded")
if bool(slot.get("online", false)):
markers.append("online")
button.text = str(slot.get("display_name", "save"))
if not markers.is_empty():
button.text += " · " + " · ".join(markers)
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
UtilityPageStyle.apply_ocean_button(button)
button.pressed.connect(_select_slot.bind(slot_id, false))
@ -321,10 +328,14 @@ func _refresh_selected_slot() -> void:
_slot_name_edit.text = ""
_slot_name_edit.editable = false
_slot_summary.text = "[center]create or import a save slot to begin.[/center]"
_online_slot_button.set_pressed_no_signal(false)
else:
_slot_name_edit.editable = true
_slot_name_edit.text = str(slot.get("display_name", "save"))
_slot_summary.text = _format_slot_summary(slot)
_online_slot_button.set_pressed_no_signal(
bool(slot.get("online", false))
)
_refresh_action_state()
@ -337,9 +348,9 @@ func _format_slot_summary(slot: Dictionary) -> String:
int(slot.get("last_played_at_unix", 0))
)
return (
"[font_size=24]level %d[/font_size]\n\n"
"[font_size=24]level %d[/font_size]\n"
+ "%d catches · %d discovered\n"
+ "%d fishcoins\n\n"
+ "%d fishcoins\n"
+ "%s · seed %d\n"
+ "last played %s"
) % [
@ -358,6 +369,7 @@ func _refresh_action_state() -> void:
var has_slot: bool = not slot.is_empty()
var has_save: bool = has_slot and bool(slot.get("has_save", false))
_play_button.disabled = _busy or not has_save
_online_slot_button.disabled = _busy or not has_save
_rename_button.disabled = _busy or not has_slot
_duplicate_button.disabled = _busy or not has_save
_export_button.disabled = _busy or not has_save
@ -375,6 +387,21 @@ func _request_play() -> void:
play_requested.emit(_selected_slot_id)
func _on_online_slot_toggled(enabled: bool) -> void:
if _catalog == null or _selected_slot_id.is_empty():
return
if not enabled:
_online_slot_button.set_pressed_no_signal(
_catalog.get_online_slot_id() == _selected_slot_id
)
return
if not _catalog.mark_slot_for_online(_selected_slot_id):
_status.text = "the online progression could not be changed."
_online_slot_button.set_pressed_no_signal(false)
return
_status.text = "this progression will be used when joining online games."
func _rename_selected_slot() -> void:
if _catalog == null or _rename_button.disabled:
return
@ -644,8 +671,9 @@ func _configure_controller_focus() -> void:
var bottom: Control = _import_button if index == _slot_buttons.size() - 1 else _slot_buttons[index + 1]
_set_neighbors(button, button, _slot_name_edit, top, bottom)
_set_neighbors(_import_button, _import_button, _slot_name_edit, last, _back_button)
_set_neighbors(_slot_name_edit, first, _slot_name_edit, _saves_tab, _play_button)
_set_neighbors(_play_button, _import_button, _play_button, _slot_name_edit, _rename_button)
_set_neighbors(_slot_name_edit, first, _slot_name_edit, _saves_tab, _online_slot_button)
_set_neighbors(_online_slot_button, _import_button, _online_slot_button, _slot_name_edit, _play_button)
_set_neighbors(_play_button, _import_button, _play_button, _online_slot_button, _rename_button)
_set_neighbors(_rename_button, _import_button, _duplicate_button, _play_button, _export_button)
_set_neighbors(_duplicate_button, _rename_button, _duplicate_button, _play_button, _delete_button)
_set_neighbors(_export_button, _import_button, _delete_button, _rename_button, _back_button)

View file

@ -140,7 +140,7 @@ layout_mode = 2
[node name="DetailsColumn" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 10
theme_override_constants/separation = 8
[node name="Title" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"]
layout_mode = 2
@ -165,6 +165,14 @@ scroll_active = false
autowrap_mode = 2
vertical_alignment = 1
[node name="OnlineSlotButton" type="CheckButton" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 44)
layout_mode = 2
focus_mode = 2
text = "use this save online"
tooltip_text = "Use this save when joining an online room"
[node name="Actions" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"]
unique_name_in_owner = true
layout_mode = 2
@ -330,7 +338,7 @@ text = "create and play"
[node name="FooterGroup" type="MarginContainer" parent="MainPanel/OuterMargin/Layout"]
layout_mode = 2
theme_override_constants/margin_top = 34
theme_override_constants/margin_top = 32
[node name="FooterLayout" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/FooterGroup"]
layout_mode = 2

View file

@ -628,6 +628,7 @@ func _prepare_awaiting_start_input() -> void:
_continue_stats_drawer.hide()
_continue_stats_drawer.modulate.a = 0.0
_continue_stats_drawer.scale = Vector2(1.0, 0.001)
_version_row.show()
_main_content.show()
_start_prompt_center.show()
_start_prompt_center.modulate.a = 1.0
@ -927,6 +928,7 @@ func _on_continue_pressed() -> void:
_release_primary_menu_focus()
_presentation_center.hide()
_start_prompt_center.hide()
_version_row.hide()
_join_game_page.close_page()
_credits_page.close_page()
_new_game_setup_page.close_page()
@ -935,6 +937,7 @@ func _on_continue_pressed() -> void:
func _close_save_slots() -> void:
_save_slots_page.close_page()
_version_row.show()
_presentation_center.show()
_button_center.show()
_start_prompt_center.hide()

View file

@ -4,6 +4,7 @@ extends RefCounted
const DEFAULT_CHARACTERS_PER_SECOND: float = 28.0
const MIN_CHARACTERS_PER_SECOND: float = 12.0
const MAX_CHARACTERS_PER_SECOND: float = 60.0
const ACTIVE_TWEEN_META: StringName = &"typewriter_reveal_active_tween"
static var _characters_per_second: float = DEFAULT_CHARACTERS_PER_SECOND
@ -26,6 +27,7 @@ static func start(
) -> float:
if label == null:
return 0.0
cancel(label, false)
var character_count: int = label.get_total_character_count()
if character_count <= 0:
label.visible_characters = -1
@ -38,10 +40,39 @@ static func start(
)
var duration := float(character_count) / resolved_characters_per_second
var reveal_tween := label.create_tween()
label.set_meta(ACTIVE_TWEEN_META, reveal_tween)
reveal_tween.tween_property(
label,
"visible_characters",
character_count,
duration,
).from(0).set_trans(Tween.TRANS_LINEAR)
reveal_tween.finished.connect(
_on_reveal_finished.bind(label, reveal_tween),
CONNECT_ONE_SHOT,
)
return duration
static func cancel(label: Label, reveal_all: bool = true) -> void:
if label == null:
return
var reveal_tween: Tween
if label.has_meta(ACTIVE_TWEEN_META):
reveal_tween = label.get_meta(ACTIVE_TWEEN_META) as Tween
if reveal_tween != null and reveal_tween.is_valid():
reveal_tween.kill()
if label.has_meta(ACTIVE_TWEEN_META):
label.remove_meta(ACTIVE_TWEEN_META)
if reveal_all:
label.visible_characters = -1
static func _on_reveal_finished(label: Label, reveal_tween: Tween) -> void:
if label == null or not is_instance_valid(label):
return
if (
label.has_meta(ACTIVE_TWEEN_META)
and label.get_meta(ACTIVE_TWEEN_META) == reveal_tween
):
label.remove_meta(ACTIVE_TWEEN_META)