swarm / village/src/main.rs

raw file 9787 bytes
//! A village of 100 LLM villagers.
//!
//! Three layers:
//!   render/60fps  — Bevy draws, bubbles update, bodies walk
//!   scheduling    — wake queue + worker pool (6 in-flight Ollama calls)
//!   brain         — each decision is one chat call with that person's own
//!                    system prompt (persona + memories + perception)

mod agent;
mod llm;
mod persona;
mod sim_config;
mod world;

use bevy::diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin};
use bevy::input::mouse::{MouseMotion, MouseWheel};
use bevy::prelude::*;
use bevy::window::PrimaryWindow;

use agent::{move_bodies, update_bubbles, Names, Speech, Villager};
use llm::{deliver_talks, start_pool, LlmPool, VillagerEvent};
use world::VillageCamera;

fn main() {
    App::new()
        .insert_resource(ClearColor(Color::srgb(0.06, 0.07, 0.10)))
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "Village — 100 LLM villagers".into(),
                resolution: (1600, 950).into(),
                ..default()
            }),
            ..default()
        }))
        .add_plugins(FrameTimeDiagnosticsPlugin::default())
        .init_resource::<EventLog>()
        .init_resource::<Selected>()
        .add_message::<llm::VillagerEvent>()
        .add_systems(
            Startup,
            (sim_config::setup, start_pool, world::spawn_village, agent::spawn_villagers).chain(),
        )
        .add_systems(
            Update,
            (
                llm::schedule_wakes,
                llm::collect_results,
                deliver_talks,
                move_bodies,
                agent::tick_needs,
                update_bubbles,
                log_villager_events,
                pick_villager,
                hud,
                camera_controls,
            )
                .chain(),
        )
        .run();
}

// ---------------------------------------------------------------------------
// Console: every conversation printed as it happens
// ---------------------------------------------------------------------------

#[derive(Resource, Default)]
pub struct EventLog {
    pub lines: Vec<(f32, String)>,
}

fn log_villager_events(
    time: Res<Time>,
    mut events: MessageReader<VillagerEvent>,
    mut log: ResMut<EventLog>,
    pool: Res<LlmPool>,
    query: Query<(&Speech, &Villager)>,
) {
    for ev in events.read() {
        match ev {
            VillagerEvent::Talk { from, to, say } => {
                let line = format!("{from} → {to}: {say}");
                info!("{line}");
                log.lines.push((time.elapsed_secs(), line));
            }
            VillagerEvent::Action { entity, text } => {
                if let Ok((_, v)) = query.get(*entity) {
                    let line = format!("{} {}", v.persona.name, text);
                    if line.contains('→') {
                        info!("{line}");
                        log.lines.push((time.elapsed_secs(), line));
                    }
                }
            }
        }
    }
    let _ = &pool;
    let len = log.lines.len();
    if len > 40 {
        log.lines.drain(0..len - 40);
    }
}

// ---------------------------------------------------------------------------
// HUD
// ---------------------------------------------------------------------------

#[derive(Component)]
struct HudText;

#[derive(Resource, Default)]
pub struct Selected(Option<Entity>);

fn hud(
    mut commands: Commands,
    diagnostics: Res<DiagnosticsStore>,
    pool: Res<LlmPool>,
    log: Res<EventLog>,
    selected: Res<Selected>,
    time: Res<Time>,
    query: Query<(&Villager, &agent::Needs, &agent::Brain, &agent::Memory, &Speech)>,
    mut texts: Query<&mut Text, With<HudText>>,
    names: Res<Names>,
) {
    let fps = diagnostics
        .get(&FrameTimeDiagnosticsPlugin::FPS)
        .and_then(|d| d.smoothed())
        .unwrap_or(0.0);

    let mut text = format!(
        "VILLAGE 100 — fps {fps:.0} | llm: {} in flight (peak {}) | done {} fail {}\n\
         click a villager to inspect | mouse-drag pan, wheel zoom\n\n",
        pool.in_flight, pool.peak_in_flight, pool.completed, pool.failed
    );

    if let Some(entity) = selected.0 {
        if let Ok((v, needs, brain, memory, speech)) = query.get(entity) {
            let p = &v.persona;
            text.push_str(&format!(
                "── {} ({}) ──\nhunger {:.0}  energy {:.0}  social {:.0}\n\
                 trait: {}\nnext decision in {:.0}s{}\n\nsaid: {}\n\nmemories:\n",
                p.name,
                p.job,
                needs.hunger,
                needs.energy,
                needs.social,
                p.traits,
                (brain.next_wake - time.elapsed_secs()).max(0.0),
                if brain.in_flight { " (thinking…)" } else { "" },
                if speech.text.is_empty() { "—" } else { &speech.text },
            ));
            for m in memory.recent(8) {
                text.push_str(&format!("  • {m}\n"));
            }
            text.push('\n');
        }
    }

    text.push_str("── recent talk ──\n");
    for (t, line) in log.lines.iter().rev().take(8).rev() {
        text.push_str(&format!("[{:4.0}s] {line}\n", t / 60.0));
    }

    if let Ok(mut t) = texts.single_mut() {
        t.0 = text;
    } else {
        commands.spawn((
            Text::new(text),
            TextFont { font_size: 13.0.into(), ..default() },
            TextColor(Color::srgba(0.9, 0.95, 1.0, 0.95)),
            BackgroundColor(Color::srgba(0.02, 0.03, 0.06, 0.82)),
            Node {
                position_type: PositionType::Absolute,
                left: Val::Px(10.0),
                top: Val::Px(10.0),
                padding: UiRect::all(Val::Px(10.0)),
                max_width: Val::Px(430.0),
                ..default()
            },
            HudText,
        ));
    }
    let _ = &names;
}

