@modelstatus/cli 0.1.34 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ /* Donkey Kong — thin Ink wrapper. h = React.createElement (NO JSX, per ui.js).
2
+ *
3
+ * Holds the game state + a synchronously-mutated inputRef (the stale-closure-safe
4
+ * pattern from useSearch / scan.js refIdxRef), runs the frame loop via useTick
5
+ * GATED so it NEVER ticks at idle, and composes a fixed-height block:
6
+ * row 0: scan-progress HUD (live scan fields → SweepBar) }
7
+ * row 1: game stats (♥ lives · score · level · best · message) } HUD_ROWS = 2
8
+ * rows 2..2+BOARD_H-1: the board (one colored <Text> per row)
9
+ * last row: in-game KeyBar
10
+ * Total rows = BOARD_H + 3 == the terminal `height` (boardSize math), so the
11
+ * surrounding window chrome never jumps and Ink keeps diffing.
12
+ *
13
+ * The component owns its own useInput (gated on `active` + `gameMode`) so it is
14
+ * independently mountable + testable; when embedded in ScanView the wrapper
15
+ * simply passes `active` through and relies on ui.setCapturing to stop the
16
+ * global app keys leaking. The SCAN is never touched — we only READ the hook
17
+ * fields the design surfaces (scan.filesScanned / scan.candidates.length /
18
+ * scan.phase). */
19
+ import React from "react";
20
+ import { Box, Text, useInput } from "ink";
21
+ import { h, C, LIGHT, GLYPH, KeyBar, SweepBar, useTick, cell, fmtNum } from "../ui.js";
22
+ import { GAME_GLYPH, GAME_COLORS, colorize } from "./dk-render.js";
23
+ import { initGame, stepGame, nextLevel, FRAME_MS, boardSize, MIN_W, MIN_H } from "./dk-core.js";
24
+
25
+ const BG_ON = !(process.env.MM_ASCII === "1" || process.env.TERM === "dumb") && process.env.NO_COLOR == null;
26
+
27
+ const GAME_KEYS = [
28
+ { k: "←→", label: "move" },
29
+ { k: "↑↓", label: "climb" },
30
+ { k: "spc", label: "jump" },
31
+ { k: "p", label: "pause" },
32
+ { k: "r", label: "restart" },
33
+ { k: "q", label: "back to scan" },
34
+ ];
35
+
36
+ const freshInput = () => ({ left: false, right: false, up: false, down: false, jump: false });
37
+
38
+ /**
39
+ * DonkeyKong overlay.
40
+ * props:
41
+ * - width, height: the SAME numbers ScanView gets (drive boardSize)
42
+ * - scan: the useStreamingScan return (read-only — filesScanned, candidates, phase)
43
+ * - ui: shell helpers ({ showToast, setCapturing, setHandlesBack })
44
+ * - onExit: called when the player quits the game (q / esc / backspace)
45
+ * - active: input gate (mirrors ScanView's `active`)
46
+ * - level: starting level (default 1)
47
+ */
48
+ export function DonkeyKong({ width = 78, height = 14, scan = {}, ui = {}, onExit = () => {}, active = true, level = 1 }) {
49
+ const dims = { width, height };
50
+ const { BOARD_W, BOARD_H } = boardSize(width, height);
51
+ const playable = BOARD_W >= MIN_W && BOARD_H >= MIN_H;
52
+
53
+ const [game, setGame] = React.useState(() => initGame({ ...dims, level }));
54
+ const [paused, setPaused] = React.useState(false);
55
+ const pausedRef = React.useRef(false);
56
+ const inputRef = React.useRef(freshInput());
57
+ const highRef = React.useRef(0);
58
+
59
+ // Track the running session high score (kept in a ref so it survives restarts
60
+ // within this mount; shown in the HUD).
61
+ if (game && game.score > highRef.current) highRef.current = game.score;
62
+
63
+ const scanLive = scan.phase ? scan.phase !== "done" : true;
64
+ // The loop NEVER ticks at idle: only while (a) the overlay is up [it's mounted
65
+ // = up], (b) not paused, (c) the game isn't on a terminal frozen frame, and
66
+ // (d) the scan is still live (or just finishing). Per the design's idle rule.
67
+ const playing = game && game.status === "playing";
68
+ const ticking = playable && active && !paused && playing && (scanLive || game.frame < 1);
69
+ const tick = useTick(FRAME_MS, ticking);
70
+
71
+ // One advance per frame; read + clear the synchronous input ref each tick so a
72
+ // keypress in the same tick is seen (and isn't applied twice).
73
+ React.useEffect(() => {
74
+ if (!ticking) return;
75
+ setGame((g) => {
76
+ const ng = stepGame(g, { input: inputRef.current });
77
+ return ng;
78
+ });
79
+ inputRef.current = freshInput();
80
+ // eslint-disable-next-line react-hooks/exhaustive-deps
81
+ }, [tick]);
82
+
83
+ // Auto level-up a beat after a win: hold the "you saved her" frame for ~1s,
84
+ // then rebuild the next level carrying score + lives.
85
+ React.useEffect(() => {
86
+ if (game && game.status === "won") {
87
+ const t = setTimeout(() => setGame((g) => (g.status === "won" ? nextLevel(g, dims) : g)), 1000);
88
+ return () => clearTimeout(t);
89
+ }
90
+ return undefined;
91
+ }, [game && game.status, game && game.level]); // eslint-disable-line react-hooks/exhaustive-deps
92
+
93
+ function exit() {
94
+ ui.setCapturing?.(false);
95
+ ui.setHandlesBack?.(false);
96
+ onExit();
97
+ }
98
+
99
+ // Single input owner for the overlay. q / esc / backspace exit; r restarts;
100
+ // p toggles ONLY the game loop (the scan keeps running). Movement keys set the
101
+ // synchronous intent flags consumed next tick. Ctrl-C is NOT swallowed.
102
+ useInput(
103
+ (input, key) => {
104
+ if (!active) return;
105
+ if (key.ctrl && input === "c") return; // let the global handler exit the app
106
+ if (input === "q" || key.escape || key.backspace || key.delete) return exit();
107
+ if (input === "r") { setGame(initGame({ ...dims, level: 1 })); inputRef.current = freshInput(); return; }
108
+ if (input === "p") { pausedRef.current = !pausedRef.current; setPaused(pausedRef.current); return; }
109
+ const ir = inputRef.current;
110
+ if (key.leftArrow || input === "h") ir.left = true;
111
+ else if (key.rightArrow || input === "l") ir.right = true;
112
+ else if (key.upArrow || input === "k") ir.up = true;
113
+ else if (key.downArrow || input === "j") ir.down = true;
114
+ else if (input === " ") ir.jump = true;
115
+ },
116
+ { isActive: active },
117
+ );
118
+
119
+ // --- too-small guard: a single line, no loop, nothing animates -----------
120
+ if (!playable) {
121
+ return h(
122
+ Box,
123
+ { flexDirection: "column" },
124
+ h(Text, { color: "#d97706" }, " terminal too small for the game — resize to ~30x12, q to go back"),
125
+ );
126
+ }
127
+
128
+ // ---- HUD row 1: live scan progress ----
129
+ const files = scan.filesScanned || 0;
130
+ const models = (scan.candidates && scan.candidates.length) || 0;
131
+ const dirs = scan.dirsSeen || 0;
132
+ const barW = Math.max(10, Math.min(22, BOARD_W - 28));
133
+ // SweepBar renders a <Box>, which can't nest inside a <Text> — so the live HUD
134
+ // row is a flex <Box> with the bar as a sibling of the text spans.
135
+ const hud1 = scanLive
136
+ ? h(
137
+ Box,
138
+ { flexDirection: "row" },
139
+ h(Text, { color: C.FG_FAINT }, " scanning "),
140
+ h(SweepBar, { tick, width: barW }),
141
+ h(Text, { color: C.FG_DIM }, ` files ${fmtNum(files)} · models ${fmtNum(models)} · dirs ${fmtNum(dirs)}`),
142
+ )
143
+ : h(
144
+ Text,
145
+ {},
146
+ h(Text, { color: "#16a34a" }, ` ${GLYPH.check} scan complete`),
147
+ h(Text, { color: C.FG_DIM }, ` · ${fmtNum(models)} models · q to view results`),
148
+ );
149
+
150
+ // ---- HUD row 2: game stats + transient message ----
151
+ const livesStr = "♥".repeat(Math.max(0, game.lives)) || "—";
152
+ const statusTag =
153
+ game.status === "over" ? ` ${game.message} · r restart · q back`
154
+ : game.status === "won" ? ` ${GLYPH.spark} ${game.message}`
155
+ : game.message ? ` ${game.message}` : "";
156
+ const hud2 = h(
157
+ Text,
158
+ {},
159
+ h(Text, { color: LIGHT.red }, ` ${livesStr}`),
160
+ h(Text, { color: C.FG_DIM }, " score "),
161
+ h(Text, { color: C.ACCENT, bold: true }, fmtNum(game.score)),
162
+ h(Text, { color: C.FG_DIM }, ` lvl ${game.level}` + (paused ? " ⏸ paused" : "")),
163
+ h(Text, { color: C.FG_FAINT }, ` best ${fmtNum(Math.max(highRef.current, game.score))}`),
164
+ h(Text, { color: game.status === "over" ? "#dc2626" : C.FG_STRONG }, statusTag),
165
+ );
166
+
167
+ // ---- board: one colored <Text> per row, padded to BOARD_W ----
168
+ const useColor = BG_ON;
169
+ const spanRows = colorize(game, { glyph: GAME_GLYPH, colors: GAME_COLORS });
170
+ const boardNodes = spanRows.map((spans, y) =>
171
+ h(
172
+ Text,
173
+ { key: "b" + y, wrap: "truncate" },
174
+ ...(useColor
175
+ ? spans.map((sp, i) => h(Text, { key: i, color: sp.color }, sp.text))
176
+ : [h(Text, { key: 0, color: C.FG }, spans.map((sp) => sp.text).join("").slice(0, BOARD_W).padEnd(BOARD_W))]),
177
+ ),
178
+ );
179
+ // Defensive height pin: render EXACTLY BOARD_H board rows.
180
+ while (boardNodes.length < BOARD_H) boardNodes.push(h(Text, { key: "bp" + boardNodes.length, color: C.FG }, cell("", BOARD_W)));
181
+
182
+ const keybar = h(KeyBar, { keys: GAME_KEYS, width });
183
+
184
+ return h(Box, { flexDirection: "column" }, hud1, hud2, ...boardNodes.slice(0, BOARD_H), keybar);
185
+ }
186
+
187
+ export default DonkeyKong;
@@ -0,0 +1,413 @@
1
+ /* Donkey Kong — PURE game core. NO React, NO ink, NO Math.random, NO I/O.
2
+ *
3
+ * This is a small, deterministic state machine: initGame(opts) builds a board
4
+ * procedurally from the terminal's width/height (so it scales + can refuse a
5
+ * too-small terminal), and stepGame(state, { input }) advances exactly one
6
+ * frame. All randomness flows through a seeded mulberry32 RNG carried in the
7
+ * state, so unit tests pass a fixed seed and assert exact barrel paths /
8
+ * collisions. The renderer (dk-render.js) and the Ink wrapper (DkGame.js) never
9
+ * mutate state — they only read it — keeping this file the single source of
10
+ * game truth and trivially unit-testable.
11
+ *
12
+ * Coordinate system: integer grid. x = column (0 = left), y = row (0 = TOP).
13
+ * "Up" decreases y. Girders ("platforms") are horizontal-ish rows that drift
14
+ * (classic DK zig-zag); ladders are vertical runs linking adjacent girders. */
15
+
16
+ // ---- Tunables --------------------------------------------------------------
17
+ export const FRAME_MS = 100; // 10fps game loop (the wrapper gates this on useTick)
18
+ export const JUMP_LEN = 4; // frames a jump arc lasts
19
+ export const MIN_W = 28; // minimum playfield width
20
+ export const MIN_H = 9; // minimum playfield height
21
+ const GRAVITY = 1; // rows/frame fallen when unsupported
22
+ const SLOPE_EVERY = 6; // a 1-row step every ~6 columns on a girder
23
+ const PRINCESS_REACH = 2; // |dx| within which touching the princess wins
24
+
25
+ /** DK barrel-throw cadence by level: faster (shorter cooldown) as you climb. */
26
+ export function cooldownForLevel(level) {
27
+ return Math.max(7, 22 - (level - 1) * 3);
28
+ }
29
+ /** Probability (0..1) a rolling barrel takes a ladder down; rises with level. */
30
+ export function ladderChanceForLevel(level) {
31
+ return Math.min(0.6, 0.18 + (level - 1) * 0.1);
32
+ }
33
+
34
+ // ---- Seeded RNG (mulberry32) — pure, threaded through state ----------------
35
+ // Returns [value in [0,1), nextSeed] so the core never holds hidden mutable
36
+ // state; stepGame folds the new seed back into the returned state.
37
+ function rng(seed) {
38
+ let t = (seed + 0x6d2b79f5) | 0;
39
+ t = Math.imul(t ^ (t >>> 15), t | 1);
40
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
41
+ const v = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
42
+ return [v, t >>> 0];
43
+ }
44
+
45
+ // ---- Board geometry --------------------------------------------------------
46
+ export function boardSize(width, height) {
47
+ const HUD_ROWS = 2; // scan-progress line + score/lives line (rendered by wrapper)
48
+ const KEY_ROW = 1; // in-game keybar (rendered by wrapper)
49
+ const BOARD_W = Math.max(MIN_W, Math.min(width - 2, 64)); // 1-col rail gutter each side
50
+ const BOARD_H = height - HUD_ROWS - KEY_ROW; // identical total rows to the list view
51
+ return { BOARD_W, BOARD_H };
52
+ }
53
+
54
+ /** Build the per-column y of one girder: a base row that steps 1 cell every
55
+ * SLOPE_EVERY columns, alternating drift direction per level (classic zig-zag),
56
+ * bounded to a `±amp` envelope around baseRow so adjacent girders never overlap
57
+ * and none drifts off the board. Returned as an Int array of length BOARD_W so
58
+ * collision/standing math reads the exact row a girder occupies at any column. */
59
+ function buildSlope(baseRow, w, down, amp) {
60
+ const offs = new Array(w);
61
+ for (let x = 0; x < w; x++) {
62
+ const steps = Math.floor(x / SLOPE_EVERY);
63
+ const off = down ? steps : -steps;
64
+ offs[x] = baseRow + Math.max(-amp, Math.min(amp, off));
65
+ }
66
+ return offs;
67
+ }
68
+
69
+ /** The girder index whose standing row is at-or-just-below y at column x, i.e.
70
+ * the platform a body at (x,y) would land on when falling. Returns -1 if none. */
71
+ function girderBelow(platforms, x, y) {
72
+ let best = -1, bestRow = Infinity;
73
+ for (let i = 0; i < platforms.length; i++) {
74
+ const row = platforms[i].slopeOffsets[x];
75
+ if (row >= y && row < bestRow) { best = i; bestRow = row; }
76
+ }
77
+ return best;
78
+ }
79
+
80
+ /** True if column x at row y is a girder cell of ANY platform. */
81
+ function isGirderAt(platforms, x, y) {
82
+ for (const p of platforms) if (p.slopeOffsets[x] === y) return true;
83
+ return false;
84
+ }
85
+
86
+ /** The ladder occupying column x within [yTop..yBottom] at row y, or null. */
87
+ function ladderAt(ladders, x, y) {
88
+ for (const l of ladders) if (l.col === x && y >= l.yTop && y <= l.yBottom) return l;
89
+ return null;
90
+ }
91
+
92
+ /** Inclusive "is `v` between `a` and `b`" regardless of order — for swept hit
93
+ * tests (the moving body crossed cell v this frame). */
94
+ function between(v, a, b) {
95
+ return v >= Math.min(a, b) && v <= Math.max(a, b);
96
+ }
97
+
98
+ // ---- initGame --------------------------------------------------------------
99
+ /**
100
+ * Build a fresh level. Carries score/lives across level-ups. Returns
101
+ * { tooSmall:true } (and nothing else) when the terminal can't host a playable
102
+ * board, so the wrapper can refuse to enter game mode without ever ticking.
103
+ */
104
+ export function initGame({ width = 80, height = 14, level = 1, score = 0, lives = 3, seed } = {}) {
105
+ const { BOARD_W, BOARD_H } = boardSize(width, height);
106
+ if (BOARD_W < MIN_W || BOARD_H < MIN_H) return { tooSmall: true, BOARD_W, BOARD_H };
107
+
108
+ // Lay out N girders evenly across the height. Highest girder (index 0) sits
109
+ // near the top (DK + princess); lowest (last) near the bottom (Jumpman start).
110
+ const N = Math.max(4, Math.min(5, Math.floor(BOARD_H / 3)));
111
+ const gap = Math.floor((BOARD_H - 2) / N); // rows between girder bands
112
+ // Slope amplitude must stay strictly inside the gap so neighboring girders
113
+ // never overlap (which would break standing/collision math) and the lowest
114
+ // girder's lowest column stays on-board. ±1 is plenty of zig-zag at our sizes.
115
+ const amp = Math.max(0, Math.min(2, Math.floor((gap - 1) / 2)));
116
+ const platforms = [];
117
+ for (let i = 0; i < N; i++) {
118
+ // Reserve `amp` rows of headroom at the bottom so the down-sloped lowest
119
+ // girder can't exceed BOARD_H - 1.
120
+ const baseRow = BOARD_H - 2 - amp - i * gap; // 0 = bottom-most girder
121
+ const down = i % 2 === 0; // alternate slope direction per level band
122
+ platforms.push({ row: baseRow, slopeOffsets: buildSlope(baseRow, BOARD_W, down, amp) });
123
+ }
124
+ // platforms[0] is the LOWEST (largest row); platforms[N-1] the HIGHEST.
125
+
126
+ // Ladders: 1 per gap, at a staggered column, linking the STANDING ROW of the
127
+ // lower girder (girderRow - 1) to the standing row of the upper girder, so a
128
+ // climber moves directly between the two walkable rows. yTop < yBottom.
129
+ const ladders = [];
130
+ for (let i = 0; i < N - 1; i++) {
131
+ const lower = platforms[i];
132
+ const upper = platforms[i + 1];
133
+ const col = Math.max(2, Math.min(BOARD_W - 3, Math.floor(((i + 1) / N) * BOARD_W) + (i % 2 ? -3 : 3)));
134
+ const yBottom = lower.slopeOffsets[col] - 1; // stand on the lower girder
135
+ const yTop = upper.slopeOffsets[col] - 1; // stand on the upper girder
136
+ ladders.push({ col, yTop: Math.min(yTop, yBottom), yBottom: Math.max(yTop, yBottom) });
137
+ }
138
+
139
+ const top = platforms[N - 1];
140
+ const dkCol = 2;
141
+ const donkeyKong = { x: dkCol, y: top.slopeOffsets[dkCol] - 1, throwCooldown: 2 };
142
+ // Princess one row above DK's girder, near top-center (the goal zone).
143
+ const princessCol = Math.floor(BOARD_W / 2);
144
+ const princess = { x: princessCol, y: top.slopeOffsets[princessCol] - 1 };
145
+
146
+ const bottom = platforms[0];
147
+ const startCol = 2;
148
+ const jumpman = {
149
+ x: startCol,
150
+ y: bottom.slopeOffsets[startCol] - 1, // stand ON the lowest girder
151
+ vy: 0,
152
+ onGround: true,
153
+ onLadder: false,
154
+ facing: 1,
155
+ jumpFrames: 0,
156
+ alive: true,
157
+ };
158
+
159
+ return {
160
+ BOARD_W,
161
+ BOARD_H,
162
+ platforms,
163
+ ladders,
164
+ jumpman,
165
+ donkeyKong,
166
+ princess,
167
+ barrels: [],
168
+ score,
169
+ lives,
170
+ level,
171
+ status: "playing", // playing | won | dead | over
172
+ rngSeed: (seed == null ? 1234567 + level * 99991 : seed) >>> 0,
173
+ frame: 0,
174
+ message: level > 1 ? `level ${level}` : "",
175
+ nextBarrelId: 1,
176
+ };
177
+ }
178
+
179
+ // ---- respawn (after a death, board/barrels keep going) ---------------------
180
+ function respawnJumpman(state) {
181
+ const bottom = state.platforms[0];
182
+ const startCol = 2;
183
+ state.jumpman = {
184
+ x: startCol,
185
+ y: bottom.slopeOffsets[startCol] - 1,
186
+ vy: 0,
187
+ onGround: true,
188
+ onLadder: false,
189
+ facing: 1,
190
+ jumpFrames: 0,
191
+ alive: true,
192
+ };
193
+ }
194
+
195
+ // ---- stepGame --------------------------------------------------------------
196
+ /**
197
+ * Advance one frame. PURE: returns a NEW state (shallow-cloned then mutated on
198
+ * the clone) so React setState(g => stepGame(g, …)) is safe. `input` is a flat
199
+ * intent object the wrapper fills from keypresses and clears each frame:
200
+ * { left, right, up, down, jump } (booleans).
201
+ */
202
+ export function stepGame(prev, { input = {} } = {}) {
203
+ // Frozen states ignore gameplay input (the wrapper handles r/q out-of-band).
204
+ if (prev.status === "over" || prev.status === "won" || prev.tooSmall) {
205
+ return prev;
206
+ }
207
+
208
+ // Clone (deep-enough: entities + arrays we mutate).
209
+ const s = {
210
+ ...prev,
211
+ jumpman: { ...prev.jumpman },
212
+ donkeyKong: { ...prev.donkeyKong },
213
+ barrels: prev.barrels.map((b) => ({ ...b })),
214
+ frame: prev.frame + 1,
215
+ };
216
+ const { platforms, ladders, BOARD_W, BOARD_H } = s;
217
+ let seed = s.rngSeed;
218
+ const rnd = () => { const [v, n] = rng(seed); seed = n; return v; };
219
+
220
+ const jm = s.jumpman;
221
+ const prevX = jm.x;
222
+ const prevY = jm.y;
223
+
224
+ // ---- 1. Jumpman intent: horizontal move + ladder + jump ----
225
+ if (input.left) { jm.x = Math.max(0, jm.x - 1); jm.facing = -1; }
226
+ if (input.right) { jm.x = Math.min(BOARD_W - 1, jm.x + 1); jm.facing = 1; }
227
+
228
+ // Ladder logic. A ladder spans the walkable run [yTop..yBottom] where BOTH
229
+ // ends are standing rows (yTop = upper girder's standing row, yBottom = lower
230
+ // girder's). When the player's column + row sit on the run, up/down moves one
231
+ // row along it (gravity suspended). At yTop, up steps onto the upper girder
232
+ // (off the ladder); at yBottom, down is a no-op (already on the lower girder).
233
+ const onLad = ladderAt(ladders, jm.x, jm.y);
234
+ if (jm.jumpFrames === 0) {
235
+ if (input.up && onLad && jm.y > onLad.yTop) {
236
+ jm.y -= 1; jm.onGround = false;
237
+ jm.onLadder = jm.y > onLad.yTop; // off the ladder once on the upper girder's row
238
+ } else if (input.down && onLad && jm.y < onLad.yBottom) {
239
+ jm.y += 1; jm.onGround = false;
240
+ jm.onLadder = jm.y < onLad.yBottom; // off the ladder once on the lower girder's row
241
+ } else {
242
+ // Standing at an endpoint counts as on the girder, not the ladder.
243
+ jm.onLadder = !!onLad && jm.y > onLad.yTop && jm.y < onLad.yBottom;
244
+ }
245
+ }
246
+
247
+ // Jump: only from the ground (not mid-air, not on a ladder).
248
+ if (input.jump && jm.onGround && jm.jumpFrames === 0 && !jm.onLadder) {
249
+ jm.jumpFrames = JUMP_LEN;
250
+ jm.onGround = false;
251
+ }
252
+
253
+ // ---- 2. Jump arc + gravity ----
254
+ if (jm.jumpFrames > 0) {
255
+ // Symmetric parabola: rise on the first half, fall on the second.
256
+ const half = Math.ceil(JUMP_LEN / 2);
257
+ if (jm.jumpFrames > half) jm.y -= 1; // going up
258
+ else jm.y += 1; // coming down
259
+ jm.jumpFrames -= 1;
260
+ if (jm.jumpFrames === 0) {
261
+ // settle onto the girder at the landing column
262
+ const gi = girderBelow(platforms, jm.x, jm.y);
263
+ if (gi >= 0) jm.y = platforms[gi].slopeOffsets[jm.x] - 1;
264
+ }
265
+ } else if (!jm.onLadder) {
266
+ // Gravity: stand on a girder if the cell directly below is one; else fall to
267
+ // the nearest girder beneath us.
268
+ const onSomeGirder = isGirderAt(platforms, jm.x, jm.y + 1);
269
+ if (onSomeGirder) {
270
+ jm.onGround = true;
271
+ jm.vy = 0;
272
+ } else {
273
+ const gi = girderBelow(platforms, jm.x, jm.y + 1);
274
+ const standRow = gi >= 0 ? platforms[gi].slopeOffsets[jm.x] : -1;
275
+ if (standRow >= 0 && standRow - 1 > jm.y) {
276
+ jm.y = Math.min(jm.y + GRAVITY, standRow - 1);
277
+ jm.onGround = jm.y === standRow - 1;
278
+ } else {
279
+ jm.onGround = false;
280
+ }
281
+ }
282
+ }
283
+ // Clamp inside the board.
284
+ jm.y = Math.max(0, Math.min(BOARD_H - 1, jm.y));
285
+
286
+ // ---- 3. DK throws barrels ----
287
+ const dk = s.donkeyKong;
288
+ if (dk.throwCooldown > 0) {
289
+ dk.throwCooldown -= 1;
290
+ } else {
291
+ const top = platforms[platforms.length - 1];
292
+ s.barrels.push({
293
+ id: s.nextBarrelId,
294
+ x: dk.x + 1,
295
+ y: top.slopeOffsets[dk.x + 1] - 1,
296
+ vx: 1, // roll to the right off the top girder
297
+ falling: false,
298
+ scored: false,
299
+ });
300
+ s.nextBarrelId += 1;
301
+ dk.throwCooldown = cooldownForLevel(s.level);
302
+ }
303
+
304
+ // ---- 4. Barrels roll / fall / descend ladders ----
305
+ const ladderChance = ladderChanceForLevel(s.level);
306
+ const surviving = [];
307
+ for (const b of s.barrels) {
308
+ // Pre-move position, so collision (below) can catch a barrel that rolled
309
+ // THROUGH the player's cell in one frame, not just where it ended up.
310
+ b.px = b.x; b.py = b.y;
311
+ if (b.falling) {
312
+ // Free-fall until it lands on the next girder below, then resume rolling.
313
+ const gi = girderBelow(platforms, b.x, b.y + 1);
314
+ const landRow = gi >= 0 ? platforms[gi].slopeOffsets[b.x] - 1 : Infinity;
315
+ if (b.y + GRAVITY >= landRow && landRow !== Infinity) {
316
+ b.y = landRow;
317
+ b.falling = false;
318
+ // continue in the same horizontal direction
319
+ } else {
320
+ b.y += GRAVITY;
321
+ }
322
+ } else {
323
+ // Maybe descend a ladder it's standing over.
324
+ const l = ladderAt(ladders, b.x, b.y + 1);
325
+ if (l && rnd() < ladderChance) {
326
+ b.falling = true;
327
+ b.y += GRAVITY;
328
+ } else {
329
+ const nx = b.x + b.vx;
330
+ if (nx < 0 || nx >= BOARD_W) {
331
+ // hit the wall: fall to the girder below and reverse
332
+ b.vx = -b.vx;
333
+ b.falling = true;
334
+ b.y += GRAVITY;
335
+ } else {
336
+ // Follow the girder's slope: snap onto the girder row at the new col
337
+ // if there is one; otherwise it rolled off the low end → fall.
338
+ const giHere = (() => { for (let i = 0; i < platforms.length; i++) if (platforms[i].slopeOffsets[b.x] === b.y + 1) return i; return -1; })();
339
+ if (giHere >= 0) {
340
+ const nextRow = platforms[giHere].slopeOffsets[nx];
341
+ b.x = nx;
342
+ b.y = nextRow - 1;
343
+ } else {
344
+ // no girder under us anymore → start falling
345
+ b.x = nx;
346
+ b.falling = true;
347
+ b.y += GRAVITY;
348
+ }
349
+ }
350
+ }
351
+ }
352
+ if (b.y < BOARD_H) surviving.push(b); // despawn below the board
353
+ }
354
+ s.barrels = surviving;
355
+
356
+ // ---- 5. Collisions + jump-over scoring (swept to avoid tunneling) ----
357
+ for (const b of s.barrels) {
358
+ // Hit if the barrel ends on the player's cell, OR it rolled/fell THROUGH the
359
+ // player this frame (swept on both axes using the captured pre-move pos), OR
360
+ // the player walked into where the barrel ended up (player's x sweep).
361
+ const endOn = b.x === jm.x && b.y === jm.y;
362
+ const barrelSwept =
363
+ (b.py === jm.y && b.y === jm.y && between(jm.x, b.px, b.x)) || // rolled across the row
364
+ (b.px === jm.x && b.x === jm.x && between(jm.y, b.py, b.y)); // fell down the column
365
+ const playerSwept = b.y === jm.y && between(b.x, prevX, jm.x);
366
+ if ((endOn || barrelSwept || playerSwept) && jm.alive) {
367
+ s.lives -= 1;
368
+ s.message = "ouch! barrel hit";
369
+ if (s.lives <= 0) { s.status = "over"; s.message = `GAME OVER — score ${s.score}`; return finalize(s, seed); }
370
+ respawnJumpman(s);
371
+ return finalize(s, seed); // brief pause; next frame resumes
372
+ }
373
+ // Jump-over credit: while airborne (mid-jump), a barrel that is below the
374
+ // player and shares his column has been cleared — +10, once per barrel.
375
+ // Checked against the barrel's pre- OR post-move column so a fast barrel
376
+ // that rolled under during the same frame still earns the credit.
377
+ if (!b.scored && jm.jumpFrames > 0 && (b.x === jm.x || b.px === jm.x) && b.y > jm.y) {
378
+ b.scored = true;
379
+ s.score += 10;
380
+ s.message = "+10 jumped!";
381
+ }
382
+ }
383
+
384
+ // ---- 6. Win: reach the princess ----
385
+ const dxP = Math.abs(jm.x - s.princess.x);
386
+ const dyP = Math.abs(jm.y - s.princess.y);
387
+ if (dxP <= PRINCESS_REACH && dyP <= 1) {
388
+ s.score += 100;
389
+ s.status = "won";
390
+ s.message = "you saved her! +100";
391
+ }
392
+
393
+ return finalize(s, seed);
394
+ }
395
+
396
+ function finalize(s, seed) {
397
+ s.rngSeed = seed >>> 0;
398
+ return s;
399
+ }
400
+
401
+ /** Build the next level after a win, carrying score+lives. */
402
+ export function nextLevel(state, dims) {
403
+ return initGame({
404
+ width: dims.width,
405
+ height: dims.height,
406
+ level: state.level + 1,
407
+ score: state.score,
408
+ lives: state.lives,
409
+ });
410
+ }
411
+
412
+ // Expose a couple of internals for focused unit tests.
413
+ export const _internals = { girderBelow, isGirderAt, ladderAt, rng };