Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion client/godot/scenes/main.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)")
Expand All @@ -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)")
Expand Down
11 changes: 11 additions & 0 deletions client/godot/scripts/autoloads/world_model.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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)

Expand Down
59 changes: 47 additions & 12 deletions server/ecosim/actors/guard_actors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
))
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
57 changes: 57 additions & 0 deletions server/ecosim/actors/interaction_actors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions server/ecosim/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────────
Expand Down
5 changes: 5 additions & 0 deletions server/ecosim/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"],
Expand Down
Loading