Add waiting arcade MVP

This commit is contained in:
Anthony Ettinger 2026-06-06 22:09:22 +00:00
parent b1d8fe475a
commit 4682b261c0
16 changed files with 1192 additions and 2 deletions

View file

@ -0,0 +1,49 @@
import type { GameAction, KeyEvent, TerminalFrame, WaitingGame } from "../types.js";
export class AgentSwarmGame implements WaitingGame {
readonly id = "agent-swarm";
readonly title = "Agent Swarm";
readonly description = "Route agents around blockers and collect approvals.";
readonly controls = [
{ key: "Arrow/WASD", action: "move lead agent" },
{ key: "L/Ctrl+G", action: "logs" },
{ key: "Esc", action: "pause" },
{ key: "Q", action: "quit" }
];
private x = 18;
private y = 8;
private tickCount = 0;
init() {
return;
}
tick() {
this.tickCount += 1;
}
input(event: KeyEvent): GameAction | void {
if (event.ctrl && event.name === "g") return { type: "toggle_logs" };
if (event.name === "escape") return { type: "pause" };
if (event.name === "q") return { type: "quit" };
if (event.name === "l") return { type: "toggle_logs" };
if (["up", "w", "k"].includes(event.name)) this.y = Math.max(2, this.y - 1);
if (["down", "s", "j"].includes(event.name)) this.y = Math.min(13, this.y + 1);
if (["left", "a", "h"].includes(event.name)) this.x = Math.max(4, this.x - 1);
if (["right", "d"].includes(event.name)) this.x = Math.min(50, this.x + 1);
}
render(frame: TerminalFrame) {
frame.box(0, 0, frame.width, frame.height - 5, this.title.toUpperCase());
frame.write(2, 4, "Guide the lead agent. Avoid blockers. Grab approvals.");
frame.write(6, 12, "B");
frame.write(10, 28, "B");
frame.write(5, 42, "$");
frame.write(12, 48, "A");
frame.write(this.y, this.x, "@");
frame.write(this.y + 1, Math.max(4, this.x - 4), "a a a");
frame.write(15, 4, `Swarm pulse: ${this.tickCount % 100}`);
frame.write(16, 4, "[Arrows/WASD] Move [L/Ctrl+G] Logs [Esc] Pause [Q] Quit");
}
}

View file

@ -0,0 +1,77 @@
import type { GameAction, GameContext, KeyEvent, TerminalFrame, WaitingGame } from "../types.js";
import { fit } from "../renderer.js";
const WORDS = ["LOGICSRC", "COMMAND BOARD", "AGENT SWARM", "PULL REQUEST", "TERMINAL", "SUPABASE", "COINPAY", "CRAWLPROOF", "UGIG", "SHIPT"];
const BAD_IDEAS = ["JIRA", "WATERFALL", "WEBPACK", "XML CONFIG", "VENDOR LOCK-IN", "MEETING", "BIG REWRITE", "GLOBAL STATE"];
export class HangmanGame implements WaitingGame {
readonly id = "hangman";
readonly title = "Logicsrc Hangman";
readonly description = "Guess terminal, agent, and Logicsrc words while a task runs.";
readonly controls = [
{ key: "A-Z", action: "guess" },
{ key: "L/Ctrl+G", action: "logs" },
{ key: "Esc", action: "pause" },
{ key: "Q", action: "quit" }
];
private word = WORDS[0] ?? "LOGICSRC";
private guesses = new Set<string>();
private mistakes = 0;
init(ctx: GameContext) {
this.word = WORDS[Math.floor(ctx.random() * WORDS.length)] ?? "LOGICSRC";
}
tick() {
return;
}
input(event: KeyEvent): GameAction | void {
if (event.ctrl && event.name === "g") {
return { type: "toggle_logs" };
}
if (event.name === "escape") {
return { type: "pause" };
}
if (event.name === "q") {
return { type: "quit" };
}
if (event.name === "l") {
return { type: "toggle_logs" };
}
if (/^[a-z]$/i.test(event.name)) {
const guess = event.name.toUpperCase();
if (!this.guesses.has(guess)) {
this.guesses.add(guess);
if (!this.word.includes(guess)) {
this.mistakes += 1;
}
}
}
}
render(frame: TerminalFrame) {
frame.box(0, 0, frame.width, frame.height - 5, this.title.toUpperCase());
frame.write(2, 4, "Phrase:");
frame.write(4, 4, this.maskedWord());
frame.write(6, 4, `Wrong guesses: ${this.mistakes}/6`);
frame.write(7, 4, BAD_IDEAS.slice(0, this.mistakes).join(" ") || "none yet");
frame.write(9, 4, fit(`Guessed: ${Array.from(this.guesses).sort().join(" ") || "none"}`, frame.width - 8));
const state = this.isWon() ? "solved" : this.mistakes >= 6 ? "reset with R or keep guessing" : "running";
frame.write(11, 4, `Round: ${state}`);
frame.write(13, 4, "[A-Z] Guess [L/Ctrl+G] Logs [Esc] Pause [Q] Quit");
}
private maskedWord() {
return this.word
.split("")
.map((char) => (char === " " ? " " : this.guesses.has(char) ? `${char} ` : "_ "))
.join("");
}
private isWon() {
return this.word.split("").every((char) => char === " " || this.guesses.has(char));
}
}

