Add collectible fish quality tiers

This commit is contained in:
Alexander Sellite 2026-08-02 17:44:11 -04:00
parent 7661b057bc
commit 35d00e8035
30 changed files with 834 additions and 73 deletions

View file

@ -2,9 +2,13 @@ class_name CollectionLog
extends Node extends Node
signal fish_discovered(fish_id: StringName) signal fish_discovered(fish_id: StringName)
signal fish_quality_discovered(fish_id: StringName, quality: int)
signal collection_changed signal collection_changed
const FishQualityType = preload("res://fish/fish_quality.gd")
var _discovered: Dictionary[StringName, bool] = {} var _discovered: Dictionary[StringName, bool] = {}
var _quality_masks: Dictionary[StringName, int] = {}
func has_discovered(fish_id: StringName) -> bool: func has_discovered(fish_id: StringName) -> bool:
@ -19,6 +23,24 @@ func mark_discovered(fish_id: StringName) -> void:
collection_changed.emit() 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]: func get_discovered_ids() -> Array[StringName]:
var discovered_ids: Array[StringName] = [] var discovered_ids: Array[StringName] = []
for fish_id: StringName in _discovered: for fish_id: StringName in _discovered:
@ -27,12 +49,53 @@ func get_discovered_ids() -> Array[StringName]:
return discovered_ids 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: 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] = {} var replacement: Dictionary[StringName, bool] = {}
for fish_id: StringName in fish_ids: for fish_id: StringName in fish_ids:
if fish_id.is_empty(): if fish_id.is_empty():
return false return false
replacement[fish_id] = true 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 _discovered = replacement
_quality_masks = replacement_masks
collection_changed.emit() collection_changed.emit()
return true return true

View file

@ -1,6 +1,8 @@
class_name FishBuyerProfile class_name FishBuyerProfile
extends Resource extends Resource
const FishQualityType = preload("res://fish/fish_quality.gd")
@export var id: StringName @export var id: StringName
@export var display_name: String @export var display_name: String
@export var animal_name_singular: String @export var animal_name_singular: String
@ -25,6 +27,18 @@ func get_offer(base_value: int) -> int:
return maxi(offer, 0) 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( func get_sale_message(
fish_name: String, fish_name: String,
payout: int, payout: int,

View file

@ -128,7 +128,15 @@ func _validate_batch(
if fish_catch.sale_value < 0: if fish_catch.sale_value < 0:
result.status = FishSaleResultType.Status.INVALID_VALUE result.status = FishSaleResultType.Status.INVALID_VALUE
return result 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: if offer < 0:
result.status = FishSaleResultType.Status.INVALID_OFFER result.status = FishSaleResultType.Status.INVALID_OFFER
return result return result

View file

@ -2,6 +2,7 @@ class_name FishCatch
extends Resource extends Resource
const FishDataType = preload("res://fish/fish_data.gd") 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_WEIGHT_LB: float = 1000000.0
const MAX_SAFE_DISPLAY_SCALE: float = 10000.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 catch_sequence: int = 0
@export var weight_lb: float = 0.0 @export var weight_lb: float = 0.0
@export var display_scale: float = 1.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 sale_value: int = 0
@export var is_favorited: bool = false @export var is_favorited: bool = false
@ -48,6 +50,7 @@ func is_valid() -> bool:
and is_finite(display_scale) and is_finite(display_scale)
and display_scale > 0.0 and display_scale > 0.0
and display_scale <= MAX_SAFE_DISPLAY_SCALE and display_scale <= MAX_SAFE_DISPLAY_SCALE
and FishQualityType.is_valid(quality)
and sale_value >= 0 and sale_value >= 0
and sale_value <= MAX_SAFE_SALE_VALUE and sale_value <= MAX_SAFE_SALE_VALUE
) )
@ -60,6 +63,7 @@ func to_save_dict() -> Dictionary:
"fish_id": String(fish_id), "fish_id": String(fish_id),
"weight_lb": weight_lb, "weight_lb": weight_lb,
"display_scale": display_scale, "display_scale": display_scale,
"quality": quality,
"sale_value": sale_value, "sale_value": sale_value,
"is_favorited": is_favorited, "is_favorited": is_favorited,
} }
@ -71,6 +75,7 @@ func to_network_dict() -> Dictionary:
"fish_id": String(fish_id), "fish_id": String(fish_id),
"weight_lb": weight_lb, "weight_lb": weight_lb,
"display_scale": display_scale, "display_scale": display_scale,
"quality": quality,
"sale_value": sale_value, "sale_value": sale_value,
"is_favorited": is_favorited, "is_favorited": is_favorited,
} }
@ -88,6 +93,7 @@ static func from_save_dict(
"fish_id", "fish_id",
"weight_lb", "weight_lb",
"display_scale", "display_scale",
"quality",
"sale_value", "sale_value",
"is_favorited", "is_favorited",
]: ]:
@ -111,6 +117,11 @@ static func from_save_dict(
-1, -1,
MAX_SAFE_SALE_VALUE 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( var loaded_weight: float = _read_safe_float(
data["weight_lb"], data["weight_lb"],
0.0, 0.0,
@ -128,6 +139,7 @@ static func from_save_dict(
or loaded_sequence <= 0 or loaded_sequence <= 0
or loaded_sequence >= MAX_SAFE_SEQUENCE or loaded_sequence >= MAX_SAFE_SEQUENCE
or loaded_sale_value < 0 or loaded_sale_value < 0
or not FishQualityType.is_valid(loaded_quality)
or loaded_weight <= 0.0 or loaded_weight <= 0.0
or loaded_scale <= 0.0 or loaded_scale <= 0.0
or typeof(loaded_favorite) != TYPE_BOOL or typeof(loaded_favorite) != TYPE_BOOL
@ -141,6 +153,7 @@ static func from_save_dict(
fish_catch.catch_sequence = loaded_sequence fish_catch.catch_sequence = loaded_sequence
fish_catch.weight_lb = loaded_weight fish_catch.weight_lb = loaded_weight
fish_catch.display_scale = loaded_scale fish_catch.display_scale = loaded_scale
fish_catch.quality = loaded_quality
fish_catch.sale_value = loaded_sale_value fish_catch.sale_value = loaded_sale_value
fish_catch.is_favorited = bool(loaded_favorite) fish_catch.is_favorited = bool(loaded_favorite)
return fish_catch if fish_catch.is_valid() else null return fish_catch if fish_catch.is_valid() else null
@ -157,6 +170,7 @@ static func from_network_dict(
"fish_id", "fish_id",
"weight_lb", "weight_lb",
"display_scale", "display_scale",
"quality",
"sale_value", "sale_value",
]: ]:
if not data.has(required_key): if not data.has(required_key):
@ -172,6 +186,11 @@ static func from_network_dict(
var loaded_value: int = _read_safe_integer( var loaded_value: int = _read_safe_integer(
data.get("sale_value"), -1, MAX_SAFE_SALE_VALUE 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 ( if (
loaded_id.is_empty() loaded_id.is_empty()
or loaded_id.length() > 160 or loaded_id.length() > 160
@ -179,6 +198,7 @@ static func from_network_dict(
or loaded_weight <= 0.0 or loaded_weight <= 0.0
or loaded_scale <= 0.0 or loaded_scale <= 0.0
or loaded_value < 0 or loaded_value < 0
or not FishQualityType.is_valid(loaded_quality)
): ):
return null return null
var fish_catch := FishCatch.new() var fish_catch := FishCatch.new()
@ -188,6 +208,7 @@ static func from_network_dict(
fish_catch.catch_sequence = 0 fish_catch.catch_sequence = 0
fish_catch.weight_lb = loaded_weight fish_catch.weight_lb = loaded_weight
fish_catch.display_scale = loaded_scale fish_catch.display_scale = loaded_scale
fish_catch.quality = loaded_quality
fish_catch.sale_value = loaded_value fish_catch.sale_value = loaded_value
fish_catch.is_favorited = false fish_catch.is_favorited = false
return fish_catch if fish_catch.is_valid() else null return fish_catch if fish_catch.is_valid() else null

91
fish/fish_quality.gd Normal file
View file

@ -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]

1
fish/fish_quality.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://csmvl1ciuux6a

View file

@ -5,10 +5,12 @@ const CollectionLogType = preload("res://collection/collection_log.gd")
const FishCatchType = preload("res://fish/fish_catch.gd") const FishCatchType = preload("res://fish/fish_catch.gd")
const FishDataType = preload("res://fish/fish_data.gd") const FishDataType = preload("res://fish/fish_data.gd")
const FishPoolType = preload("res://fish/fish_pool.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") const FishingContextType = preload("res://fishing/fishing_context.gd")
var undiscovered_weight_multiplier: float = 1.5 var undiscovered_weight_multiplier: float = 1.5
var rarity_weight_multipliers: Array[float] = [] var rarity_weight_multipliers: Array[float] = []
var quality_weight_multipliers: Array[float] = []
var use_deterministic_test_seed: bool = false var use_deterministic_test_seed: bool = false
var deterministic_test_seed: int = 24680 var deterministic_test_seed: int = 24680
var selection_seed: int = 0 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.display_scale = fish.get_display_scale_for_weight(
caught_fish.weight_lb caught_fish.weight_lb
) )
caught_fish.sale_value = fish.get_sale_value_for_weight( caught_fish.quality = FishQualityType.roll(
caught_fish.weight_lb _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 return caught_fish

View file

@ -56,6 +56,7 @@ signal showcase_changed(
fish_name: String, fish_name: String,
rarity_name: String, rarity_name: String,
weight_lb: float, weight_lb: float,
quality: int,
visible: bool, visible: bool,
) )
signal bite_activated signal bite_activated
@ -382,11 +383,14 @@ func _secure_showcase_catch_for_recovery() -> void:
and _local_collection_log != null and _local_collection_log != null
): ):
_local_inventory.add_catch(_pending_catch) _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 _pending_catch = null
_showcase_ready = false _showcase_ready = false
_put_away_press_armed = false _put_away_press_armed = false
showcase_changed.emit("", "", 0.0, false) showcase_changed.emit("", "", 0.0, 0, false)
if _active_player != null: if _active_player != null:
_active_player.end_catch_showcase(Callable(), true) _active_player.end_catch_showcase(Callable(), true)
_cleanup_attempt() _cleanup_attempt()
@ -403,7 +407,10 @@ func _exit_tree() -> void:
and is_instance_valid(_local_collection_log) and is_instance_valid(_local_collection_log)
): ):
_local_inventory.add_catch(_pending_catch) _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 _pending_catch = null
if _active_player != null and is_instance_valid(_active_player): if _active_player != null and is_instance_valid(_active_player):
_active_player.end_catch_showcase(Callable(), true) _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.display_name,
_pending_catch.fish.get_rarity_name(), _pending_catch.fish.get_rarity_name(),
_pending_catch.weight_lb, _pending_catch.weight_lb,
_pending_catch.quality,
true true
) )
@ -1054,12 +1062,15 @@ func _put_away_catch() -> void:
): ):
return return
_local_inventory.add_catch(_pending_catch) _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 _pending_catch = null
_showcase_ready = false _showcase_ready = false
_showcase_outcome_completed = false _showcase_outcome_completed = false
_put_away_press_armed = false _put_away_press_armed = false
showcase_changed.emit("", "", 0.0, false) showcase_changed.emit("", "", 0.0, 0, false)
_showcase_restore_generation += 1 _showcase_restore_generation += 1
var restore_generation: int = _showcase_restore_generation var restore_generation: int = _showcase_restore_generation
_active_player.end_catch_showcase( _active_player.end_catch_showcase(
@ -1095,7 +1106,7 @@ func _cleanup_attempt(
_showcase_ready = false _showcase_ready = false
_showcase_outcome_completed = false _showcase_outcome_completed = false
_put_away_press_armed = false _put_away_press_armed = false
showcase_changed.emit("", "", 0.0, false) showcase_changed.emit("", "", 0.0, 0, false)
_catch_controller.reset() _catch_controller.reset()
state = FishingState.RETURNING state = FishingState.RETURNING
status_changed.emit("") status_changed.emit("")
@ -1138,7 +1149,7 @@ func _finalize_attempt_cleanup(cooldown_message: String) -> void:
_showcase_ready = false _showcase_ready = false
_showcase_outcome_completed = false _showcase_outcome_completed = false
_put_away_press_armed = false _put_away_press_armed = false
showcase_changed.emit("", "", 0.0, false) showcase_changed.emit("", "", 0.0, 0, false)
_catch_controller.reset() _catch_controller.reset()
_presentation.cleanup() _presentation.cleanup()
_pending_cleanup_message = "" _pending_cleanup_message = ""

View file

@ -7,6 +7,7 @@ const MAX_SESSION_ID_LENGTH: int = 96
const MAX_FISH_ID_LENGTH: int = 96 const MAX_FISH_ID_LENGTH: int = 96
const MAX_WEIGHT_LB: float = 1000.0 const MAX_WEIGHT_LB: float = 1000.0
const MAX_DISPLAY_SCALE: float = 20.0 const MAX_DISPLAY_SCALE: float = 20.0
const FishQualityType = preload("res://fish/fish_quality.gd")
static func validate_state(data: Variant) -> bool: static func validate_state(data: Variant) -> bool:
@ -20,6 +21,7 @@ static func validate_state(data: Variant) -> bool:
"fish_id", "fish_id",
"weight_lb", "weight_lb",
"display_scale", "display_scale",
"quality",
"revision", "revision",
]: ]:
if not value.has(key): 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 not is_finite(float(value["weight_lb"]))
or typeof(value["display_scale"]) not in [TYPE_FLOAT, TYPE_INT] or typeof(value["display_scale"]) not in [TYPE_FLOAT, TYPE_INT]
or not is_finite(float(value["display_scale"])) 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 typeof(value["revision"]) != TYPE_INT
or int(value["revision"]) < 0 or int(value["revision"]) < 0
): ):
@ -53,4 +57,5 @@ static func validate_state(data: Variant) -> bool:
str(value["fish_id"]).is_empty() str(value["fish_id"]).is_empty()
and is_zero_approx(float(value["weight_lb"])) and is_zero_approx(float(value["weight_lb"]))
and is_zero_approx(float(value["display_scale"])) and is_zero_approx(float(value["display_scale"]))
and int(value["quality"]) == FishQualityType.Tier.BORING
) )

View file

@ -4,6 +4,7 @@ extends Node
const FishPoolType = preload("res://fish/fish_pool.gd") const FishPoolType = preload("res://fish/fish_pool.gd")
const FishDataType = preload("res://fish/fish_data.gd") const FishDataType = preload("res://fish/fish_data.gd")
const FishCatchType = preload("res://fish/fish_catch.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 FishInventoryType = preload("res://inventory/fish_inventory.gd")
const PlayerHotbarType = preload("res://inventory/player_hotbar.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": ( "display_scale": (
fish_catch.display_scale if fish_catch != null else 0.0 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, "revision": _local_revision,
} }
_local_visible = bool(data["visible"]) _local_visible = bool(data["visible"])

View file

@ -761,7 +761,10 @@ func _apply_target_outcome(data: Dictionary) -> void:
return return
if not already_owned: if not already_owned:
_local_inventory.add_catch(fish_catch) _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(): if not _save_manager.save_if_dirty():
return return
_result_ledgers[result_id] = true _result_ledgers[result_id] = true

View file

@ -144,6 +144,7 @@ static func attachment_signature_fields(value: Variant) -> Array:
result.append(str(fish.get("fish_id", ""))) result.append(str(fish.get("fish_id", "")))
result.append(str(fish.get("weight_lb", ""))) result.append(str(fish.get("weight_lb", "")))
result.append(str(fish.get("display_scale", ""))) result.append(str(fish.get("display_scale", "")))
result.append(int(fish.get("quality", -1)))
result.append(int(fish.get("sale_value", 0))) result.append(int(fish.get("sale_value", 0)))
result.append(bool(fish.get("is_favorited", false))) result.append(bool(fish.get("is_favorited", false)))
3: 3:

View file

@ -7,6 +7,7 @@ signal operation_finished(success: bool, message: String)
signal peers_changed signal peers_changed
const MAX_LEDGER: int = 256 const MAX_LEDGER: int = 256
const FishQualityType = preload("res://fish/fish_quality.gd")
var _session: NetworkSession var _session: NetworkSession
var _reservations: PlayerAssetReservationService var _reservations: PlayerAssetReservationService
@ -950,7 +951,10 @@ func _apply_award(attachment: Dictionary) -> bool:
PlayerAssetReservationService.AttachmentType.FISH: PlayerAssetReservationService.AttachmentType.FISH:
var fish_catch := _decode_catch(attachment) var fish_catch := _decode_catch(attachment)
_inventory.add_catch(fish_catch) _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) return _inventory.contains_catch_id(fish_catch.catch_id)
PlayerAssetReservationService.AttachmentType.CONSUMABLE: PlayerAssetReservationService.AttachmentType.CONSUMABLE:
return _bag.add_item( return _bag.add_item(
@ -962,10 +966,26 @@ func _apply_award(attachment: Dictionary) -> bool:
func _decode_catch(attachment: Dictionary) -> FishCatch: func _decode_catch(attachment: Dictionary) -> FishCatch:
var data: Dictionary = attachment.get("catch", {}) 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", ""))) 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: func _capture_assets() -> Dictionary:
@ -975,6 +995,7 @@ func _capture_assets() -> Dictionary:
"catches": _inventory.get_all_catches(), "catches": _inventory.get_all_catches(),
"next_sequence": _inventory.get_next_catch_sequence(), "next_sequence": _inventory.get_next_catch_sequence(),
"discovered": _collection_log.get_discovered_ids(), "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( _inventory.replace_all_catches(
snapshot["catches"], int(snapshot["next_sequence"]) 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: func _emit_mailbox() -> void:

View file

@ -22,6 +22,7 @@ const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v2"
const ART_SHOP_CAPABILITY: String = "art_shop_v1" const ART_SHOP_CAPABILITY: String = "art_shop_v1"
const WORLD_TIME_CAPABILITY: String = "world_time_v1" const WORLD_TIME_CAPABILITY: String = "world_time_v1"
const WORLD_WEATHER_CAPABILITY: String = "world_weather_v1" const WORLD_WEATHER_CAPABILITY: String = "world_weather_v1"
const FISH_QUALITY_CAPABILITY: String = "fish_quality_v1"
enum RejectionCode { enum RejectionCode {
NONE, NONE,
@ -88,6 +89,7 @@ static func make_client_hello(
"display_name": display_name, "display_name": display_name,
"client_nonce": client_nonce, "client_nonce": client_nonce,
"capability_flags": PackedStringArray([ "capability_flags": PackedStringArray([
FISH_QUALITY_CAPABILITY,
SURFACE_DRAWING_CAPABILITY, SURFACE_DRAWING_CAPABILITY,
WORLD_TIME_CAPABILITY, WORLD_TIME_CAPABILITY,
WORLD_WEATHER_CAPABILITY, WORLD_WEATHER_CAPABILITY,
@ -207,6 +209,7 @@ static func make_server_hello(
"item_use_v1", "item_use_v1",
"equipment_v1", "equipment_v1",
"fish_showcase_v1", "fish_showcase_v1",
FISH_QUALITY_CAPABILITY,
SURFACE_DRAWING_CAPABILITY, SURFACE_DRAWING_CAPABILITY,
WORLD_TIME_CAPABILITY, WORLD_TIME_CAPABILITY,
WORLD_WEATHER_CAPABILITY, WORLD_WEATHER_CAPABILITY,

View file

@ -58,7 +58,7 @@ static func validate_request(data: Variant) -> String:
var evidence: Dictionary = value var evidence: Dictionary = value
for key: String in [ for key: String in [
"catch_id", "fish_id", "weight_lb", "display_scale", "catch_id", "fish_id", "weight_lb", "display_scale",
"sale_value", "is_favorited", "quality", "sale_value", "is_favorited",
]: ]:
if not evidence.has(key): if not evidence.has(key):
return "Sale could not be completed." return "Sale could not be completed."

View file

@ -2,6 +2,7 @@ class_name NetworkSaleService
extends Node extends Node
const FishCatchType = preload("res://fish/fish_catch.gd") 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 FishDataType = preload("res://fish/fish_data.gd")
const FishPoolType = preload("res://fish/fish_pool.gd") const FishPoolType = preload("res://fish/fish_pool.gd")
const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.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) not str(decoded.catch_id).begins_with("%s:" % fish_id)
or decoded.weight_lb < fish.get_minimum_weight() or decoded.weight_lb < fish.get_minimum_weight()
or decoded.weight_lb > fish.get_maximum_weight() or decoded.weight_lb > fish.get_maximum_weight()
or decoded.sale_value < fish.sell_value_min or decoded.sale_value != FishQualityType.apply_sale_value(
or decoded.sale_value > fish.sell_value_max fish.get_sale_value_for_weight(decoded.weight_lb),
decoded.quality,
)
): ):
return _rejected_result( return _rejected_result(
request_id, "Sale could not be completed." request_id, "Sale could not be completed."
@ -262,7 +265,13 @@ func _build_authoritative_result(
return _rejected_result( return _rejected_result(
request_id, "Favorite catches cannot be sold." 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 ( if (
offer < 0 offer < 0
or base_value > 9223372036854775807 - decoded.sale_value or base_value > 9223372036854775807 - decoded.sale_value

View file

@ -169,6 +169,7 @@ func start_private_host(port: int = DEFAULT_PORT) -> bool:
_player_identity.fingerprint, _player_identity.fingerprint,
_player_identity.public_pem, _player_identity.public_pem,
PackedStringArray([ PackedStringArray([
NetworkProtocol.FISH_QUALITY_CAPABILITY,
NetworkProtocol.SURFACE_DRAWING_CAPABILITY, NetworkProtocol.SURFACE_DRAWING_CAPABILITY,
NetworkProtocol.WORLD_TIME_CAPABILITY, NetworkProtocol.WORLD_TIME_CAPABILITY,
NetworkProtocol.WORLD_WEATHER_CAPABILITY, NetworkProtocol.WORLD_WEATHER_CAPABILITY,
@ -367,6 +368,7 @@ func supports_server_capability(capability: StringName) -> bool:
"movement_v1", "fishing_v1", "sale_v1", "shop_v1", "movement_v1", "fishing_v1", "sale_v1", "shop_v1",
NetworkProtocol.ART_SHOP_CAPABILITY, NetworkProtocol.ART_SHOP_CAPABILITY,
"item_use_v1", "equipment_v1", "fish_showcase_v1", "item_use_v1", "equipment_v1", "fish_showcase_v1",
NetworkProtocol.FISH_QUALITY_CAPABILITY,
NetworkProtocol.SURFACE_DRAWING_CAPABILITY, NetworkProtocol.SURFACE_DRAWING_CAPABILITY,
NetworkProtocol.WORLD_TIME_CAPABILITY, NetworkProtocol.WORLD_TIME_CAPABILITY,
NetworkProtocol.WORLD_WEATHER_CAPABILITY, NetworkProtocol.WORLD_WEATHER_CAPABILITY,
@ -915,6 +917,15 @@ func submit_client_hello(data: Dictionary) -> void:
if not validation_error.is_empty(): if not validation_error.is_empty():
_reject_peer(sender_id, NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE) _reject_peer(sender_id, NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE)
return 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, {}) var identity: Dictionary = _authenticated_identity_cache.get(sender_id, {})
if identity.is_empty(): if identity.is_empty():
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF) _reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
@ -948,7 +959,7 @@ func submit_client_hello(data: Dictionary) -> void:
NetworkProtocol.PROTOCOL_VERSION, NetworkProtocol.PROTOCOL_VERSION,
identity["fingerprint"], identity["fingerprint"],
identity["public_key"], identity["public_key"],
_sanitized_capabilities(data.get("capability_flags", [])), client_capabilities,
): ):
_reject_peer( _reject_peer(
sender_id, sender_id,
@ -1071,6 +1082,10 @@ func receive_server_hello(data: Dictionary) -> void:
for value: Variant in advertised_capabilities: for value: Variant in advertised_capabilities:
if typeof(value) in [TYPE_STRING, TYPE_STRING_NAME]: if typeof(value) in [TYPE_STRING, TYPE_STRING_NAME]:
_server_capabilities.append(str(value)) _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() var local_peer_id: int = multiplayer.get_unique_id()
_registry.clear() _registry.clear()
_registry.add_peer( _registry.add_peer(
@ -1081,6 +1096,7 @@ func receive_server_hello(data: Dictionary) -> void:
_player_identity.fingerprint, _player_identity.fingerprint,
_player_identity.public_pem, _player_identity.public_pem,
PackedStringArray([ PackedStringArray([
NetworkProtocol.FISH_QUALITY_CAPABILITY,
NetworkProtocol.SURFACE_DRAWING_CAPABILITY, NetworkProtocol.SURFACE_DRAWING_CAPABILITY,
NetworkProtocol.WORLD_TIME_CAPABILITY, NetworkProtocol.WORLD_TIME_CAPABILITY,
NetworkProtocol.WORLD_WEATHER_CAPABILITY, NetworkProtocol.WORLD_WEATHER_CAPABILITY,

View file

@ -2,6 +2,7 @@ class_name PlayerSaveManager
extends Node extends Node
const FishCatchType = preload("res://fish/fish_catch.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 FishInventoryType = preload("res://inventory/fish_inventory.gd")
const CollectionLogType = preload("res://collection/collection_log.gd") const CollectionLogType = preload("res://collection/collection_log.gd")
const PlayerWalletType = preload("res://economy/player_wallet.gd") const PlayerWalletType = preload("res://economy/player_wallet.gd")
@ -21,7 +22,7 @@ const PlayerArtUnlocksType = preload(
"res://progression/player_art_unlocks.gd" "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 BASIC_ROD_ID: StringName = &"basic_fishing_rod"
const MAX_SAFE_BALANCE: int = 1000000000000 const MAX_SAFE_BALANCE: int = 1000000000000
@ -30,6 +31,7 @@ class LoadSnapshot:
var catches: Array[FishCatchType] = [] var catches: Array[FishCatchType] = []
var discovered_ids: Array[StringName] = [] var discovered_ids: Array[StringName] = []
var discovered_quality_masks: Dictionary[StringName, int] = {}
var wallet_balance: int = 0 var wallet_balance: int = 0
var next_catch_sequence: int = 1 var next_catch_sequence: int = 1
var bag_items: Array[OwnedItemType] = [] var bag_items: Array[OwnedItemType] = []
@ -124,10 +126,10 @@ func setup(
return return
if not _inventory.catches_changed.is_connected(_mark_dirty): if not _inventory.catches_changed.is_connected(_mark_dirty):
_inventory.catches_changed.connect(_mark_dirty) _inventory.catches_changed.connect(_mark_dirty)
if not _collection_log.fish_discovered.is_connected( if not _collection_log.collection_changed.is_connected(
_on_fish_discovered _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): if not _wallet.balance_changed.is_connected(_on_balance_changed):
_wallet.balance_changed.connect(_on_balance_changed) _wallet.balance_changed.connect(_on_balance_changed)
if not _bag.contents_changed.is_connected(_mark_dirty): if not _bag.contents_changed.is_connected(_mark_dirty):
@ -208,7 +210,10 @@ func load_player_data() -> bool:
snapshot.next_catch_sequence snapshot.next_catch_sequence
) )
var collection_restored: bool = ( 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( var wallet_restored: bool = _wallet.restore_balance(
snapshot.wallet_balance snapshot.wallet_balance
@ -401,6 +406,19 @@ func _build_save_dictionary() -> Dictionary:
if fish_id.is_empty(): if fish_id.is_empty():
return {} return {}
discovered_strings.append(String(fish_id)) 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] = [] var serialized_items: Array[Dictionary] = []
for owned: OwnedItemType in _bag.get_all_items(): for owned: OwnedItemType in _bag.get_all_items():
if owned == null or not owned.is_valid(): if owned == null or not owned.is_valid():
@ -430,6 +448,7 @@ func _build_save_dictionary() -> Dictionary:
}, },
"collection": { "collection": {
"discovered_fish_ids": discovered_strings, "discovered_fish_ids": discovered_strings,
"discovered_quality_masks": serialized_quality_masks,
}, },
"inventory": { "inventory": {
"next_catch_sequence": _inventory.get_next_catch_sequence(), "next_catch_sequence": _inventory.get_next_catch_sequence(),
@ -481,6 +500,8 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
if ( if (
not wallet_data.has("balance") not wallet_data.has("balance")
or typeof(collection_data.get("discovered_fish_ids")) != TYPE_ARRAY 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 typeof(inventory_data.get("catches")) != TYPE_ARRAY
or not inventory_data.has("next_catch_sequence") or not inventory_data.has("next_catch_sequence")
or typeof(bag_data.get("items")) != TYPE_ARRAY or typeof(bag_data.get("items")) != TYPE_ARRAY
@ -514,6 +535,26 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
continue continue
seen_discoveries[fish_id] = true seen_discoveries[fish_id] = true
snapshot.discovered_ids.append(fish_id) 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_ids: Dictionary[StringName, bool] = {}
var seen_sequences: Dictionary[int, bool] = {} var seen_sequences: Dictionary[int, bool] = {}
@ -701,6 +742,8 @@ func _migrate_save(
migrated = _migrate_version_2_to_3(migrated) migrated = _migrate_version_2_to_3(migrated)
3: 3:
migrated = _migrate_version_3_to_4(migrated) migrated = _migrate_version_3_to_4(migrated)
4:
migrated = _migrate_version_4_to_5(migrated)
_: _:
return {} return {}
if migrated.is_empty(): if migrated.is_empty():
@ -748,6 +791,58 @@ func _migrate_version_3_to_4(data: Dictionary) -> Dictionary:
return migrated 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: func _mark_dirty() -> void:
if ( if (
_is_restoring _is_restoring
@ -760,7 +855,7 @@ func _mark_dirty() -> void:
_autosave_timer.start(maxf(autosave_delay, 0.05)) _autosave_timer.start(maxf(autosave_delay, 0.05))
func _on_fish_discovered(_fish_id: StringName) -> void: func _on_collection_changed() -> void:
_mark_dirty() _mark_dirty()
@ -833,6 +928,7 @@ func _restore_defaults() -> void:
_is_restoring = true _is_restoring = true
var empty_catches: Array[FishCatchType] = [] var empty_catches: Array[FishCatchType] = []
var empty_discoveries: Array[StringName] = [] var empty_discoveries: Array[StringName] = []
var empty_quality_masks: Dictionary[StringName, int] = {}
var default_items: Array[OwnedItemType] = [] var default_items: Array[OwnedItemType] = []
var basic_rod := OwnedItemType.new() var basic_rod := OwnedItemType.new()
basic_rod.item_id = BASIC_ROD_ID basic_rod.item_id = BASIC_ROD_ID
@ -843,7 +939,10 @@ func _restore_defaults() -> void:
default_slots.fill(StringName()) default_slots.fill(StringName())
default_slots[0] = BASIC_ROD_ID default_slots[0] = BASIC_ROD_ID
_inventory.replace_all_catches(empty_catches, 1) _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) _wallet.restore_balance(0)
_bag.replace_all_items(default_items) _bag.replace_all_items(default_items)
_hotbar.replace_state(default_slots, 0) _hotbar.replace_state(default_slots, 0)

View file

@ -45,11 +45,17 @@ func _run() -> void:
fish_catch.display_scale = fish.get_display_scale_for_weight( fish_catch.display_scale = fish.get_display_scale_for_weight(
fish_catch.weight_lb fish_catch.weight_lb
) )
fish_catch.sale_value = fish.get_sale_value_for_weight( fish_catch.quality = FishQuality.Tier.EXCEPTIONAL
fish_catch.weight_lb 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() fish_catch.ensure_identity()
player.inventory.add_catch(fish_catch) 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 game_ui := main.get_node("%GameUI") as GameUI
var hotbar_ui := game_ui.get_node("%Hotbar") as HotbarUI var hotbar_ui := game_ui.get_node("%Hotbar") as HotbarUI
assert(hotbar_ui != null) assert(hotbar_ui != null)
@ -94,12 +100,27 @@ func _run() -> void:
var hotbar_data: Dictionary = (parsed as Dictionary)["hotbar"] var hotbar_data: Dictionary = (parsed as Dictionary)["hotbar"]
assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY) assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY)
assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id) 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(player.hotbar.clear_slot(1))
assert(save_manager.load_player_data()) assert(save_manager.load_player_data())
assert(player.hotbar.get_fish_catch_id(1) == fish_catch.catch_id) assert(player.hotbar.get_fish_catch_id(1) == fish_catch.catch_id)
assert(player.hotbar.get_selected_slot() == 1) 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(not service.is_local_showcase_visible())
assert(service.toggle_selected_fish()) assert(service.toggle_selected_fish())
assert(service.is_local_showcase_visible()) assert(service.is_local_showcase_visible())
@ -118,6 +139,7 @@ func _run() -> void:
"display_scale": fish.get_display_scale_for_weight( "display_scale": fish.get_display_scale_for_weight(
fish.get_minimum_weight() fish.get_minimum_weight()
), ),
"quality": FishQuality.Tier.BORING,
"revision": 1, "revision": 1,
} }
assert(NetworkFishShowcaseProtocol.validate_state(valid_state)) assert(NetworkFishShowcaseProtocol.validate_state(valid_state))
@ -126,6 +148,10 @@ func _run() -> void:
assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state)) assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state))
assert(NetworkProtocol.PROTOCOL_VERSION == 3) assert(NetworkProtocol.PROTOCOL_VERSION == 3)
assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10)
assert(
NetworkProtocol.FISH_QUALITY_CAPABILITY
== "fish_quality_v1"
)
assert( assert(
NetworkFishShowcaseProtocol.CAPABILITY NetworkFishShowcaseProtocol.CAPABILITY
== &"fish_showcase_v1" == &"fish_showcase_v1"

View file

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

View file

@ -0,0 +1 @@
uid://bffg3t4du01cu

View file

@ -274,6 +274,8 @@ func _validate_handwritten_logbook_font(page: LogbookPage) -> void:
var detail_body := page.get("_detail_body") as VBoxContainer var detail_body := page.get("_detail_body") as VBoxContainer
for node: Node in detail_body.find_children("*", "Label", true, false): for node: Node in detail_body.find_children("*", "Label", true, false):
var label := node as Label var label := node as Label
if label.text.begins_with("quality collection"):
continue
if label.text in [ if label.text in [
"fish facts", "fish facts",
"catalog number", "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): for node: Node in detail_body.find_children("*", "Label", true, false):
var label := node as Label 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: if label.text not in field_names:
continue continue
assert(label.get_theme_font("font") == UtilityPageStyle.TuffyFont) assert(label.get_theme_font("font") == UtilityPageStyle.TuffyFont)

View file

@ -12,6 +12,7 @@ const PlayerBagType = preload("res://inventory/player_bag.gd")
const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const FishCatchType = preload("res://fish/fish_catch.gd") const FishCatchType = preload("res://fish/fish_catch.gd")
const FishQualityType = preload("res://fish/fish_quality.gd")
const SELECTED_SCALE: float = 1.12 const SELECTED_SCALE: float = 1.12
const HOVER_SCALE: float = 1.025 const HOVER_SCALE: float = 1.025
@ -179,7 +180,10 @@ func refresh() -> void:
_quantity_label.visible = not quantity_text.is_empty() _quantity_label.visible = not quantity_text.is_empty()
tooltip_text = ( tooltip_text = (
"%s · %.1f lb" % [ "%s · %.1f lb" % [
fish_catch.fish.display_name, FishQualityType.qualified_name(
fish_catch.fish.display_name,
fish_catch.quality,
),
fish_catch.weight_lb, fish_catch.weight_lb,
] ]
if fish_catch != null if fish_catch != null

View file

@ -15,7 +15,7 @@ var _depth_scale: float = 1.0
var _batch_selected: bool = false var _batch_selected: bool = false
var _focused_catch: bool = false var _focused_catch: bool = false
var _hovered: bool = false var _hovered: bool = false
var _rarity_color := Color.WHITE var _quality_color := Color.WHITE
func _ready() -> void: func _ready() -> void:
@ -40,7 +40,7 @@ func configure(
texture: Texture2D, texture: Texture2D,
phase: float, phase: float,
depth_scale: float, depth_scale: float,
rarity_color: Color, quality_color: Color,
) -> void: ) -> void:
catch_id = identity catch_id = identity
_display_name = display_name _display_name = display_name
@ -48,7 +48,7 @@ func configure(
_fish_shadow.texture = texture _fish_shadow.texture = texture
_motion_phase = phase _motion_phase = phase
_depth_scale = depth_scale _depth_scale = depth_scale
_rarity_color = rarity_color _quality_color = quality_color
_refresh_style() _refresh_style()
tooltip_text = "%s · drag to a hotbar slot" % _display_name tooltip_text = "%s · drag to a hotbar slot" % _display_name
@ -136,14 +136,16 @@ func _update_visual_pivot() -> void:
func _refresh_style() -> void: func _refresh_style() -> void:
var idle := StyleBoxEmpty.new() var idle := StyleBoxEmpty.new()
var normal := _make_rarity_style(_rarity_color, 2, 0.25) var normal := _make_quality_style(_quality_color, 2, 0.25)
var hover := _make_rarity_style(_rarity_color.lightened(0.08), 4, 0.34) var hover := _make_quality_style(
var selected_fill: Color = ( _quality_color.lightened(0.08), 4, 0.34
_rarity_color.lightened(0.12)
if _batch_selected
else _rarity_color
) )
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, selected_fill,
6 if _batch_selected else 4, 6 if _batch_selected else 4,
0.46 if _batch_selected else 0.34, 0.46 if _batch_selected else 0.34,
@ -169,7 +171,7 @@ func _refresh_style() -> void:
add_theme_stylebox_override("pressed", selected) add_theme_stylebox_override("pressed", selected)
func _make_rarity_style( func _make_quality_style(
fill: Color, fill: Color,
shadow_size: int, shadow_size: int,
shadow_alpha: float, shadow_alpha: float,

View file

@ -5,6 +5,7 @@ const CollectionLogType = preload("res://collection/collection_log.gd")
const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd") const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd")
const FishSaleServiceType = preload("res://economy/fish_sale_service.gd") const FishSaleServiceType = preload("res://economy/fish_sale_service.gd")
const FishPoolType = preload("res://fish/fish_pool.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 FishInventoryType = preload("res://inventory/fish_inventory.gd")
const FishingSpotType = preload("res://fishing/fishing_spot.gd") const FishingSpotType = preload("res://fishing/fishing_spot.gd")
const PlayerMenuType = preload("res://ui/player_menu.gd") const PlayerMenuType = preload("res://ui/player_menu.gd")
@ -569,6 +570,7 @@ func _on_showcase_changed(
fish_name: String, fish_name: String,
rarity_name: String, rarity_name: String,
weight_lb: float, weight_lb: float,
quality: int,
visible: bool, visible: bool,
) -> void: ) -> void:
_showcase_active = visible _showcase_active = visible
@ -583,11 +585,13 @@ func _on_showcase_changed(
_barrier_health.visible = false _barrier_health.visible = false
_clear_barrier_markers() _clear_barrier_markers()
_showcase_details.text = ( _showcase_details.text = (
"%.1f lb%s" "%s%.1f lb"
% [weight_lb, rarity_name] % [rarity_name.to_lower(), weight_lb]
) )
_showcase_details.visible = true _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 _status_label.visible = true
_refresh_fishing_panel_visibility() _refresh_fishing_panel_visibility()

View file

@ -8,6 +8,7 @@ const PlayerBagType = preload("res://inventory/player_bag.gd")
const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const FishCatchType = preload("res://fish/fish_catch.gd") const FishCatchType = preload("res://fish/fish_catch.gd")
const FishQualityType = preload("res://fish/fish_quality.gd")
const BubbleHotbarSlotType = preload( const BubbleHotbarSlotType = preload(
"res://ui/components/bubble_hotbar/bubble_hotbar_slot.gd" "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: if not catch_id.is_empty() and _fish_inventory != null:
var fish_catch: FishCatchType = _fish_inventory.get_catch_by_id(catch_id) var fish_catch: FishCatchType = _fish_inventory.get_catch_by_id(catch_id)
if fish_catch != null: 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 _selected_item_label.visible = true
return return
var item = ( var item = (

View file

@ -3,6 +3,7 @@ extends Control
const CollectionLogType = preload("res://collection/collection_log.gd") const CollectionLogType = preload("res://collection/collection_log.gd")
const FishDataType = preload("res://fish/fish_data.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 FishInventoryType = preload("res://inventory/fish_inventory.gd")
const FishPoolType = preload("res://fish/fish_pool.gd") const FishPoolType = preload("res://fish/fish_pool.gd")
const SILHOUETTE_SHADER: Shader = preload( const SILHOUETTE_SHADER: Shader = preload(
@ -559,6 +560,7 @@ func _build_known_details(fish: FishDataType) -> void:
facts.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART facts.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
facts.size_flags_horizontal = Control.SIZE_EXPAND_FILL facts.size_flags_horizontal = Control.SIZE_EXPAND_FILL
facts_column.add_child(facts) facts_column.add_child(facts)
_detail_body.add_child(_build_quality_progress(fish.id))
var stats_columns := HBoxContainer.new() var stats_columns := HBoxContainer.new()
stats_columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL stats_columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL
@ -594,8 +596,14 @@ func _build_known_details(fish: FishDataType) -> void:
right_stats, right_stats,
"value range", "value range",
"%d%d fish coin" % [ "%d%d fish coin" % [
fish.sell_value_min, FishQualityType.apply_sale_value(
fish.sell_value_max, fish.sell_value_min,
FishQualityType.Tier.BORING,
),
FishQualityType.apply_sale_value(
fish.sell_value_max,
FishQualityType.Tier.SHINY,
),
], ],
) )
_add_detail_row( _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: func _show_no_selection() -> void:
_clear_details() _clear_details()
var instruction := _label("Select an entry to read its catch record.", 21) var instruction := _label("Select an entry to read its catch record.", 21)

View file

@ -16,6 +16,7 @@ const SALUTATION_LABELS := {
"salutations": "Salutations", "salutations": "Salutations",
"good_luck_have_fun": "Good Luck Have Fun", "good_luck_have_fun": "Good Luck Have Fun",
} }
const FishQualityType = preload("res://fish/fish_quality.gd")
var _service: NetworkMailService var _service: NetworkMailService
var _reservations: PlayerAssetReservationService var _reservations: PlayerAssetReservationService
@ -507,7 +508,11 @@ func _refresh_attachment_choices(_index: int) -> void:
): ):
_attachment_choice.add_item( _attachment_choice.add_item(
"%s%.1f lb" % [ "%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( _attachment_choice.set_item_metadata(
@ -719,11 +724,18 @@ func _attachment_text(attachment: Dictionary) -> String:
) )
if fish_catch != null: if fish_catch != null:
return "%s%.1f lb" % [ 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", {}) var data: Dictionary = attachment.get("catch", {})
return "%s%.1f lb" % [ 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)), float(data.get("weight_lb", 0.0)),
] ]
PlayerAssetReservationService.AttachmentType.CONSUMABLE: PlayerAssetReservationService.AttachmentType.CONSUMABLE:

View file

@ -5,6 +5,7 @@ const INPUT_OWNER: StringName = &"player_menu"
const CollectionLogType = preload("res://collection/collection_log.gd") const CollectionLogType = preload("res://collection/collection_log.gd")
const FishCatchType = preload("res://fish/fish_catch.gd") const FishCatchType = preload("res://fish/fish_catch.gd")
const FishDataType = preload("res://fish/fish_data.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 FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd")
const FishSaleResultType = preload("res://economy/fish_sale_result.gd") const FishSaleResultType = preload("res://economy/fish_sale_result.gd")
const FishSaleServiceType = preload("res://economy/fish_sale_service.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_CANONICAL_POSITION := Vector2(424.0, 44.0)
const NAVIGATION_SELECTED_SCALE: float = 1.02 const NAVIGATION_SELECTED_SCALE: float = 1.02
const INVENTORY_TAB_LEFT_INSET: float = 96.0 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" const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop"
signal menu_visibility_changed(is_open: bool) 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()) var identity_hash: int = absi(String(fish_catch.catch_id).hash())
fish_node.configure( fish_node.configure(
fish_catch.catch_id, 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, fish_catch.fish.display_texture,
float(identity_hash % 628) / 100.0, float(identity_hash % 628) / 100.0,
0.96 + float(identity_hash % 9) * 0.01, 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_node.set_item_state(
_fish_selection.is_selected(fish_catch.catch_id), _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() _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: func _layout_cooler_fish(animate: bool = true) -> void:
if not is_node_ready(): if not is_node_ready():
return return
@ -2926,7 +2911,10 @@ func _update_inventory_detail(fish_catch: FishCatchType) -> void:
and _reservations.is_fish_reserved(fish_catch.catch_id) and _reservations.is_fish_reserved(fish_catch.catch_id)
): ):
_cooler_detail_name.text += " • reserved in mail" _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: if buyer_offer >= 0 and active_buyer != null:
_cooler_offer_label.text = _get_offer_label(active_buyer) _cooler_offer_label.text = _get_offer_label(active_buyer)
_cooler_offer_value.text = "$%d" % buyer_offer _cooler_offer_value.text = "$%d" % buyer_offer

View file

@ -18,6 +18,12 @@ const RARITY_RARE := Color("5596f6")
const RARITY_EPIC := Color("b176e8") const RARITY_EPIC := Color("b176e8")
const RARITY_LEGENDARY := Color("f276b3") 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: static func get_rarity_color(rarity: int) -> Color:
match rarity: match rarity:
@ -31,3 +37,17 @@ static func get_rarity_color(rarity: int) -> Color:
return RARITY_LEGENDARY return RARITY_LEGENDARY
_: _:
return RARITY_COMMON 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