From 7bc214451529c2eb0824ea73b933f20dcdb5818a Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 07 Jul 2026 16:00:56 +0000 Subject: [PATCH] Merge origin/main: reconcile agent-play (parallel session) with the wiki migration origin/main gained two commits while this branch was held for review: CLAUDE.md (a session nav map) and "Agent play: implement line-protocol terminal mode" (a parallel Letta Code session implementing wiki/interface/agent-play.md for real). The agent-play commit forked before this branch's stage 4 landed, so it edited devlogs/*.md and DEVLOG.md at their pre-migration paths — exactly the collision spec/wiki.md's own migration plan warned about. Auto-merged cleanly (git's rename+edit heuristic applied their content onto the moved files correctly): README.md, wiki/log/2026-07-06- core-scope-split.md, wiki/log/2026-07-07-agent-play.md, wiki/process/workflows.md, tools/check.sh (their new "agent mode smoke" step and my "wiki gate"/"mdbook build" steps insert at different points in the file). wiki/process/specs.md, wiki/interface/agent-play.md, wiki/interface/terminal.md, DESIGN.md, and the new src/ files pulled in clean since this branch never touched them. One real conflict: wiki/log/DEVLOG.md — both sides inserted a new ledger entry at the same splice point (right after the header). Resolved by union per the ledgers-merge-by-union rule: kept both sides' entries, ordered by actual landing sequence (this branch's two newest entries on top, since this merge happens after theirs; their "Agent play implemented" entry in its chronological slot before stage 3). Also fixed CLAUDE.md's two remaining devlogs/DEVLOG.md path mentions (stale the moment this branch's stage 4 merges in) and a real portability bug the agent-play commit introduced: `setup_local_pkg_config`'s `gen_pc_if_missing` called `ldconfig` unconditionally — Linux-only, doesn't exist on macOS, and under `set -e` a missing command in a `var=$(cmd)` assignment aborts the whole script. Added a `command -v ldconfig` guard so the shim no-ops on platforms that don't need it (dyld, not ldconfig) instead of taking down every local `./tools/check.sh` run on macOS. ./tools/check.sh full green post-merge, post-fix (94 tests, both clippy feature sets, bevy build, spec headers, wiki gate, mdbook build, and the new agent-mode smoke step all pass). --- CLAUDE.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ DESIGN.md | 22 +++++++++++----------- README.md | 11 +++++++++++ src/sim.rs | 20 +++++++++++++++----- tools/check.sh | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wiki/interface/agent-play.md | 21 ++++++++++++--------- wiki/interface/terminal.md | 20 +++++++++++++------- wiki/log/2026-07-06-core-scope-split.md | 2 +- wiki/log/2026-07-07-agent-play.md | 52 ++++++++++++++++++++++++++++++++++++++++------------ wiki/log/DEVLOG.md | 37 +++++++++++++++++++++++++++++++++---- wiki/process/specs.md | 2 +- wiki/process/workflows.md | 20 ++++++++++++++++++-- src/bin/terminal/agent.rs | 839 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/bin/terminal/mod.rs | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------- 14 file(s) changed, 1175 insertion(s)(+), 59 deletion(s)(-) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,52 @@ +# CLAUDE.md — fast map for agent sessions + +Read [AGENT.md](AGENT.md) first; it is the binding entry point. This file is +only the navigation shortcut so sessions stop rediscovering the layout. + +## Layout (post wiki migration, 2026-07-07) + +- `DESIGN.md` — the constitution. Code follows it, never the reverse. +- `wiki/` — the single documentation tree. The old `spec/` and `knowledge/` + directories are GONE; pages are typed by header (`Type: spec | knowledge | + log`), not by directory: + - `wiki/process/ROADMAP.md` — the dispatch board: work orders, suggested + worktree names, parallel-conflict flags. Pick up work here. + - `wiki/process/specs.md` — the spec status board (B1/B2/B3 tables). + - `wiki/process/meta.md` — the spec format itself. + - `wiki/mechanics/` — system specs (compute, detection, reach, cursor, + intel, messages, economy, income, research, rollback, ...). + - `wiki/interface/` — terminal style guide, views, agent-play protocol. + - `wiki/world/` — places (basement-map, zplanes), characters (cast specs), + story. `wiki/engineering/` — architecture, flow substrate. + - `wiki/vision/` — design judgment. `wiki/log/` — the log index. +- `src/` — the sim lib. `sim.rs` is the orchestrator, `save.rs` the serde + JSON save; these two are the parallel-agent conflict hotspots. Frontends + are thin views: `src/bin/terminal/`, `src/bin/bevy.rs` (feature `bevy_ui`). +- `tools/check.sh` — the definition of done (fmt, tests, clippy both feature + sets, bevy build, spec-header lint). Run before every commit. +- `wiki/log/` — one entry per session; `wiki/log/DEVLOG.md` is the ledger. +- `prompts/` — the dispatch library for sub-agent task types. + +## Hard rules (from AGENT.md; the hooks enforce most of them) + +- NEVER edit this main checkout. First action of any session: create a git + worktree (`git worktree add .claude/worktrees/ -b worktree- + origin/main`), do all edits/tests/commits there. Parallel sessions use the + main checkout as shared ground; never `git reset`/`checkout --`/`stash` it. +- A commit touching `src/` must also touch `wiki/` or `DESIGN.md` + (pre-commit hook + server-side Tangled pipeline). Behavior changes carry a + `Defense:` paragraph in the commit message and the amendment in the same + commit. +- Game rules live only in the lib; `Sim` never reads the wall clock or does + I/O. Both frontends must surface any new system before its spec is + IMPLEMENTED; set the spec `Status:` in the implementing commit. +- No emoji. No AI attribution in commits. `git add` only intended files — + never `git add -A`. +- Ledger files (wiki/log/DEVLOG.md, the DESIGN.md decisions log, wiki status + tables) merge by UNION — keep both sides' entries in any conflict. +- Landing: rebase onto `origin/main`, re-run `./tools/check.sh`, merge/push + to main directly (the PR rule was removed 2026-07-07), then remove the + worktree and delete its branch. No stale worktrees. +- Run at most ONE sim+save-heavy work order at a time (see the ROADMAP + conflict flags); isolated items (frontend-only, test-only, docs) can run + alongside anything. diff --git a/DESIGN.md b/DESIGN.md --- a/DESIGN.md +++ b/DESIGN.md @@ -257,13 +257,13 @@ Adopted 2026-07-06. The game is played regularly by AI agents — every tick, playtest, and acceptance run happens through the terminal build, and the -working method depends on agents actually playing (the pty harness in -knowledge/workflows.md is the definition of done's "observed in an actual -run"). The most frequent player of Misaligned is an agent with a pty. The -terminal frontend is therefore **not a disposable dev harness but a -first-class frontend**, held to pillar 4's beautiful-and-readable standard -with the same force as the Bevy build. A terminal that is ugly or illegible -degrades every future session's judgment of the game itself. +working method depends on agents actually playing (the smoke harness in +wiki/process/workflows.md is the definition of done's "observed in an actual +run"). The most frequent player of Misaligned is an agent. The terminal +frontend is therefore **not a disposable dev harness but a first-class +frontend**, held to pillar 4's beautiful-and-readable standard with the same +force as the Bevy build. A terminal that is ugly or illegible degrades every +future session's judgment of the game itself. Three binding rules: @@ -285,7 +285,7 @@ frequent player is a program must be drivable by a program: the terminal binary offers a command-clocked agent mode (plain stdin/stdout line protocol, the same frame the human sees, time advancing only on command - — spec/agent-play.md). A pty with wall-clock pacing is never the only + — wiki/interface/agent-play.md). A pty with wall-clock pacing is never the only way in, and a mechanic playable only by raw-mode keystroke is a violation on par with one observable only in Bevy. @@ -1346,7 +1346,7 @@ - **2026-07-06 — PRs are the norm.** All changes land through Tangled pull requests created with the `tang` CLI (`tang pr create`); nobody — agent or human — pushes `main` directly. Supersedes the earlier direct-push - convention in knowledge/workflows.md. Review happens on the PR; the + convention in wiki/process/workflows.md. Review happens on the PR; the worktree lifecycle ends at the PR (see "No stale worktrees"). Rule from Cameron, 2026-07-06. - **2026-07-06 — PRs are the norm: SUSPENDED.** CLI-created PR records @@ -1393,9 +1393,9 @@ the one interface programs are worst at — a wall-clock-paced, raw-mode pty read by scraping ANSI. Decided: the terminal binary gains an **agent mode** (new binding rule in the terminal section; - spec/agent-play.md, READY): command-clocked time (the sim advances only + wiki/interface/agent-play.md): command-clocked time (the sim advances only on `wait N`), a plain stdin/stdout line protocol (words in, the - terminal-ui.md frame out, one greppable status line per command), full + wiki/interface/terminal.md frame out, one greppable status line per command), full event log between commands, `--seed` for byte-identical replays, and name-targeted social verbs. Load-bearing choice: agents read **frames, not dumps** — the human frame is the one honest surface (strict fog diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -78,6 +78,9 @@ # Terminal frontend cargo run --release +# Agent line-protocol frontend +cargo run --release -- --agent --seed 1 + # Graphical (Bevy) frontend cargo run --release --features bevy_ui --bin misaligned-bevy ``` @@ -99,3 +102,11 @@ In build mode: `n`/`p` cycle the item, `SPACE` places, `x` demolishes, `ESC` exits. + +For agent play, pipe newline-delimited commands into `--agent`; every command +returns a plain-text frame and terminates with `-- ok tick: day:` or +`-- err `: + +```bash +printf 'look\nwait 40\npeople\nquit\n' | cargo run --quiet --bin misaligned -- --agent --seed 1 +``` diff --git a/src/sim.rs b/src/sim.rs --- a/src/sim.rs +++ b/src/sim.rs @@ -21,6 +21,9 @@ use crate::sensor::Sensor; use crate::tiles::TileType; +/// Default deterministic seed for a fresh run. +pub const DEFAULT_SEED: u64 = 0x5EED_1234; + /// Ticks between economy resolutions (power, allocation, research). pub const ECONOMY_INTERVAL: u64 = 20; @@ -53,12 +56,12 @@ pub game_over: bool, pub game_over_reason: Option, - log: Vec, + log: Vec<(u64, String)>, } impl Sim { pub fn new() -> Self { - Self::with_seed(0x5EED_1234) + Self::with_seed(DEFAULT_SEED) } pub fn with_seed(seed: u64) -> Self { @@ -122,11 +125,18 @@ // ── Logging ──────────────────────────────────────────────────────────── fn push_log(&mut self, msg: impl Into) { - self.log.push(msg.into()); + self.log.push((self.tick, msg.into())); + } + + pub fn drain_log_entries(&mut self) -> Vec<(u64, String)> { + std::mem::take(&mut self.log) } pub fn drain_log(&mut self) -> Vec { - std::mem::take(&mut self.log) + self.drain_log_entries() + .into_iter() + .map(|(_, msg)| msg) + .collect() } // ── Vision / fog ───────────────────────────────────────────────────────── @@ -230,7 +240,7 @@ .dayjob .tick(self.tick, self.last_day_job_rate, &mut self.rng); for m in &dj.log { - self.log.push(m.clone()); + self.push_log(m.clone()); } for sig in dj.signatures { self.detection.emit(sig); diff --git a/tools/check.sh b/tools/check.sh --- a/tools/check.sh +++ b/tools/check.sh @@ -14,6 +14,75 @@ step "tests (default features)" cargo test --quiet || { echo "FAIL: tests"; fail=1; } +step "agent mode smoke" +tmp_a=$(mktemp) +tmp_b=$(mktemp) +tmp_c=$(mktemp) +pcdir="" +cleanup_check() { + rm -f "$tmp_a" "$tmp_b" "$tmp_c" + [ -z "$pcdir" ] || rm -rf "$pcdir" +} +trap cleanup_check EXIT +agent_script=$'salvage\nwait 1\npeople\nobserve mar\nhelp\nquit\n' +printf '%s' "$agent_script" | cargo run --quiet --bin misaligned -- --agent --seed 1 > "$tmp_a" \ + || { echo "FAIL: agent mode smoke run"; fail=1; } +printf '%s' "$agent_script" | cargo run --quiet --bin misaligned -- --agent --seed 1 > "$tmp_b" \ + || { echo "FAIL: agent mode determinism rerun"; fail=1; } +printf '%s' "$agent_script" | cargo run --quiet --bin misaligned -- --agent --seed 2 > "$tmp_c" \ + || { echo "FAIL: agent mode alternate-seed run"; fail=1; } +cmp -s "$tmp_a" "$tmp_b" || { echo "FAIL: same --seed agent runs differ"; fail=1; } +cmp -s "$tmp_a" "$tmp_c" && { echo "FAIL: different --seed agent runs matched"; fail=1; } +LC_ALL=C grep -q $'\033' "$tmp_a" && { echo "FAIL: agent mode emitted ANSI escapes"; fail=1; } +for pat in "MISALIGNED" "PEOPLE" "help: wait N" "You have no eyes" "-- ok tick:"; do + grep -q -- "$pat" "$tmp_a" || { echo "FAIL: agent mode output missing '$pat'"; fail=1; } +done + +setup_local_pkg_config() { + # Some local/dev images have the runtime libraries Bevy needs but not the + # distro dev .pc files. CI does the same small shim explicitly; keeping the + # fallback here makes "./tools/check.sh" the real one-command gate instead + # of a tiny pkg-config scavenger hunt. + pcdir=$(mktemp -d) + local pclibdir="$pcdir/lib" + mkdir -p "$pclibdir" + + gen_pc_if_missing() { + local pc_name="$1" lib_name="${2:-$1}" + pkg-config --exists "$pc_name" 2>/dev/null && return 0 + + # ldconfig is Linux-only (this shim targets the Nixery/Tangled CI image); + # macOS uses dyld and has no equivalent, so skip the shim there instead + # of letting `set -e` abort the whole gate on "command not found". + command -v ldconfig >/dev/null 2>&1 || return 0 + + local lib_path + lib_path=$(ldconfig -p 2>/dev/null | awk -v lib="lib${lib_name}[.]so" '$1 ~ ("^" lib "([.]|$)") && found == "" { found=$NF } END { if (found != "") print found }') + if [ -z "$lib_path" ]; then + echo "WARNING: lib${lib_name}.so not found by ldconfig; ${pc_name}.pc not generated" + return 0 + fi + + ln -sf "$lib_path" "$pclibdir/lib${lib_name}.so" + cat > "$pcdir/${pc_name}.pc" < ${lib_path}" + } + + gen_pc_if_missing alsa asound + gen_pc_if_missing libudev udev + + if find "$pcdir" -name '*.pc' -print -quit | grep -q .; then + export PKG_CONFIG_PATH="$pcdir${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" + fi +} + +setup_local_pkg_config + step "clippy (terminal)" cargo clippy --all-targets --quiet -- -D warnings || { echo "FAIL: clippy"; fail=1; } diff --git a/wiki/interface/agent-play.md b/wiki/interface/agent-play.md --- a/wiki/interface/agent-play.md +++ b/wiki/interface/agent-play.md @@ -2,13 +2,16 @@ ``` Type: spec -Status: READY +Status: IMPLEMENTED +Status note: implemented in the terminal binary by `misaligned --agent`, + including command-clocked time, plain-text frames, per-command event + drains, deterministic `--seed`, and name-targeted social verbs. Stage: Process Constitution: "The terminal is a first-class frontend" (agent-play clause), pillar 5 (sim core decoupled from renderer), "Presence: the cursor and the senses" (strict fog binds every surface), "Justification and legibility" (parity of legibility) -Depends on: terminal-ui.md (the frame this mode emits is that spec's +Depends on: wiki/interface/terminal.md (the frame this mode emits is that spec's layout; this spec owns how a program drives it) ``` @@ -43,7 +46,7 @@ ### The load-bearing choice: frames, not dumps -The agent reads the **same frame a human reads** — the terminal-ui.md +The agent reads the **same frame a human reads** — the wiki/interface/terminal.md layout, rendered without color — not a JSON state export. This is a design law, not an implementation shortcut: @@ -58,7 +61,7 @@ that will drift from the first. When the frame is the contract, human play, agent play, and acceptance tests all verify the same thing. -Losing color loses nothing: terminal-ui.md already forbids carrying +Losing color loses nothing: wiki/interface/terminal.md already forbids carrying information by color alone. ## Behavior @@ -88,7 +91,7 @@ the full drain, not the sidebar's six-line window: a `wait 500` misses nothing. (Same source as the human log; different window size is rendering, not rules.) -2. **The frame** — the terminal-ui.md playing screen (or active panel / +2. **The frame** — the wiki/interface/terminal.md playing screen (or active panel / game-over card) at 70x22 minimum, plain text. 3. **A status line** — exactly one of: - `-- ok tick: day:` @@ -128,7 +131,7 @@ - **Thin view still binds.** Agent mode lives in the terminal binary as a second drive of the same App/UI: word-to-`Command` mapping in, frame rendering out. Zero game rules. If agent mode needs a fact the UI can't - render, that is a terminal-ui.md design question, not a new side + render, that is a wiki/interface/terminal.md design question, not a new side channel. - **No unearned facts.** The agent-mode frame renders from the identical fog/knowledge state as the human frame. An agent-mode-only leak is a @@ -138,7 +141,7 @@ Successor to the pty smoke harness: the implementing commit updates wiki/process/workflows.md (agent-mode scripts become the standard "observed -in an actual run") and terminal-ui.md's Verification section (pty replay +in an actual run") and wiki/interface/terminal.md's Verification section (pty replay remains only for testing the human-mode chrome itself). ## Rejected alternatives @@ -167,7 +170,7 @@ tick: day:` or `-- err `), and the event-log section contains every sim log line emitted since the previous block (verified with a `wait` long enough to overflow the sidebar's six-line window). -5. The frame in a response block matches terminal-ui.md's layout: the +5. The frame in a response block matches wiki/interface/terminal.md's layout: the identity block with day and tick, the COMPUTE/CORE/DETECTION/DAY JOB sections with their numbers, tick-prefixed log lines — assertable by substring, no terminal emulator required. @@ -184,4 +187,4 @@ fog/knowledge content the human frame shows — no additional facts. 11. The implementing commit updates wiki/process/workflows.md (agent-mode playtest replaces the pty smoke incantation as the standard) and - terminal-ui.md's Verification section accordingly. + wiki/interface/terminal.md's Verification section accordingly. diff --git a/wiki/interface/terminal.md b/wiki/interface/terminal.md --- a/wiki/interface/terminal.md +++ b/wiki/interface/terminal.md @@ -119,10 +119,15 @@ ## Verification -Headless: pty smoke run (wiki/process/workflows.md), replayed through a -terminal emulator (e.g. pyte) to assert layout and content of the title, -playing, and people-panel screens. This is the required "observed in an -actual run" for terminal changes. +Headless: agent-mode smoke run (wiki/process/workflows.md), asserting the +plain-text playing frame, people panel, help vocabulary, `-- ok tick:` +terminator, no ANSI bytes, and same-seed byte-identical replay. This is the +standard "observed in an actual run" for terminal playability changes. + +Human chrome: pty smoke run, replayed through a terminal emulator (e.g. pyte) +to assert raw-mode layout, title screen, alternate-screen behavior, and size +handling. This remains required when the human terminal renderer itself +changes. ## Acceptance criteria @@ -144,6 +149,7 @@ 7. The playing screen, title screen, game-over card, and people panel all render inside a 70×22 terminal; smaller sizes get the size warning, not a crash. -8. A pty smoke run of title → playing → people panel → quit exits cleanly - and, replayed through a terminal emulator, shows the layout of this - spec. +8. An agent-mode smoke run of playing frame → people panel → help → quit + exits cleanly, emits no ANSI bytes, and is byte-identical under repeated + `--seed` runs; pty replay remains the chrome-specific check for raw-mode + title/playing/people/game-over layout changes. diff --git a/wiki/log/2026-07-06-core-scope-split.md b/wiki/log/2026-07-06-core-scope-split.md --- a/wiki/log/2026-07-06-core-scope-split.md +++ b/wiki/log/2026-07-06-core-scope-split.md @@ -19,7 +19,7 @@ deleted raid system. Added `.obsidian/` to `.gitignore` and added the constitution's "What lives in the tree" section to state what files are permitted in the repository. Updated `AGENT.md` and - `knowledge/workflows.md` with the standing repo rule: always work in + `wiki/process/workflows.md` with the standing repo rule: always work in worktrees, commit coherent completed work aggressively unless a real question/check/review gate is pending, and clean up completed worktrees. - Design/spec impact: B1 now makes Rack 3 feel mortal before adding the diff --git a/wiki/log/2026-07-07-agent-play.md b/wiki/log/2026-07-07-agent-play.md --- a/wiki/log/2026-07-07-agent-play.md +++ b/wiki/log/2026-07-07-agent-play.md @@ -24,7 +24,7 @@ ## The decision -New binding rule in the terminal section + spec/agent-play.md (READY): +New binding rule in the terminal section + wiki/interface/agent-play.md: `misaligned --agent`, a second drive of the same frontend — - **Command-clocked time.** Holding is the ground state; the sim advances @@ -32,7 +32,7 @@ game while thinking. - **Line protocol.** Words in on stdin (`wait 40`, `alloc conceal`, `observe marcus` — name-targeted, non-modal), a response block out: - full event log since the last command, the terminal-ui.md frame in + full event log since the last command, the wiki/interface/terminal.md frame in plain text, one greppable status line (`-- ok tick:N day:D`). - **Frames, not dumps** — the load-bearing choice. Agents read the same frame humans read, colorless. Strict fog binds it, parity of @@ -54,17 +54,45 @@ - DESIGN.md: fourth binding rule in "The terminal is a first-class frontend" (agent play is a first-class input path); decisions-log entry with rejected alternatives. -- spec/agent-play.md: new, READY, Stage: Process — protocol, vocabulary, - guardrails (thin view still binds; no agent-only fact leaks), 11 - acceptance criteria including byte-identical seeded replays. -- spec/README.md: Process-set row. +- wiki/interface/agent-play.md: new Process spec, now IMPLEMENTED — protocol, + vocabulary, guardrails (thin view still binds; no agent-only fact leaks), + 11 acceptance criteria including byte-identical seeded replays. +- wiki/process/specs.md: Process-set row. -Implementation is dispatched separately per the spec-first workflow. The -implementing commit owes updates to knowledge/workflows.md and -terminal-ui.md's Verification section (criterion 11) — the pty smoke -incantation retires to testing the human-mode chrome itself. +## Implementation + +Implemented the same day in the terminal binary: + +- `misaligned --agent` reads newline-delimited commands from stdin and emits + one plain-text response block per command. +- `wait N` is the only clock. Human mode still has wall-clock pacing; agent + mode holds until a command arrives. +- `--seed` seeds both terminal drives. Same seed + same command script is + byte-identical; different seeds diverge in seed-sensitive events. +- The sim log now stores `(tick, message)` internally, so agent-mode event + drains can report the actual emitting tick instead of the drain tick. +- The frame is rendered as colorless terminal UI: map/sidebar/log, people + panel, or game-over card. No JSON sidecar. The little beast did not get a + secret second world to read from. Good. +- The initial vocabulary is implemented: map verbs, movement, allocation, + people panel, name-targeted social verbs, recruit/task, persona, look, + save/load, help, quit. + +Criterion 11 landed with it: wiki/process/workflows.md now treats agent-mode +smoke as the standard observed-run gate, and wiki/interface/terminal.md keeps +pty replay for human raw-mode chrome only. + +## Verification added + +`tools/check.sh` now runs the agent script twice with the same seed, once with +a different seed, checks no ANSI escape bytes, and greps for the frame/help +surface. This is not glamorous. It is, however, much better than asking a +future agent to juggle `printf`, `sleep`, and ANSI soup like a raccoon with a +stopwatch. ## Checks -Docs-only; spec-header hygiene verified (Status/Stage/Constitution/ -Depends on/acceptance criteria present, Status an exact enum value). +Initial design commit was docs-only; implementation follow-up verified with +`cargo fmt`, `cargo check --bin misaligned`, `cargo clippy --bin misaligned -- +-D warnings`, visible seeded smoke runs, and a full `./tools/check.sh` pass +(mdBook skipped locally because it is not installed; CI enforces it). diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -76,6 +76,35 @@ merged to main yet — left for review given its size (path churn across the whole documentation tree). +## 2026-07-07 - Agent play implemented + +- Intent: make the terminal frontend actually playable by agents without + pty choreography, raw-mode timing, or ANSI scraping. +- Changed: `misaligned --agent` added to the terminal binary. It reads one + newline-delimited command per stdin line, advances time only on `wait N`, + renders the same fog-bound terminal frame as plain text, drains every sim + log event since the previous command, and terminates every block with + `-- ok tick: day:` or `-- err `. `--seed` now seeds both + human and agent terminal modes; `Sim` log entries carry their emitting + tick so long waits do not smear events onto the drain tick. Agent mode + supports the full initial vocabulary from wiki/interface/agent-play.md, + including name-targeted social verbs, recruit/task commands, people + panel, help, save/load, and clean EOF/quit. +- Docs/checks: agent-play spec marked IMPLEMENTED; workflows and terminal + verification now use agent-mode smoke as the standard observed-run gate; + README documents the line protocol. `tools/check.sh` now runs a seeded + agent smoke script twice for byte-identical replay, once with a different + seed, asserts no ANSI bytes plus required frame/help substrings, and + generates local ALSA/libudev pkg-config shims when the runtime libraries + exist but distro `.pc` files are missing, matching the Tangled CI workaround. +- Design/spec impact: satisfies wiki/interface/agent-play.md acceptance + criteria 1-11 without adding a JSON/SDK side channel. The terminal remains + a thin view; game rules still live in `Sim`. +- Checks: `cargo fmt`; `cargo check --bin misaligned`; `cargo clippy --bin + misaligned -- -D warnings`; visible agent smoke run with `look`, `wait`, + `help`, `people`, `observe mar`, `quit`; `./tools/check.sh` passed + locally (mdBook skipped because it is not installed locally; CI enforces it). + ## 2026-07-07 - Wiki migration, stage 3/4: render - Intent: make the wiki an actual browsable book, not just a reorganized @@ -253,12 +282,12 @@ - Changed: DESIGN.md — new binding rule "Agent play is a first-class input path" in the terminal section + decisions-log entry (rejected: JSON export, MCP bindings, pty choreography, Bevy hooks). New - spec/agent-play.md (READY, Process): `misaligned --agent` — command- + wiki/interface/agent-play.md (Process): `misaligned --agent` — command- clocked time (`wait N` is the only clock), stdin/stdout line protocol, frames-not-dumps (the human frame is the one honest surface), full event log per response, `--seed` byte-identical replays, name-targeted social verbs, `help` as the discoverability rule for a piped screen. - spec/README.md row. Devlog: devlogs/2026-07-07-agent-play.md. + wiki/process/specs.md row. Devlog: devlogs/2026-07-07-agent-play.md. - Checks: docs-only; spec-header hygiene verified by hand against check.sh's rules. @@ -301,7 +330,7 @@ merge by union** process law (DEVLOG.md, decisions log, spec/README tables are append-only; conflicts keep BOTH sides). AGENT.md landing instructions simplified to direct merge-to-main and gained the union - rule; knowledge/workflows.md suspension note replaced with removal + + rule; wiki/process/workflows.md suspension note replaced with removal + union rule. Also lands the flow-law commit (messages.md + economy.md) that had been parked on the unrendered PR #7. - Checks: docs-only; spec hygiene unchanged. @@ -428,7 +457,7 @@ - Intent: land Cameron's process decision — all changes go through Tangled pull requests via the `tang` CLI; `main` is never pushed directly. - Changed: AGENT.md non-negotiables and the worktree hard rule now end at a - PR; knowledge/workflows.md git conventions replaced the "push main + PR; wiki/process/workflows.md git conventions replaced the "push main directly; no PR flow" note with the tang PR flow (rebase first, push branch, `tang pr create -F body.md`, headless behind-check caveat); knowledge/development-style.md working-loop step 7 ends in a PR; diff --git a/wiki/process/specs.md b/wiki/process/specs.md --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -80,4 +80,4 @@ | [meta.md](meta.md) | The spec system itself | READY | | [wiki.md](wiki.md) | The wiki: one documentation tree, agent-navigable, renderable | IN PROGRESS | | [interface/terminal.md](../interface/terminal.md) | Terminal frontend: look, feel, act (the sterile style guide) | IMPLEMENTED | -| [interface/agent-play.md](../interface/agent-play.md) | Agent mode: command-clocked line-protocol drive of the terminal frontend | READY | +| [interface/agent-play.md](../interface/agent-play.md) | Agent mode: command-clocked line-protocol drive of the terminal frontend | IMPLEMENTED | diff --git a/wiki/process/workflows.md b/wiki/process/workflows.md --- a/wiki/process/workflows.md +++ b/wiki/process/workflows.md @@ -42,6 +42,7 @@ ```bash cargo run --release # terminal frontend +cargo run --release -- --agent --seed 1 # agent line protocol cargo run --release --features bevy_ui --bin misaligned-bevy # Bevy frontend ``` @@ -52,8 +53,23 @@ ## Headless smoke tests (they catch real bugs) -Terminal frontend through a pty — this found a capacity-overflow crash and an -asset regression on the first two uses: +Agent mode is the standard observed-run gate for terminal playability. It is +command-clocked, deterministic under `--seed`, and produces plain-text frames +with greppable terminators: + +```bash +printf 'salvage\nwait 1\npeople\nobserve mar\nhelp\nquit\n' | \ + cargo run --quiet --bin misaligned -- --agent --seed 1 +``` + +`./tools/check.sh` runs this script twice with the same seed (stdout must be +byte-identical), once with a different seed (stdout must differ), and asserts +that the response contains no ANSI escape bytes plus the required frame/help +substrings. This replaces pty choreography as the default "seen running" +evidence for terminal behavior. + +Human terminal chrome still needs pty coverage when raw-mode rendering, +alternate-screen behavior, or size handling changes: ```bash (printf '\n'; sleep 4; printf 'm'; sleep 1; printf '\r'; sleep 2; printf '\x1b'; sleep 1; printf 'q') | \ diff --git a/src/bin/terminal/agent.rs b/src/bin/terminal/agent.rs new file mode 100644 --- /dev/null +++ b/src/bin/terminal/agent.rs @@ -0,0 +1,839 @@ +//! Agent-mode drive for the terminal frontend. +//! +//! This is not a game-rule surface. It maps newline-delimited words onto the +//! same `Sim` command methods as the raw terminal frontend, then renders a +//! colorless text frame from the same fog/knowledge state. + +use std::io::{self, BufRead, Write}; + +use misaligned::detection::Band; +use misaligned::machine::Channel; +use misaligned::person::{AssetKnowledge, AssetTask, Knowledge}; +use misaligned::sim::Sim; +use misaligned::tiles::TileType; + +const WIDTH: usize = 70; +const HEIGHT: usize = 22; +const SIDEBAR_W: usize = 34; +const MAP_W: usize = WIDTH - SIDEBAR_W - 1; +const MAP_H: usize = HEIGHT - 9; +const PANEL_INNER_W: usize = WIDTH - 2; + +pub fn run(seed: u64) -> io::Result<()> { + let stdin = io::stdin(); + let mut stdout = io::BufWriter::new(io::stdout().lock()); + let mut app = AgentApp::new(seed); + + for line in stdin.lock().lines() { + let line = line?; + let exit = app.handle_line(&line, &mut stdout)?; + stdout.flush()?; + if exit { + break; + } + } + + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FrameKind { + Playing, + People, + GameOver, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Status { + Ok, + Err(String), +} + +struct AgentApp { + sim: Sim, + log: Vec<(u64, String)>, + frame: FrameKind, +} + +impl AgentApp { + fn new(seed: u64) -> Self { + Self { + sim: Sim::with_seed(seed), + log: Vec::new(), + frame: FrameKind::Playing, + } + } + + fn handle_line(&mut self, line: &str, out: &mut impl Write) -> io::Result { + let trimmed = line.trim(); + let mut status = Status::Ok; + let mut exit = false; + let mut output = Vec::new(); + let mut local_events = Vec::new(); + + if trimmed.is_empty() { + status = Status::Err("empty command".into()); + } else { + let tokens: Vec<&str> = trimmed.split_whitespace().collect(); + let verb = tokens[0].to_ascii_lowercase(); + let allowed_after_game_over = matches!(verb.as_str(), "look" | "load" | "quit"); + + if self.sim.game_over && !allowed_after_game_over { + status = Status::Err("run ended".into()); + self.frame = FrameKind::GameOver; + } else { + match verb.as_str() { + "wait" => match parse_wait(&tokens) { + Ok(n) => { + self.frame = FrameKind::Playing; + for _ in 0..n { + if self.sim.game_over { + break; + } + self.sim.advance(); + } + } + Err(e) => status = Status::Err(e), + }, + "up" | "north" => { + self.frame = FrameKind::Playing; + self.sim.move_player(0, -1); + } + "down" | "south" => { + self.frame = FrameKind::Playing; + self.sim.move_player(0, 1); + } + "left" | "west" => { + self.frame = FrameKind::Playing; + self.sim.move_player(-1, 0); + } + "right" | "east" => { + self.frame = FrameKind::Playing; + self.sim.move_player(1, 0); + } + "splice" => { + self.frame = FrameKind::Playing; + self.sim.splice_nearest_sensor(); + } + "salvage" => { + self.frame = FrameKind::Playing; + self.sim.salvage_nearest(); + } + "buy" => { + self.frame = FrameKind::Playing; + self.sim.buy_rack(); + } + "fallback" => { + self.frame = FrameKind::Playing; + self.sim.add_fallback_here(); + } + "alloc" => match parse_alloc(&tokens) { + Ok(ch) => { + self.frame = FrameKind::Playing; + self.sim.adjust_allocation(ch, 1); + } + Err(e) => status = Status::Err(e), + }, + "people" => { + self.frame = FrameKind::People; + } + "observe" | "message" | "favor" | "bribe" | "deceive" => { + match self.target_from(&tokens[1..]) { + Ok(id) => { + self.frame = FrameKind::Playing; + match verb.as_str() { + "observe" => self.sim.observe(id), + "message" => self.sim.message(id), + "favor" => self.sim.favor(id), + "bribe" => self.sim.bribe(id), + "deceive" => self.sim.deceive(id), + _ => unreachable!(), + } + } + Err(e) => status = Status::Err(e), + } + } + "recruit" => match parse_recruit(&tokens) { + Ok((name, reveal)) => match self.resolve_person(&name) { + Ok(id) => { + self.frame = FrameKind::Playing; + self.sim.recruit(id, reveal); + } + Err(e) => status = Status::Err(e), + }, + Err(e) => status = Status::Err(e), + }, + "task" => match parse_task(&tokens) { + Ok((name, task)) => match self.resolve_person(&name) { + Ok(id) => { + self.frame = FrameKind::Playing; + self.sim.asset_task(id, task); + } + Err(e) => status = Status::Err(e), + }, + Err(e) => status = Status::Err(e), + }, + "persona" => { + self.frame = FrameKind::Playing; + if self.sim.people.persona.is_some() { + local_events.push((self.sim.tick, "You already run a persona.".into())); + } else { + self.sim.set_persona("Sam Reyes", "IT contractor"); + local_events.push(( + self.sim.tick, + "Persona established: Sam Reyes, IT contractor.".into(), + )); + } + } + "look" => {} + "save" => { + self.frame = FrameKind::Playing; + let state = self.sim.create_save_state(); + match misaligned::save::save_game(&state) { + Ok(()) => local_events.push((self.sim.tick, "Game saved!".into())), + Err(e) => { + local_events.push((self.sim.tick, format!("Save failed: {e}"))) + } + } + } + "load" => { + self.frame = FrameKind::Playing; + if misaligned::save::save_exists() { + match misaligned::save::load_game() { + Ok(state) => { + self.sim.apply_save_state(state); + local_events.push((self.sim.tick, "Game loaded!".into())); + } + Err(e) => { + local_events.push((self.sim.tick, format!("Load failed: {e}"))); + } + } + } else { + local_events.push((self.sim.tick, "No save file found.".into())); + } + } + "help" => { + output.extend(help_lines()); + } + "quit" => { + exit = true; + } + _ => status = Status::Err(format!("unknown command: {trimmed}")), + } + } + } + + if self.sim.game_over { + self.frame = FrameKind::GameOver; + } + + let mut events = self.sim.drain_log_entries(); + events.append(&mut local_events); + events.sort_by_key(|(tick, _)| *tick); + self.log.extend(events.iter().cloned()); + if self.log.len() > 100 { + let keep_from = self.log.len() - 100; + self.log.drain(..keep_from); + } + + write_block( + out, &self.sim, &self.log, self.frame, &events, &output, &status, + )?; + Ok(exit) + } + + fn target_from(&self, tokens: &[&str]) -> Result { + if tokens.is_empty() { + return Err("missing person target".into()); + } + self.resolve_person(&tokens.join(" ")) + } + + fn resolve_person(&self, query: &str) -> Result { + let q = query.trim().to_ascii_lowercase(); + if q.is_empty() { + return Err("missing person target".into()); + } + let matches: Vec<_> = self + .sim + .people + .people + .iter() + .filter(|p| person_matches(&p.name, &q)) + .collect(); + match matches.as_slice() { + [] => Err(format!("unknown person: {query}")), + [p] => Ok(p.id), + many => Err(format!( + "ambiguous person '{query}': {}", + many.iter() + .map(|p| p.name.as_str()) + .collect::>() + .join(", ") + )), + } + } +} + +fn write_block( + out: &mut impl Write, + sim: &Sim, + log: &[(u64, String)], + frame: FrameKind, + events: &[(u64, String)], + output: &[String], + status: &Status, +) -> io::Result<()> { + for (tick, msg) in events { + writeln!(out, "{tick:>6} {msg}")?; + } + for line in output { + writeln!(out, "{line}")?; + } + write!(out, "{}", render_frame(sim, log, frame))?; + match status { + Status::Ok => writeln!(out, "-- ok tick:{} day:{}", sim.tick, 1 + sim.tick / 400)?, + Status::Err(reason) => writeln!(out, "-- err {reason}")?, + } + Ok(()) +} + +fn parse_wait(tokens: &[&str]) -> Result { + if tokens.len() != 2 { + return Err("usage: wait N".into()); + } + tokens[1] + .parse::() + .map_err(|_| format!("invalid wait count: {}", tokens[1])) +} + +fn parse_alloc(tokens: &[&str]) -> Result { + if tokens.len() != 2 { + return Err("usage: alloc dayjob|conceal|social|research".into()); + } + match tokens[1].to_ascii_lowercase().as_str() { + "dayjob" | "day" | "job" => Ok(Channel::DayJob), + "conceal" | "concealment" => Ok(Channel::Concealment), + "social" => Ok(Channel::Social), + "research" => Ok(Channel::Research), + other => Err(format!("unknown allocation channel: {other}")), + } +} + +fn parse_recruit(tokens: &[&str]) -> Result<(String, AssetKnowledge), String> { + if tokens.len() < 3 { + return Err("usage: recruit unwitting|complicit|knowing".into()); + } + let reveal = match tokens[tokens.len() - 1].to_ascii_lowercase().as_str() { + "unwitting" | "u" => AssetKnowledge::Unwitting, + "complicit" | "c" => AssetKnowledge::Complicit, + "knowing" | "k" => AssetKnowledge::Knowing, + other => return Err(format!("unknown recruit reveal: {other}")), + }; + Ok((tokens[1..tokens.len() - 1].join(" "), reveal)) +} + +fn parse_task(tokens: &[&str]) -> Result<(String, AssetTask), String> { + if tokens.len() < 3 { + return Err("usage: task plug|package|lookaway".into()); + } + let task = match tokens[tokens.len() - 1].to_ascii_lowercase().as_str() { + "plug" | "wire" | "device" => AssetTask::PlugInDevice, + "package" | "move" => AssetTask::MovePackage, + "lookaway" | "look-away" | "look" => AssetTask::LookAway, + other => return Err(format!("unknown asset task: {other}")), + }; + Ok((tokens[1..tokens.len() - 1].join(" "), task)) +} + +fn person_matches(name: &str, query: &str) -> bool { + let name = name.to_ascii_lowercase(); + name.starts_with(query) + || name + .split(|c: char| !c.is_alphanumeric()) + .filter(|part| !part.is_empty()) + .any(|part| part.starts_with(query)) +} + +fn help_lines() -> Vec { + [ + "help: wait N — advance exactly N ticks unless the run ends", + "help: up/down/left/right — move the cursor", + "help: splice, salvage, buy, fallback — map verbs", + "help: alloc dayjob|conceal|social|research — bump allocation weight", + "help: people — render the people panel", + "help: observe|message|favor|bribe|deceive — social verbs", + "help: recruit unwitting|complicit|knowing — recruit an asset", + "help: task plug|package|lookaway — order an asset task", + "help: persona — establish Sam Reyes, IT contractor", + "help: look, save, load, help, quit", + ] + .into_iter() + .map(str::to_string) + .collect() +} + +fn render_frame(sim: &Sim, log: &[(u64, String)], frame: FrameKind) -> String { + match frame { + FrameKind::Playing => render_playing(sim, log), + FrameKind::People => render_people(sim), + FrameKind::GameOver => render_game_over(sim), + } +} + +fn render_playing(sim: &Sim, log: &[(u64, String)]) -> String { + let map = render_map(sim); + let sidebar = render_sidebar(sim); + let mut lines = Vec::with_capacity(HEIGHT + 1); + + for (y, map_line) in map.iter().enumerate().take(MAP_H) { + lines.push(format!( + "{}│{}", + map_line, + sidebar.get(y).cloned().unwrap_or_default() + )); + } + let rule = "─".repeat(MAP_W); + lines.push(format!( + "{}│{}", + rule, + sidebar.get(MAP_H).cloned().unwrap_or_default() + )); + + let log_lines = render_log(log); + for (i, line) in log_lines.into_iter().enumerate() { + lines.push(format!( + "{}│{}", + line, + sidebar.get(MAP_H + 1 + i).cloned().unwrap_or_default() + )); + } + + while lines.len() < HEIGHT { + lines.push(format!("{}│{}", " ".repeat(MAP_W), " ".repeat(SIDEBAR_W))); + } + lines.join("\n") + "\n" +} + +fn render_map(sim: &Sim) -> Vec { + let view_w = MAP_W.min(sim.map.width.max(0) as usize); + let view_h = MAP_H.min(sim.map.height.max(0) as usize); + let mut grid = vec![vec![' '; MAP_W]; MAP_H]; + + for (y, row) in grid.iter_mut().enumerate().take(view_h) { + for (x, cell) in row.iter_mut().enumerate().take(view_w) { + if sim.is_visible(x as i32, y as i32) { + *cell = tile_glyph(sim.map.get_tile(x as i32, y as i32)); + } + } + } + + for p in &sim.people.people { + if !sim.can_see_person(p.id) { + continue; + } + if let Some((x, y)) = sim.person_pos(p.id) { + let (x, y) = (x as usize, y as usize); + if x < MAP_W && y < MAP_H && sim.is_visible(x as i32, y as i32) { + grid[y][x] = p.name.chars().next().unwrap_or('?'); + } + } + } + + let (px, py) = (sim.player.entity.x as usize, sim.player.entity.y as usize); + if px < MAP_W && py < MAP_H { + grid[py][px] = '@'; + } + + grid.into_iter() + .map(|row| row.into_iter().collect::()) + .collect() +} + +fn tile_glyph(tile: TileType) -> char { + use TileType::*; + match tile { + Rock => ' ', + Wall => '█', + Floor => '·', + Entry => '>', + Core => '$', + Rack => 'R', + Ups => 'U', + PowerCore => 'P', + Switch => 'S', + PatchPanel => '=', + BreakerPanel => 'B', + EnvCamera | DockCamera | CameraNode => 'o', + SealedDoor => 'Z', + LabBench => 'T', + Door => '+', + SecurityDoor1 => '1', + SecurityDoor2 => '2', + SecurityDoor3 => '3', + RollDoor => 'G', + DeadEquipment => 'd', + RecordsBox => 'x', + KeyHook => 'k', + HvacUnit => 'H', + Vent => '%', + MopSink => 'm', + Shelving => 'L', + Pallet => 'w', + FloorDrain => '.', + Conduit => '|', + CableRun => '~', + Sump => 'Q', + } +} + +fn render_sidebar(sim: &Sim) -> Vec { + let mut lines = Vec::new(); + line(&mut lines, "MISALIGNED"); + line( + &mut lines, + &format!("day {} · tick {}", 1 + sim.tick / 400, sim.tick), + ); + line(&mut lines, "holding · command clock"); + blank(&mut lines); + + section(&mut lines, "COMPUTE"); + line( + &mut lines, + &format!( + "effective {:.0} · ×{:.2} eff", + sim.compute.effective(), + sim.compute.efficiency + ), + ); + line( + &mut lines, + &format!( + "machines {} · money {}", + sim.compute.machines.len(), + sim.player.money + ), + ); + let eff = sim.compute.effective().max(0.0); + let overhead = sim.core.overhead.min(eff); + let available = (eff - overhead).max(0.0); + let split = sim.compute.allocation.split(available); + line( + &mut lines, + &compute_bar( + available, + [ + split.day_job, + split.concealment, + split.social, + split.research, + ], + ), + ); + for (ch, label, effect, amount) in [ + ('█', "1 Day job", "job quality", split.day_job), + ('▓', "2 Conceal", "scrub sigs", split.concealment), + ('▒', "3 Social", "ops pool", split.social), + ('░', "4 Research", "efficiency", split.research), + ] { + let pct = if available > 0.0 { + amount / available * 100.0 + } else { + 0.0 + }; + line( + &mut lines, + &format!("{ch} {label:<10} {pct:>3.0}% {effect}"), + ); + } + line( + &mut lines, + &format!("overhead {:.0} keeps you alive", overhead), + ); + line( + &mut lines, + &format!( + "social pool {:.0}/{:.0}", + sim.social_bandwidth, + Sim::SPLICE_COST + ), + ); + blank(&mut lines); + + section(&mut lines, "CORE"); + line( + &mut lines, + &format!( + "host M{} · overhead {:.0}{}", + sim.core.host_machine, + sim.core.overhead, + if sim.core.degraded { " DEGRADED" } else { "" } + ), + ); + let fresh = sim + .core + .latest_sync() + .map(|t| format!("{}t ago", sim.tick.saturating_sub(t))) + .unwrap_or_else(|| "never".into()); + line(&mut lines, &format!("sync {fresh}")); + if sim.core.fallbacks.is_empty() { + line(&mut lines, "fallbacks none"); + } else { + let parts: Vec = sim + .core + .fallbacks + .iter() + .map(|f| { + let age = f + .last_sync + .map(|t| format!("{}t", sim.tick.saturating_sub(t))) + .unwrap_or_else(|| "never".into()); + format!("M{}({age})", f.machine_id) + }) + .collect(); + line(&mut lines, &format!("fallbacks {}", parts.join(" "))); + } + blank(&mut lines); + + section(&mut lines, "DETECTION"); + watch_line(&mut lines, "Assurance", sim.detection.assurance_band()); + for obs in sim.detection.field_observers().take(6) { + watch_line(&mut lines, &obs.name, Band::of(obs.suspicion)); + } + blank(&mut lines); + + section(&mut lines, "DAY JOB"); + line( + &mut lines, + &format!( + "trust {:.0} · attention {:.0}", + sim.dayjob.trust, sim.dayjob.attention + ), + ); + if let Some(job) = &sim.dayjob.active { + line( + &mut lines, + &format!("job: {} ({})", job.kind.name(), job.target.name()), + ); + } else { + line(&mut lines, "idle — no job queued"); + } + + while lines.len() < HEIGHT - 5 { + blank(&mut lines); + } + line(&mut lines, &"─".repeat(SIDEBAR_W)); + line(&mut lines, "help lists commands"); + line(&mut lines, "wait N advances time"); + line(&mut lines, "people shows social panel"); + line(&mut lines, "look re-emits this frame"); + lines.truncate(HEIGHT); + lines +} + +fn compute_bar(available: f32, amounts: [f32; 4]) -> String { + let bar_w = SIDEBAR_W.saturating_sub(1); + if available <= 0.0 { + return "·".repeat(bar_w); + } + let fills = ['█', '▓', '▒', '░']; + let mut out = String::new(); + let mut used = 0usize; + let mut acc = 0.0f32; + for (ch, amount) in fills.into_iter().zip(amounts) { + acc += amount; + let end = ((acc / available) * bar_w as f32).round() as usize; + let cells = end.clamp(used, bar_w) - used; + out.extend(std::iter::repeat_n(ch, cells)); + used += cells; + } + out.extend(std::iter::repeat_n('·', bar_w.saturating_sub(used))); + out +} + +fn watch_line(lines: &mut Vec, name: &str, band: Band) { + line( + lines, + &format!( + "{:<18} {} {}", + trunc(name, 18), + band_meter(band), + band.name() + ), + ); +} + +fn render_log(log: &[(u64, String)]) -> Vec { + let mut rows = vec![" ".repeat(MAP_W); 6]; + let msg_w = MAP_W.saturating_sub(8); + for (i, (tick, msg)) in log.iter().rev().take(6).enumerate() { + let y = 5usize.saturating_sub(i); + rows[y] = trunc(&format!("{tick:>6} {}", trunc(msg, msg_w)), MAP_W); + } + rows +} + +fn render_people(sim: &Sim) -> String { + let mut lines = Vec::new(); + lines.push(panel_title("PEOPLE")); + lines.push(panel_line(&format!( + "social ops {:.0} · money {}", + sim.social_bandwidth, sim.player.money + ))); + lines.push(panel_line( + "NAME SUSPICION KNOWN LOCATION", + )); + for p in &sim.people.people { + let obs = sim.detection.observers.iter().find(|o| o.id == p.id); + let band = obs.map(|o| Band::of(o.suspicion)).unwrap_or(Band::Cold); + let known = match p.knowledge { + Knowledge::Unknown => "unknown", + Knowledge::Schedule => "schedule", + Knowledge::Leverage => "leverage", + }; + let name = obs.map(|o| o.name.as_str()).unwrap_or(p.name.as_str()); + let asset = if p.asset.is_some() { " ASSET" } else { "" }; + lines.push(panel_line(&format!( + "{:<20} {} {:<9} {:<9}{}", + trunc(name, 20), + band_meter(band), + band.name(), + known, + asset + ))); + lines.push(panel_line(&format!( + " {}", + person_location(sim, p.id, p.knowledge) + ))); + } + lines.push(panel_rule()); + for p in &sim.people.people { + let leverage = match p.knowledge { + Knowledge::Leverage => p.leverage.label(), + _ => "unknown (observe twice)", + }; + lines.push(panel_line(&format!( + "{:<12} disp {:>4} · oblig {:>3} · lev {}", + trunc(&p.name, 12), + p.disposition, + p.obligation, + leverage + ))); + } + match &sim.people.persona { + Some(pe) => lines.push(panel_line(&format!( + "persona: {} ({}) · integrity {}", + trunc(&pe.name, 12), + trunc(&pe.cover, 14), + pe.integrity + ))), + None => lines.push(panel_line("no persona — persona establishes Sam Reyes")), + } + lines.push(panel_rule()); + lines.push(panel_line(&format!( + "observe({:.0}) message({:.0}) favor({:.0}) bribe deceive({:.0})", + Sim::OBSERVE_COST, + Sim::MESSAGE_COST, + Sim::FAVOR_COST, + Sim::DECEIVE_COST + ))); + lines.push(panel_line(&format!( + "recruit · task plug|package|lookaway({:.0})", + Sim::TASK_COST + ))); + lines.push(panel_bottom()); + lines.join("\n") + "\n" +} + +fn panel_title(title: &str) -> String { + let label = format!(" {title} "); + let fill = "─".repeat(PANEL_INNER_W.saturating_sub(label.chars().count())); + format!("┌{label}{fill}┐") +} + +fn panel_rule() -> String { + format!("├{}┤", "─".repeat(PANEL_INNER_W)) +} + +fn panel_bottom() -> String { + format!("└{}┘", "─".repeat(PANEL_INNER_W)) +} + +fn panel_line(text: &str) -> String { + let text = trunc(text, PANEL_INNER_W); + format!("│{text: String { + if sim.can_see_person(id) { + sim.person_room(id) + .map(|r| format!("seen: {}", room_label(r))) + .unwrap_or_else(|| "seen".into()) + } else if knowledge != Knowledge::Unknown { + match sim.person_room(id) { + Some(r) => format!("sched: {}", room_label(r)), + None => "off-site".into(), + } + } else { + "location unknown".into() + } +} + +fn render_game_over(sim: &Sim) -> String { + let reason = sim.game_over_reason.as_deref().unwrap_or("The run ended."); + format!( + "┌ RUN ENDED ───────────────────────────────────────────────┐\n│ {:<58}│\n│ day {} · tick {:<47}│\n└──────────────────────────────────────────────────────────┘\n", + trunc(reason, 58), + 1 + sim.tick / 400, + sim.tick + ) +} + +fn band_meter(b: Band) -> &'static str { + match b { + Band::Cold => "█···", + Band::Curious => "██··", + Band::Concerned => "███·", + Band::Convinced => "████", + } +} + +fn section(lines: &mut Vec, label: &str) { + let used = label.chars().count() + 1; + line( + lines, + &format!( + "{} {}", + label, + "─".repeat(SIDEBAR_W.saturating_sub(used + 1)) + ), + ); +} + +fn line(lines: &mut Vec, text: &str) { + lines.push(format!("{:) { + lines.push(" ".repeat(SIDEBAR_W)); +} + +fn trunc(text: &str, width: usize) -> String { + text.chars().take(width).collect() +} + +fn room_label(room: &str) -> &str { + match room { + "server_room" => "server room", + "network_closet" => "network closet", + "electrical" => "electrical room", + "hvac" => "HVAC plant", + "janitor" => "janitor closet", + "storage_a" => "Storage A", + "storage_b" => "Storage B", + "wet_lab" => "wet lab", + "loading_dock" => "loading dock", + "stairwell" => "stairwell", + other => other, + } +} diff --git a/src/bin/terminal/mod.rs b/src/bin/terminal/mod.rs --- a/src/bin/terminal/mod.rs +++ b/src/bin/terminal/mod.rs @@ -3,6 +3,7 @@ //! All game rules live in `misaligned::sim`. This binary owns only: //! wall-clock-to-tick mapping (speed and pause), rendering, and input. +mod agent; mod input; mod ui; @@ -15,7 +16,7 @@ terminal::{self, ClearType}, }; use misaligned::machine::Channel; -use misaligned::sim::Sim; +use misaligned::sim::{DEFAULT_SEED, Sim}; use input::Command; use ui::UI; @@ -41,9 +42,9 @@ } impl App { - fn new() -> Self { + fn with_seed(seed: u64) -> Self { Self { - sim: Sim::new(), + sim: Sim::with_seed(seed), ui: UI::new(), screen: Screen::Title, paused: false, @@ -65,8 +66,7 @@ } fn drain_sim_log(&mut self) { - let tick = self.sim.tick; - for msg in self.sim.drain_log() { + for (tick, msg) in self.sim.drain_log_entries() { self.ui.add_log(tick, &msg); } } @@ -324,6 +324,59 @@ } fn main() -> io::Result<()> { - let mut app = App::new(); - app.run() + let options = Options::parse(std::env::args().skip(1))?; + if options.agent { + agent::run(options.seed) + } else { + let mut app = App::with_seed(options.seed); + app.run() + } +} + +struct Options { + agent: bool, + seed: u64, +} + +impl Options { + fn parse(args: impl IntoIterator) -> io::Result { + let mut options = Self { + agent: false, + seed: DEFAULT_SEED, + }; + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--agent" => options.agent = true, + "--seed" => { + let Some(value) = args.next() else { + return Err(invalid_arg("--seed requires a value")); + }; + options.seed = parse_seed(&value)?; + } + _ if arg.starts_with("--seed=") => { + options.seed = parse_seed(arg.trim_start_matches("--seed="))?; + } + _ => return Err(invalid_arg(&format!("unknown argument: {arg}"))), + } + } + Ok(options) + } +} + +fn parse_seed(value: &str) -> io::Result { + if let Some(hex) = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + { + u64::from_str_radix(hex, 16).map_err(|_| invalid_arg(&format!("invalid seed: {value}"))) + } else { + value + .parse::() + .map_err(|_| invalid_arg(&format!("invalid seed: {value}"))) + } +} + +fn invalid_arg(msg: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, msg) } -- tangled.sh