View file

@ -0,0 +1,83 @@
import type { GameAction, GameContext, KeyEvent, TerminalFrame, WaitingGame } from "../types.js";
type Cell = { mine: boolean; open: boolean; flag: boolean };
export class MinefieldGame implements WaitingGame {
readonly id = "minefield";
readonly title = "Deploy Minefield";
readonly description = "Clear risky deployment cells without triggering incidents.";
readonly controls = [
{ key: "Arrow/WASD", action: "move" },
{ key: "Space/Enter", action: "reveal" },
{ key: "F", action: "flag" },
{ key: "Q", action: "quit" }
];
private width = 8;
private height = 6;
private cells: Cell[] = [];
private cursor = { x: 0, y: 0 };
private state = "clear risky cells";
init(ctx: GameContext) {
this.cells = Array.from({ length: this.width * this.height }, (_, index) => ({
mine: index > 0 && ctx.random() < 0.16,
open: false,
flag: false
}));
}
tick() {
return;
}
input(event: KeyEvent): GameAction | void {
if (event.ctrl && event.name === "g") return { type: "toggle_logs" };
if (event.name === "escape") return { type: "pause" };
if (event.name === "q") return { type: "quit" };
if (event.name === "l") return { type: "toggle_logs" };
if (["up", "w", "k"].includes(event.name)) this.cursor.y = Math.max(0, this.cursor.y - 1);
if (["down", "s", "j"].includes(event.name)) this.cursor.y = Math.min(this.height - 1, this.cursor.y + 1);
if (["left", "a", "h"].includes(event.name)) this.cursor.x = Math.max(0, this.cursor.x - 1);
if (["right", "d"].includes(event.name)) this.cursor.x = Math.min(this.width - 1, this.cursor.x + 1);
if (event.name === "f") this.cell(this.cursor.x, this.cursor.y).flag = !this.cell(this.cursor.x, this.cursor.y).flag;
if (event.name === "space" || event.name === "return") this.reveal(this.cursor.x, this.cursor.y);
}
render(frame: TerminalFrame) {
frame.box(0, 0, frame.width, frame.height - 5, this.title.toUpperCase());
frame.write(2, 4, `Status: ${this.state}`);
for (let y = 0; y < this.height; y += 1) {
for (let x = 0; x < this.width; x += 1) {
const cell = this.cell(x, y);
const selected = this.cursor.x === x && this.cursor.y === y;
const value = cell.flag ? "F" : cell.open ? (cell.mine ? "!" : String(this.neighbors(x, y))) : "#";
frame.write(4 + y, 6 + x * 4, selected ? `[${value}]` : ` ${value} `);
}
}
frame.write(12, 4, "[Arrows/WASD] Move [Space/Enter] Reveal [F] Flag [Q] Quit");
}
private reveal(x: number, y: number) {
const cell = this.cell(x, y);
if (cell.flag) return;
cell.open = true;
this.state = cell.mine ? "incident triggered; keep mapping the blast radius" : "cell cleared";
}
private cell(x: number, y: number) {
return this.cells[y * this.width + x] ?? { mine: false, open: false, flag: false };
}
private neighbors(x: number, y: number) {
let count = 0;
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
if (dx !== 0 || dy !== 0) {
count += this.cell(x + dx, y + dy).mine ? 1 : 0;
}
}
}
return count;
}
}

