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 @@
-``` - โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— -โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ• โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•‘โ•šโ•โ•โ–ˆโ–ˆโ•”โ•โ•โ• โ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ• -โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•”โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•”โ–ˆโ–ˆโ–ˆโ–ˆโ•”โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ–ˆโ•— -โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ• โ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ•โ• โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ -โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ•šโ•โ• โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ• -โ•šโ•โ• โ•šโ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ•โ•โ•šโ•โ• โ•šโ•โ•โ•โ• โ•šโ•โ• โ•šโ•โ• โ•šโ•โ•โ•šโ•โ•โ•šโ•โ• โ•šโ•โ•โ•โ•โ•šโ•โ• โ•šโ•โ• โ•šโ•โ•โ•šโ•โ• โ•šโ•โ•โ•โ•โ•โ• -``` +Terminal RPG โ€” Chronicles of Circuitry & Sorcery + +# agent-mini-rpg ### ๐Ÿค– Terminal multi-agent RPG ยท powered by **Claude Code CLI** ๐ŸŒฒ @@ -16,9 +11,9 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](./LICENSE) [![Node โ‰ฅ 20](https://img.shields.io/badge/node-%E2%89%A520-brightgreen?style=flat-square&logo=nodedotjs)](https://nodejs.org) [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6?style=flat-square&logo=typescript)](https://www.typescriptlang.org) -[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS-lightgrey?style=flat-square&logo=linux)](https://github.com/Mundo-Dev0ps/agent-mini-rpg#compatibility) -[![Tests](https://img.shields.io/badge/tests-43%20passing-success?style=flat-square&logo=node.js)](https://github.com/Mundo-Dev0ps/agent-mini-rpg/actions) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-ff69b4?style=flat-square)](https://github.com/Mundo-Dev0ps/agent-mini-rpg/pulls) +[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS-lightgrey?style=flat-square&logo=linux)](./docs/COMPATIBILITY.md) +[![Tests](https://img.shields.io/badge/tests-43%20passing-success?style=flat-square)](https://github.com/Mundo-Dev0ps/agent-mini-rpg/actions) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-ff69b4?style=flat-square)](./docs/RELEASE.md)
@@ -35,19 +30,16 @@ | ๐ŸŒฒ Adventure Mode | ๐Ÿค– Agents vs Bugs Mode | |:-:|:-:| -| ![Adventure mode](docs/screenshots/adventure.png) | ![Bugs mode](docs/screenshots/bugs.png) | +| ![Adventure mode](docs/screenshots/agent-aventure.png) | ![Bugs mode](docs/screenshots/agents-vs-bugs.png) | | 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", () => {