swarm / office/src/shared.rs

raw file 5326 bytes
//! Types shared by every frontend (graphic main, headless runner, TUI bin).

use bevy::prelude::*;

/// Lines typed by the human "manager" (stdin) or delivered by the TUI via
/// work/commands.txt. Applied on top of the manager LLM's behavior; the sim
/// never waits for them. Receiver sits behind a mutex because `Receiver` is
/// `Send` but not `Sync`.
#[derive(Resource)]
pub struct HumanInbox(pub std::sync::Mutex<std::sync::mpsc::Receiver<String>>);

impl HumanInbox {
    /// MUST be non-blocking — this runs every frame; blocking recv would
    /// freeze the loop until someone types a line.
    pub fn try_recv(&self) -> Result<String, std::sync::mpsc::TryRecvError> {
        let rx = self.0.lock().unwrap_or_else(|e| e.into_inner());
        rx.try_recv()
    }
}

/// Font handle covering arrows/stars/speech-market glyphs (DejaVu fallback).
#[derive(Resource, Clone)]
pub struct UIFont(pub Handle<Font>);

// ---------------------------------------------------------------------------
// Social memory: per-person bonds (trust + last note) keyed by lowercase name.
// Run-scoped (component-free so no query arity changes anywhere).
// ---------------------------------------------------------------------------

#[derive(Clone, Debug)]
pub struct Bond {
    pub trust: i8,
    pub note: String,
}

#[derive(Resource, Default)]
pub struct Bonds(
    pub std::collections::HashMap<String, std::collections::HashMap<String, Bond>>,
);

impl Bonds {
    pub fn trust_of(&self, me: &str, other: &str) -> i8 {
        self.0
            .get(&me.to_lowercase())
            .and_then(|m| m.get(&other.to_lowercase()))
            .map(|b| b.trust)
            .unwrap_or(2)
    }

    pub fn adjust(&mut self, me: &str, other: &str, delta: i8, note: Option<String>) {
        let key_me = me.to_lowercase();
        let key_other = other.to_lowercase();
        let b = self
            .0
            .entry(key_me)
            .or_default()
            .entry(key_other.clone())
            .or_insert(Bond { trust: 2, note: "?".into() });
        b.trust = (b.trust + delta).clamp(0, 10);
        if let Some(n) = note {
            let short: String = n.chars().take(70).collect();
            b.note = short;
        }
    }

    /// Rendered for a person's prompt: their strongest bonds first.
    pub fn lines_for(&self, me: &str) -> String {
        let Some(map) = self.0.get(&me.to_lowercase()) else {
            return "(no working relationships formed yet — talk to people!)".into();
        };
        let mut rows: Vec<(&String, &Bond)> = map.iter().collect();
        rows.sort_by_key(|(_, b)| -b.trust);
        rows.iter()
            .take(6)
            .map(|(name, b)| format!("- {}: trust {}/10 — \"{}\"", name, b.trust, b.note))
            .collect::<Vec<_>>()
            .join("\n")
    }
}

// ---------------------------------------------------------------------------
// Desk chatter + meeting logs (work/log/, work/meetings/) — read by the TUI.
// ---------------------------------------------------------------------------

fn append_file(path: &std::path::Path, text: &str) {
    use std::io::Write;
    if let Ok(mut f) = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
    {
        let _ = f.write_all(text.as_bytes());
    }
}

/// Live desk chat: every in-person utterance, wherever it happens — desks,
/// break room or meetings. The TUI tails work/log/desk.md; meetings ALSO
/// write their own curated record.
pub fn desk_log(
    workspace: &crate::company::Workspace,
    clock: &crate::sim::SimClock,
    from: &str,
    to: &str,
    message: &str,
) {
    let dir = workspace.root.join("log");
    std::fs::create_dir_all(&dir).ok();
    append_file(
        &dir.join("desk.md"),
        &format!(
            "[day {} {}] {from} → {to}: {message}\n\n",
            clock.day(),
            clock.hhmm()
        ),
    );
}

fn meeting_open_path(workspace: &crate::company::Workspace) -> std::path::PathBuf {
    workspace.root.join("log").join("meeting_open.md")
}

/// Called when a meeting starts: fresh record with header.
pub fn start_meeting_log(
    workspace: &crate::company::Workspace,
    clock: &crate::sim::SimClock,
    topic: &str,
) {
    std::fs::create_dir_all(workspace.root.join("log")).ok();
    std::fs::write(
        meeting_open_path(workspace),
        format!("# {}\nday {} · started {}\n\n", topic, clock.day(), clock.hhmm()),
    )
    .ok();
}

/// Append one in-meeting utterance/action to the open record.
pub fn note_meeting(
    workspace: &crate::company::Workspace,
    clock: &crate::sim::SimClock,
    line: &str,
) {
    append_file(&meeting_open_path(workspace), &format!("- {line}\n"));
}

/// Called when a meeting ends: move the open record into work/meetings/.
pub fn end_meeting_log(
    workspace: &crate::company::Workspace,
    clock: &crate::sim::SimClock,
    topic: &str,
) {
    let open = meeting_open_path(workspace);
    if !open.exists() {
        return;
    }
    let dir = workspace.root.join("meetings");
    std::fs::create_dir_all(&dir).ok();
    let name = format!(
        "day{:02}_{}_{}.md",
        clock.day(),
        clock.hhmm().replace(':', ""),
        crate::company::slug(topic)
    );
    std::fs::rename(&open, dir.join(&name)).ok();
}