moshcode 0.41.0 → 0.43.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 CHANGED
@@ -50,13 +50,15 @@ 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 — six games, no menus |
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 |
57
57
  | `moshcode trade` | tools | look up markets and trade through Alpaca |
58
58
  | `moshcode stocks` <br>`advisor` | tools | equity research from advis0r.com |
59
59
  | `moshcode crypto` <br>`coins` | tools | crypto market data from advis0r.com |
60
+ | `moshcode news` | tools | headlines from your feeds, or a search |
61
+ | `moshcode rss` | tools | read the same headlines in a full-screen reader |
60
62
  | `moshcode plugin` <br>`plugins` | extend | install moshcode's slash commands into Claude Code |
61
63
  | `moshcode commands` | script | list built-in moshscript commands |
62
64
  | `moshcode completion` | extend | print a shell completion script |
@@ -584,7 +586,7 @@ the composer URL instead.
584
586
 
585
587
  ## The arcade (`/games`)
586
588
 
587
- Six games, in the pit or straight from a shell. There are no menus, no options
589
+ Twenty-two games, in the pit or straight from a shell. There are no menus, no options
588
590
  screens and no difficulty prompts — `/games tetris` is already playing.
589
591
 
590
592
  ```sh
@@ -611,7 +613,23 @@ moshcode games --json # the roster, for a machine
611
613
  | `tetris` | stack the bricks, clear the lines, outrun gravity |
612
614
  | `snake` | eat, grow, and try not to eat yourself |
613
615
  | `pacman` | eat the dots, dodge the ghosts, `✳` makes them edible |
616
+ | `invaders` | forty of them, and the last one moves fastest |
617
+ | `centipede` | shoot it in the middle and now there are two of them |
618
+ | `asteroids` | turn, thrust, shoot — every rock you break becomes two |
619
+ | `breakout` | dig a channel up the side and let the ball do the rest |
620
+ | `pong` | first to seven, and the angle is all in where you hit it |
621
+ | `tank` | two tanks, one yard, five hits — line it up and let go |
622
+ | `digdug` | dig the tunnels, pump the monsters, drop rocks on the rest |
623
+ | `frogger` | the road kills what it touches, the river kills what it doesn't |
624
+ | `kong` | five girders, four ladders, and a barrel with your name on it |
625
+ | `pitfall` | jump the logs, swing the pits, and get the gold before dark |
626
+ | `choplifter` | fly out, land, fill the back, and get them home |
627
+ | `spyhunter` | keep it on the tarmac, shoot the ones shooting back |
628
+ | `outrun` | a road that bends, traffic that doesn't, and a clock that always wins |
629
+ | `excitebike` | turbo until it cooks, and land the way you took off |
630
+ | `stagedive` | run the barricade, hop the gear, duck the crowd, take the picks |
614
631
  | `tictactoe` | three in a row against an opponent that cannot be beaten |
632
+ | `blackjack` | hit, stand, double, split — dealer stands on 17, and pays 3:2 |
615
633
  | `chess` | full rules — castling, en passant, promotion — and it plays back |
616
634
  | `hangman` | six wrong letters and you are done for |
617
635
 
@@ -620,6 +638,10 @@ another, and the controls are written along the bottom of the game itself. Each
620
638
  draws in place rather than on the alternate screen, so the board you finished on
621
639
  stays in your scrollback.
622
640
 
641
+ The two games that read letters — `blackjack`, where `h` is hit, and `hangman`,
642
+ where it is a guess — keep their letters: `h j k l` are not arrows there, and `r`
643
+ only starts another once the game is over.
644
+
623
645
  Playing needs a real terminal, because they read single keypresses — `moshcode
624
646
  games` on its own lists them anywhere, including a pipe.
625
647
 
package/bin/moshcode.mjs CHANGED
@@ -394,6 +394,22 @@ async function main() {
394
394
  if (code) process.exitCode = code;
395
395
  return;
396
396
  }
397
+ if (cmd === "news") {
398
+ const { newsCommand } = await import("../src/news.mjs");
399
+ const code = await newsCommand(rest, {
400
+ openUrl: (url) => canOpenBrowser() && openBrowser(url),
401
+ });
402
+ if (code) process.exitCode = code;
403
+ return;
404
+ }
405
+ if (cmd === "rss") {
406
+ const { rssUi } = await import("../src/rss-ui.mjs");
407
+ const code = await rssUi(rest, {
408
+ openUrl: (url) => canOpenBrowser() && openBrowser(url),
409
+ });
410
+ if (code) process.exitCode = code;
411
+ return;
412
+ }
397
413
  if (cmd === "plugin" || cmd === "plugins") {
398
414
  const code = await pluginCommand(rest);
399
415
  if (code) process.exitCode = code;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.41.0",
3
+ "version": "0.43.0",
4
4
  "type": "module",
5
5
  "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
@@ -396,7 +396,7 @@ export const CORE_CLI_COMMANDS = [
396
396
  {
397
397
  name: "games",
398
398
  group: "arcade",
399
- description: "the moshcode arcade — six games, no menus",
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. "
@@ -511,6 +515,50 @@ export const CORE_CLI_COMMANDS = [
511
515
  note: "research aid, not advice — prices are Alpaca's US crypto venue alone and can differ materially from other exchanges. Crypto trades 24/7 with no circuit breakers. Set MOSHCODE_ADVISOR_URL to point at another instance.",
512
516
  },
513
517
  { name: "coins", aliasOf: "crypto", description: "alias for crypto" },
518
+ {
519
+ name: "news",
520
+ group: "tools",
521
+ description: "headlines from your feeds, or a search",
522
+ synopsis: [
523
+ ["moshcode news", "latest headlines across every subscribed feed"],
524
+ ["moshcode news <keyword…>", "search the news"],
525
+ ["moshcode news <verb> [args…]", ""],
526
+ ],
527
+ verbs: "NEWS_VERBS",
528
+ flags: [
529
+ ["--json", "structured output instead of headlines", ""],
530
+ ["--limit <n>", "how many headlines to show", "20"],
531
+ ["--feed <name>", "only this feed", "all of them"],
532
+ ["--timeout <sec>", "per-feed fetch timeout", "10"],
533
+ ],
534
+ examples: [
535
+ ["moshcode news", "everything you subscribe to, newest first"],
536
+ ["moshcode news openai earnings", "search Google News and Bing News"],
537
+ ["moshcode news https://example.com/feed.xml", "read one feed without subscribing"],
538
+ ["moshcode news add journalists", "pull in a public OPML bundle"],
539
+ ["moshcode news export > feeds.opml", "take the list to another reader"],
540
+ ],
541
+ seeAlso: ["rss", "stocks", "crypto"],
542
+ note: "subscriptions live in ~/.moshcode/news.opml (override with MOSHCODE_NEWS_OPML). "
543
+ + "With none, a default set is read instead — `moshcode news sources` lists it. "
544
+ + "The defaults and the search are the same sources brisk.news and advis0r.com use.",
545
+ },
546
+ {
547
+ name: "rss",
548
+ group: "tools",
549
+ description: "read the same headlines in a full-screen reader",
550
+ synopsis: [
551
+ ["moshcode rss", "open the reader on your feeds"],
552
+ ["moshcode rss <keyword…>", "open it on a search"],
553
+ ],
554
+ examples: [
555
+ ["moshcode rss", "feeds on the left, headlines in the middle"],
556
+ ["moshcode rss tariffs", "open straight into a search"],
557
+ ],
558
+ seeAlso: ["news"],
559
+ note: "needs an interactive terminal. ↑↓/jk move · ⏎ read · o open in a browser · "
560
+ + "tab the feed list · / search · r refresh · q quit.",
561
+ },
514
562
  {
515
563
  name: "plugin",
516
564
  group: "extend",
@@ -736,6 +784,31 @@ export const STOCKS_VERBS = [
736
784
  { name: "open", description: "open the shareable report page in a browser", synopsis: [["moshcode stocks open <symbol>", ""]] },
737
785
  ];
738
786
 
787
+ /**
788
+ * `news`'s verbs.
789
+ *
790
+ * `latest` earns its place the way crypto's `report` does: a bare argument is
791
+ * the shortcut — `moshcode news tariffs` searches — so the plain listing needs
792
+ * a spelling that a keyword cannot be mistaken for. src/news.mjs owns the
793
+ * parser and test/news.test.mjs fails when the two lists disagree.
794
+ */
795
+ export const NEWS_VERBS = [
796
+ { name: "latest", description: "headlines across every subscribed feed", synopsis: [["moshcode news latest", "same as `moshcode news`"]] },
797
+ {
798
+ name: "search", description: "search the news for a word or phrase",
799
+ synopsis: [["moshcode news search <keyword…>", "same as `moshcode news <keyword…>`"]],
800
+ },
801
+ { name: "list", description: "the feeds you are subscribed to", synopsis: [["moshcode news list [--json]", ""]] },
802
+ {
803
+ name: "add", description: "subscribe to a feed, an OPML list, or a bundle",
804
+ synopsis: [["moshcode news add <url|file|bundle>", "an RSS/Atom link, an OPML list, or journalists|web3|blockchain"]],
805
+ },
806
+ { name: "rm", description: "unsubscribe", synopsis: [["moshcode news rm <name|url>", ""]] },
807
+ { name: "open", description: "open a headline from the last listing", synopsis: [["moshcode news open <n>", ""]] },
808
+ { name: "sources", description: "the default feeds and the bundles on offer", synopsis: [["moshcode news sources", ""]] },
809
+ { name: "export", description: "print the subscription list as OPML", synopsis: [["moshcode news export > feeds.opml", ""]] },
810
+ ];
811
+
739
812
  /**
740
813
  * `crypto`'s verbs.
741
814
  *
@@ -884,6 +957,7 @@ export const VERB_TABLES = {
884
957
  TRADE_VERBS,
885
958
  STOCKS_VERBS,
886
959
  CRYPTO_VERBS,
960
+ NEWS_VERBS,
887
961
  PLUGIN_VERBS,
888
962
  };
889
963
 
@@ -927,10 +1001,14 @@ export const PIT_COMMANDS = [
927
1001
  description: "equity research from advis0r.com" },
928
1002
  { name: "crypto", aliases: ["coins"], args: "<pair|verb> [args…]", cli: "crypto",
929
1003
  description: "crypto market data from advis0r.com" },
1004
+ { name: "news", args: "[keyword…|verb] [args…]", cli: "news",
1005
+ description: "headlines from your feeds, or a search" },
1006
+ { name: "rss", aliases: ["reader"], cli: "rss",
1007
+ description: "read the same headlines in a full-screen reader" },
930
1008
  { name: "plugin", aliases: ["plugins"], args: "<verb> [name]", cli: "plugin",
931
1009
  description: "install moshcode's slash commands into Claude Code" },
932
1010
  { name: "games", aliases: ["game", "arcade", "play"], args: "[game]", cli: "games",
933
- description: "the arcade — tetris, snake, pac-man, tic-tac-toe, chess, hangman" },
1011
+ description: "the arcade — tetris, invaders, pac-man, frogger, kong, outrun, chess and more" },
934
1012
  { name: "socials", aliases: ["social"], pitOnly: true,
935
1013
  description: "list social networks available for posting" },
936
1014
  { 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
+ };