mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 22:37:29 +00:00
Add waiting arcade MVP
This commit is contained in:
parent
b1d8fe475a
commit
4682b261c0
16 changed files with 1192 additions and 2 deletions
49
packages/tui/src/arcade/games/agent-swarm.ts
Normal file
49
packages/tui/src/arcade/games/agent-swarm.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
77
packages/tui/src/arcade/games/hangman.ts
Normal file
77
packages/tui/src/arcade/games/hangman.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
83
packages/tui/src/arcade/games/minefield.ts
Normal file
83
packages/tui/src/arcade/games/minefield.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
62
packages/tui/src/arcade/games/moon-runner.ts
Normal file
62
packages/tui/src/arcade/games/moon-runner.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
78
packages/tui/src/arcade/games/snake.ts
Normal file
78
packages/tui/src/arcade/games/snake.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
3
packages/tui/src/arcade/index.ts
Normal file
3
packages/tui/src/arcade/index.ts
Normal 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";
|
||||
75
packages/tui/src/arcade/registry.ts
Normal file
75
packages/tui/src/arcade/registry.ts
Normal 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");
|
||||
}
|
||||
98
packages/tui/src/arcade/renderer.ts
Normal file
98
packages/tui/src/arcade/renderer.ts
Normal 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");
|
||||
}
|
||||
265
packages/tui/src/arcade/runtime.ts
Normal file
265
packages/tui/src/arcade/runtime.ts
Normal 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
|
||||
)
|
||||
];
|
||||
}
|
||||
71
packages/tui/src/arcade/types.ts
Normal file
71
packages/tui/src/arcade/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue