From 46df74361a68bfcdc57f0b511936d54a63af11ac Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 3 Aug 2026 00:43:19 -0400 Subject: [PATCH] Make fishing challenge scale with catch quality --- fish/fish_quality.gd | 22 +++++++++++ fishing/bass_catch_profile.tres | 3 -- fishing/carp_catch_profile.tres | 3 -- fishing/catch_controller.gd | 40 ++++++++++++++------ fishing/catch_difficulty_profile.gd | 3 -- fishing/common_catch_profile.tres | 3 -- fishing/fishing_spot.gd | 8 +++- network/network_fishing_service.gd | 21 ++++++++--- player/player.gd | 3 +- tests/fishing_authority_validation.gd | 54 +++++++++++++++++++++++++++ 10 files changed, 128 insertions(+), 32 deletions(-) diff --git a/fish/fish_quality.gd b/fish/fish_quality.gd index f851e66..d43039b 100644 --- a/fish/fish_quality.gd +++ b/fish/fish_quality.gd @@ -16,6 +16,10 @@ const ALL_TIERS_MASK: int = (1 << TIER_COUNT) - 1 # 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] +# Provisional challenge curve. Fish profiles continue to own their baseline +# barrier health; quality scales that authored baseline before player upgrades +# apply damage. This keeps future rods, bait, and lures on one shared seam. +const BARRIER_HEALTH_MULTIPLIERS: Array[float] = [1.0, 1.25, 1.6, 2.2, 3.25] const DISPLAY_NAMES: PackedStringArray = [ "boring", "average", @@ -52,6 +56,24 @@ static func apply_sale_value(base_value: int, quality: int) -> int: ) +static func barrier_health_multiplier(quality: int) -> float: + return ( + BARRIER_HEALTH_MULTIPLIERS[quality] + if is_valid(quality) + else BARRIER_HEALTH_MULTIPLIERS[Tier.BORING] + ) + + +static func apply_barrier_health(base_health: int, quality: int) -> int: + return maxi( + ceili( + float(maxi(base_health, 1)) + * barrier_health_multiplier(quality) + ), + 1, + ) + + static func roll( rng: RandomNumberGenerator, weight_multipliers: Array[float] = [], diff --git a/fishing/bass_catch_profile.tres b/fishing/bass_catch_profile.tres index 077b229..6385eec 100644 --- a/fishing/bass_catch_profile.tres +++ b/fishing/bass_catch_profile.tres @@ -11,6 +11,3 @@ barrier_health_max = 7 first_barrier_margin = 0.14 final_barrier_margin = 0.1 minimum_barrier_spacing = 0.14 -chase_start_delay = 1.0 -chase_start_offset = 0.16 -chase_speed = 0.145 diff --git a/fishing/carp_catch_profile.tres b/fishing/carp_catch_profile.tres index bc3f5dc..94a8470 100644 --- a/fishing/carp_catch_profile.tres +++ b/fishing/carp_catch_profile.tres @@ -11,6 +11,3 @@ barrier_health_max = 8 first_barrier_margin = 0.13 final_barrier_margin = 0.09 minimum_barrier_spacing = 0.14 -chase_start_delay = 1.1 -chase_start_offset = 0.18 -chase_speed = 0.15 diff --git a/fishing/catch_controller.gd b/fishing/catch_controller.gd index 53617df..6eb058e 100644 --- a/fishing/catch_controller.gd +++ b/fishing/catch_controller.gd @@ -4,6 +4,7 @@ extends Node const CatchDifficultyProfileType = preload( "res://fishing/catch_difficulty_profile.gd" ) +const FishQualityType = preload("res://fish/fish_quality.gd") signal encounter_updated( progress: float, @@ -42,6 +43,13 @@ class Barrier: maximum_health = barrier_health +# Fight movement uses one shared baseline. Species and quality difficulty comes +# from barrier placement and health, while reel upgrades affect only the +# player's progress speed. +const CHASE_START_DELAY: float = 0.5 +const CHASE_START_OFFSET: float = 0.04 +const CHASE_SPEED: float = 0.07 + @export_category("Accessibility") @export var auto_click_enabled: bool = false @export_range(0.05, 2.0, 0.01) var auto_click_interval: float = 0.20 @@ -60,9 +68,8 @@ var _active_barrier_index: int = -1 var _reel_input_held: bool = false var _reel_speed: float = 0.0 var _click_power: int = 1 -var _chase_start_delay: float = 0.0 +var _fish_quality: int = FishQualityType.Tier.BORING var _chase_delay_remaining: float = 0.0 -var _chase_speed: float = 0.0 var _failure_epsilon: float = 0.0001 var _auto_click_accumulator: float = 0.0 var _rng: RandomNumberGenerator = RandomNumberGenerator.new() @@ -103,6 +110,7 @@ func start_encounter( profile: CatchDifficultyProfileType, reel_speed: float, click_power: int, + fish_quality: int = FishQualityType.Tier.BORING, ) -> void: reset() if profile == null: @@ -111,10 +119,13 @@ func start_encounter( _reel_speed = maxf(reel_speed, 0.0) _click_power = maxi(click_power, 1) - _chase_start_delay = maxf(profile.chase_start_delay, 0.0) - _chase_delay_remaining = _chase_start_delay - chase_progress = -maxf(profile.chase_start_offset, 0.01) - _chase_speed = maxf(profile.chase_speed, 0.0) + _fish_quality = ( + fish_quality + if FishQualityType.is_valid(fish_quality) + else FishQualityType.Tier.BORING + ) + _chase_delay_remaining = CHASE_START_DELAY + chase_progress = -CHASE_START_OFFSET _seed_encounter_rng() _generate_barriers(profile) progress = 0.0 @@ -127,12 +138,13 @@ func start_authoritative_encounter( reel_speed: float, click_power: int, seed: int, + fish_quality: int = FishQualityType.Tier.BORING, ) -> void: var previous_test_mode: bool = use_deterministic_test_seed var previous_seed: int = deterministic_test_seed use_deterministic_test_seed = true deterministic_test_seed = seed - start_encounter(profile, reel_speed, click_power) + start_encounter(profile, reel_speed, click_power, fish_quality) use_deterministic_test_seed = previous_test_mode deterministic_test_seed = previous_seed @@ -168,9 +180,8 @@ func reset() -> void: _reel_input_held = false _reel_speed = 0.0 _click_power = 1 - _chase_start_delay = 0.0 + _fish_quality = FishQualityType.Tier.BORING _chase_delay_remaining = 0.0 - _chase_speed = 0.0 _auto_click_accumulator = 0.0 emit_signal( "encounter_updated", @@ -215,7 +226,7 @@ func _update_chase(delta: float) -> void: if active_delta <= 0.0: return chase_progress = minf( - chase_progress + _chase_speed * active_delta, + chase_progress + CHASE_SPEED * active_delta, 1.0 ) @@ -302,7 +313,14 @@ func _generate_barriers(profile: CatchDifficultyProfileType) -> void: + spacing * float(barrier_index) + random_offsets[barrier_index] * random_slack ) - var health: int = _rng.randi_range(health_range.x, health_range.y) + var base_health: int = _rng.randi_range( + health_range.x, + health_range.y, + ) + var health: int = FishQualityType.apply_barrier_health( + base_health, + _fish_quality, + ) _barriers.append(Barrier.new(position, health)) diff --git a/fishing/catch_difficulty_profile.gd b/fishing/catch_difficulty_profile.gd index 3ad20ad..bd2508c 100644 --- a/fishing/catch_difficulty_profile.gd +++ b/fishing/catch_difficulty_profile.gd @@ -8,9 +8,6 @@ extends Resource @export_range(0.0, 0.45, 0.01) var first_barrier_margin: float = 0.15 @export_range(0.0, 0.45, 0.01) var final_barrier_margin: float = 0.10 @export_range(0.01, 1.0, 0.01) var minimum_barrier_spacing: float = 0.15 -@export_range(0.0, 5.0, 0.05) var chase_start_delay: float = 1.0 -@export_range(0.01, 0.5, 0.01) var chase_start_offset: float = 0.15 -@export_range(0.0, 2.0, 0.01) var chase_speed: float = 0.14 func get_barrier_count_range() -> Vector2i: diff --git a/fishing/common_catch_profile.tres b/fishing/common_catch_profile.tres index 1169e2e..4af3284 100644 --- a/fishing/common_catch_profile.tres +++ b/fishing/common_catch_profile.tres @@ -11,6 +11,3 @@ barrier_health_max = 7 first_barrier_margin = 0.15 final_barrier_margin = 0.1 minimum_barrier_spacing = 0.15 -chase_start_delay = 1.0 -chase_start_offset = 0.15 -chase_speed = 0.14 diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index c694461..a12427b 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -919,6 +919,10 @@ func _activate_bite() -> void: state = FishingState.FIGHTING _state_time_remaining = 0.0 _withdrawal_input_held = false + _pending_catch = _fish_selector.create_catch(_selected_fish) + if _pending_catch == null or not _pending_catch.is_valid(): + _cancel_attempt() + return bite_activated.emit() status_changed.emit("fish on!") _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) @@ -926,7 +930,8 @@ func _activate_bite() -> void: _catch_controller.start_encounter( _selected_fish.catch_profile, _get_effective_reel_speed(), - _get_effective_barrier_damage() + _get_effective_barrier_damage(), + _pending_catch.quality, ) _catch_controller.set_reel_input( Input.is_action_pressed("fish_primary") @@ -993,7 +998,6 @@ func _on_catch_completed() -> void: _cancel_attempt() return - _pending_catch = _fish_selector.create_catch(_selected_fish) if _pending_catch == null or not _pending_catch.is_valid(): _cancel_attempt() return diff --git a/network/network_fishing_service.gd b/network/network_fishing_service.gd index be214e2..18b68bc 100644 --- a/network/network_fishing_service.gd +++ b/network/network_fishing_service.gd @@ -429,11 +429,21 @@ func _start_bite(attempt: NetworkFishingAttempt) -> void: return attempt.phase = NetworkFishingAttempt.Phase.FIGHTING attempt.encounter_seed = _new_seed() + var selector := FishSelectorType.new() + selector.use_deterministic_test_seed = true + selector.deterministic_test_seed = attempt.encounter_seed ^ 0x5F3759DF + selector.begin_roll() + var fish_catch: FishCatch = selector.create_catch(fish) + if fish_catch == null or not fish_catch.is_valid(): + _cancel_attempt(attempt.owner_peer_id, "Fishing attempt ended.") + return + attempt.catch_payload = fish_catch.to_network_dict() attempt.controller.start_authoritative_encounter( fish.catch_profile, attempt.reel_speed, attempt.barrier_damage, - attempt.encounter_seed + attempt.encounter_seed, + fish_catch.quality, ) var data: Dictionary = { "attempt_id": attempt.attempt_id, @@ -617,11 +627,10 @@ func _on_attempt_caught(peer_id: int) -> void: if fish == null: _cancel_attempt(peer_id, "Fishing attempt ended.") return - var selector := FishSelectorType.new() - selector.use_deterministic_test_seed = true - selector.deterministic_test_seed = attempt.encounter_seed ^ 0x5F3759DF - selector.begin_roll() - var fish_catch: FishCatch = selector.create_catch(fish) + var fish_catch: FishCatch = FishCatchType.from_network_dict( + attempt.catch_payload, + fish, + ) if fish_catch == null or not fish_catch.is_valid(): _cancel_attempt(peer_id, "Fishing attempt ended.") return diff --git a/player/player.gd b/player/player.gd index 2f0f134..8933247 100644 --- a/player/player.gd +++ b/player/player.gd @@ -31,6 +31,7 @@ const FishingRodAttachmentScene = preload( const CHARACTER_IDLE_ANIMATION: StringName = &"idle" const CHARACTER_WALKING_ANIMATION: StringName = &"walking" const CHARACTER_SITTING_ANIMATION: StringName = &"sitting" +const BASE_REEL_SPEED: float = 0.16 var appearance_snapshot: Dictionary = ( CharacterCustomizationCatalog.default_snapshot() @@ -69,7 +70,7 @@ class ShowcaseCameraSnapshot: @export var local_control_enabled: bool = true @export_category("Fishing Stats") -@export_range(0.01, 2.0, 0.01) var reel_speed: float = 0.32 +@export_range(0.01, 2.0, 0.01) var reel_speed: float = BASE_REEL_SPEED @export_range(1, 100, 1) var click_power: int = 1 @export_category("Showcase") diff --git a/tests/fishing_authority_validation.gd b/tests/fishing_authority_validation.gd index 2a98968..dc79c44 100644 --- a/tests/fishing_authority_validation.gd +++ b/tests/fishing_authority_validation.gd @@ -97,6 +97,60 @@ func _run() -> void: assert(player.is_movement_enabled()) assert(not bobber.visible) + # A private host rolls and retains the authoritative catch before the fight + # so its quality can scale the same barriers clients receive in snapshots. + fishing_spot.call("_begin_aiming", player) + fishing_spot.set("_cast_charge", 0.32) + fishing_spot.call("_update_cast_charge", 0.0) + fishing_spot.call("_confirm_cast") + var second_wait_deadline: int = Time.get_ticks_msec() + 4000 + while ( + Time.get_ticks_msec() < second_wait_deadline + and fishing_spot.state != FishingSpotType.FishingState.WAITING_FOR_BITE + ): + await process_frame + assert(fishing_spot.state == FishingSpotType.FishingState.WAITING_FOR_BITE) + attempts = service.get("_attempts") + attempt = attempts.get(session.get_local_peer_id()) + assert(attempt != null) + service.call("_start_bite", attempt) + await process_frame + assert(attempt.phase == NetworkFishingAttempt.Phase.FIGHTING) + var catalog: FishPool = main.get("fish_catalog") as FishPool + assert(catalog != null) + var fish: FishData = catalog.get_fish_by_id(attempt.fish_id) + var fish_catch := FishCatch.from_network_dict( + attempt.catch_payload, + fish, + ) + assert(fish_catch != null and fish_catch.is_valid()) + var baseline_controller := CatchController.new() + root.add_child(baseline_controller) + baseline_controller.start_authoritative_encounter( + fish.catch_profile, + attempt.reel_speed, + attempt.barrier_damage, + attempt.encounter_seed, + FishQuality.Tier.BORING, + ) + var quality_barriers: Array = attempt.controller.get("_barriers") + var baseline_barriers: Array = baseline_controller.get("_barriers") + assert(quality_barriers.size() == baseline_barriers.size()) + for barrier_index: int in quality_barriers.size(): + var quality_barrier: RefCounted = quality_barriers[barrier_index] + var baseline_barrier: RefCounted = baseline_barriers[barrier_index] + assert( + int(quality_barrier.get("maximum_health")) + == FishQuality.apply_barrier_health( + int(baseline_barrier.get("maximum_health")), + fish_catch.quality, + ) + ) + baseline_controller.queue_free() + service.call("_cancel_attempt", session.get_local_peer_id(), "") + await process_frame + assert(not service.has_local_attempt()) + print("Fishing authority validation: PASS") session.disconnect_session("") main.queue_free()