From 2b5b0a63ecf38df9802594519eeeedb7ad488f0e Mon Sep 17 00:00:00 2001 From: Voyager Date: Wed, 2 Sep 2026 12:39:23 -0400 Subject: [PATCH] Add rare beach message bottles --- items/catalog/item_catalog.tres | 5 +- items/catalog/message_bottle.tres | 17 + main/main.gd | 80 ++- main/main.tscn | 12 + messages/beach_bottle_presentation.gd | 68 ++ messages/beach_bottle_presentation.gd.uid | 1 + messages/beach_bottle_presentation.tscn | 17 + messages/beach_bottle_service.gd | 619 ++++++++++++++++++ messages/beach_bottle_service.gd.uid | 1 + messages/bottle_message_catalog.gd | 61 ++ messages/bottle_message_catalog.gd.uid | 1 + messages/bottle_message_data.gd | 20 + messages/bottle_message_data.gd.uid | 1 + messages/catalog/bottle_message_catalog.tres | 9 + messages/catalog/entries/message_2.tres | 8 + messages/catalog/entries/pushing_squares.tres | 8 + messages/player_bottle_message_state.gd | 332 ++++++++++ messages/player_bottle_message_state.gd.uid | 1 + save/player_save_manager.gd | 93 ++- scripts/run_validations.sh | 3 + tests/beach_bottle_service_validation.gd | 202 ++++++ tests/beach_bottle_service_validation.gd.uid | 1 + tests/bottle_message_state_validation.gd | 187 ++++++ tests/bottle_message_state_validation.gd.uid | 1 + ...controller_world_interaction_validation.gd | 67 ++ tests/fish_hotbar_showcase_validation.gd | 2 +- tests/fish_quality_validation.gd | 2 +- tests/job_system_validation.gd | 2 +- tests/message_bottle_reader_validation.gd | 204 ++++++ tests/message_bottle_reader_validation.gd.uid | 1 + tests/player_experience_validation.gd | 2 +- tests/progression_archive_validation.gd | 35 +- ui/game_ui.gd | 13 + ui/message_bottle_reader.gd | 119 ++++ ui/message_bottle_reader.gd.uid | 1 + ui/message_bottle_reader.tscn | 172 +++++ ui/player_menu.gd | 66 ++ ui/player_menu.tscn | 6 +- 38 files changed, 2422 insertions(+), 18 deletions(-) create mode 100644 items/catalog/message_bottle.tres create mode 100644 messages/beach_bottle_presentation.gd create mode 100644 messages/beach_bottle_presentation.gd.uid create mode 100644 messages/beach_bottle_presentation.tscn create mode 100644 messages/beach_bottle_service.gd create mode 100644 messages/beach_bottle_service.gd.uid create mode 100644 messages/bottle_message_catalog.gd create mode 100644 messages/bottle_message_catalog.gd.uid create mode 100644 messages/bottle_message_data.gd create mode 100644 messages/bottle_message_data.gd.uid create mode 100644 messages/catalog/bottle_message_catalog.tres create mode 100644 messages/catalog/entries/message_2.tres create mode 100644 messages/catalog/entries/pushing_squares.tres create mode 100644 messages/player_bottle_message_state.gd create mode 100644 messages/player_bottle_message_state.gd.uid create mode 100644 tests/beach_bottle_service_validation.gd create mode 100644 tests/beach_bottle_service_validation.gd.uid create mode 100644 tests/bottle_message_state_validation.gd create mode 100644 tests/bottle_message_state_validation.gd.uid create mode 100644 tests/message_bottle_reader_validation.gd create mode 100644 tests/message_bottle_reader_validation.gd.uid create mode 100644 ui/message_bottle_reader.gd create mode 100644 ui/message_bottle_reader.gd.uid create mode 100644 ui/message_bottle_reader.tscn diff --git a/items/catalog/item_catalog.tres b/items/catalog/item_catalog.tres index 2f15e0a..36ba164 100644 --- a/items/catalog/item_catalog.tres +++ b/items/catalog/item_catalog.tres @@ -1,4 +1,4 @@ -[gd_resource type="Resource" script_class="ItemCatalog" load_steps=42 format=3] +[gd_resource type="Resource" script_class="ItemCatalog" load_steps=43 format=3] [ext_resource type="Script" path="res://items/item_catalog.gd" id="1_script"] [ext_resource type="Resource" path="res://items/catalog/basic_fishing_rod.tres" id="2_rod"] @@ -41,7 +41,8 @@ [ext_resource type="Resource" path="res://items/catalog/standard_shovel.tres" id="40_shovel"] [ext_resource type="Resource" path="res://items/catalog/fishing_net.tres" id="41_fishing_net"] [ext_resource type="Resource" path="res://items/catalog/basic_cube.tres" id="42_basic_cube"] +[ext_resource type="Resource" path="res://items/catalog/message_bottle.tres" id="43_message_bottle"] [resource] script = ExtResource("1_script") -items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries"), ExtResource("18_cardboard"), ExtResource("19_pond"), ExtResource("20_river"), ExtResource("21_lake"), ExtResource("22_salt"), ExtResource("23_whisker"), ExtResource("24_reef"), ExtResource("25_moonbeam"), ExtResource("26_sun"), ExtResource("27_rain"), ExtResource("28_fog"), ExtResource("29_guide"), ExtResource("30_small"), ExtResource("31_heavy"), ExtResource("32_rocket"), ExtResource("33_lucky"), ExtResource("34_showboat"), ExtResource("35_deep"), ExtResource("36_oddity"), ExtResource("37_aurora"), ExtResource("38_magnet"), ExtResource("39_crab_net"), ExtResource("40_shovel"), ExtResource("41_fishing_net"), ExtResource("42_basic_cube")] +items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries"), ExtResource("18_cardboard"), ExtResource("19_pond"), ExtResource("20_river"), ExtResource("21_lake"), ExtResource("22_salt"), ExtResource("23_whisker"), ExtResource("24_reef"), ExtResource("25_moonbeam"), ExtResource("26_sun"), ExtResource("27_rain"), ExtResource("28_fog"), ExtResource("29_guide"), ExtResource("30_small"), ExtResource("31_heavy"), ExtResource("32_rocket"), ExtResource("33_lucky"), ExtResource("34_showboat"), ExtResource("35_deep"), ExtResource("36_oddity"), ExtResource("37_aurora"), ExtResource("38_magnet"), ExtResource("39_crab_net"), ExtResource("40_shovel"), ExtResource("41_fishing_net"), ExtResource("42_basic_cube"), ExtResource("43_message_bottle")] diff --git a/items/catalog/message_bottle.tres b/items/catalog/message_bottle.tres new file mode 100644 index 0000000..6b458fa --- /dev/null +++ b/items/catalog/message_bottle.tres @@ -0,0 +1,17 @@ +[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3] + +[ext_resource type="Script" path="res://items/item_data.gd" id="1_item"] +[ext_resource type="Texture2D" path="res://messages/assets/message_in_a_bottle.png" id="2_icon"] + +[resource] +script = ExtResource("1_item") +item_id = &"message_bottle" +display_name = "Message in a Bottle" +description = "A salt-worn bottle carrying a message from somewhere far away. Open it to read what is inside." +category = 5 +icon = ExtResource("2_icon") +stackable = false +max_stack = 1 +usable = true +equippable = false +hotbar_allowed = false diff --git a/main/main.gd b/main/main.gd index 936f5ca..20a07da 100644 --- a/main/main.gd +++ b/main/main.gd @@ -141,6 +141,15 @@ const NetworkHomeServiceType = preload( const HomeDecorControllerType = preload( "res://homes/home_decor_controller.gd" ) +const BottleMessageCatalogType = preload( + "res://messages/bottle_message_catalog.gd" +) +const PlayerBottleMessageStateType = preload( + "res://messages/player_bottle_message_state.gd" +) +const BeachBottleServiceType = preload( + "res://messages/beach_bottle_service.gd" +) const TITLE_MUSIC_SILENCE_DB: float = -80.0 const TITLE_MUSIC_PATH: String = ( @@ -167,6 +176,7 @@ const SHOP_NPC_CONVERSATION_INPUT_OWNER: StringName = &"shop_npc_conversation" @export var main_shop_buyer_profile: FishBuyerProfileType @export var item_catalog: ItemCatalogType @export var gatherable_catalog: GatherableCatalogType +@export var bottle_message_catalog: BottleMessageCatalogType @export_category("Title Music") @export_range(-40.0, 0.0, 0.5) var title_music_volume_db: float = -6.0 @export_range(0.0, 10.0, 0.05) var title_music_fade_out_seconds: float = 5.0 @@ -264,6 +274,10 @@ const SHOP_NPC_CONVERSATION_INPUT_OWNER: StringName = &"shop_npc_conversation" @onready var _players_root: Node3D = $Players @onready var _surface_drawings_root: Node3D = $SurfaceDrawings @onready var _world_gatherables_root: Node3D = $WorldGatherables +@onready var _bottle_message_state: PlayerBottleMessageStateType = ( + %PlayerBottleMessageState +) +@onready var _beach_bottles: BeachBottleServiceType = %BeachBottleService @onready var _home_world: HomeWorldServiceType = %HomeWorldService @onready var _title_background: ColorRect = %TitleBackground @@ -809,9 +823,24 @@ func _initialize_application(dedicated: bool) -> void: _player_jobs, _player, _home_state, + _bottle_message_state, _network_profile if not dedicated else null, _appearance_store if not dedicated else null, ) + _beach_bottles.setup( + _test_world, + _player, + _player.bag, + _bottle_message_state, + bottle_message_catalog, + _save_manager, + _world_gatherables_root, + dedicated, + ) + if not dedicated: + _beach_bottles.collection_failed.connect( + _on_beach_bottle_collection_failed + ) _home_world.setup( _test_world, _player_spawn_service, @@ -1028,6 +1057,7 @@ func _initialize_application(dedicated: bool) -> void: _test_world.get_world_environment(), _test_world.get_sun(), ) + _game_ui.setup_message_bottles(_beach_bottles) _home_decor_controller.setup( _player, _player.hotbar, @@ -1746,6 +1776,13 @@ func _unhandled_input(event: InputEvent) -> void: _refresh_home_door_prompt({}) get_viewport().set_input_as_handled() return + # Beach bottles use the same compact interaction prompt as villagers, but + # resolve before nearby NPCs while the player is within the tighter bottle + # reach. Collection never happens from proximity alone. + if _can_interact_with_beach_bottle(): + _beach_bottles.try_collect_nearby() + 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. @@ -1790,18 +1827,26 @@ func _process(delta: float) -> void: # 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 home_prompt := _get_home_prompt() + var show_bottle_prompt: bool = ( + home_prompt.is_empty() and _can_interact_with_beach_bottle() + ) + var show_shop_prompt := ( + not show_bottle_prompt + and _can_show_active_shop_prompt(active_shop_interaction) ) var shop_prompt_anchor := ( - active_shop_interaction.get_prompt_anchor_position() - if active_shop_interaction != null else Vector3.ZERO + _beach_bottles.get_prompt_anchor_position() + if show_bottle_prompt + else ( + active_shop_interaction.get_prompt_anchor_position() + if active_shop_interaction != null else Vector3.ZERO + ) ) _game_ui.set_shop_prompt_visible( - show_shop_prompt, + show_bottle_prompt or show_shop_prompt, shop_prompt_anchor, ) - var home_prompt := _get_home_prompt() _refresh_home_door_prompt(home_prompt) _game_ui.set_rv_storage_button_visible( _can_show_rv_storage_button() @@ -1959,6 +2004,7 @@ func _set_gameplay_active(active: bool) -> void: ) _game_ui.set_gameplay_ui_enabled(active) _save_manager.set_autosave_enabled(active) + _beach_bottles.set_gameplay_active(active) if active: _player_jobs.begin_progression_session() _refresh_active_hotbar_item() @@ -1997,6 +2043,10 @@ func _on_natural_time_advanced(advanced_hours: float) -> void: _dusk_music.play(0.0) +func _on_beach_bottle_collection_failed(message: String) -> void: + _fishing_spot.report_external_status(message) + + static func _natural_interval_crosses_hour( previous_hour: float, advanced_hours: float, @@ -2643,6 +2693,7 @@ func _apply_world( if _application_initialized: _refresh_active_world_bindings() _network_world_spawns.refresh_world_context() + _beach_bottles.refresh_world_context() _network_home.refresh_world_presentations() if _application_initialized and not _dedicated_runtime: _water_recovery.update_world_context( @@ -3055,6 +3106,7 @@ func _on_local_home_space_changed( _rain_puddles.set_suppressed(inside_rv) _surface_drawings_root.visible = not inside_rv _world_gatherables_root.visible = not inside_rv + _beach_bottles.set_interior_active(inside_rv) _refresh_active_hotbar_item() _fishing_spot.set_fishing_environment_enabled(not inside_rv) _game_ui.set_rv_interior_camera_mode( @@ -3309,6 +3361,22 @@ func _can_show_shop_prompt() -> bool: ) +func _can_interact_with_beach_bottle() -> bool: + return ( + _gameplay_started + and not _shop_conversation_camera_restoring + and not is_instance_valid(_engaged_shop_npc_interaction) + and _beach_bottles != null + and _beach_bottles.is_local_player_in_range() + and not _game_ui.get_player_storage().visible + 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 + and not _water_recovery.is_recovery_active() + and _fishing_spot.can_open_fishing_shop() + ) + + func _can_show_decor_shop_prompt() -> bool: return ( _get_active_shop_npc_interaction() == _decor_shop_interaction diff --git a/main/main.tscn b/main/main.tscn index a86500f..23565ec 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -59,6 +59,9 @@ [ext_resource type="Script" path="res://homes/home_world_service.gd" id="62_home_world"] [ext_resource type="Script" path="res://network/network_home_service.gd" id="63_network_home"] [ext_resource type="Script" path="res://homes/home_decor_controller.gd" id="64_home_decor_controller"] +[ext_resource type="Script" path="res://messages/player_bottle_message_state.gd" id="65_bottle_state"] +[ext_resource type="Script" path="res://messages/beach_bottle_service.gd" id="66_beach_bottles"] +[ext_resource type="Resource" path="res://messages/catalog/bottle_message_catalog.tres" id="67_bottle_catalog"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_title_grass_native"] shader = ExtResource("42_menu_pattern") @@ -113,6 +116,7 @@ pelican_buyer_profile = ExtResource("7_pelicans") main_shop_buyer_profile = ExtResource("12_main_shop") item_catalog = ExtResource("11_items") gatherable_catalog = ExtResource("58_gatherable_catalog") +bottle_message_catalog = ExtResource("67_bottle_catalog") [node name="TitleBackgroundLayer" type="CanvasLayer" parent="." unique_id=1690754702] unique_name_in_owner = true @@ -303,6 +307,10 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 3.3999481, 12.07081) [node name="WorldGatherables" type="Node3D" parent="."] +[node name="BeachBottleService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("66_beach_bottles") + [node name="HomeWorldService" type="Node3D" parent="."] unique_name_in_owner = true script = ExtResource("62_home_world") @@ -323,6 +331,10 @@ script = ExtResource("8_recovery") unique_name_in_owner = true script = ExtResource("9_save") +[node name="PlayerBottleMessageState" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("65_bottle_state") + [node name="PlayerHomeState" type="Node" parent="."] unique_name_in_owner = true script = ExtResource("61_home_state") diff --git a/messages/beach_bottle_presentation.gd b/messages/beach_bottle_presentation.gd new file mode 100644 index 0000000..2fd70cf --- /dev/null +++ b/messages/beach_bottle_presentation.gd @@ -0,0 +1,68 @@ +class_name BeachBottlePresentation +extends Node3D + +const VISIBLE_BASE_TUCK_PIXELS: float = 1.0 +const AUTHORED_VISIBLE_BOTTOM_PIXEL: float = 48.0 + +@onready var _sprite: Sprite3D = %BottleSprite + +var message_id: StringName +var _collecting: bool = false + + +func _ready() -> void: + _anchor_sprite_visible_bottom() + + +func configure( + reserved_message_id: StringName, + world_position: Vector3, + phase: float = 0.0, +) -> void: + message_id = reserved_message_id + global_position = world_position + rotation.y = phase + + +func _anchor_sprite_visible_bottom() -> void: + if _sprite == null or _sprite.texture == null: + return + var texture_height: float = float(_sprite.texture.get_height()) + if texture_height <= 0.0: + return + var texture_center_pixel: float = texture_height * 0.5 + # VRAM-compressed textures do not expose reliable alpha bounds at runtime. + # The authored 64px sand sprite ends at pixel 48; sink that visible baseline + # one source pixel into the sampled terrain so it cannot visibly hover. + _sprite.position.y = maxf( + ( + AUTHORED_VISIBLE_BOTTOM_PIXEL + - texture_center_pixel + - VISIBLE_BASE_TUCK_PIXELS + ) * _sprite.pixel_size, + 0.0, + ) + + +func get_collect_position() -> Vector3: + return global_position + + +func play_collected() -> void: + if _collecting: + return + _collecting = true + var tween := create_tween() + tween.set_parallel(true) + tween.set_trans(Tween.TRANS_QUAD) + tween.set_ease(Tween.EASE_IN) + tween.tween_property(self, "scale", Vector3(0.35, 0.35, 0.35), 0.22) + tween.tween_property(self, "position:y", position.y + 0.35, 0.22) + if _sprite != null: + tween.tween_property( + _sprite, + "modulate", + Color(1.0, 1.0, 1.0, 0.0), + 0.22, + ) + tween.chain().tween_callback(queue_free) diff --git a/messages/beach_bottle_presentation.gd.uid b/messages/beach_bottle_presentation.gd.uid new file mode 100644 index 0000000..9aa34c3 --- /dev/null +++ b/messages/beach_bottle_presentation.gd.uid @@ -0,0 +1 @@ +uid://dgf6xvsiarx7o diff --git a/messages/beach_bottle_presentation.tscn b/messages/beach_bottle_presentation.tscn new file mode 100644 index 0000000..9637f6e --- /dev/null +++ b/messages/beach_bottle_presentation.tscn @@ -0,0 +1,17 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://messages/beach_bottle_presentation.gd" id="1_script"] +[ext_resource type="Texture2D" path="res://messages/assets/message_in_a_bottle_sand.png" id="2_texture"] + +[node name="BeachBottlePresentation" type="Node3D"] +script = ExtResource("1_script") + +[node name="BottleSprite" type="Sprite3D" parent="."] +unique_name_in_owner = true +texture = ExtResource("2_texture") +pixel_size = 0.007 +billboard = 2 +shaded = false +double_sided = true +alpha_cut = 1 +texture_filter = 0 diff --git a/messages/beach_bottle_service.gd b/messages/beach_bottle_service.gd new file mode 100644 index 0000000..62a9043 --- /dev/null +++ b/messages/beach_bottle_service.gd @@ -0,0 +1,619 @@ +class_name BeachBottleService +extends Node + +const PRESENTATION_SCENE: PackedScene = preload( + "res://messages/beach_bottle_presentation.tscn" +) +const BottleMessageCatalogType = preload( + "res://messages/bottle_message_catalog.gd" +) +const BOTTLE_ITEM_ID: StringName = &"message_bottle" +const SAND_MATERIALS: Array[StringName] = [&"sand"] +const SPAWN_CHECK_INTERVAL_SECONDS: float = 2.0 +const MINIMUM_SPAWN_DISTANCE: float = 6.0 +const MAXIMUM_SPAWN_DISTANCE: float = 22.0 +const PICKUP_DISTANCE: float = 1.15 +const PROMPT_HEIGHT: float = 0.72 +const SURFACE_OFFSET: float = 0.0 +const MINIMUM_SURFACE_UP_DOT: float = 0.6 +const MAXIMUM_SHORE_HEIGHT_ABOVE_WATER: float = 1.25 +const SURFACE_SAMPLE_ATTEMPTS: int = 64 + +signal bottle_spawned(message_id: StringName, position: Vector3) +signal collection_failed(message: String) + +@export_category("Testing") +@export var force_spawn_for_testing: bool = false + +var _world: Node +var _player: Node3D +var _bag: PlayerBag +var _state: Node +var _catalog: BottleMessageCatalogType +var _save_manager: PlayerSaveManager +var _presentation_root: Node3D +var _presentation: BeachBottlePresentation +var _dedicated: bool = false +var _gameplay_active: bool = false +var _interior_active: bool = false +var _spawn_check_elapsed: float = 0.0 +var _pickup_failure_notified: bool = false +var _pickup_transaction_blocked: bool = false +var _surface_triangles: Array[PackedVector3Array] = [] +var _surface_cumulative_areas := PackedFloat32Array() +var _surface_total_area: float = 0.0 +var _rng := RandomNumberGenerator.new() + + +## Bottle state remains duck typed at this seam so presentation code does not +## own the save schema. Its integration contract is: +## get_pending_message_id() -> StringName +## is_pending_bottle_picked_up() -> bool +## ensure_cooldown_scheduled(now_unix, rng) -> bool +## is_bottle_due(now_unix) -> bool +## try_assign_due_message(catalog, rng, now_unix) -> BottleMessageData +## record_bottle_picked_up(now_unix, rng) -> bool +## to_save_data() -> Dictionary +## restore_from_save_data(data: Dictionary) -> bool +## The reservation and pickup methods must emit the state's changed signal. +func setup( + world: Node, + local_player: Node3D, + bag: PlayerBag, + bottle_state: Node, + bottle_catalog: BottleMessageCatalogType, + save_manager: PlayerSaveManager, + presentation_root: Node3D = null, + dedicated: bool = false, +) -> void: + _clear_presentation(false) + _world = world + _player = local_player + _bag = bag + _state = bottle_state + _catalog = bottle_catalog + _save_manager = save_manager + _presentation_root = presentation_root + _dedicated = dedicated + _rng.randomize() + refresh_world_context() + set_process(not _dedicated) + + +func set_gameplay_active(active: bool) -> void: + _gameplay_active = active and not _dedicated + _spawn_check_elapsed = SPAWN_CHECK_INTERVAL_SECONDS + _pickup_failure_notified = false + _pickup_transaction_blocked = false + if not _gameplay_active: + _clear_presentation(false) + else: + _repair_unresolvable_pending_message() + if _state != null and _state.has_method("observe_wall_clock"): + _state.call( + "observe_wall_clock", + int(Time.get_unix_time_from_system()), + ) + set_process(not _dedicated) + + +func set_interior_active(active: bool) -> void: + _interior_active = active + if _presentation != null and is_instance_valid(_presentation): + _presentation.visible = not active + if active: + _spawn_check_elapsed = 0.0 + + +func refresh_world_context() -> void: + _clear_presentation(false) + _surface_triangles.clear() + _surface_cumulative_areas = PackedFloat32Array() + _surface_total_area = 0.0 + _spawn_check_elapsed = SPAWN_CHECK_INTERVAL_SECONDS + _cache_shore_surfaces() + + +func has_active_bottle() -> bool: + return _presentation != null and is_instance_valid(_presentation) + + +func get_active_bottle_position() -> Vector3: + return ( + _presentation.get_collect_position() + if has_active_bottle() + else Vector3(INF, INF, INF) + ) + + +func is_local_player_in_range() -> bool: + return ( + _can_present() + and has_active_bottle() + and _presentation.get_collect_position().distance_to( + _player.global_position + ) <= PICKUP_DISTANCE + ) + + +func get_prompt_anchor_position() -> Vector3: + return ( + _presentation.get_collect_position() + Vector3.UP * PROMPT_HEIGHT + if has_active_bottle() + else Vector3(INF, INF, INF) + ) + + +func try_collect_nearby() -> bool: + if ( + not _can_present() + or not has_active_bottle() + or _bag == null + or _state == null + ): + return false + var bottle_position: Vector3 = _presentation.get_collect_position() + var player_position: Vector3 = _player.global_position + if bottle_position.distance_to(player_position) > PICKUP_DISTANCE: + _pickup_failure_notified = false + _pickup_transaction_blocked = false + return false + if _pickup_transaction_blocked: + return false + var message_id: StringName = _pending_message_id() + if message_id.is_empty(): + _clear_presentation(false) + return false + if not _bag.can_add_item(BOTTLE_ITEM_ID, 1): + if not _pickup_failure_notified: + _pickup_failure_notified = true + collection_failed.emit("Inventory is full.") + return false + _pickup_failure_notified = false + var bag_snapshot: Array[OwnedItem] = _bag.get_all_items() + var state_snapshot: Dictionary = _state_save_snapshot() + if not _bag.add_item(BOTTLE_ITEM_ID, 1): + _pickup_transaction_blocked = true + collection_failed.emit("The bottle could not be picked up.") + return false + if not _record_pickup(int(Time.get_unix_time_from_system())): + _rollback_pickup(bag_snapshot, state_snapshot) + _pickup_transaction_blocked = true + collection_failed.emit("The bottle could not be picked up.") + return false + if _save_manager == null or not _save_manager.save_if_dirty(): + _rollback_pickup(bag_snapshot, state_snapshot) + _pickup_transaction_blocked = true + collection_failed.emit("The bottle could not be saved.") + return false + var collected_presentation: BeachBottlePresentation = _presentation + _presentation = null + collected_presentation.play_collected() + return true + + +func read_held_bottle() -> Dictionary: + var failure := { + "ok": false, + "text": "", + "message_id": "", + "error": "There is no message in a bottle to read.", + } + if ( + _state == null + or _catalog == null + or _bag == null + or not _state.has_method("is_pending_bottle_picked_up") + or not bool(_state.call("is_pending_bottle_picked_up")) + or not _bag.owns_item(BOTTLE_ITEM_ID) + ): + return failure + var message_id: StringName = _pending_message_id() + var message: Variant = _catalog.get_message(message_id) + if message == null: + failure["error"] = "The message inside could not be read." + return failure + var body: String = str(message.get("body")) + if body.strip_edges().is_empty(): + failure["error"] = "The message inside was blank." + return failure + var bag_snapshot: Array[OwnedItem] = _bag.get_all_items() + var state_snapshot: Dictionary = _state_save_snapshot() + if not _bag.remove_item(BOTTLE_ITEM_ID, 1): + failure["error"] = "The bottle could not be opened." + return failure + if ( + not _state.has_method("consume_pending_message") + or StringName(str(_state.call("consume_pending_message"))) != message_id + ): + _rollback_pickup(bag_snapshot, state_snapshot) + failure["error"] = "The bottle could not be opened." + return failure + if _save_manager == null or not _save_manager.save_if_dirty(): + _rollback_pickup(bag_snapshot, state_snapshot) + failure["error"] = "The opened bottle could not be saved." + return failure + return { + "ok": true, + "text": body, + "message_id": String(message_id), + "error": "", + } + + +func _process(delta: float) -> void: + if not _can_present(): + return + if has_active_bottle(): + # Collection is deliberately interaction-driven. Leaving the prompt range + # only rearms a failed save/inventory attempt for the next explicit press. + if not is_local_player_in_range(): + _pickup_failure_notified = false + _pickup_transaction_blocked = false + return + if _bag != null and _bag.owns_item(BOTTLE_ITEM_ID): + return + _spawn_check_elapsed += delta + if _spawn_check_elapsed < SPAWN_CHECK_INTERVAL_SECONDS: + return + _spawn_check_elapsed = fmod( + _spawn_check_elapsed, + SPAWN_CHECK_INTERVAL_SECONDS, + ) + _try_spawn_due_bottle() + + +func _try_spawn_due_bottle() -> bool: + if not _can_present() or has_active_bottle(): + return false + if _state == null or _catalog == null: + return false + if _state.has_method("is_pending_bottle_picked_up") and bool( + _state.call("is_pending_bottle_picked_up") + ): + return false + var now_unix: int = int(Time.get_unix_time_from_system()) + var message_id: StringName = _pending_message_id() + if message_id.is_empty(): + if force_spawn_for_testing: + if not _has_unseen_message(): + return false + else: + if not _ensure_cooldown(now_unix): + return false + if not _state.has_method("is_bottle_due") or not bool( + _state.call("is_bottle_due", now_unix) + ): + return false + if not force_spawn_for_testing and not _has_unseen_message(): + return false + if _surface_triangles.is_empty(): + _cache_shore_surfaces() + if _surface_triangles.is_empty(): + return false + var spawn_position: Vector3 = _sample_nearby_shore_position( + _player.global_position + ) + if not spawn_position.is_finite(): + return false + var state_snapshot: Dictionary = _state_save_snapshot() + if message_id.is_empty(): + message_id = ( + _assign_unseen_message_for_testing() + if force_spawn_for_testing + else _assign_due_message(now_unix) + ) + if message_id.is_empty(): + return false + # Persist the reservation before making a collectible visible. A crash or + # world rebuild can relocate this one opportunity, but cannot mint another. + if _save_manager == null or not _save_manager.save_if_dirty(): + _restore_state_snapshot(state_snapshot) + return false + _spawn_presentation(message_id, spawn_position) + return true + + +func _spawn_presentation( + message_id: StringName, + spawn_position: Vector3, +) -> void: + var presentation := ( + PRESENTATION_SCENE.instantiate() as BeachBottlePresentation + ) + if presentation == null: + return + if _presentation_root != null and is_instance_valid(_presentation_root): + _presentation_root.add_child(presentation) + else: + add_child(presentation) + presentation.configure(message_id, spawn_position, _rng.randf_range(0.0, TAU)) + presentation.visible = not _interior_active + _presentation = presentation + bottle_spawned.emit(message_id, spawn_position) + + +func _cache_shore_surfaces() -> void: + if ( + _dedicated + or _world == null + or not _world.has_method("get_spawn_surface_triangles") + ): + return + if _world.has_method("is_world_ready") and not bool( + _world.call("is_world_ready") + ): + return + var minimum_y: float = -100.0 + var maximum_y: float = 100.0 + if _world.has_method("get_saltwater_surface_height"): + var water_height: float = float( + _world.call("get_saltwater_surface_height") + ) + if is_finite(water_height): + minimum_y = water_height + 0.01 + maximum_y = water_height + MAXIMUM_SHORE_HEIGHT_ABOVE_WATER + var values: Variant = _world.call( + "get_spawn_surface_triangles", + SAND_MATERIALS, + minimum_y, + MINIMUM_SURFACE_UP_DOT, + maximum_y, + ) + if typeof(values) != TYPE_ARRAY: + return + for value: Variant in values: + if typeof(value) != TYPE_PACKED_VECTOR3_ARRAY: + continue + var triangle := value as PackedVector3Array + if triangle.size() != 3: + continue + var area: float = ( + (triangle[1] - triangle[0]).cross( + triangle[2] - triangle[0] + ).length() * 0.5 + ) + if area <= 0.000001: + continue + _surface_total_area += area + _surface_triangles.append(triangle) + _surface_cumulative_areas.append(_surface_total_area) + + +func _sample_nearby_shore_position(origin: Vector3) -> Vector3: + if _surface_triangles.is_empty() or _surface_total_area <= 0.0: + return Vector3(INF, INF, INF) + var candidates: Array[PackedVector3Array] = [] + var candidate_areas := PackedFloat32Array() + var candidate_total_area: float = 0.0 + var horizontal_origin := Vector2(origin.x, origin.z) + for triangle: PackedVector3Array in _surface_triangles: + var center: Vector3 = (triangle[0] + triangle[1] + triangle[2]) / 3.0 + var center_2d := Vector2(center.x, center.z) + var radius: float = 0.0 + for vertex: Vector3 in triangle: + radius = maxf( + radius, + center_2d.distance_to(Vector2(vertex.x, vertex.z)), + ) + var center_distance: float = horizontal_origin.distance_to(center_2d) + if ( + center_distance - radius > MAXIMUM_SPAWN_DISTANCE + or center_distance + radius < MINIMUM_SPAWN_DISTANCE + ): + continue + var area: float = ( + (triangle[1] - triangle[0]).cross( + triangle[2] - triangle[0] + ).length() * 0.5 + ) + if area <= 0.000001: + continue + candidate_total_area += area + candidates.append(triangle) + candidate_areas.append(candidate_total_area) + if candidates.is_empty() or candidate_total_area <= 0.0: + return Vector3(INF, INF, INF) + for _attempt: int in SURFACE_SAMPLE_ATTEMPTS: + var roll: float = _rng.randf() * candidate_total_area + var index: int = clampi( + candidate_areas.bsearch(roll), + 0, + candidates.size() - 1, + ) + var point: Vector3 = sample_triangle( + candidates[index], + _rng.randf(), + _rng.randf(), + ) + var distance := Vector2( + point.x - origin.x, + point.z - origin.z, + ).length() + if ( + distance >= MINIMUM_SPAWN_DISTANCE + and distance <= MAXIMUM_SPAWN_DISTANCE + ): + return point + Vector3.UP * SURFACE_OFFSET + return Vector3(INF, INF, INF) + + +static func sample_triangle( + triangle: PackedVector3Array, + root_roll: float, + edge_roll: float, +) -> Vector3: + if triangle.size() != 3: + return Vector3(INF, INF, INF) + var root: float = sqrt(clampf(root_roll, 0.0, 1.0)) + var b: float = root * (1.0 - clampf(edge_roll, 0.0, 1.0)) + var c: float = root - b + return triangle[0] * (1.0 - root) + triangle[1] * b + triangle[2] * c + + +func _can_present() -> bool: + return ( + not _dedicated + and _gameplay_active + and not _interior_active + and _world != null + and _player != null + and is_instance_valid(_player) + and _bag != null + and _state != null + ) + + +func _pending_message_id() -> StringName: + if _state == null: + return StringName() + for method_name: StringName in [ + &"get_pending_message_id", + &"get_reserved_message_id", + ]: + if _state.has_method(method_name): + return StringName(str(_state.call(method_name))) + return StringName() + + +func _has_unseen_message() -> bool: + if ( + _state == null + or _catalog == null + or not _state.has_method("get_received_message_ids") + ): + return false + var received: Array[StringName] = [] + var values: Variant = _state.call("get_received_message_ids") + if typeof(values) != TYPE_ARRAY: + return false + for value: Variant in values as Array: + if typeof(value) in [TYPE_STRING, TYPE_STRING_NAME]: + received.append(StringName(str(value))) + return not _catalog.get_active_unseen_messages(received).is_empty() + + +func _repair_unresolvable_pending_message() -> void: + var message_id: StringName = _pending_message_id() + if ( + message_id.is_empty() + or _catalog == null + or _catalog.get_message(message_id) != null + or _state == null + or _bag == null + or not _state.has_method("consume_pending_message") + ): + return + var bag_snapshot: Array[OwnedItem] = _bag.get_all_items() + var state_snapshot: Dictionary = _state_save_snapshot() + if _bag.owns_item(BOTTLE_ITEM_ID) and not _bag.remove_item( + BOTTLE_ITEM_ID, 1 + ): + return + if StringName(str(_state.call("consume_pending_message"))) != message_id: + _rollback_pickup(bag_snapshot, state_snapshot) + return + if _save_manager == null or not _save_manager.save_if_dirty(): + _rollback_pickup(bag_snapshot, state_snapshot) + return + push_warning( + "Cleared a pending message bottle whose catalog entry no longer exists." + ) + + +func _ensure_cooldown(now_unix: int) -> bool: + if _state == null or not _state.has_method("ensure_cooldown_scheduled"): + return false + var snapshot: Dictionary = _state_save_snapshot() + if not bool(_state.call("ensure_cooldown_scheduled", now_unix, _rng)): + return false + if _save_manager != null and _save_manager.save_if_dirty(): + return true + _restore_state_snapshot(snapshot) + return false + + +func _assign_due_message(now_unix: int) -> StringName: + if ( + _state == null + or _catalog == null + or not _state.has_method("try_assign_due_message") + ): + return StringName() + var message: Variant = _state.call( + "try_assign_due_message", + _catalog, + _rng, + now_unix, + ) + if message == null: + return StringName() + return StringName(str(message.get("message_id"))) + + +func _assign_unseen_message_for_testing() -> StringName: + if ( + _state == null + or _catalog == null + or not _state.has_method("assign_unseen_message_for_testing") + ): + return StringName() + var message: Variant = _state.call( + "assign_unseen_message_for_testing", + _catalog, + _rng, + ) + if message == null: + return StringName() + return StringName(str(message.get("message_id"))) + + +func _record_pickup(now_unix: int) -> bool: + if _state == null or not _state.has_method("record_bottle_picked_up"): + return false + var result: Variant = _state.call( + "record_bottle_picked_up", + now_unix, + _rng, + ) + return typeof(result) == TYPE_BOOL and bool(result) + + +func _state_save_snapshot() -> Dictionary: + if _state != null and _state.has_method("to_save_data"): + var value: Variant = _state.call("to_save_data") + if typeof(value) == TYPE_DICTIONARY: + return (value as Dictionary).duplicate(true) + return {} + + +func _restore_state_snapshot(snapshot: Dictionary) -> bool: + return ( + _state != null + and not snapshot.is_empty() + and _state.has_method("restore_from_save_data") + and bool(_state.call("restore_from_save_data", snapshot)) + ) + + +func _rollback_pickup( + bag_snapshot: Array[OwnedItem], + state_snapshot: Dictionary, +) -> void: + if _bag != null: + _bag.replace_all_items(bag_snapshot) + _restore_state_snapshot(state_snapshot) + if _save_manager != null: + _save_manager.save_if_dirty() + + +func _clear_presentation(play_collected: bool) -> void: + if _presentation == null or not is_instance_valid(_presentation): + _presentation = null + return + var previous: BeachBottlePresentation = _presentation + _presentation = null + if play_collected: + previous.play_collected() + else: + previous.queue_free() diff --git a/messages/beach_bottle_service.gd.uid b/messages/beach_bottle_service.gd.uid new file mode 100644 index 0000000..0c76c1d --- /dev/null +++ b/messages/beach_bottle_service.gd.uid @@ -0,0 +1 @@ +uid://chta0r77d4i2k diff --git a/messages/bottle_message_catalog.gd b/messages/bottle_message_catalog.gd new file mode 100644 index 0000000..b68e5aa --- /dev/null +++ b/messages/bottle_message_catalog.gd @@ -0,0 +1,61 @@ +class_name BottleMessageCatalog +extends Resource + +const BottleMessageDataType = preload( + "res://messages/bottle_message_data.gd" +) + +@export var entries: Array[BottleMessageDataType] = [] + + +func get_message(message_id: StringName) -> BottleMessageDataType: + if message_id.is_empty(): + return null + for entry: BottleMessageDataType in entries: + if ( + entry != null + and entry.is_valid() + and entry.message_id == message_id + ): + return entry + return null + + +func get_active_unseen_messages( + received_message_ids: Array[StringName], +) -> Array[BottleMessageDataType]: + var received: Dictionary[StringName, bool] = {} + for message_id: StringName in received_message_ids: + received[message_id] = true + var result: Array[BottleMessageDataType] = [] + var included: Dictionary[StringName, bool] = {} + for entry: BottleMessageDataType in entries: + if ( + entry == null + or not entry.active + or not entry.is_valid() + or received.has(entry.message_id) + or included.has(entry.message_id) + ): + continue + included[entry.message_id] = true + result.append(entry) + result.sort_custom( + func(first: BottleMessageDataType, second: BottleMessageDataType) -> bool: + return String(first.message_id) < String(second.message_id) + ) + return result + + +func choose_random_unseen_message( + received_message_ids: Array[StringName], + rng: RandomNumberGenerator, +) -> BottleMessageDataType: + if rng == null: + return null + var candidates: Array[BottleMessageDataType] = get_active_unseen_messages( + received_message_ids + ) + if candidates.is_empty(): + return null + return candidates[rng.randi_range(0, candidates.size() - 1)] diff --git a/messages/bottle_message_catalog.gd.uid b/messages/bottle_message_catalog.gd.uid new file mode 100644 index 0000000..13c78e4 --- /dev/null +++ b/messages/bottle_message_catalog.gd.uid @@ -0,0 +1 @@ +uid://c2vu7o6y4th61 diff --git a/messages/bottle_message_data.gd b/messages/bottle_message_data.gd new file mode 100644 index 0000000..5de5e13 --- /dev/null +++ b/messages/bottle_message_data.gd @@ -0,0 +1,20 @@ +class_name BottleMessageData +extends Resource + +const MAX_MESSAGE_ID_LENGTH: int = 96 +const MAX_BODY_LENGTH: int = 4096 + +@export var message_id: StringName +@export_multiline var body: String = "" +@export var active: bool = true + + +func is_valid() -> bool: + var normalized_id: String = String(message_id).strip_edges() + return ( + not normalized_id.is_empty() + and normalized_id.length() <= MAX_MESSAGE_ID_LENGTH + and normalized_id == String(message_id) + and not body.strip_edges().is_empty() + and body.length() <= MAX_BODY_LENGTH + ) diff --git a/messages/bottle_message_data.gd.uid b/messages/bottle_message_data.gd.uid new file mode 100644 index 0000000..a8d04fd --- /dev/null +++ b/messages/bottle_message_data.gd.uid @@ -0,0 +1 @@ +uid://c044pmgt5w1un diff --git a/messages/catalog/bottle_message_catalog.tres b/messages/catalog/bottle_message_catalog.tres new file mode 100644 index 0000000..dd8ad3e --- /dev/null +++ b/messages/catalog/bottle_message_catalog.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="BottleMessageCatalog" load_steps=4 format=3] + +[ext_resource type="Script" path="res://messages/bottle_message_catalog.gd" id="1_catalog"] +[ext_resource type="Resource" path="res://messages/catalog/entries/pushing_squares.tres" id="2_pushing_squares"] +[ext_resource type="Resource" path="res://messages/catalog/entries/message_2.tres" id="3_message_2"] + +[resource] +script = ExtResource("1_catalog") +entries = [ExtResource("2_pushing_squares"), ExtResource("3_message_2")] diff --git a/messages/catalog/entries/message_2.tres b/messages/catalog/entries/message_2.tres new file mode 100644 index 0000000..823def7 --- /dev/null +++ b/messages/catalog/entries/message_2.tres @@ -0,0 +1,8 @@ +[gd_resource type="Resource" script_class="BottleMessageData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://messages/bottle_message_data.gd" id="1_message"] + +[resource] +script = ExtResource("1_message") +message_id = &"message_2" +body = "01101001 00100000 01101100 01101111 01110110 01100101 00100000 01111001 01101111 01110101" diff --git a/messages/catalog/entries/pushing_squares.tres b/messages/catalog/entries/pushing_squares.tres new file mode 100644 index 0000000..cd5c4ef --- /dev/null +++ b/messages/catalog/entries/pushing_squares.tres @@ -0,0 +1,8 @@ +[gd_resource type="Resource" script_class="BottleMessageData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://messages/bottle_message_data.gd" id="1_message"] + +[resource] +script = ExtResource("1_message") +message_id = &"pushing_squares" +body = "it's as simple as pushing squares" diff --git a/messages/player_bottle_message_state.gd b/messages/player_bottle_message_state.gd new file mode 100644 index 0000000..728272b --- /dev/null +++ b/messages/player_bottle_message_state.gd @@ -0,0 +1,332 @@ +class_name PlayerBottleMessageState +extends Node + +signal changed + +const BottleMessageCatalogType = preload( + "res://messages/bottle_message_catalog.gd" +) +const BottleMessageDataType = preload( + "res://messages/bottle_message_data.gd" +) + +const SAVE_SCHEMA_VERSION: int = 1 +const MINIMUM_COOLDOWN_SECONDS: int = 3 * 24 * 60 * 60 +const MAXIMUM_COOLDOWN_SECONDS: int = 7 * 24 * 60 * 60 +const BACKWARD_CLOCK_TOLERANCE_SECONDS: int = 5 * 60 +const CLOCK_OBSERVATION_CHECKPOINT_SECONDS: int = 15 * 60 +const MAX_MESSAGE_ID_LENGTH: int = 96 +const MAX_RECEIVED_MESSAGE_IDS: int = 4096 +const MAX_SUPPORTED_UNIX_TIME: int = 32503680000 + +var _received_message_ids: Array[StringName] = [] +var _pending_message_id: StringName +var _pending_bottle_picked_up: bool = false +var _next_bottle_at_unix: int = 0 +var _last_observed_unix: int = 0 + + +func get_received_message_ids() -> Array[StringName]: + return _received_message_ids.duplicate() + + +func has_received_message(message_id: StringName) -> bool: + return _received_message_ids.has(message_id) + + +func has_pending_message() -> bool: + return not _pending_message_id.is_empty() + + +func get_pending_message_id() -> StringName: + return _pending_message_id + + +func is_pending_bottle_picked_up() -> bool: + return has_pending_message() and _pending_bottle_picked_up + + +func get_next_bottle_at_unix() -> int: + return _next_bottle_at_unix + + +func get_last_observed_unix() -> int: + return _last_observed_unix + + +func observe_wall_clock(now_unix: int) -> bool: + if not _valid_unix_time(now_unix): + return false + var changed_state: bool = false + if _last_observed_unix > 0 and now_unix < _last_observed_unix: + var rollback_seconds: int = _last_observed_unix - now_unix + if rollback_seconds <= BACKWARD_CLOCK_TOLERANCE_SECONDS: + return false + if _next_bottle_at_unix > 0: + _next_bottle_at_unix = maxi( + now_unix, + _next_bottle_at_unix - rollback_seconds, + ) + _last_observed_unix = now_unix + changed_state = true + elif ( + _last_observed_unix == 0 + or now_unix - _last_observed_unix + >= CLOCK_OBSERVATION_CHECKPOINT_SECONDS + ): + _last_observed_unix = now_unix + changed_state = true + if changed_state: + changed.emit() + return changed_state + + +func ensure_cooldown_scheduled( + now_unix: int, + rng: RandomNumberGenerator, +) -> bool: + if rng == null or not _valid_unix_time(now_unix): + return false + observe_wall_clock(now_unix) + if has_pending_message(): + return _pending_bottle_picked_up and _next_bottle_at_unix > 0 + if _next_bottle_at_unix > 0: + return true + _next_bottle_at_unix = now_unix + _random_cooldown_seconds(rng) + changed.emit() + return true + + +func is_bottle_due(now_unix: int) -> bool: + return ( + _valid_unix_time(now_unix) + and not has_pending_message() + and _next_bottle_at_unix > 0 + and now_unix >= _next_bottle_at_unix + ) + + +func try_assign_due_message( + catalog: BottleMessageCatalogType, + rng: RandomNumberGenerator, + now_unix: int, +) -> BottleMessageDataType: + if ( + catalog == null + or rng == null + or has_pending_message() + or not _valid_unix_time(now_unix) + ): + return null + observe_wall_clock(now_unix) + if _next_bottle_at_unix == 0: + ensure_cooldown_scheduled(now_unix, rng) + return null + if not is_bottle_due(now_unix): + return null + return _assign_random_unseen_message(catalog, rng) + + +func assign_unseen_message_for_testing( + catalog: BottleMessageCatalogType, + rng: RandomNumberGenerator, +) -> BottleMessageDataType: + if catalog == null or rng == null or has_pending_message(): + return null + return _assign_random_unseen_message(catalog, rng) + + +func _assign_random_unseen_message( + catalog: BottleMessageCatalogType, + rng: RandomNumberGenerator, +) -> BottleMessageDataType: + var message: BottleMessageDataType = catalog.choose_random_unseen_message( + _received_message_ids, + rng, + ) + if message == null: + return null + _pending_message_id = message.message_id + _pending_bottle_picked_up = false + _received_message_ids.append(message.message_id) + _next_bottle_at_unix = 0 + changed.emit() + return message + + +func record_bottle_picked_up( + now_unix: int, + rng: RandomNumberGenerator, +) -> bool: + if ( + rng == null + or not has_pending_message() + or _pending_bottle_picked_up + or _next_bottle_at_unix != 0 + or not _valid_unix_time(now_unix) + ): + return false + observe_wall_clock(now_unix) + _pending_bottle_picked_up = true + _next_bottle_at_unix = now_unix + _random_cooldown_seconds(rng) + changed.emit() + return true + + +func consume_pending_message() -> StringName: + var consumed_id: StringName = _pending_message_id + if consumed_id.is_empty(): + return &"" + _pending_message_id = &"" + _pending_bottle_picked_up = false + changed.emit() + return consumed_id + + +func reset_to_defaults() -> void: + _received_message_ids.clear() + _pending_message_id = &"" + _pending_bottle_picked_up = false + _next_bottle_at_unix = 0 + _last_observed_unix = 0 + changed.emit() + + +func to_save_data() -> Dictionary: + var received: Array[String] = [] + for message_id: StringName in _received_message_ids: + received.append(String(message_id)) + received.sort() + return { + "schema_version": SAVE_SCHEMA_VERSION, + "received_message_ids": received, + "pending_message_id": String(_pending_message_id), + "pending_bottle_picked_up": _pending_bottle_picked_up, + "next_bottle_at_unix": _next_bottle_at_unix, + "last_observed_unix": _last_observed_unix, + } + + +func restore_from_save_data(data: Dictionary) -> bool: + var sanitized: Dictionary = sanitize_save_data(data) + if sanitized.is_empty(): + return false + _received_message_ids.clear() + for value: Variant in sanitized["received_message_ids"] as Array: + _received_message_ids.append(StringName(str(value))) + _pending_message_id = StringName(str(sanitized["pending_message_id"])) + _pending_bottle_picked_up = bool(sanitized["pending_bottle_picked_up"]) + _next_bottle_at_unix = int(sanitized["next_bottle_at_unix"]) + _last_observed_unix = int(sanitized["last_observed_unix"]) + changed.emit() + return true + + +static func default_save_data() -> Dictionary: + return { + "schema_version": SAVE_SCHEMA_VERSION, + "received_message_ids": [], + "pending_message_id": "", + "pending_bottle_picked_up": false, + "next_bottle_at_unix": 0, + "last_observed_unix": 0, + } + + +static func sanitize_save_data(data: Dictionary) -> Dictionary: + if ( + not _is_integer_value(data.get("schema_version")) + or int(data.get("schema_version", -1)) != SAVE_SCHEMA_VERSION + or typeof(data.get("received_message_ids")) != TYPE_ARRAY + or typeof(data.get("pending_message_id")) not in [ + TYPE_STRING, TYPE_STRING_NAME, + ] + or typeof(data.get("pending_bottle_picked_up")) != TYPE_BOOL + or not _is_integer_value(data.get("next_bottle_at_unix")) + or not _is_integer_value(data.get("last_observed_unix")) + ): + return {} + var values: Array = data["received_message_ids"] + if values.size() > MAX_RECEIVED_MESSAGE_IDS: + return {} + var received: Array[String] = [] + var seen: Dictionary[String, bool] = {} + for value: Variant in values: + if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {} + var message_id: String = str(value) + if not _valid_message_id(message_id): + return {} + if seen.has(message_id): + continue + seen[message_id] = true + received.append(message_id) + var pending_message_id: String = str(data["pending_message_id"]) + var pending_bottle_picked_up: bool = bool( + data["pending_bottle_picked_up"] + ) + if ( + not pending_message_id.is_empty() + and not _valid_message_id(pending_message_id) + ): + return {} + if not pending_message_id.is_empty() and not seen.has(pending_message_id): + if received.size() >= MAX_RECEIVED_MESSAGE_IDS: + return {} + received.append(pending_message_id) + if pending_message_id.is_empty() and pending_bottle_picked_up: + return {} + var next_bottle_at_unix: int = int(data["next_bottle_at_unix"]) + var last_observed_unix: int = int(data["last_observed_unix"]) + if ( + not _valid_persisted_unix_time(next_bottle_at_unix) + or not _valid_persisted_unix_time(last_observed_unix) + or ( + not pending_message_id.is_empty() + and pending_bottle_picked_up + and next_bottle_at_unix == 0 + ) + or ( + not pending_message_id.is_empty() + and not pending_bottle_picked_up + and next_bottle_at_unix != 0 + ) + ): + return {} + return { + "schema_version": SAVE_SCHEMA_VERSION, + "received_message_ids": received, + "pending_message_id": pending_message_id, + "pending_bottle_picked_up": pending_bottle_picked_up, + "next_bottle_at_unix": next_bottle_at_unix, + "last_observed_unix": last_observed_unix, + } + + +static func _valid_message_id(message_id: String) -> bool: + return ( + not message_id.is_empty() + and message_id == message_id.strip_edges() + and message_id.length() <= MAX_MESSAGE_ID_LENGTH + ) + + +static func _valid_unix_time(value: int) -> bool: + return value > 0 and value <= MAX_SUPPORTED_UNIX_TIME + + +static func _valid_persisted_unix_time(value: int) -> bool: + return value == 0 or _valid_unix_time(value) + + +static func _is_integer_value(value: Variant) -> bool: + return typeof(value) == TYPE_INT or ( + typeof(value) == TYPE_FLOAT and is_equal_approx(value, roundf(value)) + ) + + +static func _random_cooldown_seconds(rng: RandomNumberGenerator) -> int: + return rng.randi_range( + MINIMUM_COOLDOWN_SECONDS, + MAXIMUM_COOLDOWN_SECONDS, + ) diff --git a/messages/player_bottle_message_state.gd.uid b/messages/player_bottle_message_state.gd.uid new file mode 100644 index 0000000..dc232ed --- /dev/null +++ b/messages/player_bottle_message_state.gd.uid @@ -0,0 +1 @@ +uid://bsvhqsqeu11pu diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index 6383920..e6ef426 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -42,11 +42,15 @@ const NetworkProfilePreferencesType = preload( const PlayerAppearanceStoreType = preload( "res://progression/player_appearance_store.gd" ) +const PlayerBottleMessageStateType = preload( + "res://messages/player_bottle_message_state.gd" +) -const SAVE_VERSION: int = 11 +const SAVE_VERSION: int = 12 const LEGACY_SAVE_FILENAME := "player_save.json" const ARCHIVE_EXTENSION := ".nfsave" const BASIC_ROD_ID: StringName = &"basic_fishing_rod" +const MESSAGE_BOTTLE_ITEM_ID: StringName = &"message_bottle" const MAX_SAFE_BALANCE: int = 1000000000000 const DEFAULT_WORLD_SEED: int = 13001 const MAX_WORLD_SEED: int = 2147483646 @@ -85,6 +89,10 @@ class LoadSnapshot: ) var jobs_data: Dictionary = PlayerJobServiceType.default_save_data() var home_data: Dictionary = PlayerHomeStateType.default_save_data() + var bottle_messages_data: Dictionary = ( + PlayerBottleMessageStateType.default_save_data() + ) + var requires_resave: bool = false var has_character_data: bool = false var character_profile_data: Dictionary = {} var appearance_data: Dictionary = {} @@ -111,6 +119,7 @@ var _player: PlayerType var _home_state: PlayerHomeStateType var _network_profile: NetworkProfilePreferencesType var _appearance_store: PlayerAppearanceStoreType +var _bottle_messages: PlayerBottleMessageStateType var _legacy_character_data: Dictionary = {} var _autosave_timer: Timer var _is_configured: bool = false @@ -192,6 +201,7 @@ func setup( jobs: PlayerJobServiceType, player: PlayerType, home_state: PlayerHomeStateType, + bottle_messages: PlayerBottleMessageStateType, network_profile: NetworkProfilePreferencesType = null, appearance_store: PlayerAppearanceStoreType = null, ) -> void: @@ -212,6 +222,7 @@ func setup( _jobs = jobs _player = player _home_state = home_state + _bottle_messages = bottle_messages _network_profile = network_profile _appearance_store = appearance_store capture_legacy_character_fallback() @@ -233,6 +244,7 @@ func setup( and _jobs != null and _player != null and _home_state != null + and _bottle_messages != null ) if not _is_configured: push_error("PlayerSaveManager setup is missing required references.") @@ -281,6 +293,8 @@ 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 not _bottle_messages.changed.is_connected(_mark_dirty): + _bottle_messages.changed.connect(_mark_dirty) if ( _network_profile != null and not _network_profile.profile_changed.is_connected(_mark_dirty) @@ -493,6 +507,11 @@ func load_player_data() -> bool: var home_restored: bool = _home_state.restore_from_save_data( snapshot.home_data ) + var bottle_messages_restored: bool = ( + _bottle_messages.restore_from_save_data( + snapshot.bottle_messages_data + ) + ) var cooler_restored: bool = _cooler_capacity.restore_level( snapshot.cooler_capacity_level ) @@ -540,6 +559,7 @@ func load_player_data() -> bool: or not world_weather_restored or not jobs_restored or not home_restored + or not bottle_messages_restored or not character_restored ): push_error( @@ -547,7 +567,7 @@ func load_player_data() -> bool: "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 character=%s" + + "time=%s weather=%s jobs=%s home=%s bottles=%s character=%s" ) % [ inventory_restored, @@ -565,12 +585,13 @@ func load_player_data() -> bool: world_weather_restored, jobs_restored, home_restored, + bottle_messages_restored, character_restored, ] ) return false - _is_dirty = false + _is_dirty = snapshot.requires_resave if read_path == _legacy_save_path(): _migrate_legacy_plaintext_save(save_data, read_path) print( @@ -885,8 +906,16 @@ func delete_progression_save() -> bool: func set_autosave_enabled(enabled: bool) -> void: _autosave_enabled = enabled - if not enabled and _autosave_timer != null: + if _autosave_timer == null: + return + if not enabled: _autosave_timer.stop() + elif ( + _is_dirty + and not _automatic_saving_blocked + and _autosave_timer.is_stopped() + ): + _autosave_timer.start(maxf(autosave_delay, 0.05)) func save_if_dirty() -> bool: @@ -1074,6 +1103,7 @@ func _build_save_dictionary() -> Dictionary: }, "jobs": _jobs.to_save_data(), "home": _home_state.to_save_data(), + "message_bottles": _bottle_messages.to_save_data(), } var character: Dictionary = _current_character_data() if _network_profile != null and _appearance_store != null: @@ -1095,6 +1125,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: or typeof(save_data.get("experience")) != TYPE_DICTIONARY or typeof(save_data.get("jobs")) != TYPE_DICTIONARY or typeof(save_data.get("home")) != TYPE_DICTIONARY + or typeof(save_data.get("message_bottles")) != TYPE_DICTIONARY ): return null var wallet_data: Dictionary = save_data["wallet"] @@ -1107,6 +1138,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: var experience_data: Dictionary = save_data["experience"] var jobs_data: Dictionary = save_data["jobs"] var home_data: Dictionary = save_data["home"] + var bottle_messages_data: Dictionary = save_data["message_bottles"] var world_data: Dictionary = {} if typeof(save_data.get("world")) == TYPE_DICTIONARY: world_data = save_data["world"] @@ -1206,6 +1238,14 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: if sanitized_home.is_empty(): return null snapshot.home_data = sanitized_home + var sanitized_bottle_messages: Dictionary = ( + PlayerBottleMessageStateType.sanitize_save_data( + bottle_messages_data + ) + ) + if sanitized_bottle_messages.is_empty(): + return null + snapshot.bottle_messages_data = sanitized_bottle_messages if world_data.has("time_hours"): snapshot.world_time_hours = _read_world_time_hours( world_data["time_hours"] @@ -1407,6 +1447,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: ) seen_items[item_id] = true snapshot.bag_items.append(owned) + _reconcile_bottle_message_ownership(snapshot) var seen_unlocked_baits: Dictionary[StringName, bool] = {} if bag_data.has("unlocked_bait_ids"): @@ -1567,6 +1608,8 @@ func _migrate_save( migrated = _migrate_version_9_to_10(migrated) 10: migrated = _migrate_version_10_to_11(migrated) + 11: + migrated = _migrate_version_11_to_12(migrated) _: return {} if migrated.is_empty(): @@ -1831,6 +1874,15 @@ func _migrate_version_10_to_11(data: Dictionary) -> Dictionary: return migrated +func _migrate_version_11_to_12(data: Dictionary) -> Dictionary: + var migrated: Dictionary = data.duplicate(true) + migrated["message_bottles"] = ( + PlayerBottleMessageStateType.default_save_data() + ) + migrated["save_version"] = 12 + return migrated + + func _mark_dirty() -> void: if ( _is_restoring @@ -2101,6 +2153,7 @@ func _restore_defaults() -> void: _world_seed = DEFAULT_WORLD_SEED _jobs.reset_to_defaults() _home_state.reset_to_defaults() + _bottle_messages.reset_to_defaults() _is_restoring = false _is_dirty = false @@ -2115,6 +2168,38 @@ func _valid_active_tackle(item_id: StringName, bait: bool) -> bool: ) +func _reconcile_bottle_message_ownership(snapshot: LoadSnapshot) -> void: + var bottle_item_index: int = -1 + for index: int in snapshot.bag_items.size(): + if snapshot.bag_items[index].item_id == MESSAGE_BOTTLE_ITEM_ID: + bottle_item_index = index + break + var bottle_data: Dictionary = snapshot.bottle_messages_data.duplicate(true) + var pending_id: String = str(bottle_data.get("pending_message_id", "")) + var picked_up: bool = bool( + bottle_data.get("pending_bottle_picked_up", false) + ) + if pending_id.is_empty() or not picked_up: + if bottle_item_index >= 0: + snapshot.bag_items.remove_at(bottle_item_index) + snapshot.requires_resave = true + push_warning( + "Removed an orphaned message bottle from saved inventory." + ) + return + if bottle_item_index >= 0: + return + # Preserve the assigned, never-repeat message by putting its missing bottle + # back on the beach. The pickup will start a fresh cooldown when recovered. + bottle_data["pending_bottle_picked_up"] = false + bottle_data["next_bottle_at_unix"] = 0 + snapshot.bottle_messages_data = bottle_data + snapshot.requires_resave = true + push_warning( + "Recovered a missing inventory message bottle as a beach find." + ) + + func _validated_tackle_id( item_id: StringName, bait: bool, diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index 353292b..ae8de43 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -13,6 +13,8 @@ export straywild_ISOLATED_VALIDATION_STORAGE=1 readonly -a QUICK_TESTS=( "scripts/validate_animalese_samples.gd" "tests/android_readiness_validation.gd" + "tests/beach_bottle_service_validation.gd" + "tests/bottle_message_state_validation.gd" "tests/camera_drag_validation.gd" "tests/character_rig_validation.gd" "tests/network_chat_protocol_validation.gd" @@ -40,6 +42,7 @@ readonly -a QUICK_TESTS=( "tests/inventory_storage_validation.gd" "tests/keyboard_mouse_mapping_validation.gd" "tests/logbook_validation.gd" + "tests/message_bottle_reader_validation.gd" "tests/network_player_animation_protocol_validation.gd" "tests/net_attachment_validation.gd" "tests/on_screen_keyboard_validation.gd" diff --git a/tests/beach_bottle_service_validation.gd b/tests/beach_bottle_service_validation.gd new file mode 100644 index 0000000..3470181 --- /dev/null +++ b/tests/beach_bottle_service_validation.gd @@ -0,0 +1,202 @@ +extends SceneTree + +const ServiceType = preload("res://messages/beach_bottle_service.gd") +const StateType = preload("res://messages/player_bottle_message_state.gd") +const Catalog: BottleMessageCatalog = preload( + "res://messages/catalog/bottle_message_catalog.tres" +) +const ItemCatalogResource: ItemCatalog = preload( + "res://items/catalog/item_catalog.tres" +) + + +class FakeWorld: + extends Node + + func is_world_ready() -> bool: + return true + + func get_saltwater_surface_height() -> float: + return -0.45 + + func get_spawn_surface_triangles( + _materials: Array[StringName], + _minimum_y: float, + _minimum_up_dot: float, + _maximum_y: float, + ) -> Array[PackedVector3Array]: + return [PackedVector3Array([ + Vector3(8.0, 0.0, -4.0), + Vector3(16.0, 0.0, -4.0), + Vector3(12.0, 0.0, 4.0), + ])] + + +class FakeSaveManager: + extends PlayerSaveManager + + var allow_save: bool = true + var save_count: int = 0 + + func save_if_dirty() -> bool: + save_count += 1 + return allow_save + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var root := Node.new() + get_root().add_child(root) + var world := FakeWorld.new() + var player := Node3D.new() + var presentation_root := Node3D.new() + var bag := PlayerBag.new() + var state := StateType.new() + var saves := FakeSaveManager.new() + var service := ServiceType.new() + root.add_child(world) + root.add_child(player) + root.add_child(presentation_root) + root.add_child(bag) + root.add_child(state) + root.add_child(saves) + root.add_child(service) + bag.setup(ItemCatalogResource) + var now_unix: int = int(Time.get_unix_time_from_system()) + assert(state.restore_from_save_data({ + "schema_version": 1, + "received_message_ids": [], + "pending_message_id": "", + "pending_bottle_picked_up": false, + "next_bottle_at_unix": now_unix + 300000, + "last_observed_unix": now_unix, + })) + service.setup( + world, + player, + bag, + state, + Catalog, + saves, + presentation_root, + false, + ) + service.force_spawn_for_testing = true + service.set_gameplay_active(true) + assert(service.call("_try_spawn_due_bottle")) + assert(service.has_active_bottle()) + assert(state.has_pending_message()) + assert(not state.is_pending_bottle_picked_up()) + var presentation := service.get("_presentation") as BeachBottlePresentation + var spawned_message: BottleMessageData = Catalog.get_message( + presentation.message_id + ) + assert(spawned_message != null) + var bottle_sprite := presentation.get_node("%BottleSprite") as Sprite3D + assert(bottle_sprite.texture.resource_path.ends_with( + "message_in_a_bottle_sand.png" + )) + assert(bottle_sprite.texture.get_size() == Vector2(64.0, 64.0)) + assert(bottle_sprite.position.y > 0.0) + assert(bottle_sprite.offset == Vector2.ZERO) + assert( + bottle_sprite.billboard == BaseMaterial3D.BILLBOARD_FIXED_Y + ) + var bottle_image: Image = bottle_sprite.texture.get_image() + if bottle_image.is_compressed(): + assert(bottle_image.decompress() == OK) + var visible_bounds: Rect2i = bottle_image.get_used_rect() + assert( + visible_bounds.position.y + visible_bounds.size.y + == int(presentation.AUTHORED_VISIBLE_BOTTOM_PIXEL) + ) + var texture_height: float = float(bottle_sprite.texture.get_height()) + var visible_bottom_pixel: float = presentation.AUTHORED_VISIBLE_BOTTOM_PIXEL + var expected_sprite_height: float = ( + visible_bottom_pixel + - texture_height * 0.5 + - presentation.VISIBLE_BASE_TUCK_PIXELS + ) * bottle_sprite.pixel_size + assert( + is_equal_approx(bottle_sprite.position.y, expected_sprite_height) + ) + var first_position: Vector3 = service.get_active_bottle_position() + assert(first_position.is_finite()) + assert(is_equal_approx(first_position.y, ServiceType.SURFACE_OFFSET)) + var visible_base_y: float = ( + first_position.y + + bottle_sprite.position.y + + ( + texture_height * 0.5 + - visible_bottom_pixel + ) * bottle_sprite.pixel_size + ) + assert(is_equal_approx(visible_base_y, -bottle_sprite.pixel_size)) + assert( + Vector2(first_position.x, first_position.z).length() + >= ServiceType.MINIMUM_SPAWN_DISTANCE + ) + assert( + Vector2(first_position.x, first_position.z).length() + <= ServiceType.MAXIMUM_SPAWN_DISTANCE + ) + player.global_position = first_position + assert(service.is_local_player_in_range()) + assert(service.get_prompt_anchor_position().is_finite()) + service.call("_process", 1.0) + assert(not bag.owns_item(ServiceType.BOTTLE_ITEM_ID)) + assert(service.has_active_bottle()) + saves.allow_save = false + assert(not service.try_collect_nearby()) + assert(not bag.owns_item(ServiceType.BOTTLE_ITEM_ID)) + assert(state.has_pending_message()) + assert(not state.is_pending_bottle_picked_up()) + assert(service.has_active_bottle()) + saves.allow_save = true + # A failed disk transaction remains blocked until the player leaves pickup + # range, preventing a save-conflict failure from being retried every frame. + assert(not service.try_collect_nearby()) + player.global_position = first_position + Vector3.RIGHT * 2.0 + service.call("_process", 0.1) + assert(not service.is_local_player_in_range()) + player.global_position = first_position + assert(service.try_collect_nearby()) + assert(bag.owns_item(ServiceType.BOTTLE_ITEM_ID)) + assert(state.is_pending_bottle_picked_up()) + assert(not service.has_active_bottle()) + saves.allow_save = false + var failed_read: Dictionary = service.read_held_bottle() + assert(not bool(failed_read.get("ok", false))) + assert(bag.owns_item(ServiceType.BOTTLE_ITEM_ID)) + assert(state.is_pending_bottle_picked_up()) + saves.allow_save = true + var read_result: Dictionary = service.read_held_bottle() + assert(bool(read_result.get("ok", false))) + assert( + str(read_result.get("text", "")) + == spawned_message.body + ) + assert( + str(read_result.get("message_id", "")) + == String(spawned_message.message_id) + ) + assert(not bag.owns_item(ServiceType.BOTTLE_ITEM_ID)) + assert(not state.has_pending_message()) + var point := ServiceType.sample_triangle( + PackedVector3Array([ + Vector3.ZERO, + Vector3(1.0, 0.0, 0.0), + Vector3(0.0, 0.0, 1.0), + ]), + 0.0, + 0.75, + ) + assert(point.is_equal_approx(Vector3.ZERO)) + assert(not ServiceType.sample_triangle(PackedVector3Array(), 0.5, 0.5).is_finite()) + print("Beach bottle service validation: PASS") + root.queue_free() + await process_frame + quit() diff --git a/tests/beach_bottle_service_validation.gd.uid b/tests/beach_bottle_service_validation.gd.uid new file mode 100644 index 0000000..3d9b6d6 --- /dev/null +++ b/tests/beach_bottle_service_validation.gd.uid @@ -0,0 +1 @@ +uid://dadlfrooej5k0 diff --git a/tests/bottle_message_state_validation.gd b/tests/bottle_message_state_validation.gd new file mode 100644 index 0000000..f989aa3 --- /dev/null +++ b/tests/bottle_message_state_validation.gd @@ -0,0 +1,187 @@ +extends SceneTree + +const BottleMessageCatalogType = preload( + "res://messages/bottle_message_catalog.gd" +) +const BottleMessageDataType = preload( + "res://messages/bottle_message_data.gd" +) +const PlayerBottleMessageStateType = preload( + "res://messages/player_bottle_message_state.gd" +) + +const CATALOG_PATH: String = ( + "res://messages/catalog/bottle_message_catalog.tres" +) +const BASE_TIME: int = 1788282000 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _validate_shipped_catalog() + _validate_player_save_migration() + _validate_deterministic_unseen_selection() + _validate_cooldown_and_assignment() + _validate_save_round_trip_and_unknown_ids() + _validate_backward_clock_tolerance() + print("Bottle message state validation: PASS") + quit() + + +func _validate_shipped_catalog() -> void: + var catalog := load(CATALOG_PATH) as BottleMessageCatalogType + assert(catalog != null) + var message: BottleMessageDataType = catalog.get_message( + &"pushing_squares" + ) + assert(message != null) + assert(message.active) + assert(message.body == "it's as simple as pushing squares") + var message_2: BottleMessageDataType = catalog.get_message( + &"message_2" + ) + assert(message_2 != null) + assert(message_2.active) + assert( + message_2.body + == "01101001 00100000 01101100 01101111 01110110 01100101 00100000 01111001 01101111 01110101" + ) + + +func _validate_player_save_migration() -> void: + var manager := PlayerSaveManager.new() + root.add_child(manager) + var migrated: Dictionary = manager.call( + "_migrate_save", + {"save_version": 11}, + 11, + ) + assert(int(migrated.get("save_version", -1)) == 12) + assert( + migrated.get("message_bottles", {}) + == PlayerBottleMessageStateType.default_save_data() + ) + manager.queue_free() + + +func _validate_deterministic_unseen_selection() -> void: + var catalog := BottleMessageCatalogType.new() + for message_id: StringName in [&"third", &"first", &"second"]: + var message := BottleMessageDataType.new() + message.message_id = message_id + message.body = String(message_id) + catalog.entries.append(message) + var first_rng := RandomNumberGenerator.new() + first_rng.seed = 4412 + var second_rng := RandomNumberGenerator.new() + second_rng.seed = 4412 + var received: Array[StringName] = [&"second"] + assert( + catalog.choose_random_unseen_message(received, first_rng).message_id + == catalog.choose_random_unseen_message(received, second_rng).message_id + ) + catalog.get_message(&"first").active = false + assert( + catalog.choose_random_unseen_message(received, first_rng).message_id + == &"third" + ) + + +func _validate_cooldown_and_assignment() -> void: + var catalog := load(CATALOG_PATH) as BottleMessageCatalogType + var state := PlayerBottleMessageStateType.new() + root.add_child(state) + var rng := RandomNumberGenerator.new() + rng.seed = 915 + assert(state.ensure_cooldown_scheduled(BASE_TIME, rng)) + var due_at: int = state.get_next_bottle_at_unix() + assert( + due_at >= BASE_TIME + state.MINIMUM_COOLDOWN_SECONDS + and due_at <= BASE_TIME + state.MAXIMUM_COOLDOWN_SECONDS + ) + assert(state.try_assign_due_message(catalog, rng, due_at - 1) == null) + var message: BottleMessageDataType = state.try_assign_due_message( + catalog, rng, due_at + ) + assert(message != null) + assert(state.has_pending_message()) + assert(not state.is_pending_bottle_picked_up()) + assert(state.has_received_message(message.message_id)) + assert(state.get_next_bottle_at_unix() == 0) + assert(not state.ensure_cooldown_scheduled(due_at, rng)) + assert(state.try_assign_due_message(catalog, rng, due_at) == null) + assert(state.record_bottle_picked_up(due_at + 10, rng)) + assert(state.is_pending_bottle_picked_up()) + assert( + state.get_next_bottle_at_unix() + >= due_at + 10 + state.MINIMUM_COOLDOWN_SECONDS + ) + assert(not state.record_bottle_picked_up(due_at + 20, rng)) + assert(state.consume_pending_message() == message.message_id) + assert(not state.has_pending_message()) + var second_due_at: int = state.get_next_bottle_at_unix() + var second_message: BottleMessageDataType = state.try_assign_due_message( + catalog, + rng, + second_due_at, + ) + assert(second_message != null) + assert(second_message.message_id != message.message_id) + assert(state.record_bottle_picked_up(second_due_at + 10, rng)) + assert(state.consume_pending_message() == second_message.message_id) + var exhausted_due_at: int = state.get_next_bottle_at_unix() + assert( + state.try_assign_due_message( + catalog, + rng, + exhausted_due_at, + ) == null + ) + state.queue_free() + + +func _validate_save_round_trip_and_unknown_ids() -> void: + var state := PlayerBottleMessageStateType.new() + root.add_child(state) + var data := { + "schema_version": state.SAVE_SCHEMA_VERSION, + "received_message_ids": ["retired_message"], + "pending_message_id": "pending_from_future_catalog", + "pending_bottle_picked_up": true, + "next_bottle_at_unix": BASE_TIME + 50, + "last_observed_unix": BASE_TIME, + } + assert(state.restore_from_save_data(data)) + assert(state.has_received_message(&"retired_message")) + assert(state.has_received_message(&"pending_from_future_catalog")) + assert(state.get_pending_message_id() == &"pending_from_future_catalog") + assert(state.is_pending_bottle_picked_up()) + var saved: Dictionary = state.to_save_data() + assert( + "retired_message" in (saved["received_message_ids"] as Array) + ) + assert( + "pending_from_future_catalog" + in (saved["received_message_ids"] as Array) + ) + state.queue_free() + + +func _validate_backward_clock_tolerance() -> void: + var state := PlayerBottleMessageStateType.new() + root.add_child(state) + var rng := RandomNumberGenerator.new() + rng.seed = 122 + assert(state.ensure_cooldown_scheduled(BASE_TIME, rng)) + var original_due_at: int = state.get_next_bottle_at_unix() + assert(not state.observe_wall_clock(BASE_TIME - 60)) + assert(state.get_last_observed_unix() == BASE_TIME) + assert(state.get_next_bottle_at_unix() == original_due_at) + var rollback: int = state.BACKWARD_CLOCK_TOLERANCE_SECONDS + 1 + assert(state.observe_wall_clock(BASE_TIME - rollback)) + assert(state.get_last_observed_unix() == BASE_TIME - rollback) + assert(state.get_next_bottle_at_unix() == original_due_at - rollback) + state.queue_free() diff --git a/tests/bottle_message_state_validation.gd.uid b/tests/bottle_message_state_validation.gd.uid new file mode 100644 index 0000000..50e3c59 --- /dev/null +++ b/tests/bottle_message_state_validation.gd.uid @@ -0,0 +1 @@ +uid://cjkesykq1lrfg diff --git a/tests/controller_world_interaction_validation.gd b/tests/controller_world_interaction_validation.gd index bc102c5..ef746de 100644 --- a/tests/controller_world_interaction_validation.gd +++ b/tests/controller_world_interaction_validation.gd @@ -53,6 +53,12 @@ func _run() -> void: "_decor_shop_interaction" ) as DecorShopInteraction var game_ui := main.get("_game_ui") as GameUI + var bottle_service := main.get_node( + "%BeachBottleService" + ) as BeachBottleService + var bottle_state := main.get_node( + "%PlayerBottleMessageState" + ) as PlayerBottleMessageState var chat_service := main.get_node( "%NetworkChatService" ) as NetworkChatService @@ -60,13 +66,74 @@ func _run() -> void: _expect(interaction != null, "shop interaction is unavailable") _expect(decor_interaction != null, "decor interaction is unavailable") _expect(game_ui != null, "game UI is unavailable") + _expect(bottle_service != null, "beach bottle service is unavailable") + _expect(bottle_state != null, "bottle message state is unavailable") _expect(chat_service != null, "network chat service is unavailable") if ( player != null and interaction != null and game_ui != null + and bottle_service != null + and bottle_state != null and chat_service != null ): + # A nearby beach bottle owns the same compact interaction glyph as an + # unengaged villager. Controller Y must yield its character-call alias and + # collect only after the explicit interaction press. + var now_unix: int = int(Time.get_unix_time_from_system()) + bottle_service.call("_clear_presentation", false) + _expect(bottle_state.restore_from_save_data({ + "schema_version": 1, + "received_message_ids": ["pushing_squares"], + "pending_message_id": "pushing_squares", + "pending_bottle_picked_up": false, + "next_bottle_at_unix": 0, + "last_observed_unix": now_unix, + }), "bottle interaction fixture could not be restored") + var bottle_position := ( + interaction.global_position + Vector3(12.0, 0.0, 12.0) + ) + player.global_position = bottle_position + bottle_service.call( + "_spawn_presentation", + &"pushing_squares", + bottle_position, + ) + main.call("_process", 0.0) + var bottle_prompt := game_ui.get_node("%ShopPrompt") as Control + _expect( + bottle_service.is_local_player_in_range() + and bottle_prompt.visible + and bottle_prompt.size == Vector2(30.0, 30.0), + "nearby bottle did not use the villager interaction glyph", + ) + var bottle_calls: Array[String] = [] + var capture_bottle_call := func( + _peer_id: int, + call_id: String, + _pitch_scale: float, + ) -> void: + bottle_calls.append(call_id) + chat_service.character_call_received.connect(capture_bottle_call) + var bottle_press := InputEventJoypadButton.new() + bottle_press.device = 0 + bottle_press.button_index = JOY_BUTTON_Y + bottle_press.pressed = true + game_ui.call("_input", bottle_press) + _expect( + bottle_calls.is_empty(), + "bottle interaction Y press played a character call", + ) + main.call("_unhandled_input", bottle_press) + await process_frame + _expect( + player.bag.owns_item(BeachBottleService.BOTTLE_ITEM_ID) + and not bottle_service.has_active_bottle() + and main.get("_engaged_shop_npc_interaction") == null, + "explicit bottle interaction did not collect exactly the bottle", + ) + chat_service.character_call_received.disconnect(capture_bottle_call) + player.global_position = interaction.global_position for _frame: int in 6: await physics_frame diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index ab18f07..9f7e51b 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -104,7 +104,7 @@ func _run() -> void: var hotbar_data: Dictionary = parsed["hotbar"] assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY) assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id) - assert(int(parsed["save_version"]) == 11) + assert(int(parsed["save_version"]) == 12) assert(not PlayerHomeState.sanitize_save_data( parsed.get("home", {}) ).is_empty()) diff --git a/tests/fish_quality_validation.gd b/tests/fish_quality_validation.gd index b323880..cf49c6f 100644 --- a/tests/fish_quality_validation.gd +++ b/tests/fish_quality_validation.gd @@ -505,7 +505,7 @@ func _validate_version_four_migration() -> void: version_four, 4, ) - assert(int(migrated.get("save_version", -1)) == 11) + assert(int(migrated.get("save_version", -1)) == 12) assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0) assert( is_equal_approx( diff --git a/tests/job_system_validation.gd b/tests/job_system_validation.gd index 65b7164..96ac8bb 100644 --- a/tests/job_system_validation.gd +++ b/tests/job_system_validation.gd @@ -209,7 +209,7 @@ func _run() -> void: ) assert(bool(decoded.get("ok", false))) var save_data: Dictionary = decoded["data"] - assert(int(save_data.get("save_version", -1)) == 11) + assert(int(save_data.get("save_version", -1)) == 12) assert(PlayerJobService.validate_save_data(save_data.get("jobs", {}))) assert(not PlayerHomeState.sanitize_save_data( save_data.get("home", {}) diff --git a/tests/message_bottle_reader_validation.gd b/tests/message_bottle_reader_validation.gd new file mode 100644 index 0000000..84a7eb9 --- /dev/null +++ b/tests/message_bottle_reader_validation.gd @@ -0,0 +1,204 @@ +extends SceneTree + +const ReaderScene: PackedScene = preload( + "res://ui/message_bottle_reader.tscn" +) +const PlayerMenuScene: PackedScene = preload("res://ui/player_menu.tscn") +const ItemCatalogResource: ItemCatalog = preload( + "res://items/catalog/item_catalog.tres" +) +const BottleCatalog: BottleMessageCatalog = preload( + "res://messages/catalog/bottle_message_catalog.tres" +) + + +class FakeSaveManager: + extends PlayerSaveManager + + func save_if_dirty() -> bool: + return true + + +func _initialize() -> void: + _run.call_deferred() + + +func _run() -> void: + root.size = Vector2i(1280, 720) + var prior_button := Button.new() + prior_button.text = "inventory slot" + root.add_child(prior_button) + prior_button.grab_focus() + + var reader := ReaderScene.instantiate() as MessageBottleReader + root.add_child(reader) + await process_frame + assert(not reader.is_open()) + assert(reader.mouse_filter == Control.MOUSE_FILTER_STOP) + assert(reader.find_children("*", "ColorRect", true, false).is_empty()) + assert(reader.find_child("Title", true, false) == null) + var parchment := reader.get_node("%ParchmentSurface") as Control + var parchment_art := reader.get_node("%ParchmentArt") as TextureRect + var fallback_panel := reader.get_node("%FallbackPanel") as Control + assert(parchment_art.texture != null) + assert(parchment_art.texture.resource_path.ends_with("parchment.png")) + assert(parchment_art.texture.get_size() == Vector2(256.0, 192.0)) + assert(parchment_art.visible) + assert(not fallback_panel.visible) + assert(is_equal_approx( + parchment.custom_minimum_size.x / parchment.custom_minimum_size.y, + 4.0 / 3.0, + )) + + var observed := {"closed_count": 0} + reader.closed.connect( + func() -> void: observed["closed_count"] += 1 + ) + reader.present("it's as simple as pushing squares") + await process_frame + assert(reader.is_open()) + var message := reader.get_node("%MessageLabel") as Label + var close_button := reader.get_node("%CloseButton") as Button + assert(message.text == "it's as simple as pushing squares") + assert(root.gui_get_focus_owner() == close_button) + + reader.present("a replacement message") + await process_frame + assert(message.text == "a replacement message") + assert(reader.consume_escape()) + await process_frame + assert(not reader.is_open()) + assert(int(observed["closed_count"]) == 1) + assert(root.gui_get_focus_owner() == prior_button) + assert(not reader.consume_escape()) + + root.size = Vector2i(480, 270) + reader.present("small viewport") + await process_frame + assert(parchment.size.x <= 448.0) + assert(parchment.size.y <= 238.0) + assert(absf(parchment.size.x / parchment.size.y - 4.0 / 3.0) < 0.01) + reader.queue_free() + prior_button.queue_free() + await process_frame + + await _validate_player_menu_controller_open_and_close() + + print("Message bottle reader validation: PASS") + quit(0) + + +func _validate_player_menu_controller_open_and_close() -> void: + root.size = Vector2i(1280, 720) + var host := Node.new() + root.add_child(host) + var menu := PlayerMenuScene.instantiate() as PlayerMenu + var bag := PlayerBag.new() + var catches := FishInventory.new() + var capacity := PlayerCoolerCapacity.new() + var layout := PlayerInventoryLayout.new() + var hotbar := PlayerHotbar.new() + var state := PlayerBottleMessageState.new() + var saves := FakeSaveManager.new() + var service := BeachBottleService.new() + for node: Node in [ + menu, + bag, + catches, + capacity, + layout, + hotbar, + state, + saves, + service, + ]: + host.add_child(node) + await process_frame + + bag.setup(ItemCatalogResource) + var bottle_item: ItemData = ItemCatalogResource.get_item_by_id(&"message_bottle") + assert(bottle_item != null) + assert(bottle_item.icon != null) + assert(bottle_item.icon.resource_path.ends_with("message_in_a_bottle.png")) + assert(bottle_item.icon.get_size() == Vector2(64.0, 64.0)) + layout.setup(bag, catches, ItemCatalogResource, capacity) + bag.set_inventory_layout(layout) + catches.set_inventory_layout(layout) + hotbar.setup(bag, ItemCatalogResource, catches, layout) + assert(bag.add_item(&"message_bottle")) + var now_unix: int = int(Time.get_unix_time_from_system()) + assert(state.restore_from_save_data({ + "schema_version": 1, + "received_message_ids": ["pushing_squares"], + "pending_message_id": "pushing_squares", + "pending_bottle_picked_up": true, + "next_bottle_at_unix": now_unix + 300000, + "last_observed_unix": now_unix, + })) + service.set("_bag", bag) + service.set("_state", state) + service.set("_catalog", BottleCatalog) + service.set("_save_manager", saves) + menu.set("_bag", bag) + menu.set("_inventory", catches) + menu.set("_hotbar", hotbar) + menu.set("_inventory_layout", layout) + menu.set("_item_catalog", ItemCatalogResource) + menu.setup_message_bottles(service) + var inventory_grid := menu.get("_general_inventory_grid") as GeneralInventoryGrid + inventory_grid.setup( + layout, + bag, + catches, + hotbar, + ItemCatalogResource, + PlayerInventoryLayout.InventoryContainer.INVENTORY, + ) + menu.visible = true + menu.call("_show_section_immediate", PlayerMenu.Section.BAG) + menu.call("_set_shell_interactive", true) + menu.set( + "_controller_ownership", + PlayerMenu.ControllerOwnership.ITEM_LIST, + ) + menu.call("_apply_inventory_controller_zone_focus_modes") + var bottle_slot: GeneralInventorySlot + for slot: GeneralInventorySlot in inventory_grid.get_slots(): + if slot.entry_identity == &"message_bottle": + bottle_slot = slot + break + assert(bottle_slot != null) + bottle_slot.grab_focus() + + var accept := InputEventJoypadButton.new() + accept.button_index = JOY_BUTTON_A + accept.pressed = true + assert(bool(menu.call("_handle_controller_ownership_input", accept))) + accept.pressed = false + assert(bool(menu.call("_handle_controller_ownership_input", accept))) + await process_frame + var menu_reader := menu.get_node("%MessageBottleReader") as MessageBottleReader + assert(menu_reader.is_open()) + assert( + (menu_reader.get_node("%MessageLabel") as Label).text + == "it's as simple as pushing squares" + ) + assert(not bag.owns_item(&"message_bottle")) + assert(not state.has_pending_message()) + assert( + (menu.get_node("%InventoryTab") as Button).focus_mode + == Control.FOCUS_NONE + ) + + var cancel := InputEventJoypadButton.new() + cancel.button_index = JOY_BUTTON_B + cancel.pressed = true + menu.call("_input", cancel) + await process_frame + assert(not menu_reader.is_open()) + assert( + (menu.get_node("%InventoryTab") as Button).focus_mode + == Control.FOCUS_ALL + ) + host.queue_free() + await process_frame diff --git a/tests/message_bottle_reader_validation.gd.uid b/tests/message_bottle_reader_validation.gd.uid new file mode 100644 index 0000000..8f42716 --- /dev/null +++ b/tests/message_bottle_reader_validation.gd.uid @@ -0,0 +1 @@ +uid://otelx0xjr05u diff --git a/tests/player_experience_validation.gd b/tests/player_experience_validation.gd index a76a3dd..03e4b84 100644 --- a/tests/player_experience_validation.gd +++ b/tests/player_experience_validation.gd @@ -143,7 +143,7 @@ func _validate_save_migration() -> void: version_five, 5, ) - assert(int(migrated.get("save_version", -1)) == 11) + assert(int(migrated.get("save_version", -1)) == 12) var experience_data: Dictionary = migrated.get("experience", {}) assert(int(experience_data.get("total_experience", -1)) == 0) var world_data: Dictionary = migrated.get("world", {}) diff --git a/tests/progression_archive_validation.gd b/tests/progression_archive_validation.gd index 624c8ca..9c9ad3a 100644 --- a/tests/progression_archive_validation.gd +++ b/tests/progression_archive_validation.gd @@ -72,7 +72,7 @@ func _run() -> void: _assert_opaque(save_path) var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path) assert(bool(decoded.get("ok", false))) - assert(int((decoded["data"] as Dictionary)["save_version"]) == 11) + assert(int((decoded["data"] as Dictionary)["save_version"]) == 12) var saved_character: Dictionary = ( (decoded["data"] as Dictionary).get("character", {}) ) @@ -89,6 +89,39 @@ func _run() -> void: (saved_character["appearance"] as Dictionary) == first_appearance ) + # Cross-model recovery must not merely repair runtime state. Mark the loaded + # save dirty so the next save removes the orphan permanently on disk. + var orphaned_bottle_save: Dictionary = ( + (decoded["data"] as Dictionary).duplicate(true) + ) + var orphaned_bag: Dictionary = ( + orphaned_bottle_save["bag"] as Dictionary + ) + (orphaned_bag["items"] as Array).append({ + "item_id": "message_bottle", + "quantity": 1, + "storage_slot": -1, + }) + var orphan_write: Dictionary = save_manager.call( + "_write_current_save_data", + orphaned_bottle_save, + save_manager.get("_expected_hash"), + ) + assert(bool(orphan_write.get("ok", false))) + assert(save_manager.load_player_data()) + assert(not player.bag.owns_item(&"message_bottle")) + assert(save_manager.is_dirty()) + assert(save_manager.save_now()) + var repaired_bottle_save: Dictionary = ( + ProgressionSaveCodec.read_local_save(save_path)["data"] + ) + for value: Variant in ( + repaired_bottle_save["bag"]["items"] as Array + ): + assert( + StringName(str((value as Dictionary).get("item_id", ""))) + != &"message_bottle" + ) # 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)) diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 65b72b6..90d7013 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -74,6 +74,9 @@ const PlayerExperienceType = preload( const UIReferencePresentationType = preload( "res://ui/ui_reference_presentation.gd" ) +const BeachBottleServiceType = preload( + "res://messages/beach_bottle_service.gd" +) const MAIN_BUBBLE_PROFILE: BubbleMenuProfile = preload( "res://ui/components/bubble_menu/bubble_menu_profile.tres" ) @@ -221,6 +224,7 @@ var _shop_interaction: ShopInteractionType var _decor_shop_interaction: DecorShopInteractionType var _rv_upgrade_shop_interaction: RVUpgradeInteractionType var _storage_interaction: PlayerStorageInteractionType +var _beach_bottle_service: BeachBottleServiceType var _surface_drawing: NetworkSurfaceDrawingService var _surface_drawing_hotbar_selected: bool = false var _experience: PlayerExperienceType @@ -593,6 +597,11 @@ func setup( ) +func setup_message_bottles(service: BeachBottleServiceType) -> void: + _beach_bottle_service = service + _player_menu.setup_message_bottles(service) + + func set_world_interactions( shop_interaction: ShopInteractionType, storage_interaction: PlayerStorageInteractionType, @@ -840,6 +849,10 @@ func _character_call_yields_to_world_interaction( _storage_interaction != null and _storage_interaction.is_local_player_in_range() ) + or ( + _beach_bottle_service != null + and _beach_bottle_service.is_local_player_in_range() + ) ) and _fishing_spot != null and _fishing_spot.can_open_fishing_shop() diff --git a/ui/message_bottle_reader.gd b/ui/message_bottle_reader.gd new file mode 100644 index 0000000..2a02cb6 --- /dev/null +++ b/ui/message_bottle_reader.gd @@ -0,0 +1,119 @@ +class_name MessageBottleReader +extends Control + +signal closed + +const MAX_PARCHMENT_SIZE := Vector2(640.0, 480.0) +const FALLBACK_PARCHMENT_ASPECT: float = 4.0 / 3.0 +const VIEWPORT_MARGIN := Vector2(32.0, 32.0) + +@export var parchment_texture: Texture2D: + set(value): + parchment_texture = value + _apply_parchment_texture() + +@onready var _parchment_surface: Control = %ParchmentSurface +@onready var _fallback_panel: Control = %FallbackPanel +@onready var _parchment_art: TextureRect = %ParchmentArt +@onready var _message_label: Label = %MessageLabel +@onready var _close_button: Button = %CloseButton + +var _previous_focus: WeakRef + + +func _ready() -> void: + _close_button.pressed.connect(dismiss) + _close_button.focus_neighbor_left = _close_button.get_path_to(_close_button) + _close_button.focus_neighbor_right = _close_button.get_path_to(_close_button) + _close_button.focus_neighbor_top = _close_button.get_path_to(_close_button) + _close_button.focus_neighbor_bottom = _close_button.get_path_to(_close_button) + _apply_parchment_texture() + _resize_parchment() + hide() + + +func present(message_text: String) -> void: + if not is_node_ready(): + call_deferred("present", message_text) + return + if not is_open(): + var focus_owner: Control = get_viewport().gui_get_focus_owner() + _previous_focus = weakref(focus_owner) if focus_owner != null else null + _message_label.text = message_text + show() + _close_button.call_deferred("grab_focus") + + +func dismiss() -> void: + if not is_open(): + return + hide() + _restore_previous_focus.call_deferred() + closed.emit() + + +func is_open() -> bool: + return visible + + +func consume_escape() -> bool: + if not is_open(): + return false + dismiss() + return true + + +func _unhandled_input(event: InputEvent) -> void: + if not is_open() or not event.is_action_pressed(&"ui_cancel"): + return + if consume_escape(): + get_viewport().set_input_as_handled() + + +func _notification(what: int) -> void: + if what == NOTIFICATION_RESIZED and is_node_ready(): + _resize_parchment() + + +func _resize_parchment() -> void: + var available_size: Vector2 = ( + get_viewport_rect().size - VIEWPORT_MARGIN + ).max(Vector2.ONE) + var target_size: Vector2 = MAX_PARCHMENT_SIZE.min(available_size) + var aspect: float = FALLBACK_PARCHMENT_ASPECT + if parchment_texture != null: + var texture_size: Vector2 = parchment_texture.get_size() + if texture_size.x > 0.0 and texture_size.y > 0.0: + aspect = texture_size.x / texture_size.y + if target_size.x / target_size.y > aspect: + target_size.x = target_size.y * aspect + else: + target_size.y = target_size.x / aspect + _parchment_surface.custom_minimum_size = target_size.floor() + + +func _apply_parchment_texture() -> void: + if _parchment_art == null: + return + _parchment_art.texture = parchment_texture + _parchment_art.visible = parchment_texture != null + if _fallback_panel != null: + _fallback_panel.visible = parchment_texture == null + _resize_parchment() + + +func _restore_previous_focus() -> void: + if _previous_focus == null: + get_viewport().gui_release_focus() + return + var previous: Control = _previous_focus.get_ref() as Control + _previous_focus = null + if ( + is_instance_valid(previous) + and previous.is_inside_tree() + and previous.is_visible_in_tree() + and previous.focus_mode != Control.FOCUS_NONE + ): + previous.grab_focus() + else: + get_viewport().gui_release_focus() diff --git a/ui/message_bottle_reader.gd.uid b/ui/message_bottle_reader.gd.uid new file mode 100644 index 0000000..7f47194 --- /dev/null +++ b/ui/message_bottle_reader.gd.uid @@ -0,0 +1 @@ +uid://fege70iyongd diff --git a/ui/message_bottle_reader.tscn b/ui/message_bottle_reader.tscn new file mode 100644 index 0000000..fbb5071 --- /dev/null +++ b/ui/message_bottle_reader.tscn @@ -0,0 +1,172 @@ +[gd_scene load_steps=8 format=3] + +[ext_resource type="Script" path="res://ui/message_bottle_reader.gd" id="1_reader"] +[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] +[ext_resource type="Texture2D" path="res://messages/assets/parchment.png" id="3_parchment"] + +[sub_resource type="StyleBoxFlat" id="StyleBox_parchment"] +content_margin_left = 0.0 +content_margin_top = 0.0 +content_margin_right = 0.0 +content_margin_bottom = 0.0 +bg_color = Color(0.88, 0.76, 0.49, 1) +border_width_left = 3 +border_width_top = 3 +border_width_right = 3 +border_width_bottom = 3 +border_color = Color(0.29, 0.19, 0.09, 1) +corner_radius_top_left = 18 +corner_radius_top_right = 18 +corner_radius_bottom_right = 18 +corner_radius_bottom_left = 18 + +[sub_resource type="StyleBoxFlat" id="StyleBox_button_normal"] +content_margin_left = 22.0 +content_margin_top = 8.0 +content_margin_right = 22.0 +content_margin_bottom = 8.0 +bg_color = Color(0.29, 0.19, 0.09, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBox_button_hover"] +content_margin_left = 22.0 +content_margin_top = 8.0 +content_margin_right = 22.0 +content_margin_bottom = 8.0 +bg_color = Color(0.42, 0.28, 0.12, 1) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(0.98, 0.9, 0.66, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBox_button_pressed"] +content_margin_left = 22.0 +content_margin_top = 8.0 +content_margin_right = 22.0 +content_margin_bottom = 8.0 +bg_color = Color(0.2, 0.13, 0.06, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[node name="MessageBottleReader" type="Control"] +visible = false +process_mode = 3 +z_index = 250 +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 0 +theme = ExtResource("2_theme") +script = ExtResource("1_reader") +parchment_texture = ExtResource("3_parchment") + +[node name="Center" type="CenterContainer" parent="."] +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="ParchmentSurface" type="Control" parent="Center"] +unique_name_in_owner = true +custom_minimum_size = Vector2(680, 420) +layout_mode = 2 +mouse_filter = 1 + +[node name="FallbackPanel" type="PanelContainer" parent="Center/ParchmentSurface"] +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 +theme_override_styles/panel = SubResource("StyleBox_parchment") + +[node name="ParchmentArt" type="TextureRect" parent="Center/ParchmentSurface"] +unique_name_in_owner = true +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture_filter = 1 +expand_mode = 1 +stretch_mode = 0 + +[node name="ContentMargin" type="MarginContainer" parent="Center/ParchmentSurface"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +theme_override_constants/margin_left = 70 +theme_override_constants/margin_top = 44 +theme_override_constants/margin_right = 70 +theme_override_constants/margin_bottom = 44 + +[node name="Layout" type="VBoxContainer" parent="Center/ParchmentSurface/ContentMargin"] +layout_mode = 2 +mouse_filter = 2 +theme_override_constants/separation = 12 + +[node name="MessageScroll" type="ScrollContainer" parent="Center/ParchmentSurface/ContentMargin/Layout"] +layout_mode = 2 +size_flags_vertical = 3 +mouse_filter = 0 +horizontal_scroll_mode = 0 + +[node name="MessageLabel" type="Label" parent="Center/ParchmentSurface/ContentMargin/Layout/MessageScroll"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 140) +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.22, 0.13, 0.055, 1) +theme_override_font_sizes/font_size = 24 +text = "it's as simple as pushing squares" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 3 + +[node name="ButtonCenter" type="CenterContainer" parent="Center/ParchmentSurface/ContentMargin/Layout"] +layout_mode = 2 +mouse_filter = 2 + +[node name="CloseButton" type="Button" parent="Center/ParchmentSurface/ContentMargin/Layout/ButtonCenter"] +unique_name_in_owner = true +custom_minimum_size = Vector2(120, 44) +layout_mode = 2 +focus_mode = 2 +mouse_default_cursor_shape = 2 +theme_override_colors/font_color = Color(0.98, 0.9, 0.66, 1) +theme_override_colors/font_hover_color = Color(1, 0.95, 0.8, 1) +theme_override_colors/font_pressed_color = Color(1, 0.95, 0.8, 1) +theme_override_font_sizes/font_size = 22 +theme_override_styles/normal = SubResource("StyleBox_button_normal") +theme_override_styles/hover = SubResource("StyleBox_button_hover") +theme_override_styles/focus = SubResource("StyleBox_button_hover") +theme_override_styles/pressed = SubResource("StyleBox_button_pressed") +text = "close" diff --git a/ui/player_menu.gd b/ui/player_menu.gd index cb1e720..e814f9d 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -81,6 +81,12 @@ const GeneralInventoryGridType = preload( const GeneralInventorySlotType = preload( "res://ui/components/general_inventory_slot.gd" ) +const MessageBottleReaderType = preload( + "res://ui/message_bottle_reader.gd" +) +const BeachBottleServiceType = preload( + "res://messages/beach_bottle_service.gd" +) const DESKTOP_REFERENCE_SIZE := Vector2(1280.0, 720.0) const NAVIGATION_PRESENTATION_SCALE: float = 0.60 const NAVIGATION_REFERENCE_SIZE := Vector2(840.0, 100.0) @@ -92,6 +98,7 @@ const NAVIGATION_CANONICAL_POSITION := Vector2( const NAVIGATION_SELECTED_SCALE: float = 1.02 const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop" const PELICAN_BUYER_ID: StringName = &"pelicans" +const MESSAGE_BOTTLE_ITEM_ID: StringName = &"message_bottle" signal menu_visibility_changed(is_open: bool) signal inventory_hotbar_context_changed(show_hotbar: bool) @@ -259,6 +266,9 @@ const LIGHT_COOLER_WATER_COLOR := Color(0.037, 0.27, 0.375, 1.0) @onready var _players_tab: BubbleButtonType = %PlayersTab @onready var _mail_unread_badge: Label = %MailUnreadBadge @onready var _close_button: BubbleButtonType = %CloseButton +@onready var _message_bottle_reader: MessageBottleReaderType = ( + %MessageBottleReader +) @onready var _cooler_scroll: ScrollContainer = %CoolerScroll @onready var _cooler_host: Control = %CoolerHost @onready var _sale_confirmation: PanelContainer = %SaleConfirmation @@ -293,6 +303,7 @@ var _fishing_spot: FishingSpotType var _bag: PlayerBagType var _hotbar: PlayerHotbarType var _item_catalog: ItemCatalogType +var _message_bottle_service: BeachBottleServiceType var _inventory_layout: PlayerInventoryLayout var _cooler_capacity: PlayerCoolerCapacityType var _current_section: Section = Section.BAG @@ -426,6 +437,7 @@ func _ready() -> void: _sell_bubble.pressed.connect(_on_sell_pressed) _confirm_sale_button.pressed.connect(_on_confirm_sale_pressed) _cancel_sale_button.pressed.connect(_close_sale_confirmation) + _message_bottle_reader.closed.connect(_on_message_bottle_reader_closed) _configure_sale_confirmation_focus() _cooler_sort_option.add_item("catch order", SortMode.CATCH_ORDER) _cooler_sort_option.add_item("name", SortMode.NAME) @@ -664,6 +676,10 @@ func setup_controller_mapping( _the_net_page.setup_controller_mapping(_controller_mapping_manager) +func setup_message_bottles(service: BeachBottleServiceType) -> void: + _message_bottle_service = service + + func set_profile_preview_world_pixel_size(pixel_size: int) -> void: _profile_page.set_world_pixel_size(pixel_size) @@ -677,6 +693,23 @@ func allows_global_controller_scroll() -> bool: func _input(event: InputEvent) -> void: if event is InputEventKey and event.echo: return + if visible and _message_bottle_reader.is_open(): + var bottle_cancel: bool = event.is_action_pressed(&"ui_cancel") + var bottle_button := event as InputEventJoypadButton + if ( + bottle_button != null + and bottle_button.pressed + and _event_matches_controller_role( + event, + ControllerMappingManagerType.ROLE_B, + JOY_BUTTON_B, + ) + ): + bottle_cancel = true + if bottle_cancel: + _message_bottle_reader.dismiss() + get_viewport().set_input_as_handled() + return if _handle_controller_ownership_input(event): get_viewport().set_input_as_handled() return @@ -1520,6 +1553,8 @@ func consume_escape() -> bool: func _consume_player_menu_back() -> bool: if _transitioning or _page_transitioning: return true + if _message_bottle_reader.consume_escape(): + return true if _sale_confirmation.visible: _close_sale_confirmation() return true @@ -3377,6 +3412,7 @@ func _finish_close( _page_transitioning = false _bag_drag_active = false _cancel_active_item_move() + _message_bottle_reader.dismiss() _set_inventory_notepad_visible(Section.BAG, false) _set_inventory_notepad_visible(Section.TACKLE_BOX, false) _hide_inventory_context_tooltip() @@ -3871,6 +3907,13 @@ func _on_general_inventory_slot_activated( or _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS ): return + if ( + _inventory_move_identity.is_empty() + and slot.entry_kind == PlayerInventoryLayout.EntryKind.ITEM + and slot.entry_identity == MESSAGE_BOTTLE_ITEM_ID + ): + _open_message_bottle(slot) + return if _inventory_move_identity.is_empty(): if slot.entry_identity.is_empty(): return @@ -3914,6 +3957,29 @@ func _on_general_inventory_slot_activated( ) +func _open_message_bottle(slot: GeneralInventorySlotType) -> void: + _cancel_active_item_move() + _hide_inventory_context_tooltip() + if _message_bottle_service == null: + _show_inventory_context_tooltip(slot, "The message cannot be opened yet.") + return + var result: Dictionary = _message_bottle_service.read_held_bottle() + if not bool(result.get("ok", false)): + _show_inventory_context_tooltip( + slot, + str(result.get("error", "The message could not be opened.")), + ) + return + _set_shell_interactive(false) + _message_bottle_reader.present(str(result.get("text", ""))) + + +func _on_message_bottle_reader_closed() -> void: + if not visible or _transitioning or _page_transitioning: + return + _set_shell_interactive(true) + + func _open_general_inventory_notepad( slot: GeneralInventorySlotType, ) -> void: diff --git a/ui/player_menu.tscn b/ui/player_menu.tscn index 18f9918..10481c6 100644 --- a/ui/player_menu.tscn +++ b/ui/player_menu.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=30 format=3] +[gd_scene load_steps=31 format=3] [ext_resource type="Script" path="res://ui/player_menu.gd" id="1_menu"] [ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] @@ -29,6 +29,7 @@ [ext_resource type="Texture2D" path="res://ui/icons/tab_menu/online_simple.png" id="30_online_simple"] [ext_resource type="Texture2D" path="res://ui/icons/tab_menu/close.png" id="31_close"] [ext_resource type="PackedScene" path="res://ui/components/currency_amount.tscn" id="32_currency"] +[ext_resource type="PackedScene" path="res://ui/message_bottle_reader.tscn" id="33_bottle_reader"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_cooler_water"] shader = ExtResource("14_water") @@ -1105,3 +1106,6 @@ motion_period = 5.3 motion_phase = 4.2 deformation_amplitude = 0.01 deformation_period = 6.2 + +[node name="MessageBottleReader" parent="." instance=ExtResource("33_bottle_reader")] +unique_name_in_owner = true