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

@ -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")