diff --git a/README.md b/README.md
index 5c7b42d..f078a9a 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,7 @@ or miss one that does. A test fails the build when it drifts.
| `moshcode doh` | hosting | run the DNS-over-HTTPS resolver |
| `moshcode site`
`serve` | hosting | install web-server config for a Moshpit name |
| `moshcode template`
`templates` | hosting | scaffold a stack for a Moshpit-hosted service |
-| `moshcode games`
`game` `arcade` | arcade | the moshcode arcade — six games, no menus |
+| `moshcode games`
`game` `arcade` | arcade | the moshcode arcade — eight games, no menus |
| `moshcode pwd`
`where` | system | show the current directory and git context |
| `moshcode engines` | engines | list engines and installation status |
| `moshcode tools` | tools | list workflow tools and installation status |
@@ -584,7 +584,7 @@ the composer URL instead.
## The arcade (`/games`)
-Six games, in the pit or straight from a shell. There are no menus, no options
+Eight games, in the pit or straight from a shell. There are no menus, no options
screens and no difficulty prompts — `/games tetris` is already playing.
```sh
@@ -611,7 +611,9 @@ moshcode games --json # the roster, for a machine
| `tetris` | stack the bricks, clear the lines, outrun gravity |
| `snake` | eat, grow, and try not to eat yourself |
| `pacman` | eat the dots, dodge the ghosts, `✳` makes them edible |
+| `asteroids` | turn, thrust, shoot — every rock you break becomes two |
| `tictactoe` | three in a row against an opponent that cannot be beaten |
+| `blackjack` | hit, stand, double, split — dealer stands on 17, and pays 3:2 |
| `chess` | full rules — castling, en passant, promotion — and it plays back |
| `hangman` | six wrong letters and you are done for |
@@ -620,6 +622,10 @@ another, and the controls are written along the bottom of the game itself. Each
draws in place rather than on the alternate screen, so the board you finished on
stays in your scrollback.
+The two games that read letters — `blackjack`, where `h` is hit, and `hangman`,
+where it is a guess — keep their letters: `h j k l` are not arrows there, and `r`
+only starts another once the game is over.
+
Playing needs a real terminal, because they read single keypresses — `moshcode
games` on its own lists them anywhere, including a pipe.
diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs
index 72ec2b1..7fae237 100644
--- a/src/cli-schema.mjs
+++ b/src/cli-schema.mjs
@@ -396,7 +396,7 @@ export const CORE_CLI_COMMANDS = [
{
name: "games",
group: "arcade",
- description: "the moshcode arcade — six games, no menus",
+ description: "the moshcode arcade — eight games, no menus",
synopsis: [
["moshcode games", "the cabinet, and what each one is"],
["moshcode games ", "play it, right here in the terminal"],
@@ -407,6 +407,8 @@ export const CORE_CLI_COMMANDS = [
["moshcode games tetris", ""],
["moshcode games chess", "real rules, and it plays back"],
["moshcode games pacman", "dots, ghosts, three lives"],
+ ["moshcode games asteroids", "turn, thrust, shoot"],
+ ["moshcode games 21", "blackjack, 100 chips, 3:2"],
],
seeAlso: ["help"],
note: "every game works the same way: arrows move, q quits, r starts another. "
@@ -930,7 +932,7 @@ export const PIT_COMMANDS = [
{ name: "plugin", aliases: ["plugins"], args: " [name]", cli: "plugin",
description: "install moshcode's slash commands into Claude Code" },
{ name: "games", aliases: ["game", "arcade", "play"], args: "[game]", cli: "games",
- description: "the arcade — tetris, snake, pac-man, tic-tac-toe, chess, hangman" },
+ description: "the arcade — tetris, snake, pac-man, asteroids, blackjack, chess and more" },
{ name: "socials", aliases: ["social"], pitOnly: true,
description: "list social networks available for posting" },
{ name: "post", args: ' "message"', pitOnly: true,
diff --git a/src/games-asteroids.mjs b/src/games-asteroids.mjs
new file mode 100644
index 0000000..f4fc5c2
--- /dev/null
+++ b/src/games-asteroids.mjs
@@ -0,0 +1,265 @@
+// Asteroids. Turn, thrust, shoot, and watch the rocks you shot become two
+// smaller rocks that are somehow worse.
+//
+// The one thing the 1979 cabinet did not have to worry about: a terminal cell is
+// about twice as tall as it is wide. Everything here measures distance in
+// columns, so a row counts double (see `span`) — without that, every rock is an
+// egg and the collisions land in places the screen never showed you.
+import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
+
+export const WIDTH = 44;
+export const HEIGHT = 18;
+
+/** A row is worth this many columns. Half of everything vertical follows. */
+export const ASPECT = 0.5;
+
+const TURN = Math.PI / 8; // 16 headings, which is as many as 8 glyphs can tell apart
+const THRUST = 0.14;
+const DRAG = 0.97; // space has none; a 55ms terminal very much needs some
+const MAX_SPEED = 1.15;
+const BULLET_SPEED = 1.6;
+const BULLET_LIFE = 22;
+const BULLETS = 4; // in flight at once — the arcade limit, and it is the game
+const COOLDOWN = 2;
+const INVULN = 30; // ticks of blinking after a life is lost
+const LIVES = 3;
+
+/** Radius in columns, and what a hit is worth. Small ones pay most. */
+export const ROCKS = {
+ 3: { r: 3.2, glyph: "█", points: 20, speed: 0.16 },
+ 2: { r: 2.1, glyph: "▓", points: 50, speed: 0.24 },
+ 1: { r: 1.1, glyph: "▒", points: 100, speed: 0.34 },
+};
+
+const HEADINGS = ["→", "↘", "↓", "↙", "←", "↖", "↑", "↗"];
+const FLAME = ["◄", "◤", "▲", "◥", "►", "◢", "▼", "◣"];
+
+export const wrap = (v, max) => ((v % max) + max) % max;
+
+/**
+ * The starfield: fixed, scattered, and free. Hashed from the coordinates rather
+ * than stored or rolled, so the sky is the same on every repaint — and hashed
+ * rather than `(x * 7 + y * 23) % 37`, which is a diagonal line, not a sky.
+ */
+export const star = (x, y) => ((((x * 73856093) ^ (y * 19349663)) >>> 4) % 53) === 0;
+
+/** The shorter way round a wrapping axis — the screen has no edges. */
+const delta = (a, b, max) => {
+ const d = a - b;
+ return d > max / 2 ? d - max : d < -max / 2 ? d + max : d;
+};
+
+/** Distance between two things, in columns, the short way round. */
+export function span(a, b) {
+ const dx = delta(a.x, b.x, WIDTH);
+ const dy = delta(a.y, b.y, HEIGHT) / ASPECT;
+ return Math.hypot(dx, dy);
+}
+
+const drift = (thing) => {
+ thing.x = wrap(thing.x + thing.vx, WIDTH);
+ thing.y = wrap(thing.y + thing.vy, HEIGHT);
+};
+
+/** A rock of `size`, moving somewhere, starting where it is put. */
+export function rock(size, x, y, rng) {
+ const angle = rng() * Math.PI * 2;
+ const speed = ROCKS[size].speed * (0.7 + rng() * 0.6);
+ return {
+ size, x, y,
+ vx: Math.cos(angle) * speed,
+ vy: Math.sin(angle) * speed * ASPECT,
+ };
+}
+
+/**
+ * A wave. Rocks arrive at the edges of the screen and never on top of the ship —
+ * spawning one on the ship is the cheapest way to make a game feel broken.
+ */
+export function spawnWave(wave, ship, rng) {
+ const rocks = [];
+ for (let i = 0; i < 3 + wave; i++) {
+ let x = 0;
+ let y = 0;
+ let tries = 0;
+ do {
+ x = rng() * WIDTH;
+ y = rng() * HEIGHT;
+ tries++;
+ } while (span({ x, y }, ship) < 14 && tries < 60);
+ rocks.push(rock(3, x, y, rng));
+ }
+ return rocks;
+}
+
+const newShip = () => ({ x: WIDTH / 2, y: HEIGHT / 2, vx: 0, vy: 0, angle: -Math.PI / 2 });
+
+/**
+ * One tick: everything moves, then everything that touched something else
+ * finds out. Exported so a test can fly a whole game without a clock.
+ */
+export function step(state) {
+ const { rng } = state;
+ const ship = state.ship;
+
+ ship.vx *= DRAG;
+ ship.vy *= DRAG;
+ const speed = Math.hypot(ship.vx, ship.vy / ASPECT);
+ if (speed > MAX_SPEED) {
+ ship.vx *= MAX_SPEED / speed;
+ ship.vy *= MAX_SPEED / speed;
+ }
+ drift(ship);
+
+ for (const bullet of state.bullets) { drift(bullet); bullet.life--; }
+ state.bullets = state.bullets.filter((b) => b.life > 0);
+ for (const r of state.rocks) drift(r);
+
+ if (state.cooldown > 0) state.cooldown--;
+ if (state.thrusting > 0) state.thrusting--;
+ if (state.invuln > 0) state.invuln--;
+
+ // Bullets first: a rock shot on the same tick it reaches you does not also
+ // get to take a life.
+ for (const bullet of [...state.bullets]) {
+ const hit = state.rocks.find((r) => span(bullet, r) <= ROCKS[r.size].r + 0.4);
+ if (!hit) continue;
+ state.bullets = state.bullets.filter((b) => b !== bullet);
+ state.rocks = state.rocks.filter((r) => r !== hit);
+ state.score += ROCKS[hit.size].points;
+ if (hit.size > 1) {
+ state.rocks.push(rock(hit.size - 1, hit.x, hit.y, rng), rock(hit.size - 1, hit.x, hit.y, rng));
+ }
+ }
+
+ if (!state.invuln) {
+ const struck = state.rocks.find((r) => span(ship, r) <= ROCKS[r.size].r + 0.9);
+ if (struck) {
+ state.lives--;
+ if (state.lives <= 0) {
+ state.lives = 0;
+ state.over = `wrecked on wave ${state.wave}`;
+ return state;
+ }
+ state.ship = newShip();
+ state.bullets = [];
+ state.invuln = INVULN;
+ // The rock that got you is pushed clear rather than deleted, so the
+ // respawn is not immediately fatal a second time.
+ struck.x = wrap(struck.x + WIDTH / 2, WIDTH);
+ struck.y = wrap(struck.y + HEIGHT / 2, HEIGHT);
+ }
+ }
+
+ if (!state.rocks.length) {
+ state.wave++;
+ state.rocks = spawnWave(state.wave, state.ship, rng);
+ state.invuln = Math.max(state.invuln, 12);
+ }
+ return state;
+}
+
+export const ASTEROIDS = {
+ key: "asteroids",
+ aliases: ["rocks", "asteroid"],
+ title: "ASTEROIDS",
+ blurb: "turn, thrust, shoot — every rock you break becomes two",
+ keys: "← → turn · ↑ thrust · ↓ retro · space fire · q quit",
+ tickMs: 55,
+
+ create({ rng = Math.random } = {}) {
+ const ship = newShip();
+ return {
+ ship,
+ bullets: [],
+ rocks: spawnWave(1, ship, rng),
+ score: 0,
+ lives: LIVES,
+ wave: 1,
+ cooldown: 0,
+ thrusting: 0,
+ invuln: INVULN,
+ over: null,
+ rng,
+ };
+ },
+
+ tick: step,
+
+ onKey(state, key) {
+ const ship = state.ship;
+ if (key === "left") ship.angle -= TURN;
+ else if (key === "right") ship.angle += TURN;
+ else if (key === "up" || key === "down") {
+ // Retro is half power, because a reverse as strong as the throttle turns
+ // the ship into something that cannot drift, and drifting is the game.
+ const push = key === "up" ? THRUST : -THRUST / 2;
+ ship.vx += Math.cos(ship.angle) * push;
+ ship.vy += Math.sin(ship.angle) * push * ASPECT;
+ if (key === "up") state.thrusting = 3;
+ } else if (key === "space" || key === "enter") {
+ if (state.cooldown > 0 || state.bullets.length >= BULLETS) return state;
+ state.bullets.push({
+ // Out of the nose, not the middle, or the ship shoots itself in the face
+ // at close range and the muzzle flash lands inside the hull.
+ x: wrap(ship.x + Math.cos(ship.angle) * 1.6, WIDTH),
+ y: wrap(ship.y + Math.sin(ship.angle) * 1.6 * ASPECT, HEIGHT),
+ vx: ship.vx + Math.cos(ship.angle) * BULLET_SPEED,
+ vy: ship.vy + Math.sin(ship.angle) * BULLET_SPEED * ASPECT,
+ life: BULLET_LIFE,
+ });
+ state.cooldown = COOLDOWN;
+ }
+ return state;
+ },
+
+ status(state) {
+ return state.over
+ ? `${state.over} · ${state.score} points`
+ : `score ${state.score} · wave ${state.wave} · ${"▲".repeat(state.lives)}`;
+ },
+
+ render(state) {
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
+ const put = (x, y, glyph) => {
+ const col = wrap(Math.round(x), WIDTH);
+ const row = wrap(Math.round(y), HEIGHT);
+ grid[row][col] = glyph;
+ };
+
+ for (const r of state.rocks) {
+ const { r: radius, glyph } = ROCKS[r.size];
+ const paint = r.size === 3 ? ash : r.size === 2 ? rgb(150, 158, 150) : rgb(190, 196, 188);
+ // A disc, measured in columns — hence the row doubling, which is the only
+ // reason these come out round instead of squashed.
+ for (let dy = -Math.ceil(radius * ASPECT); dy <= Math.ceil(radius * ASPECT); dy++) {
+ for (let dx = -Math.ceil(radius); dx <= Math.ceil(radius); dx++) {
+ if (Math.hypot(dx, dy / ASPECT) > radius) continue;
+ put(r.x + dx, r.y + dy, paint(glyph));
+ }
+ }
+ }
+
+ for (const bullet of state.bullets) put(bullet.x, bullet.y, amber("•"));
+
+ const heading = ((Math.round(state.ship.angle / (Math.PI / 4)) % 8) + 8) % 8;
+ if (state.thrusting) {
+ put(
+ state.ship.x - Math.cos(state.ship.angle) * 1.4,
+ state.ship.y - Math.sin(state.ship.angle) * 1.4 * ASPECT,
+ danger(FLAME[heading]),
+ );
+ }
+ if (!state.over) {
+ // Blinking is the only tell that the ship cannot be hit yet.
+ const shield = state.invuln && state.invuln % 6 < 3;
+ put(state.ship.x, state.ship.y, (shield ? bone : acid)(HEADINGS[heading]));
+ } else {
+ put(state.ship.x, state.ship.y, danger("✷"));
+ }
+
+ return grid.map((row, y) => row.map((cell, x) => (
+ cell ?? (star(x, y) ? dim("·") : " ")
+ )).join(""));
+ },
+};
diff --git a/src/games-blackjack.mjs b/src/games-blackjack.mjs
new file mode 100644
index 0000000..a03d5fb
--- /dev/null
+++ b/src/games-blackjack.mjs
@@ -0,0 +1,284 @@
+// Blackjack. One hand at a time against a dealer with no choices to make.
+//
+// House rules, printed here rather than in an options screen nobody reads:
+// dealer stands on all 17s, blackjack pays 3:2, double on any first two cards,
+// split a pair once, split aces get one card each. The stack is 100 chips and
+// the game is over when it is gone.
+import { acid, amber, ash, bone, danger, dim } from "./ui.mjs";
+
+export const SUITS = ["♠", "♥", "♦", "♣"];
+export const RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"];
+
+export const START_CHIPS = 100;
+export const MIN_BET = 5;
+export const BET_STEP = 5;
+const RESHUFFLE_AT = 15;
+
+/** A shuffled deck. Fisher–Yates, so the seeded rng in a test gives one deal. */
+export function freshDeck(rng = Math.random) {
+ const deck = [];
+ for (const suit of SUITS) for (const rank of RANKS) deck.push({ rank, suit });
+ for (let i = deck.length - 1; i > 0; i--) {
+ const j = Math.floor(rng() * (i + 1)) % (i + 1);
+ [deck[i], deck[j]] = [deck[j], deck[i]];
+ }
+ return deck;
+}
+
+/** Face value; an ace counts eleven here and is talked down by `handValue`. */
+export const cardValue = (card) => (card.rank === "A" ? 11 : ["J", "Q", "K"].includes(card.rank) ? 10 : Number(card.rank));
+
+/**
+ * The total, and whether an ace is still counting as eleven — which is the only
+ * thing that makes a 17 worth hitting.
+ */
+export function handValue(cards) {
+ let total = cards.reduce((sum, c) => sum + cardValue(c), 0);
+ let aces = cards.filter((c) => c.rank === "A").length;
+ while (total > 21 && aces > 0) { total -= 10; aces--; }
+ return { total, soft: aces > 0 };
+}
+
+/** Twenty-one on the first two cards, and nothing else. */
+export const isBlackjack = (cards) => cards.length === 2 && handValue(cards).total === 21;
+
+const clampBet = (bet, chips) => Math.max(Math.min(MIN_BET, chips), Math.min(bet, chips));
+
+function draw(state) {
+ if (!state.deck.length) state.deck = freshDeck(state.rng);
+ return state.deck.pop();
+}
+
+const newHand = (cards, bet) => ({ cards, bet, done: false, doubled: false, result: null, payout: 0 });
+
+/** Chips won or lost, the way a table would say it. */
+const signed = (n) => (n > 0 ? acid(`+${n}`) : n < 0 ? danger(`−${Math.abs(n)}`) : ash("even"));
+
+/**
+ * Start a hand. The wager leaves the stack now and comes back on settlement,
+ * so the chip count on screen is always what you could still walk away with.
+ */
+export function deal(state) {
+ if (state.chips <= 0) { state.over = `broke after ${state.played} hands`; return state; }
+ if (state.deck.length < RESHUFFLE_AT) state.deck = freshDeck(state.rng);
+
+ state.bet = clampBet(state.bet, state.chips);
+ state.chips -= state.bet;
+ state.hands = [newHand([draw(state), draw(state)], state.bet)];
+ state.dealer = [draw(state), draw(state)];
+ state.hole = true;
+ state.active = 0;
+ state.phase = "player";
+ state.message = "";
+
+ // A natural on either side ends it before anybody gets a decision.
+ if (isBlackjack(state.hands[0].cards) || isBlackjack(state.dealer)) settle(state);
+ return state;
+}
+
+/** The dealer's whole turn: show the hole card, then hit until 17. */
+function dealerPlays(state) {
+ state.hole = false;
+ // With every player hand busted there is nothing to beat, and the dealer does
+ // not draw for an audience.
+ if (!state.hands.some((h) => handValue(h.cards).total <= 21)) return;
+ // A natural is already paid; the dealer does not draw for it. Split hands are
+ // not naturals, so two 21s off a pair of aces still get a dealer's turn.
+ if (state.hands.length === 1 && isBlackjack(state.hands[0].cards)) return;
+ while (handValue(state.dealer).total < 17) state.dealer.push(draw(state));
+}
+
+/** Pay the hands out, and notice when the stack is gone. */
+export function settle(state) {
+ dealerPlays(state);
+ const dealer = handValue(state.dealer).total;
+ const dealerBJ = isBlackjack(state.dealer);
+ let net = 0;
+
+ for (const hand of state.hands) {
+ const total = handValue(hand.cards).total;
+ // A split hand that reaches 21 is twenty-one, not blackjack — it does not
+ // pay 3:2, which is the rule everybody's home version gets wrong.
+ const natural = isBlackjack(hand.cards) && state.hands.length === 1;
+ if (total > 21) { hand.result = "bust"; hand.payout = 0; }
+ else if (natural && !dealerBJ) { hand.result = "blackjack 🤘"; hand.payout = hand.bet + Math.floor(hand.bet * 1.5); }
+ else if (dealerBJ && !natural) { hand.result = "dealer blackjack"; hand.payout = 0; }
+ else if (dealer > 21) { hand.result = "dealer busts"; hand.payout = hand.bet * 2; }
+ else if (total > dealer) { hand.result = "you win"; hand.payout = hand.bet * 2; }
+ else if (total < dealer) { hand.result = "dealer wins"; hand.payout = 0; }
+ else { hand.result = "push"; hand.payout = hand.bet; }
+ state.chips += hand.payout;
+ net += hand.payout - hand.bet;
+ }
+
+ state.phase = "settled";
+ state.played++;
+ state.message = `${state.hands.map((h) => h.result).join(" · ")} ${signed(net)}`;
+ if (state.chips <= 0) state.over = `broke after ${state.played} hands`;
+ return state;
+}
+
+/** Move to the next hand with a decision left, or let the dealer answer. */
+function advance(state) {
+ const next = state.hands.findIndex((h, i) => i > state.active && !h.done);
+ if (next >= 0) { state.active = next; return state; }
+ if (state.hands.every((h) => h.done)) return settle(state);
+ return state;
+}
+
+const current = (state) => state.hands[state.active];
+
+export function hit(state) {
+ const hand = current(state);
+ hand.cards.push(draw(state));
+ // Twenty-one stands itself — there is no card that improves it, and asking is
+ // just a way to let someone bust a made hand by reflex.
+ if (handValue(hand.cards).total >= 21) hand.done = true;
+ return hand.done ? advance(state) : state;
+}
+
+export function double(state) {
+ const hand = current(state);
+ if (hand.cards.length !== 2 || state.chips < hand.bet) return state;
+ state.chips -= hand.bet;
+ hand.bet *= 2;
+ hand.doubled = true;
+ hand.cards.push(draw(state));
+ hand.done = true;
+ return advance(state);
+}
+
+export const canSplit = (state) => state.hands.length === 1
+ && current(state).cards.length === 2
+ && cardValue(current(state).cards[0]) === cardValue(current(state).cards[1])
+ && state.chips >= current(state).bet;
+
+export function split(state) {
+ if (!canSplit(state)) return state;
+ const [a, b] = current(state).cards;
+ const bet = current(state).bet;
+ state.chips -= bet;
+ state.hands = [newHand([a, draw(state)], bet), newHand([b, draw(state)], bet)];
+ state.active = 0;
+ // Split aces get one card each and that is the hand — the same deal every
+ // casino offers, and the reason splitting them is still worth it.
+ if (a.rank === "A") {
+ for (const hand of state.hands) hand.done = true;
+ return settle(state);
+ }
+ return state;
+}
+
+/* ------------------------------------------------------------------ render */
+
+const RED = ["♥", "♦"];
+const face = (card) => {
+ const label = `${card.rank}${card.suit}`.padStart(3);
+ return (RED.includes(card.suit) ? danger : bone)(label);
+};
+const BACK = dim("▚▚▚");
+
+/** Three rows of little cards, side by side. */
+export function cardRows(cards, { hole = false } = {}) {
+ const faces = cards.map((c, i) => (hole && i === 1 ? BACK : face(c)));
+ return [
+ faces.map(() => ash("┌───┐")).join(" "),
+ faces.map((f) => `${ash("│")}${f}${ash("│")}`).join(" "),
+ faces.map(() => ash("└───┘")).join(" "),
+ ];
+}
+
+const beside = (blocks, gap = " ") => blocks[0].map((_, i) => blocks.map((b) => b[i]).join(gap));
+
+/**
+ * The table is this wide, always. The footer is padded to it so the box does
+ * not breathe in and out between hands as the hint under it changes length —
+ * every other game in the arcade has a board of a fixed size, and this is how
+ * one made of cards gets one.
+ */
+const TABLE = 52;
+
+export const BLACKJACK = {
+ key: "blackjack",
+ aliases: ["21", "bj", "twentyone"],
+ title: "BLACKJACK",
+ blurb: "hit, stand, double, split — dealer stands on 17",
+ keys: "h hit · s stand · d double · p split · enter next hand · ← → bet · q quit",
+ // Letters mean letters here: `h` is hit, not the vim left it is everywhere
+ // else in the arcade. Arrows still work, and this game only needs two.
+ vim: false,
+ // `r` is not a control in blackjack, so it only starts a new stack once this
+ // one is gone.
+ restartable: false,
+
+ create({ rng = Math.random } = {}) {
+ const state = {
+ deck: freshDeck(rng),
+ chips: START_CHIPS,
+ bet: 10,
+ hands: [],
+ dealer: [],
+ hole: true,
+ active: 0,
+ phase: "player",
+ message: "",
+ played: 0,
+ over: null,
+ rng,
+ };
+ return deal(state); // dealt and waiting on you before the frame lands
+ },
+
+ onKey(state, key) {
+ if (state.phase === "settled") {
+ if (key === "enter" || key === "space") return deal(state);
+ // Between hands the arrows are the chips: the only setting in the arcade,
+ // and it lives on the table rather than in a menu.
+ if (key === "left") state.bet = clampBet(Math.max(MIN_BET, state.bet - BET_STEP), state.chips);
+ if (key === "right") state.bet = clampBet(state.bet + BET_STEP, state.chips);
+ return state;
+ }
+ if (key === "h") return hit(state);
+ if (key === "s") { current(state).done = true; return advance(state); }
+ if (key === "d") return double(state);
+ if (key === "p") return split(state);
+ return state;
+ },
+
+ status(state) {
+ if (state.over) return `${state.over} · ${state.played} hands played`;
+ const staked = state.hands.reduce((sum, h) => sum + h.bet, 0);
+ return `chips ${state.chips} · ${state.phase === "settled" ? `next bet ${state.bet}` : `bet ${staked}`}`;
+ },
+
+ render(state) {
+ const shown = state.hole ? handValue(state.dealer.slice(0, 1)).total : handValue(state.dealer).total;
+ const dealerLabel = state.hole
+ ? `${ash("dealer")} ${dim(`shows ${shown}`)}`
+ : `${ash("dealer")} ${bone(String(shown))}${shown > 21 ? danger(" bust") : ""}`;
+
+ const split = state.hands.length > 1;
+ const label = (hand, i) => {
+ const { total, soft } = handValue(hand.cards);
+ const live = split && state.phase === "player" && i === state.active;
+ const value = total > 21 ? danger(`${total} bust`) : acid(`${soft ? "soft " : ""}${total}`);
+ const bet = hand.doubled ? amber(` ·2× ${hand.bet}`) : "";
+ // The marker only exists when there is a second hand to point away from.
+ return `${live ? acid("▸") : split ? " " : ""}${ash(split ? `hand ${i + 1}` : "you")} ${value}${bet}`;
+ };
+
+ const footer = state.phase === "settled" && !state.over
+ ? `enter deals the next hand · ← → sets the bet (${state.bet})`
+ : "blackjack pays 3:2 · dealer stands on 17";
+ return [
+ ` ${dealerLabel}`,
+ ...cardRows(state.dealer, { hole: state.hole }).map((r) => ` ${r}`),
+ "",
+ ` ${state.hands.map(label).join(" ")}`,
+ ...beside(state.hands.map((h) => cardRows(h.cards))).map((r) => ` ${r}`),
+ "",
+ ` ${state.message || dim(state.phase === "player" ? "h hit · s stand · d double" : "")}`,
+ ` ${dim(footer.padEnd(TABLE))}`,
+ ];
+ },
+};
diff --git a/src/games-hangman.mjs b/src/games-hangman.mjs
index d2363da..8ddd937 100644
--- a/src/games-hangman.mjs
+++ b/src/games-hangman.mjs
@@ -54,7 +54,12 @@ export const HANGMAN = {
aliases: ["hang", "gallows"],
title: "HANGMAN",
blurb: "six wrong letters and you are done for",
- keys: "a–z guess · r new word · q quit",
+ keys: "a–z guess · r new word once it is over · q quit",
+ // Every letter has to reach the game: `h` is a guess, not the vim left it is
+ // in the games that read arrows, and `r` only restarts once the word is done.
+ // Without both, `refactor` was a word you could not spell at it.
+ vim: false,
+ restartable: false,
create({ rng = Math.random } = {}) {
return {
diff --git a/src/games.mjs b/src/games.mjs
index 7a912a5..551d71d 100644
--- a/src/games.mjs
+++ b/src/games.mjs
@@ -1,8 +1,8 @@
// The moshcode arcade — `/games` in the pit, `moshcode games` from a shell.
//
-// Six games, one frame. Every game here is the same shape (see GAME_SHAPE
+// Eight games, one frame. Every game here is the same shape (see GAME_SHAPE
// below) and is drawn by the same `frame()`, so they look like one arcade
-// rather than six weekend projects: a title, a status line, a boxed board, and
+// rather than eight weekend projects: a title, a status line, a boxed board, and
// one line of keys along the bottom. There is no menu, no options screen and no
// difficulty prompt — `/games tetris` is already playing by the time the frame
// lands, and `q` is always the way out.
@@ -18,6 +18,8 @@ import { PACMAN } from "./games-pacman.mjs";
import { TICTACTOE } from "./games-tictactoe.mjs";
import { HANGMAN } from "./games-hangman.mjs";
import { CHESS } from "./games-chess.mjs";
+import { ASTEROIDS } from "./games-asteroids.mjs";
+import { BLACKJACK } from "./games-blackjack.mjs";
/**
* @typedef {object} Game — the whole contract, so a seventh game is an import.
@@ -27,6 +29,8 @@ import { CHESS } from "./games-chess.mjs";
* @property {string} blurb one line, for /games list
* @property {string} keys the footer; the only place controls are ever explained
* @property {number|Function} [tickMs] real-time games only — a number, or (state) => number
+ * @property {boolean} [vim] false when a game wants h/j/k/l as letters, not arrows
+ * @property {boolean} [restartable] false when `r` is only a restart once the game is over
* @property {Function} create ({ rng }) => state
* @property {Function} onKey (state, key, { rng }) => state
* @property {Function} [tick] (state, { rng }) => state
@@ -35,7 +39,7 @@ import { CHESS } from "./games-chess.mjs";
*/
/** The cabinet. Order is the order `/games` lists them. */
-export const GAMES = [TETRIS, SNAKE, PACMAN, TICTACTOE, CHESS, HANGMAN];
+export const GAMES = [TETRIS, SNAKE, PACMAN, ASTEROIDS, TICTACTOE, BLACKJACK, CHESS, HANGMAN];
/** Games by name, following aliases. Case- and slash-insensitive. */
export function resolveGame(name) {
@@ -98,8 +102,12 @@ export function frame({ title = "", status = "", rows = [], keys = "" } = {}) {
* Games never see an escape sequence; they see "up", "enter", "a". A chunk can
* hold several keypresses (hold an arrow key down and they arrive in batches),
* which is why this returns a list.
+ *
+ * `vim: false` is for the games that read letters — hangman cannot ask for a
+ * word with an `h` in it while `h` means left, and blackjack wants `h` to be
+ * hit. Arrows are unaffected either way; they arrive as escape sequences.
*/
-export function decodeKeys(chunk) {
+export function decodeKeys(chunk, { vim = true } = {}) {
const input = String(chunk);
const keys = [];
for (let i = 0; i < input.length; i++) {
@@ -119,10 +127,10 @@ export function decodeKeys(chunk) {
if (c === "\x03" || c === "\x04") { keys.push("quit"); continue; }
if (c === "\x7f" || c === "\b") { keys.push("backspace"); continue; }
if (c === "\t") { keys.push("tab"); continue; }
- // vim keys, everywhere, for free — every game reads arrows, so mapping
- // hjkl here means no game has to know about them.
- const vim = { h: "left", j: "down", k: "up", l: "right" }[c];
- if (vim) { keys.push(vim); continue; }
+ // vim keys, everywhere, for free — every game that reads arrows gets them
+ // without knowing about them. A game that reads letters opts out.
+ const vimKey = vim ? { h: "left", j: "down", k: "up", l: "right" }[c] : null;
+ if (vimKey) { keys.push(vimKey); continue; }
if (c >= " " && c <= "~") keys.push(c.toLowerCase());
}
return keys;
@@ -241,7 +249,7 @@ export async function runGame(game, deps = {}) {
const onSignal = () => { restore(); process.exit(130); };
function onData(chunk) {
- for (const key of decodeKeys(chunk)) {
+ for (const key of decodeKeys(chunk, { vim: game.vim !== false })) {
if (key === "quit" || key === "q" || key === "escape") { restore(); resolve(); return; }
if (key === "r" && (state.over || game.restartable !== false)) {
state = game.create(ctx);
diff --git a/test/games.test.mjs b/test/games.test.mjs
index ccfd53a..de7b299 100644
--- a/test/games.test.mjs
+++ b/test/games.test.mjs
@@ -16,7 +16,13 @@ import {
} from "../src/games-tetris.mjs";
import { SNAKE, WIDTH as S_WIDTH, HEIGHT as S_HEIGHT, step } from "../src/games-snake.mjs";
import { PACMAN, MAZE, isWall, pellets, WIDTH as P_WIDTH, HEIGHT as P_HEIGHT } from "../src/games-pacman.mjs";
+import {
+ ASTEROIDS, ROCKS, rock, spawnWave, span, star, WIDTH as A_WIDTH, HEIGHT as A_HEIGHT,
+} from "../src/games-asteroids.mjs";
import { TICTACTOE, bestMove, emptyBoard as emptyGrid, winner } from "../src/games-tictactoe.mjs";
+import {
+ BLACKJACK, MIN_BET, canSplit, freshDeck, handValue, isBlackjack, settle,
+} from "../src/games-blackjack.mjs";
import { HANGMAN, MISSES_ALLOWED, WORDS, guess, mask } from "../src/games-hangman.mjs";
import {
CHESS, allMoves, apply, chooseMove, inCheck, legalMoves, name, outcome, parseBoard,
@@ -131,6 +137,21 @@ test("raw bytes become key names", () => {
assert.deepEqual(decodeKeys("\x1b[Z"), []);
});
+test("a game that reads letters gets its letters back", () => {
+ assert.deepEqual(decodeKeys("hjkl", { vim: false }), ["h", "j", "k", "l"]);
+ // Only the letters are given up — the arrows arrive as escape sequences and
+ // are unaffected, which is what lets blackjack use them for the bet.
+ assert.deepEqual(decodeKeys("\x1b[C\x1b[D", { vim: false }), ["right", "left"]);
+ assert.deepEqual(decodeKeys("\r ", { vim: false }), ["enter", "space"]);
+});
+
+test("the games that read letters have opted out of both intercepts", () => {
+ for (const game of GAMES.filter((g) => /a–z|hit/.test(g.keys))) {
+ assert.equal(game.vim, false, `${game.key} cannot see the letters h j k l`);
+ assert.equal(game.restartable, false, `${game.key} loses the letter r to the restart`);
+ }
+});
+
/* ------------------------------------------------------------------ tetris */
test("a shape turns without changing size", () => {
@@ -338,6 +359,122 @@ test("a ghost never steps into a wall", () => {
}
});
+/* --------------------------------------------------------------- asteroids */
+
+test("a wave arrives at full size, and never on top of the ship", () => {
+ const ship = { x: A_WIDTH / 2, y: A_HEIGHT / 2 };
+ for (let seed = 1; seed <= 25; seed++) {
+ const wave = spawnWave(3, ship, seeded(seed));
+ assert.equal(wave.length, 6, "3 + wave rocks");
+ for (const r of wave) {
+ assert.equal(r.size, 3, "a wave opens with whole rocks");
+ assert.ok(span(r, ship) >= 14, `a rock spawned ${span(r, ship).toFixed(1)} from the ship`);
+ }
+ }
+});
+
+test("the screen has no edges — everything wraps", () => {
+ const state = ASTEROIDS.create({ rng: seeded(3) });
+ state.rocks = [];
+ state.ship = { x: A_WIDTH - 0.5, y: 0.2, vx: 1, vy: -0.4, angle: 0 };
+ ASTEROIDS.tick(state);
+ assert.ok(state.ship.x < 2, "off the right edge and back on the left");
+ assert.ok(state.ship.y > A_HEIGHT - 2, "off the top and back on the bottom");
+ // And the short way round is the short way round: two things either side of
+ // the seam are close, not a screen apart.
+ assert.ok(span({ x: 0.5, y: 5 }, { x: A_WIDTH - 0.5, y: 5 }) < 2);
+});
+
+test("a shot rock becomes two smaller ones, and scores", () => {
+ const state = ASTEROIDS.create({ rng: seeded(5) });
+ state.rocks = [rock(3, 20, 9, seeded(2))];
+ state.rocks[0].vx = 0;
+ state.rocks[0].vy = 0;
+ state.bullets = [{ x: 20, y: 9, vx: 0, vy: 0, life: 5 }];
+ state.invuln = 999; // the ship is not what this test is about
+ ASTEROIDS.tick(state);
+ assert.equal(state.rocks.length, 2, "a big rock breaks in two");
+ assert.deepEqual(state.rocks.map((r) => r.size), [2, 2]);
+ assert.equal(state.score, ROCKS[3].points);
+ assert.equal(state.bullets.length, 0, "and the bullet is spent");
+
+ // Down to the smallest, which leaves nothing behind.
+ state.rocks = [{ ...rock(1, 20, 9, seeded(2)), vx: 0, vy: 0 }];
+ state.bullets = [{ x: 20, y: 9, vx: 0, vy: 0, life: 5 }];
+ ASTEROIDS.tick(state);
+ assert.equal(state.score, ROCKS[3].points + ROCKS[1].points, "small rocks pay most");
+});
+
+test("clearing the rocks brings the next wave", () => {
+ const state = ASTEROIDS.create({ rng: seeded(9) });
+ state.rocks = [];
+ ASTEROIDS.tick(state);
+ assert.equal(state.wave, 2);
+ assert.equal(state.rocks.length, 5, "a wave bigger than the last one");
+});
+
+test("a rock takes a life, and the last one ends the game", () => {
+ const state = ASTEROIDS.create({ rng: seeded(7) });
+ const sit = () => {
+ state.rocks = [{ ...rock(3, state.ship.x, state.ship.y, seeded(1)), vx: 0, vy: 0 }];
+ state.invuln = 0;
+ };
+ sit();
+ ASTEROIDS.tick(state);
+ assert.equal(state.lives, 2);
+ assert.ok(state.invuln > 0, "you get a moment to get out of the way");
+ assert.deepEqual([state.ship.x, state.ship.y], [A_WIDTH / 2, A_HEIGHT / 2], "and a fresh ship");
+
+ state.lives = 1;
+ sit();
+ ASTEROIDS.tick(state);
+ assert.match(state.over, /wrecked/);
+});
+
+test("the ship cannot be hit while it is blinking", () => {
+ const state = ASTEROIDS.create({ rng: seeded(11) });
+ state.rocks = [{ ...rock(3, state.ship.x, state.ship.y, seeded(1)), vx: 0, vy: 0 }];
+ ASTEROIDS.tick(state); // create() starts you invulnerable
+ assert.equal(state.lives, 3);
+});
+
+test("only four bullets are ever in the air", () => {
+ const state = ASTEROIDS.create({ rng: seeded(13) });
+ for (let i = 0; i < 20; i++) {
+ ASTEROIDS.onKey(state, "space");
+ state.cooldown = 0; // hammering the key, which is what everybody does
+ }
+ assert.equal(state.bullets.length, 4);
+ // And they do not fly forever, or the screen fills up with old shots.
+ for (let i = 0; i < 30; i++) ASTEROIDS.tick(state);
+ assert.equal(state.bullets.length, 0);
+});
+
+test("thrust moves the ship the way it is pointing", () => {
+ const state = ASTEROIDS.create({ rng: seeded(17) });
+ state.rocks = [];
+ state.ship.angle = 0; // due east
+ ASTEROIDS.onKey(state, "up");
+ assert.ok(state.ship.vx > 0 && Math.abs(state.ship.vy) < 1e-9);
+ const flatOut = state.ship.vx;
+ ASTEROIDS.onKey(state, "down");
+ assert.ok(state.ship.vx < flatOut, "retro slows you down");
+});
+
+test("the asteroids board is drawn to size", () => {
+ const state = ASTEROIDS.create({ rng: seeded(19) });
+ const rows = ASTEROIDS.render(state);
+ assert.equal(rows.length, A_HEIGHT);
+ for (const row of rows) assert.equal(visible(row), A_WIDTH, "a ragged row would tear the frame");
+ // The sky is scattered rather than striped — a diagonal is what you get from
+ // a linear function of x and y, and it reads as a bug on screen.
+ const byRow = new Set();
+ for (let y = 0; y < A_HEIGHT; y++) {
+ for (let x = 0; x < A_WIDTH; x++) if (star(x, y)) byRow.add(`${y}:${x}`);
+ }
+ assert.ok(byRow.size >= 8, "a sky with no stars in it");
+});
+
/* -------------------------------------------------------------- tictactoe */
test("three in a row is spotted in every direction", () => {
@@ -392,6 +529,183 @@ test("the cursor wraps rather than sticking to an edge", () => {
assert.equal(state.cursor, 8);
});
+/* -------------------------------------------------------------- blackjack */
+
+/** A hand, spelled the way a table would say it: "AS 8H" is an ace and an eight. */
+const cards = (spec) => spec.split(" ").map((c) => ({ rank: c.slice(0, -1), suit: c.slice(-1) }));
+
+/**
+ * A game holding exactly the cards this test wants. The deck is drawn from the
+ * end, so it is stacked back to front.
+ */
+function table(player, dealer, upcoming = "", chips = 100, bet = 10) {
+ const state = BLACKJACK.create({ rng: seeded(1) });
+ state.chips = chips - bet;
+ state.bet = bet;
+ state.hands = [{ cards: cards(player), bet, done: false, doubled: false, result: null, payout: 0 }];
+ state.dealer = cards(dealer);
+ state.deck = upcoming ? cards(upcoming).reverse() : freshDeck(seeded(2));
+ state.hole = true;
+ state.active = 0;
+ state.phase = "player";
+ state.message = "";
+ state.over = null;
+ return state;
+}
+
+test("aces count eleven until they cannot", () => {
+ assert.deepEqual(handValue(cards("A♠ 8♥")), { total: 19, soft: true });
+ assert.deepEqual(handValue(cards("A♠ 8♥ 5♣")), { total: 14, soft: false });
+ assert.deepEqual(handValue(cards("A♠ A♥ 9♣")), { total: 21, soft: true });
+ assert.deepEqual(handValue(cards("K♠ Q♥ J♣")), { total: 30, soft: false });
+ assert.equal(isBlackjack(cards("A♠ K♥")), true);
+ assert.equal(isBlackjack(cards("7♠ 7♥ 7♣")), false, "21 on three cards is not blackjack");
+});
+
+test("a deck is 52 different cards, however it is shuffled", () => {
+ const deck = freshDeck(seeded(5));
+ assert.equal(deck.length, 52);
+ assert.equal(new Set(deck.map((c) => `${c.rank}${c.suit}`)).size, 52);
+ assert.notDeepEqual(deck, freshDeck(seeded(9)), "two shuffles are not the same shuffle");
+});
+
+test("blackjack pays 3:2, and a pushed blackjack pays nothing", () => {
+ const state = table("A♠ K♥", "9♦ 8♣");
+ settle(state);
+ assert.match(state.hands[0].result, /blackjack/);
+ assert.equal(state.chips, 115, "the 10 back, plus 15");
+
+ const tie = table("A♠ K♥", "A♦ K♣");
+ settle(tie);
+ assert.equal(tie.hands[0].result, "push");
+ assert.equal(tie.chips, 100, "nothing won, nothing lost");
+});
+
+test("the dealer draws to 17 and stands on it, soft or not", () => {
+ const hard = table("K♠ 7♥", "9♦ 3♣", "4♥ 2♠");
+ settle(hard);
+ assert.equal(handValue(hard.dealer).total, 18, "12 → 16 → 18, and stop");
+ assert.equal(hard.hands[0].result, "dealer wins", "17 loses to 18");
+
+ const soft = table("K♠ 8♥", "A♦ 6♣");
+ settle(soft);
+ assert.equal(hard.hole, false, "the hole card is turned over either way");
+ assert.equal(soft.dealer.length, 2, "a soft 17 stands, house rules");
+ assert.equal(soft.hands[0].result, "you win");
+ assert.equal(soft.chips, 110);
+});
+
+test("a bust loses the bet, and the dealer does not bother drawing", () => {
+ const state = table("K♠ 8♥", "9♦ 3♣", "5♣");
+ BLACKJACK.onKey(state, "h");
+ assert.equal(handValue(state.hands[0].cards).total, 23);
+ assert.equal(state.hands[0].result, "bust");
+ assert.equal(state.dealer.length, 2, "nothing left to beat");
+ assert.equal(state.chips, 90, "the wager is gone");
+ assert.equal(state.phase, "settled");
+});
+
+test("twenty-one stands itself rather than waiting to be busted", () => {
+ const state = table("7♠ 6♥", "K♦ 9♣", "8♣");
+ BLACKJACK.onKey(state, "h");
+ assert.equal(state.phase, "settled", "21 does not get asked twice");
+ assert.equal(state.hands[0].result, "you win");
+});
+
+test("double takes one card, doubles the stake, and ends the hand", () => {
+ const state = table("6♠ 5♥", "K♦ 7♣", "9♥");
+ BLACKJACK.onKey(state, "d");
+ assert.equal(state.hands[0].cards.length, 3);
+ assert.equal(state.hands[0].bet, 20);
+ assert.equal(state.phase, "settled");
+ assert.equal(state.chips, 120, "20 up on a 20 wager");
+
+ // Three cards in, there is nothing to double.
+ const late = table("6♠ 5♥ 2♦", "K♦ 7♣");
+ const chips = late.chips;
+ BLACKJACK.onKey(late, "d");
+ assert.equal(late.hands[0].cards.length, 3);
+ assert.equal(late.chips, chips, "and nothing was staked on it");
+});
+
+test("a pair splits into two hands, each with its own bet", () => {
+ const state = table("8♠ 8♥", "K♦ 7♣", "3♥ 2♠");
+ assert.equal(canSplit(state), true);
+ BLACKJACK.onKey(state, "p");
+ assert.equal(state.hands.length, 2);
+ assert.deepEqual(state.hands.map((h) => h.cards.length), [2, 2]);
+ assert.deepEqual(state.hands.map((h) => h.bet), [10, 10]);
+ assert.equal(state.chips, 80, "two wagers on the table");
+ assert.equal(state.active, 0);
+
+ // Standing on the first hand moves to the second rather than to the dealer.
+ BLACKJACK.onKey(state, "s");
+ assert.equal(state.active, 1);
+ assert.equal(state.phase, "player");
+ BLACKJACK.onKey(state, "s");
+ assert.equal(state.phase, "settled");
+ assert.equal(state.hands.filter((h) => h.result).length, 2, "both hands are paid");
+});
+
+test("split aces get one card each, and 21 on them is not blackjack", () => {
+ const state = table("A♠ A♥", "K♦ 7♣", "K♥ Q♠");
+ BLACKJACK.onKey(state, "p");
+ assert.equal(state.phase, "settled", "no decisions on split aces");
+ assert.deepEqual(state.hands.map((h) => handValue(h.cards).total), [21, 21]);
+ assert.deepEqual(state.hands.map((h) => h.result), ["you win", "you win"]);
+ assert.equal(state.chips, 120, "paid 1:1 twice — not 3:2");
+});
+
+test("only a real pair splits, and only with the chips to back it", () => {
+ assert.equal(canSplit(table("8♠ 9♥", "K♦ 7♣")), false);
+ assert.equal(canSplit(table("K♠ Q♥", "K♦ 7♣")), true, "two tens is a pair at the table");
+ assert.equal(canSplit(table("8♠ 8♥", "K♦ 7♣", "", 10, 10)), false, "nothing left to stake");
+});
+
+test("between hands the arrows are the chips", () => {
+ const state = table("K♠ K♥", "9♦ 8♣", "2♣ 3♦ 4♥ 5♠");
+ settle(state);
+ assert.equal(state.phase, "settled");
+ BLACKJACK.onKey(state, "right");
+ assert.equal(state.bet, 15);
+ for (let i = 0; i < 10; i++) BLACKJACK.onKey(state, "left");
+ assert.equal(state.bet, MIN_BET, "and it does not go below the table minimum");
+ BLACKJACK.onKey(state, "enter");
+ assert.equal(state.phase, "player");
+ assert.equal(state.hands[0].cards.length, 2, "and the next hand is dealt");
+});
+
+test("the last of the stack ends it, and a bet is never more than you have", () => {
+ const broke = table("K♠ 5♥", "9♦ 8♣", "", 30, 30);
+ settle(broke); // 15 against a dealer 17 — the whole stack was on it
+ assert.equal(broke.chips, 0);
+ assert.match(broke.over, /broke/);
+
+ const short = table("K♠ K♥", "9♦ 8♣", "2♣ 3♦ 4♥ 5♠", 20, 20);
+ settle(short); // everything staked, and 40 back on the win
+ assert.equal(short.chips, 40);
+ short.bet = 500;
+ BLACKJACK.onKey(short, "enter");
+ assert.equal(short.hands[0].bet, 40, "you can only bet what you have");
+ assert.equal(short.chips, 0);
+});
+
+test("the table is dealt before the frame lands, and stays one size", () => {
+ const fresh = BLACKJACK.create({ rng: seeded(21) });
+ assert.equal(fresh.hands[0].cards.length, 2, "a hand is already out");
+ assert.equal(fresh.dealer.length, 2, "and so is the dealer's");
+
+ const width = (s) => Math.max(...BLACKJACK.render(s).map(visible));
+ const playing = table("K♠ 7♥", "9♦ 8♣");
+ const dealt = width(playing);
+ settle(playing);
+ assert.equal(width(playing), dealt, "the box must not breathe between hands");
+
+ // The hole card stays a hole card until the dealer plays.
+ assert.ok(strip(BLACKJACK.render(table("K♠ 7♥", "9♦ 8♣")).join("\n")).includes("▚▚▚"));
+ assert.ok(!strip(BLACKJACK.render(playing).join("\n")).includes("▚▚▚"), "and is turned over after");
+});
+
/* ---------------------------------------------------------------- hangman */
test("a right letter is revealed and a wrong one costs a limb", () => {
@@ -423,6 +737,16 @@ test("every hangman word is guessable with the keys the game offers", () => {
for (const word of WORDS) {
assert.match(word, /^[a-z]+$/, `"${word}" has a character nobody can type at it`);
}
+ // And "typeable" means all the way through the decoder and the driver's own
+ // keys: `h` used to arrive as a left arrow and `r` as a restart, which made
+ // `refactor` a word nobody could spell at the gallows.
+ for (const letter of "hjklr") {
+ const [key] = decodeKeys(letter, { vim: HANGMAN.vim !== false });
+ assert.equal(key, letter, `${letter} never reaches hangman`);
+ const state = { word: "hjklr", guessed: new Set(), missed: [], over: null };
+ HANGMAN.onKey(state, key);
+ assert.ok(state.guessed.has(letter), `${letter} was not taken as a guess`);
+ }
});
/* ------------------------------------------------------------------ chess */
@@ -603,6 +927,35 @@ test("an identical frame is not repainted", async () => {
await done;
});
+test("letters reach the games that read letters, all the way from the wire", async () => {
+ const { input, output } = fakeIO();
+ let state = null;
+ // The driver owns the decoder, so this is the only place the opt-out can be
+ // proved: `h` has to arrive as a hit rather than as a left arrow.
+ const spy = { ...BLACKJACK, onKey: (s, key, ctx) => { state = BLACKJACK.onKey(s, key, ctx); return state; } };
+ const done = runGame(spy, { input, output, rng: seeded(4) });
+ await new Promise((r) => setImmediate(r));
+ input.emit("data", "h");
+ assert.ok(state, "the keypress never arrived");
+ assert.ok(state.hands[0].cards.length >= 3 || state.phase === "settled", "h dealt no card");
+ input.emit("data", "q");
+ await done;
+});
+
+test("r is not a restart in a game where r is a letter", async () => {
+ const { input, output } = fakeIO();
+ let state = null;
+ const spy = { ...HANGMAN, onKey: (s, key, ctx) => { state = HANGMAN.onKey(s, key, ctx); return state; } };
+ const done = runGame(spy, { input, output, rng: seeded(3) });
+ await new Promise((r) => setImmediate(r));
+ input.emit("data", "r");
+ assert.ok(state, "r was eaten by the restart before hangman saw it");
+ // In the word or not, it was played as a guess.
+ assert.ok(state.guessed.has("r") || state.missed.includes("r"));
+ input.emit("data", "q");
+ await done;
+});
+
test("a real-time game runs on the clock it is given, and stops when it ends", async () => {
const { input, output } = fakeIO();
let fire = null;