@modelstatus/cli 0.1.34 → 0.1.36

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,688 @@
1
+ /* Donkey Kong — PURE game engine. NO React, NO ink, NO Math.random, NO Date.now,
2
+ * NO I/O. The single source of game truth, unit-tested in isolation.
3
+ *
4
+ * This is a deterministic discrete map. initGame(opts) builds a board
5
+ * procedurally from the terminal's width/height (so it scales + can refuse a
6
+ * too-small terminal). stepGame(prev, { input, dt }) advances exactly one tick
7
+ * (1/60s) and returns a NEW state. All randomness flows through a seeded
8
+ * mulberry32 RNG carried in the state, so unit tests pass a fixed seed and
9
+ * assert EXACT positions, barrel paths and collisions — and two runtimes (node
10
+ * and the bun-compiled binary) produce byte-identical state.
11
+ *
12
+ * === WHY A REWRITE (sub-cell fixed-point) ===
13
+ * The old core stepped a single integer cell per 100ms frame and jumped a
14
+ * 4-frame triangle. That coarse 10fps single-cell grid is the "stiffness". This
15
+ * engine runs true 60Hz with SUB-CELL fixed-point positions so the player
16
+ * accelerates/slides across girders and the jump is a readable parabolic arc.
17
+ *
18
+ * === COORDINATE / FIXED-POINT MODEL ===
19
+ * Public grid stays INTEGER: x = column (0 = left), y = row (0 = TOP), up = -y.
20
+ * Girders/ladders/DK/princess and the RENDERER all read integer cells. The
21
+ * renderer reads jumpman.x/y and barrels[].x/y, which we keep as integer fields
22
+ * SYNCED each tick from the sub-cell px/py (round-to-nearest). So dk-render.js
23
+ * is unchanged in contract.
24
+ *
25
+ * Internally the player + barrels carry Int32 fixed-point position/velocity in
26
+ * units of 1/SCALE cell (SCALE = 256, a power of two → exact, no float drift
27
+ * across machines/runtimes). The render cell is round-to-nearest: (fx+128)>>8.
28
+ * Integer fixed-point with >>/* is bit-identical under V8 and JSC; that is the
29
+ * determinism guarantee the tests assert.
30
+ */
31
+
32
+ // ---- Fixed-point helpers ---------------------------------------------------
33
+ export const SCALE = 256; // 1 cell = 256 fixed-point units (power of two → exact)
34
+ export const toFx = (n) => (n * SCALE) | 0; // integer cell -> fx
35
+ export const cell = (fx) => (fx + (SCALE >> 1)) >> 8; // fx -> round-to-nearest cell
36
+ export const floorCell = (fx) => fx >> 8; // fx -> floor cell (>> on Int32 = floor for our range)
37
+ const clampFx = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
38
+
39
+ // ---- Tunables (per 1/60s tick unless noted) --------------------------------
40
+ export const TICK_HZ = 60;
41
+ export const FIXED_DT = 1; // the loop ALWAYS steps with dt=1 (true fixed timestep)
42
+ // Legacy alias so old imports of FRAME_MS keep working (now = one 60Hz tick).
43
+ export const FRAME_MS = 1000 / TICK_HZ;
44
+ export const JUMP_LEN = 4; // legacy export (old triangle length); unused by physics
45
+
46
+ export const MIN_W = 28; // minimum playfield width
47
+ export const MIN_H = 9; // minimum playfield height
48
+
49
+ // Player horizontal
50
+ export const RUN_ACCEL = 22; // fx/tick (ramps to top speed in ~6 ticks ≈ 100ms)
51
+ export const RUN_MAX = 130; // fx/tick (~0.5 cell/tick → brisk but readable)
52
+ export const GROUND_FRICTION = 30; // fx/tick decel when no input (the "slide")
53
+ export const AIR_ACCEL = 14; // weaker air-control
54
+ export const AIR_MAX = 130;
55
+
56
+ // Gravity / jump
57
+ export const GRAVITY = 14; // fx/tick added to vy while airborne (full jump → apex ≈ 3 cells)
58
+ export const JUMP_CUT_GRAVITY = 42; // stronger gravity while RISING after release → short hop (~1.5 cells)
59
+ export const TERMINAL_VY = 220; // fx/tick max fall speed
60
+ export const JUMP_VY = -150; // initial up-velocity → apex ≈ 3 cells, hang ≈ 21 ticks
61
+ export const COYOTE_TICKS = 5; // jump still allowed N ticks after walking off
62
+ export const JUMP_BUFFER_TICKS = 6; // a press N ticks before landing still fires on land
63
+
64
+ // Ladder
65
+ export const CLIMB_SPEED = 70; // fx/tick (gravity suspended while latched)
66
+
67
+ // Barrels
68
+ export const BARREL_ROLL = 95; // fx/tick base roll speed
69
+ export const MAX_BARRELS = 24; // hard cap (oldest despawns) — bounded state growth
70
+
71
+ // Geometry / scoring
72
+ const SLOPE_EVERY = 6; // a 1-row step every ~6 columns on a girder
73
+ const PRINCESS_REACH = 2; // |dx| within which touching the princess wins
74
+
75
+ // State-machine timers (ticks)
76
+ export const GETREADY_TICKS = 90; // ≈1.5s orient beat
77
+ export const DEATH_TICKS = 60; // death-pop / pause before respawn
78
+ export const LEVELCLEAR_TICKS = 120; // banner + bonus count-up
79
+ export const RESPAWN_INVULN = 120; // ≈2s of blink after a respawn
80
+
81
+ // Bonus timer
82
+ export const BONUS_DRAIN = 10; // points removed each drain
83
+ export const BONUS_TICK = 24; // drain every 24 ticks (~0.4s)
84
+ export function bonusForLevel(level) {
85
+ return 5000 + level * 1000;
86
+ }
87
+
88
+ const SCHEMA = 2;
89
+
90
+ // ---- Level scaling (pure, tested monotonic) --------------------------------
91
+ /** DK barrel-throw cadence in TICKS — faster (shorter) as you climb. */
92
+ export function cooldownForLevel(level) {
93
+ return Math.max(45, 132 - (level - 1) * 18);
94
+ }
95
+ /** Barrel roll speed (fx/tick) — faster each level. */
96
+ export function barrelSpeedForLevel(level) {
97
+ return Math.min(170, BARREL_ROLL + (level - 1) * 12);
98
+ }
99
+ /** Probability a rolling barrel takes a ladder down per eligibility check. */
100
+ export function ladderChanceForLevel(level) {
101
+ return Math.min(0.6, 0.18 + (level - 1) * 0.1);
102
+ }
103
+ /** By construction the jump apex (~3 cells) clears a 1-cell barrel + player at
104
+ * every level — exposed so a test can assert the invariant numerically. */
105
+ export function jumpClearsBarrel(/* level */) {
106
+ const apexCells = (JUMP_VY * JUMP_VY) / (2 * GRAVITY) / SCALE; // ≈ 3.14
107
+ return apexCells > 2; // barrel(1) + player(1) with margin
108
+ }
109
+
110
+ // ---- Seeded RNG (mulberry32) — pure, threaded through state ----------------
111
+ function rng(seed) {
112
+ let t = (seed + 0x6d2b79f5) | 0;
113
+ t = Math.imul(t ^ (t >>> 15), t | 1);
114
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
115
+ const v = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
116
+ return [v, t >>> 0];
117
+ }
118
+
119
+ // ---- Board geometry (KEPT from the old core — integer grid, good) ----------
120
+ export function boardSize(width, height) {
121
+ const HUD_ROWS = 2; // scan-progress line + score/lives line (rendered by loop)
122
+ const KEY_ROW = 1; // in-game keybar (rendered by loop)
123
+ const BOARD_W = Math.max(MIN_W, Math.min(width - 2, 64));
124
+ const BOARD_H = height - HUD_ROWS - KEY_ROW;
125
+ return { BOARD_W, BOARD_H };
126
+ }
127
+
128
+ function buildSlope(baseRow, w, down, amp) {
129
+ const offs = new Array(w);
130
+ for (let x = 0; x < w; x++) {
131
+ const steps = Math.floor(x / SLOPE_EVERY);
132
+ const off = down ? steps : -steps;
133
+ offs[x] = baseRow + Math.max(-amp, Math.min(amp, off));
134
+ }
135
+ return offs;
136
+ }
137
+
138
+ /** Girder index whose standing row is at-or-just-below row y at column x. */
139
+ function girderBelow(platforms, x, y) {
140
+ let best = -1, bestRow = Infinity;
141
+ for (let i = 0; i < platforms.length; i++) {
142
+ const row = platforms[i].slopeOffsets[x];
143
+ if (row >= y && row < bestRow) { best = i; bestRow = row; }
144
+ }
145
+ return best;
146
+ }
147
+
148
+ /** True if column x at row y is a girder cell of ANY platform. */
149
+ function isGirderAt(platforms, x, y) {
150
+ for (const p of platforms) if (p.slopeOffsets[x] === y) return true;
151
+ return false;
152
+ }
153
+
154
+ /** The platform index whose girder row is exactly `y` at column `x`, or -1. */
155
+ function girderIndexAt(platforms, x, y) {
156
+ for (let i = 0; i < platforms.length; i++) if (platforms[i].slopeOffsets[x] === y) return i;
157
+ return -1;
158
+ }
159
+
160
+ /** The ladder occupying column x within [yTop..yBottom] at row y, or null. */
161
+ function ladderAt(ladders, x, y) {
162
+ for (const l of ladders) if (l.col === x && y >= l.yTop && y <= l.yBottom) return l;
163
+ return null;
164
+ }
165
+
166
+ /** Inclusive "is v between a and b" regardless of order — swept hit test. */
167
+ function between(v, a, b) {
168
+ return v >= Math.min(a, b) && v <= Math.max(a, b);
169
+ }
170
+
171
+ // ---- initGame --------------------------------------------------------------
172
+ /**
173
+ * Build a fresh game. Carries score/lives/best across level-ups. Returns
174
+ * { tooSmall:true } when the terminal can't host a playable board so the loop
175
+ * can refuse to enter without ever ticking.
176
+ *
177
+ * status starts at 'getready' (a brief orient beat) — the loop can set 'title'
178
+ * for the cold-start title card; both are valid entry states.
179
+ */
180
+ export function initGame({
181
+ width = 80, height = 14, level = 1, score = 0, lives = 3, best = 0, seed,
182
+ status = "getready",
183
+ } = {}) {
184
+ const { BOARD_W, BOARD_H } = boardSize(width, height);
185
+ if (BOARD_W < MIN_W || BOARD_H < MIN_H) return { tooSmall: true, BOARD_W, BOARD_H };
186
+
187
+ const N = Math.max(4, Math.min(5, Math.floor(BOARD_H / 3)));
188
+ const gap = Math.floor((BOARD_H - 2) / N);
189
+ const amp = Math.max(0, Math.min(2, Math.floor((gap - 1) / 2)));
190
+ const platforms = [];
191
+ for (let i = 0; i < N; i++) {
192
+ const baseRow = BOARD_H - 2 - amp - i * gap; // 0 = bottom-most girder
193
+ const down = i % 2 === 0;
194
+ platforms.push({ row: baseRow, slopeOffsets: buildSlope(baseRow, BOARD_W, down, amp) });
195
+ }
196
+ // platforms[0] is the LOWEST (largest row); platforms[N-1] the HIGHEST.
197
+
198
+ const ladders = [];
199
+ for (let i = 0; i < N - 1; i++) {
200
+ const lower = platforms[i];
201
+ const upper = platforms[i + 1];
202
+ const col = Math.max(2, Math.min(BOARD_W - 3, Math.floor(((i + 1) / N) * BOARD_W) + (i % 2 ? -3 : 3)));
203
+ const yBottom = lower.slopeOffsets[col] - 1;
204
+ const yTop = upper.slopeOffsets[col] - 1;
205
+ ladders.push({ col, yTop: Math.min(yTop, yBottom), yBottom: Math.max(yTop, yBottom) });
206
+ }
207
+
208
+ const top = platforms[N - 1];
209
+ const dkCol = 2;
210
+ const donkeyKong = { x: dkCol, y: top.slopeOffsets[dkCol] - 1, throwCooldown: 2, animPhase: 0 };
211
+ const princessCol = Math.floor(BOARD_W / 2);
212
+ const princess = { x: princessCol, y: top.slopeOffsets[princessCol] - 1, animPhase: 0 };
213
+
214
+ const player = makePlayer(platforms);
215
+
216
+ return {
217
+ schema: SCHEMA,
218
+ BOARD_W,
219
+ BOARD_H,
220
+ platforms,
221
+ ladders,
222
+ player,
223
+ // `jumpman` is a same-object alias of `player` so dk-render.js (which reads
224
+ // state.jumpman.x/y) stays unchanged in contract. Re-pointed after each clone.
225
+ jumpman: player,
226
+ donkeyKong,
227
+ princess,
228
+ barrels: [],
229
+ // --- meta / state machine ---
230
+ status, // title | getready | playing | dying | levelclear | over
231
+ statusTimer: status === "getready" ? GETREADY_TICKS : 0,
232
+ score,
233
+ lives,
234
+ level,
235
+ bonus: bonusForLevel(level),
236
+ bonusDrainTick: 0,
237
+ best: Math.max(best, score) >>> 0,
238
+ rngSeed: (seed == null ? 1234567 + level * 99991 : seed) >>> 0,
239
+ frame: 0,
240
+ message: level > 1 ? `level ${level}` : "",
241
+ nextBarrelId: 1,
242
+ };
243
+ }
244
+
245
+ /** Fresh player standing on the lowest girder at the start column. */
246
+ function makePlayer(platforms, invuln = 0) {
247
+ const bottom = platforms[0];
248
+ const startCol = 2;
249
+ const standRow = bottom.slopeOffsets[startCol] - 1;
250
+ return {
251
+ px: toFx(startCol),
252
+ py: toFx(standRow),
253
+ vx: 0,
254
+ vy: 0,
255
+ facing: 1,
256
+ onGround: true,
257
+ onLadder: false,
258
+ jumpHeld: false,
259
+ coyote: 0,
260
+ jumpBuffer: 0,
261
+ invuln,
262
+ alive: true,
263
+ // integer mirror read by the renderer (synced each tick)
264
+ x: startCol,
265
+ y: standRow,
266
+ };
267
+ }
268
+
269
+ /** Sync the integer cell mirror the renderer reads from sub-cell fx. */
270
+ function syncCell(body) {
271
+ body.x = cell(body.px);
272
+ body.y = cell(body.py);
273
+ }
274
+
275
+ // ---- stepGame --------------------------------------------------------------
276
+ /**
277
+ * Advance one tick. PURE: returns a NEW state. `input` is a flat intent object:
278
+ * { left, right, up, down, jump, start, restart } (booleans).
279
+ * `jump` is EDGE-triggered (true only on the press tick); held is derived.
280
+ * dt is a tick multiplier (default 1); the loop ALWAYS passes dt=1.
281
+ */
282
+ export function stepGame(prev, { input = {}, dt = FIXED_DT } = {}) {
283
+ if (prev.tooSmall) return prev;
284
+ // 'over' is frozen — a hard no-op (preserves the loop-freeze contract).
285
+ if (prev.status === "over") return prev;
286
+
287
+ // Clone (entities + arrays we mutate).
288
+ const s = {
289
+ ...prev,
290
+ player: { ...prev.player },
291
+ donkeyKong: { ...prev.donkeyKong },
292
+ princess: { ...prev.princess },
293
+ barrels: prev.barrels.map((b) => ({ ...b })),
294
+ frame: prev.frame + 1,
295
+ };
296
+ s.jumpman = s.player; // keep the renderer's alias pointing at the live player
297
+
298
+ switch (s.status) {
299
+ case "title": return stepTitle(s, input);
300
+ case "getready": return stepGetReady(s, input);
301
+ case "playing": return stepPlaying(s, input, dt);
302
+ case "dying": return stepDying(s, dt);
303
+ case "levelclear": return stepLevelClear(s, dt);
304
+ default: return s;
305
+ }
306
+ }
307
+
308
+ // ---- transient-status steps ------------------------------------------------
309
+ function stepTitle(s, input) {
310
+ s.donkeyKong.animPhase = (s.frame >> 4) & 1; // slow idle chest-beat on the title only
311
+ if (input.start) {
312
+ s.status = "getready";
313
+ s.statusTimer = GETREADY_TICKS;
314
+ s.player = makePlayer(s.platforms);
315
+ s.jumpman = s.player;
316
+ s.message = "GET READY";
317
+ }
318
+ return s;
319
+ }
320
+
321
+ function stepGetReady(s, _input) {
322
+ s.message = "GET READY";
323
+ s.statusTimer -= 1;
324
+ if (s.statusTimer <= 0) {
325
+ s.status = "playing";
326
+ s.statusTimer = 0;
327
+ s.message = s.level > 1 ? `level ${s.level}` : "";
328
+ }
329
+ return s;
330
+ }
331
+
332
+ function stepDying(s, _dt) {
333
+ // Death-pop: a small hop then fall, purely visual; barrels keep rolling.
334
+ const p = s.player;
335
+ p.vy = Math.min(p.vy + GRAVITY, TERMINAL_VY);
336
+ p.py = clampFx(p.py + p.vy, 0, toFx(s.BOARD_H - 1));
337
+ syncCell(p);
338
+ advanceBarrels(s, makeRnd(s)); // juice: hazards stay live during the death beat
339
+ s.statusTimer -= 1;
340
+ if (s.statusTimer <= 0) {
341
+ if (s.lives > 0) {
342
+ // respawn with invulnerability (blink window)
343
+ s.player = makePlayer(s.platforms, RESPAWN_INVULN);
344
+ s.jumpman = s.player;
345
+ s.status = "getready";
346
+ s.statusTimer = Math.floor(GETREADY_TICKS / 2);
347
+ s.message = "GET READY";
348
+ } else {
349
+ s.status = "over";
350
+ s.statusTimer = 0;
351
+ s.best = Math.max(s.best, s.score) >>> 0;
352
+ s.message = `GAME OVER — score ${s.score}`;
353
+ }
354
+ }
355
+ return finalize(s, s.rngSeed);
356
+ }
357
+
358
+ function stepLevelClear(s, _dt) {
359
+ // Count the remaining bonus up into the score over the banner (juice).
360
+ if (s.bonus > 0) {
361
+ const award = Math.min(s.bonus, 100);
362
+ s.bonus -= award;
363
+ s.score += award;
364
+ s.best = Math.max(s.best, s.score) >>> 0;
365
+ }
366
+ s.statusTimer -= 1;
367
+ if (s.statusTimer <= 0) {
368
+ return nextLevel(s, { width: s.BOARD_W + 2, height: s.BOARD_H + 3 });
369
+ }
370
+ return s;
371
+ }
372
+
373
+ // ---- the playing pipeline --------------------------------------------------
374
+ function makeRnd(s) {
375
+ let seed = s.rngSeed;
376
+ const rnd = () => { const [v, n] = rng(seed); seed = n; return v; };
377
+ rnd.flush = () => { s.rngSeed = seed >>> 0; };
378
+ return rnd;
379
+ }
380
+
381
+ function stepPlaying(s, input, dt) {
382
+ const { platforms, ladders, BOARD_W, BOARD_H } = s;
383
+ const rnd = makeRnd(s);
384
+ const p = s.player;
385
+ const maxPx = toFx(BOARD_W - 1);
386
+ const maxPy = toFx(BOARD_H - 1);
387
+
388
+ // 1. LADDER LATCH ----------------------------------------------------------
389
+ // Latch when the player's cell sits on a ladder run and up/down is pressed.
390
+ const col = cell(p.px);
391
+ const row = cell(p.py);
392
+ const onLad = ladderAt(ladders, col, row);
393
+ // Latch when the player's cell sits on a ladder run and is pressing up/down
394
+ // (and isn't trying to jump). Up only latches if there's run above; down only
395
+ // if there's run below — so standing at an endpoint and pressing "off" doesn't
396
+ // glue you to the ladder.
397
+ const wantUp = input.up && onLad && row > onLad.yTop;
398
+ const wantDown = input.down && onLad && row < onLad.yBottom;
399
+ if ((wantUp || wantDown) && !input.jump) {
400
+ p.onLadder = true;
401
+ p.vx = 0; p.vy = 0;
402
+ p.px = toFx(onLad.col); // snap to the ladder column while latched
403
+ if (wantUp) p.py = Math.max(toFx(onLad.yTop), p.py - CLIMB_SPEED);
404
+ else p.py = Math.min(toFx(onLad.yBottom), p.py + CLIMB_SPEED);
405
+ // Reached an endpoint EXACTLY → step off onto that girder (off the ladder).
406
+ if (p.py <= toFx(onLad.yTop)) { p.onLadder = false; p.onGround = true; p.py = toFx(onLad.yTop); }
407
+ else if (p.py >= toFx(onLad.yBottom)) { p.onLadder = false; p.onGround = true; p.py = toFx(onLad.yBottom); }
408
+ } else if (p.onLadder) {
409
+ // Left the column, pressed jump, or no climb input → unlatch.
410
+ const stillOn = onLad && cell(p.px) === onLad.col;
411
+ if (!stillOn || input.jump || !(input.up || input.down)) {
412
+ p.onLadder = false;
413
+ // settle onto a girder if one is at/below this cell; else gravity takes over.
414
+ if (isGirderAt(platforms, cell(p.px), cell(p.py) + 1) || isGirderAt(platforms, cell(p.px), cell(p.py))) p.onGround = true;
415
+ }
416
+ }
417
+
418
+ if (!p.onLadder) {
419
+ // 2. HORIZONTAL --------------------------------------------------------
420
+ const accel = p.onGround ? RUN_ACCEL : AIR_ACCEL;
421
+ const max = p.onGround ? RUN_MAX : AIR_MAX;
422
+ if (input.left && !input.right) { p.vx = Math.max(-max, p.vx - accel * dt); p.facing = -1; }
423
+ else if (input.right && !input.left) { p.vx = Math.min(max, p.vx + accel * dt); p.facing = 1; }
424
+ else if (p.onGround) {
425
+ // friction (the slide) — decel toward 0
426
+ if (p.vx > 0) p.vx = Math.max(0, p.vx - GROUND_FRICTION * dt);
427
+ else if (p.vx < 0) p.vx = Math.min(0, p.vx + GROUND_FRICTION * dt);
428
+ }
429
+ p.px = clampFx(p.px + p.vx * dt, 0, maxPx);
430
+ if (p.px === 0 || p.px === maxPx) p.vx = 0; // wall contact
431
+
432
+ // 3. JUMP --------------------------------------------------------------
433
+ if (p.coyote > 0) p.coyote -= 1;
434
+ if (p.jumpBuffer > 0) p.jumpBuffer -= 1;
435
+ if (input.jump) p.jumpBuffer = JUMP_BUFFER_TICKS; // buffer the press
436
+ const canJump = (p.onGround || p.coyote > 0);
437
+ if (p.jumpBuffer > 0 && canJump) {
438
+ p.vy = JUMP_VY;
439
+ p.onGround = false;
440
+ p.coyote = 0;
441
+ p.jumpBuffer = 0;
442
+ p.jumpHeld = true;
443
+ }
444
+ // Variable height: holding through the rise = full jump (~3 cells); letting
445
+ // go while still rising applies a stronger cut-gravity → a short hop.
446
+ if (!input.jump && p.vy < 0) p.jumpHeld = false;
447
+ if (p.vy >= 0) p.jumpHeld = false;
448
+
449
+ // 4. GRAVITY + SWEPT LANDING ------------------------------------------
450
+ if (!p.onGround) {
451
+ const rising = p.vy < 0;
452
+ const g = rising && !p.jumpHeld ? JUMP_CUT_GRAVITY : GRAVITY;
453
+ p.vy = Math.min(p.vy + g * dt, TERMINAL_VY);
454
+ const prevPy = p.py;
455
+ let nextPy = p.py + p.vy * dt;
456
+ const c = cell(p.px);
457
+ if (p.vy < 0) {
458
+ // Rising: bonk a girder directly overhead (can't pass up THROUGH one).
459
+ const headRow = cell(prevPy) - 1;
460
+ if (headRow >= 0 && isGirderAt(platforms, c, headRow)) {
461
+ // stop the rise just below the girder (stand row of THIS body unchanged)
462
+ p.py = toFx(cell(prevPy));
463
+ p.vy = 0;
464
+ p.jumpHeld = false;
465
+ } else {
466
+ p.py = clampFx(nextPy, 0, maxPy);
467
+ }
468
+ } else if (p.vy > 0) {
469
+ // Falling: land on the HIGHEST girder top crossed top-down this tick
470
+ // (prevPy at/above the stand row, nextPy at/below it → a real landing).
471
+ const y0 = Math.max(0, floorCell(prevPy));
472
+ const y1 = Math.min(BOARD_H - 1, floorCell(nextPy) + 1);
473
+ let landRow = -1;
474
+ for (let gy = y0; gy <= y1; gy++) {
475
+ if (!isGirderAt(platforms, c, gy)) continue;
476
+ const stand = gy - 1; // stand ON TOP of the girder
477
+ if (prevPy <= toFx(stand) && nextPy >= toFx(stand)) { landRow = stand; break; }
478
+ }
479
+ if (landRow >= 0) {
480
+ p.py = toFx(landRow);
481
+ p.vy = 0;
482
+ p.onGround = true;
483
+ p.jumpHeld = false;
484
+ } else {
485
+ p.py = clampFx(nextPy, 0, maxPy);
486
+ }
487
+ } else {
488
+ p.py = clampFx(nextPy, 0, maxPy);
489
+ }
490
+ } else {
491
+ // Grounded: follow the slope at the current column, detect walk-off.
492
+ const c = cell(p.px);
493
+ const gi = girderIndexAt(platforms, c, cell(p.py) + 1);
494
+ if (gi >= 0) {
495
+ p.py = toFx(platforms[gi].slopeOffsets[c] - 1); // re-snap to slope
496
+ p.vy = 0;
497
+ } else {
498
+ // walked off the edge → coyote window, begin falling
499
+ p.onGround = false;
500
+ p.coyote = COYOTE_TICKS;
501
+ }
502
+ }
503
+ }
504
+ syncCell(p);
505
+
506
+ // 5. DK THROW --------------------------------------------------------------
507
+ const dk = s.donkeyKong;
508
+ dk.throwCooldown -= 1;
509
+ if (dk.throwCooldown <= 0) {
510
+ const top = platforms[platforms.length - 1];
511
+ const jitter = rnd() < 0.5 ? 0 : 1; // seeded ±1 col so barrels don't stack
512
+ const spawnCol = Math.min(BOARD_W - 1, dk.x + 1 + jitter);
513
+ s.barrels.push({
514
+ id: s.nextBarrelId++,
515
+ px: toFx(spawnCol),
516
+ py: toFx(top.slopeOffsets[spawnCol] - 1),
517
+ vx: barrelSpeedForLevel(s.level), // roll right
518
+ vy: 0,
519
+ falling: false,
520
+ onLadder: false,
521
+ scored: false,
522
+ spin: 0,
523
+ x: spawnCol,
524
+ y: top.slopeOffsets[spawnCol] - 1,
525
+ });
526
+ if (s.barrels.length > MAX_BARRELS) s.barrels.shift(); // cap (oldest despawns)
527
+ dk.throwCooldown = cooldownForLevel(s.level);
528
+ dk.animPhase ^= 1; // chest-beat juice (only on a throw)
529
+ }
530
+
531
+ // 6/7. BARRELS -------------------------------------------------------------
532
+ advanceBarrels(s, rnd);
533
+
534
+ // 8. COLLISIONS + SCORING --------------------------------------------------
535
+ const pCol = cell(p.px), pRow = cell(p.py);
536
+ for (const b of s.barrels) {
537
+ const bCol = cell(b.px), bRow = cell(b.py);
538
+ const endOn = bCol === pCol && bRow === pRow;
539
+ // Swept overlap using pre-move floor cells (no tunneling).
540
+ const bPrevCol = b._pCol ?? bCol, bPrevRow = b._pRow ?? bRow;
541
+ const sameRowSweep = (bRow === pRow || bPrevRow === pRow) && between(pCol, bPrevCol, bCol);
542
+ const sameColSweep = (bCol === pCol || bPrevCol === pCol) && between(pRow, bPrevRow, bRow);
543
+ const playerSweep = bRow === pRow && between(bCol, p._prevCol ?? pCol, pCol);
544
+ const hit = endOn || sameRowSweep || sameColSweep || playerSweep;
545
+ if (hit && p.invuln === 0 && p.alive) {
546
+ s.lives -= 1;
547
+ s.status = "dying";
548
+ s.statusTimer = DEATH_TICKS;
549
+ s.message = "ouch! barrel hit";
550
+ p.vy = -90; // death-pop kick
551
+ rnd.flush();
552
+ return finalize(s, s.rngSeed);
553
+ }
554
+ // Jump-over credit: airborne, barrel below + shares column, once per barrel.
555
+ const airborne = !p.onGround || p.vy !== 0;
556
+ if (!b.scored && airborne && (bCol === pCol || bPrevCol === pCol) && bRow > pRow) {
557
+ b.scored = true;
558
+ s.score += 10;
559
+ s.best = Math.max(s.best, s.score) >>> 0;
560
+ s.message = "+10 jumped!";
561
+ }
562
+ }
563
+
564
+ // 9. BONUS DRAIN -----------------------------------------------------------
565
+ s.bonusDrainTick += 1;
566
+ if (s.bonusDrainTick >= BONUS_TICK) {
567
+ s.bonusDrainTick = 0;
568
+ s.bonus = Math.max(0, s.bonus - BONUS_DRAIN);
569
+ }
570
+
571
+ // 10. WIN ------------------------------------------------------------------
572
+ const dxP = Math.abs(pCol - s.princess.x);
573
+ const dyP = Math.abs(pRow - s.princess.y);
574
+ if (dxP <= PRINCESS_REACH && dyP <= 1) {
575
+ s.status = "levelclear";
576
+ s.statusTimer = LEVELCLEAR_TICKS;
577
+ s.score += 100;
578
+ s.best = Math.max(s.best, s.score) >>> 0;
579
+ s.message = "YOU SAVED HER! +100";
580
+ rnd.flush();
581
+ return finalize(s, s.rngSeed);
582
+ }
583
+
584
+ // 11. invuln decay + record prev cells for next tick's sweep
585
+ if (p.invuln > 0) p.invuln -= 1;
586
+ p._prevCol = pCol;
587
+ rnd.flush();
588
+ return finalize(s, s.rngSeed);
589
+ }
590
+
591
+ /** Advance every barrel one tick (roll / fall / descend ladders) in fx. */
592
+ function advanceBarrels(s, rnd) {
593
+ const { platforms, ladders, BOARD_W, BOARD_H } = s;
594
+ const ladderChance = ladderChanceForLevel(s.level);
595
+ const maxPx = toFx(BOARD_W - 1);
596
+ const surviving = [];
597
+ for (const b of s.barrels) {
598
+ b._pCol = cell(b.px); b._pRow = cell(b.py); // pre-move cells for the sweep
599
+ b.spin = (b.spin + 1) & 0xffff;
600
+ const c = cell(b.px);
601
+ if (b.falling) {
602
+ b.vy = Math.min((b.vy || 0) + GRAVITY, TERMINAL_VY);
603
+ const nextPy = b.py + b.vy;
604
+ const gi = girderIndexAt(platforms, c, floorCell(nextPy) + 1) >= 0
605
+ ? girderIndexAt(platforms, c, floorCell(nextPy) + 1)
606
+ : girderBelow(platforms, c, cell(b.py) + 1);
607
+ const landRow = gi >= 0 ? platforms[gi].slopeOffsets[c] - 1 : -1;
608
+ if (landRow >= 0 && nextPy >= toFx(landRow) && toFx(landRow) >= b.py - 1) {
609
+ b.py = toFx(landRow);
610
+ b.vy = 0;
611
+ b.falling = false;
612
+ b.onLadder = false;
613
+ } else {
614
+ b.py = nextPy;
615
+ }
616
+ } else {
617
+ // On a girder: maybe descend a ladder it's over.
618
+ const lad = ladderAt(ladders, c, cell(b.py) + 1);
619
+ if (lad && rnd() < ladderChance) {
620
+ b.falling = true;
621
+ b.onLadder = true;
622
+ b.px = toFx(lad.col);
623
+ b.vy = GRAVITY;
624
+ b.py = b.py + b.vy;
625
+ } else {
626
+ const nextPx = b.px + b.vx;
627
+ const nc = cell(nextPx);
628
+ if (nextPx < 0 || nextPx > maxPx) {
629
+ b.vx = -b.vx; // bounce off the wall and fall to the girder below
630
+ b.falling = true;
631
+ b.vy = GRAVITY;
632
+ b.py = b.py + b.vy;
633
+ } else {
634
+ const giHere = girderIndexAt(platforms, c, cell(b.py) + 1);
635
+ if (giHere >= 0 && nc >= 0 && nc < BOARD_W && platforms[giHere].slopeOffsets[nc] !== undefined) {
636
+ b.px = nextPx;
637
+ b.py = toFx(platforms[giHere].slopeOffsets[nc] - 1); // follow slope
638
+ } else {
639
+ b.px = nextPx; // rolled off the end → fall
640
+ b.falling = true;
641
+ b.vy = GRAVITY;
642
+ b.py = b.py + b.vy;
643
+ }
644
+ }
645
+ }
646
+ }
647
+ syncCell(b);
648
+ if (cell(b.py) < BOARD_H) surviving.push(b); // despawn below the board
649
+ }
650
+ s.barrels = surviving;
651
+ }
652
+
653
+ function finalize(s, seed) {
654
+ s.rngSeed = seed >>> 0;
655
+ return s;
656
+ }
657
+
658
+ // ---- nextLevel / serialize -------------------------------------------------
659
+ /** Build the next level after a win, carrying score + lives + best. */
660
+ export function nextLevel(state, dims) {
661
+ return initGame({
662
+ width: dims.width,
663
+ height: dims.height,
664
+ level: state.level + 1,
665
+ score: state.score,
666
+ lives: state.lives,
667
+ best: state.best,
668
+ status: "getready",
669
+ });
670
+ }
671
+
672
+ /** serialize/deserialize: the state is plain JSON already (Int32 fx + arrays).
673
+ * serialize drops the `jumpman` alias (it duplicates `player`); deserialize and
674
+ * stepGame re-point it, so round-tripping is byte-stable and compact. */
675
+ export function serialize(state) {
676
+ const { jumpman, ...rest } = state; // jumpman === player; don't double-encode
677
+ return JSON.parse(JSON.stringify(rest));
678
+ }
679
+ export function deserialize(obj) {
680
+ obj.jumpman = obj.player; // re-establish the renderer alias
681
+ return obj;
682
+ }
683
+
684
+ // Expose internals for focused unit tests.
685
+ export const _internals = {
686
+ girderBelow, isGirderAt, girderIndexAt, ladderAt, rng, between,
687
+ makePlayer, advanceBarrels, syncCell,
688
+ };