moshcode 0.44.0 → 0.46.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.44.0",
3
+ "version": "0.46.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": {
@@ -4,6 +4,7 @@
4
4
  // decides the angle it leaves at, so the paddle is a steering wheel rather than
5
5
  // a wall. Without that you cannot dig a channel up the side of the wall, and
6
6
  // digging a channel is the entire reason anybody still plays this.
7
+ import { ballCell } from "./games-draw.mjs";
7
8
  import { acid, amber, bone, danger, rgb } from "./ui.mjs";
8
9
 
9
10
  export const WIDTH = 40;
@@ -16,13 +17,31 @@ export const BRICK_TOP = 1;
16
17
 
17
18
  export const PADDLE_W = 7;
18
19
  export const PADDLE_ROW = HEIGHT - 1;
19
- const PADDLE_STEP = 2;
20
+ const PADDLE_STEP = 2; // a keypress, not a tick — unchanged by the tick rate
21
+
22
+ /**
23
+ * How often the wall is stepped. See the note in games-pong.mjs: the ball can
24
+ * only be drawn on whole cells, so smoothness comes from ticking often enough
25
+ * that the frames where it has not crossed into the next one go by too fast to
26
+ * read as a stall. Those frames are identical, and `runGame` does not write
27
+ * identical frames, so the extra ticks cost nothing on the wire.
28
+ *
29
+ * Ticking finer buys this game a second thing: at 0.34 rows a tick the ball
30
+ * used to cross a whole brick row between two samples, so which side it bounced
31
+ * off was a guess. It now samples inside every row it enters.
32
+ */
33
+ export const TICK_MS = 16;
34
+
35
+ /** The speeds below are still written per 50ms, the rate this was tuned at. */
36
+ const SCALE = TICK_MS / 50;
20
37
 
21
38
  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;
39
+ const BASE_VX = 0.62 * SCALE;
40
+ const BASE_VY = 0.34 * SCALE; // half of vx, because a row is two columns
41
+ const SPIN = 0.5 * SCALE;
42
+ const MAX_VX = 1.4 * SCALE;
43
+ const MIN_VX = 0.15 * SCALE; // never let it go vertical and unsteerable
44
+ const LEVEL_UP = 1.12; // a multiplier on pace, so it does not scale
26
45
 
27
46
  /** Top rows are worth more, which is what makes the ball worth risking. */
28
47
  export const ROW_POINTS = [50, 40, 30, 20, 10];
@@ -98,8 +117,8 @@ export function step(state) {
98
117
  ball.y = PADDLE_ROW - 1;
99
118
  ball.vy = -Math.abs(ball.vy);
100
119
  // 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;
120
+ ball.vx = clamp(ball.vx + (off / (PADDLE_W / 2)) * SPIN * 0.5, -MAX_VX, MAX_VX);
121
+ if (Math.abs(ball.vx) < MIN_VX) ball.vx = ball.vx < 0 ? -MIN_VX : MIN_VX;
103
122
  }
104
123
  }
105
124
 
