From da144bc20f7f75852df9e3d06d5013a4c368b34b Mon Sep 17 00:00:00 2001 From: Chris Malpass Date: Sat, 29 Aug 2026 22:14:47 -0400 Subject: [PATCH] Harden gameplay routes and add browser playtests --- README.md | 22 +- scripts/playtest.mjs | 1291 ++++++++++++++++++++++++++++++++++++++ src/boss.ts | 3 + src/cheats.ts | 5 +- src/daily.ts | 74 ++- src/enemy.ts | 5 +- src/game.ts | 100 ++- src/ghost.ts | 2 +- src/goal.ts | 4 +- src/level-data.ts | 204 ++++-- src/level.ts | 4 + src/projectile.ts | 8 +- src/store.ts | 68 +- src/weather.ts | 27 +- tests/boss.test.ts | 4 +- tests/cheats.test.ts | 141 +++++ tests/dusk.test.ts | 57 ++ tests/game.test.ts | 25 +- tests/ghost.test.ts | 42 ++ tests/level-data.test.ts | 132 +++- tests/mechanics.test.ts | 10 +- tests/store.test.ts | 131 ++++ tests/weather.test.ts | 60 ++ 23 files changed, 2287 insertions(+), 132 deletions(-) create mode 100644 scripts/playtest.mjs create mode 100644 tests/cheats.test.ts create mode 100644 tests/store.test.ts diff --git a/README.md b/README.md index 71b763d..7ebbc1f 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,26 @@ npm run dev & # or any server URL as the second arg node scripts/capture-evidence.mjs ``` +## Headless playtest + +`scripts/playtest.mjs` drives the **production build** with a simple but +honest bot: every 50 ms it reads the live game state, dispatches real +keyboard events, and tries to win. A scenario fails on a softlock (no forward +progress for 10 s), a page error, or a timeout; hand-built levels must reach +the victory state. + +```sh +npm run preview -- --port 4173 --strictPort & # the bot expects a server +node scripts/playtest.mjs # all levels (0-4) + daily +node scripts/playtest.mjs 0 2 4 # specific hand-built levels +node scripts/playtest.mjs daily # daily challenge only +node scripts/playtest.mjs dusk-stress # Duskfen late-tide respawn stress +``` + +Per-scenario screenshots and jump-decision logs land in `/tmp/tinyrex-e2e/` +(`*.png`, `*.jumps.json`). The bot is intentionally naive — it exists to +catch uncompletable layouts, softlocks, and crashes, not to beat the game. + ## Controls | Action | Keyboard | Touch | @@ -166,7 +186,7 @@ src/ sprite.ts Procedural drawing of Rex + all enemies. game.ts State machine, fixed-timestep loop, HUD, menus. touch-controls.ts DOM touch-button bindings. - level-data.ts LEVELS — the two hand-designed levels (Crystal Valley, Volcanic Depths). + level-data.ts LEVELS — the five hand-designed levels (Crystal Valley, Volcanic Depths, Frostpeak Pass, Molten Nest, Duskfen). util.ts Small shared helpers (clamp, lerp, overlap, rng, fmtTime). tests/ Vitest suite: physics, state machine, level-data integrity. README.md This file. diff --git a/scripts/playtest.mjs b/scripts/playtest.mjs new file mode 100644 index 0000000..d1d73e4 --- /dev/null +++ b/scripts/playtest.mjs @@ -0,0 +1,1291 @@ +#!/usr/bin/env node +/** + * Tiny Rex — headless playtest harness. + * + * Drives the production build with a simple but honest bot: every 50 ms it + * reads the live game state, dispatches real keyboard events, and tries to + * win. A scenario fails on a softlock (no forward progress for 10 s), a page + * error, or a timeout; hand-built levels must reach the victory state. + * + * Usage: + * node scripts/playtest.mjs # all levels (0-4) + daily + * node scripts/playtest.mjs 0 2 4 # specific hand-built levels + * node scripts/playtest.mjs daily # daily challenge only + * node scripts/playtest.mjs dusk-stress # Duskfen late-tide respawn stress + * + * Expects a running preview server (default http://localhost:4173, override + * with BASE_URL). Start one with: npm run preview -- --port 4173 --strictPort + */ +import { chromium } from 'playwright-core'; +import { mkdirSync } from 'node:fs'; + +const BASE_URL = process.env.BASE_URL || 'http://localhost:4173'; +const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const SHOT_DIR = '/tmp/tinyrex-e2e'; + +/** Per-scenario time budgets (ms) and pass rules. */ +const LEVEL_BUDGET = [120_000, 150_000, 150_000, 200_000, 200_000]; +const DAILY_BUDGET = 120_000; +const DAILY_MIN_PROGRESS = 0.5; + +// --------------------------------------------------------------------------- +// In-page bot. Injected once; runs its own 50 ms tick loop and resolves with +// a verdict. All state reads + key dispatches happen in-page so nothing +// crosses the Playwright serialization boundary (getters would vanish). +// --------------------------------------------------------------------------- +const BOT_SOURCE = ` + ({ levelIdx, mode, maxMs, minProgress, stress }) => new Promise((resolve) => { + const g = window.TINY_REX.game; + const held = new Set(); + const key = (code, isDown) => + window.dispatchEvent(new KeyboardEvent(isDown ? 'keydown' : 'keyup', { code, key: code, bubbles: true, cancelable: true })); + const hold = (code, on) => { + if (on && !held.has(code)) { held.add(code); key(code, true); } + else if (!on && held.has(code)) { held.delete(code); key(code, false); } + }; + let jumpLockUntil = 0; + // The engine's jump buffer only re-stamps on a Space keydown RISING edge + // (a keydown while Space is already held is ignored). A held key therefore + // swallows later stamps, so every jump press forces a fresh edge: + // release-then-press. Safe because presses only fire while grounded or + // falling (vy >= 0), where the release cannot trigger a jump cut. + let spaceDown = false; + const pressSpace = () => { + if (spaceDown) key('Space', false); + key('Space', true); + spaceDown = true; + }; + const releaseSpace = () => { + if (spaceDown) { key('Space', false); spaceDown = false; } + }; + // Two jump heights (variable-height engine: releasing early cuts vy by + // 0.42 while vy < -140): + // - full: hold past the cut window -> 130 px apex, ~233 px flat reach + // - short: 100 ms hold -> ~68 px apex, ~149 px flat reach + const jumpLog = []; + let spaceReleaseTimer = 0; + const scheduleSpaceRelease = (holdMs) => { + if (spaceReleaseTimer) clearTimeout(spaceReleaseTimer); + spaceReleaseTimer = setTimeout(() => { + spaceReleaseTimer = 0; + releaseSpace(); + }, holdMs); + }; + const doJump = (holdMs, why) => { + const now = performance.now(); + if (now < jumpLockUntil) return; + jumpLockUntil = now + 380; + const q = g.player; + const standP = q && g.level + ? g.level.platforms.find((pl) => + pl.active !== false && q.x + q.w > pl.x + 2 && q.x < pl.x + pl.w - 2 && Math.abs(q.y + q.h - pl.y) < 8) + : null; + jumpLog.push([ + Math.round(now - start), holdMs, + q ? Math.round(q.x + q.w / 2) : -1, + q ? Math.round(q.y + q.h) : -1, + q ? (q.grounded ? 1 : 0) : -1, + standP ? Math.round(standP.x) + '..' + Math.round(standP.x + standP.w) + '@' + Math.round(standP.y) + '/' + (standP.type || 'g') : 'none', + why || '?', + ]); + pressSpace(); + scheduleSpaceRelease(holdMs); + }; + const fullJump = (why) => doJump(350, why); + const shortJump = (why) => doJump(100, why); + // Back-compat alias for the stress scenario. + const tapJump = fullJump; + // Crumble-bridge hop: a dedicated press with its own 300 ms lock and a + // 450 ms hold. The hold keeps Space down until well past the moment the + // buffered press converts on landing, so the keyup never lands inside the + // engine's variable-height cut window (an early keyup would cut the hop). + // The lock is shorter than the hold on purpose: the only presses that + // matter (one per slab approach, ~800 ms apart) never collide, and any + // duplicate within 300 ms is a no-op that would not re-stamp the engine's + // buffer anyway (a keydown while Space is already held is not a rising + // edge). + // 120 ms lock (was 300): the engine only honors a press within 130 ms of + // the landing tick, so the last stamp before a slab landing must land in + // that window; re-stamping every 120 ms keeps it fresh. + let crumbleLockUntil = 0; + const crumbleJump = (why) => { + const t = performance.now(); + if (t < crumbleLockUntil) return; + const q = g.player; + // Mid-rise from a previous crumble hop: releasing Space would cut the + // hop short; the in-flight hop is all we need, so skip re-stamping. + if (spaceDown && q && q.vy < 0) { crumbleLockUntil = t + 60; return; } + crumbleLockUntil = t + 120; + jumpLog.push([ + Math.round(t - start), 450, + q ? Math.round(q.x + q.w / 2) : -1, + q ? Math.round(q.y + q.h) : -1, + q ? (q.grounded ? 1 : 0) : -1, + 'crumble', why || 'crumbleChain', + ]); + pressSpace(); + scheduleSpaceRelease(450); + }; + + window.addEventListener('blur', () => jumpLog.push(['blur', Math.round(performance.now() - start)])); + window.addEventListener('keyup', (e) => { + if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') + jumpLog.push(['keyup', e.code, Math.round(performance.now() - start)]); + }, true); + window.addEventListener('keydown', (e) => { + if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') + jumpLog.push(['keydown', e.code, Math.round(performance.now() - start)]); + }, true); + const start = performance.now(); + let bestX = -Infinity; + let lastProgress = start; + let wasPlaying = false; + let verdict = null; + let verdictExtra = ''; + let dryStreak = 0; + let arcHoldT0 = -1, arcHoldKey = -1; // timed wait while an enemy blocks the arc + const finish = () => { + held.forEach((c) => key(c, false)); + return { + verdict, + extra: verdictExtra, + x: g.player ? Math.round(g.player.x + g.player.w / 2) : 0, + width: g.level ? g.level.width : 0, + ms: Math.round(performance.now() - start), + state: g.state, + debug: g.state === 'gameover' && g.level && g.player ? { + hearts: g.player.hearts, + player: [Math.round(g.player.x), Math.round(g.player.y)], + enemies: g.level.enemies.map((e) => [e.type, Math.round(e.x), Math.round(e.y), e.dead]), + hazards: g.level.hazards.map((h) => [h.type, Math.round(h.x), Math.round(h.w)]), + projectiles: g.level.projectiles.map((p) => [Math.round(p.x), Math.round(p.y)]), + } : null, + jumpLog: jumpLog.slice(-1500), + }; + }; + const done = (v, extra) => { verdict = v; verdictExtra = extra || ''; }; + + if (mode === 'level') { g.selectLevel(levelIdx); } + else if (mode === 'daily') { g.selectDaily(); } + + const timer = setInterval(() => { + const now = performance.now(); + if (now - start > maxMs) { + const p = g.player; + const prog = p && g.level ? (p.x + p.w / 2) / g.level.width : 0; + if (mode === 'daily' && prog >= minProgress) done('daily-progress'); + else done('timeout', 'progress=' + Math.round(prog * 100) + '%'); + return; + } + if (g.state === 'victory') return done('victory'); + if (g.state === 'gameover') return done('gameover'); + if (g.state !== 'playing' || !g.player || !g.level) { + if (g.state === 'menu') { key('Space', true); setTimeout(() => key('Space', false), 60); } + wasPlaying = false; + return; // dying / paused: wait + } + + const p = g.player, lvl = g.level; + const px = p.x + p.w / 2, feet = p.y + p.h; + + // --- Landing safety ------------------------------------------------------- + // A full jump from run speed lands ~233 px ahead. If the landing body + // does not overlap a solid platform at foot level, the jump is a + // one-way trip, so rules that jump reactively (enemies, ground hazards) + // check this. + const supportedAt = (x) => lvl.platforms.some((pl) => + pl.active !== false && (pl.type || 'ground') !== 'crumble' && + pl.x < x + 23 && pl.x + pl.w > x - 23 && Math.abs(pl.y - feet) <= 30); + const safelySupportedAt = (x) => lvl.platforms.some((pl) => { + if (pl.active === false || (pl.type || 'ground') === 'crumble') return false; + const margin = pl.w >= 220 ? 60 : 24; + return x >= pl.x + margin && x <= pl.x + pl.w - margin && + Math.abs(pl.y - feet) <= 30; + }); + const willLandInGap = () => !supportedAt(px + 233); + + if (stress) { + // Late-tide stress: after a forced respawn the bot must stand on dry + // ground (platform top <= 446) and make forward progress. + const dry = p.grounded && feet <= 447; + dryStreak = dry && px > stress.x + 80 ? dryStreak + 1 : 0; + if (dry && px > stress.x + 180) return done('safe'); + if (dryStreak >= 4) return done('safe'); + if (now - lastProgress > 10_000) return done('stall', 'x=' + Math.round(px)); + if (px > bestX + 30) { bestX = px; lastProgress = now; } + // Keep running right; jump over the bog pit edges. + hold('ArrowRight', true); hold('ArrowLeft', false); + let refuge = null; + if (p.grounded) { + const stand = lvl.platforms.find((pl) => + p.x + p.w > pl.x + 2 && p.x < pl.x + pl.w - 2 && Math.abs(feet - pl.y) < 8); + // A late respawn can begin on ground already below the final tide. + // Take the first reachable elevated refuge before the water drains + // the remaining hearts; this also exercises the intended canopy + // route instead of treating the drowned floor as safe. + refuge = lvl.platforms + .filter((pl) => + pl.active !== false && + pl.type !== 'ground' && + pl.type !== 'mover' && + pl.y <= 446 && + pl.y < feet - 20 && + pl.x + pl.w > p.x + 20 && + pl.x < p.x + 280) + .sort((a, b) => a.x - b.x)[0]; + if (refuge && stand) { + const rise = feet - refuge.y; + const disc = 625 * 625 - 4 * 750 * rise; + const dFull = disc >= 0 + ? 285 * ((625 + Math.sqrt(disc)) / 1500) * 0.98 + : 0; + const fullWindow = dFull > 0 + ? [refuge.x + 24 - dFull, refuge.x + refuge.w - 24 - dFull] + : [Infinity, -Infinity]; + const dShort = rise <= 68 + ? (285 * (0.233 + Math.sqrt(2 * (68.3 - rise) / 1500)) * 0.98) + : 0; + const shortWindow = dShort > 0 + ? [refuge.x + 24 - dShort, refuge.x + refuge.w - 24 - dShort] + : [Infinity, -Infinity]; + if (px >= fullWindow[0] && px <= fullWindow[1]) fullJump('dryRefuge'); + else if (px >= shortWindow[0] && px <= shortWindow[1]) shortJump('dryRefugeShort'); + } + const edge = stand ? stand.x + stand.w : Infinity; + if (edge - (p.x + p.w) <= 110 && !refuge) tapJump(); + } + return; + } + + // Respawn reset: a fresh entry into the playing state (start, resume, + // or after a death) means Rex was (re)placed at a checkpoint. Reset the + // progress/stall clock so death-retry loops are never mistaken for + // softlocks, even when the checkpoint sits within 50 px of bestX. + if (!wasPlaying) { bestX = px; lastProgress = now; } + wasPlaying = true; + // Stall detection. A big backwards jump in x means a respawn: reset + // the clock so death-retry loops aren't mistaken for softlocks. + if (px < bestX - 50) { bestX = px; lastProgress = now; } + if (px > bestX + 30) { bestX = px; lastProgress = now; } + if (now - lastProgress > 10_000) return done('stall', 'x=' + Math.round(px)); + + let wantRight = true, wantLeft = false; + let wantJump = false; // full-height jump (also used by boss/wall/hazards) + + // --- Jump physics (matches the engine: v0=625, g=1500, run=285, drag) --- + // Horizontal reach of a jump that lands on a surface rise px above the + // feet (negative = below). Calibrated to the measured 233 px flat jump. + const reach = (rise, short) => { + if (short) { + if (rise > 68) return 0; // a 100 ms hop peaks at ~68 px + const t = 0.233 + Math.sqrt(2 * (68.3 - rise) / 1500); + return 285 * t * 0.98; + } + const disc = 625 * 625 - 4 * 750 * rise; + if (disc < 0) return 0; + return 285 * ((625 + Math.sqrt(disc)) / 1500) * 0.98; + }; + + // px window in which a jump of reach d lands well inside t. The engine + // accepts edge overlap, but a near-edge landing can be side-resolved + // before the descending body reaches the platform top. Wide banks can + // afford a larger margin; narrow ledges keep a usable aiming window. + const okWindow = (t, d) => { + const margin = t.w >= 220 ? 60 : 24; + return [t.x + margin - d, t.x + t.w - margin - d]; + }; + const inWin = (w) => !!w && px >= w[0] && px <= w[1]; + + // True when some platform's underside lies in this jump's ascent band + // (body top rises from stand.y-42 up to apex px) and its x-range meets + // the body's sweep (the body travels ~0.45 px right per px of rise), + // which would clip the head, zero vy, and kill the jump. + const headBlocked = (apex, ignored = null) => { + if (!stand) return false; + const top = stand.y - 46; + const lo = top - apex; + for (const pl of lvl.platforms) { + if (pl.active === false) continue; + // The platform being targeted is already above the player's head + // when the jump begins; its underside is a landing surface, not a + // ceiling that should veto the route. + if (ignored && (pl === ignored || pl === ignored.platform || + (pl.x === ignored.x && pl.y === ignored.y && pl.w === ignored.w))) continue; + const b = pl.y + pl.h; + if (b >= top - 2 || b <= lo) continue; + const travel = 0.45 * (top - b); + if (pl.x + pl.w > p.x - 2 && pl.x < p.x + p.w + travel) return true; + } + return false; + }; + + // Measured velocity (px/s) of a mover, stashed on the platform object + // so the jump planner can predict where it will be at landing. + const moverVel = (pl) => { + const tNow = performance.now(); + if (pl.__pt && tNow - pl.__pt < 500) { + const dt = (tNow - pl.__pt) / 1000; + pl.__vx = (pl.x - pl.__px) / dt; + pl.__vy = (pl.y - pl.__py) / dt; + } + pl.__pt = tNow; pl.__px = pl.x; pl.__py = pl.y; + return { x: pl.__vx || 0, y: pl.__vy || 0 }; + }; + + // Sampled flight-vs-enemy test. Predicts each patrolling enemy along its + // patrol (sin wave for pteros, linear + bounds for walkers) and checks + // for body contact every ~8 px along the jump, touchdown included — a + // hit at any point is knockback that shaves the landing (often into a + // hazard). short=true switches to the 100 ms-hold arc, modeled at the + // frame-quantized worst case (the release is processed one frame after + // keyup, so the cut lands ~117 ms in: vy ~= 189, apex ~75 px, not 68). + const arcBlocked = (d, rise, short, ignoredEnemy = null) => { + const feetAt = (s) => { + if (short && s > 32.6) { + const t = (s - 32.6) / 279; + return feet - 62.7 - (189 * t - 750 * t * t); + } + const t = s / 279; + return feet - (625 * t - 750 * t * t); + }; + for (const e of lvl.enemies) { + if (e.dead || e === ignoredEnemy) continue; + const sm = e.speedMult || 1; + for (let s = 0; s <= d; s += 8) { + const t = s / 279; + let ex, ey; + if (e.type === 'ptero') { + const ph = e.phase + t * sm; + ex = e.ax + Math.sin(ph * 0.9 + e.phase0) * e.range; + ey = e.ay + Math.sin(ph * 1.7 + e.phase0 * 2) * 46; + } else if (e.type === 'beetle') { + ex = Math.max(e.minX, Math.min(e.maxX - e.w, e.x + e.dir * 62 * sm * t)); + ey = e.y; + } else if (e.type === 'trike') { + ex = Math.max(e.minX, Math.min(e.maxX - e.w, e.x + (e.vx || 0) * t)); + ey = Math.max(e.y, e.spawnY); // hops make y unpredictable; stay conservative + } else { + ex = e.x; ey = e.y; + } + const bFeet = feetAt(s); + // True player box (17 half-width, 46 tall) plus a ~10 px margin: + // the enemy's predicted position drifts a few px with frame delay, + // and a near-miss still clips Rex mid-flight, so bias to block. + if (Math.abs(px + s - (ex + e.w / 2)) < 17 + e.w / 2 + 10 && + bFeet - 54 < ey + e.h + 8 && bFeet > ey - 8) return true; + } + } + return false; + }; + + // Onward reach: the px band on pl from which a jump still lands on some + // forward platform — a hop into a narrow platform must not overshoot + // the next window (a landing past it leaves no onward move). + const onwardWindow = (pl) => { + let lo = Infinity, hi = -Infinity; + for (const q of lvl.platforms) { + if (q === pl || q.active === false || q.type === 'mover') continue; + const gap = q.x - (pl.x + pl.w); + // Overlapping platforms are valid onward routes (notably the + // static ledge into an x-mover). Only ignore platforms that end + // well before this one or begin too far beyond its far edge. + if (q.x < pl.x - 40 || gap > 270) continue; + const r = pl.y - q.y; + if (r > 130 || r < -140) continue; + for (const d of [reach(r, false), reach(r, true)]) { + if (d <= 0) continue; + const w = okWindow(q, d); + const a = Math.max(w[0], pl.x + 2), b = Math.min(w[1], pl.x + pl.w - 2); + if (b > a) { lo = Math.min(lo, a); hi = Math.max(hi, b); } + } + } + return hi > lo ? [lo, hi] : null; + }; + + // Jump the first tick the landing would sit on target. Runs right to + // the window, prefers the quick short hop, and holds at a stand edge when + // only an x-oscillating mover can catch us. E = px where body leaves stand. + const aimAt = (target, rise, E) => { + // stand is null when grounded with < 2 px overlap (edge landing); + // fall back to the foot level so callers outside the if(stand) block + // (the enemy rule) cannot crash here. + const standY = stand ? stand.y : feet; + // An x-mover whose top is at or just below foot level can be boarded + // by walking off the stand edge — no jump, no prediction error. Hold + // at the edge until it swings under us. + if (target.type === 'mover' && (target.axis || 'x') === 'x' && target.cx !== undefined) { + const drop = target.cy - standY; + if (drop >= 0 && drop <= 55) { + if (target.cx < E + 23 && target.cx + target.w > px + 20) { return 'run'; } + // Brake before the edge while an x-mover is still out of reach. + // Letting residual momentum carry Rex past the stand turns a + // recoverable timing wait into an unrecoverable fall. + if (px + 23 < E) return E - (px + 23) > 55 ? 'run' : 'wait'; + return 'wait'; + } + } + // A spitter on the same ledge within ~100 px ahead clips every forward + // jump arc (its body sits in the takeoff band). Back off until the arc + // clears; on a narrow ledge the back-off walk drops to the ground below, + // where its lob flies over Rex's head. + for (const e of lvl.enemies) { + if (e.dead || e.type !== 'spitter') continue; + const sd = e.x + e.w / 2 - px; + if (sd > 10 && sd < 100 && Math.abs(e.y - p.y) < 70) return 'backoff'; + } + // The reach model assumes full run speed. After knockback the bot fires + // gap jumps too early and lands short in the pit; keep running until + // vx recovers. + // A recent enemy hop or landing can leave Rex a few px below top + // speed; that is still enough for the calibrated jump reach. Waiting + // for the exact cap risks walking off a ledge before the next tick. + if (p.vx < 250) return 'run'; + const dFull = reach(rise, false); + const dShort = reach(rise, true); + const wF = dFull > 0 ? okWindow(target, dFull) : null; + const wS = dShort > 0 ? okWindow(target, dShort) : null; + // The landing model is optimistic by ~24 px near a target's edge: a + // window landing predicted within 24 px of a STATIC target's edge + // usually misses and drops into the gap (or under a floating target). + // Keep running — the edge/spring rules handle the run-off. + const supWin = (d) => d > 0 && + (target.type === 'mover' || + (px + d >= target.x + 24 && px + d <= target.x + target.w - 24)); + if (target.type !== 'mover' && !supWin(dFull) && !supWin(dShort)) return 'run'; + if (px + 23 <= E) { + const fullArc = wF ? arcBlocked(dFull, rise, false) : false; + const shortArc = wS ? arcBlocked(dShort, rise, true) : false; + if (inWin(wS) && !headBlocked(76, target)) { + if (!shortArc) { doJump(100, 'aimShort'); return 'jumped'; } + // Short hop clipped: use the full window if it is open and clear. + if (inWin(wF) && !headBlocked(130, target) && !fullArc) { doJump(350, 'aimFull'); return 'jumped'; } + // Otherwise hold here until the short arc clears (the flyer's + // bobbing lifts it out of the band within a couple of seconds). + if (target.x !== arcHoldKey) { arcHoldKey = target.x; arcHoldT0 = now; } + if (now - arcHoldT0 < 4000) { return 'wait'; } + doJump(100, 'arcTimeoutS'); return 'jumped'; + } + if (inWin(wF) && !headBlocked(130, target)) { + if (!fullArc) { doJump(350, 'aimFull'); return 'jumped'; } + // A low hop can slip under a flyer a full jump would clip: skip + // the full window and run for the short one instead. + if (dShort > 0 && wS && wS[0] > px - 2 && wS[0] < px + 60 && !shortArc) { + return 'run'; + } + // Otherwise hold at the window until the enemy leaves the arc + // (a patrol passes in under a second); if it lingers, take the + // clear option or gamble. + if (target.x !== arcHoldKey) { arcHoldKey = target.x; arcHoldT0 = now; } + if (now - arcHoldT0 < 4000) { return 'wait'; } + if (!shortArc) { doJump(100, 'arcTimeoutS'); return 'jumped'; } + doJump(350, 'arcTimeout'); return 'jumped'; + } + } + if (px + 23 >= E) { + // Short hop first: at the stand edge a full hop tends to overshoot + // the next platform's onward window; the short hop lands closer. + if (inWin(wS) && !headBlocked(76, target) && !arcBlocked(dShort, rise, true)) { doJump(100, 'aimShortEdge'); return 'jumped'; } + if (inWin(wF) && !headBlocked(130, target) && !arcBlocked(dFull, rise, false)) { doJump(350, 'aimFullEdge'); return 'jumped'; } + if (target.type === 'mover' && (target.axis || 'x') === 'x') return 'wait'; + // past every window: best-effort jump, but only when the predicted + // landing is supported and the head clears; otherwise walk off the + // edge and fall below. + const landX = px + reach(rise, false); + const landY = standY - rise; // land rise px above the feet (below if negative) + // Landing error is about 13 px, so the predicted landing center + // must sit well INSIDE a platform — a landing near or past the + // edge drops off it (into the pit/lava below). + const supported = lvl.platforms.some((pl) => + pl.active !== false && + landX >= pl.x + 15 && landX <= pl.x + pl.w - 15 && + Math.abs(pl.y - landY) <= 24); + if (!supported || headBlocked(130, target) || arcBlocked(dFull, rise, false)) return 'run'; + + doJump(350, 'aimBest'); // last-resort best effort + return 'jumped'; + } + return 'run'; // keep running right to the takeoff window + }; + + const stand = p.grounded + ? lvl.platforms.find((pl) => + pl.active !== false && + p.x + p.w > pl.x + 2 && p.x < pl.x + pl.w - 2 && Math.abs(feet - pl.y) < 8) + : null; + + // --- Crumble chain ---------------------------------------------------------- + // Crumble slabs stop being solid the moment Rex lands on them, so he + // can never stand still on one: the tick after landing is grounded + // (stale) with no stand while coyote is still alive. Two press gates, + // each stamping the engine's jump buffer so the press converts into a + // hop: + // a) the stale-grounded tick right after a slab landing (coyote), + // b) mid-fall with the next slab's top within 40 px below the feet + // (the stamp is still fresh when the landing converts it). + // crumbleJump holds Space 450 ms so the key is still down when the + // buffered press converts — an early keyup would cut the jump height. + if (!stand) { + const slab = lvl.platforms.find((pl) => + pl.type === 'crumble' && + pl.x < p.x + p.w - 4 && pl.x + pl.w > p.x + 4 && + pl.y >= feet - 60 && pl.y <= feet + 50); + const ccNext = slab ? lvl.platforms.some((pl) => + pl.active !== false && + pl.x >= p.x + p.w - 20 && pl.x < p.x + p.w + 280 && + Math.abs(pl.y - slab.y) <= 60) : false; + if (slab) { + const next = lvl.platforms.some((pl) => + pl.active !== false && + pl.x >= p.x + p.w - 20 && pl.x < p.x + p.w + 280 && + Math.abs(pl.y - slab.y) <= 60); + const gateA = p.grounded; // stale tick on a just-deactivated slab + const gateB = !p.grounded && p.vy > 0 && + feet >= slab.y - 40 && feet <= slab.y + 30; + if (next && (gateA || gateB)) { + wantRight = true; wantLeft = false; + crumbleJump('crumbleChain'); + } + } + } + + // --- Pressure plate / door ------------------------------------------------- + // The door latches only when FULLY open (open >= 1, a 0.5 s cycle) and + // starts closing the moment the plate is released, so the bot must hold + // the plate for the entire open cycle — no early exit, or the door + // oscillates and the bot ping-pongs between plate and gate forever. + // Claim the plate before the lower-floor drop rule sees the ledge as + // optional. The second gate is intentionally a little farther from its + // approach edge than the first, so include the full plate approach. + const door = lvl.doors.find((d) => !d.latched && d.x + d.w / 2 > px && d.x + d.w / 2 < px + 520); + let holding = false, waitHold = false; + if (door) { + const plate = door.plate; + // Take over only when the bot is at plate level (on the ledge the plate + // sits on) or the plate is already pressed. Below the ledge, walking + // with jumps disabled would stick the bot to the ledge wall — let the + // normal target logic step it up instead. + const atPlateLevel = !!plate && !!stand && Math.abs(stand.y - plate.y) < 8 && + px > plate.x - 70 && px < plate.x + plate.w + 70; + if (plate && (plate.pressed || atPlateLevel)) { + // Counter-steer while momentum is carrying Rex across the small + // pressure-plate zone; friction alone can slide him off before the + // door finishes its latch cycle. + const cx = plate.x + plate.w / 2; + if (px < cx - 8 || p.vx < -70) { wantRight = true; wantLeft = false; } + else if (px > cx + 8 || p.vx > 70) { wantRight = false; wantLeft = true; } + else { wantRight = false; wantLeft = false; } + wantJump = false; holding = true; + } + } + + // Daily Rex ground segments are deliberately connected by short pits. + // Take a dedicated, margin-aware jump for those gaps before optional + // ledges can steal the target and leave Rex running off the bank. + if (mode === 'daily' && stand && p.grounded && !holding && !waitHold && !wantJump) { + const edge = stand.x + stand.w; + const nextGround = lvl.platforms + .filter((pl) => pl.active !== false && pl.type === 'ground' && pl.x >= edge - 8) + .sort((a, b) => a.x - b.x)[0]; + const gap = nextGround ? nextGround.x - edge : 0; + const landing = nextGround ? nextGround.x + 36 : 0; + const takeoff = landing - reach(0, false); + if (nextGround && gap > 0 && gap <= 180 && px >= takeoff && px <= edge - 10) { + fullJump('dailyGap'); + } + } + + // --- Boss (Molten Nest) ---------------------------------------------------- + // The Magma King charges toward whichever side Rex is on and only + // staggers (stompable) when he clamps at a wall. His minX (2400) keeps + // his body 100px clear of the entry pocket (2248..2300), so the pocket + // is always body-safe. The fight: + // • hold the pocket until he staggers at the left wall, then full-jump + // right off it — the descent crosses his stomp band over his left + // half (stomp) and the bounce carries Rex to his right side; + // • from there, hop left back over him (a clank, no damage) and settle + // in the pocket again. One stomp per stagger, three to kill. + const boss = lvl.boss; + const ARENA_L = 2244, POCKET_L = 2248, POCKET_R = 2300; + const inArena = px >= ARENA_L; + if (boss && boss.state === 'dying') { + // Gate just opened — run to the nest, don't go back to the pocket. + wantRight = true; wantLeft = false; + } else if (boss && !boss.dead) { + if (!inArena) { + // Entry: the pocket is body-safe (boss minX 2400), so just run in — + // the hazard-jump clears the entry lava and lands Rex in the pocket. + if (px > 1990 && px < ARENA_L) { wantRight = true; wantLeft = false; } + } else { + const bossL = boss.x, bossR = boss.x + boss.w; + const stompWindow = boss.vulnerable && boss.x < 2600; + if (stompWindow) { + // Stomp: build rightward speed in the pocket and launch into the + // boss's left half. If Rex is already past him (a chained bounce), + // swing back left over him first. + if (px - 17 >= bossR) { wantLeft = true; wantRight = false; } + else if (px < POCKET_L) { wantRight = true; wantLeft = false; } + else if (p.grounded && p.vx > 150) fullJump('bossStomp'); + else { wantRight = true; wantLeft = false; } + } else if (px - 17 >= bossL && px > POCKET_R) { + // Over or right of the boss while he's not stompable: hop left + // back over him (clanks off his top, no damage) toward the pocket. + if (p.grounded) fullJump('returnPocket'); + wantLeft = true; wantRight = false; + } else { + // Everything else (walk, telegraph, charge, right-side stagger): + // hold the pocket — the only body-safe side. + if (px > POCKET_R) { wantLeft = true; wantRight = false; } + else if (px < POCKET_L) { wantRight = true; wantLeft = false; } + else { wantLeft = false; wantRight = false; } + } + } + } + + // An elevated optional ledge can have a safe lower landing directly + // ahead. Jump to that floor rather than dropping onto an incidental + // crumble slab that physically intercepts the fall. + if (stand && !wantJump && !holding && stand.y < 460) { + const dropOptions = [ + { x: px + reach(0, false), jump: fullJump, name: 'safeDrop' }, + { x: px + reach(0, true), jump: shortJump, name: 'safeDropShort' }, + ]; + const drop = dropOptions.find((option) => { + const lower = lvl.platforms.find((pl) => + pl !== stand && pl.active !== false && + pl.y > stand.y + 24 && pl.y - stand.y <= 190 && + option.x >= pl.x + 24 && option.x <= pl.x + pl.w - 24); + if (!lower) return false; + return !lvl.hazards.some((hz) => + hz.type !== 'rocks' && hz.rect && + hz.rect.x < option.x + 20 && hz.rect.x + hz.rect.w > option.x - 20 && + hz.rect.y < lower.y + 12 && hz.rect.y + hz.rect.h > stand.y); + }); + if (drop && px + 23 < stand.x + stand.w - 18 && p.vx >= 272) { + drop.jump(drop.name); + } + } + + // --- Targeted jump (gaps, steps, stone chains) ------------------------------ + // Find the closest forward target — static platforms first (only if a + // landing actually fits from this stand; movers swing in, so they are the + // fallback) — and take off inside the window that lands on it. + let target = null; + if (stand && !wantJump && !holding) { + const edge = stand.x + stand.w; + const standLo = stand.x + 23, standHi = edge - 23; + const jumpable = (pl, rise) => { + for (const d of [reach(rise, false), reach(rise, true)]) { + if (d <= 0) continue; + const w = okWindow(pl, d); + // The window must still be ahead of the body's center — a window + // already behind us is a dead target (inWin compares px, not the + // body edge, so px-space is the right liveness test). + if (w && w[0] < standHi && w[1] > px) return true; + } + return false; + }; + // A platform with no way on from its far edge is a trap — running off + // it drops you in a hazard. Used to skip optional step-up detours. + const isDeadEnd = (pl, depth) => { + if (depth > 2) return false; + // A platform containing the goal is a valid route endpoint even + // when the level intentionally has no further platform exit. + if (lvl.goal && lvl.goal.x >= pl.x && lvl.goal.x <= pl.x + pl.w) return false; + let exits = 0, dead = 0; + for (const q of lvl.platforms) { + if (q === pl || q.active === false) continue; + const gap = q.x - (pl.x + pl.w); + if (gap < -8 || gap > 270) continue; + const rise = pl.y - q.y; + if (rise > 130 || rise < -200) continue; + exits++; + if (isDeadEnd(q, depth + 1)) dead++; + } + return exits === 0 || dead === exits; + }; + const findTarget = (moversOnly) => { + // A step-up whose jump window overlaps this stand means backing up + // is a live plan — so a lower decoy across a hazard gap can be + // skipped. When the drop is the only way forward there is no such + // window and the target must be kept. + let stepUpWindow = false; + if (!moversOnly) { + for (const q of lvl.platforms) { + if (q === stand || q.active === false) continue; + const riseU = stand.y - q.y; + if (riseU <= 0 || riseU > 130) continue; + const gapU = q.x - edge; + const upU = q.x + q.w > p.x + p.w; + const minGapU = upU ? -Infinity : -8; + if (gapU < minGapU || gapU > 270) continue; + const dU = reach(riseU, false); + if (dU <= 0) continue; + const wU = okWindow(q, dU); + if (wU && wU[1] > standLo && wU[0] < standHi) { stepUpWindow = true; break; } + } + } + let t = null, bestGap = Infinity; + for (const pl of lvl.platforms) { + if (pl === stand || pl.active === false) continue; + if (pl.type === 'door') continue; // a gate the bot passes through, never a landing + // A spring pad on the run path to this target launches Rex onto + // it for free; aiming past the pad wastes the jump and can fly + // under a floating target (or into the gap beyond). + if (!moversOnly && (lvl.springs || []).some((s) => + s.x - 4 >= p.x + p.w - 20 && s.x - 4 <= pl.x && + s.y + 4 > stand.y - 30 && s.y - 26 < stand.y + 40)) continue; + const isMover = pl.type === 'mover'; + if (moversOnly !== isMover) continue; + const gap = pl.x - edge; + const rise = stand.y - pl.y; // >0 = target is higher + // A platform above the stand is a step-up target (crumble/ledge + // hops) even when it starts before the stand's edge; the window + // check below still enforces real reachability. + const stepUp = !moversOnly && rise > 0 && pl.x + pl.w > p.x + p.w; + const minGap = stepUp ? -Infinity : (moversOnly ? -40 : -8); + if (gap < minGap || gap > 270 || gap >= bestGap) continue; + if (rise > 130 || rise < -200) continue; + if (stepUp) { + if ((pl.type || 'ground') === 'crumble') { + // A crumble crumbles on touch: take it only when the floor below + // catches the drop and the shaft is hazard-free — otherwise it is + // a one-way trip into the pit. + const below = lvl.platforms.some((q) => + q.active !== false && q !== pl && q.y > pl.y + 24 && q.y <= pl.y + 170 && + q.x < pl.x + pl.w - 10 && q.x + q.w > pl.x + 10); + // A crumble bridge spans an open gap (no floor below), but the next + // slab/bank within hop reach carries the chain across (crumbleChain). + const chained = lvl.platforms.some((q) => + q.active !== false && q !== pl && + q.x >= pl.x + pl.w - 20 && q.x < pl.x + pl.w + 280 && + Math.abs(q.y - pl.y) <= 60); + const shaftHazard = lvl.hazards.some((hz) => + hz.type !== 'rocks' && hz.rect && hz.rect.y > pl.y && hz.rect.y < pl.y + 200 && + hz.rect.x < pl.x + pl.w - 8 && hz.rect.x + hz.rect.w > pl.x + 8); + if ((!below && !chained) || shaftHazard) continue; + } else if (isDeadEnd(pl, 0) && + !(pl.x < edge && pl.x + pl.w > p.x + p.w)) { + continue; // optional detour into a trap + } + } + // A lower platform reached across a gap that holds a hazard is a + // trap: a hop from the stand edge re-lands on the stand's sliver + // and drifts off into the hazard. Skip it only when a step-up's + // window overlaps this stand (backing up is a live plan); when the + // drop is the only way forward the target must be kept. + if (!moversOnly && rise < -24) { + const gapHazard = lvl.hazards.some((hz) => + hz.type !== 'rocks' && hz.rect && + hz.rect.y >= stand.y && + hz.rect.x < pl.x + 20 && hz.rect.x + hz.rect.w > edge - 20); + if (gapHazard && stepUpWindow) continue; + } + if (!moversOnly && !jumpable(pl, rise)) continue; // static must fit from here + bestGap = gap; t = pl; + } + return t; + }; + target = findTarget(false) || findTarget(true); + // A near-max ground gap with a mover in its flight path is a bridge + // route, not a blind leap to the far bank. Use the mover explicitly + // so its timing and landing margins remain under planner control. + if (target && target.type !== 'mover' && stand && target.y === stand.y && + target.x - (stand.x + stand.w) > 150) { + target = findTarget(true) || target; + } + // A low mover can sit in the direct jump's head path. Prefer that + // mover as the intended bridge instead of running off the edge while + // waiting for the static landing route to clear. + if (target && target.type !== 'mover' && headBlocked(130, target)) { + target = findTarget(true) || target; + } + if (target) { + if (target.type === 'mover') { + // Predict where the mover will be when this flight lands so the + // takeoff window tracks its drift (measured velocity, linear + // over the flight). + const mv = moverVel(target); + const tF = Math.max(reach(stand.y - target.y, false), reach(stand.y - target.y, true), 1) / 279; + const cx = target.x, cy = target.y; // current body (for walk-catch) + target = { x: target.x + mv.x * tF, y: target.y + mv.y * tF, w: target.w, type: 'mover', axis: target.axis, cx, cy, platform: target }; + } + const aim = aimAt(target, stand.y - target.y, edge - 23); + if (aim === 'wait') { + waitHold = true; + // Counter-steer at a stand edge so braking actually takes effect + // before the next tick can move Rex into the gap. + wantLeft = p.vx > 25 || (stand && px + 23 >= stand.x + stand.w - 30); + wantRight = !wantLeft && p.vx < -25; + } + else if (aim === 'backoff') { wantLeft = true; wantRight = false; waitHold = true; } + } else if (stand.type !== 'mover' && wantRight) { + const toEdge = edge - (p.x + p.w); + // A spring pad on the run path launches Rex before the edge matters; + // an edge-jump would skip over it. Walk into the spring instead. + const springOnPath = (lvl.springs || []).some((s) => + s.x - 4 <= edge + 40 && s.x + 52 >= p.x + p.w - 12 && + s.y + 4 > p.y && s.y - 26 < p.y + p.h); + if (toEdge <= 70 && springOnPath) { } + else if (toEdge <= 70) { + // A spring/moving platform may be waiting out there — jump only + // when the predicted landing is actually supported, otherwise walk + // off the edge and fall to whatever is below. + const landX = px + reach(0, false); + const supported = lvl.platforms.some((pl) => + pl.active !== false && + landX >= pl.x + 15 && landX <= pl.x + pl.w - 15 && + Math.abs(pl.y - stand.y) <= 24); + if (supported && p.vx >= 272) { fullJump('noTargetEdge'); } + else { + // Walk off the right edge: does the drop land on a floor below? + // A jump would overshoot the drift, so this is a run-off. + let edgeDrop = false; + for (const pl of lvl.platforms) { + if (pl.active === false || pl.type === 'mover') continue; + if (pl.y <= stand.y + 24 || pl.y > stand.y + 300) continue; + const t = Math.sqrt(2 * (pl.y - stand.y) / 1500); + const xLand = edge + 21 + 279 * t; + if (xLand < pl.x + 15 || xLand > pl.x + pl.w - 15) continue; + if (pl.type === 'crumble' && !lvl.platforms.some((q) => + q.active !== false && q !== pl && q.y > pl.y + 24 && q.y <= pl.y + 170 && + q.x < pl.x + pl.w - 10 && q.x + q.w > pl.x + 10)) continue; + const shaftHazard = lvl.hazards.some((hz) => + hz.type !== 'rocks' && hz.rect && hz.rect.y >= stand.y && hz.rect.y < pl.y && + hz.rect.x < xLand + 21 && hz.rect.x + hz.rect.w > edge - 21); + if (shaftHazard) continue; + edgeDrop = true; break; + } + if (edgeDrop) { } + else { + // Dead end ahead: if the floor below the LEFT edge is safe, + // back up and drop off that way instead of drifting into the + // pit in front. + const leftSup = lvl.platforms.some((pl) => + pl.active !== false && pl.type !== 'mover' && + pl.y > stand.y + 24 && pl.y <= stand.y + 170 && + pl.x < stand.x + 46 && pl.x + pl.w > stand.x - 60); + const leftHazard = lvl.hazards.some((hz) => + hz.type !== 'rocks' && hz.rect && hz.rect.y > stand.y && hz.rect.y < stand.y + 200 && + hz.rect.x < stand.x + 40 && hz.rect.x + hz.rect.w > stand.x - 70); + if (leftSup && !leftHazard) { wantLeft = true; wantRight = false; } + else { } + } + } + } + // else: run on (crumble chains, flat runs) + } else if (stand.type === 'mover' && !target) { + wantRight = false; wantLeft = false; // ride until a target line opens + } + } + + // --- Wall ahead: a solid face at body height (crumble steps, ledges) ---------- + // The run lane is blocked when a platform's left face is just in front of + // Rex and its top is above foot level. No auto step-up exists, so any + // such face needs a jump. + if (wantRight && p.grounded && !wantJump) { + for (const pl of lvl.platforms) { + if (pl.active === false) continue; + const ahead = pl.x - (p.x + p.w); + if (ahead < -8 || ahead > 34) continue; + if (pl.y >= feet - 4) continue; // floor we're standing on / step down + if (pl.y + pl.h <= p.y + 4) continue; // floats above the head + wantJump = true; + break; + } + } + + // --- Ground-level hazards ahead ---------------------------------------------- + // Jump only once the full-jump landing comes down past the hazard end. + // If a full jump would bonk its head on a floating platform above, fall + // back to a short hop once its landing clears the hazard; otherwise run + // one more tick and re-evaluate. + // Falling rocks expose a separate warning timer instead of a ground + // hitbox. Treat the telegraph and any descending rock as a jump cue so + // browser hardening exercises the intended readable counterplay. + if (p.grounded && !holding && !waitHold && !wantJump) { + for (const h of lvl.hazards) { + if (h.type !== 'rocks') continue; + const rockAhead = h.warnTimer > 0 + ? h.warnX > px - 35 && h.warnX < px + 210 + : h.rocks.some((r) => r.x > px - 35 && r.x < px + 210 && r.y > -10); + if (!rockAhead) continue; + const landX = px + reach(0, false); + if (supportedAt(landX) && !headBlocked(130) && !arcBlocked(reach(0, false), 0, false)) { + fullJump('rockfall'); + break; + } + } + } + for (const h of lvl.hazards) { + const r = h.rect; + if (h.type === 'rocks') continue; + // Lava hitboxes sit below the ground line, so they are not inside the + // normal spike band until Rex has already stepped off the bank. When a + // predicted full jump lands on a platform beyond the lava's near edge, + // leave early enough to use that platform instead of treating the + // whole lava span as a single blind gap. + const lavaAhead = h.type === 'lava' && r.y < feet + 80; + const groundHazard = r.y + r.h > feet - 14 && r.y < feet + 24; + if (lavaAhead || groundHazard) { + const d = r.x - px; + if (!(d > -12 && d < 150 && p.grounded && !waitHold)) continue; + if (px + 23 > r.x + r.w) continue; // body already past the hazard + const dFull = reach(0, false); + const dShort = reach(0, true); + if (lavaAhead && px + dFull > r.x + 10 && safelySupportedAt(px + dFull) && + !headBlocked(130) && !arcBlocked(dFull, 0, false)) { + fullJump('lavaRoute'); + break; + } + const need = r.x + r.w + 25 - (dFull - 23); // landing 25 px past the hazard + if (!headBlocked(130)) { + if (px >= need && p.vx >= 272 && supportedAt(px + dFull)) { fullJump('hazard'); break; } + } else if (p.vx >= 272 && px + dShort - 23 >= r.x + r.w && supportedAt(px + dShort)) { + shortJump('hazardShort'); break; + } + } + } + + // --- Ground enemy pre-hop -------------------------------------------------------- + // A patrolling beetle/trike in the run lane costs a heart and the knockback + // shaves the next gap jump. A cheap short hop well before contact clears it + // while preserving run speed — but only when the whole landing box comes + // down well INSIDE a solid platform (never on a gap edge) with no hazard + // in the landing band. + if (p.grounded && !holding && !waitHold && !wantJump) { + for (const e of lvl.enemies) { + if (e.dead || (e.type !== 'beetle' && e.type !== 'trike')) continue; + const d = e.x + e.w / 2 - px; + if (!(d > 30 && d < 200) || Math.abs(e.y - p.y) >= 70) continue; + // Use a full hop when the enemy is still far enough away that the + // short arc would land inside its patrol. Keep the short hop for + // close encounters so the bot does not overshoot a ledge. + // Trikes hop unpredictably and can drift toward Rex between planner + // ticks, so always use the long arc when there is a safe landing. + let longHop = e.type === 'trike' || d > 120; + let jumpReach = reach(0, longHop ? false : true); + if (jumpReach <= 0) continue; + const landingOnSolid = (distance) => lvl.platforms.some((pl) => + pl.active !== false && (pl.type || 'ground') !== 'crumble' && + pl.x + 8 <= px + distance - 23 && pl.x + pl.w - 8 >= px + distance + 23 && + Math.abs(pl.y - feet) <= 30); + // A distant walker normally gets a full hop, but a full arc can + // overshoot the shelf Rex is standing on near a gap. Prefer a + // short hop when it still clears the enemy and lands on solid ground. + if (longHop && !landingOnSolid(jumpReach)) { + const shortReach = reach(0, true); + if (shortReach > 0 && landingOnSolid(shortReach)) { + longHop = false; + jumpReach = shortReach; + } + } + const landX = px + jumpReach; + const landOk = landingOnSolid(jumpReach); + if (!landOk) continue; + const hazardIn = lvl.hazards.some((hz) => hz.type !== 'rocks' && hz.rect && + hz.rect.y + hz.rect.h > feet - 14 && hz.rect.y < feet + 24 && + hz.rect.x < landX + 23 && hz.rect.x + hz.rect.w > landX - 23); + if (hazardIn) continue; + if (arcBlocked(jumpReach, 0, !longHop, e)) continue; + if (longHop) fullJump('enemyHop'); + else shortJump('enemyHop'); + break; + } + } + + // Emergency walker hop: when a walker is closing inside the next + // short-hop window, a conservative arc-block result must not leave Rex + // walking into it (especially when the walker guards a spike exit). + // Prefer the full arc when its landing is solid and hazard-free. + if (p.grounded && !holding && !waitHold && !wantJump) { + for (const e of lvl.enemies) { + if (e.dead || (e.type !== 'beetle' && e.type !== 'trike')) continue; + const d = e.x + e.w / 2 - px; + if (!(d > 40 && d < 155) || Math.abs(e.y - p.y) >= 80) continue; + const full = reach(0, false); + const landX = px + full; + const landingOnSolid = lvl.platforms.some((pl) => + pl.active !== false && + (pl.type || 'ground') !== 'crumble' && + pl.x + 8 <= landX - 23 && + pl.x + pl.w - 8 >= landX + 23 && + Math.abs(pl.y - feet) <= 30); + const hazardIn = lvl.hazards.some((hz) => hz.type !== 'rocks' && hz.rect && + hz.rect.y + hz.rect.h > feet - 14 && hz.rect.y < feet + 24 && + hz.rect.x < landX + 23 && hz.rect.x + hz.rect.w > landX - 23); + if (landingOnSolid && !hazardIn && !headBlocked(130)) { + fullJump('enemyEmergency'); + break; + } + } + } + + // --- Enemies ahead ------------------------------------------------------------- + // If a platform sits under the jump arc, land on it deterministically — + // that's the route past the enemy (e.g. the stone chain over the trike). + // Otherwise take a full jump once the landing is safe, never from a spot + // that lands in a ground gap. + for (const e of lvl.enemies) { + if (e.dead) continue; + const d = e.x + e.w / 2 - px; + if (!(d > 0 && d < 450) || Math.abs(e.y - p.y) >= 80 || !p.grounded || !stand) continue; + if (holding) continue; // holding for the plate / door + if (target) continue; // any target means the planner owns the timing + const under = lvl.platforms.find((pl) => + pl.active !== false && (pl.type || 'ground') !== 'ground' && pl.type !== 'mover' && + pl.x < px + 280 && pl.x + pl.w > px + 30 && + pl.y <= feet - 20 && pl.y >= feet - 130) || + lvl.platforms.find((pl) => + pl.active !== false && (pl.type || 'ground') !== 'ground' && + pl.x < px + 280 && pl.x + pl.w > px + 30 && + pl.y <= feet - 20 && pl.y >= feet - 130); + if (under) { + const r = aimAt(under, feet - under.y, stand ? stand.x + stand.w - 23 : px); + if (r === 'wait') wantRight = false; + } else { + // Blind jump only when a static platform catches the landing — a + // mover may have drifted away by touchdown. + const staticSup = lvl.platforms.some((pl) => + pl.active !== false && (pl.type || 'ground') !== 'crumble' && pl.type !== 'mover' && + pl.x < px + 256 && pl.x + pl.w > px + 210 && Math.abs(pl.y - feet) <= 30); + if (staticSup) fullJump('enemyReact'); + } + } + + // --- Glob dodge ------------------------------------------------------------------ + // A glob at body height (spitter lob at descent, boss spread) is a hit if + // Rex stays put. A full jump clears it when the landing is supported; + // skip rising lobes (apex sits above the jump) and globs not closing in. + if (p.grounded && !holding && !waitHold && !wantJump) { + for (const pr of lvl.projectiles) { + if (!pr || pr.dead) continue; + const ddx = pr.x - px; + const ddy = pr.y - (feet - 11); + if (!(ddx > -40 && ddx < 170) || !(ddy > -40 && ddy < 45)) continue; + if (pr.vy < -80) continue; // rising lob: apex above the jump + const closing = (pr.vx < -60 && ddx > 0) || (pr.vx > 60 && ddx < 0) || Math.abs(ddx) < 40; + if (!closing) continue; + if (!supportedAt(px + 233)) continue; + if (arcBlocked(233, 0, false)) continue; + fullJump('dodge'); + break; + } + } + + // Flyer overhead: a pteranodon's low dip clips Rex standing under its + // patrol. Hold still until it sweeps clear — on a narrow stone there is + // no room to back off. + for (const e of lvl.enemies) { + if (e.dead) continue; + const ec = e.x + e.w / 2; + if (Math.abs(ec - px) < 45 && e.y + e.h < feet - 8 && e.y + e.h > p.y - 6) { + wantLeft = false; wantRight = false; waitHold = true; + } + } + + hold('ArrowLeft', wantLeft); + hold('ArrowRight', wantRight); + if (wantJump && p.grounded) fullJump('wantJump'); + }, 50); + + // Watch for the verdict (set by the tick above). + const watch = setInterval(() => { + if (verdict) { + clearInterval(timer); + clearInterval(watch); + resolve(finish()); + } + }, 100); + + // Hard backstop so a stuck bot can never hang the harness. + setTimeout(() => { + if (!verdict) { + clearInterval(timer); + clearInterval(watch); + done('timeout', 'backstop state=' + g.state); + resolve(finish()); + } + }, maxMs + 20_000); + }) +`; + +// --------------------------------------------------------------------------- + +async function waitServer(url) { + for (let i = 0; i < 30; i++) { + try { + const r = await fetch(url); + if (r.ok) return; + } catch { /* not up yet */ } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`No preview server at ${url}. Start one first: npm run preview -- --port 4173 --strictPort`); +} + +/** + * Duskfen late-tide stress: force the water to its final level, respawn at + * the spawn point and every checkpoint, and require the bot to stand on dry + * ground (top <= 446) with forward progress within 15 s each time. + */ +async function runDuskStress(browser) { + const context = await browser.newContext({ viewport: { width: 960, height: 540 } }); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(e.stack || String(e))); + await page.goto(BASE_URL, { waitUntil: 'load' }); + await page.waitForFunction(() => window.TINY_REX && window.TINY_REX.game); + + await page.evaluate(() => { + const g = window.TINY_REX.game; + g.selectLevel(4); + window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', key: ' ', bubbles: true, cancelable: true })); + window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space', key: ' ', bubbles: true, cancelable: true })); + }); + await page.waitForFunction(() => window.TINY_REX.game.state === 'playing', null, { timeout: 8000 }); + + const cps = await page.evaluate(() => { + const g = window.TINY_REX.game; + return [ + { name: 'spawn', x: g.level.start.x, idx: 0 }, + ...g.level.checkpoints.map((c, i) => ({ name: 'cp' + (i + 1), x: c.x, idx: i + 1 })), + ]; + }); + + const results = []; + for (const cp of cps) { + await page.evaluate((idx) => { + const g = window.TINY_REX.game; + g.setCheckpointAt(idx); + g.level.waterY = g.level.tide.toY; // final waterline (452) + g.level.tideWarned = true; + if (g.state === 'playing') g.respawn(); + }, cp.idx); + const t0 = Date.now(); + let result; + try { + result = await Promise.race([ + page.evaluate(`(${BOT_SOURCE})(${JSON.stringify({ levelIdx: 4, mode: 'level', maxMs: 15_000, minProgress: 0, stress: { x: cp.x } })})`), + new Promise((_, rej) => setTimeout(() => rej(new Error('stress bot hung')), 22_000)), + ]); + } catch (e) { + result = { verdict: 'error', extra: String(e), x: 0, width: 0, ms: 0, state: '?' }; + } + const ok = result.verdict === 'safe'; + results.push({ + name: `dusk-${cp.name}`, + ok, + detail: ok ? 'safe on dry ground' : `verdict=${result.verdict} ${result.extra}`, + ms: Date.now() - t0, + }); + if (!ok) { + try { await page.screenshot({ path: `${SHOT_DIR}/dusk-${cp.name}.png` }); } catch { /* ignore */ } + break; + } + } + try { await page.screenshot({ path: `${SHOT_DIR}/dusk-stress.png` }); } catch { /* ignore */ } + await context.close(); + return { results, errors }; +} + +async function runScenario(browser, name, opts) { + const context = await browser.newContext({ viewport: { width: 960, height: 540 } }); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(e.stack || String(e))); + await page.goto(BASE_URL, { waitUntil: 'load' }); + await page.waitForFunction(() => window.TINY_REX && window.TINY_REX.game); + + let result; + try { + result = await Promise.race([ + page.evaluate(`(${BOT_SOURCE})(${JSON.stringify(opts)})`), + new Promise((_, rej) => setTimeout(() => rej(new Error('bot evaluate hung')), opts.maxMs + 25_000)), + ]); + } catch (e) { + result = { verdict: 'error', extra: String(e), x: 0, width: 0, ms: 0, state: '?' }; + } + + const shot = `${SHOT_DIR}/${name}.png`; + try { await page.screenshot({ path: shot }); } catch { /* page may be gone */ } + if (result.jumpLog) { + const { writeFileSync } = await import('node:fs'); + writeFileSync(`${SHOT_DIR}/${name}.jumps.json`, JSON.stringify(result.jumpLog, null, 1)); + } + let ok; + let detail; + if (opts.mode === 'daily') { + ok = result.verdict === 'victory' || result.verdict === 'daily-progress'; + detail = result.verdict === 'victory' + ? `victory in ${Math.round(result.ms / 1000)}s` + : result.verdict === 'daily-progress' + ? `reached ${Math.round((result.x / result.width) * 100)}% (no softlock)` + : `verdict=${result.verdict} ${result.extra}`; + } else { + ok = result.verdict === 'victory'; + detail = result.verdict === 'victory' + ? `victory in ${Math.round(result.ms / 1000)}s` + : `verdict=${result.verdict} ${result.extra}`; + } + if (errors.length) { + ok = false; + detail += ` | page errors: ${errors.join(' ; ')}`; + } + await context.close(); + return { name, ok, detail, shot, errors, debug: result.debug }; +} + +const arg = process.argv.slice(2); +const stressMode = arg.includes('dusk-stress'); +let scenarios = []; +if (stressMode) { + scenarios = []; +} else if (arg.includes('daily')) { + scenarios = [{ name: 'daily', mode: 'daily', levelIdx: 0, maxMs: DAILY_BUDGET, minProgress: DAILY_MIN_PROGRESS }]; +} else if (arg.length) { + for (const a of arg) { + const n = Number(a); + if (!Number.isInteger(n) || n < 0 || n > 4) throw new Error(`Unknown scenario: ${a}`); + scenarios.push({ name: `level-${n}`, mode: 'level', levelIdx: n, maxMs: LEVEL_BUDGET[n] }); + } +} else { + for (let n = 0; n < 5; n++) scenarios.push({ name: `level-${n}`, mode: 'level', levelIdx: n, maxMs: LEVEL_BUDGET[n] }); + scenarios.push({ name: 'daily', mode: 'daily', levelIdx: 0, maxMs: DAILY_BUDGET, minProgress: DAILY_MIN_PROGRESS }); +} + +mkdirSync(SHOT_DIR, { recursive: true }); +await waitServer(BASE_URL); +const browser = await chromium.launch({ + executablePath: CHROME, + args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'], +}); + +let failed = 0; +console.log(`\nTiny Rex playtest @ ${BASE_URL}\n`); +if (stressMode) { + const { results, errors } = await runDuskStress(browser); + for (const res of results) { + const secs = (res.ms / 1000).toFixed(1); + const detail = errors.length ? `${res.detail} | page errors: ${errors.join(' ; ')}` : res.detail; + console.log(` ${res.ok ? 'PASS' : 'FAIL'} ${res.name.padEnd(12)} ${secs.padStart(7)}s ${detail}`); + if (!res.ok || errors.length) failed++; + } +} else { + console.log(`(${scenarios.length} scenario(s))\n`); + for (const s of scenarios) { + const t0 = Date.now(); + const res = await runScenario(browser, s.name, { + levelIdx: s.levelIdx, + mode: s.mode, + maxMs: s.maxMs, + minProgress: s.minProgress ?? 0, + }); + const secs = ((Date.now() - t0) / 1000).toFixed(1); + console.log(` ${res.ok ? 'PASS' : 'FAIL'} ${res.name.padEnd(12)} ${secs.padStart(7)}s ${res.detail}`); + if (!res.ok && res.debug) console.log(' debug:', JSON.stringify(res.debug)); + if (!res.ok) failed++; + } +} +await browser.close(); + +console.log(failed ? `\n${failed} scenario(s) FAILED — screenshots in ${SHOT_DIR}` : `\nAll scenarios passed — screenshots in ${SHOT_DIR}`); +process.exit(failed ? 1 : 0); diff --git a/src/boss.ts b/src/boss.ts index 2491541..d0b3fef 100644 --- a/src/boss.ts +++ b/src/boss.ts @@ -283,6 +283,9 @@ export class MagmaKing { fireAt(player: Player): void { const g = this.level.game; + // The entry pocket is a deliberate recovery space before the fight. Do + // not aim a spread through the arena wall while Rex is still entering. + if (player.x > this.minX - 220 && player.x + player.w < this.minX - 40) return; const bx = this.x + this.w / 2; const by = this.y + 34; const px = player.x + player.w / 2; diff --git a/src/cheats.ts b/src/cheats.ts index 86738aa..b4d4423 100644 --- a/src/cheats.ts +++ b/src/cheats.ts @@ -21,8 +21,9 @@ export const CHEATS: CheatDef[] = [ { id: 'god', seq: ['left', 'left', 'right', 'right', 'up', 'up', 'primary'], windowMs: 900, cooldownMs: 3000 }, // Max hearts (one time) { id: 'maxhearts', seq: ['down', 'down', 'up', 'up', 'left', 'right', 'primary'], windowMs: 900, cooldownMs: 60000, once: true }, - // Score surge: triple-tap jump - { id: 'surge', seq: ['primary', 'primary', 'primary'], windowMs: 560, cooldownMs: 12000 }, + // Score surge: down, down, jump. 'down' is never part of normal movement, + // so rapid jumping in play can no longer false-fire the +1000 surge. + { id: 'surge', seq: ['down', 'down', 'primary'], windowMs: 900, cooldownMs: 12000 }, ]; /** diff --git a/src/daily.ts b/src/daily.ts index c5e4afd..a7dd44c 100644 --- a/src/daily.ts +++ b/src/daily.ts @@ -103,7 +103,8 @@ export function generateDailyLevel(seed: number): LevelInfo { if (i >= 1 && rnd() < 0.8) { const pw = ri(110, 130); const t1: PlatformDef = { - x: seg.x0 + ri(40, Math.max(41, w - pw - 40)), + // Leave a readable run-up after each pit before the first step. + x: seg.x0 + ri(140, Math.max(141, w - pw - 40)), y: GY - ri(50, 80), w: pw, h: 24, @@ -114,17 +115,48 @@ export function generateDailyLevel(seed: number): LevelInfo { def.crystals.push({ x: t1.x + t1.w / 2, y: t1.y - 35 }); // Tier-2 platform above a tier-1 (step ≤110px) if (rnd() < 0.45) { - const t2: PlatformDef = { - x: Math.max(seg.x0 + 20, Math.min(t1.x + ri(-120, 120), seg.x1 - 140)), - y: t1.y - ri(40, 70), - w: ri(100, 120), - h: 24, - type: 'stone', - }; - def.platforms.push(t2); - def.crystals.push({ x: t2.x + t2.w / 2, y: t2.y - 35, bonus: rnd() < 0.35 }); + // Keep the upper tier after the lower one. A tier that starts behind + // its approach slab can be mistaken for the primary target, causing + // a full jump from the floor to overshoot both platforms. + const t2Min = t1.x + t1.w + 20; + const t2Max = Math.min(t1.x + t1.w + 140, seg.x1 - 140); + if (t2Max >= t2Min) { + const t2: PlatformDef = { + x: ri(t2Min, t2Max), + y: t1.y - ri(40, 70), + w: ri(100, 120), + h: 24, + type: 'stone', + }; + def.platforms.push(t2); + def.crystals.push({ x: t2.x + t2.w / 2, y: t2.y - 35, bonus: rnd() < 0.35 }); + } } } + const floorHazard = def.hazards.find((h) => + (h.type === 'spikes' || h.type === 'rocks') && h.x >= seg.x0 && h.x < seg.x1); + if (floorHazard) { + // A floor hazard directly below a tier platform can turn the intended + // landing into an unavoidable hit. Relocate it to open floor space so + // every generated hazard retains readable counterplay. + const candidates: number[] = []; + for (let sx = seg.x0 + 40; sx <= seg.x1 - floorHazard.w - 40; sx += 10) candidates.push(sx); + const originalX = floorHazard.x; + const open = candidates + .filter((sx) => !def.platforms.some((pl) => + pl.y < GY && pl.x < sx + floorHazard.w + 12 && pl.x + pl.w > sx - 12)) + .sort((a, b) => Math.abs(a - originalX) - Math.abs(b - originalX))[0]; + if (open !== undefined) floorHazard.x = open; + else { + // A wide rockfall may have no clear floor slot in a compact segment. + // Omit it rather than making the only elevated route random damage. + const hazardIndex = def.hazards.indexOf(floorHazard); + if (hazardIndex >= 0) def.hazards.splice(hazardIndex, 1); + if (floorHazard.type === 'rocks') seg.hasRocks = false; + else seg.hasSpikes = false; + } + } + // Ground crystal (skip when spikes occupy the floor); every segment is // guaranteed at least one crystal so no daily run is content-light. const segCrystalsBefore = def.crystals.length; @@ -138,13 +170,25 @@ export function generateDailyLevel(seed: number): LevelInfo { // Enemy: one per segment, never in the first two if (i >= 2) { const roll = rnd(); - if (roll < 0.35 && seg.tier1) { - def.enemies.push({ type: 'spitter', x: seg.tier1.x + seg.tier1.w / 2, y: seg.tier1.y - 38 }); - } else if (roll < 0.55) { + if (roll < 0.55) { def.enemies.push({ type: 'ptero', x: seg.x0 + w / 2, y: ri(250, 300), range: ri(100, 150) }); } else { - const patrol = ri(120, Math.min(250, w - 120)); - const ex = seg.x0 + ri(60, Math.max(61, w - patrol - 60)); + let patrol = ri(120, Math.min(250, w - 120)); + let ex = seg.x0 + ri(60, Math.max(61, w - patrol - 60)); + // Do not place a ground walker directly under the landing line from + // an elevated ledge. The player is still airborne while dropping to + // the lower floor, so this otherwise creates an unavoidable collision + // before the enemy-hop counterplay becomes available. + const elevatedEnds = def.platforms + .filter((pl) => pl.y < GY && pl.x >= seg.x0 && pl.x < seg.x1) + .map((pl) => pl.x + pl.w); + if (elevatedEnds.length) { + const clearStart = Math.max(...elevatedEnds) + 160; + if (clearStart + patrol > seg.x1 - 24 && clearStart < seg.x1 - 124) { + patrol = Math.max(120, seg.x1 - clearStart - 24); + } + if (clearStart + patrol <= seg.x1 - 24 && ex < clearStart) ex = clearStart; + } const type = rnd() < 0.5 ? 'beetle' : 'trike'; def.enemies.push({ type, x: ex + patrol / 2, y: type === 'beetle' ? 432 : 424, minX: ex, maxX: ex + patrol }); } diff --git a/src/enemy.ts b/src/enemy.ts index 9e822f4..b5626fa 100644 --- a/src/enemy.ts +++ b/src/enemy.ts @@ -31,6 +31,8 @@ export class Enemy { range: number; /** Spitter: seconds until the next glob. */ fireCd: number = CFG.spitter.fireCd; + /** Optional per-placement range for readable, localised projectile threats. */ + private readonly fireRange: number; /** Spitter: 1 right after firing, eases to 0 (nozzle recoil/glow). */ charge = 0; /** Spitter: faces the player. */ @@ -50,6 +52,7 @@ export class Enemy { this.minX = d.minX !== undefined ? d.minX : d.x - 60; this.maxX = d.maxX !== undefined ? d.maxX : d.x + 60; this.dir = d.dir || 1; + this.fireRange = d.range ?? CFG.spitter.range; this.ax = d.x; this.ay = d.y; this.range = d.range || 130; @@ -159,7 +162,7 @@ export class Enemy { const dx = pcx - ecx; const dyFeet = player.y + 46 - (this.y + this.h); const target = !player.dead && player.state !== 'victory' && - Math.abs(dx) < CFG.spitter.range && Math.abs(dyFeet) < CFG.spitter.band; + Math.abs(dx) < this.fireRange && Math.abs(dyFeet) < CFG.spitter.band; this.facing = dx >= 0 ? 1 : -1; if (target) { this.fireCd -= dt; diff --git a/src/game.ts b/src/game.ts index 22d692a..e1bf064 100644 --- a/src/game.ts +++ b/src/game.ts @@ -154,6 +154,9 @@ export class Game implements GameCtx { lastRun: RunRecord | null = null; /** Rank (1-based) of lastRun inside topRuns(10); null when it didn't place. */ lastRunRank: number | null = null; + /** Set when a cheat fires during the current run; cheat runs stay out of + * the Hall of Claws and don't overwrite the stored ghost track. */ + private cheatUsedThisRun = false; private ghost: GhostPlayer | null = null; private ghostRec: GhostRecorder | null = null; /** Cheat queue: apply max hearts once a player exists. */ @@ -373,6 +376,7 @@ export class Game implements GameCtx { this.victoryT = 0; this.pbAnnounced = false; this.pbBaseline = this.best.score; + this.cheatUsedThisRun = false; // Ghost race: record this run and replay the stored best alongside it this.ghostRec = new GhostRecorder(); this.ghost = null; @@ -437,6 +441,9 @@ export class Game implements GameCtx { } handleKey(k: GameKey): void { + // A keydown is a user gesture: unlock audio so menu SFX work even when + // the player never touches the mouse (idempotent + cheap when unlocked). + this.audio.unlock(); // Cheat codes: every press in the menu or in play feeds the detector. if (this.state === 'menu' || this.state === 'playing') { const fired = this.cheats.press(k, performance.now()); @@ -530,6 +537,7 @@ export class Game implements GameCtx { /** Apply a matched cheat code (see CHEATS in src/cheats.ts). */ private applyCheat(id: CheatId): void { this.audio.play('cheat'); + if (this.state === 'playing') this.cheatUsedThisRun = true; const p = this.player; if (id === 'rainbow') { this.rainbow = !this.rainbow; @@ -795,34 +803,47 @@ export class Game implements GameCtx { this.best = newBest; } // Ghost race: keep this run's track when it sets a new best score + // (cheat-assisted runs don't get to rewrite the ghost) const rec = this.ghostRec; this.ghostRec = null; - if (rec && isBestScore) { + if (rec && isBestScore && !this.cheatUsedThisRun) { const track = rec.finish(this.score, this.elapsed); if (track) { track.date = this.daily ? dailySeed() : -1; saveGhostTrack(this.daily ? -1 : this.levelIdx, track); } } - // Hall of Claws: carve this run into the local leaderboard - const hallRec: RunRecord = { - score: this.score, - time: this.elapsed, - level: this.daily ? 'Daily · ' + new Date().toLocaleDateString() : LEVELS[this.levelIdx].name, - difficulty: this.difficulty, - date: Date.now(), - }; - addRun(hallRec); - // Object identity is lost through the localStorage round-trip, so match by content. - const rank = topRuns(10).findIndex( - (r) => r.score === hallRec.score && r.date === hallRec.date && r.level === hallRec.level, - ); - this.lastRun = hallRec; - this.lastRunRank = rank >= 0 ? rank + 1 : null; + // Hall of Claws: carve this run into the local leaderboard. Cheat-assisted + // runs are excluded so the board stays honest. + if (!this.cheatUsedThisRun) { + const hallRec: RunRecord = { + score: this.score, + time: this.elapsed, + level: this.daily ? 'Daily · ' + new Date().toLocaleDateString() : LEVELS[this.levelIdx].name, + difficulty: this.difficulty, + date: Date.now(), + }; + addRun(hallRec); + // Object identity is lost through the localStorage round-trip, so match by content. + const rank = topRuns(10).findIndex( + (r) => r.score === hallRec.score && r.date === hallRec.date && r.level === hallRec.level, + ); + this.lastRun = hallRec; + this.lastRunRank = rank >= 0 ? rank + 1 : null; + } else { + this.lastRun = null; + this.lastRunRank = null; + } // Lifetime stats const s = getStats(); s.victories += 1; s.crystals += this.crystalsGot; + // All-clear: every handcrafted level finished at least once (the daily + // challenge is excluded). A level's best time is non-null exactly once it + // has been completed, so that is the reliable "cleared" marker. + if (!this.daily) { + s.allClear = LEVELS.every((_, i) => getBest(i).time !== null); + } Store.set('tinyrex_stats', s); this.stats = s; this.audio.stopMusic(); @@ -876,6 +897,15 @@ export class Game implements GameCtx { this.addStatus('The tide is rising!', '#8fd0ff'); this.audio.play('tide'); } + // Tension cue: once the water has climbed 80% of its total rise, ping a + // second, more urgent status and flag the level so the render creeps a + // subtle blue glow in from the screen edges. + const t = lvl.tide; + const risen = (t.fromY - lvl.waterY) / Math.max(1, t.fromY - t.toY); + if (!lvl.tideTense && risen >= 0.8) { + lvl.tideTense = true; + this.addStatus('The water climbs…', '#8fd0ff'); + } } update(dt: number): void { @@ -1086,6 +1116,20 @@ export class Game implements GameCtx { ctx.lineWidth = 2; ctx.stroke(); } + // Tension: once the tide is 80% risen, a cold blue light creeps in from + // the screen edges (a steady glow under reduced motion). + if (this.level.tideTense) { + const pulse = this.reducedMotion ? 1 : 0.5 + 0.5 * Math.sin(this.time * 2.2); + const a = 0.1 + 0.1 * pulse; + const eg = ctx.createRadialGradient( + VW / 2, VH / 2, Math.min(VW, VH) * 0.32, + VW / 2, VH / 2, Math.max(VW, VH) * 0.72, + ); + eg.addColorStop(0, 'rgba(80,150,220,0)'); + eg.addColorStop(1, 'rgba(120,190,255,' + a.toFixed(3) + ')'); + ctx.fillStyle = eg; + ctx.fillRect(0, 0, VW, VH); + } } // Vignette-ish bottom fade for depth @@ -1680,6 +1724,18 @@ export class Game implements GameCtx { ctx.fillText('Recounting the run…', VW / 2, VH * 0.3 + 46); ctx.globalAlpha = 1; } + // All-clear flourish: finishing the last fen earns a one-line coda. + if (this.stats.allClear && t > 0.9) { + ctx.globalAlpha = clamp((t - 0.9) / 0.4, 0, 0.95); + ctx.textAlign = 'center'; + ctx.font = '800 20px ' + FONT_STACK; + ctx.lineWidth = 4; + ctx.strokeStyle = 'rgba(20,40,60,0.6)'; + ctx.strokeText('✦ All fens crossed — the marsh is at peace ✦', VW / 2, VH * 0.3 + 76); + ctx.fillStyle = '#8fd0ff'; + ctx.fillText('✦ All fens crossed — the marsh is at peace ✦', VW / 2, VH * 0.3 + 76); + ctx.globalAlpha = 1; + } ctx.fillStyle = 'rgba(255,255,255,' + clamp(VICTORY_PANEL_T - t, 0, 1) * 0.5 + ')'; ctx.fillRect(0, 0, VW, VH); return; @@ -1873,13 +1929,19 @@ export class Game implements GameCtx { this.drawTracked(ctx, lvl.subtitle, VW / 2, 156 + bounce * 0.4, 7, true); ctx.fillStyle = '#ffe28a'; this.drawTracked(ctx, lvl.subtitle, VW / 2, 156 + bounce * 0.4, 7, false); - // Per-level records + // Per-level records (nudged down to make room for the all-clear badge) + const clearOffset = this.stats.allClear ? 16 : 0; + if (this.stats.allClear) { + ctx.font = '800 14px ' + FONT_STACK; + ctx.fillStyle = '#8fd0ff'; + ctx.fillText('✦ All fens crossed', VW / 2, 182); + } ctx.font = '600 13px ' + FONT_STACK; ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.fillText( 'Best Score ' + (this.best.score || '—') + ' · Best Time ' + (this.best.time === null ? '—' : fmtTime(this.best.time)), VW / 2, - 182, + 182 + clearOffset, ); // Lifetime stats const since = this.stats.firstPlayed ? new Date(this.stats.firstPlayed).toLocaleDateString() : '—'; @@ -1888,7 +1950,7 @@ export class Game implements GameCtx { ctx.fillText( 'PLAYS ' + this.stats.runs + ' · DEATHS ' + this.stats.deaths + ' · CRYSTALS ' + this.stats.crystals + ' · HEARTS ' + this.stats.hearts + ' · FOSSILS ' + this.fossilsFound.length + '/' + this.totalFossils() + ' · NOTES ' + this.notesFound.length + '/' + this.totalNotes() + ' · SINCE ' + since, VW / 2, - 205, + 205 + clearOffset, ); // Controls panel (left) const cpx = 34, cpy = 232, cpw = 240, cph = 238; diff --git a/src/ghost.ts b/src/ghost.ts index 7fa253c..474abc2 100644 --- a/src/ghost.ts +++ b/src/ghost.ts @@ -19,7 +19,7 @@ export interface GhostTrack { /** Seconds between stored samples (~10 Hz). */ const SAMPLE_DT = 0.1; /** Hard cap (~10 min at 10 Hz) so the persisted track stays small. */ -const MAX_POINTS = 6000; +export const MAX_POINTS = 6000; /** Tracks shorter than this are discarded (accidental taps, instant deaths). */ export const MIN_TRACK_POINTS = 4; diff --git a/src/goal.ts b/src/goal.ts index 104cd68..0af8638 100644 --- a/src/goal.ts +++ b/src/goal.ts @@ -11,7 +11,9 @@ export class Goal { } get rect(): { x: number; y: number; w: number; h: number } { - return { x: this.x - 34, y: this.y - 52, w: 68, h: 52 }; + // Let a jumping Rex finish inside the nest's visible light pillar instead + // of requiring a precise landing on the small nest mound. + return { x: this.x - 60, y: this.y - 140, w: 120, h: 140 }; } draw(ctx: CanvasRenderingContext2D, t: number): void { diff --git a/src/level-data.ts b/src/level-data.ts index 2cad33a..1ccfafe 100644 --- a/src/level-data.ts +++ b/src/level-data.ts @@ -109,45 +109,66 @@ const LEVEL_1: LevelDef = { startGroundY: 460, platforms: [ // ---- Section A: gentle opening (teach move + jump) ---- - { x: 0, y: 460, w: 1150, h: 120, type: 'ground' }, + // Give the opening crumble hop a forgiving landing margin before the + // first beetle and the level's first real gap. + { x: 0, y: 460, w: 1190, h: 120, type: 'ground' }, { x: 320, y: 372, w: 130, h: 24, type: 'wood' }, { x: 520, y: 300, w: 130, h: 24, type: 'wood' }, { x: 760, y: 372, w: 130, h: 24, type: 'wood' }, { x: 1010, y: 412, w: 48, h: 24, type: 'crumble' }, { x: 1068, y: 364, w: 48, h: 24, type: 'crumble' }, // ---- Section B: first enemies + small gaps ---- - { x: 1230, y: 460, w: 400, h: 120, type: 'ground' }, + { x: 1220, y: 460, w: 410, h: 120, type: 'ground' }, { x: 1710, y: 460, w: 690, h: 120, type: 'ground' }, { x: 1860, y: 372, w: 120, h: 24, type: 'stone' }, { x: 2040, y: 312, w: 120, h: 24, type: 'stone' }, { x: 2210, y: 372, w: 120, h: 24, type: 'stone' }, // ---- Section C: spikes, bonus route, first lava pool ---- - { x: 2480, y: 460, w: 320, h: 120, type: 'ground' }, + // The bonus ledge's short drop lands on this bank; carry the bank to the + // next section so a buffered short hop cannot fall through the seam. + { x: 2480, y: 460, w: 400, h: 120, type: 'ground' }, { x: 2580, y: 360, w: 160, h: 24, type: 'stone' }, - { x: 2880, y: 460, w: 270, h: 120, type: 'ground' }, - { x: 2950, y: 350, w: 110, h: 24, type: 'stone' }, + { x: 2880, y: 460, w: 300, h: 120, type: 'ground' }, + { x: 2820, y: 350, w: 110, h: 24, type: 'stone' }, // elevated bonus route (hidden-ish, needs the jump chain) { x: 3080, y: 260, w: 110, h: 24, type: 'stone' }, { x: 3220, y: 200, w: 130, h: 24, type: 'stone' }, { x: 3200, y: 400, w: 110, h: 24, type: 'stone' }, // stepping stone over lava A { x: 3350, y: 460, w: 120, h: 120, type: 'ground' }, // ---- Section D: moving platforms over the lava river ---- - { x: 3470, y: 390, w: 100, h: 24, type: 'stone' }, + // Broad landing stone keeps the mover route readable without making a + // phase-sensitive miss drop Rex directly into the lava. + // Start the first river stone just beyond the bank so Rex can launch + // before the platform underside, rather than jumping into its edge. + { x: 3520, y: 390, w: 290, h: 24, type: 'stone' }, { x: 3540, y: 430, w: 110, h: 24, type: 'mover', axis: 'x', amp: 100, speed: 1.0 }, { x: 3760, y: 390, w: 100, h: 24, type: 'stone' }, + // The y-mover is still a visible shortcut, but this low cap makes a + // missed phase recoverable instead of turning the whole river into a + // one-shot fall. + { x: 3860, y: 390, w: 180, h: 24, type: 'stone' }, { x: 3920, y: 430, w: 110, h: 24, type: 'mover', axis: 'y', amp: 90, speed: 1.2 }, { x: 4080, y: 390, w: 100, h: 24, type: 'stone' }, + // A fixed cap keeps the far side of the mover readable: the moving + // platform remains an optional shortcut, while a mistimed transfer still + // has a recoverable stone instead of an unrecoverable lava drop. + { x: 4230, y: 390, w: 110, h: 24, type: 'stone' }, { x: 4230, y: 420, w: 110, h: 24, type: 'mover', axis: 'x', amp: 120, speed: 0.9 }, - { x: 4470, y: 460, w: 330, h: 120, type: 'ground' }, + // Bridge the small checkpoint seam so the patrol can be cleared with a + // full hop instead of forcing a landing beside the trike at the edge. + { x: 4470, y: 460, w: 410, h: 120, type: 'ground' }, { x: 4650, y: 360, w: 120, h: 24, type: 'stone' }, // ---- Section E: checkpoint 2, ptero & spike gauntlet ---- { x: 4880, y: 460, w: 420, h: 120, type: 'ground' }, - { x: 4970, y: 360, w: 130, h: 24, type: 'stone' }, + { x: 5100, y: 320, w: 130, h: 24, type: 'stone' }, // ---- Section F: falling rocks, lava pit B, last enemies ---- { x: 5300, y: 460, w: 400, h: 120, type: 'ground' }, { x: 5480, y: 350, w: 110, h: 24, type: 'stone' }, + { x: 5700, y: 390, w: 110, h: 24, type: 'stone' }, { x: 5430, y: 412, w: 48, h: 24, type: 'crumble' }, - { x: 5950, y: 460, w: 450, h: 120, type: 'ground' }, + // Gap to the previous ground is 180 px: a full-speed jump clears ~233 px, + // so this stays clearable (the old 250 px gap was unjumpable by anyone). + { x: 5880, y: 460, w: 520, h: 120, type: 'ground' }, { x: 6150, y: 360, w: 120, h: 24, type: 'stone' }, // ---- Section G: home stretch ---- { x: 6400, y: 460, w: 1150, h: 120, type: 'ground' }, @@ -165,7 +186,7 @@ const LEVEL_1: LevelDef = { // Section C { x: 2520, y: 425 }, { x: 2620, y: 323 }, { x: 2690, y: 323 }, - { x: 2930, y: 425 }, { x: 3005, y: 313 }, + { x: 2930, y: 425 }, { x: 2875, y: 313 }, { x: 3135, y: 223 }, { x: 3285, y: 160, bonus: true }, { x: 3330, y: 223 }, { x: 3255, y: 363 }, // Section D (river) @@ -183,25 +204,33 @@ const LEVEL_1: LevelDef = { { type: 'beetle', x: 950, y: 432, minX: 850, maxX: 1100 }, { type: 'beetle', x: 1450, y: 432, minX: 1290, maxX: 1600 }, { type: 'trike', x: 1950, y: 424, minX: 1850, maxX: 2150 }, - { type: 'trike', x: 2930, y: 424, minX: 2895, maxX: 3060 }, - { type: 'ptero', x: 3250, y: 320, range: 90 }, - { type: 'ptero', x: 3950, y: 290, range: 150 }, - { type: 'ptero', x: 4230, y: 250, range: 130 }, - { type: 'ptero', x: 4700, y: 240, range: 120 }, - { type: 'trike', x: 4700, y: 424, minX: 4500, maxX: 4780 }, + // Keep a safe landing margin after the short spike route before the + // patrol begins; the old patrol could overlap Rex on the first frame + // after the 80 px ground gap. + { type: 'trike', x: 3040, y: 424, minX: 3010, maxX: 3130 }, + // Keep the flyer above the lava jump's apex so the stepping-stone route + // remains a readable hazard choice instead of a timing lottery. + { type: 'ptero', x: 3250, y: 220, range: 90 }, + // Keep the river flyer above the jump corridor; its old dip could clip + // Rex while leaving the vertical mover. + { type: 'ptero', x: 3950, y: 145, range: 150 }, + { type: 'ptero', x: 4230, y: 120, range: 80 }, + { type: 'ptero', x: 4700, y: 120, range: 80 }, + { type: 'trike', x: 4800, y: 424, minX: 4770, maxX: 4920 }, { type: 'beetle', x: 5150, y: 432, minX: 5050, maxX: 5280 }, - { type: 'ptero', x: 5500, y: 250, range: 110 }, - { type: 'beetle', x: 6100, y: 432, minX: 5990, maxX: 6350 }, - { type: 'trike', x: 6250, y: 424, minX: 6050, maxX: 6380 }, + // Keep the checkpoint approach clear beneath this high flight path. + { type: 'ptero', x: 5500, y: 120, range: 70 }, + { type: 'beetle', x: 6000, y: 432, minX: 5920, maxX: 6080 }, + { type: 'trike', x: 6350, y: 424, minX: 6300, maxX: 6400 }, { type: 'beetle', x: 6900, y: 432, minX: 6700, maxX: 7050 }, ], hazards: [ { type: 'spikes', x: 2620, y: 460, w: 80 }, - { type: 'lava', x: 3150, y: 520, w: 200 }, + { type: 'lava', x: 3180, y: 520, w: 170 }, { type: 'lava', x: 3470, y: 520, w: 1000 }, - { type: 'spikes', x: 5000, y: 460, w: 70 }, + { type: 'spikes', x: 5100, y: 460, w: 70 }, { type: 'rocks', x: 5380, y: 460, w: 240, interval: 2.0 }, - { type: 'lava', x: 5700, y: 520, w: 250 }, + { type: 'lava', x: 5700, y: 520, w: 180 }, ], checkpoints: [ { x: 2330, y: 460 }, @@ -215,7 +244,7 @@ const LEVEL_1: LevelDef = { fossils: [ { x: 3255, y: 163 }, // high bonus route — top of the jump chain { x: 3230, y: 363 }, // stepping stone over lava pool A - { x: 4990, y: 323 }, // stone ledge right above the spike pit + { x: 5105, y: 283 }, // bonus ledge beyond the spike pit ], notes: [ { x: 2400, y: 436 }, // by the first flag @@ -266,24 +295,38 @@ const LEVEL_2: LevelDef = { { x: 1820, y: 460, w: 560, h: 120, type: 'ground' }, { x: 1950, y: 360, w: 130, h: 24, type: 'stone' }, { x: 2200, y: 300, w: 130, h: 24, type: 'stone' }, - { x: 2450, y: 360, w: 130, h: 24, type: 'stone' }, + // Widen the far moat landing so the calibrated full arc lands well + // inside the stone instead of clipping its left edge. + { x: 2400, y: 360, w: 180, h: 24, type: 'stone' }, // ---- Section C: the lava moat (stepping stones → mover → stone → y-mover) ---- { x: 2560, y: 430, w: 100, h: 24, type: 'stone' }, - { x: 2780, y: 430, w: 100, h: 24, type: 'stone' }, - { x: 3000, y: 460, w: 300, h: 120, type: 'ground' }, - { x: 3380, y: 420, w: 100, h: 24, type: 'mover', axis: 'x', amp: 90, speed: 1.1 }, - { x: 3700, y: 390, w: 100, h: 24, type: 'stone' }, + // Broaden the second moat step so the standard full arc lands away from + // its left lip instead of clipping into the lava before the bank. + { x: 2740, y: 430, w: 180, h: 24, type: 'stone' }, + // A broad landing deck keeps the first moving-platform approach forgiving + // after the lava moat; the mover remains available as the intended shortcut. + { x: 3000, y: 460, w: 500, h: 120, type: 'ground' }, + { x: 3540, y: 420, w: 100, h: 24, type: 'mover', axis: 'x', amp: 90, speed: 1.1 }, + { x: 3700, y: 390, w: 180, h: 24, type: 'stone' }, { x: 3880, y: 430, w: 110, h: 24, type: 'mover', axis: 'y', amp: 80, speed: 1.3 }, - { x: 4100, y: 460, w: 320, h: 120, type: 'ground' }, + // Extend the far shelf under the vertical mover so a late jump still + // lands on solid basalt instead of falling through the narrow seam. + { x: 3980, y: 460, w: 620, h: 120, type: 'ground' }, // ---- Section D: spike ledges between the shelf and the rockfall ---- - { x: 4560, y: 460, w: 380, h: 120, type: 'ground' }, + { x: 4560, y: 460, w: 420, h: 120, type: 'ground' }, { x: 4660, y: 360, w: 120, h: 24, type: 'stone' }, { x: 5000, y: 460, w: 420, h: 120, type: 'ground' }, + // A fixed basalt bridge keeps the late lava seam readable and + // recoverable without requiring a pixel-perfect takeoff. + { x: 5430, y: 460, w: 130, h: 24, type: 'stone' }, // ---- Section E: twin falling-rock gauntlet ---- { x: 5560, y: 460, w: 360, h: 120, type: 'ground' }, { x: 5660, y: 350, w: 110, h: 24, type: 'stone' }, { x: 6060, y: 460, w: 400, h: 120, type: 'ground' }, { x: 6240, y: 360, w: 120, h: 24, type: 'stone' }, + // A fixed bridge removes the phase-sensitive final lava leap while + // preserving the raised stone as an optional crystal route. + { x: 6460, y: 390, w: 150, h: 24, type: 'stone' }, // ---- Section F: home stretch to the ember nest ---- { x: 6600, y: 460, w: 950, h: 120, type: 'ground' }, { x: 6760, y: 372, w: 130, h: 24, type: 'stone' }, @@ -314,21 +357,28 @@ const LEVEL_2: LevelDef = { { type: 'beetle', x: 1500, y: 432, minX: 1290, maxX: 1650 }, { type: 'trike', x: 2000, y: 424, minX: 1860, maxX: 2360 }, { type: 'trike', x: 3080, y: 424, minX: 3020, maxX: 3290 }, - { type: 'ptero', x: 3500, y: 320, range: 90 }, - { type: 'ptero', x: 3950, y: 280, range: 120 }, + { type: 'ptero', x: 3500, y: 190, range: 70 }, // high patrol leaves the x-mover jump lane readable + { type: 'ptero', x: 3950, y: 120, range: 100 }, // skyline patrol stays clear of the y-mover exit { type: 'beetle', x: 4700, y: 432, minX: 4600, maxX: 4930 }, - { type: 'ptero', x: 4750, y: 250, range: 110 }, - { type: 'trike', x: 5200, y: 424, minX: 5040, maxX: 5400 }, - { type: 'ptero', x: 5700, y: 260, range: 100 }, - { type: 'beetle', x: 5800, y: 432, minX: 5580, maxX: 5910 }, - { type: 'ptero', x: 6150, y: 250, range: 130 }, + { type: 'ptero', x: 4750, y: 120, range: 90 }, + // The spike exit and lava bridge already provide the timing challenge; + // leave this short shelf clear for a readable transition into the rocks. + { type: 'ptero', x: 5700, y: 120, range: 90 }, + // The twin rockfall lane already supplies the timing challenge; removing + // the overlapping patrol keeps the recovery shelf from becoming a blind + // double-hit immediately after the bridge. + { type: 'ptero', x: 6150, y: 120, range: 100 }, { type: 'trike', x: 6300, y: 424, minX: 6090, maxX: 6440 }, { type: 'beetle', x: 6800, y: 432, minX: 6650, maxX: 7100 }, - { type: 'ptero', x: 7050, y: 240, range: 120 }, + // Keep the final flyer as skyline atmosphere rather than a surprise + // collision in the optional high-ledge landing corridor. + { type: 'ptero', x: 7050, y: 100, range: 120 }, ], hazards: [ { type: 'spikes', x: 4330, y: 460, w: 70 }, - { type: 'spikes', x: 5100, y: 460, w: 80 }, + // The late shelf leads directly into the falling-rock gauntlet; keep its + // landing readable instead of stacking a near-invisible spike hitbox at + // the checkpoint exit. { type: 'lava', x: 2420, y: 520, w: 560 }, { type: 'lava', x: 5430, y: 520, w: 130 }, { type: 'lava', x: 6470, y: 520, w: 130 }, @@ -380,29 +430,35 @@ const LEVEL_3: LevelDef = { startGroundY: 460, platforms: [ // ---- Section A: alpine opening (teach the mood) ---- - { x: 0, y: 460, w: 1300, h: 120, type: 'ground' }, + { x: 0, y: 460, w: 1500, h: 120, type: 'ground' }, { x: 380, y: 372, w: 120, h: 24, type: 'stone' }, { x: 620, y: 300, w: 120, h: 24, type: 'stone' }, { x: 860, y: 372, w: 120, h: 24, type: 'stone' }, // ---- Section B: the spring gardens (teach springs) ---- { x: 1500, y: 460, w: 1000, h: 120, type: 'ground' }, { x: 1860, y: 300, w: 130, h: 24, type: 'stone' }, // spring 1 ledge - { x: 2380, y: 300, w: 130, h: 24, type: 'stone' }, // spring 2 ledge + { x: 2380, y: 300, w: 200, h: 24, type: 'stone' }, // spring 2 ledge (wide enough to catch a full-speed spring launch) // ---- Section C: gate of the pass (spring → plate → door) ---- - { x: 2720, y: 460, w: 1180, h: 120, type: 'ground' }, + { x: 2720, y: 460, w: 1360, h: 120, type: 'ground' }, { x: 3150, y: 320, w: 150, h: 24, type: 'stone' }, // plate ledge // ---- Section D: spitter meadow ---- - { x: 4120, y: 460, w: 1180, h: 120, type: 'ground' }, + // Carry the lower shelf through the late transition; the raised stone + // remains an optional shortcut, while a missed hop never becomes a + // one-life drop before the rockfall ridge. + { x: 4080, y: 460, w: 1440, h: 120, type: 'ground' }, { x: 4500, y: 380, w: 100, h: 24, type: 'stone' }, // spitter perch { x: 4900, y: 380, w: 100, h: 24, type: 'stone' }, // spitter perch { x: 4000, y: 390, w: 90, h: 24, type: 'mover', axis: 'x', amp: 60, speed: 1.0 }, // over gap // ---- Section E: rockfall ridge + bonus spring ledge ---- { x: 5520, y: 460, w: 1080, h: 120, type: 'ground' }, - { x: 5400, y: 380, w: 90, h: 24, type: 'stone' }, // over gap + // Widen the late gap catch so a mistimed hop still lands safely before + // the rockfall ridge. + { x: 5360, y: 380, w: 230, h: 24, type: 'stone' }, { x: 5900, y: 300, w: 130, h: 24, type: 'stone' }, // bonus spring ledge // ---- Section F: second gate, final spitter, goal ---- { x: 6800, y: 460, w: 1200, h: 120, type: 'ground' }, - { x: 7200, y: 320, w: 150, h: 24, type: 'stone' }, // plate ledge + // Extend left to catch the spring's descending arc reliably. + { x: 7160, y: 350, w: 190, h: 24, type: 'stone' }, // plate ledge { x: 7450, y: 380, w: 100, h: 24, type: 'stone' }, // spitter perch ], springs: [ @@ -410,11 +466,14 @@ const LEVEL_3: LevelDef = { { x: 2300, y: 460 }, { x: 3000, y: 460 }, { x: 5800, y: 460 }, - { x: 7050, y: 460 }, + // Launch onto the pressure-plate ledge, not the lower spitter perch. + { x: 6900, y: 460 }, ], plates: [ { x: 3200, y: 320, door: 0 }, - { x: 7250, y: 320, door: 1 }, + // Place the plate under the spring's reliable landing window so Rex can + // hold it immediately instead of sliding off the ledge before it latches. + { x: 7210, y: 350, door: 1 }, ], doors: [ { x: 3650, y: 310, w: 40, h: 150 }, @@ -461,10 +520,15 @@ const LEVEL_3: LevelDef = { { type: 'spitter', x: 4540, y: 342 }, { type: 'spitter', x: 4940, y: 342 }, { type: 'beetle', x: 5200, y: 432, minX: 5100, maxX: 5280 }, - { type: 'beetle', x: 5600, y: 432, minX: 5560, maxX: 5680 }, - { type: 'trike', x: 6250, y: 424, minX: 6100, maxX: 6520 }, - { type: 'spitter', x: 7490, y: 342 }, - { type: 'beetle', x: 7700, y: 432, minX: 7650, maxX: 7900 }, + // Keep the ridge guard clear of the rockfall's first safe landing. + { type: 'beetle', x: 5450, y: 432, minX: 5380, maxX: 5510 }, + { type: 'trike', x: 6250, y: 424, minX: 6200, maxX: 6520 }, + // Keep the final perch's glob threat local to the gate approach so the + // lower route is not hit by projectiles fired before the spring jump. + { type: 'spitter', x: 7490, y: 342, range: 90 }, + // Keep the final collectible's guard beyond the nest so the goal approach + // remains a clean victory beat after the gate and spitter sequence. + { type: 'beetle', x: 7920, y: 432, minX: 7880, maxX: 7990 }, ], hazards: [ { type: 'spikes', x: 3450, y: 460, w: 80 }, @@ -523,12 +587,16 @@ const LEVEL_4: LevelDef = { { x: 2244, y: 150, w: 40, h: 190, type: 'stone' }, // left wall (gap under = entry) { x: 2244, y: 460, w: 1100, h: 120, type: 'ground' }, // arena floor (runs under left wall) { x: 3344, y: 150, w: 40, h: 190, type: 'stone' }, // right wall (gap under = exit) - // Orb perches - { x: 2380, y: 320, w: 110, h: 24, type: 'stone' }, + // Orb perches. The left perch sits just right of the Magma King's left-wall + // position so the pocket stomp arc (left edge 2282→~2467) clears its face and + // the post-stomp bounce (landing left edge ~2586) comes down onto its top. + { x: 2560, y: 320, w: 110, h: 24, type: 'stone' }, { x: 3150, y: 320, w: 110, h: 24, type: 'stone' }, { x: 2720, y: 250, w: 120, h: 24, type: 'stone' }, // ---- Exit: nest gate + goal ---- - { x: 3384, y: 460, w: 566, h: 120, type: 'ground' }, + // Carry the exit floor under the arena wall so the post-boss victory run + // cannot catch the forty-pixel seam as Rex leaves the room. + { x: 3344, y: 460, w: 606, h: 120, type: 'ground' }, ], crystals: [ { x: 560, y: 420 }, { x: 700, y: 420 }, { x: 840, y: 420 }, @@ -562,9 +630,12 @@ const LEVEL_4: LevelDef = { { x: 3286, y: 428 }, // arena nook beside the right wall { x: 3686, y: 428 }, // behind the gate, near the nest ], - boss: { x: 2760, y: 356, minX: 2320, maxX: 3160 }, + // minX (2400) keeps the Magma King's body 100px clear of the entry pocket + // (2248..2300), so a full jump off the pocket crosses his stomp band over + // his left half instead of clipping his left edge on the way up. + boss: { x: 2760, y: 356, minX: 2400, maxX: 3160 }, orbs: [ - { x: 2435, y: 286 }, + { x: 2615, y: 286 }, // above the left perch (2560..2670) { x: 3205, y: 286 }, { x: 2780, y: 216 }, ], @@ -620,13 +691,13 @@ const LEVEL_5: LevelDef = { { x: 5230, y: 430, w: 80, h: 20, type: 'crumble' }, // the crumble bridge { x: 5350, y: 430, w: 80, h: 20, type: 'crumble' }, { x: 5470, y: 430, w: 90, h: 20, type: 'crumble' }, - { x: 5300, y: 330, w: 110, h: 24, type: 'stone' }, // fossil perch over the gap + { x: 6020, y: 330, w: 110, h: 24, type: 'stone' }, // fossil perch before the vale hops // ---- Section E: the drowned vale — dry hops to the spring & nest rock ---- { x: 7140, y: 460, w: 460, h: 120, type: 'ground' }, { x: 6200, y: 420, w: 110, h: 24, type: 'wood' }, // dry hop { x: 6500, y: 420, w: 110, h: 24, type: 'wood' }, // dry hop { x: 6800, y: 420, w: 110, h: 24, type: 'wood' }, // dry hop - { x: 7200, y: 340, w: 160, h: 24, type: 'stone' }, // the nest rock (goal) + { x: 7160, y: 340, w: 220, h: 24, type: 'stone' }, // the nest rock (goal) ], springs: [ { x: 3255, y: 460 }, // up into the canopy @@ -667,10 +738,12 @@ const LEVEL_5: LevelDef = { { type: 'lava', x: 2020, y: 520, w: 120 }, // the bog pit (the tide will drown it) { type: 'spikes', x: 5750, y: 460, w: 70 }, // sunken teeth on the low ground ], + // Checkpoints sit on the dry-hop ledges (tops <= 446) so a late respawn — + // when the tide has submerged the low ground — is never a death trap. checkpoints: [ - { x: 1520, y: 460 }, // the bog - { x: 4230, y: 460 }, // under the canopy crown - { x: 6050, y: 460 }, // the drowned vale + { x: 1900, y: 420 }, // the bog (dry hop before the pit) + { x: 4290, y: 398 }, // the canopy edge + { x: 6200, y: 420 }, // the drowned vale (dry hop) ], enemies: [ { type: 'beetle', x: 2500, y: 432, minX: 2350, maxX: 2900 }, @@ -678,8 +751,11 @@ const LEVEL_5: LevelDef = { { type: 'spitter', x: 4080, y: 306 }, { type: 'ptero', x: 4350, y: 250, range: 120 }, { type: 'trike', x: 5700, y: 424, minX: 5600, maxX: 6000 }, - { type: 'beetle', x: 6300, y: 432, minX: 6150, maxX: 6600 }, - { type: 'ptero', x: 6600, y: 240, range: 130 }, + // Give the late checkpoint a clean exit before the vale beetle patrol. + { type: 'beetle', x: 6400, y: 432, minX: 6350, maxX: 6600 }, + // Keep the final flight above the dry-hop arc so the spring approach + // stays readable instead of turning into a blind midair collision. + { type: 'ptero', x: 6600, y: 160, range: 130 }, ], hearts: [ { x: 2700, y: 428 }, // the bog @@ -691,7 +767,7 @@ const LEVEL_5: LevelDef = { fossils: [ { x: 1842, y: 294 }, // high ledge in the bog { x: 3855, y: 256 }, // crown of the canopy - { x: 5345, y: 306 }, // perch over the sunken gate + { x: 6075, y: 306 }, // perch before the vale hops ], notes: [ { x: 2210, y: 436 }, // the bog diff --git a/src/level.ts b/src/level.ts index 5016828..9025388 100644 --- a/src/level.ts +++ b/src/level.ts @@ -52,6 +52,8 @@ export class Level { waterY: number; /** One-shot "the tide is rising" ping. */ tideWarned: boolean; + /** One-shot tension cue: the tide has climbed 80% of its rise. */ + tideTense: boolean; readonly game: GameCtx; constructor(d: LevelDef, game: GameCtx, enemySpeed = 1, levelIdx = 0) { @@ -95,6 +97,7 @@ export class Level { this.tide = d.tide ?? null; this.waterY = this.tide ? this.tide.fromY : Infinity; this.tideWarned = false; + this.tideTense = false; } /** Spawn a power-up capsule (enemy-kill drop). */ @@ -179,5 +182,6 @@ export class Level { } if (this.tide) this.waterY = this.tide.fromY; this.tideWarned = false; + this.tideTense = false; } } diff --git a/src/projectile.ts b/src/projectile.ts index c14f21f..5066af8 100644 --- a/src/projectile.ts +++ b/src/projectile.ts @@ -20,6 +20,7 @@ export class Projectile { /** extra life granted after a bounce. */ life = 2.4; kind: ProjectileKind; + readonly originX: number; constructor( x: number, y: number, vx: number, vy: number, @@ -30,6 +31,7 @@ export class Projectile { this.vx = vx; this.vy = vy; this.kind = kind; + this.originX = x; } get rect(): { x: number; y: number; w: number; h: number } { @@ -61,7 +63,11 @@ export class Projectile { } if (this.age > this.life) this.dead = true; // hit the player - if (!player.dead && player.state !== 'victory' && player.invulnT <= 0 && overlap(this.rect, player.rect)) { + const isBehindPlayer = + (this.vx < 0 && player.x > this.originX + this.r) || + (this.vx > 0 && player.x + player.w < this.originX - this.r); + if (!isBehindPlayer && !player.dead && player.state !== 'victory' && + player.invulnT <= 0 && overlap(this.rect, player.rect)) { this.dead = true; player.damage({ x: this.x - this.r, w: this.r * 2 }, 'spit'); } diff --git a/src/store.ts b/src/store.ts index 12d05ee..598ea8a 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,8 +1,34 @@ import type { Difficulty } from './config'; import type { GhostTrack } from './ghost'; -import { MIN_TRACK_POINTS } from './ghost'; +import { MIN_TRACK_POINTS, MAX_POINTS } from './ghost'; import { SKINS } from './sprite'; +const MAX_STARS = 3; + +function cleanScore(v: unknown): number { + return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0; +} + +function cleanTime(v: unknown): number | null { + return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null; +} + +/** + * Corrupt/legacy best records degrade to a clean zero state instead of + * leaking NaN into scoring (e.g. a score of `Infinity` from an old build). + */ +function cleanBest(v: unknown): { score: number; time: number | null } { + if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return { score: v, time: null }; + if (!v || typeof v !== 'object') return { score: 0, time: null }; + const b = v as { score?: unknown; time?: unknown }; + return { score: cleanScore(b.score), time: cleanTime(b.time) }; +} + +function cleanStars(v: unknown): number { + const n = typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : 0; + return Math.min(MAX_STARS, Math.max(0, n)); +} + const SKIN_IDS: string[] = SKINS.map((s) => s.id); /** Lifetime play statistics shown on the main menu. */ @@ -15,6 +41,8 @@ export interface GameStats { hearts: number; /** Epoch ms of the first play; null before the first run. */ firstPlayed: number | null; + /** True once every handcrafted level has been completed at least once. */ + allClear: boolean; } export function getStats(): GameStats { @@ -27,29 +55,30 @@ export function getStats(): GameStats { victories: s.victories ?? 0, hearts: s.hearts ?? 0, firstPlayed: s.firstPlayed ?? null, + allClear: s.allClear ?? false, }; } /** Best score/time for one level (falls back to the legacy global key for level 0). */ export function getBest(idx: number): { score: number; time: number | null } { - const b = Store.get<{ score: number; time: number | null } | null>('tinyrex_best_' + idx, null); - if (b) return b; - if (idx === 0) return Store.get('tinyrex_best_score', { score: 0, time: null as number | null }); + const b = Store.get('tinyrex_best_' + idx, null); + if (b) return cleanBest(b); + if (idx === 0) return cleanBest(Store.get('tinyrex_best_score', null)); return { score: 0, time: null }; } export function getBestStars(idx: number): number { - return Store.get('tinyrex_stars_' + idx, 0); + return cleanStars(Store.get('tinyrex_stars_' + idx, 0)); } /** Best score/time for today's Daily Rex challenge. */ export function getDailyBest(): { score: number; time: number | null } { - return Store.get('tinyrex_best_daily', { score: 0, time: null as number | null }); + return cleanBest(Store.get('tinyrex_best_daily', null)); } /** Best star rating for the current Daily Rex challenge (0–3). */ export function getDailyStars(): number { - return Store.get('tinyrex_stars_daily', 0); + return cleanStars(Store.get('tinyrex_stars_daily', 0)); } /** Ghost race toggle (default on). */ @@ -68,10 +97,27 @@ export function setGhostEnabled(on: boolean): void { */ export function getGhostTrack(idx: number, date: number): GhostTrack | null { const key = idx === -1 ? 'tinyrex_ghost_daily' : 'tinyrex_ghost_' + idx; - const t = Store.get(key, null); - if (!t || !Array.isArray(t.pts) || t.pts.length < MIN_TRACK_POINTS) return null; - if (idx === -1 && t.date !== date) return null; - return t; + const t = Store.get(key, null); + if (!t || typeof t !== 'object') return null; + const track = t as GhostTrack; + if (idx === -1 && track.date !== date) return null; + if ( + !Array.isArray(track.pts) || + track.pts.length < MIN_TRACK_POINTS || + track.pts.length > MAX_POINTS + ) return null; + if (typeof track.score !== 'number' || !Number.isFinite(track.score)) return null; + if (typeof track.time !== 'number' || !Number.isFinite(track.time) || track.time <= 0) return null; + // Every sample must be finite and the clock must never run backwards — + // a broken track would make GhostPlayer interpolate NaN or jump in time. + let prevT = -Infinity; + for (const p of track.pts) { + if (!p || typeof p.t !== 'number' || typeof p.x !== 'number' || typeof p.y !== 'number') return null; + if (!Number.isFinite(p.t) || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return null; + if (p.t < prevT) return null; + prevT = p.t; + } + return track; } export function saveGhostTrack(idx: number, track: GhostTrack): void { diff --git a/src/weather.ts b/src/weather.ts index 9bdc0cb..2abc21d 100644 --- a/src/weather.ts +++ b/src/weather.ts @@ -6,7 +6,7 @@ import { TAU, VW } from './config'; /* --- Weather tuning --- */ export const GUST_INTERVAL = 8; // seconds between frost gusts (nominal) export const GUST_DUR = 2.4; // seconds an active gust lasts -export const GUST_PUSH = 150; // target drift speed while gusting (px/s) +export const GUST_FORCE = 950; // gust push force (px/s^2) — must stay <= airAccel (see updateFrost) export const MEADOW_DRIFT = 14; // gentle pollen sway amplitude (px/s) export const GEYSER_PERIOD = 6; // full volcanic vent cycle (s) export const GEYSER_ERUPT = 1.4; // seconds the eruption column is live @@ -179,13 +179,21 @@ export class Weather { private updateFrost(dt: number, player: Player | null): void { if (this.gusting > 0) { this.gusting -= dt; - // Blend Rex's velocity toward the gust: the drag model in player.ts - // would otherwise eat a plain force almost instantly. + // Push as a FORCE, never as a velocity blend. Blending vx toward the + // gust target overrides Rex's input entirely: a headwind dragged a + // Rex running at full speed BACKWARD at ~115 px/s for the whole 2.4 s + // gust, walking him off ledges into pits (Frostpeak, gap 1300-1500). + // As a force the gust competes with the player's own physics, and with + // GUST_FORCE == airAccel the invariants hold: + // grounded + countering: 1500 accel > 950 -> Rex still advances; + // grounded + idle: 1750 friction > 950 -> never shoved off a + // ledge while standing still; + // air + countering: 950 airAccel >= 950 -> jump arcs are + // unaffected when Rex actively fights it; + // air + idle: 950 >> 110 airDrag -> strong visible drift + // on unattended jumps/falls. if (player && !player.dead) { - // Strong blend so the drift survives the player's air drag: - // steady-state drift ≈ GUST_PUSH * 40 / airDrag (~55 px/s). - const target = this.gustDir * GUST_PUSH; - player.vx += (target - player.vx) * Math.min(1, dt * 40); + player.vx += this.gustDir * GUST_FORCE * dt; } if (!this.reducedMotion && player) { for (let i = 0; i < 2; i++) { @@ -242,7 +250,10 @@ export class Weather { private updateMeadow(dt: number, player: Player | null): void { if (player && !player.dead) { const sway = Math.sin(this.t * 0.6) * MEADOW_DRIFT; - player.vx += (sway - player.vx) * Math.min(1, dt * 20); + // Gentle additive nudge — NOT a blend toward the sway. Blending drags + // vx to the drift itself and clamps Rex's top speed to ~1/4 of + // maxSpeed for the whole level; control must always dominate. + player.vx += sway * Math.min(1, dt * 20); } for (const m of this.motes) { m.phase += dt; diff --git a/tests/boss.test.ts b/tests/boss.test.ts index 56fd241..d91a45e 100644 --- a/tests/boss.test.ts +++ b/tests/boss.test.ts @@ -144,8 +144,8 @@ describe('MagmaKing behaviour', () => { it('shatters an orb when stomped: score, bounce, and a respawn timer', () => { const { ctx, boss } = makeRig(); - const o = boss.orbs[0]; // (2435, 286) - const p = dummy(2420, 240, 300); + const o = boss.orbs[0]; // (2615, 286) + const p = dummy(2600, 240, 300); boss.update(DT, 0, p); expect(o.alive).toBe(false); expect(o.respawnT).toBeGreaterThan(0); diff --git a/tests/cheats.test.ts b/tests/cheats.test.ts new file mode 100644 index 0000000..f743db7 --- /dev/null +++ b/tests/cheats.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { CheatSystem } from '../src/cheats'; +import type { GameKey } from '../src/input'; +import { Game } from '../src/game'; +import { getRuns, clearRuns, getGhostTrack } from '../src/store'; + +function pressSeq(sys: CheatSystem, keys: GameKey[], stepMs = 180, t0 = 1000): void { + let t = t0; + for (const k of keys) { + sys.press(k, t); + t += stepMs; + } +} + +describe('CheatSystem sequence detection', () => { + it('fires the new surge code: down, down, jump', () => { + const sys = new CheatSystem(); + expect(sys.press('down', 1000)).toBeNull(); + expect(sys.press('down', 1100)).toBeNull(); + expect(sys.press('primary', 1200)).toBe('surge'); + }); + + it('does not false-fire on rapid jumping or movement wiggles', () => { + const sys = new CheatSystem(); + // triple jump (the old accidental trigger) + pressSeq(sys, ['primary', 'primary', 'primary'], 90); + expect(sys.press('primary', 2000)).toBeNull(); + // running with wiggles + pressSeq(sys, ['right', 'left', 'right', 'left', 'right', 'primary'], 120, 3000); + // jump spam mixed with left/right + pressSeq(sys, ['primary', 'right', 'primary', 'left', 'primary', 'primary'], 100, 4000); + }); + + it('still fires the Konami code', () => { + const sys = new CheatSystem(); + pressSeq(sys, ['up', 'up', 'down', 'down', 'left', 'right', 'left', 'right', 'primary'], 180, 5000); + expect(sys.press('primary', 7000)).toBeNull(); + }); + it('konami fires on its final press', () => { + const sys = new CheatSystem(); + const fired: string[] = []; + for (const k of ['up', 'up', 'down', 'down', 'left', 'right', 'left', 'right', 'primary'] as GameKey[]) { + const r = sys.press(k, 10000); + if (r) fired.push(r); + } + expect(fired).toEqual(['rainbow']); + }); + + it('respects per-code cooldowns', () => { + const sys = new CheatSystem(); + // fires at t ≈ 1450 + pressSeq(sys, ['down', 'down', 'primary'], 150); + // Re-run the exact sequence well inside the 12 s cooldown + let firedInCooldown: string | null = null; + let t = 3000; + for (const k of ['down', 'down', 'primary'] as GameKey[]) { + const r = sys.press(k, t); + if (r) firedInCooldown = r; + t += 150; + } + expect(firedInCooldown).toBeNull(); + // After the cooldown elapses it fires again + t = 20_000; + firedInCooldown = null; + for (const k of ['down', 'down', 'primary'] as GameKey[]) { + const r = sys.press(k, t); + if (r) firedInCooldown = r; + t += 150; + } + expect(firedInCooldown).toBe('surge'); + }); + + it('maxhearts fires once per page load', () => { + const sys = new CheatSystem(); + const seq: GameKey[] = ['down', 'down', 'up', 'up', 'left', 'right', 'primary']; + let fired: string | null = null; + for (const k of seq) { + const r = sys.press(k, 10000); + if (r) fired = r; + } + expect(fired).toBe('maxhearts'); + // Re-run the exact sequence far later (past the 60 s cooldown) + fired = null; + let t = 100_000; + for (const k of seq) { + const r = sys.press(k, t); + if (r) fired = r; + t += 150; + } + expect(fired).toBeNull(); + }); +}); + +describe('cheat runs vs Hall of Claws', () => { + function makeGame(): Game { + const canvas = document.createElement('canvas'); + document.body.appendChild(canvas); + return new Game(canvas); + } + + function konami(g: Game): void { + for (const k of ['up', 'up', 'down', 'down', 'left', 'right', 'left', 'right', 'primary'] as GameKey[]) { + g.handleKey(k); + } + } + + beforeEach(() => { + clearRuns(); + localStorage.clear(); + }); + + it('a cheat-assisted victory is excluded from the Hall of Claws', () => { + const g = makeGame(); + g.levelIdx = 0; + g.handleKey('primary'); // start + expect(g.state).toBe('playing'); + konami(g); // rainbow fires mid-run + g.onPlayerVictory(); + expect(g.state).toBe('victory'); + expect(getRuns().length).toBe(0); + expect(g.lastRun).toBeNull(); + }); + + it('a clean victory is recorded in the Hall of Claws', () => { + const g = makeGame(); + g.levelIdx = 0; + g.handleKey('primary'); + g.onPlayerVictory(); + expect(getRuns().length).toBe(1); + expect(g.lastRun).not.toBeNull(); + }); + + it('a cheat run does not overwrite a stored ghost track', () => { + const g = makeGame(); + g.levelIdx = 0; + g.handleKey('primary'); + konami(g); + g.onPlayerVictory(); + expect(getGhostTrack(0, 0)).toBeNull(); + }); +}); diff --git a/tests/dusk.test.ts b/tests/dusk.test.ts index f0490a9..140ccfb 100644 --- a/tests/dusk.test.ts +++ b/tests/dusk.test.ts @@ -101,6 +101,63 @@ describe('rising tide mechanic', () => { lvl.reset(); expect(lvl.waterY).toBe(dusk.def.tide!.fromY); expect(lvl.tideWarned).toBe(false); + expect(lvl.tideTense).toBe(false); + }); + + it('flags tideTense once the water has climbed 80% of its rise, and reset clears it', () => { + const game = makeGame(); + game.levelIdx = 4; + game.handleKey('primary'); + const lvl = game.level!; + const t = dusk.def.tide!; + expect(lvl.tideTense).toBe(false); + // Just under the 80% threshold: no tension cue yet. + lvl.waterY = t.fromY - 0.79 * (t.fromY - t.toY); + game.update(0.016); + expect(lvl.tideTense).toBe(false); + // Past the threshold: the one-shot tension flag trips. + lvl.waterY = t.fromY - 0.81 * (t.fromY - t.toY); + game.update(0.016); + expect(lvl.tideTense).toBe(true); + // reset() clears the cue for the next run. + lvl.reset(); + expect(lvl.tideTense).toBe(false); + }); +}); + +describe('checkpoint & spawn safety under the full tide', () => { + // Lowest platform top that stays dry forever: the final waterline minus a + // margin. A respawn point is safe iff it has a dry refuge within running + // jump range (max flat gap ~210-230px, jump apex ~130-135px). + const SAFE_TOP = dusk.def.tide!.toY - 6; + const REACH_X = 230; + const REACH_UP = 130; + + function dryRefugeNear(x: number, y: number): boolean { + return dusk.def.platforms.some((pl) => { + if (pl.y > SAFE_TOP || pl.y < y - REACH_UP) return false; + return Math.abs(pl.x - x) <= REACH_X; + }); + } + + it('every checkpoint and the spawn have a reachable dry refuge', () => { + const points = [...dusk.def.checkpoints, { x: dusk.def.startX, y: dusk.def.startY }]; + expect(points.length).toBeGreaterThanOrEqual(4); + for (const pt of points) { + expect( + dryRefugeNear(pt.x, pt.y), + `no dry refuge near respawn point (${pt.x}, ${pt.y})`, + ).toBe(true); + } + }); + + it('no checkpoint sits on ground the tide eventually drowns', () => { + for (const cp of dusk.def.checkpoints) { + expect( + cp.y, + `checkpoint at x=${cp.x} stands on submergeable ground (top ${cp.y} > ${SAFE_TOP})`, + ).toBeLessThanOrEqual(SAFE_TOP); + } }); }); diff --git a/tests/game.test.ts b/tests/game.test.ts index 8bb02b3..26c9c96 100644 --- a/tests/game.test.ts +++ b/tests/game.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { CFG } from '../src/config'; import { Game } from '../src/game'; import { LEVEL_DATA, LEVELS } from '../src/level-data'; -import { Store, getGhostEnabled, getFoundFossils, getSkinId, type GameStats } from '../src/store'; +import { Store, getStats, getGhostEnabled, getFoundFossils, getSkinId, type GameStats } from '../src/store'; import type { RexView } from '../src/sprite'; function makeGame(): Game { @@ -239,6 +239,29 @@ describe('Run end: stars & per-level records', () => { expect(Store.get('tinyrex_stats', null)!.deaths).toBe(1); }); + it('flags allClear on victory once every level has a completed record', () => { + for (let i = 0; i < LEVELS.length; i++) { + Store.set('tinyrex_best_' + i, { score: 100 + i, time: 50 + i }); + } + game.handleKey('primary'); // level 0, non-daily + game.elapsed = 100; // a real (positive) run duration + game.onPlayerVictory(); + expect(game.stats.allClear).toBe(true); + expect(getStats().allClear).toBe(true); + }); + + it('does not flag allClear until the final level is completed', () => { + for (let i = 0; i < LEVELS.length - 1; i++) { + Store.set('tinyrex_best_' + i, { score: 100 + i, time: 50 + i }); + } + // Duskfen (the last level) intentionally has no record yet. + game.handleKey('primary'); // level 0, non-daily + game.elapsed = 100; + game.onPlayerVictory(); + expect(game.stats.allClear).toBe(false); + expect(getStats().allClear).toBe(false); + }); + it('unlocks a new max heart every three hearts collected, capped at five', () => { game.handleKey('primary'); const p = game.player!; diff --git a/tests/ghost.test.ts b/tests/ghost.test.ts index 44ca3bb..1e89a68 100644 --- a/tests/ghost.test.ts +++ b/tests/ghost.test.ts @@ -77,6 +77,21 @@ describe('GhostPlayer', () => { expect(g.view.state).toBe('idle'); expect(g.moving).toBe(false); }); + + it('clamps before the first sample without NaN', () => { + const g = new GhostPlayer(mkTrack([[0, 0, 414], [1, 100, 414]])); + g.update(-5); + expect(Number.isFinite(g.x)).toBe(true); + expect(g.x).toBeCloseTo(0); + expect(g.y).toBeCloseTo(414); + }); + + it('survives zero-span samples (duplicate timestamps)', () => { + const g = new GhostPlayer(mkTrack([[0, 0, 414], [0, 50, 400], [1, 100, 390]])); + g.update(0.5); + expect(Number.isFinite(g.x)).toBe(true); + expect(Number.isFinite(g.y)).toBe(true); + }); }); describe('ghost persistence', () => { @@ -105,6 +120,33 @@ describe('ghost persistence', () => { expect(getGhostTrack(1, 0)).toBeNull(); }); + it('rejects tracks with NaN samples or a backwards clock', () => { + const base = mkTrack([[0, 0, 0], [1, 10, 0], [2, 20, 0], [3, 30, 0]]); + const nan = { ...base, pts: [{ t: 0, x: NaN, y: 0 }, ...base.pts.slice(1)] }; + Store.set('tinyrex_ghost_1', nan); + expect(getGhostTrack(1, 0)).toBeNull(); + const back = { + ...base, + pts: base.pts.map((p, i) => (i === 2 ? { ...p, t: 0.5 } : p)), // t: 0,1,0.5,3 + }; + Store.set('tinyrex_ghost_1', back); + expect(getGhostTrack(1, 0)).toBeNull(); + }); + + it('rejects tracks longer than the hard cap or with a bad score/time', () => { + const huge = mkTrack( + Array.from({ length: 6001 }, (_, i) => [i * 0.1, i, 0] as [number, number, number]), + ); + Store.set('tinyrex_ghost_1', huge); + expect(getGhostTrack(1, 0)).toBeNull(); + const badScore = mkTrack([[0, 0, 0], [1, 10, 0], [2, 20, 0], [3, 30, 0]], NaN, 10); + Store.set('tinyrex_ghost_1', badScore); + expect(getGhostTrack(1, 0)).toBeNull(); + const badTime = mkTrack([[0, 0, 0], [1, 10, 0], [2, 20, 0], [3, 30, 0]], 50, -1); + Store.set('tinyrex_ghost_1', badTime); + expect(getGhostTrack(1, 0)).toBeNull(); + }); + it('toggle persists and defaults on', () => { expect(getGhostEnabled()).toBe(true); setGhostEnabled(false); diff --git a/tests/level-data.test.ts b/tests/level-data.test.ts index d57f511..17607dc 100644 --- a/tests/level-data.test.ts +++ b/tests/level-data.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { LEVEL_DATA, LEVELS } from '../src/level-data'; +import { CFG } from '../src/config'; describe('LEVEL_DATA — Crystal Valley integrity', () => { it('has the original top-level shape', () => { @@ -11,7 +12,7 @@ describe('LEVEL_DATA — Crystal Valley integrity', () => { }); it('keeps the original entity counts', () => { - expect(LEVEL_DATA.platforms).toHaveLength(35); + expect(LEVEL_DATA.platforms).toHaveLength(38); expect(LEVEL_DATA.crystals).toHaveLength(39); expect(LEVEL_DATA.crystals.filter((c) => c.bonus)).toHaveLength(1); expect(LEVEL_DATA.enemies).toHaveLength(14); @@ -83,6 +84,48 @@ describe('LEVELS registry', () => { expect(LEVELS[4].theme).toBe('dusk'); expect(LEVELS[0].def).toBe(LEVEL_DATA); }); + + it('keeps every ground gap jumpable (or assisted by a reachable platform)', () => { + // Max measured jump distance is ~233 px (285 px/s × ~0.83 s airtime). + // A wider gap is only legal if a non-ground platform overlaps it with a + // top surface the player can reach from the ground line. + const MAX_JUMP = 233; + for (const info of LEVELS) { + const d = info.def; + const GY = d.startGroundY; + const grounds = d.platforms + .filter((p) => (p.type ?? 'ground') === 'ground' && p.y === GY) + .sort((a, b) => a.x - b.x); + for (let i = 1; i < grounds.length; i++) { + const prev = grounds[i - 1]; + const cur = grounds[i]; + const gapStart = prev.x + prev.w; + const gap = cur.x - gapStart; + if (gap <= 0) continue; + if (gap <= MAX_JUMP) continue; + const assisted = d.platforms.some( + (p) => + (p.type ?? 'ground') !== 'ground' && + p.x < cur.x && + p.x + p.w > gapStart && + p.y >= GY - 135 && + p.y <= GY + 10, + ); + expect(assisted, `${info.name}: unassisted ${gap}px ground gap at x=${gapStart}–${cur.x}`).toBe(true); + } + } + }); + + it('places every goal on its final ground segment', () => { + for (const info of LEVELS) { + const d = info.def; + const GY = d.startGroundY; + const grounds = d.platforms.filter((p) => (p.type ?? 'ground') === 'ground' && p.y === GY); + const last = grounds.reduce((a, b) => (b.x > a.x ? b : a)); + expect(d.goal.x, info.name).toBeGreaterThanOrEqual(last.x); + expect(d.goal.x, info.name).toBeLessThanOrEqual(last.x + last.w); + } + }); }); describe('LEVEL_2 — Volcanic Depths integrity', () => { @@ -339,6 +382,14 @@ describe('LEVEL_4 — Molten Nest integrity', () => { } }); + it('bridges the right-wall seam for a safe boss exit', () => { + const arena = L4.platforms.find((p) => p.type === 'ground' && p.x === 2244); + const exit = L4.platforms.find((p) => p.type === 'ground' && p.x === 3344); + expect(arena).toBeTruthy(); + expect(exit).toBeTruthy(); + expect(exit!.x).toBe(arena!.x + arena!.w); + }); + it('places the nest gate on the exit floor ahead of the goal', () => { expect(L4.doors).toHaveLength(1); const d = L4.doors![0]; @@ -381,6 +432,85 @@ describe('LEVEL_4 — Molten Nest integrity', () => { ).toBe(true); } }); + + describe('left-perch / stomp-band route', () => { + const P = CFG.player; + const DT = 1 / 60; // match the game's fixed timestep + const boss = L4.boss!; + const bossW = 120; + const stompBandY = boss.y + 18; // where a falling foot registers a stomp + const leftPerch = L4.platforms.find( + (p) => p.type === 'stone' && p.y === 320 && p.x < 2700, + )!; + const leftOrb = L4.orbs![0]; + const floorTop = 460; + + /** + * Simulate the intended entry move: Rex leaves the pocket just right of the + * left wall running right at full speed with a full jump, stomps the Magma + * King the moment his falling foot is over the boss's body, and the stomp + * bounce should deposit him on the left perch. Mirrors the game's 60 Hz + * per-frame checks. + */ + function simulateRoute(bossX: number) { + let x = 2282; // entry pocket, just right of the left wall (2284) + let feet = floorTop; + let vy = -P.jumpVel; + const vx = P.maxSpeed; + let stomped = false; + let stompX = -1; + let landX = -1; + let perchClear = true; + for (let i = 0; i < 240; i++) { + x += vx * DT; + vy += P.gravity * DT; + feet += vy * DT; + // Pre-landing arc must not clip the left perch's face/body. + const overPerchX = x + P.w > leftPerch.x && x < leftPerch.x + leftPerch.w; + const overPerchY = feet > leftPerch.y && feet - P.h < leftPerch.y + leftPerch.h; + if (stomped === false && overPerchX && overPerchY) perchClear = false; + // Stomp: falling fast, foot at/above the band, body over the boss. + const overBoss = x + P.w > bossX && x < bossX + bossW; + if (!stomped && vy > 40 && feet <= stompBandY && overBoss) { + stomped = true; + stompX = x; + vy = -P.stompBounce; + } else if ( + stomped && + vy > 0 && + overPerchX && + feet >= leftPerch.y && + feet - P.h <= leftPerch.y && + landX < 0 + ) { + landX = x; + break; + } + } + return { stomped, stompX, landX, perchClear }; + } + + it('left orb sits above the left perch so it is stompable from it', () => { + expect(leftOrb.x).toBeGreaterThanOrEqual(leftPerch.x); + expect(leftOrb.x).toBeLessThanOrEqual(leftPerch.x + leftPerch.w); + expect(leftOrb.y).toBeLessThan(leftPerch.y); // above the perch top + expect(leftOrb.y + 13).toBeGreaterThan(leftPerch.y - P.h); // reachable band + }); + + it('the boss at his leftmost patrol spot leaves a gap to the left perch', () => { + // Boss right edge at minX must stay left of the perch so the perch is + // never blocked by the boss body. + expect(boss.minX + bossW).toBeLessThan(leftPerch.x); + }); + + it('pocket → stomp → perch route is kinematically intact', () => { + const r = simulateRoute(boss.minX); + expect(r.stomped, 'arc should stomp the Magma King').toBe(true); + expect(r.perchClear, 'pre-landing arc should clear the left perch').toBe(true); + expect(r.landX, 'bounce should land on the left perch').toBeGreaterThan(leftPerch.x - P.w); + expect(r.landX + P.w).toBeLessThanOrEqual(leftPerch.x + leftPerch.w + 4); + }); + }); }); describe('Hidden fossils (all hand-built levels)', () => { diff --git a/tests/mechanics.test.ts b/tests/mechanics.test.ts index d904a93..25d0b47 100644 --- a/tests/mechanics.test.ts +++ b/tests/mechanics.test.ts @@ -251,9 +251,11 @@ describe('CheatSystem', () => { expect(feed(cs, GOD, 0)).toBe('god'); }); - it('fires score surge on a triple jump tap', () => { + it('fires score surge on down, down, jump (and not on jump spam)', () => { const cs = new CheatSystem(); - expect(feed(cs, ['primary', 'primary', 'primary'], 0, 150)).toBe('surge'); + expect(feed(cs, ['primary', 'primary', 'primary'], 0, 150)).toBeNull(); // jump spam is normal play + const cs2 = new CheatSystem(); + expect(feed(cs2, ['down', 'down', 'primary'], 0, 150)).toBe('surge'); }); it('max hearts fires on its sequence', () => { @@ -286,8 +288,8 @@ describe('CheatSystem', () => { it('surge can be re-triggered after its cooldown', () => { const cs = new CheatSystem(); - expect(feed(cs, ['primary', 'primary', 'primary'], 0, 150)).toBe('surge'); - expect(feed(cs, ['primary', 'primary', 'primary'], 12500, 150)).toBe('surge'); + expect(feed(cs, ['down', 'down', 'primary'], 0, 150)).toBe('surge'); + expect(feed(cs, ['down', 'down', 'primary'], 12500, 150)).toBe('surge'); }); }); diff --git a/tests/store.test.ts b/tests/store.test.ts new file mode 100644 index 0000000..f8b9a86 --- /dev/null +++ b/tests/store.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + Store, + getBest, + getBestStars, + getDailyBest, + getDailyStars, + getStats, + getRuns, + addRun, + MAX_RUNS, + getSkinId, + setSkinId, + type RunRecord, +} from '../src/store'; + +function mkRun(i: number): RunRecord { + return { score: i, time: 10 + i, level: 'Crystal Valley', difficulty: 'normal', date: Date.now() + i }; +} + +describe('best records survive corrupt storage', () => { + beforeEach(() => localStorage.clear()); + + it('round-trips a valid best', () => { + Store.set('tinyrex_best_2', { score: 4200, time: 71.5 }); + expect(getBest(2)).toEqual({ score: 4200, time: 71.5 }); + }); + + it('degrades corrupt types to a zero state', () => { + for (const junk of ['garbage', 12, true, [1, 2], { score: 'fast', time: null }, { score: NaN, time: -5 }, { time: 'soon' }]) { + Store.set('tinyrex_best_2', junk); + const b = getBest(2); + expect(Number.isFinite(b.score)).toBe(true); + expect(b.score).toBeGreaterThanOrEqual(0); + expect(b.time === null || b.time > 0).toBe(true); + } + }); + + it('keeps a partial record (score only)', () => { + Store.set('tinyrex_best_2', { score: 900 }); + expect(getBest(2)).toEqual({ score: 900, time: null }); + }); + + it('reads the legacy level-0 key as a plain score', () => { + Store.set('tinyrex_best_score', 3333); + expect(getBest(0)).toEqual({ score: 3333, time: null }); + // a modern key still wins over the legacy one + Store.set('tinyrex_best_0', { score: 500, time: 20 }); + expect(getBest(0)).toEqual({ score: 500, time: 20 }); + }); + + it('validates the daily best the same way', () => { + Store.set('tinyrex_best_daily', 'oops'); + expect(getDailyBest()).toEqual({ score: 0, time: null }); + Store.set('tinyrex_best_daily', { score: 100, time: 12.5 }); + expect(getDailyBest()).toEqual({ score: 100, time: 12.5 }); + }); +}); + +describe('star ratings clamp to 0–3', () => { + beforeEach(() => localStorage.clear()); + + it.each([ + [-5, 0], + [0, 0], + [2.4, 2], + [2.6, 3], + [7, 3], + ['x', 0], + [NaN, 0], + ])('clamps %p to %p', (stored, expected) => { + Store.set('tinyrex_stars_1', stored); + expect(getBestStars(1)).toBe(expected); + Store.set('tinyrex_stars_daily', stored); + expect(getDailyStars()).toBe(expected); + }); +}); + +describe('lifetime stats normalize corrupt values', () => { + beforeEach(() => localStorage.clear()); + + it('returns zeros for missing or junk stats', () => { + expect(getStats()).toEqual({ runs: 0, deaths: 0, crystals: 0, victories: 0, hearts: 0, firstPlayed: null, allClear: false }); + Store.set('tinyrex_stats', 'junk'); + const s = getStats(); + expect(s.runs).toBe(0); + expect(typeof s.victories).toBe('number'); + expect(s.allClear).toBe(false); + }); + + it('persists and normalizes the all-clear flag', () => { + expect(getStats().allClear).toBe(false); // absent field → false + Store.set('tinyrex_stats', { runs: 3, deaths: 1, crystals: 10, victories: 5, hearts: 2, firstPlayed: 123, allClear: true }); + expect(getStats().allClear).toBe(true); + Store.set('tinyrex_stats', 'junk'); + expect(getStats().allClear).toBe(false); // corrupt → false + }); +}); + +describe('Hall of Claws run log', () => { + beforeEach(() => localStorage.clear()); + + it('filters corrupt entries out of a mixed array', () => { + Store.set('tinyrex_runs', [mkRun(1), null, 'junk', { score: 'nope' }, { score: 2, level: 42 }, { score: 3, level: 'Frostpeak' }]); + const runs = getRuns(); + expect(runs.length).toBe(2); + expect(runs[0].score).toBe(1); + }); + + it('caps the log at MAX_RUNS, keeping the newest', () => { + for (let i = 0; i < MAX_RUNS + 5; i++) addRun(mkRun(i)); + const runs = getRuns(); + expect(runs.length).toBe(MAX_RUNS); + expect(runs[0].score).toBe(MAX_RUNS + 4); // newest first (104) + expect(runs[MAX_RUNS - 1].score).toBe(5); // oldest five (0–4) dropped + }); +}); + +describe('skin selection', () => { + beforeEach(() => localStorage.clear()); + + it('defaults to classic and rejects unknown ids', () => { + expect(getSkinId()).toBe('classic'); + Store.set('tinyrex_skin', 'rainbow-fox'); + expect(getSkinId()).toBe('classic'); + setSkinId('ember'); + expect(getSkinId()).toBe('ember'); + setSkinId('nope'); + expect(getSkinId()).toBe('classic'); + }); +}); diff --git a/tests/weather.test.ts b/tests/weather.test.ts index 94ad1f7..d225ad9 100644 --- a/tests/weather.test.ts +++ b/tests/weather.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Game } from '../src/game'; +import { CFG } from '../src/config'; import { Weather, ventState, GUST_DUR, + GUST_FORCE, GEYSER_PERIOD, GEYSER_ERUPT, GEYSER_BUBBLE, @@ -111,6 +113,51 @@ describe('frost gusts', () => { expect(p.vx).toBeGreaterThan(10); }); + it('headwind can never push back a player actively countering (ground or air)', () => { + // Fairness invariant of the force model (GUST_FORCE == airAccel): while + // Rex holds the direction opposite the gust, his velocity must never + // turn negative — on the ground or in the air. Regression: the old + // velocity-blend model dragged a full-speed Rex BACKWARD at ~115 px/s + // for the whole 2.4 s gust and walked him off ledges into pits. + const P = CFG.player; + expect(GUST_FORCE).toBeLessThanOrEqual(P.airAccel); + const w = new Weather(); + w.rng = () => 0.1; // gustDir -1: leftward headwind + w.apply('frost', [], 0); + w.gusting = GUST_DUR; + const frames = Math.ceil(GUST_DUR * 60); + for (const accel of [P.accel, P.airAccel]) { + const { p } = fakePlayer(0); + p.vx = P.maxSpeed; // running right into the wind + for (let i = 0; i < frames; i++) { + const dt = 1 / 60; + p.vx += accel * dt; // player.ts input step + p.vx = Math.max(-P.maxSpeed, Math.min(P.maxSpeed, p.vx)); // maxSpeed clamp + w.update(dt, p); // gust force step + expect(p.vx).toBeGreaterThanOrEqual(0); + } + } + }); + + it('a gust cannot drag an idle grounded player off the ledge', () => { + // Ground friction (1750) beats the gust force (950): a Rex who is not + // running stays put while the wind howls around him. + const P = CFG.player; + const w = new Weather(); + w.rng = () => 0.1; // leftward gust + w.apply('frost', [], 0); + w.gusting = GUST_DUR; + const { p } = fakePlayer(0); + for (let i = 0; i < Math.ceil(GUST_DUR * 60); i++) { + const dt = 1 / 60; + w.update(dt, p); // gust pushes left + const s = Math.sign(p.vx); // player.ts idle friction step + p.vx -= s * P.friction * dt; + if (Math.sign(p.vx) !== s) p.vx = 0; + expect(p.vx).toBe(0); + } + }); + it('spawns streaks while gusting, unless reduced motion', () => { const w = new Weather(); w.rng = () => 0.5; @@ -190,6 +237,19 @@ describe('meadow drift', () => { } expect(p.x).toBe(x0); // drift is a force, not a teleport }); + + it('never clamps the player run speed (pollen must not fight control)', () => { + const w = new Weather(); + w.rng = () => 0.5; + w.apply('meadow', [], 100); + const { p } = fakePlayer(100); + p.vx = 285; // max run speed + for (let i = 0; i < 60; i++) w.update(1 / 60, p); // 1 s of updates + // A blend toward the drift would drag vx down to ~14; the nudge may + // only add a small wobble on top of the player's own speed. + expect(p.vx).toBeGreaterThan(150); + expect(p.vx).toBeLessThan(450); + }); }); describe('Game integration', () => {