From 13fc64b80c5cbaac023925e44882eec26bc69e91 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sat, 15 Aug 2026 09:53:45 -0400 Subject: [PATCH] Make crab behavior reflect catch quality --- gathering/gatherable_data.gd | 55 +++++++++++++++++++++ network/network_world_spawn_service.gd | 50 ++++++++++++++++--- tests/world_spawn_multiplayer_validation.gd | 15 ++++-- tests/world_spawn_protocol_validation.gd | 52 +++++++++++++++++++ 4 files changed, 161 insertions(+), 11 deletions(-) diff --git a/gathering/gatherable_data.gd b/gathering/gatherable_data.gd index f1be67c..c5164d0 100644 --- a/gathering/gatherable_data.gd +++ b/gathering/gatherable_data.gd @@ -2,6 +2,7 @@ class_name GatherableData extends Resource const FishDataType = preload("res://fish/fish_data.gd") +const FishQualityType = preload("res://fish/fish_quality.gd") @export var type_id: StringName @export var catch_data: FishDataType @@ -12,6 +13,20 @@ const FishDataType = preload("res://fish/fish_data.gd") @export_range(0.0, 10.0, 0.05) var movement_speed: float = 0.35 @export_range(0.1, 20.0, 0.1) var roam_radius: float = 3.5 @export_range(0.1, 20.0, 0.1) var scare_radius: float = 2.8 +@export var quality_movement_speed_multipliers: Array[float] = [ + 1.0, + 1.15, + 1.35, + 1.6, + 2.0, +] +@export var quality_scare_radius_multipliers: Array[float] = [ + 1.0, + 1.1, + 1.25, + 1.45, + 1.7, +] @export_range(0.1, 5.0, 0.05) var capture_radius: float = 0.7 @export_range(0.1, 10.0, 0.05) var interaction_range: float = 2.6 @export_range(0.1, 10.0, 0.05) var charge_duration: float = 2.0 @@ -37,6 +52,12 @@ func is_valid() -> bool: and movement_speed >= 0.0 and roam_radius > 0.0 and scare_radius > 0.0 + and _quality_multipliers_are_valid( + quality_movement_speed_multipliers + ) + and _quality_multipliers_are_valid( + quality_scare_radius_multipliers + ) and capture_radius > 0.0 and interaction_range > 0.0 and charge_duration > 0.0 @@ -46,6 +67,20 @@ func is_valid() -> bool: ) +func get_movement_speed_for_quality(quality: int) -> float: + return movement_speed * _quality_multiplier( + quality_movement_speed_multipliers, + quality, + ) + + +func get_scare_radius_for_quality(quality: int) -> float: + return scare_radius * _quality_multiplier( + quality_scare_radius_multipliers, + quality, + ) + + func get_respawn_delay(reason: StringName, rng: RandomNumberGenerator) -> float: var minimum_seconds: float = capture_respawn_min_seconds var maximum_seconds: float = capture_respawn_max_seconds @@ -59,3 +94,23 @@ func get_respawn_delay(reason: StringName, rng: RandomNumberGenerator) -> float: func is_available() -> bool: return catch_data != null and catch_data.active and is_valid() + + +func _quality_multiplier(values: Array[float], quality: int) -> float: + if values.size() != FishQualityType.TIER_COUNT: + return 1.0 + var safe_quality: int = ( + quality + if FishQualityType.is_valid(quality) + else FishQualityType.Tier.BORING + ) + return maxf(values[safe_quality], 0.0) + + +func _quality_multipliers_are_valid(values: Array[float]) -> bool: + if values.size() != FishQualityType.TIER_COUNT: + return false + for multiplier: float in values: + if not is_finite(multiplier) or multiplier <= 0.0: + return false + return true diff --git a/network/network_world_spawn_service.gd b/network/network_world_spawn_service.gd index 7c50731..35f7eba 100644 --- a/network/network_world_spawn_service.gd +++ b/network/network_world_spawn_service.gd @@ -5,6 +5,7 @@ const FishCatchType = preload("res://fish/fish_catch.gd") const FishDataType = preload("res://fish/fish_data.gd") const FishExperienceType = preload("res://fish/fish_experience.gd") const FishPoolType = preload("res://fish/fish_pool.gd") +const FishQualityType = preload("res://fish/fish_quality.gd") const FishSelectorType = preload("res://fish/fish_selector.gd") const GatherableCatalogType = preload("res://gathering/gatherable_catalog.gd") const GatherableDataType = preload("res://gathering/gatherable_data.gd") @@ -278,6 +279,7 @@ func _spawn_entity(entry: GatherableDataType) -> void: var position: Vector3 = _sample_surface_position(entry) if not position.is_finite(): return + var quality: int = FishQualityType.roll(_rng) var entity_id: String = _new_id("world") var state: Dictionary = { "entity_id": entity_id, @@ -286,6 +288,7 @@ func _spawn_entity(entry: GatherableDataType) -> void: "position": position, "target": _sample_surface_position(entry, position, entry.roam_radius), "yaw": _rng.randf_range(-PI, PI), + "quality": quality, "revision": 1, "locked": false, } @@ -308,6 +311,7 @@ func _update_host_entities(delta: float) -> void: var entry := state.get("data") as GatherableDataType if entry == null: continue + var quality: int = _get_state_quality(state) var position: Vector3 = state["position"] var target: Vector3 = state["target"] var horizontal_delta := Vector3( @@ -331,7 +335,7 @@ func _update_host_entities(delta: float) -> void: ) if not horizontal_delta.is_zero_approx(): var step: float = minf( - entry.movement_speed * delta, + entry.get_movement_speed_for_quality(quality) * delta, horizontal_delta.length(), ) var direction: Vector3 = horizontal_delta.normalized() @@ -345,12 +349,17 @@ func _update_host_entities(delta: float) -> void: state["yaw"] = atan2(-direction.x, -direction.z) state["position"] = position _entities[entity_id] = state - if _should_scare(entry, position): + if _should_scare(entry, position, quality): _despawn_entity(entity_id, &"scared", true, true) -func _should_scare(entry: GatherableDataType, position: Vector3) -> bool: - var radius_squared: float = entry.scare_radius * entry.scare_radius +func _should_scare( + entry: GatherableDataType, + position: Vector3, + quality: int, +) -> bool: + var scare_radius: float = entry.get_scare_radius_for_quality(quality) + var radius_squared: float = scare_radius * scare_radius for peer_id: int in _spawn_service.get_peer_ids(): var avatar: Player = _spawn_service.get_avatar(peer_id) if ( @@ -571,9 +580,7 @@ func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void: if not error.is_empty(): _send_interaction_result(peer_id, request_id, false, error) return - var selector := FishSelectorType.new() - selector.begin_roll() - var fish_catch: FishCatch = selector.create_catch(entry.catch_data) + var fish_catch: FishCatch = _create_catch_for_state(entry, state) if fish_catch == null or not fish_catch.is_valid(): _send_interaction_result(peer_id, request_id, false, "The catch could not be recorded.") return @@ -600,6 +607,35 @@ func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void: receive_capacity_probe.rpc_id(peer_id, probe) +func _create_catch_for_state( + entry: GatherableDataType, + state: Dictionary, +) -> FishCatch: + var selector := FishSelectorType.new() + selector.begin_roll() + var fish_catch: FishCatch = selector.create_catch(entry.catch_data) + if fish_catch == null: + return null + var quality: int = _get_state_quality(state) + fish_catch.quality = quality + fish_catch.sale_value = FishQualityType.apply_sale_value( + entry.catch_data.get_sale_value_for_weight(fish_catch.weight_lb), + quality, + ) + return fish_catch + + +func _get_state_quality(state: Dictionary) -> int: + var quality: int = int( + state.get("quality", FishQualityType.Tier.BORING) + ) + return ( + quality + if FishQualityType.is_valid(quality) + else FishQualityType.Tier.BORING + ) + + @rpc( "any_peer", "call_remote", diff --git a/tests/world_spawn_multiplayer_validation.gd b/tests/world_spawn_multiplayer_validation.gd index c2907f0..5378188 100644 --- a/tests/world_spawn_multiplayer_validation.gd +++ b/tests/world_spawn_multiplayer_validation.gd @@ -28,7 +28,7 @@ func _run_host() -> void: assert(session.start_dedicated_host(TEST_PORT, 8, "127.0.0.1")) assert(session.set_host_open(true)) await _wait_for_population(service) - _validate_population(service) + _validate_population(service, true) var remote_peer_id: int = 0 var join_deadline: int = Time.get_ticks_msec() + 20000 @@ -51,7 +51,7 @@ func _run_host() -> void: ): await process_frame assert(not session.is_authenticated_peer(remote_peer_id)) - _validate_population(service) + _validate_population(service, true) _validate_respawn_budget(service) print("World spawn multiplayer host validation: PASS") session.disconnect_session("") @@ -82,7 +82,7 @@ func _run_client() -> void: NetworkProtocol.WORLD_SPAWN_CAPABILITY )) await _wait_for_population(service) - _validate_population(service) + _validate_population(service, false) print("World spawn multiplayer client validation: PASS") session.disconnect_session("") main.queue_free() @@ -101,7 +101,7 @@ func _wait_for_population(service: Node) -> void: assert(false, "Timed out waiting for the world spawn population.") -func _validate_population(service: Node) -> void: +func _validate_population(service: Node, expect_authoritative_state: bool) -> void: var entities: Dictionary = service.get("_entities") var presentations: Dictionary = service.get("_presentations") assert(entities.size() == EXPECTED_POPULATION) @@ -112,6 +112,13 @@ func _validate_population(service: Node) -> void: assert(typeof(position) == TYPE_VECTOR3) assert((position as Vector3).is_finite()) assert((position as Vector3).y > 0.08) + if expect_authoritative_state: + var quality: int = int(state.get("quality", -1)) + assert(FishQuality.is_valid(quality)) + var entry := state.get("data") as GatherableData + assert(entry != null) + assert(entry.get_movement_speed_for_quality(quality) > 0.0) + assert(entry.get_scare_radius_for_quality(quality) > 0.0) func _validate_respawn_budget(service: Node) -> void: diff --git a/tests/world_spawn_protocol_validation.gd b/tests/world_spawn_protocol_validation.gd index f7d611f..2819ba7 100644 --- a/tests/world_spawn_protocol_validation.gd +++ b/tests/world_spawn_protocol_validation.gd @@ -27,6 +27,7 @@ func _validate_catalog_statuses() -> void: assert(brown.catch_data.logbook_section == FishData.LogbookSection.SHELLFISH) assert(brown.population == 2) assert(is_equal_approx(brown.charge_duration, 2.0)) + _validate_quality_behavior(brown) assert(is_equal_approx(brown.capture_respawn_min_seconds, 480.0)) assert(is_equal_approx(brown.capture_respawn_max_seconds, 720.0)) assert(is_equal_approx(brown.scare_respawn_min_seconds, 45.0)) @@ -63,6 +64,57 @@ func _validate_catalog_statuses() -> void: assert(scared_delay >= 45.0 and scared_delay <= 90.0) +func _validate_quality_behavior(entry: GatherableData) -> void: + var prior_speed: float = -1.0 + var prior_scare_radius: float = -1.0 + for quality: int in FishQuality.TIER_COUNT: + var movement_speed: float = entry.get_movement_speed_for_quality( + quality + ) + var scare_radius: float = entry.get_scare_radius_for_quality(quality) + assert(movement_speed > prior_speed) + assert(scare_radius > prior_scare_radius) + prior_speed = movement_speed + prior_scare_radius = scare_radius + assert( + is_equal_approx( + entry.get_movement_speed_for_quality(FishQuality.Tier.BORING), + entry.movement_speed, + ) + ) + assert( + is_equal_approx( + entry.get_movement_speed_for_quality(FishQuality.Tier.SHINY), + entry.movement_speed * 2.0, + ) + ) + assert( + is_equal_approx( + entry.get_scare_radius_for_quality(FishQuality.Tier.SHINY), + entry.scare_radius * 1.7, + ) + ) + var service := NetworkWorldSpawnService.new() + var spawned_catch := service.call( + "_create_catch_for_state", + entry, + {"quality": FishQuality.Tier.EXCEPTIONAL}, + ) as FishCatch + assert(spawned_catch != null and spawned_catch.is_valid()) + assert(spawned_catch.quality == FishQuality.Tier.EXCEPTIONAL) + assert( + spawned_catch.sale_value + == FishQuality.apply_sale_value( + entry.catch_data.get_sale_value_for_weight( + spawned_catch.weight_lb + ), + FishQuality.Tier.EXCEPTIONAL, + ) + ) + spawned_catch = null + service.free() + + func _validate_envelopes() -> void: var entity: Dictionary = { "entity_id": "world:sample",