moshcode 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/games.mjs ADDED
@@ -0,0 +1,337 @@
1
+ // The moshcode arcade — `/games` in the pit, `moshcode games` from a shell.
2
+ //
3
+ // Six games, one frame. Every game here is the same shape (see GAME_SHAPE
4
+ // below) and is drawn by the same `frame()`, so they look like one arcade
5
+ // rather than six weekend projects: a title, a status line, a boxed board, and
6
+ // one line of keys along the bottom. There is no menu, no options screen and no
7
+ // difficulty prompt — `/games tetris` is already playing by the time the frame
8
+ // lands, and `q` is always the way out.
9
+ //
10
+ // The split is deliberate: the games themselves (games-*.mjs) are pure — create
11
+ // a state, hand it a key, hand it a tick, ask it for rows — and everything that
12
+ // touches a terminal lives in `runGame` down the bottom. That is what makes an
13
+ // arcade testable: test/games.test.mjs plays entire games without a TTY.
14
+ import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
15
+ import { TETRIS } from "./games-tetris.mjs";
16
+ import { SNAKE } from "./games-snake.mjs";
17
+ import { PACMAN } from "./games-pacman.mjs";
18
+ import { TICTACTOE } from "./games-tictactoe.mjs";
19
+ import { HANGMAN } from "./games-hangman.mjs";
20
+ import { CHESS } from "./games-chess.mjs";
21
+
22
+ /**
23
+ * @typedef {object} Game — the whole contract, so a seventh game is an import.
24
+ * @property {string} key the name typed after /games
25
+ * @property {string[]} aliases other spellings (tiktaktoe is a real thing people type)
26
+ * @property {string} title shown in the frame's header
27
+ * @property {string} blurb one line, for /games list
28
+ * @property {string} keys the footer; the only place controls are ever explained
29
+ * @property {number|Function} [tickMs] real-time games only — a number, or (state) => number
30
+ * @property {Function} create ({ rng }) => state
31
+ * @property {Function} onKey (state, key, { rng }) => state
32
+ * @property {Function} [tick] (state, { rng }) => state
33
+ * @property {Function} render (state) => string[] the board, already coloured
34
+ * @property {Function} status (state) => string right of the title
35
+ */
36
+
37
+ /** The cabinet. Order is the order `/games` lists them. */
38
+ export const GAMES = [TETRIS, SNAKE, PACMAN, TICTACTOE, CHESS, HANGMAN];
39
+
40
+ /** Games by name, following aliases. Case- and slash-insensitive. */
41
+ export function resolveGame(name) {
42
+ const wanted = String(name ?? "").toLowerCase().replace(/^\//, "").replace(/[-_\s]/g, "");
43
+ if (!wanted) return null;
44
+ return GAMES.find((g) => g.key === wanted || (g.aliases || []).includes(wanted)) ?? null;
45
+ }
46
+
47
+ /* ------------------------------------------------------------------- frame */
48
+
49
+ // Colour codes are invisible but not zero-width to `.length`, so every pad in
50
+ // here measures the stripped string. Getting this wrong is how a board's right
51
+ // edge ends up ragged the moment someone wins.
52
+ const ANSI = /\x1b\[[0-9;]*m/g;
53
+ export const strip = (s) => String(s).replace(ANSI, "");
54
+ export const visible = (s) => strip(s).length;
55
+ const pad = (s, width) => s + " ".repeat(Math.max(0, width - visible(s)));
56
+
57
+ /**
58
+ * The one frame every game is drawn in.
59
+ *
60
+ * ```
61
+ * TETRIS score 1200 · lines 12
62
+ * ┌────────────────────┐
63
+ * │ ██████ │
64
+ * └────────────────────┘
65
+ * ← → move · ↑ rotate · space slam · q quit
66
+ * ```
67
+ *
68
+ * Returns a string with no trailing newline; `runGame` owns the cursor.
69
+ */
70
+ export function frame({ title = "", status = "", rows = [], keys = "" } = {}) {
71
+ const body = rows.map((r) => String(r));
72
+ // The board sets the width. The header and the key line sit outside the box,
73
+ // so letting either of them stretch it is how a 20-column tetris well ends up
74
+ // in a 54-column frame.
75
+ const inner = Math.max(...body.map(visible), 20);
76
+ const gap = inner - visible(title) - visible(status);
77
+ const head = !visible(status) ? acid(title)
78
+ // Right-align the status to the box edge when there is room for it, and
79
+ // fall back to a caption rather than pushing the board around when a long
80
+ // status (chess, mid-game) would not fit.
81
+ : gap >= 2 ? pad(acid(title), inner - visible(status)) + ash(status)
82
+ : `${acid(title)} ${ash(status)}`;
83
+ const out = [
84
+ ` ${head}`,
85
+ ` ${ash(`┌${"─".repeat(inner + 2)}┐`)}`,
86
+ ...body.map((row) => ` ${ash("│")} ${pad(row, inner)} ${ash("│")}`),
87
+ ` ${ash(`└${"─".repeat(inner + 2)}┘`)}`,
88
+ ` ${ash(keys)}`,
89
+ ];
90
+ return out.join("\n");
91
+ }
92
+
93
+ /* --------------------------------------------------------------------- keys */
94
+
95
+ /**
96
+ * Raw terminal bytes → key names the games understand.
97
+ *
98
+ * Games never see an escape sequence; they see "up", "enter", "a". A chunk can
99
+ * hold several keypresses (hold an arrow key down and they arrive in batches),
100
+ * which is why this returns a list.
101
+ */
102
+ export function decodeKeys(chunk) {
103
+ const input = String(chunk);
104
+ const keys = [];
105
+ for (let i = 0; i < input.length; i++) {
106
+ const c = input[i];
107
+ if (c === "\x1b") {
108
+ const seq = input.slice(i, i + 3);
109
+ const arrow = { "\x1b[A": "up", "\x1b[B": "down", "\x1b[C": "right", "\x1b[D": "left" }[seq];
110
+ if (arrow) { keys.push(arrow); i += 2; continue; }
111
+ // A bare escape is a quit everywhere in the arcade; a longer sequence we
112
+ // don't know (mouse, function key) is swallowed rather than misread.
113
+ if (input[i + 1] === "[" || input[i + 1] === "O") { i += 2; continue; }
114
+ keys.push("escape");
115
+ continue;
116
+ }
117
+ if (c === "\r" || c === "\n") { keys.push("enter"); continue; }
118
+ if (c === " ") { keys.push("space"); continue; }
119
+ if (c === "\x03" || c === "\x04") { keys.push("quit"); continue; }
120
+ if (c === "\x7f" || c === "\b") { keys.push("backspace"); continue; }
121
+ if (c === "\t") { keys.push("tab"); continue; }
122
+ // vim keys, everywhere, for free — every game reads arrows, so mapping
123
+ // hjkl here means no game has to know about them.
124
+ const vim = { h: "left", j: "down", k: "up", l: "right" }[c];
125
+ if (vim) { keys.push(vim); continue; }
126
+ if (c >= " " && c <= "~") keys.push(c.toLowerCase());
127
+ }
128
+ return keys;
129
+ }
130
+
131
+ /* -------------------------------------------------------------------- list */
132
+
133
+ /**
134
+ * `/games` with no argument: the cabinet, and how to start one.
135
+ *
136
+ * `prefix` is how the caller is spelled — the pit says `/games tetris` and a
137
+ * shell says `moshcode games tetris`, and printing the wrong one is how a list
138
+ * teaches somebody a command that does not work where they are standing.
139
+ */
140
+ export function renderList({ prefix = "moshcode games" } = {}) {
141
+ const width = Math.max(...GAMES.map((g) => g.key.length));
142
+ return [
143
+ ` ${acid("moshcode arcade")} ${ash(`— ${GAMES.length} games, no menus, no options screens`)}`,
144
+ "",
145
+ ...GAMES.map((g) => ` ${bone(g.key.padEnd(width))} ${ash(g.blurb)}`),
146
+ "",
147
+ ` ${ash("play one:")} ${acid(`${prefix} ${GAMES[0].key}`)}`,
148
+ ` ${ash("every game: arrows move · q quits · r starts another")}`,
149
+ ].join("\n");
150
+ }
151
+
152
+ /** The same cabinet, for something that cannot read a terminal. */
153
+ export function gamesModel() {
154
+ return {
155
+ games: GAMES.map((g) => ({
156
+ name: g.key,
157
+ aliases: g.aliases || [],
158
+ description: g.blurb,
159
+ keys: g.keys,
160
+ realtime: Boolean(g.tickMs),
161
+ })),
162
+ };
163
+ }
164
+
165
+ /* ------------------------------------------------------------------ driver */
166
+
167
+ const ESC = {
168
+ hideCursor: "\x1b[?25l",
169
+ showCursor: "\x1b[?25h",
170
+ up: (n) => (n > 0 ? `\x1b[${n}A` : ""),
171
+ eraseDown: "\x1b[0J",
172
+ };
173
+
174
+ /**
175
+ * Play one game until `q`.
176
+ *
177
+ * Drawn in place rather than on the alternate screen, so the final board — the
178
+ * score, the checkmate, the word you didn't get — stays in the pit's scrollback
179
+ * where you can look at it. Redrawing is "jump back up over the frame and
180
+ * write it again", which is why every frame is the same height.
181
+ */
182
+ export async function runGame(game, deps = {}) {
183
+ const {
184
+ input = process.stdin,
185
+ output = process.stdout,
186
+ rng = Math.random,
187
+ // Tests hand in their own clock so a "real-time" game can be played turn by
188
+ // turn, deterministically, with no timers left running after the assertion.
189
+ setTimer = (fn, ms) => setTimeout(fn, ms),
190
+ clearTimer = (t) => clearTimeout(t),
191
+ } = deps;
192
+
193
+ const ctx = { rng };
194
+ let state = game.create(ctx);
195
+ let height = 0;
196
+ let timer = null;
197
+ let closed = false;
198
+
199
+ let painted = null;
200
+ const draw = () => {
201
+ if (closed) return;
202
+ const text = frame({
203
+ title: game.title,
204
+ status: game.status(state),
205
+ rows: game.render(state),
206
+ keys: state.over ? `${game.keys} · ${bone("r")} again` : game.keys,
207
+ });
208
+ // A frame identical to the one already on the screen is not written at all.
209
+ // Chess idles on its clock while it is your move, and repainting the same
210
+ // board twice a second is exactly the flicker that would make it feel busy.
211
+ if (text === painted) return;
212
+ output.write(`${ESC.up(height)}${ESC.eraseDown}${text}\n`);
213
+ painted = text;
214
+ height = text.split("\n").length;
215
+ };
216
+
217
+ const stop = () => { if (timer !== null) { clearTimer(timer); timer = null; } };
218
+ const schedule = () => {
219
+ stop();
220
+ if (!game.tickMs || state.over) return;
221
+ const ms = typeof game.tickMs === "function" ? game.tickMs(state) : game.tickMs;
222
+ timer = setTimer(() => {
223
+ timer = null;
224
+ if (closed || state.over) return;
225
+ state = game.tick(state, ctx) || state;
226
+ draw();
227
+ schedule();
228
+ }, ms);
229
+ };
230
+
231
+ const wasRaw = Boolean(input.isRaw);
232
+ const restore = () => {
233
+ if (closed) return;
234
+ closed = true;
235
+ stop();
236
+ output.write(ESC.showCursor);
237
+ try { input.setRawMode?.(wasRaw); } catch { /* already gone */ }
238
+ input.off?.("data", onData);
239
+ input.pause?.();
240
+ };
241
+ const onSignal = () => { restore(); process.exit(130); };
242
+
243
+ function onData(chunk) {
244
+ for (const key of decodeKeys(chunk)) {
245
+ if (key === "quit" || key === "q" || key === "escape") { restore(); resolve(); return; }
246
+ if (key === "r" && (state.over || game.restartable !== false)) {
247
+ state = game.create(ctx);
248
+ draw();
249
+ schedule();
250
+ continue;
251
+ }
252
+ if (state.over) continue; // a finished board takes r and q, nothing else
253
+ state = game.onKey(state, key, ctx) || state;
254
+ draw();
255
+ // A key can end a real-time game (a hard drop into the ceiling) or start
256
+ // one moving again, so the clock is re-armed off every keypress.
257
+ if (game.tickMs) schedule();
258
+ }
259
+ }
260
+
261
+ let resolve;
262
+ const done = new Promise((res) => { resolve = res; });
263
+
264
+ output.write(ESC.hideCursor);
265
+ try { input.setRawMode?.(true); } catch { /* not a tty */ }
266
+ input.setEncoding?.("utf8");
267
+ input.resume?.();
268
+ input.on?.("data", onData);
269
+ process.on("SIGINT", onSignal);
270
+ process.on("SIGTERM", onSignal);
271
+
272
+ draw();
273
+ schedule();
274
+ await done;
275
+ restore();
276
+ process.off("SIGINT", onSignal);
277
+ process.off("SIGTERM", onSignal);
278
+ output.write(` ${ash("thanks for playing 🤘")}\n`);
279
+ return 0;
280
+ }
281
+
282
+ /* ----------------------------------------------------------------- command */
283
+
284
+ /**
285
+ * `/games [name]` in the pit, `moshcode games [name]` from a shell.
286
+ *
287
+ * The two are the same call; only the exit code is read by the CLI. Listing
288
+ * works anywhere, including a pipe — starting a game does not, because raw mode
289
+ * is how every one of them reads a key.
290
+ */
291
+ export async function gamesCommand(argv = [], deps = {}) {
292
+ const {
293
+ out = (s) => console.log(s),
294
+ fail = (s) => console.error(s),
295
+ input = process.stdin,
296
+ output = process.stdout,
297
+ interactive = Boolean(input.isTTY && output.isTTY),
298
+ prefix,
299
+ ...rest
300
+ } = deps;
301
+
302
+ const args = argv.filter((a) => a !== undefined && a !== null).map(String);
303
+ const json = args.includes("--json");
304
+ const positional = args.filter((a) => !a.startsWith("-"));
305
+ const [name] = positional;
306
+
307
+ if (json && (!name || name === "list")) { out(JSON.stringify(gamesModel(), null, 2)); return 0; }
308
+ if (!name || name === "list" || name === "ls" || name === "games") { out(renderList({ prefix })); return 0; }
309
+
310
+ const game = resolveGame(name);
311
+ if (!game) {
312
+ fail(`${danger("✗ ")}no game called "${name}". ${ash(`try: ${GAMES.map((g) => g.key).join(" · ")}`)}`);
313
+ return 1;
314
+ }
315
+ if (!interactive) {
316
+ fail(`${danger("✗ ")}${game.key} needs an interactive terminal — it reads single keypresses.`);
317
+ fail(`${ash("· ")}${ash("run it from the pit, or a real shell — `moshcode games list` works anywhere.")}`);
318
+ return 1;
319
+ }
320
+
321
+ return runGame(game, { input, output, ...rest });
322
+ }
323
+
324
+ /* Shared by more than one game, and kept here so they agree on what a wall or a
325
+ * hazard looks like. A game that invents its own palette stops looking like the
326
+ * arcade it is in. */
327
+ export const PALETTE = {
328
+ wall: (s) => ash(s),
329
+ empty: (s) => dim(s),
330
+ you: (s) => acid(s),
331
+ prize: (s) => amber(s),
332
+ hazard: (s) => danger(s),
333
+ piece: (s) => bone(s),
334
+ cool: rgb(90, 200, 250),
335
+ violet: rgb(190, 130, 255),
336
+ rose: rgb(255, 120, 180),
337
+ };
package/src/tui.mjs CHANGED
@@ -23,6 +23,7 @@ import { moshVocabulary } from "./commands.mjs";
23
23
  import { mcpCommand, pluginCommand, skillCommand } from "./integrations.mjs";
24
24
  import { stocksCommand } from "./advisor.mjs";
25
25
  import { cryptoCommand } from "./crypto.mjs";
26
+ import { gamesCommand } from "./games.mjs";
26
27
  import { canOpenBrowser, openBrowser } from "./open-url.mjs";
27
28
  import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion } from "./ui.mjs";
28
29
  import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
@@ -882,6 +883,18 @@ export async function tui() {
882
883
  await pluginCommand(rest);
883
884
  continue;
884
885
  }
886
+ // The arcade (src/games.mjs). Takes the terminal the way an engine session
887
+ // does, because every game reads single keypresses and readline cannot hand
888
+ // those over while it owns stdin. Listing is just printing, so it keeps the
889
+ // prompt.
890
+ if (cmd === "games" || cmd === "game" || cmd === "arcade" || cmd === "play") {
891
+ const listing = !rest.length || rest[0] === "list" || rest[0] === "ls" || rest[0] === "--json";
892
+ if (listing) { await gamesCommand(rest, { prefix: "/games" }); continue; }
893
+ rl.close();
894
+ await gamesCommand(rest, { prefix: "/games" });
895
+ rl = mkrl();
896
+ continue;
897
+ }
885
898
  if (cmd === "socials" || cmd === "social") {
886
899
  printSocials();
887
900
  continue;
package/src/ui.mjs CHANGED
@@ -12,7 +12,9 @@ export function moshcodeVersion() {
12
12
  }
13
13
 
14
14
  const useColor = process.env.NO_COLOR == null && process.stdout.isTTY === true;
15
- const rgb = (r, g, b) => (s) => (useColor ? `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m` : String(s));
15
+ // Exported so a module with its own hues (the arcade's seven tetrominoes) mixes
16
+ // them the same way, and honours NO_COLOR without knowing it exists.
17
+ export const rgb = (r, g, b) => (s) => (useColor ? `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m` : String(s));
16
18
  const wrap = (o, c) => (s) => (useColor ? `\x1b[${o}m${s}\x1b[${c}m` : String(s));
17
19
 
18
20
  export const acid = rgb(158, 240, 26);