diff --git a/client/browser/index.html b/client/browser/index.html
index 05f7a65..a872ffd 100644
--- a/client/browser/index.html
+++ b/client/browser/index.html
@@ -185,6 +185,7 @@
+
@@ -222,6 +223,8 @@
līlā
// Entities
deer: '#c4956a',
+ bird: '#8a7b6b',
+ birdSong: 'rgba(196, 170, 120, ', // prefix for alpha
butterfly: '#a87cc4',
oak: '#3d6b3d',
oakCanopy: 'rgba(61, 107, 61, 0.12)',
@@ -730,6 +733,7 @@
līlā
{ types: ['PLANT'], species: ['meadow_grass'], draw: drawGrass },
{ types: ['PLANT'], species: ['wildflower'], draw: drawFlower },
{ types: ['ANIMAL'], draw: drawDeer },
+ { types: ['BIRD'], draw: drawBird },
{ types: ['INSECT'], draw: drawButterfly },
];
@@ -915,6 +919,168 @@
līlā
ctx.fillText(state.toLowerCase(), cx, cz + size + 10);
}
+// ─── Songbird Rendering ──────────────────────────────
+
+// Persistent song ring particles keyed by entity id
+const birdSongRings = new Map();
+
+function drawBird(ctx, cx, cz, ent) {
+ const state = ent.state;
+ const time = performance.now() * 0.001;
+
+ // Direction from velocity (prev → next)
+ const dx = ent.next.x - ent.prev.x;
+ const dz = ent.next.z - ent.prev.z;
+ const angle = Math.atan2(dz, dx);
+
+ ctx.save();
+ ctx.translate(cx, cz);
+ ctx.rotate(angle);
+
+ // Wing flap rate depends on state
+ let flapSpeed = 6; // default cruising
+ let flapAmp = 0.45; // wing arc amplitude (radians)
+ if (state === 'HUNTING' || state === 'FLEEING') {
+ flapSpeed = 14;
+ flapAmp = 0.6;
+ } else if (state === 'FORAGING') {
+ flapSpeed = 4;
+ flapAmp = 0.35;
+ } else if (state === 'RESTING' || state === 'DRINKING') {
+ flapSpeed = 1;
+ flapAmp = 0.1;
+ }
+
+ const wingAngle = Math.sin(time * flapSpeed + cx * 0.3) * flapAmp;
+
+ // Body — small elongated teardrop (longer than wide)
+ const bodyLen = 6;
+ const bodyWid = 2.5;
+
+ ctx.fillStyle = COLORS.bird;
+ ctx.beginPath();
+ ctx.moveTo(bodyLen, 0); // beak tip
+ ctx.quadraticCurveTo(bodyLen * 0.3, -bodyWid, -bodyLen * 0.6, 0); // top curve to tail
+ ctx.quadraticCurveTo(bodyLen * 0.3, bodyWid, bodyLen, 0); // bottom curve back
+ ctx.fill();
+
+ // Tail — dovetail (filled diamond notch)
+ const tailSpread = state === 'FLEEING' ? 4 : 2.5;
+ const tailBaseX = -bodyLen * 0.5;
+ const tailTipX = -bodyLen * 0.9;
+ ctx.fillStyle = '#6b5e52';
+ ctx.beginPath();
+ ctx.moveTo(tailBaseX, -tailSpread);
+ ctx.lineTo(tailTipX, -tailSpread * 0.3); // upper outer → inner notch
+ ctx.lineTo(tailTipX + 1, 0); // center cleft (slightly forward)
+ ctx.lineTo(tailTipX, tailSpread * 0.3); // lower inner notch
+ ctx.lineTo(tailBaseX, tailSpread);
+ ctx.closePath();
+ ctx.fill();
+
+ // Wings — two arcs above and below body, rotating with flap
+ const wingLen = 7;
+ const wingBaseX = 0.5;
+
+ ctx.strokeStyle = COLORS.bird;
+ ctx.lineWidth = 1.2;
+ ctx.globalAlpha = 0.6 + Math.abs(Math.sin(time * flapSpeed)) * 0.4;
+
+ // Upper wing (sweeps upward)
+ ctx.beginPath();
+ ctx.moveTo(wingBaseX, -bodyWid * 0.5);
+ const uwEndX = wingBaseX - wingLen * Math.cos(wingAngle);
+ const uwEndY = -wingLen * Math.sin(Math.abs(wingAngle) + 0.3);
+ ctx.quadraticCurveTo(
+ wingBaseX - wingLen * 0.4, -bodyWid - wingLen * 0.5,
+ uwEndX, uwEndY
+ );
+ ctx.stroke();
+
+ // Lower wing (sweeps downward)
+ ctx.beginPath();
+ ctx.moveTo(wingBaseX, bodyWid * 0.5);
+ const lwEndX = wingBaseX - wingLen * Math.cos(-wingAngle);
+ const lwEndY = wingLen * Math.sin(Math.abs(wingAngle) + 0.3);
+ ctx.quadraticCurveTo(
+ wingBaseX - wingLen * 0.4, bodyWid + wingLen * 0.5,
+ lwEndX, lwEndY
+ );
+ ctx.stroke();
+
+ ctx.globalAlpha = 1;
+ ctx.restore();
+
+ // ── Song rings (IDLE / REPRODUCING states) ──
+ if (state === 'IDLE' || state === 'REPRODUCING') {
+ const ringKey = ent.id;
+ let rings = birdSongRings.get(ringKey);
+ if (!rings) {
+ rings = [];
+ birdSongRings.set(ringKey, rings);
+ }
+
+ // Spawn a new ring every ~1.2 seconds (staggered per entity)
+ const spawnInterval = 1200 + (ent.id.charCodeAt(ent.id.length - 1) % 600);
+ if (!rings.lastSpawn || time * 1000 - rings.lastSpawn > spawnInterval) {
+ rings.push({ birth: time, maxAge: 2.5 });
+ rings.lastSpawn = time * 1000;
+ }
+
+ // Draw and cull rings
+ for (let i = rings.length - 1; i >= 0; i--) {
+ const ring = rings[i];
+ const age = time - ring.birth;
+ if (age > ring.maxAge) { rings.splice(i, 1); continue; }
+
+ const progress = age / ring.maxAge;
+ const radius = 4 + progress * 20;
+ const alpha = (1 - progress) * 0.35;
+
+ ctx.strokeStyle = COLORS.birdSong + alpha + ')';
+ ctx.lineWidth = 1 - progress * 0.6;
+ ctx.beginPath();
+ ctx.arc(cx, cz, radius, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+
+ // Clean up empty maps entries
+ if (rings.length === 0) birdSongRings.delete(ringKey);
+ } else {
+ // Bird stopped singing — clear its rings
+ birdSongRings.delete(ent.id);
+ }
+
+ // ── State indicator ring ──
+ if (state === 'HUNTING') {
+ ctx.strokeStyle = 'rgba(180, 120, 90, 0.4)';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.arc(cx, cz, 10, 0, Math.PI * 2);
+ ctx.stroke();
+ } else if (state === 'DRINKING') {
+ ctx.strokeStyle = 'rgba(90, 140, 180, 0.5)';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.arc(cx, cz, 9, 0, Math.PI * 2);
+ ctx.stroke();
+ } else if (state === 'RESTING') {
+ ctx.strokeStyle = 'rgba(140, 130, 110, 0.25)';
+ ctx.lineWidth = 1;
+ ctx.setLineDash([2, 2]);
+ ctx.beginPath();
+ ctx.arc(cx, cz, 8, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+
+ // ── Label ──
+ ctx.fillStyle = COLORS.label;
+ ctx.font = '7px JetBrains Mono';
+ ctx.textAlign = 'center';
+ ctx.fillText(state.toLowerCase(), cx, cz + 14);
+}
+
function drawButterfly(ctx, cx, cz, ent) {
const time = performance.now() * 0.008;
const wingFlap = Math.sin(time + cx * 0.1) * 0.5 + 0.5;
@@ -1009,6 +1175,7 @@
līlā
// For the demo, we pre-populate types based on known IDs.
function inferEntityType(id) {
if (id.startsWith('deer')) return { type: 'ANIMAL', species: 'deer' };
+ if (id.startsWith('bird') || id.startsWith('songbird')) return { type: 'BIRD', species: 'songbird' };
if (id.startsWith('butterfly')) return { type: 'INSECT', species: 'monarch' };
if (id.startsWith('oak')) return { type: 'TREE', species: 'meadow_oak' };
if (id.startsWith('grass')) return { type: 'PLANT', species: 'meadow_grass' };
diff --git a/server/ecosim/actors/flow_actors.py b/server/ecosim/actors/flow_actors.py
index b0bc900..39a6f3c 100644
--- a/server/ecosim/actors/flow_actors.py
+++ b/server/ecosim/actors/flow_actors.py
@@ -42,6 +42,8 @@
REPRO_BUILD_MIN_HEALTH,
REPRO_DECAY_ENERGY,
REPRO_DECAY_HUNGER,
+ ROOST_ENERGY_BONUS_FACTOR,
+ ROOST_PROXIMITY_BUFFER,
STARVATION_HUNGER,
WATER_DRY_THRESHOLD,
WATER_PROXIMITY_COLONY_FACTOR,
@@ -104,9 +106,14 @@ def resolve(self, ctx: Any) -> list[Any]:
delta=-drain, tick=ctx.tick,
))
elif ctx.entity["state"] in ENERGY_RECOVERY_STATES:
+ energy_gain = p.energy_recovery * dt
+ # Roosting bonus: birds within canopy of a preferred roost tree
+ # recover extra energy (shade/wind protection).
+ if p.roost_affinity and self._is_near_roost_tree(ctx, p):
+ energy_gain *= (1.0 + ROOST_ENERGY_BONUS_FACTOR)
effects.append(StateVarDelta(
entity_id=ctx.entity["id"], var_name="energy",
- delta=p.energy_recovery * dt, tick=ctx.tick,
+ delta=energy_gain, tick=ctx.tick,
))
# Lingering at a resource (e.g. pollination visit) also recovers energy
@@ -253,6 +260,42 @@ def _is_near_water(self, ctx: Any) -> bool:
return True
return False
+ def _is_near_roost_tree(self, ctx: Any, p: Any) -> bool:
+ """Check if entity is within canopy of a preferred roost tree.
+
+ Returns True if the entity is within (canopy_radius + buffer)
+ of any living TREE whose species matches the entity's roost_affinity.
+ Uses ctx._entities for global lookup and ctx._get_params for traits.
+ """
+ pos = ctx.entity["position"]
+ all_entities = getattr(ctx, "_entities", {})
+ get_params = getattr(ctx, "_get_params", None)
+
+ for other in all_entities.values():
+ if other.get("type") != "TREE":
+ continue
+ if other["state"] in ("DEAD", "DYING", "DORMANT"):
+ continue
+ tree_species = other.get("species", "")
+ if tree_species not in p.roost_affinity:
+ continue
+ # Look up canopy radius from DerivedParams or metadata fallback
+ canopy_radius = 0.0
+ if get_params is not None:
+ tree_params = get_params(other)
+ if tree_params is not None:
+ canopy_radius = getattr(tree_params, "canopy_radius", 0.0) or 0.0
+ if canopy_radius <= 0:
+ canopy_radius = other.get("metadata", {}).get("canopy_radius", 0.0)
+ if canopy_radius <= 0:
+ continue
+ dx = pos[0] - other["position"][0]
+ dz = pos[2] - other["position"][2]
+ dist = math.sqrt(dx * dx + dz * dz)
+ if dist <= canopy_radius + ROOST_PROXIMITY_BUFFER:
+ return True
+ return False
+
@staticmethod
def _drain_nearest_water(ctx: Any, amount: float) -> None:
"""Drain water from the nearest source (called during drinking)."""
diff --git a/server/ecosim/actors/guard_actors.py b/server/ecosim/actors/guard_actors.py
index e8b99cb..b6b1d55 100644
--- a/server/ecosim/actors/guard_actors.py
+++ b/server/ecosim/actors/guard_actors.py
@@ -198,8 +198,8 @@ def resolve(self, ctx: Any) -> list[Any]:
# ── Reproduction exit — one-time event, then return to normal behavior. ──
# After spawning offspring and resetting reproductive_drive to 0, the entity
- # must leave REPRODUCING so it can forage/drink/rest again. Since drive is
- # permanently at 0, it will never reproduce a second time.
+ # must leave REPRODUCING so it can forage/drink/rest again. Drive will rebuild
+ # over time via consumer flow if conditions are good (low hunger, high energy).
elif ctx.entity["state"] == "REPRODUCING":
effects.append(StateTransition(
entity_id=ctx.entity["id"], new_state="IDLE", tick=ctx.tick,
@@ -262,7 +262,7 @@ def resolve(self, ctx: Any) -> list[Any]:
effects.append(StateTransition(
entity_id=ctx.entity["id"], new_state="IDLE", tick=ctx.tick,
))
- elif p.diet_type in ("carnivore", "insectivore") and sv["hunger"] > CARNIVORE_HUNT_HUNGER:
+ elif self._should_hunt(ctx, p, sv):
effects.append(StateTransition(
entity_id=ctx.entity["id"], new_state="HUNTING", tick=ctx.tick,
))
@@ -282,7 +282,7 @@ def resolve(self, ctx: Any) -> list[Any]:
value=float(POLLINATOR_WANDER_COOLDOWN), tick=ctx.tick,
))
elif sv["hunger"] >= p.hunger_enter:
- if p.diet_type in ("carnivore", "insectivore") and sv["hunger"] > CARNIVORE_HUNT_HUNGER:
+ if self._should_hunt(ctx, p, sv):
effects.append(StateTransition(
entity_id=ctx.entity["id"], new_state="HUNTING", tick=ctx.tick,
))
@@ -304,6 +304,66 @@ def resolve(self, ctx: Any) -> list[Any]:
return effects
+ @staticmethod
+ def _should_hunt(ctx: Any, p: Any, sv: dict[str, float]) -> bool:
+ """Determine if an entity should transition to HUNTING state.
+
+ Obligate carnivores/insectivores hunt when hunger > CARNIVORE_HUNT_HUNGER.
+ Omnivores also hunt at that threshold, but additionally escalate earlier
+ when prey populations are high relative to plant food sources. This models
+ the ecological dynamic where predators switch to hunting when their primary
+ prey becomes abundant (e.g., songbirds eating butterflies during explosions).
+ """
+ if p.diet_type in ("carnivore", "insectivore"):
+ return sv["hunger"] > CARNIVORE_HUNT_HUNGER
+
+ if p.diet_type == "omnivore":
+ # Always hunt at critical hunger levels
+ if sv["hunger"] > CARNIVORE_HUNT_HUNGER:
+ return True
+
+ # Population-based escalation: check prey-to-plant ratio.
+ # When pollinators outstrip flowers, omnivores switch to hunting
+ # even at moderate hunger. Threshold: 3+ living pollinators per
+ # viable flower (GROWING or FRUITING state).
+ all_entities = getattr(ctx, "_entities", {})
+ if not all_entities:
+ return False
+
+ prey_species = []
+ plant_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)
+ for ix in interactions:
+ if ix.interaction_type == "predation":
+ prey_species.append(target_species)
+ elif ix.interaction_type == "herbivory":
+ plant_species.append(target_species)
+
+ if not prey_species or not plant_species:
+ return False
+
+ living_prey = sum(
+ 1 for e in all_entities.values()
+ if e.get("species") in prey_species and _is_alive_guard(e)
+ )
+ viable_plants = sum(
+ 1 for e in all_entities.values()
+ if e.get("species") in plant_species
+ and e["state"] in ("GROWING", "FRUITING")
+ )
+
+ # Ratio threshold: hunt when prey outnumber plants by 3:1
+ return viable_plants > 0 and living_prey / max(viable_plants, 1) >= 3.0
+
+ return False
+
+
+def _is_alive_guard(entity: dict[str, Any]) -> bool:
+ """Check if entity is alive for guard actor population counting."""
+ return entity["state"] not in ("DEAD", "DYING", "DORMANT")
+
class ProducerGuardActor:
"""Guard evaluation for autotroph sessile entities (plants, trees).
diff --git a/server/ecosim/actors/interaction_actors.py b/server/ecosim/actors/interaction_actors.py
index 0b70db1..723ff6f 100644
--- a/server/ecosim/actors/interaction_actors.py
+++ b/server/ecosim/actors/interaction_actors.py
@@ -30,7 +30,6 @@
from ..config import SIM_CONFIG
from ..constants import (
FLEE_ESCAPE_DISTANCE,
- FLEE_TRIGGER_DISTANCE,
HERBIVORY_CONSUME_DISTANCE,
HERBIVORY_MIN_HUNGER,
OM_DEPOSIT_MAX,
@@ -65,7 +64,7 @@ class FleeActor:
"""Check for nearby predators and trigger flee response.
Detection: entity has flee targets from interaction matrix, predator
- within FLEE_TRIGGER_DISTANCE (2.0).
+ within the entity's sensory range.
Returns effects: StateTransition to FLEEING, SetTarget with escape position,
and optionally an EventRecord if the state actually changed.
@@ -96,7 +95,7 @@ def resolve(self, ctx: Any) -> list[Effect]:
for other in ctx.nearby_entities:
if other.get("species", "") in flee_targets:
dist = self._distance(ctx.entity["position"], other["position"])
- if dist < FLEE_TRIGGER_DISTANCE:
+ if dist < p.sensory_range:
escape_pos = self._flee_direction(
ctx.entity["position"], other["position"]
)
@@ -181,8 +180,22 @@ def resolve(self, ctx: Any) -> list[Effect]:
if p.diet_type not in ("carnivore", "insectivore", "omnivore"):
return []
- # Check hunting state and hunger threshold
- if ctx.entity["state"] != "HUNTING" or ctx.entity["state_vars"]["hunger"] <= 0.3:
+ # Check hunting state and hunger threshold.
+ # Obligate carnivores/insectivores require HUNTING state + high hunger.
+ # Omnivores can opportunistically catch prey while FORAGING —
+ # a songbird encounters a butterfly while foraging flowers and catches it.
+ if p.diet_type in ("carnivore", "insectivore"):
+ if ctx.entity["state"] != "HUNTING" or ctx.entity["state_vars"]["hunger"] <= 0.3:
+ return []
+ elif p.diet_type == "omnivore":
+ # Omnivores hunt in both HUNTING and FORAGING states (opportunistic)
+ if ctx.entity["state"] not in ("HUNTING", "FORAGING"):
+ return []
+ # Lower hunger threshold for opportunistic predation — omnivores
+ # already entered FORAGING because they're hungry enough to seek food.
+ # No additional hunger gate needed; if they're foraging and prey is
+ # within catch distance, take it.
+ else:
return []
# Find catchable prey from interaction matrix
diff --git a/server/ecosim/actors/movement_actors.py b/server/ecosim/actors/movement_actors.py
index dc34564..f4daa69 100644
--- a/server/ecosim/actors/movement_actors.py
+++ b/server/ecosim/actors/movement_actors.py
@@ -29,6 +29,7 @@
POLLINATOR_CROWD_RADIUS,
POLLINATOR_MAX_PER_FLOWER,
REPRO_MATE_SEEK_DRIVE,
+ ROOST_PROXIMITY_BUFFER,
WANDER_RANGE,
WATER_DRY_THRESHOLD,
)
@@ -131,6 +132,13 @@ def resolve(self, ctx: Any) -> list[Effect]:
return [SetTarget(
entity_id=ctx.entity["id"], position=target, tick=ctx.tick)]
+ # ── Roosting — birds with roost_affinity seek trees when RESTING/IDLE ──
+ if p.roost_affinity and state in ("RESTING", "IDLE"):
+ target = self._resolve_roosting_target(ctx, pos, p)
+ if target is not None:
+ return [SetTarget(
+ entity_id=ctx.entity["id"], position=target, tick=ctx.tick)]
+
# ── IDLE pollinators — actively explore for flowers ──
# WANDERING is excluded — during forced exploration cooldown, butterflies
# should wander randomly to disperse, not fly back to nearby flowers.
@@ -160,6 +168,16 @@ def _resolve_foraging_target(
if food:
return food
+ # Omnivores with no plant food nearby: fall back to seeking prey.
+ # This allows songbirds in FORAGING state to hunt butterflies when
+ # flowers are scarce or all on cooldown.
+ if p.diet_type == "omnivore":
+ prey_species = [s for s, _ in diet_order]
+ target = self._find_nearest_prey(
+ pos, p.sensory_range, prey_species, ctx.nearby_entities)
+ if target:
+ return target
+
# Emergency: critically dehydrated forager with no food nearby.
hydration = ctx.entity["state_vars"].get("hydration", 1.0)
if hydration < DEHYDRATION_HYDRATION:
@@ -227,6 +245,26 @@ def _resolve_pollinator_idle_target(
# No flowers in range — wander randomly
return self._clamp_to_grid(pos, grid_max)
+ def _resolve_roosting_target(
+ self, ctx: Any, pos: list[float], p: Any,
+ ) -> list[float] | None:
+ """Resolve RESTING/IDLE bird target: seek nearest preferred roost tree.
+
+ Birds with roost_affinity seek trees whose species matches their
+ preference list. They approach to within the tree's canopy radius
+ (plus a small buffer) so they can perch in its branches.
+
+ Uses ctx._entities for global search since birds may need to spot
+ trees across the landscape. Falls back to None (no target) if no
+ suitable tree is found — the caller handles default behavior.
+ """
+ all_entities = getattr(ctx, "_entities", {})
+ get_params = getattr(ctx, "_get_params", None)
+ return self._find_nearest_roost_tree(
+ pos, p.sensory_range * 2, p.roost_affinity,
+ all_entities, get_params,
+ )
+
# ── Target search helpers ─────────────────────────────────────────────────
@staticmethod
@@ -281,6 +319,62 @@ def _find_nearest_prey(
best_dist, best_pos = d, list(other["position"])
return best_pos
+ @staticmethod
+ def _find_nearest_roost_tree(
+ pos: list[float], search_range: float,
+ roost_affinity: list[str],
+ all_entities: dict[str, Any],
+ get_params: Any = None,
+ ) -> list[float] | None:
+ """Find nearest tree species matching the bird's roost affinity.
+
+ Returns a position just inside the tree's canopy radius (approach point),
+ or None if no suitable tree is found within search_range.
+ Only considers living trees (not DEAD/DYING/DORMANT) with a canopy_radius > 0.
+ """
+ best_dist: float = float("inf")
+ best_pos: list[float] | None = None
+
+ for other in all_entities.values():
+ if other.get("type") != "TREE":
+ continue
+ if other["state"] in ("DEAD", "DYING", "DORMANT"):
+ continue
+ # Check species match against roost affinity list
+ tree_species = other.get("species", "")
+ if tree_species not in roost_affinity:
+ continue
+ # Tree must have a canopy to perch in — look up via DerivedParams
+ canopy_radius = 0.0
+ if get_params is not None:
+ tree_params = get_params(other)
+ if tree_params is not None:
+ canopy_radius = getattr(tree_params, "canopy_radius", 0.0) or 0.0
+ # Fallback: check metadata for canopy radius from world definition
+ if canopy_radius <= 0:
+ canopy_radius = other.get("metadata", {}).get("canopy_radius", 0.0)
+ if canopy_radius <= 0:
+ continue
+
+ d = _distance(pos, other["position"])
+ if d > search_range:
+ continue
+
+ # Approach to just inside the canopy edge (with small buffer)
+ approach_dist = max(canopy_radius - ROOST_PROXIMITY_BUFFER, 0.5)
+ target_pos = list(other["position"])
+ if d > approach_dist:
+ dx = other["position"][0] - pos[0]
+ dz = other["position"][2] - pos[2]
+ ndist = math.sqrt(dx * dx + dz * dz) or 1.0
+ nx, nz = dx / ndist, dz / ndist
+ target_pos[0] = other["position"][0] - nx * approach_dist
+ target_pos[2] = other["position"][2] - nz * approach_dist
+
+ if d < best_dist:
+ best_dist, best_pos = d, target_pos
+ return best_pos
+
@staticmethod
def _find_nearest_flower(
pos: list[float], search_range: float, p: Any,
diff --git a/server/ecosim/actors/reproduction_actor.py b/server/ecosim/actors/reproduction_actor.py
index aa18bb1..f50acfc 100644
--- a/server/ecosim/actors/reproduction_actor.py
+++ b/server/ecosim/actors/reproduction_actor.py
@@ -82,6 +82,10 @@ def resolve_animal(self, ctx: Any) -> list[Any]:
p = ctx.params
sv = ctx.entity["state_vars"]
+ # Only females produce offspring.
+ if ctx.entity.get("sex") != "female":
+ return []
+
# Drive must exceed threshold
if sv.get("reproductive_drive", 0) <= p.repro_drive_threshold:
return []
@@ -99,9 +103,10 @@ def resolve_animal(self, ctx: Any) -> list[Any]:
@staticmethod
def _find_mate(ctx: Any) -> bool:
- """Check if a living mate of the same species is within sensory range."""
+ """Check if a living mate of the same species and opposite sex is within sensory range."""
entity = ctx.entity
params = ctx.params
+ parent_sex = entity.get("sex")
for other in ctx._entities.values():
if not is_alive(other):
continue
@@ -109,6 +114,11 @@ def _find_mate(ctx: Any) -> bool:
continue
if other.get("species") != entity.get("species"):
continue
+ # Must be opposite sex (both must have a sex assigned)
+ if other.get("sex") is None or parent_sex is None:
+ continue
+ if other["sex"] == parent_sex:
+ continue
dx = other["position"][0] - entity["position"][0]
dz = other["position"][2] - entity["position"][2]
dist = math.sqrt(dx * dx + dz * dz)
@@ -167,6 +177,9 @@ def _animal_reproduction_effects(
"age": 0.0,
}
+ # Offspring sex — randomly assigned male or female
+ offspring_sex = _random.choice(("male", "female"))
+
if "colony_health" in sv:
offspring_sv["colony_health"] = max(
CHILD_COLONY_FLOOR, sv["colony_health"] * CHILD_COLONY_INHERIT)
@@ -187,6 +200,7 @@ def _animal_reproduction_effects(
state_vars=offspring_sv,
skeleton_id=ctx.entity.get("skeleton_id"),
initial_attrs={
+ "sex": offspring_sex,
# Newborn pollinators start with a cooldown so they don't
# immediately re-pollinate the flower their parent was at.
"_pollination_cooldown": float(POLLINATOR_POST_VISIT_COOLDOWN),
diff --git a/server/ecosim/constants.py b/server/ecosim/constants.py
index c50a819..fca7cd8 100644
--- a/server/ecosim/constants.py
+++ b/server/ecosim/constants.py
@@ -66,7 +66,6 @@
POLLINATION_MAX_LINGER = 10 # hard cap on linger ticks per visit
# ── Predation & herbivory distances ───────────────────────────────────────────
-FLEE_TRIGGER_DISTANCE = 2.0 # predator must be this close to trigger flee
PREDATION_CATCH_DISTANCE = 1.5 # predator must be this close to catch
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
@@ -156,3 +155,15 @@
ACTIVE_MOVEMENT_STATES = frozenset({"FORAGING", "HUNTING", "FLEEING", "DRINKING", "SWARMING"})
ACTIVE_ENERGY_DRAIN_STATES = frozenset({"FORAGING", "HUNTING", "FLEEING", "SWARMING"})
ENERGY_RECOVERY_STATES = frozenset({"RESTING", "IDLE"})
+
+# ── Roosting (birds seeking trees for rest/shelter) ───────────────────────────
+ROOST_ENERGY_BONUS_FACTOR = 0.5 # energy_recovery bonus when within canopy of a preferred roost tree
+ # (total recovery = base + base × factor)
+ROOST_PROXIMITY_BUFFER = 1.0 # extra distance beyond canopy_radius to still count as "at tree"
+ # accounts for branch overhang / approach offset
+
+# ── Roosting (birds seeking trees for rest/shelter) ───────────────────────────
+ROOST_ENERGY_BONUS_FACTOR = 0.5 # energy_recovery bonus when within canopy of a preferred roost tree
+ # (total recovery = base + base × factor)
+ROOST_PROXIMITY_BUFFER = 1.0 # extra distance beyond canopy_radius to still count as "at tree"
+ # accounts for branch overhang / approach offset
diff --git a/server/ecosim/effects.py b/server/ecosim/effects.py
index f862f5d..74c63ed 100644
--- a/server/ecosim/effects.py
+++ b/server/ecosim/effects.py
@@ -134,6 +134,7 @@ class SpawnEntity(Effect):
metadata: dict[str, Any]
state_vars: dict[str, float]
skeleton_id: str | None = None
+ sex: str | None = None # "male" or "female"
initial_attrs: dict[str, float] | None = field(default=None) # entity-level attrs
@@ -520,6 +521,7 @@ def apply_effects(
"metadata": effect.metadata,
"state_vars": effect.state_vars,
"skeleton_id": effect.skeleton_id,
+ "sex": effect.sex,
"initial_attrs": effect.initial_attrs or {},
})
@@ -709,6 +711,7 @@ def apply_effects_with_om_deposit(
"metadata": effect.metadata,
"state_vars": effect.state_vars,
"skeleton_id": effect.skeleton_id,
+ "sex": effect.sex,
"initial_attrs": effect.initial_attrs or {},
})
diff --git a/server/ecosim/entities.py b/server/ecosim/entities.py
index 606e9d8..1ebdee6 100644
--- a/server/ecosim/entities.py
+++ b/server/ecosim/entities.py
@@ -23,6 +23,7 @@
from __future__ import annotations
+import random as _random
from typing import Any
# -- Discrete states by entity type ------------------------------------------
@@ -70,6 +71,9 @@
"reproductive_drive": 0.0,
}
+# Entity types that have sex (male/female) for reproduction gating.
+_SEXED_TYPES = frozenset({"ANIMAL", "BIRD", "INSECT"})
+
_PLANT_DEFAULTS: dict[str, float] = {
"hydration": 1.0,
"growth": 0.1,
@@ -141,6 +145,11 @@ def init_entity(raw: dict[str, Any]) -> dict[str, Any]:
if "metadata" not in raw:
raw["metadata"] = {}
+ # Assign sex for mobile consumer types (ANIMAL, BIRD, INSECT).
+ # If not provided in the world definition, pick randomly.
+ if entity_type in _SEXED_TYPES and "sex" not in raw:
+ raw["sex"] = _random.choice(("male", "female"))
+
# Apply initial entity-level attributes (e.g. _pollination_cooldown for newborns)
# These are set directly on the entity dict, not in state_vars.
initial_attrs = raw.pop("initial_attrs", None) or {}
diff --git a/server/ecosim/movement_system.py b/server/ecosim/movement_system.py
index de2a3dc..06af9db 100644
--- a/server/ecosim/movement_system.py
+++ b/server/ecosim/movement_system.py
@@ -86,9 +86,12 @@ def step(self, entity: dict[str, Any], params: Any, dt: float) -> None:
# IDLE: actively seek and discover flowers across the field.
# WANDERING: wander randomly (no flower-seeking) to disperse
# after pollination bouts, exploring new areas.
+ # Birds with roost_affinity move during RESTING/IDLE to seek trees.
can_move = (
entity["state"] in ACTIVE_MOVEMENT_STATES
or (params.floral_affinity and entity["state"] in ("IDLE", "WANDERING"))
+ or (getattr(params, "roost_affinity", False)
+ and entity["state"] in ("RESTING", "IDLE"))
)
if params.speed > 0 and can_move:
diff --git a/server/ecosim/traits.py b/server/ecosim/traits.py
index 468a193..810df6c 100644
--- a/server/ecosim/traits.py
+++ b/server/ecosim/traits.py
@@ -109,6 +109,10 @@ class TraitVector:
resource_tags: list[str] = field(default_factory=list)
pollination_syndrome: str | None = None
floral_affinity: list[str] = field(default_factory=list)
+ # Species IDs of trees this entity prefers for roosting (e.g. ["oak"]).
+ # When non-empty, the entity seeks a matching tree when RESTING or IDLE
+ # and gains energy recovery bonus within its canopy radius.
+ roost_affinity: list[str] = field(default_factory=list)
@property
def is_mobile(self) -> bool:
@@ -179,6 +183,7 @@ class DerivedParams:
trophic_level: float = 2.0
pollination_syndrome: str | None = None
floral_affinity: list[str] = field(default_factory=list)
+ roost_affinity: list[str] = field(default_factory=list)
# ─────────────────────────────────────────────────────────────────────────────
@@ -378,6 +383,7 @@ def derive_all(traits: TraitVector) -> DerivedParams:
locomotion=traits.locomotion, skeleton_id=traits.skeleton_id,
trophic_level=traits.trophic_level, pollination_syndrome=traits.pollination_syndrome,
floral_affinity=list(traits.floral_affinity),
+ roost_affinity=list(traits.roost_affinity),
)
@@ -409,6 +415,7 @@ def trait_vector_from_dict(d: dict) -> TraitVector:
resource_tags=d.get("resource_tags", []),
pollination_syndrome=d.get("pollination_syndrome"),
floral_affinity=d.get("floral_affinity", []),
+ roost_affinity=d.get("roost_affinity", []),
)
diff --git a/server/examples/demo_world.json b/server/examples/demo_world.json
index b094716..04abebf 100755
--- a/server/examples/demo_world.json
+++ b/server/examples/demo_world.json
@@ -51,10 +51,10 @@
"seed": 42
},
"rates": {
- "consumption": 4.0,
+ "consumption": 2.0,
"hunger": 1.0,
"thirst": 1.0,
- "growth": 0.6,
+ "growth": 1.0,
"reproduction": 1.0,
"water_replenishment": 0.4
},
@@ -75,6 +75,7 @@
"id": "deer_01",
"type": "ANIMAL",
"species": "deer",
+ "sex": "female",
"position": [
16.0,
0.0,
@@ -95,6 +96,7 @@
"id": "deer_02",
"type": "ANIMAL",
"species": "deer",
+ "sex": "male",
"position": [
20.0,
0.0,
@@ -115,6 +117,7 @@
"id": "butterfly_01",
"type": "INSECT",
"species": "butterfly",
+ "sex": "female",
"position": [
10.0,
0.0,
@@ -134,6 +137,7 @@
"id": "butterfly_02",
"type": "INSECT",
"species": "butterfly",
+ "sex": "male",
"position": [
18.0,
0.0,
@@ -149,6 +153,88 @@
},
"skeleton_id": "insect_wing"
},
+ {
+ "id": "butterfly_03",
+ "type": "INSECT",
+ "species": "butterfly",
+ "sex": "female",
+ "position": [
+ 14.0,
+ 0.0,
+ 20.0
+ ],
+ "metadata": {
+ "diet": "herbivore",
+ "colony_size": 1,
+ "metabolism_rate": 0.6,
+ "pollination_range": 6.0,
+ "movement_speed": 2.0,
+ "lifespan": 150.0
+ },
+ "skeleton_id": "insect_wing"
+ },
+ {
+ "id": "butterfly_04",
+ "type": "INSECT",
+ "species": "butterfly",
+ "sex": "male",
+ "position": [
+ 22.0,
+ 0.0,
+ 18.0
+ ],
+ "metadata": {
+ "diet": "herbivore",
+ "colony_size": 1,
+ "metabolism_rate": 0.6,
+ "pollination_range": 6.0,
+ "movement_speed": 2.0,
+ "lifespan": 150.0
+ },
+ "skeleton_id": "insect_wing"
+ },
+ {
+ "id": "songbird_01",
+ "type": "BIRD",
+ "species": "songbird",
+ "sex": "female",
+ "position": [
+ 14.0,
+ 0.0,
+ 6.0
+ ],
+ "metadata": {
+ "diet": "omnivore",
+ "body_mass": 0.025,
+ "metabolism_rate": 0.8,
+ "sensory_range": 10.0,
+ "movement_speed": 4.0,
+ "lifespan": 600.0,
+ "reproduction_threshold": 0.7
+ },
+ "skeleton_id": "bird_small"
+ },
+ {
+ "id": "songbird_02",
+ "type": "BIRD",
+ "species": "songbird",
+ "sex": "male",
+ "position": [
+ 22.0,
+ 0.0,
+ 14.0
+ ],
+ "metadata": {
+ "diet": "omnivore",
+ "body_mass": 0.025,
+ "metabolism_rate": 0.8,
+ "sensory_range": 10.0,
+ "movement_speed": 4.0,
+ "lifespan": 600.0,
+ "reproduction_threshold": 0.7
+ },
+ "skeleton_id": "bird_small"
+ },
{
"id": "oak_01",
"type": "TREE",
@@ -597,7 +683,7 @@
],
"trophic_level": 2.0,
"reproductive_strategy": "r_selected",
- "clutch_size": 1,
+ "clutch_size": 2,
"generation_time_ticks": 2000,
"thermal_range": [
10,
@@ -689,7 +775,7 @@
5,
35
],
- "drought_tolerance": 0.15,
+ "drought_tolerance": 0.05,
"shade_tolerance": 0.3,
"sensory_range_multiplier": 0.0,
"movement_budget": 0.0,
@@ -702,6 +788,34 @@
"forb"
],
"pollination_syndrome": "insect_generalist"
+ },
+ {
+ "species_id": "songbird",
+ "functional_group": "insectivore",
+ "entity_class": "BIRD",
+ "body_mass_kg": 0.025,
+ "locomotion": "flight_bird",
+ "skeleton_id": "bird_small",
+ "thermoregulation": "endotherm",
+ "diet_type": "omnivore",
+ "diet_breadth": [
+ "pollinator",
+ "forb:fruiting"
+ ],
+ "trophic_level": 2.5,
+ "reproductive_strategy": "r_selected",
+ "clutch_size": 1,
+ "generation_time_ticks": 3000,
+ "thermal_range": [
+ 5,
+ 35
+ ],
+ "drought_tolerance": 0.2,
+ "shade_tolerance": 0.6,
+ "sensory_range_multiplier": 2.0,
+ "movement_budget": 0.5,
+ "roost_affinity": ["meadow_oak"],
+ "resource_tags": []
}
]
}
diff --git a/server/examples/species_definitions.json b/server/examples/species_definitions.json
index 8d22008..77fc34a 100644
--- a/server/examples/species_definitions.json
+++ b/server/examples/species_definitions.json
@@ -157,13 +157,14 @@
"diet_breadth": ["pollinator", "forb:fruiting"],
"trophic_level": 2.5,
"reproductive_strategy": "r_selected",
- "clutch_size": 4,
+ "clutch_size": 1,
"generation_time_ticks": 3000,
"thermal_range": [5, 35],
"drought_tolerance": 0.2,
"shade_tolerance": 0.6,
"sensory_range_multiplier": 2.0,
"movement_budget": 0.5,
+ "roost_affinity": ["oak", "meadow_oak"],
"resource_tags": []
},
{
diff --git a/server/tests/test_actors.py b/server/tests/test_actors.py
index b4bdf1e..6d257d9 100644
--- a/server/tests/test_actors.py
+++ b/server/tests/test_actors.py
@@ -123,11 +123,12 @@ def test_no_flee_when_no_predators_nearby(self):
self.assertEqual(effects, [])
def test_flee_when_predator_nearby(self):
- """Flee triggers when predator is within trigger distance."""
+ """Flee triggers when predator is within sensory range."""
p = MagicMock()
p.species_id = "deer"
p.diet_type = "herbivore"
p.speed = 1.0
+ p.sensory_range = 5.0
# Mock flee targets from compiled ecology
ctx = make_context(
params=p,
diff --git a/server/tests/test_reproduction_actor.py b/server/tests/test_reproduction_actor.py
index 10fbf4b..425c95b 100644
--- a/server/tests/test_reproduction_actor.py
+++ b/server/tests/test_reproduction_actor.py
@@ -62,6 +62,7 @@ def make_animal_context(
"type": entity_type,
"state": state,
"position": pos,
+ "sex": "female",
"state_vars": sv,
"metadata": {"body_mass": 100.0},
}
@@ -84,6 +85,7 @@ def make_animal_context(
"type": entity_type,
"state": "IDLE",
"position": mate_pos,
+ "sex": "male",
"state_vars": {"health": 0.8},
}
ctx._entities = entities
@@ -279,6 +281,23 @@ def test_reproductionactor_transitions_to_reproducing(self):
self.assertEqual(len(transitions), 1)
self.assertEqual(transitions[0].new_state, "REPRODUCING")
+ def test_reproductionactor_male_cannot_spawn(self):
+ """Male entities never produce offspring even with drive and mate."""
+ ctx = make_animal_context(reproductive_drive=0.9, has_mate=True)
+ ctx.entity["sex"] = "male"
+
+ effects = self.actor.resolve_animal(ctx)
+ self.assertEqual(effects, [])
+
+ def test_reproductionactor_no_spawn_when_same_sex(self):
+ """Female with only same-sex mates nearby → no spawn."""
+ ctx = make_animal_context(reproductive_drive=0.9, has_mate=True)
+ # Mate is also female — should not count as valid mate
+ ctx._entities["mate_1"]["sex"] = "female"
+
+ effects = self.actor.resolve_animal(ctx)
+ self.assertEqual(effects, [])
+
class TestReproductionActorPlant(unittest.TestCase):
"""Test plant vegetative spreading via ReproductionActor.resolve_plant()."""