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..70340e5 100644 --- a/server/ecosim/actors/guard_actors.py +++ b/server/ecosim/actors/guard_actors.py @@ -21,6 +21,9 @@ CARNIVORE_HUNT_HUNGER, DEHYDRATION_HYDRATION, DORMANCY_RECOVERY_EXIT_HEALTH, + FLEE_MAX_DURATION, + FLEE_REPRO_DRIVE_EXIT, + MIN_FORAGING_BOUT_FEEDS, POLLINATOR_VISIT_LIMIT, POLLINATOR_WANDER_COOLDOWN, ) @@ -191,7 +194,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, )) @@ -250,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, @@ -290,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 df9463a..55df62e 100644 --- a/server/ecosim/actors/interaction_actors.py +++ b/server/ecosim/actors/interaction_actors.py @@ -35,6 +35,7 @@ OM_DEPOSIT_MAX, OM_DEPOSIT_MIN, OM_DEPOSIT_SCALE, + OMNIVORE_INSECT_MIN_PREY_COUNT, POLLINATION_HEALTH_BOOST, POLLINATION_VISIT_DISTANCE, POLLINATOR_CROWD_RADIUS, @@ -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"], @@ -213,6 +223,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: @@ -244,6 +266,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"], @@ -290,6 +320,25 @@ 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.""" + 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.""" @@ -383,6 +432,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 ac9c8be..df371d9 100644 --- a/server/ecosim/constants.py +++ b/server/ecosim/constants.py @@ -67,10 +67,25 @@ # ── 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 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"],