From 1a8ce185ff67f0269bc732c67c40c7d624a0b3ce Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 25 Mar 2026 23:53:21 -0700 Subject: [PATCH] =?UTF-8?q?feat(events):=20=E2=9C=A8=20Introduce=20event?= =?UTF-8?q?=20handlers=20for=20marine=20harvest=20actions=20with=20validat?= =?UTF-8?q?ion,=20triggers,=20and=20optimizations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- engine/src/modules/events/marine_harvest.gd | 371 ++++++++++++++++++++ 1 file changed, 371 insertions(+) diff --git a/engine/src/modules/events/marine_harvest.gd b/engine/src/modules/events/marine_harvest.gd index b1477c06..271b25d5 100644 --- a/engine/src/modules/events/marine_harvest.gd +++ b/engine/src/modules/events/marine_harvest.gd @@ -1,2 +1,373 @@ class_name MarineHarvest extends RefCounted +## Marine ecology system: fish stock depletion/recovery, coral reef health, +## iron seeding bloom propagation, corrupted creature DoT, ocean dead fraction. +## +## Called once per full game turn by TurnManager._process_climate(), BEFORE +## climate.process_turn() so that ocean_dead_fraction is ready for evaporation scaling. + +const HexUtilsScript = preload("res://engine/src/map/hex_utils.gd") +const UnitScript = preload("res://engine/src/entities/unit.gd") + +## Marine resource IDs that track fish stock depletion. +const FISHING_RESOURCE_IDS: Array[String] = [ + "coral_reef", "merfolk_shallows", "sea_serpent_shoals", "leviathan_grounds", +] + +## All marine resource IDs (includes non-fish ones like pearl_beds, kraken_ink_vent). +const MARINE_RESOURCE_IDS: Array[String] = [ + "coral_reef", "merfolk_shallows", "sea_serpent_shoals", + "pearl_beds", "leviathan_grounds", "kraken_ink_vent", +] + +## Water biome IDs where fishing spots can migrate to. +const WATER_BIOMES: Array[String] = ["coast", "ocean", "lake", "inland_sea"] + +const STOCK_MAX: int = 100 +const STOCK_RECOVERY: int = 1 # +1 stock/turn when no boats present +const STOCK_DEPLETION_PER_BOAT: int = 3 # -3 stock/turn per fishing unit on tile +const STOCK_ABUNDANT_MIN: int = 70 # above this: full yield +const STOCK_HEALTHY_MIN: int = 40 # above this: normal yield +const STOCK_DEPLETED_MIN: int = 15 # above this: reduced yield (-1 food) + +const REEF_BLEACH_TEMP: float = 0.75 # tile.temperature above this → reef degrades +const REEF_RECOVER_TEMP: float = 0.65 # tile.temperature below this → reef can recover +const REEF_DEGRADE_RATE: float = 0.05 # health lost per turn when bleaching +const REEF_RECOVER_RATE: float = 0.02 # health gained per turn when recovering +const REEF_BLEACH_THRESHOLD: float = 0.4 # below this: partial yield; bleached signal fires +const REEF_DEAD_THRESHOLD: float = 0.0 # at this: resource removed, dead coast + +const OCEAN_WARN_YELLOW: float = 0.25 # first warning threshold +const OCEAN_WARN_ORANGE: float = 0.50 # second warning threshold + +## School affiliation of each corrupted creature type. +const CREATURE_SCHOOL: Dictionary = { + "wraith_eel": "death", + "bone_shark": "death", + "drowned_leviathan": "death", + "death_kelp": "death", + "lava_eel": "chaos", + "pyroclastic_manta": "chaos", + "infernal_serpent": "chaos", + "volcanic_jellyfish": "chaos", +} + +## Base DoT dealt per turn to non-aligned naval units. +const CREATURE_DOT: Dictionary = { + "wraith_eel": 3, + "bone_shark": 6, + "drowned_leviathan": 12, + "death_kelp": 2, + "lava_eel": 4, + "pyroclastic_manta": 8, + "infernal_serpent": 15, + "volcanic_jellyfish": 2, +} + +## Fraction of coast tiles permanently dead — updated each turn, read by climate.gd. +var ocean_dead_fraction: float = 0.0 + +## Previous warning level, used to avoid repeating the same warning every turn. +var _prev_warn_level: String = "green" + + +func process_turn(game_map: RefCounted, players: Array) -> void: + ## Run the full marine ecology tick for one game turn. + _tick_fish_stocks(game_map, players) + _tick_reef_health(game_map) + _tick_marine_blooms(game_map) + _tick_marine_creatures(game_map, players) + _compute_ocean_dead_fraction(game_map) + _check_ocean_warnings() + + +# -- Fish stock depletion / recovery / migration -- + + +func _tick_fish_stocks(game_map: RefCounted, players: Array) -> void: + for axial: Vector2i in game_map.tiles: + var tile: Variant = game_map.tiles[axial] + if tile.resource_id not in FISHING_RESOURCE_IDS: + continue + + # Initialize stock on first encounter (newly spawned resource tile) + if tile.fish_stock < 0: + tile.fish_stock = STOCK_MAX + + # Count fishing units on this tile across all players + var boat_count: int = 0 + for player: Variant in players: + for unit: Variant in player.units: + if unit is UnitScript and (unit as UnitScript).position == axial: + var unit_data: Dictionary = DataLoader.get_unit((unit as UnitScript).type_id) + if unit_data.get("fishing_capacity", 0) > 0: + boat_count += 1 + + # Marine creatures also drain stock + if tile.marine_creature != "": + boat_count += 1 # creature counts as persistent pressure source + + if boat_count > 0: + tile.fish_stock = maxi(0, tile.fish_stock - STOCK_DEPLETION_PER_BOAT * boat_count) + else: + tile.fish_stock = mini(STOCK_MAX, tile.fish_stock + STOCK_RECOVERY) + + # Migrate if stock hits zero + if tile.fish_stock == 0: + _migrate_fishing_spot(game_map, axial, tile) + + +func _migrate_fishing_spot(game_map: RefCounted, axial: Vector2i, tile: Variant) -> void: + ## Move the fishing resource to an adjacent water tile with no existing resource. + ## Permanent extinction if no valid tile found. + var candidates: Array[Vector2i] = [] + for nb_pos: Vector2i in HexUtilsScript.get_neighbors(axial): + var nb: Variant = game_map.tiles.get(nb_pos) + if nb == null: + continue + if nb.biome_id not in WATER_BIOMES: + continue + if nb.resource_id != "": + continue + if nb.fish_stock == 0: + continue # also a dead tile + candidates.append(nb_pos) + + var old_resource: String = tile.resource_id + tile.resource_id = "" + # fish_stock stays 0 — marks this tile as a dead zone for ocean_dead_fraction + + if candidates.is_empty(): + EventBus.fishing_spot_exhausted.emit(axial) + return + + # Pick the candidate with the highest stock (healthiest water) + var best_pos: Vector2i = candidates[0] + var best_stock: int = -1 + for pos: Vector2i in candidates: + var nb: Variant = game_map.tiles[pos] + var nb_stock: int = nb.fish_stock if nb.fish_stock >= 0 else STOCK_MAX + if nb_stock > best_stock: + best_stock = nb_stock + best_pos = pos + + var dest: Variant = game_map.tiles[best_pos] + dest.resource_id = old_resource + dest.fish_stock = STOCK_MAX / 2 # migrated stock starts at half capacity + EventBus.fishing_spot_migrated.emit(axial, best_pos) + + +# -- Coral reef health -- + + +func _tick_reef_health(game_map: RefCounted) -> void: + for axial: Vector2i in game_map.tiles: + var tile: Variant = game_map.tiles[axial] + if tile.resource_id != "coral_reef": + continue + + var health_before: float = tile.reef_health + + # Determine whether conditions favor bleaching or recovery + var bleaching: bool = _is_bleaching(game_map, axial, tile) + + if bleaching: + tile.reef_health = maxf(0.0, tile.reef_health - REEF_DEGRADE_RATE) + elif tile.temperature < REEF_RECOVER_TEMP: + tile.reef_health = minf(1.0, tile.reef_health + REEF_RECOVER_RATE) + + # Bleached threshold crossing — signal fires once per crossing + if health_before >= REEF_BLEACH_THRESHOLD and tile.reef_health < REEF_BLEACH_THRESHOLD: + EventBus.reef_bleached.emit(axial) + + # Reef death + if tile.reef_health <= REEF_DEAD_THRESHOLD: + tile.resource_id = "" + tile.fish_stock = 0 # mark as dead coast for ocean_dead_fraction + EventBus.reef_died.emit(axial) + + +func _is_bleaching(_game_map: RefCounted, _axial: Vector2i, tile: Variant) -> bool: + ## True if temperature is above bleaching threshold. + return tile.temperature > REEF_BLEACH_TEMP + + +# -- Iron seeding bloom propagation -- + + +func _tick_marine_blooms(game_map: RefCounted) -> void: + ## Decrement bloom counters. The +2 food bonus is applied directly in tile.get_quality_yields(). + for axial: Vector2i in game_map.tiles: + var tile: Variant = game_map.tiles[axial] + if tile.marine_bloom_turns <= 0: + continue + tile.marine_bloom_turns -= 1 + if tile.marine_bloom_turns == 0 and tile.fish_stock >= 0: + # Bloom expiry restores some stock on the host tile + tile.fish_stock = mini(STOCK_MAX, tile.fish_stock + 20) + + +func apply_iron_seeding(game_map: RefCounted, origin: Vector2i, current_dir: int) -> void: + ## Trigger a bloom propagating downstream along ocean currents. + ## Call from the Marine Engineer's Iron Seeding action. + ## current_dir: hex edge index (0-5) representing the ocean current direction. + ## The bloom spreads 1 tile per turn for 6 turns, staying within water tiles. + var pos: Vector2i = origin + for _step: int in 6: + var nb_pos: Vector2i = pos + HexUtilsScript.AXIAL_DIRECTIONS[current_dir] + var nb: Variant = game_map.tiles.get(nb_pos) + if nb == null or nb.biome_id not in WATER_BIOMES: + break + nb.marine_bloom_turns = maxi(nb.marine_bloom_turns, 6 - _step) + if nb.fish_stock >= 0: + nb.fish_stock = mini(STOCK_MAX, nb.fish_stock + 5) + pos = nb_pos + + +# -- Corrupted marine creature DoT -- + + +func _tick_marine_creatures(game_map: RefCounted, players: Array) -> void: + for axial: Vector2i in game_map.tiles: + var tile: Variant = game_map.tiles[axial] + if tile.marine_creature == "": + continue + var creature: String = tile.marine_creature + var creature_school: String = CREATURE_SCHOOL.get(creature, "") + var base_dot: int = CREATURE_DOT.get(creature, 0) + + # Drain stock from the infested tile + if tile.fish_stock >= 0: + tile.fish_stock = maxi(0, tile.fish_stock - 2) + if tile.fish_stock == 0 and tile.resource_id != "": + # Infested spots extinguish rather than migrate — directly kills the spot + tile.resource_id = "" + EventBus.fishing_spot_exhausted.emit(axial) + + # Apply DoT to non-aligned naval units on this tile + for player: Variant in players: + var player_schools: Array = player.get("schools", []) + var immune: bool = _is_immune(player_schools, creature_school) + + for unit: Variant in player.units: + if not unit is UnitScript: + continue + if (unit as UnitScript).position != axial: + continue + if not _is_naval_unit(unit as UnitScript): + continue + if immune: + continue + + var dot: int = _compute_dot(base_dot, player_schools, creature_school) + (unit as UnitScript).take_damage(dot) + EventBus.climate_damage_applied.emit(unit, "marine_creature", dot) + + +func _is_immune(player_schools: Array, creature_school: String) -> bool: + ## Death and Chaos players are immune to each other's (and their own) creatures. + if creature_school == "death" or creature_school == "chaos": + return "death" in player_schools or "chaos" in player_schools + return false + + +func _compute_dot(base_dot: int, player_schools: Array, creature_school: String) -> int: + ## Life players take 50% more damage from death creatures. + ## Nature players take 50% more damage from chaos creatures. + if creature_school == "death" and "life" in player_schools: + return roundi(base_dot * 1.5) + if creature_school == "chaos" and "nature" in player_schools: + return roundi(base_dot * 1.5) + return base_dot + + +func _is_naval_unit(unit: UnitScript) -> bool: + var unit_data: Dictionary = DataLoader.get_unit(unit.type_id) + return "naval_only" in unit_data.get("flags", []) + + +# -- Ocean dead fraction -- + + +func _compute_ocean_dead_fraction(game_map: RefCounted) -> void: + ## dead_coast_tiles: coast/ocean tiles where fish_stock == 0 and resource_id == "" + ## (meaning a resource once lived there and was permanently destroyed). + ## Divided by total coast tile count. + var total_coast: int = 0 + var dead_coast: int = 0 + for axial: Vector2i in game_map.tiles: + var tile: Variant = game_map.tiles[axial] + if tile.biome_id != "coast": + continue + total_coast += 1 + if tile.fish_stock == 0 and tile.resource_id == "": + dead_coast += 1 + if total_coast == 0: + ocean_dead_fraction = 0.0 + else: + ocean_dead_fraction = float(dead_coast) / float(total_coast) + + +func _check_ocean_warnings() -> void: + var level: String + if ocean_dead_fraction >= OCEAN_WARN_ORANGE: + level = "orange" + elif ocean_dead_fraction >= OCEAN_WARN_YELLOW: + level = "yellow" + else: + level = "green" + + if level != _prev_warn_level and level != "green": + EventBus.ocean_health_warning.emit(ocean_dead_fraction, level) + _prev_warn_level = level + + +# -- Spell / action entry points -- + + +func apply_reef_restore(tile: Variant, amount: float) -> void: + ## Life spell: Coral Tendril / Sanctify Waters. Directly boost reef health. + if tile.resource_id == "coral_reef": + tile.reef_health = minf(1.0, tile.reef_health + amount) + + +func apply_stock_restore(tile: Variant, amount: int) -> void: + ## Life: Rejuvenate Fishing Spot. Aether: Upwelling. Nature: Phytoplankton Bloom. + if tile.fish_stock < 0 and tile.resource_id in FISHING_RESOURCE_IDS: + tile.fish_stock = STOCK_MAX / 2 + tile.fish_stock = mini(STOCK_MAX, tile.fish_stock + amount) + + +func apply_necro_bloom(game_map: RefCounted, axial: Vector2i) -> void: + ## Death spell: restore a permanently extinct fishing spot (the only way to do so). + var tile: Variant = game_map.tiles.get(axial) + if tile == null: + return + if tile.fish_stock != 0 or tile.resource_id != "": + return # not a dead spot + + # Resurrect as merfolk_shallows (closest generic coastal resource) + tile.resource_id = "merfolk_shallows" if tile.biome_id == "coast" else "leviathan_grounds" + tile.fish_stock = 50 + EventBus.fishing_spot_migrated.emit(axial, axial) # "migrated" back to same tile + + +func spawn_marine_creature(game_map: RefCounted, axial: Vector2i, creature: String) -> void: + ## Death/Chaos spell entry point: place a corrupted creature on a water tile. + var tile: Variant = game_map.tiles.get(axial) + if tile == null or tile.biome_id not in WATER_BIOMES: + return + if tile.marine_creature != "": + return # already occupied + tile.marine_creature = creature + EventBus.marine_creature_spawned.emit(axial, creature) + + +func clear_marine_creature(game_map: RefCounted, axial: Vector2i) -> void: + ## Remove a corrupted creature (Life: Sanctify Waters, Aether: Tidal Surge, combat). + var tile: Variant = game_map.tiles.get(axial) + if tile == null or tile.marine_creature == "": + return + var creature: String = tile.marine_creature + tile.marine_creature = "" + EventBus.marine_creature_cleared.emit(axial, creature)