diff --git a/audio/music/world/tulips.wav b/audio/music/world/tulips.wav new file mode 100644 index 0000000..9cd4605 Binary files /dev/null and b/audio/music/world/tulips.wav differ diff --git a/audio/music/world/tulips.wav.import b/audio/music/world/tulips.wav.import new file mode 100644 index 0000000..0f8cce1 --- /dev/null +++ b/audio/music/world/tulips.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://5hgmht4bq1my" +path="res://.godot/imported/tulips.wav-7c18a300a62f25396913efb28c2ba3f6.sample" + +[deps] + +source_file="res://audio/music/world/tulips.wav" +dest_files=["res://.godot/imported/tulips.wav-7c18a300a62f25396913efb28c2ba3f6.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/economy/fishing_shop_stock.gd b/economy/fishing_shop_stock.gd index 962ba3a..b9d3eaf 100644 --- a/economy/fishing_shop_stock.gd +++ b/economy/fishing_shop_stock.gd @@ -12,6 +12,7 @@ const MAGNET_ID: StringName = &"magnet" const BATTERIES_ID: StringName = &"batteries" const CRAB_NET_ID: StringName = &"crab_net" const STANDARD_SHOVEL_ID: StringName = &"standard_shovel" +const FISHING_NET_ID: StringName = &"fishing_net" const ITEM_PRICES: Dictionary[StringName, int] = { &"worms": 1, @@ -30,6 +31,7 @@ const ITEM_PRICES: Dictionary[StringName, int] = { BATTERIES_ID: 15, CRAB_NET_ID: 50, STANDARD_SHOVEL_ID: 75, + FISHING_NET_ID: 250, } const BAIT_UNLOCK_PRICES: Dictionary[StringName, int] = { &"snails": 400, @@ -52,6 +54,7 @@ const ITEM_ORDER: Array[StringName] = [ MAGNET_ID, CRAB_NET_ID, STANDARD_SHOVEL_ID, + FISHING_NET_ID, &"coffee", &"energy_drink", &"snack", @@ -120,6 +123,7 @@ static func is_permanent_unlock( or item_id == MAGNET_ID or item_id == CRAB_NET_ID or item_id == STANDARD_SHOVEL_ID + or item_id == FISHING_NET_ID or item is FishingRodDataType ) ) diff --git a/fish/fish_data.gd b/fish/fish_data.gd index 2696d4b..46d4675 100644 --- a/fish/fish_data.gd +++ b/fish/fish_data.gd @@ -36,6 +36,12 @@ enum LogbookSection { SHELLFISH, } +enum CreatureGroup { + FISH, + INSECT, + SHELLFISH, +} + @export var id: StringName @export var display_name: String @export_category("Developer Catalog") @@ -94,6 +100,14 @@ func is_fishable() -> bool: return is_selectable() and collection_method == CollectionMethod.FISHING +func get_creature_group() -> CreatureGroup: + if logbook_section == LogbookSection.SHELLFISH: + return CreatureGroup.SHELLFISH + if collection_method == CollectionMethod.FISHING: + return CreatureGroup.FISH + return CreatureGroup.INSECT + + func get_habitat_label() -> String: if not habitat_label.strip_edges().is_empty(): return habitat_label.strip_edges().to_lower() diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index e2fdef0..389d40b 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -111,7 +111,9 @@ const BITE_QUICK_PROBABILITY: float = 0.30 const BITE_TYPICAL_PROBABILITY: float = 0.65 const BITE_LONG_PROBABILITY: float = 0.04 const NETWORK_INPUT_RESEND_INTERVAL_SECONDS: float = 0.1 +const ALTERNATE_REEL_ACTION: StringName = &"reel_alternate" @export_range(0.1, 10.0, 0.1) var cooldown_duration: float = 1.0 +@export_range(0.1, 5.0, 0.05) var minimum_showcase_duration: float = 0.8 @export_category("Selection") @export_range(0.0, 10.0, 0.05) var undiscovered_weight_multiplier: float = 1.5 @@ -172,6 +174,7 @@ var _selected_fish: FishDataType var _pending_catch: FishCatchType var _showcase_ready: bool = false var _put_away_press_armed: bool = false +var _showcase_input_lock_remaining: float = 0.0 var _showcase_restore_generation: int = 0 var _showcase_outcome_completed: bool = false var _network_auto_click_accumulator: float = 0.0 @@ -444,6 +447,7 @@ func _secure_showcase_catch_for_recovery() -> void: _pending_catch = null _showcase_ready = false _put_away_press_armed = false + _showcase_input_lock_remaining = 0.0 showcase_changed.emit("", "", 0.0, 0, false) if _active_player != null: _active_player.end_catch_showcase(Callable(), true) @@ -473,14 +477,21 @@ func _exit_tree() -> void: _selected_fish = null _showcase_ready = false _put_away_press_armed = false + _showcase_input_lock_remaining = 0.0 func _process(delta: float) -> void: if state == FishingState.READY and not Input.is_action_pressed("fish_primary"): _new_cast_press_armed = true + if state == FishingState.SHOWING_CATCH: + _showcase_input_lock_remaining = maxf( + _showcase_input_lock_remaining - delta, + 0.0, + ) if ( state == FishingState.SHOWING_CATCH and _showcase_ready + and _showcase_input_lock_remaining <= 0.0 and not Input.is_action_pressed("fish_primary") ): _put_away_press_armed = true @@ -501,7 +512,7 @@ func _process(delta: float) -> void: _get_effective_reel_speed(), _get_effective_barrier_damage() ) - if not Input.is_action_pressed("fish_primary"): + if not _is_reel_input_pressed(): _catch_controller.set_reel_input(false) else: _resend_network_input_state(delta) @@ -521,7 +532,11 @@ func _unhandled_input(event: InputEvent) -> void: return if _local_player == null or not _local_player.is_local_control_enabled(): return - if not event.is_action("fish_primary"): + var primary_action: bool = event.is_action("fish_primary") + var alternate_reel: bool = event.is_action(ALTERNATE_REEL_ACTION) + if not primary_action and not ( + alternate_reel and state == FishingState.FIGHTING + ): return var selected_item: ItemDataType = _get_active_item() if ( @@ -582,7 +597,11 @@ func _unhandled_input(event: InputEvent) -> void: FishingPresentationType.LineMode.TAUT ) FishingState.SHOWING_CATCH: - if _showcase_ready and _put_away_press_armed: + if ( + _showcase_ready + and _showcase_input_lock_remaining <= 0.0 + and _put_away_press_armed + ): _put_away_catch() else: return @@ -619,15 +638,23 @@ func _unhandled_input(event: InputEvent) -> void: _presentation.set_line_mode(FishingPresentationType.LineMode.SLACK) get_viewport().set_input_as_handled() elif state == FishingState.FIGHTING: + var reel_input_held: bool = _is_reel_input_pressed() if _network_fishing != null: - _network_primary_input_held = false + _network_primary_input_held = reel_input_held _network_input_resend_elapsed = 0.0 - _network_fishing.submit_local_input(false, false) + _network_fishing.submit_local_input(reel_input_held, false) else: - _catch_controller.set_reel_input(false) + _catch_controller.set_reel_input(reel_input_held) get_viewport().set_input_as_handled() +func _is_reel_input_pressed() -> bool: + return ( + Input.is_action_pressed("fish_primary") + or Input.is_action_pressed(ALTERNATE_REEL_ACTION) + ) + + func _begin_aiming(player: PlayerType) -> void: if state != FishingState.READY or _active_player != null: return @@ -756,6 +783,17 @@ func is_returning() -> bool: return state == FishingState.RETURNING +func is_fishing_sequence_active() -> bool: + return state in [ + FishingState.AIMING_CAST, + FishingState.CASTING, + FishingState.WAITING_FOR_BITE, + FishingState.FIGHTING, + FishingState.SHOWING_CATCH, + FishingState.RETURNING, + ] + + func refresh_active_item_status() -> void: if state == FishingState.READY and has_active_fishing_rod(): status_changed.emit("") @@ -780,6 +818,7 @@ func present_external_catch(fish_catch: FishCatchType) -> bool: _showcase_ready = true _showcase_outcome_completed = true _put_away_press_armed = false + _showcase_input_lock_remaining = minimum_showcase_duration _active_player.begin_catch_showcase(fish_catch) showcase_changed.emit( fish_catch.fish.display_name, @@ -1112,7 +1151,7 @@ func _activate_bite(confirmation_override: bool = false) -> void: _pending_catch.quality, ) _catch_controller.set_reel_input( - Input.is_action_pressed("fish_primary") + _is_reel_input_pressed() ) _presentation.show_bite() @@ -1197,6 +1236,7 @@ func _on_catch_completed() -> void: _showcase_ready = false _showcase_outcome_completed = false _put_away_press_armed = false + _showcase_input_lock_remaining = 0.0 _catch_controller.reset() _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) _presentation.play_outcome(&"catch") @@ -1232,6 +1272,7 @@ func _on_outcome_completed(outcome: StringName) -> void: return _showcase_outcome_completed = true _showcase_ready = true + _showcase_input_lock_remaining = minimum_showcase_duration _active_player.begin_catch_showcase(_pending_catch) showcase_changed.emit( _pending_catch.fish.display_name, @@ -1265,6 +1306,7 @@ func _put_away_catch() -> void: _showcase_ready = false _showcase_outcome_completed = false _put_away_press_armed = false + _showcase_input_lock_remaining = 0.0 showcase_changed.emit("", "", 0.0, 0, false) _showcase_restore_generation += 1 var restore_generation: int = _showcase_restore_generation @@ -1338,6 +1380,7 @@ func _cleanup_attempt( _showcase_ready = false _showcase_outcome_completed = false _put_away_press_armed = false + _showcase_input_lock_remaining = 0.0 showcase_changed.emit("", "", 0.0, 0, false) _catch_controller.reset() state = FishingState.RETURNING @@ -1387,6 +1430,7 @@ func _finalize_attempt_cleanup( _showcase_ready = false _showcase_outcome_completed = false _put_away_press_armed = false + _showcase_input_lock_remaining = 0.0 showcase_changed.emit("", "", 0.0, 0, false) _catch_controller.reset() _presentation.cleanup() @@ -1724,7 +1768,7 @@ func _on_network_bite_started(_attempt_id: String) -> void: if _active_player != null: _active_player.set_fighting_visual(true) _withdrawal_input_held = false - _network_primary_input_held = Input.is_action_pressed("fish_primary") + _network_primary_input_held = _is_reel_input_pressed() _network_input_resend_elapsed = 0.0 _network_auto_click_accumulator = 0.0 _network_active_barrier_index = -1 @@ -1796,7 +1840,7 @@ func _on_network_fishing_snapshot(snapshot: Dictionary) -> void: func _update_network_auto_click(delta: float) -> void: if ( not _catch_controller.auto_click_enabled - or not Input.is_action_pressed("fish_primary") + or not _is_reel_input_pressed() or _network_active_barrier_index < 0 ): _network_auto_click_accumulator = 0.0 @@ -1823,6 +1867,7 @@ func _on_network_catch_received(fish_catch: FishCatchType) -> void: _showcase_ready = false _showcase_outcome_completed = false _put_away_press_armed = false + _showcase_input_lock_remaining = 0.0 catch_display_changed.emit( 0.0, 0.0, PackedFloat32Array(), PackedInt32Array(), PackedInt32Array(), -1, false diff --git a/items/catalog/fishing_net.tres b/items/catalog/fishing_net.tres new file mode 100644 index 0000000..04b1919 --- /dev/null +++ b/items/catalog/fishing_net.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="ItemData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://items/item_data.gd" id="1_item"] + +[resource] +script = ExtResource("1_item") +item_id = &"fishing_net" +display_name = "fishing net" +description = "a placeable water net reserved for a future passive fishing system." +active = true +category = 1 +stackable = false +max_stack = 1 +usable = false +equippable = false +hotbar_allowed = false diff --git a/items/catalog/item_catalog.tres b/items/catalog/item_catalog.tres index 1c006f4..63ef3ea 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=40 format=3] +[gd_resource type="Resource" script_class="ItemCatalog" load_steps=41 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"] @@ -39,7 +39,8 @@ [ext_resource type="Resource" path="res://items/catalog/magnet.tres" id="38_magnet"] [ext_resource type="Resource" path="res://items/catalog/crab_net.tres" id="39_crab_net"] [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"] [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")] +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")] diff --git a/jobs/job_catalog.gd b/jobs/job_catalog.gd index 754263d..eb3ea02 100644 --- a/jobs/job_catalog.gd +++ b/jobs/job_catalog.gd @@ -14,6 +14,9 @@ enum Kind { REACH_LEVEL, DISCOVER_SPECIES, MASTER_QUALITIES, + CATCH_CREATURE_GROUP, + DISCOVER_CREATURE_GROUP, + MASTER_CREATURE_GROUP, } const DAILY_JOB_COUNT: int = 4 @@ -111,6 +114,28 @@ static func generate_daily_jobs( 50, {"fish_id": String(selected.id)}, )) + if _has_creature_group(candidates, FishDataType.CreatureGroup.INSECT): + optional.append(_job( + "insect_roundup", + "bug hunt", + "catch 3 insects", + Kind.CATCH_CREATURE_GROUP, + 3, + 40, + 50, + {"creature_group": int(FishDataType.CreatureGroup.INSECT)}, + )) + if _has_creature_group(candidates, FishDataType.CreatureGroup.SHELLFISH): + optional.append(_job( + "shellfish_search", + "shore patrol", + "catch 3 shellfish", + Kind.CATCH_CREATURE_GROUP, + 3, + 45, + 50, + {"creature_group": int(FishDataType.CreatureGroup.SHELLFISH)}, + )) _shuffle(optional, rng) while jobs.size() < DAILY_JOB_COUNT and not optional.is_empty(): jobs.append(optional.pop_back()) @@ -172,6 +197,8 @@ static func generate_weather_schedule( static func lifetime_chains( registered_species_count: int, + registered_insect_count: int = 0, + registered_shellfish_count: int = 0, ) -> Array[Dictionary]: var chains: Array[Dictionary] = [ _chain("catch", Kind.CATCH_TOTAL, [100, 1000, 5000], [ @@ -197,15 +224,33 @@ static func lifetime_chains( [registered_species_count], [[1500, 2000]], )) + _append_creature_lifetime_chains( + chains, + "insect", + FishDataType.CreatureGroup.INSECT, + registered_insect_count, + ) + _append_creature_lifetime_chains( + chains, + "shellfish", + FishDataType.CreatureGroup.SHELLFISH, + registered_shellfish_count, + ) return chains static func visible_lifetime_jobs( registered_species_count: int, claimed_ids: Array[String], + registered_insect_count: int = 0, + registered_shellfish_count: int = 0, ) -> Array[Dictionary]: var visible: Array[Dictionary] = [] - for chain: Dictionary in lifetime_chains(registered_species_count): + for chain: Dictionary in lifetime_chains( + registered_species_count, + registered_insect_count, + registered_shellfish_count, + ): var targets: Array = chain.get("targets", []) var rewards: Array = chain.get("rewards", []) for tier_index: int in targets.size(): @@ -216,6 +261,9 @@ static func visible_lifetime_jobs( if job_id in claimed_ids: continue var reward: Array = rewards[tier_index] + var extra: Dictionary = {} + if chain.has("creature_group"): + extra["creature_group"] = int(chain["creature_group"]) visible.append(_job( job_id, _lifetime_title(str(chain.get("id", "")), int(targets[tier_index])), @@ -226,6 +274,7 @@ static func visible_lifetime_jobs( int(targets[tier_index]), int(reward[0]), int(reward[1]), + extra, )) break return visible @@ -256,10 +305,10 @@ static func is_valid_job(value: Variant) -> bool: and typeof(job.get("description")) == TYPE_STRING and str(job["description"]).length() <= 160 and is_bounded_integer( - job.get("kind"), Kind.CATCH_TOTAL, Kind.MASTER_QUALITIES + job.get("kind"), Kind.CATCH_TOTAL, Kind.MASTER_CREATURE_GROUP ) and kind >= Kind.CATCH_TOTAL - and kind <= Kind.MASTER_QUALITIES + and kind <= Kind.MASTER_CREATURE_GROUP and is_bounded_integer(job.get("target"), 1, 1000000000) and is_bounded_integer( job.get("fish_coin"), 0, MAX_JOB_REWARD_COINS @@ -318,6 +367,12 @@ static func is_valid_job(value: Variant) -> bool: and not str(job.get("fish_id", "")).is_empty() and str(job.get("fish_id", "")).length() <= 96 ) + Kind.CATCH_CREATURE_GROUP, Kind.DISCOVER_CREATURE_GROUP, Kind.MASTER_CREATURE_GROUP: + return is_bounded_integer( + job.get("creature_group"), + FishDataType.CreatureGroup.FISH, + FishDataType.CreatureGroup.SHELLFISH, + ) return true @@ -401,13 +456,16 @@ static func _chain( kind: Kind, targets: Array[int], rewards: Array, + extra: Dictionary = {}, ) -> Dictionary: - return { + var result: Dictionary = { "id": id, "kind": int(kind), "targets": targets, "rewards": rewards, } + result.merge(extra, true) + return result static func _lifetime_title(chain_id: String, target: int) -> String: @@ -422,6 +480,18 @@ static func _lifetime_title(chain_id: String, target: int) -> String: return "complete the catalog" "master": return "quality master" + "insect_catch": + return "seasoned bug catcher" + "insect_discover": + return "complete the insect catalog" + "insect_master": + return "insect quality master" + "shellfish_catch": + return "seasoned beachcomber" + "shellfish_discover": + return "complete the shellfish catalog" + "shellfish_master": + return "shellfish quality master" return "long-term job" @@ -437,9 +507,67 @@ static func _lifetime_description(chain_id: String, target: int) -> String: return "discover all %d cataloged fish" % target "master": return "collect every quality of all %d fish" % target + "insect_catch": + return "catch %d insects" % target + "insect_discover": + return "discover all %d cataloged insects" % target + "insect_master": + return "collect every quality of all %d insects" % target + "shellfish_catch": + return "catch %d shellfish" % target + "shellfish_discover": + return "discover all %d cataloged shellfish" % target + "shellfish_master": + return "collect every quality of all %d shellfish" % target return "keep fishing" +static func _has_creature_group( + candidates: Array[FishDataType], + creature_group: FishDataType.CreatureGroup, +) -> bool: + for fish: FishDataType in candidates: + if ( + fish != null + and fish.is_selectable() + and fish.get_creature_group() == creature_group + ): + return true + return false + + +static func _append_creature_lifetime_chains( + chains: Array[Dictionary], + id_prefix: String, + creature_group: FishDataType.CreatureGroup, + registered_count: int, +) -> void: + if registered_count <= 0: + return + var group_data := {"creature_group": int(creature_group)} + chains.append(_chain( + "%s_catch" % id_prefix, + Kind.CATCH_CREATURE_GROUP, + [10, 50, 250], + [[75, 100], [250, 350], [900, 1100]], + group_data, + )) + chains.append(_chain( + "%s_discover" % id_prefix, + Kind.DISCOVER_CREATURE_GROUP, + [registered_count], + [[250, 350]], + group_data, + )) + chains.append(_chain( + "%s_master" % id_prefix, + Kind.MASTER_CREATURE_GROUP, + [registered_count], + [[750, 1000]], + group_data, + )) + + static func _shuffle(values: Array[Dictionary], rng: RandomNumberGenerator) -> void: for index: int in range(values.size() - 1, 0, -1): var swap_index: int = rng.randi_range(0, index) diff --git a/jobs/player_job_service.gd b/jobs/player_job_service.gd index 7b19cc6..7ede866 100644 --- a/jobs/player_job_service.gd +++ b/jobs/player_job_service.gd @@ -25,6 +25,7 @@ var _session: NetworkSession var _save_manager: PlayerSaveManager var _network_fishing: NetworkFishingService var _network_sale: NetworkSaleService +var _network_world_spawns: NetworkWorldSpawnService var _progression_ready: bool = false var _host_board: Dictionary = {} @@ -35,6 +36,8 @@ var _daily_completions: Dictionary[String, int] = {} var _pending_rewards: Array[Dictionary] = [] var _lifetime_claimed: Array[String] = [] var _total_catches: int = 0 +var _total_insects: int = 0 +var _total_shellfish: int = 0 var _total_sold: int = 0 var _daily_clock_hour: float = WorldTimeService.DEFAULT_START_HOUR @@ -65,9 +68,11 @@ func setup( func bind_authoritative_services( network_fishing: NetworkFishingService, network_sale: NetworkSaleService, + network_world_spawns: NetworkWorldSpawnService, ) -> void: _network_fishing = network_fishing _network_sale = network_sale + _network_world_spawns = network_world_spawns if not _network_fishing.local_catch_received.is_connected( _on_authoritative_catch ): @@ -78,6 +83,12 @@ func bind_authoritative_services( _network_sale.local_sale_finished.connect( _on_authoritative_sale_finished ) + if not _network_world_spawns.local_capture_received.is_connected( + _on_authoritative_catch + ): + _network_world_spawns.local_capture_received.connect( + _on_authoritative_catch + ) func set_save_manager(save_manager: PlayerSaveManager) -> void: @@ -123,7 +134,10 @@ func get_daily_jobs() -> Array[Dictionary]: func get_lifetime_jobs() -> Array[Dictionary]: var result: Array[Dictionary] = JobCatalog.visible_lifetime_jobs( - _registered_species_count(), _lifetime_claimed + _registered_species_count(), + _lifetime_claimed, + _registered_creature_count(FishDataType.CreatureGroup.INSECT), + _registered_creature_count(FishDataType.CreatureGroup.SHELLFISH), ) for job: Dictionary in result: var progress: int = _lifetime_progress(job) @@ -236,6 +250,8 @@ func to_save_data() -> Dictionary: "lifetime_claimed": _lifetime_claimed.duplicate(), "statistics": { "fish_caught": _total_catches, + "insects_caught": _total_insects, + "shellfish_caught": _total_shellfish, "fish_sold": _total_sold, }, } @@ -294,6 +310,8 @@ func restore_from_save_data(data: Dictionary) -> bool: _lifetime_claimed.append(str(value)) var statistics: Dictionary = data.get("statistics", {}) _total_catches = int(statistics.get("fish_caught", 0)) + _total_insects = int(statistics.get("insects_caught", 0)) + _total_shellfish = int(statistics.get("shellfish_caught", 0)) _total_sold = int(statistics.get("fish_sold", 0)) if _session != null and _session.is_host(): _active_board = _host_board.duplicate(true) @@ -318,6 +336,8 @@ func reset_to_defaults() -> void: _pending_rewards.clear() _lifetime_claimed.clear() _total_catches = 0 + _total_insects = 0 + _total_shellfish = 0 _total_sold = 0 _daily_clock_hour = WorldTimeService.DEFAULT_START_HOUR changed.emit() @@ -333,7 +353,12 @@ static func default_save_data() -> Dictionary: "daily_clock_hour": WorldTimeService.DEFAULT_START_HOUR, "pending_rewards": [], "lifetime_claimed": [], - "statistics": {"fish_caught": 0, "fish_sold": 0}, + "statistics": { + "fish_caught": 0, + "insects_caught": 0, + "shellfish_caught": 0, + "fish_sold": 0, + }, } @@ -456,6 +481,12 @@ static func validate_save_data(value: Variant) -> bool: JobCatalog.is_bounded_integer( statistics.get("fish_caught"), 0, MAX_PROGRESS_VALUE ) + and JobCatalog.is_bounded_integer( + statistics.get("insects_caught", 0), 0, MAX_PROGRESS_VALUE + ) + and JobCatalog.is_bounded_integer( + statistics.get("shellfish_caught", 0), 0, MAX_PROGRESS_VALUE + ) and JobCatalog.is_bounded_integer( statistics.get("fish_sold"), 0, MAX_PROGRESS_VALUE ) @@ -514,7 +545,7 @@ func _generate_host_board(cycle: int) -> void: var candidates: Array[FishDataType] = [] if _catalog != null: for fish: FishDataType in _catalog.candidates: - if fish != null and fish.is_fishable(): + if fish != null and fish.is_selectable(): candidates.append(fish) var schedule_anchor_index: int = _current_weather_segment() var allow_weather_jobs: bool = ( @@ -608,7 +639,13 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: func _on_authoritative_catch(fish_catch: FishCatchType) -> void: if not _progression_ready or fish_catch == null or not fish_catch.is_valid(): return - _total_catches = mini(_total_catches + 1, MAX_PROGRESS_VALUE) + match fish_catch.fish.get_creature_group(): + FishDataType.CreatureGroup.FISH: + _total_catches = mini(_total_catches + 1, MAX_PROGRESS_VALUE) + FishDataType.CreatureGroup.INSECT: + _total_insects = mini(_total_insects + 1, MAX_PROGRESS_VALUE) + FishDataType.CreatureGroup.SHELLFISH: + _total_shellfish = mini(_total_shellfish + 1, MAX_PROGRESS_VALUE) _update_daily_for_catch(fish_catch) changed.emit() @@ -630,23 +667,40 @@ func _on_authoritative_sale_finished( func _update_daily_for_catch(fish_catch: FishCatchType) -> void: + var creature_group: int = int(fish_catch.fish.get_creature_group()) + var is_fish: bool = creature_group == FishDataType.CreatureGroup.FISH for job: Dictionary in get_daily_jobs(): var matches: bool = false match int(job.get("kind", -1)): JobCatalog.Kind.CATCH_TOTAL: - matches = true + matches = is_fish JobCatalog.Kind.CATCH_WATER: - matches = fish_catch.fish.is_allowed_in_water( + matches = is_fish and fish_catch.fish.is_allowed_in_water( int(job.get("water_type", WaterType.Type.OTHER)) as WaterType.Type ) JobCatalog.Kind.CATCH_QUALITY: - matches = fish_catch.quality >= int(job.get("minimum_quality", 0)) + matches = ( + is_fish + and fish_catch.quality >= int(job.get("minimum_quality", 0)) + ) JobCatalog.Kind.CATCH_PHASE: - matches = int(_world_time.get_phase()) == int(job.get("phase", -1)) + matches = ( + is_fish + and int(_world_time.get_phase()) == int(job.get("phase", -1)) + ) JobCatalog.Kind.CATCH_WEATHER: - matches = int(_world_weather.get_weather()) == int(job.get("weather", -1)) + matches = ( + is_fish + and int(_world_weather.get_weather()) + == int(job.get("weather", -1)) + ) JobCatalog.Kind.CATCH_SPECIES: - matches = String(fish_catch.fish_id) == str(job.get("fish_id", "")) + matches = ( + is_fish + and String(fish_catch.fish_id) == str(job.get("fish_id", "")) + ) + JobCatalog.Kind.CATCH_CREATURE_GROUP: + matches = creature_group == int(job.get("creature_group", -1)) if matches: _advance_daily(job, 1) @@ -741,26 +795,74 @@ func _lifetime_progress(job: Dictionary) -> int: JobCatalog.Kind.REACH_LEVEL: return _experience.get_level() if _experience != null else 0 JobCatalog.Kind.DISCOVER_SPECIES: - return _collection.get_discovered_ids().size() if _collection != null else 0 + return _collection_progress( + FishDataType.CreatureGroup.FISH, false + ) JobCatalog.Kind.MASTER_QUALITIES: - var mastered: int = 0 - if _collection != null and _catalog != null: - for fish: FishDataType in _catalog.candidates: - if fish != null and _collection.has_mastered(fish.id): - mastered += 1 - return mastered + return _collection_progress( + FishDataType.CreatureGroup.FISH, true + ) + JobCatalog.Kind.CATCH_CREATURE_GROUP: + match int(job.get("creature_group", -1)): + FishDataType.CreatureGroup.INSECT: + return _total_insects + FishDataType.CreatureGroup.SHELLFISH: + return _total_shellfish + JobCatalog.Kind.DISCOVER_CREATURE_GROUP: + return _collection_progress( + int(job.get("creature_group", -1)) as FishDataType.CreatureGroup, + false, + ) + JobCatalog.Kind.MASTER_CREATURE_GROUP: + return _collection_progress( + int(job.get("creature_group", -1)) as FishDataType.CreatureGroup, + true, + ) return 0 func _registered_species_count() -> int: + return _registered_creature_count(FishDataType.CreatureGroup.FISH) + + +func _registered_creature_count( + creature_group: FishDataType.CreatureGroup, +) -> int: var count: int = 0 if _catalog != null: for fish: FishDataType in _catalog.candidates: - if fish != null and fish.is_fishable(): + if ( + fish != null + and fish.is_selectable() + and fish.get_creature_group() == creature_group + ): count += 1 return count +func _collection_progress( + creature_group: FishDataType.CreatureGroup, + require_mastery: bool, +) -> int: + var progress: int = 0 + if _collection == null or _catalog == null: + return progress + for fish: FishDataType in _catalog.candidates: + if ( + fish == null + or not fish.is_selectable() + or fish.get_creature_group() != creature_group + ): + continue + if ( + _collection.has_mastered(fish.id) + if require_mastery + else _collection.has_discovered(fish.id) + ): + progress += 1 + return progress + + func _expire_incomplete_daily_jobs() -> void: _daily_progress.clear() _daily_completions.clear() @@ -795,7 +897,7 @@ func _matches_canonical_board(board: Dictionary) -> bool: var candidates: Array[FishDataType] = [] if _catalog != null: for fish: FishDataType in _catalog.candidates: - if fish != null and fish.is_fishable(): + if fish != null and fish.is_selectable(): candidates.append(fish) var anchor_index: int = int(board.get("schedule_anchor_index", -1)) var allow_weather_jobs: bool = ( diff --git a/main/main.gd b/main/main.gd index 2db40ac..d57c724 100644 --- a/main/main.gd +++ b/main/main.gd @@ -124,6 +124,7 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0 const TITLE_MUSIC_PATH: String = ( "res://audio/music/title/as_in_four_wolves.ogg" ) +const NEW_GAME_MUSIC_PATH: String = "res://audio/music/world/tulips.wav" const DUSK_MUSIC_PATH: String = "res://audio/music/world/craft.mp3" const TIME_CROSSING_EPSILON_HOURS: float = 0.000001 const PLAYER_MENU_PATTERN_SCALE: float = 0.85 @@ -138,6 +139,7 @@ const SHOP_PATTERN_SCALE: float = 1.75 @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 +@export_range(-40.0, 0.0, 0.5) var new_game_music_volume_db: float = -6.0 @onready var _test_world: TestWorldType = $TestWorld @onready var _player: PlayerType = %Player @@ -154,6 +156,7 @@ const SHOP_PATTERN_SCALE: float = 1.75 ) @onready var _interface_fonts: InterfaceFontController = %InterfaceFontController @onready var _title_music: AudioStreamPlayer = %TitleMusic +@onready var _new_game_music: AudioStreamPlayer = %NewGameMusic @onready var _dusk_music: AudioStreamPlayer = %DuskMusic @onready var _ui_pixelation: UIPixelationPresenterType = %UIPresentation @onready var _pixelation_reset: PixelationResetOverlayType = ( @@ -356,6 +359,7 @@ func _configure_presentation_performance_profile(light_profile: bool) -> void: func _configure_audio_performance_profile(light_profile: bool) -> void: _title_music.stream = _load_optional_audio_stream(TITLE_MUSIC_PATH) + _new_game_music.stream = _load_optional_audio_stream(NEW_GAME_MUSIC_PATH) _dusk_music.stream = _load_optional_audio_stream(DUSK_MUSIC_PATH) if light_profile: _shoreline_ambience.set_audio_enabled(false) @@ -409,6 +413,14 @@ func _start_dedicated_server() -> void: ): _fail_dedicated_server("The server operator list is invalid.") return + if not _network_chat.configure_dedicated_history( + config.chat_logging, + config.chat_log_path, + ): + _fail_dedicated_server( + "The dedicated-server chat privacy configuration is invalid." + ) + return if not _apply_generated_world(config.world_seed, false): _fail_dedicated_server("The configured world seed could not be generated.") return @@ -636,6 +648,7 @@ func _initialize_application(dedicated: bool) -> void: _world_time, _world_weather, _player_jobs, + _player, ) _player_jobs.set_save_manager(_save_manager) _save_manager.set_autosave_enabled(false) @@ -747,7 +760,11 @@ func _initialize_application(dedicated: bool) -> void: _asset_reservations, _player.inventory_layout, ) - _player_jobs.bind_authoritative_services(_network_fishing, _network_sale) + _player_jobs.bind_authoritative_services( + _network_fishing, + _network_sale, + _network_world_spawns, + ) _network_shop.setup( _network_session, _player_spawn_service, @@ -824,6 +841,7 @@ func _initialize_application(dedicated: bool) -> void: ) _game_ui.setup_data_and_identity( _data_root, + _save_manager, _identity_backups, _player_identity, _host_identity, @@ -1571,7 +1589,10 @@ func _on_natural_time_advanced(advanced_hours: float) -> void: ) ): _dusk_music_played_for_natural_day = true - if _dusk_music.stream != null: + if ( + _dusk_music.stream != null + and not _new_game_music.playing + ): _dusk_music.play(0.0) @@ -1675,23 +1696,28 @@ func _resize_native_overlays() -> void: func _on_new_game_requested(world_seed: int) -> void: if _gameplay_started or _quit_in_progress: return + _start_new_game_music() if not _apply_generated_world(world_seed, true): + _show_title_music(true) _game_ui.get_title_screen().report_network_error( "Could not generate that world seed. Try rolling another world." ) return if not _prepare_host_world_seed(world_seed): + _show_title_music(true) _game_ui.get_title_screen().report_network_error( "Could not prepare the generated world for hosting." ) return if not _prepare_private_host(): + _show_title_music(true) return if ( not _save_manager.delete_progression_save() or not _save_manager.initialize_new_game(world_seed) ): _network_session.disconnect_session("New Game setup failed.") + _show_title_music(true) _game_ui.get_title_screen().report_network_error( "Could not initialize local progression. Existing data was preserved where possible." ) @@ -2120,6 +2146,7 @@ func _on_quit_requested() -> void: func _show_title_music(restart_from_beginning: bool = false) -> void: + _new_game_music.stop() _title_music_requested = true _replace_title_music_transition() if _title_music.stream == null: @@ -2131,6 +2158,19 @@ func _show_title_music(restart_from_beginning: bool = false) -> void: _title_music.play(0.0) +func _start_new_game_music() -> void: + if _new_game_music.stream == null: + return + _title_music_requested = false + _replace_title_music_transition() + _title_music.stop() + _title_music.volume_db = title_music_volume_db + _dusk_music.stop() + _new_game_music.stop() + _new_game_music.volume_db = new_game_music_volume_db + _new_game_music.play(0.0) + + func _fade_out_title_music(on_complete: Callable = Callable()) -> void: _title_music_requested = false var generation: int = _replace_title_music_transition() @@ -2196,6 +2236,9 @@ func _exit_tree() -> void: if is_instance_valid(_title_music): _title_music.stop() _title_music.stream = null + if is_instance_valid(_new_game_music): + _new_game_music.stop() + _new_game_music.stream = null if is_instance_valid(_dusk_music): _dusk_music.stop() _dusk_music.stream = null diff --git a/main/main.tscn b/main/main.tscn index e8dba76..b9ad6fb 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -361,6 +361,10 @@ unique_name_in_owner = true volume_db = -80.0 bus = &"Music" +[node name="NewGameMusic" type="AudioStreamPlayer" parent="."] +unique_name_in_owner = true +bus = &"Music" + [node name="DuskMusic" type="AudioStreamPlayer" parent="." unique_id=992744241] unique_name_in_owner = true bus = &"Music" diff --git a/network/network_chat_service.gd b/network/network_chat_service.gd index 0502a38..b24d558 100644 --- a/network/network_chat_service.gd +++ b/network/network_chat_service.gd @@ -4,7 +4,10 @@ extends Node const BURST_COUNT: int = 3 const WINDOW_COUNT: int = 5 const WINDOW_SECONDS: float = 10.0 -const CALL_COOLDOWN_MILLISECONDS: int = 180 +const CALL_COOLDOWN_MILLISECONDS: int = 90 +const DEDICATED_CHAT_WARNING: String = ( + "Privacy notice: This dedicated server records and retains chat messages." +) const CALL_PITCH_VARIANTS: Array[float] = [ 0.96, 1.03, @@ -38,6 +41,32 @@ var _call_variant_indices: Dictionary[int, int] = {} var _sequence: int = 0 var _peer_names: Dictionary[int, String] = {} var _relationships: PlayerRelationshipStore +var _dedicated_history_enabled: bool = false +var _chat_log_path: String = "" + + +func configure_dedicated_history(enabled: bool, log_path: String) -> bool: + _dedicated_history_enabled = false + _chat_log_path = "" + if not enabled: + return true + var normalized_path: String = log_path.strip_edges() + if normalized_path.is_empty() or not normalized_path.is_absolute_path(): + return false + var directory: String = normalized_path.get_base_dir() + if DirAccess.make_dir_recursive_absolute(directory) != OK: + return false + var file: FileAccess + if FileAccess.file_exists(normalized_path): + file = FileAccess.open(normalized_path, FileAccess.READ_WRITE) + else: + file = FileAccess.open(normalized_path, FileAccess.WRITE) + if file == null: + return false + file.close() + _dedicated_history_enabled = true + _chat_log_path = normalized_path + return true func setup(session: NetworkSession) -> void: @@ -288,7 +317,8 @@ func _handle_request(peer_id: int, data: Dictionary) -> void: var request_id: String = data["request_id"] var ledger: Dictionary = _request_ledgers.get(peer_id, {}) if ledger.has(request_id): - _send_message(peer_id, ledger[request_id]) + if typeof(ledger[request_id]) == TYPE_DICTIONARY: + _send_message(peer_id, ledger[request_id]) return if not _consume_rate(peer_id): _send_rejection(peer_id, "Slow down.") @@ -308,7 +338,11 @@ func _handle_request(peer_id: int, data: Dictionary) -> void: message["request_id"] = request_id message["sender_fingerprint"] = data["sender_fingerprint"] message["sender_signature"] = data["sender_signature"] - ledger[request_id] = message.duplicate(true) + ledger[request_id] = ( + message.duplicate(true) + if _should_store_host_history() + else true + ) while ledger.size() > 64: ledger.erase(ledger.keys().front()) _request_ledgers[peer_id] = ledger @@ -404,9 +438,12 @@ func _apply_message( kind == NetworkChatProtocol.Kind.PLAYER and is_sender_filtered(str(data.get("sender_fingerprint", ""))) ) - _history.append(stored_message) - while _history.size() > NetworkChatProtocol.MAX_HISTORY: - _history.pop_front() + if _should_store_local_history(): + _history.append(stored_message) + while _history.size() > NetworkChatProtocol.MAX_HISTORY: + _history.pop_front() + if _should_log_message(stored_message): + _append_chat_log(stored_message) if emit_live_signals and _message_is_visible(stored_message): message_received.emit(stored_message.duplicate(true)) if ( @@ -433,8 +470,26 @@ func _on_peer_authenticated(peer_id: int, display_name: String) -> void: if not _session.is_host(): return _peer_names[peer_id] = display_name - var start := maxi(0, _history.size() - NetworkChatProtocol.LATE_JOIN_HISTORY) - receive_chat_history.rpc_id(peer_id, _history.slice(start)) + var history: Array[Dictionary] = [] + if _dedicated_history_enabled and _session.is_dedicated_host(): + var start := maxi( + 0, + _history.size() - NetworkChatProtocol.LATE_JOIN_HISTORY, + ) + history = _history.slice(start) + # An explicit empty replacement is intentional for player-hosted rooms and + # dedicated servers without logging. It destroys any stale client scrollback + # instead of replaying another player's live-session chat on reconnect. + receive_chat_history.rpc_id(peer_id, history) + if _dedicated_history_enabled and _session.is_dedicated_host(): + # Deliver the disclosure as a live system message after scrollback. This + # raises the normal unread indicator without creating speech or audio. + _send_message(peer_id, _make_message( + NetworkChatProtocol.Kind.SYSTEM, + 0, + "", + DEDICATED_CHAT_WARNING, + )) _broadcast(_make_message( NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s joined." % display_name )) @@ -494,6 +549,44 @@ func _consume_rate(peer_id: int) -> bool: return true +func _should_store_host_history() -> bool: + return ( + _session == null + or not _session.is_dedicated_host() + or _dedicated_history_enabled + ) + + +func _should_store_local_history() -> bool: + return _should_store_host_history() + + +func _should_log_message(message: Dictionary) -> bool: + return ( + _dedicated_history_enabled + and not _chat_log_path.is_empty() + and _session != null + and _session.is_dedicated_host() + and int(message.get("kind", -1)) == NetworkChatProtocol.Kind.PLAYER + ) + + +func _append_chat_log(message: Dictionary) -> void: + var file := FileAccess.open(_chat_log_path, FileAccess.READ_WRITE) + if file == null: + push_error("The configured dedicated-server chat log is unavailable.") + return + file.seek_end() + file.store_line(JSON.stringify({ + "recorded_at_utc": ( + Time.get_datetime_string_from_system(true, false) + "Z" + ), + "sender_display_name": str(message.get("sender_display_name", "")), + "body": str(message.get("body", "")), + })) + file.close() + + func _on_session_state_changed(state: NetworkSession.State) -> void: if state in [ NetworkSession.State.INACTIVE, diff --git a/network/player_data_root.gd b/network/player_data_root.gd index 9e50fe7..998e380 100644 --- a/network/player_data_root.gd +++ b/network/player_data_root.gd @@ -188,7 +188,7 @@ func activate_process_root(path: String) -> bool: func path_for(store_owner: StringName) -> String: var relative: String = { - &"player_save": "player/player_save.json", + &"player_save": "player/player_save.nfsave", &"network_profile": "player/network_profile.json", &"player_appearance": "player/player_appearance.json", &"saved_servers": "social/saved_servers.json", @@ -208,6 +208,10 @@ func identity_backup_directory() -> String: return root_path.path_join("identity-backups") +func progression_backup_directory() -> String: + return root_path.path_join("progression-backups") + + func migration_backup_directory() -> String: return root_path.path_join("backups/migrations") @@ -347,7 +351,7 @@ func _test_writable(path: String) -> bool: func _create_layout(path: String, id: String) -> bool: for relative: String in [ "player", "social", "backups/saves", "backups/migrations", - "backups/conflicts", "identity-backups", + "backups/conflicts", "identity-backups", "progression-backups", ]: if DirAccess.make_dir_recursive_absolute(path.path_join(relative)) != OK: return false @@ -369,6 +373,7 @@ func _create_layout(path: String, id: String) -> bool: + "This folder is safe to synchronize with tools such as Syncthing.\n" + "Active private identity keys remain device-local.\n" + "Encrypted identity backups require their passphrase.\n" + + "Progression exports do not contain identity keys or social data.\n" + "Chat and Session Mail are not stored here.\n" + "Do not play the same profile on two devices at the same time.\n" + "Conflicting edits are preserved under backups/conflicts; they are not merged.\n" diff --git a/network/portable_data_migration.gd b/network/portable_data_migration.gd index ab8f21d..a74db6d 100644 --- a/network/portable_data_migration.gd +++ b/network/portable_data_migration.gd @@ -122,6 +122,7 @@ static func migrate_active_to( _remove_tree(staging) return {"ok": false, "message": str(created.get("message", ""))} for relative: String in [ + "player/player_save.nfsave", "player/player_save.json", "player/network_profile.json", "player/player_appearance.json", @@ -134,7 +135,11 @@ static func migrate_active_to( var source: String = data_root.root_path.path_join(relative) if not FileAccess.file_exists(source): continue - if not bool(_copy_verified_json(source, staging.path_join(relative)).get("ok", false)): + if not bool( + _copy_verified_owned_file( + source, staging.path_join(relative) + ).get("ok", false) + ): _remove_tree(staging) return {"ok": false, "message": "Migration validation failed for %s." % relative} if DirAccess.dir_exists_absolute(normalized): @@ -248,6 +253,30 @@ static func _copy_verified_json(source: String, destination: String) -> Dictiona } +static func _copy_verified_owned_file( + source: String, + destination: String, +) -> Dictionary: + if source.get_file() != "player_save.nfsave": + return _copy_verified_json(source, destination) + var decoded: Dictionary = ProgressionSaveCodec.read_local_save(source) + if not bool(decoded.get("ok", false)): + return {"ok": false} + var save_data: Dictionary = decoded["data"] + var version: int = int(save_data.get("save_version", -1)) + if version < 1 or version > PlayerSaveManager.SAVE_VERSION: + return {"ok": false} + var bytes: PackedByteArray = PortableFileGuard.read_bytes(source) + if bytes.is_empty() or not _write_bytes(destination, bytes): + return {"ok": false} + return { + "ok": ( + PortableFileGuard.hash_file(destination) + == PortableFileGuard.hash_bytes(bytes) + ) + } + + static func _valid_owned_data(filename: String, data: Dictionary) -> bool: if filename == "player_save.json": var version: int = int(data.get("save_version", -1)) diff --git a/project.godot b/project.godot index c09f8a1..c77d597 100644 --- a/project.godot +++ b/project.godot @@ -107,6 +107,11 @@ fish_primary={ , Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":10,"pressure":0.0,"pressed":false,"script":null) ] } +reel_alternate={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":96,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} camera_drag={ "deadzone": 0.2, "events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":32,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null) diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index 7a2e626..d800670 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -32,8 +32,11 @@ const WorldWeatherServiceType = preload( "res://world/world_weather_service.gd" ) const PlayerJobServiceType = preload("res://jobs/player_job_service.gd") +const PlayerType = preload("res://player/player.gd") -const SAVE_VERSION: int = 9 +const SAVE_VERSION: int = 10 +const LEGACY_SAVE_FILENAME := "player_save.json" +const ARCHIVE_EXTENSION := ".nfsave" const BASIC_ROD_ID: StringName = &"basic_fishing_rod" const MAX_SAFE_BALANCE: int = 1000000000000 const DEFAULT_WORLD_SEED: int = 13001 @@ -49,6 +52,8 @@ class LoadSnapshot: var next_catch_sequence: int = 1 var bag_items: Array[OwnedItemType] = [] var unlocked_bait_ids: Array[StringName] = [] + var active_bait_id: StringName + var active_lure_id: StringName var hotbar_slots: Array[StringName] = [] var fish_hotbar_slots: Array[StringName] = [] var selected_hotbar_slot: int = 0 @@ -87,6 +92,7 @@ var _experience: PlayerExperienceType var _world_time: WorldTimeServiceType var _world_weather: WorldWeatherServiceType var _jobs: PlayerJobServiceType +var _player: PlayerType var _autosave_timer: Timer var _is_configured: bool = false var _is_restoring: bool = false @@ -112,6 +118,21 @@ func _backup_path() -> String: return _save_path + ".backup" +func _codec_scratch_path() -> String: + return _save_path + ".codec.tmp" + + +func _legacy_save_path() -> String: + return _save_path.get_base_dir().path_join(LEGACY_SAVE_FILENAME) + + +func _read_path() -> String: + if FileAccess.file_exists(_save_path): + return _save_path + var legacy: String = _legacy_save_path() + return legacy if FileAccess.file_exists(legacy) else "" + + func _ready() -> void: _autosave_timer = Timer.new() _autosave_timer.one_shot = true @@ -135,6 +156,7 @@ func setup( world_time: WorldTimeServiceType, world_weather: WorldWeatherServiceType, jobs: PlayerJobServiceType, + player: PlayerType, ) -> void: _inventory = inventory _collection_log = collection_log @@ -151,6 +173,7 @@ func setup( _world_time = world_time _world_weather = world_weather _jobs = jobs + _player = player _is_configured = ( _inventory != null and _collection_log != null @@ -167,6 +190,7 @@ func setup( and _world_time != null and _world_weather != null and _jobs != null + and _player != null ) if not _is_configured: push_error("PlayerSaveManager setup is missing required references.") @@ -209,6 +233,10 @@ func setup( _experience.experience_changed.connect(_on_experience_changed) if not _jobs.changed.is_connected(_mark_dirty): _jobs.changed.connect(_mark_dirty) + if not _player.active_bait_changed.is_connected(_on_active_tackle_changed): + _player.active_bait_changed.connect(_on_active_tackle_changed) + if not _player.active_lure_changed.is_connected(_on_active_tackle_changed): + _player.active_lure_changed.connect(_on_active_tackle_changed) func load_player_data() -> bool: @@ -216,24 +244,22 @@ func load_player_data() -> bool: return false _automatic_saving_blocked = false _recover_interrupted_write() - if _save_path.is_empty() or not FileAccess.file_exists(_save_path): + var read_path: String = _read_path() + if _save_path.is_empty() or read_path.is_empty(): _is_dirty = false return true - - var save_file := FileAccess.open(_save_path, FileAccess.READ) - if save_file == null: - _handle_corrupt_save("Unable to open player save.") + var decoded: Dictionary = ProgressionSaveCodec.read_local_save( + read_path, + read_path == _legacy_save_path(), + ) + if not bool(decoded.get("ok", false)): + _handle_corrupt_save("Player save could not be decoded.", read_path) return false - var json_text: String = save_file.get_as_text() - save_file.close() - _expected_hash = PortableFileGuard.hash_file(_save_path) - - var json := JSON.new() - var parse_error: Error = json.parse(json_text) - if parse_error != OK or typeof(json.data) != TYPE_DICTIONARY: - _handle_corrupt_save("Player save contains malformed JSON.") - return false - var save_data: Dictionary = json.data + _expected_hash = ( + PortableFileGuard.hash_file(_save_path) + if read_path == _save_path else "" + ) + var save_data: Dictionary = decoded["data"] var version: int = _read_integer(save_data.get("save_version"), -1) if version > SAVE_VERSION: _automatic_saving_blocked = true @@ -250,13 +276,16 @@ func load_player_data() -> bool: save_data = _migrate_save(save_data, version) if save_data.is_empty(): _handle_corrupt_save( - "Player save version %d is unsupported." % version + "Player save version %d is unsupported." % version, + read_path, ) return false var snapshot: LoadSnapshot = _build_load_snapshot(save_data) if snapshot == null: - _handle_corrupt_save("Player save failed structural validation.") + _handle_corrupt_save( + "Player save failed structural validation.", read_path + ) return false _is_restoring = true @@ -277,6 +306,7 @@ func load_player_data() -> bool: _bag.replace_all_items(snapshot.bag_items) and _bag.replace_unlocked_bait_ids(snapshot.unlocked_bait_ids) ) + var tackle_restored: bool = _restore_tackle_selection(snapshot) var upgrades_restored: bool = _fishing_upgrades.restore_levels( snapshot.reel_speed_level, snapshot.barrier_power_level @@ -316,6 +346,7 @@ func load_player_data() -> bool: or not collection_restored or not wallet_restored or not bag_restored + or not tackle_restored or not hotbar_restored or not upgrades_restored or not cooler_restored @@ -329,7 +360,7 @@ func load_player_data() -> bool: push_error( ( "Validated player save could not be restored: " - + "inventory=%s collection=%s wallet=%s bag=%s hotbar=%s " + + "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" ) @@ -338,6 +369,7 @@ func load_player_data() -> bool: collection_restored, wallet_restored, bag_restored, + tackle_restored, hotbar_restored, upgrades_restored, cooler_restored, @@ -352,6 +384,8 @@ func load_player_data() -> bool: return false _is_dirty = false + if read_path == _legacy_save_path(): + _migrate_legacy_plaintext_save(save_data, read_path) print( "Loaded player save version %d with %d catches." % [SAVE_VERSION, snapshot.catches.size()] @@ -362,24 +396,21 @@ func load_player_data() -> bool: func inspect_save() -> SaveInspectionType: var result := SaveInspectionType.new() _recover_interrupted_write() - result.has_primary_file = FileAccess.file_exists(_save_path) + var read_path: String = _read_path() + result.has_primary_file = not read_path.is_empty() if not result.has_primary_file: result.status = SaveInspectionType.Status.MISSING result.message = "no save found." return result - var save_file := FileAccess.open(_save_path, FileAccess.READ) - if save_file == null: + var decoded: Dictionary = ProgressionSaveCodec.read_local_save( + read_path, + read_path == _legacy_save_path(), + ) + if not bool(decoded.get("ok", false)): result.status = SaveInspectionType.Status.IO_ERROR result.message = "the save could not be read." return result - var json := JSON.new() - var parse_error: Error = json.parse(save_file.get_as_text()) - save_file.close() - if parse_error != OK or typeof(json.data) != TYPE_DICTIONARY: - result.status = SaveInspectionType.Status.MALFORMED - result.message = "the save is corrupt and was preserved." - return result - var save_data: Dictionary = json.data + var save_data: Dictionary = decoded["data"] result.detected_version = _read_integer(save_data.get("save_version"), -1) if result.detected_version > SAVE_VERSION: result.status = SaveInspectionType.Status.UNSUPPORTED_VERSION @@ -408,6 +439,88 @@ func inspect_save() -> SaveInspectionType: return result +func export_progression_archive(path: String) -> Dictionary: + if not _is_configured or path.is_empty(): + return {"ok": false, "message": "progression export is unavailable."} + if _is_dirty and not save_if_dirty(): + return {"ok": false, "message": "progression could not be saved first."} + var source_path: String = _read_path() + if source_path.is_empty(): + return {"ok": false, "message": "there is no progression to export."} + var decoded: Dictionary = ProgressionSaveCodec.read_local_save( + source_path, + source_path == _legacy_save_path(), + ) + if not bool(decoded.get("ok", false)): + return {"ok": false, "message": "the active progression could not be read."} + var prepared: Dictionary = _prepare_external_save_data(decoded["data"]) + if not bool(prepared.get("ok", false)): + return prepared + var bytes: PackedByteArray = ProgressionSaveCodec.encode_archive( + prepared["data"], + path + ".codec.tmp", + ) + if bytes.is_empty(): + return {"ok": false, "message": "the progression archive could not be encoded."} + var result: Dictionary = PortableFileGuard.write_guarded( + path, + bytes, + PortableFileGuard.hash_file(path), + _data_root.conflict_directory(), + _data_root.device_id, + ) + if not bool(result.get("ok", false)): + return {"ok": false, "message": "the progression archive could not be written."} + var verified: Dictionary = inspect_progression_archive(path) + if not bool(verified.get("ok", false)): + return {"ok": false, "message": "the progression archive could not be verified."} + verified["message"] = "progression archive created." + return verified + + +func inspect_progression_archive(path: String) -> Dictionary: + var decoded: Dictionary = ProgressionSaveCodec.read_archive(path) + if not bool(decoded.get("ok", false)): + return { + "ok": false, + "message": str(decoded.get("message", "could not open progression archive.")), + } + var prepared: Dictionary = _prepare_external_save_data(decoded["data"]) + if not bool(prepared.get("ok", false)): + return prepared + prepared["game_version"] = str(decoded.get("game_version", "")) + prepared["created_at_unix"] = int(decoded.get("created_at_unix", 0)) + return prepared + + +func import_progression_archive(path: String) -> Dictionary: + if not _is_configured: + return {"ok": false, "message": "progression import is unavailable."} + var inspected: Dictionary = inspect_progression_archive(path) + if not bool(inspected.get("ok", false)): + return inspected + var current_path: String = _read_path() + if not current_path.is_empty() and not _archive_replaced_save(current_path): + return {"ok": false, "message": "the current progression could not be backed up."} + var expected_hash: String = PortableFileGuard.hash_file(_save_path) + var result: Dictionary = _write_current_save_data( + inspected["data"], expected_hash + ) + if not bool(result.get("ok", false)): + return {"ok": false, "message": "the imported progression could not be installed."} + _expected_hash = str(result.get("hash", "")) + _remove_legacy_save_files() + _is_dirty = false + _automatic_saving_blocked = false + return { + "ok": true, + "message": "progression imported.", + "catch_count": inspected["catch_count"], + "wallet_balance": inspected["wallet_balance"], + "discovered_species_count": inspected["discovered_species_count"], + } + + func initialize_new_game(world_seed: int = DEFAULT_WORLD_SEED) -> bool: if not _is_configured: return false @@ -431,9 +544,19 @@ static func roll_world_seed() -> int: func delete_progression_save() -> bool: - if FileAccess.file_exists(_save_path) and not _remove_if_present(_save_path): - return false - for path: String in [_temp_path(), _backup_path()]: + for primary_path: String in [_save_path, _legacy_save_path()]: + if ( + FileAccess.file_exists(primary_path) + and not _remove_if_present(primary_path) + ): + return false + for path: String in [ + _temp_path(), + _backup_path(), + _codec_scratch_path(), + _legacy_save_path() + ".tmp", + _legacy_save_path() + ".backup", + ]: if FileAccess.file_exists(path) and not _remove_if_present(path): push_warning("Unable to remove stale player-save auxiliary file.") if _autosave_timer != null: @@ -485,16 +608,8 @@ func save_now() -> bool: var save_data: Dictionary = _build_save_dictionary() if save_data.is_empty(): return false - var json_text: String = JSON.stringify(save_data, "\t") - if json_text.is_empty(): - return false - - var result := PortableFileGuard.write_guarded( - _save_path, - json_text.to_utf8_buffer(), - _expected_hash, - _data_root.conflict_directory(), - _data_root.device_id, + var result: Dictionary = _write_current_save_data( + save_data, _expected_hash ) if bool(result.get("conflict", false)): _data_root.report_conflict( @@ -574,6 +689,17 @@ func _build_save_dictionary() -> Dictionary: ) ): return {} + var active_bait_id: StringName = _player.active_bait_id + var active_lure_id: StringName = _player.active_lure_id + if ( + (not active_bait_id.is_empty() and not _valid_active_tackle( + active_bait_id, true + )) + or (not active_lure_id.is_empty() and not _valid_active_tackle( + active_lure_id, false + )) + ): + return {} return { "save_version": SAVE_VERSION, @@ -592,6 +718,10 @@ func _build_save_dictionary() -> Dictionary: "items": serialized_items, "unlocked_bait_ids": serialized_unlocked_baits, }, + "tackle": { + "active_bait_id": String(active_bait_id), + "active_lure_id": String(active_lure_id), + }, "hotbar": { "selected_slot": _hotbar.get_selected_slot(), "slots": serialized_slots, @@ -620,6 +750,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: or typeof(save_data.get("collection")) != TYPE_DICTIONARY or typeof(save_data.get("inventory")) != TYPE_DICTIONARY or typeof(save_data.get("bag")) != TYPE_DICTIONARY + or typeof(save_data.get("tackle")) != TYPE_DICTIONARY or typeof(save_data.get("hotbar")) != TYPE_DICTIONARY or typeof(save_data.get("inventory_layout")) != TYPE_DICTIONARY or typeof(save_data.get("experience")) != TYPE_DICTIONARY @@ -630,6 +761,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: var collection_data: Dictionary = save_data["collection"] var inventory_data: Dictionary = save_data["inventory"] var bag_data: Dictionary = save_data["bag"] + var tackle_data: Dictionary = save_data["tackle"] var hotbar_data: Dictionary = save_data["hotbar"] var inventory_layout_data: Dictionary = save_data["inventory_layout"] var experience_data: Dictionary = save_data["experience"] @@ -665,6 +797,12 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: and typeof(bag_data.get("unlocked_bait_ids")) != TYPE_ARRAY ) or typeof(hotbar_data.get("slots")) != TYPE_ARRAY + or typeof(tackle_data.get("active_bait_id")) not in [ + TYPE_STRING, TYPE_STRING_NAME, + ] + or typeof(tackle_data.get("active_lure_id")) not in [ + TYPE_STRING, TYPE_STRING_NAME, + ] or not hotbar_data.has("selected_slot") or not experience_data.has("total_experience") ): @@ -897,6 +1035,16 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: ): seen_unlocked_baits[owned.item_id] = true snapshot.unlocked_bait_ids.append(owned.item_id) + snapshot.active_bait_id = _validated_tackle_id( + StringName(str(tackle_data.get("active_bait_id", ""))), + true, + seen_items, + ) + snapshot.active_lure_id = _validated_tackle_id( + StringName(str(tackle_data.get("active_lure_id", ""))), + false, + seen_items, + ) var slot_values: Array = hotbar_data["slots"] snapshot.hotbar_slots.resize(PlayerHotbarType.SLOT_COUNT) @@ -995,6 +1143,8 @@ func _migrate_save( migrated = _migrate_version_7_to_8(migrated) 8: migrated = _migrate_version_8_to_9(migrated) + 9: + migrated = _migrate_version_9_to_10(migrated) _: return {} if migrated.is_empty(): @@ -1242,6 +1392,16 @@ func _migrate_version_8_to_9(data: Dictionary) -> Dictionary: return migrated +func _migrate_version_9_to_10(data: Dictionary) -> Dictionary: + var migrated: Dictionary = data.duplicate(true) + migrated["tackle"] = { + "active_bait_id": "", + "active_lure_id": "", + } + migrated["save_version"] = 10 + return migrated + + func _mark_dirty() -> void: if ( _is_restoring @@ -1291,33 +1451,168 @@ func _on_experience_changed(_total_experience: int, _level: int) -> void: _mark_dirty() +func _on_active_tackle_changed(_item_id: StringName) -> void: + _mark_dirty() + + func _on_autosave_timeout() -> void: if _is_dirty: save_now() func _recover_interrupted_write() -> void: - if FileAccess.file_exists(_save_path): - _remove_if_present(_temp_path()) - _remove_if_present(_backup_path()) + _remove_if_present(_codec_scratch_path()) + _recover_path_write(_save_path) + _recover_path_write(_legacy_save_path()) + + +func _recover_path_write(path: String) -> void: + var temporary: String = path + ".tmp" + var backup: String = path + ".backup" + if FileAccess.file_exists(path): + _remove_if_present(temporary) + _remove_if_present(backup) return - if FileAccess.file_exists(_backup_path()): - _rename_file(_backup_path(), _save_path) - _remove_if_present(_temp_path()) + if FileAccess.file_exists(backup): + _rename_file(backup, path) + _remove_if_present(temporary) -func _handle_corrupt_save(message: String) -> void: +func _write_current_save_data( + save_data: Dictionary, + expected_hash: String, +) -> Dictionary: + var bytes: PackedByteArray = ProgressionSaveCodec.encode_local_save( + save_data, + _codec_scratch_path(), + ) + if bytes.is_empty(): + return {"ok": false} + return PortableFileGuard.write_guarded( + _save_path, + bytes, + expected_hash, + _data_root.conflict_directory(), + _data_root.device_id, + ) + + +func _prepare_external_save_data(value: Variant) -> Dictionary: + if typeof(value) != TYPE_DICTIONARY: + return {"ok": false, "message": "progression data is malformed."} + var save_data: Dictionary = (value as Dictionary).duplicate(true) + var version: int = _read_integer(save_data.get("save_version"), -1) + if version > SAVE_VERSION: + return { + "ok": false, + "message": "this progression belongs to a newer game version.", + } + if version < 1: + return {"ok": false, "message": "the progression version is unsupported."} + if version != SAVE_VERSION: + save_data = _migrate_save(save_data, version) + if save_data.is_empty(): + return { + "ok": false, + "message": "the progression version is unsupported.", + } + var snapshot: LoadSnapshot = _build_load_snapshot(save_data) + if snapshot == null: + return {"ok": false, "message": "the progression is structurally invalid."} + return { + "ok": true, + "data": save_data, + "catch_count": snapshot.catches.size(), + "wallet_balance": snapshot.wallet_balance, + "discovered_species_count": snapshot.discovered_ids.size(), + "world_seed": snapshot.world_seed, + } + + +func _migrate_legacy_plaintext_save( + save_data: Dictionary, + legacy_path: String, +) -> void: + var result: Dictionary = _write_current_save_data(save_data, "") + if not bool(result.get("ok", false)): + push_warning("Legacy progression loaded but could not be encrypted yet.") + return + _expected_hash = str(result.get("hash", "")) + if not _archive_replaced_save(legacy_path, "plaintext-migration"): + push_warning( + "Legacy progression was encrypted, but its migration backup failed." + ) + return + _remove_legacy_save_files() + print("Upgraded legacy plaintext progression to the opaque save format.") + + +func _archive_replaced_save( + source_path: String, + reason: String = "before-import", +) -> bool: + if source_path.is_empty() or not FileAccess.file_exists(source_path): + return true + var timestamp: String = Time.get_datetime_string_from_system().replace( + ":", "-" + ) + var destination: String = _data_root.root_path.path_join( + "backups/saves/player-save-%s-%s%s" + % [reason, timestamp, ARCHIVE_EXTENSION] + ) + if FileAccess.file_exists(destination): + destination = destination.trim_suffix(ARCHIVE_EXTENSION) + ( + "-%s%s" % [Time.get_ticks_usec(), ARCHIVE_EXTENSION] + ) + var decoded: Dictionary = ProgressionSaveCodec.read_local_save( + source_path, + source_path == _legacy_save_path(), + ) + var bytes: PackedByteArray + if bool(decoded.get("ok", false)): + var prepared: Dictionary = _prepare_external_save_data(decoded["data"]) + if bool(prepared.get("ok", false)): + bytes = ProgressionSaveCodec.encode_archive( + prepared["data"], destination + ".codec.tmp" + ) + if bytes.is_empty(): + bytes = PortableFileGuard.read_bytes(source_path) + destination = ( + destination.trim_suffix(ARCHIVE_EXTENSION) + ".preserved" + ) + if bytes.is_empty(): + return false + var result: Dictionary = PortableFileGuard.write_guarded( + destination, + bytes, + "", + _data_root.conflict_directory(), + _data_root.device_id, + ) + return bool(result.get("ok", false)) + + +func _remove_legacy_save_files() -> void: + for path: String in [ + _legacy_save_path(), + _legacy_save_path() + ".tmp", + _legacy_save_path() + ".backup", + ]: + _remove_if_present(path) + + +func _handle_corrupt_save(message: String, source_path: String) -> void: push_warning(message) _restore_defaults() - if not FileAccess.file_exists(_save_path): + if not FileAccess.file_exists(source_path): return var timestamp: int = int(Time.get_unix_time_from_system()) var corrupt_path: String = ( - "%s.corrupt-%d" % [_save_path, timestamp] + "%s.corrupt-%d" % [source_path, timestamp] ) if FileAccess.file_exists(corrupt_path): corrupt_path += "-%d" % Time.get_ticks_usec() - if not _rename_file(_save_path, corrupt_path): + if not _rename_file(source_path, corrupt_path): _automatic_saving_blocked = true push_warning( "Corrupt player save was left in place; automatic saving is " @@ -1350,6 +1645,8 @@ func _restore_defaults() -> void: _bag.replace_all_items(default_items) var default_unlocked_baits: Array[StringName] = [] _bag.replace_unlocked_bait_ids(default_unlocked_baits) + _player.unequip_bait() + _player.unequip_lure() _fishing_upgrades.reset_to_defaults() _cooler_capacity.reset_to_defaults() _inventory_layout.reset_to_defaults() @@ -1366,6 +1663,53 @@ func _restore_defaults() -> void: _is_dirty = false +func _valid_active_tackle(item_id: StringName, bait: bool) -> bool: + var item = _item_catalog.get_item_by_id(item_id) + return ( + item != null + and item.is_available() + and (item.is_bait() if bait else item.is_lure()) + and _bag.owns_item(item_id) + ) + + +func _validated_tackle_id( + item_id: StringName, + bait: bool, + seen_items: Dictionary[StringName, bool], +) -> StringName: + if item_id.is_empty(): + return StringName() + var item = _item_catalog.get_item_by_id(item_id) + if ( + item == null + or not item.is_available() + or not seen_items.has(item_id) + or not (item.is_bait() if bait else item.is_lure()) + ): + push_warning("Skipped invalid saved active tackle '%s'." % String(item_id)) + return StringName() + return item_id + + +func _restore_tackle_selection(snapshot: LoadSnapshot) -> bool: + _player.unequip_bait() + _player.unequip_lure() + var bait_restored: bool = ( + snapshot.active_bait_id.is_empty() + or _player.equip_bait( + _item_catalog.get_item_by_id(snapshot.active_bait_id) + ) + ) + var lure_restored: bool = ( + snapshot.active_lure_id.is_empty() + or _player.equip_lure( + _item_catalog.get_item_by_id(snapshot.active_lure_id) + ) + ) + return bait_restored and lure_restored + + func _read_integer( value: Variant, invalid_value: int, diff --git a/save/progression_save_codec.gd b/save/progression_save_codec.gd new file mode 100644 index 0000000..7844e16 --- /dev/null +++ b/save/progression_save_codec.gd @@ -0,0 +1,179 @@ +class_name ProgressionSaveCodec +extends RefCounted + +const MAGIC := "NETFISHING_PROGRESSION_CONTAINER" +const FORMAT_VERSION := 1 +const LOCAL_KIND := "local_save" +const ARCHIVE_KIND := "portable_archive" +const MAX_CONTAINER_BYTES := 16 * 1024 * 1024 + +# This key keeps routine progression files opaque to casual editing. Since the +# client is open source, it is intentionally not treated as a security secret. +const CONTAINER_PASSPHRASE := ( + "NETfishing progression container v1 / not an identity credential" +) + + +static func encode_local_save( + save_data: Dictionary, + scratch_path: String, +) -> PackedByteArray: + return _encode_container(LOCAL_KIND, save_data, scratch_path) + + +static func encode_archive( + save_data: Dictionary, + scratch_path: String, +) -> PackedByteArray: + return _encode_container(ARCHIVE_KIND, save_data, scratch_path) + + +static func read_local_save( + path: String, + allow_legacy_plaintext: bool = false, +) -> Dictionary: + if allow_legacy_plaintext and not _looks_encrypted(path): + var legacy: Dictionary = _read_plaintext_dictionary(path) + if bool(legacy.get("ok", false)): + legacy["legacy_plaintext"] = true + return legacy + return _read_container(path, LOCAL_KIND) + + +static func read_archive(path: String) -> Dictionary: + if not _looks_encrypted(path): + return {"ok": false, "message": "progression archive is not recognized."} + return _read_container(path, ARCHIVE_KIND) + + +static func _encode_container( + kind: String, + save_data: Dictionary, + scratch_path: String, +) -> PackedByteArray: + if save_data.is_empty() or scratch_path.is_empty(): + return PackedByteArray() + var payload_json: String = JSON.stringify(save_data) + if payload_json.is_empty(): + return PackedByteArray() + var payload_bytes: PackedByteArray = payload_json.to_utf8_buffer() + var envelope: Dictionary = { + "magic": MAGIC, + "format_version": FORMAT_VERSION, + "kind": kind, + "game_version": str( + ProjectSettings.get_setting("application/config/version", "") + ), + "created_at_unix": int(Time.get_unix_time_from_system()), + "payload_sha256": PortableFileGuard.hash_bytes(payload_bytes), + "payload_json": payload_json, + } + if DirAccess.make_dir_recursive_absolute( + scratch_path.get_base_dir() + ) != OK: + return PackedByteArray() + _remove_if_present(scratch_path) + var file: FileAccess = FileAccess.open_encrypted_with_pass( + scratch_path, + FileAccess.WRITE, + CONTAINER_PASSPHRASE, + ) + if file == null: + return PackedByteArray() + file.store_string(JSON.stringify(envelope)) + file.flush() + var write_ok: bool = file.get_error() == OK + file.close() + if not write_ok: + _remove_if_present(scratch_path) + return PackedByteArray() + var encoded: PackedByteArray = PortableFileGuard.read_bytes( + scratch_path, + MAX_CONTAINER_BYTES, + ) + _remove_if_present(scratch_path) + return encoded + + +static func _read_container(path: String, expected_kind: String) -> Dictionary: + if ( + path.is_empty() + or not FileAccess.file_exists(path) + or FileAccess.get_size(path) > MAX_CONTAINER_BYTES + ): + return {"ok": false, "message": "progression file is unavailable."} + var file: FileAccess = FileAccess.open_encrypted_with_pass( + path, + FileAccess.READ, + CONTAINER_PASSPHRASE, + ) + if file == null: + return {"ok": false, "message": "progression file could not be opened."} + var envelope_text: String = file.get_as_text() + file.close() + var envelope_json := JSON.new() + if ( + envelope_json.parse(envelope_text) != OK + or typeof(envelope_json.data) != TYPE_DICTIONARY + ): + return {"ok": false, "message": "progression container is malformed."} + var envelope: Dictionary = envelope_json.data + if ( + envelope.get("magic") != MAGIC + or int(envelope.get("format_version", -1)) != FORMAT_VERSION + or str(envelope.get("kind", "")) != expected_kind + ): + return {"ok": false, "message": "progression container is unsupported."} + var payload_json: String = str(envelope.get("payload_json", "")) + var payload_bytes: PackedByteArray = payload_json.to_utf8_buffer() + if ( + payload_json.is_empty() + or str(envelope.get("payload_sha256", "")) + != PortableFileGuard.hash_bytes(payload_bytes) + ): + return {"ok": false, "message": "progression container failed validation."} + var payload_parser := JSON.new() + if ( + payload_parser.parse(payload_json) != OK + or typeof(payload_parser.data) != TYPE_DICTIONARY + ): + return {"ok": false, "message": "progression payload is malformed."} + return { + "ok": true, + "data": payload_parser.data, + "game_version": str(envelope.get("game_version", "")), + "created_at_unix": int(envelope.get("created_at_unix", 0)), + "legacy_plaintext": false, + } + + +static func _read_plaintext_dictionary(path: String) -> Dictionary: + var bytes: PackedByteArray = PortableFileGuard.read_bytes( + path, + MAX_CONTAINER_BYTES, + ) + if bytes.is_empty(): + return {"ok": false} + var text: String = bytes.get_string_from_utf8() + if not text.strip_edges().begins_with("{"): + return {"ok": false} + var parser := JSON.new() + if parser.parse(text) != OK or typeof(parser.data) != TYPE_DICTIONARY: + return {"ok": false} + return {"ok": true, "data": parser.data} + + +static func _looks_encrypted(path: String) -> bool: + if not FileAccess.file_exists(path) or FileAccess.get_size(path) < 4: + return false + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return false + var header: PackedByteArray = file.get_buffer(4) + file.close() + return header == PackedByteArray([0x47, 0x44, 0x45, 0x43]) + + +static func _remove_if_present(path: String) -> void: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(path) diff --git a/save/progression_save_codec.gd.uid b/save/progression_save_codec.gd.uid new file mode 100644 index 0000000..6db5823 --- /dev/null +++ b/save/progression_save_codec.gd.uid @@ -0,0 +1 @@ +uid://lad8mvedktww diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index 67b8abe..7e82076 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -22,6 +22,7 @@ readonly -a QUICK_TESTS=( "tests/controller_ui_navigation_validation.gd" "tests/dedicated_server_config_validation.gd" "tests/digging_prototype_validation.gd" + "tests/equipment_catalog_validation.gd" "tests/exported_decal_hotfix_validation.gd" "tests/file_dialog_controller_navigation_validation.gd" "tests/fish_catalog_content_validation.gd" @@ -72,10 +73,12 @@ readonly -a HOST_TESTS=( "tests/gathering_showcase_validation.gd" "tests/inventory_storage_persistence_validation.gd" "tests/job_system_validation.gd" + "tests/progression_archive_validation.gd" "tests/surface_drawing_runtime_validation.gd" ) readonly -a NETWORK_TESTS=( + "tests/chat_privacy_multiplayer_validation.gd" "tests/economy_regression_validation.gd" "tests/fish_showcase_multiplayer_validation.gd" "tests/fishing_multiplayer_validation.gd" diff --git a/server/dedicated_server_config.gd b/server/dedicated_server_config.gd index 7812800..2f515c4 100644 --- a/server/dedicated_server_config.gd +++ b/server/dedicated_server_config.gd @@ -17,6 +17,8 @@ var world_seed: int = DEFAULT_WORLD_SEED var public_listing: bool = false var discovery_url: String = "" var data_directory: String = "" +var chat_logging: bool = false +var chat_log_path: String = "" var operator_fingerprints: PackedStringArray = PackedStringArray() var error_message: String = "" @@ -71,6 +73,12 @@ func _load_file(path: String) -> bool: discovery_url = str(file.get_value( "discovery", "url", discovery_url )) + chat_logging = bool(file.get_value( + "privacy", "chat_logging", chat_logging + )) + chat_log_path = str(file.get_value( + "privacy", "chat_log_path", chat_log_path + )) operator_fingerprints = _parse_fingerprint_list(file.get_value( "moderation", "operators", operator_fingerprints )) @@ -100,6 +108,12 @@ func _apply_environment() -> void: data_directory = _environment_string( "NETFISHING_DATA_DIR", data_directory ) + chat_logging = _environment_bool( + "NETFISHING_CHAT_LOGGING", chat_logging + ) + chat_log_path = _environment_string( + "NETFISHING_CHAT_LOG_PATH", chat_log_path + ) if OS.has_environment("NETFISHING_SERVER_OPERATORS"): operator_fingerprints = _parse_fingerprint_list( OS.get_environment("NETFISHING_SERVER_OPERATORS") @@ -130,10 +144,16 @@ func _apply_arguments(arguments: PackedStringArray) -> void: operator_fingerprints = _parse_fingerprint_list( argument.trim_prefix("--operators=") ) + elif argument.begins_with("--chat-log-path="): + chat_log_path = argument.trim_prefix("--chat-log-path=") elif argument == "--public": public_listing = true elif argument == "--private": public_listing = false + elif argument == "--chat-logging": + chat_logging = true + elif argument == "--no-chat-logging": + chat_logging = false func _validate() -> void: @@ -141,6 +161,9 @@ func _validate() -> void: bind_address = bind_address.strip_edges() discovery_url = discovery_url.strip_edges().trim_suffix("/") data_directory = data_directory.strip_edges() + chat_log_path = chat_log_path.strip_edges() + if chat_logging and chat_log_path.is_empty() and not data_directory.is_empty(): + chat_log_path = data_directory.path_join("logs/chat.jsonl") operator_fingerprints = _normalized_fingerprints(operator_fingerprints) if server_name.is_empty(): error_message = "Server name cannot be empty." @@ -156,6 +179,12 @@ func _validate() -> void: error_message = "World seed must be between 1 and %d." % MAX_WORLD_SEED elif not data_directory.is_empty() and not data_directory.is_absolute_path(): error_message = "Server data directory must be an absolute path." + elif chat_logging and ( + chat_log_path.is_empty() or not chat_log_path.is_absolute_path() + ): + error_message = ( + "Chat logging requires an absolute chat log path." + ) elif public_listing and not ( discovery_url.begins_with("https://") or discovery_url.begins_with("http://") diff --git a/settings/keyboard_mouse_mapping_manager.gd b/settings/keyboard_mouse_mapping_manager.gd index a8d4f09..d9cb03d 100644 --- a/settings/keyboard_mouse_mapping_manager.gd +++ b/settings/keyboard_mouse_mapping_manager.gd @@ -3,7 +3,8 @@ extends Node signal mapping_changed -const FORMAT_VERSION: int = 1 +const FORMAT_VERSION: int = 2 +const LEGACY_FORMAT_VERSION: int = 1 const MAPPING_PATH: String = "user://keyboard_mouse_bindings.json" const MAPPING_TEMP_PATH: String = "user://keyboard_mouse_bindings.json.tmp" const MAPPING_BACKUP_PATH: String = ( @@ -21,6 +22,7 @@ const ROLE_SNEAK: StringName = &"sneak" const ROLE_SLOW_WALK: StringName = &"slow_walk" const ROLE_INTERACT: StringName = &"interact" const ROLE_PRIMARY_ACTION: StringName = &"fish_primary" +const ROLE_ALTERNATE_REEL: StringName = &"reel_alternate" const ROLE_CAMERA_DRAG: StringName = &"camera_drag" const ROLE_CAMERA_ZOOM_IN: StringName = &"camera_zoom_in" const ROLE_CAMERA_ZOOM_OUT: StringName = &"camera_zoom_out" @@ -46,6 +48,7 @@ const ROLE_ORDER: Array[StringName] = [ ROLE_SLOW_WALK, ROLE_INTERACT, ROLE_PRIMARY_ACTION, + ROLE_ALTERNATE_REEL, ROLE_CAMERA_DRAG, ROLE_CAMERA_ZOOM_IN, ROLE_CAMERA_ZOOM_OUT, @@ -81,6 +84,7 @@ const ROLE_LABELS: Dictionary = { ROLE_SLOW_WALK: "slow walk", ROLE_INTERACT: "interact", ROLE_PRIMARY_ACTION: "primary action", + ROLE_ALTERNATE_REEL: "alternate reel", ROLE_CAMERA_DRAG: "rotate camera", ROLE_CAMERA_ZOOM_IN: "camera zoom in", ROLE_CAMERA_ZOOM_OUT: "camera zoom out", @@ -138,19 +142,28 @@ func load_mapping() -> bool: _bindings = {} return false var data := json.data as Dictionary - if int(data.get("format_version", -1)) != FORMAT_VERSION: + var format_version: int = int(data.get("format_version", -1)) + if format_version not in [LEGACY_FORMAT_VERSION, FORMAT_VERSION]: push_warning("Keyboard binding version is unsupported; using defaults.") _bindings = {} return false var raw_bindings: Variant = data.get("bindings", {}) if ( typeof(raw_bindings) != TYPE_DICTIONARY - or not _validate_complete_bindings(raw_bindings as Dictionary) ): push_warning("Keyboard bindings are incomplete; using defaults.") _bindings = {} return false - _bindings = (raw_bindings as Dictionary).duplicate(true) + var loaded_bindings := (raw_bindings as Dictionary).duplicate(true) + if format_version == LEGACY_FORMAT_VERSION: + loaded_bindings[str(ROLE_ALTERNATE_REEL)] = ( + _default_bindings[str(ROLE_ALTERNATE_REEL)].duplicate(true) + ) + if not _validate_complete_bindings(loaded_bindings): + push_warning("Keyboard bindings are incomplete; using defaults.") + _bindings = {} + return false + _bindings = loaded_bindings return true diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index 78a020f..aeebe05 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -86,6 +86,11 @@ func _run() -> void: var chat_ui := game_ui.get_node("%ChatUI") as ChatUI assert(player != null and service != null and toolbar != null) assert(chat_ui != null) + assert(toolbar.get_parent() == chat_ui.get_parent()) + assert( + toolbar.get_index() > chat_ui.get_index(), + "The art-kit toolbar must receive overlapping pointer input before Chat.", + ) assert(bool(game_ui.call("_can_start_virtual_mouse"))) var virtual_pointer_states: Array[bool] = [] game_ui.virtual_pointer_mode_changed.connect( @@ -317,6 +322,22 @@ func _run() -> void: assert(not ( game_ui.get_node("%ExperiencePresentation") as Control ).visible) + var hud_fishing_spot := main.get_node("%FishingSpot") as FishingSpot + assert(hud_fishing_spot != null) + hud_fishing_spot.state = FishingSpot.FishingState.SHOWING_CATCH + game_ui.call("_refresh_gameplay_hud_visibility") + assert(( + game_ui.get_node("%GameplayTransientHUD") as Control + ).visible) + assert(not ( + game_ui.get_node("%ExperiencePresentation") as Control + ).visible) + assert(chat_ui.is_hud_hidden()) + hud_fishing_spot.state = FishingSpot.FishingState.READY + game_ui.call("_refresh_gameplay_hud_visibility") + assert(not ( + game_ui.get_node("%GameplayTransientHUD") as Control + ).visible) quick_menu.open_menu(true) assert(quick_menu.visible and quick_menu.is_open()) quick_menu.close_menu() @@ -365,6 +386,12 @@ func _run() -> void: await process_frame assert(service.is_active() and not service.is_placement_mode()) assert(toolbar.visible) + chat_ui.open_chat() + assert(chat_ui.is_open()) + assert(not service.is_active() and not toolbar.visible) + chat_ui.close_chat() + assert(not chat_ui.is_open()) + assert(service.is_active() and toolbar.visible) var ui_root := game_ui.get_node("%UIRoot") as Control assert(toolbar.get_parent() == ui_root) assert(is_zero_approx(toolbar.position.y)) diff --git a/tests/chat_privacy_multiplayer_validation.gd b/tests/chat_privacy_multiplayer_validation.gd new file mode 100644 index 0000000..5d38b08 --- /dev/null +++ b/tests/chat_privacy_multiplayer_validation.gd @@ -0,0 +1,120 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") +const TEST_PORT: int = 18152 +const PREJOIN_MESSAGE: String = "private prejoin message" +const LIVE_MESSAGE: String = "live message after join" + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var arguments: PackedStringArray = OS.get_cmdline_user_args() + if arguments.has("host"): + await _run_host() + return + if arguments.has("client"): + await _run_client() + return + push_error("Chat privacy validation needs host or client mode.") + quit(1) + + +func _run_host() -> void: + var main: Node = await _create_initialized_main() + var session := main.get_node("%NetworkSession") as NetworkSession + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(session.start_private_host(TEST_PORT)) + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + for _frame: int in 4: + await physics_frame + assert(session.set_host_open(true)) + var chat_service := main.get_node( + "%NetworkChatService" + ) as NetworkChatService + assert(chat_service.send_local_message(PREJOIN_MESSAGE)) + var remote_peer_id: int = await _wait_for_remote_peer(session) + assert(remote_peer_id > 1) + await create_timer(1.0).timeout + assert(chat_service.send_local_message(LIVE_MESSAGE)) + var disconnect_deadline: int = Time.get_ticks_msec() + 8000 + while ( + Time.get_ticks_msec() < disconnect_deadline + and session.is_authenticated_peer(remote_peer_id) + ): + await process_frame + assert(not session.is_authenticated_peer(remote_peer_id)) + print("Chat privacy multiplayer host validation: PASS") + await _session_cleanup(main, session) + + +func _run_client() -> void: + var main: Node = await _create_initialized_main() + main.call("_on_title_join_game_requested", "127.0.0.1:%d" % TEST_PORT) + var session := main.get_node("%NetworkSession") as NetworkSession + var join_deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < join_deadline: + await process_frame + if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY: + main.call("_confirm_server_trust") + if session.is_joined_client() and bool(main.get("_gameplay_started")): + break + assert(session.is_joined_client()) + var chat_service := main.get_node( + "%NetworkChatService" + ) as NetworkChatService + await create_timer(0.5).timeout + assert(not _history_contains(chat_service, PREJOIN_MESSAGE)) + var live_deadline: int = Time.get_ticks_msec() + 8000 + while ( + Time.get_ticks_msec() < live_deadline + and not _history_contains(chat_service, LIVE_MESSAGE) + ): + await process_frame + assert(_history_contains(chat_service, LIVE_MESSAGE)) + assert(not _history_contains(chat_service, PREJOIN_MESSAGE)) + print("Chat privacy multiplayer client validation: PASS") + await _session_cleanup(main, session) + + +func _history_contains(service: NetworkChatService, body: String) -> bool: + return service.get_history().any( + func(message: Dictionary) -> bool: + return str(message.get("body", "")) == body + ) + + +func _create_initialized_main() -> Node: + root.size = Vector2i(1280, 720) + var main: Node = MainScene.instantiate() + root.add_child(main) + for _frame: int in 4: + await process_frame + if not bool(main.get("_application_initialized")): + main.call("_activate_selected_data_path", "", true) + for _frame: int in 8: + await process_frame + assert(bool(main.get("_application_initialized"))) + return main + + +func _wait_for_remote_peer(session: NetworkSession) -> int: + var deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < deadline: + await process_frame + for peer_id: int in session.get_authenticated_peer_ids(): + if peer_id != session.get_local_peer_id(): + return peer_id + return 0 + + +func _session_cleanup(main: Node, session: NetworkSession) -> void: + session.disconnect_session("") + main.queue_free() + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout + quit() diff --git a/tests/chat_privacy_multiplayer_validation.gd.uid b/tests/chat_privacy_multiplayer_validation.gd.uid new file mode 100644 index 0000000..33b210d --- /dev/null +++ b/tests/chat_privacy_multiplayer_validation.gd.uid @@ -0,0 +1 @@ +uid://ivtqrnppnmli diff --git a/tests/dedicated_server_config_validation.gd b/tests/dedicated_server_config_validation.gd index c9a82c0..02a881d 100644 --- a/tests/dedicated_server_config_validation.gd +++ b/tests/dedicated_server_config_validation.gd @@ -19,6 +19,12 @@ func _run() -> void: defaults.call("_validate") assert(defaults.is_valid()) assert(not bool(defaults.get("public_listing"))) + assert(not bool(defaults.get("chat_logging"))) + assert(str(defaults.get("chat_log_path")).is_empty()) + assert(NetworkChatService.BURST_COUNT == 3) + assert(NetworkChatService.WINDOW_COUNT == 5) + assert(is_equal_approx(NetworkChatService.WINDOW_SECONDS, 10.0)) + assert(NetworkChatService.CALL_COOLDOWN_MILLISECONDS == 90) var path: String = ProjectSettings.globalize_path( "user://dedicated-server-validation.cfg" @@ -32,6 +38,12 @@ func _run() -> void: file.set_value("server", "public", true) file.set_value("server", "data_directory", "/tmp/configured-server") file.set_value("discovery", "url", "https://discovery.netfishing.org/") + file.set_value("privacy", "chat_logging", true) + file.set_value( + "privacy", + "chat_log_path", + "/tmp/configured-server/logs/chat.jsonl", + ) file.set_value( "moderation", "operators", PackedStringArray([OPERATOR_B, OPERATOR_A]) ) @@ -48,6 +60,11 @@ func _run() -> void: assert(int(configured.get("world_seed")) == 928374) assert(bool(configured.get("public_listing"))) assert(str(configured.get("discovery_url")) == "https://discovery.netfishing.org") + assert(bool(configured.get("chat_logging"))) + assert( + str(configured.get("chat_log_path")) + == "/tmp/configured-server/logs/chat.jsonl" + ) assert( configured.get("operator_fingerprints") == PackedStringArray([OPERATOR_A, OPERATOR_B]) @@ -89,6 +106,51 @@ func _run() -> void: invalid_seed.call("_validate") assert(not invalid_seed.is_valid()) + var default_chat_path := ConfigType.new() + default_chat_path.set("data_directory", "/tmp/default-chat-path-server") + default_chat_path.set("chat_logging", true) + default_chat_path.call("_validate") + assert(default_chat_path.is_valid()) + assert( + str(default_chat_path.get("chat_log_path")) + == "/tmp/default-chat-path-server/logs/chat.jsonl" + ) + + var invalid_chat_path := ConfigType.new() + invalid_chat_path.set("data_directory", "/tmp/invalid-chat-path-server") + invalid_chat_path.set("chat_logging", true) + invalid_chat_path.set("chat_log_path", "relative/chat.jsonl") + invalid_chat_path.call("_validate") + assert(not invalid_chat_path.is_valid()) + + var chat_log_validation_path: String = ProjectSettings.globalize_path( + "user://dedicated-chat-log-validation.jsonl" + ) + var chat_service := NetworkChatService.new() + assert(not chat_service.configure_dedicated_history( + true, + "relative/chat.jsonl", + )) + assert(chat_service.configure_dedicated_history( + true, + chat_log_validation_path, + )) + assert(FileAccess.file_exists(chat_log_validation_path)) + chat_service.call("_append_chat_log", { + "sender_display_name": "Privacy Tester", + "body": "explicitly retained message", + }) + var logged_message: Variant = JSON.parse_string( + FileAccess.get_file_as_string(chat_log_validation_path).strip_edges() + ) + assert(typeof(logged_message) == TYPE_DICTIONARY) + assert(str(logged_message.get("recorded_at_utc", "")).ends_with("Z")) + assert(logged_message.get("sender_display_name") == "Privacy Tester") + assert(logged_message.get("body") == "explicitly retained message") + assert(chat_service.configure_dedicated_history(false, "")) + assert(DirAccess.remove_absolute(chat_log_validation_path) == OK) + chat_service.free() + var discovery := DiscoveryClient.new() assert( str(discovery.call("_default_room_name", "River")) diff --git a/tests/equipment_catalog_validation.gd b/tests/equipment_catalog_validation.gd index a74d066..bc549aa 100644 --- a/tests/equipment_catalog_validation.gd +++ b/tests/equipment_catalog_validation.gd @@ -54,6 +54,21 @@ func _initialize() -> void: assert(FishingShopStockType.get_price(&"magnet") == 250) assert(FishingShopStockType.get_stock_item_ids().has(&"magnet")) assert(FishingShopStockType.is_permanent_unlock(&"magnet", magnet)) + var fishing_net: ItemDataType = ( + ItemCatalogResource.get_available_item_by_id(&"fishing_net") + ) + assert(fishing_net != null) + assert(fishing_net.category == ItemDataType.Category.TOOL) + assert(fishing_net.icon == null) + assert(not fishing_net.usable) + assert(not fishing_net.equippable) + assert(not fishing_net.hotbar_allowed) + assert(FishingShopStockType.get_price(&"fishing_net") == 250) + assert(FishingShopStockType.get_stock_item_ids().has(&"fishing_net")) + assert(FishingShopStockType.is_permanent_unlock( + &"fishing_net", fishing_net + )) + assert(not FishingShopStockType.is_gathering_tool(&"fishing_net")) var crab_net: ItemDataType = ItemCatalogResource.get_available_item_by_id( &"crab_net" ) diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index d61d002..9421816 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -98,29 +98,27 @@ func _run() -> void: assert(save_manager.save_now()) var save_path: String = str(save_manager.get("_save_path")) - var save_file := FileAccess.open(save_path, FileAccess.READ) - assert(save_file != null) - var parsed: Variant = JSON.parse_string(save_file.get_as_text()) - save_file.close() - assert(typeof(parsed) == TYPE_DICTIONARY) - var hotbar_data: Dictionary = (parsed as Dictionary)["hotbar"] + var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path) + assert(bool(decoded.get("ok", false))) + var parsed: Dictionary = decoded["data"] + 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 as Dictionary)["save_version"]) == 9) + assert(int(parsed["save_version"]) == 10) assert( - int((parsed as Dictionary)["experience"]["total_experience"]) + int(parsed["experience"]["total_experience"]) == 125 ) assert(absf( - float((parsed as Dictionary)["world"]["time_hours"]) - saved_time_hours + float(parsed["world"]["time_hours"]) - saved_time_hours ) < 0.01) assert( - int((parsed as Dictionary)["world"]["weather"]) + int(parsed["world"]["weather"]) == int(saved_weather) ) assert(absf( float( - (parsed as Dictionary)["world"][ + parsed["world"][ "weather_seconds_remaining" ] ) - saved_weather_seconds diff --git a/tests/fish_quality_validation.gd b/tests/fish_quality_validation.gd index 31e4cc1..9c5d338 100644 --- a/tests/fish_quality_validation.gd +++ b/tests/fish_quality_validation.gd @@ -433,7 +433,7 @@ func _validate_version_four_migration() -> void: version_four, 4, ) - assert(int(migrated.get("save_version", -1)) == 9) + assert(int(migrated.get("save_version", -1)) == 10) assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0) assert( is_equal_approx( diff --git a/tests/gathering_showcase_validation.gd b/tests/gathering_showcase_validation.gd index 0138bad..e565beb 100644 --- a/tests/gathering_showcase_validation.gd +++ b/tests/gathering_showcase_validation.gd @@ -32,6 +32,7 @@ func _run() -> void: var fishing_spot := main.get_node("%FishingSpot") as FishingSpot var catalog := main.get("fish_catalog") as FishPool assert(player != null and fishing_spot != null and catalog != null) + fishing_spot.minimum_showcase_duration = 0.15 assert(player.bag.add_item(&"crab_net")) assert(player.hotbar.assign_item(0, &"crab_net")) assert(player.hotbar.select_slot(0)) @@ -60,13 +61,20 @@ func _run() -> void: assert(not player.is_movement_enabled()) Input.action_release("fish_primary") await process_frame - assert(bool(fishing_spot.get("_put_away_press_armed"))) + assert(not bool(fishing_spot.get("_put_away_press_armed"))) var pocket_event := InputEventAction.new() pocket_event.action = &"fish_primary" pocket_event.pressed = true Input.parse_input_event(pocket_event) await process_frame + assert(fishing_spot.get("_pending_catch") == crab_catch) + Input.action_release("fish_primary") + await create_timer(0.2).timeout + await process_frame + assert(bool(fishing_spot.get("_put_away_press_armed"))) + Input.parse_input_event(pocket_event) + await process_frame assert(fishing_spot.get("_pending_catch") == null) var pocket_deadline: int = Time.get_ticks_msec() + 6000 diff --git a/tests/inventory_storage_persistence_validation.gd b/tests/inventory_storage_persistence_validation.gd index 013ebbf..c3026d7 100644 --- a/tests/inventory_storage_persistence_validation.gd +++ b/tests/inventory_storage_persistence_validation.gd @@ -1,6 +1,9 @@ extends SceneTree const MainScene: PackedScene = preload("res://main/main.tscn") +const ItemCatalogResource: ItemCatalog = preload( + "res://items/catalog/item_catalog.tres" +) func _initialize() -> void: @@ -27,29 +30,34 @@ func _run() -> void: save_manager.set_autosave_enabled(true) assert(player.bag.add_item(&"art_kit")) assert(player.bag.add_item(&"coffee")) + assert(player.bag.add_item(&"worms")) + assert(player.bag.add_item(&"the_standby")) + assert(player.equip_bait(ItemCatalogResource.get_item_by_id(&"worms"))) + assert(player.equip_lure(ItemCatalogResource.get_item_by_id(&"the_standby"))) assert(player.bag.move_item_to_storage_slot(&"basic_fishing_rod", 14)) assert(player.bag.move_item_to_storage_slot(&"coffee", 12)) assert(save_manager.save_now()) - var save_file := FileAccess.open( - str(save_manager.get("_save_path")), - FileAccess.READ, + var decoded: Dictionary = ProgressionSaveCodec.read_local_save( + str(save_manager.get("_save_path")) ) - assert(save_file != null) - var parsed: Variant = JSON.parse_string(save_file.get_as_text()) - save_file.close() - assert(typeof(parsed) == TYPE_DICTIONARY) - var records: Array = (parsed as Dictionary)["bag"]["items"] - assert(int((parsed as Dictionary)["world"]["seed"]) == TEST_WORLD_SEED) + assert(bool(decoded.get("ok", false))) + var parsed: Dictionary = decoded["data"] + var records: Array = parsed["bag"]["items"] + assert(int(parsed["world"]["seed"]) == TEST_WORLD_SEED) assert(_saved_slot(records, &"basic_fishing_rod") == 14) assert(_saved_slot(records, &"coffee") == 12) assert(player.bag.move_item_to_storage_slot(&"basic_fishing_rod", 0)) assert(player.bag.move_item_to_storage_slot(&"coffee", 0)) + player.unequip_bait() + player.unequip_lure() assert(save_manager.load_player_data()) assert(save_manager.get_world_seed() == TEST_WORLD_SEED) assert(player.bag.get_storage_slot(&"basic_fishing_rod") == 14) assert(player.bag.get_storage_slot(&"coffee") == 12) + assert(player.active_bait_id == &"worms") + assert(player.active_lure_id == &"the_standby") main.queue_free() for _frame: int in 4: diff --git a/tests/job_system_validation.gd b/tests/job_system_validation.gd index d2f2ae7..1526e44 100644 --- a/tests/job_system_validation.gd +++ b/tests/job_system_validation.gd @@ -49,6 +49,7 @@ func _run() -> void: _validate_weather_guarantees(board) _validate_late_board_weather_fallback() _validate_remote_tamper_rejection(jobs, board) + _validate_creature_jobs(jobs) var sell_job: Dictionary = _find_job( jobs.get_daily_jobs(), JobCatalog.Kind.SELL_TOTAL @@ -195,15 +196,12 @@ func _run() -> void: assert(session.set_host_open(false)) assert(save_manager.save_now()) - var save_file := FileAccess.open( - str(save_manager.get("_save_path")), FileAccess.READ + var decoded: Dictionary = ProgressionSaveCodec.read_local_save( + str(save_manager.get("_save_path")) ) - assert(save_file != null) - var parsed: Variant = JSON.parse_string(save_file.get_as_text()) - save_file.close() - assert(typeof(parsed) == TYPE_DICTIONARY) - var save_data: Dictionary = parsed - assert(int(save_data.get("save_version", -1)) == 9) + assert(bool(decoded.get("ok", false))) + var save_data: Dictionary = decoded["data"] + assert(int(save_data.get("save_version", -1)) == 10) assert(PlayerJobService.validate_save_data(save_data.get("jobs", {}))) _validate_pause_session_switch(main, session) @@ -263,6 +261,100 @@ func _validate_unavailable_fishnet_layout() -> void: await process_frame +func _validate_creature_jobs(jobs: PlayerJobService) -> void: + var fish_count: int = 0 + var insect_count: int = 0 + var shellfish_count: int = 0 + var candidates: Array[FishData] = [] + for fish: FishData in Catalog.candidates: + if fish == null or not fish.is_selectable(): + continue + candidates.append(fish) + match fish.get_creature_group(): + FishData.CreatureGroup.FISH: + fish_count += 1 + FishData.CreatureGroup.INSECT: + insect_count += 1 + FishData.CreatureGroup.SHELLFISH: + shellfish_count += 1 + assert(fish_count > 0 and insect_count > 0 and shellfish_count > 0) + + var lifetime: Array[Dictionary] = jobs.get_lifetime_jobs() + var fish_discovery: Dictionary = _find_job( + lifetime, JobCatalog.Kind.DISCOVER_SPECIES + ) + assert(int(fish_discovery.get("target", -1)) == fish_count) + for creature_group: int in [ + FishData.CreatureGroup.INSECT, + FishData.CreatureGroup.SHELLFISH, + ]: + assert(not _find_creature_job( + lifetime, + JobCatalog.Kind.CATCH_CREATURE_GROUP, + creature_group, + ).is_empty()) + assert(not _find_creature_job( + lifetime, + JobCatalog.Kind.DISCOVER_CREATURE_GROUP, + creature_group, + ).is_empty()) + assert(not _find_creature_job( + lifetime, + JobCatalog.Kind.MASTER_CREATURE_GROUP, + creature_group, + ).is_empty()) + + var daily_groups: Dictionary[int, bool] = {} + for seed_index: int in 64: + for job: Dictionary in JobCatalog.generate_daily_jobs( + "creature-job-validation-%d" % seed_index, + candidates, + true, + ): + if int(job.get("kind", -1)) == JobCatalog.Kind.CATCH_CREATURE_GROUP: + daily_groups[int(job.get("creature_group", -1))] = true + assert(daily_groups.has(FishData.CreatureGroup.INSECT)) + assert(daily_groups.has(FishData.CreatureGroup.SHELLFISH)) + + var beetle: FishData = Catalog.get_fish_by_id(&"beetle_stag_common") + assert(beetle != null) + var beetle_catch := _make_test_catch(beetle, &"job-beetle") + jobs.call("_on_authoritative_catch", beetle_catch) + var statistics: Dictionary = jobs.to_save_data().get("statistics", {}) + assert(int(statistics.get("fish_caught", -1)) == 0) + assert(int(statistics.get("insects_caught", -1)) == 1) + + +func _find_creature_job( + jobs: Array[Dictionary], + kind: JobCatalog.Kind, + creature_group: int, +) -> Dictionary: + for job: Dictionary in jobs: + if ( + int(job.get("kind", -1)) == kind + and int(job.get("creature_group", -1)) == creature_group + ): + return job + return {} + + +func _make_test_catch(fish: FishData, catch_id: StringName) -> FishCatch: + var fish_catch := FishCatch.new() + fish_catch.fish = fish + fish_catch.fish_id = fish.id + fish_catch.catch_id = catch_id + fish_catch.catch_sequence = 1 + fish_catch.weight_lb = fish.get_minimum_weight() + fish_catch.display_scale = fish.get_display_scale_for_weight( + fish_catch.weight_lb + ) + fish_catch.quality = FishQuality.Tier.BORING + fish_catch.sale_value = fish.get_sale_value_for_weight(fish_catch.weight_lb) + assert(fish_catch.is_valid()) + return fish_catch + + func _validate_weather_guarantees(board: Dictionary) -> void: var jobs: Array = board.get("jobs", []) var schedule: Array = board.get("weather_schedule", []) diff --git a/tests/keyboard_mouse_mapping_validation.gd b/tests/keyboard_mouse_mapping_validation.gd index cca2ac2..7d02736 100644 --- a/tests/keyboard_mouse_mapping_validation.gd +++ b/tests/keyboard_mouse_mapping_validation.gd @@ -7,6 +7,12 @@ const KeyboardMouseMappingPanelType = preload( "res://ui/keyboard_mouse_mapping_panel.gd" ) const SettingsPanelScene = preload("res://ui/settings_panel.tscn") +const FishingSpotScene = preload("res://fishing/fishing_spot.tscn") +const FishingSpotType = preload("res://fishing/fishing_spot.gd") +const CatchDifficultyProfileType = preload( + "res://fishing/catch_difficulty_profile.gd" +) +const PlayerType = preload("res://player/player.gd") func _initialize() -> void: @@ -27,6 +33,13 @@ func _run() -> void: str(KeyboardMouseMappingManagerType.ROLE_PRIMARY_ACTION) ].get("kind", "")) == "mouse_button" ) + assert( + str(defaults[ + str(KeyboardMouseMappingManagerType.ROLE_ALTERNATE_REEL) + ].get("kind", "")) == "key" + ) + assert(_has_physical_key(&"reel_alternate", KEY_QUOTELEFT)) + assert(_event_count(&"reel_alternate", true) == 0) assert( str(defaults[ str(KeyboardMouseMappingManagerType.ROLE_CHAT) @@ -60,6 +73,15 @@ func _run() -> void: )) assert(_has_mouse_button(&"fish_primary", MOUSE_BUTTON_XBUTTON1)) assert(not _has_mouse_button(&"fish_primary", MOUSE_BUTTON_LEFT)) + var alternate_reel_key := InputEventKey.new() + alternate_reel_key.physical_keycode = KEY_G + alternate_reel_key.pressed = true + assert(manager.set_binding( + KeyboardMouseMappingManagerType.ROLE_ALTERNATE_REEL, + manager.binding_from_event(alternate_reel_key), + )) + assert(_has_physical_key(&"reel_alternate", KEY_G)) + assert(_event_count(&"reel_alternate", true) == 0) var joypad_event := InputEventJoypadButton.new() joypad_event.button_index = JOY_BUTTON_A @@ -117,11 +139,64 @@ func _run() -> void: )) assert(_has_physical_key(&"jump", KEY_SPACE)) assert(_has_mouse_button(&"fish_primary", MOUSE_BUTTON_LEFT)) + assert(_has_physical_key(&"reel_alternate", KEY_QUOTELEFT)) + assert(_event_count(&"reel_alternate", true) == 0) assert(_has_physical_key(&"open_chat", KEY_T)) assert(_event_count(&"jump", true) == joypad_events_before) + var legacy_bindings: Dictionary = manager.get_active_bindings() + legacy_bindings.erase(str( + KeyboardMouseMappingManagerType.ROLE_ALTERNATE_REEL + )) + legacy_bindings[str(KeyboardMouseMappingManagerType.ROLE_JUMP)] = ( + manager.binding_from_event(rebind_key) + ) + var legacy_file := FileAccess.open( + KeyboardMouseMappingManagerType.MAPPING_PATH, + FileAccess.WRITE, + ) + assert(legacy_file != null) + legacy_file.store_string(JSON.stringify({ + "format_version": KeyboardMouseMappingManagerType.LEGACY_FORMAT_VERSION, + "bindings": legacy_bindings, + })) + legacy_file.close() + var migrated_manager := KeyboardMouseMappingManagerType.new() + root.add_child(migrated_manager) + await process_frame + assert(migrated_manager.has_custom_mapping()) + assert(_has_physical_key(&"jump", KEY_R)) + assert(_has_physical_key(&"reel_alternate", KEY_QUOTELEFT)) + assert(migrated_manager.reset_mapping()) + + var fishing_spot := FishingSpotScene.instantiate() as FishingSpotType + root.add_child(fishing_spot) + await process_frame + var local_player := PlayerType.new() + fishing_spot.set("_local_player", local_player) + fishing_spot.set("_gameplay_input_enabled", true) + fishing_spot.state = FishingSpotType.FishingState.FIGHTING + var catch_controller := fishing_spot.get_node("%CatchController") + var catch_profile := CatchDifficultyProfileType.new() + catch_profile.barrier_count_min = 0 + catch_profile.barrier_count_max = 0 + catch_controller.start_encounter(catch_profile, 1.0, 1) + var alternate_press := InputEventAction.new() + alternate_press.action = &"reel_alternate" + alternate_press.pressed = true + fishing_spot._unhandled_input(alternate_press) + assert(bool(catch_controller.get("_reel_input_held"))) + var alternate_release := InputEventAction.new() + alternate_release.action = &"reel_alternate" + alternate_release.pressed = false + fishing_spot._unhandled_input(alternate_release) + assert(not bool(catch_controller.get("_reel_input_held"))) + fishing_spot.free() + local_player.free() + settings_panel.queue_free() panel.queue_free() + migrated_manager.queue_free() manager.queue_free() print("Keyboard and mouse mapping validation: PASS") quit(0) diff --git a/tests/light_performance_profile_validation.gd b/tests/light_performance_profile_validation.gd index 9421901..4533abb 100644 --- a/tests/light_performance_profile_validation.gd +++ b/tests/light_performance_profile_validation.gd @@ -43,6 +43,7 @@ func _run() -> void: await process_frame assert(bool(normal_main.get("_application_initialized"))) _validate_normal_profile(normal_main) + _validate_new_game_music_transition(normal_main) _stop_audio_players(normal_main) normal_main.queue_free() for _frame: int in 8: @@ -115,6 +116,7 @@ func _validate_main_profile(main: Node) -> void: assert(player_menu != null) assert(not player_menu.is_cooler_water_effect_enabled()) assert((main.get_node("%TitleMusic") as AudioStreamPlayer).stream != null) + assert((main.get_node("%NewGameMusic") as AudioStreamPlayer).stream != null) assert((main.get_node("%DuskMusic") as AudioStreamPlayer).stream != null) assert( (main.get_node("%WavesAudio") as AudioStreamPlayer).stream == null @@ -183,6 +185,7 @@ func _validate_normal_profile(main: Node) -> void: assert(clouds.get_node_or_null("CloudField") is MultiMeshInstance3D) assert(clouds.get_node_or_null("CloudCeiling") == null) assert((main.get_node("%TitleMusic") as AudioStreamPlayer).stream != null) + assert((main.get_node("%NewGameMusic") as AudioStreamPlayer).stream != null) assert((main.get_node("%DuskMusic") as AudioStreamPlayer).stream != null) assert( (main.get_node("%WavesAudio") as AudioStreamPlayer).stream != null @@ -192,6 +195,18 @@ func _validate_normal_profile(main: Node) -> void: ) +func _validate_new_game_music_transition(main: Node) -> void: + var title_music := main.get_node("%TitleMusic") as AudioStreamPlayer + var new_game_music := main.get_node("%NewGameMusic") as AudioStreamPlayer + assert(title_music.playing) + main.call("_start_new_game_music") + assert(not title_music.playing) + assert(new_game_music.playing) + main.call("_show_title_music", true) + assert(not new_game_music.playing) + assert(title_music.playing) + + func _first_fresh_water_visual(main: Node) -> MeshInstance3D: var root := main.get_node( "TestWorld/Regions/GeneratedWorldRegion/WaterBodies/FreshWaterBodies" diff --git a/tests/player_experience_validation.gd b/tests/player_experience_validation.gd index 0c1a9f7..5e9dbef 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)) == 9) + assert(int(migrated.get("save_version", -1)) == 10) 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/profile_multiplayer_validation.gd b/tests/profile_multiplayer_validation.gd index 669c155..ec7a669 100644 --- a/tests/profile_multiplayer_validation.gd +++ b/tests/profile_multiplayer_validation.gd @@ -152,15 +152,10 @@ func _run_client() -> void: var chat_service := main.get_node( "%NetworkChatService" ) as NetworkChatService - var history_deadline: int = Time.get_ticks_msec() + 8000 - while Time.get_ticks_msec() < history_deadline: - if chat_service.get_history().any( - func(message: Dictionary) -> bool: - return str(message.get("body", "")) == HOST_HISTORY_MESSAGE - ): - break - await process_frame - assert(chat_service.get_history().any( + # Player-hosted rooms deliberately send no prior scrollback to joining or + # reconnecting clients. Live messages sent after authentication still work. + await create_timer(0.5).timeout + assert(not chat_service.get_history().any( func(message: Dictionary) -> bool: return str(message.get("body", "")) == HOST_HISTORY_MESSAGE )) diff --git a/tests/progression_archive_validation.gd b/tests/progression_archive_validation.gd new file mode 100644 index 0000000..11fc51c --- /dev/null +++ b/tests/progression_archive_validation.gd @@ -0,0 +1,135 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + root.size = Vector2i(1280, 720) + var main: Node = MainScene.instantiate() + root.add_child(main) + for _frame: int in 4: + await process_frame + if not bool(main.get("_application_initialized")): + main.call("_activate_selected_data_path", "", true) + for _frame: int in 8: + await process_frame + assert(bool(main.get("_application_initialized"))) + + var save_manager := main.get("_save_manager") as PlayerSaveManager + var player := main.get("_player") as Player + var data_root := main.get("_data_root") as PlayerDataRoot + assert(save_manager != null and player != null and data_root != null) + assert(save_manager.initialize_new_game(864209)) + save_manager.set_autosave_enabled(true) + assert(player.wallet.restore_balance(4321)) + assert(player.bag.add_item(&"coffee")) + assert(save_manager.save_now()) + + var save_path: String = str(save_manager.get("_save_path")) + assert(save_path.ends_with("player/player_save.nfsave")) + assert(FileAccess.file_exists(save_path)) + assert(not FileAccess.file_exists(save_path.get_base_dir().path_join( + "player_save.json" + ))) + _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"]) == 10) + + var archive_path: String = data_root.progression_backup_directory().path_join( + "progression-test.nfsave" + ) + var exported: Dictionary = save_manager.export_progression_archive( + archive_path + ) + assert(bool(exported.get("ok", false))) + assert(FileAccess.file_exists(archive_path)) + _assert_opaque(archive_path) + var inspected: Dictionary = save_manager.inspect_progression_archive( + archive_path + ) + assert(bool(inspected.get("ok", false))) + assert(int(inspected.get("wallet_balance", -1)) == 4321) + assert(int(inspected.get("world_seed", -1)) == 864209) + + assert(player.wallet.restore_balance(7)) + assert(save_manager.save_now()) + var imported: Dictionary = save_manager.import_progression_archive( + archive_path + ) + assert(bool(imported.get("ok", false))) + assert(save_manager.load_player_data()) + assert(player.wallet.get_balance() == 4321) + assert(save_manager.get_world_seed() == 864209) + + var legacy_data: Dictionary = ( + ProgressionSaveCodec.read_local_save(save_path)["data"] + ) + assert(save_manager.delete_progression_save()) + var legacy_path: String = save_path.get_base_dir().path_join( + "player_save.json" + ) + var legacy_file := FileAccess.open(legacy_path, FileAccess.WRITE) + assert(legacy_file != null) + legacy_file.store_string(JSON.stringify(legacy_data, "\t")) + legacy_file.close() + assert(save_manager.load_player_data()) + assert(FileAccess.file_exists(save_path)) + assert(not FileAccess.file_exists(legacy_path)) + _assert_opaque(save_path) + + var settings_panels: Array[Node] = main.find_children( + "*", "SettingsPanel", true, false + ) + assert(settings_panels.size() == 2) + for settings_panel: SettingsPanel in settings_panels: + var export_button := settings_panel.get_node( + "%ExportProgression" + ) as Button + var import_button := settings_panel.get_node( + "%ImportProgression" + ) as Button + assert(export_button != null and import_button != null) + assert(not export_button.disabled and not import_button.disabled) + assert( + export_button.get_node(export_button.focus_neighbor_right) + == import_button + ) + assert( + import_button.get_node(import_button.focus_neighbor_left) + == export_button + ) + + var migrated_root: String = data_root.root_path.get_base_dir().path_join( + "progression-migrated-data" + ) + var data_migration: Dictionary = PortableDataMigration.migrate_active_to( + data_root, migrated_root + ) + assert(bool(data_migration.get("ok", false))) + var migrated_save: String = migrated_root.path_join( + "player/player_save.nfsave" + ) + assert(FileAccess.file_exists(migrated_save)) + assert(bool( + ProgressionSaveCodec.read_local_save(migrated_save).get("ok", false) + )) + + main.queue_free() + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout + print("Progression archive validation: PASS") + quit() + + +func _assert_opaque(path: String) -> void: + var bytes: PackedByteArray = PortableFileGuard.read_bytes(path) + assert(not bytes.is_empty()) + var encoded_hex: String = bytes.hex_encode() + assert("save_version".to_utf8_buffer().hex_encode() not in encoded_hex) + assert("wallet".to_utf8_buffer().hex_encode() not in encoded_hex) diff --git a/tests/progression_archive_validation.gd.uid b/tests/progression_archive_validation.gd.uid new file mode 100644 index 0000000..59699fd --- /dev/null +++ b/tests/progression_archive_validation.gd.uid @@ -0,0 +1 @@ +uid://ccl47ftc1cu5l diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index c79d80c..0e94e15 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -897,6 +897,8 @@ func _send() -> void: if body.strip_edges().is_empty(): close_chat() return + if _handle_editor_world_command(body): + return _send_pending = true _pending_send_body = NetworkChatProtocol.sanitize_body(body) _entry.editable = false @@ -912,6 +914,82 @@ func _send() -> void: _set_status("Sending…") +func _handle_editor_world_command(body: String) -> bool: + # These commands are deliberately limited to sessions launched by the + # Godot editor. Exported builds do not have the editor feature tag, and a + # joined editor client must not be able to mutate its host's world. + if not OS.has_feature("editor"): + return false + var command_text: String = body.strip_edges() + if not ( + command_text.begins_with("/time") + or command_text.begins_with("/weather") + ): + return false + var parts: PackedStringArray = command_text.split(" ", false) + var command: String = String(parts[0]).trim_prefix("/").to_lower() + var result: String = "" + if _session == null or not _session.is_host(): + result = "Editor world commands require the authoritative host." + elif command == "time": + result = _apply_editor_time_command(parts) + elif command == "weather": + result = _apply_editor_weather_command(parts) + else: + return false + _entry.clear() + _flush_draft() + _set_status(result) + close_chat(true) + return true + + +func _apply_editor_time_command(parts: PackedStringArray) -> String: + if parts.size() != 2 or _world_time == null: + return "Usage: /time [dawn, day, dusk, night]" + var phase_name: String = String(parts[1]).to_lower() + var target_hour: float = -1.0 + match phase_name: + "dawn": + target_hour = WorldTimeServiceType.DAWN_START_HOUR + "day": + target_hour = WorldTimeServiceType.DAWN_END_HOUR + "dusk": + target_hour = WorldTimeServiceType.DUSK_START_HOUR + "night": + target_hour = WorldTimeServiceType.DUSK_END_HOUR + _: + return "Usage: /time [dawn, day, dusk, night]" + if not _world_time.set_authoritative_time(target_hour): + return "Editor world time could not be changed." + return "Editor time: %s (%s)." % [ + phase_name, + _world_time.get_clock_text(), + ] + + +func _apply_editor_weather_command(parts: PackedStringArray) -> String: + if parts.size() != 2 or _world_weather == null: + return "Usage: /weather [sunny, cloudy, rainy, foggy]" + var weather_name: String = String(parts[1]).to_lower() + var target_weather: WorldWeatherServiceType.Weather + match weather_name: + "clear", "sunny": + weather_name = "sunny" + target_weather = WorldWeatherServiceType.Weather.SUNNY + "cloudy": + target_weather = WorldWeatherServiceType.Weather.CLOUDY + "rainy": + target_weather = WorldWeatherServiceType.Weather.RAINY + "foggy": + target_weather = WorldWeatherServiceType.Weather.FOGGY + _: + return "Usage: /weather [sunny, cloudy, rainy, foggy]" + if not _world_weather.set_authoritative_weather(target_weather): + return "Editor world weather could not be changed." + return "Editor weather: %s." % weather_name + + func _on_local_message_confirmed(message: Dictionary) -> void: if ( not _send_pending diff --git a/ui/components/general_inventory_slot.gd b/ui/components/general_inventory_slot.gd index bdb132f..9204d0a 100644 --- a/ui/components/general_inventory_slot.gd +++ b/ui/components/general_inventory_slot.gd @@ -168,6 +168,12 @@ func _on_pressed() -> void: func _on_gui_input(event: InputEvent) -> void: var mouse_event := event as InputEventMouseButton + if mouse_event != null and mouse_event.pressed: + # Pointer clicks give Buttons keyboard focus after their GUI callback. + # Release that pointer-created focus on the next frame so a tooltip does + # not remain pinned after the pointer leaves. Controller navigation still + # keeps focus normally because it does not arrive as a mouse event. + call_deferred("_release_pointer_focus") if ( mouse_event != null and mouse_event.button_index == MOUSE_BUTTON_RIGHT @@ -179,6 +185,11 @@ func _on_gui_input(event: InputEvent) -> void: accept_event() +func _release_pointer_focus() -> void: + if has_focus(): + release_focus() + + func _set_context_hovered(active: bool) -> void: _context_hovered = active _update_context_presence() diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 9d9ab32..1465990 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -214,6 +214,7 @@ var _controller_text_entry_is_open: Callable func _ready() -> void: + _prioritize_surface_drawing_pointer_input() _bite_prompt_button.pressed.connect(_on_bite_prompt_pressed) _apply_active_bait_indicator_style() _refresh_active_bait_indicator() @@ -487,6 +488,27 @@ func setup( ) +func _prioritize_surface_drawing_pointer_input() -> void: + # Control input follows sibling order rather than CanvasItem.z_index. Chat is + # a full-screen Control with interactive mobile children, so the toolbar must + # follow it in the tree to own their overlap while the art kit is active. + # Opening Chat deactivates surface drawing and hides the toolbar, returning + # the same area to Chat without any special-case pointer forwarding. + if ( + _surface_drawing_toolbar == null + or _chat_ui == null + or _surface_drawing_toolbar.get_parent() != _chat_ui.get_parent() + ): + return + var ui_root: Node = _surface_drawing_toolbar.get_parent() + var toolbar_index: int = _surface_drawing_toolbar.get_index() + var chat_index: int = _chat_ui.get_index() + if toolbar_index > chat_index: + return + # Removing the earlier toolbar shifts Chat one position toward the start. + ui_root.move_child(_surface_drawing_toolbar, chat_index) + + func _input(event: InputEvent) -> void: # The on-screen keyboard owns controller input while it is open. Its # overlay is processed before the UI beneath it and consumes the event. @@ -1264,6 +1286,7 @@ func _drawing_pointer_window_position() -> Vector2: func setup_data_and_identity( data_root: PlayerDataRoot, + progression_saves: PlayerSaveManager, identity_backups: IdentityBackupService, player_identity: PlayerIdentityStore, host_identity: HostIdentityStore, @@ -1275,6 +1298,7 @@ func setup_data_and_identity( ]: panel.setup_data_and_identity( data_root, + progression_saves, identity_backups, player_identity, host_identity, @@ -1603,9 +1627,14 @@ func _can_toggle_gameplay_hud() -> bool: func _refresh_gameplay_hud_visibility() -> void: + var fishing_override_active: bool = ( + _gameplay_hud_hidden + and _fishing_spot != null + and _fishing_spot.is_fishing_sequence_active() + ) var show_world_hud: bool = ( _gameplay_ui_enabled - and not _gameplay_hud_hidden + and (not _gameplay_hud_hidden or fishing_override_active) and not _system_menu_open and not _player_menu_open and not _shop_open @@ -1660,6 +1689,7 @@ func set_storage_prompt_visible( _storage_prompt.visible = ( requested_visible and _gameplay_ui_enabled + and not _gameplay_hud_hidden and not _system_menu_open and not _player_menu_open and not _shop_open @@ -1697,6 +1727,7 @@ func set_shop_prompt_visible( _shop_prompt.visible = ( requested_visible and _gameplay_ui_enabled + and not _gameplay_hud_hidden and not _system_menu_open and not _player_menu_open and not _shop_open @@ -1923,6 +1954,7 @@ func _refresh_fishing_panel_visibility() -> void: _gameplay_ui_enabled and has_content ) + _refresh_gameplay_hud_visibility() func _on_bite_prompt_changed(prompt_visible: bool) -> void: diff --git a/ui/on_screen_keyboard.gd b/ui/on_screen_keyboard.gd index a6ed070..ee7475c 100644 --- a/ui/on_screen_keyboard.gd +++ b/ui/on_screen_keyboard.gd @@ -430,7 +430,8 @@ func _show_native_keyboard_for(control: Control) -> void: func _hide_native_keyboard() -> void: - DisplayServer.virtual_keyboard_hide() + if DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD): + DisplayServer.virtual_keyboard_hide() static func _line_edit_keyboard_type( diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 263c5e4..3bb428e 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -1693,6 +1693,7 @@ func close_menu( _release_controller_ownership(false, true) get_viewport().gui_cancel_drag() _close_sale_confirmation() + _hide_inventory_context_tooltip() if reason in [ CloseReason.BITE_STARTED, CloseReason.WATER_RECOVERY, @@ -2497,6 +2498,8 @@ func _populate_tackle_column( owned.quantity, item.max_stack, &"QuantityBadge", + UtilityPageStyle.SUPPLY_BADGE_EDGE_MARGIN, + 0.0, ) button.pressed.connect(_select_tackle_item.bind(owned.item_id)) button.gui_input.connect( @@ -2540,6 +2543,8 @@ func _on_tackle_button_gui_input( button: Button, ) -> void: var mouse_event := event as InputEventMouseButton + if mouse_event != null and mouse_event.pressed: + call_deferred("_release_pointer_focus", button) if ( mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_RIGHT @@ -2555,6 +2560,11 @@ func _on_tackle_button_gui_input( button.accept_event() +func _release_pointer_focus(control: Control) -> void: + if control != null and is_instance_valid(control) and control.has_focus(): + control.release_focus() + + func _configure_tackle_item_focus() -> void: if not is_node_ready(): return diff --git a/ui/settings_panel.gd b/ui/settings_panel.gd index b49db11..17115a6 100644 --- a/ui/settings_panel.gd +++ b/ui/settings_panel.gd @@ -125,6 +125,7 @@ var _environment_volume: float = 1.0 var _network_profile: NetworkProfilePreferences var _network_session: NetworkSession var _data_root: PlayerDataRoot +var _progression_saves: PlayerSaveManager var _identity_backups: IdentityBackupService var _player_identity: PlayerIdentityStore var _host_identity: HostIdentityStore @@ -134,6 +135,8 @@ var _controller_mapping_panel: ControllerMappingPanelType var _keyboard_mouse_mapping_manager: KeyboardMouseMappingManagerType var _keyboard_mouse_mapping_panel: KeyboardMouseMappingPanelType var _data_folder_dialog: FileDialog +var _progression_import_dialog: FileDialog +var _progression_export_dialog: FileDialog var _backup_file_dialog: FileDialog var _export_file_dialog: FileDialog var _passphrase_dialog: ConfirmationDialog @@ -143,6 +146,7 @@ var _pending_identity_operation := "" var _pending_identity_type := "" var _pending_identity_path := "" var _pending_import_data: Dictionary = {} +var _pending_progression_path := "" func _notification(what: int) -> void: @@ -244,6 +248,8 @@ func _connect_controls() -> void: %KeyboardMapping.pressed.connect(_open_keyboard_mouse_mapping) %OpenDataFolder.pressed.connect(_open_data_folder) %ChangeDataFolder.pressed.connect(_choose_data_folder) + %ExportProgression.pressed.connect(_choose_progression_export) + %ImportProgression.pressed.connect(_choose_progression_import) %ExportPlayerIdentity.pressed.connect( _choose_identity_export.bind("player") ) @@ -337,6 +343,7 @@ func setup_keyboard_mouse_mapping( func setup_data_and_identity( data_root: PlayerDataRoot, + progression_saves: PlayerSaveManager, identity_backups: IdentityBackupService, player_identity: PlayerIdentityStore, host_identity: HostIdentityStore, @@ -344,6 +351,7 @@ func setup_data_and_identity( interface_fonts: InterfaceFontController, ) -> void: _data_root = data_root + _progression_saves = progression_saves _identity_backups = identity_backups _player_identity = player_identity _host_identity = host_identity @@ -611,6 +619,11 @@ func _refresh_data_page() -> void: _data_root.override_active or (_network_session != null and _network_session.is_session_active()) ) + %ExportProgression.disabled = _progression_saves == null + %ImportProgression.disabled = ( + _progression_saves == null + or (_network_session != null and _network_session.is_session_active()) + ) var fingerprint: String = ( _player_identity.fingerprint if _player_identity != null else "" ) @@ -661,6 +674,118 @@ func _copy_player_fingerprint() -> void: _feedback.text = "full player fingerprint copied for server operator setup." +func _choose_progression_export() -> void: + if _progression_saves == null or _data_root == null: + _feedback.text = "progression export is unavailable." + return + var timestamp: String = Time.get_datetime_string_from_system().replace( + ":", "-" + ) + var suggested: String = _data_root.progression_backup_directory().path_join( + "NETfishing-progression-%s%s" + % [timestamp, PlayerSaveManager.ARCHIVE_EXTENSION] + ) + if _progression_export_dialog == null: + _progression_export_dialog = FileDialog.new() + _progression_export_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE + _progression_export_dialog.access = FileDialog.ACCESS_FILESYSTEM + _progression_export_dialog.use_native_dialog = false + _progression_export_dialog.filters = PackedStringArray([ + "*.nfsave ; NETfishing progression archive", + ]) + _progression_export_dialog.file_selected.connect( + _progression_export_file_selected + ) + _interface_fonts.apply_utility_theme(_progression_export_dialog) + add_child(_progression_export_dialog) + _progression_export_dialog.current_dir = suggested.get_base_dir() + _progression_export_dialog.current_file = suggested.get_file() + _interface_fonts.popup_file_dialog(_progression_export_dialog) + + +func _progression_export_file_selected(path: String) -> void: + var destination: String = ( + path + if path.ends_with(PlayerSaveManager.ARCHIVE_EXTENSION) + else path + PlayerSaveManager.ARCHIVE_EXTENSION + ) + var result: Dictionary = _progression_saves.export_progression_archive( + destination + ) + _feedback.text = str( + result.get("message", "progression export failed.") + ) + + +func _choose_progression_import() -> void: + if _progression_saves == null or _data_root == null: + _feedback.text = "progression import is unavailable." + return + if _network_session != null and _network_session.is_session_active(): + _feedback.text = "return to title before importing progression." + return + if _progression_import_dialog == null: + _progression_import_dialog = FileDialog.new() + _progression_import_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE + _progression_import_dialog.access = FileDialog.ACCESS_FILESYSTEM + _progression_import_dialog.use_native_dialog = false + _progression_import_dialog.filters = PackedStringArray([ + "*.nfsave ; NETfishing progression archive", + ]) + _progression_import_dialog.file_selected.connect( + _progression_import_file_selected + ) + _interface_fonts.apply_utility_theme(_progression_import_dialog) + add_child(_progression_import_dialog) + _progression_import_dialog.current_dir = ( + _data_root.progression_backup_directory() + ) + _interface_fonts.popup_file_dialog(_progression_import_dialog) + + +func _progression_import_file_selected(path: String) -> void: + var inspected: Dictionary = ( + _progression_saves.inspect_progression_archive(path) + ) + if not bool(inspected.get("ok", false)): + _feedback.text = str( + inspected.get("message", "progression archive could not be opened.") + ) + return + _pending_progression_path = path + var dialog := ConfirmationDialog.new() + dialog.title = "replace saved progression?" + dialog.ok_button_text = "import progression" + dialog.dialog_text = ( + "this will replace the current progression after making a backup.\n\n" + + "fish: %d\ndiscovered: %d\nworld seed: %d\n\n" + + "identities, settings, friends, bans, and trusted servers are unchanged." + ) % [ + int(inspected.get("catch_count", 0)), + int(inspected.get("discovered_species_count", 0)), + int(inspected.get("world_seed", 0)), + ] + dialog.confirmed.connect(_confirm_progression_import.bind(dialog)) + dialog.canceled.connect(dialog.queue_free) + _interface_fonts.apply_utility_theme(dialog) + add_child(dialog) + dialog.popup_centered(Vector2i(640, 390)) + _configure_confirmation_dialog.call_deferred( + dialog, dialog.get_cancel_button() + ) + + +func _confirm_progression_import(dialog: ConfirmationDialog) -> void: + var result: Dictionary = _progression_saves.import_progression_archive( + _pending_progression_path + ) + _feedback.text = str( + result.get("message", "progression import failed.") + ) + _pending_progression_path = "" + dialog.queue_free() + + func _choose_data_folder() -> void: if _data_root == null or _data_root.override_active: _feedback.text = "the data folder is externally managed." diff --git a/ui/settings_panel.tscn b/ui/settings_panel.tscn index 2af0256..94f627c 100644 --- a/ui/settings_panel.tscn +++ b/ui/settings_panel.tscn @@ -567,7 +567,7 @@ size_flags_horizontal = 3 focus_mode = 2 focus_neighbor_right = NodePath("../ChangeDataFolder") focus_neighbor_top = NodePath("../../../../../../../../TabBar/DataTab") -focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint") +focus_neighbor_bottom = NodePath("../../ProgressionRow/ExportProgression") text = "open data folder" [node name="ChangeDataFolder" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons/FolderRow"] @@ -578,15 +578,41 @@ size_flags_horizontal = 3 focus_mode = 2 focus_neighbor_left = NodePath("../OpenDataFolder") focus_neighbor_top = NodePath("../../../../../../../../TabBar/DataTab") -focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint") +focus_neighbor_bottom = NodePath("../../ProgressionRow/ImportProgression") text = "change data folder" +[node name="ProgressionRow" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="ExportProgression" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons/ProgressionRow"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 2 +focus_neighbor_right = NodePath("../ImportProgression") +focus_neighbor_top = NodePath("../../FolderRow/OpenDataFolder") +focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint") +text = "export progression" + +[node name="ImportProgression" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons/ProgressionRow"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 2 +focus_neighbor_left = NodePath("../ExportProgression") +focus_neighbor_top = NodePath("../../FolderRow/ChangeDataFolder") +focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint") +text = "import progression" + [node name="CopyPlayerFingerprint" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 44) layout_mode = 2 focus_mode = 2 -focus_neighbor_top = NodePath("../FolderRow/OpenDataFolder") +focus_neighbor_top = NodePath("../ProgressionRow/ExportProgression") focus_neighbor_bottom = NodePath("../PlayerIdentityRow/ExportPlayerIdentity") text = "copy player fingerprint" diff --git a/ui/title_screen.gd b/ui/title_screen.gd index 757f005..81a4acf 100644 --- a/ui/title_screen.gd +++ b/ui/title_screen.gd @@ -1295,7 +1295,7 @@ func _on_settings_applied() -> void: func _on_settings_closed() -> void: - pass + _refresh_save_inspection() func _on_settings_closing() -> void: diff --git a/ui/utility_page_style.gd b/ui/utility_page_style.gd index edaa837..e31f6ac 100644 --- a/ui/utility_page_style.gd +++ b/ui/utility_page_style.gd @@ -53,6 +53,7 @@ static func add_supply_quantity_badge( total: int, badge_name: StringName = &"SupplyQuantityBadge", top_margin: float = SUPPLY_BADGE_EDGE_MARGIN, + x_offset: float = SUPPLY_BADGE_X_OFFSET, ) -> Panel: var badge := Panel.new() badge.name = badge_name @@ -68,7 +69,7 @@ static func add_supply_quantity_badge( quantity_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER badge.add_child(quantity_label) configure_supply_quantity_badge( - badge, quantity_label, current, total, top_margin + badge, quantity_label, current, total, top_margin, x_offset ) return badge @@ -79,6 +80,7 @@ static func configure_supply_quantity_badge( current: int, total: int, top_margin: float = SUPPLY_BADGE_EDGE_MARGIN, + x_offset: float = SUPPLY_BADGE_X_OFFSET, ) -> void: var supply_text := supply_quantity_text(current, total) var text_width: float = TuffyFont.get_string_size( @@ -92,10 +94,10 @@ static func configure_supply_quantity_badge( ceilf(text_width + SUPPLY_BADGE_HORIZONTAL_PADDING), ) badge.offset_left = ( - -badge_width - SUPPLY_BADGE_EDGE_MARGIN + SUPPLY_BADGE_X_OFFSET + -badge_width - SUPPLY_BADGE_EDGE_MARGIN + x_offset ) badge.offset_top = top_margin - badge.offset_right = -SUPPLY_BADGE_EDGE_MARGIN + SUPPLY_BADGE_X_OFFSET + badge.offset_right = -SUPPLY_BADGE_EDGE_MARGIN + x_offset badge.offset_bottom = top_margin + SUPPLY_BADGE_HEIGHT badge.pivot_offset = Vector2( badge_width * 0.5,