View file

@ -0,0 +1,62 @@
import type { GameAction, GameContext, KeyEvent, TerminalFrame, WaitingGame } from "../types.js";
export class MoonRunnerGame implements WaitingGame {
readonly id = "moon-runner";
readonly title = "Moon Runner";
readonly description = "Jump over failed builds and collect green checks.";
readonly controls = [
{ key: "Space/Up", action: "jump" },
{ key: "L/Ctrl+G", action: "logs" },
{ key: "Esc", action: "pause" },
{ key: "Q", action: "quit" }
];
private runnerY = 0;
private velocity = 0;
private obstacleX = 46;
private score = 0;
private random = Math.random;
init(ctx: GameContext) {
this.random = ctx.random;
}
tick(deltaMs: number) {
const step = Math.max(1, Math.round(deltaMs / 60));
this.obstacleX -= step;
if (this.obstacleX < 4) {
this.obstacleX = 42 + Math.floor(this.random() * 24);
this.score += 1;
}
this.velocity -= 0.08 * step;
this.runnerY = Math.max(0, this.runnerY + this.velocity);
if (this.runnerY === 0 && this.velocity < 0) {
this.velocity = 0;
}
if (this.obstacleX >= 8 && this.obstacleX <= 10 && this.runnerY < 1) {
this.score = 0;
this.obstacleX = 46;
}
}
input(event: KeyEvent): GameAction | void {
if (event.ctrl && event.name === "g") return { type: "toggle_logs" };
if (event.name === "escape") return { type: "pause" };
if (event.name === "q") return { type: "quit" };
if (event.name === "l") return { type: "toggle_logs" };
if (["space", "up", "w"].includes(event.name) && this.runnerY === 0) {
this.velocity = 1.15;
}
}
render(frame: TerminalFrame) {
const ground = Math.max(7, frame.height - 9);
frame.box(0, 0, frame.width, frame.height - 5, `${this.title.toUpperCase()} CHECKS ${this.score}`);
frame.write(ground + 1, 4, "_".repeat(Math.max(10, frame.width - 8)));
frame.write(ground - Math.round(this.runnerY), 9, "@");
frame.write(ground, this.obstacleX, "X");
frame.write(ground - 2, Math.max(18, this.obstacleX + 10), "+");
frame.write(2, 4, "Low gravity. High uptime.");
frame.write(ground + 3, 4, "[Space/Up] Jump [L/Ctrl+G] Logs [Esc] Pause [Q] Quit");
}
}

View file

