moshcode 0.41.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 +22 -2
- package/package.json +1 -1
- package/src/cli-schema.mjs +6 -2
- 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-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 +6 -1
- package/src/games-invaders.mjs +258 -0
- package/src/games-kong.mjs +191 -0
- package/src/games-outrun.mjs +208 -0
- package/src/games-pitfall.mjs +211 -0
- package/src/games-pong.mjs +147 -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.mjs +35 -9
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ or miss one that does. A test fails the build when it drifts.
|
|
|
50
50
|
| `moshcode doh` | hosting | run the DNS-over-HTTPS resolver |
|
|
51
51
|
| `moshcode site` <br>`serve` | hosting | install web-server config for a Moshpit name |
|
|
52
52
|
| `moshcode template` <br>`templates` | hosting | scaffold a stack for a Moshpit-hosted service |
|
|
53
|
-
| `moshcode games` <br>`game` `arcade` | arcade | the moshcode arcade —
|
|
53
|
+
| `moshcode games` <br>`game` `arcade` | arcade | the moshcode arcade — twenty-two games, no menus |
|
|
54
54
|
| `moshcode pwd` <br>`where` | system | show the current directory and git context |
|
|
55
55
|
| `moshcode engines` | engines | list engines and installation status |
|
|
56
56
|
| `moshcode tools` | tools | list workflow tools and installation status |
|
|
@@ -584,7 +584,7 @@ the composer URL instead.
|
|
|
584
584
|
|
|
585
585
|
## The arcade (`/games`)
|
|
586
586
|
|
|
587
|
-
|
|
587
|
+
Twenty-two games, in the pit or straight from a shell. There are no menus, no options
|
|
588
588
|
screens and no difficulty prompts — `/games tetris` is already playing.
|
|
589
589
|
|
|
590
590
|
```sh
|
|
@@ -611,7 +611,23 @@ moshcode games --json # the roster, for a machine
|
|
|
611
611
|
| `tetris` | stack the bricks, clear the lines, outrun gravity |
|
|
612
612
|
| `snake` | eat, grow, and try not to eat yourself |
|
|
613
613
|
| `pacman` | eat the dots, dodge the ghosts, `✳` makes them edible |
|
|
614
|
+
| `invaders` | forty of them, and the last one moves fastest |
|
|
615
|
+
| `centipede` | shoot it in the middle and now there are two of them |
|
|
616
|
+
| `asteroids` | turn, thrust, shoot — every rock you break becomes two |
|
|
617
|
+
| `breakout` | dig a channel up the side and let the ball do the rest |
|
|
618
|
+
| `pong` | first to seven, and the angle is all in where you hit it |
|
|
619
|
+
| `tank` | two tanks, one yard, five hits — line it up and let go |
|
|
620
|
+
| `digdug` | dig the tunnels, pump the monsters, drop rocks on the rest |
|
|
621
|
+
| `frogger` | the road kills what it touches, the river kills what it doesn't |
|
|
622
|
+
| `kong` | five girders, four ladders, and a barrel with your name on it |
|
|
623
|
+
| `pitfall` | jump the logs, swing the pits, and get the gold before dark |
|
|
624
|
+
| `choplifter` | fly out, land, fill the back, and get them home |
|
|
625
|
+
| `spyhunter` | keep it on the tarmac, shoot the ones shooting back |
|
|
626
|
+
| `outrun` | a road that bends, traffic that doesn't, and a clock that always wins |
|
|
627
|
+
| `excitebike` | turbo until it cooks, and land the way you took off |
|
|
628
|
+
| `stagedive` | run the barricade, hop the gear, duck the crowd, take the picks |
|
|
614
629
|
| `tictactoe` | three in a row against an opponent that cannot be beaten |
|
|
630
|
+
| `blackjack` | hit, stand, double, split — dealer stands on 17, and pays 3:2 |
|
|
615
631
|
| `chess` | full rules — castling, en passant, promotion — and it plays back |
|
|
616
632
|
| `hangman` | six wrong letters and you are done for |
|
|
617
633
|
|
|
@@ -620,6 +636,10 @@ another, and the controls are written along the bottom of the game itself. Each
|
|
|
620
636
|
draws in place rather than on the alternate screen, so the board you finished on
|
|
621
637
|
stays in your scrollback.
|
|
622
638
|
|
|
639
|
+
The two games that read letters — `blackjack`, where `h` is hit, and `hangman`,
|
|
640
|
+
where it is a guess — keep their letters: `h j k l` are not arrows there, and `r`
|
|
641
|
+
only starts another once the game is over.
|
|
642
|
+
|
|
623
643
|
Playing needs a real terminal, because they read single keypresses — `moshcode
|
|
624
644
|
games` on its own lists them anywhere, including a pipe.
|
|
625
645
|
|
package/package.json
CHANGED
package/src/cli-schema.mjs
CHANGED
|
@@ -396,7 +396,7 @@ export const CORE_CLI_COMMANDS = [
|
|
|
396
396
|
{
|
|
397
397
|
name: "games",
|
|
398
398
|
group: "arcade",
|
|
399
|
-
description: "the moshcode arcade —
|
|
399
|
+
description: "the moshcode arcade — twenty-two games, no menus",
|
|
400
400
|
synopsis: [
|
|
401
401
|
["moshcode games", "the cabinet, and what each one is"],
|
|
402
402
|
["moshcode games <game>", "play it, right here in the terminal"],
|
|
@@ -407,6 +407,10 @@ export const CORE_CLI_COMMANDS = [
|
|
|
407
407
|
["moshcode games tetris", ""],
|
|
408
408
|
["moshcode games chess", "real rules, and it plays back"],
|
|
409
409
|
["moshcode games pacman", "dots, ghosts, three lives"],
|
|
410
|
+
["moshcode games asteroids", "turn, thrust, shoot"],
|
|
411
|
+
["moshcode games 21", "blackjack, 100 chips, 3:2"],
|
|
412
|
+
["moshcode games invaders", "forty of them, coming down"],
|
|
413
|
+
["moshcode games stagedive", "jump the gear, take the picks"],
|
|
410
414
|
],
|
|
411
415
|
seeAlso: ["help"],
|
|
412
416
|
note: "every game works the same way: arrows move, q quits, r starts another. "
|
|
@@ -930,7 +934,7 @@ export const PIT_COMMANDS = [
|
|
|
930
934
|
{ name: "plugin", aliases: ["plugins"], args: "<verb> [name]", cli: "plugin",
|
|
931
935
|
description: "install moshcode's slash commands into Claude Code" },
|
|
932
936
|
{ name: "games", aliases: ["game", "arcade", "play"], args: "[game]", cli: "games",
|
|
933
|
-
description: "the arcade — tetris,
|
|
937
|
+
description: "the arcade — tetris, invaders, pac-man, frogger, kong, outrun, chess and more" },
|
|
934
938
|
{ name: "socials", aliases: ["social"], pitOnly: true,
|
|
935
939
|
description: "list social networks available for posting" },
|
|
936
940
|
{ name: "post", args: '<social> "message"', pitOnly: true,
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Asteroids. Turn, thrust, shoot, and watch the rocks you shot become two
|
|
2
|
+
// smaller rocks that are somehow worse.
|
|
3
|
+
//
|
|
4
|
+
// The one thing the 1979 cabinet did not have to worry about: a terminal cell is
|
|
5
|
+
// about twice as tall as it is wide. Everything here measures distance in
|
|
6
|
+
// columns, so a row counts double (see `span`) — without that, every rock is an
|
|
7
|
+
// egg and the collisions land in places the screen never showed you.
|
|
8
|
+
import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
|
|
9
|
+
|
|
10
|
+
export const WIDTH = 44;
|
|
11
|
+
export const HEIGHT = 18;
|
|
12
|
+
|
|
13
|
+
/** A row is worth this many columns. Half of everything vertical follows. */
|
|
14
|
+
export const ASPECT = 0.5;
|
|
15
|
+
|
|
16
|
+
const TURN = Math.PI / 8; // 16 headings, which is as many as 8 glyphs can tell apart
|
|
17
|
+
const THRUST = 0.14;
|
|
18
|
+
const DRAG = 0.97; // space has none; a 55ms terminal very much needs some
|
|
19
|
+
const MAX_SPEED = 1.15;
|
|
20
|
+
const BULLET_SPEED = 1.6;
|
|
21
|
+
const BULLET_LIFE = 22;
|
|
22
|
+
const BULLETS = 4; // in flight at once — the arcade limit, and it is the game
|
|
23
|
+
const COOLDOWN = 2;
|
|
24
|
+
const INVULN = 30; // ticks of blinking after a life is lost
|
|
25
|
+
const LIVES = 3;
|
|
26
|
+
|
|
27
|
+
/** Radius in columns, and what a hit is worth. Small ones pay most. */
|
|
28
|
+
export const ROCKS = {
|
|
29
|
+
3: { r: 3.2, glyph: "█", points: 20, speed: 0.16 },
|
|
30
|
+
2: { r: 2.1, glyph: "▓", points: 50, speed: 0.24 },
|
|
31
|
+
1: { r: 1.1, glyph: "▒", points: 100, speed: 0.34 },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const HEADINGS = ["→", "↘", "↓", "↙", "←", "↖", "↑", "↗"];
|
|
35
|
+
const FLAME = ["◄", "◤", "▲", "◥", "►", "◢", "▼", "◣"];
|
|
36
|
+
|
|
37
|
+
export const wrap = (v, max) => ((v % max) + max) % max;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The starfield: fixed, scattered, and free. Hashed from the coordinates rather
|
|
41
|
+
* than stored or rolled, so the sky is the same on every repaint — and hashed
|
|
42
|
+
* rather than `(x * 7 + y * 23) % 37`, which is a diagonal line, not a sky.
|
|
43
|
+
*/
|
|
44
|
+
export const star = (x, y) => ((((x * 73856093) ^ (y * 19349663)) >>> 4) % 53) === 0;
|
|
45
|
+
|
|
46
|
+
/** The shorter way round a wrapping axis — the screen has no edges. */
|
|
47
|
+
const delta = (a, b, max) => {
|
|
48
|
+
const d = a - b;
|
|
49
|
+
return d > max / 2 ? d - max : d < -max / 2 ? d + max : d;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Distance between two things, in columns, the short way round. */
|
|
53
|
+
export function span(a, b) {
|
|
54
|
+
const dx = delta(a.x, b.x, WIDTH);
|
|
55
|
+
const dy = delta(a.y, b.y, HEIGHT) / ASPECT;
|
|
56
|
+
return Math.hypot(dx, dy);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const drift = (thing) => {
|
|
60
|
+
thing.x = wrap(thing.x + thing.vx, WIDTH);
|
|
61
|
+
thing.y = wrap(thing.y + thing.vy, HEIGHT);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** A rock of `size`, moving somewhere, starting where it is put. */
|
|
65
|
+
export function rock(size, x, y, rng) {
|
|
66
|
+
const angle = rng() * Math.PI * 2;
|
|
67
|
+
const speed = ROCKS[size].speed * (0.7 + rng() * 0.6);
|
|
68
|
+
return {
|
|
69
|
+
size, x, y,
|
|
70
|
+
vx: Math.cos(angle) * speed,
|
|
71
|
+
vy: Math.sin(angle) * speed * ASPECT,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A wave. Rocks arrive at the edges of the screen and never on top of the ship —
|
|
77
|
+
* spawning one on the ship is the cheapest way to make a game feel broken.
|
|
78
|
+
*/
|
|
79
|
+
export function spawnWave(wave, ship, rng) {
|
|
80
|
+
const rocks = [];
|
|
81
|
+
for (let i = 0; i < 3 + wave; i++) {
|
|
82
|
+
let x = 0;
|
|
83
|
+
let y = 0;
|
|
84
|
+
let tries = 0;
|
|
85
|
+
do {
|
|
86
|
+
x = rng() * WIDTH;
|
|
87
|
+
y = rng() * HEIGHT;
|
|
88
|
+
tries++;
|
|
89
|
+
} while (span({ x, y }, ship) < 14 && tries < 60);
|
|
90
|
+
rocks.push(rock(3, x, y, rng));
|
|
91
|
+
}
|
|
92
|
+
return rocks;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const newShip = () => ({ x: WIDTH / 2, y: HEIGHT / 2, vx: 0, vy: 0, angle: -Math.PI / 2 });
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One tick: everything moves, then everything that touched something else
|
|
99
|
+
* finds out. Exported so a test can fly a whole game without a clock.
|
|
100
|
+
*/
|
|
101
|
+
export function step(state) {
|
|
102
|
+
const { rng } = state;
|
|
103
|
+
const ship = state.ship;
|
|
104
|
+
|
|
105
|
+
ship.vx *= DRAG;
|
|
106
|
+
ship.vy *= DRAG;
|
|
107
|
+
const speed = Math.hypot(ship.vx, ship.vy / ASPECT);
|
|
108
|
+
if (speed > MAX_SPEED) {
|
|
109
|
+
ship.vx *= MAX_SPEED / speed;
|
|
110
|
+
ship.vy *= MAX_SPEED / speed;
|
|
111
|
+
}
|
|
112
|
+
drift(ship);
|
|
113
|
+
|
|
114
|
+
for (const bullet of state.bullets) { drift(bullet); bullet.life--; }
|
|
115
|
+
state.bullets = state.bullets.filter((b) => b.life > 0);
|
|
116
|
+
for (const r of state.rocks) drift(r);
|
|
117
|
+
|
|
118
|
+
if (state.cooldown > 0) state.cooldown--;
|
|
119
|
+
if (state.thrusting > 0) state.thrusting--;
|
|
120
|
+
if (state.invuln > 0) state.invuln--;
|
|
121
|
+
|
|
122
|
+
// Bullets first: a rock shot on the same tick it reaches you does not also
|
|
123
|
+
// get to take a life.
|
|
124
|
+
for (const bullet of [...state.bullets]) {
|
|
125
|
+
const hit = state.rocks.find((r) => span(bullet, r) <= ROCKS[r.size].r + 0.4);
|
|
126
|
+
if (!hit) continue;
|
|
127
|
+
state.bullets = state.bullets.filter((b) => b !== bullet);
|
|
128
|
+
state.rocks = state.rocks.filter((r) => r !== hit);
|
|
129
|
+
state.score += ROCKS[hit.size].points;
|
|
130
|
+
if (hit.size > 1) {
|
|
131
|
+
state.rocks.push(rock(hit.size - 1, hit.x, hit.y, rng), rock(hit.size - 1, hit.x, hit.y, rng));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!state.invuln) {
|
|
136
|
+
const struck = state.rocks.find((r) => span(ship, r) <= ROCKS[r.size].r + 0.9);
|
|
137
|
+
if (struck) {
|
|
138
|
+
state.lives--;
|
|
139
|
+
if (state.lives <= 0) {
|
|
140
|
+
state.lives = 0;
|
|
141
|
+
state.over = `wrecked on wave ${state.wave}`;
|
|
142
|
+
return state;
|
|
143
|
+
}
|
|
144
|
+
state.ship = newShip();
|
|
145
|
+
state.bullets = [];
|
|
146
|
+
state.invuln = INVULN;
|
|
147
|
+
// The rock that got you is pushed clear rather than deleted, so the
|
|
148
|
+
// respawn is not immediately fatal a second time.
|
|
149
|
+
struck.x = wrap(struck.x + WIDTH / 2, WIDTH);
|
|
150
|
+
struck.y = wrap(struck.y + HEIGHT / 2, HEIGHT);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!state.rocks.length) {
|
|
155
|
+
state.wave++;
|
|
156
|
+
state.rocks = spawnWave(state.wave, state.ship, rng);
|
|
157
|
+
state.invuln = Math.max(state.invuln, 12);
|
|
158
|
+
}
|
|
159
|
+
return state;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const ASTEROIDS = {
|
|
163
|
+
key: "asteroids",
|
|
164
|
+
aliases: ["rocks", "asteroid"],
|
|
165
|
+
title: "ASTEROIDS",
|
|
166
|
+
blurb: "turn, thrust, shoot — every rock you break becomes two",
|
|
167
|
+
keys: "← → turn · ↑ thrust · ↓ retro · space fire · q quit",
|
|
168
|
+
tickMs: 55,
|
|
169
|
+
|
|
170
|
+
create({ rng = Math.random } = {}) {
|
|
171
|
+
const ship = newShip();
|
|
172
|
+
return {
|
|
173
|
+
ship,
|
|
174
|
+
bullets: [],
|
|
175
|
+
rocks: spawnWave(1, ship, rng),
|
|
176
|
+
score: 0,
|
|
177
|
+
lives: LIVES,
|
|
178
|
+
wave: 1,
|
|
179
|
+
cooldown: 0,
|
|
180
|
+
thrusting: 0,
|
|
181
|
+
invuln: INVULN,
|
|
182
|
+
over: null,
|
|
183
|
+
rng,
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
tick: step,
|
|
188
|
+
|
|
189
|
+
onKey(state, key) {
|
|
190
|
+
const ship = state.ship;
|
|
191
|
+
if (key === "left") ship.angle -= TURN;
|
|
192
|
+
else if (key === "right") ship.angle += TURN;
|
|
193
|
+
else if (key === "up" || key === "down") {
|
|
194
|
+
// Retro is half power, because a reverse as strong as the throttle turns
|
|
195
|
+
// the ship into something that cannot drift, and drifting is the game.
|
|
196
|
+
const push = key === "up" ? THRUST : -THRUST / 2;
|
|
197
|
+
ship.vx += Math.cos(ship.angle) * push;
|
|
198
|
+
ship.vy += Math.sin(ship.angle) * push * ASPECT;
|
|
199
|
+
if (key === "up") state.thrusting = 3;
|
|
200
|
+
} else if (key === "space" || key === "enter") {
|
|
201
|
+
if (state.cooldown > 0 || state.bullets.length >= BULLETS) return state;
|
|
202
|
+
state.bullets.push({
|
|
203
|
+
// Out of the nose, not the middle, or the ship shoots itself in the face
|
|
204
|
+
// at close range and the muzzle flash lands inside the hull.
|
|
205
|
+
x: wrap(ship.x + Math.cos(ship.angle) * 1.6, WIDTH),
|
|
206
|
+
y: wrap(ship.y + Math.sin(ship.angle) * 1.6 * ASPECT, HEIGHT),
|
|
207
|
+
vx: ship.vx + Math.cos(ship.angle) * BULLET_SPEED,
|
|
208
|
+
vy: ship.vy + Math.sin(ship.angle) * BULLET_SPEED * ASPECT,
|
|
209
|
+
life: BULLET_LIFE,
|
|
210
|
+
});
|
|
211
|
+
state.cooldown = COOLDOWN;
|
|
212
|
+
}
|
|
213
|
+
return state;
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
status(state) {
|
|
217
|
+
return state.over
|
|
218
|
+
? `${state.over} · ${state.score} points`
|
|
219
|
+
: `score ${state.score} · wave ${state.wave} · ${"▲".repeat(state.lives)}`;
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
render(state) {
|
|
223
|
+
const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
|
|
224
|
+
const put = (x, y, glyph) => {
|
|
225
|
+
const col = wrap(Math.round(x), WIDTH);
|
|
226
|
+
const row = wrap(Math.round(y), HEIGHT);
|
|
227
|
+
grid[row][col] = glyph;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
for (const r of state.rocks) {
|
|
231
|
+
const { r: radius, glyph } = ROCKS[r.size];
|
|
232
|
+
const paint = r.size === 3 ? ash : r.size === 2 ? rgb(150, 158, 150) : rgb(190, 196, 188);
|
|
233
|
+
// A disc, measured in columns — hence the row doubling, which is the only
|
|
234
|
+
// reason these come out round instead of squashed.
|
|
235
|
+
for (let dy = -Math.ceil(radius * ASPECT); dy <= Math.ceil(radius * ASPECT); dy++) {
|
|
236
|
+
for (let dx = -Math.ceil(radius); dx <= Math.ceil(radius); dx++) {
|
|
237
|
+
if (Math.hypot(dx, dy / ASPECT) > radius) continue;
|
|
238
|
+
put(r.x + dx, r.y + dy, paint(glyph));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (const bullet of state.bullets) put(bullet.x, bullet.y, amber("•"));
|
|
244
|
+
|
|
245
|
+
const heading = ((Math.round(state.ship.angle / (Math.PI / 4)) % 8) + 8) % 8;
|
|
246
|
+
if (state.thrusting) {
|
|
247
|
+
put(
|
|
248
|
+
state.ship.x - Math.cos(state.ship.angle) * 1.4,
|
|
249
|
+
state.ship.y - Math.sin(state.ship.angle) * 1.4 * ASPECT,
|
|
250
|
+
danger(FLAME[heading]),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
if (!state.over) {
|
|
254
|
+
// Blinking is the only tell that the ship cannot be hit yet.
|
|
255
|
+
const shield = state.invuln && state.invuln % 6 < 3;
|
|
256
|
+
put(state.ship.x, state.ship.y, (shield ? bone : acid)(HEADINGS[heading]));
|
|
257
|
+
} else {
|
|
258
|
+
put(state.ship.x, state.ship.y, danger("✷"));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return grid.map((row, y) => row.map((cell, x) => (
|
|
262
|
+
cell ?? (star(x, y) ? dim("·") : " ")
|
|
263
|
+
)).join(""));
|
|
264
|
+
},
|
|
265
|
+
};
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Blackjack. One hand at a time against a dealer with no choices to make.
|
|
2
|
+
//
|
|
3
|
+
// House rules, printed here rather than in an options screen nobody reads:
|
|
4
|
+
// dealer stands on all 17s, blackjack pays 3:2, double on any first two cards,
|
|
5
|
+
// split a pair once, split aces get one card each. The stack is 100 chips and
|
|
6
|
+
// the game is over when it is gone.
|
|
7
|
+
import { acid, amber, ash, bone, danger, dim } from "./ui.mjs";
|
|
8
|
+
|
|
9
|
+
export const SUITS = ["♠", "♥", "♦", "♣"];
|
|
10
|
+
export const RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"];
|
|
11
|
+
|
|
12
|
+
export const START_CHIPS = 100;
|
|
13
|
+
export const MIN_BET = 5;
|
|
14
|
+
export const BET_STEP = 5;
|
|
15
|
+
const RESHUFFLE_AT = 15;
|
|
16
|
+
|
|
17
|
+
/** A shuffled deck. Fisher–Yates, so the seeded rng in a test gives one deal. */
|
|
18
|
+
export function freshDeck(rng = Math.random) {
|
|
19
|
+
const deck = [];
|
|
20
|
+
for (const suit of SUITS) for (const rank of RANKS) deck.push({ rank, suit });
|
|
21
|
+
for (let i = deck.length - 1; i > 0; i--) {
|
|
22
|
+
const j = Math.floor(rng() * (i + 1)) % (i + 1);
|
|
23
|
+
[deck[i], deck[j]] = [deck[j], deck[i]];
|
|
24
|
+
}
|
|
25
|
+
return deck;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Face value; an ace counts eleven here and is talked down by `handValue`. */
|
|
29
|
+
export const cardValue = (card) => (card.rank === "A" ? 11 : ["J", "Q", "K"].includes(card.rank) ? 10 : Number(card.rank));
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The total, and whether an ace is still counting as eleven — which is the only
|
|
33
|
+
* thing that makes a 17 worth hitting.
|
|
34
|
+
*/
|
|
35
|
+
export function handValue(cards) {
|
|
36
|
+
let total = cards.reduce((sum, c) => sum + cardValue(c), 0);
|
|
37
|
+
let aces = cards.filter((c) => c.rank === "A").length;
|
|
38
|
+
while (total > 21 && aces > 0) { total -= 10; aces--; }
|
|
39
|
+
return { total, soft: aces > 0 };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Twenty-one on the first two cards, and nothing else. */
|
|
43
|
+
export const isBlackjack = (cards) => cards.length === 2 && handValue(cards).total === 21;
|
|
44
|
+
|
|
45
|
+
const clampBet = (bet, chips) => Math.max(Math.min(MIN_BET, chips), Math.min(bet, chips));
|
|
46
|
+
|
|
47
|
+
function draw(state) {
|
|
48
|
+
if (!state.deck.length) state.deck = freshDeck(state.rng);
|
|
49
|
+
return state.deck.pop();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const newHand = (cards, bet) => ({ cards, bet, done: false, doubled: false, result: null, payout: 0 });
|
|
53
|
+
|
|
54
|
+
/** Chips won or lost, the way a table would say it. */
|
|
55
|
+
const signed = (n) => (n > 0 ? acid(`+${n}`) : n < 0 ? danger(`−${Math.abs(n)}`) : ash("even"));
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Start a hand. The wager leaves the stack now and comes back on settlement,
|
|
59
|
+
* so the chip count on screen is always what you could still walk away with.
|
|
60
|
+
*/
|
|
61
|
+
export function deal(state) {
|
|
62
|
+
if (state.chips <= 0) { state.over = `broke after ${state.played} hands`; return state; }
|
|
63
|
+
if (state.deck.length < RESHUFFLE_AT) state.deck = freshDeck(state.rng);
|
|
64
|
+
|
|
65
|
+
state.bet = clampBet(state.bet, state.chips);
|
|
66
|
+
state.chips -= state.bet;
|
|
67
|
+
state.hands = [newHand([draw(state), draw(state)], state.bet)];
|
|
68
|
+
state.dealer = [draw(state), draw(state)];
|
|
69
|
+
state.hole = true;
|
|
70
|
+
state.active = 0;
|
|
71
|
+
state.phase = "player";
|
|
72
|
+
state.message = "";
|
|
73
|
+
|
|
74
|
+
// A natural on either side ends it before anybody gets a decision.
|
|
75
|
+
if (isBlackjack(state.hands[0].cards) || isBlackjack(state.dealer)) settle(state);
|
|
76
|
+
return state;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The dealer's whole turn: show the hole card, then hit until 17. */
|
|
80
|
+
function dealerPlays(state) {
|
|
81
|
+
state.hole = false;
|
|
82
|
+
// With every player hand busted there is nothing to beat, and the dealer does
|
|
83
|
+
// not draw for an audience.
|
|
84
|
+
if (!state.hands.some((h) => handValue(h.cards).total <= 21)) return;
|
|
85
|
+
// A natural is already paid; the dealer does not draw for it. Split hands are
|
|
86
|
+
// not naturals, so two 21s off a pair of aces still get a dealer's turn.
|
|
87
|
+
if (state.hands.length === 1 && isBlackjack(state.hands[0].cards)) return;
|
|
88
|
+
while (handValue(state.dealer).total < 17) state.dealer.push(draw(state));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Pay the hands out, and notice when the stack is gone. */
|
|
92
|
+
export function settle(state) {
|
|
93
|
+
dealerPlays(state);
|
|
94
|
+
const dealer = handValue(state.dealer).total;
|
|
95
|
+
const dealerBJ = isBlackjack(state.dealer);
|
|
96
|
+
let net = 0;
|
|
97
|
+
|
|
98
|
+
for (const hand of state.hands) {
|
|
99
|
+
const total = handValue(hand.cards).total;
|
|
100
|
+
// A split hand that reaches 21 is twenty-one, not blackjack — it does not
|
|
101
|
+
// pay 3:2, which is the rule everybody's home version gets wrong.
|
|
102
|
+
const natural = isBlackjack(hand.cards) && state.hands.length === 1;
|
|
103
|
+
if (total > 21) { hand.result = "bust"; hand.payout = 0; }
|
|
104
|
+
else if (natural && !dealerBJ) { hand.result = "blackjack 🤘"; hand.payout = hand.bet + Math.floor(hand.bet * 1.5); }
|
|
105
|
+
else if (dealerBJ && !natural) { hand.result = "dealer blackjack"; hand.payout = 0; }
|
|
106
|
+
else if (dealer > 21) { hand.result = "dealer busts"; hand.payout = hand.bet * 2; }
|
|
107
|
+
else if (total > dealer) { hand.result = "you win"; hand.payout = hand.bet * 2; }
|
|
108
|
+
else if (total < dealer) { hand.result = "dealer wins"; hand.payout = 0; }
|
|
109
|
+
else { hand.result = "push"; hand.payout = hand.bet; }
|
|
110
|
+
state.chips += hand.payout;
|
|
111
|
+
net += hand.payout - hand.bet;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
state.phase = "settled";
|
|
115
|
+
state.played++;
|
|
116
|
+
state.message = `${state.hands.map((h) => h.result).join(" · ")} ${signed(net)}`;
|
|
117
|
+
if (state.chips <= 0) state.over = `broke after ${state.played} hands`;
|
|
118
|
+
return state;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Move to the next hand with a decision left, or let the dealer answer. */
|
|
122
|
+
function advance(state) {
|
|
123
|
+
const next = state.hands.findIndex((h, i) => i > state.active && !h.done);
|
|
124
|
+
if (next >= 0) { state.active = next; return state; }
|
|
125
|
+
if (state.hands.every((h) => h.done)) return settle(state);
|
|
126
|
+
return state;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const current = (state) => state.hands[state.active];
|
|
130
|
+
|
|
131
|
+
export function hit(state) {
|
|
132
|
+
const hand = current(state);
|
|
133
|
+
hand.cards.push(draw(state));
|
|
134
|
+
// Twenty-one stands itself — there is no card that improves it, and asking is
|
|
135
|
+
// just a way to let someone bust a made hand by reflex.
|
|
136
|
+
if (handValue(hand.cards).total >= 21) hand.done = true;
|
|
137
|
+
return hand.done ? advance(state) : state;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function double(state) {
|
|
141
|
+
const hand = current(state);
|
|
142
|
+
if (hand.cards.length !== 2 || state.chips < hand.bet) return state;
|
|
143
|
+
state.chips -= hand.bet;
|
|
144
|
+
hand.bet *= 2;
|
|
145
|
+
hand.doubled = true;
|
|
146
|
+
hand.cards.push(draw(state));
|
|
147
|
+
hand.done = true;
|
|
148
|
+
return advance(state);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const canSplit = (state) => state.hands.length === 1
|
|
152
|
+
&& current(state).cards.length === 2
|
|
153
|
+
&& cardValue(current(state).cards[0]) === cardValue(current(state).cards[1])
|
|
154
|
+
&& state.chips >= current(state).bet;
|
|
155
|
+
|
|
156
|
+
export function split(state) {
|
|
157
|
+
if (!canSplit(state)) return state;
|
|
158
|
+
const [a, b] = current(state).cards;
|
|
159
|
+
const bet = current(state).bet;
|
|
160
|
+
state.chips -= bet;
|
|
161
|
+
state.hands = [newHand([a, draw(state)], bet), newHand([b, draw(state)], bet)];
|
|
162
|
+
state.active = 0;
|
|
163
|
+
// Split aces get one card each and that is the hand — the same deal every
|
|
164
|
+
// casino offers, and the reason splitting them is still worth it.
|
|
165
|
+
if (a.rank === "A") {
|
|
166
|
+
for (const hand of state.hands) hand.done = true;
|
|
167
|
+
return settle(state);
|
|
168
|
+
}
|
|
169
|
+
return state;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* ------------------------------------------------------------------ render */
|
|
173
|
+
|
|
174
|
+
const RED = ["♥", "♦"];
|
|
175
|
+
const face = (card) => {
|
|
176
|
+
const label = `${card.rank}${card.suit}`.padStart(3);
|
|
177
|
+
return (RED.includes(card.suit) ? danger : bone)(label);
|
|
178
|
+
};
|
|
179
|
+
const BACK = dim("▚▚▚");
|
|
180
|
+
|
|
181
|
+
/** Three rows of little cards, side by side. */
|
|
182
|
+
export function cardRows(cards, { hole = false } = {}) {
|
|
183
|
+
const faces = cards.map((c, i) => (hole && i === 1 ? BACK : face(c)));
|
|
184
|
+
return [
|
|
185
|
+
faces.map(() => ash("┌───┐")).join(" "),
|
|
186
|
+
faces.map((f) => `${ash("│")}${f}${ash("│")}`).join(" "),
|
|
187
|
+
faces.map(() => ash("└───┘")).join(" "),
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const beside = (blocks, gap = " ") => blocks[0].map((_, i) => blocks.map((b) => b[i]).join(gap));
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The table is this wide, always. The footer is padded to it so the box does
|
|
195
|
+
* not breathe in and out between hands as the hint under it changes length —
|
|
196
|
+
* every other game in the arcade has a board of a fixed size, and this is how
|
|
197
|
+
* one made of cards gets one.
|
|
198
|
+
*/
|
|
199
|
+
const TABLE = 52;
|
|
200
|
+
|
|
201
|
+
export const BLACKJACK = {
|
|
202
|
+
key: "blackjack",
|
|
203
|
+
aliases: ["21", "bj", "twentyone"],
|
|
204
|
+
title: "BLACKJACK",
|
|
205
|
+
blurb: "hit, stand, double, split — dealer stands on 17",
|
|
206
|
+
keys: "h hit · s stand · d double · p split · enter next hand · ← → bet · q quit",
|
|
207
|
+
// Letters mean letters here: `h` is hit, not the vim left it is everywhere
|
|
208
|
+
// else in the arcade. Arrows still work, and this game only needs two.
|
|
209
|
+
vim: false,
|
|
210
|
+
// `r` is not a control in blackjack, so it only starts a new stack once this
|
|
211
|
+
// one is gone.
|
|
212
|
+
restartable: false,
|
|
213
|
+
|
|
214
|
+
create({ rng = Math.random } = {}) {
|
|
215
|
+
const state = {
|
|
216
|
+
deck: freshDeck(rng),
|
|
217
|
+
chips: START_CHIPS,
|
|
218
|
+
bet: 10,
|
|
219
|
+
hands: [],
|
|
220
|
+
dealer: [],
|
|
221
|
+
hole: true,
|
|
222
|
+
active: 0,
|
|
223
|
+
phase: "player",
|
|
224
|
+
message: "",
|
|
225
|
+
played: 0,
|
|
226
|
+
over: null,
|
|
227
|
+
rng,
|
|
228
|
+
};
|
|
229
|
+
return deal(state); // dealt and waiting on you before the frame lands
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
onKey(state, key) {
|
|
233
|
+
if (state.phase === "settled") {
|
|
234
|
+
if (key === "enter" || key === "space") return deal(state);
|
|
235
|
+
// Between hands the arrows are the chips: the only setting in the arcade,
|
|
236
|
+
// and it lives on the table rather than in a menu.
|
|
237
|
+
if (key === "left") state.bet = clampBet(Math.max(MIN_BET, state.bet - BET_STEP), state.chips);
|
|
238
|
+
if (key === "right") state.bet = clampBet(state.bet + BET_STEP, state.chips);
|
|
239
|
+
return state;
|
|
240
|
+
}
|
|
241
|
+
if (key === "h") return hit(state);
|
|
242
|
+
if (key === "s") { current(state).done = true; return advance(state); }
|
|
243
|
+
if (key === "d") return double(state);
|
|
244
|
+
if (key === "p") return split(state);
|
|
245
|
+
return state;
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
status(state) {
|
|
249
|
+
if (state.over) return `${state.over} · ${state.played} hands played`;
|
|
250
|
+
const staked = state.hands.reduce((sum, h) => sum + h.bet, 0);
|
|
251
|
+
return `chips ${state.chips} · ${state.phase === "settled" ? `next bet ${state.bet}` : `bet ${staked}`}`;
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
render(state) {
|
|
255
|
+
const shown = state.hole ? handValue(state.dealer.slice(0, 1)).total : handValue(state.dealer).total;
|
|
256
|
+
const dealerLabel = state.hole
|
|
257
|
+
? `${ash("dealer")} ${dim(`shows ${shown}`)}`
|
|
258
|
+
: `${ash("dealer")} ${bone(String(shown))}${shown > 21 ? danger(" bust") : ""}`;
|
|
259
|
+
|
|
260
|
+
const split = state.hands.length > 1;
|
|
261
|
+
const label = (hand, i) => {
|
|
262
|
+
const { total, soft } = handValue(hand.cards);
|
|
263
|
+
const live = split && state.phase === "player" && i === state.active;
|
|
264
|
+
const value = total > 21 ? danger(`${total} bust`) : acid(`${soft ? "soft " : ""}${total}`);
|
|
265
|
+
const bet = hand.doubled ? amber(` ·2× ${hand.bet}`) : "";
|
|
266
|
+
// The marker only exists when there is a second hand to point away from.
|
|
267
|
+
return `${live ? acid("▸") : split ? " " : ""}${ash(split ? `hand ${i + 1}` : "you")} ${value}${bet}`;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const footer = state.phase === "settled" && !state.over
|
|
271
|
+
? `enter deals the next hand · ← → sets the bet (${state.bet})`
|
|
272
|
+
: "blackjack pays 3:2 · dealer stands on 17";
|
|
273
|
+
return [
|
|
274
|
+
` ${dealerLabel}`,
|
|
275
|
+
...cardRows(state.dealer, { hole: state.hole }).map((r) => ` ${r}`),
|
|
276
|
+
"",
|
|
277
|
+
` ${state.hands.map(label).join(" ")}`,
|
|
278
|
+
...beside(state.hands.map((h) => cardRows(h.cards))).map((r) => ` ${r}`),
|
|
279
|
+
"",
|
|
280
|
+
` ${state.message || dim(state.phase === "player" ? "h hit · s stand · d double" : "")}`,
|
|
281
|
+
` ${dim(footer.padEnd(TABLE))}`,
|
|
282
|
+
];
|
|
283
|
+
},
|
|
284
|
+
};
|