diff --git a/CLAUDE.md b/CLAUDE.md
index 9eb08e1..4e3be20 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -35,7 +35,7 @@ Five classes in `core/avatars.ts`: `mage` (๐ง), `tech` (๐ค), `wolf` (๐บ),
## Engine state gating
-Game tick frozen when engine paused, no Claude process connected, or connected process `IDLE`/`STANDBY` + MCP bridge stale (>5 seconds). When frozen, renderer dim map + show `SYSTEM SUSPENDED` overlay. Intentional โ in-game world mirror activity of real Claude process.
+Game tick frozen when engine paused, no Claude process connected, or connected process `IDLE` + MCP bridge stale (>5 seconds). `STANDBY` (CPU 1-15%) treated as alive โ covers API waits + file I/O. When frozen, renderer dim map + show `SYSTEM SUSPENDED` overlay. Intentional โ in-game world mirror activity of real Claude process.
## Conventions
diff --git a/README.md b/README.md
index 8d8b67a..1974c55 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,8 @@
-```
- โโโโโโ โโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโ โโโโโโโโโโโ โโโโโโ โโโโโโโ โโโโโโโ โโโโโโโ
-โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโ โโโโโโโโโโโโโ โโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโ
-โโโโโโโโโโโ โโโโโโโโโโ โโโโโโ โโโ โโโ โโโโโโโโโโโโโโโโโโโโ โโโโโโ โโโโโโโโโโโโโโโโโโโ โโโโ
-โโโโโโโโโโโ โโโโโโโโโ โโโโโโโโโโ โโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโ โโโ
-โโโ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโ โโโ โโโ โโโ โโโโโโโโโ โโโโโโโโโ โโโ โโโโโโ โโโโโโโโโ
-โโโ โโโ โโโโโโโ โโโโโโโโโโโ โโโโโ โโโ โโโ โโโโโโโโโ โโโโโโโโ โโโ โโโโโโ โโโโโโโ
-```
+