@ -0,0 +1,78 @@
import type { GameAction, GameContext, KeyEvent, TerminalFrame, WaitingGame } from "../types.js";
type Point = { x: number; y: number };
export class SnakeGame implements WaitingGame {
readonly id = "snake";
readonly title = "Token Snake";
readonly description = "Eat task tokens, grow the trail, and avoid blockers.";
readonly controls = [
{ key: "Arrow/WASD", action: "move" },
{ key: "L/Ctrl+G", action: "logs" },
{ key: "Esc", action: "pause" },
{ key: "Q", action: "quit" }
];
private snake: Point[] = [{ x: 12, y: 6 }];
private direction: Point = { x: 1, y: 0 };
private token: Point = { x: 20, y: 6 };
private elapsed = 0;
private score = 0;
private random = Math.random;
init(ctx: GameContext) {
this.random = ctx.random;
}
tick(deltaMs: number) {
this.elapsed += deltaMs;
if (this.elapsed < 150) {
return;
}
this.elapsed = 0;
const head = this.snake[0] ?? { x: 12, y: 6 };
const next = { x: head.x + this.direction.x, y: head.y + this.direction.y };
const maxX = 38;
const maxY = 12;
if (next.x < 1 || next.x > maxX || next.y < 1 || next.y > maxY || this.snake.some((point) => point.x === next.x && point.y === next.y)) {
this.snake = [{ x: 12, y: 6 }];
this.direction = { x: 1, y: 0 };
this.score = 0;
return;
}
this.snake.unshift(next);
if (next.x === this.token.x && next.y === this.token.y) {
this.score += 1;
this.token = { x: 1 + Math.floor(this.random() * maxX), y: 1 + Math.floor(this.random() * maxY) };
} else {
this.snake.pop();
}
}
input(event: KeyEvent): GameAction | void {
if (event.ctrl && event.name === "g") return { type: "toggle_logs" };
if (event.name === "escape") return { type: "pause" };
if (event.name === "q") return { type: "quit" };
if (event.name === "l") return { type: "toggle_logs" };
if (["up", "w", "k"].includes(event.name) && this.direction.y !== 1) this.direction = { x: 0, y: -1 };
if (["down", "s", "j"].includes(event.name) && this.direction.y !== -1) this.direction = { x: 0, y: 1 };
if (["left", "a", "h"].includes(event.name) && this.direction.x !== 1) this.direction = { x: -1, y: 0 };
if (["right", "d", "l"].includes(event.name) && this.direction.x !== -1) this.direction = { x: 1, y: 0 };
}
render(frame: TerminalFrame) {
frame.box(0, 0, frame.width, frame.height - 5, `${this.title.toUpperCase()} SCORE ${this.score}`);
for (let y = 1; y <= 12; y += 1) {
for (let x = 1; x <= 38; x += 1) {
frame.write(y + 2, x + 4, ".");
}
}
frame.write(this.token.y + 2, this.token.x + 4, "$");
for (const [index, point] of this.snake.entries()) {
frame.write(point.y + 2, point.x + 4, index === 0 ? "@" : "o");
}
frame.write(16, 4, "[Arrows/WASD] Move [L/Ctrl+G] Logs [Esc] Pause [Q] Quit");
}
}

View file

@ -0,0 +1,3 @@
export { createDefaultArcadeRegistry, renderArcadeList, ArcadeRegistry } from "./registry.js";
export { renderArcadeSnapshot, runArcadeSession } from "./runtime.js";
export type { ArcadeEvent, GameAction, GameContext, GameControl, KeyEvent, TaskEvent, TaskSnapshot, TerminalFrame, WaitingGame } from "./types.js";

View file

@ -0,0 +1,75 @@
import type { WaitingGame } from "./types.js";
import { AgentSwarmGame } from "./games/agent-swarm.js";
import { HangmanGame } from "./games/hangman.js";
import { MinefieldGame } from "./games/minefield.js";
import { MoonRunnerGame } from "./games/moon-runner.js";
import { SnakeGame } from "./games/snake.js";
export type GameFactory = () => WaitingGame;
export class ArcadeRegistry {
private readonly games = new Map<string, GameFactory>();
add(factory: GameFactory) {
const game = factory();
if (this.games.has(game.id)) {
throw new Error(`Arcade game already registered: ${game.id}`);
}
this.games.set(game.id, factory);
}
create(id: string) {
const factory = this.games.get(id);
if (!factory) {
throw new Error(`Unknown arcade game "${id}". Run "logicsrc arcade list" to see available games.`);
}
return factory();
}
list() {
return Array.from(this.games.values()).map((factory) => {
const game = factory();
return {
id: game.id,
title: game.title,
description: game.description,
controls: game.controls
};
});
}
choose(id: string | undefined, random = Math.random) {
if (!id || id === "default") {
return "hangman";
}
if (id !== "random") {
return id;
}
const games = this.list();
return games[Math.floor(random() * games.length)]?.id ?? "hangman";
}
}
export function createDefaultArcadeRegistry() {
const registry = new ArcadeRegistry();
registry.add(() => new HangmanGame());
registry.add(() => new SnakeGame());
registry.add(() => new MoonRunnerGame());
registry.add(() => new MinefieldGame());
registry.add(() => new AgentSwarmGame());
return registry;
}
export function renderArcadeList(registry = createDefaultArcadeRegistry()) {
const rows = registry.list();
const idWidth = Math.max(...rows.map((row) => row.id.length), 2);
const titleWidth = Math.max(...rows.map((row) => row.title.length), 5);
return [
"Waiting Arcade games",
"",
`${"id".padEnd(idWidth)} ${"title".padEnd(titleWidth)} description`,
`${"-".repeat(idWidth)} ${"-".repeat(titleWidth)} ${"-".repeat(40)}`,
...rows.map((row) => `${row.id.padEnd(idWidth)} ${row.title.padEnd(titleWidth)} ${row.description}`)
].join("\n");
}

