moshcode 0.40.0 → 0.42.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.
@@ -0,0 +1,124 @@
1
+ // Tic-tac-toe against an opponent that cannot be beaten, only held.
2
+ //
3
+ // The AI is a full minimax — the search space is 9! at its very worst, which is
4
+ // nothing — so a draw is a win. It breaks ties at random, which is the only
5
+ // reason two games in a row are not the same game.
6
+ import { acid, ash, bone, danger, dim } from "./ui.mjs";
7
+
8
+ export const LINES = [
9
+ [0, 1, 2], [3, 4, 5], [6, 7, 8],
10
+ [0, 3, 6], [1, 4, 7], [2, 5, 8],
11
+ [0, 4, 8], [2, 4, 6],
12
+ ];
13
+
14
+ export const emptyBoard = () => Array.from({ length: 9 }, () => null);
15
+
16
+ /** "X", "O", "draw", or null while there is still a game on. */
17
+ export function winner(board) {
18
+ for (const [a, b, c] of LINES) {
19
+ if (board[a] && board[a] === board[b] && board[b] === board[c]) return board[a];
20
+ }
21
+ return board.every(Boolean) ? "draw" : null;
22
+ }
23
+
24
+ const other = (player) => (player === "X" ? "O" : "X");
25
+
26
+ /**
27
+ * Minimax with no pruning and no depth limit — 3×3 does not need either.
28
+ * Depth is in the score so it prefers winning sooner and losing later, which is
29
+ * what stops it from wandering into a fork it could have blocked.
30
+ */
31
+ export function score(board, player, me, depth = 0) {
32
+ const done = winner(board);
33
+ if (done === me) return 10 - depth;
34
+ if (done === other(me)) return depth - 10;
35
+ if (done === "draw") return 0;
36
+
37
+ const scores = [];
38
+ for (let i = 0; i < 9; i++) {
39
+ if (board[i]) continue;
40
+ board[i] = player;
41
+ scores.push(score(board, other(player), me, depth + 1));
42
+ board[i] = null;
43
+ }
44
+ return player === me ? Math.max(...scores) : Math.min(...scores);
45
+ }
46
+
47
+ /** The best square for `player`, chosen at random among equally good ones. */
48
+ export function bestMove(board, player, rng = Math.random) {
49
+ let best = -Infinity;
50
+ let moves = [];
51
+ for (let i = 0; i < 9; i++) {
52
+ if (board[i]) continue;
53
+ board[i] = player;
54
+ const value = score(board, other(player), player, 1);
55
+ board[i] = null;
56
+ if (value > best) { best = value; moves = [i]; }
57
+ else if (value === best) moves.push(i);
58
+ }
59
+ if (!moves.length) return null;
60
+ return moves[Math.floor(rng() * moves.length) % moves.length];
61
+ }
62
+
63
+ function finish(state) {
64
+ const result = winner(state.board);
65
+ if (!result) return state;
66
+ state.over = result === "draw" ? "a draw — the only honest result"
67
+ : result === "X" ? "you win 🤘" : "the machine takes it";
68
+ return state;
69
+ }
70
+
71
+ export const TICTACTOE = {
72
+ key: "tictactoe",
73
+ // `tic-tac-toe` needs no alias — resolveGame drops dashes before it looks.
74
+ aliases: ["ttt", "tiktaktoe", "noughts", "xo"],
75
+ title: "TIC-TAC-TOE",
76
+ blurb: "three in a row against a perfect opponent",
77
+ keys: "← ↑ ↓ → move · enter mark · r new game · q quit",
78
+
79
+ create() {
80
+ return { board: emptyBoard(), cursor: 4, over: null, turn: "X" };
81
+ },
82
+
83
+ onKey(state, pressed, { rng = Math.random } = {}) {
84
+ const x = state.cursor % 3;
85
+ const y = Math.floor(state.cursor / 3);
86
+ if (pressed === "left") state.cursor = y * 3 + (x + 2) % 3;
87
+ else if (pressed === "right") state.cursor = y * 3 + (x + 1) % 3;
88
+ else if (pressed === "up") state.cursor = ((y + 2) % 3) * 3 + x;
89
+ else if (pressed === "down") state.cursor = ((y + 1) % 3) * 3 + x;
90
+ else if (pressed === "enter" || pressed === "space") {
91
+ if (state.board[state.cursor]) return state;
92
+ state.board[state.cursor] = "X";
93
+ if (finish(state).over) return state;
94
+ const reply = bestMove(state.board, "O", rng);
95
+ if (reply != null) state.board[reply] = "O";
96
+ finish(state);
97
+ }
98
+ return state;
99
+ },
100
+
101
+ status(state) {
102
+ return state.over ? state.over : `you ${acid("X")} ${ash("· machine")} ${bone("O")}`;
103
+ },
104
+
105
+ render(state) {
106
+ const mark = (i) => {
107
+ const value = state.board[i];
108
+ const glyph = value === "X" ? acid("X") : value === "O" ? danger("O") : " ";
109
+ // The cursor is drawn as brackets rather than a highlight so it survives
110
+ // NO_COLOR, a pipe, and every terminal that lies about its capabilities.
111
+ return i === state.cursor && !state.over ? ` ${acid("[")}${glyph}${acid("]")} ` : ` ${glyph} `;
112
+ };
113
+ const line = (l, m, r) => ash(`${l}─────${m}─────${m}─────${r}`);
114
+ const rows = [];
115
+ rows.push(line("┌", "┬", "┐"));
116
+ for (let y = 0; y < 3; y++) {
117
+ rows.push(`${ash("│")}${mark(y * 3)}${ash("│")}${mark(y * 3 + 1)}${ash("│")}${mark(y * 3 + 2)}${ash("│")}`);
118
+ rows.push(y < 2 ? line("├", "┼", "┤") : line("└", "┴", "┘"));
119
+ }
120
+ rows.push("");
121
+ rows.push(dim(state.over ? "r for another" : "enter to mark the square"));
122
+ return rows;
123
+ },
124
+ };
package/src/games.mjs ADDED
@@ -0,0 +1,363 @@
1
+ // The moshcode arcade — `/games` in the pit, `moshcode games` from a shell.
2
+ //
3
+ // Twenty-two 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 twenty-two 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
+ import { ASTEROIDS } from "./games-asteroids.mjs";
22
+ import { BLACKJACK } from "./games-blackjack.mjs";
23
+ import { STAGEDIVE } from "./games-stagedive.mjs";
24
+ import { INVADERS } from "./games-invaders.mjs";
25
+ import { BREAKOUT } from "./games-breakout.mjs";
26
+ import { PONG } from "./games-pong.mjs";
27
+ import { TANK } from "./games-tank.mjs";
28
+ import { SPYHUNTER } from "./games-spyhunter.mjs";
29
+ import { CENTIPEDE } from "./games-centipede.mjs";
30
+ import { FROGGER } from "./games-frogger.mjs";
31
+ import { DIGDUG } from "./games-digdug.mjs";
32
+ import { KONG } from "./games-kong.mjs";
33
+ import { PITFALL } from "./games-pitfall.mjs";
34
+ import { CHOPLIFTER } from "./games-choplifter.mjs";
35
+ import { EXCITEBIKE } from "./games-excitebike.mjs";
36
+ import { OUTRUN } from "./games-outrun.mjs";
37
+
38
+ /**
39
+ * @typedef {object} Game — the whole contract, so a seventh game is an import.
40
+ * @property {string} key the name typed after /games
41
+ * @property {string[]} aliases other spellings (tiktaktoe is a real thing people type)
42
+ * @property {string} title shown in the frame's header
43
+ * @property {string} blurb one line, for /games list
44
+ * @property {string} keys the footer; the only place controls are ever explained
45
+ * @property {number|Function} [tickMs] real-time games only — a number, or (state) => number
46
+ * @property {boolean} [vim] false when a game wants h/j/k/l as letters, not arrows
47
+ * @property {boolean} [restartable] false when `r` is only a restart once the game is over
48
+ * @property {Function} create ({ rng }) => state
49
+ * @property {Function} onKey (state, key, { rng }) => state
50
+ * @property {Function} [tick] (state, { rng }) => state
51
+ * @property {Function} render (state) => string[] the board, already coloured
52
+ * @property {Function} status (state) => string right of the title
53
+ */
54
+
55
+ /** The cabinet. Order is the order `/games` lists them. */
56
+ export const GAMES = [
57
+ TETRIS, SNAKE, PACMAN, INVADERS, CENTIPEDE, ASTEROIDS, BREAKOUT, PONG, TANK, DIGDUG,
58
+ FROGGER, KONG, PITFALL, CHOPLIFTER, SPYHUNTER, OUTRUN, EXCITEBIKE, STAGEDIVE,
59
+ TICTACTOE, BLACKJACK, CHESS, HANGMAN,
60
+ ];
61
+
62
+ /** Games by name, following aliases. Case- and slash-insensitive. */
63
+ export function resolveGame(name) {
64
+ const wanted = String(name ?? "").toLowerCase().replace(/^\//, "").replace(/[-_\s]/g, "");
65
+ if (!wanted) return null;
66
+ return GAMES.find((g) => g.key === wanted || (g.aliases || []).includes(wanted)) ?? null;
67
+ }
68
+
69
+ /* ------------------------------------------------------------------- frame */
70
+
71
+ // Colour codes are invisible but not zero-width to `.length`, so every pad in
72
+ // here measures the stripped string. Getting this wrong is how a board's right
73
+ // edge ends up ragged the moment someone wins.
74
+ const ANSI = /\x1b\[[0-9;]*m/g;
75
+ export const strip = (s) => String(s).replace(ANSI, "");
76
+ export const visible = (s) => strip(s).length;
77
+ const pad = (s, width) => s + " ".repeat(Math.max(0, width - visible(s)));
78
+
79
+ /**
80
+ * The one frame every game is drawn in.
81
+ *
82
+ * ```
83
+ * TETRIS score 1200 · lines 12
84
+ * ┌────────────────────┐
85
+ * │ ██████ │
86
+ * └────────────────────┘
87
+ * ← → move · ↑ rotate · space slam · q quit
88
+ * ```
89
+ *
90
+ * Returns a string with no trailing newline; `runGame` owns the cursor.
91
+ */
92
+ export function frame({ title = "", status = "", rows = [], keys = "" } = {}) {
93
+ const body = rows.map((r) => String(r));
94
+ // The board sets the width. The header and the key line sit outside the box,
95
+ // so letting either of them stretch it is how a 20-column tetris well ends up
96
+ // in a 54-column frame.
97
+ const inner = Math.max(...body.map(visible), 20);
98
+ const gap = inner - visible(title) - visible(status);
99
+ const head = !visible(status) ? acid(title)
100
+ // Right-align the status to the box edge when there is room for it, and
101
+ // fall back to a caption rather than pushing the board around when a long
102
+ // status (chess, mid-game) would not fit.
103
+ : gap >= 2 ? pad(acid(title), inner - visible(status)) + ash(status)
104
+ : `${acid(title)} ${ash(status)}`;
105
+ const out = [
106
+ ` ${head}`,
107
+ ` ${ash(`┌${"─".repeat(inner + 2)}┐`)}`,
108
+ ...body.map((row) => ` ${ash("│")} ${pad(row, inner)} ${ash("│")}`),
109
+ ` ${ash(`└${"─".repeat(inner + 2)}┘`)}`,
110
+ ` ${ash(keys)}`,
111
+ ];
112
+ return out.join("\n");
113
+ }
114
+
115
+ /* --------------------------------------------------------------------- keys */
116
+
117
+ /**
118
+ * Raw terminal bytes → key names the games understand.
119
+ *
120
+ * Games never see an escape sequence; they see "up", "enter", "a". A chunk can
121
+ * hold several keypresses (hold an arrow key down and they arrive in batches),
122
+ * which is why this returns a list.
123
+ *
124
+ * `vim: false` is for the games that read letters — hangman cannot ask for a
125
+ * word with an `h` in it while `h` means left, and blackjack wants `h` to be
126
+ * hit. Arrows are unaffected either way; they arrive as escape sequences.
127
+ */
128
+ export function decodeKeys(chunk, { vim = true } = {}) {
129
+ const input = String(chunk);
130
+ const keys = [];
131
+ for (let i = 0; i < input.length; i++) {
132
+ const c = input[i];
133
+ if (c === "\x1b") {
134
+ const seq = input.slice(i, i + 3);
135
+ const arrow = { "\x1b[A": "up", "\x1b[B": "down", "\x1b[C": "right", "\x1b[D": "left" }[seq];
136
+ if (arrow) { keys.push(arrow); i += 2; continue; }
137
+ // A bare escape is a quit everywhere in the arcade; a longer sequence we
138
+ // don't know (mouse, function key) is swallowed rather than misread.
139
+ if (input[i + 1] === "[" || input[i + 1] === "O") { i += 2; continue; }
140
+ keys.push("escape");
141
+ continue;
142
+ }
143
+ if (c === "\r" || c === "\n") { keys.push("enter"); continue; }
144
+ if (c === " ") { keys.push("space"); continue; }
145
+ if (c === "\x03" || c === "\x04") { keys.push("quit"); continue; }
146
+ if (c === "\x7f" || c === "\b") { keys.push("backspace"); continue; }
147
+ if (c === "\t") { keys.push("tab"); continue; }
148
+ // vim keys, everywhere, for free — every game that reads arrows gets them
149
+ // without knowing about them. A game that reads letters opts out.
150
+ const vimKey = vim ? { h: "left", j: "down", k: "up", l: "right" }[c] : null;
151
+ if (vimKey) { keys.push(vimKey); continue; }
152
+ if (c >= " " && c <= "~") keys.push(c.toLowerCase());
153
+ }
154
+ return keys;
155
+ }
156
+
157
+ /* -------------------------------------------------------------------- list */
158
+
159
+ /**
160
+ * `/games` with no argument: the cabinet, and how to start one.
161
+ *
162
+ * `prefix` is how the caller is spelled — the pit says `/games tetris` and a
163
+ * shell says `moshcode games tetris`, and printing the wrong one is how a list
164
+ * teaches somebody a command that does not work where they are standing.
165
+ */
166
+ export function renderList({ prefix = "moshcode games" } = {}) {
167
+ const width = Math.max(...GAMES.map((g) => g.key.length));
168
+ return [
169
+ ` ${acid("moshcode arcade")} ${ash(`— ${GAMES.length} games, no menus, no options screens`)}`,
170
+ "",
171
+ ...GAMES.map((g) => ` ${bone(g.key.padEnd(width))} ${ash(g.blurb)}`),
172
+ "",
173
+ ` ${ash("play one:")} ${acid(`${prefix} ${GAMES[0].key}`)}`,
174
+ ` ${ash("every game: arrows move · q quits · r starts another")}`,
175
+ ].join("\n");
176
+ }
177
+
178
+ /** The same cabinet, for something that cannot read a terminal. */
179
+ export function gamesModel() {
180
+ return {
181
+ games: GAMES.map((g) => ({
182
+ name: g.key,
183
+ aliases: g.aliases || [],
184
+ description: g.blurb,
185
+ keys: g.keys,
186
+ realtime: Boolean(g.tickMs),
187
+ })),
188
+ };
189
+ }
190
+
191
+ /* ------------------------------------------------------------------ driver */
192
+
193
+ const ESC = {
194
+ hideCursor: "\x1b[?25l",
195
+ showCursor: "\x1b[?25h",
196
+ up: (n) => (n > 0 ? `\x1b[${n}A` : ""),
197
+ eraseDown: "\x1b[0J",
198
+ };
199
+
200
+ /**
201
+ * Play one game until `q`.
202
+ *
203
+ * Drawn in place rather than on the alternate screen, so the final board — the
204
+ * score, the checkmate, the word you didn't get — stays in the pit's scrollback
205
+ * where you can look at it. Redrawing is "jump back up over the frame and
206
+ * write it again", which is why every frame is the same height.
207
+ */
208
+ export async function runGame(game, deps = {}) {
209
+ const {
210
+ input = process.stdin,
211
+ output = process.stdout,
212
+ rng = Math.random,
213
+ // Tests hand in their own clock so a "real-time" game can be played turn by
214
+ // turn, deterministically, with no timers left running after the assertion.
215
+ setTimer = (fn, ms) => setTimeout(fn, ms),
216
+ clearTimer = (t) => clearTimeout(t),
217
+ } = deps;
218
+
219
+ const ctx = { rng };
220
+ let state = game.create(ctx);
221
+ let height = 0;
222
+ let timer = null;
223
+ let closed = false;
224
+
225
+ let painted = null;
226
+ const draw = () => {
227
+ if (closed) return;
228
+ const text = frame({
229
+ title: game.title,
230
+ status: game.status(state),
231
+ rows: game.render(state),
232
+ keys: state.over ? `${game.keys} · ${bone("r")} again` : game.keys,
233
+ });
234
+ // A frame identical to the one already on the screen is not written at all.
235
+ // Chess idles on its clock while it is your move, and repainting the same
236
+ // board twice a second is exactly the flicker that would make it feel busy.
237
+ if (text === painted) return;
238
+ output.write(`${ESC.up(height)}${ESC.eraseDown}${text}\n`);
239
+ painted = text;
240
+ height = text.split("\n").length;
241
+ };
242
+
243
+ const stop = () => { if (timer !== null) { clearTimer(timer); timer = null; } };
244
+ const schedule = () => {
245
+ stop();
246
+ if (!game.tickMs || state.over) return;
247
+ const ms = typeof game.tickMs === "function" ? game.tickMs(state) : game.tickMs;
248
+ timer = setTimer(() => {
249
+ timer = null;
250
+ if (closed || state.over) return;
251
+ state = game.tick(state, ctx) || state;
252
+ draw();
253
+ schedule();
254
+ }, ms);
255
+ };
256
+
257
+ const wasRaw = Boolean(input.isRaw);
258
+ const restore = () => {
259
+ if (closed) return;
260
+ closed = true;
261
+ stop();
262
+ output.write(ESC.showCursor);
263
+ try { input.setRawMode?.(wasRaw); } catch { /* already gone */ }
264
+ input.off?.("data", onData);
265
+ input.pause?.();
266
+ };
267
+ const onSignal = () => { restore(); process.exit(130); };
268
+
269
+ function onData(chunk) {
270
+ for (const key of decodeKeys(chunk, { vim: game.vim !== false })) {
271
+ if (key === "quit" || key === "q" || key === "escape") { restore(); resolve(); return; }
272
+ if (key === "r" && (state.over || game.restartable !== false)) {
273
+ state = game.create(ctx);
274
+ draw();
275
+ schedule();
276
+ continue;
277
+ }
278
+ if (state.over) continue; // a finished board takes r and q, nothing else
279
+ state = game.onKey(state, key, ctx) || state;
280
+ draw();
281
+ // A key can end a real-time game (a hard drop into the ceiling) or start
282
+ // one moving again, so the clock is re-armed off every keypress.
283
+ if (game.tickMs) schedule();
284
+ }
285
+ }
286
+
287
+ let resolve;
288
+ const done = new Promise((res) => { resolve = res; });
289
+
290
+ output.write(ESC.hideCursor);
291
+ try { input.setRawMode?.(true); } catch { /* not a tty */ }
292
+ input.setEncoding?.("utf8");
293
+ input.resume?.();
294
+ input.on?.("data", onData);
295
+ process.on("SIGINT", onSignal);
296
+ process.on("SIGTERM", onSignal);
297
+
298
+ draw();
299
+ schedule();
300
+ await done;
301
+ restore();
302
+ process.off("SIGINT", onSignal);
303
+ process.off("SIGTERM", onSignal);
304
+ output.write(` ${ash("thanks for playing 🤘")}\n`);
305
+ return 0;
306
+ }
307
+
308
+ /* ----------------------------------------------------------------- command */
309
+
310
+ /**
311
+ * `/games [name]` in the pit, `moshcode games [name]` from a shell.
312
+ *
313
+ * The two are the same call; only the exit code is read by the CLI. Listing
314
+ * works anywhere, including a pipe — starting a game does not, because raw mode
315
+ * is how every one of them reads a key.
316
+ */
317
+ export async function gamesCommand(argv = [], deps = {}) {
318
+ const {
319
+ out = (s) => console.log(s),
320
+ fail = (s) => console.error(s),
321
+ input = process.stdin,
322
+ output = process.stdout,
323
+ interactive = Boolean(input.isTTY && output.isTTY),
324
+ prefix,
325
+ ...rest
326
+ } = deps;
327
+
328
+ const args = argv.filter((a) => a !== undefined && a !== null).map(String);
329
+ const json = args.includes("--json");
330
+ const positional = args.filter((a) => !a.startsWith("-"));
331
+ const [name] = positional;
332
+
333
+ if (json && (!name || name === "list")) { out(JSON.stringify(gamesModel(), null, 2)); return 0; }
334
+ if (!name || name === "list" || name === "ls" || name === "games") { out(renderList({ prefix })); return 0; }
335
+
336
+ const game = resolveGame(name);
337
+ if (!game) {
338
+ fail(`${danger("✗ ")}no game called "${name}". ${ash(`try: ${GAMES.map((g) => g.key).join(" · ")}`)}`);
339
+ return 1;
340
+ }
341
+ if (!interactive) {
342
+ fail(`${danger("✗ ")}${game.key} needs an interactive terminal — it reads single keypresses.`);
343
+ fail(`${ash("· ")}${ash("run it from the pit, or a real shell — `moshcode games list` works anywhere.")}`);
344
+ return 1;
345
+ }
346
+
347
+ return runGame(game, { input, output, ...rest });
348
+ }
349
+
350
+ /* Shared by more than one game, and kept here so they agree on what a wall or a
351
+ * hazard looks like. A game that invents its own palette stops looking like the
352
+ * arcade it is in. */
353
+ export const PALETTE = {
354
+ wall: (s) => ash(s),
355
+ empty: (s) => dim(s),
356
+ you: (s) => acid(s),
357
+ prize: (s) => amber(s),
358
+ hazard: (s) => danger(s),
359
+ piece: (s) => bone(s),
360
+ cool: rgb(90, 200, 250),
361
+ violet: rgb(190, 130, 255),
362
+ rose: rgb(255, 120, 180),
363
+ };
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);