diff --git a/client/browser/js/agency.js b/client/browser/js/agency.js index 0a80e7c..ce0a321 100644 --- a/client/browser/js/agency.js +++ b/client/browser/js/agency.js @@ -19,6 +19,11 @@ import { hasReconcileTarget, getReconcileTarget, advanceReconcile } from './reco // Each entity also has _syncSpeed (0.4..1.0) that modulates this. const GRAVITY_WELL_FACTOR = 0.05; +// Flee direction cache: how long (ms) to keep running in the last +// known safe direction when no threat is found. Mirrors server +// behavior: FleeActor computes escape once, guard exits on arrival. +const FLEE_DIR_TIMEOUT_MS = 3000; + /** * Run one frame of local agency for all mobile entities. * Called every render frame (~60 Hz). @@ -204,6 +209,8 @@ function evaluateFleeing(ent, world, speciesDef) { const fleeTargets = speciesDef?.flee_targets || []; if (fleeTargets.length === 0) return evaluateWandering(ent, world); + const now = performance.now(); + // Find nearest threat let nearestThreat = null; let bestDistSq = Infinity; @@ -219,18 +226,29 @@ function evaluateFleeing(ent, world, speciesDef) { } if (nearestThreat && bestDistSq < 400) { // ~20 world units sensory range² - // Flee away from threat + // Threat confirmed — cache the flee direction const dx = ent.x - nearestThreat.x; const dz = ent.z - nearestThreat.z; const dist = Math.sqrt(dx * dx + dz * dz) || 1; + ent._fleeDirX = dx / dist; + ent._fleeDirZ = dz / dist; + ent._fleeDirExpiry = now + FLEE_DIR_TIMEOUT_MS; + } + + // Use cached flee direction as fallback. + // The server computes the escape target once (FleeActor fires on entry only) + // and holds it until arrival. Mirror this: if the threat is not found this + // frame (client/server divergence, stale positions, etc.), keep running in + // the last known safe direction for a short window. + if (ent._fleeDirX != null && now < ent._fleeDirExpiry) { return { type: 'flee', - targetX: clamp(ent.x + (dx / dist) * 8, GRID_SIZE), - targetZ: clamp(ent.z + (dz / dist) * 8, GRID_SIZE), + targetX: clamp(ent.x + ent._fleeDirX * 8, GRID_SIZE), + targetZ: clamp(ent.z + ent._fleeDirZ * 8, GRID_SIZE), }; } - // No threat nearby — fall through to wander + // No threat and no cached direction — fall through to wander return evaluateWandering(ent, world); } diff --git a/client/godot/scenes/main.gd b/client/godot/scenes/main.gd index ed01764..798374d 100644 --- a/client/godot/scenes/main.gd +++ b/client/godot/scenes/main.gd @@ -9,7 +9,7 @@ extends Node3D -@onready var camera: Camera3D = $Camera +@onready var camera = $Camera @onready var renderer: Node = $Renderer @onready var ground_mi: MultiMeshInstance3D = $Ground @onready var entity_parent: Node3D = $Entities @@ -18,6 +18,7 @@ extends Node3D @onready var stats_label: Label = $HUD/VBox/StatsLabel @onready var event_log: RichTextLabel = $HUD/VBox/EventLog @onready var rain_button: Button = $HUD/VBox/RainButton +@onready var legend_label: Label = $HUD/LegendLabel var _agency: Agency = Agency.new() var _heartbeat: HeartbeatSender = HeartbeatSender.new() @@ -32,6 +33,11 @@ var _fps: int = 0 var _frame_count: int = 0 var _fps_timer: float = 0.0 +# Selection state +var _selected_entity = null # World.WorldEntity (inner class, no global type) +var _mouse_down_pos: Vector2 = Vector2.ZERO +var _mouse_down_time: float = 0.0 + # Renderer state var _type_meshes: Dictionary = {} var _ground_mat: ShaderMaterial = null @@ -58,6 +64,9 @@ func _ready() -> void: ) camera._update_position() + # Clear selection if selected entity dies + World.entity_removed.connect(_on_entity_removed) + # ── Renderer setup ──────────────────────────────────────────────────── @@ -138,6 +147,9 @@ func _process(delta: float) -> void: _build_entities() _update_particle_mesh() + # Update selection visuals every frame + _update_selection_billboard() + ## Update ground voxel colors (MultiMesh was built once in setup). func _build_ground() -> void: @@ -149,14 +161,31 @@ func _build_ground() -> void: ## Update all per-type InstancedMesh entities. func _build_entities() -> void: var entities: Array = World.get_alive() - renderer.update_entities(_type_meshes, entities) + var sel_id: String = _selected_entity.id if _selected_entity else "" + renderer.update_entities(_type_meshes, entities, sel_id) # ── Input ───────────────────────────────────────────────────────────── func _input(event: InputEvent) -> void: + # ── Mouse click detection (click vs drag) ────────────────────── + if event is InputEventMouseButton: + if event.button_index == MOUSE_BUTTON_LEFT: + if event.pressed: + _mouse_down_pos = event.position + _mouse_down_time = Time.get_ticks_msec() / 1000.0 + elif not event.pressed: + # Check if this was a click (not a drag) + var elapsed: float = (Time.get_ticks_msec() / 1000.0) - _mouse_down_time + var distance: float = _mouse_down_pos.distance_to(event.position) + if elapsed < 0.35 and distance < 12.0: + _select_entity_at_click() + + # ── Keyboard ──────────────────────────────────────────────────── if event is InputEventKey and event.pressed: - if event.keycode == KEY_R: + if event.keycode == KEY_ESCAPE: + _deselect_entity() + elif event.keycode == KEY_R: WS.send_control("rain", {"intensity": 0.8}) _add_hud_event("☔ Rain triggered!") elif event.keycode == KEY_SPACE: @@ -271,3 +300,212 @@ func _log_entity_telemetry() -> void: ent.reconcile_queue.size(), ] LilaConstants.log(log_line) + + +# ── Entity Selection ───────────────────────────────────────────────── + +## Build a camera ray from a viewport (screen) position. +func _make_camera_ray(viewport_pos: Vector2) -> Dictionary: + var cam = get_viewport().get_camera_3d() + if cam == null: + return {} + return { + "origin": cam.project_ray_origin(viewport_pos), + "dir": cam.project_ray_normal(viewport_pos), + } + +## Closest distance from a point to an infinite ray (origin → dir). +func _point_to_ray_dist(point: Vector3, ray_origin: Vector3, ray_dir: Vector3) -> float: + var v: Vector3 = point - ray_origin + # Project v onto ray_dir; clamp t >= 0 so we only check forward. + var t: float = v.dot(ray_dir) + if t < 0.0: + return point.distance_to(ray_origin) + var closest: Vector3 = ray_origin + ray_dir * t + return point.distance_to(closest) + +## Compute the entity's 3D world position (accounts for flight altitude). +func _entity_world_pos(ent) -> Vector3: + var y: float = 0.5 # ground surface + match ent.type: + "INSECT": + y = 1.75 + "BIRD": + y = 4.0 + return Vector3(ent.x, y, ent.z) + +## Find the nearest alive entity by ray-to-point distance in 3D space. +## This works for flying entities (birds/insects) as well as ground entities. +func _find_nearest_entity(ray_origin: Vector3, ray_dir: Vector3): + var best = null + var best_dist: float = INF + for ent in World.get_alive(): + var epos: Vector3 = _entity_world_pos(ent) + var dist: float = _point_to_ray_dist(epos, ray_origin, ray_dir) + var hit_radius: float = _get_hit_radius(ent) + if dist < hit_radius and dist < best_dist: + best_dist = dist + best = ent + return best + +## Per-type selection hitbox radius (generous for small/flying entities). +func _get_hit_radius(ent) -> float: + match ent.type: + "TREE": + return 2.5 + "ANIMAL": + return 2.0 + "BIRD": + return 4.0 + "INSECT": + return 4.0 + "PLANT": + return 2.0 + "MICROORGANISM": + return 3.0 + return 3.0 + +## Handle a click-to-select event. +func _select_entity_at_click() -> void: + var ray: Dictionary = _make_camera_ray(_mouse_down_pos) + if ray.is_empty(): + LilaConstants.log("[select] no camera") + return + var ent = _find_nearest_entity(ray["origin"], ray["dir"]) + if ent != null: + _selected_entity = ent + var epos: Vector3 = _entity_world_pos(ent) + var dist: float = _point_to_ray_dist(epos, ray["origin"], ray["dir"]) + _add_hud_event("🔍 %s · %s" % [ent.species.capitalize(), ent.state]) + LilaConstants.log("[select] %s (%s) at (%.1f,%.1f,%.1f) ray_dist=%.2f" % [ + ent.id, ent.type, epos.x, epos.y, epos.z, dist]) + else: + LilaConstants.log("[select] no entity near click ray, %d alive" % World.get_entity_count()) + _deselect_entity() + +## Clear selection. +func _deselect_entity() -> void: + _selected_entity = null + legend_label.visible = false + +## Called when an entity is removed from the world. +func _on_entity_removed(entity_id: String) -> void: + if _selected_entity and _selected_entity.id == entity_id: + _selected_entity = null + legend_label.visible = false + +## Update the selection stats billboard on the HUD. +func _update_selection_billboard() -> void: + var selection_label: Label = $HUD/SelectionLabel + if selection_label == null: + return + + if _selected_entity: + selection_label.visible = true + var ent = _selected_entity + var type_emoji: String = _get_type_emoji(ent.type) + selection_label.text = "%s %s · %s\n%s" % [ + type_emoji, + ent.species.capitalize(), + ent.state, + _format_drives(ent) + ] + legend_label.visible = true + legend_label.text = _build_legend(ent.type) + else: + selection_label.visible = false + legend_label.visible = false + +## Return an emoji for the entity type. +func _get_type_emoji(etype: String) -> String: + match etype: + "ANIMAL": + return "🦌" + "BIRD": + return "🐦" + "INSECT": + return "🦋" + "PLANT": + return "🌿" + "TREE": + return "🌳" + "MICROORGANISM": + return "🍄" + return "❓" + +## Format drive/state variables as a compact stats line. +func _format_drives(ent) -> String: + var sv: Dictionary = ent.drive + var parts: Array[String] = [] + + match ent.type: + "ANIMAL", "BIRD": + if sv.has("hunger"): + parts.append("🍖%.0f" % (sv["hunger"] * 100.0)) + if sv.has("energy"): + parts.append("⚡%.0f" % (sv["energy"] * 100.0)) + if sv.has("hydration"): + parts.append("💧%.0f" % (sv["hydration"] * 100.0)) + if sv.has("health"): + parts.append("❤️%.0f" % (sv["health"] * 100.0)) + if sv.has("reproductive_drive") and sv["reproductive_drive"] > 0.0: + parts.append("💕%.0f" % (sv["reproductive_drive"] * 100.0)) + if sv.has("age"): + parts.append("⏳%.1f" % sv["age"]) + "PLANT", "TREE": + if sv.has("hydration"): + parts.append("💧%.0f" % (sv["hydration"] * 100.0)) + if sv.has("growth"): + parts.append("🌱%.0f" % (sv["growth"] * 100.0)) + if sv.has("nutrient_store"): + parts.append("🧪%.0f" % (sv["nutrient_store"] * 100.0)) + if sv.has("health"): + parts.append("❤️%.0f" % (sv["health"] * 100.0)) + if sv.has("age"): + parts.append("⏳%.1f" % sv["age"]) + "INSECT": + if sv.has("hunger"): + parts.append("🍖%.0f" % (sv["hunger"] * 100.0)) + if sv.has("energy"): + parts.append("⚡%.0f" % (sv["energy"] * 100.0)) + if sv.has("colony_health"): + parts.append("🐝%.0f" % (sv["colony_health"] * 100.0)) + if sv.has("reproductive_drive") and sv["reproductive_drive"] > 0.0: + parts.append("💕%.0f" % (sv["reproductive_drive"] * 100.0)) + "MICROORGANISM": + if sv.has("population"): + parts.append("🧫%.0f" % (sv["population"] * 100.0)) + if sv.has("activity"): + parts.append("🔬%.0f" % (sv["activity"] * 100.0)) + + return " ".join(parts) + +## Build a legend explaining the stat icons for the given entity type. +func _build_legend(etype: String) -> String: + var lines: Array[String] = [] + lines.append("─ Stats ─") + match etype: + "ANIMAL", "BIRD": + lines.append("🍖 Hunger (0–100)") + lines.append("⚡ Energy (0–100)") + lines.append("💧 Hydration (0–100)") + lines.append("❤️ Health (0–100)") + lines.append("💕 Repro drive (0–100)") + lines.append("⏳ Age (ticks)") + "PLANT", "TREE": + lines.append("💧 Hydration (0–100)") + lines.append("🌱 Growth (0–100)") + lines.append("🧪 Nutrients (0–100)") + lines.append("❤️ Health (0–100)") + lines.append("⏳ Age (ticks)") + "INSECT": + lines.append("🍖 Hunger (0–100)") + lines.append("⚡ Energy (0–100)") + lines.append("🐝 Colony health (0–100)") + lines.append("💕 Repro drive (0–100)") + "MICROORGANISM": + lines.append("🧫 Population (0–100)") + lines.append("🔬 Activity (0–100)") + _: + lines.append("(no stats)") + return "\n".join(lines) diff --git a/client/godot/scenes/main.tscn b/client/godot/scenes/main.tscn index 72a6528..167b92a 100644 --- a/client/godot/scenes/main.tscn +++ b/client/godot/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=4 format=3 uid="uid://lila_main"] +[gd_scene load_steps=5 format=3 uid="uid://lila_main"] [ext_resource type="Script" path="res://scenes/main.gd" id="1"] [ext_resource type="Script" path="res://scripts/camera/orbit_camera.gd" id="2"] @@ -81,7 +81,43 @@ theme_override_font_sizes/font_size = 12 layout_mode = 1 text = "☔ Rain" +[node name="SelectionLabel" type="Label" parent="HUD"] +visible = false +layout_mode = 3 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.0 +anchor_right = 0.5 +anchor_bottom = 0.1 +offset_left = -160.0 +offset_top = 10.0 +offset_right = 160.0 +offset_bottom = 60.0 +grow_horizontal = 2 +grow_vertical = 2 +text = "Click an entity to select" +horizontal_alignment = 1 +vertical_alignment = 1 +theme_override_font_sizes/font_size = 14 + [node name="HelpLabel" type="Label" parent="HUD/VBox"] layout_mode = 1 -text = "LMB: orbit | RMB: pan | Scroll: zoom | R: rain | Space: pause" +text = "LMB: orbit/select | RMB: pan | Esc: deselect | R: rain" theme_override_font_sizes/font_size = 10 + +[node name="LegendLabel" type="Label" parent="HUD"] +visible = false +layout_mode = 3 +anchors_preset = 2 +anchor_left = 0.75 +anchor_top = 0.0 +anchor_right = 1.0 +anchor_bottom = 0.15 +offset_left = 10.0 +offset_top = 10.0 +offset_right = -10.0 +offset_bottom = 80.0 +grow_horizontal = 1 +grow_vertical = 1 +text = "" +theme_override_font_sizes/font_size = 12 \ No newline at end of file diff --git a/client/godot/scripts/agency.gd b/client/godot/scripts/agency.gd index 71711ae..59f6390 100644 --- a/client/godot/scripts/agency.gd +++ b/client/godot/scripts/agency.gd @@ -141,20 +141,29 @@ func _evaluate_fleeing(ent, world: Node) -> Dictionary: if flee_targets.is_empty(): return {"target": Vector2.ZERO} + var now: float = Time.get_ticks_msec() / 1000.0 var best: Variant = world.find_nearest(ent.x, ent.z, PackedStringArray(["ANIMAL", "BIRD"])) - if best == null or flee_targets.has(best.species): - if best != null: - # Flee away from threat - var away_x: float = ent.x - best.x - var away_z: float = ent.z - best.z - var len: float = sqrt(away_x * away_x + away_z * away_z) - if len > 0.01: - away_x /= len - away_z /= len - # Clamp to grid - var target_x: float = clampf(ent.x + away_x * 10.0, 0.0, float(LilaConstants.GRID_SIZE - 1)) - var target_z: float = clampf(ent.z + away_z * 10.0, 0.0, float(LilaConstants.GRID_SIZE - 1)) - return {"target": Vector2(target_x, target_z)} + + if best != null and flee_targets.has(best.species): + # Threat confirmed — cache the flee direction + var away_x: float = ent.x - best.x + var away_z: float = ent.z - best.z + var len: float = sqrt(away_x * away_x + away_z * away_z) + if len > 0.01: + away_x /= len + away_z /= len + ent.flee_dir = Vector2(away_x, away_z) + ent.flee_dir_expiry = now + ent.FLEE_DIR_TIMEOUT + + # Use cached flee direction as fallback. + # The server computes the escape target once (FleeActor fires on entry only) + # and holds it until arrival. Mirror this: if the threat is not found this + # frame (client/server divergence, stale positions, etc.), keep running in + # the last known safe direction for a short window. + if ent.flee_dir != Vector2.ZERO and now < ent.flee_dir_expiry: + var target_x: float = clampf(ent.x + ent.flee_dir.x * 10.0, 0.0, float(LilaConstants.GRID_SIZE - 1)) + var target_z: float = clampf(ent.z + ent.flee_dir.y * 10.0, 0.0, float(LilaConstants.GRID_SIZE - 1)) + return {"target": Vector2(target_x, target_z)} return {"target": Vector2.ZERO} diff --git a/client/godot/scripts/autoloads/world_model.gd b/client/godot/scripts/autoloads/world_model.gd index 6039d5e..59c7dda 100644 --- a/client/godot/scripts/autoloads/world_model.gd +++ b/client/godot/scripts/autoloads/world_model.gd @@ -65,6 +65,13 @@ class WorldEntity: ## Reconciliation tracking (mirrors browser/Python _lastReconciledTick) var last_reconciled_tick: int = -10 + ## Flee persistence — cached direction + expiry so the entity keeps running + ## even when the threat is momentarily not found (client/server divergence). + ## Mirrors server behavior: FleeActor computes escape once, guard exits on arrival. + var flee_dir: Vector2 = Vector2.ZERO + var flee_dir_expiry: float = 0.0 + const FLEE_DIR_TIMEOUT: float = 3.0 # seconds to keep fleeing on cached direction + func _init(entity_id: String, etype: String, especies: String): id = entity_id type = etype diff --git a/client/godot/scripts/renderer.gd b/client/godot/scripts/renderer.gd index 07a48d3..e99225e 100644 --- a/client/godot/scripts/renderer.gd +++ b/client/godot/scripts/renderer.gd @@ -164,12 +164,17 @@ func setup_type_meshes(parent: Node3D, meshes: Dictionary) -> Dictionary: return result +## Highlight color for the selected entity (bright white-yellow pulse). +const C_HIGHLIGHT: Color = Color(1.0, 1.0, 0.85) +const HIGHLIGHT_PULSE_FACTOR: float = 0.6 + ## Update all MultiMeshInstance3D instances for the current entity set. ## Sorts entities by type, then populates transforms + colors. +## If sel_id is non-empty, that entity's instance is drawn in the highlight color. func update_entities( type_meshes: Dictionary, entities: Array, - face_dir: bool = true, + selected_id: String = "", ) -> void: # Bucket entities by mesh key var buckets: Dictionary = {} @@ -212,6 +217,11 @@ func update_entities( if ent.type == "PLANT" and ent.state == "FRUITING" and ent.species == "wildflower": color = C_FRUITING + # Selected entity: pulse between its normal color and highlight + if selected_id != "" and ent.id == selected_id: + var pulse: float = (sin(tick_ms / 200.0) + 1.0) * 0.5 # 0..1 + color = color.lerp(C_HIGHLIGHT, HIGHLIGHT_PULSE_FACTOR + pulse * (1.0 - HIGHLIGHT_PULSE_FACTOR)) + mm.set_instance_transform(i, transform) mm.set_instance_custom_data(i, color) diff --git a/client/python/lila_client/agency.py b/client/python/lila_client/agency.py index 5ac358f..3937144 100644 --- a/client/python/lila_client/agency.py +++ b/client/python/lila_client/agency.py @@ -5,13 +5,13 @@ # lila_client/agency.py — 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) +# - 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." -This is the "body" in "server is nervous system, client is body." -""" from __future__ import annotations @@ -30,6 +30,11 @@ # Each entity also has _sync_speed (0.4..1.0) that modulates this. GRAVITY_WELL_FACTOR = 0.05 +# Flee direction cache: how long (seconds) to keep running in the last +# known safe direction when no threat is found. Mirrors server +# behavior: FleeActor computes escape once, guard exits on arrival. +FLEE_DIR_TIMEOUT = 3.0 # seconds + def step_agency(world, dt: float) -> list[dict]: """Run one frame of local agency for all mobile entities. @@ -199,11 +204,19 @@ def evaluate_behavior(ent: WorldEntity, world) -> dict[str, Any]: def evaluate_fleeing(ent: WorldEntity, world, species_def: dict) -> dict: - """Flee from nearest threat.""" + """Flee from nearest threat. + + Caches the last known flee direction so the entity keeps running + even when the threat is momentarily not found (client/server + position divergence). Mirrors server: FleeActor computes escape + once, guard exits on arrival. + """ flee_targets = species_def.get("flee_targets", []) if not flee_targets: return evaluate_wandering(ent, world) + now = time.monotonic() + nearest_threat: WorldEntity | None = None best_dist_sq = float("inf") for other in world.entities.values(): @@ -218,13 +231,24 @@ def evaluate_fleeing(ent: WorldEntity, world, species_def: dict) -> dict: nearest_threat = other if nearest_threat and best_dist_sq < 400: # ~20 world units sensory range² + # Threat confirmed — cache the flee direction dx = ent.x - nearest_threat.x dz = ent.z - nearest_threat.z dist = math.sqrt(dx * dx + dz * dz) or 1 + ent._flee_dir_x = dx / dist + ent._flee_dir_z = dz / dist + ent._flee_dir_expiry = now + FLEE_DIR_TIMEOUT + + # Use cached flee direction as fallback. + # If the threat is not found this frame (position divergence, stale + # data, etc.), keep running in the last known safe direction. + flee_dir_x = getattr(ent, "_flee_dir_x", None) + flee_dir_expiry = getattr(ent, "_flee_dir_expiry", 0.0) + if flee_dir_x is not None and now < flee_dir_expiry: return { "type": "flee", - "target_x": clamp(ent.x + (dx / dist) * 8, GRID_SIZE), - "target_z": clamp(ent.z + (dz / dist) * 8, GRID_SIZE), + "target_x": clamp(ent.x + ent._flee_dir_x * 8, GRID_SIZE), + "target_z": clamp(ent.z + ent._flee_dir_z * 8, GRID_SIZE), } return evaluate_wandering(ent, world) diff --git a/client/python/lila_client/main.py b/client/python/lila_client/main.py index fa4c7a5..64ab2e2 100644 --- a/client/python/lila_client/main.py +++ b/client/python/lila_client/main.py @@ -5,8 +5,8 @@ # lila_client/main.py — Main entry point # # Usage: - lila-client [--host localhost] [--port 8001] [--world path/to/world.json] -""" +# lila-client [--host localhost] [--port 8001] [--world path/to/world.json] + from __future__ import annotations diff --git a/client/python/lila_client/pygame_renderer.py b/client/python/lila_client/pygame_renderer.py index d79436f..00fa267 100644 --- a/client/python/lila_client/pygame_renderer.py +++ b/client/python/lila_client/pygame_renderer.py @@ -5,8 +5,8 @@ # lila_client/pygame_renderer.py — Pygame scene renderer # # Mirrors the browser client's canvas renderer: moisture heatmap, grid, -water sources, and entities drawn as layered sprites. -""" +# water sources, and entities drawn as layered sprites. + from __future__ import annotations diff --git a/client/python/lila_client/reconciliation.py b/client/python/lila_client/reconciliation.py index 43dbe75..8763c20 100644 --- a/client/python/lila_client/reconciliation.py +++ b/client/python/lila_client/reconciliation.py @@ -5,22 +5,22 @@ # lila_client/reconciliation.py — 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. -""" +# 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 diff --git a/client/python/lila_client/replay.py b/client/python/lila_client/replay.py index 77d53c3..58f89a5 100644 --- a/client/python/lila_client/replay.py +++ b/client/python/lila_client/replay.py @@ -5,11 +5,11 @@ # lila_client/replay.py — 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. +# 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] -Usage: - lila-client-replay ~/.lila/logs/demo-alpha-001.jsonl [--speed 2.0] -""" from __future__ import annotations diff --git a/client/python/lila_client/websocket.py b/client/python/lila_client/websocket.py index 4c25d87..2897fff 100644 --- a/client/python/lila_client/websocket.py +++ b/client/python/lila_client/websocket.py @@ -5,9 +5,9 @@ # lila_client/websocket.py — 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. -""" +# 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 diff --git a/client/python/lila_client/world_model.py b/client/python/lila_client/world_model.py index 77d5a95..0e7d3db 100644 --- a/client/python/lila_client/world_model.py +++ b/client/python/lila_client/world_model.py @@ -5,8 +5,8 @@ # lila_client/world_model.py — 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. -""" +# Used by both the ImGui renderer and the local agency system. + from __future__ import annotations diff --git a/server/ecosim/actors/interaction_actors.py b/server/ecosim/actors/interaction_actors.py index 723ff6f..df9463a 100644 --- a/server/ecosim/actors/interaction_actors.py +++ b/server/ecosim/actors/interaction_actors.py @@ -73,6 +73,13 @@ class FleeActor: def resolve(self, ctx: Any) -> list[Effect]: """Evaluate flee conditions and return effects. + Only emits effects on state entry (transition to FLEEING). Once the + entity is already FLEEING, the guard actor handles exit — it + transitions back to IDLE when _target is cleared on arrival. + This prevents the FleeActor from continuously resetting the escape + target every tick, which would keep _target non-None forever and + trap the entity in FLEEING indefinitely. + Args: ctx: InteractionContext with entity, params, nearby_entities, etc. @@ -86,6 +93,12 @@ def resolve(self, ctx: Any) -> list[Effect]: if p.speed <= 0: return [] + # Skip if already fleeing — let the guard actor handle exit. + # The entity will transition to IDLE once it reaches its escape target + # (_target is cleared by movement system on arrival). + if ctx.entity["state"] == "FLEEING": + return [] + # Get flee targets from compiled ecology flee_targets = self._get_flee_targets(p.species_id, ctx) if not flee_targets: @@ -100,7 +113,6 @@ def resolve(self, ctx: Any) -> list[Effect]: ctx.entity["position"], other["position"] ) - old_state = ctx.entity["state"] effects: list[Effect] = [ StateTransition( entity_id=ctx.entity["id"], @@ -112,17 +124,15 @@ def resolve(self, ctx: Any) -> list[Effect]: position=escape_pos, tick=ctx.tick, ), - ] - - if old_state != "FLEEING": - effects.append(EventRecord( + EventRecord( event_type="STATE_CHANGE", source_id=ctx.entity["id"], target_id=None, position=list(ctx.entity["position"]), - extra={"prev_state": old_state, "new_state": "FLEEING"}, + extra={"prev_state": ctx.entity["state"], "new_state": "FLEEING"}, tick=ctx.tick, - )) + ), + ] return effects # First predator triggers flee; no need to check others diff --git a/server/ecosim/actors/movement_actors.py b/server/ecosim/actors/movement_actors.py index f4daa69..46e285a 100644 --- a/server/ecosim/actors/movement_actors.py +++ b/server/ecosim/actors/movement_actors.py @@ -73,6 +73,14 @@ def resolve(self, ctx: Any) -> list[Effect]: if ctx.entity.get("_target") is not None: return [] + # Skip FLEEING entities — let the guard actor handle exit. + # When _target is cleared on arrival, the guard transitions FLEEING + # → IDLE. If we set a wander target here (flow phase runs before + # guards), the guard sees _target is set and never exits FLEEING, + # trapping the entity in an energy-draining death spiral. + if ctx.entity["state"] == "FLEEING": + return [] + p = ctx.params if p is None: return []