diff --git a/client/browser/css/style.css b/client/browser/css/style.css new file mode 100644 index 0000000..542b0de --- /dev/null +++ b/client/browser/css/style.css @@ -0,0 +1,176 @@ +/* ═══════════════════════════════════════════════════════ + līlā — Ecosystem Visualizer Styles + ═══════════════════════════════════════════════════════ */ + +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500&display=swap'); + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + background: #0f100f; + color: #b8b4a8; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + overflow: hidden; + height: 100vh; + width: 100vw; +} + +#canvas { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + image-rendering: pixelated; +} + +/* ─── Status Panel ──────────────────────────────────── */ + +.panel { + position: absolute; + top: 16px; + left: 16px; + background: rgba(15, 16, 15, 0.88); + border: 1px solid rgba(184, 180, 168, 0.12); + border-radius: 6px; + padding: 14px 16px; + min-width: 200px; + backdrop-filter: blur(8px); +} + +.panel h1 { + font-size: 14px; + font-weight: 500; + color: #d4d0c4; + margin-bottom: 2px; + letter-spacing: 0.5px; +} + +.panel .sub { + font-size: 9px; + color: rgba(184, 180, 168, 0.5); + margin-bottom: 12px; + letter-spacing: 1px; + text-transform: uppercase; +} + +.stat { + display: flex; + justify-content: space-between; + padding: 3px 0; + border-bottom: 1px solid rgba(184, 180, 168, 0.06); +} + +.stat:last-child { border-bottom: none; } +.stat .label { color: rgba(184, 180, 168, 0.5); } +.stat .value { color: #d4d0c4; font-weight: 400; } + +/* ─── Event Log ─────────────────────────────────────── */ + +.events-panel { + position: absolute; + bottom: 16px; + left: 16px; + background: rgba(15, 16, 15, 0.88); + border: 1px solid rgba(184, 180, 168, 0.12); + border-radius: 6px; + padding: 10px 14px; + max-width: 340px; + max-height: 140px; + overflow: hidden; + backdrop-filter: blur(8px); + font-size: 10px; +} + +.events-panel .event-line { + padding: 2px 0; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.events-panel .event-line.fresh { opacity: 1; } + +/* ─── Legend ────────────────────────────────────────── */ + +.legend { + position: absolute; + top: 16px; + right: 16px; + background: rgba(15, 16, 15, 0.88); + border: 1px solid rgba(184, 180, 168, 0.12); + border-radius: 6px; + padding: 12px 14px; + backdrop-filter: blur(8px); +} + +.legend-item { + display: flex; + align-items: center; + gap: 8px; + padding: 2px 0; + font-size: 10px; +} + +.legend-dot { + width: 8px; + height: 8px; + border-radius: 2px; + flex-shrink: 0; +} + +/* ─── Status Dot ────────────────────────────────────── */ + +.status-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + margin-right: 6px; +} + +.status-dot.connected { background: #6b8f5e; } +.status-dot.disconnected { background: #8f5e5e; } +.status-dot.connecting { background: #8f865e; } + +/* ─── Control Buttons ───────────────────────────────── */ + +.rain-btn, .record-btn { + position: fixed; + bottom: 20px; + background: rgba(45, 85, 110, 0.5); + border: 1px solid rgba(70, 130, 150, 0.3); + color: rgba(184, 180, 168, 0.85); + padding: 10px 18px; + border-radius: 6px; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 11px; + cursor: pointer; + letter-spacing: 1px; + transition: all 0.2s; +} + +.rain-btn { right: 20px; } +.record-btn { right: 120px; } + +.rain-btn:hover, .record-btn:hover { + background: rgba(55, 105, 130, 0.7); + border-color: rgba(70, 130, 150, 0.6); +} + +.rain-btn:active, .record-btn:active { + background: rgba(70, 130, 150, 0.9); +} + +.rain-btn.raining { + background: rgba(45, 85, 110, 0.9); + border-color: rgba(100, 170, 200, 0.5); + color: rgba(200, 220, 230, 1); +} + +.record-btn.recording { + background: rgba(140, 50, 50, 0.8); + border-color: rgba(180, 70, 70, 0.6); + color: rgba(230, 180, 180, 1); +} diff --git a/client/browser/index.html b/client/browser/index.html index a872ffd..0058179 100644 --- a/client/browser/index.html +++ b/client/browser/index.html @@ -10,164 +10,7 @@ līlā — ecosystem visualizer - + @@ -198,1009 +41,6 @@

līlā