@@ -129,7 +148,7 @@ export const BREAKOUT = {
129
148
  title: "BREAKOUT",
130
149
  blurb: "dig a channel up the side and let the ball do the rest",
131
150
  keys: "← → paddle · space launch · q quit",
132
- tickMs: 50,
151
+ tickMs: TICK_MS,
133
152
 
134
153
  create({ rng = Math.random } = {}) {
135
154
  const state = {
@@ -179,7 +198,10 @@ export const BREAKOUT = {
179
198
  }
180
199
 
181
200
  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("●"));
201
+ // Drawn on half-rows, so the ball steps the same distance down the wall as
202
+ // it does across it. See games-draw.mjs.
203
+ const ball = ballCell(state.ball.x, state.ball.y);
204
+ put(ball.col, ball.row, (state.stuck ? amber : bone)(ball.glyph));
183
205
 
184
206
  return grid.map((row) => row.map((cell) => cell ?? " ").join(""));
185
207
  },
@@ -0,0 +1,50 @@
1
+ // Shared drawing for the things in the arcade that move on a diagonal.
2
+ //
3
+ // A terminal cell is about twice as tall as it is wide, so a board drawn one
4
+ // object per cell has half the resolution going down that it has going across.
5
+ // For a ball that is not a rounding detail, it is the whole reason the motion
6
+ // looks wrong. Two things fall out of it, and both of them read as bad physics
7
+ // rather than as a coarse grid:
8
+ //
9
+ // A step sideways moves the ball one pixel and a step down moves it two, so the
10
+ // same ball appears to travel faster down the board than across it, and a
11
+ // trajectory the maths has at forty-five degrees is drawn at sixty.
12
+ //
13
+ // Worse, the two steps keep their own schedules. Crossing into the next column
14
+ // and crossing into the next row almost never land on the same tick, so a ball
15
+ // going diagonally does not move diagonally — it hops sideways, then a tick or
16
+ // two later hops down. Measured on pong, the ball holds a cell for four ticks,
17
+ // four ticks, then two and two as a row change splits one of those spans. That
18
+ // 4-4-2-2 stutter, three or four times a second, is what is left of the jiggle
19
+ // after the clock was fixed.
20
+ //
21
+ // Half blocks fix both. `▀` and `▄` each fill half a cell, which is close to
22
+ // square, so the ball is drawn on a grid with the same pitch both ways: it
23
+ // steps the same distance whichever way it goes, a row is two steps rather than
24
+ // one, and the corner it turns is half as wide. It is also a better ball than
25
+ // `●` was — a square pixel moving on a square grid, rather than a round dot
26
+ // snapping between cells twice its own height apart.
27
+
28
+ /**
29
+ * Where to draw a ball whose true position is (x, y) in board coordinates.
30
+ *
31
+ * `y` is a row centre, so row r covers y from r - 0.5 up to r + 0.5: below the
32
+ * centre the ball is in the top half of the cell, at or above it the bottom.
33
+ * Returns the cell to write into and the half block to write there.
34
+ */
35
+ export function ballCell(x, y) {
36
+ const col = Math.round(x);
37
+ const row = Math.round(y);
38
+ return { col, row, glyph: y < row ? "▀" : "▄" };
39
+ }
40
+
41
+ /**
42
+ * The ball's position in half-rows — the unit it is actually drawn in.
43
+ *
44
+ * Only the tests use this, to assert that a step down the board is the same
45
+ * size as a step across it.
46
+ */
47
+ export const halfRow = (y) => {
48
+ const row = Math.round(y);
49
+ return y < row ? row * 2 : row * 2 + 1;
50
+ };
@@ -7,6 +7,7 @@
7
7
  // A flat return it will always get; one taken off the end of your paddle it will
8
8
  // not. That is the whole game, and it is why the angle off the paddle depends on
9
9
  // where the ball hit it.
10
+ import { ballCell } from "./games-draw.mjs";
10
11
  import { acid, bone, danger, dim } from "./ui.mjs";
11
12
 
12
13
  export const WIDTH = 44;
@@ -20,11 +21,38 @@ export const YOU_COL = 2;
20
21
  export const THEM_COL = WIDTH - 3;
21
22
  export const TARGET = 7; // first to this many
22
23
 
23
- const SERVE_SPEED = 0.85;
24
- const MAX_SPEED = 1.7;
25
- const SPIN = 0.55; // how much the edge of the paddle bends the ball
26
- const THEM_SPEED = 0.3; // slow enough that a ball into the corner beats it
27
- const YOU_STEP = 1;
24
+ /**
25
+ * How often the table is stepped.
26
+ *
27
+ * The board is a grid of characters, so wherever the ball really is it can only
28
+ * ever be drawn on a whole cell — and what reads as smooth is not the size of
29
+ * that step but the evenness of it. At 55ms a ball crossing fifteen columns a
30
+ * second advances a cell on six ticks out of seven and stands still on the
31
+ * seventh, and that one stalled frame, arriving three times a second, is the
32
+ * jiggle. Ticking at 16ms does not move the ball anywhere different at any
33
+ * given moment; it shrinks the stall from 55ms to 16ms, which is under what the
34
+ * eye reads as a stop. It is close to free, too: a tick that leaves the ball in
35
+ * the same cell renders an identical frame, and `runGame` never writes one of
36
+ * those to the terminal.
37
+ */
38
+ export const TICK_MS = 16;
39
+
40
+ /**
41
+ * The speeds below are still written per 55ms — the rate this game was tuned
42
+ * at — and scaled to the tick. Keeping the tuned numbers legible matters more
43
+ * than saving a multiply: they are what makes the machine beatable off the end
44
+ * of the paddle and not from the middle, and that balance is the game.
45
+ */
46
+ const SCALE = TICK_MS / 55;
47
+
48
+ const SERVE_SPEED = 0.85 * SCALE;
49
+ const MAX_SPEED = 1.7 * SCALE;
50
+ const SPIN = 0.55 * SCALE; // how much the edge of the paddle bends the ball
51
+ const THEM_SPEED = 0.3 * SCALE; // slow enough that a ball into the corner beats it
52
+ const FLAT = 0.08 * SCALE; // below this a rally has gone flat
53
+ const NUDGE = 0.12 * SCALE; // and this is the angle it is put back at
54
+ const MAX_VY = 0.5 * SCALE;
55
+ const YOU_STEP = 1; // a keypress, not a tick — the same either way
28
56
 
29
57
  const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
30
58
 
@@ -35,7 +63,7 @@ export function serve(state, toward) {
35
63
  y: HEIGHT / 2,
36
64
  vx: toward * SERVE_SPEED,
37
65
  // Never dead flat: a ball with no angle is a rally nobody can lose.
38
- vy: (state.rng() < 0.5 ? -1 : 1) * (0.15 + state.rng() * 0.2),
66
+ vy: (state.rng() < 0.5 ? -1 : 1) * (0.15 + state.rng() * 0.2) * SCALE,
39
67
  };
40
68
  return state;
41
69
  }
