From 385a08f41b5d8ce2f1db2c491a3f74bcec628e9b Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Mon, 8 Jun 2026 21:04:35 -0400 Subject: [PATCH 1/2] feat: songbird rendering, sex-based reproduction, and flee improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add songbird rendering to visualizer (teardrop body, animated wings, dovetail tail, expanding song rings when IDLE/REPRODUCING) - Introduce male/female sex for ANIMAL/BIRD/INSECT entities: - Only females produce offspring (clutch_size applies per female) - Mate must be opposite sex within sensory range - Offspring get random sex at birth - Auto-assign random sex on init if not provided in world def - Replace hardcoded FLEE_TRIGGER_DISTANCE with entity's sensory_range, so prey flee proportionally to what they can detect - Add songbird omnivore fallback: foraging songbirds hunt butterflies when flowers are scarce or all on cooldown - Increase demo butterfly count from 2 to 4 (2♀ + 2♂) - Fix stale comment about reproductive drive being permanently zero - Update SpawnEntity effect and both spawn handlers to carry sex field - Add tests: male cannot spawn, same-sex mate rejected --- client/browser/index.html | 167 +++++++++++++++++++++ server/ecosim/actors/guard_actors.py | 68 ++++++++- server/ecosim/actors/interaction_actors.py | 23 ++- server/ecosim/actors/movement_actors.py | 10 ++ server/ecosim/actors/reproduction_actor.py | 16 +- server/ecosim/constants.py | 1 - server/ecosim/effects.py | 3 + server/ecosim/entities.py | 9 ++ server/examples/demo_world.json | 121 ++++++++++++++- server/examples/species_definitions.json | 2 +- server/tests/test_actors.py | 3 +- server/tests/test_reproduction_actor.py | 19 +++ 12 files changed, 425 insertions(+), 17 deletions(-) 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 @@

līlā

deer
+
songbird
butterfly
oak
grass
@@ -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/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..bfdb4f1 100644 --- a/server/ecosim/actors/movement_actors.py +++ b/server/ecosim/actors/movement_actors.py @@ -160,6 +160,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: 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..50fe802 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 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/examples/demo_world.json b/server/examples/demo_world.json index b094716..fdcd7f2 100755 --- a/server/examples/demo_world.json +++ b/server/examples/demo_world.json @@ -51,12 +51,12 @@ "seed": 42 }, "rates": { - "consumption": 4.0, + "consumption": 1.0, "hunger": 1.0, "thirst": 1.0, - "growth": 0.6, + "growth": 1.0, "reproduction": 1.0, - "water_replenishment": 0.4 + "water_replenishment": 1.0 }, "randomize": { "jitter": 1.5, @@ -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, @@ -702,6 +788,33 @@ "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, + "resource_tags": [] } ] } diff --git a/server/examples/species_definitions.json b/server/examples/species_definitions.json index 8d22008..a71487e 100644 --- a/server/examples/species_definitions.json +++ b/server/examples/species_definitions.json @@ -157,7 +157,7 @@ "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, 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().""" From d4c7c8fae9d24a30a051386cb558025521325959 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Mon, 8 Jun 2026 22:38:11 -0400 Subject: [PATCH 2/2] feat: add bird roosting behavior on trees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add roost_affinity trait (list of preferred tree species) to TraitVector/DerivedParams - Birds with roost_affinity seek nearest matching tree when RESTING or IDLE - Movement gate allows resting birds to move toward trees (like pollinators for flowers) - Energy recovery boosted by 50% when within canopy of a preferred roost tree - All behavior driven by data — no engine special-casing by entity type Files changed: traits.py — new roost_affinity field, flows through derive_all() constants.py — ROOST_ENERGY_BONUS_FACTOR, ROOST_PROXIMITY_BUFFER movement_actors.py — _resolve_roosting_target(), _find_nearest_roost_tree() movement_system.py — allow RESTING/IDLE birds with roost_affinity to move flow_actors.py — energy recovery bonus near preferred roost trees species_definitions.json, demo_world.json — wire up songbird → oak affinity --- server/ecosim/actors/flow_actors.py | 45 ++++++++++++- server/ecosim/actors/movement_actors.py | 84 ++++++++++++++++++++++++ server/ecosim/constants.py | 12 ++++ server/ecosim/movement_system.py | 3 + server/ecosim/traits.py | 7 ++ server/examples/demo_world.json | 7 +- server/examples/species_definitions.json | 1 + 7 files changed, 155 insertions(+), 4 deletions(-) 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/movement_actors.py b/server/ecosim/actors/movement_actors.py index bfdb4f1..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. @@ -237,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 @@ -291,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/constants.py b/server/ecosim/constants.py index 50fe802..fca7cd8 100644 --- a/server/ecosim/constants.py +++ b/server/ecosim/constants.py @@ -155,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/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 fdcd7f2..04abebf 100755 --- a/server/examples/demo_world.json +++ b/server/examples/demo_world.json @@ -51,12 +51,12 @@ "seed": 42 }, "rates": { - "consumption": 1.0, + "consumption": 2.0, "hunger": 1.0, "thirst": 1.0, "growth": 1.0, "reproduction": 1.0, - "water_replenishment": 1.0 + "water_replenishment": 0.4 }, "randomize": { "jitter": 1.5, @@ -775,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, @@ -814,6 +814,7 @@ "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 a71487e..77fc34a 100644 --- a/server/examples/species_definitions.json +++ b/server/examples/species_definitions.json @@ -164,6 +164,7 @@ "shade_tolerance": 0.6, "sensory_range_multiplier": 2.0, "movement_budget": 0.5, + "roost_affinity": ["oak", "meadow_oak"], "resource_tags": [] }, {