// ---------------------------------------------------------------------------
// Camera + picking
// ---------------------------------------------------------------------------

// Camera component lives in world.rs (spawned there); controls target it.

fn camera_controls(
    mut query: Query<(&mut Transform, &mut Projection), With<VillageCamera>>,
    mouse: Res<ButtonInput<MouseButton>>,
    keys: Res<ButtonInput<KeyCode>>,
    mut motion: MessageReader<MouseMotion>,
    mut wheel: MessageReader<MouseWheel>,
    windows: Query<&Window, With<PrimaryWindow>>,
    mut holding: Local<bool>,
    time: Res<Time>,
) {
    let Ok((mut transform, mut projection)) = query.single_mut() else {
        return;
    };
    let dt = time.delta_secs();

    let dragged: Vec2 = motion.read().map(|e| e.delta).sum();
    if mouse.just_pressed(MouseButton::Right) {
        *holding = true;
    }
    if mouse.just_released(MouseButton::Right) {
        *holding = false;
    }
    if *holding {
        // pan in screen space (camera is isometric: use its right/up)
        let right = Vec3::new(transform.right().x, 0.0, transform.right().z);
        let scale = projection_scale(&projection);
        transform.translation -= right * dragged.x * scale * 0.9;
        transform.translation += Vec3::Y * dragged.y * scale * 0.9;
        transform.translation.y = 96.0; // ortho: height irrelevant visually
    }

    let zoom: f32 = wheel.read().map(|e| e.y).sum();
    if zoom.abs() > 0.0 {
        if let Projection::Orthographic(ortho) = projection.as_mut() {
            ortho.scale = (ortho.scale * (1.0 - zoom * 0.12)).clamp(0.03, 0.5);
        }
    }

    let pan = 40.0 * dt * projection_scale(&projection);
    let right = Vec3::new(transform.right().x, 0.0, transform.right().z);
    let right = if right.length_squared() > 1e-6 {
        right.normalize()
    } else {
        Vec3::X
    };
    let fwd = Vec3::new(-right.z, 0.0, right.x);
    let mut motion_v = Vec3::ZERO;
    if keys.pressed(KeyCode::KeyW) || keys.pressed(KeyCode::ArrowUp) {
        motion_v += fwd;
    }
    if keys.pressed(KeyCode::KeyS) || keys.pressed(KeyCode::ArrowDown) {
        motion_v -= fwd;
    }
    if keys.pressed(KeyCode::KeyA) || keys.pressed(KeyCode::ArrowLeft) {
        motion_v -= right;
    }
    if keys.pressed(KeyCode::KeyD) || keys.pressed(KeyCode::ArrowRight) {
        motion_v += right;
    }
    if motion_v.length_squared() > 0.0 {
        transform.translation += motion_v.normalize() * pan;
    }
    let _ = &windows;
}

fn projection_scale(projection: &Projection) -> f32 {
    match projection {
        Projection::Orthographic(o) => o.scale,
        _ => 0.1,
    }
}

fn pick_villager(
    mouse: Res<ButtonInput<MouseButton>>,
    windows: Query<&Window, With<PrimaryWindow>>,
    camera: Query<(&Camera, &GlobalTransform), With<VillageCamera>>,
    villagers: Query<(Entity, &Transform), With<Villager>>,
    mut selected: ResMut<Selected>,
) {
    if !mouse.just_pressed(MouseButton::Left) {
        return;
    }
    let Ok(window) = windows.single() else { return };
    let Some(cursor) = window.cursor_position() else { return };
    let Ok((cam, cam_t)) = camera.single() else { return };
    let Ok(ray) = cam.viewport_to_world(cam_t, cursor) else { return };
    // intersect y = 0.45 plane (villager mid-height)
    let t = (0.45 - ray.origin.y) / ray.direction.y;
    if !t.is_finite() || t < 0.0 {
        return;
    }
    let point = ray.origin + *ray.direction * t;
    let mut best: Option<(Entity, f32)> = None;
    for (entity, t) in &villagers {
        let d = Vec2::new(point.x - t.translation.x, point.z - t.translation.z).length();
        if d < 1.2 && best.map(|(_, bd)| d < bd).unwrap_or(true) {
            best = Some((entity, d));
        }
    }
    selected.0 = best.map(|(e, _)| e);
}