diff --git a/artifacts/curses-cli/curses-bench-live-2026-02-26.zip b/artifacts/curses-cli/curses-bench-live-2026-02-26.zip new file mode 100644 index 000000000000..00bd9dbfbbf3 Binary files /dev/null and b/artifacts/curses-cli/curses-bench-live-2026-02-26.zip differ diff --git a/data/json/main.lua b/data/json/main.lua index 508f242fe6f1..f470b8b5a9aa 100644 --- a/data/json/main.lua +++ b/data/json/main.lua @@ -2,6 +2,7 @@ local voltmeter = require("./voltmeter") local slimepit = require("./slimepit") local artifact_analyzer = require("./artifact_analyzer") local lua_traits = require("./lua_traits") +local action_menu_macros = require("lib.action_menu_macros") local mod = game.mod_runtime[game.current_mod] local storage = game.mod_storage[game.current_mod] @@ -11,3 +12,4 @@ mod.slimepit = slimepit mod.artifact_analyzer = artifact_analyzer mod.lua_traits = lua_traits lua_traits.register(mod) +action_menu_macros.register_defaults() diff --git a/data/lua/lib/action_menu_macros.lua b/data/lua/lib/action_menu_macros.lua new file mode 100644 index 000000000000..6d8e89bfb5b2 --- /dev/null +++ b/data/lua/lib/action_menu_macros.lua @@ -0,0 +1,150 @@ +local ui = require("lib.ui") + +local action_menu_macros = {} + +local function popup_recent_messages() + local entries = gapi.get_messages(12) + if #entries == 0 then + ui.popup(locale.gettext("No recent messages.")) + return + end + + local lines = { locale.gettext("Recent Messages"), "" } + for _, entry in ipairs(entries) do + table.insert(lines, string.format("[%s] %s", entry.time, entry.text)) + end + + ui.popup(table.concat(lines, "\n")) +end + +local function popup_recent_lua_log() + local entries = gapi.get_lua_log(20) + if #entries == 0 then + ui.popup(locale.gettext("No recent Lua log entries.")) + return + end + + local lines = { locale.gettext("Recent Lua Log"), "" } + for _, entry in ipairs(entries) do + local source_prefix = entry.from_user and "> " or "" + table.insert(lines, string.format("[%s] %s%s", entry.level, source_prefix, entry.text)) + end + + ui.popup(table.concat(lines, "\n")) +end + +local function announce_current_turn() + local turn_value = gapi.current_turn():to_turn() + gapi.add_msg(string.format(locale.gettext("Current turn: %d"), turn_value)) +end + +local function report_agent_context() + local avatar = gapi.get_avatar() + local map = gapi.get_map() + local pos = avatar:get_pos_ms() + local turn_value = gapi.current_turn():to_turn() + local details = string.format( + "[AI] turn=%d local_ms=(%d,%d,%d) outside=%s sheltered=%s", + turn_value, + pos.x, + pos.y, + pos.z, + tostring(map:is_outside(pos)), + tostring(map:is_sheltered(pos)) + ) + gapi.add_msg(details) +end + +local function report_look_target() + local target = gapi.look_around() + if not target then + gapi.add_msg(locale.gettext("Look canceled.")) + return + end + + local target_abs = gapi.get_map():get_abs_ms(target) + gapi.add_msg( + string.format( + "[AI] look local_ms=(%d,%d,%d) abs_ms=(%d,%d,%d)", + target.x, + target.y, + target.z, + target_abs.x, + target_abs.y, + target_abs.z + ) + ) +end + +local function report_adjacent_choice() + local target = gapi.choose_adjacent(locale.gettext("Choose adjacent tile for AI context"), true) + if not target then + gapi.add_msg(locale.gettext("Adjacent selection canceled.")) + return + end + + local target_abs = gapi.get_map():get_abs_ms(target) + gapi.add_msg( + string.format( + "[AI] adjacent local_ms=(%d,%d,%d) abs_ms=(%d,%d,%d)", + target.x, + target.y, + target.z, + target_abs.x, + target_abs.y, + target_abs.z + ) + ) +end + +action_menu_macros.register_defaults = function() + gapi.register_action_menu_entry({ + id = "bn_macro_recent_messages", + name = locale.gettext("Recent Messages"), + description = locale.gettext("Show the latest in-game messages in a popup."), + category = "info", + fn = popup_recent_messages, + }) + + gapi.register_action_menu_entry({ + id = "bn_macro_recent_lua_log", + name = locale.gettext("Recent Lua Log"), + description = locale.gettext("Show the latest Lua console log entries in a popup."), + category = "info", + fn = popup_recent_lua_log, + }) + + gapi.register_action_menu_entry({ + id = "bn_macro_current_turn", + name = locale.gettext("Current Turn"), + description = locale.gettext("Print the current absolute turn in the message log."), + category = "info", + fn = announce_current_turn, + }) + + gapi.register_action_menu_entry({ + id = "bn_macro_agent_context", + name = locale.gettext("AI Context Packet"), + description = locale.gettext("Print turn, local coordinates, and shelter/outside state."), + category = "info", + fn = report_agent_context, + }) + + gapi.register_action_menu_entry({ + id = "bn_macro_look_target", + name = locale.gettext("AI Look Target"), + description = locale.gettext("Pick a tile via look-around and print local/absolute coordinates."), + category = "info", + fn = report_look_target, + }) + + gapi.register_action_menu_entry({ + id = "bn_macro_adjacent_target", + name = locale.gettext("AI Adjacent Target"), + description = locale.gettext("Pick an adjacent tile and print local/absolute coordinates."), + category = "info", + fn = report_adjacent_choice, + }) +end + +return action_menu_macros diff --git a/docs/en/dev/guides/bench.md b/docs/en/dev/guides/bench.md new file mode 100644 index 000000000000..a9f91d8fbd09 --- /dev/null +++ b/docs/en/dev/guides/bench.md @@ -0,0 +1,62 @@ +# Curses CLI Benchmark Protocol + +This guide defines a repeatable benchmark loop for Cataclysm-BN `curses-cli` automation and documents known churn points plus mitigation. + +## Goal + +Run a deterministic scenario that validates real gameplay control quality: + +1. start as evacuee, +2. collect baseline tools, +3. travel toward nearest town, +4. fight naturally encountered enemies, +5. archive cast and compact artifacts. + +The benchmark is invalid when debug spawning/wish/debug-menu actions are used. + +## Standard run + +```bash +deno task pr:verify:curses-cli start --state-file /tmp/curses-bench.json --render-webp false +deno task pr:verify:curses-cli inputs-jsonl --state-file /tmp/curses-bench.json +# execute scripted scenario steps +deno task pr:verify:curses-cli capture --state-file /tmp/curses-bench.json --id bench-final --caption "Benchmark final state" --lines 120 +deno task pr:verify:curses-cli stop --state-file /tmp/curses-bench.json --status passed +``` + +## Artifact contract + +- Cast: `/tmp/curses-cli/casts/*.cast` +- Session manifest and captures: `/tmp/curses-cli/runs/live-*/` +- Runtime exports: + - `available_keys.json` + - `available_macros.json` + - `ai_state.json` + +## Churn control checklist + +- Validate current mode before each critical key sequence. +- Guard against nested UI states (`look`, map, debug, targeting, lua console). +- Keep safe-mode policy explicit for the profile. +- Use macro IDs (`macro:`) for robust intent calls where possible. +- Persist stop reason category on failure (`menu_drift`, `mode_trap`, `safe_mode_interrupt`, `repro_drift`). + +## Planned compact dump + +A compact dump should be preferred over raw repeated full snapshots for AI loops. Target payload: + +- ASCII pane excerpt (trimmed) +- available inputs (prompt-derived) +- available action keys JSON snapshot +- available macros JSON snapshot +- recent logs and ai_state summary +- run metadata (turn, coords, mode, stop reason) + +This reduces token load while preserving actionable context. + +## Low-priority reproducibility improvements + +- Add benchmark profile presets with fixed seed and explicit scenario/profession. +- Add a pre-generated benchmark world/save fixture to avoid early-world variance. +- Add deterministic character template to avoid random traits affecting control difficulty. +- Add fast-start option to skip non-critical intro screens. diff --git a/opencode.json b/opencode.json new file mode 100644 index 000000000000..a649becd1b62 --- /dev/null +++ b/opencode.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "curses_mcp": { + "type": "local", + "command": [ + "deno", + "task", + "pr:verify:curses-mcp:server" + ], + "enabled": true, + "timeout": 15000 + } + } +} diff --git a/scripts/curses-cli/common.ts b/scripts/curses-cli/common.ts new file mode 100644 index 000000000000..9d8cd0089e13 --- /dev/null +++ b/scripts/curses-cli/common.ts @@ -0,0 +1,493 @@ +import { dirname, fromFileUrl, resolve } from "@std/path" + +const SCRIPT_DIR = dirname(fromFileUrl(import.meta.url)) +const REPO_ROOT = resolve(SCRIPT_DIR, "..", "..") + +const PROMPT_CONFIRM_KEY = "Enter" +const PROMPT_NEXT_TAB_KEY = "Tab" +const PROMPT_PREV_TAB_KEY = "BTab" + +export type CaptureEntry = { + id: string + caption: string + text_file: string + code_block_file: string + screenshot_file?: string +} + +type RunStatus = "passed" | "failed" + +type InputSource = "prompt" +type InputPriority = "normal" | "high" + +export type AvailableInput = { + id: string + key: string + source: InputSource + priority: InputPriority + label: string + description: string +} + +export const timestamp = (): string => + new Date().toISOString().replaceAll(":", "").replaceAll(".", "-") + +/** Produces `yyyy-mm-dd_hh-mm-ss` for cast filenames. */ +export const sessionTimestamp = (): string => { + const d = new Date() + const pad = (n: number) => String(n).padStart(2, "0") + const date = [d.getFullYear(), pad(d.getMonth() + 1), pad(d.getDate())].join("-") + const time = [pad(d.getHours()), pad(d.getMinutes()), pad(d.getSeconds())].join("-") + return `${date}_${time}` +} + +const shellEscape = (value: string): string => `'${value.replaceAll("'", "'\\''")}'` + +export const sanitizeId = (value: string): string => + value.replaceAll(/[^a-zA-Z0-9_-]/g, "-").replaceAll(/-+/g, "-") + +export const resolveInputKey = (inputId: string): string => { + const normalized = inputId.trim() + if (normalized.length === 0) { + throw new Error("input id cannot be empty") + } + + if (normalized.startsWith("key:")) { + const key = normalized.slice("key:".length) + if (key.length === 0) { + throw new Error("key input id cannot be empty") + } + return key + } + + return normalized +} + +const dedupeInputs = (inputs: AvailableInput[]): AvailableInput[] => { + const byId = new Map() + for (const input of inputs) { + if (!byId.has(input.id)) { + byId.set(input.id, input) + } + } + return [...byId.values()] +} + +const normalizePromptKey = (raw: string): string | undefined => { + const token = raw.trim().replaceAll("`", "") + if (token.length === 0) { + return undefined + } + + if (token === "'" || token === '"\'"') { + return "'" + } + + const upper = token.toUpperCase() + if (upper === "TAB") { + return "Tab" + } + if (upper === "BACKTAB") { + return "BTab" + } + if (upper === "SPACE") { + return "Space" + } + if (upper === "ENTER") { + return "Enter" + } + if (token.length === 1) { + return token + } + + return undefined +} + +const toPromptLabel = (text: string): string => + text + .trim() + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, "_") + .replaceAll(/^_+|_+$/g, "") + +const detectInlinePromptChoices = (pane: string): AvailableInput[] => { + const detected: AvailableInput[] = [] + + for (const match of pane.matchAll(/\(([A-Za-z0-9!?'./;,])\)\s*([A-Za-z][A-Za-z0-9_-]*)/g)) { + const promptKey = normalizePromptKey(match[1]) + if (promptKey === undefined) { + continue + } + + const actionText = match[2] + const label = toPromptLabel(actionText) + detected.push({ + id: `key:${promptKey}`, + key: promptKey, + source: "prompt", + priority: "high", + label: label.length > 0 ? label : `key_${promptKey}`, + description: `Prompt option: ${actionText}`, + }) + } + + for ( + const match of pane.matchAll( + /(?:Press|press)\s+([^\s]+)\s+to\s+([^,.\n\r)]+)/g, + ) + ) { + const promptKey = normalizePromptKey(match[1]) + if (promptKey === undefined) { + continue + } + + const actionText = match[2].trim() + const label = toPromptLabel(actionText) + detected.push({ + id: `key:${promptKey}`, + key: promptKey, + source: "prompt", + priority: "high", + label: label.length > 0 ? label : `key_${promptKey}`, + description: `Prompt option: ${actionText}`, + }) + } + + return dedupeInputs(detected) +} + +export const detectPromptInputs = (pane: string): AvailableInput[] => { + const detected: AvailableInput[] = [...detectInlinePromptChoices(pane)] + // --- Press any key / space bar prompts --- + if (pane.includes("Press any key") || pane.includes("space bar")) { + detected.push({ + id: "key:Space", + key: "Space", + source: "prompt", + priority: "high", + label: "dismiss_prompt", + description: "Advance a Press any key style prompt", + }) + } + + // --- Safe mode prompt --- + if (pane.includes("safe mode is on") || pane.includes("Press ! to turn it off")) { + detected.push({ + id: "key:!", + key: "!", + source: "prompt", + priority: "high", + label: "disable_safe_mode", + description: "Disable safe mode warning stops", + }) + detected.push({ + id: "key:'", + key: "'", + source: "prompt", + priority: "high", + label: "ignore_current_monster", + description: "Ignore current spotted monster and continue", + }) + } + + // --- Generic yes/no confirmation prompts --- + // Covers: "Are you SURE you're finished?", "Really step into ...?", + // "Really walk into ...?", "Wield the ..?" and other Y/N query_yn prompts + if ( + pane.includes("Are you SURE you're finished?") || + /Really (step|walk|drag|drive|dismount|fly|swim|climb|go)/.test(pane) || + /Wield the .+\?/.test(pane) || + /Consume the .+\?/.test(pane) || + /Drink the .+\?/.test(pane) || + /Start butchering/.test(pane) || + /You see .*\. Continue\?/.test(pane) || + /You hear .*\. Continue\?/.test(pane) + ) { + detected.push({ + id: "key:Y", + key: "Y", + source: "prompt", + priority: "high", + label: "answer_yes", + description: "Confirm yes/no prompt", + }) + detected.push({ + id: "key:N", + key: "N", + source: "prompt", + priority: "normal", + label: "answer_no", + description: "Decline yes/no prompt", + }) + } + + // --- Character creation finish prompt --- + if (pane.includes("Press TAB to finish character creation or BACKTAB to go back.")) { + detected.push({ + id: `key:${PROMPT_NEXT_TAB_KEY}`, + key: PROMPT_NEXT_TAB_KEY, + source: "prompt", + priority: "high", + label: "finish_character_creation", + description: "Move to finish character creation", + }) + detected.push({ + id: `key:${PROMPT_PREV_TAB_KEY}`, + key: PROMPT_PREV_TAB_KEY, + source: "prompt", + priority: "normal", + label: "go_back_character_creation", + description: "Go back in character creation tabs", + }) + } + + // --- Inventory item selection / letter prompts --- + // When the game shows an inventory list for wield/eat/wear/drop/read/apply, + // items are listed as " a - rock" or " b - pipe" with letter keys a-z, A-Z + if ( + pane.includes("Wield what?") || + pane.includes("Wear what?") || + pane.includes("Eat what?") || + pane.includes("Read what?") || + pane.includes("Drop what?") || + pane.includes("Apply what?") || + pane.includes("Consume item:") || + pane.includes("Use item:") || + pane.includes("Compare:") || + pane.includes("Choose an item") || + pane.includes("Select an item") || + /Get items from where\?/.test(pane) + ) { + // Extract letter-keyed items like "a - rock", "B - pipe" + for (const match of pane.matchAll(/^\s*([a-zA-Z])\s+-\s+(.+?)\s*$/gm)) { + const letterKey = match[1] + const itemName = match[2].trim() + detected.push({ + id: `key:${letterKey}`, + key: letterKey, + source: "prompt", + priority: "high", + label: `select_item_${letterKey}`, + description: `Select: ${itemName}`, + }) + } + } + + // --- Crafting menu detection --- + // The crafting menu shows recipe categories and filter prompt + if ( + pane.includes("Craft:") || pane.includes("Your crafting inventory") || + pane.includes("Recipe search") + ) { + detected.push({ + id: "key:/", + key: "/", + source: "prompt", + priority: "high", + label: "filter_recipes", + description: "Type to filter/search recipes in crafting menu", + }) + detected.push({ + id: `key:${PROMPT_NEXT_TAB_KEY}`, + key: PROMPT_NEXT_TAB_KEY, + source: "prompt", + priority: "high", + label: "next_craft_category", + description: "Switch to next crafting category tab", + }) + detected.push({ + id: `key:${PROMPT_PREV_TAB_KEY}`, + key: PROMPT_PREV_TAB_KEY, + source: "prompt", + priority: "normal", + label: "prev_craft_category", + description: "Switch to previous crafting category tab", + }) + detected.push({ + id: `key:${PROMPT_CONFIRM_KEY}`, + key: PROMPT_CONFIRM_KEY, + source: "prompt", + priority: "high", + label: "select_recipe", + description: "Select the highlighted recipe to craft", + }) + } + + // --- Quantity / number input prompts --- + // "How many? (0-10)" or "Craft how many?" or "Set value (0-100):" + if (/[Hh]ow many|[Ss]et value|[Ee]nter a number|[Qq]uantity/.test(pane)) { + detected.push({ + id: `key:${PROMPT_CONFIRM_KEY}`, + key: PROMPT_CONFIRM_KEY, + source: "prompt", + priority: "high", + label: "confirm_quantity", + description: "Confirm the entered quantity/number", + }) + } + + // --- Pickup prompt (multi-item) --- + // When 'g' is pressed with multiple items, shows pickup list + if (pane.includes("PICK UP") || pane.includes("Pickup:")) { + detected.push({ + id: `key:${PROMPT_CONFIRM_KEY}`, + key: PROMPT_CONFIRM_KEY, + source: "prompt", + priority: "high", + label: "confirm_pickup", + description: "Confirm item pickup selection", + }) + } + + // --- Butcher / disassemble menu --- + if (pane.includes("Choose corpse to butcher") || pane.includes("item to disassemble")) { + detected.push({ + id: `key:${PROMPT_CONFIRM_KEY}`, + key: PROMPT_CONFIRM_KEY, + source: "prompt", + priority: "high", + label: "confirm_butcher_selection", + description: "Confirm butcher/disassemble selection", + }) + } + + // --- World building / loading screen --- + if (pane.includes("Please wait as we build your world") || pane.includes("Loading the save")) { + detected.push({ + id: "key:Space", + key: "Space", + source: "prompt", + priority: "normal", + label: "wait_loading", + description: "Game is loading \u2014 wait for completion", + }) + } + + // --- Main menu detection --- + if (pane.includes("MOTD") && (pane.includes("New Game") || pane.includes("Load"))) { + detected.push({ + id: "key:Down", + key: "Down", + source: "prompt", + priority: "high", + label: "navigate_main_menu", + description: "Navigate main menu options", + }) + detected.push({ + id: `key:${PROMPT_CONFIRM_KEY}`, + key: PROMPT_CONFIRM_KEY, + source: "prompt", + priority: "high", + label: "select_main_menu", + description: "Select highlighted main menu option", + }) + } + return dedupeInputs(detected) +} + +export const listAvailableInputs = ( + pane: string, + _includeCatalog = true, +): AvailableInput[] => { + return detectPromptInputs(pane) +} + +type BuildLaunchCommandOptions = { + binPath: string + userdir: string + world: string + seed?: string + availableKeysJson?: string + availableMacrosJson?: string + aiHelperOutput?: string +} + +export const buildLaunchCommand = (options: BuildLaunchCommandOptions): string => { + const seed = options.seed ?? "" + const availableKeysJson = options.availableKeysJson ?? "" + const availableMacrosJson = options.availableMacrosJson ?? "" + const aiHelperOutput = options.aiHelperOutput ?? "" + const parts = [ + "TERM=xterm-256color", + "COLORTERM=truecolor", + "BROWSER=true", + "CATA_DISABLE_OPEN_URL=1", + ...(availableKeysJson.length > 0 + ? [`CATA_AVAILABLE_KEYS_JSON=${shellEscape(availableKeysJson)}`] + : []), + ...(availableMacrosJson.length > 0 + ? [`CATA_AVAILABLE_MACROS_JSON=${shellEscape(availableMacrosJson)}`] + : []), + ...(aiHelperOutput.length > 0 ? [`AI_HELPER_OUTPUT=${shellEscape(aiHelperOutput)}`] : []), + shellEscape(options.binPath), + "--basepath", + shellEscape(REPO_ROOT), + "--userdir", + shellEscape(options.userdir), + ] + + if (options.world.length > 0) { + parts.push("--world", shellEscape(options.world)) + } + + if (seed.length > 0) { + parts.push("--seed", shellEscape(seed)) + } + + return parts.join(" ") +} + +export const writeCodeBlockCapture = async ( + outputPath: string, + textContent: string, +): Promise => { + const body = ["```text", textContent, "```", ""].join("\n") + await Deno.writeTextFile(outputPath, body) +} + +type WriteIndexOptions = { + outputPath: string + sessionName: string + captures: CaptureEntry[] + status: RunStatus + castFile?: string + failureMessage?: string +} + +export const writeIndex = async (options: WriteIndexOptions): Promise => { + const lines = [ + "# PR Verify Artifact", + "", + `Session: ${options.sessionName}`, + `Status: ${options.status}`, + "", + "## Captures", + "", + ...options.captures.map((capture, index) => { + const title = capture.caption.length > 0 ? capture.caption : capture.id + const screenshotSuffix = capture.screenshot_file + ? `, screenshot: \`${capture.screenshot_file}\`` + : "" + return `${ + index + 1 + }. ${title} - text: \`${capture.text_file}\`, code block: \`${capture.code_block_file}\`${screenshotSuffix}` + }), + "", + ] + + if (options.castFile !== undefined) { + lines.push(`Cast recording: \`${options.castFile}\``) + lines.push("") + } + + if (options.failureMessage !== undefined) { + lines.push("## Failure") + lines.push("") + lines.push("```text") + lines.push(options.failureMessage) + lines.push("```") + lines.push("") + } + + await Deno.writeTextFile(options.outputPath, lines.join("\n")) +} diff --git a/scripts/curses-cli/main.ts b/scripts/curses-cli/main.ts new file mode 100644 index 000000000000..2d5cd6b12e09 --- /dev/null +++ b/scripts/curses-cli/main.ts @@ -0,0 +1,1539 @@ +#!/usr/bin/env -S deno run --allow-run --allow-read --allow-write + +import { ensureDir } from "@std/fs" +import { dirname, fromFileUrl, join, relative, resolve } from "@std/path" +import { Command } from "@cliffy/command" +import { + buildLaunchCommand, + listAvailableInputs, + resolveInputKey, + sanitizeId, + sessionTimestamp, + timestamp, + writeCodeBlockCapture, + writeIndex, +} from "./common.ts" +import type { CaptureEntry } from "./common.ts" +import { + configureTmuxBinary, + configureTmuxSocket, + ensureTmuxAvailable, + TmuxSession, +} from "./tmux.ts" +import type { MouseButton, MouseEventType } from "./tmux.ts" + +const SCRIPT_DIR = dirname(fromFileUrl(import.meta.url)) +const REPO_ROOT = resolve(SCRIPT_DIR, "..", "..") +const TMP_CLI_ROOT = resolve("/tmp", "curses-cli") +const DEFAULT_STATE_FILE = resolve(TMP_CLI_ROOT, "state", ".live-session.json") +const SINGLETON_SESSION_NAME = "curses-cli" +const SINGLETON_TMUX_SOCKET = "curses-cli" + +type SessionState = { + id: string + artifact_dir: string + captures_dir: string + session_name: string + userdir: string + bin_path: string + world: string + seed?: string + available_keys_json?: string + available_macros_json?: string + tmux_bin: string + tmux_socket?: string + width: number + height: number + magick_bin: string + render_webp: boolean + webp_quality: number + record_cast: boolean + asciinema_bin: string + cast_idle_time_limit?: number + cast_file?: string + cast_pid?: number + cast_log_file?: string + status: "running" | "stopped" + started_at: string + finished_at?: string + captures: CaptureEntry[] +} + +const delay = (ms: number): Promise => + new Promise((resolveDelay) => setTimeout(resolveDelay, ms)) + +const ANSI_ESCAPE_PATTERN = new RegExp("\\u001B(?:\\[[0-?]*[ -/]*[@-~]|[@-Z\\\\-_])", "g") + +const stripAnsiFromPane = (pane: string): string => pane.replaceAll(ANSI_ESCAPE_PATTERN, "") + +const displayPath = (absolutePath: string): string => + absolutePath.startsWith(`${REPO_ROOT}/`) ? relative(REPO_ROOT, absolutePath) : absolutePath + +const shellEscape = (value: string): string => `'${value.replaceAll("'", "'\\''")}'` + +const isBinaryAvailable = async ( + binary: string, + versionArgs: string[] = ["-version"], +): Promise => { + try { + const output = await new Deno.Command(binary, { + args: versionArgs, + stdout: "null", + stderr: "null", + }).output() + return output.success + } catch { + return false + } +} + +const ensureLaunchBinaryExists = async (binPath: string): Promise => { + try { + const stat = await Deno.stat(binPath) + if (!stat.isFile) { + throw new Error(`game binary path is not a file: ${binPath}`) + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + throw new Error(`game binary not found: ${binPath}`) + } + throw error + } +} + +type StartDetachedCastProcessOptions = { + asciinemaBin: string + castArgs: string[] + castLogFile: string +} + +const startDetachedCastProcess = async ( + options: StartDetachedCastProcessOptions, +): Promise => { + const escapedArgs = [shellEscape(options.asciinemaBin), ...options.castArgs.map(shellEscape)] + .join(" ") + const launchScript = `nohup env -u TMUX ${escapedArgs} >${ + shellEscape(options.castLogFile) + } 2>&1 < /dev/null & echo $!` + const output = await new Deno.Command("bash", { + args: ["-lc", launchScript], + cwd: REPO_ROOT, + stdout: "piped", + stderr: "piped", + }).output() + + const stderr = new TextDecoder().decode(output.stderr).trim() + const stdout = new TextDecoder().decode(output.stdout).trim() + if (!output.success) { + throw new Error( + stderr.length > 0 + ? `failed to start detached cast recorder: ${stderr}` + : "failed to start detached cast recorder", + ) + } + + const pid = Number.parseInt(stdout.split("\n").at(-1)?.trim() ?? "", 10) + if (!Number.isFinite(pid) || pid <= 0) { + const details = stderr.length > 0 ? stderr : stdout + throw new Error(`failed to parse cast recorder pid: ${details}`) + } + + return pid +} + +const getFileSize = async (filePath: string): Promise => { + try { + const stat = await Deno.stat(filePath) + return stat.size + } catch { + return 0 + } +} + +const waitForFileData = async (filePath: string, timeoutMs: number): Promise => { + const startedAt = Date.now() + while (Date.now() - startedAt <= timeoutMs) { + if (await getFileSize(filePath) > 0) { + return true + } + await delay(200) + } + return false +} + +const getCastStats = async ( + castFile: string | undefined, +): Promise<{ bytes: number; lines: number; has_events: boolean } | undefined> => { + if (castFile === undefined) { + return undefined + } + + try { + const content = await Deno.readTextFile(castFile) + const lines = content.length === 0 ? 0 : content.split("\n").length + const bytes = new TextEncoder().encode(content).length + return { + bytes, + lines, + has_events: lines > 1, + } + } catch { + return undefined + } +} + +type AvailableKeysSnapshot = { + actions?: Array<{ + id: string + name: string + description?: string + key: string + requires_coordinate?: boolean + mouse_capable?: boolean + }> +} + +type AvailableMacrosSnapshot = { + macros?: Array<{ + id: string + name: string + description: string + category: string + hotkey: string + }> +} + +const loadAvailableKeysSnapshot = async ( + state: SessionState, +): Promise => { + if (state.available_keys_json === undefined || state.available_keys_json.length === 0) { + return undefined + } + + try { + const content = await Deno.readTextFile(state.available_keys_json) + return JSON.parse(content) as AvailableKeysSnapshot + } catch { + return undefined + } +} + +const loadAvailableMacrosSnapshot = async ( + state: SessionState, +): Promise => { + if (state.available_macros_json === undefined || state.available_macros_json.length === 0) { + return undefined + } + + try { + const content = await Deno.readTextFile(state.available_macros_json) + return JSON.parse(content) as AvailableMacrosSnapshot + } catch { + return undefined + } +} + +type ResolvedSessionInput = + | { + kind: "key" + key: string + } + | { + kind: "mouse" + x: number + y: number + button: MouseButton + event: MouseEventType + } + +type CoordinateOptions = { + col?: number + row?: number +} + +const COMPACT_KEY_ALIASES: Record = { + enter: "Enter", + esc: "Escape", + escape: "Escape", + tab: "Tab", + btab: "BTab", + up: "Up", + down: "Down", + left: "Left", + right: "Right", + home: "Home", + end: "End", + pgup: "PageUp", + pgdn: "PageDown", + space: "Space", +} + +const UNICODE_ARROW_KEY_ALIASES: Record = { + "↑": "Up", + "↓": "Down", + "←": "Left", + "→": "Right", +} + +const parseCompactInputId = (inputId: string): string => { + const normalized = inputId.trim() + const unicodeArrowAlias = UNICODE_ARROW_KEY_ALIASES[normalized] + if (unicodeArrowAlias !== undefined) { + return `key:${unicodeArrowAlias}` + } + + const usesSlashPrefix = normalized.startsWith("/") && normalized.length > 1 + if (!usesSlashPrefix) { + return normalized + } + + if (normalized === "//") { + return "key:/" + } + + const compactToken = normalized.slice(1) + const keyAlias = COMPACT_KEY_ALIASES[compactToken.toLowerCase()] + if (keyAlias !== undefined) { + return `key:${keyAlias}` + } + + return `engine_action:${compactToken}` +} + +const requireCoordinate = ( + coordinate: CoordinateOptions, +): { + col: number + row: number +} => { + if (coordinate.col === undefined || coordinate.row === undefined) { + throw new Error("this input requires --col and --row") + } + + const col = Math.floor(coordinate.col) + const row = Math.floor(coordinate.row) + if (col < 1 || row < 1) { + throw new Error("--col and --row must be >= 1") + } + + return { col, row } +} + +type ResolveSessionInputOptions = { + state: SessionState + inputId: string + coordinate?: CoordinateOptions +} + +const resolveSessionInput = async ( + options: ResolveSessionInputOptions, +): Promise => { + const normalized = parseCompactInputId(options.inputId) + const coordinate = options.coordinate ?? {} + + if (normalized === "mouse:waypoint") { + const target = requireCoordinate(coordinate) + return { + kind: "mouse", + x: target.col, + y: target.row, + button: "left", + event: "click", + } + } + + if (!normalized.startsWith("engine_action:")) { + return { + kind: "key", + key: resolveInputKey(normalized), + } + } + + const actionId = normalized.slice("engine_action:".length) + if (actionId.length === 0) { + throw new Error("engine_action input id cannot be empty") + } + + const snapshot = await loadAvailableKeysSnapshot(options.state) + const actions = snapshot?.actions ?? [] + const matched = actions.find((action) => action.id === actionId) + + if (matched === undefined) { + throw new Error(`engine action not available in current context: ${actionId}`) + } + + if (matched.requires_coordinate || actionId === "COORDINATE") { + const target = requireCoordinate(coordinate) + return { + kind: "mouse", + x: target.col, + y: target.row, + button: matched.key === "MOUSE_RIGHT" ? "right" : "left", + event: "click", + } + } + + if (matched.key.startsWith("Unbound")) { + throw new Error(`engine action is not bound in current context: ${actionId}`) + } + + if (matched.key === "MOUSE_LEFT" || matched.key === "MOUSE_RIGHT") { + const target = requireCoordinate(coordinate) + return { + kind: "mouse", + x: target.col, + y: target.row, + button: matched.key === "MOUSE_RIGHT" ? "right" : "left", + event: "click", + } + } + + return { + kind: "key", + key: resolveInputKey(`key:${matched.key}`), + } +} + +type RenderTextCaptureWebpOptions = { + magickBin: string + textPath: string + screenshotPath: string + quality: number +} + +const renderTextCaptureWebp = async (options: RenderTextCaptureWebpOptions): Promise => { + const output = await new Deno.Command(options.magickBin, { + args: [ + "-background", + "#101010", + "-fill", + "#f5f5f5", + "-pointsize", + "16", + "-interline-spacing", + "2", + `label:@${options.textPath}`, + "-strip", + "-colorspace", + "sRGB", + "-quality", + `${options.quality}`, + "-define", + "webp:method=6", + "-define", + "webp:sns-strength=0", + "-define", + "webp:filter-strength=0", + "-define", + "webp:preprocessing=0", + "-define", + "webp:segments=2", + "-define", + "webp:exact=true", + options.screenshotPath, + ], + stdout: "piped", + stderr: "piped", + }).output() + + if (!output.success) { + const decoder = new TextDecoder() + const stderr = decoder.decode(output.stderr).trim() + throw new Error( + stderr.length > 0 + ? `failed to render webp screenshot with ${options.magickBin}: ${stderr}` + : `failed to render webp screenshot with ${options.magickBin}`, + ) + } +} + +const loadState = async (stateFile: string): Promise => { + const content = await Deno.readTextFile(stateFile) + return JSON.parse(content) as SessionState +} + +const saveState = async (stateFile: string, state: SessionState): Promise => { + await ensureDir(dirname(stateFile)) + await Deno.writeTextFile(stateFile, JSON.stringify(state, null, 2)) +} + +const requireRunningState = async (stateFile: string): Promise => { + const state = await loadState(stateFile) + if (state.status !== "running") { + throw new Error(`session is not running in ${stateFile}`) + } + return state +} + +const stopRunningStateSession = async ( + stateFilePath: string, + fallbackTmuxBin: string, +): Promise => { + let state: SessionState | undefined + try { + state = await loadState(stateFilePath) + } catch { + return + } + + if (state.status !== "running") { + return + } + + const tmuxBin = state.tmux_bin.length > 0 ? state.tmux_bin : fallbackTmuxBin + try { + configureTmuxBinary(tmuxBin) + configureTmuxSocket(normalizeTmuxSocket(state.tmux_socket)) + await stopCastProcess(state.cast_pid, state.cast_file) + await TmuxSession.killByName(state.session_name, REPO_ROOT) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`warning: failed stopping previous session from ${stateFilePath}: ${message}`) + } + + state.status = "stopped" + state.finished_at = new Date().toISOString() + await saveState(stateFilePath, state) +} + +const ensureSingleSessionPerMachine = async (fallbackTmuxBin: string): Promise => { + const stateDir = resolve(TMP_CLI_ROOT, "state") + await ensureDir(stateDir) + for await (const entry of Deno.readDir(stateDir)) { + if (!entry.isFile || !entry.name.endsWith(".json")) { + continue + } + + await stopRunningStateSession(join(stateDir, entry.name), fallbackTmuxBin) + } +} + +const normalizeTmuxSocket = (socketName: string | undefined): string => + socketName !== undefined && socketName.length > 0 ? socketName : SINGLETON_TMUX_SOCKET + +type CaptureIntoArtifactsOptions = { + state: SessionState + session: TmuxSession + id: string + caption: string + lines: number +} + +const captureIntoArtifacts = async ( + options: CaptureIntoArtifactsOptions, +): Promise => { + const captureNumber = options.state.captures.length + 1 + const fileName = `${String(captureNumber).padStart(2, "0")}-${sanitizeId(options.id)}.txt` + const absPath = join(options.state.captures_dir, fileName) + const content = stripAnsiFromPane(await options.session.capturePane(options.lines)) + await Deno.writeTextFile(absPath, content) + + const codeBlockFileName = fileName.replace(/\.txt$/, ".md") + const absCodeBlockPath = join(options.state.captures_dir, codeBlockFileName) + await writeCodeBlockCapture(absCodeBlockPath, content) + + let screenshotPath: string | undefined + if (options.state.render_webp && await isBinaryAvailable(options.state.magick_bin)) { + const screenshotName = fileName.replace(/\.txt$/, ".webp") + const absScreenshotPath = join(options.state.captures_dir, screenshotName) + await renderTextCaptureWebp({ + magickBin: options.state.magick_bin, + textPath: absPath, + screenshotPath: absScreenshotPath, + quality: options.state.webp_quality, + }) + screenshotPath = displayPath(absScreenshotPath) + } + + return { + id: options.id, + caption: options.caption, + text_file: displayPath(absPath), + code_block_file: displayPath(absCodeBlockPath), + screenshot_file: screenshotPath, + } +} + +const parseInputIdsJson = (input: string): string[] => { + const parsed = JSON.parse(input) + if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) { + throw new Error("--ids-json must be a JSON array of strings") + } + if (parsed.length === 0) { + throw new Error("--ids-json must include at least one input id") + } + return parsed +} + +const resolveStateFilePath = (stateFile: string): string => resolve(REPO_ROOT, stateFile) + +const withRunningSession = async ( + stateFile: string, + handler: (state: SessionState, session: TmuxSession) => Promise, +): Promise => { + const state = await requireRunningState(resolveStateFilePath(stateFile)) + configureTmuxBinary(state.tmux_bin) + configureTmuxSocket(normalizeTmuxSocket(state.tmux_socket)) + const session = await TmuxSession.attach(state.session_name, REPO_ROOT) + await handler(state, session) +} + +class PendingTmuxSessionResource { + #tmuxBin: string + #tmuxSocket: string | undefined + #sessionName: string + #cwd: string + #completed = false + + constructor( + options: { tmuxBin: string; tmuxSocket: string | undefined; sessionName: string; cwd: string }, + ) { + this.#tmuxBin = options.tmuxBin + this.#tmuxSocket = options.tmuxSocket + this.#sessionName = options.sessionName + this.#cwd = options.cwd + } + + get sessionName(): string { + return this.#sessionName + } + + markCompleted(): void { + this.#completed = true + } + + async [Symbol.asyncDispose](): Promise { + if (this.#completed) { + return + } + + configureTmuxBinary(this.#tmuxBin) + configureTmuxSocket(this.#tmuxSocket) + await TmuxSession.killByName(this.#sessionName, this.#cwd) + } +} + +type CreateTmuxSessionOptions = { + tmuxBin: string + tmuxSocket: string | undefined + session: { + name: string + command: string + cwd: string + width: number + height: number + } +} + +const createTmuxSession = async ( + options: CreateTmuxSessionOptions, +): Promise => { + configureTmuxBinary(options.tmuxBin) + configureTmuxSocket(options.tmuxSocket) + await TmuxSession.start(options.session) + + try { + const attached = await TmuxSession.attach(options.session.name, options.session.cwd) + await attached.capturePane(1) + await delay(150) + const hasSession = await TmuxSession.hasSession(options.session.name, options.session.cwd) + if (!hasSession) { + throw new Error( + `tmux session exited immediately: ${options.session.name}. ` + + "verify --bin points to a runnable cataclysm-bn curses binary.", + ) + } + } catch (error) { + try { + await TmuxSession.killByName(options.session.name, options.session.cwd) + } catch { + } + throw error + } + + return new PendingTmuxSessionResource({ + tmuxBin: options.tmuxBin, + tmuxSocket: options.tmuxSocket, + sessionName: options.session.name, + cwd: options.session.cwd, + }) +} + +type SendWaypointClicksOptions = { + session: TmuxSession + col: number + row: number + delayMs: number + clicks: number +} + +const sendWaypointClicks = async ( + options: SendWaypointClicksOptions, +): Promise<{ clicks_sent: number }> => { + const clickCount = Math.max(1, options.clicks) + for (let index = 0; index < clickCount; index += 1) { + await options.session.sendMouse({ + x: options.col, + y: options.row, + button: "left", + event: "click", + }) + if (options.delayMs > 0 && index + 1 < clickCount) { + await delay(options.delayMs) + } + } + + return { + clicks_sent: clickCount, + } +} + +const stopCastProcess = async ( + castPid: number | undefined, + castFile: string | undefined, +): Promise => { + if (castPid === undefined) { + return + } + + if (castFile !== undefined) { + await waitForFileData(castFile, 1_500) + } + + try { + Deno.kill(castPid, "SIGTERM") + } catch (_error) { + void _error + } + await delay(500) + + if (castFile !== undefined) { + await waitForFileData(castFile, 4_000) + } +} + +class StartRollbackResource { + #tmuxBin: string + #tmuxSocket: string | undefined + #sessionName: string | undefined + #castPid: number | undefined + #castFile: string | undefined + #completed = false + + constructor(tmuxBin: string, tmuxSocket: string | undefined) { + this.#tmuxBin = tmuxBin + this.#tmuxSocket = tmuxSocket + } + + setSession(sessionName: string): void { + this.#sessionName = sessionName + } + + setCast(castPid: number, castFile: string): void { + this.#castPid = castPid + this.#castFile = castFile + } + + markCompleted(): void { + this.#completed = true + } + + async [Symbol.asyncDispose](): Promise { + if (this.#completed) { + return + } + + await stopCastProcess(this.#castPid, this.#castFile) + + if (this.#sessionName !== undefined) { + configureTmuxBinary(this.#tmuxBin) + configureTmuxSocket(this.#tmuxSocket) + await TmuxSession.killByName(this.#sessionName, REPO_ROOT) + } + } +} + +class StopCleanupResource { + #tmuxBin: string + #tmuxSocket: string | undefined + #sessionName: string + #castPid: number | undefined + #castFile: string | undefined + + constructor(options: { + tmuxBin: string + tmuxSocket: string | undefined + sessionName: string + castPid: number | undefined + castFile: string | undefined + }) { + this.#tmuxBin = options.tmuxBin + this.#tmuxSocket = options.tmuxSocket + this.#sessionName = options.sessionName + this.#castPid = options.castPid + this.#castFile = options.castFile + } + + async [Symbol.asyncDispose](): Promise { + await stopCastProcess(this.#castPid, this.#castFile) + configureTmuxBinary(this.#tmuxBin) + configureTmuxSocket(this.#tmuxSocket) + await TmuxSession.killByName(this.#sessionName, REPO_ROOT) + } +} + +if (import.meta.main) { + await new Command() + .name("pr-verify-curses-cli") + .description("Playwright-like command-driven CLI for tmux curses verification") + .command( + "actions", + new Command() + .description("Print deprecation notice for removed static catalog") + .action(() => { + console.log(JSON.stringify( + { + deprecated: true, + message: + "Static key catalog was removed. Use 'inputs-jsonl' or 'available-keys-json' for runtime keys.", + }, + null, + 2, + )) + }), + ) + .command( + "inputs-jsonl", + new Command() + .description("Emit available input list as JSONL from current pane") + .option("--state-file ", "State file path", { default: DEFAULT_STATE_FILE }) + .option("--lines ", "Pane lines to inspect", { default: 200 }) + .action(async (options) => { + await withRunningSession(options.stateFile, async (state, session) => { + const pane = stripAnsiFromPane(await session.capturePane(options.lines)) + const inputs = listAvailableInputs(pane) + const engineSnapshot = await loadAvailableKeysSnapshot(state) + const macrosSnapshot = await loadAvailableMacrosSnapshot(state) + + if (engineSnapshot?.actions !== undefined) { + const hasCoordinateAction = engineSnapshot.actions.some((action) => + action.id === "COORDINATE" + ) + if (hasCoordinateAction) { + console.log(JSON.stringify({ + id: "mouse:waypoint", + key: "MOUSE_LEFT@", + source: "mouse_waypoint", + priority: "high", + label: "Point-and-click move", + description: + "Use send-input --id mouse:waypoint --col --row to click a tile waypoint.", + requires_coordinate: true, + mouse_capable: true, + })) + } + + for (const action of engineSnapshot.actions) { + console.log(JSON.stringify({ + id: `engine_action:${action.id}`, + key: action.key, + source: "engine_json", + priority: "high", + label: action.name, + description: action.id, + requires_coordinate: action.requires_coordinate ?? false, + mouse_capable: action.mouse_capable ?? false, + })) + } + } + + if (macrosSnapshot?.macros !== undefined) { + for (const macro of macrosSnapshot.macros) { + console.log(JSON.stringify({ + id: `macro:${macro.id}`, + key: `/m ${macro.id}`, + source: "macro_json", + priority: "high", + label: macro.name, + description: macro.description, + category: macro.category, + hotkey: macro.hotkey, + })) + } + } + + for (const input of inputs) { + console.log(JSON.stringify(input)) + } + }) + }), + ) + .command( + "available-keys-json", + new Command() + .description("Read currently available keybindings JSON from active game context") + .option("--state-file ", "State file path", { default: DEFAULT_STATE_FILE }) + .action(async (options) => { + const state = await requireRunningState(resolveStateFilePath(options.stateFile)) + const snapshot = await loadAvailableKeysSnapshot(state) + if (snapshot === undefined) { + console.log(JSON.stringify({ actions: [] }, null, 2)) + return + } + + console.log(JSON.stringify(snapshot, null, 2)) + }), + ) + .command( + "available-macros-json", + new Command() + .description("Read currently available Lua action menu macros JSON") + .option("--state-file ", "State file path", { default: DEFAULT_STATE_FILE }) + .action(async (options) => { + const state = await requireRunningState(resolveStateFilePath(options.stateFile)) + const snapshot = await loadAvailableMacrosSnapshot(state) + if (snapshot === undefined) { + console.log(JSON.stringify({ macros: [] }, null, 2)) + return + } + + console.log(JSON.stringify(snapshot, null, 2)) + }), + ) + .command( + "start", + new Command() + .description("Start a persistent tmux game session and cast recording") + .option("--state-file ", "State file path", { default: DEFAULT_STATE_FILE }) + .option("--bin ", "Path to curses binary", { + default: "./out/build/linux-curses/src/cataclysm-bn", + }) + .option("--world ", "World to auto-load", { default: "" }) + .option("--seed ", "Deterministic seed string", { default: "" }) + .option("--artifact-dir ", "Artifact directory") + .option("--session ", "tmux session name (default singleton: curses-cli)") + .option("--userdir ", "Userdir path") + .option("--tmux-bin ", "tmux binary", { default: "tmux" }) + .option("--tmux-socket ", "tmux socket name (default singleton: curses-cli)") + .option("--width ", "Terminal width for tmux/asciinema", { default: 120 }) + .option("--height ", "Terminal height for tmux/asciinema", { default: 40 }) + .option("--render-webp ", "Render captures to webp", { default: true }) + .option("--webp-quality ", "Webp quality", { default: 70 }) + .option("--magick-bin ", "ImageMagick binary", { default: "magick" }) + .option( + "--record-cast ", + "Deprecated: ignored, cast recording is always enabled", + { default: true }, + ) + .option("--asciinema-bin ", "asciinema binary", { default: "asciinema" }) + .option( + "--cast-idle-time-limit ", + "Optional asciinema idle-time limit seconds", + ) + .action(async (options) => { + const runId = timestamp() + const tmuxSocket = normalizeTmuxSocket(options.tmuxSocket) + const resolvedBinPath = resolve(REPO_ROOT, options.bin) + await ensureLaunchBinaryExists(resolvedBinPath) + configureTmuxBinary(options.tmuxBin) + configureTmuxSocket(tmuxSocket) + await ensureTmuxAvailable() + await ensureSingleSessionPerMachine(options.tmuxBin) + const castAvailable = await isBinaryAvailable(options.asciinemaBin, ["--version"]) + if (!castAvailable) { + throw new Error( + `asciinema binary not found: ${options.asciinemaBin}. ` + + "Cast recording is always required and written to /tmp.", + ) + } + + await using rollback = new StartRollbackResource(options.tmuxBin, tmuxSocket) + const stateFilePath = resolveStateFilePath(options.stateFile) + const artifactDir = options.artifactDir + ? resolve(REPO_ROOT, options.artifactDir) + : resolve(TMP_CLI_ROOT, "runs", `live-${runId}`) + const capturesDir = join(artifactDir, "captures") + await ensureDir(capturesDir) + const width = Math.max(40, Math.floor(options.width)) + const height = Math.max(12, Math.floor(options.height)) + + const sessionName = options.session ?? SINGLETON_SESSION_NAME + const defaultUserdirRoot = resolve(TMP_CLI_ROOT, "userdirs") + await ensureDir(defaultUserdirRoot) + const userdir = options.userdir ?? + await Deno.makeTempDir({ dir: defaultUserdirRoot, prefix: "userdir-" }) + const availableKeysJsonPath = join(userdir, "available_keys.json") + const availableMacrosJsonPath = join(userdir, "available_macros.json") + const aiStateJsonPath = join(userdir, "ai_state.json") + const launchCommand = buildLaunchCommand({ + binPath: resolvedBinPath, + userdir, + world: options.world, + seed: options.seed, + availableKeysJson: availableKeysJsonPath, + availableMacrosJson: availableMacrosJsonPath, + aiHelperOutput: aiStateJsonPath, + }) + + await using tmuxSession = await createTmuxSession({ + tmuxBin: options.tmuxBin, + tmuxSocket, + session: { + name: sessionName, + command: launchCommand, + cwd: REPO_ROOT, + width, + height, + }, + }) + + const castDir = resolve(TMP_CLI_ROOT, "casts") + await ensureDir(castDir) + const castFile = join(castDir, `curses-session_${sessionTimestamp()}.cast`) + const castLogFile = join(castDir, `curses-session_${sessionTimestamp()}-asciinema.log`) + const castArgs = [ + "record", + "--overwrite", + "--headless", + "--window-size", + `${width}x${height}`, + ] + if (options.castIdleTimeLimit !== undefined) { + castArgs.push("--idle-time-limit", `${options.castIdleTimeLimit}`) + } + castArgs.push( + "--command", + `env -u TMUX TERM=xterm-256color COLORTERM=truecolor tmux -L ${tmuxSocket} -2 attach -t ${sessionName}`, + castFile, + ) + + const castPid = await startDetachedCastProcess({ + asciinemaBin: options.asciinemaBin, + castArgs, + castLogFile, + }) + rollback.setCast(castPid, castFile) + await delay(250) + + const state: SessionState = { + id: runId, + artifact_dir: artifactDir, + captures_dir: capturesDir, + session_name: sessionName, + userdir, + bin_path: resolvedBinPath, + world: options.world, + seed: options.seed, + available_keys_json: availableKeysJsonPath, + available_macros_json: availableMacrosJsonPath, + tmux_bin: options.tmuxBin, + tmux_socket: tmuxSocket, + width, + height, + magick_bin: options.magickBin, + render_webp: options.renderWebp, + webp_quality: options.webpQuality, + record_cast: true, + asciinema_bin: options.asciinemaBin, + cast_idle_time_limit: options.castIdleTimeLimit, + cast_file: castFile, + cast_pid: castPid, + cast_log_file: castLogFile, + status: "running", + started_at: new Date().toISOString(), + captures: [], + } + + await saveState(stateFilePath, state) + tmuxSession.markCompleted() + rollback.markCompleted() + console.log(JSON.stringify(state, null, 2)) + }), + ) + .command( + "get-game-state", + new Command() + .description("Read current game state JSON from active session userdir") + .option("--state-file ", "State file path", { default: DEFAULT_STATE_FILE }) + .option("--include-cast-stats ", "Include cast stats", { default: true }) + .action(async (options) => { + const state = await requireRunningState(resolveStateFilePath(options.stateFile)) + const gameStatePath = join(state.userdir, "ai_state.json") + let gameState: unknown = null + try { + const content = await Deno.readTextFile(gameStatePath) + gameState = JSON.parse(content) + } catch (_error) { + void _error + } + + const castStats = options.includeCastStats + ? await getCastStats(state.cast_file) + : undefined + console.log(JSON.stringify( + { + state_id: state.id, + status: state.status, + game_state_path: gameStatePath, + game_state: gameState, + cast_file: state.cast_file, + cast_log_file: state.cast_log_file, + cast_stats: castStats, + available_keys_json: state.available_keys_json, + available_macros_json: state.available_macros_json, + started_at: state.started_at, + }, + null, + 2, + )) + }), + ) + .command( + "snapshot", + new Command() + .description("Print current pane snapshot text") + .option("--state-file ", "State file path", { default: DEFAULT_STATE_FILE }) + .option("--lines ", "Lines to capture", { default: 120 }) + .action(async (options) => { + await withRunningSession(options.stateFile, async (_state, session) => { + const pane = stripAnsiFromPane(await session.capturePane(options.lines)) + console.log(pane) + }) + }), + ) + .command( + "capture", + new Command() + .description("Persist capture artifacts (txt, markdown, optional webp)") + .option("--state-file ", "State file path", { default: DEFAULT_STATE_FILE }) + .option("--id ", "Capture id", { required: true }) + .option("--caption ", "Capture caption", { default: "" }) + .option("--lines ", "Lines to capture", { default: 350 }) + .action(async (options) => { + const stateFile = resolveStateFilePath(options.stateFile) + await withRunningSession(options.stateFile, async (state, session) => { + const entry = await captureIntoArtifacts({ + state, + session, + id: options.id, + caption: options.caption, + lines: options.lines, + }) + state.captures.push(entry) + await saveState(stateFile, state) + console.log(JSON.stringify(entry, null, 2)) + }) + }), + ) + .command( + "send-input", + new Command() + .description( + "Send one input id (raw key, engine_action:, mouse:waypoint, /alias, or arrows: ↑↓←→)", + ) + .option("--state-file ", "State file path", { default: DEFAULT_STATE_FILE }) + .option("--id ", "Input id or raw key", { required: true }) + .option("--col ", "1-based column for coordinate/mouse inputs") + .option("--row ", "1-based row for coordinate/mouse inputs") + .option("--delay-ms ", "Delay between repeated waypoint clicks", { + default: 40, + }) + .option("--waypoint-clicks ", "Repeated LMB clicks for mouse waypoint", { + default: 2, + }) + .action(async (options) => { + await withRunningSession(options.stateFile, async (state, session) => { + const resolvedInput = await resolveSessionInput({ + state, + inputId: options.id, + coordinate: { + col: options.col, + row: options.row, + }, + }) + const compactId = parseCompactInputId(options.id) + const waypointMode = compactId === "mouse:waypoint" || + compactId === "engine_action:COORDINATE" + const movementDelayMs = Math.max(0, options.delayMs) + const waypointClicks = Math.max(1, options.waypointClicks) + + if (resolvedInput.kind === "key") { + await session.sendKeys([resolvedInput.key]) + console.log(JSON.stringify({ id: options.id, key: resolvedInput.key }, null, 2)) + return + } + + if (waypointMode) { + const waypointResult = await sendWaypointClicks({ + session, + col: resolvedInput.x, + row: resolvedInput.y, + delayMs: movementDelayMs, + clicks: waypointClicks, + }) + console.log(JSON.stringify( + { + id: options.id, + waypoint: { + x: resolvedInput.x, + y: resolvedInput.y, + }, + clicks_sent: waypointResult.clicks_sent, + }, + null, + 2, + )) + return + } + + await session.sendMouse({ + x: resolvedInput.x, + y: resolvedInput.y, + button: resolvedInput.button, + event: resolvedInput.event, + }) + console.log(JSON.stringify( + { + id: options.id, + mouse: { + x: resolvedInput.x, + y: resolvedInput.y, + button: resolvedInput.button, + event: resolvedInput.event, + }, + }, + null, + 2, + )) + }) + }), + ) + .command( + "send-inputs", + new Command() + .description( + "Send multiple input ids sequentially (raw keys, engine_action, mouse, /alias, or arrows: ↑↓←→)", + ) + .option("--state-file ", "State file path", { default: DEFAULT_STATE_FILE }) + .option("--ids-json ", "JSON array of input ids", { required: true }) + .option("--col ", "1-based column for coordinate/mouse ids") + .option("--row ", "1-based row for coordinate/mouse ids") + .option("--repeat ", "Repeat whole sequence count", { default: 1 }) + .option("--delay-ms ", "Delay between key sends", { default: 40 }) + .option("--waypoint-clicks ", "Repeated LMB clicks for mouse waypoint", { + default: 2, + }) + .action(async (options) => { + await withRunningSession(options.stateFile, async (state, session) => { + const ids = parseInputIdsJson(options.idsJson) + const repeat = Math.max(1, options.repeat) + const delayMs = Math.max(0, options.delayMs) + const waypointClicks = Math.max(1, options.waypointClicks) + const dispatched: Array = [] + + for (let index = 0; index < repeat; index += 1) { + for (const id of ids) { + const resolvedInput = await resolveSessionInput({ + state, + inputId: id, + coordinate: { + col: options.col, + row: options.row, + }, + }) + const compactId = parseCompactInputId(id) + const waypointMode = compactId === "mouse:waypoint" || + compactId === "engine_action:COORDINATE" + + if (resolvedInput.kind === "key") { + await session.sendKeys([resolvedInput.key]) + dispatched.push({ id, kind: "key", key: resolvedInput.key }) + } else if (waypointMode) { + const waypointResult = await sendWaypointClicks({ + session, + col: resolvedInput.x, + row: resolvedInput.y, + delayMs, + clicks: waypointClicks, + }) + dispatched.push({ + id, + kind: "waypoint", + x: resolvedInput.x, + y: resolvedInput.y, + clicks_sent: waypointResult.clicks_sent, + }) + } else { + await session.sendMouse({ + x: resolvedInput.x, + y: resolvedInput.y, + button: resolvedInput.button, + event: resolvedInput.event, + }) + dispatched.push({ + id, + kind: "mouse", + x: resolvedInput.x, + y: resolvedInput.y, + button: resolvedInput.button, + event: resolvedInput.event, + }) + } + + if (delayMs > 0) { + await delay(delayMs) + } + } + } + + console.log(JSON.stringify( + { + ids, + dispatched, + repeat, + delay_ms: delayMs, + }, + null, + 2, + )) + }) + }), + ) + .command( + "send-mouse", + new Command() + .description("Send one mouse event (xterm SGR) into curses pane") + .option("--state-file ", "State file path", { default: DEFAULT_STATE_FILE }) + .option("--col ", "1-based column coordinate", { required: true }) + .option("--row ", "1-based row coordinate", { required: true }) + .option("--button ", "left|middle|right|wheel_up|wheel_down|none", { + default: "left", + }) + .option("--event ", "click|press|release|move", { default: "click" }) + .option("--repeat ", "Repeat count", { default: 1 }) + .option("--delay-ms ", "Delay between repeats", { default: 40 }) + .action(async (options) => { + await withRunningSession(options.stateFile, async (_state, session) => { + const allowedButtons: MouseButton[] = [ + "left", + "middle", + "right", + "wheel_up", + "wheel_down", + "none", + ] + const allowedEvents: MouseEventType[] = ["click", "press", "release", "move"] + if (!allowedButtons.includes(options.button as MouseButton)) { + throw new Error(`invalid --button: ${options.button}`) + } + if (!allowedEvents.includes(options.event as MouseEventType)) { + throw new Error(`invalid --event: ${options.event}`) + } + const button = options.button as MouseButton + const event = options.event as MouseEventType + + const repeat = Math.max(1, options.repeat) + const delayMs = Math.max(0, options.delayMs) + for (let index = 0; index < repeat; index += 1) { + await session.sendMouse({ + x: options.col, + y: options.row, + button, + event, + }) + if (delayMs > 0) { + await delay(delayMs) + } + } + + console.log(JSON.stringify( + { + x: options.col, + y: options.row, + button, + event, + repeat, + delay_ms: delayMs, + }, + null, + 2, + )) + }) + }), + ) + .command( + "send-waypoint", + new Command() + .description("Click the same tile repeatedly to trigger waypoint movement") + .option("--state-file ", "State file path", { default: DEFAULT_STATE_FILE }) + .option("--col ", "1-based column coordinate", { required: true }) + .option("--row ", "1-based row coordinate", { required: true }) + .option("--delay-ms ", "Delay between repeated clicks", { + default: 40, + }) + .option("--waypoint-clicks ", "Repeated LMB clicks", { + default: 2, + }) + .action(async (options) => { + await withRunningSession(options.stateFile, async (_state, session) => { + const delayMs = Math.max(0, options.delayMs) + const waypointClicks = Math.max(1, options.waypointClicks) + const waypointResult = await sendWaypointClicks({ + session, + col: options.col, + row: options.row, + delayMs, + clicks: waypointClicks, + }) + + console.log(JSON.stringify( + { + waypoint: { + x: options.col, + y: options.row, + }, + clicks_sent: waypointResult.clicks_sent, + delay_ms: delayMs, + waypoint_clicks: waypointClicks, + }, + null, + 2, + )) + }) + }), + ) + .command( + "wait-text", + new Command() + .description("Wait until pane contains text") + .option("--state-file ", "State file path", { default: DEFAULT_STATE_FILE }) + .option("--text ", "Text to wait for", { required: true }) + .option("--timeout-ms ", "Timeout ms", { default: 120000 }) + .option("--poll-interval-ms ", "Poll interval ms", { default: 120 }) + .action(async (options) => { + await withRunningSession(options.stateFile, async (_state, session) => { + await session.waitForText(options.text, { + timeoutMs: options.timeoutMs, + pollIntervalMs: options.pollIntervalMs, + }) + console.log(JSON.stringify({ ok: true, text: options.text }, null, 2)) + }) + }), + ) + .command( + "wait-text-gone", + new Command() + .description("Wait until pane no longer contains text") + .option("--state-file ", "State file path", { default: DEFAULT_STATE_FILE }) + .option("--text ", "Text to wait to disappear", { required: true }) + .option("--timeout-ms ", "Timeout ms", { default: 120000 }) + .option("--poll-interval-ms ", "Poll interval ms", { default: 120 }) + .action(async (options) => { + await withRunningSession(options.stateFile, async (_state, session) => { + await session.waitForTextGone(options.text, { + timeoutMs: options.timeoutMs, + pollIntervalMs: options.pollIntervalMs, + }) + console.log(JSON.stringify({ ok: true, text_gone: options.text }, null, 2)) + }) + }), + ) + .command( + "sleep", + new Command() + .description("Wait in-session workflow") + .option("--ms ", "Milliseconds", { required: true }) + .action(async (options) => { + await delay(options.ms) + console.log(JSON.stringify({ ok: true, slept_ms: options.ms }, null, 2)) + }), + ) + .command( + "stop", + new Command() + .description("Stop session and finalize manifest/index artifacts") + .option("--state-file ", "State file path", { default: DEFAULT_STATE_FILE }) + .option("--status ", "Final status: passed or failed", { default: "passed" }) + .option("--failure ", "Failure message", { default: "" }) + .action(async (options) => { + const stateFile = resolveStateFilePath(options.stateFile) + const state = await loadState(stateFile) + { + configureTmuxBinary(state.tmux_bin) + configureTmuxSocket(normalizeTmuxSocket(state.tmux_socket)) + await using _cleanup = new StopCleanupResource({ + tmuxBin: state.tmux_bin, + tmuxSocket: normalizeTmuxSocket(state.tmux_socket), + sessionName: state.session_name, + castPid: state.cast_pid, + castFile: state.cast_file, + }) + + const hasSession = await TmuxSession.hasSession(state.session_name, REPO_ROOT) + if (hasSession) { + const session = await TmuxSession.attach(state.session_name, REPO_ROOT) + const finalPane = stripAnsiFromPane(await session.capturePane(500)) + await Deno.writeTextFile(join(state.artifact_dir, "final-pane.txt"), finalPane) + } + } + + state.status = "stopped" + state.finished_at = new Date().toISOString() + + const castStats = await getCastStats(state.cast_file) + + const finalStatus = options.status === "failed" ? "failed" : "passed" + const failure = options.failure.length > 0 ? options.failure : undefined + + const metadata = { + generated_at: new Date().toISOString(), + mode: "mcp_cli", + session_id: state.id, + session_name: state.session_name, + started_at: state.started_at, + finished_at: state.finished_at, + binary: state.bin_path, + world: state.world, + available_keys_json: state.available_keys_json, + available_macros_json: state.available_macros_json, + status: finalStatus, + failure, + cast_file: state.cast_file, + cast_log_file: state.cast_log_file, + cast_stats: castStats, + captures: state.captures, + } + + await Deno.writeTextFile( + join(state.artifact_dir, "manifest.json"), + JSON.stringify(metadata, null, 2), + ) + + await writeIndex({ + outputPath: join(state.artifact_dir, "index.md"), + sessionName: "live-curses-mcp-session", + captures: state.captures, + status: finalStatus, + castFile: state.cast_file, + failureMessage: failure, + }) + + await saveState(stateFile, state) + console.log(JSON.stringify( + { + artifact_dir: displayPath(state.artifact_dir), + cast_file: state.cast_file, + cast_log_file: state.cast_log_file, + cast_stats: castStats, + captures: state.captures.length, + available_keys_json: state.available_keys_json, + available_macros_json: state.available_macros_json, + status: finalStatus, + }, + null, + 2, + )) + }), + ) + .parse(Deno.args) +} diff --git a/scripts/curses-cli/main_test.ts b/scripts/curses-cli/main_test.ts new file mode 100644 index 000000000000..548d6e77a670 --- /dev/null +++ b/scripts/curses-cli/main_test.ts @@ -0,0 +1,160 @@ +import { assertEquals, assertRejects, assertStringIncludes } from "@std/assert" +import { join } from "@std/path" +import { + buildLaunchCommand, + detectPromptInputs, + listAvailableInputs, + resolveInputKey, + sanitizeId, + writeCodeBlockCapture, + writeIndex, +} from "./common.ts" + +Deno.test("pr_verify: sanitizeId normalizes unsafe characters", () => { + assertEquals(sanitizeId("open help/screen"), "open-help-screen") + assertEquals(sanitizeId("already-safe_id"), "already-safe_id") + assertEquals(sanitizeId("a---b"), "a-b") +}) + +Deno.test("pr_verify: buildLaunchCommand includes base args, optional world, and seed", () => { + const withoutWorld = buildLaunchCommand({ + binPath: "/tmp/cata", + userdir: "/tmp/user", + world: "", + }) + assertStringIncludes(withoutWorld, "TERM=xterm-256color") + assertStringIncludes(withoutWorld, "COLORTERM=truecolor") + assertStringIncludes(withoutWorld, "--basepath") + assertStringIncludes(withoutWorld, "--userdir") + + const withWorld = buildLaunchCommand({ + binPath: "/tmp/cata", + userdir: "/tmp/user", + world: "fixture_world", + }) + assertStringIncludes(withWorld, "--world") + assertStringIncludes(withWorld, "fixture_world") + + const withSeed = buildLaunchCommand({ + binPath: "/tmp/cata", + userdir: "/tmp/user", + world: "", + seed: "seed-01", + }) + assertStringIncludes(withSeed, "--seed") + assertStringIncludes(withSeed, "seed-01") + + const withAvailableKeysJson = buildLaunchCommand({ + binPath: "/tmp/cata", + userdir: "/tmp/user", + world: "", + availableKeysJson: "/tmp/available_keys.json", + }) + assertStringIncludes(withAvailableKeysJson, "CATA_AVAILABLE_KEYS_JSON") + assertStringIncludes(withAvailableKeysJson, "/tmp/available_keys.json") + + const withAvailableMacrosJson = buildLaunchCommand({ + binPath: "/tmp/cata", + userdir: "/tmp/user", + world: "", + availableMacrosJson: "/tmp/available_macros.json", + }) + assertStringIncludes(withAvailableMacrosJson, "CATA_AVAILABLE_MACROS_JSON") + assertStringIncludes(withAvailableMacrosJson, "/tmp/available_macros.json") +}) + +Deno.test("pr_verify: resolveInputKey handles action ids and raw key ids", () => { + assertEquals(resolveInputKey("key:!"), "!") + assertEquals(resolveInputKey("Escape"), "Escape") +}) + +Deno.test("pr_verify: detectPromptInputs catches safe mode prompt inputs", () => { + const pane = + "Spotted fat zombie--safe mode is on! (Press ! to turn it off, press ' to ignore monster)" + const inputs = detectPromptInputs(pane) + assertEquals(inputs.some((input) => input.id === "key:!"), true) + assertEquals(inputs.some((input) => input.id === "key:'"), true) +}) + +Deno.test("pr_verify: detectPromptInputs extracts parenthesized choice prompts", () => { + const pane = "Really step into window frame? (Y)es (N)o" + const inputs = detectPromptInputs(pane) + assertEquals(inputs.some((input) => input.id === "key:Y"), true) + assertEquals(inputs.some((input) => input.id === "key:N"), true) +}) + +Deno.test("pr_verify: listAvailableInputs merges prompt and catalog options", () => { + const pane = "Press any key to continue" + const inputs = listAvailableInputs(pane, true) + assertEquals(inputs.some((input) => input.id === "key:Space"), true) + assertEquals(inputs.some((input) => input.id === "key:k"), false) +}) + +Deno.test({ + name: "pr_verify: writeIndex writes captures and failure block", + async fn() { + const readPermission = await Deno.permissions.query({ name: "read" }) + const writePermission = await Deno.permissions.query({ name: "write" }) + if (readPermission.state !== "granted" || writePermission.state !== "granted") { + return + } + + const tempDir = await Deno.makeTempDir({ prefix: "pr_verify_index_test_" }) + const indexPath = join(tempDir, "index.md") + + try { + await writeIndex({ + outputPath: indexPath, + sessionName: "live-curses-mcp-session", + captures: [ + { + id: "loaded", + caption: "Gameplay screen after load", + text_file: "artifacts/pr-verify/123/captures/01-loaded.txt", + code_block_file: "artifacts/pr-verify/123/captures/01-loaded.md", + screenshot_file: "artifacts/pr-verify/123/captures/01-loaded.webp", + }, + ], + status: "failed", + castFile: "artifacts/pr-verify/123/session.cast", + failureMessage: "timeout waiting for text", + }) + + const rendered = await Deno.readTextFile(indexPath) + assertStringIncludes(rendered, "# PR Verify Artifact") + assertStringIncludes(rendered, "Session: live-curses-mcp-session") + assertStringIncludes(rendered, "Status: failed") + assertStringIncludes(rendered, "Gameplay screen after load") + assertStringIncludes(rendered, "timeout waiting for text") + assertStringIncludes(rendered, "code block") + assertStringIncludes(rendered, "session.cast") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } + }, +}) + +Deno.test({ + name: "pr_verify: writeCodeBlockCapture creates fenced text block", + async fn() { + const readPermission = await Deno.permissions.query({ name: "read" }) + const writePermission = await Deno.permissions.query({ name: "write" }) + if (readPermission.state !== "granted" || writePermission.state !== "granted") { + return + } + + const tempDir = await Deno.makeTempDir({ prefix: "pr_verify_code_block_test_" }) + const outputPath = join(tempDir, "capture.md") + + try { + await writeCodeBlockCapture(outputPath, "line 1\nline 2") + const rendered = await Deno.readTextFile(outputPath) + assertStringIncludes(rendered, "```text") + assertStringIncludes(rendered, "line 1") + assertStringIncludes(rendered, "line 2") + assertStringIncludes(rendered, "```") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } + }, +}) diff --git a/scripts/curses-cli/tmux.ts b/scripts/curses-cli/tmux.ts new file mode 100644 index 000000000000..54d9ac945bde --- /dev/null +++ b/scripts/curses-cli/tmux.ts @@ -0,0 +1,359 @@ +const TEXT_DECODER = new TextDecoder() +let tmuxBinary = "tmux" +let tmuxSocketName: string | undefined + +type RunTmuxOptions = { + cwd?: string + allowFailure?: boolean +} + +export type WaitOptions = { + timeoutMs?: number + pollIntervalMs?: number + lines?: number +} + +export type MouseButton = "left" | "middle" | "right" | "wheel_up" | "wheel_down" | "none" +export type MouseEventType = "press" | "release" | "click" | "move" + +export type MouseEventOptions = { + x: number + y: number + button?: MouseButton + event?: MouseEventType +} + +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +const withSocketArgs = (args: string[]): string[] => + tmuxSocketName === undefined || tmuxSocketName.length === 0 + ? args + : ["-L", tmuxSocketName, ...args] + +const formatCommand = (args: string[]): string => [tmuxBinary, ...withSocketArgs(args)].join(" ") + +const TMUX_KEY_ALIASES: Record = { + ESC: "Escape", + RETURN: "Enter", + TAB: "Tab", + BACKTAB: "BTab", + NPAGE: "NPage", + PPAGE: "PPage", + PAGEUP: "PageUp", + PAGEDOWN: "PageDown", + SPACE: "Space", +} + +const normalizeTmuxKey = (key: string): string => { + const trimmed = key.trim() + if (trimmed.length === 0) { + return trimmed + } + + const upper = trimmed.toUpperCase() + const alias = TMUX_KEY_ALIASES[upper] + if (alias !== undefined) { + return alias + } + + const ctrlMatch = /^CTRL\+([A-Za-z])$/.exec(upper) + if (ctrlMatch !== null) { + return `C-${ctrlMatch[1]}` + } + + return trimmed +} + +const isTmuxNamedKey = (key: string): boolean => { + return /^(Enter|Escape|Tab|BTab|Up|Down|Left|Right|Home|End|PageUp|PageDown|BSpace|Space|Delete|Insert|NPage|PPage|F\d+|C-[A-Za-z]|M-[A-Za-z]|S-[A-Za-z])$/ + .test(key) +} + +const controlKeyHex = (key: string): string | undefined => { + const match = /^C-([A-Za-z])$/.exec(key) + if (match === null) { + return undefined + } + + const asciiCode = match[1].toUpperCase().charCodeAt(0) + const controlCode = asciiCode - 64 + if (controlCode < 0 || controlCode > 31) { + return undefined + } + return controlCode.toString(16).padStart(2, "0") +} + +const normalizeMouseCoordinate = (value: number): number => { + const normalized = Math.floor(value) + return normalized < 1 ? 1 : normalized +} + +const buttonCodeForPress = (button: MouseButton): number => { + switch (button) { + case "left": + return 0 + case "middle": + return 1 + case "right": + return 2 + case "wheel_up": + return 64 + case "wheel_down": + return 65 + case "none": + return 3 + } +} + +const buttonCodeForMove = (button: MouseButton): number => { + if (button === "none") { + return 35 + } + return buttonCodeForPress(button) + 32 +} + +type SgrMouseFrameOptions = { + code: number + x: number + y: number + pressed: boolean +} + +const sgrMouseFrame = (options: SgrMouseFrameOptions): string => { + return `\x1b[<${options.code};${options.x};${options.y}${options.pressed ? "M" : "m"}` +} + +const mouseFrames = (options: MouseEventOptions): string[] => { + const button = options.button ?? "left" + const event = options.event ?? "click" + const x = normalizeMouseCoordinate(options.x) + const y = normalizeMouseCoordinate(options.y) + + if (event === "move") { + return [sgrMouseFrame({ code: buttonCodeForMove(button), x, y, pressed: true })] + } + + if (event === "release") { + return [sgrMouseFrame({ code: buttonCodeForPress(button), x, y, pressed: false })] + } + + const pressFrame = sgrMouseFrame({ code: buttonCodeForPress(button), x, y, pressed: true }) + if (event === "press" || button === "wheel_up" || button === "wheel_down") { + return [pressFrame] + } + + return [pressFrame, sgrMouseFrame({ code: buttonCodeForPress(button), x, y, pressed: false })] +} + +export const configureTmuxBinary = (path: string): void => { + tmuxBinary = path +} + +export const configureTmuxSocket = (socketName: string | undefined): void => { + tmuxSocketName = socketName !== undefined && socketName.length > 0 ? socketName : undefined +} + +export const ensureTmuxAvailable = async (): Promise => { + try { + const output = await new Deno.Command(tmuxBinary, { + args: ["-V"], + stdout: "null", + stderr: "null", + }).output() + if (!output.success) { + throw new Error(`failed to execute ${tmuxBinary} -V`) + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + throw new Error( + `tmux binary not found: ${tmuxBinary}. Install tmux or pass --tmux-bin .`, + ) + } + throw error + } +} + +const runTmux = async (args: string[], options: RunTmuxOptions = {}): Promise => { + const resolvedArgs = withSocketArgs(args) + const output = await new Deno.Command(tmuxBinary, { + args: resolvedArgs, + cwd: options.cwd, + stdout: "piped", + stderr: "piped", + }).output() + + if (!output.success && !options.allowFailure) { + const stderr = TEXT_DECODER.decode(output.stderr).trim() + const stdout = TEXT_DECODER.decode(output.stdout).trim() + throw new Error( + [ + `tmux command failed (${output.code}): ${formatCommand(args)}`, + stderr.length > 0 ? `stderr:\n${stderr}` : "", + stdout.length > 0 ? `stdout:\n${stdout}` : "", + ] + .filter((line) => line.length > 0) + .join("\n"), + ) + } + + return TEXT_DECODER.decode(output.stdout) +} + +export type StartSessionOptions = { + name: string + command: string + width?: number + height?: number + cwd?: string +} + +export class TmuxSession { + readonly name: string + readonly target: string + readonly cwd?: string + + private constructor(options: { name: string; target: string; cwd?: string }) { + this.name = options.name + this.target = options.target + this.cwd = options.cwd + } + + static async hasSession(name: string, cwd?: string): Promise { + const args = withSocketArgs(["has-session", "-t", name]) + const output = await new Deno.Command(tmuxBinary, { + args, + cwd, + stdout: "null", + stderr: "null", + }).output() + return output.success + } + + static async start(options: StartSessionOptions): Promise { + const width = options.width ?? 120 + const height = options.height ?? 40 + + if (await TmuxSession.hasSession(options.name, options.cwd)) { + throw new Error(`tmux session already exists: ${options.name}`) + } + + await runTmux( + [ + "new-session", + "-d", + "-s", + options.name, + "-e", + "TERM=xterm-256color", + "-e", + "COLORTERM=truecolor", + "-x", + `${width}`, + "-y", + `${height}`, + options.command, + ], + { cwd: options.cwd }, + ) + + return new TmuxSession({ name: options.name, target: `${options.name}:0.0`, cwd: options.cwd }) + } + + static async attach(name: string, cwd?: string): Promise { + if (!await TmuxSession.hasSession(name, cwd)) { + throw new Error(`tmux session not found: ${name}`) + } + + return new TmuxSession({ name, target: `${name}:0.0`, cwd }) + } + + static async killByName(name: string, cwd?: string): Promise { + await runTmux(["kill-session", "-t", name], { cwd, allowFailure: true }) + } + + async sendKeys(keys: string[]): Promise { + if (keys.length === 0) { + return + } + + for (const key of keys) { + const normalizedKey = normalizeTmuxKey(key) + const controlHex = controlKeyHex(normalizedKey) + const args = controlHex !== undefined + ? ["send-keys", "-t", this.target, "-H", controlHex] + : isTmuxNamedKey(normalizedKey) + ? ["send-keys", "-t", this.target, normalizedKey] + : ["send-keys", "-t", this.target, "-l", normalizedKey] + await runTmux(args, { cwd: this.cwd }) + } + } + + async sendMouse(options: MouseEventOptions): Promise { + const frames = mouseFrames(options) + for (const frame of frames) { + await runTmux(["send-keys", "-t", this.target, "-l", frame], { cwd: this.cwd }) + } + } + + async capturePane(lines = 250): Promise { + return await runTmux( + ["capture-pane", "-p", "-t", this.target, "-S", `-${lines}`], + { cwd: this.cwd }, + ) + } + + async waitForText(text: string, options: WaitOptions = {}): Promise { + const timeoutMs = options.timeoutMs ?? 60_000 + const pollIntervalMs = options.pollIntervalMs ?? 100 + const lines = options.lines ?? 300 + const startAt = Date.now() + + while (Date.now() - startAt <= timeoutMs) { + const pane = await this.capturePane(lines) + if (pane.includes(text)) { + return + } + await delay(pollIntervalMs) + } + + throw new Error(`timeout waiting for text: ${text}`) + } + + async waitForTextGone(text: string, options: WaitOptions = {}): Promise { + const timeoutMs = options.timeoutMs ?? 60_000 + const pollIntervalMs = options.pollIntervalMs ?? 100 + const lines = options.lines ?? 300 + const startAt = Date.now() + + while (Date.now() - startAt <= timeoutMs) { + const pane = await this.capturePane(lines) + if (!pane.includes(text)) { + return + } + await delay(pollIntervalMs) + } + + throw new Error(`timeout waiting for text to disappear: ${text}`) + } + + async dismissPrompts(prompts: string[], limit = 6): Promise { + let dismissed = 0 + + for (let index = 0; index < limit; index += 1) { + const pane = await this.capturePane() + const hasPrompt = prompts.some((prompt) => pane.includes(prompt)) + if (!hasPrompt) { + break + } + await this.sendKeys(["Space"]) + dismissed += 1 + await delay(120) + } + + return dismissed + } + + async kill(): Promise { + await TmuxSession.killByName(this.name, this.cwd) + } +} diff --git a/skills/curses-cli/SKILL.md b/skills/curses-cli/SKILL.md new file mode 100644 index 000000000000..97b858a51b77 --- /dev/null +++ b/skills/curses-cli/SKILL.md @@ -0,0 +1,38 @@ +--- +name: curses-cli +description: Automates Cataclysm-BN curses gameplay via CLI for regression checks and reproducible interaction scripts. +allowed-tools: Bash(deno task pr:verify:curses-cli:*) +--- + +# Curses CLI + +Use the CLI wrapper to drive game sessions directly without MCP tool orchestration. + +## Quick start + +```bash +deno task pr:verify:curses-cli start --state-file /tmp/curses-demo.json --render-webp false +deno task pr:verify:curses-cli inputs-jsonl --state-file /tmp/curses-demo.json +deno task pr:verify:curses-cli send-input --state-file /tmp/curses-demo.json --id action:confirm +deno task pr:verify:curses-cli snapshot --state-file /tmp/curses-demo.json --lines 120 +deno task pr:verify:curses-cli stop --state-file /tmp/curses-demo.json +``` + +## Core commands + +- `start` starts a tmux session and launches the game binary. +- `inputs-jsonl` lists prompt-aware inputs plus action catalog aliases. +- `available-keys-json` reads game-native keybinding context. +- `available-macros-json` reads game-native Lua action-menu macro context. +- `send-input` and `send-inputs` send one or many actions/keys. +- `snapshot` and `capture` collect text/screenshot artifacts. +- `wait-text` and `wait-text-gone` synchronize flows on UI text. +- `get-game-state` reads structured state JSON from runtime userdir. +- `state-dump` (planned) should emit one compact AI-friendly JSON payload. +- `stop` finalizes artifacts and stops the tmux session. + +## References + +- `references/session-management.md` +- `references/macros.md` +- `references/benchmark-loop.md` diff --git a/skills/curses-cli/references/benchmark-loop.md b/skills/curses-cli/references/benchmark-loop.md new file mode 100644 index 000000000000..58daad393348 --- /dev/null +++ b/skills/curses-cli/references/benchmark-loop.md @@ -0,0 +1,49 @@ +# Benchmark Loop + +This runbook defines a durable `curses-cli` benchmark loop that survives context compaction and keeps results comparable across sessions. + +## Benchmark target + +`town_fight_v1` success criteria: + +1. Start as evacuee in reproducible profile. +2. Reach nearest town using map-assisted travel flow. +3. Engage at least one naturally encountered hostile in melee/ranged combat. +4. Finish with cast + compact artifacts; no debug spawning/actions. + +## Required invariants + +- Fixed benchmark profile (`scenario`, `profession`, seed policy, safe mode policy). +- Deterministic script path and timeout budget. +- No debug menu usage (`F12`, debug spawn, wish menu). +- Standard artifact roots under `/tmp/curses-cli`. + +## Operator command skeleton + +```bash +deno task pr:verify:curses-cli start --state-file /tmp/curses-bench.json --render-webp false +deno task pr:verify:curses-cli inputs-jsonl --state-file /tmp/curses-bench.json +# ... scripted control loop ... +deno task pr:verify:curses-cli capture --state-file /tmp/curses-bench.json --id bench-final --caption "Final benchmark state" --lines 120 +deno task pr:verify:curses-cli stop --state-file /tmp/curses-bench.json --status passed +``` + +## Failure taxonomy + +- `menu_drift`: expected gameplay mode but nested UI/menu mode detected. +- `mode_trap`: targeting/look/map/debug mode not exited within guard timeout. +- `safe_mode_interrupt`: hostile detected and run stalled due to safe mode. +- `repro_drift`: unexpected profile/trait/seed-dependent divergence. + +## Compact state-dump direction (planned) + +Add a single compact dump command that emits: + +- current ASCII pane excerpt +- prompt-derived available inputs +- engine action keys JSON snapshot +- macro JSON snapshot +- recent game logs / ai_state +- run metadata and stop reason class + +Use this as primary AI context; keep full captures only for divergence points. diff --git a/skills/curses-cli/references/macros.md b/skills/curses-cli/references/macros.md new file mode 100644 index 000000000000..e6f83624cabe --- /dev/null +++ b/skills/curses-cli/references/macros.md @@ -0,0 +1,65 @@ +# Macro Workflows + +This reference covers how to discover and use runtime Lua action-menu macros from `curses-cli`, and how to add new macros in game code. + +## Use macros from CLI + +```bash +deno task pr:verify:curses-cli start --state-file /tmp/curses-macro.json --render-webp false +deno task pr:verify:curses-cli available-macros-json --state-file /tmp/curses-macro.json +deno task pr:verify:curses-cli inputs-jsonl --state-file /tmp/curses-macro.json +deno task pr:verify:curses-cli send-input --state-file /tmp/curses-macro.json --id macro:bn_macro_agent_context +deno task pr:verify:curses-cli stop --state-file /tmp/curses-macro.json +``` + +Notes: + +- `available-macros-json` returns runtime macro entries exported by the game. +- `inputs-jsonl` includes macros as `macro:` entries when macro export is present. +- Macro IDs are stable and should be used for automation scripts. + +## Add a new macro in Lua stdlib + +1. Add function logic in `data/lua/lib/action_menu_macros.lua`. +2. Register via `gapi.register_action_menu_entry({ ... })` with: + - `id`: stable snake_case ID (for CLI `macro:`) + - `name`: user-facing label + - `description`: concise automation-friendly description + - `category`: grouping label (for action menu UX) + - `fn`: callable Lua function +3. Keep behavior deterministic and side effects explicit. + +Minimal pattern: + +```lua +gapi.register_action_menu_entry({ + id = "bn_macro_example", + name = locale.gettext("Example Macro"), + description = locale.gettext("Prints a compact state message for automation."), + category = "info", + fn = function() + gapi.add_msg("[AI] example macro ran") + end, +}) +``` + +## C++ export path used by CLI + +- Action keys JSON: written by `input_context::handle_input()` in `src/input.cpp` to `CATA_AVAILABLE_KEYS_JSON`. +- Macro JSON: written by `lua_action_menu::dump_entries_to_json()` in `src/lua_action_menu.cpp` to `CATA_AVAILABLE_MACROS_JSON`. +- CLI launch sets both env vars in `scripts/curses-cli/common.ts` (`buildLaunchCommand`). + +## Validation checklist + +```bash +deno task pr:verify:curses-cli start --state-file /tmp/curses-macro-validate.json --render-webp false +deno task pr:verify:curses-cli available-macros-json --state-file /tmp/curses-macro-validate.json +deno task pr:verify:curses-cli inputs-jsonl --state-file /tmp/curses-macro-validate.json +deno task pr:verify:curses-cli stop --state-file /tmp/curses-macro-validate.json +``` + +Expected: + +- New macro appears in `available-macros-json`. +- `inputs-jsonl` contains `macro:`. +- `send-input --id macro:` executes without mode drift. diff --git a/skills/curses-cli/references/session-management.md b/skills/curses-cli/references/session-management.md new file mode 100644 index 000000000000..eef15d9d7501 --- /dev/null +++ b/skills/curses-cli/references/session-management.md @@ -0,0 +1,15 @@ +# Session Management + +Use a unique state file per run to avoid collisions. + +```bash +deno task pr:verify:curses-cli start --state-file /tmp/curses-session.json --render-webp false +deno task pr:verify:curses-cli snapshot --state-file /tmp/curses-session.json --lines 120 +deno task pr:verify:curses-cli stop --state-file /tmp/curses-session.json --status passed +``` + +Notes: + +- `start` writes metadata that all other commands use via `--state-file`. +- If a run fails, call `stop --status failed --failure "reason"` to preserve diagnostics. +- Clean up stale tmux sessions with `tmux ls` and `tmux kill-session -t `. diff --git a/src/catalua_bindings_game.cpp b/src/catalua_bindings_game.cpp index 5c76da269087..bd0852a19c94 100644 --- a/src/catalua_bindings_game.cpp +++ b/src/catalua_bindings_game.cpp @@ -126,6 +126,7 @@ void cata::detail::reg_game_api( sol::state &lua ) luna::set_fx( lib, "register_action_menu_entry", []( sol::table opts ) -> void { auto id = opts.get_or( "id", std::string{} ); auto name = opts.get_or( "name", std::string{} ); + auto description = opts.get_or( "description", std::string{} ); auto category_id = opts.get_or( "category", std::string{ "misc" } ); auto hotkey = opts.get>( "hotkey" ); auto hotkey_value = std::optional{}; @@ -137,11 +138,20 @@ void cata::detail::reg_game_api( sol::state &lua ) cata::lua_action_menu::register_entry( { .id = std::move( id ), .name = std::move( name ), + .description = std::move( description ), .category_id = std::move( category_id ), .hotkey = std::move( hotkey_value ), .fn = std::move( fn ), } ); } ); + DOC( "Returns action menu entries as JSON string for CLI integration." ); + luna::set_fx( lib, "list_action_menu_entries_json", []() -> std::string { + return cata::lua_action_menu::list_entries_json(); + } ); + DOC( "Run action menu entry by id. Returns true on success." ); + luna::set_fx( lib, "run_action_menu_entry", []( const std::string & id ) -> bool { + return cata::lua_action_menu::run_entry( id ); + } ); DOC( "Spawns a new item. Same as Item::spawn " ); luna::set_fx( lib, "create_item", []( const itype_id & itype, int count ) -> detached_ptr { diff --git a/src/input.cpp b/src/input.cpp index 548b559a4b91..33ac88490b27 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -40,6 +41,84 @@ using std::max; static const std::string default_context_id( "default" ); +namespace +{ + +auto best_keyboard_key_for_events( const std::vector &events ) -> std::optional +{ + auto best_key = -1; + for( const auto &events_event : events ) { + if( events_event.type != input_event_t::keyboard || events_event.sequence.size() != 1 ) { + continue; + } + + const auto event_key = events_event.sequence.front(); + const auto is_ascii_char = isprint( event_key ) && event_key < 0xFF; + const auto is_best_ascii_char = best_key >= 0 && isprint( best_key ) && best_key < 0xFF; + if( best_key < 0 || ( is_ascii_char && !is_best_ascii_char ) ) { + best_key = event_key; + } + } + + if( best_key < 0 ) { + return std::nullopt; + } + + return best_key; +} + +auto first_key_for_event_type( const std::vector &events, + const input_event_t event_type ) -> std::optional +{ + const auto event_it = std::ranges::find_if( events, [&]( const input_event & event ) { + return event.type == event_type && event.sequence.size() == 1; + } ); + if( event_it == events.end() ) { + return std::nullopt; + } + + return event_it->sequence.front(); +} + +auto get_available_keys_output_path() -> const char * +{ + static const auto *output_path = std::getenv( "CATA_AVAILABLE_KEYS_JSON" ); + if( output_path == nullptr || output_path[0] == '\0' ) { + return nullptr; + } + return output_path; +} + +auto dump_available_action_keys_to_json( const input_context &ctxt ) -> void +{ + const auto *output_path = get_available_keys_output_path(); + if( output_path == nullptr ) { + return; + } + + const auto actions = ctxt.get_available_action_keys(); + write_to_file( output_path, [&actions]( std::ostream & output_stream ) { + JsonOut jsout( output_stream, true ); + jsout.start_object(); + jsout.member( "actions" ); + jsout.start_array(); + for( const auto &action : actions ) { + jsout.start_object(); + jsout.member( "id", action.id ); + jsout.member( "name", action.name ); + jsout.member( "description", action.description ); + jsout.member( "key", action.key ); + jsout.member( "requires_coordinate", action.requires_coordinate ); + jsout.member( "mouse_capable", action.mouse_capable ); + jsout.end_object(); + } + jsout.end_array(); + jsout.end_object(); + }, "" ); +} + +} // namespace + template struct ContainsPredicate { const T1 &container; @@ -893,6 +972,68 @@ std::string input_context::describe_key_and_name( const std::string &action_desc return get_desc( action_descriptor, get_action_name( action_descriptor ), evt_filter ); } +auto input_context::get_available_action_keys() const -> +std::vector +{ + auto result = std::vector(); + for( const auto &action_id : registered_actions ) { + if( action_id == "ANY_INPUT" ) { + continue; + } + + const auto &events = inp_mngr.get_input_for_action( action_id, category ); + const auto mouse_capable = std::ranges::any_of( events, []( const input_event & event ) { + return event.type == input_event_t::mouse; + } ); + const auto requires_coordinate = action_id == "COORDINATE"; + + auto key_name = std::string {}; + const auto best_key = best_keyboard_key_for_events( events ); + if( best_key.has_value() ) { + key_name = inp_mngr.get_keyname( *best_key, input_event_t::keyboard, true ); + } else { + const auto first_mouse_key = first_key_for_event_type( events, input_event_t::mouse ); + if( first_mouse_key.has_value() ) { + key_name = inp_mngr.get_keyname( *first_mouse_key, input_event_t::mouse, true ); + } + } + + if( key_name.empty() ) { + continue; + } + + const auto action_name = get_action_name( action_id ); + result.push_back( input_context::available_action_key{ + .id = action_id, + .name = action_name, + .description = get_desc( action_id, action_name ), + .key = key_name, + .requires_coordinate = requires_coordinate, + .mouse_capable = mouse_capable, + } ); + } + +#if defined(__ANDROID__) + for( const auto &manual_key : registered_manual_keys ) { + const auto key_name = inp_mngr.get_keyname( manual_key.key, input_event_t::keyboard, true ); + if( key_name.empty() ) { + continue; + } + + result.push_back( input_context::available_action_key{ + .id = string_format( "manual:%d", manual_key.key ), + .name = manual_key.text.empty() ? key_name : manual_key.text, + .description = manual_key.text.empty() ? key_name : manual_key.text, + .key = key_name, + .requires_coordinate = false, + .mouse_capable = false, + } ); + } +#endif + + return result; +} + const std::string &input_context::handle_input() { return handle_input( timeout ); @@ -900,6 +1041,8 @@ const std::string &input_context::handle_input() const std::string &input_context::handle_input( const int timeout ) { + dump_available_action_keys_to_json( *this ); + const auto old_timeout = inp_mngr.get_timeout(); if( timeout >= 0 ) { inp_mngr.set_timeout( timeout ); diff --git a/src/input.h b/src/input.h index 6a4347e5650d..36ab53b797c5 100644 --- a/src/input.h +++ b/src/input.h @@ -391,6 +391,15 @@ extern input_manager inp_mngr; class input_context { public: + struct available_action_key { + std::string id; + std::string name; + std::string description; + std::string key; + bool requires_coordinate = false; + bool mouse_capable = false; + }; + #if defined(__ANDROID__) // Whatever's on top is our current input context. static std::list input_context_stack; @@ -662,6 +671,8 @@ class input_context */ std::string get_action_name( const std::string &action_id ) const; + auto get_available_action_keys() const -> std::vector; + /* For the future, something like this might be nice: * const std::string register_action(const std::string& action_descriptor, x, y, width, height); * (x, y, width, height) would describe an area on the visible window that, if clicked, triggers the action. @@ -768,5 +779,3 @@ bool gamepad_available(); // rotate a delta direction clockwise void rotate_direction_cw( int &dx, int &dy ); - - diff --git a/src/lua_action_menu.cpp b/src/lua_action_menu.cpp index ef2d079fe950..f19f53a39cb0 100644 --- a/src/lua_action_menu.cpp +++ b/src/lua_action_menu.cpp @@ -1,12 +1,16 @@ #include "lua_action_menu.h" #include +#include #include +#include #include #include "catalua_impl.h" #include "debug.h" +#include "fstream_utils.h" #include "input.h" +#include "json.h" namespace cata::lua_action_menu { @@ -24,6 +28,19 @@ auto normalize_category( const std::string &category_id ) -> std::string category_id; } +auto normalize_description( const entry_options &opts ) -> std::string +{ + if( !opts.description.empty() ) { + return opts.description; + } + + if( !opts.name.empty() ) { + return opts.name; + } + + return opts.id; +} + auto parse_hotkey( const std::optional &hotkey ) -> int { if( !hotkey || hotkey->empty() ) { @@ -38,6 +55,60 @@ auto parse_hotkey( const std::optional &hotkey ) -> int return keycode; } + +auto hotkey_to_string( const int hotkey ) -> std::string +{ + if( hotkey < 0 ) { + return ""; + } + + return inp_mngr.get_keyname( hotkey, input_event_t::keyboard, true ); +} + +auto get_macros_json_output_path() -> const char * +{ + const auto *output_path = std::getenv( "CATA_AVAILABLE_MACROS_JSON" ); + if( output_path == nullptr || output_path[0] == '\0' ) { + return nullptr; + } + + return output_path; +} + +auto write_entries_json( std::ostream &output_stream ) -> void +{ + const auto &entries = entries_storage(); + auto jsout = JsonOut( output_stream, true ); + jsout.start_object(); + jsout.member( "macros" ); + jsout.start_array(); + for( const auto &entries_entry : entries ) { + jsout.start_object(); + jsout.member( "id", entries_entry.id ); + jsout.member( "name", entries_entry.name ); + jsout.member( "description", entries_entry.description ); + jsout.member( "category", entries_entry.category_id ); + jsout.member( "hotkey", hotkey_to_string( entries_entry.hotkey ) ); + jsout.end_object(); + } + jsout.end_array(); + jsout.end_object(); +} + +auto dump_entries_to_json() -> void +{ + const auto *output_path = get_macros_json_output_path(); + if( output_path == nullptr ) { + return; + } + + const auto write_succeeded = write_to_file( output_path, []( std::ostream & output_stream ) { + write_entries_json( output_stream ); + }, "" ); + if( !write_succeeded ) { + debugmsg( "Failed to write available Lua action menu macros JSON to '%s'.", output_path ); + } +} } // namespace auto register_entry( const entry_options &opts ) -> void @@ -55,6 +126,7 @@ auto register_entry( const entry_options &opts ) -> void auto new_entry = entry{ .id = opts.id, .name = std::move( entry_name ), + .description = normalize_description( opts ), .category_id = normalize_category( opts.category_id ), .hotkey = parse_hotkey( opts.hotkey ), .fn = opts.fn, @@ -64,14 +136,17 @@ auto register_entry( const entry_options &opts ) -> void auto existing = std::ranges::find( entries, opts.id, &entry::id ); if( existing != entries.end() ) { *existing = std::move( new_entry ); + dump_entries_to_json(); return; } entries.push_back( std::move( new_entry ) ); + dump_entries_to_json(); } auto clear_entries() -> void { entries_storage().clear(); + dump_entries_to_json(); } auto get_entries() -> const std::vector & // *NOPAD* @@ -98,4 +173,11 @@ auto run_entry( const std::string &id ) -> bool return true; } + +auto list_entries_json() -> std::string +{ + auto output_stream = std::ostringstream(); + write_entries_json( output_stream ); + return output_stream.str(); +} } // namespace cata::lua_action_menu diff --git a/src/lua_action_menu.h b/src/lua_action_menu.h index a143ff259cf1..17b56943494b 100644 --- a/src/lua_action_menu.h +++ b/src/lua_action_menu.h @@ -11,6 +11,7 @@ namespace cata::lua_action_menu struct entry { std::string id; std::string name; + std::string description; std::string category_id; int hotkey = -1; sol::protected_function fn; @@ -19,6 +20,7 @@ struct entry { struct entry_options { std::string id; std::string name; + std::string description; std::string category_id = "misc"; std::optional hotkey; sol::protected_function fn; @@ -28,4 +30,5 @@ auto register_entry( const entry_options &opts ) -> void; auto clear_entries() -> void; auto get_entries() -> const std::vector &; // *NOPAD* auto run_entry( const std::string &id ) -> bool; +auto list_entries_json() -> std::string; } // namespace cata::lua_action_menu