View file

@ -0,0 +1,98 @@
import type { TaskSnapshot, TerminalFrame } from "./types.js";
export class TextFrame implements TerminalFrame {
readonly width: number;
readonly height: number;
private readonly cells: string[][];
constructor(width: number, height: number) {
this.width = Math.max(40, width);
this.height = Math.max(16, height);
this.cells = Array.from({ length: this.height }, () => Array.from({ length: this.width }, () => " "));
}
write(row: number, col: number, text: string) {
if (row < 0 || row >= this.height || col >= this.width) {
return;
}
for (let index = 0; index < text.length && col + index < this.width; index += 1) {
if (col + index >= 0) {
this.cells[row][col + index] = text[index] ?? " ";
}
}
}
box(row: number, col: number, width: number, height: number, title?: string) {
const right = Math.min(this.width - 1, col + width - 1);
const bottom = Math.min(this.height - 1, row + height - 1);
const left = Math.max(0, col);
const top = Math.max(0, row);
for (let x = left; x <= right; x += 1) {
this.write(top, x, x === left || x === right ? "+" : "-");
this.write(bottom, x, x === left || x === right ? "+" : "-");
}
for (let y = top + 1; y < bottom; y += 1) {
this.write(y, left, "|");
this.write(y, right, "|");
}
if (title) {
this.write(top, left + 2, ` ${fit(title, Math.max(0, width - 6))} `);
}
}
toString() {
return this.cells.map((line) => line.join("").trimEnd()).join("\n").trimEnd();
}
}
export function fit(value: string, width: number) {
if (width <= 0) {
return "";
}
return value.length > width ? value.slice(0, Math.max(0, width - 1)) + "." : value.padEnd(width);
}
export function center(value: string, width: number) {
if (value.length >= width) {
return value.slice(0, width);
}
const left = Math.floor((width - value.length) / 2);
return `${" ".repeat(left)}${value}`;
}
export function renderTaskOverlay(frame: TerminalFrame, task: TaskSnapshot | undefined, standalone: boolean) {
const top = frame.height - 5;
frame.box(top, 0, frame.width, 5, standalone ? "STANDALONE MODE" : "TASK STATUS");
if (!task) {
frame.write(top + 1, 2, "No active task. Play mode only.");
frame.write(top + 2, 2, "[L/Ctrl+G] logs [Esc] pause [Q] quit");
return;
}
const cost = typeof task.costUsd === "number" ? ` Cost: $${task.costUsd.toFixed(2)}` : "";
const progress = typeof task.progress === "number" ? ` ${Math.round(task.progress * 100)}%` : "";
frame.write(top + 1, 2, fit(`Task: ${task.title}`, frame.width - 4));
frame.write(top + 2, 2, fit(`Status: ${task.status}${progress} Phase: ${task.phase ?? "unknown"}${cost}`, frame.width - 4));
frame.write(top + 3, 2, fit(`Last: ${task.lastMessage ?? "waiting for task events"} [L/Ctrl+G] logs [Esc] pause [Q] quit`, frame.width - 4));
}
export function renderModal(title: string, body: string[], actions: string[], width = 58) {
const inner = width - 4;
const lines = [
`+${"=".repeat(width - 2)}+`,
`| ${fit(title, inner)} |`,
`|${" ".repeat(width - 2)}|`
];
for (const line of body) {
lines.push(`| ${fit(line, inner)} |`);
}
lines.push(`|${" ".repeat(width - 2)}|`);
lines.push(`| ${fit(actions.join(" "), inner)} |`);
lines.push(`+${"=".repeat(width - 2)}+`);
return lines.join("\n");
}

