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.
- package/README.md +62 -0
- package/bin/moshcode.mjs +6 -0
- package/package.json +1 -1
- package/src/cli-schema.mjs +28 -0
- package/src/games-asteroids.mjs +265 -0
- package/src/games-blackjack.mjs +284 -0
- package/src/games-breakout.mjs +186 -0
- package/src/games-centipede.mjs +241 -0
- package/src/games-chess.mjs +376 -0
- package/src/games-choplifter.mjs +179 -0
- package/src/games-digdug.mjs +267 -0
- package/src/games-excitebike.mjs +206 -0
- package/src/games-frogger.mjs +194 -0
- package/src/games-hangman.mjs +102 -0
- package/src/games-invaders.mjs +258 -0
- package/src/games-kong.mjs +191 -0
- package/src/games-outrun.mjs +208 -0
- package/src/games-pacman.mjs +205 -0
- package/src/games-pitfall.mjs +211 -0
- package/src/games-pong.mjs +147 -0
- package/src/games-snake.mjs +111 -0
- package/src/games-spyhunter.mjs +230 -0
- package/src/games-stagedive.mjs +206 -0
- package/src/games-tank.mjs +230 -0
- package/src/games-tetris.mjs +221 -0
- package/src/games-tictactoe.mjs +124 -0
- package/src/games.mjs +363 -0
- package/src/tui.mjs +13 -0
- package/src/ui.mjs +3 -1
|
@@ -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
|
-
|
|
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);
|