feat(events): Introduce event handlers for marine harvest actions with validation, triggers, and optimizations

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-25 23:53:21 -07:00
parent ddf41217ca
commit 1a8ce185ff

View file

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