View file

@ -0,0 +1,265 @@
import { createDefaultArcadeRegistry, type ArcadeRegistry } from "./registry.js";
import { fit, renderModal, renderTaskOverlay, TextFrame } from "./renderer.js";
import type { KeyEvent, TaskEvent, TaskSnapshot, WaitingGame } from "./types.js";
export interface ArcadeSessionOptions {
game?: string | boolean;
standalone?: boolean;
task?: TaskSnapshot;
logs?: string[];
registry?: ArcadeRegistry;
input?: NodeJS.ReadStream;
output?: NodeJS.WriteStream;
simulateTask?: boolean;
}
type Modal =
| { kind: "pause"; title: string; body: string[]; actions: string[] }
| { kind: "complete"; title: string; body: string[]; actions: string[] }
| { kind: "approval"; title: string; body: string[]; actions: string[] }
| { kind: "error"; title: string; body: string[]; actions: string[] };
export function renderArcadeSnapshot(options: ArcadeSessionOptions = {}) {
const registry = options.registry ?? createDefaultArcadeRegistry();
const gameId = registry.choose(typeof options.game === "string" ? options.game : undefined);
const game = registry.create(gameId);
const task = options.task;
const frame = new TextFrame(80, 28);
game.init({ width: frame.width, height: frame.height, random: Math.random, task, emit: () => undefined });
game.render(frame);
renderTaskOverlay(frame, task, options.standalone ?? true);
return frame.toString();
}
export async function runArcadeSession(options: ArcadeSessionOptions = {}) {
const input = options.input ?? process.stdin;
const output = options.output ?? process.stdout;
const registry = options.registry ?? createDefaultArcadeRegistry();
const gameId = registry.choose(typeof options.game === "string" ? options.game : undefined);
const game = registry.create(gameId);
let task = options.task;
const logs = [...(options.logs ?? [])];
if (!input.isTTY || !output.isTTY) {
output.write(renderArcadeSnapshot({ ...options, game: gameId, task, logs }) + "\n");
if (!options.standalone) {
output.write("\nWaiting Arcade interactive mode is disabled outside an interactive terminal.\n");
}
return;
}
await game.init({ width: output.columns ?? 80, height: output.rows ?? 28, random: Math.random, task, emit: () => undefined });
let modal: Modal | undefined;
let showLogs = false;
let closed = false;
let lastFrameAt = Date.now();
let cleanupComplete = false;
const previousRawMode = input.isRaw;
const cleanup = () => {
if (cleanupComplete) {
return;
}
cleanupComplete = true;
clearInterval(loop);
for (const timer of timers) {
clearTimeout(timer);
}
input.off("data", onData);
process.off("SIGINT", onSigint);
if (input.isTTY) {
input.setRawMode(previousRawMode ?? false);
}
input.pause();
output.write("\x1b[?25h\x1b[?1049l");
};
const onSigint = () => {
closed = true;
cleanup();
};
const onData = (data: Buffer) => {
const event = parseKey(data);
if (event.ctrl && event.name === "c") {
modal = { kind: "pause", title: "STOP TASK?", body: ["Ctrl+C received.", "Press Q to quit arcade, or Esc to continue."], actions: ["[Q] Quit", "[Esc] Continue"] };
return;
}
if (showLogs) {
if (event.name === "escape" || event.name === "l" || (event.ctrl && event.name === "g")) {
showLogs = false;
}
return;
}
if (modal) {
if (event.name === "q") {
closed = true;
cleanup();
} else if (event.name === "l" || (event.ctrl && event.name === "g")) {
showLogs = true;
} else if (event.name === "escape" || event.name === "c" || event.name === "a" || event.name === "d" || event.name === "r") {
modal = undefined;
game.resume?.();
}
return;
}
const action = game.input(event);
if (action?.type === "quit") {
closed = true;
cleanup();
} else if (action?.type === "pause") {
game.pause?.();
modal = { kind: "pause", title: "ARCADE PAUSED", body: ["Task continues in the background."], actions: ["[Esc/C] Continue", "[L] Logs", "[Q] Quit"] };
} else if (action?.type === "toggle_logs") {
showLogs = true;
} else if (action?.type === "redraw") {
render();
}
};
const applyTaskEvent = (event: TaskEvent) => {
if ("task" in event) {
task = event.task;
}
if (event.type === "task:log") {
logs.push(`[${event.timestamp}] ${event.line}`);
}
if (event.type === "task:approval_required" || event.type === "task:expense_required" || event.type === "task:done" || event.type === "task:error") {
game.pause?.();
}
if (event.type === "task:approval_required") {
modal = { kind: "approval", title: "APPROVAL REQUIRED", body: [event.reason], actions: ["[A] Approve", "[D] Deny", "[L] Logs", "[P] Pause Job"] };
}
if (event.type === "task:expense_required") {
modal = { kind: "approval", title: "EXPENSE APPROVAL", body: [`Provider: ${event.provider}`, `Cost: $${event.amountUsd.toFixed(2)}`, event.reason], actions: ["[A] Approve", "[D] Deny", "[L] Logs"] };
}
if (event.type === "task:done") {
modal = { kind: "complete", title: "TASK COMPLETE", body: [event.result, `Cost: $${event.task.costUsd?.toFixed(2) ?? "0.00"}`], actions: ["[R] Review", "[L] Logs", "[C] Continue", "[Q] Quit"] };
}
if (event.type === "task:error") {
modal = { kind: "error", title: "TASK FAILED", body: [event.error], actions: ["[R] Retry", "[L] Logs", "[Q] Quit"] };
}
};
const timers = options.simulateTask ? createDemoTaskEvents(applyTaskEvent) : [];
input.setRawMode(true);
input.resume();
input.on("data", onData);
process.on("SIGINT", onSigint);
output.write("\x1b[?1049h\x1b[?25l");
const render = () => {
if (closed) {
return;
}
const width = output.columns ?? 80;
const height = output.rows ?? 28;
const frame = new TextFrame(width, height);
if (showLogs) {
renderLogs(frame, logs);
} else {
game.render(frame);
renderTaskOverlay(frame, task, options.standalone ?? true);
}
output.write(`\x1b[H\x1b[2J${frame.toString()}`);
if (modal) {
output.write("\n\n" + renderModal(modal.title, modal.body, modal.actions));
}
};
const loop = setInterval(() => {
const now = Date.now();
if (!modal && !showLogs) {
game.tick(now - lastFrameAt);
}
lastFrameAt = now;
render();
}, 66);
render();
await new Promise<void>((resolve) => {
const done = setInterval(() => {
if (closed) {
clearInterval(done);
resolve();
}
}, 50);
});
}
function renderLogs(frame: TextFrame, logs: string[]) {
frame.box(0, 0, frame.width, frame.height, "TASK LOGS");
const visible = logs.slice(-(frame.height - 4));
if (visible.length === 0) {
frame.write(2, 2, "No logs yet.");
}
for (const [index, line] of visible.entries()) {
frame.write(2 + index, 2, fit(line, frame.width - 4));
}
frame.write(frame.height - 2, 2, "[Esc/L/Ctrl+G] close logs");
}
function parseKey(data: Buffer): KeyEvent {
const sequence = data.toString("utf8");
if (sequence === "\u0003") return { name: "c", sequence, ctrl: true };
if (sequence === "\u0007") return { name: "g", sequence, ctrl: true };
if (sequence === "\u001b") return { name: "escape", sequence };
if (sequence === "\r") return { name: "return", sequence };
if (sequence === " ") return { name: "space", sequence };
if (sequence === "\u001b[A") return { name: "up", sequence };
if (sequence === "\u001b[B") return { name: "down", sequence };
if (sequence === "\u001b[C") return { name: "right", sequence };
if (sequence === "\u001b[D") return { name: "left", sequence };
return { name: sequence.toLowerCase(), sequence };
}
function createDemoTaskEvents(apply: (event: TaskEvent) => void) {
const started: TaskSnapshot = {
id: "demo_yolo",
title: "AgentSwarm YOLO session",
status: "running",
phase: "inspecting repo",
progress: 0.2,
costUsd: 0.08,
lastMessage: "cloned repo"
};
return [
setTimeout(() => apply({ type: "task:started", task: started }), 250),
setTimeout(() => apply({ type: "task:log", timestamp: new Date().toISOString(), line: "agent swarm inspected issue context" }), 900),
setTimeout(
() =>
apply({
type: "task:progress",
task: { ...started, phase: "running tests", progress: 0.58, costUsd: 0.21, lastMessage: "generated patch" }
}),
2200
),
setTimeout(
() =>
apply({
type: "task:expense_required",
task: { ...started, status: "approval_required", phase: "browser QA", progress: 0.72, costUsd: 0.21, lastMessage: "approval required" },
provider: "Browserless",
amountUsd: 0.18,
reason: "Verify checkout flow before opening the PR."
}),
5200
),
setTimeout(
() =>
apply({
type: "task:done",
task: { ...started, status: "done", phase: "complete", progress: 1, costUsd: 0.72, lastMessage: "PR ready for review" },
result: "PR #184 is ready for review. Tests passed."
}),
11000
)
];
}

