Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
373 changes: 74 additions & 299 deletions README.md

Large diffs are not rendered by default.

109 changes: 64 additions & 45 deletions cli/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
}
Expand All @@ -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`,
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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]) =>
Expand All @@ -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(
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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));
Expand All @@ -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) {
Expand Down
31 changes: 13 additions & 18 deletions core/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,30 +226,23 @@ 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`;
this.path = [];
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) {
Expand Down Expand Up @@ -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;
}
}
}

Expand All @@ -415,7 +411,6 @@ export class Agent extends Entity {
return (
t === "%" ||
t === "M" ||
t === "+" ||
t === "E" ||
t === "$" ||
t === "H"
Expand Down
20 changes: 17 additions & 3 deletions core/brain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading