@modelstatus/cli 0.1.39 → 0.1.41

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": "@modelstatus/cli",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "description": "Track which AI models you use, where, and never get surprised by a retirement. Free offline model-health for any repo (mm status), browser sign-in for cloud inventory + alerts.",
5
5
  "keywords": [
6
6
  "llm",
@@ -85,7 +85,7 @@ export function bonusForLevel(level) {
85
85
  return 5000 + level * 1000;
86
86
  }
87
87
 
88
- const SCHEMA = 2;
88
+ const SCHEMA = 3; // bumped: board contract now varies per level (N/slope/ladder placement)
89
89
 
90
90
  // ---- Level scaling (pure, tested monotonic) --------------------------------
91
91
  /** DK barrel-throw cadence in TICKS — faster (shorter) as you climb. */
@@ -121,16 +121,145 @@ export function boardSize(width, height) {
121
121
  return { BOARD_W, BOARD_H };
122
122
  }
123
123
 
124
- function buildSlope(baseRow, w, down, amp) {
124
+ function buildSlope(baseRow, w, down, amp, slopeEvery = SLOPE_EVERY) {
125
125
  const offs = new Array(w);
126
126
  for (let x = 0; x < w; x++) {
127
- const steps = Math.floor(x / SLOPE_EVERY);
127
+ const steps = Math.floor(x / slopeEvery);
128
128
  const off = down ? steps : -steps;
129
129
  offs[x] = baseRow + Math.max(-amp, Math.min(amp, off));
130
130
  }
131
131
  return offs;
132
132
  }
133
133
 
134
+ // ---- Per-level layout generator (PURE, deterministic) ----------------------
135
+ /* `levelLayout(level, BOARD_W, BOARD_H)` returns the few geometry knobs initGame
136
+ * reads instead of hardcoded values, keyed on k = (level-1) % 10 so levels 1-10
137
+ * are distinct and 11+ cycle (L11 === L1). It is a pure function of its three
138
+ * args only — no Math.random / Date.now / rngSeed — so the same (size, level)
139
+ * yields a byte-identical board on node and the bun binary (INVARIANT 4).
140
+ *
141
+ * Every knob is CLAMPED to the feasible window so it can never break an invariant:
142
+ * - N is clamped to [3, Nmax(BOARD_H)] (Nmax = largest N<=5 with gap>=GAP_MIN),
143
+ * so on the minimum 28x12 board every level collapses to N=3 (== today) and a
144
+ * small terminal never gets an unsafe extra girder. (INVARIANT 3.)
145
+ * - amp is only ever LOWERED below the existing floor((gap-1)/2) clamp, AND
146
+ * further capped so every ladder spans >=2 rows (gap-2*amp>=2 → amp<=(gap-2)/2)
147
+ * — so girders never cross (amp<gap/2, INVARIANT 2) and ladders are never the
148
+ * 1-row fragile latches the critic flagged (B3).
149
+ * - ladder columns are clamped to [2, BOARD_W-3] (double-wide [col,col+1] fits,
150
+ * INVARIANT 1) AND nudged so no two ladders' footprints overlap (>=2 apart),
151
+ * which the critic flagged would otherwise shadow a ladder in ladderAt (B2).
152
+ * - dkCol / princessCol clamped to [2, BOARD_W-3].
153
+ * The slope DIRECTION/cadence knobs (slopeDownFor, slopeEvery) only move points
154
+ * WITHIN the ±amp band, so no pattern can induce a crossing. Up-slope traversal
155
+ * (the critic's B1) is handled in stepPlaying's grounded branch (riser auto-mount).
156
+ */
157
+ const GAP_MIN = 4; // min girder spacing for the N-delta to take effect on tall boards
158
+ const clampInt = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
159
+ // Per-k knob tables (k = (level-1) % 10). All bounded; clamps make them safe.
160
+ const L_NDELTA = [0, 0, 1, 0, 1, 0, 2, 1, 0, 2]; // extra girders (capped at Nmax)
161
+ const L_AMPCAP = [2, 1, 2, 0, 2, 1, 2, 1, 2, 0]; // some levels flatter (amp 0/1)
162
+ const L_SLOPEEV = [6, 5, 7, 6, 4, 6, 8, 5, 6, 4]; // stair cadence (cosmetic)
163
+ const L_SLOPEPAT = [0, 0, 1, 2, 3, 4, 5, 6, 7, 1]; // slope-direction pattern id
164
+ const L_LADSTY = [0, 1, 0, 5, 4, 0, 0, 1, 5, 0]; // ladder placement style id
165
+ const L_STAGMAG = [3, 2, 4, 3, 5, 2, 3, 4, 2, 3]; // ladder stagger magnitude
166
+ const L_DKSTY = [0, 0, 1, 0, 2, 3, 1, 0, 2, 3]; // DK column style id (cosmetic)
167
+ const L_PRSTY = [0, 0, 1, 2, 0, 3, 4, 0, 5, 0]; // princess column style id
168
+
169
+ /** Largest girder count N in [3,5] whose even spacing gap>=GAP_MIN; else 3. The
170
+ * Math.max(3,…) floor (not the gap gate) is what guarantees the small board. */
171
+ function nMaxForHeight(BOARD_H) {
172
+ let best = 3;
173
+ for (let n = 3; n <= 5; n++) if (Math.floor((BOARD_H - 2) / n) >= GAP_MIN) best = n;
174
+ return best;
175
+ }
176
+ /** Per-girder slope direction (down-rightward) for a slope pattern. ANY value is
177
+ * safe (clamped to ±amp around distinct baseRows → never crosses). */
178
+ function slopeDownFor(pat, i) {
179
+ switch (pat) {
180
+ case 1: return i % 2 === 1; // alternating from odd (phase flip)
181
+ case 2: return true; // all-down
182
+ case 3: return false; // all-up
183
+ case 4: return Math.floor(i / 2) % 2 === 0; // zig-zag pairs
184
+ case 5: return true; // all-down (gentle via slopeEvery)
185
+ case 6: return false; // all-up
186
+ case 7: return i % 2 === 0; // alternating from even
187
+ case 0:
188
+ default: return i % 2 === 0; // alternating from even (classic)
189
+ }
190
+ }
191
+ export function levelLayout(level, BOARD_W, BOARD_H) {
192
+ const k = (((level - 1) % 10) + 10) % 10;
193
+ const Nmax = nMaxForHeight(BOARD_H);
194
+ const N = clampInt(3 + L_NDELTA[k], 3, Nmax);
195
+ const gap = Math.floor((BOARD_H - 2) / N);
196
+ // amp: never above today's floor((gap-1)/2); also cap so every ladder spans
197
+ // >=2 rows (gap - 2*amp >= 2 ⇒ amp <= (gap-2)/2). amp<gap/2 ⇒ no girder cross.
198
+ let amp = Math.max(0, Math.min(L_AMPCAP[k], Math.floor((gap - 1) / 2)));
199
+ amp = Math.max(0, Math.min(amp, Math.floor((gap - 2) / 2)));
200
+ const slopeEvery = L_SLOPEEV[k];
201
+ const pat = L_SLOPEPAT[k];
202
+ const sty = L_LADSTY[k];
203
+ const stag = L_STAGMAG[k];
204
+ const lo = 2, hi = BOARD_W - 3;
205
+
206
+ // Ladder columns: one per adjacent pair, per-level placement style, then nudged
207
+ // so no two footprints [col,col+1] overlap (>=2 apart) and all in [lo,hi].
208
+ const cols = [];
209
+ for (let i = 0; i < N - 1; i++) {
210
+ const frac = (i + 1) / N;
211
+ let base;
212
+ switch (sty) {
213
+ case 1: base = lo + 2 + i * 2 + (i % 2 ? 0 : 1); break; // clustered-left
214
+ case 2: base = hi - 2 - i * 2; break; // clustered-right
215
+ case 3: base = Math.floor(frac * BOARD_W) + (i % 2 ? stag : -stag); break; // reversed stagger
216
+ case 4: base = hi - 3 - (N - 2 - i) * 2 - (i % 2 ? stag : 0); break; // clustered-right staggered
217
+ case 5: base = (i % 2 === 0) ? lo + 1 + i : hi - 1 - i; break; // near-edges alternating
218
+ case 0:
219
+ default: base = Math.floor(frac * BOARD_W) + (i % 2 ? -stag : stag); // spread (classic)
220
+ }
221
+ cols.push(clampInt(base, lo, hi));
222
+ }
223
+ for (let i = 1; i < cols.length; i++) {
224
+ for (let j = 0; j < i; j++) {
225
+ let guard = 0;
226
+ while (Math.abs(cols[i] - cols[j]) < 2 && guard++ < 256) {
227
+ cols[i] = cols[i] + 2 <= hi ? cols[i] + 2 : cols[i] - 2;
228
+ if (cols[i] < lo) { cols[i] = lo; break; }
229
+ }
230
+ }
231
+ cols[i] = clampInt(cols[i], lo, hi);
232
+ }
233
+
234
+ let dkCol;
235
+ switch (L_DKSTY[k]) {
236
+ case 1: dkCol = BOARD_W - 3; break;
237
+ case 2: dkCol = BOARD_W - 4; break;
238
+ case 3: dkCol = 3; break;
239
+ case 0:
240
+ default: dkCol = 2;
241
+ }
242
+ const cen = Math.floor(BOARD_W / 2);
243
+ let princessCol;
244
+ switch (L_PRSTY[k]) {
245
+ case 1: princessCol = Math.floor(BOARD_W / 3); break;
246
+ case 2: princessCol = Math.floor((2 * BOARD_W) / 3); break;
247
+ case 3: princessCol = Math.floor(BOARD_W / 4); break;
248
+ case 4: princessCol = Math.floor((3 * BOARD_W) / 4); break;
249
+ case 5: princessCol = cen + 4; break;
250
+ case 0:
251
+ default: princessCol = cen;
252
+ }
253
+
254
+ return {
255
+ k, N, gap, amp, slopeEvery, slopePat: pat,
256
+ slopeDownFor: (i) => slopeDownFor(pat, i),
257
+ ladderCols: cols,
258
+ dkCol: clampInt(dkCol, lo, hi),
259
+ princessCol: clampInt(princessCol, lo, hi),
260
+ };
261
+ }
262
+
134
263
  /** Girder index whose standing row is at-or-just-below row y at column x. */
135
264
  function girderBelow(platforms, x, y) {
136
265
  let best = -1, bestRow = Infinity;
@@ -182,34 +311,40 @@ export function initGame({
182
311
  const { BOARD_W, BOARD_H } = boardSize(width, height);
183
312
  if (BOARD_W < MIN_W || BOARD_H < MIN_H) return { tooSmall: true, BOARD_W, BOARD_H };
184
313
 
185
- // Fewer girders bigger vertical gaps real jump headroom (a jump head-bonks
186
- // the girder overhead, so girders ~3 rows apart capped jumps at ~1 cell; ~4+
187
- // rows lets the higher JUMP_VY actually read as a tall jump).
188
- const N = Math.max(3, Math.min(4, Math.floor(BOARD_H / 4)));
189
- const gap = Math.floor((BOARD_H - 2) / N);
190
- const amp = Math.max(0, Math.min(2, Math.floor((gap - 1) / 2)));
314
+ // Per-level layout: a PURE function of (level, BOARD_W, BOARD_H) see
315
+ // levelLayout for the clamps that keep every knob within its invariant window.
316
+ // Fewer girders bigger vertical gaps real jump headroom (~4+ rows apart so
317
+ // the higher JUMP_VY reads as a tall jump); levelLayout's Nmax(BOARD_H) gate
318
+ // preserves that on small boards (N collapses to 3 exactly as the old gen did).
319
+ const layout = levelLayout(level, BOARD_W, BOARD_H);
320
+ const { N, gap, amp, slopeEvery } = layout;
191
321
  const platforms = [];
192
322
  for (let i = 0; i < N; i++) {
193
323
  const baseRow = BOARD_H - 2 - amp - i * gap; // 0 = bottom-most girder
194
- const down = i % 2 === 0;
195
- platforms.push({ row: baseRow, slopeOffsets: buildSlope(baseRow, BOARD_W, down, amp) });
324
+ const down = layout.slopeDownFor(i);
325
+ platforms.push({ row: baseRow, slopeOffsets: buildSlope(baseRow, BOARD_W, down, amp, slopeEvery) });
196
326
  }
197
327
  // platforms[0] is the LOWEST (largest row); platforms[N-1] the HIGHEST.
198
328
 
329
+ // One ladder per adjacent pair, IN ORDER (ladders[i] connects platforms[i]→[i+1])
330
+ // so the autopilot's ladders[gi] assumption holds verbatim. The column comes from
331
+ // the layout (already clamped to [2,BOARD_W-3] + footprint-deconflicted); the
332
+ // endpoints are recomputed from the slope AT THAT column so both rails touch both
333
+ // standing rows even on sloped girders.
199
334
  const ladders = [];
200
335
  for (let i = 0; i < N - 1; i++) {
201
336
  const lower = platforms[i];
202
337
  const upper = platforms[i + 1];
203
- const col = Math.max(2, Math.min(BOARD_W - 3, Math.floor(((i + 1) / N) * BOARD_W) + (i % 2 ? -3 : 3)));
338
+ const col = layout.ladderCols[i];
204
339
  const yBottom = lower.slopeOffsets[col] - 1;
205
340
  const yTop = upper.slopeOffsets[col] - 1;
206
341
  ladders.push({ col, yTop: Math.min(yTop, yBottom), yBottom: Math.max(yTop, yBottom) });
207
342
  }
208
343
 
209
344
  const top = platforms[N - 1];
210
- const dkCol = 2;
345
+ const dkCol = layout.dkCol;
211
346
  const donkeyKong = { x: dkCol, y: top.slopeOffsets[dkCol] - 1, throwCooldown: 2, animPhase: 0 };
212
- const princessCol = Math.floor(BOARD_W / 2);
347
+ const princessCol = layout.princessCol;
213
348
  const princess = { x: princessCol, y: top.slopeOffsets[princessCol] - 1, animPhase: 0 };
214
349
 
215
350
  const player = makePlayer(platforms);
@@ -498,10 +633,24 @@ function stepPlaying(s, input, dt) {
498
633
  }
499
634
  } else {
500
635
  // Grounded: follow the slope at the current column, detect walk-off.
636
+ // A sloped girder steps ±1 row every `slopeEvery` columns, so when walking
637
+ // INTO a riser the girder's stand row at the next column is one ABOVE the
638
+ // player's current cell (girderIndexAt at cell(py)+1 then misses → the
639
+ // player walks off the riser and falls — the critic's B1). Re-snap to the
640
+ // girder whose stand row is within ±1 of the player's cell so the player
641
+ // auto-mounts a one-row riser (and follows a one-row drop) instead of
642
+ // falling off. This is the same riser the player was already standing on
643
+ // (girders are >=2 rows apart, so at most one girder qualifies), so it
644
+ // never teleports between platforms — it just walks the staircase.
501
645
  const c = cell(p.px);
502
- const gi = girderIndexAt(platforms, c, cell(p.py) + 1);
646
+ const cy = cell(p.py);
647
+ // girder cell one below the player (flat / down-step → stand row stays cy).
648
+ let gi = girderIndexAt(platforms, c, cy + 1);
649
+ // up-step: the girder cell is AT the player's current row (stand row cy-1),
650
+ // so the next-column girder stepped up one — auto-mount it.
651
+ if (gi < 0) gi = girderIndexAt(platforms, c, cy);
503
652
  if (gi >= 0) {
504
- p.py = toFx(platforms[gi].slopeOffsets[c] - 1); // re-snap to slope
653
+ p.py = toFx(platforms[gi].slopeOffsets[c] - 1); // re-snap to slope (auto-mount the riser)
505
654
  p.vy = 0;
506
655
  } else {
507
656
  // walked off the edge → coyote window, begin falling
@@ -686,5 +835,5 @@ export function deserialize(obj) {
686
835
  // Expose internals for focused unit tests.
687
836
  export const _internals = {
688
837
  girderBelow, isGirderAt, girderIndexAt, ladderAt, rng, between,
689
- makePlayer, advanceBarrels, syncCell,
838
+ makePlayer, advanceBarrels, syncCell, levelLayout, nMaxForHeight,
690
839
  };
@@ -29,16 +29,38 @@ const G_ASCII = {
29
29
  };
30
30
  export const GAME_GLYPH = ASCII ? G_ASCII : G_UNICODE;
31
31
 
32
+ // Animation frames cycled by state.frame / entity fields so the board feels alive:
33
+ // barrels spin, the player bobs while walking/climbing + opens up airborne, the
34
+ // princess pulses a heart. ASCII variants stay pure-ASCII for the MM_ASCII path.
35
+ const PLAYER_FRAMES = ASCII ? ["P", "p"] : ["☻", "☺"];
36
+ const BARREL_FRAMES = ASCII ? ["o", "O", "0"] : ["◐", "◓", "◑", "◒"];
37
+ const PRINCESS_FRAMES = ASCII ? ["V", "v"] : ["♥", "♡"];
38
+ export const GAME_FRAMES = {
39
+ player: PLAYER_FRAMES, barrel: BARREL_FRAMES, princess: PRINCESS_FRAMES,
40
+ dk: [GAME_GLYPH.dk], girder: [GAME_GLYPH.girder], ladder: [GAME_GLYPH.ladder],
41
+ };
42
+
43
+ /** Animated player glyph from motion state + the global frame counter. */
44
+ function playerFrame(state) {
45
+ const p = state.jumpman || state.player;
46
+ const f = state.frame || 0;
47
+ if (!p) return PLAYER_FRAMES[0];
48
+ if (p.onLadder) return PLAYER_FRAMES[(f >> 2) & 1]; // climb bob
49
+ if (!p.onGround) return PLAYER_FRAMES[1]; // airborne
50
+ if (Math.abs(p.vx || 0) > 30) return PLAYER_FRAMES[(f >> 2) & 1]; // walk bob
51
+ return PLAYER_FRAMES[0]; // standing
52
+ }
53
+
32
54
  // Entity color keys (resolved against the C palette by the wrapper; kept here as
33
55
  // literal hex so the renderer stays dependency-free + the colorize output is
34
56
  // directly usable by tests without importing the design system).
35
57
  export const GAME_COLORS = {
36
- jumpman: "#22d3ee", // C.ACCENT
37
- barrel: "#ea580c", // retiring-orange
38
- girder: "#243042", // C.BORDER
39
- ladder: "#8b98a5", // C.FG_DIM
40
- dk: "#dc2626", // retired-red
41
- princess: "#a78bfa", // violet
58
+ jumpman: "#ef4444", // Mario red — pops off the board
59
+ barrel: "#f97316", // bright orange
60
+ girder: "#e0698c", // classic DK rose girders
61
+ ladder: "#38bdf8", // sky-blue ladders (cool contrast vs the warm board)
62
+ dk: "#c2630a", // gorilla amber-brown
63
+ princess: "#f472b6", // pink
42
64
  empty: "#5b6673", // C.FG_FAINT
43
65
  };
44
66
 
@@ -67,11 +89,12 @@ function buildGrid(state, glyph) {
67
89
  for (const l of ladders) {
68
90
  for (let y = l.yTop; y <= l.yBottom; y++) { put(l.col, y, glyph.ladder, "ladder"); put(l.col + 1, y, glyph.ladder, "ladder"); }
69
91
  }
70
- // goal + actors
71
- put(princess.x, princess.y, glyph.princess, "princess");
92
+ // goal + actors (animated: princess pulse, barrel spin, player bob)
93
+ const f = state.frame || 0;
94
+ put(princess.x, princess.y, PRINCESS_FRAMES[(f >> 4) & 1], "princess");
72
95
  put(donkeyKong.x, donkeyKong.y, glyph.dk, "dk");
73
- for (const b of barrels) put(b.x, b.y, glyph.barrel, "barrel");
74
- put(jumpman.x, jumpman.y, glyph.jumpman, "jumpman");
96
+ for (const b of barrels) put(b.x, b.y, BARREL_FRAMES[(b.spin >> 1) % BARREL_FRAMES.length], "barrel");
97
+ put(jumpman.x, jumpman.y, playerFrame(state), "jumpman");
75
98
  return grid;
76
99
  }
77
100
 
@@ -51,8 +51,14 @@ const COL = {
51
51
  green: packHex("#16a34a"),
52
52
  amber: packHex("#d97706"),
53
53
  violet: packHex("#a78bfa"),
54
+ pink: packHex("#f472b6"),
55
+ gold: packHex("#fbbf24"),
54
56
  };
55
57
 
58
+ // Win-celebration sparkles + a color-cycle palette (the flare on a level clear).
59
+ const WIN_SPARK = ASCII ? ["*", "+", ".", "'", "o"] : ["✦", "✧", "✺", "·", "♥"];
60
+ const WIN_PAL = [COL.pink, COL.gold, COL.accent, COL.green, COL.red, COL.violet];
61
+
56
62
  const fmtNum = (n) => (n == null ? "0" : Number(n).toLocaleString("en-US"));
57
63
 
58
64
  /**
@@ -213,7 +219,9 @@ export function runGame({ width, height, level = 1, scanStore = null, onExit, _i
213
219
  bb.blit(cells, 0, HUD_ROWS, cells.width, cells.height);
214
220
  // Respawn blink: hide the player glyph on alternate frames during invuln.
215
221
  maybeBlinkPlayer(bb, game, curW);
216
- drawBanner(bb, game, curW, curH);
222
+ // Level-clear gets the full celebration; everything else the plain banner.
223
+ if (game.status === "levelclear") drawWinFlare(bb, game, curW, curH);
224
+ else drawBanner(bb, game, curW, curH);
217
225
  drawKeybar(bb, curW, HUD_ROWS + curH);
218
226
  const s = bb.render();
219
227
  if (s) term.write(s);
@@ -323,6 +331,36 @@ function drawBanner(bb, game, w, boardH) {
323
331
  }
324
332
  }
325
333
 
334
+ /** Win celebration: a drifting confetti field + a color-cycling heart banner +
335
+ * the bonus tally. Drawn over the board during the levelclear window (the loop is
336
+ * actively ticking, so animating here is fine — it's not idle). All deterministic
337
+ * off game.frame (no RNG), so it's stable + cheap (the diff renderer only emits
338
+ * the cells that changed). */
339
+ function drawWinFlare(bb, game, w, boardH) {
340
+ const f = game.frame || 0;
341
+ // Confetti: a deterministic, gently-falling sparkle field across the board.
342
+ const count = Math.min(48, Math.max(16, Math.floor((w * boardH) / 10)));
343
+ for (let i = 0; i < count; i++) {
344
+ const col = (i * 17 + (i % 5)) % w;
345
+ const row = (i * 7 + (f >> 1)) % boardH; // drifts downward each frame
346
+ const ch = WIN_SPARK[(i + (f >> 2)) % WIN_SPARK.length];
347
+ const c = WIN_PAL[(i + (f >> 3)) % WIN_PAL.length];
348
+ bb.setCell(col, HUD_ROWS + row, ch.charCodeAt(0), c);
349
+ }
350
+ // Flashing centered banner with hearts.
351
+ const text = `${G.spark} ${G.heart} YOU SAVED HER! ${G.heart} ${G.spark}`;
352
+ const brow = HUD_ROWS + Math.floor(boardH / 2);
353
+ const bx = Math.max(0, Math.floor((w - text.length) / 2));
354
+ for (let cx = 0; cx < w; cx++) bb.setCell(cx, brow, 32, -1); // clear row for legibility
355
+ bb.setText(bx, brow, clip(text, w - bx), WIN_PAL[(f >> 2) % WIN_PAL.length]);
356
+ // Bonus tally sub-line.
357
+ const sub = `+100 bonus ${fmtNum(game.bonus || 0)} ${ASCII ? "->" : "→"} next level`;
358
+ const sy = Math.min(HUD_ROWS + boardH - 1, brow + 1);
359
+ const sx = Math.max(0, Math.floor((w - sub.length) / 2));
360
+ for (let cx = 0; cx < w; cx++) bb.setCell(cx, sy, 32, -1);
361
+ bb.setText(sx, sy, clip(sub, w - sx), COL.strong);
362
+ }
363
+
326
364
  /** Bottom row: in-game keybar. */
327
365
  function drawKeybar(bb, w, y) {
328
366
  const bar = ASCII
@@ -335,3 +373,6 @@ function clip(s, w) {
335
373
  if (w <= 0) return "";
336
374
  return s.length > w ? s.slice(0, w) : s;
337
375
  }
376
+
377
+ // Exposed for focused render tests (the win celebration + banners).
378
+ export const _internals = { drawWinFlare, drawBanner, drawStats, HUD_ROWS, KEY_ROW };