+
+# agent-mini-rpg
### ๐ค Terminal multi-agent RPG ยท powered by **Claude Code CLI** ๐ฒ
@@ -16,9 +11,9 @@
[](./LICENSE)
[](https://nodejs.org)
[](https://www.typescriptlang.org)
-[](https://github.com/Mundo-Dev0ps/agent-mini-rpg#compatibility)
-[](https://github.com/Mundo-Dev0ps/agent-mini-rpg/actions)
-[](https://github.com/Mundo-Dev0ps/agent-mini-rpg/pulls)
+[](./docs/COMPATIBILITY.md)
+[](https://github.com/Mundo-Dev0ps/agent-mini-rpg/actions)
+[](./docs/RELEASE.md)
@@ -35,19 +30,16 @@
| ๐ฒ Adventure Mode | ๐ค Agents vs Bugs Mode |
|:-:|:-:|
-|  |  |
+|  |  |
| Forest fantasy ยท quests ยท beasts | Cyber-tech ยท bugs scale per round |
-> **Add your own screenshots**: `scrot` / `screencapture` โ save to `docs/screenshots/`.
-> Record a demo: `asciinema rec docs/screenshots/demo.cast`
-
---
## โจ What is this?
-**agent-mini-rpg** is a terminal tile-grid RPG where **your Claude Code CLI sessions are the characters**.
+Terminal tile-grid RPG where **your Claude Code CLI sessions are the characters**.
```
Claude editing a file โ Agent moves toward resource
@@ -56,54 +48,43 @@
Claude goes idle โ World freezes, map dims
```
-It connects via an **MCP file-bridge** so the MCP server's stdio never collides with the TUI renderer.
-Real tool calls (`Edit`, `Bash`, `Read`, `Grep`, `Task`, โฆ) appear live in the event log.
+Connects via **MCP file-bridge** โ real tool calls (`Edit`, `Bash`, `Read`, `Task`โฆ) appear live in the event log. Game runs standalone without Claude too.
---
-## ๐ฎ Highlights
-
-| Feature | Description |
-|---------|-------------|
-| ๐ฒ๐ค **Two modes** | *Adventure* (forest fantasy) and *Agents vs Bugs* (cyber-tech) โ each with their own avatars, enemies, and HUD |
-| ๐ง๐ง๐ง๐ก๏ธ **4 playable avatars per mode** | Distinct stats, abilities, and aura colors |
-| โ๏ธ **Round-based progression** | Enemies scale per round, concurrent cap enforced, boss waves, final-boss revive |
-| ๐ก **MCP integration** | Real Claude tool calls appear in the event log live |
-| ๐ง **Process-aware engine** | Tick freezes when all linked Claude processes go idle |
-| ๐ **Input-needed alerts** | Audio cue + blinking HUD badge when Claude needs you |
-| ๐ฅ๏ธ **Anti-flicker renderer** | Per-cell cursor positioning โ no full-screen clear |
-| ๐๏ธ **Observer mode** | Spectate agents without controlling a player |
-| ๐ **Night mode** | Toggleable โ dims map, changes border style |
-| โฌ๏ธ **Agent leveling** | Agents gain XP, level up, heal to full, improve ATK/DEF |
-| ๐ **Wave balance** | Concurrent enemy cap + deescalate cooldown after deaths |
+## ๐ฎ Features
+
+| | |
+|--|--|
+| ๐ฒ๐ค Two modes | *Adventure* (forest) and *Agents vs Bugs* (cyber-tech) |
+| ๐ง๐ง๐ง๐ก๏ธ 4 avatars per mode | Distinct stats, abilities, aura colors |
+| โ๏ธ Round-based progression | Enemies scale, boss waves, concurrent cap, deescalate |
+| ๐ก MCP live event log | Every Claude tool call appears in-game |
+| ๐ง Process-aware engine | Tick freezes when all Claude PIDs go idle |
+| ๐ Input-needed alerts | Audio + blinking badge when Claude needs you |
+| ๐๏ธ Observer mode | Spectate agents, no player |
+| ๐ Night mode | Toggleable dim map + star overlay |
+| โฌ๏ธ Agent leveling | XP โ level-up โ full heal + stat boost |
---
## ๐ฒ Adventure Mode
-### Playable Avatars
-
-| Icon | Name | Base Class | Ability | Style |
-|:----:|------|-----------|---------|-------|
-| ๐ง | **Elf** | Scout | **Elven Leap** โ phase through terrain | Agile ยท high dodge ยท high speed |
-| ๐ง | **Wizard** | Mage | **Arcane Spell** โ remote heal on nearby ally | Glass cannon ยท high ATK |
-| ๐ง | **Fairy** | Flyer | **Magic Flight** โ fly over all obstacles | Tank ยท high HP/DEF ยท healer |
-| ๐ก๏ธ | **Knight** | Wolf | **Heroic Charge** โ pack hunt scan radius | Balanced ยท high HP |
+| Icon | Avatar | Ability |
+|:----:|--------|---------|
+| ๐ง | **Elf** | Elven Leap โ phase through terrain |
+| ๐ง | **Wizard** | Arcane Spell โ remote heal on ally |
+| ๐ง | **Fairy** | Magic Flight โ fly over all obstacles |
+| ๐ก๏ธ | **Knight** | Heroic Charge โ pack hunt scan |
-
-
-### Enemies & Bosses
-
-
-
-| Tier | Icons | Example Names | Notes |
-|:----:|:-----:|---------------|-------|
-| **L1** (basic) | ๐บ ๐ฏ | WildWolf, ForestSnake, VineCreeper | Spawns early rounds |
-| **L2** (elite) | ๐ป ๐ฆ | ShadowBeast, MossOgre, ThornHound | More HP + ATK |
-| **L3** (apex) | ๐ฆ ๐ | RootGolem, FrostLynx | Final-round regulars |
-| **Boss** | ๐ ๐ฆ ๐ฆฃ ๐ฆ ๐ฒ | Named boss per wave | High HP, scaled ATK cap |
+| Tier | Icons | Example names |
+|:----:|:-----:|---------------|
+| L1 | ๐บ ๐ฏ | WildWolf, ForestSnake |
+| L2 | ๐ป ๐ฆ | ShadowBeast, MossOgre |
+| L3 | ๐ฆ ๐ | RootGolem, FrostLynx |
+| Boss | ๐ ๐ฆ ๐ฆฃ ๐ฆ ๐ฒ | Named per wave |
@@ -111,52 +92,42 @@ Real tool calls (`Edit`, `Bash`, `Read`, `Grep`, `Task`, โฆ) appear live in the
## ๐ค Agents vs Bugs Mode
-### Playable Avatars
-
-| Icon | Name | Base Class | Ability | Style |
-|:----:|------|-----------|---------|-------|
-| ๐ค | **Robot** | Tech | **Turbo-Deploy** โ x2 speed when BUSY | Balanced ยท steady |
-| ๐ฐ๏ธ | **Drone** | Flyer | **Bypass** โ fly over walls and debris | Scout ยท high evasion |
-| ๐ก๏ธ | **Firewall** | Scout | **Block Packet** โ terrain bypass + block | Defensive ยท resilient |
-| ๐ง | **Debugger** | Mage | **Remote Patch** โ heals nearby allies | Support ยท high ATK |
+| Icon | Avatar | Ability |
+|:----:|--------|---------|
+| ๐ค | **Robot** | Turbo-Deploy โ x2 speed when BUSY |
+| ๐ฐ๏ธ | **Drone** | Bypass โ fly over walls |
+| ๐ก๏ธ | **Firewall** | Block Packet โ terrain bypass + block |
+| ๐ง | **Debugger** | Remote Patch โ heals nearby allies |
-
-
-### Enemies & Bosses
-
-
-
-| Tier | Icons | Example Names | Notes |
-|:----:|:-----:|---------------|-------|
-| **L1** (bug) | ๐ ๐ | TimeoutError, 404_NotFound, TypeError | Common spawns |
-| **L2** (error) | ๐พ ๐ฆ | RaceCondition, MemoryLeak, StackOverflow | Faster, more ATK |
-| **L3** (critical) | ๐ท๏ธ ๐ฆ | SegFault, InfiniteLoop, DeadlockBug | Hard hitters |
-| **Boss** | ๐ฆ ๐ ๐ค ๐น ๐ | Named crash per wave | Max-tier ATK cap |
+| Tier | Icons | Example names |
+|:----:|:-----:|---------------|
+| L1 | ๐ ๐ | TimeoutError, TypeError |
+| L2 | ๐พ ๐ฆ | RaceCondition, MemoryLeak |
+| L3 | ๐ท๏ธ ๐ฆ | SegFault, InfiniteLoop |
+| Boss | ๐ฆ ๐ ๐ค ๐น ๐ | Named per wave |
---
-## ๐บ๏ธ Map Tiles & Items
+## ๐บ๏ธ Map Tiles
-| Tile | Adventure | Bugs mode | Effect |
-|:----:|-----------|-----------|--------|
+| Tile | Adventure | Bugs | Effect |
+|:----:|-----------|------|--------|
| โค๏ธ | Heart | Heart | +10 HP |
| ๐ฅฉ / ๐ | Meat | Battery | Refills hunger |
-| โ๏ธ | Weapon +1 | Crate +1 | ATK +1 |
-| ๐ก๏ธ | Weapon +2 | Hammer +2 | ATK +2 |
+| โ๏ธ | Weapon | Crate | ATK +1 |
+| ๐ก๏ธ | Rare weapon | Hammer | ATK +2 |
| โ | Cure | Cure | Heals |
-| ๐ณ๏ธ | Trap | Trap | -3 HP on trigger |
+| ๐ณ๏ธ | Trap | Trap | โ3 HP |
| ๐ฐ | Gold | Gold | Score |
-| ๐ฒ | Tree | ๐งฑ Wall | Obstacle (bypassable) |
+| ๐ฒ | Tree | ๐งฑ Wall | Obstacle |
| ๐ชจ | Rock | ๐งฑ Wall | Obstacle |
-| ๐งฑ | Brick wall | Brick wall | Built via BUILD action |
-| ๐ / ๐พ | Herb | Disk | Class resource |
-| ๐ / ๐ฟ | Fish | CD | Class resource |
+| ๐งฑ | Brick wall | Brick wall | Built via `BUILD` |
@@ -177,18 +148,13 @@ agent-rpg
git clone https://github.com/Mundo-Dev0ps/agent-mini-rpg.git
cd agent-mini-rpg
npm install
-npm run dev # ts-node, no build step
-# or
-npm run start # compile then run
+npm run dev # ts-node, no build step
```
-On first launch:
-
-1. **Terminal-size gate** โ blocks until window is large enough (resize live to dismiss)
-2. **Mode menu** โ Adventure vs Agents vs Bugs
-3. **Avatar menu** โ 4 choices per mode with stats and ability description
+> **Terminal size:** minimum **113ร35**, recommended **140ร42**.
+> The game shows a resize gate if your window is too small โ just resize to dismiss.
-Navigate with `โ/โ`, confirm `โต`, back `b`, quit `q`.
+On first launch: mode menu โ avatar menu โ game starts. Navigate with `โ/โ โต`, back with `b`, quit with `q`. Press `1`โ`2` in mode menu or `1`โ`6` in avatar menu for instant selection.
---
@@ -196,220 +162,29 @@ Navigate with `โ/โ`, confirm `โต`, back `b`, quit `q`.
| Key | Action |
|:---:|--------|
-| `โ โ โ โ` | Move player |
-| `space` / `โต` | Interact โ talk, pick up, attack adjacent |
-| `a` | Use class ability |
-| `v` | Send handshake to MCP bridge |
-| `r` | Respawn player |
-| `n` | Restart current round |
+| `โ โ โ โ` | Move |
+| `space` / `โต` | Interact (attack, pick up, talk) |
+| `a` | Class ability |
+| `r` | Respawn |
| `p` | Pause / resume |
-| `i` / `tab` | Cycle inspect panel (agent details) |
-| `m` / `esc` | Open settings overlay |
-| `1`โ`5` | Filter event log (all / agents / tools / combat / alerts) |
-| `+` / `-` | Adjust tick speed |
+| `i` / `tab` | Inspect agent panel |
+| `m` / `esc` | Settings overlay |
+| `1`โ`2` / `1`โ`6` | Quick-select in mode / avatar menus |
+| `1`โ`5` | Filter event log (in-game) |
| `s` | Toggle audio |
| `q` | Quit |
---
-## ๐ก MCP Integration
-
-Connect Claude Code so **every tool call appears as a live event**:
-
-```bash
-npm run install-mcp # auto-merges entry into Claude Code config (+ backup)
-# โ or โ
-npm run show-config # prints JSON block to copy manually
-```
-
-Wire the hook scripts in `~/.claude/settings.json`:
-
-```jsonc
-{
- "hooks": {
- "PreToolUse": [{
- "matcher": "Edit|Write|Bash|Read|Grep|Glob|MultiEdit|Task",
- "hooks": [{ "type": "command", "command": "bash /path/to/bin/notify-tool.sh" }]
- }],
- "SubagentStop": [{
- "hooks": [{ "type": "command", "command": "bash /path/to/bin/notify-subagent.sh" }]
- }]
- }
-}
-```
-
-Both channels write to `~/.agent_rpg_sync.json` and `~/.agent_rpg_queue.ndjson`. The game polls every 2 seconds.
-
----
-
-## ๐๏ธ Architecture
-
-```
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-โ index.ts CLI entry, class menu, main loop โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
-โ cli/ ANSI renderer, dashboard, input layer โ
-โ renderer.ts Per-cell CHA positioning, no cls() โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
-โ core/ Domain logic โ zero terminal I/O โ
-โ game.ts Orchestrator: world, agents, rounds โ
-โ world.ts Procedural map gen, water, connectivity โ
-โ agent.ts FSM, XP/level system, pathfinding โ
-โ player.ts Input-driven movement, class abilities โ
-โ brain.ts MockBrain + ClaudeBrain (LLM decisions) โ
-โ process_monitor.ts Claude PID discovery + CPU state โ
-โ bridge.ts .agent_sync.json file-bridge poller โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
-โ mcp-server.ts Standalone MCP server (separate proc) โ
-โ bin/ Hook scripts + npm bin shim โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-```
-
-**Why a file-bridge?** The MCP SDK uses stdio transport โ writing directly would corrupt the TUI output. The bridge decouples both: MCP server writes JSON, game polls the file.
-
-**Anti-flicker pattern**: the renderer writes each row with `\x1b[row;1H` + per-cell `\x1b[colG` cursor-absolute positioning. No `\x1b[2J` full clear in the main loop.
-
-**Engine freeze**: game tick halts when all linked Claude processes are `IDLE`/`STANDBY`/`DISCONNECTED` *and* the MCP bridge file is older than 5 seconds.
-
----
-
-## ๐ฅ๏ธ Compatibility
-
-### Supported AI agents
-
-| Agent | Status | Notes |
-|-------|:------:|-------|
-| **Claude Code CLI** | โ
| Process detection, MCP bridge, PreToolUse / SubagentStop hooks wired |
-| Claude Desktop | โ ๏ธ | MCP entry installs; process detection untested on Desktop |
-| Gemini CLI | โ | No detection or hook adapter yet |
-| Codex CLI | โ | No detection or hook adapter yet |
-| Others | โ | Generic adapter not implemented |
-
-### Operating systems
-
-| OS | Status | Notes |
-|----|:------:|-------|
-| **Linux** | โ
| Tested. Audio: `pw-play` โ `paplay` โ `aplay` |
-| **macOS** | โ
| Audio via `afplay`. `ps -axo` fallback works out of the box |
-| Windows native | โ | POSIX-only: process detection + audio |
-| Windows WSL2 | โ ๏ธ | Expected to work as Linux โ unverified |
-
-### Terminal emulators
-
-| Terminal | Status |
-|----------|:------:|
-| iTerm2, Alacritty, Kitty, GNOME Terminal, Konsole, WezTerm | โ
Best emoji metrics |
-| **VS Code integrated terminal** (Linux / macOS) | โ
|
-| VS Code on Windows (WSL2 profile only) | โ ๏ธ |
-| JetBrains IDE terminal | โ
|
-| Tmux / screen | โ
|
-| Native Windows Terminal (PowerShell / cmd) | โ |
-
-### Requirements
-
-- **Node.js โฅ 20** (LTS recommended)
-- Terminal โฅ **100ร28** columnsรrows (recommended **140ร36**)
-- Optional: `pw-play` / `paplay` / `aplay` (Linux) or `afplay` (macOS) for audio alerts
-- Optional: [Claude Code CLI](https://docs.claude.com) for MCP integration (game runs standalone without it)
-
----
-
-## ๐ง CLI Flags
-
-```bash
-agent-rpg --mode=adventure # skip mode menu
-agent-rpg --mode=bugs # skip mode menu
-agent-rpg --class=wizard # skip avatar menu (adventure)
-agent-rpg --class=robot # skip avatar menu (bugs)
-agent-rpg --observer # spectator mode
-agent-rpg --pacifist # no bug spawning
-agent-rpg --help=classes # list avatar classes
-```
-
-Environment variables: `PLAYER_CLASS`, `GAME_MODE`, `OBSERVER_MODE=1`.
-
----
-
-## โ๏ธ Settings Overlay
-
-Press `m` in-game:
-
-- ๐ Toggle audio
-- ๐จ Toggle color-blind palette
-- ๐๏ธ Toggle observer mode
-- ๐ Toggle night mode
-- ๐ Restart game
-- ๐ Change mode (Adventure / Bugs)
-- ๐ง Change avatar
-
-Navigate with `โ/โ`, confirm `โต`.
-
----
-
-## ๐งช Testing
-
-```bash
-npm test # 43 unit tests via node:test
-npm run typecheck # tsc --noEmit
-```
-
-Tests cover: abilities, world tile rules, player movement (including bypass), wave-cap config, deescalate cooldown, agent leveling, observation radius, and resource detection.
-
----
-
-## ๐ Privacy
-
-Writes **only** to `$HOME`:
-
-- `~/.agent_rpg_sync.json` โ last bridge update (overwritten each tick)
-- `~/.agent_rpg_queue.ndjson` โ pending events (truncated when drained)
-
-Nothing sent over the network. The `ClaudeBrain` LLM class (`core/brain.ts`) is not wired into the default game loop.
-
----
-
-## ๐ Troubleshooting
-
-| Issue | Fix |
-|-------|-----|
-| **No sound on Linux** | Check sink: `wpctl status` / `pactl list sinks short`. Game tries `pw-play โ paplay โ aplay` |
-| **Broken terminal layout** | Use a terminal with 24-bit color + emoji width support |
-| **No Claude PIDs detected** | `npm install ps-list pidusage` for richer process info; fallback uses `ps -axo` |
-| **Running on Windows** | Use WSL2 (untested, expected to work). Native PowerShell will fail |
-| **MCP events missing** | `chmod +x bin/notify-*.sh`; verify `ls -la ~/.agent_rpg_queue.ndjson` is being written |
-
----
-
-## ๐ฆ Release Process
-
-CI runs on every push / PR โ typecheck + build on Ubuntu + macOS, Node 20 and 22.
-
-Publishing is **tag-driven**:
-
-```bash
-npm version patch # bumps version in package.json
-git push --follow-tags # triggers publish workflow on tag v*.*.*
-```
-
-The workflow: verifies tag matches `package.json` โ typecheck + build โ publish to npm with `--provenance` โ creates GitHub Release with auto-generated notes.
-
-### One-time setup
-
-1. Update `repository`, `homepage`, and `bugs` URLs in `package.json` (currently `Mundo-Dev0ps` placeholder)
-2. Add `NPM_TOKEN` to GitHub repo secrets (Settings โ Secrets and variables โ Actions)
-3. Optional: link repo to npm package for OIDC provenance
-
----
-
-## ๐ค Contributing
-
-PRs welcome. Before submitting:
-
-```bash
-npm test && npm run typecheck
-```
+## ๐ Documentation
-Keep emoji tile widths consistent โ most layout issues come from mixing 1-cell and 2-cell emoji in the same grid row.
+| Doc | Contents |
+|-----|----------|
+| [Compatibility](./docs/COMPATIBILITY.md) | Supported agents, OS, terminals, requirements, troubleshooting |
+| [MCP Integration](./docs/MCP.md) | Setup, hook wiring, event log filters, privacy |
+| [Settings & CLI Flags](./docs/SETTINGS.md) | In-game settings, `--flags`, env vars, audio |
+| [Architecture](./docs/ARCHITECTURE.md) | Layer overview, design decisions, engine internals |
+| [Release & Contributing](./docs/RELEASE.md) | CI, npm publish, adding agents/classes, PR guidelines |
---
diff --git a/cli/renderer.ts b/cli/renderer.ts
index d75156e..d7b7873 100644
--- a/cli/renderer.ts
+++ b/cli/renderer.ts
@@ -10,8 +10,9 @@ import {
Weapon,
} from "../core/entity";
import { DIR_ARROW, DIR_EMOJI, Direction } from "../core/direction";
-import { CLASS_SPECS, AuraColor, CharacterClass } from "../core/avatars";
+import { CLASS_SPECS, AuraColor, CharacterClass, canBypass } from "../core/avatars";
import { isAudioEnabled as isAudioEnabledShim } from "../core/audio";
+import { isNotificationsEnabled, setNotificationsEnabled } from "../core/notifications";
import { Theme } from "../core/themes";
import { AgentRole, AgentState, Quest } from "../core/types";
@@ -214,7 +215,7 @@ function atqBar(atq: number, baseline: number = 20, len: number = 6): string {
return `[${chalk.redBright(bar(atq, baseline, len))}]`;
}
-type FloatColorName = "red" | "green" | "cyan" | "yellow" | "magenta" | "white" | "orange";
+type FloatColorName = "red" | "green" | "cyan" | "yellow" | "magenta" | "white" | "orange" | "gray";
function floatStyler(c: FloatColorName, colorBlind: boolean): (s: string) => string {
if (colorBlind) return chalk.bold.whiteBright;
@@ -231,6 +232,8 @@ function floatStyler(c: FloatColorName, colorBlind: boolean): (s: string) => str
return chalk.bold.whiteBright;
case "orange":
return chalk.bold.yellow;
+ case "gray":
+ return chalk.dim.gray;
case "yellow":
default:
return chalk.bold.yellowBright;
@@ -255,23 +258,19 @@ export class Renderer {
colorBlind: boolean = false;
settingsOpen: boolean = false;
pendingMenuRequest: "mode" | "avatar" | null = null;
- eventFilter: "all" | "agents" | "tools" | "combat" | "alerts" = "all";
+ eventFilter: "all" | "agents" | "tools" | "combat" | "system" = "all";
private settingsCursor: number = 0;
cycleEventFilter(): void {
- const order: Array<"all" | "agents" | "tools" | "combat" | "alerts"> = [
- "all",
- "agents",
- "tools",
- "combat",
- "alerts",
+ const order: Array<"all" | "agents" | "tools" | "combat" | "system"> = [
+ "all", "agents", "tools", "combat", "system",
];
const idx = order.indexOf(this.eventFilter);
this.eventFilter = order[(idx + 1) % order.length];
}
setEventFilter(
- f: "all" | "agents" | "tools" | "combat" | "alerts"
+ f: "all" | "agents" | "tools" | "combat" | "system"
): void {
this.eventFilter = f;
}
@@ -286,20 +285,22 @@ export class Renderer {
}
settingsCursorMove(dir: 1 | -1): void {
- const total = 7;
+ const total = 8;
this.settingsCursor = (this.settingsCursor + dir + total) % total;
}
settingsItems(game: Game): string[] {
const aud = isAudioEnabledShim() ? "ON" : "OFF";
+ const notif = isNotificationsEnabled() ? "ON" : "OFF";
const cb = this.colorBlind ? "ON" : "OFF";
const obs = game.observerMode ? "ON" : "OFF";
const night = game.nightMode ? "๐ NIGHT" : "โ DAY";
return [
- `Audio: ${aud}`,
- `Color blind: ${cb}`,
- `Observer: ${obs} (read-only)`,
- `Cycle: ${night}`,
+ `Audio: ${aud}`,
+ `Notifications: ${notif}`,
+ `Color blind: ${cb}`,
+ `Observer: ${obs} (read-only)`,
+ `Cycle: ${night}`,
`๐ Restart game (R1)`,
`๐ Change mode (Adventure/Bugs)`,
`๐ง Change avatar`,
@@ -308,17 +309,18 @@ export class Renderer {
settingsActivate(game: Game, audioToggle: () => void, observerToggle: () => void): void {
if (this.settingsCursor === 0) audioToggle();
- else if (this.settingsCursor === 1) this.toggleColorBlind();
- else if (this.settingsCursor === 2) observerToggle();
- else if (this.settingsCursor === 3) {
+ else if (this.settingsCursor === 1) setNotificationsEnabled(!isNotificationsEnabled());
+ else if (this.settingsCursor === 2) this.toggleColorBlind();
+ else if (this.settingsCursor === 3) observerToggle();
+ else if (this.settingsCursor === 4) {
game.nightMode = !game.nightMode;
- } else if (this.settingsCursor === 4) {
+ } else if (this.settingsCursor === 5) {
game.restart();
this.settingsOpen = false;
- } else if (this.settingsCursor === 5) {
+ } else if (this.settingsCursor === 6) {
this.pendingMenuRequest = "mode";
this.settingsOpen = false;
- } else if (this.settingsCursor === 6) {
+ } else if (this.settingsCursor === 7) {
this.pendingMenuRequest = "avatar";
this.settingsOpen = false;
}
@@ -616,7 +618,6 @@ export class Renderer {
for (const sub of game.subAgents) {
if (sub.state === "done") continue;
if (!game.world.inBounds(sub.pos.x, sub.pos.y)) continue;
- if (!game.isRevealed(sub.pos.x, sub.pos.y)) continue;
if (game.entitiesAt(sub.pos.x, sub.pos.y).length > 0) continue;
const r = VIEW_MAP_TOP + sub.pos.y + 1;
const c = 2 + sub.pos.x * TILE_CELL_WIDTH;
@@ -714,12 +715,12 @@ export class Renderer {
}
private renderEventTabs(active: string): string {
- const tabs: Array<[string, string, string]> = [
- ["all", "[1]ALL", "all"],
- ["agents", "[2]๐คgame", "agents"],
- ["tools", "[3]โ๏ธtools", "tools"],
- ["combat", "[4]โ", "combat"],
- ["alerts", "[5]๐จ", "alerts"],
+ const tabs: Array<[string, string]> = [
+ ["all", "[1]ALL" ],
+ ["agents", "[2]๐คagents" ],
+ ["tools", "[3]โ๏ธtools" ],
+ ["combat", "[4]โcombat" ],
+ ["system", "[5]๐system" ],
];
return tabs
.map(([key, lbl]) =>
@@ -739,20 +740,14 @@ export class Renderer {
);
if (filter === "tools") return events.filter((e) => e.source === "mcp");
if (filter === "combat") return events.filter((e) => e.severity === "combat");
- if (filter === "alerts") return events.filter((e) => e.severity === "error" || e.severity === "warn");
+ if (filter === "system") return events.filter(
+ (e) => e.severity === "system" || e.severity === "info" || e.severity === "error" || e.severity === "warn"
+ );
return events;
}
private pidColorChalk(pid: number | null): (s: string) => string {
- if (pid === null) return chalk.gray;
- const colors = [
- chalk.cyanBright,
- chalk.magentaBright,
- chalk.yellowBright,
- chalk.greenBright,
- chalk.blueBright,
- ];
- return colors[pid % colors.length];
+ return pidColor(pid);
}
private renderEventLine(
@@ -1076,9 +1071,6 @@ export class Renderer {
y: number,
selected: Agent | undefined
): string {
- if (!game.isRevealed(x, y)) {
- return chalk.bgBlack.gray("โโ");
- }
const ents = game.entitiesAt(x, y);
let player: Player | undefined;
@@ -1098,8 +1090,13 @@ export class Renderer {
else if (e instanceof Weapon) weapon = e;
}
+ if (!game.isRevealed(x, y) && !agent && !fairy) {
+ return chalk.bgBlack.gray("โโ");
+ }
+
if (player) {
if (game.observerMode) return this.groundTile();
+ if (player.hp <= 0) return chalk.bgGray.dim("๐") + RESET;
const icon = iconCell(player.characterClass, game.theme);
const justMoved = game.tick - player.lastMoveTick <= 1;
let wrapped: string;
@@ -1121,16 +1118,28 @@ export class Renderer {
const busy = proc ? proc.cpu > 1.0 : false;
const aura = pidColor(agent.linkedPid);
const justMoved = game.tick - agent.lastMoveTick <= 1;
+ const hasWorkingSubs = game.subAgents.some(
+ (s) => s.parentAgentId === agent.id && s.state === "working"
+ );
+ const subPulseOn = hasWorkingSubs && Math.floor(game.tick / 4) % 2 === 0;
+ const flying =
+ canBypass(agent.characterClass) &&
+ !game.world.isWalkable(agent.pos.x, agent.pos.y);
+ const flyPulseOn = flying && Math.floor(game.tick / 2) % 2 === 0;
let icon: string;
- if (game.engineOffline) icon = "โ ๏ธ ";
+ if (agent.hp <= 0 && agent.deadSinceTick >= 0) icon = "๐";
+ else if (game.engineOffline) icon = "โ ๏ธ ";
else if (zombie) icon = "๐ค";
else icon = iconCell(agent.characterClass, game.theme);
let colored: string;
- if (game.engineOffline) colored = chalk.bgRed.white(icon);
+ if (agent.hp <= 0 && agent.deadSinceTick >= 0) colored = chalk.bgGray.dim(icon);
+ else if (game.engineOffline) colored = chalk.bgRed.white(icon);
else if (zombie) colored = chalk.bgRed.dim(icon);
else if (game.tick < agent.damageFlashUntil) colored = chalk.bgRed.bold(icon);
else if (game.tick < agent.pickupFlashUntil) colored = chalk.bgWhite.black.bold(icon);
else if (justMoved) colored = chalk.bgCyan.bold(icon);
+ else if (subPulseOn) colored = chalk.bgCyanBright.bold(icon);
+ else if (flying) colored = (flyPulseOn ? chalk.bgBlueBright : chalk.bgBlue).bold(icon);
else if (sleeping) colored = chalk.dim(icon);
else if (busy) colored = chalk.bgMagenta(aura(icon));
else colored = aura(icon);
@@ -1487,8 +1496,12 @@ export class Renderer {
const cls = classLabel(a.characterClass, game.theme);
const medal = i === 0 ? "๐ฅ" : i === 1 ? "๐ฅ" : i === 2 ? "๐ฅ" : " ";
const pidStr = a.linkedPid !== null ? `pid${a.linkedPid}` : "โ";
+ const subCount = game.subAgents.filter(
+ (s) => s.parentAgentId === a.id && s.state !== "done"
+ ).length;
+ const subBadge = subCount > 0 ? chalk.cyanBright(` ๐งฌ${subCount}`) : "";
lines.push(
- ` ${medal} ${icon} ${chalk.gray(cls.padEnd(7))} ${chalk.gray(pidStr.padEnd(8))} ${chalk.redBright(`${a.kills}k`)}`
+ ` ${medal} ${icon} ${chalk.gray(cls.padEnd(7))} ${chalk.gray(pidStr.padEnd(8))} ${chalk.redBright(`${a.kills}k`)}${subBadge}`
);
}
lines.push(this.sectionFooter(chalk.magentaBright));
@@ -1498,7 +1511,13 @@ export class Renderer {
const a = game.agents[sel];
lines.push("");
lines.push(chalk.bold.white(`Inspect ${trunc(a.name, 20)}`));
- lines.push(` role ${a.role}`);
+ lines.push(` role ${a.role} state ${chalk.cyanBright(a.state())}`);
+ const pidStr = a.linkedPid !== null ? String(a.linkedPid) : "โ";
+ const pidState = a.linkedPid !== null ? game.monitor.pidState(a.linkedPid) : "n/a";
+ lines.push(` pid ${chalk.gray(pidStr)} pidState ${chalk.yellow(pidState)}`);
+ const mcpStr = game.mcpConnected ? (game.mcpFresh(5000) ? "fresh" : "stale") : "off";
+ lines.push(` mcp ${chalk.gray(mcpStr)} lastAct ${chalk.gray(trunc(game.lastMcpAction || "-", 24))}`);
+ lines.push(` ${chalk.gray("why:")} ${chalk.white(trunc(a.reasoning || "-", REASON_MAX))}`);
if (a.log.length > 0) {
lines.push(chalk.gray(" recent:"));
for (const l of a.log) {
diff --git a/core/agent.ts b/core/agent.ts
index 711bd4a..e18a79e 100644
--- a/core/agent.ts
+++ b/core/agent.ts
@@ -226,13 +226,12 @@ export class Agent extends Entity {
this.pendingDecision = null;
return;
}
- if (ctx.mcpConnected) {
- if (!ctx.mcpFresh) {
- this.fsm.force("sleep");
- this.reasoning = `๐ค MCP idle โ no recent telemetry from PID ${proc.pid}`;
- this.path = [];
- return;
- }
+ const pidState = ctx.monitor.pidState(proc.pid);
+ if (pidState === "ACTIVE") {
+ // CPU evidence โ proceed regardless of MCP staleness
+ } else if (pidState === "STANDBY") {
+ // STANDBY = CPU activity 1-15%, treat as alive
+ } else if (ctx.mcpConnected && ctx.mcpFresh) {
if (MCP_IDLE_RE.test(ctx.mcpLastAction)) {
this.fsm.force("sleep");
this.reasoning = `๐ค MCP says "${ctx.mcpLastAction}" โ sitting`;
@@ -240,16 +239,10 @@ export class Agent extends Entity {
return;
}
} else {
- if (!ctx.monitor.isPidActive(proc.pid)) {
- const stateLabel = ctx.monitor.pidState(proc.pid);
- this.fsm.force(stateLabel === "STANDBY" ? "thinking" : "sleep");
- this.reasoning =
- stateLabel === "STANDBY"
- ? `Standby... PID ${proc.pid} cpu ${proc.cpu.toFixed(1)}% (debouncing)`
- : `๐ค idle... PID ${proc.pid} cpu ${proc.cpu.toFixed(1)}%`;
- this.path = [];
- return;
- }
+ this.fsm.force("sleep");
+ this.reasoning = `๐ค idle... PID ${proc.pid} cpu ${proc.cpu.toFixed(1)}%`;
+ this.path = [];
+ return;
}
} else {
if (ctx.monitor.processes.length === 0) {
@@ -396,6 +389,9 @@ export class Agent extends Entity {
this.fsm.force("moving");
this.recomputePath(world, target);
this.stepPath(world, occupied, ctx.tick);
+ if (this.path.length === 0) {
+ this.currentDecision = null;
+ }
}
}
@@ -415,7 +411,6 @@ export class Agent extends Entity {
return (
t === "%" ||
t === "M" ||
- t === "+" ||
t === "E" ||
t === "$" ||
t === "H"
diff --git a/core/brain.ts b/core/brain.ts
index 637c292..52c81f1 100644
--- a/core/brain.ts
+++ b/core/brain.ts
@@ -147,8 +147,23 @@ export class MockBrain implements Brain {
return this.pack("COLLECT", res.pos, thought, inputBlob);
}
- const thought = `${op}, map quiet. Holding position.`;
- return this.pack("WAIT", undefined, thought, inputBlob);
+ const dirs = [
+ { dx: 0, dy: -1 },
+ { dx: 0, dy: 1 },
+ { dx: -1, dy: 0 },
+ { dx: 1, dy: 0 },
+ { dx: -2, dy: 0 },
+ { dx: 2, dy: 0 },
+ { dx: 0, dy: -2 },
+ { dx: 0, dy: 2 },
+ ];
+ const pick = dirs[Math.floor(Math.random() * dirs.length)];
+ const wanderTarget: Position = {
+ x: obs.current_pos.x + pick.dx,
+ y: obs.current_pos.y + pick.dy,
+ };
+ const thought = `${op}, map quiet. Wandering to (${wanderTarget.x},${wanderTarget.y}).`;
+ return this.pack("MOVE", wanderTarget, thought, inputBlob);
}
private pack(
@@ -299,7 +314,6 @@ export function buildObservation(
else if (t === "M") obsResources.push({ kind: "meat", pos: { x, y }, distance: d });
else if (t === "E") obsResources.push({ kind: "energy", pos: { x, y }, distance: d });
else if (t === "H") obsResources.push({ kind: "heart", pos: { x, y }, distance: d });
- else if (t === "+") obsResources.push({ kind: "cure", pos: { x, y }, distance: d });
}
}
obsResources.sort((a, b) => a.distance - b.distance);
diff --git a/core/game.ts b/core/game.ts
index 4771330..75048a4 100644
--- a/core/game.ts
+++ b/core/game.ts
@@ -27,6 +27,7 @@ import {
import { Bridge, BridgePayload } from "./bridge";
import { ProcessMonitor, ProcessState } from "./process_monitor";
import { playNeedsInput, setAudioTheme } from "./audio";
+import { sendNeedsInputNotification } from "./notifications";
import {
CharacterClass,
CLASS_SPECS,
@@ -67,7 +68,8 @@ export type FloatColor =
| "yellow"
| "magenta"
| "white"
- | "orange";
+ | "orange"
+ | "gray";
export interface GameOptions {
width?: number;
@@ -250,7 +252,6 @@ export class Game {
this.deescalateUntil = 0;
this.weather = "clear";
this.escalation = 0;
- this.fogEnabled = true;
this.revealed = new Set();
this.fairyCooldownUntil = 0;
this.fairyDespawnAt = 0;
@@ -272,6 +273,7 @@ export class Game {
this.mode = opts.mode ?? "bugs";
this.theme = themeFor(this.mode);
setAudioTheme(this.mode);
+ this.fogEnabled = this.mode === "adventure";
this.crashed = false;
this.crashedAt = 0;
this.fairyOffline = false;
@@ -823,6 +825,8 @@ export class Game {
this.agentDeathTicks = [];
this.deescalateUntil = 0;
this.killTicks = [];
+ this.fogEnabled = this.mode === "adventure";
+ this.revealed = new Set();
this.bootstrap();
this.maybeAutoHandshake();
this.pushEvent(`SYSTEM REBOOT โ fresh deploy for ${this.identity.displayName}`);
@@ -1045,9 +1049,19 @@ export class Game {
const m = msg.toLowerCase();
if (m.includes("error") || m.includes("disconnect") || m.includes("crash") || m.includes("fail")) return "error";
if (m.includes("idle") || m.includes("waiting input") || m.includes("warn") || m.includes("stale")) return "warn";
- if (m.includes("conflict") || m.includes("โ") || m.includes("hits") || m.includes("kill") || m.includes("boss") || m.includes("crit")) return "combat";
+ if (
+ m.includes("๐") || m.includes("โ") || m.includes("hits") || m.includes("kill") ||
+ m.includes("boss") || m.includes("crit") || m.includes("damage") || m.includes("atk") ||
+ m.includes("matรณ") || m.includes("drop") || m.includes("revive") || m.includes("fell") ||
+ m.includes("down โ") || m.includes("respawn") || m.includes("zombie")
+ ) return "combat";
if (m.includes("claude-") || m.includes("๐ก") || m.includes("mcp")) return "agent";
- if (m.includes("victory") || m.includes("clear") || m.includes("level") || m.includes("โ")) return "info";
+ if (
+ m.includes("round") || m.includes("victory") || m.includes("clear") ||
+ m.includes("level") || m.includes("โ") || m.includes("โ
") || m.includes("system") ||
+ m.includes("reboot") || m.includes("spawn") || m.includes("wave") || m.includes("โณ") ||
+ m.includes("โธ") || m.includes("stable")
+ ) return "system";
return "system";
}
@@ -1312,6 +1326,23 @@ export class Game {
}
step(): void {
+ if (process.env.DEBUG_AGENTS === "1" && this.tick % 20 === 0) {
+ try {
+ const fs = require("fs") as typeof import("fs");
+ const lines: string[] = [`=== tick ${this.tick} mcpConnected=${this.mcpConnected} mcpFresh=${this.mcpFresh(5000)} lastAct="${this.lastMcpAction}" ===`];
+ for (const a of this.agents) {
+ const pid = a.linkedPid;
+ const ps = pid !== null ? this.monitor.pidState(pid) : "n/a";
+ const proc = pid !== null ? this.monitor.getByPid(pid) : null;
+ const cpu = proc ? proc.cpu.toFixed(1) : "-";
+ const tile = this.world.tiles[a.pos.y]?.[a.pos.x] ?? "?";
+ const cls = a.characterClass;
+ const bypass = ["scout", "flyer"].includes(cls) ? "bypass" : "no-bypass";
+ lines.push(` ${a.name}(${cls}/${bypass}) pid=${pid ?? "โ"} pidState=${ps} cpu=${cpu}% state=${a.state()} hp=${a.hp}/${a.maxHp} pos=(${a.pos.x},${a.pos.y}) tile='${tile}' why="${a.reasoning}"`);
+ }
+ fs.appendFileSync("/tmp/agent-rpg-debug.log", lines.join("\n") + "\n");
+ } catch { /* ignore */ }
+ }
if (this.crashed) {
if (Date.now() - this.crashedAt >= CRASH_RESET_MS) this.reset();
return;
@@ -1727,6 +1758,11 @@ export class Game {
a.hp = Math.max(1, Math.floor(a.maxHp / 2));
a.deadSinceTick = -1;
a.invulnUntilTick = this.tick + 30;
+ a.path = [];
+ a.currentDecision = null;
+ a.pendingDecision = null;
+ a.fsm.force("idle");
+ a.reasoning = "respawned, re-evaluating";
this.pushEvent(
`โจ ${a.name} respawned (death#${a.deathCount}, HP+${Math.round((buffMult - 1) * 100)}% โ invuln 30t)`,
a.linkedPid
@@ -1881,6 +1917,7 @@ export class Game {
if (this.player.hp > 0) return;
if (this.playerDeadAt === 0) {
this.playerDeadAt = Date.now();
+ this.pushFloatingText("๐", this.player.pos, 2500, "white");
this.pushEvent(`๐ [PLAYER] down โ respawn 3s...`);
return;
}
@@ -2311,7 +2348,7 @@ export class Game {
const cls = CLASS_SPECS[this.player.characterClass].label;
const reason = inSafe ? " (safe zone slow drain)" : "";
this.pushEvent(
- `${this.helperIcon()}: ยก${this.identity.displayName}, el ${cls} is hungry!${reason}`
+ `${this.helperIcon()}: ยก${this.identity.displayName}, ${this.mode === "bugs" ? `el ${cls} battery crรญtica!` : `el ${cls} is hungry!`}${reason}`
);
}
}
@@ -2371,6 +2408,7 @@ export class Game {
if (prev !== cur) {
if (prev === "ACTIVE" && cur === "IDLE") {
playNeedsInput();
+ sendNeedsInputNotification(a.name, String(a.linkedPid));
a.needsInput = true;
a.needsInputSinceMs = Date.now();
a.bashActive = false;
@@ -2390,6 +2428,7 @@ export class Game {
} else if (cur === "IDLE" && prev !== undefined) {
if (!a.needsInput) {
playNeedsInput();
+ sendNeedsInputNotification(a.name, String(a.linkedPid));
a.pushAction(this.tick, "๐ก", "IDLE wait");
this.pushEvent(
`๐ก ${a.name} IDLE โ waiting your input`,
@@ -2418,7 +2457,11 @@ export class Game {
agentIsFrozen(agent: Agent): boolean {
if (agent.linkedPid === null) return false;
const s = this.monitor.pidState(agent.linkedPid);
- return s === "IDLE" || s === "STANDBY" || s === "DISCONNECTED";
+ if (s === "DISCONNECTED") return true;
+ if (s === "ACTIVE") return false;
+ if (s === "STANDBY") return false;
+ // IDLE: freeze only when MCP bridge is also stale
+ return !this.mcpFresh(5000);
}
agentIsInvulnerable(agent: Agent): boolean {
@@ -2746,6 +2789,7 @@ export class Game {
this.pushFloatingText("REVIVE", bug.pos, 1500, "red");
continue;
}
+ this.pushFloatingText("โ", bug.pos, 350, "gray");
const dropTiles = this.dropItemsAroundBug(bug);
this.bugsKilled += 1;
this.killTicks.push(this.tick);
@@ -2829,6 +2873,7 @@ export class Game {
);
}
}
+ this.pushFloatingText("๐", a.pos, 1800, "white");
a.lastDamageTick = this.tick;
a.deadSinceTick = this.tick;
if (a.autoDeploys > 0) {
diff --git a/core/notifications.ts b/core/notifications.ts
new file mode 100644
index 0000000..0ac65db
--- /dev/null
+++ b/core/notifications.ts
@@ -0,0 +1,46 @@
+import { spawn } from "child_process";
+import * as os from "os";
+
+const MIN_GAP_MS = 10_000;
+const lastNotifiedAt = new Map();
+let enabled = true;
+
+export function setNotificationsEnabled(on: boolean): void {
+ enabled = on;
+}
+
+export function isNotificationsEnabled(): boolean {
+ return enabled;
+}
+
+function detached(file: string, args: string[]): void {
+ try {
+ const child = spawn(file, args, { detached: true, stdio: "ignore" });
+ child.on("error", () => {});
+ child.unref();
+ } catch {
+ /* swallow */
+ }
+}
+
+export function sendNeedsInputNotification(agentName: string, agentKey: string): void {
+ if (!enabled) return;
+ const now = Date.now();
+ const last = lastNotifiedAt.get(agentKey) ?? 0;
+ if (now - last < MIN_GAP_MS) return;
+ lastNotifiedAt.set(agentKey, now);
+
+ const title = "โ Agent Mini RPG";
+ const body = `${agentName} is waiting for your input`;
+ const platform = os.platform();
+
+ if (platform === "darwin") {
+ const script = `display notification "${body}" with title "${title}"`;
+ detached("/bin/sh", ["-c", `osascript -e '${script}' 2>/dev/null`]);
+ } else if (platform === "linux") {
+ detached("/bin/sh", [
+ "-c",
+ `notify-send "${title}" "${body}" --expire-time=5000 --urgency=normal 2>/dev/null`,
+ ]);
+ }
+}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..2ca76df
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,145 @@
+# Architecture
+
+โ [Back to README](../README.md)
+
+## Layer overview
+
+```
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โ index.ts CLI entry, class menu, main loop โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
+โ cli/ ANSI renderer, dashboard, input โ
+โ renderer.ts Per-cell CHA positioning, no cls() โ
+โ input.ts Raw-mode key handler โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
+โ core/ Domain logic โ zero terminal I/O โ
+โ game.ts Orchestrator: world, agents, rounds โ
+โ world.ts Procedural map gen, connectivity โ
+โ agent.ts FSM, XP/level system, pathfinding โ
+โ player.ts Input-driven movement + abilities โ
+โ brain.ts MockBrain + ClaudeBrain decisions โ
+โ avatars.ts Class specs, abilities, diet rules โ
+โ themes.ts Adventure / Bugs tile + icon sets โ
+โ process_monitor.ts Claude PID discovery + CPU state โ
+โ bridge.ts .agent_sync.json file-bridge poller โ
+โ entity.ts Base entity types (Bug, NPC, Weaponโฆ) โ
+โ pathfinding.ts BFS with bypass option โ
+โ direction.ts Direction helpers + arrow chars โ
+โ quest.ts Quest board logic โ
+โ identity.ts Hostname / username resolution โ
+โ fsm.ts Finite state machine โ
+โ mcp.ts MCP server helpers โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
+โ mcp-server.ts Standalone MCP server (separate proc) โ
+โ show-config.ts Print MCP config snippet โ
+โ bin/ Hook scripts + npm bin shim โ
+โ scripts/ setup-mcp.ts (config auto-installer) โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+```
+
+---
+
+## Key design decisions
+
+### File-bridge for MCP
+
+The MCP SDK communicates over stdio. If the MCP server ran in-process it would corrupt the TUI output. Solution: MCP server runs as a separate process, writes JSON to `~/.agent_rpg_sync.json`, and the game's `Bridge` polls every 2 seconds.
+
+```
+MCP server (stdio) โ ~/.agent_rpg_sync.json โ Bridge.poll() โ game events
+Hook scripts โ ~/.agent_rpg_queue.ndjson โ Bridge.drain() โ game events
+```
+
+### Anti-flicker renderer
+
+No `\x1b[2J` (full clear) in the main loop. Every frame:
+1. Each map row written at absolute cursor position `\x1b[row;1H`
+2. Each cell within a row uses `\x1b[colG` (CHA) โ locks horizontal position regardless of emoji width variance
+3. `\x1b[K` (CLEAR_EOL) erases stale content to the right
+4. All output buffered in a string array and written in a single `process.stdout.write()` call
+
+### Engine freeze
+
+Global tick halts when **all** of:
+- Every linked Claude process is `IDLE` / `STANDBY` / `DISCONNECTED`
+- MCP bridge file is older than 5 seconds
+
+Map dims, `SYSTEM SUSPENDED` overlay appears. No game state changes while frozen.
+
+Individual agent freeze (per-agent movement halts) when:
+- PID is `DISCONNECTED`, **or**
+- PID is `IDLE` **and** MCP bridge is stale (>5 s)
+
+`ACTIVE` and `STANDBY` never freeze โ both indicate CPU activity. `STANDBY` (1-15% CPU) covers API waits and file I/O. Only true `IDLE` (<1% CPU) without MCP signal is treated as no-activity.
+
+Fog of war: revealed area only follows the player. Agents and fairies are rendered above fog so they never disappear when wandering outside the player's vision radius.
+
+### Process state classification
+
+`ProcessMonitor` polls running processes every ~500ms and classifies each PID:
+
+| State | CPU threshold | Notes |
+|-------|:------------:|-------|
+| `ACTIVE` | > 15% for 1.5 s | Claude doing CPU work |
+| `STANDBY` | 1โ15% | File I/O, API wait, low activity |
+| `IDLE` | < 1% sustained | Truly idle, waiting for input |
+| `DISCONNECTED` | not found | Process terminated |
+
+### Fog of war
+
+| Mode | Default |
+|------|---------|
+| Adventure | Enabled โ unrevealed tiles show `โโ`, radius 8 around player |
+| Bugs | Disabled โ full map visible from start |
+
+Toggle in-game with the `๐ALL` indicator (via `toggleFog()`). When fog is off, the HUD shows a `๐ALL` badge.
+
+### Agent FSM states
+
+```
+idle โ moving โ working โ idle
+ โ fighting
+ โ sleep (Claude process idle)
+ โ thinking (decision pending)
+ โ zombie (linked PID terminated)
+```
+
+### Agent leveling
+
+XP threshold: `15 ร current_level`. On level-up:
+- Full HP heal
+- `maxHp += 5`
+- `atq = min(20, atq + 1)`
+- `def = min(20, def + 1)`
+
+### Wave balance
+
+- `concurrentMax` per round: 4 / 5 / 6 / 7 / 8
+- Deescalate trigger: 3+ deaths in 60 ticks โ `deescalateUntil = tick + 100`, bug ATQ reset
+
+### Escalation indicator (`๐ฅLx` HUD badge)
+
+Global counter, both modes. Tracks kills in rolling 60-tick window:
+
+| Recent kills | Escalation |
+|:---:|:---:|
+| 2 | L1 |
+| 4 | L2 |
+| 6 | L3 |
+| 9 | L4 |
+| 12 | L5 |
+
+Decays 1 level every 30 ticks when kill rate drops. Bugs mode triggers higher levels more often due to denser spawn rate โ by design, not asymmetry.
+
+---
+
+## Testing
+
+```bash
+npm test # 43 unit tests via node:test + ts-node
+npm run typecheck # tsc --noEmit
+```
+
+Tests live in `tests/*.test.ts` and use the built-in `node:test` runner. No external test framework.
+
+Coverage areas: abilities (all 4 classes), world tile rules, player movement + bypass, wave-cap config, deescalate cooldown, agent leveling, observation radius, resource detection.
diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md
new file mode 100644
index 0000000..bc58d3e
--- /dev/null
+++ b/docs/COMPATIBILITY.md
@@ -0,0 +1,65 @@
+# Compatibility
+
+โ [Back to README](../README.md)
+
+## Supported AI agents
+
+| Agent | Status | Notes |
+|-------|:------:|-------|
+| **Claude Code CLI** | โ
| Process detection, MCP bridge, PreToolUse / SubagentStop hooks wired |
+| Claude Desktop | โ ๏ธ | MCP entry installs; process detection untested on Desktop |
+| Gemini CLI | โ | No detection or hook adapter yet |
+| Codex CLI | โ | No detection or hook adapter yet |
+| Others | โ | Generic adapter not implemented |
+
+Adding another agent requires: a name matcher in `ProcessMonitor`, hook scripts that write to `~/.agent_rpg_queue.ndjson` in the same JSON line format, and optionally a label entry.
+
+---
+
+## Operating systems
+
+| OS | Status | Notes |
+|----|:------:|-------|
+| **Linux** | โ
| Tested. Audio: `pw-play` โ `paplay` โ `aplay` |
+| **macOS** | โ
| Audio via `afplay`. `ps -axo` fallback works out of the box |
+| Windows native | โ | POSIX-only: process detection + audio path |
+| Windows WSL2 | โ ๏ธ | Expected to work as Linux โ unverified |
+
+---
+
+## Terminal emulators
+
+The game is a POSIX TUI โ any terminal with 24-bit color, raw-mode key passthrough, and emoji support works.
+
+| Terminal | Status | Notes |
+|----------|:------:|-------|
+| iTerm2, Alacritty, Kitty, GNOME Terminal, Konsole, WezTerm | โ
| Best emoji metrics |
+| **VS Code integrated terminal** (Linux / macOS) | โ
| |
+| VS Code on Windows (WSL2 profile only) | โ ๏ธ | PowerShell / cmd profiles will fail |
+| JetBrains IDE terminal (Linux / macOS) | โ
| Same caveats as VS Code |
+| Tmux / screen | โ
| Inherits outer terminal capabilities |
+| Native Windows Terminal (PowerShell / cmd) | โ | POSIX assumptions break |
+
+> **Emoji alignment note:** VS Code's webview renderer may show wider emoji differently from native terminals. Mixing 1-cell and 2-cell emoji in the same grid row can misalign the map. The renderer uses `\x1b[colG` per-cell absolute positioning to mitigate this, but visual results vary by emulator.
+
+---
+
+## Requirements
+
+- **Node.js โฅ 20** (LTS recommended)
+- Terminal โฅ **100ร28** columnsรrows (recommended **140ร36**)
+- Optional: `pw-play` / `paplay` / `aplay` (Linux) or `afplay` (macOS) for audio alerts
+- Optional: `ps-list` + `pidusage` npm packages for richer process info (`npm install ps-list pidusage`)
+- Optional: [Claude Code CLI](https://docs.claude.com) โ game runs standalone without it
+
+---
+
+## Troubleshooting
+
+| Issue | Fix |
+|-------|-----|
+| **No sound on Linux** | Check sink: `wpctl status` / `pactl list sinks short`. Game tries `pw-play โ paplay โ aplay` |
+| **Broken terminal layout** | Use a terminal with 24-bit color + emoji width support |
+| **No Claude PIDs detected** | `npm install ps-list pidusage` for richer process info; fallback uses `ps -axo` |
+| **Running on Windows** | Use WSL2 (untested, expected to work). Native PowerShell will fail |
+| **MCP events missing** | `chmod +x bin/notify-*.sh`; verify `ls -la ~/.agent_rpg_queue.ndjson` is being written |
diff --git a/docs/MCP.md b/docs/MCP.md
new file mode 100644
index 0000000..55d3ce2
--- /dev/null
+++ b/docs/MCP.md
@@ -0,0 +1,101 @@
+# MCP Integration
+
+โ [Back to README](../README.md)
+
+The game connects to Claude Code via two channels that both write to the same files in `$HOME`:
+
+```
+Claude Code process
+ โโโ MCP server (stdio) โ writes ~/.agent_rpg_sync.json
+ โโโ Hook scripts โ appends ~/.agent_rpg_queue.ndjson
+
+Game process
+ โโโ Bridge poller (2s) โ reads both files โ renders in event log
+```
+
+**Why a file-bridge?** The MCP SDK uses stdio transport. Writing directly to stdout would corrupt the TUI. The file decouples both sides cleanly.
+
+---
+
+## Auto-install
+
+```bash
+npm run install-mcp
+```
+
+Merges the MCP server entry into your Claude Code or Claude Desktop config file. Creates a timestamped backup if the file is unparseable.
+
+---
+
+## Manual config
+
+```bash
+npm run show-config
+```
+
+Prints a JSON block to copy into your Claude Code MCP settings manually.
+
+---
+
+## Hook scripts
+
+Wire `bin/notify-tool.sh` and `bin/notify-subagent.sh` in `~/.claude/settings.json`:
+
+```jsonc
+{
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Edit|Write|Bash|Read|Grep|Glob|MultiEdit|Task",
+ "hooks": [
+ { "type": "command", "command": "bash /path/to/agent-mini-rpg/bin/notify-tool.sh" }
+ ]
+ }
+ ],
+ "SubagentStop": [
+ {
+ "hooks": [
+ { "type": "command", "command": "bash /path/to/agent-mini-rpg/bin/notify-subagent.sh" }
+ ]
+ }
+ ]
+ }
+}
+```
+
+Make sure the scripts are executable:
+
+```bash
+chmod +x bin/notify-tool.sh bin/notify-subagent.sh
+```
+
+---
+
+## Event log filters
+
+In-game press `1`โ`5` to filter the event log:
+
+| Key | Filter | Shows |
+|:---:|--------|-------|
+| `1` | ALL | Everything |
+| `2` | ๐ค game | Agent actions, level-ups, deaths |
+| `3` | โ๏ธ tools | MCP tool calls (Edit, Bash, Readโฆ) |
+| `4` | โ combat | Damage, kills, boss events |
+| `5` | ๐จ alerts | Errors, input-needed, warnings |
+
+---
+
+## Runtime files
+
+| File | Purpose |
+|------|---------|
+| `~/.agent_rpg_sync.json` | Last MCP bridge update โ overwritten each tick |
+| `~/.agent_rpg_queue.ndjson` | Pending hook events โ truncated when drained |
+
+Nothing is sent over the network.
+
+---
+
+## Privacy
+
+The optional `ClaudeBrain` LLM class (`core/brain.ts`) accepts an Anthropic API key as a constructor parameter. It is **not** wired into the default game loop โ the game uses `MockBrain` unless `ANTHROPIC_API_KEY` is set.
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
new file mode 100644
index 0000000..a2483d5
--- /dev/null
+++ b/docs/RELEASE.md
@@ -0,0 +1,69 @@
+# Release Process & Contributing
+
+โ [Back to README](../README.md)
+
+## CI pipeline
+
+Runs on every push and pull request via `.github/workflows/ci.yml`:
+
+- TypeScript type check (`tsc --noEmit`)
+- Build (`tsc`)
+- Matrix: Ubuntu + macOS ร Node 20 + Node 22
+
+---
+
+## Publishing to npm
+
+Publishing is **tag-driven** via `.github/workflows/publish.yml`.
+
+```bash
+# 1. Bump version
+npm version patch # or minor / major
+
+# 2. Push commit + tag
+git push --follow-tags
+```
+
+On tag push matching `v*.*.*` the workflow:
+1. Verifies tag matches `package.json` version
+2. Runs typecheck + build
+3. Publishes to npm with `--provenance`
+4. Creates a GitHub Release with auto-generated notes
+
+### One-time setup
+
+1. Replace `OWNER` placeholder in `package.json` โ `repository`, `homepage`, `bugs` URLs
+2. Generate an npm automation token at
+3. Add it to GitHub repo secrets: **Settings โ Secrets and variables โ Actions โ `NPM_TOKEN`**
+4. Optional: link repo to npm package for OIDC provenance โ see [npm docs](https://docs.npmjs.com/generating-provenance-statements)
+
+---
+
+## Contributing
+
+PRs welcome. Before submitting:
+
+```bash
+npm test && npm run typecheck
+```
+
+### Guidelines
+
+- **Emoji tile widths**: most layout issues come from mixing 1-cell and 2-cell emoji in the same grid row. Verify visually after tile changes.
+- **No dead code**: if a stat or tile has no gameplay effect, remove it rather than leaving it wired up but ignored.
+- **No backwards-compat shims**: delete cleanly. No `// removed` comments, no dead aliases.
+- **No full-screen clears** in the renderer main loop โ keep the per-cell CHA pattern.
+- **TypeScript strict mode**: no `any`, no `@ts-ignore` without a comment explaining why.
+
+### Adding a new AI agent
+
+1. Add a name matcher in `ProcessMonitor` (looks for process names containing `claude` today)
+2. Write hook scripts that append events to `~/.agent_rpg_queue.ndjson` in the same NDJSON format
+3. Optionally add a label entry for the HUD
+
+### Adding a character class
+
+1. Add spec to `CLASS_SPECS` in `core/avatars.ts`
+2. Add theme entries in both `THEMES.adventure.displayAvatars` and `THEMES.bugs.displayAvatars`
+3. Wire ability logic in `core/game.ts` โ `useAbility()`
+4. Add tests in `tests/abilities.test.ts`
diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md
new file mode 100644
index 0000000..a1c3be6
--- /dev/null
+++ b/docs/SETTINGS.md
@@ -0,0 +1,89 @@
+# Settings & CLI Flags
+
+โ [Back to README](../README.md)
+
+## In-game settings overlay
+
+Press `m` (or `Esc`) to open the settings panel. Navigate with `โ/โ`, confirm with `โต`.
+
+| Option | Description |
+|--------|-------------|
+| ๐ **Audio** | Toggle input-needed alert sound (ON / OFF) |
+| ๐ **Notifications** | Toggle OS desktop notifications when agent needs input (ON / OFF) |
+| ๐จ **Color blind** | Switch to high-contrast white-only palette |
+| ๐๏ธ **Observer mode** | Hide player, camera follows top-CPU agent |
+| ๐ **Night mode** | Dim map, change border style, add star overlay |
+| ๐ **Restart game** | Reset round, world, and all agents |
+| ๐ **Change mode** | Switch Adventure โ Agents vs Bugs |
+| ๐ง **Change avatar** | Pick a different character class |
+
+---
+
+## System notifications
+
+When a Claude agent is waiting for input, the game fires an OS desktop notification (outside the terminal). Throttled to one notification per agent every 10 seconds.
+
+| Platform | Backend |
+|----------|---------|
+| macOS | `osascript display notification` |
+| Linux | `notify-send` (requires `libnotify`) |
+| Other | Silent fallback |
+
+Toggle in-game via the settings overlay (`m` / `Esc` โ **Notifications**) or with `s` for audio.
+
+---
+
+## CLI flags
+
+```bash
+agent-rpg [options]
+
+Options:
+ --mode= Skip mode menu
+ --class= Skip avatar menu
+ --observer Start in spectator mode
+ --pacifist Disable bug spawning
+ --help=classes List available avatar classes and exit
+```
+
+### Avatar class names by mode
+
+**Adventure mode:**
+
+| Flag value | Avatar |
+|-----------|--------|
+| `elf`, `scout` | ๐ง Elf |
+| `wizard`, `mage` | ๐ง Wizard |
+| `fairy`, `flyer` | ๐ง Fairy |
+| `knight`, `wolf` | ๐ก๏ธ Knight |
+
+**Bugs mode:**
+
+| Flag value | Avatar |
+|-----------|--------|
+| `robot`, `tech` | ๐ค Robot |
+| `drone`, `flyer` | ๐ฐ๏ธ Drone |
+| `firewall`, `scout` | ๐ก๏ธ Firewall |
+| `debugger`, `mage` | ๐ง Debugger |
+
+### Environment variables
+
+```bash
+PLAYER_CLASS=wolf # same values as --class
+GAME_MODE=adventure # same values as --mode
+OBSERVER_MODE=1 # same as --observer
+```
+
+---
+
+## Audio
+
+Audio plays a system beep when an agent needs input (blinking ๐จ badge in HUD). No external audio files โ uses the OS sound stack directly:
+
+| Platform | Command tried |
+|----------|--------------|
+| Linux | `pw-play` โ `paplay` โ `aplay` (first found wins) |
+| macOS | `afplay` |
+| Other | Silent fallback |
+
+Toggle in-game with `s` or via the settings overlay.
diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md
index efa71bb..b77e49c 100644
--- a/docs/screenshots/README.md
+++ b/docs/screenshots/README.md
@@ -1,7 +1,8 @@
# Screenshots
-Add PNG/GIF captures here:
-- adventure.png
-- bugs.png
+Current assets:
+- `main-image.png` โ pixel-art hero banner (Robot + Elf, "Terminal RPG / Chronicles of Circuitry & Sorcery")
+- `agent-aventure.png` โ Adventure mode in-game capture (forest theme)
+- `agents-vs-bugs.png` โ Agents vs Bugs mode in-game capture (cyber theme)
-Record with: asciinema rec docs/screenshots/demo.cast
+Record terminal sessions with: `asciinema rec docs/screenshots/demo.cast`
diff --git a/docs/screenshots/agent-aventure.png b/docs/screenshots/agent-aventure.png
new file mode 100644
index 0000000..d963e87
Binary files /dev/null and b/docs/screenshots/agent-aventure.png differ
diff --git a/docs/screenshots/agents-vs-bugs.png b/docs/screenshots/agents-vs-bugs.png
new file mode 100644
index 0000000..75aa5e2
Binary files /dev/null and b/docs/screenshots/agents-vs-bugs.png differ
diff --git a/docs/screenshots/main-image.png b/docs/screenshots/main-image.png
new file mode 100644
index 0000000..6659a59
Binary files /dev/null and b/docs/screenshots/main-image.png differ
diff --git a/index.ts b/index.ts
index a4bad27..567a3b0 100644
--- a/index.ts
+++ b/index.ts
@@ -18,10 +18,10 @@ const MIN_TICK_MS = 100;
const MAX_TICK_MS = 1000;
const STEP_MS = 50;
-const MIN_COLS = 100;
-const MIN_ROWS = 28;
+const MIN_COLS = 113;
+const MIN_ROWS = 35;
const REC_COLS = 140;
-const REC_ROWS = 36;
+const REC_ROWS = 42;
const ANSI_CLEAR = "\x1b[2J";
const ANSI_HOME = "\x1b[H";
@@ -67,41 +67,65 @@ function drawSizeGate(): void {
const okW = w >= MIN_COLS;
const okH = h >= MIN_ROWS;
const recOk = w >= REC_COLS && h >= REC_ROWS;
- const wTxt = okW ? chalk.green(String(w)) : chalk.red.bold(String(w));
- const hTxt = okH ? chalk.green(String(h)) : chalk.red.bold(String(h));
+
+ const wTxt = okW ? chalk.greenBright.bold(String(w)) : chalk.redBright.bold(String(w));
+ const hTxt = okH ? chalk.greenBright.bold(String(h)) : chalk.redBright.bold(String(h));
const recTxt = recOk
- ? chalk.green(`${REC_COLS}ร${REC_ROWS} โ`)
+ ? chalk.greenBright(`${REC_COLS}ร${REC_ROWS} โ`)
: chalk.gray(`${REC_COLS}ร${REC_ROWS}`);
- const lines = [
- "",
- " " + chalk.bgRed.white.bold(" โ TERMINAL TOO SMALL โ game blocked "),
- "",
- " " + chalk.bold("Current size: ") + `${wTxt} cols ร ${hTxt} rows`,
- " " + chalk.bold("Minimum: ") + chalk.cyan(`${MIN_COLS}ร${MIN_ROWS}`) + chalk.gray(" (required to start)"),
- " " + chalk.bold("Recommended: ") + recTxt,
+
+ const content = [
+ chalk.bgRed.white.bold(" โ TERMINAL TOO SMALL โ RESIZE TO CONTINUE "),
"",
- " " + chalk.gray("Resize terminal โ UI updates live."),
- " " + chalk.gray("Ctrl-C to quit."),
+ ` Current: ${wTxt} cols ร ${hTxt} rows`,
+ ` Required: ${chalk.cyanBright(`${MIN_COLS} ร ${MIN_ROWS}`)}`,
+ ` Recommended: ${recTxt}`,
"",
+ chalk.gray(" Drag terminal edge to resize โ updates live."),
+ chalk.gray(" Game is paused. Ctrl-C to quit."),
];
- process.stdout.write(ANSI_CLEAR + ANSI_HOME + ANSI_HIDE + lines.join("\n"));
+
+ const stripLen = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
+ const maxLen = Math.max(...content.map(stripLen));
+ const blank = " ".repeat(Math.max(0, w));
+ const out: string[] = [ANSI_CLEAR + ANSI_HOME + ANSI_HIDE];
+
+ const vPad = Math.max(0, Math.floor((h - content.length) / 2));
+ for (let i = 0; i < vPad; i++) out.push(blank);
+ for (const line of content) {
+ const hPad = Math.max(0, Math.floor((w - maxLen) / 2));
+ out.push(" ".repeat(hPad) + line);
+ }
+ for (let i = vPad + content.length; i < h; i++) out.push(blank);
+
+ try { process.stdout.write(out.join("\n")); } catch { /* terminal too small to write */ }
}
function waitForTerminalSize(): Promise {
return new Promise((resolve) => {
- const w = process.stdout.columns || 80;
- const h = process.stdout.rows || 24;
- if (w >= MIN_COLS && h >= MIN_ROWS) {
- resolve();
- return;
- }
+ const ok = () => (process.stdout.columns || 80) >= MIN_COLS && (process.stdout.rows || 24) >= MIN_ROWS;
+ if (ok()) { resolve(); return; }
+
drawSizeGate();
+
+ // setInterval keeps event loop alive (SIGWINCH alone does not ref the loop in all Node versions)
+ // also refreshes the gate display live so current size updates as user drags
+ const poll = setInterval(() => {
+ if (ok()) {
+ clearInterval(poll);
+ process.removeListener("SIGWINCH", onResize);
+ try { process.stdout.write(ANSI_CLEAR + ANSI_HOME + ANSI_SHOW + ANSI_RESET); } catch { /* ignore */ }
+ resolve();
+ } else {
+ drawSizeGate();
+ }
+ }, 250);
+
const onResize = () => {
- const w2 = process.stdout.columns || 80;
- const h2 = process.stdout.rows || 24;
- if (w2 >= MIN_COLS && h2 >= MIN_ROWS) {
+ if (ok()) {
+ clearInterval(poll);
process.removeListener("SIGWINCH", onResize);
- process.stdout.write(ANSI_CLEAR + ANSI_HOME + ANSI_SHOW + ANSI_RESET);
+ try { process.stdout.write(ANSI_CLEAR + ANSI_HOME + ANSI_SHOW + ANSI_RESET); } catch { /* ignore */ }
resolve();
} else {
drawSizeGate();
@@ -169,9 +193,10 @@ function drawBoxMenu(title: string, footer: string, items: MenuItem[], cursor: n
const tone = toneColor(it.tone);
const stripe = tone("โ");
const arrow = selected ? tone("โถ") : " ";
+ const numTag = chalk.dim(`[${i + 1}]`);
const labelText = selected
- ? chalk.bgWhite.black.bold(` ${arrow} ${stripe} ${it.label} `)
- : ` ${arrow} ${stripe} ${it.label} `;
+ ? chalk.bgWhite.black.bold(` ${arrow} ${stripe} ${numTag} ${it.label} `)
+ : ` ${arrow} ${stripe} ${numTag} ${it.label} `;
const padded = padVisible(labelText, inner);
out.push(side + padded + side + "\n");
});
@@ -211,7 +236,18 @@ function runBoxMenu(title: string, footer: string, items: MenuItem[], initialCur
stdin.resume();
stdin.setEncoding("utf8");
let buf = "";
+ const onMenuResize = () => {
+ const w = process.stdout.columns || 80;
+ const h = process.stdout.rows || 24;
+ if (w < MIN_COLS || h < MIN_ROWS) {
+ drawSizeGate();
+ } else {
+ try { drawBoxMenu(title, footer, items, cursor); } catch { /* ignore */ }
+ }
+ };
+ process.on("SIGWINCH", onMenuResize);
const cleanup = () => {
+ process.removeListener("SIGWINCH", onMenuResize);
stdin.off("data", onData);
stdin.setRawMode(false);
stdin.pause();
@@ -222,6 +258,7 @@ function runBoxMenu(title: string, footer: string, items: MenuItem[], initialCur
while (buf.length > 0) {
let consumed = 0;
let action: "up" | "down" | "enter" | "back" | "quit" | null = null;
+ let directIndex = -1;
if (buf.startsWith("\x1b[A")) { action = "up"; consumed = 3; }
else if (buf.startsWith("\x1b[B")) { action = "down"; consumed = 3; }
else if (buf.startsWith("\x1b") && buf.length === 1) { return; }
@@ -233,9 +270,17 @@ function runBoxMenu(title: string, footer: string, items: MenuItem[], initialCur
else if (buf[0].toLowerCase() === "b") { action = "back"; consumed = 1; }
else if (buf[0] === "k") { action = "up"; consumed = 1; }
else if (buf[0] === "j") { action = "down"; consumed = 1; }
+ else if (buf[0] >= "1" && buf[0] <= "9") {
+ directIndex = parseInt(buf[0], 10) - 1;
+ consumed = 1;
+ }
else { consumed = 1; }
buf = buf.slice(consumed);
- if (action === "up") {
+ if (directIndex >= 0 && directIndex < items.length) {
+ cleanup();
+ resolve(items[directIndex].value);
+ return;
+ } else if (action === "up") {
cursor = (cursor - 1 + items.length) % items.length;
drawBoxMenu(title, footer, items, cursor);
} else if (action === "down") {
@@ -290,7 +335,7 @@ async function promptMode(): Promise {
while (true) {
const v = await runBoxMenu(
"๐ฎ GAME MODE",
- "โ/โ move ยท โต select ยท q quit",
+ "โ/โ move ยท โต select ยท 1-2 quick ยท q quit",
items
);
if (v === "adventure" || v === "bugs") return v;
@@ -340,7 +385,7 @@ async function promptMenu(theme: Theme): Promise {
});
const v = await runBoxMenu(
`${theme.label.toUpperCase()} โ CHOOSE AVATAR`,
- "โ/โ move ยท โต select ยท b back ยท q quit",
+ "โ/โ move ยท โต select ยท 1-6 quick ยท b back ยท q quit",
items
);
if (v === null || v === "back") return { observer: false, cls: null, back: true };
@@ -363,11 +408,22 @@ function printSelector(): void {
}
async function main(): Promise {
+ // Suppress EPIPE โ stdout write fails silently when terminal shrinks
+ process.stdout.on("error", (err: NodeJS.ErrnoException) => {
+ if (err.code !== "EPIPE") throw err;
+ });
+ process.stderr.on("error", (err: NodeJS.ErrnoException) => {
+ if (err.code !== "EPIPE") throw err;
+ });
+ // SIGHUP โ sent by some terminals on aggressive resize or detach; ignore to stay alive
+ process.on("SIGHUP", () => {});
+
const args = parseArgs();
if (args["help"] === "classes" || args["list-classes"] !== undefined) {
printSelector();
process.exit(0);
}
+ await waitForTerminalSize();
const observerFlag =
args["observer"] !== undefined || process.env.OBSERVER_MODE === "1";
@@ -420,15 +476,20 @@ async function main(): Promise {
const renderer = new Renderer();
renderer.init();
+ let resizePaused = false;
process.on("SIGWINCH", () => {
const w = process.stdout.columns || 80;
const h = process.stdout.rows || 24;
if (w < MIN_COLS || h < MIN_ROWS) {
- process.stdout.write(ANSI_CLEAR + ANSI_HOME);
+ resizePaused = true;
+ drawSizeGate();
return;
}
- process.stdout.write(ANSI_CLEAR + ANSI_HOME);
- renderer.flash(`๐ resize ${w}x${h}`, 6, game.tick);
+ if (resizePaused) {
+ resizePaused = false;
+ try { renderer.render(game, tickMs); } catch { /* ignore */ }
+ }
+ renderer.flash(`๐ ${w}ร${h}`, 6, game.tick);
});
let running = true;
@@ -490,7 +551,7 @@ async function main(): Promise {
return;
}
if (key === "5") {
- renderer.setEventFilter("alerts");
+ renderer.setEventFilter("system");
return;
}
if (key === "n") {
@@ -584,6 +645,10 @@ async function main(): Promise {
await new Promise((resolve) => {
const loop = () => {
+ if (resizePaused) {
+ setTimeout(loop, 200);
+ return;
+ }
if (renderer.pendingMenuRequest) {
exitReason.reason = renderer.pendingMenuRequest;
renderer.pendingMenuRequest = null;
@@ -596,8 +661,10 @@ async function main(): Promise {
resolve();
return;
}
- game.step();
- renderer.render(game, tickMs);
+ try {
+ game.step();
+ renderer.render(game, tickMs);
+ } catch { /* swallow render errors โ e.g. terminal shrunk mid-frame */ }
setTimeout(loop, tickMs);
};
renderer.render(game, tickMs);
diff --git a/tests/observation.test.ts b/tests/observation.test.ts
index 847b0e1..57a4d5c 100644
--- a/tests/observation.test.ts
+++ b/tests/observation.test.ts
@@ -30,7 +30,7 @@ test("observation includes heart H tile", () => {
assert.deepEqual(heart!.pos, { x: 6, y: 5 });
});
-test("observation includes energy E + cure +", () => {
+test("observation includes energy E, excludes cure + (safe zone)", () => {
const w = new World(20, 15, "bugs");
for (let y = 0; y < 15; y++) for (let x = 0; x < 20; x++) w.tiles[y][x] = ".";
w.tiles[5][6] = "E";
@@ -56,7 +56,7 @@ test("observation includes energy E + cure +", () => {
const energy = obs.nearby_entities.resources.find((r) => r.kind === "energy");
const cure = obs.nearby_entities.resources.find((r) => r.kind === "cure");
assert.ok(energy, "energy detected");
- assert.ok(cure, "cure detected");
+ assert.equal(cure, undefined, "cure + tile (safe zone) excluded from observation");
});
test("observation excludes resources beyond vision radius", () => {