//! 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
] [--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::>()
.join(", "),
if route.write.is_empty() {
"shared mode (work notes inline)".to_string()
} else {
route
.write
.iter()
.map(|e| format!("write ← {}", e.label()))
.collect::>()
.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::()
.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 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::();
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, cfg: Res) {
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, mut query: Query<(&Person, &mut Walking)>, mut done: Local) {
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