View file

@ -0,0 +1,71 @@
export interface GameControl {
key: string;
action: string;
}
export interface KeyEvent {
name: string;
sequence: string;
ctrl?: boolean;
}
export type GameAction =
| { type: "quit" }
| { type: "pause" }
| { type: "toggle_logs" }
| { type: "redraw" }
| { type: "message"; message: string };
export interface TaskSnapshot {
id: string;
title: string;
status: "queued" | "running" | "paused" | "approval_required" | "done" | "error" | "cancelled";
phase?: string;
progress?: number;
costUsd?: number;
lastMessage?: string;
}
export type TaskEvent =
| { type: "task:started"; task: TaskSnapshot }
| { type: "task:progress"; task: TaskSnapshot }
| { type: "task:log"; line: string; timestamp: string }
| { type: "task:approval_required"; task: TaskSnapshot; reason: string }
| { type: "task:expense_required"; task: TaskSnapshot; provider: string; amountUsd: number; reason: string }
| { type: "task:done"; task: TaskSnapshot; result: string }
| { type: "task:error"; task: TaskSnapshot; error: string }
| { type: "task:cancelled"; task: TaskSnapshot };
export type ArcadeEvent = { type: "game:score"; score: number } | { type: "game:message"; message: string };
export interface GameContext {
width: number;
height: number;
random: () => number;
task?: TaskSnapshot;
emit: (event: ArcadeEvent) => void;
}
export interface WaitingGame {
id: string;
title: string;
description: string;
controls: GameControl[];
init(ctx: GameContext): void | Promise<void>;
tick(deltaMs: number): void;
input(event: KeyEvent): GameAction | void;
render(frame: TerminalFrame): void;
pause?(): void;
resume?(): void;
stop?(): void;
}
export interface TerminalFrame {
width: number;
height: number;
write(row: number, col: number, text: string): void;
box(row: number, col: number, width: number, height: number, title?: string): void;
toString(): string;
}

View file

@ -1,6 +1,8 @@
import { createPluginRegistry } from "@logicsrc/plugin-core";
import { coinPayPlugin } from "@logicsrc/plugin-coinpay";
import { uGigPlugin } from "@logicsrc/plugin-ugig";
export { ArcadeRegistry, createDefaultArcadeRegistry, renderArcadeList, renderArcadeSnapshot, runArcadeSession } from "./arcade/index.js";
export type { ArcadeEvent, GameAction, GameContext, GameControl, KeyEvent, TaskEvent, TaskSnapshot, TerminalFrame, WaitingGame } from "./arcade/index.js";
export interface TuiState {
did: string;