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,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
+ };
@@ -0,0 +1,186 @@
1
+ // Breakout. A wall, a paddle, and one ball that is always your fault.
2
+ //
3
+ // The bounce off the paddle is not a mirror: where the ball lands on the paddle
4
+ // decides the angle it leaves at, so the paddle is a steering wheel rather than
5
+ // a wall. Without that you cannot dig a channel up the side of the wall, and
6
+ // digging a channel is the entire reason anybody still plays this.
7
+ import { acid, amber, bone, danger, rgb } from "./ui.mjs";
8
+
9
+ export const WIDTH = 40;
10
+ export const HEIGHT = 17;
11
+
12
+ export const BRICK_W = 4;
13
+ export const BRICK_COLS = WIDTH / BRICK_W; // 10
14
+ export const BRICK_ROWS = 5;
15
+ export const BRICK_TOP = 1;
16
+
17
+ export const PADDLE_W = 7;
18
+ export const PADDLE_ROW = HEIGHT - 1;
19
+ const PADDLE_STEP = 2;
20
+
21
+ const LIVES = 3;
22
+ const BASE_VX = 0.62;
23
+ const BASE_VY = 0.34; // rows per tick — half of vx, because a row is two columns
24
+ const SPIN = 0.5;
25
+ const LEVEL_UP = 1.12;
26
+
27
+ /** Top rows are worth more, which is what makes the ball worth risking. */
28
+ export const ROW_POINTS = [50, 40, 30, 20, 10];
29
+ const ROW_COLOR = [danger, amber, acid, rgb(90, 200, 250), rgb(190, 130, 255)];
30
+
31
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
32
+
33
+ /** A full wall: every brick standing. */
34
+ export const buildWall = () => Array.from({ length: BRICK_ROWS }, () => Array.from({ length: BRICK_COLS }, () => true));
35
+
36
+ export const bricksLeft = (wall) => wall.reduce((n, row) => n + row.filter(Boolean).length, 0);
37
+
38
+ /** The brick under a cell, or null. */
39
+ export function brickAt(wall, x, y) {
40
+ const row = y - BRICK_TOP;
41
+ if (row < 0 || row >= BRICK_ROWS) return null;
42
+ const col = Math.floor(x / BRICK_W);
43
+ if (col < 0 || col >= BRICK_COLS || !wall[row]?.[col]) return null;
44
+ return { row, col };
45
+ }
46
+
47
+ /** The ball sitting on the paddle, waiting for space. */
48
+ function rest(state) {
49
+ state.ball = { x: state.paddle + PADDLE_W / 2, y: PADDLE_ROW - 1, vx: 0, vy: 0 };
50
+ state.stuck = true;
51
+ return state;
52
+ }
53
+
54
+ export function launch(state) {
55
+ if (!state.stuck) return state;
56
+ state.stuck = false;
57
+ state.ball.vx = (state.rng() < 0.5 ? -1 : 1) * BASE_VX * state.pace;
58
+ state.ball.vy = -BASE_VY * state.pace;
59
+ return state;
60
+ }
61
+
62
+ /** One tick. Exported so a test can clear a whole wall with no clock. */
63
+ export function step(state) {
64
+ if (state.stuck) {
65
+ // A ball that has not been launched rides the paddle, so moving before you
66
+ // serve aims the serve.
67
+ state.ball.x = state.paddle + PADDLE_W / 2;
68
+ return state;
69
+ }
70
+
71
+ const ball = state.ball;
72
+ const wasCol = Math.round(ball.x);
73
+ const wasRow = Math.round(ball.y);
74
+ ball.x += ball.vx;
75
+ ball.y += ball.vy;
76
+
77
+ if (ball.x < 0) { ball.x = -ball.x; ball.vx = Math.abs(ball.vx); }
78
+ if (ball.x > WIDTH - 1) { ball.x = 2 * (WIDTH - 1) - ball.x; ball.vx = -Math.abs(ball.vx); }
79
+ if (ball.y < 0) { ball.y = -ball.y; ball.vy = Math.abs(ball.vy); }
80
+
81
+ const col = Math.round(ball.x);
82
+ const row = Math.round(ball.y);
83
+ const brick = brickAt(state.wall, col, row);
84
+ if (brick) {
85
+ state.wall[brick.row][brick.col] = false;
86
+ state.score += ROW_POINTS[brick.row];
87
+ // Which way it bounces depends on which way it came in: through a row means
88
+ // the ball flips vertically, along a row means it flips sideways.
89
+ if (row !== wasRow) ball.vy = -ball.vy;
90
+ else if (col !== wasCol) ball.vx = -ball.vx;
91
+ else ball.vy = -ball.vy;
92
+ if (!bricksLeft(state.wall)) return cleared(state);
93
+ }
94
+
95
+ if (ball.vy > 0 && ball.y >= PADDLE_ROW - 1) {
96
+ const off = ball.x - (state.paddle + (PADDLE_W - 1) / 2);
97
+ if (Math.abs(off) <= PADDLE_W / 2 + 0.5) {
98
+ ball.y = PADDLE_ROW - 1;
99
+ ball.vy = -Math.abs(ball.vy);
100
+ // The steering wheel: the further out you take it, the flatter it leaves.
101
+ ball.vx = clamp(ball.vx + (off / (PADDLE_W / 2)) * SPIN * 0.5, -1.4, 1.4);
102
+ if (Math.abs(ball.vx) < 0.15) ball.vx = ball.vx < 0 ? -0.15 : 0.15;
103
+ }
104
+ }
105
+
106
+ if (ball.y > PADDLE_ROW) {
107
+ state.lives--;
108
+ if (state.lives <= 0) {
109
+ state.lives = 0;
110
+ state.over = `out of balls · ${state.score} points`;
111
+ return state;
112
+ }
113
+ rest(state);
114
+ }
115
+ return state;
116
+ }
117
+
118
+ function cleared(state) {
119
+ state.level++;
120
+ state.pace *= LEVEL_UP;
121
+ state.wall = buildWall();
122
+ state.score += 100;
123
+ return rest(state);
124
+ }
125
+
126
+ export const BREAKOUT = {
127
+ key: "breakout",
128
+ aliases: ["arkanoid", "wall"],
129
+ title: "BREAKOUT",
130
+ blurb: "dig a channel up the side and let the ball do the rest",
131
+ keys: "← → paddle · space launch · q quit",
132
+ tickMs: 50,
133
+
134
+ create({ rng = Math.random } = {}) {
135
+ const state = {
136
+ wall: buildWall(),
137
+ paddle: Math.floor((WIDTH - PADDLE_W) / 2),
138
+ score: 0,
139
+ lives: LIVES,
140
+ level: 1,
141
+ pace: 1,
142
+ over: null,
143
+ rng,
144
+ };
145
+ return rest(state);
146
+ },
147
+
148
+ tick: step,
149
+
150
+ onKey(state, key) {
151
+ if (key === "left") state.paddle = clamp(state.paddle - PADDLE_STEP, 0, WIDTH - PADDLE_W);
152
+ else if (key === "right") state.paddle = clamp(state.paddle + PADDLE_STEP, 0, WIDTH - PADDLE_W);
153
+ else if (key === "space" || key === "up" || key === "enter") launch(state);
154
+ return state;
155
+ },
156
+
157
+ status(state) {
158
+ return state.over
159
+ ? state.over
160
+ : `${state.score} · level ${state.level} · ${"●".repeat(state.lives)}`;
161
+ },
162
+
163
+ render(state) {
164
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
165
+ const put = (x, y, glyph) => {
166
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
167
+ grid[y][x] = glyph;
168
+ };
169
+
170
+ for (let row = 0; row < BRICK_ROWS; row++) {
171
+ for (let col = 0; col < BRICK_COLS; col++) {
172
+ if (!state.wall[row][col]) continue;
173
+ // A brick is drawn exactly as wide as it is hit, with a seam so the wall
174
+ // reads as bricks rather than as one solid slab.
175
+ for (let i = 0; i < BRICK_W; i++) {
176
+ put(col * BRICK_W + i, BRICK_TOP + row, ROW_COLOR[row](i === BRICK_W - 1 ? "▓" : "█"));
177
+ }
178
+ }
179
+ }
180
+
181
+ for (let i = 0; i < PADDLE_W; i++) put(state.paddle + i, PADDLE_ROW, bone("▀"));
182
+ put(Math.round(state.ball.x), Math.round(state.ball.y), state.stuck ? amber("●") : bone("●"));
183
+
184
+ return grid.map((row) => row.map((cell) => cell ?? " ").join(""));
185
+ },
186
+ };