@@ -52,10 +80,10 @@ const catches = (top, y) => y >= top - 0.5 && y <= top + PADDLE - 0.5;
52
80
  function returned(ball, top, dir) {
53
81
  const offset = (ball.y - (top + (PADDLE - 1) / 2)) / (PADDLE / 2);
54
82
  ball.vx = dir * Math.min(MAX_SPEED, Math.abs(ball.vx) * 1.06);
55
- ball.vy = clamp(offset * SPIN * ASPECT + ball.vy * 0.3, -0.5, 0.5);
83
+ ball.vy = clamp(offset * SPIN * ASPECT + ball.vy * 0.3, -MAX_VY, MAX_VY);
56
84
  // Never let a rally go flat. A ball with no angle is one the machine can park
57
85
  // in front of forever, and a rally that cannot end is not a game.
58
- if (Math.abs(ball.vy) < 0.08) ball.vy = (ball.vy < 0 ? -1 : 1) * 0.12;
86
+ if (Math.abs(ball.vy) < FLAT) ball.vy = (ball.vy < 0 ? -1 : 1) * NUDGE;
59
87
  return ball;
60
88
  }
61
89
 
@@ -102,7 +130,7 @@ export const PONG = {
102
130
  title: "PONG",
103
131
  blurb: "first to seven, and the angle is all in where you hit it",
104
132
  keys: "↑ ↓ move · q quit",
105
- tickMs: 55,
133
+ tickMs: TICK_MS,
106
134
 
107
135
  create({ rng = Math.random } = {}) {
108
136
  const state = {
@@ -137,7 +165,10 @@ export const PONG = {
137
165
 
138
166
  for (const row of paddleRows(state.you)) put(YOU_COL, row, acid("█"));
139
167
  for (const row of paddleRows(state.them)) put(THEM_COL, row, danger("█"));
140
- put(Math.round(state.ball.x), Math.round(state.ball.y), bone("●"));
168
+ // Drawn on half-rows, so the ball steps the same distance up the table as
169
+ // it does across it. See games-draw.mjs.
170
+ const ball = ballCell(state.ball.x, state.ball.y);
171
+ put(ball.col, ball.row, bone(ball.glyph));
141
172
 
142
173
  return grid.map((row, y) => row.map((cell, x) => (
143
174
  // The net, which is only there so the middle of the table has a middle.
package/src/games.mjs CHANGED
@@ -194,9 +194,20 @@ const ESC = {
194
194
  hideCursor: "\x1b[?25l",
195
195
  showCursor: "\x1b[?25h",
196
196
  up: (n) => (n > 0 ? `\x1b[${n}A` : ""),
197
+ down: (n) => (n > 0 ? `\x1b[${n}B` : ""),
197
198
  eraseDown: "\x1b[0J",
199
+ eraseLine: "\x1b[K",
198
200
  };
199
201
 
202
+ /**
203
+ * How many ticks a late clock is allowed to make up in one go.
204
+ *
205
+ * Enough to ride out a garbage collection or a busy event loop without the game
206
+ * quietly running slow; small enough that a laptop coming back from sleep
207
+ * resumes the game rather than fast-forwarding the ball across the board.
208
+ */
209
+ const CATCH_UP = 4;
210
+
200
211
  /**
201
212
  * Play one game until `q`.
202
213
  *
@@ -214,46 +225,115 @@ export async function runGame(game, deps = {}) {
214
225
  // turn, deterministically, with no timers left running after the assertion.
215
226
  setTimer = (fn, ms) => setTimeout(fn, ms),
216
227
  clearTimer = (t) => clearTimeout(t),
228
+ // The clock the tick deadline is measured against. Injectable for the same
229
+ // reason the timer is: a test should be able to say what time it is.
230
+ now = () => Date.now(),
217
231
  } = deps;
218
232
 
219
233
  const ctx = { rng };
220
234
  let state = game.create(ctx);
221
- let height = 0;
222
235
  let timer = null;
223
236
  let closed = false;
224
237
 
238
+ /** The lines currently on the screen, so a redraw can write only the changes. */
225
239
  let painted = null;
226
240
  const draw = () => {
227
241
  if (closed) return;
228
- const text = frame({
242
+ const lines = frame({
229
243
  title: game.title,
230
244
  status: game.status(state),
231
245
  rows: game.render(state),
232
246
  keys: state.over ? `${game.keys} · ${bone("r")} again` : game.keys,
233
- });
247
+ }).split("\n");
248
+
234
249
  // A frame identical to the one already on the screen is not written at all.
235
250
  // Chess idles on its clock while it is your move, and repainting the same
236
251
  // board twice a second is exactly the flicker that would make it feel busy.
237
- if (text === painted) return;
238
- output.write(`${ESC.up(height)}${ESC.eraseDown}${text}\n`);
239
- painted = text;
240
- height = text.split("\n").length;
252
+ // It is also what lets the fast games tick at 60Hz for free: a ball that has
253
+ // not crossed into a new cell yet produces the same frame, and the same
254
+ // frame costs nothing.
255
+ const same = painted?.length === lines.length && lines.every((l, i) => l === painted[i]);
256
+ if (same) return;
257
+
258
+ if (!painted || painted.length !== lines.length) {
259
+ // First frame, or one that changed height: there is nothing to diff
260
+ // against, so clear what was there and lay the whole thing down.
261
+ output.write(`${ESC.up(painted?.length ?? 0)}${ESC.eraseDown}${lines.join("\n")}\n`);
262
+ } else {
263
+ // Only the rows that actually changed are written. A pong ball crossing a
264
+ // cell touches two rows out of twenty-one, and repainting the other
265
+ // nineteen is both the blink you can see — a full repaint has to erase
266
+ // first, and for that instant the board is not there — and, over the
267
+ // pit's socket, twenty times the bytes standing between a tick and a
268
+ // moved ball. Rows are skipped with a cursor-down rather than a newline
269
+ // so that a frame sitting at the bottom of the screen cannot scroll it.
270
+ let out = ESC.up(lines.length);
271
+ let row = 0;
272
+ for (let i = 0; i < lines.length; i++) {
273
+ if (lines[i] === painted[i]) continue;
274
+ out += `${ESC.down(i - row)}\r${lines[i]}${ESC.eraseLine}`;
275
+ row = i;
276
+ }
277
+ output.write(`${out}${ESC.down(lines.length - row)}\r`);
278
+ }
279
+ painted = lines;
241
280
  };
242
281
 
282
+ const tickMs = () => (typeof game.tickMs === "function" ? game.tickMs(state) : game.tickMs);
243
283
  const stop = () => { if (timer !== null) { clearTimer(timer); timer = null; } };
244
- const schedule = () => {
284
+
285
+ /** When the next tick is due. Null means the clock is not running. */
286
+ let dueAt = null;
287
+
288
+ /**
289
+ * Arm the clock for the next tick, on its own deadline.
290
+ *
291
+ * Sleeping a full period from the moment the last tick *finished* is how a
292
+ * game ends up running slower than the rate it asked for: the work and the
293
+ * timer's own overshoot are added to every period, and the error accumulates.
294
+ * Counting from a deadline instead keeps the ball on real time.
295
+ */
296
+ const arm = () => {
245
297
  stop();
246
- if (!game.tickMs || state.over) return;
247
- const ms = typeof game.tickMs === "function" ? game.tickMs(state) : game.tickMs;
248
- timer = setTimer(() => {
249
- timer = null;
250
- if (closed || state.over) return;
251
- state = game.tick(state, ctx) || state;
252
- draw();
253
- schedule();
254
- }, ms);
298
+ if (!game.tickMs || state.over || closed) return;
299
+ if (dueAt === null) dueAt = now() + tickMs();
300
+ timer = setTimer(onTick, Math.max(0, dueAt - now()));
301
+ };
302
+
303
+ /**
304
+ * What a keypress is allowed to do to the clock: bring the next tick nearer,
305
+ * never push it away.
306
+ *
307
+ * Both halves matter. Chess runs its clock slowly while it is your move and
308
+ * quickly once it is the engine's, so the move you just made has to be able to
309
+ * pull the next tick forward or the reply arrives a beat late. But a key that
310
+ * could push the tick back is how the ball used to stall: a held arrow arrives
311
+ * as a burst of repeats, and re-arming on each one kept the next tick
312
+ * permanently a full period away, for as long as you held the key down.
313
+ */
314
+ const nudge = () => {
315
+ if (!game.tickMs || state.over || closed) return;
316
+ const soonest = now() + tickMs();
317
+ if (timer !== null && dueAt !== null && dueAt <= soonest) return;
318
+ dueAt = soonest;
319
+ arm();
255
320
  };
256
321
 
322
+ function onTick() {
323
+ timer = null;
324
+ if (closed || state.over) return;
325
+ const ms = tickMs();
326
+ // One step for the tick that just came due, plus any whole ticks the event
327
+ // loop was too busy to deliver, so a stall shows up as a jump rather than
328
+ // as the whole game quietly slowing down and speeding back up.
329
+ const late = Math.max(0, now() - dueAt);
330
+ const steps = Math.min(CATCH_UP, 1 + Math.floor(late / ms));
331
+ dueAt += steps * ms;
332
+ for (let i = 0; i < steps && !state.over; i++) state = game.tick(state, ctx) || state;
333
+ draw();
334
+ arm();
335
+ }
336
+
257
337
  const wasRaw = Boolean(input.isRaw);
258
338
  const restore = () => {
259
339
  if (closed) return;
@@ -271,16 +351,20 @@ export async function runGame(game, deps = {}) {
271
351
  if (key === "quit" || key === "q" || key === "escape") { restore(); resolve(); return; }
272
352
  if (key === "r" && (state.over || game.restartable !== false)) {
273
353
  state = game.create(ctx);
354
+ dueAt = null; // a new game starts its clock from now, not from the old one
274
355
  draw();
275
- schedule();
356
+ arm();
276
357
  continue;
277
358
  }
278
359
  if (state.over) continue; // a finished board takes r and q, nothing else
279
360
  state = game.onKey(state, key, ctx) || state;
280
361
  draw();
281
- // A key can end a real-time game (a hard drop into the ceiling) or start
282
- // one moving again, so the clock is re-armed off every keypress.
283
- if (game.tickMs) schedule();
362
+ // A key can end a real-time game a hard drop into the ceiling so the
363
+ // clock is stopped when that happens. Otherwise see `nudge`.
364
+ if (game.tickMs) {
365
+ if (state.over) stop();
366
+ else nudge();
367
+ }
284
368
  }
285
369
  }
286
370
 
@@ -296,7 +380,7 @@ export async function runGame(game, deps = {}) {
296
380
  process.on("SIGTERM", onSignal);
297
381
 
298
382
  draw();
299
- schedule();
383
+ arm();
300
384
  await done;
301
385
  restore();
302
386
  process.off("SIGINT", onSignal);