//! Office — an LLM-run company. Straight top-down, axis-aligned, barrier
//! stepping: every round all agents decide via LLM while the scene freezes,
//! then the whole scene steps 30 sim-minutes at once.
//!
//! The engine lives in lib.rs (src/*). This binary is the graphic window;
//! `--headless` runs the same loop with no renderer, paired with the TUI.
use office::{company, llm, panel, people, round, scene, sim, shared, tasks, verify};
use office::shared::{HumanInbox, UIFont};
use bevy::diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin};
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use company::{CompanyCfg, Workspace};
use llm::LlmPool;
use people::{Memory, Person, Selected, Walking};
use round::{collect_results, gather_due};
use scene::{spawn_office, Spots};
use sim::*;
use tasks::TaskBoard;
fn main() {
let mut company_dir = std::path::PathBuf::from("companies/starter");
let mut use_remote = true;
let mut split = false;
let mut no_local = true; // home GPU idle by default; --local re-enables
let mut coder_url = llm::LLAMA_CODER_URL.to_string();
let mut coder_model = llm::LLAMA_CODER_MODEL.to_string();
let mut headless = false;
let mut all_27b = false;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--no-remote" => use_remote = false,
// remote-only is the default now; "--no-local" kept for old habits
"--no-local" => no_local = true,
// run models on the home machine (9070 / llama.cpp / local ollama)
"--local" => no_local = false,
// 2060 talks (decisions), local big model writes work logs.
"--split" => split = true,
// EVERYTHING on the home llama.cpp 27B (decide + write).
"--27b" => {
use_remote = false;
split = true;
no_local = false;
all_27b = true;
}
"--coder-url" => coder_url = args.next().unwrap_or_default(),
"--coder-model" => coder_model = args.next().unwrap_or_default(),
"--company" => {
if let Some(dir) = args.next() {
company_dir = dir.into();
}
}
"--headless" => headless = true,
other if !other.starts_with("--") => company_dir = other.into(),
other => {
eprintln!(
"unknown flag: {other}\nusage: office [--company <dir>] [--split] [--no-local] \
[--no-remote]"
);
std::process::exit(2);
}
}
}
let cfg = CompanyCfg::load(&resolve_company_dir(&company_dir)).unwrap_or_else(|e| {
eprintln!("error: {e}");
std::process::exit(1);
});
let company_dir = resolve_company_dir(&company_dir);
let workspace = Workspace::new(&company_dir).unwrap_or_else(|e| {
eprintln!("error creating workspace: {e}");
std::process::exit(1);
});
let board = TaskBoard::load_or_init(&workspace.root.join("tasks.json"), &cfg);
info!(
"company: {} — {} people, {} backlog tasks",
cfg.company.name,
cfg.people.len(),
board.tasks.len()
);
// Day 1 starts with everyone gathered for standup.
let mut events = CompanyEvents::default();
events.meeting = Some(Meeting {
topic: "Morning standup".into(),
until: DAY_START + 30.0,
});
// Route: where each kind of job may run.
let route = if all_27b {
// Full 27B mode: every decision AND every write is the home llama.cpp
// server (its own queue handles concurrency: 3 decide + 2 write).
llm::Route {
decide: vec![llm::Endpoint::new(&coder_url, &coder_model, 3)],
write: vec![llm::Endpoint::new(&coder_url, &coder_model, 2)],
}
} else if split && use_remote {
// Split mode: the 2060 does ALL talking/deciding, the local 27b
// only writes work logs (heavier, fewer calls).
llm::Route {
decide: vec![llm::Endpoint::new(llm::REMOTE_URL, llm::REMOTE_MODEL, 3)],
write: vec![llm::Endpoint::new(&coder_url, &coder_model, 2)],
}
} else if split && no_local {
// Split, home GPU off: BOTH queues ride the 2060 (its Ollama queues
// concurrent calls internally, so one endpoint is fine).
llm::Route {
decide: vec![llm::Endpoint::new(llm::REMOTE_URL, llm::REMOTE_MODEL, 3)],
write: vec![llm::Endpoint::new(llm::REMOTE_URL, llm::REMOTE_MODEL, 2)],
}
} else if split {
// Split without remote: everything local (spark decides, 27b writes).
llm::Route {
decide: vec![llm::Endpoint::new(llm::LOCAL_URL, llm::LOCAL_MODEL, 4)],
write: vec![llm::Endpoint::new(&coder_url, &coder_model, 2)],
}
} else if use_remote && no_local {
llm::Route {
decide: vec![llm::Endpoint::new(llm::REMOTE_URL, llm::REMOTE_MODEL, 6)],
write: vec![],
}
} else if use_remote {
// Shared: round-robin across both machines for every job.
llm::Route {
decide: vec![
llm::Endpoint::new(llm::LOCAL_URL, llm::LOCAL_MODEL, 4),
llm::Endpoint::new(llm::REMOTE_URL, llm::REMOTE_MODEL, 3),
],
write: vec![],
}
} else {
llm::Route {
decide: vec![llm::Endpoint::new(llm::LOCAL_URL, llm::LOCAL_MODEL, 6)],
write: vec![],
}
};
info!(
"route: {} | {}",
route
.decide
.iter()
.map(|e| format!("decide ← {}", e.label()))
.collect::<Vec<_>>()
.join(", "),
if route.write.is_empty() {
"shared mode (work notes inline)".to_string()
} else {
route
.write
.iter()
.map(|e| format!("write ← {}", e.label()))
.collect::<Vec<_>>()
.join(", ")
}
);
// ── headless: same loop, no renderer — pairs with the office-tui bin ──
if headless {
let spots0 = scene::headless_spots(&cfg);
App::new()
.add_plugins(bevy::MinimalPlugins.set(bevy::app::ScheduleRunnerPlugin::run_loop(
std::time::Duration::from_millis(50),
)))
.add_plugins(bevy::log::LogPlugin::default())
.insert_resource(spots0)
.insert_resource(SimClock(if board.sim_minutes > 0.01 {
board.sim_minutes
} else {
DAY_START
}))
.insert_resource(RoundState {
step_until: 0.0,
..default()
})
.insert_resource(events)
.insert_resource(Phase::Stepping)
.insert_resource(cfg)
.insert_resource(workspace)
.insert_resource(board)
.insert_resource(Selected(None))
.insert_resource(crate::shared::Bonds::default())
.insert_resource(route)
.add_systems(
Startup,
(people::spawn_people_headless, start_pool, start_stdin).chain(),
)
.add_systems(
Update,
(gather_due, collect_results, round::human_commands).chain(),
)
.run();
return;
}
App::new()
.insert_resource(ClearColor(Color::srgb(0.12, 0.12, 0.14)))
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: format!("Office — {}", cfg.company.name),
resolution: (1600, 950).into(),
..default()
}),
..default()
}))
.add_plugins(FrameTimeDiagnosticsPlugin::default())
.insert_resource(SimClock(if board.sim_minutes > 0.01 {
board.sim_minutes
} else {
DAY_START
}))
.insert_resource(RoundState {
step_until: 0.0, // first gather fires immediately
..default()
})
.insert_resource(events)
.insert_resource(Phase::Stepping)
.insert_resource(cfg)
.insert_resource(workspace)
.insert_resource(board)
.insert_resource(Selected(None))
.insert_resource(crate::shared::Bonds::default())
.init_resource::<panel::PanelState>()
.insert_resource(route)
.add_systems(
Startup,
(
start_font,
spawn_office,
people::spawn_people,
snap_to_standup_seats,
start_pool,
start_stdin,
)
.chain(),
)
.add_systems(
Update,
(
gather_due,
collect_results,
crate::round::human_commands,
move_people.after(gather_due),
people::update_bubbles,
click_select,
hud,
panel::panel,
)
.chain(),
)
.run();
}
/// Resolve a company dir relative to cwd first, then relative to the
/// executable (walking up a few levels) — so the sim runs from any cwd.
fn resolve_company_dir(given: &std::path::Path) -> std::path::PathBuf {
if given.join("company.toml").exists() {
return given.to_path_buf();
}
if let Ok(exe) = std::env::current_exe() {
let mut dir = exe
.parent()
.map(|d| d.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("/"));
for _ in 0..6 {
let candidate = dir.join(given);
if candidate.join("company.toml").exists() {
info!("company dir: {}", candidate.display());
return candidate;
}
match dir.parent() {
Some(p) => dir = p.to_path_buf(),
None => break,
}
}
}
given.to_path_buf() // fall through; load() reports the real error
}
fn start_font(mut commands: Commands, server: Res<AssetServer>) {
// AssetServer paths are relative to ./assets — copy the system font in,
// then load it normally (absolute paths silently fail to resolve).
let candidates = [
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
];
let best = candidates
.iter()
.find(|p| std::path::Path::new(p).exists());
match best {
Some(font_path) => {
// Bevy's asset root sits next to the executable, not the cwd.
let exe_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
.unwrap_or_else(|| std::path::PathBuf::from("."));
let local = exe_dir.join("assets/fonts/DejaVuSans.ttf");
let _ = std::fs::create_dir_all(local.parent().unwrap());
if !local.exists() {
match std::fs::copy(font_path, &local) {
Ok(_) => info!("font copied to {}", local.display()),
Err(e) => warn!("font copy failed: {e}"),
}
}
info!("font: {}", local.display());
commands.insert_resource(UIFont(server.load("fonts/DejaVuSans.ttf")));
}
None => {
warn!("DejaVu not found — non-ASCII glyphs will render as boxes");
commands.insert_resource(UIFont(server.load("fonts/none.ttf")));
}
}
}
fn start_stdin(mut commands: Commands) {
let (tx, rx) = std::sync::mpsc::channel::<String>();
std::thread::spawn(move || {
use std::io::BufRead;
for line in std::io::stdin().lock().lines() {
match line {
Ok(l) => {
if tx.send(l).is_err() {
break;
}
}
Err(_) => break,
}
}
});
commands.insert_resource(HumanInbox(std::sync::Mutex::new(rx)));
println!("manager console ready — type help for commands (sim runs without you)");
}
fn start_pool(mut commands: Commands, route: Res<llm::Route>, cfg: Res<CompanyCfg>) {
let roster: Vec<(String, String, String)> = cfg
.people
.iter()
.map(|p| (p.name.clone(), p.role.clone(), p.personality.clone()))
.collect();
llm::start_pool(&mut commands, route.clone(), roster);
}
/// One-shot: send everyone to their meeting seat for day-1 standup.
fn snap_to_standup_seats(spots: Res<Spots>, mut query: Query<(&Person, &mut Walking)>, mut done: Local<bool>) {
if *done {
return;
}
let mut seat = 0;
for (_, mut walking) in &mut query {
walking.0 = spots
.meeting_seats
.get(seat % spots.meeting_seats.len().max(1))
.copied();
seat += 1;
}
*done = true;
}
// ---------------------------------------------------------------------------
// Movement / selection / HUD
// ---------------------------------------------------------------------------
fn move_people(
time: Res<Time>,
phase: Res<Phase>,
mut query: Query<(&mut Transform, &mut Walking)>,
) {
if *phase != Phase::Stepping {
return;
}
let dt = time.delta_secs();
for (mut transform, mut walking) in query.iter_mut() {
let Some(target) = walking.0 else { continue };
let current = Vec2::new(transform.translation.x, transform.translation.z);
let delta = target - current;
let dist = delta.length();
let speed = 300.0; // units/sec
if dist <= speed * dt {
transform.translation.x = target.x;
transform.translation.z = target.y;
walking.0 = None;
} else {
let step = delta.normalize() * speed * dt;
transform.translation.x += step.x;
transform.translation.z += step.y;
}
}
}
fn click_select(
mouse: Res<ButtonInput<MouseButton>>,
windows: Query<&Window, With<PrimaryWindow>>,
camera: Query<(&Camera, &GlobalTransform), With<Camera3d>>,
people: Query<(Entity, &Transform), With<Person>>,
monitors: Query<(&Transform, &scene::DeskOwner), Without<Person>>,
cfg: Res<CompanyCfg>,
names: Res<people::Names>,
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 the capsule's mid-height plane.
let t = (scene::AGENT_Y - 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, transform) in &people {
let d = scene::flat(transform.translation).distance(scene::flat(point));
if d < 30.0 && best.map(|(_, bd)| d < bd).unwrap_or(true) {
best = Some((entity, d));
}
}
// Desk monitors are click targets too: clicking a machine selects the
// person it belongs to (work browser folder 7 = their computer).
for (transform, owner) in &monitors {
let d = scene::flat(transform.translation).distance(scene::flat(point));
if d < 34.0 && best.map(|(_, bd)| d < bd).unwrap_or(true) {
// resolve person name by index
if let Some(p) = cfg.people.get(owner.0) {
if let Some(&entity) = names.0.get(&p.name.to_lowercase()) {
best = Some((entity, d));
}
}
}
}
selected.0 = best.map(|(e, _)| e);
}
#[derive(Component)]
struct HudText;
#[allow(clippy::too_many_arguments)]
fn hud(
mut commands: Commands,
font: Res<UIFont>,
diagnostics: Res<DiagnosticsStore>,
clock: Res<SimClock>,
phase: Res<Phase>,
round: Res<RoundState>,
pool: Res<LlmPool>,
cfg: Res<CompanyCfg>,
board: Res<TaskBoard>,
events: Res<CompanyEvents>,
selected: Res<Selected>,
people: Query<(&Person, &Memory, &Transform)>,
mut texts: Query<&mut Text, With<HudText>>,
) {
let fps = diagnostics
.get(&FrameTimeDiagnosticsPlugin::FPS)
.and_then(|d| d.smoothed())
.unwrap_or(0.0);
let phase_str = match *phase {
Phase::Computing => {
format!("COMPUTING — {} pending, scene frozen", round.pending.len())
}
Phase::Stepping => "STEPPING — scene live".to_string(),
};
let mut text = format!(
"{} | Day {} {} | {}\nround {} | fps {:.0} | decisions ok {} fail {}\n",
cfg.company.name,
clock.day(),
clock.hhmm(),
phase_str,
round.rounds,
fps,
pool.completed,
pool.failed,
);
if let Some(m) = &events.meeting {
text.push_str(&format!("meeting: \"{}\"\n", m.topic));
}
text.push_str(
"you (as manager): assign T7 \"Priya Nair\" 3 | review T3 approve ok | meeting topic\n",
);
text.push('\n');
let done = board
.tasks
.iter()
.filter(|t| t.status == tasks::TaskStatus::Done)
.count();
text.push_str(&format!("tasks: {}/{} done\n", done, board.tasks.len()));
for t in board
.tasks
.iter()
.filter(|t| !matches!(t.status, tasks::TaskStatus::Done | tasks::TaskStatus::Backlog))
{
let who = t.assignee.as_deref().unwrap_or("-");
text.push_str(&format!(
" {} {} {}% {} (due d{}){}\n",
t.id,
who,
t.progress,
t.status.label(),
t.due_day.unwrap_or(0),
t.concern
.as_ref()
.map(|c| format!("\n ⚠ {c}"))
.unwrap_or_default()
));
}
if let Some(entity) = selected.0 {
if let Ok((person, memory, transform)) = people.get(entity) {
text.push_str(&format!(
"\n── {} ({}) @ {},{} ──\n",
person.name,
person.role,
transform.translation.x as i32,
transform.translation.z as i32
));
text.push_str(&board.my_work(&person.name));
text.push('\n');
for m in memory.recent(6) {
text.push_str(&format!(" • {m}\n"));
}
}
}
match texts.single_mut() {
Ok(mut t) => t.0 = text,
Err(_) => {
commands.spawn((
Text::new(text),
TextFont {
font: font.0.clone().into(),
font_size: 13.0.into(),
..default()
},
TextColor(Color::srgba(0.92, 0.95, 1.0, 0.96)),
BackgroundColor(Color::srgba(0.05, 0.06, 0.1, 0.85)),
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(470.0),
..default()
},
HudText,
));
}
}
}