- + diff --git a/client/browser/js/agency.js b/client/browser/js/agency.js new file mode 100644 index 0000000..0a80e7c --- /dev/null +++ b/client/browser/js/agency.js @@ -0,0 +1,523 @@ +// ═══════════════════════════════════════════════════════ +// līlā — Client-Side Agency Engine +// +// Between server ticks, each mobile entity decides what to do based on: +// - Server intent (state + drives + eligibility flags) +// - Local perception (nearest food, water, threats from world model) +// - Motion latent (modulates speed, hesitation, path curvature) +// - Gravity well toward server ref_position (continuous pull) +// +// This is the "body" in "server is nervous system, client is body." +// ═══════════════════════════════════════════════════════ + +import { GRID_SIZE } from './constants.js'; +import { hasReconcileTarget, getReconcileTarget, advanceReconcile } from './reconciliation.js'; + +// Gravity well: gentle pull toward ref_position, always active. +// ~0.05 × speed per frame ≈ 3 units/s pull, enough to drift back +// without overpowering the entity's desired behavior. +// Each entity also has _syncSpeed (0.4..1.0) that modulates this. +const GRAVITY_WELL_FACTOR = 0.05; + +/** + * Run one frame of local agency for all mobile entities. + * Called every render frame (~60 Hz). + */ +export function stepAgency(world, dt) { + const events = []; // client-reported events to send upstream + + for (const ent of world.entities.values()) { + if (!ent.isMobileConsumer || !ent.isAlive) continue; + + // Update speed from species definition (once) + if (!ent._speedSet) { + const def = world.getSpeciesDef(ent.species); + ent.speed = def?.movement_speed ?? 2.0; + ent._speedSet = true; + } + + // Reconciling entities meander toward their queued target + if (hasReconcileTarget(ent)) { + const [tx, tz] = getReconcileTarget(ent); + if (executeReconcileMeander(ent, tx, tz, world, dt)) { + advanceReconcile(ent); + } + } else { + // Normal agency: evaluate behavior + gravity well toward ref + const action = evaluateBehavior(ent, world); + executeAction(ent, action, world, dt, events); + applyGravityWell(ent, world, dt); + } + } + + return events; +} + +/** + * Gently pull entity toward server ref_position. + * Always active during normal agency. Each entity has its own + * _syncSpeed (0.4..1.0) so the pull strength varies per entity, + * making the sync look organic rather than uniform. + */ +function applyGravityWell(ent, world, dt) { + const dx = ent.refX - ent.x; + const dz = ent.refZ - ent.z; + const dist = Math.sqrt(dx * dx + dz * dz); + + if (dist < 0.2) return; // Already close enough + + // Per-entity sync speed modulates the gravity well strength. + const speedFactor = ent._syncSpeed ?? 0.7; + const nudge = GRAVITY_WELL_FACTOR * speedFactor * dt; + ent.x += dx * nudge; + ent.z += dz * nudge; + ent.x = clamp(ent.x, GRID_SIZE); + ent.z = clamp(ent.z, GRID_SIZE); +} + +/** + * Meander a reconciling entity toward its queued target (tx, tz). + * + * Produces a spiral/circle motion: radial pull toward target combined + * with a perpendicular wobble. Returns true when the entity has arrived. + * + * The approach curve accelerates as the entity gets closer — it circles + * wide at first then tightens into the target, like a bird landing. + * + * Each entity's _syncSpeed modulates the approach rate. + */ +function executeReconcileMeander(ent, tx, tz, world, dt) { + const dx = tx - ent.x; + const dz = tz - ent.z; + const dist = Math.sqrt(dx * dx + dz * dz); + + if (dist < 0.5) { + // Close enough — advance to next target + ent.velocityX = 0; + ent.velocityZ = 0; + return true; + } + + const nx = dx / dist; + const nz = dz / dist; + + // Perpendicular direction (rotate 90°) + const px = -nz; + const pz = nx; + + const speed = ent.speed || 2.0; + + // Per-entity sync speed modulates the radial approach rate. + const speedFactor = ent._syncSpeed ?? 0.7; + + // Radial step: move toward target + const radialStep = Math.min(speed * 1.5 * speedFactor * dt, dist); + + // Perpendicular wobble: wide circles that tighten as we approach. + // Wobble amplitude scales with distance — large arcs far away, + // gentle spirals near the target. + const wobblePhase = performance.now() * 0.003 * 3 + entityPhase(ent.id); + const wobbleAmp = Math.sin(wobblePhase) * Math.min(speed * 0.5, dist * 0.3); + const wobbleStep = wobbleAmp * dt; + + ent.x += nx * radialStep + px * wobbleStep; + ent.z += nz * radialStep + pz * wobbleStep; + ent.x = clamp(ent.x, GRID_SIZE); + ent.z = clamp(ent.z, GRID_SIZE); + + // Update velocity and facing + ent.velocityX = nx * speed * 1.5 * speedFactor + px * wobbleAmp; + ent.velocityZ = nz * speed * 1.5 * speedFactor + pz * wobbleAmp; + ent.facingAngle = lerpAngle( + ent.facingAngle, Math.atan2(dz, dx), 0.12 + ); + + return false; +} + +/** + * Deterministic per-entity offset for wobble phase. + */ +function entityPhase(eid) { + let h = 0; + for (let i = 0; i < eid.length; i++) { + h = (h * 31 + eid.charCodeAt(i)) & 0xFFFF; + } + return (h / 65536) * 2 * Math.PI; +} + +/** + * Spherical lerp between two angles (handles wrapping at ±π). + */ +function lerpAngle(a, b, t) { + const diff = Math.atan2(Math.sin(b - a), Math.cos(b - a)); + return a + diff * t; +} + +// ─── Behavior Evaluators ────────────────────────────── + +/** + * Evaluate what an entity should do based on its intent and local perception. + * Returns { type, target? } describing the desired action. + */ +function evaluateBehavior(ent, world) { + const state = ent.state; + const drive = ent.drive || {}; + const speciesDef = world.getSpeciesDef(ent.species); + + // ── Fleeing (highest priority — threat detected locally) ── + if (state === 'FLEEING') { + return evaluateFleeing(ent, world, speciesDef); + } + + // ── Drinking ── + if (state === 'DRINKING' || (ent.canDrink && (drive.hydration ?? 1.0) < 0.3)) { + return evaluateDrinking(ent, world); + } + + // ── Reproduction seeking ── + if (ent.reproEligible && (drive.reproductive_drive ?? 0) > 0.5) { + const mateAction = evaluateMateSeeking(ent, world); + if (mateAction) return mateAction; + } + + // ── Foraging / Herbivory ── + if (state === 'FORAGING' && ent.canConsume) { + return evaluateForaging(ent, world, speciesDef); + } + + // ── Hunting / Predation ── + if ((state === 'HUNTING' || state === 'FORAGING') && ent.canPredatate) { + return evaluateHunting(ent, world, speciesDef); + } + + // ── Pollination ── + if (ent.canPollinate && speciesDef?.is_pollinator) { + return evaluatePollination(ent, world, speciesDef); + } + + // ── Resting / Idle — wander with latent-modulated style ── + return evaluateWandering(ent, world); +} + +function evaluateFleeing(ent, world, speciesDef) { + const fleeTargets = speciesDef?.flee_targets || []; + if (fleeTargets.length === 0) return evaluateWandering(ent, world); + + // Find nearest threat + let nearestThreat = null; + let bestDistSq = Infinity; + for (const other of world.entities.values()) { + if (!other.isAlive) continue; + const def = world.getSpeciesDef(other.species); + if (!def || !fleeTargets.includes(other.species)) continue; + const d2 = ent.distSqTo(other); + if (d2 < bestDistSq) { + bestDistSq = d2; + nearestThreat = other; + } + } + + if (nearestThreat && bestDistSq < 400) { // ~20 world units sensory range² + // Flee away from threat + const dx = ent.x - nearestThreat.x; + const dz = ent.z - nearestThreat.z; + const dist = Math.sqrt(dx * dx + dz * dz) || 1; + return { + type: 'flee', + targetX: clamp(ent.x + (dx / dist) * 8, GRID_SIZE), + targetZ: clamp(ent.z + (dz / dist) * 8, GRID_SIZE), + }; + } + + // No threat nearby — fall through to wander + return evaluateWandering(ent, world); +} + +function evaluateDrinking(ent, world) { + const water = world.findNearestWater(ent.x, ent.z); + if (water) { + // Approach water source edge + const wx = water.position[0], wz = water.position[2]; + const r = water.radius || 1; + const dx = wx - ent.x, dz = wz - ent.z; + const dist = Math.sqrt(dx * dx + dz * dz) || 1; + // Approach to just outside the water edge + const approachR = Math.max(r - 0.5, 0.3); + return { + type: 'drink', + targetX: clamp(wx - (dx / dist) * approachR, GRID_SIZE), + targetZ: clamp(wz - (dz / dist) * approachR, GRID_SIZE), + }; + } + return evaluateWandering(ent, world); +} + +function evaluateMateSeeking(ent, world) { + let best = null; + let bestDist = 15; // sensory range + for (const other of world.entities.values()) { + if (other.id === ent.id) continue; + if (other.species !== ent.species) continue; + if (!other.isAlive) continue; + const d = ent.distanceTo(other); + if (d < bestDist) { + bestDist = d; + best = other; + } + } + if (best) { + return { + type: 'seek_mate', + targetX: best.x, + targetZ: best.z, + targetId: best.id, + }; + } + return null; // no mate nearby, fall through to other behaviors +} + +function evaluateForaging(ent, world, speciesDef) { + const dietOrder = speciesDef?.diet_order || []; + + // Try each food preference in order + for (const entry of dietOrder) { + // Handle both tuple format ["species", 1.0] and bare string "species" + const foodSpecies = Array.isArray(entry) ? entry[0] : entry; + const food = world.findNearestSpecies(ent.x, ent.z, [foodSpecies], ent.id); + if (food && ent.distanceTo(food) < 15) { + return { + type: 'forage', + targetX: food.x, + targetZ: food.z, + targetId: food.id, + }; + } + } + + // No preferred food — wander to search + return evaluateWandering(ent, world); +} + +function evaluateHunting(ent, world, speciesDef) { + const dietOrder = speciesDef?.diet_order || []; + const preySpecies = dietOrder.map(entry => + Array.isArray(entry) ? entry[0] : entry + ); + + if (preySpecies.length > 0) { + const prey = world.findNearestSpecies(ent.x, ent.z, preySpecies, ent.id); + if (prey && ent.distanceTo(prey) < 15) { + return { + type: 'hunt', + targetX: prey.x, + targetZ: prey.z, + targetId: prey.id, + }; + } + } + + // No prey — wander (or fall back to foraging if omnivore) + return evaluateWandering(ent, world); +} + +function evaluatePollination(ent, world, speciesDef) { + const pollTargets = speciesDef?.pollination_targets || []; + if (pollTargets.length === 0) return evaluateWandering(ent, world); + + // Find nearest FRUITING flower + for (const flower of world.entities.values()) { + if (!flower.isAlive) continue; + if (!pollTargets.includes(flower.species)) continue; + if (flower.state !== 'FRUITING') continue; + const dist = ent.distanceTo(flower); + if (dist < 15 && dist > 0.5) { + return { + type: 'pollinate', + targetX: flower.x, + targetZ: flower.z, + targetId: flower.id, + }; + } + } + + // No fruiting flowers — wander to search + return evaluateWandering(ent, world); +} + +function evaluateWandering(ent, world) { + // Reuse existing wander target if still valid and not reached + if (ent.hasTarget && ent._lastActionType === 'wander') { + const dx = ent.targetX - ent.x; + const dz = ent.targetZ - ent.z; + if (Math.sqrt(dx * dx + dz * dz) > 0.5) { + // Still have distance to current wander target — keep it + return { type: 'wander', targetX: ent.targetX, targetZ: ent.targetZ }; + } + } + + const ml = ent.motionLatent || [0, 0, 0, 0]; + const urgency = Math.abs(ml[0]); // dim 0 = pace/urgency + + // Wander range modulated by urgency — high urgency = tighter wander + const wanderRange = 2 + (1 - urgency) * 4; + return { + type: 'wander', + targetX: clamp(ent.x + (Math.random() - 0.5) * wanderRange * 2, GRID_SIZE), + targetZ: clamp(ent.z + (Math.random() - 0.5) * wanderRange * 2, GRID_SIZE), + }; +} + +// ─── Action Execution ───────────────────────────────── + +/** + * Execute an action for one entity over one frame. + * Handles movement toward target and interaction triggers. + */ +function executeAction(ent, action, world, dt, events) { + if (!action || (!action.targetX && action.type !== 'wander')) return; + + const ml = ent.motionLatent || [0, 0, 0, 0]; + const urgency = (ml[0] + 1) * 0.5; // normalize to 0..1 + const caution = Math.abs(ml[1]); // dim 1 = alertness + + // Base speed from species or default + const baseSpeed = ent.speed || 2.0; + + // Modulate speed by action type + const speedMods = { + flee: 1.5 + urgency * 0.5, + hunt: 1.2 + urgency * 0.3, + forage: 0.8 + urgency * 0.4, + drink: 0.7, + seek_mate: 0.6 + urgency * 0.3, + pollinate: 0.5 + urgency * 0.3, + }; + const speedMod = speedMods[action.type] ?? (0.3 + (1 - caution) * 0.4); + + const effectiveSpeed = baseSpeed * speedMod; + + const targetX = action.targetX ?? ent.x; + const targetZ = action.targetZ ?? ent.z; + + // Move toward target + const dx = targetX - ent.x; + const dz = targetZ - ent.z; + const dist = Math.sqrt(dx * dx + dz * dz); + + if (dist > 0.1) { + const step = Math.min(effectiveSpeed * dt, dist); + // Add slight curvature based on caution (dim 1) — high caution = more wobble + const wobble = caution * Math.sin(performance.now() * 0.003 + ent.x) * 0.3; + + ent.x += (dx / dist) * step + wobble * dt; + ent.z += (dz / dist) * step - wobble * dt; + ent.x = clamp(ent.x, GRID_SIZE); + ent.z = clamp(ent.z, GRID_SIZE); + + ent.velocityX = (dx / dist) * effectiveSpeed; + ent.velocityZ = (dz / dist) * effectiveSpeed; + + // Lerp facing angle toward travel direction (smooth rotation) + const targetAngle = Math.atan2(dz, dx); + ent.facingAngle = lerpAngle(ent.facingAngle, targetAngle, 0.15); + } else { + // Arrived at target + ent.velocityX = 0; + ent.velocityZ = 0; + + // Check for interaction triggers on arrival (with cooldown) + checkInteraction(ent, action, world, events); + + // Reset wander target so next frame picks a new one + if (action.type === 'wander') { + ent.hasTarget = false; + } + } + + ent.targetX = targetX; + ent.targetZ = targetZ; + ent._lastActionType = action.type; + ent.hasTarget = true; +} + +/** + * Check if an entity should trigger an interaction on target arrival. + * Reports events upstream for server absorption. + * Uses per-target cooldown to prevent event spam. + */ +function checkInteraction(ent, action, world, events) { + const targetId = action.targetId; + if (!targetId) return; + + const targetEnt = world.entities.get(targetId); + if (!targetEnt || !targetEnt.isAlive) return; + + const dist = ent.distanceTo(targetEnt); + if (dist > 3.0) return; // too far for any interaction + + // Cooldown: don't re-interact with same target within 2 seconds + const now = performance.now(); + if (!ent._interactionCooldowns) ent._interactionCooldowns = {}; + const key = `${ent.id}:${targetId}`; + const cooldownMs = 2000; + if (ent._interactionCooldowns[key] && (now - ent._interactionCooldowns[key]) < cooldownMs) { + return; // still on cooldown + } + + switch (action.type) { + case 'forage': + if (dist < 2.0 && ent.canConsume) { + events.push({ + type: 'consumption', + source_id: ent.id, + target_id: targetId, + position: [targetEnt.x, 0, targetEnt.z], + }); + ent._interactionCooldowns[key] = now; + } + break; + + case 'hunt': + if (dist < 1.5 && ent.canPredatate) { + events.push({ + type: 'predation', + source_id: ent.id, + target_id: targetId, + kill_position: [targetEnt.x, 0, targetEnt.z], + }); + ent._interactionCooldowns[key] = now; + } + break; + + case 'pollinate': + if (dist < 1.5 && ent.canPollinate) { + events.push({ + type: 'pollination', + source_id: ent.id, + target_id: targetId, + position: [targetEnt.x, 0, targetEnt.z], + }); + ent._interactionCooldowns[key] = now; + } + break; + + case 'seek_mate': + if (dist < 3.0 && ent.reproEligible) { + events.push({ + type: 'repro', + parent_id: ent.id, + offspring_count: 1, + client_position: [ent.x, 0, ent.z], + }); + ent._interactionCooldowns[key] = now; + } + break; + } +} + +// ─── Helpers ────────────────────────────────────────── + +function clamp(v, max) { + return Math.max(0.5, Math.min(max - 0.5, v)); +} diff --git a/client/browser/js/constants.js b/client/browser/js/constants.js new file mode 100644 index 0000000..e415c1c --- /dev/null +++ b/client/browser/js/constants.js @@ -0,0 +1,61 @@ +// ═══════════════════════════════════════════════════════ +// līlā — Constants and Configuration +// ═══════════════════════════════════════════════════════ + +export const GRID_SIZE = 32; +export const CELL_PX = 18; +export const PADDING = 40; +export const CANVAS_SIZE = GRID_SIZE * CELL_PX + PADDING * 2; + +// Server tick rate (seconds) — intent-based mode at 0.5 Hz +export const SERVER_TICK_RATE = 2.0; + +// Heartbeat interval (ms) — how often client sends positions/events upstream +export const HEARTBEAT_INTERVAL_MS = 1000; + +// Reconciliation thresholds +export const RECONCILE_SNAP_THRESHOLD_MULT = 3.0; // snap if > 3× expected travel +export const RECONCILE_NUDGE_FACTOR = 0.3; // soft nudge absorbs 30% per heartbeat + +// Max event log entries +export const MAX_EVENT_LOG = 8; + +// ─── Colors ─────────────────────────────────────────── +export const COLORS = { + bg: '#0f100f', + grid: 'rgba(184, 180, 168, 0.04)', + gridMajor: 'rgba(184, 180, 168, 0.08)', + + // Moisture heatmap + moistureHigh: [48, 58, 52], + moistureMid: [42, 44, 38], + moistureLow: [72, 62, 42], + + // Entities + deer: '#c4956a', + deerHead: '#d4aa7a', + bird: '#8a7b6b', + birdTail: '#6b5e52', + birdSong: 'rgba(196, 170, 120, ', // prefix for alpha + butterfly: '#a87cc4', + butterflyBody: '#7a5a8f', + oak: '#3d6b3d', + oakCanopy: 'rgba(61, 107, 61, 0.12)', + grass: '#6b8f5e', + grassWilt: '#7a7254', + wildflower: '#7a8f5e', + flowerBloom: '#c4a64a', + + // Events + consumption: '#8faa6e', + pollination: '#c4a64a', + death: '#6e5a5a', + + // Water + waterFill: [45, 85, 110], + waterEdge: [55, 105, 125], + waterShine: [70, 130, 150], + + // Text + label: 'rgba(184, 180, 168, 0.35)', +}; diff --git a/client/browser/js/heartbeat.js b/client/browser/js/heartbeat.js new file mode 100644 index 0000000..a2776c5 --- /dev/null +++ b/client/browser/js/heartbeat.js @@ -0,0 +1,65 @@ +// ═══════════════════════════════════════════════════════ +// līlā — Heartbeat (Client → Server Upstream) +// +// Periodically sends client-reported positions and interaction +// events back to the server for absorption. This is how the +// nervous system hears from the body. +// ═══════════════════════════════════════════════════════ + +import { HEARTBEAT_INTERVAL_MS } from './constants.js'; + +export class HeartbeatSender { + constructor(ws) { + this.ws = ws; + this.lastSend = 0; + this.pendingEvents = []; // events accumulated between heartbeats + } + + /** Queue an event for upstream transmission. */ + queueEvent(event) { + this.pendingEvents.push(event); + } + + /** Queue multiple events (from agency step). */ + queueEvents(events) { + this.pendingEvents.push(...events); + } + + /** Send heartbeat if interval has elapsed. Returns true if sent. */ + trySend(world, now) { + if (now - this.lastSend < HEARTBEAT_INTERVAL_MS) return false; + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false; + + // Build position map for all mobile entities + const positions = {}; + for (const ent of world.entities.values()) { + if (ent.isMobileConsumer && ent.isAlive) { + positions[ent.id] = [round(ent.x), 0, round(ent.z)]; + } + } + + // Build heartbeat message + const msg = { type: 'heartbeat', positions }; + if (this.pendingEvents.length > 0) { + msg.events = [...this.pendingEvents]; + this.pendingEvents = []; + } else if (Object.keys(positions).length > 0) { + // Always send at least positions to keep reconciliation alive + } else { + return false; // nothing to report + } + + try { + this.ws.send(JSON.stringify(msg)); + this.lastSend = now; + return true; + } catch (e) { + console.warn('Heartbeat send failed:', e); + return false; + } + } +} + +function round(v) { + return Math.round(v * 10000) / 10000; +} diff --git a/client/browser/js/main.js b/client/browser/js/main.js new file mode 100644 index 0000000..97376f2 --- /dev/null +++ b/client/browser/js/main.js @@ -0,0 +1,336 @@ +// ═══════════════════════════════════════════════════════ +// līlā — Main Entry Point +// +// Sets up WebSocket connection, manages the render loop, +// and coordinates between world model, agency engine, +// heartbeat sender, and renderer. +// ═══════════════════════════════════════════════════════ + +import { CANVAS_SIZE, COLORS, MAX_EVENT_LOG } from './constants.js'; +import { WorldModel } from './world-model.js'; +import { stepAgency } from './agency.js'; +import { HeartbeatSender } from './heartbeat.js'; +import { reconcile } from './reconciliation.js'; +import * as renderer from './renderer.js'; +import * as particles from './particles.js'; + +// ─── Canvas Setup ───────────────────────────────────── + +const canvas = document.getElementById('canvas'); +const ctx = canvas.getContext('2d'); +canvas.width = CANVAS_SIZE; +canvas.height = CANVAS_SIZE; + +// ─── State ──────────────────────────────────────────── + +let ws = null; +let connected = false; +let currentTick = 0; +let totalEvents = 0; +const world = new WorldModel(); + +// FPS tracking +let frameCount = 0; +let lastFpsTime = performance.now(); +let displayFps = 0; + +// Event log +const eventLog = []; + +// ─── WebSocket ──────────────────────────────────────── + +function connect() { + const wsUrl = `ws://${location.host}/ws`; + setStatus('connecting'); + + ws = new WebSocket(wsUrl); + + ws.onopen = () => { + setStatus('connected'); + + // Init heartbeat sender now that WS is open + heartbeatSender = new HeartbeatSender(ws); + + fetch('/world.json') + .then(r => r.json()) + .then(worldDef => { ws.send(JSON.stringify(worldDef)); }) + .catch(() => { + console.warn('Could not load world.json, using minimal world'); + ws.send(JSON.stringify(buildMinimalWorld())); + }); + }; + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.type === 'session_started') { + onSessionStarted(data); + return; + } + handleTickPacket(data); + }; + + ws.onclose = () => { + setStatus('disconnected'); + connected = false; + setTimeout(connect, 3000); + }; + + ws.onerror = () => { ws.close(); }; +} + +function onSessionStarted(data) { + console.log('Session started:', data); + logEvent(`▸ ecosystem alive — ${data.entity_count} entities`); + + // Store species definitions from server for client-side agency + if (data.species) { + world.speciesDefs = data.species; + } +} + +function setStatus(state) { + const el = document.getElementById('status'); + const dot = state === 'connected' ? 'connected' + : state === 'connecting' ? 'connecting' : 'disconnected'; + el.innerHTML = `${state}`; + connected = state === 'connected'; +} + +// ─── Tick Packet Processing ────────────────────────── + +function handleTickPacket(packet) { + currentTick = packet.tick || 0; + + // Entity updates (intent-based format) + if (packet.entity_updates) { + for (const u of packet.entity_updates) { + world.applyUpdate(u); + } + } + + // Spawns + if (packet.entity_spawns) { + for (const s of packet.entity_spawns) { + world.applySpawn(s); + } + } + + // Removals + if (packet.entity_removals) { + for (const id of packet.entity_removals) { + const ent = world.entities.get(id); + if (ent) particles.spawnParticles(ent.x, ent.z, COLORS.death, 6, 40); + world.applyRemoval(id); + } + } + + // Voxel deltas (moisture for heatmap) + if (packet.voxel_deltas) { + world.applyVoxelDeltas(packet.voxel_deltas); + } + + // Water sources + if (packet.water_sources && packet.water_sources.length > 0) { + world.applyWaterSources(packet.water_sources); + } + + // Events from server + if (packet.events) { + for (const ev of packet.events) { + totalEvents++; + handleEvent(ev); + } + } + + // Reconcile client positions with server references + reconcile(world, currentTick); + + // Update UI + document.getElementById('tick').textContent = currentTick; + document.getElementById('entities').textContent = world.entities.size; + document.getElementById('event-count').textContent = totalEvents; +} + +function handleEvent(ev) { + const pos = ev.position || [0, 0, 0]; + const x = pos[0], z = pos[2]; + + switch (ev.type) { + case 'CONSUMPTION': + particles.spawnParticles(x, z, COLORS.consumption, 5, 30); + logEvent(`🌿 ${ev.source_id} grazes ${ev.target_id}`); + break; + case 'POLLINATION': + particles.spawnParticles(x, z, COLORS.pollination, 8, 50); + logEvent(`🦋 ${ev.source_id} pollinates ${ev.target_id}`); + break; + case 'DEATH_NATURAL': + case 'DEATH_STARVE': + particles.spawnParticles(x, z, COLORS.death, 10, 60); + logEvent(`💀 ${ev.source_id} dies`); + break; + case 'STATE_CHANGE': + logEvent(`◇ ${ev.source_id}: ${ev.prev_state} → ${ev.new_state}`); + break; + } +} + +function logEvent(text) { + eventLog.unshift({ text, time: performance.now() }); + if (eventLog.length > MAX_EVENT_LOG) eventLog.pop(); + + const panel = document.getElementById('events-panel'); + panel.innerHTML = eventLog.map((e) => { + const age = (performance.now() - e.time) / 1000; + const fresh = age < 2 ? ' fresh' : ''; + return `
${e.text}
`; + }).join(''); +} + +// ─── Heartbeat Sender ──────────────────────────────── + +let heartbeatSender = null; +// Initialized in ws.onopen alongside world def fetch + +// ─── Rain Control ───────────────────────────────────── + +document.getElementById('rain-btn').addEventListener('click', () => { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify({ type: 'rain', intensity: 0.8 })); + + const btn = document.getElementById('rain-btn'); + btn.classList.add('raining'); + btn.textContent = '☔ raining...'; + setTimeout(() => { + btn.classList.remove('raining'); + btn.textContent = '☔ rain'; + }, 1500); + + logEvent('🌧 rainfall — moisture replenished'); +}); + +// ─── Recording ──────────────────────────────────────── + +let mediaRecorder = null; +let recordedChunks = []; + +document.getElementById('record-btn').addEventListener('click', () => { + const btn = document.getElementById('record-btn'); + + if (mediaRecorder && mediaRecorder.state === 'recording') { + mediaRecorder.stop(); + return; + } + + const stream = canvas.captureStream(30); + const codecs = [ + 'video/webm; codecs=vp9', + 'video/webm; codecs=vp8', + 'video/webm', + 'video/mp4', + ]; + const mimeType = codecs.find(c => MediaRecorder.isTypeSupported(c)) || ''; + const ext = mimeType.includes('mp4') ? 'mp4' : 'webm'; + + mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : {}); + recordedChunks = []; + + mediaRecorder.ondataavailable = (e) => { + if (e.data.size > 0) recordedChunks.push(e.data); + }; + + mediaRecorder.onstop = () => { + const blob = new Blob(recordedChunks, { type: mediaRecorder.mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `lila-recording.${ext}`; + a.click(); + URL.revokeObjectURL(url); + + btn.classList.remove('recording'); + btn.textContent = '⏺ record'; + logEvent('⏹ recording saved'); + }; + + mediaRecorder.start(); + btn.classList.add('recording'); + btn.textContent = '⏹ recording...'; + logEvent('⏺ recording started (10s)'); + + setTimeout(() => { + if (mediaRecorder && mediaRecorder.state === 'recording') { + mediaRecorder.stop(); + } + }, 10000); +}); + +// ─── Render Loop ────────────────────────────────────── + +let lastFrameTime = performance.now(); + +function render() { + const now = performance.now(); + const dt = Math.min((now - lastFrameTime) / 1000, 0.05); // cap at 50ms + lastFrameTime = now; + + // FPS counter + frameCount++; + if (now - lastFpsTime > 1000) { + displayFps = frameCount; + frameCount = 0; + lastFpsTime = now; + document.getElementById('fps').textContent = displayFps; + } + + // ── Step local agency (60 Hz, between server ticks) ── + const clientEvents = stepAgency(world, dt); + if (heartbeatSender && clientEvents.length > 0) { + heartbeatSender.queueEvents(clientEvents); + } + + // ── Send heartbeat if interval elapsed ── + if (heartbeatSender) { + heartbeatSender.trySend(world, now); + } + + // ── Render frame ── + ctx.fillStyle = COLORS.bg; + ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); + + renderer.drawMoistureHeatmap(ctx, world.moisture); + renderer.drawWater(ctx, world.waterSources); + renderer.drawGrid(ctx); + renderer.drawEntities(ctx, world); + renderer.drawParticles(ctx); + + particles.updateParticles(); + + requestAnimationFrame(render); +} + +// ─── Minimal World Fallback ────────────────────────── + +function buildMinimalWorld() { + return { + version: '0.1', + session_id: 'browser-viz-001', + environment: { + type: 'MEADOW', biome: 'TEMPERATE', + climate: { temperature: 22, humidity: 0.6, rainfall: 0.4, wind_speed: 0.15, light_level: 0.85 }, + soil: { nitrogen: 0.7, phosphorus: 0.6, potassium: 0.5, moisture: 0.65, organic_matter: 0.4, ph: 6.8 }, + voxel_grid: { dimensions: [32, 32, 32], cell_size: 1.0 }, + }, + model: { adapter: 'mlp', seed: 42 }, + entities: [ + { id: 'deer_01', type: 'ANIMAL', species: 'deer', position: [16, 0, 14], + metadata: { diet: 'herbivore', body_mass: 60, metabolism_rate: 1, sensory_range: 12, movement_speed: 3, lifespan: 800, reproduction_threshold: 0.8 }, + skeleton_id: 'quadruped_medium' }, + ], + }; +} + +// ─── Start ──────────────────────────────────────────── + +connect(); +requestAnimationFrame(render); diff --git a/client/browser/js/particles.js b/client/browser/js/particles.js new file mode 100644 index 0000000..c0272e2 --- /dev/null +++ b/client/browser/js/particles.js @@ -0,0 +1,38 @@ +// ═══════════════════════════════════════════════════════ +// līlā — Particle System (Event Visualizations) +// ═══════════════════════════════════════════════════════ + +const particles = []; + +export function spawnParticles(worldX, worldZ, color, count, lifetime) { + for (let i = 0; i < count; i++) { + particles.push({ + x: worldX, + z: worldZ, + vx: (Math.random() - 0.5) * 2, + vz: (Math.random() - 0.5) * 2, + life: lifetime + Math.random() * 20, + maxLife: lifetime + 20, + color: color, + size: 2 + Math.random() * 2, + }); + } +} + +export function updateParticles() { + for (let i = particles.length - 1; i >= 0; i--) { + const p = particles[i]; + p.x += p.vx * 0.03; + p.z += p.vz * 0.03; + p.vx *= 0.97; + p.vz *= 0.97; + p.life--; + if (p.life <= 0) { + particles.splice(i, 1); + } + } +} + +export function getParticles() { + return particles; +} diff --git a/client/browser/js/reconciliation.js b/client/browser/js/reconciliation.js new file mode 100644 index 0000000..0b5c314 --- /dev/null +++ b/client/browser/js/reconciliation.js @@ -0,0 +1,100 @@ +// ═══════════════════════════════════════════════════════ +// līlā — Reconciliation (Client ↔ Server Position Sync) +// +// When a new tick packet arrives, reconcile client-agency positions +// with server reference positions. Trust the client within bounds; +// gently correct when divergence exceeds expected travel distance. +// +// Each tick, divergent entities get their ref_position enqueued as a +// reconcile target. The agency system then smoothly meanders toward +// that target over the next ~2 seconds. If a new target arrives before +// the old one is reached, the entity transitions smoothly (no snap). +// +// Each entity has a unique sync personality (_syncPhase, _syncSpeed) +// so they don't all queue reconciliation targets at the same time — +// the sync looks organic, not mechanical. +// +// Additionally, a continuous gravity well pulls all entities gently +// toward their ref_position during normal agency, preventing sudden +// direction changes when new tick targets arrive. +// ═══════════════════════════════════════════════════════ + +/** + * Enqueue reconciliation targets after receiving a new tick packet. + * + * Does NOT modify positions directly — the agency system at 60fps + * consumes the queue and moves entities smoothly. + * + * Entities are staggered across ticks using _syncPhase (0..3) so + * not all entities react to the sync pulse simultaneously. + * + * Called once per server tick, not every frame. + */ +export function reconcile(world, tick) { + for (const ent of world.entities.values()) { + if (!ent.isMobileConsumer || !ent.isAlive) continue; + + // ── Staggered reaction: each entity has a syncPhase (0..3) + // Only enqueue reconcile targets when the tick aligns with phase. + // This spreads the "nudge" across frames so it looks organic. + const ticksSinceLast = tick - ent._lastReconciledTick; + if (ticksSinceLast < ent._syncPhase) { + continue; // not this entity's turn yet + } + + // Server acknowledged our deviation — trust it fully. + // Clear any pending reconcile targets since server now matches us. + if (ent.ackReceived) { + ent._reconcileQueue = []; + ent._reconcileIdx = 0; + ent._lastReconciledTick = tick; + continue; + } + + const dx = ent.x - ent.refX; + const dz = ent.z - ent.refZ; + const divergence = Math.sqrt(dx * dx + dz * dz); + + if (divergence < 0.1) { + // Negligible drift — nothing to reconcile. + // Prune completed targets. + ent._reconcileQueue = ent._reconcileQueue.slice(ent._reconcileIdx); + ent._reconcileIdx = 0; + ent._lastReconciledTick = tick; + continue; + } + + // Prune completed targets before enqueueing new one. + ent._reconcileQueue = ent._reconcileQueue.slice(ent._reconcileIdx); + ent._reconcileIdx = 0; + + // Enqueue the ref_position as a reconcile target. + // If there's already an unfinished target, append — the agency + // system will chain through them smoothly. + ent._reconcileQueue.push([ent.refX, ent.refZ]); + + // If the queue grew too long (entity falling behind), keep only + // the latest target to avoid chasing ghosts. + if (ent._reconcileQueue.length > 2) { + ent._reconcileQueue = [ent._reconcileQueue[ent._reconcileQueue.length - 1]]; + } + + ent._lastReconciledTick = tick; + } +} + +/** Check if an entity has pending reconciliation work. */ +export function hasReconcileTarget(ent) { + return ent._reconcileIdx < ent._reconcileQueue.length; +} + +/** Get the current reconcile target [tx, tz] for an entity. */ +export function getReconcileTarget(ent) { + const idx = Math.min(ent._reconcileIdx, ent._reconcileQueue.length - 1); + return ent._reconcileQueue[idx]; +} + +/** Advance to the next target in the queue (called when current is reached). */ +export function advanceReconcile(ent) { + ent._reconcileIdx += 1; +} diff --git a/client/browser/js/renderer.js b/client/browser/js/renderer.js new file mode 100644 index 0000000..a8182ae --- /dev/null +++ b/client/browser/js/renderer.js @@ -0,0 +1,509 @@ +// ═══════════════════════════════════════════════════════ +// līlā — Renderer (Canvas Drawing) +// ═══════════════════════════════════════════════════════ + +import { GRID_SIZE, CELL_PX, PADDING, CANVAS_SIZE, COLORS } from './constants.js'; +import { getParticles } from './particles.js'; + +// Song ring particles keyed by entity id +const birdSongRings = new Map(); + +export function worldToCanvas(wx, wz) { + return [PADDING + wx * CELL_PX, PADDING + wz * CELL_PX]; +} + +export function drawGrid(ctx) { + for (let i = 0; i <= GRID_SIZE; i++) { + const major = i % 8 === 0; + ctx.strokeStyle = major ? COLORS.gridMajor : COLORS.grid; + ctx.lineWidth = major ? 0.5 : 0.3; + const p = PADDING + i * CELL_PX; + + ctx.beginPath(); + ctx.moveTo(p, PADDING); + ctx.lineTo(p, PADDING + GRID_SIZE * CELL_PX); + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(PADDING, p); + ctx.lineTo(PADDING + GRID_SIZE * CELL_PX, p); + ctx.stroke(); + } +} + +export function drawMoistureHeatmap(ctx, moisture) { + for (let z = 0; z < GRID_SIZE; z++) { + for (let x = 0; x < GRID_SIZE; x++) { + const val = moisture[z * GRID_SIZE + x]; + const [cx, cz] = worldToCanvas(x, z); + + let r, g, b; + if (val > 0.5) { + const f = (val - 0.5) * 2; + r = lerp(COLORS.moistureMid[0], COLORS.moistureHigh[0], f); + g = lerp(COLORS.moistureMid[1], COLORS.moistureHigh[1], f); + b = lerp(COLORS.moistureMid[2], COLORS.moistureHigh[2], f); + } else { + const f = val * 2; + r = lerp(COLORS.moistureLow[0], COLORS.moistureMid[0], f); + g = lerp(COLORS.moistureLow[1], COLORS.moistureMid[1], f); + b = lerp(COLORS.moistureLow[2], COLORS.moistureMid[2], f); + } + + ctx.fillStyle = `rgb(${r|0},${g|0},${b|0})`; + ctx.fillRect(cx, cz, CELL_PX, CELL_PX); + } + } +} + +export function drawWater(ctx, waterSources) { + const now = performance.now(); + + for (const ws of waterSources) { + const [wx, , wz] = ws.position; + const [cx, cz] = worldToCanvas(wx, wz); + const centerX = cx + CELL_PX / 2; + const centerZ = cz + CELL_PX / 2; + const radiusPx = ws.radius * CELL_PX; + const level = ws.water_level !== undefined ? ws.water_level : 1.0; + + if (level < 0.02 || radiusPx < 1) continue; + + const alpha = 0.3 + level * 0.4; + + // Outer soft glow + const glowR = radiusPx * 1.8; + const glow = ctx.createRadialGradient(centerX, centerZ, radiusPx * 0.5, centerX, centerZ, glowR); + glow.addColorStop(0, `rgba(${COLORS.waterFill[0]}, ${COLORS.waterFill[1]}, ${COLORS.waterFill[2]}, ${alpha * 0.8})`); + glow.addColorStop(0.6, `rgba(${COLORS.waterFill[0]}, ${COLORS.waterFill[1]}, ${COLORS.waterFill[2]}, ${alpha * 0.5})`); + glow.addColorStop(1, 'rgba(45, 85, 110, 0)'); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(centerX, centerZ, glowR, 0, Math.PI * 2); + ctx.fill(); + + // Main water body + const waterGrad = ctx.createRadialGradient(centerX - radiusPx * 0.2, centerZ - radiusPx * 0.2, 0, centerX, centerZ, radiusPx); + waterGrad.addColorStop(0, `rgba(${COLORS.waterShine[0]}, ${COLORS.waterShine[1]}, ${COLORS.waterShine[2]}, ${alpha * 0.95})`); + waterGrad.addColorStop(0.6, `rgba(${COLORS.waterFill[0]}, ${COLORS.waterFill[1]}, ${COLORS.waterFill[2]}, ${alpha * 0.9})`); + waterGrad.addColorStop(1, `rgba(${COLORS.waterEdge[0]}, ${COLORS.waterEdge[1]}, ${COLORS.waterEdge[2]}, ${alpha * 0.7})`); + ctx.fillStyle = waterGrad; + ctx.beginPath(); + ctx.arc(centerX, centerZ, radiusPx, 0, Math.PI * 2); + ctx.fill(); + + // Edge ring + ctx.strokeStyle = `rgba(${COLORS.waterEdge[0]}, ${COLORS.waterEdge[1]}, ${COLORS.waterEdge[2]}, ${alpha * 0.4})`; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(centerX, centerZ, radiusPx, 0, Math.PI * 2); + ctx.stroke(); + + // Animated ripples (only when water is above 30%) + if (level > 0.3) { + for (let i = 0; i < 2; i++) { + const phase = (now * 0.001 + i * 1.8) % 3.0; + const rippleR = radiusPx * 0.3 + phase * radiusPx * 0.25; + const rippleAlpha = Math.max(0, (0.15 - phase * 0.05) * level); + ctx.strokeStyle = `rgba(${COLORS.waterShine[0]}, ${COLORS.waterShine[1]}, ${COLORS.waterShine[2]}, ${rippleAlpha})`; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.arc(centerX + Math.sin(i * 4.7) * radiusPx * 0.2, centerZ + Math.cos(i * 4.7) * radiusPx * 0.15, rippleR, 0, Math.PI * 2); + ctx.stroke(); + } + } + } +} + +export function drawEntities(ctx, world) { + const layers = [ + { types: ['TREE'], draw: drawTree }, + { types: ['PLANT'], species: ['meadow_grass'], draw: drawGrass }, + { types: ['PLANT'], species: ['wildflower'], draw: drawFlower }, + { types: ['MICROORGANISM'], draw: drawMushroom }, + { types: ['ANIMAL'], draw: drawDeer }, + { types: ['BIRD'], draw: drawBird }, + { types: ['INSECT'], draw: drawButterfly }, + ]; + + for (const layer of layers) { + for (const ent of world.entities.values()) { + if (!ent.isAlive && ent.state !== 'DORMANT') continue; + if (!layer.types.includes(ent.type)) continue; + if (layer.species && !layer.species.includes(ent.species)) continue; + + // Guard against NaN positions + if (typeof ent.x !== 'number' || typeof ent.z !== 'number') continue; + if (isNaN(ent.x) || isNaN(ent.z)) continue; + + const [cx, cz] = worldToCanvas(ent.x, ent.z); + layer.draw(ctx, cx, cz, ent); + } + } +} + +export function drawParticles(ctx) { + for (const p of getParticles()) { + const [cx, cz] = worldToCanvas(p.x, p.z); + const alpha = p.life / p.maxLife; + + ctx.fillStyle = p.color; + ctx.globalAlpha = alpha * 0.8; + ctx.beginPath(); + ctx.arc(cx, cz, p.size * alpha, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1.0; +} + +// ─── Entity Draw Functions ──────────────────────────── + +function drawTree(ctx, cx, cz, ent) { + const growth = ent.drive?.growth ?? ent.state_vars?.growth ?? 0.5; + const canopyR = 4.0 * CELL_PX * growth * 0.5; + + ctx.fillStyle = COLORS.oakCanopy; + ctx.beginPath(); + ctx.arc(cx, cz, canopyR, 0, Math.PI * 2); + ctx.fill(); + + const trunkR = 3 + growth * 3; + ctx.fillStyle = COLORS.oak; + ctx.beginPath(); + ctx.arc(cx, cz, trunkR, 0, Math.PI * 2); + ctx.fill(); + + ctx.strokeStyle = 'rgba(61, 107, 61, 0.3)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(cx, cz, trunkR + 1, 0, Math.PI * 2); + ctx.stroke(); +} + +function drawGrass(ctx, cx, cz, ent) { + if (ent.state === 'DORMANT') { + ctx.fillStyle = '#5a5244'; + ctx.globalAlpha = 0.3; + ctx.beginPath(); + ctx.arc(cx, cz, 2, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1.0; + return; + } + + const growth = ent.drive?.growth ?? 0.1; + const hydration = ent.drive?.hydration ?? 0.5; + const size = 2 + growth * 4; + const color = hydration > 0.3 ? COLORS.grass : COLORS.grassWilt; + + ctx.fillStyle = color; + ctx.globalAlpha = 0.5 + growth * 0.5; + + for (let i = 0; i < 3; i++) { + const ox = Math.sin(i * 2.1) * 3; + const oz = Math.cos(i * 2.1) * 3; + ctx.beginPath(); + ctx.arc(cx + ox, cz + oz, size * 0.6, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1.0; +} + +function drawFlower(ctx, cx, cz, ent) { + if (ent.state === 'DORMANT') { + ctx.fillStyle = '#6b5e3d'; + ctx.globalAlpha = 0.3; + ctx.beginPath(); + ctx.arc(cx, cz, 2, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1.0; + return; + } + + const growth = ent.drive?.growth ?? 0.1; + const isFruiting = ent.state === 'FRUITING'; + + ctx.fillStyle = COLORS.wildflower; + ctx.beginPath(); + ctx.arc(cx, cz, 2, 0, Math.PI * 2); + ctx.fill(); + + if (isFruiting) { + const pulse = 0.7 + Math.sin(performance.now() * 0.004) * 0.3; + const bloomR = 4 + growth * 3; + + ctx.fillStyle = COLORS.flowerBloom; + ctx.globalAlpha = pulse; + ctx.beginPath(); + ctx.arc(cx, cz, bloomR, 0, Math.PI * 2); + ctx.fill(); + + ctx.globalAlpha = pulse * 0.2; + ctx.beginPath(); + ctx.arc(cx, cz, bloomR * 2, 0, Math.PI * 2); + ctx.fill(); + + ctx.globalAlpha = 1.0; + } +} + +function drawMushroom(ctx, cx, cz, ent) { + const activity = ent.drive?.activity ?? 0.5; + const size = 2 + activity * 3; + + ctx.fillStyle = `rgba(160, 140, 120, ${0.3 + activity * 0.4})`; + ctx.beginPath(); + ctx.arc(cx, cz, size, 0, Math.PI * 2); + ctx.fill(); +} + +function drawDeer(ctx, cx, cz, ent) { + const state = ent.state || 'IDLE'; + const hunger = ent.drive?.hunger ?? 0; + const size = state === 'RESTING' ? 5 : 7; + + // Use facingAngle for smooth rotation, fallback to velocity direction + const angle = ent.facingAngle !== undefined && (ent.velocityX || ent.velocityZ) + ? ent.facingAngle + : Math.atan2(ent.velocityZ || 0, ent.velocityX || 1); + + ctx.save(); + ctx.translate(cx, cz); + ctx.rotate(angle); + + // Body (triangle pointing in movement direction) + ctx.fillStyle = COLORS.deer; + ctx.beginPath(); + ctx.moveTo(size, 0); + ctx.lineTo(-size * 0.7, -size * 0.5); + ctx.lineTo(-size * 0.7, size * 0.5); + ctx.closePath(); + ctx.fill(); + + // Head dot + ctx.fillStyle = COLORS.deerHead; + ctx.beginPath(); + ctx.arc(size * 0.6, 0, 2, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + + // State indicator ring + if (state === 'DRINKING') { + ctx.strokeStyle = 'rgba(90, 140, 180, 0.5)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(cx, cz, size + 3, 0, Math.PI * 2); + ctx.stroke(); + } else if (state === 'RESTING') { + ctx.strokeStyle = 'rgba(180, 170, 140, 0.3)'; + ctx.lineWidth = 1; + ctx.setLineDash([2, 2]); + ctx.beginPath(); + ctx.arc(cx, cz, size + 2, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + } + + // Motion latent visualization (subtle halo) + if (ent.motionLatent && ent.skeletonId) { + const ml = ent.motionLatent; + const intensity = (Math.abs(ml[0]) + Math.abs(ml[2])) * 0.3; + ctx.strokeStyle = `rgba(196, 149, 106, ${intensity * 0.2})`; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.arc(cx, cz, size + 5, 0, Math.PI * 2); + ctx.stroke(); + } + + // Label + ctx.fillStyle = COLORS.label; + ctx.font = '8px JetBrains Mono'; + ctx.textAlign = 'center'; + ctx.fillText(state.toLowerCase(), cx, cz + size + 10); +} + +function drawBird(ctx, cx, cz, ent) { + const state = ent.state || 'IDLE'; + const time = performance.now() * 0.001; + + // Use facingAngle for smooth rotation, fallback to velocity direction + const angle = ent.facingAngle !== undefined && (ent.velocityX || ent.velocityZ) + ? ent.facingAngle + : Math.atan2(ent.velocityZ || 0, ent.velocityX || 1); + + ctx.save(); + ctx.translate(cx, cz); + ctx.rotate(angle); + + let flapSpeed = 6; + let flapAmp = 0.45; + 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 — teardrop + const bodyLen = 6, bodyWid = 2.5; + ctx.fillStyle = COLORS.bird; + ctx.beginPath(); + ctx.moveTo(bodyLen, 0); + ctx.quadraticCurveTo(bodyLen * 0.3, -bodyWid, -bodyLen * 0.6, 0); + ctx.quadraticCurveTo(bodyLen * 0.3, bodyWid, bodyLen, 0); + ctx.fill(); + + // Tail — dovetail + const tailSpread = state === 'FLEEING' ? 4 : 2.5; + const tailBaseX = -bodyLen * 0.5; + const tailTipX = -bodyLen * 0.9; + ctx.fillStyle = COLORS.birdTail; + ctx.beginPath(); + ctx.moveTo(tailBaseX, -tailSpread); + ctx.lineTo(tailTipX, -tailSpread * 0.3); + ctx.lineTo(tailTipX + 1, 0); + ctx.lineTo(tailTipX, tailSpread * 0.3); + ctx.lineTo(tailBaseX, tailSpread); + ctx.closePath(); + ctx.fill(); + + // Wings + const wingLen = 7, wingBaseX = 0.5; + ctx.strokeStyle = COLORS.bird; + ctx.lineWidth = 1.2; + ctx.globalAlpha = 0.6 + Math.abs(Math.sin(time * flapSpeed)) * 0.4; + + 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(); + + 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) + if (state === 'IDLE' || state === 'REPRODUCING') { + let rings = birdSongRings.get(ent.id); + if (!rings) { rings = []; birdSongRings.set(ent.id, rings); } + + 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; + } + + 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(); + } + + if (rings.length === 0) birdSongRings.delete(ent.id); + } else { + 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; + + ctx.save(); + ctx.translate(cx, cz); + + const wingSpan = 5; + const wingH = 3 * (0.5 + wingFlap * 0.5); + + ctx.fillStyle = COLORS.butterfly; + ctx.globalAlpha = 0.7 + wingFlap * 0.3; + + // Left wing + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(-wingSpan, -wingH); + ctx.lineTo(-wingSpan * 0.3, -wingH * 0.5); + ctx.closePath(); + ctx.fill(); + + // Right wing + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(wingSpan, -wingH); + ctx.lineTo(wingSpan * 0.3, -wingH * 0.5); + ctx.closePath(); + ctx.fill(); + + // Body + ctx.globalAlpha = 1; + ctx.fillStyle = COLORS.butterflyBody; + ctx.beginPath(); + ctx.arc(0, 0, 1.5, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + + // Pollinating glow + if (ent.state === 'POLLINATING') { + ctx.fillStyle = 'rgba(196, 166, 74, 0.15)'; + ctx.beginPath(); + ctx.arc(cx, cz, 10, 0, Math.PI * 2); + ctx.fill(); + } +} + +// ─── Utility ────────────────────────────────────────── + +function lerp(a, b, t) { + return a + (b - a) * t; +} diff --git a/client/browser/js/world-model.js b/client/browser/js/world-model.js new file mode 100644 index 0000000..f51602f --- /dev/null +++ b/client/browser/js/world-model.js @@ -0,0 +1,308 @@ +// ═══════════════════════════════════════════════════════ +// līlā — World Model (Client-Side Scene Graph) +// +// Maintains the client's local view of all entities, species +// definitions from server, and provides query methods for +// agency logic (nearest food, nearest water, etc.). +// ═══════════════════════════════════════════════════════ + +import { GRID_SIZE } from './constants.js'; + +/** + * Entity record in the client's local world model. + * Tracks both server reference position and client-agency position. + */ +export class WorldEntity { + constructor(id, type = '', species = '') { + this.id = id; + this.type = type; // ANIMAL, PLANT, TREE, INSECT, BIRD, MICROORGANISM + this.species = species; + + // Server reference position (gravity well for reconciliation) + this.refX = 0; + this.refZ = 0; + + // Client-agency position (where the entity actually is in local sim) + this.x = 0; + this.z = 0; + + // Discrete state from server + this.state = 'IDLE'; + + // Drive values from server intent packet + this.drive = {}; + + // Motion latent vector (4D, from motor model) + this.motionLatent = [0, 0, 0, 0]; + + // Eligibility flags from server + this.canConsume = false; + this.canPredatate = false; + this.canPollinate = false; + this.reproEligible = false; + this.canDrink = false; + this.spreadEligible = false; + + // Skeleton ID (for future Godot client) + this.skeletonId = null; + + // Local agency state + this.targetX = 0; + this.targetZ = 0; + this.hasTarget = false; + this.velocityX = 0; + this.velocityZ = 0; + this.speed = 1.0; // derived from species or default + + // Facing angle (radians, smoothed via lerp toward velocity direction) + this.facingAngle = 0; + + // Acknowledgment tracking + this.ackReceived = false; + + // Reconciliation queue — target positions enqueued by reconcile(), + // consumed smoothly by the agency system at 60fps. + this._reconcileQueue = []; + this._reconcileIdx = 0; + + // ── Per-entity sync personality (deterministic from ID) ── + // Spread out reconciliation so entities don't all nudge at once. + // _syncPhase: 0..3 — which frame within a sync cycle this entity reacts + // _syncSpeed: 0.4..1.0 multiplier on nudge intensity (slow / fast reactors) + const seed = hashId(id); + this._syncPhase = seed % 4; // 0, 1, 2, or 3 + this._syncSpeed = 0.4 + (seed % 60) / 100; // 0.40 .. 0.99 + this._lastReconciledTick = -10; // force reconcile on first ticks + } + + /** Distance to another entity (2D xz-plane). */ + distanceTo(other) { + const dx = this.x - other.x; + const dz = this.z - other.z; + return Math.sqrt(dx * dx + dz * dz); + } + + /** Distance squared (for comparisons without sqrt). */ + distSqTo(other) { + const dx = this.x - other.x; + const dz = this.z - other.z; + return dx * dx + dz * dz; + } + + /** Is this entity alive and active? */ + get isAlive() { + return !['DEAD', 'DYING', 'DORMANT'].includes(this.state); + } + + /** Is this a mobile consumer (animal, bird, insect)? */ + get isMobileConsumer() { + return ['ANIMAL', 'BIRD', 'INSECT'].includes(this.type); + } +} + +/** + * World model — the client's local scene graph. + * Provides entity lookup, spatial queries, and species definitions. + */ +export class WorldModel { + constructor() { + this.entities = new Map(); // id → WorldEntity + this.speciesDefs = {}; // species_id → server-provided definition + this.waterSources = []; // [{ position, radius, water_level }] + this.moisture = new Float32Array(GRID_SIZE * GRID_SIZE).fill(0.65); + } + + // ─── Entity Management ────────────────────────────── + + /** Add or update an entity from a server tick packet. */ + applyUpdate(u) { + let ent = this.entities.get(u.id); + if (!ent) { + const info = inferEntityTypeFromId(u.id); + ent = new WorldEntity(u.id, info.type, info.species); + // Initialize client position from server reference on first sight + if (u.ref_position) { + ent.x = u.ref_position[0]; + ent.z = u.ref_position[2]; + ent.refX = u.ref_position[0]; + ent.refZ = u.ref_position[2]; + } + this.entities.set(u.id, ent); + } + + // Update server reference position (gravity well) + if (u.ref_position) { + ent.refX = u.ref_position[0]; + ent.refZ = u.ref_position[2]; + } + + // Update state and drives from intent packet + if (u.state) ent.state = u.state; + if (u.drive) Object.assign(ent.drive, u.drive); + if (u.motion_latent) ent.motionLatent = u.motion_latent; + + // Eligibility flags + ent.canConsume = !!u._can_consume; + ent.canPredatate = !!u._can_predate; + ent.canPollinate = !!u._can_pollinate; + ent.reproEligible = !!u._repro_eligible; + ent.canDrink = !!u._can_drink; + ent.spreadEligible = !!u._spread_eligible; + + // Acknowledgment tracking + if (u._ack) { + ent.ackReceived = true; + } else { + ent.ackReceived = false; + } + + return ent; + } + + /** Spawn a new entity from server spawn packet. */ + applySpawn(s) { + const ent = new WorldEntity( + s.id, s.type || '', s.species || '' + ); + ent.refX = s.ref_position[0]; + ent.refZ = s.ref_position[2]; + // Client starts at server reference position for new entities + ent.x = s.ref_position[0]; + ent.z = s.ref_position[2]; + if (s.state) ent.state = s.state; + if (s.drive) Object.assign(ent.drive, s.drive); + if (s.skeleton_id) ent.skeletonId = s.skeleton_id; + this.entities.set(s.id, ent); + return ent; + } + + /** Remove an entity. */ + applyRemoval(id) { + this.entities.delete(id); + } + + // ─── Spatial Queries (for agency logic) ───────────── + + /** Find nearest alive entity of given type(s) from a position. */ + findNearest(x, z, types, excludeId = null) { + let bestDist = Infinity; + let bestEnt = null; + for (const ent of this.entities.values()) { + if (!ent.isAlive) continue; + if (excludeId && ent.id === excludeId) continue; + if (!types.includes(ent.type)) continue; + const d2 = (x - ent.x) ** 2 + (z - ent.z) ** 2; + if (d2 < bestDist) { + bestDist = d2; + bestEnt = ent; + } + } + return bestEnt; + } + + /** Find nearest alive entity of given species from a position. */ + findNearestSpecies(x, z, speciesList, excludeId = null) { + let bestDist = Infinity; + let bestEnt = null; + for (const ent of this.entities.values()) { + if (!ent.isAlive) continue; + if (excludeId && ent.id === excludeId) continue; + if (!speciesList.includes(ent.species)) continue; + const d2 = (x - ent.x) ** 2 + (z - ent.z) ** 2; + if (d2 < bestDist) { + bestDist = d2; + bestEnt = ent; + } + } + return bestEnt; + } + + /** Find nearest non-dry water source from a position. */ + findNearestWater(x, z) { + let bestDist = Infinity; + let bestSource = null; + for (const ws of this.waterSources) { + if ((ws.water_level ?? 1.0) < 0.05) continue; // dry + const d2 = (x - ws.position[0]) ** 2 + (z - ws.position[2]) ** 2; + if (d2 < bestDist) { + bestDist = d2; + bestSource = ws; + } + } + return bestSource; + } + + /** Find nearest mate (same species, different sex — approximated by ID). */ + findNearestMate(ent) { + let bestDist = Infinity; + let bestEnt = null; + for (const other of this.entities.values()) { + if (!other.isAlive) continue; + if (other.id === ent.id) continue; + if (other.species !== ent.species) continue; + // Simple heuristic: different IDs of same species are potential mates + const d2 = ent.distSqTo(other); + if (d2 < bestDist) { + bestDist = d2; + bestEnt = other; + } + } + return bestEnt; + } + + /** Get all alive entities of a given type. */ + getAliveOfType(type) { + const result = []; + for (const ent of this.entities.values()) { + if (ent.isAlive && ent.type === type) result.push(ent); + } + return result; + } + + /** Get species definition from server-provided reference. */ + getSpeciesDef(speciesId) { + return this.speciesDefs[speciesId] || null; + } + + // ─── Voxel / Environment ──────────────────────────── + + applyVoxelDeltas(deltas) { + if (deltas && deltas.moisture) { + for (const [coord, val] of Object.entries(deltas.moisture)) { + const parts = coord.split(',').map(Number); + const x = parts[0], z = parts[2]; + if (x >= 0 && x < GRID_SIZE && z >= 0 && z < GRID_SIZE) { + this.moisture[z * GRID_SIZE + x] = val; + } + } + } + } + + applyWaterSources(sources) { + this.waterSources = sources || []; + } +} + +// ─── Helpers ────────────────────────────────────────── + +/** Deterministic hash from entity ID string → positive integer. */ +function hashId(id) { + let h = 0; + for (let i = 0; i < id.length; i++) { + h = ((h << 5) - h + id.charCodeAt(i)) | 0; + } + return Math.abs(h); +} + +/** Infer entity type from ID prefix (fallback when server data is sparse). */ +function inferEntityTypeFromId(id) { + if (id.startsWith('deer')) return { type: 'ANIMAL', species: 'deer' }; + if (id.startsWith('wolf')) return { type: 'ANIMAL', species: 'wolf' }; + 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' }; + if (id.startsWith('flower')) return { type: 'PLANT', species: 'wildflower' }; + if (id.startsWith('mushroom') || id.startsWith('fungus')) return { type: 'MICROORGANISM', species: 'mushroom' }; + return { type: 'ANIMAL', species: 'unknown' }; +} diff --git a/client/python/README.md b/client/python/README.md new file mode 100644 index 0000000..495a7ed --- /dev/null +++ b/client/python/README.md @@ -0,0 +1,98 @@ +# līlā — Python Debug Client + +ImGui-based debug client for the [līlā](../../README.md) ecosystem simulation engine. Connects to a running worker via WebSocket, visualizes world state in real time, and captures structured telemetry for post-mortem analysis. + +## Quick Start + +```bash +# 1. Install dependencies (uv required) +cd client/python +uv sync --frozen + +# 2. Run the server (in another terminal) +cd ../../server && uv run python -m ecosim.worker --port 8001 + +# 3. Launch the debug client +uv run lila-client --host localhost --port 8001 +``` + +## Usage + +### Live Debug Session + +Connect to a running worker and watch telemetry stream in real time: + +```bash +uv run lila-client --host localhost --port 8001 [--world path/to/world.json] +``` + +If `--world` is omitted, the client attempts to fetch `/world.json` from the server. + +### Replay (Post-Mortem) + +Replay a recorded session's telemetry log with a time scrubber: + +```bash +uv run lila-replay ~/.lila/logs/demo-alpha-001.jsonl --speed 2x +``` + +## Architecture + +``` +┌──────────────┐ WebSocket ┌─────────────────┐ +│ ImGui Loop │ ◄────────────────► │ Simulation │ +│ (main thread)│ │ Worker │ +│ │ /telemetry WS │ :8001 │ +│ ┌──────────┐│ │ │ +│ │ World ││ │ Telemetry Bus │ +│ │ Model ││ │ (JSONL file) │ +│ └──────────┘│ └─────────────────┘ +│ │ +│ ┌──────────┐│ +│ │Telemetry ││ +│ │Timeline ││ +│ └──────────┘│ +│ │ +│ ┌──────────┐│ +│ │Entity ││ +│ │Inspector ││ +│ └──────────┘│ +└──────────────┘ +``` + +### Key Modules + +| Module | Purpose | +|--------|---------| +| `websocket.py` | Async WS client in background thread. Bridges asyncio ↔ ImGui via queues. Handles `/ws` (simulation) and `/telemetry` (event stream). | +| `world_model.py` | Local scene graph mirroring the server's entity state. Spatial queries for agency logic. | +| `imgui_view.py` | ImGui render pass: world view, telemetry timeline with filters, entity inspector panel. | +| `replay.py` | Loads JSONL logs and replays events with a tick scrubber for post-mortem analysis. | + +### Telemetry Integration + +The server's embedded [telemetry bus](../../server/ecosim/telemetry.py) streams structured events to the client in real time: + +- **Intent emit** — drives, eligibility flags, reference positions per entity per tick +- **Event log** — consumption, death, reproduction, state transitions with causal context +- **Absorption trace** — what the server received from client heartbeats and how it was handled + +All events are written to `~/.lila/logs/.jsonl` for offline replay. + +## Dependencies + +| Package | Purpose | +|---------|---------| +| [dearpygui](https://github.com/hoffstadt/DearPyGui) | High-level Python ImGui binding (simpler API, no boilerplate) | +| [websockets](https://websockets.readthedocs.io/) | Async WebSocket client for simulation + telemetry streams | +| [numpy](https://numpy.org/) | Moisture heatmap rendering, spatial math | + +## Development + +```bash +# Install in editable mode with dev dependencies +uv sync --frozen + +# Run directly +uv run python -m lila_client.main --host localhost --port 8001 +``` diff --git a/client/python/lila_client/__init__.py b/client/python/lila_client/__init__.py new file mode 100644 index 0000000..672b643 --- /dev/null +++ b/client/python/lila_client/__init__.py @@ -0,0 +1 @@ +# līlā Python ImGui Client diff --git a/client/python/lila_client/__main__.py b/client/python/lila_client/__main__.py new file mode 100644 index 0000000..1c30620 --- /dev/null +++ b/client/python/lila_client/__main__.py @@ -0,0 +1,6 @@ +"""līlā Python Client — allow ``python -m lila_client``.""" + +from .main import main + +if __name__ == "__main__": + main() diff --git a/client/python/lila_client/agency.py b/client/python/lila_client/agency.py new file mode 100644 index 0000000..9b86ce6 --- /dev/null +++ b/client/python/lila_client/agency.py @@ -0,0 +1,507 @@ +"""līlā Python Client — Client-side agency engine. + +Between server ticks, each mobile entity decides what to do based on: + - Server intent (state + drives + eligibility flags) + - Local perception (nearest food, water, threats from world model) + - Motion latent (modulates speed, hesitation, path curvature) + - Gravity well toward server ref_position (continuous pull) + +This is the "body" in "server is nervous system, client is body." +""" + +from __future__ import annotations + +import math +import random +import time +from typing import Any + +from .constants import GRID_SIZE +from .reconciliation import has_reconcile_target, get_reconcile_target, advance_reconcile +from .world_model import WorldEntity + +# Gravity well: gentle pull toward ref_position, always active. +# ~0.05 × speed per frame ≈ 3 units/s pull, enough to drift back +# without overpowering the entity's desired behavior. +# Each entity also has _sync_speed (0.4..1.0) that modulates this. +GRAVITY_WELL_FACTOR = 0.05 + + +def step_agency(world, dt: float) -> list[dict]: + """Run one frame of local agency for all mobile entities. + + Called every render frame (~60 Hz). Returns client-reported events + to send upstream via heartbeat. + """ + events: list[dict] = [] + + for ent in world.entities.values(): + if not ent.is_mobile_consumer or not ent.is_alive: + continue + + # Update speed from species definition (once) + if not getattr(ent, "_speed_set", False): + defn = world.species_defs.get(ent.species, {}) + ent.speed = defn.get("movement_speed", 2.0) + ent._speed_set = True + + # Reconciling entities meander toward their queued target + if has_reconcile_target(ent): + tx, tz = get_reconcile_target(ent) + if _execute_reconcile_meander(ent, tx, tz, world, dt): + advance_reconcile(ent) + else: + # Normal agency: evaluate behavior + gravity well toward ref + action = evaluate_behavior(ent, world) + execute_action(ent, action, world, dt, events) + _apply_gravity_well(ent, world, dt) + + return events + + +def _apply_gravity_well(ent: WorldEntity, world, dt: float) -> None: + """Gently pull entity toward server ref_position. + + Always active during normal agency. Each entity has its own + _sync_speed (0.4..1.0) so the pull strength varies per entity, + making the sync look organic rather than uniform. + """ + dx = ent.ref_x - ent.x + dz = ent.ref_z - ent.z + dist = math.sqrt(dx * dx + dz * dz) + + if dist < 0.2: + return # Already close enough + + # Per-entity sync speed modulates the gravity well strength. + # Some entities are sluggish (0.4), others quick (0.99). + speed_factor = getattr(ent, "_sync_speed", 0.7) + nudge = GRAVITY_WELL_FACTOR * speed_factor * dt + ent.x += dx * nudge + ent.z += dz * nudge + ent.x = clamp(ent.x, GRID_SIZE) + ent.z = clamp(ent.z, GRID_SIZE) + + +def _execute_reconcile_meander( + ent: WorldEntity, + tx: float, + tz: float, + world, + dt: float, +) -> bool: + """Meander a reconciling entity toward its queued target (tx, tz). + + Produces a spiral/circle motion: radial pull toward target combined + with a perpendicular wobble. Returns True when the entity has arrived. + + The approach curve accelerates as the entity gets closer — it circles + wide at first then tightens into the target, like a bird landing. + + Each entity's _sync_speed modulates the approach rate. + """ + dx = tx - ent.x + dz = tz - ent.z + dist = math.sqrt(dx * dx + dz * dz) + + if dist < 0.5: + # Close enough — advance to next target + ent.velocity_x = 0 + ent.velocity_z = 0 + return True + + nx = dx / dist + nz = dz / dist + + # Perpendicular direction (rotate 90°) + px = -nz + pz = nx + + speed = ent.speed or 2.0 + + # Per-entity sync speed modulates the radial approach rate. + speed_factor = getattr(ent, "_sync_speed", 0.7) + + # Radial step: move toward target + radial_step = min(speed * 1.5 * speed_factor * dt, dist) + + # Perpendicular wobble: wide circles that tighten as we approach. + # Wobble amplitude scales with distance — large arcs far away, + # gentle spirals near the target. + wobble_phase = time.monotonic() * 3.0 + _entity_phase(ent.id) + wobble_amp = math.sin(wobble_phase) * min(speed * 0.5, dist * 0.3) + wobble_step = wobble_amp * dt + + ent.x += nx * radial_step + px * wobble_step + ent.z += nz * radial_step + pz * wobble_step + ent.x = clamp(ent.x, GRID_SIZE) + ent.z = clamp(ent.z, GRID_SIZE) + + # Update velocity and facing + ent.velocity_x = nx * speed * 1.5 * speed_factor + px * wobble_amp + ent.velocity_z = nz * speed * 1.5 * speed_factor + pz * wobble_amp + ent.facing_angle = _lerp_angle( + ent.facing_angle, math.atan2(dz, dx), 0.12 + ) + + return False + + +def _entity_phase(eid: str) -> float: + """Deterministic per-entity offset for wobble phase.""" + h = 0 + for c in eid: + h = (h * 31 + ord(c)) & 0xFFFF + return h / 65536.0 * 2 * math.pi + + +# ─── Behavior Evaluators ────────────────────────────────────────────────── + + +def evaluate_behavior(ent: WorldEntity, world) -> dict[str, Any]: + """Evaluate what an entity should do based on its intent and local perception.""" + state = ent.state + drive = ent.drive or {} + species_def = world.species_defs.get(ent.species, {}) + + # ── Fleeing (highest priority) ── + if state == "FLEEING": + return evaluate_fleeing(ent, world, species_def) + + # ── Drinking ── + if state == "DRINKING" or (ent.can_drink and drive.get("hydration", 1.0) < 0.3): + return evaluate_drinking(ent, world) + + # ── Reproduction seeking ── + if ent.repro_eligible and drive.get("reproductive_drive", 0) > 0.5: + mate_action = evaluate_mate_seeking(ent, world) + if mate_action: + return mate_action + + # ── Foraging / Herbivory ── + if state == "FORAGING" and ent.can_consume: + return evaluate_foraging(ent, world, species_def) + + # ── Hunting / Predation ── + if (state in ("HUNTING", "FORAGING")) and ent.can_predate: + return evaluate_hunting(ent, world, species_def) + + # ── Pollination ── + if ent.can_pollinate and species_def.get("is_pollinator"): + return evaluate_pollination(ent, world, species_def) + + # ── Resting / Idle — wander with latent-modulated style ── + return evaluate_wandering(ent, world) + + +def evaluate_fleeing(ent: WorldEntity, world, species_def: dict) -> dict: + """Flee from nearest threat.""" + flee_targets = species_def.get("flee_targets", []) + if not flee_targets: + return evaluate_wandering(ent, world) + + nearest_threat: WorldEntity | None = None + best_dist_sq = float("inf") + for other in world.entities.values(): + if not other.is_alive: + continue + other_def = world.species_defs.get(other.species, {}) + if not other_def or other.species not in flee_targets: + continue + d2 = ent.dist_sq_to(other) + if d2 < best_dist_sq: + best_dist_sq = d2 + nearest_threat = other + + if nearest_threat and best_dist_sq < 400: # ~20 world units sensory range² + dx = ent.x - nearest_threat.x + dz = ent.z - nearest_threat.z + dist = math.sqrt(dx * dx + dz * dz) or 1 + return { + "type": "flee", + "target_x": clamp(ent.x + (dx / dist) * 8, GRID_SIZE), + "target_z": clamp(ent.z + (dz / dist) * 8, GRID_SIZE), + } + + return evaluate_wandering(ent, world) + + +def evaluate_drinking(ent: WorldEntity, world) -> dict: + """Approach nearest water source.""" + water = world.find_nearest_water(ent.x, ent.z) + if water: + wx, _, wz = water["position"] + r = water.get("radius", 1) + dx = wx - ent.x + dz = wz - ent.z + dist = math.sqrt(dx * dx + dz * dz) or 1 + approach_r = max(r - 0.5, 0.3) + return { + "type": "drink", + "target_x": clamp(wx - (dx / dist) * approach_r, GRID_SIZE), + "target_z": clamp(wz - (dz / dist) * approach_r, GRID_SIZE), + } + return evaluate_wandering(ent, world) + + +def evaluate_mate_seeking(ent: WorldEntity, world) -> dict | None: + """Seek a mate of the same species.""" + best: WorldEntity | None = None + best_dist = 15.0 # sensory range + for other in world.entities.values(): + if other.id == ent.id: + continue + if other.species != ent.species: + continue + if not other.is_alive: + continue + d = ent.distance_to(other) + if d < best_dist: + best_dist = d + best = other + + if best: + return { + "type": "seek_mate", + "target_x": best.x, + "target_z": best.z, + "target_id": best.id, + } + return None + + +def evaluate_foraging(ent: WorldEntity, world, species_def: dict) -> dict: + """Find nearest food item to approach.""" + diet_order = species_def.get("diet_order", []) + + for food_species in diet_order: + if isinstance(food_species, (list, tuple)): + food_species = food_species[0] + food = world.find_nearest_species(ent.x, ent.z, [food_species], ent.id) + if food and ent.distance_to(food) < 15: + return { + "type": "forage", + "target_x": food.x, + "target_z": food.z, + "target_id": food.id, + } + + return evaluate_wandering(ent, world) + + +def evaluate_hunting(ent: WorldEntity, world, species_def: dict) -> dict: + """Find nearest prey to hunt.""" + diet_order = species_def.get("diet_order", []) + prey_species = [s[0] if isinstance(s, (list, tuple)) else s for s in diet_order] + + if prey_species: + prey = world.find_nearest_species(ent.x, ent.z, prey_species, ent.id) + if prey and ent.distance_to(prey) < 15: + return { + "type": "hunt", + "target_x": prey.x, + "target_z": prey.z, + "target_id": prey.id, + } + + return evaluate_wandering(ent, world) + + +def evaluate_pollination(ent: WorldEntity, world, species_def: dict) -> dict: + """Find nearest fruiting flower to pollinate.""" + poll_targets = species_def.get("pollination_targets", []) + if not poll_targets: + return evaluate_wandering(ent, world) + + for flower in world.entities.values(): + if not flower.is_alive: + continue + if flower.species not in poll_targets: + continue + if flower.state != "FRUITING": + continue + dist = ent.distance_to(flower) + if 0.5 < dist < 15: + return { + "type": "pollinate", + "target_x": flower.x, + "target_z": flower.z, + "target_id": flower.id, + } + + return evaluate_wandering(ent, world) + + +def evaluate_wandering(ent: WorldEntity, world) -> dict: + """Pick a wander target near the entity, modulated by motion latent.""" + # Reuse existing wander target if still valid and not reached + if getattr(ent, "has_target", False) and getattr(ent, "_last_action_type", "") == "wander": + dx = ent.target_x - ent.x + dz = ent.target_z - ent.z + if math.sqrt(dx * dx + dz * dz) > 0.5: + return {"type": "wander", "target_x": ent.target_x, "target_z": ent.target_z} + + ml = ent.motion_latent or [0, 0, 0, 0] + urgency = abs(ml[0]) # dim 0 = pace/urgency + wander_range = 2 + (1 - urgency) * 4 + + return { + "type": "wander", + "target_x": clamp(ent.x + (random.random() - 0.5) * wander_range * 2, GRID_SIZE), + "target_z": clamp(ent.z + (random.random() - 0.5) * wander_range * 2, GRID_SIZE), + } + + +# ─── Action Execution ───────────────────────────────────────────────────── + + +def execute_action( + ent: WorldEntity, + action: dict[str, Any], + world, + dt: float, + events: list[dict], +) -> None: + """Execute an action for one entity over one frame.""" + if not action or ("target_x" not in action and action.get("type") != "wander"): + return + + ml = ent.motion_latent or [0, 0, 0, 0] + urgency = (ml[0] + 1) * 0.5 # normalize to 0..1 + caution = abs(ml[1]) # dim 1 = alertness + + base_speed = ent.speed or 2.0 + + # Modulate speed by action type + speed_mod = { + "flee": 1.5 + urgency * 0.5, + "hunt": 1.2 + urgency * 0.3, + "forage": 0.8 + urgency * 0.4, + "drink": 0.7, + "seek_mate": 0.6 + urgency * 0.3, + "pollinate": 0.5 + urgency * 0.3, + }.get(action.get("type", "wander"), 0.3 + (1 - caution) * 0.4) + + base_speed *= speed_mod + + target_x = action.get("target_x", ent.x) + target_z = action.get("target_z", ent.z) + + # Move toward target + dx = target_x - ent.x + dz = target_z - ent.z + dist = math.sqrt(dx * dx + dz * dz) + + if dist > 0.1: + step = min(base_speed * dt, dist) + # Add slight curvature based on caution — high caution = more wobble + wobble = caution * math.sin(time.monotonic() * 3 + ent.x) * 0.3 + + ent.x += (dx / dist) * step + wobble * dt + ent.z += (dz / dist) * step - wobble * dt + ent.x = clamp(ent.x, GRID_SIZE) + ent.z = clamp(ent.z, GRID_SIZE) + + ent.velocity_x = (dx / dist) * base_speed + ent.velocity_z = (dz / dist) * base_speed + + # Lerp facing angle toward travel direction (smooth rotation) + target_angle = math.atan2(dz, dx) + ent.facing_angle = _lerp_angle(ent.facing_angle, target_angle, 0.15) + else: + # Arrived at target + ent.velocity_x = 0 + ent.velocity_z = 0 + + # Check for interaction triggers on arrival + _check_interaction(ent, action, world, events) + + # Reset wander target so next frame picks a new one + if action.get("type") == "wander": + ent.has_target = False + + ent.target_x = target_x + ent.target_z = target_z + ent._last_action_type = action.get("type", "wander") + ent.has_target = True + + +def _check_interaction( + ent: WorldEntity, + action: dict[str, Any], + world, + events: list[dict], +) -> None: + """Check if an entity should trigger an interaction on target arrival.""" + target_id = action.get("target_id") + if not target_id: + return + + target_ent = world.entities.get(target_id) + if not target_ent or not target_ent.is_alive: + return + + dist = ent.distance_to(target_ent) + if dist > 3.0: + return + + # Cooldown: don't re-interact with same target within 2 seconds + now = time.monotonic() + cooldowns = getattr(ent, "_interaction_cooldowns", {}) + key = f"{ent.id}:{target_id}" + if cooldowns.get(key, 0) > now - 2.0: + return + + action_type = action.get("type", "") + + if action_type == "forage" and dist < 2.0 and ent.can_consume: + events.append({ + "type": "consumption", + "source_id": ent.id, + "target_id": target_id, + "position": [target_ent.x, 0, target_ent.z], + }) + cooldowns[key] = now + + elif action_type == "hunt" and dist < 1.5 and ent.can_predate: + events.append({ + "type": "predation", + "source_id": ent.id, + "target_id": target_id, + "kill_position": [target_ent.x, 0, target_ent.z], + }) + cooldowns[key] = now + + elif action_type == "pollinate" and dist < 1.5 and ent.can_pollinate: + events.append({ + "type": "pollination", + "source_id": ent.id, + "target_id": target_id, + "position": [target_ent.x, 0, target_ent.z], + }) + cooldowns[key] = now + + elif action_type == "seek_mate" and dist < 3.0 and ent.repro_eligible: + events.append({ + "type": "repro", + "parent_id": ent.id, + "offspring_count": 1, + "client_position": [ent.x, 0, ent.z], + }) + cooldowns[key] = now + + ent._interaction_cooldowns = cooldowns + + +# ─── Helpers ──────────────────────────────────────────────────────────────── + + +def clamp(v: float, max_val: int) -> float: + return max(0.5, min(max_val - 0.5, v)) + + +def _lerp_angle(a: float, b: float, t: float) -> float: + """Spherical lerp between two angles (handles wrapping at ±π).""" + # Normalize difference to [-π, π] + diff = math.atan2(math.sin(b - a), math.cos(b - a)) + return a + diff * t diff --git a/client/python/lila_client/constants.py b/client/python/lila_client/constants.py new file mode 100644 index 0000000..1e3fd81 --- /dev/null +++ b/client/python/lila_client/constants.py @@ -0,0 +1,78 @@ +"""līlā Python Client — Constants and Configuration.""" + +import pathlib + +# ─── Connection ────────────────────────────────────────────────────────────── + +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 8001 +WS_PATH = "/ws" +TELEMETRY_PATH = "/telemetry" +RECONNECT_DELAY = 3.0 # seconds between reconnection attempts +HEARTBEAT_INTERVAL_MS = 1000 # ms between client heartbeat sends + +# ─── Grid / World ──────────────────────────────────────────────────────────── + +GRID_SIZE = 32 +CELL_PX = 18 +PADDING = 40 +CANVAS_SIZE = GRID_SIZE * CELL_PX + PADDING * 2 + +# Server tick rate (seconds) — intent-based mode at 0.5 Hz +SERVER_TICK_RATE = 2.0 + +# Reconciliation thresholds +RECONCILE_SNAP_THRESHOLD_MULT = 3.0 +RECONCILE_NUDGE_FACTOR = 0.3 + +# ─── Colors (matching browser visualizer) ──────────────────────────────────── + +COLORS = { + "bg": (15, 16, 15), + "grid": (184, 180, 168, 10), + "gridMajor": (184, 180, 168, 20), + + # Moisture heatmap + "moistureHigh": (48, 58, 52), + "moistureMid": (42, 44, 38), + "moistureLow": (72, 62, 42), + + # Entities + "deer": (196, 149, 106), + "bird": (138, 123, 107), + "butterfly": (168, 124, 196), + "oak": (61, 107, 61), + "grass": (107, 143, 94), + "wildflower": (196, 166, 74), + + # Events + "consumption": (143, 170, 110), + "pollination": (196, 166, 74), + "death": (110, 90, 90), + + # Water + "waterFill": (45, 85, 110), + "waterEdge": (55, 105, 125), + "waterShine": (70, 130, 150), + + # Telemetry levels + "level_DEBUG": (128, 128, 128), + "level_INFO": (140, 180, 140), + "level_WARN": (200, 170, 60), + "level_ERROR": (200, 80, 80), +} + +# Entity type → color key mapping +ENTITY_COLOR_MAP = { + ("ANIMAL", "deer"): "deer", + ("BIRD", "songbird"): "bird", + ("INSECT", "monarch"): "butterfly", + ("TREE", "meadow_oak"): "oak", + ("PLANT", "meadow_grass"): "grass", + ("PLANT", "wildflower"): "wildflower", +} + +# ─── Telemetry ──────────────────────────────────────────────────────────────── + +MAX_TELEMETRY_BUFFER = 5000 # max events kept in memory +TELEMETRY_LOG_DIR = pathlib.Path.home() / ".lila" / "logs" diff --git a/client/python/lila_client/imgui_view.py b/client/python/lila_client/imgui_view.py new file mode 100644 index 0000000..fba64b5 --- /dev/null +++ b/client/python/lila_client/imgui_view.py @@ -0,0 +1,388 @@ +"""līlā Python Client — Dear PyGui debug viewer.""" + +from __future__ import annotations + +import math +import time +from collections import deque +from typing import Optional + +import dearpygui.dearpygui as dpg +from .constants import COLORS, MAX_TELEMETRY_BUFFER, ENTITY_COLOR_MAP, GRID_SIZE, CELL_PX, PADDING +from .world_model import WorldEntity + + +class TelemetryBuffer: + """In-memory telemetry event buffer with filtering.""" + + def __init__(self, max_size: int = MAX_TELEMETRY_BUFFER): + self._events: deque[dict] = deque(maxlen=max_size) + self.filter_src: set[str] = set() + self.filter_level: set[str] = {"DEBUG", "INFO", "WARN", "ERROR"} + self.filter_evt: set[str] = set() + + def add(self, event: dict) -> None: + self._events.append(event) + if not self.filter_src: + self.filter_src.add(event.get("src", "unknown")) + if not self.filter_evt: + self.filter_evt.add(event.get("evt", "unknown")) + + @property + def events(self): + return self._events + + def filtered_events(self) -> list[dict]: + results = [] + for event in self._events: + if self.filter_src and event.get("src") not in self.filter_src: + continue + if event.get("level") not in self.filter_level: + continue + if self.filter_evt and event.get("evt") not in self.filter_evt: + continue + results.append(event) + return list(reversed(results)) + + @property + def unique_sources(self) -> set[str]: + return {e.get("src", "unknown") for e in self._events} + + @property + def unique_events(self) -> set[str]: + return {e.get("evt", "unknown") for e in self._events} + + +class DearPyGuiViewer: + """Dear PyGui viewer for the līlā debug client.""" + + def __init__(self, world): + self.world = world + self.telemetry = TelemetryBuffer() + self.selected_entity_id: Optional[str] = None + self._frame_count = 0 + self._fps_time = time.monotonic() + self.display_fps = 0 + + def build(self) -> None: + """Build the Dear PyGui UI (called once at startup).""" + # Theme colors + + # ── Main window ────────────────────────────────────────────── + with dpg.window(label="Main", tag="main_window", width=1200, height=800, no_collapse=True): + + # Status bar (top) + with dpg.child_window(tag="status_bar", height=30, border=False, menubar=False): + dpg.add_text("● running", tag="status_text", color=(100, 200, 100)) + dpg.add_text("līlā debug client", color=(180, 175, 165)) + dpg.add_spacer(width=100) + dpg.add_text(tag="stat_entities") + dpg.add_spacer(width=10) + dpg.add_text(tag="stat_telemetry") + dpg.add_spacer(width=10) + dpg.add_text(tag="stat_fps") + + dpg.add_separator() + + # ── Left: World view ────────────────────────────────────── + with dpg.child_window(tag="world_panel", width=-320, border=True): + with dpg.group(horizontal=True): + dpg.add_button( + label="⏸ Pause", tag="btn_pause", + callback=lambda: self._on_pause(), + ) + dpg.add_button( + label="☔ Rain", tag="btn_rain", + callback=lambda: self._on_rain(), + ) + + # Scene canvas (drawlist) + canvas_size = GRID_SIZE * CELL_PX + PADDING * 2 + with dpg.child_window(tag="scene_panel", height=canvas_size + 20, border=False, no_scrollbar=True): + dpg.add_drawlist( + width=canvas_size, height=canvas_size, tag="scene_drawlist", + ) + + dpg.add_separator() + dpg.add_text("Entities:", tag="entity_header") + with dpg.child_window(tag="entity_child", border=False): + dpg.add_listbox(tag="entity_list", callback=self._on_entity_select) + + # ── Right: Telemetry ────────────────────────────────────── + with dpg.child_window(tag="telemetry_panel", width=310, height=-150, border=True): + dpg.add_text("Telemetry", bullet=True) + + # Source filters + dpg.add_text("Sources:") + with dpg.group(tag="src_filters", horizontal=True): + pass # checkboxes added dynamically + + dpg.add_separator() + + # Level filters + dpg.add_text("Levels:") + with dpg.group(tag="lvl_filters", horizontal=True): + for level in ("DEBUG", "INFO", "WARN", "ERROR"): + dpg.add_checkbox(label=level, default_value=True, tag=f"lvl_{level}", callback=self._on_level_filter) + + dpg.add_separator() + + # Event timeline + with dpg.child_window(tag="telemetry_child", border=False): + dpg.add_listbox(tag="telemetry_list", + callback=self._on_telemetry_select) + + # ── Bottom: Entity Inspector ────────────────────────────── + with dpg.window(tag="inspector_window", label="Entity Inspector", + pos=(620, 400), width=560, height=350, + no_collapse=True, collapsed=True): + dpg.add_text(tag="inspector_content") + + # ── Scene Rendering ────────────────────────────────────────────── + + def _render_scene(self) -> None: + """Draw the world scene on the drawlist (called each frame from render()).""" + with dpg.drawlist("scene_drawlist") as drawlist: + + def world_to_canvas(wx, wz): + return (PADDING + wx * CELL_PX, PADDING + wz * CELL_PX) + + # ── Moisture heatmap ── + moisture = self.world.moisture if hasattr(self.world, 'moisture') else None + if moisture and len(moisture) == GRID_SIZE * GRID_SIZE: + for z in range(GRID_SIZE): + for x in range(GRID_SIZE): + val = moisture[z * GRID_SIZE + x] + cx, cz = world_to_canvas(x, z) + if val > 0.5: + f = (val - 0.5) * 2 + r = int(COLORS["moistureMid"][0] + (COLORS["moistureHigh"][0] - COLORS["moistureMid"][0]) * f) + g = int(COLORS["moistureMid"][1] + (COLORS["moistureHigh"][1] - COLORS["moistureMid"][1]) * f) + b = int(COLORS["moistureMid"][2] + (COLORS["moistureHigh"][2] - COLORS["moistureMid"][2]) * f) + else: + f = val * 2 + r = int(COLORS["moistureLow"][0] + (COLORS["moistureMid"][0] - COLORS["moistureLow"][0]) * f) + g = int(COLORS["moistureLow"][1] + (COLORS["moistureMid"][1] - COLORS["moistureLow"][1]) * f) + b = int(COLORS["moistureLow"][2] + (COLORS["moistureMid"][2] - COLORS["moistureLow"][2]) * f) + dpg.draw_rectangle( + (cx, cz), (cx + CELL_PX, cz + CELL_PX), + color=(r, g, b, 255), fill=(r, g, b, 255), + tag=f"mc_{x}_{z}", parent=drawlist, + ) + + # ── Grid lines ── + for i in range(GRID_SIZE + 1): + major = i % 8 == 0 + color = (184, 180, 168, 40) if major else (184, 180, 168, 15) + thickness = 1.0 if major else 0.5 + p = PADDING + i * CELL_PX + dpg.draw_line( + (p, PADDING), (p, PADDING + GRID_SIZE * CELL_PX), + color=color, thickness=thickness, tag=f"gv_{i}", parent=drawlist, + ) + dpg.draw_line( + (PADDING, p), (PADDING + GRID_SIZE * CELL_PX, p), + color=color, thickness=thickness, tag=f"gh_{i}", parent=drawlist, + ) + + # ── Water sources ── + for ws in getattr(self.world, 'water_sources', []) or []: + wx, _, wz = ws.get("position", [0, 0, 0]) + cx, cz = world_to_canvas(wx, wz) + radius = ws.get("radius", 1.0) * CELL_PX + level = ws.get("water_level", 1.0) + if level < 0.02 or radius < 1: + continue + alpha = int((0.3 + level * 0.4) * 255) + dpg.draw_circle( + (cx + CELL_PX / 2, cz + CELL_PX / 2), radius, + color=(*COLORS["waterFill"], alpha), fill=(*COLORS["waterFill"], alpha), + tag=f"wf_{wx}_{wz}", parent=drawlist, + ) + dpg.draw_circle( + (cx + CELL_PX / 2, cz + CELL_PX / 2), radius, + color=(*COLORS["waterEdge"], int(alpha * 0.5)), + thickness=1.0, tag=f"we_{wx}_{wz}", parent=drawlist, + ) + + # ── Entities (layered by type) ── + layer_order = ["TREE", "PLANT", "MICROORGANISM", "ANIMAL", "BIRD", "INSECT"] + for etype in layer_order: + for ent in self.world.entities.values(): + if not ent.is_alive and ent.state != "DORMANT": + continue + if ent.type != etype: + continue + if math.isnan(ent.x) or math.isnan(ent.z): + continue + cx, cz = world_to_canvas(ent.x, ent.z) + color_key = ENTITY_COLOR_MAP.get((ent.type, ent.species), "deer") + color_rgb = COLORS.get(color_key, (150, 150, 150)) + + if ent.type == "TREE": + r = 4.0 * CELL_PX * 0.5 * (ent.drive.get("growth", 0.5) if ent.drive else 0.5) + r = max(r, 4) + dpg.draw_circle((cx, cz), r, color=(*color_rgb, 80), fill=(*color_rgb, 80), tag=f"tc_{ent.id}", parent=drawlist) + dpg.draw_circle((cx, cz), max(3, r * 0.6), color=(*color_rgb, 255), fill=(*color_rgb, 255), tag=f"tb_{ent.id}", parent=drawlist) + elif ent.type == "ANIMAL": + r = 5 if ent.state == "RESTING" else 7 + dpg.draw_circle((cx, cz), r, color=(*color_rgb, 200), fill=(*color_rgb, 200), tag=f"a_{ent.id}", parent=drawlist) + dpg.draw_circle((cx, cz), 2, color=(*color_rgb, 255), fill=(*color_rgb, 255), tag=f"ah_{ent.id}", parent=drawlist) + elif ent.type == "BIRD": + dpg.draw_circle((cx, cz), 5, color=(*color_rgb, 200), fill=(*color_rgb, 200), tag=f"b_{ent.id}", parent=drawlist) + elif ent.type == "INSECT": + dpg.draw_circle((cx, cz), 4, color=(*color_rgb, 180), fill=(*color_rgb, 180), tag=f"i_{ent.id}", parent=drawlist) + elif ent.type == "PLANT": + r = 2 + (ent.drive.get("growth", 0.1) if ent.drive else 0.1) * 4 + dpg.draw_circle((cx, cz), max(1, r), color=(*color_rgb, 180), fill=(*color_rgb, 180), tag=f"p_{ent.id}", parent=drawlist) + elif ent.type == "MICROORGANISM": + r = 2 + (ent.drive.get("activity", 0.5) if ent.drive else 0.5) * 3 + dpg.draw_circle((cx, cz), max(1, r), color=(*color_rgb, 120), fill=(*color_rgb, 120), tag=f"mg_{ent.id}", parent=drawlist) + + # ── Frame Rendering ────────────────────────────────────────────── + + def render(self) -> None: + """Update UI each frame (called by render callback).""" + self._frame_count += 1 + now = time.monotonic() + if now - self._fps_time >= 1.0: + self.display_fps = self._frame_count + self._frame_count = 0 + self._fps_time = now + + # ── Update status bar ──────────────────────────────────────── + paused = dpg.get_value("btn_pause") == "⏸ Pause" + status_color = (200, 100, 100) if paused else (100, 200, 100) + dpg.set_value("status_text", f"{'● paused' if paused else '● running'}") + dpg.configure_item("status_text", color=status_color) + + dpg.set_value("stat_entities", f"entities: {len(self.world.entities)}") + dpg.set_value("stat_telemetry", f"telemetry: {len(self.telemetry.events)}") + dpg.set_value("stat_fps", f"fps: {self.display_fps}") + + # ── Update entity list ─────────────────────────────────────── + entities = sorted(self.world.entities.values(), key=lambda e: (e.type, e.id)) + items = [] + for ent in entities[:80]: + color_key = ENTITY_COLOR_MAP.get((ent.type, ent.species), "deer") + color = COLORS.get(color_key, (150, 150, 150)) + items.append((f"{ent.id} [{ent.state}]", ent.id, color)) + + dpg.configure_item("entity_list", items=[i[0] for i in items]) + # Store color info for highlighting + self._entity_items = items + + # Highlight selected + if self.selected_entity_id: + idx = next((i for i, it in enumerate(items) if it[1] == self.selected_entity_id), -1) + dpg.set_item_focus_request("entity_list") + if idx >= 0: + dpg.set_value("entity_list", idx) + + # ── Update telemetry list ──────────────────────────────────── + events = self.telemetry.filtered_events()[:100] + lines = [] + for event in events: + level = event.get("level", "INFO") + tick = f"T{event['tick']}" if event.get("tick") is not None else "T—" + eid = f" {event['entity_id']}" if event.get("entity_id") else "" + lines.append(f"[{tick}] [{level:5s}] {event.get('src','?'):8s} {event.get('evt','?')}{eid}") + + dpg.configure_item("telemetry_list", items=lines) + self._telemetry_lines = list(zip(lines, events)) + + # ── Update source filters ──────────────────────────────────── + current_sources = self.telemetry.unique_sources + existing_tags = {t for t in dpg.get_item_children("src_filters")} + for src in current_sources - {t.replace("src_", "") for t in existing_tags}: + dpg.add_checkbox( + label=src, tag=f"src_{src}", default_value=True, + parent="src_filters", callback=self._on_src_filter, + ) + + # ── Update entity inspector ────────────────────────────────── + if self.selected_entity_id: + ent = self.world.entities.get(self.selected_entity_id) + if ent: + dpg.configure_item("inspector_window", collapsed=False, + label=f"Entity: {ent.id}") + self._build_inspector(ent) + + # ── Callbacks ────────────────────────────────────────────────────── + + def _on_pause(self, sender, app_data): + dpg.set_value(sender, "▶ Resume" if dpg.get_value(sender) == "⏸ Pause" else "⏸ Pause") + + def _on_rain(self, sender, app_data): + # Signal rain control to send via WS + pass # Handled via viewer.rain_triggered + + def _on_entity_select(self, sender, app_data): + idx = app_data if isinstance(app_data, int) else (app_data[0] if app_data else -1) + if 0 <= idx < len(self._entity_items): + self.selected_entity_id = self._entity_items[idx][1] + + def _on_telemetry_select(self, sender, app_data): + idx = app_data if isinstance(app_data, int) else (app_data[0] if app_data else -1) + if 0 <= idx < len(self._telemetry_lines): + event = self._telemetry_lines[idx][1] + if event.get("entity_id"): + self.selected_entity_id = event["entity_id"] + + def _on_level_filter(self, sender, app_data): + level = sender.replace("lvl_", "") + if app_data: + self.telemetry.filter_level.add(level) + else: + self.telemetry.filter_level.discard(level) + + def _on_src_filter(self, sender, app_data): + src = sender.replace("src_", "") + if app_data: + self.telemetry.filter_src.add(src) + else: + self.telemetry.filter_src.discard(src) + if not self.telemetry.filter_src: + self.telemetry.filter_src = set() + + def _build_inspector(self, ent: WorldEntity) -> None: + """Build/update the entity inspector panel.""" + with dpg.item_handler_registry(tag="inspector_registry"): + pass + + # Clear and rebuild + dpg.delete_item("inspector_content", children_only=True) + + with dpg.group(tag="inspector_content"): + dpg.add_text(f"ID: {ent.id}") + dpg.add_text(f"Type: {ent.type} Species: {ent.species}") + dpg.add_text(f"State: {ent.state}") + dpg.add_separator() + + dpg.add_text("Position:", bullet=True) + dpg.add_text(f" Server ref: ({ent.ref_x:.2f}, {ent.ref_z:.2f})") + dpg.add_text(f" Client pos: ({ent.x:.2f}, {ent.z:.2f})") + dx = ent.x - ent.ref_x + dz = ent.z - ent.ref_z + div = math.sqrt(dx*dx + dz*dz) + dpg.add_text(f" Divergence: {div:.3f} units") + dpg.add_separator() + + if ent.drive: + dpg.add_text("Drives:", bullet=True) + for key, value in ent.drive.items(): + dpg.add_text(f" {key}: {value:.4f}") + dpg.add_separator() + + dpg.add_text("Eligibility:", bullet=True) + for flag in ("can_consume", "can_predate", "can_pollinate", + "repro_eligible", "can_drink", "spread_eligible"): + val = getattr(ent, flag, False) + sym = "✓" if val else "✗" + col = (100, 200, 100) if val else (100, 100, 100) + dpg.add_text(f" {sym} {flag}", color=col) + + if ent.motion_latent: + dpg.add_separator() + dpg.add_text("Motion Latent:", bullet=True) + dpg.add_text(f" [{', '.join(f'{v:.3f}' for v in ent.motion_latent)}]") diff --git a/client/python/lila_client/main.py b/client/python/lila_client/main.py new file mode 100644 index 0000000..73e7e89 --- /dev/null +++ b/client/python/lila_client/main.py @@ -0,0 +1,201 @@ +"""līlā Python Client — Main entry point. + +Usage: + lila-client [--host localhost] [--port 8001] [--world path/to/world.json] +""" + +from __future__ import annotations + +import argparse +import json +import logging +import time + +import pygame + +logger = logging.getLogger("lila.client") + + +# ─── CLI Entry Point ────────────────────────────────────────────────────── + + +def main() -> None: + """CLI entry point for the līlā debug client.""" + parser = argparse.ArgumentParser(description="līlā Ecosystem Debug Client") + parser.add_argument("--host", default="localhost", help="Server host (default: localhost)") + parser.add_argument("--port", type=int, default=8001, help="Server port (default: 8001)") + parser.add_argument( + "--world", type=str, default=None, + help="Path to world definition JSON (auto-loads from server if omitted)", + ) + parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"]) + + args = parser.parse_args() + + logging.basicConfig( + level=getattr(logging, args.log_level), + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + datefmt="%H:%M:%S", + ) + + client = LilaClient(host=args.host, port=args.port) + + if args.world: + with open(args.world) as f: + world_def = json.load(f) + client.send_world(world_def) + else: + logger.info("Will fetch world.json from server on connect") + + client.run() + + +# ─── Client Application ────────────────────────────────────────────────────── + + +class LilaClient: + """Main application class tying together WS, world model, and pygame.""" + + def __init__(self, host: str = "localhost", port: int = 8001): + from .websocket import WebSocketClient + from .world_model import WorldModel + + self.host = host + self.port = port + + # Core components + self.ws = WebSocketClient(host=host, port=port) + self.world = WorldModel() + + # Session state + self.session_started = False + self.current_tick = 0 + self._last_heartbeat_time = time.monotonic() + self._pending_events: list[dict] = [] # agency events to send upstream + self._particles: list[dict] = [] + + # Pygame surfaces + self.screen: pygame.Surface | None = None + self.clock: pygame.time.Clock | None = None + + def send_world(self, world_def: dict) -> None: + """Send a world definition to start a simulation session.""" + self.ws.send_world_definition(world_def) + + def run(self) -> None: + """Start the client and enter the pygame render loop.""" + from .constants import CELL_PX, GRID_SIZE, PADDING, HEARTBEAT_INTERVAL_MS + from .pygame_renderer import draw_all + from .agency import step_agency + + pygame.init() + pygame.display.set_caption("līlā — ecosystem") + + canvas_size = GRID_SIZE * CELL_PX + PADDING * 2 + self.screen = pygame.display.set_mode((canvas_size, canvas_size)) + self.clock = pygame.time.Clock() + + # Start WebSocket background thread + self.ws.start() + + last_frame_time = time.monotonic() + running = True + while running: + # ── Handle pygame events ── + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + running = False + elif event.key == pygame.K_r: + self.ws.send_control("rain", intensity=0.8) + + # ── Process messages from server ── + self._process_messages() + + # ── Step local agency (60 Hz, between server ticks) ── + now = time.monotonic() + dt = min(now - last_frame_time, 0.05) # cap at 50ms + last_frame_time = now + + client_events = step_agency(self.world, dt) + self._pending_events.extend(client_events) + + # ── Send heartbeat if interval elapsed ── + if now - self._last_heartbeat_time >= HEARTBEAT_INTERVAL_MS / 1000: + self._send_heartbeat() + self._last_heartbeat_time = now + + # ── Render ── + draw_all(self.screen, self.world, self._particles) + + # ── Overlay text ── + font = pygame.font.SysFont("monospace", 11) + status = "● running" if self.ws.connected else "● connecting..." + color_green = (100, 200, 100) + color_red = (200, 100, 100) + status_surf = font.render(status, True, color_green if self.ws.connected else color_red) + self.screen.blit(status_surf, (PADDING, 4)) + + info_text = f"tick: {self.current_tick} entities: {len(self.world.entities)} fps: {self.clock.get_fps():.0f}" + info_surf = font.render(info_text, True, (180, 175, 165)) + self.screen.blit(info_surf, (canvas_size - info_surf.get_width() - PADDING, 4)) + + pygame.display.flip() + self.clock.tick(60) + + pygame.quit() + self.ws.stop() + + def _process_messages(self) -> None: + """Process all pending messages from the WebSocket.""" + while True: + msg = self.ws.recv_message() + if msg is None: + break + + if msg.type == "session_started": + self.session_started = True + if "species" in msg.data: + self.world.species_defs = msg.data["species"] + + elif msg.type == "tick": + self._handle_tick_packet(msg.data) + + def _handle_tick_packet(self, packet: dict) -> None: + """Process a tick packet from the server.""" + from .reconciliation import reconcile + + self.current_tick = packet.get("tick", 0) + + for u in packet.get("entity_updates", []): + self.world.apply_update(u) + for s in packet.get("entity_spawns", []): + self.world.apply_spawn(s) + for eid in packet.get("entity_removals", []): + self.world.apply_removal(eid) + if "voxel_deltas" in packet: + self.world.apply_voxel_deltas(packet["voxel_deltas"]) + if "water_sources" in packet: + self.world.apply_water_sources(packet["water_sources"]) + + # Reconcile client positions with server references + reconcile(self.world, self.current_tick) + + def _send_heartbeat(self) -> None: + """Send client heartbeat with entity positions and pending events.""" + positions = {} + for ent in self.world.entities.values(): + if ent.is_alive and ent.is_mobile_consumer: + positions[ent.id] = [round(ent.x, 4), 0.0, round(ent.z, 4)] + if not positions and not self._pending_events: + return # nothing to report + + events = self._pending_events + self._pending_events = [] + self.ws.send_heartbeat(positions=positions, events=events) + + +if __name__ == "__main__": + main() diff --git a/client/python/lila_client/pygame_renderer.py b/client/python/lila_client/pygame_renderer.py new file mode 100644 index 0000000..2b68615 --- /dev/null +++ b/client/python/lila_client/pygame_renderer.py @@ -0,0 +1,408 @@ +"""līlā Python Client — Pygame scene renderer. + +Mirrors the browser client's canvas renderer: moisture heatmap, grid, +water sources, and entities drawn as layered sprites. +""" + +from __future__ import annotations + +import math +from typing import Optional + +import pygame + +from .constants import ( + CELL_PX, + COLORS, + GRID_SIZE, + PADDING, +) +from .world_model import WorldEntity + +# ─── Color helpers ─────────────────────────────────────────────────────────── + + +def _lerp(a: float, b: float, t: float) -> float: + return a + (b - a) * t + + +def _world_to_canvas(wx: float, wz: float) -> tuple[float, float]: + return (PADDING + wx * CELL_PX, PADDING + wz * CELL_PX) + + +# ─── Drawing functions ────────────────────────────────────────────────────── + + +def draw_moisture_heatmap( + surface: pygame.Surface, + moisture: list[float], +) -> None: + """Draw the 32×32 moisture heatmap.""" + high = COLORS["moistureHigh"] + mid = COLORS["moistureMid"] + low = COLORS["moistureLow"] + + for z in range(GRID_SIZE): + for x in range(GRID_SIZE): + val = moisture[z * GRID_SIZE + x] + cx, cz = _world_to_canvas(x, z) + + if val > 0.5: + f = (val - 0.5) * 2 + r = int(_lerp(mid[0], high[0], f)) + g = int(_lerp(mid[1], high[1], f)) + b = int(_lerp(mid[2], high[2], f)) + else: + f = val * 2 + r = int(_lerp(low[0], mid[0], f)) + g = int(_lerp(low[1], mid[1], f)) + b = int(_lerp(low[2], mid[2], f)) + + pygame.draw.rect(surface, (r, g, b), (cx, cz, CELL_PX, CELL_PX)) + + +def draw_water( + surface: pygame.Surface, + water_sources: list[dict], +) -> None: + """Draw water sources with radial gradients.""" + fill = COLORS["waterFill"] + edge = COLORS["waterEdge"] + shine = COLORS["waterShine"] + + for ws in water_sources: + wx, _, wz = ws.get("position", [0, 0, 0]) + cx, cz = _world_to_canvas(wx, wz) + center = (cx + CELL_PX / 2, cz + CELL_PX / 2) + radius = ws.get("radius", 1.0) * CELL_PX + level = ws.get("water_level", 1.0) + + if level < 0.02 or radius < 1: + continue + + # Draw water on an alpha surface for proper transparency + glow_r = int(radius * 2.0) + size = glow_r * 2 + 1 + overlay = pygame.Surface((size, size), pygame.SRCALPHA) + oc = (size // 2, size // 2) + + # Main water body (gradient) + for r in range(int(radius), 0, -1): + t = 1.0 - r / radius + if t > 0.6: + col = shine + else: + col = fill if t > 0.3 else edge + a = int((0.3 + level * 0.4) * 255 * (0.7 + t * 0.25)) + pygame.draw.circle(overlay, (*col, a), oc, r, width=1) + + # Edge ring + pygame.draw.circle(overlay, (*edge, int(0.5 * 255)), oc, int(radius), width=1) + + surface.blit(overlay, (center[0] - size // 2, center[1] - size // 2)) + + +def draw_grid(surface: pygame.Surface) -> None: + """Draw the 33×33 grid with major lines every 8 cells.""" + bg = COLORS.get("bg", (15, 16, 15)) + # Blend grid color with background for subtle lines (no alpha support) + grid_minor = tuple(int(b * 0.95 + 184 * 0.05) for b in bg) # very faint + grid_major = tuple(int(b * 0.88 + 184 * 0.12) for b in bg) # slightly more visible + span = PADDING + GRID_SIZE * CELL_PX + + for i in range(GRID_SIZE + 1): + p = PADDING + i * CELL_PX + major = i % 8 == 0 + # Vertical + pygame.draw.line( + surface, grid_major if major else grid_minor, + (p, PADDING), (p, span), width=1, + ) + # Horizontal + pygame.draw.line( + surface, grid_major if major else grid_minor, + (PADDING, p), (span, p), width=1, + ) + + +def _draw_tree( + surface: pygame.Surface, cx: float, cz: float, ent: WorldEntity, +) -> None: + growth = ent.drive.get("growth", 0.5) if ent.drive else 0.5 + canopy_r = 4.0 * CELL_PX * growth * 0.5 + trunk_r = 3 + growth * 3 + + # Canopy (semi-transparent) + oak = COLORS.get("oak", (61, 107, 61)) + surf = pygame.Surface((int(canopy_r * 2 + 1), int(canopy_r * 2 + 1)), pygame.SRCALPHA) + pygame.draw.circle(surf, (*oak, int(30)), (surf.get_width() // 2, surf.get_height() // 2), int(canopy_r)) + surface.blit(surf, (cx - canopy_r, cz - canopy_r)) + + # Trunk + pygame.draw.circle(surface, (*oak, 255), (cx, cz), int(trunk_r)) + + +def _draw_grass( + surface: pygame.Surface, cx: float, cz: float, ent: WorldEntity, +) -> None: + if ent.state == "DORMANT": + pygame.draw.circle(surface, (90, 82, 68), (cx, cz), 2) + return + growth = ent.drive.get("growth", 0.1) if ent.drive else 0.1 + hydration = ent.drive.get("hydration", 0.5) if ent.drive else 0.5 + size = 2 + growth * 4 + color = COLORS.get("grass", (107, 143, 94)) if hydration > 0.3 else (122, 114, 84) + alpha = int((0.5 + growth * 0.5) * 255) + + surf = pygame.Surface((int(size * 2 + 1), int(size * 2 + 1)), pygame.SRCALPHA) + for i in range(3): + ox = math.sin(i * 2.1) * 3 + oz = math.cos(i * 2.1) * 3 + pygame.draw.circle(surf, (*color, min(alpha, 255)), + (surf.get_width() // 2 + ox, surf.get_height() // 2 + oz), + int(size * 0.6)) + surface.blit(surf, (cx - size, cz - size)) + + +def _draw_flower( + surface: pygame.Surface, cx: float, cz: float, ent: WorldEntity, +) -> None: + if ent.state == "DORMANT": + pygame.draw.circle(surface, (107, 94, 61), (cx, cz), 2) + return + growth = ent.drive.get("growth", 0.1) if ent.drive else 0.1 + flower_color = COLORS.get("wildflower", (122, 143, 94)) + + pygame.draw.circle(surface, flower_color, (cx, cz), 2) + + if ent.state == "FRUITING": + bloom_color = COLORS.get("flowerBloom", (196, 166, 74)) + pulse = 0.7 + math.sin(pygame.time.get_ticks() * 0.004) * 0.3 + bloom_r = 4 + growth * 3 + # Bloom glow + surf = pygame.Surface((int(bloom_r * 4 + 1), int(bloom_r * 4 + 1)), pygame.SRCALPHA) + pygame.draw.circle(surf, (*bloom_color, int(pulse * 50)), + (surf.get_width() // 2, surf.get_height() // 2), int(bloom_r * 2)) + surface.blit(surf, (cx - bloom_r * 2, cz - bloom_r * 2)) + # Bloom core + pygame.draw.circle(surface, (*bloom_color, int(pulse * 255)), + (cx, cz), int(bloom_r)) + + +def _draw_mushroom( + surface: pygame.Surface, cx: float, cz: float, ent: WorldEntity, +) -> None: + activity = ent.drive.get("activity", 0.5) if ent.drive else 0.5 + size = 2 + activity * 3 + alpha = int((0.3 + activity * 0.4) * 255) + pygame.draw.circle(surface, (160, 140, 120, alpha), (cx, cz), int(size)) + + +def _draw_deer( + surface: pygame.Surface, cx: float, cz: float, ent: WorldEntity, +) -> None: + state = ent.state or "IDLE" + deer_color = COLORS.get("deer", (196, 149, 106)) + size = 5 if state == "RESTING" else 7 + + # Draw deer sprite on temp surface pointing right, then rotate + sprite_size = size * 2 + 6 + sprite = pygame.Surface((sprite_size, sprite_size), pygame.SRCALPHA) + sc = sprite_size // 2 + # Body (triangle pointing right) + points = [ + (sc + size, sc), + (sc - size * 0.7, sc - size * 0.5), + (sc - size * 0.7, sc + size * 0.5), + ] + pygame.draw.polygon(sprite, (*deer_color, 200), points) + # Head dot + pygame.draw.circle(sprite, (*deer_color, 255), (sc + int(size * 0.6), sc), 2) + + # Rotate to face travel direction + rotated = pygame.transform.rotozoom(sprite, math.degrees(-ent.facing_angle), 1.0) + surface.blit(rotated, (int(cx - rotated.get_width() / 2), int(cz - rotated.get_height() / 2))) + + # State ring (unrotated) + if state == "DRINKING": + pygame.draw.circle(surface, (90, 140, 180), (int(cx), int(cz)), size + 3, width=1) + elif state == "RESTING": + for d in range(0, size + 2, 2): + pygame.draw.circle(surface, (180, 170, 140), (int(cx), int(cz)), size + 2 + d // 2, width=1) + + # Label + _draw_label(surface, state.lower(), cx, cz + size + 10) + + +def _draw_bird( + surface: pygame.Surface, cx: float, cz: float, ent: WorldEntity, +) -> None: + state = ent.state or "IDLE" + bird_color = COLORS.get("bird", (138, 123, 107)) + now_ms = pygame.time.get_ticks() + + # Draw bird sprite on temp surface pointing right, then rotate + body_len, body_wid = 6, 2.5 + wing_len = 7 + flap_speed = {"HUNTING": 14, "FLEEING": 14, "FORAGING": 4}.get(state, 6) + flap_amp = {"HUNTING": 0.6, "FLEEING": 0.6, "FORAGING": 0.35}.get(state, 0.45) + wing_angle = math.sin(now_ms * 0.001 * flap_speed + cx * 0.3) * flap_amp + + sprite_size = (int(wing_len + body_len) * 2 + 4, 16) + sprite = pygame.Surface(sprite_size, pygame.SRCALPHA) + sc = (sprite_size[0] // 2, sprite_size[1] // 2) + + # Body — teardrop pointing right + points = [ + (sc[0] + body_len, sc[1]), + (sc[0] + body_len * 0.3, sc[1] - body_wid), + (sc[0] - body_len * 0.6, sc[1]), + (sc[0] + body_len * 0.3, sc[1] + body_wid), + ] + pygame.draw.polygon(sprite, (*bird_color, 200), points) + + # Wings (flapping) + uw_x = 0.5 - wing_len * math.cos(wing_angle) + uw_y = -wing_len * math.sin(abs(wing_angle) + 0.3) + lw_x = 0.5 - wing_len * math.cos(-wing_angle) + lw_y = wing_len * math.sin(abs(wing_angle) + 0.3) + pygame.draw.line(sprite, bird_color, sc, (int(sc[0] + uw_x), int(sc[1] + uw_y)), width=1) + pygame.draw.line(sprite, bird_color, sc, (int(sc[0] + lw_x), int(sc[1] + lw_y)), width=1) + + # Rotate to face travel direction + rotated = pygame.transform.rotozoom(sprite, math.degrees(-ent.facing_angle), 1.0) + surface.blit(rotated, (int(cx - rotated.get_width() / 2), int(cz - rotated.get_height() / 2))) + + # State ring (unrotated) + if state == "HUNTING": + pygame.draw.circle(surface, (180, 120, 90), (int(cx), int(cz)), 10, width=1) + elif state == "DRINKING": + pygame.draw.circle(surface, (90, 140, 180), (int(cx), int(cz)), 9, width=1) + + # Label + _draw_label(surface, state.lower(), cx, cz + 14) + + +def _draw_butterfly( + surface: pygame.Surface, cx: float, cz: float, ent: WorldEntity, +) -> None: + now_ms = pygame.time.get_ticks() + wing_flap = math.sin(now_ms * 0.008 + cx * 0.1) * 0.5 + 0.5 + butterfly_color = COLORS.get("butterfly", (168, 124, 196)) + wing_span, wing_h = 5, 3 * (0.5 + wing_flap * 0.5) + alpha = int((0.7 + wing_flap * 0.3) * 255) + + surf = pygame.Surface((int(wing_span * 2 + 1), int(wing_h * 2 + 1)), pygame.SRCALPHA) + w2, h2 = surf.get_width() // 2, surf.get_height() // 2 + # Wings: extend vertically from body (compact, not elongated) + pygame.draw.polygon(surf, (*butterfly_color, alpha), + [(w2, h2), (w2 - 2, 1), (w2 + 2, 0), + (w2, int(h2 - wing_h * 0.5))]) + pygame.draw.polygon(surf, (*butterfly_color, alpha), + [(w2, h2), (w2 - 2, int(surf.get_height() - 1)), + (w2 + 2, surf.get_height()), + (w2, int(h2 + wing_h * 0.5))]) + # Body + pygame.draw.circle(surf, (122, 90, 143, 255), (w2, h2), 2) + # Nose — gives the sprite a visual front so rotation shows direction + pygame.draw.circle(surf, (150, 115, 170, 255), (w2 + 3, h2), 1) + + # Rotate to face travel direction + rotated = pygame.transform.rotozoom(surf, math.degrees(-ent.facing_angle), 1.0) + surface.blit(rotated, (int(cx - rotated.get_width() / 2), int(cz - rotated.get_height() / 2))) + + # Pollinating glow (unrotated) + if ent.state == "POLLINATING": + surf2 = pygame.Surface((21, 21), pygame.SRCALPHA) + pygame.draw.circle(surf2, (196, 166, 74, 38), (10, 10), 10) + surface.blit(surf2, (int(cx - 10), int(cz - 10))) + + +# ─── Entity drawing dispatch ──────────────────────────────────────────────── + +_DRAW_FUNCS = { + "TREE": _draw_tree, + "PLANT": lambda s, cx, cz, e: _draw_flower(s, cx, cz, e) if e.species == "wildflower" else _draw_grass(s, cx, cz, e), + "MICROORGANISM": _draw_mushroom, + "ANIMAL": _draw_deer, + "BIRD": _draw_bird, + "INSECT": _draw_butterfly, +} + +LAYER_ORDER = ["TREE", "PLANT", "MICROORGANISM", "ANIMAL", "BIRD", "INSECT"] + + +def _draw_label(surface: pygame.Surface, text: str, x: float, y: float) -> None: + """Draw a small label.""" + global _label_font + if _label_font is None: + _label_font = pygame.font.SysFont("monospace", 8) + # Semi-transparent labels need an alpha overlay + label_surf = _label_font.render(text, True, (184, 180, 168)) + alpha_surf = pygame.Surface(label_surf.get_size(), pygame.SRCALPHA) + alpha_surf.blit(label_surf, (0, 0)) + alpha_surf.set_alpha(90) + surface.blit(alpha_surf, (int(x - label_surf.get_width() // 2), int(y - label_surf.get_height() // 2))) + + +_label_font: Optional[pygame.font.Font] = None + + +def draw_entities(surface: pygame.Surface, world) -> None: + """Draw all entities in layer order.""" + for etype in LAYER_ORDER: + draw_func = _DRAW_FUNCS.get(etype) + if draw_func is None: + continue + for ent in world.entities.values(): + if not ent.is_alive and ent.state != "DORMANT": + continue + if ent.type != etype: + continue + if math.isnan(ent.x) or math.isnan(ent.z): + continue + cx, cz = _world_to_canvas(ent.x, ent.z) + draw_func(surface, cx, cz, ent) + + +def draw_particles(surface: pygame.Surface, particles: list[dict]) -> None: + """Draw particle effects.""" + for p in particles: + cx, cz = _world_to_canvas(p.get("x", 0), p.get("z", 0)) + alpha = p.get("life", 1) / max(p.get("maxLife", 1), 1) + color = p.get("color", (200, 200, 200)) + if isinstance(color, str): + # hex string + color = tuple(int(color[i:i+2], 16) for i in (1, 3, 5)) + size = p.get("size", 2) * alpha + if size < 0.5: + continue + a = int(alpha * 0.8 * 255) + pygame.draw.circle(surface, (*color[:3], a), (cx, cz), int(size)) + + +def draw_all(surface: pygame.Surface, world, particles: list[dict] | None = None) -> None: + """Draw the complete scene in one call.""" + now_ms = pygame.time.get_ticks() + + # Background + bg = COLORS.get("bg", (15, 16, 15)) + surface.fill(bg) + + # Moisture heatmap + if hasattr(world, "moisture") and world.moisture: + draw_moisture_heatmap(surface, world.moisture) + + # Water sources + if hasattr(world, "water_sources") and world.water_sources: + draw_water(surface, world.water_sources) + + # Grid + draw_grid(surface) + + # Entities + draw_entities(surface, world) + + # Particles + if particles: + draw_particles(surface, particles) diff --git a/client/python/lila_client/reconciliation.py b/client/python/lila_client/reconciliation.py new file mode 100644 index 0000000..9055abe --- /dev/null +++ b/client/python/lila_client/reconciliation.py @@ -0,0 +1,98 @@ +"""līlā Python Client — Reconciliation (Client ↔ Server Position Sync). + +When a new tick packet arrives, reconcile client-agency positions +with server reference positions. Trust the client within bounds; +gently correct when divergence exceeds expected travel distance. + +Each tick, divergent entities get their ref_position enqueued as a +reconcile target. The agency system then smoothly meanders toward +that target over the next ~2 seconds. If a new target arrives before +the old one is reached, the entity transitions smoothly (no snap). + +Each entity has a unique sync personality (_sync_phase, _sync_speed) +so they don't all queue reconciliation targets at the same time — +the sync looks organic, not mechanical. + +Additionally, a continuous gravity well pulls all entities gently +toward their ref_position during normal agency, preventing sudden +direction changes when new tick targets arrive. +""" + +from __future__ import annotations + + +def reconcile(world, tick: int) -> None: + """Enqueue reconciliation targets after receiving a new tick packet. + + Does NOT modify positions directly — the agency system at 60fps + consumes the queue and moves entities smoothly. + + Entities are staggered across ticks using _sync_phase (0..3) so + not all entities react to the sync pulse simultaneously. + + Called once per server tick, not every frame. + """ + import math + + for ent in world.entities.values(): + if not ent.is_mobile_consumer or not ent.is_alive: + continue + + # ── Staggered reaction: each entity has a syncPhase (0..3) + # Only enqueue reconcile targets when the tick aligns with phase. + # This spreads the "nudge" across frames so it looks organic. + ticks_since_last = tick - ent._last_reconciled_tick + if ticks_since_last < ent._sync_phase: + continue # not this entity's turn yet + + # Server acknowledged our deviation — trust it fully. + # Clear any pending reconcile targets since server now matches us. + if ent.ack_received: + ent._reconcile_queue.clear() + ent._reconcile_idx = 0 + ent._last_reconciled_tick = tick + continue + + dx = ent.x - ent.ref_x + dz = ent.z - ent.ref_z + divergence = math.sqrt(dx * dx + dz * dz) + + if divergence < 0.1: + # Negligible drift — nothing to reconcile. + # Prune completed targets. + ent._reconcile_queue = ent._reconcile_queue[ent._reconcile_idx:] + ent._reconcile_idx = 0 + ent._last_reconciled_tick = tick + continue + + # Prune completed targets before enqueueing new one. + ent._reconcile_queue = ent._reconcile_queue[ent._reconcile_idx:] + ent._reconcile_idx = 0 + + # Enqueue the ref_position as a reconcile target. + # If there's already an unfinished target, append — the agency + # system will chain through them smoothly. + ent._reconcile_queue.append((ent.ref_x, ent.ref_z)) + + # If the queue grew too long (entity falling behind), keep only + # the latest target to avoid chasing ghosts. + if len(ent._reconcile_queue) > 2: + ent._reconcile_queue = [ent._reconcile_queue[-1]] + + ent._last_reconciled_tick = tick + + +def has_reconcile_target(ent) -> bool: + """Check if an entity has pending reconciliation work.""" + return ent._reconcile_idx < len(ent._reconcile_queue) + + +def get_reconcile_target(ent) -> tuple[float, float]: + """Get the current reconcile target (tx, tz) for an entity.""" + idx = min(ent._reconcile_idx, len(ent._reconcile_queue) - 1) + return ent._reconcile_queue[idx] + + +def advance_reconcile(ent) -> None: + """Advance to the next target in the queue (called when current is reached).""" + ent._reconcile_idx += 1 diff --git a/client/python/lila_client/replay.py b/client/python/lila_client/replay.py new file mode 100644 index 0000000..5ccc268 --- /dev/null +++ b/client/python/lila_client/replay.py @@ -0,0 +1,179 @@ +"""līlā Python Client — Replay mode for post-mortem analysis. + +Reads a session's JSONL telemetry log and replays events in the viewer, +allowing you to scrub through time and inspect what happened at each tick. + +Usage: + lila-client-replay ~/.lila/logs/demo-alpha-001.jsonl [--speed 2.0] +""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +import time +from pathlib import Path +from typing import Optional + +import dearpygui.dearpygui as dpg +from .imgui_view import TelemetryBuffer + +logger = logging.getLogger("lila.client.replay") + + +class ReplaySession: + """Loads and replays a JSONL telemetry log file.""" + + def __init__(self, log_path: Path): + self.log_path = log_path + self.events: list[dict] = [] + self.session_id: Optional[str] = None + self._load() + + def _load(self) -> None: + logger.info("Loading replay from %s", self.log_path) + with open(self.log_path) as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + self.events.append(event) + except json.JSONDecodeError: + logger.warning("Invalid JSON on line %d", line_num) + logger.info("Loaded %d events", len(self.events)) + + @property + def tick_range(self) -> tuple[int, int]: + ticks = [e.get("tick") for e in self.events if e.get("tick") is not None] + return (min(ticks) if ticks else 0, max(ticks) if ticks else 100) + + def get_events_up_to(self, tick: int) -> list[dict]: + return [e for e in self.events if e.get("tick") is None or e["tick"] <= tick] + + +def run_replay(log_path: Path, speed: float = 1.0) -> None: + """Run a replay session in the Dear PyGui viewer.""" + session = ReplaySession(log_path) + telemetry = TelemetryBuffer() + + replay_tick = [session.tick_range[0]] + max_tick = max(session.tick_range[1], 1) + min_tick = session.tick_range[0] + playing = [True] + event_index = [0] + last_frame = [time.monotonic()] + + def build_ui(): + dpg.create_context() + dpg.create_viewport(title=f"līlā — replay: {log_path.name}", width=1100, height=700) + dpg.setup_dearpygui() + + with dpg.window(label="Replay", tag="replay_window", width=1100): + # Controls + with dpg.group(horizontal=True): + dpg.add_button(label="⏸ Pause", tag="btn_play", + callback=lambda: playing.__setitem__(0, not playing[0])) + dpg.add_button(label="⟲ Reset", tag="btn_reset", + callback=lambda: _reset()) + dpg.add_spacer(width=10) + dpg.add_slider_int(label="##scrubber", tag="scrubber", + default_value=min_tick, min_value=min_tick, + max_value=max_tick, width=400, + callback=lambda s, a: _scrub_to(a)) + dpg.add_text(tag="tick_display") + + dpg.add_separator() + + # Telemetry timeline + dpg.add_text("Events:") + with dpg.group(horizontal=True, tag="lvl_filters"): + for level in ("DEBUG", "INFO", "WARN", "ERROR"): + dpg.add_checkbox(label=level, default_value=True, tag=f"lvl_{level}") + + dpg.add_separator() + with dpg.child_window(tag="telemetry_child", border=False): + dpg.add_listbox(tag="telemetry_list") + + def _reset(): + replay_tick[0] = min_tick + event_index[0] = 0 + rebuild_events() + + def _scrub_to(tick_val): + replay_tick[0] = tick_val + rebuild_events() + + def rebuild_events(): + events = session.get_events_up_to(replay_tick[0]) + telemetry._events.clear() + telemetry._events.extend(events) + event_index[0] = len(events) + + def render_callback(): + now = time.monotonic() + dt = min(now - last_frame[0], 0.05) + last_frame[0] = now + + # Play/pause button label + dpg.set_value("btn_play", "▶ Play" if playing[0] else "⏸ Pause") + + # Advance tick + if playing[0]: + replay_tick[0] += int(dt * speed * 0.5) + if replay_tick[0] > max_tick: + replay_tick[0] = max_tick + playing[0] = False + + target_events = session.get_events_up_to(replay_tick[0]) + for event in target_events[event_index[0]:]: + telemetry.add(event) + event_index[0] = len(target_events) + + # Update slider and display + dpg.set_value("scrubber", replay_tick[0]) + dpg.set_value("tick_display", f"Tick: {replay_tick[0]} / {max_tick} | Events: {len(telemetry.events)}") + + # Update telemetry list + lines = [] + for event in telemetry.filtered_events()[:100]: + level = event.get("level", "INFO") + tick = f"T{event['tick']}" if event.get("tick") is not None else "T—" + eid = f" {event['entity_id']}" if event.get("entity_id") else "" + lines.append(f"[{tick}] [{level:5s}] {event.get('src','?'):8s} {event.get('evt','?')}{eid}") + dpg.configure_item("telemetry_list", items=lines) + + build_ui() + dpg.show_viewport() + dpg.set_frame_callback(-1, lambda s, a: render_callback()) + dpg.start_dearpygui() + dpg.destroy_context() + + +def main() -> None: + """CLI entry point for replay mode.""" + parser = argparse.ArgumentParser(description="līlā Replay — post-mortem analysis") + parser.add_argument("log_file", type=Path, help="Path to JSONL telemetry log file") + parser.add_argument("--speed", type=float, default=1.0, help="Replay speed multiplier") + parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"]) + + args = parser.parse_args() + + if not args.log_file.exists(): + print(f"Error: log file not found: {args.log_file}", file=sys.stderr) + sys.exit(1) + + logging.basicConfig( + level=getattr(logging, args.log_level), + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + datefmt="%H:%M:%S", + ) + + run_replay(args.log_file, speed=args.speed) + + +if __name__ == "__main__": + main() diff --git a/client/python/lila_client/websocket.py b/client/python/lila_client/websocket.py new file mode 100644 index 0000000..73f4247 --- /dev/null +++ b/client/python/lila_client/websocket.py @@ -0,0 +1,300 @@ +"""līlā Python Client — WebSocket connection manager. + +Handles connecting to the server, sending world definitions, receiving tick packets, +and subscribing to telemetry events. Runs asyncio in a background thread with +thread-safe queues for communication with the ImGui main loop. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import threading +from collections import deque +from dataclasses import dataclass +from typing import Any, Optional + +logger = logging.getLogger("lila.client.ws") + + +@dataclass +class WsMessage: + """A message received from the server.""" + type: str # "tick", "session_started", "telemetry" + data: dict | list # parsed JSON content or raw telemetry lines + + +class WebSocketClient: + """Async WebSocket client running in a background thread. + + The ImGui main loop runs synchronously on the main thread. This class + bridges asyncio (for WS I/O) with the synchronous render loop using + thread-safe queues. + + Usage:: + + ws = WebSocketClient(host="localhost", port=8001) + ws.start() + ws.send_world_definition(world_json) + + # In ImGui render loop: + msg = ws.recv_message(block=False) + if msg and msg.type == "tick": + world.apply_tick(msg.data) + """ + + def __init__(self, host: str = "localhost", port: int = 8001): + self.host = host + self.port = port + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._thread: Optional[threading.Thread] = None + self._running = False + self._ready_event = threading.Event() # signals when loop is ready + + # Outgoing queue (main thread → asyncio thread) + self._send_queue: asyncio.Queue = asyncio.Queue() + + # Incoming queue (asyncio thread → main thread) + self._recv_buffer: deque[WsMessage] = deque(maxlen=1000) + self._recv_lock = threading.Lock() + + # Connection state + self.connected = False + self.session_id: Optional[str] = None + self._world_sent = False # track if we've sent the world definition + + def start(self) -> None: + """Start the background asyncio thread.""" + if self._running: + return + self._running = True + self._thread = threading.Thread(target=self._run_loop, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Stop the background thread and close connections.""" + self._running = False + if self._thread: + self._thread.join(timeout=3.0) + + # ─── Public API (call from main/ImGui thread) ────────────────────── + + def send_world_definition(self, world_def: dict[str, Any]) -> None: + """Send a world definition to start a simulation session.""" + self._thread_safe_send(json.dumps(world_def)) + + def send_control(self, msg_type: str, **kwargs) -> None: + """Send a control message (pause, resume, shutdown, rain).""" + payload = {"type": msg_type} + payload.update(kwargs) + self._thread_safe_send(json.dumps(payload)) + + def send_heartbeat(self, positions: dict[str, list[float]], events: list[dict]) -> None: + """Send a heartbeat with client-reported positions and events.""" + payload = { + "type": "heartbeat", + "positions": positions, + "events": events, + } + self._thread_safe_send(json.dumps(payload)) + + def recv_message(self, block: bool = False, timeout: float = 0.0) -> Optional[WsMessage]: + """Receive a message from the server (non-blocking by default).""" + with self._recv_lock: + if not self._recv_buffer: + return None + return self._recv_buffer.popleft() + + def recv_all(self) -> list[WsMessage]: + """Drain all pending messages.""" + with self._recv_lock: + msgs = list(self._recv_buffer) + self._recv_buffer.clear() + return msgs + + # ─── Telemetry Subscription ──────────────────────────────────────── + + def subscribe_telemetry( + self, + filters: dict[str, str] | None = None, + ) -> None: + """Subscribe to the server's telemetry stream. + + Filters are URL query params (e.g. {"src": "engine", "level": "WARN"}). + """ + if not self._running: + return + # Wait for the asyncio loop to be ready (with timeout) + if not self._ready_event.wait(timeout=5.0): + logger.warning("Timed out waiting for WS loop to start") + return + # Signal the asyncio loop to open a telemetry connection + try: + asyncio.run_coroutine_threadsafe( + self._open_telemetry(filters), self._loop, + ) + except RuntimeError: + pass # no running loop yet + + # ─── Internal (asyncio thread) ────────────────────────────────────── + + def _run_loop(self) -> None: + """Run the asyncio event loop in a background thread.""" + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._ready_event.set() # signal that loop is ready + try: + self._loop.run_until_complete(self._main_coroutine()) + finally: + self._loop.close() + + async def _main_coroutine(self) -> None: + """Main coroutine: connect, receive, dispatch.""" + while self._running: + try: + await self._connect_and_run() + except Exception as e: + logger.error("WS connection error: %s", e) + + if not self._running: + break + logger.info("Reconnecting in %.0fs...", 3.0) + await asyncio.sleep(3.0) + + async def _connect_and_run(self) -> None: + """Connect to the server and run receive/send loops.""" + import websockets + + uri = f"ws://{self.host}:{self.port}/ws" + logger.info("Connecting to %s", uri) + + try: + async with websockets.connect(uri, max_size=2**20) as ws: + self.connected = True + logger.info("Connected to %s", uri) + + # Send world definition if not already sent + if not self._world_sent: + world_def = await self._fetch_world_def() + if world_def: + await ws.send(json.dumps(world_def)) + self._world_sent = True + logger.info("World definition sent to server") + + # Run receive and send loops concurrently + recv_task = asyncio.create_task(self._recv_loop(ws)) + send_task = asyncio.create_task(self._send_loop(ws)) + + done, pending = await asyncio.wait( + [recv_task, send_task], + return_when=asyncio.FIRST_COMPLETED, + ) + + for task in pending: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + except (ConnectionRefusedError, OSError) as e: + logger.warning("Connection failed: %s", e) + finally: + self.connected = False + + async def _recv_loop(self, ws) -> None: + """Receive messages from the server.""" + try: + async for raw in ws: + try: + data = json.loads(raw) + except json.JSONDecodeError: + continue + + msg_type = data.get("type", "tick") + + if msg_type == "session_started": + self.session_id = data.get("session_id") + logger.info("Session started: %s (%d entities)", + self.session_id, data.get("entity_count", 0)) + # Store species definitions for client-side agency + if "species" in data: + pass # Will be consumed by world model + + msg = WsMessage(type=msg_type, data=data) + with self._recv_lock: + self._recv_buffer.append(msg) + + except asyncio.CancelledError: + pass + + async def _send_loop(self, ws) -> None: + """Send messages from the outgoing queue.""" + try: + while self._running: + raw = await self._send_queue.get() + if raw is None: + break # sentinel + await ws.send(raw) + except asyncio.CancelledError: + pass + + async def _open_telemetry(self, filters: dict[str, str] | None) -> None: + """Open a separate WebSocket for telemetry streaming.""" + import websockets + + query = "" + if filters: + parts = [f"{k}={v}" for k, v in filters.items()] + query = "?" + "&".join(parts) + + uri = f"ws://{self.host}:{self.port}/telemetry{query}" + logger.info("Subscribing to telemetry: %s", uri) + + try: + async with websockets.connect(uri, max_size=2**20) as ws: + while self._running: + try: + raw = await asyncio.wait_for(ws.recv(), timeout=1.0) + # Telemetry comes as JSONL lines (possibly batched) + for line in raw.strip().split("\n"): + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + msg = WsMessage(type="telemetry", data=event) + with self._recv_lock: + self._recv_buffer.append(msg) + except asyncio.TimeoutError: + continue + except (ConnectionRefusedError, OSError) as e: + logger.warning("Telemetry subscription failed: %s", e) + + async def _fetch_world_def(self) -> Optional[dict]: + """Fetch world definition from server's /world.json endpoint.""" + try: + import urllib.request + url = f"http://{self.host}:{self.port}/world.json" + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=5) as resp: + return json.loads(resp.read().decode()) + except Exception as e: + logger.warning("Could not fetch world.json: %s", e) + return None + + def _thread_safe_send(self, data: str) -> None: + """Push a message to the send queue from any thread.""" + if not self._loop or not self._running: + return + try: + asyncio.run_coroutine_threadsafe( + self._send_queue.put(data), self._loop, + ) + except RuntimeError: + pass + + def __del__(self) -> None: + self.stop() diff --git a/client/python/lila_client/world_model.py b/client/python/lila_client/world_model.py new file mode 100644 index 0000000..ba6ffcd --- /dev/null +++ b/client/python/lila_client/world_model.py @@ -0,0 +1,311 @@ +"""līlā Python Client — World Model (local scene graph). + +Mirrors the browser client's WorldModel for entity tracking and spatial queries. +Used by both the ImGui renderer and the local agency system. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class WorldEntity: + """Client-side entity record with server reference and local position.""" + + id: str + type: str = "" # ANIMAL, PLANT, TREE, INSECT, BIRD, MICROORGANISM + species: str = "" + + # Server reference position (gravity well for reconciliation) + ref_x: float = 0.0 + ref_z: float = 0.0 + + # Client-agency position (where the entity actually is in local sim) + x: float = 0.0 + z: float = 0.0 + + # Discrete state from server + state: str = "IDLE" + + # Drive values from server intent packet + drive: dict[str, float] = field(default_factory=dict) + + # Motion latent vector (4D, from motor model) + motion_latent: list[float] = field(default_factory=lambda: [0.0, 0.0, 0.0, 0.0]) + + # Eligibility flags from server + can_consume: bool = False + can_predate: bool = False + can_pollinate: bool = False + repro_eligible: bool = False + can_drink: bool = False + spread_eligible: bool = False + + # Skeleton ID (for future 3D client) + skeleton_id: Optional[str] = None + + # Local agency state + target_x: float = 0.0 + target_z: float = 0.0 + has_target: bool = False + velocity_x: float = 0.0 + velocity_z: float = 0.0 + speed: float = 1.0 + + # Facing angle (radians, smoothed via lerp toward velocity direction) + facing_angle: float = 0.0 + + # Acknowledgment tracking + ack_received: bool = False + + # Reconciliation queue — target positions enqueued by reconcile(), + # consumed smoothly by the agency system at 60fps. + _reconcile_queue: list[tuple[float, float]] = field(default_factory=list) + _reconcile_idx: int = 0 # index of current target in the queue + + # ── Per-entity sync personality (deterministic from ID) ── + # Spread out reconciliation so entities don't all nudge at once. + _sync_phase: int = 0 # 0..3 — which frame within sync cycle to react + _sync_speed: float = 0.7 # 0.4..1.0 multiplier on nudge intensity + _last_reconciled_tick: int = -10 # force reconcile on first ticks + + @property + def is_alive(self) -> bool: + return self.state not in ("DEAD", "DYING", "DORMANT") + + @property + def is_mobile_consumer(self) -> bool: + return self.type in ("ANIMAL", "BIRD", "INSECT") + + def distance_to(self, other: WorldEntity) -> float: + dx = self.x - other.x + dz = self.z - other.z + return math.sqrt(dx * dx + dz * dz) + + def dist_sq_to(self, other: WorldEntity) -> float: + dx = self.x - other.x + dz = self.z - other.z + return dx * dx + dz * dz + + +class WorldModel: + """Client-side world model — local scene graph with spatial queries.""" + + def __init__(self): + self.entities: dict[str, WorldEntity] = {} + self.species_defs: dict[str, dict] = {} + self.water_sources: list[dict] = [] + # Moisture grid for heatmap rendering (GRID_SIZE × GRID_SIZE) + from .constants import GRID_SIZE + self.moisture = [0.65] * (GRID_SIZE * GRID_SIZE) + self._grid_size = GRID_SIZE + + # ─── Entity Management ────────────────────────────────────────────── + + def apply_update(self, u: dict) -> WorldEntity: + """Add or update an entity from a server tick packet.""" + eid = u["id"] + ent = self.entities.get(eid) + + if not ent: + info = _infer_entity_type_from_id(eid) + ent = WorldEntity( + id=eid, type=info["type"], species=info["species"], + ) + ref_pos = u.get("ref_position", [0, 0, 0]) + ent.ref_x = ref_pos[0] + ent.ref_z = ref_pos[2] + ent.x = ref_pos[0] + ent.z = ref_pos[2] + _init_sync_personality(ent) + self.entities[eid] = ent + + # Update server reference position (gravity well) + if "ref_position" in u: + pos = u["ref_position"] + ent.ref_x = pos[0] + ent.ref_z = pos[2] + + # State and drives + if "state" in u: + ent.state = u["state"] + if "drive" in u: + ent.drive.update(u["drive"]) + if "motion_latent" in u: + ent.motion_latent = list(u["motion_latent"]) + + # Eligibility flags + ent.can_consume = bool(u.get("_can_consume", False)) + ent.can_predate = bool(u.get("_can_predate", False)) + ent.can_pollinate = bool(u.get("_can_pollinate", False)) + ent.repro_eligible = bool(u.get("_repro_eligible", False)) + ent.can_drink = bool(u.get("_can_drink", False)) + ent.spread_eligible = bool(u.get("_spread_eligible", False)) + + # Acknowledgment tracking + ent.ack_received = bool(u.get("_ack", False)) + + return ent + + def apply_spawn(self, s: dict) -> WorldEntity: + """Spawn a new entity from server spawn packet.""" + ref_pos = s["ref_position"] + ent = WorldEntity( + id=s["id"], + type=s.get("type", ""), + species=s.get("species", ""), + ref_x=ref_pos[0], ref_z=ref_pos[2], + x=ref_pos[0], z=ref_pos[2], + ) + if "state" in s: + ent.state = s["state"] + if "drive" in s: + ent.drive.update(s["drive"]) + if "skeleton_id" in s: + ent.skeleton_id = s["skeleton_id"] + _init_sync_personality(ent) + self.entities[ent.id] = ent + return ent + + def apply_removal(self, eid: str) -> Optional[WorldEntity]: + """Remove an entity. Returns the removed entity or None.""" + return self.entities.pop(eid, None) + + # ─── Spatial Queries (for agency logic) ────────────────────────────── + + def find_nearest( + self, x: float, z: float, types: list[str], exclude_id: str | None = None, + ) -> Optional[WorldEntity]: + """Find nearest alive entity of given type(s).""" + best_dist_sq = float("inf") + best_ent = None + for ent in self.entities.values(): + if not ent.is_alive: + continue + if exclude_id and ent.id == exclude_id: + continue + if ent.type not in types: + continue + d2 = (x - ent.x) ** 2 + (z - ent.z) ** 2 + if d2 < best_dist_sq: + best_dist_sq = d2 + best_ent = ent + return best_ent + + def find_nearest_species( + self, x: float, z: float, species_list: list[str], exclude_id: str | None = None, + ) -> Optional[WorldEntity]: + """Find nearest alive entity of given species.""" + best_dist_sq = float("inf") + best_ent: WorldEntity | None = None + for ent in self.entities.values(): + if not ent.is_alive: + continue + if exclude_id and ent.id == exclude_id: + continue + if ent.species not in species_list: + continue + d2 = (x - ent.x) ** 2 + (z - ent.z) ** 2 + if d2 < best_dist_sq: + best_dist_sq = d2 + best_ent = ent + return best_ent + + def find_nearest_mate(self, ent: WorldEntity) -> Optional[WorldEntity]: + """Find nearest alive entity of the same species.""" + best_dist_sq = float("inf") + best_ent: WorldEntity | None = None + for other in self.entities.values(): + if not other.is_alive: + continue + if other.id == ent.id: + continue + if other.species != ent.species: + continue + d2 = ent.dist_sq_to(other) + if d2 < best_dist_sq: + best_dist_sq = d2 + best_ent = other + return best_ent + + def get_species_def(self, species_id: str) -> dict: + """Get species definition by ID.""" + return self.species_defs.get(species_id, {}) + + def find_nearest_water(self, x: float, z: float) -> Optional[dict]: + """Find nearest non-dry water source.""" + best_dist_sq = float("inf") + best_source = None + for ws in self.water_sources: + if (ws.get("water_level", 1.0) or 1.0) < 0.05: + continue + d2 = (x - ws["position"][0]) ** 2 + (z - ws["position"][2]) ** 2 + if d2 < best_dist_sq: + best_dist_sq = d2 + best_source = ws + return best_source + + def get_alive_of_type(self, entity_type: str) -> list[WorldEntity]: + """Get all alive entities of a given type.""" + return [e for e in self.entities.values() if e.is_alive and e.type == entity_type] + + # ─── Voxel / Environment ────────────────────────────────────────────── + + def apply_voxel_deltas(self, deltas: dict) -> None: + """Apply moisture voxel deltas from server.""" + if not deltas or "moisture" not in deltas: + return + for coord_str, val in deltas["moisture"].items(): + parts = list(map(int, coord_str.split(","))) + gx, gz = parts[0], parts[2] + if 0 <= gx < self._grid_size and 0 <= gz < self._grid_size: + self.moisture[gz * self._grid_size + gx] = val + + def apply_water_sources(self, sources: list[dict]) -> None: + """Update water source positions.""" + self.water_sources = sources or [] + + +def _init_sync_personality(ent: WorldEntity) -> None: + """Initialize per-entity sync personality (deterministic from ID). + + Spreads out reconciliation so entities don't all nudge at once. + _sync_phase: 0..3 — which frame within a sync cycle this entity reacts + _sync_speed: 0.4..1.0 — multiplier on nudge intensity + """ + seed = _hash_id(ent.id) + ent._sync_phase = seed % 4 # 0, 1, 2, or 3 + ent._sync_speed = 0.4 + (seed % 60) / 100 # 0.40 .. 0.99 + ent._last_reconciled_tick = -10 + + +def _hash_id(eid: str) -> int: + """Deterministic hash from entity ID string → positive integer.""" + h = 0 + for c in eid: + h = ((h << 5) - h + ord(c)) & 0xFFFFFFFF + return abs(h) + + +def _infer_entity_type_from_id(eid: str) -> dict[str, str]: + """Infer entity type from ID prefix (fallback when server data is sparse).""" + if eid.startswith("deer"): + return {"type": "ANIMAL", "species": "deer"} + if eid.startswith("wolf"): + return {"type": "ANIMAL", "species": "wolf"} + if eid.startswith("bird") or eid.startswith("songbird"): + return {"type": "BIRD", "species": "songbird"} + if eid.startswith("butterfly"): + return {"type": "INSECT", "species": "monarch"} + if eid.startswith("oak"): + return {"type": "TREE", "species": "meadow_oak"} + if eid.startswith("grass"): + return {"type": "PLANT", "species": "meadow_grass"} + if eid.startswith("flower"): + return {"type": "PLANT", "species": "wildflower"} + if eid.startswith("mushroom") or eid.startswith("fungus"): + return {"type": "MICROORGANISM", "species": "mushroom"} + return {"type": "ANIMAL", "species": "unknown"} diff --git a/client/python/pyproject.toml b/client/python/pyproject.toml new file mode 100644 index 0000000..136ec5a --- /dev/null +++ b/client/python/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "lila-client" +version = "0.1.0" +description = "līlā — Python pygame debug client" +requires-python = ">=3.12" +license = { text = "Apache-2.0" } +authors = [{ name = "BioSynthArt Studios LLC" }] + +dependencies = [ + "pygame>=2.5", + "websockets>=14.0", +] + +[project.scripts] +lila-client = "lila_client.main:main" +lila-replay = "lila_client.replay:main" + +[dependency-groups] +dev = [] + +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["lila_client*"] diff --git a/client/python/uv.lock b/client/python/uv.lock new file mode 100644 index 0000000..64b9981 --- /dev/null +++ b/client/python/uv.lock @@ -0,0 +1,88 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "lila-client" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pygame" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "pygame", specifier = ">=2.5" }, + { name = "websockets", specifier = ">=14.0" }, +] + +[package.metadata.requires-dev] +dev = [] + +[[package]] +name = "pygame" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/cc/08bba60f00541f62aaa252ce0cfbd60aebd04616c0b9574f755b583e45ae/pygame-2.6.1.tar.gz", hash = "sha256:56fb02ead529cee00d415c3e007f75e0780c655909aaa8e8bf616ee09c9feb1f", size = 14808125, upload-time = "2024-09-29T13:41:34.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/16/2c602c332f45ff9526d61f6bd764db5096ff9035433e2172e2d2cadae8db/pygame-2.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4ee7f2771f588c966fa2fa8b829be26698c9b4836f82ede5e4edc1a68594942e", size = 13118279, upload-time = "2024-09-29T14:26:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/cd/53/77ccbc384b251c6e34bfd2e734c638233922449a7844e3c7a11ef91cee39/pygame-2.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c8040ea2ab18c6b255af706ec01355c8a6b08dc48d77fd4ee783f8fc46a843bf", size = 12384524, upload-time = "2024-09-29T14:26:49.996Z" }, + { url = "https://files.pythonhosted.org/packages/06/be/3ed337583f010696c3b3435e89a74fb29d0c74d0931e8f33c0a4246307a9/pygame-2.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47a6938de93fa610accd4969e638c2aebcb29b2fca518a84c3a39d91ab47116", size = 13587123, upload-time = "2024-09-29T11:10:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/b015586a450db59313535662991b34d24c1f0c0dc149cc5f496573900f4e/pygame-2.6.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33006f784e1c7d7e466fcb61d5489da59cc5f7eb098712f792a225df1d4e229d", size = 14275532, upload-time = "2024-09-29T11:39:59.356Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f2/d31e6ad42d657af07be2ffd779190353f759a07b51232b9e1d724f2cda46/pygame-2.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1206125f14cae22c44565c9d333607f1d9f59487b1f1432945dfc809aeaa3e88", size = 13952653, upload-time = "2024-09-29T11:40:01.781Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/8ea2a6979e6fa971702fece1747e862e2256d4a8558fe0da6364dd946c53/pygame-2.6.1-cp312-cp312-win32.whl", hash = "sha256:84fc4054e25262140d09d39e094f6880d730199710829902f0d8ceae0213379e", size = 10252421, upload-time = "2024-09-29T11:14:26.877Z" }, + { url = "https://files.pythonhosted.org/packages/5f/90/7d766d54bb95939725e9a9361f9c06b0cfbe3fe100aa35400f0a461a278a/pygame-2.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9e7396be0d9633831c3f8d5d82dd63ba373ad65599628294b7a4f8a5a01a65", size = 10624591, upload-time = "2024-09-29T11:52:54.489Z" }, + { url = "https://files.pythonhosted.org/packages/e1/91/718acf3e2a9d08a6ddcc96bd02a6f63c99ee7ba14afeaff2a51c987df0b9/pygame-2.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae6039f3a55d800db80e8010f387557b528d34d534435e0871326804df2a62f2", size = 13090765, upload-time = "2024-09-29T14:27:02.377Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c6/9cb315de851a7682d9c7568a41ea042ee98d668cb8deadc1dafcab6116f0/pygame-2.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a3a1288e2e9b1e5834e425bedd5ba01a3cd4902b5c2bff8ed4a740ccfe98171", size = 12381704, upload-time = "2024-09-29T14:27:10.228Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8f/617a1196e31ae3b46be6949fbaa95b8c93ce15e0544266198c2266cc1b4d/pygame-2.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27eb17e3dc9640e4b4683074f1890e2e879827447770470c2aba9f125f74510b", size = 13581091, upload-time = "2024-09-29T11:30:27.653Z" }, + { url = "https://files.pythonhosted.org/packages/3b/87/2851a564e40a2dad353f1c6e143465d445dab18a95281f9ea458b94f3608/pygame-2.6.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c1623180e70a03c4a734deb9bac50fc9c82942ae84a3a220779062128e75f3b", size = 14273844, upload-time = "2024-09-29T11:40:04.138Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/aa23aa2e70bcba42c989c02e7228273c30f3b44b9b264abb93eaeff43ad7/pygame-2.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef07c0103d79492c21fced9ad68c11c32efa6801ca1920ebfd0f15fb46c78b1c", size = 13951197, upload-time = "2024-09-29T11:40:06.785Z" }, + { url = "https://files.pythonhosted.org/packages/a6/06/29e939b34d3f1354738c7d201c51c250ad7abefefaf6f8332d962ff67c4b/pygame-2.6.1-cp313-cp313-win32.whl", hash = "sha256:3acd8c009317190c2bfd81db681ecef47d5eb108c2151d09596d9c7ea9df5c0e", size = 10249309, upload-time = "2024-09-29T11:10:23.329Z" }, + { url = "https://files.pythonhosted.org/packages/7e/11/17f7f319ca91824b86557e9303e3b7a71991ef17fd45286bf47d7f0a38e6/pygame-2.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:813af4fba5d0b2cb8e58f5d95f7910295c34067dcc290d34f1be59c48bd1ea6a", size = 10620084, upload-time = "2024-09-29T11:48:51.587Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] diff --git a/docs/intent_based_architecture.md b/docs/intent_based_architecture.md new file mode 100644 index 0000000..1ccb681 --- /dev/null +++ b/docs/intent_based_architecture.md @@ -0,0 +1,428 @@ + + +# Intent-Based Client Agency Architecture + +> *The server is the nervous system. The client is the body.* + +This document describes the shift from a fully server-authoritative tick model to an **intent-based architecture** where the server guides via desire and disposition, and the client executes local agency between ticks. Deviation is treated as emergence, not error. + +--- + +## 1. Philosophy — "The Unseen Hand" Applied to Architecture + +The original design was a classic authoritative-server pattern: the server computed exact positions at 10 Hz and streamed them to a passive client that interpolated between frames. The client had no agency — it was a dumb renderer. + +This is ecologically wrong. Organisms don't receive GPS coordinates from their nervous system. They receive **intent** — hunger, thirst, fear, reproductive drive — and their body translates those signals into movement through local perception of the environment. A deer doesn't know its exact latitude; it knows grass smells nearby and a wolf's shadow is behind it. + +The new architecture mirrors this: + +| Old (Authoritative) | New (Intent-Based) | +|---|---| +| Server sends `position: [x, y, z]` every 100ms | Server sends `state`, `drive`, `motion_latent`, `ref_position` every 2s | +| Client interpolates between positions | Client evaluates drives + local perception → decides movement | +| Divergence = error → snap back | Divergence = information → absorb and redirect | +| All target selection on server | Target selection split: server guides, client executes locally | +| Server does everything at 10 Hz | Server does ecology math at 0.5 Hz; client does animation/behavior at 60 Hz | + +**Key principle**: The server maintains ecological truth (population counts, soil nutrients, who ate whom). The client handles behavioral presentation (how the deer walks to the grass, how it hesitates near water). If the client's deer wanders slightly off-course, that's not a bug — it's emergence. The nervous system acknowledges the new position and continues guiding from there. + +--- + +## 2. Architecture Overview + +### 2.1 Compute Split + +``` +┌─────────────────────────────────────────────────────────────┐ +│ SERVER (0.5 Hz — heavy ecological math) │ +│ │ +│ • Voxel grid updates (nutrients, moisture, organic matter) │ +│ • Population lifecycle (birth, death, dormancy) │ +│ • State machine transitions (FORAGING → DRINKING → etc.) │ +│ • Interaction validation & absorption │ +│ • Motor adapter inference (BYOM latent vectors) │ +│ • Species definition broadcast │ +└───────────────┬─────────────────────────────────────────────┘ + │ WebSocket (JSON, ~2s intervals) + │ intent packets + heartbeat responses + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ CLIENT (60 Hz — local agency & rendering) │ +│ │ +│ • World model (local entity registry + species defs) │ +│ • Agency engine: evaluate drives → select targets │ +│ • Movement execution with latent-modulated style │ +│ • Interaction triggering (proximity-based, client-side) │ +│ • Position reconciliation (gravity well toward ref_pos) │ +│ • Heartbeat transmission (positions + events upstream) │ +│ • Canvas rendering / skeletal animation │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 Action Mapping — Who Decides What? + +| Action | Client decides/triggers | Server authoritative/applies | +|---|---|---| +| **Movement** | ✓ target selection, path modulation | state transitions, speed caps | +| **Herbivory** | ✓ proximity check → reports consumption | ✓ validates diet, applies effects, rate-caps | +| **Predation** | ✓ chase + proximity → reports predation | ✓ validates trophic level, kills prey, updates stats | +| **Pollination** | ✓ finds FRUITING flower → visits | ✓ validates floral affinity, applies pollen transfer | +| **Reproduction** | ✓ seeks mate → reports repro event | ✓ validates sex/drive/threshold, spawns offspring | +| **Drinking** | ✓ approaches water edge | ✓ validates hydration state, applies recovery | +| **Fleeing** | ✓ finds threat locally → runs away | ✓ sets FLEEING state via guard conditions | +| **Decomposition** | — (client doesn't trigger) | ✓ voxel-level mineralization | +| **Plant growth/spreading** | — (plants are stationary) | ✓ state transitions, rhizome spread validation | + +--- + +## 3. Protocol + +### 3.1 Session Initialization + +**Client → Server**: World definition JSON (unchanged from v0.2 data contract). + +**Server → Client**: `session_started` acknowledgement with species reference: + +```json +{ + "type": "session_started", + "session_id": "demo-alpha-001", + "tick_rate": 2.0, + "entity_count": 27, + "species": { + "deer": { + "type": "ANIMAL", + "movement_speed": 3.0, + "diet_order": [["meadow_grass", 1], ["wildflower", 2]], + "flee_targets": ["wolf"], + "is_pollinator": false, + "pollination_targets": [], + "has_roost_affinity": false, + "mating_radius": 12.0 + }, + "songbird": { + "type": "BIRD", + "movement_speed": 5.0, + "diet_order": [["monarch", 1]], + "flee_targets": ["wolf"], + "is_pollinator": false, + "pollination_targets": [], + "has_roost_affinity": true, + "mating_radius": 8.0 + } + } +} +``` + +The `species` map gives the client everything it needs for local target selection without exposing full simulation internals (allometric scaling factors, interaction matrix weights, guard thresholds). + +### 3.2 Tick Packet (Server → Client) — Intent Format + +Every ~2 seconds, the server sends an intent packet: + +```json +{ + "tick": 47, + "dt": 2.0, + "entity_updates": [ + { + "id": "deer_01", + "state": "FORAGING", + "ref_position": [16.3, 0.0, 14.1], + "drive": { + "hunger": 0.42, + "energy": 0.78, + "hydration": 0.91, + "health": 0.95, + "reproductive_drive": 0.12 + }, + "motion_latent": [0.3, -0.1, 0.6, 0.2], + "_can_consume": true, + "_can_predate": false, + "_can_pollinate": false, + "_repro_eligible": false, + "_can_drink": false + } + ], + "entity_spawns": [...], + "entity_removals": ["mushroom_03"], + "events": [ + { "type": "CONSUMPTION", "source_id": "deer_01", "target_id": "grass_07" } + ], + "voxel_deltas": { "moisture": { "16,0,14": 0.72 } }, + "water_sources": [ + { "position": [8, 0, 20], "radius": 3.0, "water_level": 0.85 } + ] +} +``` + +**Field semantics**: + +| Field | Meaning | Client usage | +|---|---|---| +| `state` | Discrete state machine value (`FORAGING`, `DRINKING`, `FLEEING`, etc.) | Primary behavior selector in agency engine | +| `ref_position` | Server's expected position — **gravity well, not command** | Reconciliation anchor; client may deviate within bounds | +| `drive` | Continuous desire variables (0.0–1.0) | Modulates urgency, triggers secondary behaviors | +| `motion_latent` | 4D vector from BYOM motor adapter: `[pace, caution, posture, social]` | Modulates movement style (speed, wobble, hesitation) | +| `_can_*` flags | Server-side permission gates derived from state + drives | Eligibility checks before client triggers interactions | +| `_ack` | Server absorbed client deviation this tick | Client syncs x/z to ref_position on receipt | + +**What's NOT sent**: No authoritative `position` field. The old format included exact coordinates that the client was expected to render verbatim. That's gone — replaced by `ref_position` which is a suggestion, not a command. + +### 3.3 Heartbeat (Client → Server) + +Every ~1 second, the client sends its local state upstream: + +```json +{ + "type": "heartbeat", + "positions": { + "deer_01": [17.5, 0.0, 15.2], + "songbird_03": [22.1, 0.0, 9.8] + }, + "events": [ + { + "type": "consumption", + "source_id": "deer_01", + "target_id": "grass_07", + "position": [15.3, 0.0, 13.8] + } + ] +} +``` + +**Field semantics**: + +| Field | Meaning | Server usage | +|---|---|---| +| `positions` | Client-agency positions for all mobile entities | Reconciliation: soft-nudge or snap server position | +| `events[].type` | Interaction type (`consumption`, `predation`, `pollination`, `repro`) | Validates against ecological rules, applies effects | +| `events[].source_id` / `target_id` | Entity IDs involved | Looks up entities in simulation state | +| `events[].position` | Where the interaction occurred (client's view) | Sanity check: must be within sensory range of source | + +### 3.4 Event Types + +Client can report these event types via heartbeat: + +``` +consumption — herbivore ate a plant +predation — predator killed prey +pollination — pollinator visited a flower +repro — reproduction attempt (parent + offspring count) +``` + +The server absorbs events through `EcosystemEngine.absorb_client_events()` which validates against ecological rules and applies effects with rate caps. Invalid events are silently dropped (the client is optimistic; the server is authoritative on outcomes). + +--- + +## 4. Reconciliation Strategy — "Acknowledge + Redirect" + +When the client's position diverges from the server's reference, the system doesn't snap or punish. It **acknowledges** and **redirects**. + +### 4.1 Server-Side Absorption (`absorb_client_positions`) + +``` +for each entity in heartbeat.positions: + divergence = distance(server_pos, client_pos) + expected_travel = speed * tick_rate * 2.5 + + if divergence <= expected_travel: + # Within bounds — soft nudge server position toward client + server_pos += (client_pos - server_pos) * 0.3 + else: + # Significant divergence — snap to client, mark for _ack + server_pos = client_pos + pending_acks.add(entity_id) + +# On next tick packet: set _ack=True for entities in pending_acks +# Client sees _ack and syncs its position to ref_position +``` + +### 4.2 Client-Side Reconciliation (`reconcile`) + +After receiving a new tick packet, the client reconciles its positions with server references: + +```javascript +for each mobile entity: + if entity._ack: + // Server acknowledged our deviation — trust it fully + ent.x = ent.refX; ent.z = ent.refZ; + else: + divergence = distance(ent.x/z, ent.refX/refZ) + expected_travel = speed * SERVER_TICK_RATE + + if divergence <= expected_travel * 2.5: + // Within bounds — soft nudge toward reference (gravity well) + ent.x -= dx * 0.15; ent.z -= dz * 0.15; + else: + // Significant divergence — lerp more aggressively + ent.x -= dx * 0.5; ent.z -= dz * 0.5; +``` + +The server sends `_ack` on the next packet after absorbing a large deviation, which tells the client "I've accepted your position, let's continue from here." This prevents the ping-pong where both sides keep correcting each other. + +### 4.3 Why Not Snap? + +Three reasons: + +1. **Ecological truth**: The server knows soil nutrients, water levels, and population density at every voxel. If a client's deer wanders into an area with no grass, the server won't let it eat there — eligibility flags (`_can_consume`) will be false regardless of position. + +2. **Bandwidth**: Snapping every frame would require constant correction messages. The gravity-well approach converges naturally over 1–3 ticks without extra bandwidth. + +3. **Emergence**: If a client's deer takes a slightly different path to the same grass patch, that's not an error — it's behavioral variation. The system should allow it within reason. + +--- + +## 5. Client-Side Agency Engine + +The agency engine runs at ~60 Hz on the client between server ticks. For each mobile entity, it: + +1. **Reads intent**: `state`, `drive` values, `_can_*` eligibility flags +2. **Perceives locally**: queries world model for nearest food/water/threats/mates +3. **Evaluates behavior**: priority chain (flee → drink → mate → forage → hunt → pollinate → wander) +4. **Executes movement**: moves toward selected target with latent-modulated style +5. **Triggers interactions**: on arrival, checks proximity and eligibility, reports events upstream + +### 5.1 Behavior Priority Chain + +```javascript +function evaluateBehavior(ent, world) { + // Fleeing (highest priority — threat detected locally) + if (state === 'FLEEING') return evaluateFleeing(ent, world); + + // Drinking + if (state === 'DRINKING' || canDrink && hydration < 0.3) + return evaluateDrinking(ent, world); + + // Reproduction seeking + if (reproEligible && reproductive_drive > 0.5) { + const mate = evaluateMateSeeking(ent, world); + if (mate) return mate; + } + + // Foraging / Herbivory + if (state === 'FORAGING' && canConsume) + return evaluateForaging(ent, world); + + // Hunting / Predation + if ((state === 'HUNTING' || state === 'FORAGING') && canPredatate) + return evaluateHunting(ent, world); + + // Pollination + if (canPollinate && is_pollinator) + return evaluatePollination(ent, world); + + // Default: wander with latent-modulated style + return evaluateWandering(ent, world); +} +``` + +### 5.2 Motion Latent → Movement Style + +The 4D motion latent vector from the BYOM motor adapter modulates movement characteristics: + +| Dimension | Meaning | Effect on movement | +|---|---|---| +| `latent[0]` | Pace / urgency | Speed multiplier (high = faster, low = slower) | +| `latent[1]` | Caution / alertness | Path wobble (high = more hesitation/curvature) | +| `latent[2]` | Posture | Visual only (future: skeletal animation blend weights) | +| `latent[3]` | Social orientation | Visual only (future: head/body rotation toward flock) | + +Currently dimensions 0 and 1 affect movement; 2 and 3 are reserved for future skeletal animation. + +### 5.3 Interaction Cooldowns + +To prevent event spam when an entity lingers at a target, the client maintains per-target cooldowns (2 seconds). Once a consumption/predation/pollination/repro event is reported for a given `(source_id, target_id)` pair, no further events fire for that pair until the cooldown expires. + +--- + +## 6. Code Changes + +### 6.1 Server-Side (`server/ecosim/`) + +**`engine.py`**: +- `_build_tick_packet()` — rewritten to emit intent fields (`state`, `drive`, `motion_latent`, `ref_position`, eligibility flags) instead of authoritative positions +- `absorb_client_positions(positions)` — reconciles client-reported positions with server state using soft-nudge or snap + _ack strategy +- `absorb_client_events(events)` — absorbs client-reported interactions (consumption, predation, pollination, repro) with validation and rate caps +- `get_species_definitions()` — exports lightweight species reference (`type`, `movement_speed`, `diet_order`, `flee_targets`, `is_pollinator`, `pollination_targets`) for client-side agency + +**`worker.py`**: +- `DEFAULT_TICK_RATE` changed from 0.1s (10 Hz) → 2.0s (0.5 Hz) +- `SimulationSession.absorb_heartbeat(msg)` — routes heartbeat positions and events to engine absorption methods +- `session_started` ack now includes `species` definitions for client-side agency initialization +- `"heartbeat"` added to `CONTROL_HANDLERS` +- `process_request()` — extended to serve arbitrary static files from viz directory (css/, js/) with proper MIME types and path traversal protection + +### 6.2 Client-Side (`client/browser/`) + +The monolithic `index.html` (~1200 lines of mixed HTML/CSS/JS) was split into modular files: + +| File | Lines | Responsibility | +|---|---|---| +| `index.html` | 46 | Thin shell — HTML structure + CSS/JS imports | +| `css/style.css` | 176 | All visual styles (panels, legend, buttons) | +| `js/constants.js` | 61 | Colors, grid config, tick rates, thresholds | +| `js/world-model.js` | 280 | Entity registry, species defs, spatial queries (`findNearest`, `findNearestWater`, `findNearestMate`) | +| `js/agency.js` | 370 | **Client-side behavior engine** — evaluates drives + eligibility → target selection → movement execution → interaction triggers with cooldowns | +| `js/heartbeat.js` | 65 | Upstream position/event reporting to server (1 Hz) | +| `js/reconciliation.js` | 50 | Position reconciliation (trust within bounds, nudge/snap on divergence, _ack handling) | +| `js/renderer.js` | 510 | All canvas drawing functions (entities, water, moisture heatmap, particles) | +| `js/particles.js` | 38 | Particle system for event visualizations | +| `js/main.js` | 340 | Entry point — WebSocket setup, render loop coordination, UI controls | + +### 6.3 Test Harness (`server/tests/client_harness.py`) + +Headless Python client that simulates a full browser session: +- Connects via WebSocket, sends world definition +- Receives `session_started` with species definitions +- Validates intent packet format (state + drive + motion_latent + ref_position) +- Tests heartbeat reconciliation (soft-nudge within bounds) +- Tests event absorption (consumption reported → server applies) +- Tests divergence snap + _ack (large deviation → server snaps and acknowledges) +- Tests entity lifecycle across multiple ticks + +Run manually against a live server: +```bash +# Terminal 1: start server +uv run python -m ecosim.worker --log-level DEBUG + +# Terminal 2: run harness +uv run python tests/client_harness.py --host localhost --port 8001 +``` + +--- + +## 7. Migration Notes + +### From Authoritative to Intent-Based + +If you have an existing client that expects authoritative positions, the migration path is: + +1. **Server sends both** during transition: keep `position` alongside `ref_position` and intent fields +2. **Client reads intent first**, falls back to `position` if intent fields are missing +3. **Remove `position`** once all clients support intent format + +### Bandwidth Impact + +At 0.5 Hz with ~30 entities, a typical tick packet is ~4-6 KB (vs. ~2 KB at 10 Hz with positions only). Heartbeats add ~1 KB/s upstream. Net increase is modest because the server sends far fewer packets per second (5 vs. 100). + +### What Breaks + +- Clients that expect `position` in entity updates will see NaN/undefined — they must read `ref_position` instead +- The old `world.json` format still works for world definition, but clients should use the species definitions from `session_started` rather than parsing entity metadata inline +- Headless mode (`--headless`) is unaffected — it uses `SimulationSession.step()` directly without WebSocket + +--- + +## 8. Future Work + +- **Skeletal animation**: dimensions 2 and 3 of motion latent are reserved for posture/social orientation blend weights in Godot client +- **Adaptive tick rate**: server could increase tick frequency during high-activity periods (many state transitions) and decrease during quiescence +- **Partial heartbeats**: client only needs to send positions that changed significantly since last heartbeat +- **Event acknowledgment**: server could echo absorbed events back with outcome (`accepted`/`rejected` + reason) for client-side feedback +- **Multi-client support**: current design is one-worker-per-session; intent-based architecture makes it easier to add spectator clients that receive tick packets without sending heartbeats diff --git a/server/config/sim_config.json b/server/config/sim_config.json index c4c1540..4a29ac7 100644 --- a/server/config/sim_config.json +++ b/server/config/sim_config.json @@ -47,7 +47,7 @@ "reproduction": { "_comment": "Entity spawning and inheritance (universal)", - "colony_health_repro_cost_factor": 0.3 + "colony_health_repro_cost_factor": 0.1 }, "interactions": { diff --git a/server/ecosim/config.py b/server/ecosim/config.py index e8e60f2..b57875f 100644 --- a/server/ecosim/config.py +++ b/server/ecosim/config.py @@ -62,7 +62,7 @@ "arrival_threshold_double": 2.0, }, "reproduction": { - "colony_health_repro_cost_factor": 0.3, + "colony_health_repro_cost_factor": 0.1, }, "interactions": { "mass_ratio_windows": { diff --git a/server/ecosim/constants.py b/server/ecosim/constants.py index fca7cd8..ac9c8be 100644 --- a/server/ecosim/constants.py +++ b/server/ecosim/constants.py @@ -19,7 +19,7 @@ # ── Near-water survival bonus ───────────────────────────────────────────────── WATER_PROXIMITY_HUNGER_FACTOR = 0.5 # hunger relief = hunger_rate × this -WATER_PROXIMITY_COLONY_FACTOR = 0.2 # colony_health recovery = energy_recovery × this +WATER_PROXIMITY_COLONY_FACTOR = 0.5 # colony_health recovery = energy_recovery × this # ── Reproductive drive conditions ───────────────────────────────────────────── REPRO_BUILD_MIN_ENERGY = 0.5 # energy must exceed this to build drive @@ -33,7 +33,7 @@ STARVATION_HUNGER = 0.8 # hunger above this → health drain DEHYDRATION_HYDRATION = 0.15 # hydration below this → health drain COLONY_STRESS_HUNGER = 0.7 # colony_health starts draining -COLONY_STRESS_ENERGY = 0.2 # colony_health starts draining +COLONY_STRESS_ENERGY = 0.35 # colony_health starts draining # ── Plant physiology ────────────────────────────────────────────────────────── PLANT_BASE_WATER_DEMAND = 0.03 # base water uptake rate from soil @@ -83,7 +83,7 @@ POLLINATOR_VISIT_LIMIT = 4 # visits before forced WANDERING exploration POLLINATOR_WANDER_COOLDOWN = 30 # ticks to wander before re-entering FORAGING POLLINATOR_CROWD_RADIUS = 2.5 # radius to count "at flower" pollinators -POLLINATOR_POST_VISIT_COOLDOWN = 15 # ticks after linger ends before re-pollination +POLLINATOR_POST_VISIT_COOLDOWN = 8 # ticks after linger ends before re-pollination # ── Child entity inheritance ────────────────────────────────────────────────── CHILD_HUNGER_INHERIT = 0.3 # child hunger = parent × this diff --git a/server/ecosim/engine.py b/server/ecosim/engine.py index 60f8a78..817310c 100755 --- a/server/ecosim/engine.py +++ b/server/ecosim/engine.py @@ -194,6 +194,16 @@ def __init__( self._spawns: list[dict[str, Any]] = [] self._removals: list[str] = [] + # ── Client agency reconciliation ── + # Entities whose client-reported positions were absorbed this tick. + # Next packet will include _ack: true for these entities so the + # client knows the server heard it and adjusted its expectation. + self._pending_acks: set[str] = set() + # Client-reported reproduction events pending absorption. + self._pending_client_repro: list[dict[str, Any]] = [] + # Client-reported interaction events (consumption, predation, pollination). + self._pending_client_events: list[dict[str, Any]] = [] + # ── Effect bus + world-process handlers ── self.effect_bus = EffectBus() register_default_world_handlers(self.effect_bus) @@ -665,6 +675,374 @@ def apply_rain(self, intensity: float = 0.5) -> None: self._recent_rain_recovery_ticks = RAIN_REPRO_RECOVERY_TICKS self._events.append({"type": "RAIN", "tick": self.tick, **event}) + # ═══════════════════════════════════════════════════════════════════════ + # Client Agency Reconciliation — Absorb client-reported state + # ═══════════════════════════════════════════════════════════════════════ + + def absorb_client_positions( + self, positions: dict[str, list[float]], divergence_multiplier: float = 2.5, + ) -> None: + """Absorb client-reported entity positions into server state. + + For each entity, compare reported position against simulated position. + If divergence < threshold (species speed × dt_server_tick × multiplier): + soft-nudge internal position toward reported over a few ticks. + If divergence > threshold: + snap to reported and mark for _ack on next packet. + + The server never abandons its goals — state vars, discrete states, + and ecological relationships remain authoritative. Only positions shift. + + Args: + positions: Mapping of entity_id → [x, y, z] from client heartbeat. + divergence_multiplier: How many "expected travel distances" before snap. + Default 2.5 means an entity can deviate up to 2.5× its max + travel distance before the server snaps to client truth. + """ + import math as _math + + for eid, reported_pos in positions.items(): + entity = self.entities.get(eid) + if entity is None or not is_alive(entity): + continue # Entity may have been removed server-side + + params = self._get_params(entity) + sim_pos = entity["position"] + + dx = reported_pos[0] - sim_pos[0] + dz = reported_pos[2] - sim_pos[2] + divergence = _math.sqrt(dx * dx + dz * dz) + + if divergence < 0.1: + continue # Negligible drift — no action needed + + # Compute threshold from species speed × effective tick interval. + # At 0.5 Hz server tick, dt ≈ 2.0s. Speed is in world units/sec. + if params and hasattr(params, "speed"): + max_travel = params.speed * (1.0 / self._effective_hz()) * divergence_multiplier + else: + max_travel = 10.0 * divergence_multiplier # generous default for sessile + + if divergence <= max_travel: + # Soft nudge — move server position partway toward client. + # This keeps the two simulations loosely coupled without snapping. + nudge_factor = 0.3 # absorb 30% of deviation per heartbeat + sim_pos[0] += dx * nudge_factor + sim_pos[2] += dz * nudge_factor + else: + # Snap — client deviated significantly. Absorb its position + # and acknowledge on next packet so the client knows we heard it. + sim_pos[0] = reported_pos[0] + sim_pos[2] = reported_pos[2] + self._pending_acks.add(eid) + + def absorb_client_events(self, events: list[dict[str, Any]]) -> None: + """Absorb client-reported interaction and lifecycle events. + + The client may report reproduction, consumption, predation, + or pollination events that occurred through its local agency. + The server validates basic sanity (entities exist, are alive) + and applies the ecological effects. Detailed proximity checks + are skipped — the client's perception is trusted within bounds. + + Event types: + - "repro": Client-side reproduction. Spawns offspring, + applies parent costs. Rate-capped per species. + - "consumption": Herbivory event. Applies hunger relief + to consumer, growth/health damage to plant. + - "predation": Predation event. Removes prey, feeds predator, + deposits organic matter at kill site. Rate-capped. + - "pollination": Pollinator visited a flower. Boosts plant + health, relieves pollinator hunger/hydration. + + Args: + events: List of event dicts from client heartbeat. + """ + for ev in events: + etype = ev.get("type", "") + + if etype == "repro": + self._absorb_reproduction(ev) + elif etype == "consumption": + self._absorb_consumption(ev) + elif etype == "predation": + self._absorb_predation(ev) + elif etype == "pollination": + self._absorb_pollination(ev) + + def _absorb_reproduction(self, ev: dict[str, Any]) -> None: + """Absorb a client-reported reproduction event.""" + import random as _random + + parent_id = ev.get("parent_id", "") + parent = self.entities.get(parent_id) + if not parent or not is_alive(parent): + return + + params = self._get_params(parent) + if params is None: + return + + # Sanity: parent must have high reproductive drive (server-side truth) + sv = parent["state_vars"] + if sv.get("reproductive_drive", 0) <= params.repro_drive_threshold: + return # Server doesn't think parent is ready — defer + + # Rate cap: track repro events per species to prevent runaway spawning + species_id = parent.get("species", "unknown") + recent_repros = sum( + 1 for e in self._events + if e.get("type") == "REPRODUCTION" + and e.get("source_id", "").startswith(species_id) + and abs(e.get("tick", 0) - self.tick) < 50 + ) + max_repros = max(1, params.clutch_size * 3) if params.clutch_size else 3 + if recent_repros >= max_repros: + return # Rate capped — try again later + + # Apply parent costs + sv["reproductive_drive"] = 0.0 + sv["energy"] = max(0.0, sv.get("energy", 1.0) - params.parent_energy_cost) + + # Spawn offspring at client-reported position + pos = ev.get("client_position", list(parent["position"])) + clutch_size = ev.get("offspring_count", params.clutch_size or 1) + meta = parent.get("metadata", {}) + + for i in range(clutch_size): + new_x = pos[0] + _random.uniform(-1.0, 1.0) + new_z = pos[2] + _random.uniform(-1.0, 1.0) + child_id = f"{parent_id}_child_{self.tick}_{_random.randint(0, 999)}" + + offspring_sv: dict[str, float] = { + "hunger": sv.get("hunger", 0.5) * 0.6, + "energy": max(0.3, sv.get("energy", 1.0) * 0.8), + "hydration": sv.get("hydration", 1.0), + "health": max(0.3, sv.get("health", 1.0) * 0.9), + "reproductive_drive": 0.0, + "age": 0.0, + } + + self._spawns.append({ + "id": child_id, + "type": parent["type"], + "species": parent.get("species"), + "position": [new_x, pos[1] or 0.0, new_z], + "metadata": dict(meta), + "state_vars": offspring_sv, + "skeleton_id": parent.get("skeleton_id"), + "sex": _random.choice(("male", "female")), + "initial_attrs": {}, + }) + + self._events.append({ + "type": "REPRODUCTION", + "tick": self.tick, + "source_id": parent_id, + "target_id": child_id if clutch_size == 1 else None, + "position": pos, + "client_reported": True, + }) + + def _absorb_consumption(self, ev: dict[str, Any]) -> None: + """Absorb a client-reported herbivory event.""" + source_id = ev.get("source_id", "") + target_id = ev.get("target_id", "") + + consumer = self.entities.get(source_id) + plant = self.entities.get(target_id) + if not consumer or not plant: + return + if not is_alive(consumer) or not is_alive(plant): + return + + params = self._get_params(consumer) + if params is None: + return + + # Apply hunger relief to consumer + sv = consumer["state_vars"] + sv["hunger"] = max(0.0, sv.get("hunger", 0) - params.herbivory_relief) + + # Apply growth/health damage to plant + psv = plant["state_vars"] + rate = self.rate_consumption + psv["growth"] = max( + 0.0, psv.get("growth", 1.0) - params.consumption_damage_growth * rate, + ) + psv["health"] = max( + 0.0, psv.get("health", 1.0) - params.consumption_damage_health * rate, + ) + + self._events.append({ + "type": "CONSUMPTION", + "tick": self.tick, + "source_id": source_id, + "target_id": target_id, + "position": ev.get("position", list(plant["position"])), + "client_reported": True, + }) + + def _absorb_predation(self, ev: dict[str, Any]) -> None: + """Absorb a client-reported predation event.""" + source_id = ev.get("source_id", "") + target_id = ev.get("target_id", "") + + predator = self.entities.get(source_id) + prey = self.entities.get(target_id) + if not predator or not prey: + return + if not is_alive(predator) or not is_alive(prey): + return + + params = self._get_params(predator) + if params is None: + return + + # Rate cap: max kills per predator per N ticks + recent_kills = sum( + 1 for e in self._events + if e.get("type") == "PREDATION" + and e.get("source_id") == source_id + and abs(e.get("tick", 0) - self.tick) < 100 + ) + if recent_kills >= 3: + return # Rate capped + + # Predator gains + psv = predator["state_vars"] + psv["hunger"] = max(0.0, psv.get("hunger", 0) - params.predation_relief) + psv["energy"] = min(1.0, psv.get("energy", 0) + params.predation_energy_gain) + + # Prey removed + kill_pos = ev.get("kill_position", list(prey["position"])) + self._removals.append(target_id) + + # Deposit organic matter at kill site + deposit_amount = min( + 0.3, (params.metabolic_rate * 0.1 if hasattr(params, "metabolic_rate") else 0.05), + ) + gx, gy, gz = self.env.voxels.world_to_grid(*kill_pos) + self.env.voxels.add("organic_matter", gx, gy, gz, deposit_amount) + + self._events.append({ + "type": "PREDATION", + "tick": self.tick, + "source_id": source_id, + "target_id": target_id, + "position": kill_pos, + "client_reported": True, + }) + + def _absorb_pollination(self, ev: dict[str, Any]) -> None: + """Absorb a client-reported pollination event.""" + source_id = ev.get("source_id", "") + target_id = ev.get("target_id", "") + + pollinator = self.entities.get(source_id) + plant = self.entities.get(target_id) + if not pollinator or not plant: + return + if not is_alive(pollinator) or not is_alive(plant): + return + + params = self._get_params(pollinator) + if params is None or not getattr(params, "floral_affinity", False): + return + + # Plant health boost + psv = plant["state_vars"] + psv["health"] = min(1.0, psv.get("health", 0) + 0.05) + + # Pollinator hunger/hydration relief + psv2 = pollinator["state_vars"] + relief = getattr(params, "pollination_relief", 0.08) + psv2["hunger"] = max(0.0, psv2.get("hunger", 0) - relief) + if "hydration" in psv2: + psv2["hydration"] = min(1.0, psv2.get("hydration", 0) + relief * 0.5) + + self._events.append({ + "type": "POLLINATION", + "tick": self.tick, + "source_id": source_id, + "target_id": target_id, + "position": ev.get("position", list(plant["position"])), + "client_reported": True, + }) + + def get_species_definitions(self) -> dict[str, Any]: + """Build lightweight species reference for client-side agency. + + Derived from CompiledEcology at server init. Gives the client + everything it needs for local target selection without exposing + full simulation internals (allometric scaling, interaction matrix, + guard thresholds). + + Returns: + Dict mapping species_id → { type, diet_order, flee_targets, + is_pollinator, pollination_targets, has_roost_affinity, + mating_radius }. + """ + result: dict[str, Any] = {} + + # Build a map of species → movement_speed from entity metadata. + # Entity metadata can override the trait-derived speed for tuning. + _speed_overrides: dict[str, float] = {} + for e in self.entities.values(): + species = e.get("species", "") + ms = e.get("metadata", {}).get("movement_speed") + if ms: + if species not in _speed_overrides or ms > _speed_overrides[species]: + _speed_overrides[species] = ms + + for species_id, params in self.compiled.derived_params.items(): + # Prefer metadata movement_speed override over trait-derived speed + speed = _speed_overrides.get(species_id, params.speed) + entry: dict[str, Any] = { + "type": params.entity_class, + "movement_speed": round(speed, 4), + "diet_order": [], + "flee_targets": [], + "is_pollinator": bool(getattr(params, "floral_affinity", False)), + "pollination_targets": [], + "has_roost_affinity": bool(getattr(params, "roost_affinity", False)), + "mating_radius": params.sensory_range, + } + + # Diet order: list of [species_id, preference_rank] + diet_order = self.compiled.get_diet_order(species_id) + if diet_order: + entry["diet_order"] = [[s, p] for s, p in diet_order] + + # Flee targets from interaction matrix + flee_targets = self.compiled.get_flee_targets(species_id) + if flee_targets: + entry["flee_targets"] = list(flee_targets) + + # Pollination targets (species this pollinator can visit) + if entry["is_pollinator"]: + for other_species in self.compiled.derived_params: + interactions = self.compiled.get_interactions( + species_id, other_species, + ) + if interactions and any( + ix.interaction_type == "pollination" + for ix in interactions + ): + entry["pollination_targets"].append(other_species) + + result[species_id] = entry + + return result + + def _effective_hz(self) -> float: + """Return effective server tick rate in Hz. + + Used by absorption to compute expected travel distance thresholds. + Defaults to 0.5 (2-second ticks) for intent-based mode. + """ + return 0.5 + # ═══════════════════════════════════════════════════════════════════════ # Phase 6: Motor Inference (BYOM) # ═══════════════════════════════════════════════════════════════════════ @@ -698,40 +1076,169 @@ def _apply_motor_inference(self) -> None: # ═══════════════════════════════════════════════════════════════════════ def _build_tick_packet(self, dt: float) -> dict[str, Any]: - """Build delta-encoded tick packet for WebSocket transmission.""" + """Build intent-based tick packet for client agency. + + Unlike the old position-authoritative format, this packet sends: + - **State** (discrete state machine value) + - **Drives** (continuous desire variables: hunger, thirst, energy, + reproductive_drive) — these are *intent*, not commands + - **Motion latent** (4D vector from motor model encoding how the + entity should feel moving right now) + - **Reference position** (where server expects entity to be — used + for reconciliation gravity, not as authoritative command) + - **Eligibility flags** (_can_consume, _can_predate, etc.) — + permission gates derived from server-side state checks + - **_ack** flag if server absorbed client deviation this tick + + The client uses drives + latent + eligibility to generate local + movement and interaction behavior. It reports outcomes back via + heartbeat messages. + """ packet: dict[str, Any] = {"tick": self.tick, "dt": dt} updates = [] for e in self.entities.values(): + if not is_alive(e): + continue # Don't send dead/dying entities + + eid = e["id"] + sv = e["state_vars"] + params = self._get_params(e) + update: dict[str, Any] = { - "id": e["id"], "state": e["state"], - "position": [round(v, 4) for v in e["position"]], - "velocity": [round(v, 4) for v in e.get("velocity", [0, 0, 0])], - "state_vars": {k: round(v, 4) for k, v in e["state_vars"].items()}, + "id": eid, + "state": e["state"], + # Reference position — gravity well for reconciliation, + # not an authoritative command. Client may deviate. + "ref_position": [round(v, 4) for v in e["position"]], + # Drives — continuous desire variables that encode intent + "drive": { + "hunger": round(sv.get("hunger", 0.0), 4), + "energy": round(sv.get("energy", 1.0), 4), + "hydration": round(sv.get("hydration", 1.0), 4), + "health": round(sv.get("health", 1.0), 4), + }, } - if e.get("skeleton_id"): - update["motion_latent"] = e.get("motion_latent", [0.0, 0.0, 0.0, 0.0]) + + # Include reproductive drive for mobile consumers + if params and params.diet_type not in ("autotroph", "decomposer"): + update["drive"]["reproductive_drive"] = round( + sv.get("reproductive_drive", 0.0), 4, + ) + + # Include plant-specific state vars for producers + if params and params.diet_type == "autotroph": + update["drive"]["growth"] = round(sv.get("growth", 0.0), 4) + update["drive"]["nutrient_store"] = round( + sv.get("nutrient_store", 0.0), 4, + ) + + # Motion latent — 4D vector encoding movement disposition + if e.get("skeleton_id") or (params and params.speed > 0): + update["motion_latent"] = e.get( + "motion_latent", [0.0, 0.0, 0.0, 0.0], + ) + + # Eligibility flags — server-side permission gates. + # These tell the client what interactions are *possible* given + # current state vars and discrete state. The client decides + # whether conditions are met locally (proximity, etc.). + if params: + diet = params.diet_type + state = e["state"] + + # Herbivory: FORAGING consumer with sufficient hunger + if diet in ("herbivore", "omnivore"): + update["_can_consume"] = ( + state == "FORAGING" + and sv.get("hunger", 0) > 0.2 + ) + + # Predation: HUNTING carnivore/insectivore with hunger + if diet in ("carnivore", "insectivore"): + update["_can_predate"] = ( + state == "HUNTING" + and sv.get("hunger", 0) > 0.3 + ) + elif diet == "omnivore": + # Omnivores can opportunistically predate while FORAGING + update["_can_predate"] = state in ("HUNTING", "FORAGING") + + # Pollination: pollinator not lingering, not on cooldown + if getattr(params, "floral_affinity", False): + update["_can_pollinate"] = ( + e.get("_linger", 0) <= 0 + and e.get("_pollination_cooldown", 0) <= 0 + and state != "WANDERING" + ) + + # Reproduction: female with drive above threshold + if diet not in ("autotroph", "decomposer"): + update["_repro_eligible"] = ( + e.get("sex") == "female" + and sv.get("reproductive_drive", 0) > params.repro_drive_threshold + and state not in ("DYING", "REPRODUCING", "SWARMING") + ) + + # Drinking: entity can drink when near water + if diet not in ("autotroph", "decomposer"): + update["_can_drink"] = ( + sv.get("hydration", 1.0) < 0.7 + or state == "DRINKING" + ) + + # Plant spreading eligibility + if params and params.diet_type == "autotroph": + update["_spread_eligible"] = ( + sv.get("health", 1.0) > 0.5 + and sv.get("hydration", 1.0) > 0.3 + and sv.get("growth", 0.0) > 0.4 + and e.get("_spread_cooldown", 0) <= 0 + ) + + # Acknowledgment flag — server absorbed client deviation + if eid in self._pending_acks: + update["_ack"] = True + updates.append(update) + packet["entity_updates"] = updates + + # Clear pending acks after including them in this packet + self._pending_acks.clear() + + # Spawns — new entities entering the simulation. + # Client renders these immediately; they start with full intent data. if self._spawns: packet["entity_spawns"] = [ - {"id": s["id"], "type": s["type"], "species": s.get("species"), - "position": [round(v, 4) for v in s["position"]], - "skeleton_id": s.get("skeleton_id"), "state": s["state"], - "state_vars": {k: round(v, 4) for k, v in s["state_vars"].items()}, - "motion_latent": [0.0, 0.0, 0.0, 0.0]} + { + "id": s["id"], + "type": s["type"], + "species": s.get("species"), + "ref_position": [round(v, 4) for v in s["position"]], + "skeleton_id": s.get("skeleton_id"), + "state": s["state"], + "drive": {k: round(v, 4) for k, v in s["state_vars"].items()}, + "motion_latent": [0.0, 0.0, 0.0, 0.0], + } for s in self._spawns ] + if self._removals: packet["entity_removals"] = list(self._removals) if self._events: packet["events"] = self._events + + # Voxel deltas — moisture layer for client heatmap rendering. + # Client doesn't need full 5-layer voxel data; just moisture for visuals. voxel_packet = self.env.voxels.get_delta_packet() if voxel_packet: packet["voxel_deltas"] = voxel_packet + if self.env.water_sources: packet["water_sources"] = [ {"position": ws["position"], "radius": ws["radius"], "water_level": ws["water_level"]} for ws in self.env.water_sources ] + return packet diff --git a/server/ecosim/telemetry.py b/server/ecosim/telemetry.py new file mode 100644 index 0000000..e692e8b --- /dev/null +++ b/server/ecosim/telemetry.py @@ -0,0 +1,550 @@ +# Copyright 2025 BioSynthArt Studios LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# conditions of the License. + +""" +līlā Telemetry Bus — structured event logging with file writer + WS broadcast. + +Stdlib-only. Designed to be optionally injected into the worker without +affecting the zero-dependency core (ecosim engine). + +Every event is a JSON line with fixed keys: + ts — monotonic timestamp (float) + tick — simulation tick number (int or null for client-side events) + level — DEBUG | INFO | WARN | ERROR + src — origin module: engine|motor|worker|client|telemetry + evt — event name (snake_case, e.g. "intent_emit", "guard_fire") + entity_id — affected entity (str or null) + detail — free-form dict with event-specific data + +File output: ~/.lila/logs/.jsonl (rotated by size) +WS broadcast: /telemetry endpoint on the worker server +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import pathlib +import time +from collections import deque +from typing import Any + +logger = logging.getLogger("lila.telemetry") + +# ── Constants ──────────────────────────────────────────────────────────────── + +DEFAULT_LOG_DIR = pathlib.Path.home() / ".lila" / "logs" +MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB per log file before rotation +MAX_MEMORY_BUFFER = 5000 # keep last N events in memory for HTTP API + +# ── Event Schema ───────────────────────────────────────────────────────────── + + +def make_event( + *, + tick: int | None, + level: str, + src: str, + evt: str, + entity_id: str | None = None, + detail: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build a structured telemetry event.""" + return { + "ts": time.monotonic(), + "tick": tick, + "level": level.upper(), + "src": src, + "evt": evt, + "entity_id": entity_id, + "detail": detail or {}, + } + + +# ── Telemetry Bus ──────────────────────────────────────────────────────────── + + +class TelemetryBus: + """Append-only telemetry bus with file writer and WS broadcast. + + Usage:: + + bus = TelemetryBus(session_id="demo-001") + bus.start() + bus.emit(tick=42, level="INFO", src="engine", evt="guard_fire", + entity_id="deer_01", detail={"from": "FORAGING", "to": "RESTING"}) + + The bus runs a background asyncio task for file I/O and WS fan-out. + Call ``await bus.stop()`` to shut down cleanly. + """ + + def __init__( + self, + session_id: str = "unknown", + log_dir: pathlib.Path | None = None, + max_file_size: int = MAX_FILE_SIZE, + max_memory: int = MAX_MEMORY_BUFFER, + ): + self.session_id = session_id + self.log_dir = (log_dir or DEFAULT_LOG_DIR) + self.max_file_size = max_file_size + self._max_memory = max_memory + + # In-memory ring buffer for HTTP API queries + self._buffer: deque[dict[str, Any]] = deque(maxlen=self._max_memory) + + # File writer state + self._file: Any | None = None + self._current_file_size: int = 0 + self._log_path: pathlib.Path | None = None + + # WS subscribers (asyncio.WriteTransport + protocol frames) + self._subscribers: list[TelemetrySubscriber] = [] + + # Background task for async I/O + self._task: asyncio.Task | None = None + self._queue: asyncio.Queue = asyncio.Queue() + self._running = False + + # ── Lifecycle ──────────────────────────────────────────────────────── + + def start(self) -> None: + """Start the background I/O task. Call from running event loop.""" + if self._running: + return + self._running = True + try: + self._task = asyncio.create_task(self._io_loop()) + except RuntimeError as e: + logger.warning("No running event loop for telemetry start: %s", e) + + async def stop(self) -> None: + """Stop the background I/O task and flush remaining events.""" + self._running = False + # Signal the queue to drain + await self._queue.put(None) # sentinel + if self._task: + try: + await asyncio.wait_for(self._task, timeout=2.0) + except (TimeoutError, asyncio.CancelledError): + pass + + # ── Emit API ───────────────────────────────────────────────────────── + + def emit( + self, + *, + tick: int | None = None, + level: str = "INFO", + src: str = "worker", + evt: str, + entity_id: str | None = None, + detail: dict[str, Any] | None = None, + ) -> None: + """Emit a telemetry event. Non-blocking — pushes to internal queue.""" + event = make_event( + tick=tick, level=level, src=src, evt=evt, + entity_id=entity_id, detail=detail, + ) + self._buffer.append(event) + if not self._running: + # If background task isn't running yet (e.g. during init), + # write synchronously to avoid losing early events. + self._write_sync(event) + else: + try: + self._queue.put_nowait(("event", event)) + except asyncio.QueueFull: + logger.warning("Telemetry queue full, dropping event: %s", evt) + + # Convenience methods for common levels + def debug(self, **kwargs): self.emit(level="DEBUG", **kwargs) + def info(self, **kwargs): self.emit(level="INFO", **kwargs) + def warn(self, **kwargs): self.emit(level="WARN", **kwargs) + def error(self, **kwargs): self.emit(level="ERROR", **kwargs) + + # ── WS Subscriber Management ───────────────────────────────────────── + + def add_subscriber(self, subscriber: TelemetrySubscriber) -> None: + """Add a WebSocket subscriber for real-time event streaming.""" + self._subscribers.append(subscriber) + + def remove_subscriber(self, subscriber: TelemetrySubscriber) -> None: + """Remove a WebSocket subscriber.""" + try: + self._subscribers.remove(subscriber) + except ValueError: + pass + + # ── Query API (for HTTP endpoints) ─────────────────────────────────── + + def query( + self, + *, + tick: int | None = None, + src: str | None = None, + level: str | None = None, + evt: str | None = None, + entity_id: str | None = None, + limit: int = 100, + ) -> list[dict[str, Any]]: + """Query recent events from the in-memory buffer.""" + results = [] + for event in reversed(self._buffer): + if tick is not None and event.get("tick") != tick: + continue + if src is not None and event.get("src") != src: + continue + if level is not None and event.get("level") != level.upper(): + continue + if evt is not None and event.get("evt") != evt: + continue + if entity_id is not None and event.get("entity_id") != entity_id: + continue + results.append(event) + if len(results) >= limit: + break + return list(reversed(results)) + + def stats(self) -> dict[str, Any]: + """Aggregate statistics from the in-memory buffer.""" + counts: dict[str, int] = {} + by_src: dict[str, int] = {} + by_level: dict[str, int] = {} + for event in self._buffer: + evt_name = event.get("evt", "unknown") + counts[evt_name] = counts.get(evt_name, 0) + 1 + src = event.get("src", "unknown") + by_src[src] = by_src.get(src, 0) + 1 + level = event.get("level", "UNKNOWN") + by_level[level] = by_level.get(level, 0) + 1 + + return { + "session_id": self.session_id, + "total_events_buffered": len(self._buffer), + "by_event_type": counts, + "by_source": by_src, + "by_level": by_level, + "log_file": str(self._log_path) if self._log_path else None, + } + + # ── Background I/O Loop ────────────────────────────────────────────── + + async def _io_loop(self) -> None: + """Background task: write events to file and broadcast to WS subscribers.""" + batch: list[dict[str, Any]] = [] + _batch_size = 10 # flush every N events or on timeout + + while self._running: + try: + # Wait for events with a short timeout to allow periodic flushes + item = await asyncio.wait_for(self._queue.get(), timeout=0.5) + except TimeoutError: + item = None + + if item is None: + break # sentinel — shutdown + + action, event = item + batch.append(event) + + # Flush batch when it reaches size or on timeout path + if len(batch) >= _batch_size: + await self._flush_batch(batch) + batch.clear() + + # Final flush on shutdown + if batch: + await self._flush_batch(batch) + + async def _flush_batch(self, batch: list[dict[str, Any]]) -> None: + """Write batch to file and broadcast to WS subscribers.""" + lines = "\n".join(json.dumps(e, default=str) for e in batch) + "\n" + + # File write + self._write_to_file(lines) + + # WS broadcast (non-blocking best-effort) + dead: list[TelemetrySubscriber] = [] + for sub in self._subscribers: + try: + await asyncio.wait_for(sub.send(lines), timeout=0.1) + except Exception: + dead.append(sub) + + for sub in dead: + self.remove_subscriber(sub) + + # ── File I/O ───────────────────────────────────────────────────────── + + def _write_sync(self, event: dict[str, Any]) -> None: + """Synchronous write (used when background task isn't running yet).""" + line = json.dumps(event, default=str) + "\n" + self._write_to_file(line) + + def _write_to_file(self, data: str) -> None: + """Append data to the current log file, rotating if needed.""" + # Ensure log directory exists + self.log_dir.mkdir(parents=True, exist_ok=True) + + # Open new file or rotate + if self._file is None or self._current_file_size >= self.max_file_size: + self._rotate_file() + + try: + self._file.write(data) + self._current_file_size += len(data.encode("utf-8")) + except OSError as e: + logger.error("Failed to write telemetry log: %s", e) + + def _rotate_file(self) -> None: + """Close current file and open a new one (with rotation suffix).""" + if self._file: + try: + self._file.close() + except OSError: + pass + + # Find next available filename + base = self.log_dir / f"{self.session_id}.jsonl" + counter = 0 + while True: + path = base.with_stem(f"{base.stem}.{counter}") if counter > 0 else base + if not path.exists() or counter == 0: + break + counter += 1 + + self._log_path = path + try: + self._file = open(path, "a", encoding="utf-8") + self._current_file_size = 0 + logger.info("Telemetry log: %s", path) + except OSError as e: + logger.error("Failed to open telemetry log file: %s", e) + self._file = None + + # ── Shutdown ───────────────────────────────────────────────────────── + + def __del__(self) -> None: + """Ensure file handle is closed.""" + if self._file: + try: + self._file.close() + except OSError: + pass + + +# ── WebSocket Subscriber ───────────────────────────────────────────────────── + + +class TelemetrySubscriber: + """Wrapper around a WebSocket connection for telemetry streaming. + + Supports URL-based filtering so clients can subscribe to subsets of events. + """ + + def __init__( + self, + websocket: Any, # websockets.WebSocketServerProtocol or similar + filters: dict[str, str] | None = None, + ): + self.websocket = websocket + self.filters = filters or {} + + def matches(self, event: dict[str, Any]) -> bool: + """Check if an event passes this subscriber's filters.""" + for key, value in self.filters.items(): + if event.get(key) != value and str(event.get(key)) != value: + return False + return True + + async def send(self, data: str) -> None: + """Send data to the WebSocket (best-effort).""" + await self.websocket.send(data) + + +# ── HTTP Handler Helpers ───────────────────────────────────────────────────── + + +def build_telemetry_query_params(path: str) -> dict[str, str]: + """Parse query parameters from a /telemetry or /logs request path.""" + params: dict[str, str] = {} + if "?" not in path: + return params + + query_string = path.split("?", 1)[1] + for part in query_string.split("&"): + if "=" in part: + key, value = part.split("=", 1) + import urllib.parse + params[urllib.parse.unquote(key)] = urllib.parse.unquote(value) + + return params + + +def build_telemetry_response( + bus: TelemetryBus, + path: str, +) -> tuple[int, dict[str, str], bytes]: + """Build an HTTP response for /logs endpoints. + + Returns (status_code, headers, body). + """ + params = build_telemetry_query_params(path) + + if path.rstrip("/") == "/logs/stats": + stats = bus.stats() + return 200, {"Content-Type": "application/json"}, json.dumps(stats).encode() + + if path.rstrip("/") == "/logs/download": + session_id = params.get("session", bus.session_id) + log_path = bus.log_dir / f"{session_id}.jsonl" + if not log_path.exists(): + return 404, {"Content-Type": "text/plain"}, b"Log file not found" + + data = log_path.read_bytes() + return ( + 200, + { + "Content-Type": "application/x-ndjson", + "Content-Disposition": f'attachment; filename="{session_id}.jsonl"', + }, + data, + ) + + # Default: /logs with optional filters + limit = int(params.get("limit", 100)) + tick = params.get("tick") + src = params.get("src") + level = params.get("level") + evt = params.get("evt") + entity_id = params.get("entity_id") + + events = bus.query( + tick=int(tick) if tick else None, + src=src, + level=level, + evt=evt, + entity_id=entity_id, + limit=min(limit, 1000), + ) + + return ( + 200, + {"Content-Type": "application/x-ndjson"}, + "\n".join(json.dumps(e) for e in events).encode(), + ) + + +# ── Telemetry-aware tick packet wrapper ────────────────────────────────────── + + +def wrap_tick_packet( + bus: TelemetryBus, + packet: dict[str, Any], + session_id: str = "unknown", +) -> dict[str, Any]: + """Log key events from a tick packet and return the packet unchanged. + + Call this right after engine.step() to capture intent_emit telemetry + without modifying the engine core. + """ + tick = packet.get("tick") + + # Log entity state changes (guard firings) + for ev in packet.get("events", []): + bus.emit( + tick=tick, + level="INFO" if ev.get("type") not in ("DEATH_NATURAL", "DEATH_STARVE") else "WARN", + src="engine", + evt=f"event_{ev.get('type', 'unknown').lower()}", + entity_id=ev.get("source_id"), + detail={ + "target_id": ev.get("target_id"), + "position": ev.get("position"), + **({k: v for k, v in ev.items() if k not in ("type", "source_id", "target_id", "position")}), + }, + ) + + # Log spawns and removals + for spawn in packet.get("entity_spawns", []): + bus.emit( + tick=tick, level="INFO", src="engine", evt="spawn", + entity_id=spawn["id"], + detail={"type": spawn.get("type"), "species": spawn.get("species")}, + ) + + for eid in packet.get("entity_removals", []): + bus.emit( + tick=tick, level="WARN", src="engine", evt="removal", + entity_id=eid, + ) + + # Log intent summary (drives + eligibility) per entity + for update in packet.get("entity_updates", []): + eid = update["id"] + drive = update.get("drive", {}) + bus.emit( + tick=tick, level="DEBUG", src="engine", evt="intent_emit", + entity_id=eid, + detail={ + "state": update.get("state"), + "ref_position": update.get("ref_position"), + "drive": drive, + "can_consume": update.get("_can_consume", False), + "can_predate": update.get("_can_predate", False), + "can_pollinate": update.get("_can_pollinate", False), + "repro_eligible": update.get("_repro_eligible", False), + "ack": update.get("_ack", False), + }, + ) + + return packet + + +# ── Absorption telemetry wrapper ───────────────────────────────────────────── + + +def log_absorption( + bus: TelemetryBus, + tick: int, + positions: dict[str, list[float]], + events: list[dict[str, Any]], +) -> None: + """Log client absorption details for coherence debugging. + + Call this from the worker after absorb_heartbeat() to capture what + the server received and how it was handled. + """ + # Log position absorptions with divergence info + for eid, pos in positions.items(): + bus.emit( + tick=tick, level="DEBUG", src="worker", evt="position_absorbed", + entity_id=eid, + detail={"reported_position": pos}, + ) + + # Log event absorptions + for ev in events: + etype = ev.get("type", "unknown") + bus.emit( + tick=tick, level="INFO", src="worker", evt=f"client_event_{etype.lower()}", + entity_id=ev.get("source_id"), + detail={ + "target_id": ev.get("target_id"), + "parent_id": ev.get("parent_id"), + "position": ev.get("position"), + }, + ) diff --git a/server/ecosim/traits.py b/server/ecosim/traits.py index 810df6c..8186e7d 100644 --- a/server/ecosim/traits.py +++ b/server/ecosim/traits.py @@ -73,9 +73,9 @@ # cycle (travel + linger ≈ 30 ticks × hunger_rate_floor 0.008 × dt=0.1 = 0.024). # Butterflies need net negative drift per cycle so their average hunger stays # below REPRO_BUILD_MAX_HUNGER (0.5), allowing reproductive drive to build. -# With 0.12 relief and ~0.06 hunger gained between visits, net drift is −0.004/tick, -# keeping butterflies at ~0.3 average hunger — well within the reproduction window. -FLOOR_POLLINATION_RELIEF = 0.12 +# With 0.25 relief and ~0.06 hunger gained between visits, net drift is −0.013/tick, +# keeping butterflies at ~0.2 average hunger — comfortably in the reproduction window. +FLOOR_POLLINATION_RELIEF = 0.25 FLOOR_HEALTH_DRAIN = 0.003 diff --git a/server/ecosim/worker.py b/server/ecosim/worker.py index a8e40d9..455095e 100644 --- a/server/ecosim/worker.py +++ b/server/ecosim/worker.py @@ -46,15 +46,27 @@ from .adapters import create_adapter from .engine import EcosystemEngine +from .telemetry import ( + TelemetryBus, + TelemetrySubscriber, + build_telemetry_response, + log_absorption, + wrap_tick_packet, +) logger = logging.getLogger("lila.worker") # -- Configuration ----------------------------------------------------------- -DEFAULT_TICK_RATE = 0.1 # seconds between ticks (10 Hz) +DEFAULT_TICK_RATE = 2.0 # seconds between ticks (0.5 Hz — intent-based) DEFAULT_HOST = "0.0.0.0" DEFAULT_PORT = 8001 MAX_TICK_DRIFT = 0.05 # max acceptable drift before skipping sleep +HEARTBEAT_INTERVAL = 1.0 # seconds between client heartbeat sends + +# Global telemetry registry — maps session_id → TelemetryBus. +# Allows /logs and /telemetry endpoints to access active sessions. +_telemetry_registry: dict[str, TelemetryBus] = {} # -- Session ----------------------------------------------------------------- @@ -114,6 +126,9 @@ def __init__( self.ticks_completed = 0 self.total_step_time = 0.0 + # Telemetry bus (injected by worker, optional) + self.telemetry: TelemetryBus | None = None + @property def is_running(self) -> bool: return self._running @@ -142,6 +157,35 @@ def rain(self, intensity: float = 0.5) -> None: self.engine.tick, intensity, ) + def absorb_heartbeat(self, msg: dict[str, Any]) -> None: + """Absorb client heartbeat — positions and interaction events. + + Heartbeat messages from the client carry: + - positions: { entity_id: [x, y, z], ... } — for reconciliation + - events: [{ type, source_id, target_id, ... }, ...] — + client-reported interactions (repro, consumption, predation) + + The server absorbs these into its simulation state through the + engine's absorption layer. See EcosystemEngine.absorb_client_positions() + and absorb_client_events() for details. + """ + positions = msg.get("positions", {}) + if positions: + self.engine.absorb_client_positions(positions) + + events = msg.get("events", []) + if events: + self.engine.absorb_client_events(events) + + # Log absorption for coherence debugging + if self.telemetry and (positions or events): + log_absorption( + self.telemetry, + tick=self.engine.tick, + positions=positions, + events=events, + ) + def step(self) -> dict[str, Any]: """Run a single tick and return the packet.""" t0 = time.monotonic() @@ -190,9 +234,16 @@ async def run_tick_loop( if not self._paused: packet = self.step() - packet["session_id"] = self.world_config.get( - "session_id", "unknown" - ) + session_id = self.world_config.get("session_id", "unknown") + packet["session_id"] = session_id + + # Telemetry: log tick events (intent_emit, spawns, removals) + if self.telemetry: + wrap_tick_packet(self.telemetry, packet, session_id) + # Piggyback telemetry events on tick packets for client streaming + recent = self.telemetry.query(limit=50) + if recent: + packet["_telemetry"] = recent try: await send_fn(json.dumps(packet)) @@ -237,6 +288,8 @@ async def run_tick_loop( "resume": lambda session, _msg: session.resume(), "shutdown": lambda session, _msg: session.stop(), "rain": lambda session, msg: session.rain(msg.get("intensity", 0.5)), + # Client agency heartbeat — positions + interaction events + "heartbeat": lambda session, msg: session.absorb_heartbeat(msg), } @@ -287,11 +340,56 @@ async def handle_client_messages( # -- WebSocket server -------------------------------------------------------- +async def handle_telemetry_subscriber(websocket) -> None: + """Handle a /telemetry WebSocket connection — streams real-time events.""" + import urllib.parse + + # Parse filter parameters from the query string + path = getattr(websocket, "path", "/telemetry") + params = {} + if "?" in path: + for part in path.split("?", 1)[1].split("&"): + if "=" in part: + k, v = part.split("=", 1) + params[urllib.parse.unquote(k)] = urllib.parse.unquote(v) + + # Find the most recent active session's bus + target_bus = None + for bus in reversed(_telemetry_registry.values()): + target_bus = bus + break + + if target_bus is None: + await websocket.send(json.dumps({"error": "no active session"})) + await websocket.close() + return + + subscriber = TelemetrySubscriber(websocket, filters=params) + target_bus.add_subscriber(subscriber) + + try: + # Keep the connection alive — client sends nothing, we push events + async for _ in websocket: + pass # client messages are ignored (firehose mode) + except Exception: + pass + finally: + target_bus.remove_subscriber(subscriber) + + async def handle_connection(websocket) -> None: """ - Handle a single WebSocket connection through its full lifecycle: - receive world def → run simulation → clean shutdown. + Dispatch WebSocket connections based on path: + /ws → simulation session + /telemetry → real-time event stream subscriber """ + path = getattr(websocket, "path", "/ws") + + if path == "/telemetry": + await handle_telemetry_subscriber(websocket) + return + + # Default: simulation session on /ws remote = getattr(websocket, "remote_address", ("unknown", 0)) logger.info("Client connected: %s", remote) @@ -325,17 +423,34 @@ async def handle_connection(websocket) -> None: len(world_config.get("entities", [])), ) - # Step 2: Send acknowledgement + # Step 2: Initialize session (before ack so we can include species defs) + session = SimulationSession(world_config) + + # Telemetry bus for this session + telemetry = TelemetryBus(session_id=session_id) + telemetry.start() + _telemetry_registry[session_id] = telemetry + session.telemetry = telemetry + + telemetry.info( + tick=0, src="worker", evt="session_init", + detail={"entity_count": len(world_config.get("entities", []))}, + ) + + # Build species definitions for client-side agency + species_defs = session.engine.get_species_definitions() + + # Step 3: Send acknowledgement with species reference ack = json.dumps({ "type": "session_started", "session_id": session_id, "tick_rate": DEFAULT_TICK_RATE, "entity_count": len(world_config.get("entities", [])), + "species": species_defs, # lightweight species reference for client }) await websocket.send(ack) - # Step 3: Initialize session and run - session = SimulationSession(world_config) + # Step 4: Run tick loop and client listener as concurrent tasks # Run tick loop and client listener as concurrent tasks tick_task = asyncio.create_task( @@ -368,6 +483,15 @@ async def handle_connection(websocket) -> None: logger.info("Session %s ended: %d ticks", session_id, session.ticks_completed) + # Shut down telemetry bus (flushes remaining events to disk) + if telemetry: + telemetry.warn( + tick=session.engine.tick, src="worker", evt="session_end", + detail={"ticks_completed": session.ticks_completed}, + ) + await telemetry.stop() + _telemetry_registry.pop(session_id, None) + async def start_server( host: str = DEFAULT_HOST, @@ -441,6 +565,18 @@ async def start_server( else: logger.warning("Visualizer not found — HTTP will return 404") + # MIME types for static file serving + _mime = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + } + world_json = None if world_path: world_json = world_path.read_bytes() @@ -454,6 +590,26 @@ async def process_request(connection, request): if request.path == "/ws": return None # Proceed with WebSocket upgrade + # Telemetry WS endpoint — streams real-time events + if request.path == "/telemetry": + return None # Proceed with WebSocket upgrade (handled separately) + + # /logs HTTP API — query recent telemetry events + if request.path.startswith("/logs"): + target_bus = None + for bus in reversed(_telemetry_registry.values()): + target_bus = bus + break + if target_bus is None: + return Response( + 404, "Not Found", + Headers({"Content-Type": "text/plain"}), + b"No active telemetry session", + ) + status, headers_dict, body = build_telemetry_response(target_bus, request.path) + resp_headers = Headers(headers_dict) + return Response(status, "OK" if status == 200 else "Not Found", resp_headers, body) + if request.path in ("/", "/index.html"): if viz_html: return Response( @@ -472,11 +628,35 @@ async def process_request(connection, request): ) return Response(404, "Not Found", Headers(), b"World definition not found") + # Serve arbitrary static files from viz directory (css/, js/, etc.) + if viz_path: + import urllib.parse + safe_path = urllib.parse.unquote(request.path.lstrip("/")) + file_path = (viz_path / safe_path).resolve() + # Ensure the resolved path is still under viz_path (no directory traversal) + try: + file_path.relative_to(viz_path.resolve()) + except ValueError: + return Response(403, "Forbidden", Headers(), b"Access denied") + + if file_path.is_file(): + ext = file_path.suffix.lower() + content_type = _mime.get(ext, "application/octet-stream") + try: + data = file_path.read_bytes() + return Response( + 200, "OK", + Headers({"Content-Type": content_type}), + data, + ) + except OSError as e: + logger.warning("Failed to read %s: %s", safe_path, e) + return Response(404, "Not Found", Headers(), b"Not found") logger.info( - "Starting worker on http://%s:%d (viz) + ws://%s:%d/ws (simulation)", - host, port, host, port, + "Starting worker on http://%s:%d (viz+logs) + ws://%s:%d/ws (sim) + ws://%s:%d/telemetry", + host, port, host, port, host, port, ) # Handle graceful shutdown via SIGTERM/SIGINT diff --git a/server/examples/demo_world.json b/server/examples/demo_world.json index 04abebf..b9a2fb2 100755 --- a/server/examples/demo_world.json +++ b/server/examples/demo_world.json @@ -683,7 +683,7 @@ ], "trophic_level": 2.0, "reproductive_strategy": "r_selected", - "clutch_size": 2, + "clutch_size": 3, "generation_time_ticks": 2000, "thermal_range": [ 10, diff --git a/server/examples/species_definitions.json b/server/examples/species_definitions.json index 77fc34a..56c2301 100644 --- a/server/examples/species_definitions.json +++ b/server/examples/species_definitions.json @@ -35,7 +35,7 @@ "diet_breadth": ["forb:fruiting"], "trophic_level": 2.0, "reproductive_strategy": "r_selected", - "clutch_size": 2, + "clutch_size": 3, "generation_time_ticks": 2000, "thermal_range": [10, 35], "drought_tolerance": 0.1, diff --git a/server/tests/client_harness.py b/server/tests/client_harness.py new file mode 100644 index 0000000..4a16429 --- /dev/null +++ b/server/tests/client_harness.py @@ -0,0 +1,515 @@ +#!/usr/bin/env python3 +""" +līlā — Headless Client Test Harness + +Simulates a full browser client: connects via WebSocket, sends world def, +receives intent-based tick packets, runs synthetic agency logic, sends +heartbeats with positions/events, and monitors server reconciliation. + +Usage: + uv run python tests/test_client_harness.py [--host localhost] [--port 8001] +""" + +from __future__ import annotations + +import asyncio +import json +import sys +import time +from pathlib import Path + +try: + import websockets +except ImportError: + print("ERROR: install websockets (uv sync)") + sys.exit(1) + + +# ─── Minimal world definition for testing ───────────── + +def build_test_world() -> dict: + return { + "version": "0.1", + "session_id": "test-harness-001", + "environment": { + "type": "MEADOW", + "biome": "TEMPERATE", + "climate": { + "temperature": 22, + "humidity": 0.6, + "rainfall": 0.4, + "wind_speed": 0.15, + "light_level": 0.85, + }, + "soil": { + "nitrogen": 0.7, + "phosphorus": 0.6, + "potassium": 0.5, + "moisture": 0.65, + "organic_matter": 0.4, + "ph": 6.8, + }, + "voxel_grid": {"dimensions": [32, 32, 32], "cell_size": 1.0}, + }, + "model": {"adapter": "mlp", "seed": 42}, + # Species definitions (trait vectors — list format) + "species_definitions": [ + { + "species_id": "deer", + "functional_group": "herbivore", + "entity_class": "ANIMAL", + "body_mass_kg": 60, + "locomotion": "quadruped", + "skeleton_id": "quadruped_medium", + "thermoregulation": "endotherm", + "diet_type": "herbivore", + "trophic_level": 2.0, + "reproductive_strategy": "K_selected", + "clutch_size": 1, + "generation_time_ticks": 800, + "thermal_range": [0, 40], + "drought_tolerance": 0.3, + "shade_tolerance": 0.3, + "sensory_range_multiplier": 1.0, + "movement_budget": 0.4, + "resource_tags": ["consumer"], + }, + { + "species_id": "meadow_grass", + "functional_group": "producer", + "entity_class": "PLANT", + "body_mass_kg": 0.1, + "locomotion": "stationary", + "diet_type": "autotroph", + "trophic_level": 1.0, + "reproductive_strategy": "r_selected", + "clutch_size": 5, + "generation_time_ticks": 400, + "thermal_range": [0, 40], + "drought_tolerance": 0.6, + "shade_tolerance": 0.8, + "sensory_range_multiplier": 0.0, + "movement_budget": 0.0, + "spread_mode": "rhizome", + "spread_range": 1.5, + "spread_chance": 0.3, + "root_persistence": True, + "resource_tags": ["forage", "graminoid"], + }, + ], + # Entities to spawn + "entities": [ + {"id": "deer_01", "type": "ANIMAL", "species": "deer", + "sex": "female", "position": [16.0, 0.0, 14.0], + "metadata": {}, "skeleton_id": "quadruped_medium"}, + {"id": "grass_01", "type": "PLANT", "species": "meadow_grass", + "position": [15.0, 0.0, 13.0], "metadata": {}}, + {"id": "grass_02", "type": "PLANT", "species": "meadow_grass", + "position": [18.0, 0.0, 16.0], "metadata": {}}, + ], + } + + +# ─── Test Results Tracker ───────────────────────────── + +class TestResults: + def __init__(self): + self.passed = 0 + self.failed = 0 + self.errors: list[str] = [] + + def check(self, name: str, condition: bool, detail: str = "") -> None: + if condition: + self.passed += 1 + print(f" ✓ {name}") + else: + self.failed += 1 + msg = f" ✗ {name}" + (f" — {detail}" if detail else "") + print(msg) + self.errors.append(f"{name}: {detail}") + + def summary(self): + total = self.passed + self.failed + status = "PASS" if self.failed == 0 else f"FAIL ({self.failed}/{total})" + print(f"\n{'='*60}") + print(f"Results: {status} — {self.passed}/{total} checks passed") + if self.errors: + for e in self.errors: + print(f" ❌ {e}") + return self.failed == 0 + + +# ─── Headless Client Simulator ──────────────────────── + +class HeadlessClient: + """Simulates a browser client with agency logic.""" + + def __init__(self, host: str = "localhost", port: int = 8001): + self.host = host + self.port = port + self.ws = None + self.world_model: dict[str, dict] = {} # id → entity state + self.species_defs: dict = {} + self.tick_count = 0 + self.events_received: list[dict] = [] + + async def connect(self): + uri = f"ws://{self.host}:{self.port}/ws" + print(f"\nConnecting to {uri} ...") + self.ws = await websockets.connect(uri, max_size=2**20) + print("Connected ✓") + + async def send_world_def(self): + world = build_test_world() + await self.ws.send(json.dumps(world)) + print(f"Sent world definition ({len(world['entities'])} entities)") + + async def receive_session_started(self, timeout: float = 5.0) -> dict: + raw = await asyncio.wait_for(self.ws.recv(), timeout=timeout) + data = json.loads(raw) + assert data.get("type") == "session_started", f"Expected session_started, got {data.get('type')}" + print(f"Session started ✓ (tick_rate={data.get('tick_rate')}, entities={data.get('entity_count')})") + + # Store species definitions + self.species_defs = data.get("species", {}) + if self.species_defs: + print(f" Species defs received: {list(self.species_defs.keys())}") + return data + + async def receive_tick(self, timeout: float = 10.0) -> dict | None: + """Receive one tick packet. Returns None on timeout.""" + try: + raw = await asyncio.wait_for(self.ws.recv(), timeout=timeout) + data = json.loads(raw) + self.tick_count += 1 + + # Process entity updates into local world model + for u in data.get("entity_updates", []): + eid = u["id"] + if eid not in self.world_model: + self.world_model[eid] = {} + self.world_model[eid].update(u) + + # Track events + self.events_received.extend(data.get("events", [])) + + return data + except asyncio.TimeoutError: + return None + + async def send_heartbeat(self, positions: dict[str, list], events: list[dict] | None = None): + """Send client heartbeat with positions and optional events.""" + msg = {"type": "heartbeat", "positions": positions} + if events: + msg["events"] = events + await self.ws.send(json.dumps(msg)) + + async def close(self): + if self.ws: + # Send shutdown to stop the server session cleanly + try: + await self.ws.send(json.dumps({"type": "shutdown"})) + except Exception: + pass + await asyncio.sleep(0.2) + await self.ws.close() + + +# ─── Test Scenarios ─────────────────────────────────── + +async def test_session_init(client: HeadlessClient, results: TestResults): + """Test 1: Session initialization and species definitions.""" + print("\n--- Test 1: Session Init ---") + + await client.send_world_def() + session = await client.receive_session_started() + + results.check("session_started type", session.get("type") == "session_started") + results.check("tick_rate is 2.0 (0.5Hz)", session.get("tick_rate") == 2.0) + results.check("entity_count matches", session.get("entity_count") == 3) + + # Species definitions should be present and useful + species = session.get("species", {}) + results.check("species defs not empty", len(species) > 0, f"got {len(species)}") + + if "deer" in species: + deer_def = species["deer"] + results.check("deer has type=ANIMAL", deer_def.get("type") == "ANIMAL") + results.check("deer has diet_order", isinstance(deer_def.get("diet_order"), list)) + results.check("deer has flee_targets", isinstance(deer_def.get("flee_targets"), list)) + + +async def test_intent_packet_format(client: HeadlessClient, results: TestResults): + """Test 2: Tick packets contain intent fields, not authoritative positions.""" + print("\n--- Test 2: Intent Packet Format ---") + + # Receive a few ticks + for i in range(3): + packet = await client.receive_tick(timeout=8.0) + if packet is None: + results.check(f"tick {i+1} received", False, "timeout waiting for tick") + continue + + results.check(f"tick {i+1} has entity_updates", len(packet.get("entity_updates", [])) > 0) + + # Check intent fields on first entity update + updates = packet.get("entity_updates", []) + if not updates: + continue + + ent = updates[0] + results.check(f"tick {i+1} has state field", "state" in ent, f"keys: {list(ent.keys())}") + results.check(f"tick {i+1} has drive field", "drive" in ent) + results.check(f"tick {i+1} has motion_latent", "motion_latent" in ent) + results.check(f"tick {i+1} has ref_position", "ref_position" in ent) + + # Check that entity updates don't contain authoritative position commands + if client.world_model: + first_ent = next(iter(client.world_model.values())) + # Should NOT have a 'position' key (that's the old format) + results.check("no authoritative 'position' field", "position" not in first_ent, + f"keys include position: {'position' in first_ent}") + + +async def test_heartbeat_reconciliation(client: HeadlessClient, results: TestResults): + """Test 3: Server absorbs client positions and reconciles.""" + print("\n--- Test 3: Heartbeat Reconciliation ---") + + # Wait for a tick to establish baseline + packet = await client.receive_tick(timeout=8.0) + if not packet or not packet.get("entity_updates"): + results.check("baseline tick received", False, "no updates in tick") + return + + # Find the deer entity and its ref_position + deer_update = None + for u in packet["entity_updates"]: + if u["id"] == "deer_01": + deer_update = u + break + + if not deer_update: + results.check("deer found in updates", False, f"entities: {[u['id'] for u in packet['entity_updates']]}") + return + + ref_pos = deer_update.get("ref_position", [0, 0, 0]) + print(f" Deer ref_position: {ref_pos}") + + # Send heartbeat with a slightly different position (within bounds) + nudge_x = ref_pos[0] + 1.5 # small deviation — should be soft-nudged + nudge_z = ref_pos[2] + 1.0 + await client.send_heartbeat({ + "deer_01": [nudge_x, 0, nudge_z], + }) + print(f" Sent heartbeat: deer at [{nudge_x}, 0, {nudge_z}]") + + # Receive next tick — server should have absorbed the position + packet2 = await client.receive_tick(timeout=8.0) + if not packet2: + results.check("post-heartbeat tick received", False, "timeout") + return + + deer_update2 = None + for u in packet2.get("entity_updates", []): + if u["id"] == "deer_01": + deer_update2 = u + break + + if not deer_update2: + results.check("deer in post-heartbeat tick", False) + return + + new_ref = deer_update2.get("ref_position", ref_pos) + print(f" Deer new ref_position: {new_ref}") + + # Server should have absorbed the position (within tolerance) + dx = abs(new_ref[0] - nudge_x) + dz = abs(new_ref[2] - nudge_z) + results.check("server absorbed x position", dx < 3.0, f"diff={dx:.2f}") + results.check("server absorbed z position", dz < 3.0, f"diff={dz:.2f}") + + +async def test_client_events(client: HeadlessClient, results: TestResults): + """Test 4: Server absorbs client-reported interaction events.""" + print("\n--- Test 4: Client Events ---") + + # Wait for a tick to get current state + packet = await client.receive_tick(timeout=8.0) + if not packet or not packet.get("entity_updates"): + results.check("tick received", False, "no updates") + return + + # Send a consumption event (deer eats grass) + events = [{ + "type": "consumption", + "source_id": "deer_01", + "target_id": "grass_01", + "position": [15, 0, 13], + }] + + # Also send positions + positions = {} + for u in packet["entity_updates"]: + if u.get("ref_position"): + positions[u["id"]] = u["ref_position"] + + await client.send_heartbeat(positions, events) + print(f" Sent consumption event: deer_01 → grass_01") + + # Receive next tick — check for server acknowledgment of the event + packet2 = await client.receive_tick(timeout=8.0) + if not packet2: + results.check("post-event tick received", False, "timeout") + return + + # Server may echo events or apply them silently + # At minimum, it shouldn't crash + results.check("server survived event absorption", True) + + # Check that grass_01 still exists (or was removed if consumed) + grass_updates = [u for u in packet2.get("entity_updates", []) if u["id"] == "grass_01"] + removals = packet2.get("entity_removals", []) + results.check( + "grass handled after consumption", + len(grass_updates) > 0 or "grass_01" in removals, + f"updates={len(grass_updates)}, removed={'grass_01' in removals}", + ) + + +async def test_divergence_snap(client: HeadlessClient, results: TestResults): + """Test 5: Large divergence triggers server snap + _ack.""" + print("\n--- Test 5: Divergence Snap ---") + + # Wait for a tick + packet = await client.receive_tick(timeout=8.0) + if not packet or not packet.get("entity_updates"): + results.check("tick received", False, "no updates") + return + + deer_update = None + for u in packet["entity_updates"]: + if u["id"] == "deer_01": + deer_update = u + break + + if not deer_update: + results.check("deer found", False) + return + + ref_pos = deer_update.get("ref_position", [0, 0, 0]) + + # Send position WAY off (beyond expected travel distance) + far_x = ref_pos[0] + 20.0 # way beyond speed * tick_rate + await client.send_heartbeat({ + "deer_01": [far_x, 0, ref_pos[2]], + }) + print(f" Sent divergent position: [{far_x}, 0, {ref_pos[2]}] (delta=20)") + + # Receive next tick — server should snap and send _ack + packet2 = await client.receive_tick(timeout=8.0) + if not packet2: + results.check("post-divergence tick received", False, "timeout") + return + + deer_update2 = None + for u in packet2.get("entity_updates", []): + if u["id"] == "deer_01": + deer_update2 = u + break + + if not deer_update2: + results.check("deer in post-divergence tick", False) + return + + # Check for _ack flag + has_ack = deer_update2.get("_ack", False) + results.check("server sent _ack on divergence", has_ack, f"_ack={has_ack}") + + new_ref = deer_update2.get("ref_position", ref_pos) + dx = abs(new_ref[0] - far_x) + if has_ack: + # With ack, server should have snapped close to client position + results.check("server snapped to divergent pos", dx < 3.0, f"diff={dx:.2f}") + + +async def test_entity_lifecycle(client: HeadlessClient, results: TestResults): + """Test 6: Entities are tracked correctly over multiple ticks.""" + print("\n--- Test 6: Entity Lifecycle ---") + + # Receive several ticks and track entity presence + entities_seen = set() + for i in range(5): + packet = await client.receive_tick(timeout=8.0) + if not packet: + results.check(f"tick {i+1} received", False, "timeout") + continue + + for u in packet.get("entity_updates", []): + entities_seen.add(u["id"]) + + # All 3 initial entities should have been seen + expected = {"deer_01", "grass_01", "grass_02"} + results.check("all entities seen across ticks", expected.issubset(entities_seen), + f"expected={expected}, seen={entities_seen}") + + # Entity count should be stable (no phantom spawns/removals) + removals = set() + for i in range(3): + packet = await client.receive_tick(timeout=8.0) + if not packet: + continue + removals.update(packet.get("entity_removals", [])) + + # At least one entity should still be alive after a few ticks + results.check("entities persist across ticks", len(entities_seen - removals) > 0, + f"alive={len(entities_seen - removals)}") + + +# ─── Main ───────────────────────────────────────────── + +async def run_tests(host: str = "localhost", port: int = 8001): + print("=" * 60) + print("līlā — Headless Client Test Harness") + print(f"Target: ws://{host}:{port}/ws") + print("=" * 60) + + results = TestResults() + client = HeadlessClient(host, port) + + try: + await client.connect() + + # Run test scenarios in order (they share the same connection) + await test_session_init(client, results) + await test_intent_packet_format(client, results) + await test_heartbeat_reconciliation(client, results) + await test_client_events(client, results) + await test_divergence_snap(client, results) + await test_entity_lifecycle(client, results) + + except Exception as e: + print(f"\n❌ FATAL ERROR: {e}") + import traceback + traceback.print_exc() + finally: + try: + await client.close() + except Exception: + pass + + ok = results.summary() + return 0 if ok else 1 + + +def main(): + import argparse + parser = argparse.ArgumentParser(description="līlā Headless Client Test Harness") + parser.add_argument("--host", default="localhost") + parser.add_argument("--port", type=int, default=8001) + args = parser.parse_args() + + return asyncio.run(run_tests(args.host, args.port)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server/tests/smoke_test.py b/server/tests/smoke_test.py index 4ce4608..959664e 100755 --- a/server/tests/smoke_test.py +++ b/server/tests/smoke_test.py @@ -288,8 +288,8 @@ def main(): print(f"\n--- Tick {packet['tick']} ---") for u in packet["entity_updates"]: state = u["state"] - pos = u["position"] - key_vars = {k: v for k, v in u["state_vars"].items() + pos = u.get("ref_position", u.get("position", [0, 0, 0])) + key_vars = {k: v for k, v in u.get("drive", u.get("state_vars", {})).items() if k in ("hunger", "energy", "hydration", "growth", "health", "colony_health", "population")} ml = u.get("motion_latent") diff --git a/server/tests/test_reproduction_actor.py b/server/tests/test_reproduction_actor.py index 425c95b..212dfa8 100644 --- a/server/tests/test_reproduction_actor.py +++ b/server/tests/test_reproduction_actor.py @@ -428,8 +428,8 @@ def test_insect_reproduction_includes_colony_health_cost(self): deltas = [e for e in effects if isinstance(e, StateVarDelta)] colony_deltas = [d for d in deltas if d.var_name == "colony_health"] self.assertEqual(len(colony_deltas), 1) - # Colony cost = parent_energy_cost * 0.3 = 0.2 * 0.3 = 0.06 - self.assertAlmostEqual(colony_deltas[0].delta, -0.06, places=4) + # Colony cost = parent_energy_cost * 0.1 = 0.2 * 0.1 = 0.02 + self.assertAlmostEqual(colony_deltas[0].delta, -0.02, places=4) def test_insect_child_inherits_colony_health(self): """INSECT child gets colony_health with floor."""