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
167 changes: 167 additions & 0 deletions client/browser/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ <h1>līlā</h1>

<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background:#c4956a"></div>deer</div>
<div class="legend-item"><div class="legend-dot" style="background:#8a7b6b; border-radius:1px"></div>songbird</div>
<div class="legend-item"><div class="legend-dot" style="background:#a87cc4"></div>butterfly</div>
<div class="legend-item"><div class="legend-dot" style="background:#3d6b3d; border-radius:50%"></div>oak</div>
<div class="legend-item"><div class="legend-dot" style="background:#6b8f5e"></div>grass</div>
Expand Down Expand Up @@ -222,6 +223,8 @@ <h1>līlā</h1>

// Entities
deer: '#c4956a',
bird: '#8a7b6b',
birdSong: 'rgba(196, 170, 120, ', // prefix for alpha
butterfly: '#a87cc4',
oak: '#3d6b3d',
oakCanopy: 'rgba(61, 107, 61, 0.12)',
Expand Down Expand Up @@ -730,6 +733,7 @@ <h1>līlā</h1>
{ 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 },
];

Expand Down Expand Up @@ -915,6 +919,168 @@ <h1>līlā</h1>
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;
Expand Down Expand Up @@ -1009,6 +1175,7 @@ <h1>līlā</h1>
// 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' };
Expand Down
45 changes: 44 additions & 1 deletion server/ecosim/actors/flow_actors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)."""
Expand Down
68 changes: 64 additions & 4 deletions server/ecosim/actors/guard_actors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
))
Expand All @@ -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,
))
Expand All @@ -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).
Expand Down
Loading
Loading