diff --git a/collection/collection_log.gd b/collection/collection_log.gd index ad0ab56..da36eb8 100644 --- a/collection/collection_log.gd +++ b/collection/collection_log.gd @@ -2,9 +2,13 @@ class_name CollectionLog extends Node signal fish_discovered(fish_id: StringName) +signal fish_quality_discovered(fish_id: StringName, quality: int) signal collection_changed +const FishQualityType = preload("res://fish/fish_quality.gd") + var _discovered: Dictionary[StringName, bool] = {} +var _quality_masks: Dictionary[StringName, int] = {} func has_discovered(fish_id: StringName) -> bool: @@ -19,6 +23,24 @@ func mark_discovered(fish_id: StringName) -> void: collection_changed.emit() +func mark_quality_discovered(fish_id: StringName, quality: int) -> void: + if fish_id.is_empty() or not FishQualityType.is_valid(quality): + return + var species_was_discovered: bool = has_discovered(fish_id) + if not species_was_discovered: + _discovered[fish_id] = true + var previous_mask: int = _quality_masks.get(fish_id, 0) + var next_mask: int = previous_mask | FishQualityType.bit_for(quality) + if species_was_discovered and next_mask == previous_mask: + return + _quality_masks[fish_id] = next_mask + if not species_was_discovered: + fish_discovered.emit(fish_id) + if next_mask != previous_mask: + fish_quality_discovered.emit(fish_id, quality) + collection_changed.emit() + + func get_discovered_ids() -> Array[StringName]: var discovered_ids: Array[StringName] = [] for fish_id: StringName in _discovered: @@ -27,12 +49,53 @@ func get_discovered_ids() -> Array[StringName]: return discovered_ids +func has_discovered_quality(fish_id: StringName, quality: int) -> bool: + return ( + not fish_id.is_empty() + and FishQualityType.is_valid(quality) + and (_quality_masks.get(fish_id, 0) & FishQualityType.bit_for(quality)) != 0 + ) + + +func get_quality_mask(fish_id: StringName) -> int: + return _quality_masks.get(fish_id, 0) if has_discovered(fish_id) else 0 + + +func get_discovered_quality_masks() -> Dictionary[StringName, int]: + return _quality_masks.duplicate() + + +func has_mastered(fish_id: StringName) -> bool: + return get_quality_mask(fish_id) == FishQualityType.ALL_TIERS_MASK + + func replace_discovered_ids(fish_ids: Array[StringName]) -> bool: + var empty_masks: Dictionary[StringName, int] = {} + return replace_discovery_state(fish_ids, empty_masks) + + +func replace_discovery_state( + fish_ids: Array[StringName], + quality_masks: Dictionary[StringName, int], +) -> bool: var replacement: Dictionary[StringName, bool] = {} for fish_id: StringName in fish_ids: if fish_id.is_empty(): return false replacement[fish_id] = true + var replacement_masks: Dictionary[StringName, int] = {} + for fish_id: StringName in quality_masks: + var mask: int = quality_masks[fish_id] + if ( + fish_id.is_empty() + or not replacement.has(fish_id) + or mask < 0 + or (mask & ~FishQualityType.ALL_TIERS_MASK) != 0 + ): + return false + if mask != 0: + replacement_masks[fish_id] = mask _discovered = replacement + _quality_masks = replacement_masks collection_changed.emit() return true diff --git a/economy/fish_buyer_profile.gd b/economy/fish_buyer_profile.gd index bca1cea..485df20 100644 --- a/economy/fish_buyer_profile.gd +++ b/economy/fish_buyer_profile.gd @@ -1,6 +1,8 @@ class_name FishBuyerProfile extends Resource +const FishQualityType = preload("res://fish/fish_quality.gd") + @export var id: StringName @export var display_name: String @export var animal_name_singular: String @@ -25,6 +27,18 @@ func get_offer(base_value: int) -> int: return maxi(offer, 0) +func get_quality_offer(base_value: int, quality: int) -> int: + if not FishQualityType.is_valid(quality): + return -1 + var ordinary_offer: int = get_offer(base_value) + var adjusted_offer: int = get_offer( + FishQualityType.apply_sale_value(base_value, quality) + ) + if ordinary_offer < 0 or adjusted_offer < 0: + return -1 + return maxi(adjusted_offer, ordinary_offer + quality) + + func get_sale_message( fish_name: String, payout: int, diff --git a/economy/fish_sale_service.gd b/economy/fish_sale_service.gd index 4de1faf..722f766 100644 --- a/economy/fish_sale_service.gd +++ b/economy/fish_sale_service.gd @@ -128,7 +128,15 @@ func _validate_batch( if fish_catch.sale_value < 0: result.status = FishSaleResultType.Status.INVALID_VALUE return result - var offer: int = buyer.get_offer(fish_catch.sale_value) + var ordinary_value: int = ( + fish_catch.fish.get_sale_value_for_weight( + fish_catch.weight_lb + ) + ) + var offer: int = buyer.get_quality_offer( + ordinary_value, + fish_catch.quality, + ) if offer < 0: result.status = FishSaleResultType.Status.INVALID_OFFER return result diff --git a/fish/fish_catch.gd b/fish/fish_catch.gd index 57369ef..5edc9c4 100644 --- a/fish/fish_catch.gd +++ b/fish/fish_catch.gd @@ -2,6 +2,7 @@ class_name FishCatch extends Resource const FishDataType = preload("res://fish/fish_data.gd") +const FishQualityType = preload("res://fish/fish_quality.gd") const MAX_SAFE_WEIGHT_LB: float = 1000000.0 const MAX_SAFE_DISPLAY_SCALE: float = 10000.0 @@ -14,6 +15,7 @@ const MAX_SAFE_SEQUENCE: int = 2147483647 @export var catch_sequence: int = 0 @export var weight_lb: float = 0.0 @export var display_scale: float = 1.0 +@export_range(0, 4, 1) var quality: int = FishQualityType.Tier.BORING @export var sale_value: int = 0 @export var is_favorited: bool = false @@ -48,6 +50,7 @@ func is_valid() -> bool: and is_finite(display_scale) and display_scale > 0.0 and display_scale <= MAX_SAFE_DISPLAY_SCALE + and FishQualityType.is_valid(quality) and sale_value >= 0 and sale_value <= MAX_SAFE_SALE_VALUE ) @@ -60,6 +63,7 @@ func to_save_dict() -> Dictionary: "fish_id": String(fish_id), "weight_lb": weight_lb, "display_scale": display_scale, + "quality": quality, "sale_value": sale_value, "is_favorited": is_favorited, } @@ -71,6 +75,7 @@ func to_network_dict() -> Dictionary: "fish_id": String(fish_id), "weight_lb": weight_lb, "display_scale": display_scale, + "quality": quality, "sale_value": sale_value, "is_favorited": is_favorited, } @@ -88,6 +93,7 @@ static func from_save_dict( "fish_id", "weight_lb", "display_scale", + "quality", "sale_value", "is_favorited", ]: @@ -111,6 +117,11 @@ static func from_save_dict( -1, MAX_SAFE_SALE_VALUE ) + var loaded_quality: int = _read_safe_integer( + data["quality"], + -1, + FishQualityType.TIER_COUNT - 1, + ) var loaded_weight: float = _read_safe_float( data["weight_lb"], 0.0, @@ -128,6 +139,7 @@ static func from_save_dict( or loaded_sequence <= 0 or loaded_sequence >= MAX_SAFE_SEQUENCE or loaded_sale_value < 0 + or not FishQualityType.is_valid(loaded_quality) or loaded_weight <= 0.0 or loaded_scale <= 0.0 or typeof(loaded_favorite) != TYPE_BOOL @@ -141,6 +153,7 @@ static func from_save_dict( fish_catch.catch_sequence = loaded_sequence fish_catch.weight_lb = loaded_weight fish_catch.display_scale = loaded_scale + fish_catch.quality = loaded_quality fish_catch.sale_value = loaded_sale_value fish_catch.is_favorited = bool(loaded_favorite) return fish_catch if fish_catch.is_valid() else null @@ -157,6 +170,7 @@ static func from_network_dict( "fish_id", "weight_lb", "display_scale", + "quality", "sale_value", ]: if not data.has(required_key): @@ -172,6 +186,11 @@ static func from_network_dict( var loaded_value: int = _read_safe_integer( data.get("sale_value"), -1, MAX_SAFE_SALE_VALUE ) + var loaded_quality: int = _read_safe_integer( + data.get("quality"), + -1, + FishQualityType.TIER_COUNT - 1, + ) if ( loaded_id.is_empty() or loaded_id.length() > 160 @@ -179,6 +198,7 @@ static func from_network_dict( or loaded_weight <= 0.0 or loaded_scale <= 0.0 or loaded_value < 0 + or not FishQualityType.is_valid(loaded_quality) ): return null var fish_catch := FishCatch.new() @@ -188,6 +208,7 @@ static func from_network_dict( fish_catch.catch_sequence = 0 fish_catch.weight_lb = loaded_weight fish_catch.display_scale = loaded_scale + fish_catch.quality = loaded_quality fish_catch.sale_value = loaded_value fish_catch.is_favorited = false return fish_catch if fish_catch.is_valid() else null diff --git a/fish/fish_experience.gd b/fish/fish_experience.gd new file mode 100644 index 0000000..592042f --- /dev/null +++ b/fish/fish_experience.gd @@ -0,0 +1,73 @@ +class_name FishExperience +extends RefCounted + +const FishCatchType = preload("res://fish/fish_catch.gd") +const FishQualityType = preload("res://fish/fish_quality.gd") +const CollectionLogType = preload("res://collection/collection_log.gd") + +const RARITY_BASE_EXPERIENCE: Array[int] = [10, 18, 32, 55, 90] +const QUALITY_MULTIPLIERS: Array[float] = [1.0, 1.15, 1.4, 1.8, 2.5] +const MAXIMUM_WEIGHT_BONUS: float = 0.25 +const FIRST_SPECIES_BONUS: int = 25 +const FIRST_QUALITY_BONUS: int = 15 +const SPECIES_MASTERY_BONUS: int = 100 + + +static func calculate_for_collection( + fish_catch: FishCatchType, + collection_log: CollectionLogType, +) -> int: + if fish_catch == null or collection_log == null: + return 0 + return calculate_catch_experience( + fish_catch, + collection_log.has_discovered(fish_catch.fish_id), + collection_log.get_quality_mask(fish_catch.fish_id), + ) + + +static func calculate_catch_experience( + fish_catch: FishCatchType, + was_species_discovered: bool, + previous_quality_mask: int, +) -> int: + if fish_catch == null or not fish_catch.is_valid(): + return 0 + var rarity: int = int(fish_catch.fish.rarity) + if rarity < 0 or rarity >= RARITY_BASE_EXPERIENCE.size(): + return 0 + if not FishQualityType.is_valid(fish_catch.quality): + return 0 + var weight_percentile: float = _get_weight_percentile(fish_catch) + var weight_multiplier: float = ( + 1.0 + MAXIMUM_WEIGHT_BONUS * weight_percentile * weight_percentile + ) + var catch_experience: int = roundi( + float(RARITY_BASE_EXPERIENCE[rarity]) + * QUALITY_MULTIPLIERS[fish_catch.quality] + * weight_multiplier + ) + var quality_bit: int = FishQualityType.bit_for(fish_catch.quality) + if not was_species_discovered: + catch_experience += FIRST_SPECIES_BONUS + if (previous_quality_mask & quality_bit) == 0: + catch_experience += FIRST_QUALITY_BONUS + var next_quality_mask: int = previous_quality_mask | quality_bit + if ( + previous_quality_mask != FishQualityType.ALL_TIERS_MASK + and next_quality_mask == FishQualityType.ALL_TIERS_MASK + ): + catch_experience += SPECIES_MASTERY_BONUS + return maxi(catch_experience, 0) + + +static func _get_weight_percentile(fish_catch: FishCatchType) -> float: + var minimum_weight: float = fish_catch.fish.get_minimum_weight() + var maximum_weight: float = fish_catch.fish.get_maximum_weight() + if maximum_weight <= minimum_weight: + return 0.0 + return clampf( + inverse_lerp(minimum_weight, maximum_weight, fish_catch.weight_lb), + 0.0, + 1.0, + ) diff --git a/fish/fish_experience.gd.uid b/fish/fish_experience.gd.uid new file mode 100644 index 0000000..aafa145 --- /dev/null +++ b/fish/fish_experience.gd.uid @@ -0,0 +1 @@ +uid://d05mwat7tju1b diff --git a/fish/fish_quality.gd b/fish/fish_quality.gd new file mode 100644 index 0000000..f851e66 --- /dev/null +++ b/fish/fish_quality.gd @@ -0,0 +1,91 @@ +class_name FishQuality +extends RefCounted + +enum Tier { + BORING, + AVERAGE, + IMPRESSIVE, + EXCEPTIONAL, + SHINY, +} + +const TIER_COUNT: int = 5 +const ALL_TIERS_MASK: int = (1 << TIER_COUNT) - 1 + +# Starting distribution. Equipment and tackle can supply per-tier +# multipliers later without changing catch serialization or tier identity. +const BASE_ROLL_WEIGHTS: Array[float] = [40.0, 32.0, 18.0, 8.0, 2.0] +const SALE_MULTIPLIERS: Array[float] = [1.0, 1.1, 1.25, 1.5, 2.0] +const DISPLAY_NAMES: PackedStringArray = [ + "boring", + "average", + "impressive", + "exceptional", + "shiny", +] + + +static func is_valid(quality: int) -> bool: + return quality >= 0 and quality < TIER_COUNT + + +static func display_name(quality: int) -> String: + return DISPLAY_NAMES[quality] if is_valid(quality) else "unknown" + + +static func bit_for(quality: int) -> int: + return 1 << quality if is_valid(quality) else 0 + + +static func sale_multiplier(quality: int) -> float: + return SALE_MULTIPLIERS[quality] if is_valid(quality) else 1.0 + + +static func apply_sale_value(base_value: int, quality: int) -> int: + if base_value <= 0 or not is_valid(quality): + return maxi(base_value, 0) + if quality == Tier.BORING: + return base_value + return maxi( + base_value + quality, + ceili(float(base_value) * sale_multiplier(quality)), + ) + + +static func roll( + rng: RandomNumberGenerator, + weight_multipliers: Array[float] = [], +) -> int: + if rng == null: + return Tier.BORING + var weights: Array[float] = [] + var total_weight: float = 0.0 + for quality: int in TIER_COUNT: + var multiplier: float = 1.0 + if quality < weight_multipliers.size(): + multiplier = maxf(weight_multipliers[quality], 0.0) + var weight: float = BASE_ROLL_WEIGHTS[quality] * multiplier + weights.append(weight) + total_weight += weight + if total_weight <= 0.0: + return Tier.BORING + var rolled_weight: float = rng.randf() * total_weight + var accumulated_weight: float = 0.0 + for quality: int in TIER_COUNT: + accumulated_weight += weights[quality] + if rolled_weight <= accumulated_weight: + return quality + return Tier.SHINY + + +static func qualified_name(fish_name: String, quality: int) -> String: + return "%s %s" % [display_name(quality), fish_name] + + +static func qualified_name_with_article( + fish_name: String, + quality: int, +) -> String: + var quality_name: String = display_name(quality) + var article: String = "an" if quality_name[0] in ["a", "e", "i", "o", "u"] else "a" + return "%s %s %s" % [article, quality_name, fish_name] diff --git a/fish/fish_quality.gd.uid b/fish/fish_quality.gd.uid new file mode 100644 index 0000000..0875763 --- /dev/null +++ b/fish/fish_quality.gd.uid @@ -0,0 +1 @@ +uid://csmvl1ciuux6a diff --git a/fish/fish_selector.gd b/fish/fish_selector.gd index 35ea1d3..8ed9a2a 100644 --- a/fish/fish_selector.gd +++ b/fish/fish_selector.gd @@ -5,10 +5,12 @@ const CollectionLogType = preload("res://collection/collection_log.gd") const FishCatchType = preload("res://fish/fish_catch.gd") const FishDataType = preload("res://fish/fish_data.gd") const FishPoolType = preload("res://fish/fish_pool.gd") +const FishQualityType = preload("res://fish/fish_quality.gd") const FishingContextType = preload("res://fishing/fishing_context.gd") var undiscovered_weight_multiplier: float = 1.5 var rarity_weight_multipliers: Array[float] = [] +var quality_weight_multipliers: Array[float] = [] var use_deterministic_test_seed: bool = false var deterministic_test_seed: int = 24680 var selection_seed: int = 0 @@ -81,7 +83,12 @@ func create_catch(fish: FishDataType) -> FishCatchType: caught_fish.display_scale = fish.get_display_scale_for_weight( caught_fish.weight_lb ) - caught_fish.sale_value = fish.get_sale_value_for_weight( - caught_fish.weight_lb + caught_fish.quality = FishQualityType.roll( + _rng, + quality_weight_multipliers, + ) + caught_fish.sale_value = FishQualityType.apply_sale_value( + fish.get_sale_value_for_weight(caught_fish.weight_lb), + caught_fish.quality, ) return caught_fish diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 0dcaf1f..c694461 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -9,6 +9,10 @@ const CatchControllerType = preload("res://fishing/catch_controller.gd") const FishingContextType = preload("res://fishing/fishing_context.gd") const FishingPresentationType = preload("res://fishing/fishing_presentation.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd") +const FishExperienceType = preload("res://fish/fish_experience.gd") +const PlayerExperienceType = preload( + "res://progression/player_experience.gd" +) const ItemCatalogType = preload("res://items/item_catalog.gd") const ItemDataType = preload("res://items/item_data.gd") const PlayerBagType = preload("res://inventory/player_bag.gd") @@ -56,6 +60,7 @@ signal showcase_changed( fish_name: String, rarity_name: String, weight_lb: float, + quality: int, visible: bool, ) signal bite_activated @@ -100,9 +105,9 @@ const BITE_QUICK_MAX_SECONDS: float = 30.0 const BITE_TYPICAL_MAX_SECONDS: float = 90.0 const BITE_LONG_MAX_SECONDS: float = 180.0 const BITE_MAX_SECONDS: float = 240.0 -const BITE_QUICK_PROBABILITY: float = 0.15 -const BITE_TYPICAL_PROBABILITY: float = 0.55 -const BITE_LONG_PROBABILITY: float = 0.25 +const BITE_QUICK_PROBABILITY: float = 0.25 +const BITE_TYPICAL_PROBABILITY: float = 0.65 +const BITE_LONG_PROBABILITY: float = 0.08 const NETWORK_INPUT_RESEND_INTERVAL_SECONDS: float = 0.1 @export_range(0.1, 10.0, 0.1) var cooldown_duration: float = 1.0 @@ -125,6 +130,7 @@ var _local_menu_input_owners: Dictionary[StringName, bool] = {} var _local_player: PlayerType var _local_inventory: FishInventoryType var _local_collection_log: CollectionLogType +var _local_experience: PlayerExperienceType var _local_bag: PlayerBagType var _local_hotbar: PlayerHotbarType var _item_catalog: ItemCatalogType @@ -185,6 +191,7 @@ func setup( local_player: PlayerType, local_inventory: FishInventoryType, local_collection_log: CollectionLogType, + local_experience: PlayerExperienceType, local_bag: PlayerBagType, local_hotbar: PlayerHotbarType, item_catalog: ItemCatalogType, @@ -200,6 +207,7 @@ func setup( _local_player = local_player _local_inventory = local_inventory _local_collection_log = local_collection_log + _local_experience = local_experience _local_bag = local_bag _local_hotbar = local_hotbar _item_catalog = item_catalog @@ -381,12 +389,11 @@ func _secure_showcase_catch_for_recovery() -> void: and _local_inventory != null and _local_collection_log != null ): - _local_inventory.add_catch(_pending_catch) - _local_collection_log.mark_discovered(_pending_catch.fish_id) + _store_catch_progression(_pending_catch) _pending_catch = null _showcase_ready = false _put_away_press_armed = false - showcase_changed.emit("", "", 0.0, false) + showcase_changed.emit("", "", 0.0, 0, false) if _active_player != null: _active_player.end_catch_showcase(Callable(), true) _cleanup_attempt() @@ -402,8 +409,7 @@ func _exit_tree() -> void: and _local_collection_log != null and is_instance_valid(_local_collection_log) ): - _local_inventory.add_catch(_pending_catch) - _local_collection_log.mark_discovered(_pending_catch.fish_id) + _store_catch_progression(_pending_catch) _pending_catch = null if _active_player != null and is_instance_valid(_active_player): _active_player.end_catch_showcase(Callable(), true) @@ -1031,6 +1037,7 @@ func _on_outcome_completed(outcome: StringName) -> void: _pending_catch.fish.display_name, _pending_catch.fish.get_rarity_name(), _pending_catch.weight_lb, + _pending_catch.quality, true ) @@ -1053,13 +1060,12 @@ func _put_away_catch() -> void: or _pending_catch == null ): return - _local_inventory.add_catch(_pending_catch) - _local_collection_log.mark_discovered(_pending_catch.fish_id) + _store_catch_progression(_pending_catch) _pending_catch = null _showcase_ready = false _showcase_outcome_completed = false _put_away_press_armed = false - showcase_changed.emit("", "", 0.0, false) + showcase_changed.emit("", "", 0.0, 0, false) _showcase_restore_generation += 1 var restore_generation: int = _showcase_restore_generation _active_player.end_catch_showcase( @@ -1070,6 +1076,29 @@ func _put_away_catch() -> void: ) +func _store_catch_progression(fish_catch: FishCatchType) -> void: + if ( + fish_catch == null + or _local_inventory == null + or _local_collection_log == null + or _local_experience == null + or _local_inventory.contains_catch_id(fish_catch.catch_id) + ): + return + var experience_award: int = ( + FishExperienceType.calculate_for_collection( + fish_catch, + _local_collection_log, + ) + ) + _local_inventory.add_catch(fish_catch) + _local_collection_log.mark_quality_discovered( + fish_catch.fish_id, + fish_catch.quality, + ) + _local_experience.award_experience(experience_award) + + func _finish_showcase_put_away( restore_generation: int, cooldown_message: String, @@ -1095,7 +1124,7 @@ func _cleanup_attempt( _showcase_ready = false _showcase_outcome_completed = false _put_away_press_armed = false - showcase_changed.emit("", "", 0.0, false) + showcase_changed.emit("", "", 0.0, 0, false) _catch_controller.reset() state = FishingState.RETURNING status_changed.emit("") @@ -1138,7 +1167,7 @@ func _finalize_attempt_cleanup(cooldown_message: String) -> void: _showcase_ready = false _showcase_outcome_completed = false _put_away_press_armed = false - showcase_changed.emit("", "", 0.0, false) + showcase_changed.emit("", "", 0.0, 0, false) _catch_controller.reset() _presentation.cleanup() _pending_cleanup_message = "" diff --git a/main/main.gd b/main/main.gd index a08a53e..7a074a3 100644 --- a/main/main.gd +++ b/main/main.gd @@ -357,6 +357,8 @@ func _initialize_after_data_root() -> void: _player.fishing_upgrades, _player.cooler_capacity, _player.art_unlocks, + _player.experience, + _world_time, ) _save_manager.set_autosave_enabled(false) _asset_reservations.setup( @@ -411,6 +413,7 @@ func _initialize_after_data_root() -> void: _player.inventory, _player.collection_log, _player.cooler_capacity, + _player.experience, _save_manager, item_catalog, fish_catalog, @@ -451,6 +454,7 @@ func _initialize_after_data_root() -> void: _player, _player.inventory, _player.collection_log, + _player.experience, _player.bag, _player.hotbar, item_catalog, @@ -467,6 +471,7 @@ func _initialize_after_data_root() -> void: _player, _player.inventory, _player.collection_log, + _player.experience, _player.wallet, _player.fish_sale_service, pelican_buyer_profile, @@ -1154,6 +1159,11 @@ func _on_return_to_title_requested() -> void: if _quit_in_progress: return var pause_menu: PauseMenuType = _game_ui.get_pause_menu() + if not _save_manager.save_world_time_checkpoint(): + pause_menu.report_network_error( + "Could not save progression before returning to title." + ) + return pause_menu.close_for_title_transition() _network_session.disconnect_session("Returned to title.") _set_gameplay_active(false) @@ -1176,7 +1186,7 @@ func _on_title_join_game_requested(endpoint: String) -> void: func _on_pause_join_game_requested(endpoint: String) -> void: if _quit_in_progress or not _gameplay_started: return - if not _save_manager.save_if_dirty(): + if not _save_manager.save_world_time_checkpoint(): _game_ui.get_pause_menu().report_network_error( "Could not save progression before leaving this session." ) @@ -1409,7 +1419,7 @@ func _on_quit_requested() -> void: _quit_in_progress = true _settings_manager.save_if_dirty() if _gameplay_started: - _save_manager.save_if_dirty() + _save_manager.save_world_time_checkpoint() _network_session.disconnect_session("Application closing.") _title_music_requested = false _replace_title_music_transition() diff --git a/network/network_fish_showcase_protocol.gd b/network/network_fish_showcase_protocol.gd index 2028976..d69e9dd 100644 --- a/network/network_fish_showcase_protocol.gd +++ b/network/network_fish_showcase_protocol.gd @@ -7,6 +7,7 @@ const MAX_SESSION_ID_LENGTH: int = 96 const MAX_FISH_ID_LENGTH: int = 96 const MAX_WEIGHT_LB: float = 1000.0 const MAX_DISPLAY_SCALE: float = 20.0 +const FishQualityType = preload("res://fish/fish_quality.gd") static func validate_state(data: Variant) -> bool: @@ -20,6 +21,7 @@ static func validate_state(data: Variant) -> bool: "fish_id", "weight_lb", "display_scale", + "quality", "revision", ]: if not value.has(key): @@ -37,6 +39,8 @@ static func validate_state(data: Variant) -> bool: or not is_finite(float(value["weight_lb"])) or typeof(value["display_scale"]) not in [TYPE_FLOAT, TYPE_INT] or not is_finite(float(value["display_scale"])) + or typeof(value["quality"]) != TYPE_INT + or not FishQualityType.is_valid(int(value["quality"])) or typeof(value["revision"]) != TYPE_INT or int(value["revision"]) < 0 ): @@ -53,4 +57,5 @@ static func validate_state(data: Variant) -> bool: str(value["fish_id"]).is_empty() and is_zero_approx(float(value["weight_lb"])) and is_zero_approx(float(value["display_scale"])) + and int(value["quality"]) == FishQualityType.Tier.BORING ) diff --git a/network/network_fish_showcase_service.gd b/network/network_fish_showcase_service.gd index f4670c5..822d2e8 100644 --- a/network/network_fish_showcase_service.gd +++ b/network/network_fish_showcase_service.gd @@ -4,6 +4,7 @@ extends Node const FishPoolType = preload("res://fish/fish_pool.gd") const FishDataType = preload("res://fish/fish_data.gd") const FishCatchType = preload("res://fish/fish_catch.gd") +const FishQualityType = preload("res://fish/fish_quality.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd") const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") @@ -80,6 +81,11 @@ func _submit_local_state(fish_catch: FishCatchType, should_show: bool) -> void: "display_scale": ( fish_catch.display_scale if fish_catch != null else 0.0 ), + "quality": ( + fish_catch.quality + if fish_catch != null + else FishQualityType.Tier.BORING + ), "revision": _local_revision, } _local_visible = bool(data["visible"]) diff --git a/network/network_fishing_service.gd b/network/network_fishing_service.gd index 9329aea..be214e2 100644 --- a/network/network_fishing_service.gd +++ b/network/network_fishing_service.gd @@ -7,6 +7,10 @@ const FishPoolType = preload("res://fish/fish_pool.gd") const FishSelectorType = preload("res://fish/fish_selector.gd") const FishingContextType = preload("res://fishing/fishing_context.gd") const CollectionLogType = preload("res://collection/collection_log.gd") +const FishExperienceType = preload("res://fish/fish_experience.gd") +const PlayerExperienceType = preload( + "res://progression/player_experience.gd" +) const RemotePresentationType = preload( "res://fishing/remote_fishing_presentation.gd" ) @@ -29,6 +33,7 @@ var _fishing_spot: FishingSpot var _local_inventory: FishInventory var _local_collection: CollectionLog var _local_capacity: PlayerCoolerCapacity +var _local_experience: PlayerExperienceType var _save_manager: PlayerSaveManager var _item_catalog: ItemCatalog var _fish_catalog: FishPoolType @@ -51,6 +56,7 @@ func setup( local_inventory: FishInventory, local_collection: CollectionLog, local_capacity: PlayerCoolerCapacity, + local_experience: PlayerExperienceType, save_manager: PlayerSaveManager, item_catalog: ItemCatalog, fish_catalog: FishPoolType, @@ -62,6 +68,7 @@ func setup( _local_inventory = local_inventory _local_collection = local_collection _local_capacity = local_capacity + _local_experience = local_experience _save_manager = save_manager _item_catalog = item_catalog _fish_catalog = fish_catalog @@ -760,8 +767,18 @@ func _apply_target_outcome(data: Dictionary) -> void: ): return if not already_owned: + var experience_award: int = ( + FishExperienceType.calculate_for_collection( + fish_catch, + _local_collection, + ) + ) _local_inventory.add_catch(fish_catch) - _local_collection.mark_discovered(fish_id) + _local_collection.mark_quality_discovered( + fish_id, + fish_catch.quality, + ) + _local_experience.award_experience(experience_award) if not _save_manager.save_if_dirty(): return _result_ledgers[result_id] = true diff --git a/network/network_mail_protocol.gd b/network/network_mail_protocol.gd index a3faeb2..f6441c7 100644 --- a/network/network_mail_protocol.gd +++ b/network/network_mail_protocol.gd @@ -144,6 +144,7 @@ static func attachment_signature_fields(value: Variant) -> Array: result.append(str(fish.get("fish_id", ""))) result.append(str(fish.get("weight_lb", ""))) result.append(str(fish.get("display_scale", ""))) + result.append(int(fish.get("quality", -1))) result.append(int(fish.get("sale_value", 0))) result.append(bool(fish.get("is_favorited", false))) 3: diff --git a/network/network_mail_service.gd b/network/network_mail_service.gd index 04893d1..f63c7ce 100644 --- a/network/network_mail_service.gd +++ b/network/network_mail_service.gd @@ -7,6 +7,7 @@ signal operation_finished(success: bool, message: String) signal peers_changed const MAX_LEDGER: int = 256 +const FishQualityType = preload("res://fish/fish_quality.gd") var _session: NetworkSession var _reservations: PlayerAssetReservationService @@ -950,7 +951,10 @@ func _apply_award(attachment: Dictionary) -> bool: PlayerAssetReservationService.AttachmentType.FISH: var fish_catch := _decode_catch(attachment) _inventory.add_catch(fish_catch) - _collection_log.mark_discovered(fish_catch.fish_id) + _collection_log.mark_quality_discovered( + fish_catch.fish_id, + fish_catch.quality, + ) return _inventory.contains_catch_id(fish_catch.catch_id) PlayerAssetReservationService.AttachmentType.CONSUMABLE: return _bag.add_item( @@ -962,10 +966,26 @@ func _apply_award(attachment: Dictionary) -> bool: func _decode_catch(attachment: Dictionary) -> FishCatch: var data: Dictionary = attachment.get("catch", {}) - var fish := _fish_catalog.get_fish_by_id( + var fish: FishData = _fish_catalog.get_fish_by_id( StringName(str(data.get("fish_id", ""))) ) - return FishCatch.from_network_dict(data, fish) + var fish_catch: FishCatch = FishCatch.from_network_dict(data, fish) + if fish_catch == null or fish == null: + return null + if ( + fish_catch.weight_lb < fish.get_minimum_weight() + or fish_catch.weight_lb > fish.get_maximum_weight() + or not is_equal_approx( + fish_catch.display_scale, + fish.get_display_scale_for_weight(fish_catch.weight_lb), + ) + or fish_catch.sale_value != FishQualityType.apply_sale_value( + fish.get_sale_value_for_weight(fish_catch.weight_lb), + fish_catch.quality, + ) + ): + return null + return fish_catch func _capture_assets() -> Dictionary: @@ -975,6 +995,7 @@ func _capture_assets() -> Dictionary: "catches": _inventory.get_all_catches(), "next_sequence": _inventory.get_next_catch_sequence(), "discovered": _collection_log.get_discovered_ids(), + "quality_masks": _collection_log.get_discovered_quality_masks(), } @@ -984,7 +1005,10 @@ func _restore_assets(snapshot: Dictionary) -> void: _inventory.replace_all_catches( snapshot["catches"], int(snapshot["next_sequence"]) ) - _collection_log.replace_discovered_ids(snapshot["discovered"]) + _collection_log.replace_discovery_state( + snapshot["discovered"], + snapshot["quality_masks"], + ) func _emit_mailbox() -> void: diff --git a/network/network_protocol.gd b/network/network_protocol.gd index ee1c277..94236b7 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -22,6 +22,7 @@ const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v2" const ART_SHOP_CAPABILITY: String = "art_shop_v1" const WORLD_TIME_CAPABILITY: String = "world_time_v1" const WORLD_WEATHER_CAPABILITY: String = "world_weather_v1" +const FISH_QUALITY_CAPABILITY: String = "fish_quality_v1" enum RejectionCode { NONE, @@ -88,6 +89,7 @@ static func make_client_hello( "display_name": display_name, "client_nonce": client_nonce, "capability_flags": PackedStringArray([ + FISH_QUALITY_CAPABILITY, SURFACE_DRAWING_CAPABILITY, WORLD_TIME_CAPABILITY, WORLD_WEATHER_CAPABILITY, @@ -207,6 +209,7 @@ static func make_server_hello( "item_use_v1", "equipment_v1", "fish_showcase_v1", + FISH_QUALITY_CAPABILITY, SURFACE_DRAWING_CAPABILITY, WORLD_TIME_CAPABILITY, WORLD_WEATHER_CAPABILITY, diff --git a/network/network_sale_protocol.gd b/network/network_sale_protocol.gd index ef91ccf..3b80de6 100644 --- a/network/network_sale_protocol.gd +++ b/network/network_sale_protocol.gd @@ -58,7 +58,7 @@ static func validate_request(data: Variant) -> String: var evidence: Dictionary = value for key: String in [ "catch_id", "fish_id", "weight_lb", "display_scale", - "sale_value", "is_favorited", + "quality", "sale_value", "is_favorited", ]: if not evidence.has(key): return "Sale could not be completed." diff --git a/network/network_sale_service.gd b/network/network_sale_service.gd index 06eefb8..11a499c 100644 --- a/network/network_sale_service.gd +++ b/network/network_sale_service.gd @@ -2,6 +2,7 @@ class_name NetworkSaleService extends Node const FishCatchType = preload("res://fish/fish_catch.gd") +const FishQualityType = preload("res://fish/fish_quality.gd") const FishDataType = preload("res://fish/fish_data.gd") const FishPoolType = preload("res://fish/fish_pool.gd") const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd") @@ -252,8 +253,10 @@ func _build_authoritative_result( not str(decoded.catch_id).begins_with("%s:" % fish_id) or decoded.weight_lb < fish.get_minimum_weight() or decoded.weight_lb > fish.get_maximum_weight() - or decoded.sale_value < fish.sell_value_min - or decoded.sale_value > fish.sell_value_max + or decoded.sale_value != FishQualityType.apply_sale_value( + fish.get_sale_value_for_weight(decoded.weight_lb), + decoded.quality, + ) ): return _rejected_result( request_id, "Sale could not be completed." @@ -262,7 +265,13 @@ func _build_authoritative_result( return _rejected_result( request_id, "Favorite catches cannot be sold." ) - var offer: int = buyer.get_offer(decoded.sale_value) + var ordinary_value: int = fish.get_sale_value_for_weight( + decoded.weight_lb + ) + var offer: int = buyer.get_quality_offer( + ordinary_value, + decoded.quality, + ) if ( offer < 0 or base_value > 9223372036854775807 - decoded.sale_value diff --git a/network/network_session.gd b/network/network_session.gd index d7ecb6d..8c948af 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -169,6 +169,7 @@ func start_private_host(port: int = DEFAULT_PORT) -> bool: _player_identity.fingerprint, _player_identity.public_pem, PackedStringArray([ + NetworkProtocol.FISH_QUALITY_CAPABILITY, NetworkProtocol.SURFACE_DRAWING_CAPABILITY, NetworkProtocol.WORLD_TIME_CAPABILITY, NetworkProtocol.WORLD_WEATHER_CAPABILITY, @@ -367,6 +368,7 @@ func supports_server_capability(capability: StringName) -> bool: "movement_v1", "fishing_v1", "sale_v1", "shop_v1", NetworkProtocol.ART_SHOP_CAPABILITY, "item_use_v1", "equipment_v1", "fish_showcase_v1", + NetworkProtocol.FISH_QUALITY_CAPABILITY, NetworkProtocol.SURFACE_DRAWING_CAPABILITY, NetworkProtocol.WORLD_TIME_CAPABILITY, NetworkProtocol.WORLD_WEATHER_CAPABILITY, @@ -915,6 +917,15 @@ func submit_client_hello(data: Dictionary) -> void: if not validation_error.is_empty(): _reject_peer(sender_id, NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE) return + var client_capabilities: PackedStringArray = _sanitized_capabilities( + data.get("capability_flags", []) + ) + if NetworkProtocol.FISH_QUALITY_CAPABILITY not in client_capabilities: + _reject_peer( + sender_id, + NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT, + ) + return var identity: Dictionary = _authenticated_identity_cache.get(sender_id, {}) if identity.is_empty(): _reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF) @@ -948,7 +959,7 @@ func submit_client_hello(data: Dictionary) -> void: NetworkProtocol.PROTOCOL_VERSION, identity["fingerprint"], identity["public_key"], - _sanitized_capabilities(data.get("capability_flags", [])), + client_capabilities, ): _reject_peer( sender_id, @@ -1071,6 +1082,10 @@ func receive_server_hello(data: Dictionary) -> void: for value: Variant in advertised_capabilities: if typeof(value) in [TYPE_STRING, TYPE_STRING_NAME]: _server_capabilities.append(str(value)) + if NetworkProtocol.FISH_QUALITY_CAPABILITY not in _server_capabilities: + _teardown_peer() + _fail("This server does not support fish quality data.") + return var local_peer_id: int = multiplayer.get_unique_id() _registry.clear() _registry.add_peer( @@ -1081,6 +1096,7 @@ func receive_server_hello(data: Dictionary) -> void: _player_identity.fingerprint, _player_identity.public_pem, PackedStringArray([ + NetworkProtocol.FISH_QUALITY_CAPABILITY, NetworkProtocol.SURFACE_DRAWING_CAPABILITY, NetworkProtocol.WORLD_TIME_CAPABILITY, NetworkProtocol.WORLD_WEATHER_CAPABILITY, diff --git a/network/network_world_time_service.gd b/network/network_world_time_service.gd index ad50255..91b4060 100644 --- a/network/network_world_time_service.gd +++ b/network/network_world_time_service.gd @@ -39,12 +39,16 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _sequence = 0 _last_received_sequence = -1 _sync_elapsed = 0.0 - _world_time.begin_session() + _world_time.set_persistence_tracking_enabled(true) + _world_time.begin_session( + _world_time.get_persistent_time_hours() + ) return if state == NetworkSession.State.JOINED_CLIENT: _active_session_id = _session.get_session_id() _last_received_sequence = -1 _sync_elapsed = 0.0 + _world_time.set_persistence_tracking_enabled(false) if _session.supports_server_capability( NetworkProtocol.WORLD_TIME_CAPABILITY ): @@ -62,6 +66,7 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _sequence = 0 _last_received_sequence = -1 _sync_elapsed = 0.0 + _world_time.set_persistence_tracking_enabled(false) _world_time.end_session() diff --git a/player/player.gd b/player/player.gd index 7b8a947..2f0f134 100644 --- a/player/player.gd +++ b/player/player.gd @@ -21,6 +21,9 @@ const PlayerCoolerCapacityType = preload( const PlayerArtUnlocksType = preload( "res://progression/player_art_unlocks.gd" ) +const PlayerExperienceType = preload( + "res://progression/player_experience.gd" +) const FishingRodAttachmentScene = preload( "res://player/fishing_rod_attachment.tscn" ) @@ -109,6 +112,7 @@ class ShowcaseCameraSnapshot: @onready var item_effects: PlayerItemEffectsType = %ItemEffects @onready var cooler_capacity: PlayerCoolerCapacityType = %CoolerCapacity @onready var art_unlocks: PlayerArtUnlocksType = %ArtUnlocks +@onready var experience: PlayerExperienceType = %Experience @onready var _cast_origin: Marker3D = %CastOrigin @onready var _catch_display: Node3D = %CatchDisplay @onready var _catch_sprite: Sprite3D = %CatchSprite diff --git a/player/player.tscn b/player/player.tscn index 9606811..2f7c0fb 100644 --- a/player/player.tscn +++ b/player/player.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=17 format=3] +[gd_scene load_steps=18 format=3] [ext_resource type="Script" path="res://player/player.gd" id="1_script"] [ext_resource type="Script" path="res://inventory/fish_inventory.gd" id="2_inventory"] @@ -14,6 +14,7 @@ [ext_resource type="Material" path="res://player/materials/player_blob_shadow.tres" id="12_blob_shadow"] [ext_resource type="PackedScene" path="res://art/exported/characters/base/netfishing_base_character.glb" id="13_character"] [ext_resource type="Script" path="res://progression/player_art_unlocks.gd" id="14_art_unlocks"] +[ext_resource type="Script" path="res://progression/player_experience.gd" id="15_experience"] [sub_resource type="CapsuleShape3D" id="PlayerShape"] radius = 0.45 @@ -104,6 +105,10 @@ script = ExtResource("10_capacity") unique_name_in_owner = true script = ExtResource("14_art_unlocks") +[node name="Experience" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("15_experience") + [node name="CatchDisplayAnchor" type="Marker3D" parent="Visuals"] unique_name_in_owner = true position = Vector3(0, 1.45, -1.15) diff --git a/progression/player_experience.gd b/progression/player_experience.gd new file mode 100644 index 0000000..ec5ba20 --- /dev/null +++ b/progression/player_experience.gd @@ -0,0 +1,130 @@ +class_name PlayerExperience +extends Node + +signal experience_changed(total_experience: int, level: int) +signal experience_awarded( + amount: int, + previous_total: int, + new_total: int, + previous_level: int, + new_level: int, +) + +const MAX_TOTAL_EXPERIENCE: int = 1000000000000 +const MAX_LEVEL: int = 100000 + +var _total_experience: int = 0 + + +func get_total_experience() -> int: + return _total_experience + + +func get_level() -> int: + return level_for_total_experience(_total_experience) + + +func get_experience_in_level() -> int: + return _total_experience - total_experience_for_level(get_level()) + + +func get_experience_for_next_level() -> int: + return experience_required_for_next_level(get_level()) + + +func get_level_progress() -> float: + return progress_for_total_experience(_total_experience) + + +func award_experience(amount: int) -> bool: + if amount <= 0 or _total_experience >= MAX_TOTAL_EXPERIENCE: + return false + var previous_total: int = _total_experience + var previous_level: int = level_for_total_experience(previous_total) + _total_experience = mini( + _total_experience + amount, + MAX_TOTAL_EXPERIENCE, + ) + var awarded_amount: int = _total_experience - previous_total + if awarded_amount <= 0: + return false + var new_level: int = level_for_total_experience(_total_experience) + experience_changed.emit(_total_experience, new_level) + experience_awarded.emit( + awarded_amount, + previous_total, + _total_experience, + previous_level, + new_level, + ) + return true + + +func restore_total_experience(total_experience: int) -> bool: + if total_experience < 0 or total_experience > MAX_TOTAL_EXPERIENCE: + return false + var changed: bool = _total_experience != total_experience + _total_experience = total_experience + if changed: + experience_changed.emit(_total_experience, get_level()) + return true + + +func reset_to_defaults() -> void: + restore_total_experience(0) + + +func to_save_data() -> Dictionary: + return {"total_experience": _total_experience} + + +static func experience_required_for_next_level(level: int) -> int: + var safe_level: int = maxi(level, 1) + var level_index: int = safe_level - 1 + return 100 + level_index * 25 + level_index * level_index * 5 + + +static func total_experience_for_level(level: int) -> int: + var completed_levels: int = clampi(level - 1, 0, MAX_LEVEL - 1) + if completed_levels == 0: + return 0 + var linear_sum: int = floori( + float(completed_levels) * float(completed_levels - 1) / 2.0 + ) + var square_sum: int = floori( + float(completed_levels - 1) + * float(completed_levels) + * float(2 * completed_levels - 1) + / 6.0 + ) + return completed_levels * 100 + linear_sum * 25 + square_sum * 5 + + +static func level_for_total_experience(total_experience: int) -> int: + var safe_total: int = clampi( + total_experience, + 0, + MAX_TOTAL_EXPERIENCE, + ) + var low: int = 1 + var high: int = MAX_LEVEL + while low < high: + var middle: int = low + floori(float(high - low + 1) / 2.0) + if total_experience_for_level(middle) <= safe_total: + low = middle + else: + high = middle - 1 + return low + + +static func progress_for_total_experience(total_experience: int) -> float: + var level: int = level_for_total_experience(total_experience) + var level_start: int = total_experience_for_level(level) + var required: int = experience_required_for_next_level(level) + if required <= 0: + return 0.0 + return clampf( + float(total_experience - level_start) / float(required), + 0.0, + 1.0, + ) diff --git a/progression/player_experience.gd.uid b/progression/player_experience.gd.uid new file mode 100644 index 0000000..9b49089 --- /dev/null +++ b/progression/player_experience.gd.uid @@ -0,0 +1 @@ +uid://eml3k7a43e1d diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index e24cd33..72f7676 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -2,6 +2,7 @@ class_name PlayerSaveManager extends Node const FishCatchType = preload("res://fish/fish_catch.gd") +const FishQualityType = preload("res://fish/fish_quality.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd") const CollectionLogType = preload("res://collection/collection_log.gd") const PlayerWalletType = preload("res://economy/player_wallet.gd") @@ -20,8 +21,12 @@ const PlayerCoolerCapacityType = preload( const PlayerArtUnlocksType = preload( "res://progression/player_art_unlocks.gd" ) +const PlayerExperienceType = preload( + "res://progression/player_experience.gd" +) +const WorldTimeServiceType = preload("res://world/world_time_service.gd") -const SAVE_VERSION: int = 4 +const SAVE_VERSION: int = 6 const BASIC_ROD_ID: StringName = &"basic_fishing_rod" const MAX_SAFE_BALANCE: int = 1000000000000 @@ -30,6 +35,7 @@ class LoadSnapshot: var catches: Array[FishCatchType] = [] var discovered_ids: Array[StringName] = [] + var discovered_quality_masks: Dictionary[StringName, int] = {} var wallet_balance: int = 0 var next_catch_sequence: int = 1 var bag_items: Array[OwnedItemType] = [] @@ -40,6 +46,8 @@ class LoadSnapshot: var barrier_power_level: int = 0 var cooler_capacity_level: int = 0 var art_unlock_mask: int = 0 + var total_experience: int = 0 + var world_time_hours: float = WorldTimeServiceType.DEFAULT_START_HOUR @export_range(0.05, 5.0, 0.05) var autosave_delay: float = 0.5 @@ -54,6 +62,8 @@ var _item_catalog: ItemCatalogType var _fishing_upgrades: PlayerFishingUpgradesType var _cooler_capacity: PlayerCoolerCapacityType var _art_unlocks: PlayerArtUnlocksType +var _experience: PlayerExperienceType +var _world_time: WorldTimeServiceType var _autosave_timer: Timer var _is_configured: bool = false var _is_restoring: bool = false @@ -96,6 +106,8 @@ func setup( fishing_upgrades: PlayerFishingUpgradesType, cooler_capacity: PlayerCoolerCapacityType, art_unlocks: PlayerArtUnlocksType, + experience: PlayerExperienceType, + world_time: WorldTimeServiceType, ) -> void: _inventory = inventory _collection_log = collection_log @@ -107,6 +119,8 @@ func setup( _fishing_upgrades = fishing_upgrades _cooler_capacity = cooler_capacity _art_unlocks = art_unlocks + _experience = experience + _world_time = world_time _is_configured = ( _inventory != null and _collection_log != null @@ -118,16 +132,18 @@ func setup( and _fishing_upgrades != null and _cooler_capacity != null and _art_unlocks != null + and _experience != null + and _world_time != null ) if not _is_configured: push_error("PlayerSaveManager setup is missing required references.") return if not _inventory.catches_changed.is_connected(_mark_dirty): _inventory.catches_changed.connect(_mark_dirty) - if not _collection_log.fish_discovered.is_connected( - _on_fish_discovered + if not _collection_log.collection_changed.is_connected( + _on_collection_changed ): - _collection_log.fish_discovered.connect(_on_fish_discovered) + _collection_log.collection_changed.connect(_on_collection_changed) if not _wallet.balance_changed.is_connected(_on_balance_changed): _wallet.balance_changed.connect(_on_balance_changed) if not _bag.contents_changed.is_connected(_mark_dirty): @@ -152,6 +168,10 @@ func setup( ) if not _art_unlocks.unlocks_changed.is_connected(_on_art_unlocks_changed): _art_unlocks.unlocks_changed.connect(_on_art_unlocks_changed) + if not _experience.experience_changed.is_connected( + _on_experience_changed + ): + _experience.experience_changed.connect(_on_experience_changed) func load_player_data() -> bool: @@ -208,7 +228,10 @@ func load_player_data() -> bool: snapshot.next_catch_sequence ) var collection_restored: bool = ( - _collection_log.replace_discovered_ids(snapshot.discovered_ids) + _collection_log.replace_discovery_state( + snapshot.discovered_ids, + snapshot.discovered_quality_masks, + ) ) var wallet_restored: bool = _wallet.restore_balance( snapshot.wallet_balance @@ -229,6 +252,12 @@ func load_player_data() -> bool: var art_restored: bool = _art_unlocks.restore_mask( snapshot.art_unlock_mask ) + var experience_restored: bool = _experience.restore_total_experience( + snapshot.total_experience + ) + var world_time_restored: bool = ( + _world_time.restore_persistent_time_hours(snapshot.world_time_hours) + ) _is_restoring = false if ( not inventory_restored @@ -239,6 +268,8 @@ func load_player_data() -> bool: or not upgrades_restored or not cooler_restored or not art_restored + or not experience_restored + or not world_time_restored ): push_error("Validated player save could not be restored.") return false @@ -332,6 +363,17 @@ func save_if_dirty() -> bool: return not _is_dirty or save_now() +func save_world_time_checkpoint() -> bool: + if ( + not _is_configured + or _automatic_saving_blocked + or not _autosave_enabled + ): + return false + _is_dirty = true + return save_now() + + func is_dirty() -> bool: return _is_dirty @@ -401,6 +443,19 @@ func _build_save_dictionary() -> Dictionary: if fish_id.is_empty(): return {} discovered_strings.append(String(fish_id)) + var serialized_quality_masks: Dictionary = {} + var quality_masks: Dictionary[StringName, int] = ( + _collection_log.get_discovered_quality_masks() + ) + for fish_id: StringName in quality_masks: + var quality_mask: int = quality_masks[fish_id] + if ( + fish_id.is_empty() + or quality_mask <= 0 + or (quality_mask & ~FishQualityType.ALL_TIERS_MASK) != 0 + ): + return {} + serialized_quality_masks[String(fish_id)] = quality_mask var serialized_items: Array[Dictionary] = [] for owned: OwnedItemType in _bag.get_all_items(): if owned == null or not owned.is_valid(): @@ -430,6 +485,7 @@ func _build_save_dictionary() -> Dictionary: }, "collection": { "discovered_fish_ids": discovered_strings, + "discovered_quality_masks": serialized_quality_masks, }, "inventory": { "next_catch_sequence": _inventory.get_next_catch_sequence(), @@ -446,6 +502,10 @@ func _build_save_dictionary() -> Dictionary: "upgrades": _fishing_upgrades.to_save_data(), "cooler": _cooler_capacity.to_save_data(), "art": _art_unlocks.to_save_data(), + "experience": _experience.to_save_data(), + "world": { + "time_hours": _world_time.get_persistent_time_hours(), + }, } @@ -456,6 +516,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: or typeof(save_data.get("inventory")) != TYPE_DICTIONARY or typeof(save_data.get("bag")) != TYPE_DICTIONARY or typeof(save_data.get("hotbar")) != TYPE_DICTIONARY + or typeof(save_data.get("experience")) != TYPE_DICTIONARY ): return null var wallet_data: Dictionary = save_data["wallet"] @@ -463,6 +524,10 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: var inventory_data: Dictionary = save_data["inventory"] var bag_data: Dictionary = save_data["bag"] var hotbar_data: Dictionary = save_data["hotbar"] + var experience_data: Dictionary = save_data["experience"] + var world_data: Dictionary = {} + if typeof(save_data.get("world")) == TYPE_DICTIONARY: + world_data = save_data["world"] var upgrades_data: Dictionary = {} var cooler_data: Dictionary = {} var art_data: Dictionary = {} @@ -481,11 +546,14 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: if ( not wallet_data.has("balance") or typeof(collection_data.get("discovered_fish_ids")) != TYPE_ARRAY + or typeof(collection_data.get("discovered_quality_masks")) + != TYPE_DICTIONARY or typeof(inventory_data.get("catches")) != TYPE_ARRAY or not inventory_data.has("next_catch_sequence") or typeof(bag_data.get("items")) != TYPE_ARRAY or typeof(hotbar_data.get("slots")) != TYPE_ARRAY or not hotbar_data.has("selected_slot") + or not experience_data.has("total_experience") ): return null @@ -504,6 +572,19 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: var snapshot := LoadSnapshot.new() snapshot.wallet_balance = balance + snapshot.total_experience = _read_integer( + experience_data["total_experience"], + -1, + PlayerExperienceType.MAX_TOTAL_EXPERIENCE, + ) + if snapshot.total_experience < 0: + return null + if world_data.has("time_hours"): + snapshot.world_time_hours = _read_world_time_hours( + world_data["time_hours"] + ) + if snapshot.world_time_hours < 0.0: + return null var discovered_values: Array = collection_data["discovered_fish_ids"] var seen_discoveries: Dictionary[StringName, bool] = {} for value: Variant in discovered_values: @@ -514,6 +595,26 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: continue seen_discoveries[fish_id] = true snapshot.discovered_ids.append(fish_id) + var quality_mask_values: Dictionary = ( + collection_data["discovered_quality_masks"] + ) + for key: Variant in quality_mask_values: + if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME]: + return null + var fish_id: StringName = StringName(str(key)) + var mask: int = _read_integer( + quality_mask_values[key], + -1, + FishQualityType.ALL_TIERS_MASK, + ) + if ( + fish_id.is_empty() + or not seen_discoveries.has(fish_id) + or mask <= 0 + or (mask & ~FishQualityType.ALL_TIERS_MASK) != 0 + ): + return null + snapshot.discovered_quality_masks[fish_id] = mask var seen_ids: Dictionary[StringName, bool] = {} var seen_sequences: Dictionary[int, bool] = {} @@ -701,6 +802,10 @@ func _migrate_save( migrated = _migrate_version_2_to_3(migrated) 3: migrated = _migrate_version_3_to_4(migrated) + 4: + migrated = _migrate_version_4_to_5(migrated) + 5: + migrated = _migrate_version_5_to_6(migrated) _: return {} if migrated.is_empty(): @@ -748,6 +853,68 @@ func _migrate_version_3_to_4(data: Dictionary) -> Dictionary: return migrated +func _migrate_version_4_to_5(data: Dictionary) -> Dictionary: + var migrated: Dictionary = data.duplicate(true) + if ( + typeof(migrated.get("inventory")) != TYPE_DICTIONARY + or typeof(migrated.get("collection")) != TYPE_DICTIONARY + ): + return {} + var inventory_data: Dictionary = migrated["inventory"] + var collection_data: Dictionary = migrated["collection"] + if ( + typeof(inventory_data.get("catches")) != TYPE_ARRAY + or typeof(collection_data.get("discovered_fish_ids")) != TYPE_ARRAY + ): + return {} + var catches: Array = inventory_data["catches"] + var discovered_values: Array = collection_data["discovered_fish_ids"] + var discovered_lookup: Dictionary[String, bool] = {} + for value: Variant in discovered_values: + if typeof(value) in [TYPE_STRING, TYPE_STRING_NAME]: + var existing_id: String = str(value) + if not existing_id.is_empty(): + discovered_lookup[existing_id] = true + for index: int in catches.size(): + if typeof(catches[index]) != TYPE_DICTIONARY: + continue + var catch_data: Dictionary = catches[index] + catch_data["quality"] = FishQualityType.Tier.BORING + var catch_fish_id: String = str(catch_data.get("fish_id", "")) + if not catch_fish_id.is_empty() and not discovered_lookup.has( + catch_fish_id + ): + discovered_values.append(catch_fish_id) + discovered_lookup[catch_fish_id] = true + catches[index] = catch_data + inventory_data["catches"] = catches + migrated["inventory"] = inventory_data + var quality_masks: Dictionary = {} + for value: Variant in discovered_values: + if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]: + continue + var fish_id: String = str(value) + if not fish_id.is_empty(): + quality_masks[fish_id] = FishQualityType.bit_for( + FishQualityType.Tier.BORING + ) + collection_data["discovered_quality_masks"] = quality_masks + collection_data["discovered_fish_ids"] = discovered_values + migrated["collection"] = collection_data + migrated["save_version"] = 5 + return migrated + + +func _migrate_version_5_to_6(data: Dictionary) -> Dictionary: + var migrated: Dictionary = data.duplicate(true) + migrated["save_version"] = 6 + migrated["experience"] = {"total_experience": 0} + migrated["world"] = { + "time_hours": WorldTimeServiceType.DEFAULT_START_HOUR, + } + return migrated + + func _mark_dirty() -> void: if ( _is_restoring @@ -760,7 +927,7 @@ func _mark_dirty() -> void: _autosave_timer.start(maxf(autosave_delay, 0.05)) -func _on_fish_discovered(_fish_id: StringName) -> void: +func _on_collection_changed() -> void: _mark_dirty() @@ -793,6 +960,10 @@ func _on_art_unlocks_changed(_unlock_mask: int) -> void: _mark_dirty() +func _on_experience_changed(_total_experience: int, _level: int) -> void: + _mark_dirty() + + func _on_autosave_timeout() -> void: if _is_dirty: save_now() @@ -833,6 +1004,7 @@ func _restore_defaults() -> void: _is_restoring = true var empty_catches: Array[FishCatchType] = [] var empty_discoveries: Array[StringName] = [] + var empty_quality_masks: Dictionary[StringName, int] = {} var default_items: Array[OwnedItemType] = [] var basic_rod := OwnedItemType.new() basic_rod.item_id = BASIC_ROD_ID @@ -843,13 +1015,20 @@ func _restore_defaults() -> void: default_slots.fill(StringName()) default_slots[0] = BASIC_ROD_ID _inventory.replace_all_catches(empty_catches, 1) - _collection_log.replace_discovered_ids(empty_discoveries) + _collection_log.replace_discovery_state( + empty_discoveries, + empty_quality_masks, + ) _wallet.restore_balance(0) _bag.replace_all_items(default_items) _hotbar.replace_state(default_slots, 0) _fishing_upgrades.reset_to_defaults() _cooler_capacity.reset_to_defaults() _art_unlocks.reset_to_defaults() + _experience.reset_to_defaults() + _world_time.restore_persistent_time_hours( + WorldTimeServiceType.DEFAULT_START_HOUR + ) _is_restoring = false _is_dirty = false @@ -875,6 +1054,19 @@ func _read_integer( return invalid_value +func _read_world_time_hours(value: Variant) -> float: + if typeof(value) not in [TYPE_FLOAT, TYPE_INT]: + return -1.0 + var time_hours: float = float(value) + if ( + not is_finite(time_hours) + or time_hours < 0.0 + or time_hours >= WorldTimeServiceType.HOURS_PER_DAY + ): + return -1.0 + return time_hours + + func _read_upgrade_level( value: Variant, maximum_level: int, diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index 81f648d..bd1df0f 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -30,6 +30,9 @@ func _run() -> void: await process_frame var player := main.get("_player") as Player + var world_time := main.get_node("%WorldTimeService") as WorldTimeService + world_time.synchronize_time(19.75) + assert(player.experience.award_experience(125)) var fish_catalog := main.get("fish_catalog") as FishPool var service := main.get_node( "%NetworkFishShowcaseService" @@ -45,11 +48,17 @@ func _run() -> void: fish_catch.display_scale = fish.get_display_scale_for_weight( fish_catch.weight_lb ) - fish_catch.sale_value = fish.get_sale_value_for_weight( - fish_catch.weight_lb + fish_catch.quality = FishQuality.Tier.EXCEPTIONAL + fish_catch.sale_value = FishQuality.apply_sale_value( + fish.get_sale_value_for_weight(fish_catch.weight_lb), + fish_catch.quality, ) fish_catch.ensure_identity() player.inventory.add_catch(fish_catch) + player.collection_log.mark_quality_discovered( + fish_catch.fish_id, + fish_catch.quality, + ) var game_ui := main.get_node("%GameUI") as GameUI var hotbar_ui := game_ui.get_node("%Hotbar") as HotbarUI assert(hotbar_ui != null) @@ -94,12 +103,42 @@ func _run() -> void: var hotbar_data: Dictionary = (parsed as Dictionary)["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"]) == 4) + assert(int((parsed as Dictionary)["save_version"]) == 6) + assert( + int((parsed as Dictionary)["experience"]["total_experience"]) + == 125 + ) + assert( + is_equal_approx( + float((parsed as Dictionary)["world"]["time_hours"]), + 19.75, + ) + ) + var saved_catches: Array = (parsed as Dictionary)["inventory"]["catches"] + assert(int((saved_catches[0] as Dictionary)["quality"]) == fish_catch.quality) + var saved_masks: Dictionary = ( + (parsed as Dictionary)["collection"]["discovered_quality_masks"] + ) + assert( + int(saved_masks[String(fish.id)]) + == FishQuality.bit_for(FishQuality.Tier.EXCEPTIONAL) + ) assert(player.hotbar.clear_slot(1)) + assert(player.experience.restore_total_experience(0)) + world_time.synchronize_time(8.0) assert(save_manager.load_player_data()) + assert(player.experience.get_total_experience() == 125) + assert(is_equal_approx(world_time.get_time_hours(), 19.75)) + assert(player.experience.get_level() == 2) assert(player.hotbar.get_fish_catch_id(1) == fish_catch.catch_id) assert(player.hotbar.get_selected_slot() == 1) + assert( + player.collection_log.has_discovered_quality( + fish.id, + FishQuality.Tier.EXCEPTIONAL, + ) + ) assert(not service.is_local_showcase_visible()) assert(service.toggle_selected_fish()) assert(service.is_local_showcase_visible()) @@ -118,6 +157,7 @@ func _run() -> void: "display_scale": fish.get_display_scale_for_weight( fish.get_minimum_weight() ), + "quality": FishQuality.Tier.BORING, "revision": 1, } assert(NetworkFishShowcaseProtocol.validate_state(valid_state)) @@ -126,6 +166,10 @@ func _run() -> void: assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state)) assert(NetworkProtocol.PROTOCOL_VERSION == 3) assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) + assert( + NetworkProtocol.FISH_QUALITY_CAPABILITY + == "fish_quality_v1" + ) assert( NetworkFishShowcaseProtocol.CAPABILITY == &"fish_showcase_v1" diff --git a/tests/fish_quality_validation.gd b/tests/fish_quality_validation.gd new file mode 100644 index 0000000..e4f3288 --- /dev/null +++ b/tests/fish_quality_validation.gd @@ -0,0 +1,262 @@ +extends SceneTree + +const FishCatchType = preload("res://fish/fish_catch.gd") +const FishQualityType = preload("res://fish/fish_quality.gd") +const FishSelectorType = preload("res://fish/fish_selector.gd") +const CollectionLogType = preload("res://collection/collection_log.gd") +const NetworkSaleServiceType = preload( + "res://network/network_sale_service.gd" +) +const Catalog: FishPool = preload("res://fish/pools/fish_catalog.tres") +const PelicanBuyer: FishBuyerProfile = preload( + "res://economy/buyers/pelicans.tres" +) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _validate_tiers_and_distribution() + _validate_catch_round_trip_and_sale() + _validate_mail_round_trip() + _validate_collection_mastery() + _validate_version_four_migration() + assert(NetworkProtocol.PROTOCOL_VERSION == 3) + assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) + assert(NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1") + print("Fish quality validation: PASS") + quit() + + +func _validate_tiers_and_distribution() -> void: + assert(FishQualityType.TIER_COUNT == 5) + assert(FishQualityType.display_name(0) == "boring") + assert(FishQualityType.display_name(4) == "shiny") + assert(UIPalette.get_quality_color(0) == UIPalette.QUALITY_BORING) + assert(UIPalette.get_quality_color(4) == UIPalette.QUALITY_SHINY) + assert( + UIPalette.get_quality_color(0) + != UIPalette.get_quality_color(1) + ) + assert(FishQualityType.apply_sale_value(3, 0) == 3) + assert(FishQualityType.apply_sale_value(3, 1) == 4) + assert(FishQualityType.apply_sale_value(3, 2) == 5) + assert(FishQualityType.apply_sale_value(3, 3) == 6) + assert(FishQualityType.apply_sale_value(3, 4) == 7) + var previous_offer: int = -1 + for quality: int in FishQualityType.TIER_COUNT: + var offer: int = PelicanBuyer.get_quality_offer(3, quality) + assert(offer > previous_offer) + previous_offer = offer + assert( + FishQualityType.qualified_name_with_article("bluegill", 0) + == "a boring bluegill" + ) + assert( + FishQualityType.qualified_name_with_article("bluegill", 1) + == "an average bluegill" + ) + var rng := RandomNumberGenerator.new() + rng.seed = 727272 + var counts: Array[int] = [0, 0, 0, 0, 0] + const SAMPLE_COUNT: int = 50000 + for _sample: int in SAMPLE_COUNT: + counts[FishQualityType.roll(rng)] += 1 + for quality: int in FishQualityType.TIER_COUNT: + var observed: float = float(counts[quality]) / float(SAMPLE_COUNT) + var expected: float = ( + FishQualityType.BASE_ROLL_WEIGHTS[quality] / 100.0 + ) + assert(absf(observed - expected) < 0.015) + + +func _validate_catch_round_trip_and_sale() -> void: + var fish: FishData = Catalog.get_fish_by_id(&"bluegill") + assert(fish != null) + var selector := FishSelectorType.new() + selector.use_deterministic_test_seed = true + selector.deterministic_test_seed = 9911 + selector.quality_weight_multipliers = [0.0, 0.0, 0.0, 0.0, 1.0] + selector.begin_roll() + var fish_catch: FishCatch = selector.create_catch(fish) + assert(fish_catch != null) + assert(fish_catch.quality == FishQualityType.Tier.SHINY) + assert( + fish_catch.sale_value + == FishQualityType.apply_sale_value( + fish.get_sale_value_for_weight(fish_catch.weight_lb), + FishQualityType.Tier.SHINY, + ) + ) + var save_data: Dictionary = fish_catch.to_save_dict() + fish_catch.catch_sequence = 1 + save_data = fish_catch.to_save_dict() + var restored: FishCatch = FishCatchType.from_save_dict(save_data, fish) + assert(restored != null and restored.quality == fish_catch.quality) + var network_data: Dictionary = fish_catch.to_network_dict() + var replicated: FishCatch = FishCatchType.from_network_dict( + network_data, + fish, + ) + assert(replicated != null and replicated.quality == fish_catch.quality) + var missing_quality: Dictionary = network_data.duplicate(true) + missing_quality.erase("quality") + assert(FishCatchType.from_network_dict(missing_quality, fish) == null) + + var session := NetworkSession.new() + var sale_service := NetworkSaleServiceType.new() + root.add_child(session) + root.add_child(sale_service) + sale_service.set("_session", session) + sale_service.set("_fish_catalog", Catalog) + var accepted: Dictionary = sale_service.call( + "_build_authoritative_result", + 1, + "quality_sale", + [network_data], + PelicanBuyer, + ) + assert(bool(accepted.get("accepted", false))) + assert( + int(accepted["payout"]) + == PelicanBuyer.get_quality_offer( + fish.get_sale_value_for_weight(fish_catch.weight_lb), + fish_catch.quality, + ) + ) + var inventory := FishInventory.new() + var wallet := PlayerWallet.new() + var local_sale := FishSaleService.new() + root.add_child(inventory) + root.add_child(wallet) + root.add_child(local_sale) + local_sale.setup(inventory, wallet) + inventory.add_catch(fish_catch) + var local_preview: FishSaleResult = local_sale.preview_batch( + [fish_catch.catch_id], + PelicanBuyer, + ) + assert(local_preview.is_success()) + assert(local_preview.payout == int(accepted["payout"])) + var forged: Dictionary = network_data.duplicate(true) + forged["quality"] = FishQualityType.Tier.BORING + var rejected: Dictionary = sale_service.call( + "_build_authoritative_result", + 1, + "quality_sale_forged", + [forged], + PelicanBuyer, + ) + assert(not bool(rejected.get("accepted", false))) + local_sale.queue_free() + wallet.queue_free() + inventory.queue_free() + sale_service.queue_free() + session.queue_free() + + +func _validate_mail_round_trip() -> void: + var fish: FishData = Catalog.get_fish_by_id(&"bluegill") + var fish_catch := FishCatchType.new() + fish_catch.fish = fish + fish_catch.fish_id = fish.id + 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 = FishQualityType.Tier.IMPRESSIVE + fish_catch.sale_value = FishQualityType.apply_sale_value( + fish.get_sale_value_for_weight(fish_catch.weight_lb), + fish_catch.quality, + ) + fish_catch.ensure_identity() + var attachment: Dictionary = { + "type": PlayerAssetReservationService.AttachmentType.FISH, + "catch_id": String(fish_catch.catch_id), + "catch": fish_catch.to_network_dict(), + } + var service := NetworkMailService.new() + root.add_child(service) + service.set("_fish_catalog", Catalog) + var restored: FishCatch = service.call("_decode_catch", attachment) + assert(restored != null) + assert(restored.quality == FishQualityType.Tier.IMPRESSIVE) + var signature: Array = NetworkMailProtocol.attachment_signature_fields( + attachment + ) + assert(int(signature[5]) == FishQualityType.Tier.IMPRESSIVE) + var altered: Dictionary = attachment.duplicate(true) + (altered["catch"] as Dictionary)["quality"] = ( + FishQualityType.Tier.BORING + ) + assert( + NetworkMailProtocol.attachment_signature_fields(altered) + != signature + ) + service.queue_free() + + +func _validate_collection_mastery() -> void: + var collection := CollectionLogType.new() + root.add_child(collection) + for quality: int in FishQualityType.TIER_COUNT: + collection.mark_quality_discovered(&"bluegill", quality) + assert(collection.has_discovered_quality(&"bluegill", quality)) + assert(collection.has_discovered(&"bluegill")) + assert(collection.has_mastered(&"bluegill")) + assert( + collection.get_quality_mask(&"bluegill") + == FishQualityType.ALL_TIERS_MASK + ) + collection.queue_free() + + +func _validate_version_four_migration() -> void: + var manager := PlayerSaveManager.new() + root.add_child(manager) + var version_four: Dictionary = { + "save_version": 4, + "wallet": {"balance": 12}, + "collection": {"discovered_fish_ids": ["bluegill"]}, + "inventory": { + "next_catch_sequence": 2, + "catches": [{ + "catch_id": "carp:legacy", + "catch_sequence": 1, + "fish_id": "carp", + "weight_lb": 2.0, + "display_scale": 1.0, + "sale_value": 4, + "is_favorited": false, + }], + }, + "bag": {"items": []}, + "hotbar": {"selected_slot": 0, "slots": []}, + "upgrades": {"reel_speed_level": 0, "barrier_power_level": 0}, + "cooler": {"capacity_level": 0}, + } + var migrated: Dictionary = manager.call( + "_migrate_save", + version_four, + 4, + ) + assert(int(migrated.get("save_version", -1)) == 6) + assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0) + assert( + is_equal_approx( + float((migrated["world"] as Dictionary)["time_hours"]), + 8.0, + ) + ) + var catches: Array = migrated["inventory"]["catches"] + assert(int((catches[0] as Dictionary)["quality"]) == 0) + var collection: Dictionary = migrated["collection"] + var ids: Array = collection["discovered_fish_ids"] + assert("bluegill" in ids and "carp" in ids) + var masks: Dictionary = collection["discovered_quality_masks"] + var boring_bit: int = FishQualityType.bit_for(FishQualityType.Tier.BORING) + assert(int(masks["bluegill"]) == boring_bit) + assert(int(masks["carp"]) == boring_bit) + manager.queue_free() diff --git a/tests/fish_quality_validation.gd.uid b/tests/fish_quality_validation.gd.uid new file mode 100644 index 0000000..e129478 --- /dev/null +++ b/tests/fish_quality_validation.gd.uid @@ -0,0 +1 @@ +uid://bffg3t4du01cu diff --git a/tests/fishing_surface_validation.gd b/tests/fishing_surface_validation.gd index 3c0ad87..c178aac 100644 --- a/tests/fishing_surface_validation.gd +++ b/tests/fishing_surface_validation.gd @@ -239,18 +239,29 @@ func _validate_bite_wait_distribution() -> void: var fishing_spot := FishingSpotScene.instantiate() as FishingSpotType root.add_child(fishing_spot) await process_frame + var bite_rng := fishing_spot.get("_bite_rng") as RandomNumberGenerator + bite_rng.seed = 84219 var quick_count: int = 0 var typical_or_long_count: int = 0 + var very_long_count: int = 0 + var total_wait_seconds: float = 0.0 for _sample_index: int in 10000: var wait_seconds: float = fishing_spot.roll_bite_wait_time() assert(wait_seconds >= 10.0) assert(wait_seconds <= 240.0) + total_wait_seconds += wait_seconds if wait_seconds < 30.0: quick_count += 1 else: typical_or_long_count += 1 - assert(quick_count > 0) + if wait_seconds >= 180.0: + very_long_count += 1 + var average_wait_seconds: float = total_wait_seconds / 10000.0 + assert(quick_count >= 2300 and quick_count <= 2700) assert(typical_or_long_count > quick_count) + assert(very_long_count >= 100 and very_long_count <= 300) + assert(average_wait_seconds >= 57.0) + assert(average_wait_seconds <= 61.0) fishing_spot.queue_free() await process_frame diff --git a/tests/logbook_validation.gd b/tests/logbook_validation.gd index c584f0e..4c25e35 100644 --- a/tests/logbook_validation.gd +++ b/tests/logbook_validation.gd @@ -274,6 +274,8 @@ func _validate_handwritten_logbook_font(page: LogbookPage) -> void: var detail_body := page.get("_detail_body") as VBoxContainer for node: Node in detail_body.find_children("*", "Label", true, false): var label := node as Label + if label.text.begins_with("quality collection"): + continue if label.text in [ "fish facts", "catalog number", @@ -307,6 +309,9 @@ func _validate_detail_field_fonts(page: LogbookPage) -> void: ] for node: Node in detail_body.find_children("*", "Label", true, false): var label := node as Label + if label.text.begins_with("quality collection"): + assert(label.get_theme_font("font") == UtilityPageStyle.TuffyFont) + continue if label.text not in field_names: continue assert(label.get_theme_font("font") == UtilityPageStyle.TuffyFont) diff --git a/tests/player_experience_ui_validation.gd b/tests/player_experience_ui_validation.gd new file mode 100644 index 0000000..93c23eb --- /dev/null +++ b/tests/player_experience_ui_validation.gd @@ -0,0 +1,47 @@ +extends SceneTree + +const GameUIScene: PackedScene = preload("res://ui/game_ui.tscn") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var game_ui := GameUIScene.instantiate() as GameUI + root.add_child(game_ui) + await process_frame + game_ui.set("_gameplay_ui_enabled", true) + game_ui.call( + "_on_experience_awarded", + 50, + 0, + 50, + 1, + 1, + ) + var panel := game_ui.get_node("%ExperienceProgressPanel") as PanelContainer + var bubble := game_ui.get_node("%ExperienceBubble") as PanelContainer + var bubble_label := game_ui.get_node("%ExperienceBubbleLabel") as Label + var award_label := game_ui.get_node("%ExperienceAwardLabel") as Label + assert(panel != null and bubble != null) + assert(not panel.visible and not bubble.visible) + game_ui.call("_on_showcase_changed", "bluegill", "common", 1.0, 0, true) + await process_frame + assert(not panel.visible and not bubble.visible) + game_ui.call("_on_showcase_changed", "", "", 0.0, 0, false) + await process_frame + await process_frame + assert(panel.visible and bubble.visible) + assert(award_label.text == "+50 xp") + assert(bubble_label.text == "+50 xp!") + await create_timer(1.6).timeout + var progress := game_ui.get_node("%ExperienceProgress") as ProgressBar + var level_label := game_ui.get_node("%ExperienceLevelLabel") as Label + assert(is_equal_approx(progress.value, 50.0)) + assert(level_label.text == "level 1") + await create_timer(1.2).timeout + assert(not panel.visible and not bubble.visible) + game_ui.queue_free() + print("Player experience UI validation: PASS") + quit() diff --git a/tests/player_experience_ui_validation.gd.uid b/tests/player_experience_ui_validation.gd.uid new file mode 100644 index 0000000..b2f976b --- /dev/null +++ b/tests/player_experience_ui_validation.gd.uid @@ -0,0 +1 @@ +uid://c03tvq1ydhdqj diff --git a/tests/player_experience_validation.gd b/tests/player_experience_validation.gd new file mode 100644 index 0000000..e7b8c18 --- /dev/null +++ b/tests/player_experience_validation.gd @@ -0,0 +1,178 @@ +extends SceneTree + +const FishCatchType = preload("res://fish/fish_catch.gd") +const FishExperienceType = preload("res://fish/fish_experience.gd") +const FishQualityType = preload("res://fish/fish_quality.gd") +const PlayerExperienceType = preload( + "res://progression/player_experience.gd" +) +const Catalog: FishPool = preload("res://fish/pools/fish_catalog.tres") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _validate_level_curve() + _validate_catch_awards() + _validate_player_experience_state() + _validate_save_migration() + print("Player experience validation: PASS") + quit() + + +func _validate_level_curve() -> void: + assert(PlayerExperienceType.experience_required_for_next_level(1) == 100) + assert(PlayerExperienceType.experience_required_for_next_level(2) == 130) + assert(PlayerExperienceType.experience_required_for_next_level(3) == 170) + assert(PlayerExperienceType.total_experience_for_level(1) == 0) + assert(PlayerExperienceType.total_experience_for_level(2) == 100) + assert(PlayerExperienceType.total_experience_for_level(3) == 230) + assert(PlayerExperienceType.total_experience_for_level(4) == 400) + assert(PlayerExperienceType.level_for_total_experience(99) == 1) + assert(PlayerExperienceType.level_for_total_experience(100) == 2) + assert(PlayerExperienceType.level_for_total_experience(399) == 3) + assert(PlayerExperienceType.level_for_total_experience(400) == 4) + assert( + is_equal_approx( + PlayerExperienceType.progress_for_total_experience(50), + 0.5, + ) + ) + + +func _validate_catch_awards() -> void: + var minimum_boring: FishCatch = _make_catch( + FishQualityType.Tier.BORING, + false, + ) + assert( + FishExperienceType.calculate_catch_experience( + minimum_boring, + false, + 0, + ) == 50 + ) + var maximum_shiny: FishCatch = _make_catch( + FishQualityType.Tier.SHINY, + true, + ) + var boring_mask: int = FishQualityType.bit_for( + FishQualityType.Tier.BORING + ) + assert( + FishExperienceType.calculate_catch_experience( + maximum_shiny, + true, + boring_mask, + ) == 46 + ) + var almost_mastered: int = ( + FishQualityType.ALL_TIERS_MASK + & ~FishQualityType.bit_for(FishQualityType.Tier.SHINY) + ) + assert( + FishExperienceType.calculate_catch_experience( + maximum_shiny, + true, + almost_mastered, + ) == 146 + ) + assert( + FishExperienceType.calculate_catch_experience( + maximum_shiny, + true, + FishQualityType.ALL_TIERS_MASK, + ) == 31 + ) + + +func _validate_player_experience_state() -> void: + var experience := PlayerExperienceType.new() + root.add_child(experience) + var awards: Array[Array] = [] + experience.experience_awarded.connect( + func( + amount: int, + previous_total: int, + new_total: int, + previous_level: int, + new_level: int, + ) -> void: + awards.append([ + amount, + previous_total, + new_total, + previous_level, + new_level, + ]) + ) + assert(experience.get_level() == 1) + assert(experience.award_experience(125)) + assert(experience.get_total_experience() == 125) + assert(experience.get_level() == 2) + assert(experience.get_experience_in_level() == 25) + assert(awards.size() == 1) + assert(awards[0] == [125, 0, 125, 1, 2]) + assert(experience.restore_total_experience(400)) + assert(experience.get_level() == 4) + assert(not experience.restore_total_experience(-1)) + experience.queue_free() + + +func _validate_save_migration() -> void: + var manager := PlayerSaveManager.new() + root.add_child(manager) + var version_five: Dictionary = { + "save_version": 5, + "wallet": {"balance": 0}, + "collection": { + "discovered_fish_ids": [], + "discovered_quality_masks": {}, + }, + "inventory": {"next_catch_sequence": 1, "catches": []}, + "bag": {"items": []}, + "hotbar": {"selected_slot": 0, "slots": []}, + "upgrades": {"reel_speed_level": 0, "barrier_power_level": 0}, + "cooler": {"capacity_level": 0}, + "art": {"unlock_mask": 0}, + } + var migrated: Dictionary = manager.call( + "_migrate_save", + version_five, + 5, + ) + assert(int(migrated.get("save_version", -1)) == 6) + var experience_data: Dictionary = migrated.get("experience", {}) + assert(int(experience_data.get("total_experience", -1)) == 0) + var world_data: Dictionary = migrated.get("world", {}) + assert(is_equal_approx(float(world_data.get("time_hours", -1.0)), 8.0)) + manager.queue_free() + + +func _make_catch(quality: int, maximum_weight: bool) -> FishCatch: + var fish: FishData = Catalog.get_fish_by_id(&"bluegill") + assert(fish != null) + var fish_catch := FishCatchType.new() + fish_catch.fish = fish + fish_catch.fish_id = fish.id + fish_catch.catch_id = StringName("bluegill:xp-%d-%s" % [ + quality, + "max" if maximum_weight else "min", + ]) + fish_catch.catch_sequence = 1 + fish_catch.weight_lb = ( + fish.get_maximum_weight() + if maximum_weight + else fish.get_minimum_weight() + ) + fish_catch.display_scale = fish.get_display_scale_for_weight( + fish_catch.weight_lb + ) + fish_catch.quality = quality + fish_catch.sale_value = FishQualityType.apply_sale_value( + fish.get_sale_value_for_weight(fish_catch.weight_lb), + quality, + ) + return fish_catch diff --git a/tests/player_experience_validation.gd.uid b/tests/player_experience_validation.gd.uid new file mode 100644 index 0000000..1afe50c --- /dev/null +++ b/tests/player_experience_validation.gd.uid @@ -0,0 +1 @@ +uid://djkr2iaa5taks diff --git a/tests/world_time_multiplayer_validation.gd b/tests/world_time_multiplayer_validation.gd index 66f7dad..6aa89aa 100644 --- a/tests/world_time_multiplayer_validation.gd +++ b/tests/world_time_multiplayer_validation.gd @@ -32,6 +32,9 @@ func _run_host() -> void: ) assert(session.start_private_host(TEST_PORT)) world_time.synchronize_time(INITIAL_HOST_TIME) + assert(is_equal_approx( + world_time.get_persistent_time_hours(), INITIAL_HOST_TIME + )) world_weather.apply_authoritative_snapshot( WorldWeatherService.Weather.RAINY, 300.0 ) @@ -56,6 +59,9 @@ func _run_host() -> void: await create_timer(1.0).timeout world_time.synchronize_time(UPDATED_HOST_TIME) + assert(is_equal_approx( + world_time.get_persistent_time_hours(), UPDATED_HOST_TIME + )) world_weather.apply_authoritative_snapshot( WorldWeatherService.Weather.FOGGY, 300.0 ) @@ -98,6 +104,7 @@ func _run_client() -> void: assert(session.supports_server_capability( NetworkProtocol.WORLD_WEATHER_CAPABILITY )) + assert(world_time.restore_persistent_time_hours(15.25)) var initial_deadline: int = Time.get_ticks_msec() + 8000 while ( @@ -145,6 +152,7 @@ func _run_client() -> void: world_time.get_time_hours(), UPDATED_HOST_TIME ) <= TIME_TOLERANCE_HOURS) assert(world_time.get_phase() == WorldTimeService.Phase.NIGHT) + assert(is_equal_approx(world_time.get_persistent_time_hours(), 15.25)) assert(clock_label.text == world_time.get_clock_text()) var fog_deadline: int = Time.get_ticks_msec() + 8000 while ( diff --git a/tests/world_time_validation.gd b/tests/world_time_validation.gd index 666831e..1908951 100644 --- a/tests/world_time_validation.gd +++ b/tests/world_time_validation.gd @@ -25,6 +25,7 @@ func _initialize() -> void: func _run() -> void: _validate_clock_boundaries_and_duration() + _validate_persistent_host_clock() _validate_fishing_availability() _validate_fishing_spot_context() _validate_network_snapshot_bounds() @@ -78,6 +79,24 @@ func _validate_clock_boundaries_and_duration() -> void: clock.queue_free() +func _validate_persistent_host_clock() -> void: + var clock := WorldTimeServiceType.new() + root.add_child(clock) + assert(clock.restore_persistent_time_hours(18.75)) + clock.set_persistence_tracking_enabled(true) + clock.begin_session(clock.get_persistent_time_hours()) + assert(is_equal_approx(clock.get_time_hours(), 18.75)) + clock.synchronize_time(19.25) + assert(is_equal_approx(clock.get_persistent_time_hours(), 19.25)) + clock.set_persistence_tracking_enabled(false) + clock.synchronize_time(6.5) + assert(is_equal_approx(clock.get_time_hours(), 6.5)) + assert(is_equal_approx(clock.get_persistent_time_hours(), 19.25)) + assert(not clock.restore_persistent_time_hours(-1.0)) + assert(not clock.restore_persistent_time_hours(24.0)) + clock.queue_free() + + func _validate_fishing_availability() -> void: var day_context := FishingContextType.new() day_context.location_tags = [&"starter_pond"] diff --git a/ui/components/bubble_hotbar/bubble_hotbar_slot.gd b/ui/components/bubble_hotbar/bubble_hotbar_slot.gd index 6b9e9c2..11a7db3 100644 --- a/ui/components/bubble_hotbar/bubble_hotbar_slot.gd +++ b/ui/components/bubble_hotbar/bubble_hotbar_slot.gd @@ -12,6 +12,7 @@ const PlayerBagType = preload("res://inventory/player_bag.gd") const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd") const FishCatchType = preload("res://fish/fish_catch.gd") +const FishQualityType = preload("res://fish/fish_quality.gd") const SELECTED_SCALE: float = 1.12 const HOVER_SCALE: float = 1.025 @@ -179,7 +180,10 @@ func refresh() -> void: _quantity_label.visible = not quantity_text.is_empty() tooltip_text = ( "%s · %.1f lb" % [ - fish_catch.fish.display_name, + FishQualityType.qualified_name( + fish_catch.fish.display_name, + fish_catch.quality, + ), fish_catch.weight_lb, ] if fish_catch != null diff --git a/ui/components/bubble_menu/cooler_fish_sprite.gd b/ui/components/bubble_menu/cooler_fish_sprite.gd index 0e7384f..d491969 100644 --- a/ui/components/bubble_menu/cooler_fish_sprite.gd +++ b/ui/components/bubble_menu/cooler_fish_sprite.gd @@ -15,7 +15,7 @@ var _depth_scale: float = 1.0 var _batch_selected: bool = false var _focused_catch: bool = false var _hovered: bool = false -var _rarity_color := Color.WHITE +var _quality_color := Color.WHITE func _ready() -> void: @@ -40,7 +40,7 @@ func configure( texture: Texture2D, phase: float, depth_scale: float, - rarity_color: Color, + quality_color: Color, ) -> void: catch_id = identity _display_name = display_name @@ -48,7 +48,7 @@ func configure( _fish_shadow.texture = texture _motion_phase = phase _depth_scale = depth_scale - _rarity_color = rarity_color + _quality_color = quality_color _refresh_style() tooltip_text = "%s · drag to a hotbar slot" % _display_name @@ -136,14 +136,16 @@ func _update_visual_pivot() -> void: func _refresh_style() -> void: var idle := StyleBoxEmpty.new() - var normal := _make_rarity_style(_rarity_color, 2, 0.25) - var hover := _make_rarity_style(_rarity_color.lightened(0.08), 4, 0.34) - var selected_fill: Color = ( - _rarity_color.lightened(0.12) - if _batch_selected - else _rarity_color + var normal := _make_quality_style(_quality_color, 2, 0.25) + var hover := _make_quality_style( + _quality_color.lightened(0.08), 4, 0.34 ) - var selected := _make_rarity_style( + var selected_fill: Color = ( + _quality_color.lightened(0.12) + if _batch_selected + else _quality_color + ) + var selected := _make_quality_style( selected_fill, 6 if _batch_selected else 4, 0.46 if _batch_selected else 0.34, @@ -169,7 +171,7 @@ func _refresh_style() -> void: add_theme_stylebox_override("pressed", selected) -func _make_rarity_style( +func _make_quality_style( fill: Color, shadow_size: int, shadow_alpha: float, diff --git a/ui/game_ui.gd b/ui/game_ui.gd index f7e2d09..0bcaa53 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -5,6 +5,7 @@ const CollectionLogType = preload("res://collection/collection_log.gd") const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd") const FishSaleServiceType = preload("res://economy/fish_sale_service.gd") const FishPoolType = preload("res://fish/fish_pool.gd") +const FishQualityType = preload("res://fish/fish_quality.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd") const FishingSpotType = preload("res://fishing/fishing_spot.gd") const PlayerMenuType = preload("res://ui/player_menu.gd") @@ -43,6 +44,12 @@ const WorldTimeServiceType = preload("res://world/world_time_service.gd") const WorldWeatherServiceType = preload( "res://world/world_weather_service.gd" ) +const PlayerExperienceType = preload( + "res://progression/player_experience.gd" +) +const UIReferencePresentationType = preload( + "res://ui/ui_reference_presentation.gd" +) signal pixelation_settings_visibility_changed(is_visible: bool) signal crisp_reset_focus_requested @@ -62,6 +69,13 @@ signal shop_backdrop_visibility_changed(is_visible: bool) @onready var _barrier_health: Label = %BarrierHealth @onready var _showcase_details: Label = %ShowcaseDetails @onready var _fishing_panel: PanelContainer = %FishingPanel +@onready var _experience_panel: PanelContainer = %ExperienceProgressPanel +@onready var _experience_level_label: Label = %ExperienceLevelLabel +@onready var _experience_award_label: Label = %ExperienceAwardLabel +@onready var _experience_progress: ProgressBar = %ExperienceProgress +@onready var _experience_bubble: PanelContainer = %ExperienceBubble +@onready var _experience_bubble_label: Label = %ExperienceBubbleLabel +@onready var _canonical_stage: Control = %CanonicalStage @onready var _player_menu: PlayerMenuType = %PlayerMenu @onready var _screen_fade: ScreenFade = %ScreenFade @onready var _title_screen: TitleScreenType = %TitleScreen @@ -95,6 +109,11 @@ var _item_effects: PlayerItemEffectsType var _main_shop_buyer: FishBuyerProfileType var _shop_interaction: ShopInteractionType var _surface_drawing: NetworkSurfaceDrawingService +var _experience: PlayerExperienceType +var _experience_award_queue: Array[Dictionary] = [] +var _experience_animation_active: bool = false +var _experience_animation_generation: int = 0 +var _experience_panel_rest_y: float = 18.0 func _ready() -> void: @@ -123,6 +142,7 @@ func setup( player: PlayerType, inventory: FishInventoryType, collection_log: CollectionLogType, + experience: PlayerExperienceType, wallet: PlayerWalletType, sale_service: FishSaleServiceType, default_buyer: FishBuyerProfileType, @@ -155,6 +175,14 @@ func setup( _player = player _fishing_spot = fishing_spot _item_effects = item_effects + _experience = experience + if ( + _experience != null + and not _experience.experience_awarded.is_connected( + _on_experience_awarded + ) + ): + _experience.experience_awarded.connect(_on_experience_awarded) _chat_ui.setup( network_chat_service, network_session, spawn_service, player, fishing_spot, settings_manager, world_time, world_weather @@ -303,6 +331,7 @@ func setup_data_and_identity( func _process(_delta: float) -> void: + _update_experience_bubble_position() if _item_effects == null or not _gameplay_ui_enabled: _effect_status.hide() return @@ -371,6 +400,7 @@ func set_gameplay_ui_enabled(enabled: bool) -> void: _refresh_hotbar_visibility() _hotbar_ui.set_gameplay_input_enabled(true) _refresh_fishing_panel_visibility() + call_deferred("_start_next_experience_animation") func set_system_menu_open(is_open: bool) -> void: @@ -569,6 +599,7 @@ func _on_showcase_changed( fish_name: String, rarity_name: String, weight_lb: float, + quality: int, visible: bool, ) -> void: _showcase_active = visible @@ -576,6 +607,7 @@ func _on_showcase_changed( _showcase_details.text = "" _showcase_details.visible = false _set_fishing_status("") + call_deferred("_start_next_experience_animation") return _catch_track.visible = false _barrier_prompt_panel.visible = false @@ -583,15 +615,208 @@ func _on_showcase_changed( _barrier_health.visible = false _clear_barrier_markers() _showcase_details.text = ( - "%.1f lb • %s" - % [weight_lb, rarity_name] + "%s • %.1f lb" + % [rarity_name.to_lower(), weight_lb] ) _showcase_details.visible = true - _status_label.text = "You caught a %s!" % fish_name + _status_label.text = "You caught %s!" % ( + FishQualityType.qualified_name_with_article(fish_name, quality) + ) _status_label.visible = true _refresh_fishing_panel_visibility() +func _on_experience_awarded( + amount: int, + previous_total: int, + new_total: int, + previous_level: int, + new_level: int, +) -> void: + if amount <= 0: + return + _experience_award_queue.append({ + "amount": amount, + "previous_total": previous_total, + "new_total": new_total, + "previous_level": previous_level, + "new_level": new_level, + }) + + +func _start_next_experience_animation() -> void: + if ( + _experience_animation_active + or _showcase_active + or _experience_award_queue.is_empty() + or not _gameplay_ui_enabled + ): + return + var award: Dictionary = _experience_award_queue.pop_front() + _experience_animation_active = true + _experience_animation_generation += 1 + var generation: int = _experience_animation_generation + _play_experience_animation(award, generation) + + +func _play_experience_animation( + award: Dictionary, + generation: int, +) -> void: + var amount: int = int(award.get("amount", 0)) + var previous_total: int = int(award.get("previous_total", 0)) + var new_total: int = int(award.get("new_total", previous_total)) + _experience_award_label.text = "+%d xp" % amount + _experience_bubble_label.text = "+%d xp!" % amount + _update_experience_progress(previous_total) + _experience_panel.position.y = -_experience_panel.size.y - 8.0 + _experience_panel.modulate.a = 0.0 + _experience_panel.show() + _experience_bubble.modulate.a = 0.0 + _experience_bubble.scale = Vector2(0.72, 0.72) + _experience_bubble.pivot_offset = _experience_bubble.size * 0.5 + _experience_bubble.show() + + var entry_tween: Tween = create_tween() + entry_tween.set_parallel(true) + entry_tween.tween_property( + _experience_panel, + "position:y", + _experience_panel_rest_y, + 0.26, + ).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + entry_tween.tween_property( + _experience_panel, + "modulate:a", + 1.0, + 0.18, + ) + entry_tween.tween_property( + _experience_bubble, + "modulate:a", + 1.0, + 0.15, + ) + entry_tween.tween_property( + _experience_bubble, + "scale", + Vector2.ONE, + 0.28, + ).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + await entry_tween.finished + if generation != _experience_animation_generation: + return + + var fill_duration: float = clampf( + 0.8 + float(amount) * 0.006, + 0.9, + 1.65, + ) + var fill_tween: Tween = create_tween() + fill_tween.tween_method( + Callable(self, "_set_experience_animation_progress").bind( + previous_total, + new_total, + ), + 0.0, + 1.0, + fill_duration, + ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + await fill_tween.finished + if generation != _experience_animation_generation: + return + await get_tree().create_timer(0.75).timeout + if generation != _experience_animation_generation: + return + + var exit_tween: Tween = create_tween() + exit_tween.set_parallel(true) + exit_tween.tween_property( + _experience_panel, + "modulate:a", + 0.0, + 0.24, + ) + exit_tween.tween_property( + _experience_bubble, + "modulate:a", + 0.0, + 0.2, + ) + exit_tween.tween_property( + _experience_bubble, + "scale", + Vector2(0.82, 0.82), + 0.24, + ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) + await exit_tween.finished + if generation != _experience_animation_generation: + return + _experience_panel.hide() + _experience_bubble.hide() + _experience_animation_active = false + call_deferred("_start_next_experience_animation") + + +func _set_experience_animation_progress( + progress: float, + previous_total: int, + new_total: int, +) -> void: + var displayed_total: int = roundi(lerpf( + float(previous_total), + float(new_total), + clampf(progress, 0.0, 1.0), + )) + _update_experience_progress(displayed_total) + + +func _update_experience_progress(total_experience: int) -> void: + var level: int = PlayerExperienceType.level_for_total_experience( + total_experience + ) + _experience_level_label.text = "level %d" % level + _experience_progress.value = ( + PlayerExperienceType.progress_for_total_experience(total_experience) + * 100.0 + ) + + +func _update_experience_bubble_position() -> void: + if not _experience_animation_active or _player == null: + return + var camera: Camera3D = _player.get_gameplay_camera() + var anchor_position: Vector3 = _player.get_chat_anchor_position() + if camera == null or camera.is_position_behind(anchor_position): + _experience_bubble.hide() + return + _experience_bubble.show() + var window_size := Vector2(get_window().size) + var output_scale: float = UIReferencePresentationType.get_scale( + window_size + ) + var stage_position: Vector2 = ( + camera.unproject_position(anchor_position) / output_scale + - _canonical_stage.position + ) + var desired: Vector2 = stage_position - Vector2( + _experience_bubble.size.x * 0.5, + _experience_bubble.size.y + 10.0, + ) + _experience_bubble.position = Vector2( + clampf( + desired.x, + 8.0, + _canonical_stage.size.x - _experience_bubble.size.x - 8.0, + ), + clampf( + desired.y, + 8.0, + _canonical_stage.size.y - _experience_bubble.size.y - 8.0, + ), + ) + + func _on_player_menu_visibility_changed(is_open: bool) -> void: _player_menu_open = is_open if is_open and _surface_drawing != null: diff --git a/ui/game_ui.tscn b/ui/game_ui.tscn index 4289771..3d59a55 100644 --- a/ui/game_ui.tscn +++ b/ui/game_ui.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=16 format=3] +[gd_scene load_steps=19 format=3] [ext_resource type="Script" path="res://ui/game_ui.gd" id="1_ui"] [ext_resource type="PackedScene" path="res://ui/player_menu.tscn" id="2_menu"] @@ -35,6 +35,27 @@ corner_radius_bottom_left = 12 [sub_resource type="StyleBoxFlat" id="StyleBox_transparent"] bg_color = Color(0, 0, 0, 0) +[sub_resource type="StyleBoxFlat" id="StyleBox_experience_panel"] +bg_color = Color(0.051, 0.173, 0.227, 0.97) +corner_radius_top_left = 14 +corner_radius_top_right = 14 +corner_radius_bottom_right = 14 +corner_radius_bottom_left = 14 + +[sub_resource type="StyleBoxFlat" id="StyleBox_experience_background"] +bg_color = Color(0.025, 0.102, 0.137, 1) +corner_radius_top_left = 7 +corner_radius_top_right = 7 +corner_radius_bottom_right = 7 +corner_radius_bottom_left = 7 + +[sub_resource type="StyleBoxFlat" id="StyleBox_experience_fill"] +bg_color = Color(1, 0.82, 0.4, 1) +corner_radius_top_left = 7 +corner_radius_top_right = 7 +corner_radius_bottom_right = 7 +corner_radius_bottom_left = 7 + [node name="GameUI" type="CanvasLayer"] script = ExtResource("1_ui") @@ -59,6 +80,91 @@ grow_horizontal = 2 grow_vertical = 2 mouse_filter = 2 +[node name="ExperienceProgressPanel" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"] +unique_name_in_owner = true +visible = false +z_index = 63 +anchors_preset = 10 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -240.0 +offset_top = 18.0 +offset_right = 240.0 +offset_bottom = 82.0 +grow_horizontal = 2 +mouse_filter = 2 +theme = ExtResource("3_theme") +theme_override_styles/panel = SubResource("StyleBox_experience_panel") + +[node name="Margin" type="MarginContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 14 +theme_override_constants/margin_top = 7 +theme_override_constants/margin_right = 14 +theme_override_constants/margin_bottom = 9 + +[node name="Layout" type="VBoxContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 4 + +[node name="Header" type="HBoxContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin/Layout"] +layout_mode = 2 + +[node name="ExperienceLevelLabel" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin/Layout/Header"] +unique_name_in_owner = true +layout_mode = 2 +text = "level 1" +theme_override_colors/font_color = Color(0.95, 0.98, 1, 1) +theme_override_font_sizes/font_size = 16 + +[node name="Spacer" type="Control" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin/Layout/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +mouse_filter = 2 + +[node name="ExperienceAwardLabel" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin/Layout/Header"] +unique_name_in_owner = true +layout_mode = 2 +text = "+0 xp" +theme_override_colors/font_color = Color(1, 0.82, 0.4, 1) +theme_override_font_sizes/font_size = 16 + +[node name="ExperienceProgress" type="ProgressBar" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceProgressPanel/Margin/Layout"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 20) +layout_mode = 2 +mouse_filter = 2 +theme_override_styles/background = SubResource("StyleBox_experience_background") +theme_override_styles/fill = SubResource("StyleBox_experience_fill") +value = 0.0 +show_percentage = false + +[node name="ExperienceBubble" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"] +unique_name_in_owner = true +visible = false +z_index = 63 +offset_right = 122.0 +offset_bottom = 46.0 +mouse_filter = 2 +theme = ExtResource("3_theme") +theme_override_styles/panel = SubResource("StyleBox_experience_panel") + +[node name="Margin" type="MarginContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceBubble"] +layout_mode = 2 +theme_override_constants/margin_left = 10 +theme_override_constants/margin_top = 5 +theme_override_constants/margin_right = 10 +theme_override_constants/margin_bottom = 5 + +[node name="ExperienceBubbleLabel" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/ExperienceBubble/Margin"] +unique_name_in_owner = true +layout_mode = 2 +text = "+0 xp!" +horizontal_alignment = 1 +vertical_alignment = 1 +theme_override_colors/font_color = Color(1, 0.82, 0.4, 1) +theme_override_font_sizes/font_size = 22 + [node name="FishingPanel" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"] unique_name_in_owner = true visible = false diff --git a/ui/hotbar.gd b/ui/hotbar.gd index a1bbf6e..6466c34 100644 --- a/ui/hotbar.gd +++ b/ui/hotbar.gd @@ -8,6 +8,7 @@ const PlayerBagType = preload("res://inventory/player_bag.gd") const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd") const FishCatchType = preload("res://fish/fish_catch.gd") +const FishQualityType = preload("res://fish/fish_quality.gd") const BubbleHotbarSlotType = preload( "res://ui/components/bubble_hotbar/bubble_hotbar_slot.gd" ) @@ -321,7 +322,10 @@ func _show_assignment_name(slot_index: int, identity: StringName) -> void: if not catch_id.is_empty() and _fish_inventory != null: var fish_catch: FishCatchType = _fish_inventory.get_catch_by_id(catch_id) if fish_catch != null: - _selected_item_label.text = fish_catch.fish.display_name + _selected_item_label.text = FishQualityType.qualified_name( + fish_catch.fish.display_name, + fish_catch.quality, + ) _selected_item_label.visible = true return var item = ( diff --git a/ui/logbook_page.gd b/ui/logbook_page.gd index aa56cbf..115b791 100644 --- a/ui/logbook_page.gd +++ b/ui/logbook_page.gd @@ -3,6 +3,7 @@ extends Control const CollectionLogType = preload("res://collection/collection_log.gd") const FishDataType = preload("res://fish/fish_data.gd") +const FishQualityType = preload("res://fish/fish_quality.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd") const FishPoolType = preload("res://fish/fish_pool.gd") const SILHOUETTE_SHADER: Shader = preload( @@ -559,6 +560,7 @@ func _build_known_details(fish: FishDataType) -> void: facts.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART facts.size_flags_horizontal = Control.SIZE_EXPAND_FILL facts_column.add_child(facts) + _detail_body.add_child(_build_quality_progress(fish.id)) var stats_columns := HBoxContainer.new() stats_columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -594,8 +596,14 @@ func _build_known_details(fish: FishDataType) -> void: right_stats, "value range", "%d–%d fish coin" % [ - fish.sell_value_min, - fish.sell_value_max, + FishQualityType.apply_sale_value( + fish.sell_value_min, + FishQualityType.Tier.BORING, + ), + FishQualityType.apply_sale_value( + fish.sell_value_max, + FishQualityType.Tier.SHINY, + ), ], ) _add_detail_row( @@ -605,6 +613,56 @@ func _build_known_details(fish: FishDataType) -> void: ) +func _build_quality_progress(fish_id: StringName) -> VBoxContainer: + var quality_section := VBoxContainer.new() + quality_section.add_theme_constant_override("separation", 3) + var discovered_count: int = 0 + for quality: int in FishQualityType.TIER_COUNT: + if _collection_log.has_discovered_quality(fish_id, quality): + discovered_count += 1 + var heading := _field_label( + "quality collection • %d / %d" % [ + discovered_count, + FishQualityType.TIER_COUNT, + ], + 14, + ) + heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + heading.add_theme_color_override("font_color", MUTED_INK) + quality_section.add_child(heading) + var tiers := HBoxContainer.new() + tiers.alignment = BoxContainer.ALIGNMENT_CENTER + tiers.add_theme_constant_override("separation", 10) + quality_section.add_child(tiers) + for quality: int in FishQualityType.TIER_COUNT: + var discovered: bool = _collection_log.has_discovered_quality( + fish_id, + quality, + ) + var tier := HBoxContainer.new() + tier.alignment = BoxContainer.ALIGNMENT_CENTER + tier.add_theme_constant_override("separation", 3) + var quality_color: Color = UIPalette.get_quality_color(quality) + var dot := _label("●" if discovered else "○", 13) + dot.add_theme_color_override("font_color", quality_color) + dot.modulate.a = 1.0 if discovered else 0.48 + tier.add_child(dot) + var tier_label := _label(FishQualityType.display_name(quality), 13) + tier_label.add_theme_color_override( + "font_color", + INK if discovered else MUTED_INK, + ) + tier_label.modulate.a = 1.0 if discovered else 0.58 + tier.tooltip_text = ( + "%s quality collected" + if discovered + else "%s quality not yet collected" + ) % FishQualityType.display_name(quality) + tier.add_child(tier_label) + tiers.add_child(tier) + return quality_section + + func _show_no_selection() -> void: _clear_details() var instruction := _label("Select an entry to read its catch record.", 21) diff --git a/ui/mail_page.gd b/ui/mail_page.gd index 1f5e607..746835a 100644 --- a/ui/mail_page.gd +++ b/ui/mail_page.gd @@ -16,6 +16,7 @@ const SALUTATION_LABELS := { "salutations": "Salutations", "good_luck_have_fun": "Good Luck Have Fun", } +const FishQualityType = preload("res://fish/fish_quality.gd") var _service: NetworkMailService var _reservations: PlayerAssetReservationService @@ -507,7 +508,11 @@ func _refresh_attachment_choices(_index: int) -> void: ): _attachment_choice.add_item( "%s — %.1f lb" % [ - fish_catch.fish.display_name, fish_catch.weight_lb, + FishQualityType.qualified_name( + fish_catch.fish.display_name, + fish_catch.quality, + ), + fish_catch.weight_lb, ] ) _attachment_choice.set_item_metadata( @@ -719,11 +724,18 @@ func _attachment_text(attachment: Dictionary) -> String: ) if fish_catch != null: return "%s — %.1f lb" % [ - fish_catch.fish.display_name, fish_catch.weight_lb, + FishQualityType.qualified_name( + fish_catch.fish.display_name, + fish_catch.quality, + ), + fish_catch.weight_lb, ] var data: Dictionary = attachment.get("catch", {}) return "%s — %.1f lb" % [ - str(data.get("fish_id", "fish")).capitalize(), + FishQualityType.qualified_name( + str(data.get("fish_id", "fish")).capitalize(), + int(data.get("quality", FishQualityType.Tier.BORING)), + ), float(data.get("weight_lb", 0.0)), ] PlayerAssetReservationService.AttachmentType.CONSUMABLE: diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 54cd109..dd13792 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -5,6 +5,7 @@ const INPUT_OWNER: StringName = &"player_menu" const CollectionLogType = preload("res://collection/collection_log.gd") const FishCatchType = preload("res://fish/fish_catch.gd") const FishDataType = preload("res://fish/fish_data.gd") +const FishQualityType = preload("res://fish/fish_quality.gd") const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd") const FishSaleResultType = preload("res://economy/fish_sale_result.gd") const FishSaleServiceType = preload("res://economy/fish_sale_service.gd") @@ -74,11 +75,6 @@ const NAVIGATION_PRESENTATION_SCALE: float = 0.60 const NAVIGATION_CANONICAL_POSITION := Vector2(424.0, 44.0) const NAVIGATION_SELECTED_SCALE: float = 1.02 const INVENTORY_TAB_LEFT_INSET: float = 96.0 -const COOLER_RARITY_COMMON := Color("e8eef0") -const COOLER_RARITY_UNCOMMON := Color("64c87c") -const COOLER_RARITY_RARE := Color("6098dd") -const COOLER_RARITY_EPIC := Color("a979cf") -const COOLER_RARITY_LEGENDARY := Color("db78a7") const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop" signal menu_visibility_changed(is_open: bool) @@ -2609,11 +2605,14 @@ func _sync_cooler_fish_nodes(catches: Array[FishCatchType]) -> void: var identity_hash: int = absi(String(fish_catch.catch_id).hash()) fish_node.configure( fish_catch.catch_id, - fish_catch.fish.display_name, + FishQualityType.qualified_name( + fish_catch.fish.display_name, + fish_catch.quality, + ), fish_catch.fish.display_texture, float(identity_hash % 628) / 100.0, 0.96 + float(identity_hash % 9) * 0.01, - _get_cooler_rarity_color(fish_catch.fish.rarity), + UIPalette.get_quality_color(fish_catch.quality), ) fish_node.set_item_state( _fish_selection.is_selected(fish_catch.catch_id), @@ -2631,20 +2630,6 @@ func _sync_cooler_fish_nodes(catches: Array[FishCatchType]) -> void: _configure_cooler_fish_focus() -func _get_cooler_rarity_color(rarity: int) -> Color: - match rarity: - FishDataType.Rarity.UNCOMMON: - return COOLER_RARITY_UNCOMMON - FishDataType.Rarity.RARE: - return COOLER_RARITY_RARE - FishDataType.Rarity.EPIC: - return COOLER_RARITY_EPIC - FishDataType.Rarity.LEGENDARY: - return COOLER_RARITY_LEGENDARY - _: - return COOLER_RARITY_COMMON - - func _layout_cooler_fish(animate: bool = true) -> void: if not is_node_ready(): return @@ -2926,7 +2911,10 @@ func _update_inventory_detail(fish_catch: FishCatchType) -> void: and _reservations.is_fish_reserved(fish_catch.catch_id) ): _cooler_detail_name.text += " • reserved in mail" - _cooler_weight_unit.text = "lb • %s" % fish_catch.fish.get_rarity_name() + _cooler_weight_unit.text = "lb • %s • %s" % [ + FishQualityType.display_name(fish_catch.quality), + fish_catch.fish.get_rarity_name(), + ] if buyer_offer >= 0 and active_buyer != null: _cooler_offer_label.text = _get_offer_label(active_buyer) _cooler_offer_value.text = "$%d" % buyer_offer diff --git a/ui/ui_palette.gd b/ui/ui_palette.gd index e2e7604..dec14ce 100644 --- a/ui/ui_palette.gd +++ b/ui/ui_palette.gd @@ -18,6 +18,12 @@ const RARITY_RARE := Color("5596f6") const RARITY_EPIC := Color("b176e8") const RARITY_LEGENDARY := Color("f276b3") +const QUALITY_BORING := Color("e8eef0") +const QUALITY_AVERAGE := Color("64c87c") +const QUALITY_IMPRESSIVE := Color("6098dd") +const QUALITY_EXCEPTIONAL := Color("a979cf") +const QUALITY_SHINY := Color("db78a7") + static func get_rarity_color(rarity: int) -> Color: match rarity: @@ -31,3 +37,17 @@ static func get_rarity_color(rarity: int) -> Color: return RARITY_LEGENDARY _: return RARITY_COMMON + + +static func get_quality_color(quality: int) -> Color: + match quality: + 1: + return QUALITY_AVERAGE + 2: + return QUALITY_IMPRESSIVE + 3: + return QUALITY_EXCEPTIONAL + 4: + return QUALITY_SHINY + _: + return QUALITY_BORING diff --git a/world/world_time_service.gd b/world/world_time_service.gd index aef3054..378fcd1 100644 --- a/world/world_time_service.gd +++ b/world/world_time_service.gd @@ -24,6 +24,8 @@ const DUSK_END_HOUR: float = NIGHT_START_HOUR + TRANSITION_HALF_HOURS const DEFAULT_START_HOUR: float = DAY_START_HOUR var _time_hours: float = DEFAULT_START_HOUR +var _persistent_time_hours: float = DEFAULT_START_HOUR +var _persistence_tracking_enabled: bool = false var _running: bool = false var _phase: Phase = Phase.DAWN @@ -63,6 +65,31 @@ func synchronize_time(authoritative_time_hours: float) -> void: _set_time_hours(authoritative_time_hours, true) +func set_persistence_tracking_enabled(enabled: bool) -> void: + if _persistence_tracking_enabled == enabled: + return + if _persistence_tracking_enabled: + _persistent_time_hours = _time_hours + _persistence_tracking_enabled = enabled + + +func restore_persistent_time_hours(time_hours: float) -> bool: + if ( + not is_finite(time_hours) + or time_hours < 0.0 + or time_hours >= HOURS_PER_DAY + ): + return false + _persistent_time_hours = time_hours + if _persistence_tracking_enabled: + _set_time_hours(_persistent_time_hours, true) + return true + + +func get_persistent_time_hours() -> float: + return _persistent_time_hours + + func get_time_hours() -> float: return _time_hours @@ -116,6 +143,8 @@ func _set_time_hours(time_hours: float, force_emit: bool) -> void: var time_was_changed: bool = not is_equal_approx(normalized, _time_hours) _time_hours = normalized _phase = next_phase + if _persistence_tracking_enabled: + _persistent_time_hours = normalized if phase_was_changed: phase_changed.emit(_phase) if force_emit or time_was_changed or phase_was_changed: