From ea47c656cfa6c9a9f7c660e6c67ab4c2e4d779b1 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Mon, 22 Jun 2026 13:10:05 -0400 Subject: [PATCH 1/3] feat: add sex display for mobile consumers and improve fleeing exit logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add sex field serialization in engine (updates + spawns) for ANIMAL/BIRD/INSECT - Add sex field to WorldEntity and apply it on updates/spawns - Display sex symbol (♂/♀) in selection billboard for sexed entity types - Add sex info to the drive legend for ANIMAL, BIRD, INSECT types - Add FLEE_MAX_DURATION constant: force exit FLEEING after 15 ticks - Add FLEE_REPRO_DRIVE_EXIT constant: abandon fleeing when reproductive drive > 0.6 - Track _flee_start_tick in FleeActor so guard can enforce timeout --- client/godot/scenes/main.gd | 17 +++++++++++++- client/godot/scripts/autoloads/world_model.gd | 11 ++++++++++ server/ecosim/actors/guard_actors.py | 22 +++++++++++++++++++ server/ecosim/actors/interaction_actors.py | 10 +++++++++ server/ecosim/constants.py | 2 ++ server/ecosim/engine.py | 5 +++++ 6 files changed, 66 insertions(+), 1 deletion(-) diff --git a/client/godot/scenes/main.gd b/client/godot/scenes/main.gd index 798374d..008c39f 100644 --- a/client/godot/scenes/main.gd +++ b/client/godot/scenes/main.gd @@ -404,10 +404,12 @@ func _update_selection_billboard() -> void: selection_label.visible = true var ent = _selected_entity var type_emoji: String = _get_type_emoji(ent.type) - selection_label.text = "%s %s · %s\n%s" % [ + var sex_text: String = _get_sex_display(ent) + selection_label.text = "%s %s · %s %s\n%s" % [ type_emoji, ent.species.capitalize(), ent.state, + sex_text, _format_drives(ent) ] legend_label.visible = true @@ -416,6 +418,17 @@ func _update_selection_billboard() -> void: selection_label.visible = false legend_label.visible = false +## Return a sex display string (e.g. "♂" or "♀") for sexed entity types, or empty. +func _get_sex_display(ent) -> String: + if ent.type not in ["ANIMAL", "BIRD", "INSECT"]: + return "" + match ent.sex: + "male": + return "♂" + "female": + return "♀" + return "" + ## Return an emoji for the entity type. func _get_type_emoji(etype: String) -> String: match etype: @@ -486,6 +499,7 @@ func _build_legend(etype: String) -> String: lines.append("─ Stats ─") match etype: "ANIMAL", "BIRD": + lines.append("♂/♀ Sex (male/female)") lines.append("🍖 Hunger (0–100)") lines.append("⚡ Energy (0–100)") lines.append("💧 Hydration (0–100)") @@ -499,6 +513,7 @@ func _build_legend(etype: String) -> String: lines.append("❤️ Health (0–100)") lines.append("⏳ Age (ticks)") "INSECT": + lines.append("♂/♀ Sex (male/female)") lines.append("🍖 Hunger (0–100)") lines.append("⚡ Energy (0–100)") lines.append("🐝 Colony health (0–100)") diff --git a/client/godot/scripts/autoloads/world_model.gd b/client/godot/scripts/autoloads/world_model.gd index 59c7dda..68de4c0 100644 --- a/client/godot/scripts/autoloads/world_model.gd +++ b/client/godot/scripts/autoloads/world_model.gd @@ -52,6 +52,9 @@ class WorldEntity: var sync_phase: int = 0 var sync_speed: float = 1.0 + ## Sex ("male", "female", or "" for asexual types) + var sex: String = "" + ## Rendering var facing_angle: float = 0.0 var alive: bool = true @@ -143,6 +146,10 @@ func apply_update(data: Dictionary) -> void: ent.can_drink = data.get("_can_drink", false) ent.ack = data.get("_ack", false) + # Sex — only for mobile consumer types + if ent.type in ["ANIMAL", "BIRD", "INSECT"]: + ent.sex = data.get("sex", "") + ## Apply entity spawn from tick packet. func apply_spawn(data: Dictionary) -> void: @@ -169,6 +176,10 @@ func apply_spawn(data: Dictionary) -> void: if latent is Array: ent.motion_latent = PackedFloat32Array(latent) + # Sex — only for mobile consumer types + if ent.type in ["ANIMAL", "BIRD", "INSECT"]: + ent.sex = data.get("sex", "") + entities[eid] = ent entity_spawned.emit(eid) diff --git a/server/ecosim/actors/guard_actors.py b/server/ecosim/actors/guard_actors.py index b6b1d55..410bf61 100644 --- a/server/ecosim/actors/guard_actors.py +++ b/server/ecosim/actors/guard_actors.py @@ -21,6 +21,8 @@ CARNIVORE_HUNT_HUNGER, DEHYDRATION_HYDRATION, DORMANCY_RECOVERY_EXIT_HEALTH, + FLEE_MAX_DURATION, + FLEE_REPRO_DRIVE_EXIT, POLLINATOR_VISIT_LIMIT, POLLINATOR_WANDER_COOLDOWN, ) @@ -191,7 +193,27 @@ def resolve(self, ctx: Any) -> list[Any]: # ── Fleeing (managed by interaction resolver) ── elif ctx.entity["state"] == "FLEEING": + flee_exit = False + + # Exit when escape target is reached (normal path). if ctx.entity.get("_target") is None: + flee_exit = True + + # Timeout: force exit after FLEE_MAX_DURATION ticks. + # Prevents entities from being trapped in FLEEING indefinitely + # (e.g. predator still within sensory range after arrival). + else: + flee_start = ctx.entity.get("_flee_start_tick", ctx.tick) + if ctx.tick - flee_start >= FLEE_MAX_DURATION: + flee_exit = True + + # High reproductive drive: abandon fleeing to seek a mate. + # This lets populations recover when individuals have strong + # reproductive pressure and the threat has passed or is distant. + if not flee_exit and sv.get("reproductive_drive", 0) > FLEE_REPRO_DRIVE_EXIT: + flee_exit = True + + if flee_exit: effects.append(StateTransition( entity_id=ctx.entity["id"], new_state="IDLE", tick=ctx.tick, )) diff --git a/server/ecosim/actors/interaction_actors.py b/server/ecosim/actors/interaction_actors.py index df9463a..4bec175 100644 --- a/server/ecosim/actors/interaction_actors.py +++ b/server/ecosim/actors/interaction_actors.py @@ -30,6 +30,7 @@ from ..config import SIM_CONFIG from ..constants import ( FLEE_ESCAPE_DISTANCE, + FLEE_MAX_DURATION, HERBIVORY_CONSUME_DISTANCE, HERBIVORY_MIN_HUNGER, OM_DEPOSIT_MAX, @@ -124,6 +125,15 @@ def resolve(self, ctx: Any) -> list[Effect]: position=escape_pos, tick=ctx.tick, ), + # Track when fleeing started so the guard can enforce a timeout. + # This prevents entities from being trapped in FLEEING + # indefinitely (e.g. predator still in range after arrival). + SetEntityAttr( + entity_id=ctx.entity["id"], + attr_name="_flee_start_tick", + value=float(ctx.tick), + tick=ctx.tick, + ), EventRecord( event_type="STATE_CHANGE", source_id=ctx.entity["id"], diff --git a/server/ecosim/constants.py b/server/ecosim/constants.py index ac9c8be..e09deaa 100644 --- a/server/ecosim/constants.py +++ b/server/ecosim/constants.py @@ -71,6 +71,8 @@ POLLINATION_VISIT_DISTANCE = 2.0 # pollinator must be this close to visit a flower HERBIVORY_MIN_HUNGER = 0.2 # minimum hunger to trigger consumption FLEE_ESCAPE_DISTANCE = 8.0 # how far prey runs from predator +FLEE_MAX_DURATION = 15 # max ticks in FLEEING before forced exit to IDLE +FLEE_REPRO_DRIVE_EXIT = 0.6 # reproductive_drive above this exits FLEEING early CARNIVORE_HUNT_HUNGER = 0.5 # hunger above this → HUNTING instead of FORAGING # ── Movement ────────────────────────────────────────────────────────────────── diff --git a/server/ecosim/engine.py b/server/ecosim/engine.py index 199f1d4..e77e514 100755 --- a/server/ecosim/engine.py +++ b/server/ecosim/engine.py @@ -1134,6 +1134,10 @@ def _build_tick_packet(self, dt: float) -> dict[str, Any]: sv.get("nutrient_store", 0.0), 4, ) + # Sex — sent for mobile consumer types (reproduction context) + if e["type"] in ("ANIMAL", "BIRD", "INSECT"): + update["sex"] = e.get("sex") + # Motion latent — 4D vector encoding movement disposition if e.get("skeleton_id") or (params and params.speed > 0): update["motion_latent"] = e.get( @@ -1216,6 +1220,7 @@ def _build_tick_packet(self, dt: float) -> dict[str, Any]: "id": s["id"], "type": s["type"], "species": s.get("species"), + "sex": s.get("sex"), "ref_position": [round(v, 4) for v in s["position"]], "skeleton_id": s.get("skeleton_id"), "state": s["state"], From 1b1b14c4383c0eea771635535e19874d400ae21a Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Mon, 22 Jun 2026 13:35:54 -0400 Subject: [PATCH 2/3] feat: add feeding bout momentum and population-based insect predation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MIN_FORAGING_BOUT_FEEDS (3): entities must complete at least 3 successful feeding events before the guard allows FORAGING/HUNTING exit. Prevents the 'one bite → exit → rest 37 ticks' cycle where a single relief value exceeds the hunger hysteresis gap. - Add OMNIVORE_INSECT_MIN_PREY_COUNT (5): omnivores skip insect/pollinator predation when living prey count is below 5. This prevents early extinction of small prey populations (e.g. 4 butterflies at sim start) and lets omnivores fall back to plant foraging instead. - Track _foraging_bout_feeds counter: reset on FORAGING/HUNTING entry, incremented on each successful predation or herbivory event. --- server/ecosim/actors/guard_actors.py | 37 ++++++++++------ server/ecosim/actors/interaction_actors.py | 50 ++++++++++++++++++++++ server/ecosim/constants.py | 13 ++++++ 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/server/ecosim/actors/guard_actors.py b/server/ecosim/actors/guard_actors.py index 410bf61..70340e5 100644 --- a/server/ecosim/actors/guard_actors.py +++ b/server/ecosim/actors/guard_actors.py @@ -23,6 +23,7 @@ DORMANCY_RECOVERY_EXIT_HEALTH, FLEE_MAX_DURATION, FLEE_REPRO_DRIVE_EXIT, + MIN_FORAGING_BOUT_FEEDS, POLLINATOR_VISIT_LIMIT, POLLINATOR_WANDER_COOLDOWN, ) @@ -272,18 +273,25 @@ def resolve(self, ctx: Any) -> list[Any]: # ── Foraging / Hunting (hysteresis) ── elif ctx.entity["state"] in ("FORAGING", "HUNTING"): if sv["hunger"] < p.hunger_exit: - # Pollinators transition to WANDERING instead of IDLE when satiated. - # This keeps them moving and searching for flowers rather than - # sitting still, which prevents FORAGING↔IDLE chattering after - # each pollination visit (relief drops hunger below exit threshold). - if p.floral_affinity: - effects.append(StateTransition( - entity_id=ctx.entity["id"], new_state="WANDERING", tick=ctx.tick, - )) - else: - effects.append(StateTransition( - entity_id=ctx.entity["id"], new_state="IDLE", tick=ctx.tick, - )) + # Feeding bout momentum: require a minimum number of successful + # feeding events before allowing exit from FORAGING/HUNTING. + # Without this, a single relief value (e.g. 0.10 from one kill) + # exceeds the hunger hysteresis gap (e.g. 0.105), causing the + # entity to exit after 1-2 bites, then rest for dozens of ticks. + bout_feeds = ctx.entity.get("_foraging_bout_feeds", 0) + if bout_feeds >= MIN_FORAGING_BOUT_FEEDS: + # Pollinators transition to WANDERING instead of IDLE when satiated. + # This keeps them moving and searching for flowers rather than + # sitting still, which prevents FORAGING↔IDLE chattering after + # each pollination visit (relief drops hunger below exit threshold). + if p.floral_affinity: + effects.append(StateTransition( + entity_id=ctx.entity["id"], new_state="WANDERING", tick=ctx.tick, + )) + else: + effects.append(StateTransition( + entity_id=ctx.entity["id"], new_state="IDLE", tick=ctx.tick, + )) elif self._should_hunt(ctx, p, sv): effects.append(StateTransition( entity_id=ctx.entity["id"], new_state="HUNTING", tick=ctx.tick, @@ -312,6 +320,11 @@ def resolve(self, ctx: Any) -> list[Any]: effects.append(StateTransition( entity_id=ctx.entity["id"], new_state="FORAGING", tick=ctx.tick, )) + # Reset feeding bout counter on entry to FORAGING/HUNTING. + effects.append(SetEntityAttr( + entity_id=ctx.entity["id"], attr_name="_foraging_bout_feeds", + value=0.0, tick=ctx.tick, + )) # ── Default ── else: diff --git a/server/ecosim/actors/interaction_actors.py b/server/ecosim/actors/interaction_actors.py index 4bec175..5e85ec3 100644 --- a/server/ecosim/actors/interaction_actors.py +++ b/server/ecosim/actors/interaction_actors.py @@ -33,9 +33,11 @@ FLEE_MAX_DURATION, HERBIVORY_CONSUME_DISTANCE, HERBIVORY_MIN_HUNGER, + MIN_FORAGING_BOUT_FEEDS, OM_DEPOSIT_MAX, OM_DEPOSIT_MIN, OM_DEPOSIT_SCALE, + OMNIVORE_INSECT_MIN_PREY_COUNT, POLLINATION_HEALTH_BOOST, POLLINATION_VISIT_DISTANCE, POLLINATOR_CROWD_RADIUS, @@ -223,6 +225,18 @@ def resolve(self, ctx: Any) -> list[Effect]: if not prey_species: return [] + # Omnivore population-based check: skip insect/pollinator prey when + # population is too low. This prevents early extinction of small prey + # populations and lets omnivores fall back to plant food. + if p.diet_type == "omnivore": + living_insect_prey = self._count_living_insect_prey(p, ctx) + if living_insect_prey > 0 and living_insect_prey < OMNIVORE_INSECT_MIN_PREY_COUNT: + # Population too low — skip insect predation, let it forage plants + return [] + elif living_insect_prey == 0: + # No insect prey alive — skip predation entirely + return [] + prey = None best_dist = float("inf") for other in ctx.nearby_entities: @@ -254,6 +268,14 @@ def resolve(self, ctx: Any) -> list[Effect]: delta=p.predation_energy_gain, tick=ctx.tick, ), + # Increment feeding bout counter so the guard requires + # MIN_FORAGING_BOUT_FEEDS before allowing FORAGING/HUNTING exit. + SetEntityAttr( + entity_id=ctx.entity["id"], + attr_name="_foraging_bout_feeds", + value=float(ctx.entity.get("_foraging_bout_feeds", 0) + 1), + tick=ctx.tick, + ), # Prey is killed SetStateVar( entity_id=prey["id"], @@ -300,6 +322,26 @@ def _distance(a: list[float], b: list[float]) -> float: dz = a[2] - b[2] return math.sqrt(dx * dx + dz * dz) + @staticmethod + def _count_living_insect_prey(p: Any, ctx: Any) -> int: + """Count living prey species that match insect/pollinator diet tags.""" + insect_groups = {"pollinator", "insect", "arthropod"} + prey_species = [] + diet_order = ctx.compiled.get_diet_order(p.species_id) if ctx.compiled else [] + for target_species, _ in diet_order: + interactions = ctx.compiled.get_interactions(p.species_id, target_species) + if any(ix.interaction_type == "predation" for ix in interactions): + # Check if this prey is targeted by an insect diet tag + prey_species.append(target_species) + all_entities = getattr(ctx, "_entities", {}) + if not all_entities: + return 0 + return sum( + 1 for e in all_entities.values() + if e.get("species") in prey_species + and e.get("state") not in ("DEAD", "DYING") + ) + @staticmethod def _compute_om_deposit(entity: dict, params: Any) -> float: """Compute organic matter deposit from entity biomass.""" @@ -393,6 +435,14 @@ def resolve(self, ctx: Any) -> list[Effect]: position=list(plant["position"]), tick=ctx.tick, ), + # Increment feeding bout counter so the guard requires + # MIN_FORAGING_BOUT_FEEDS before allowing FORAGING/HUNTING exit. + SetEntityAttr( + entity_id=ctx.entity["id"], + attr_name="_foraging_bout_feeds", + value=float(ctx.entity.get("_foraging_bout_feeds", 0) + 1), + tick=ctx.tick, + ), ] return effects diff --git a/server/ecosim/constants.py b/server/ecosim/constants.py index e09deaa..df371d9 100644 --- a/server/ecosim/constants.py +++ b/server/ecosim/constants.py @@ -67,6 +67,19 @@ # ── Predation & herbivory distances ─────────────────────────────────────────── PREDATION_CATCH_DISTANCE = 1.5 # predator must be this close to catch + +# ── Feeding bout momentum ───────────────────────────────────────────────────── +# Consumers need at least this many successful feeding events per FORAGING/HUNTING +# bout before the hunger exit check is considered. Prevents the "one bite → exit +# → rest for dozens of ticks" cycle where a single relief value exceeds the +# hunger hysteresis gap. +MIN_FORAGING_BOUT_FEEDS = 3 + +# ── Omnivore population-based predation ──────────────────────────────────────── +# When an omnivore targets insect/pollinator prey, it only hunts if the living +# prey count is at least this many. Below this, it ignores insects and forages +# plants instead — prevents early extinction of small prey populations. +OMNIVORE_INSECT_MIN_PREY_COUNT = 5 HERBIVORY_CONSUME_DISTANCE = 2.0 # herbivore must be this close to eat POLLINATION_VISIT_DISTANCE = 2.0 # pollinator must be this close to visit a flower HERBIVORY_MIN_HUNGER = 0.2 # minimum hunger to trigger consumption From 7fa547f32a28b02743e511315fcaf4b6a4e99645 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Mon, 22 Jun 2026 13:42:07 -0400 Subject: [PATCH 3/3] fix: remove unused imports and dead variable in interaction_actors - Remove unused FLEE_MAX_DURATION import (used only in guard_actors) - Remove unused MIN_FORAGING_BOUT_FEEDS import (used only in guard_actors) - Remove unused insect_groups variable in _count_living_insect_prey --- server/ecosim/actors/interaction_actors.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/server/ecosim/actors/interaction_actors.py b/server/ecosim/actors/interaction_actors.py index 5e85ec3..55df62e 100644 --- a/server/ecosim/actors/interaction_actors.py +++ b/server/ecosim/actors/interaction_actors.py @@ -30,10 +30,8 @@ from ..config import SIM_CONFIG from ..constants import ( FLEE_ESCAPE_DISTANCE, - FLEE_MAX_DURATION, HERBIVORY_CONSUME_DISTANCE, HERBIVORY_MIN_HUNGER, - MIN_FORAGING_BOUT_FEEDS, OM_DEPOSIT_MAX, OM_DEPOSIT_MIN, OM_DEPOSIT_SCALE, @@ -325,7 +323,6 @@ def _distance(a: list[float], b: list[float]) -> float: @staticmethod def _count_living_insect_prey(p: Any, ctx: Any) -> int: """Count living prey species that match insect/pollinator diet tags.""" - insect_groups = {"pollinator", "insect", "arthropod"} prey_species = [] diet_order = ctx.compiled.get_diet_order(p.species_id) if ctx.compiled else [] for target_species, _ in diet_order: