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_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..1b4e577 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -56,6 +56,7 @@ signal showcase_changed( fish_name: String, rarity_name: String, weight_lb: float, + quality: int, visible: bool, ) signal bite_activated @@ -382,11 +383,14 @@ func _secure_showcase_catch_for_recovery() -> void: and _local_collection_log != null ): _local_inventory.add_catch(_pending_catch) - _local_collection_log.mark_discovered(_pending_catch.fish_id) + _local_collection_log.mark_quality_discovered( + _pending_catch.fish_id, + _pending_catch.quality, + ) _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() @@ -403,7 +407,10 @@ func _exit_tree() -> void: and is_instance_valid(_local_collection_log) ): _local_inventory.add_catch(_pending_catch) - _local_collection_log.mark_discovered(_pending_catch.fish_id) + _local_collection_log.mark_quality_discovered( + _pending_catch.fish_id, + _pending_catch.quality, + ) _pending_catch = null if _active_player != null and is_instance_valid(_active_player): _active_player.end_catch_showcase(Callable(), true) @@ -1031,6 +1038,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 ) @@ -1054,12 +1062,15 @@ func _put_away_catch() -> void: ): return _local_inventory.add_catch(_pending_catch) - _local_collection_log.mark_discovered(_pending_catch.fish_id) + _local_collection_log.mark_quality_discovered( + _pending_catch.fish_id, + _pending_catch.quality, + ) _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( @@ -1095,7 +1106,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 +1149,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/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..0df4355 100644 --- a/network/network_fishing_service.gd +++ b/network/network_fishing_service.gd @@ -761,7 +761,10 @@ func _apply_target_outcome(data: Dictionary) -> void: return if not already_owned: _local_inventory.add_catch(fish_catch) - _local_collection.mark_discovered(fish_id) + _local_collection.mark_quality_discovered( + fish_id, + fish_catch.quality, + ) 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/save/player_save_manager.gd b/save/player_save_manager.gd index e24cd33..e8486c6 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") @@ -21,7 +22,7 @@ const PlayerArtUnlocksType = preload( "res://progression/player_art_unlocks.gd" ) -const SAVE_VERSION: int = 4 +const SAVE_VERSION: int = 5 const BASIC_ROD_ID: StringName = &"basic_fishing_rod" const MAX_SAFE_BALANCE: int = 1000000000000 @@ -30,6 +31,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] = [] @@ -124,10 +126,10 @@ func setup( 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): @@ -208,7 +210,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 @@ -401,6 +406,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 +448,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(), @@ -481,6 +500,8 @@ 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 @@ -514,6 +535,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 +742,8 @@ 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) _: return {} if migrated.is_empty(): @@ -748,6 +791,58 @@ 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 _mark_dirty() -> void: if ( _is_restoring @@ -760,7 +855,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() @@ -833,6 +928,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,7 +939,10 @@ 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) diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index 81f648d..86cfe03 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -45,11 +45,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 +100,27 @@ 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"]) == 5) + 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(save_manager.load_player_data()) 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 +139,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 +148,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..8cd1564 --- /dev/null +++ b/tests/fish_quality_validation.gd @@ -0,0 +1,255 @@ +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)) == 5) + 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/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/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..a43d021 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") @@ -569,6 +570,7 @@ func _on_showcase_changed( fish_name: String, rarity_name: String, weight_lb: float, + quality: int, visible: bool, ) -> void: _showcase_active = visible @@ -583,11 +585,13 @@ 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() 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