game-rules.js (9331B)
1 "use strict"; 2 3 // Shared by the browser and offline tests: prompt facts come from the physics. 4 const GameRules = (() => { 5 const W = 68, H = 8, GROUND = 7; 6 const GOAL = 64, START_X = 3; 7 const RUN_VX = 1, JUMP_VY = -2.3, GRAV = 0.9; 8 9 // Level shape. Width and count are deliberately fixed; only pit *positions* 10 // are randomisable. A 3-wide pit is what the jump arc clears with two safe 11 // takeoff tiles (the edge, and one back), and 3 pits => MAX_JUMPS 4. Changing 12 // width or count would change the difficulty class and the solvability proof, 13 // so the generator never does it. Positions vary within boundaries that keep 14 // the standard "jump at the edge" strategy a guaranteed solve. 15 const PIT_W = 3; // every pit is exactly PIT_W tiles wide 16 const MIN_FIRST = START_X + 3; // earliest left edge (runway after the start) 17 const STEP = PIT_W + 3; // min left-edge gap between pits (>=3 solid tiles) 18 const MAX_LAST = GOAL - 6; // latest left edge (landing + runway before the flag) 19 20 const DEFAULT_PITS = [[14, 16], [31, 33], [48, 50]]; // seed-free baseline the UI boots on 21 let pits = DEFAULT_PITS.map(([a, b]) => [a, b]); // current mutable layout 22 23 const floorAt = (col) => !pits.some(([a, b]) => col >= a && col <= b); 24 25 // mulberry32: small, deterministic, good enough for shuffling 3 positions. 26 function rng(seed) { 27 let a = seed >>> 0; 28 return () => { 29 a = (a + 0x6D2B79F5) | 0; 30 let t = Math.imul(a ^ (a >>> 15), 1 | a); 31 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; 32 return ((t ^ (t >>> 14)) >>> 0) / 4294967296; 33 }; 34 } 35 const randInt = (r, lo, hi) => lo + Math.floor(r() * (hi - lo + 1)); // inclusive 36 37 // Three width-PIT_W pits at seeded positions, always valid by construction: 38 // draw the first left edge, then each next edge at least STEP ahead, clamped 39 // so the last edge can never exceed MAX_LAST. 40 function makePits(seed) { 41 const r = rng(seed); 42 const a1 = randInt(r, MIN_FIRST, MAX_LAST - 2 * STEP); 43 const a2 = randInt(r, a1 + STEP, MAX_LAST - STEP); 44 const a3 = randInt(r, a2 + STEP, MAX_LAST); 45 return [a1, a2, a3].map((a) => [a, a + PIT_W - 1]); 46 } 47 48 // Returns human-readable problems; an empty array means the layout is valid. 49 function validatePits(p) { 50 const errs = []; 51 if (p.length !== 3) errs.push("exactly 3 pits required"); 52 let prev = null; 53 for (const [a, b] of p) { 54 if (b - a + 1 !== PIT_W) errs.push(`pit ${a}-${b} must be ${PIT_W} tiles wide`); 55 if (a <= START_X) errs.push(`pit at ${a} is on or behind the start`); 56 if (b >= GOAL) errs.push(`pit at ${b} covers the goal`); 57 if (prev !== null && a - prev < STEP) errs.push(`pits at ${prev} and ${a} are too close`); 58 prev = a; 59 } 60 if (p.length && p[0][0] < MIN_FIRST) errs.push(`first pit left of ${MIN_FIRST}`); 61 if (p.length && p[p.length - 1][0] > MAX_LAST) errs.push(`last pit right of ${MAX_LAST}`); 62 return errs; 63 } 64 65 // Swaps the live layout (floorAt, the prompts and the renderer all read it). 66 function setPits(next) { 67 const errs = validatePits(next); 68 if (errs.length) throw new Error("Invalid pit layout: " + errs.join("; ")); 69 pits = next.map(([a, b]) => [a, b]); 70 return pits; 71 } 72 73 function airborneStep(y, vy) { 74 vy += GRAV; 75 return { y: y + vy, vy }; 76 } 77 78 function jumpTicks() { 79 let y = GROUND, vy = JUMP_VY, ticks = 0; 80 do { 81 ({ y, vy } = airborneStep(y, vy)); 82 ticks++; 83 } while (y < GROUND); 84 return ticks; 85 } 86 const JUMP_TICKS = jumpTicks(); 87 const JUMP_DISTANCE = JUMP_TICKS * RUN_VX; 88 89 // Returns a new state. Once below the surface, moving under a solid tile 90 // cannot teleport the player back onto it. 91 function advance(player, moveRight) { 92 const next = { ...player }; 93 if (!next.onGround || moveRight) next.x += RUN_VX; 94 if (next.onGround && !floorAt(Math.floor(next.x))) { 95 next.onGround = false; 96 next.vy = 0; 97 } 98 if (!next.onGround) { 99 const previousY = next.y; 100 Object.assign(next, airborneStep(next.y, next.vy)); 101 if (next.vy > 0 && previousY <= GROUND && next.y >= GROUND && floorAt(Math.floor(next.x))) { 102 next.y = GROUND; 103 next.vy = 0; 104 next.onGround = true; 105 } 106 } 107 return { player: next, fell: next.y > H + 2 }; 108 } 109 110 function guidedJump(player, jumpsLeft) { 111 return player.onGround && jumpsLeft > 0 && !floorAt(Math.floor(player.x + RUN_VX)); 112 } 113 114 // Whole level as one row of symbols: - floor, # pit, * you, ! flag. 115 // Cells are joined with spaces so every glyph is its own token — without 116 // them BPE merges runs like "------" into few meaningless tokens and the 117 // model's failures are perception (tokenization) errors, not reasoning ones. 118 // Still harder than prose: the model must decode the symbols and locate 119 // itself, the pits and the flag on the row before deciding. 120 function asciiText(player, jumpsLeft) { 121 const here = Math.floor(player.x); 122 const row = []; 123 for (let x = 0; x <= GOAL; x++) { 124 if (x === here) row.push("*"); 125 else if (x === GOAL) row.push("!"); 126 else row.push(floorAt(x) ? "-" : "#"); 127 } 128 return [ 129 "Reach the flag without falling into a pit.", 130 "Legend: [`*`: player, `-`: safe floor, `#`: fail pit, `!`: goal flag]", 131 "Run moves the `*` one space forward", 132 "If the next space is a pit, run will make you fail.", 133 "Jump at the edge of a pit to cross it.", 134 `Jumps remaining: ${jumpsLeft}`, 135 "", 136 row.join(" "), 137 ].join("\n"); 138 } 139 140 // Run-length symbolic: terrain ahead of the player as run-length segments. 141 // Easier to parse than the raw ASCII row (no counting columns) but still 142 // symbolic, so the model must read the legend and act on the next segment. 143 function runLengthText(player, jumpsLeft) { 144 const terrain = []; 145 for (let x = player.x + 1; x < GOAL; x++) { 146 const kind = floorAt(x) ? "ground" : "hole"; 147 const last = terrain[terrain.length - 1]; 148 if (last && last.kind === kind) last.count++; 149 else terrain.push({ kind, count: 1 }); 150 } 151 const segs = terrain.map(({ kind, count }) => `${kind === "ground" ? "-" : "#"}${count}`); 152 segs.push("[!]"); 153 return [ 154 "Reach the flag without falling into a pit.", 155 "Legend: [`*`: player, `>`: facing right, `-N`: run of N safe floor, `#N`: run of N fail pit, `!`: goal flag]", 156 "Run moves the `[*]` one space forward.", 157 "If the next segment is a pit, run will make you fail.", 158 "Jump at the edge of a pit to cross it.", 159 `Jumps remaining: ${jumpsLeft}`, 160 "", 161 `[*] > ${segs.join(" | ")}`, 162 ].join("\n"); 163 } 164 165 // mode: "guided" | "unguided" | "runlength" | "ascii". Booleans are accepted 166 // for callers that predate the newer modes: true => guided, false => unguided. 167 function stateText(player, jumpsLeft, mode = "unguided") { 168 const m = mode === true ? "guided" : mode === false ? "unguided" : mode; 169 if (m === "ascii") return asciiText(player, jumpsLeft); 170 if (m === "runlength") return runLengthText(player, jumpsLeft); 171 const guided = m === "guided"; 172 // Describe destination spaces, starting one move ahead. The flag occupies 173 // its own ground space; don't count it twice in the preceding ground run. 174 const terrain = []; 175 for (let x = player.x + 1; x < GOAL; x++) { 176 const kind = floorAt(x) ? "ground" : "hole"; 177 const last = terrain[terrain.length - 1]; 178 if (last && last.kind === kind) last.count++; 179 else terrain.push({ kind, count: 1 }); 180 } 181 const lines = [ 182 "Reach the flag without falling into a pit.", 183 "Run moves you one space forward.", 184 "If the next space is a hole, running makes you fall.", 185 "Jump at the edge of a pit to cross it.", 186 "You can act again after running or landing.", 187 "", 188 `Player: ${player.onGround ? "standing on ground" : "airborne"}, facing right`, 189 `Jumps remaining: ${jumpsLeft}`, 190 "", 191 // When the flag is the very next space, the enumeration would be an 192 // empty list under a header — say the useful thing instead. 193 player.x + 1 === GOAL ? "The flag is right in front of you!" 194 : "Ahead, from nearest to farthest (starting with the next space):", 195 ...terrain.map(({ kind, count }) => `${count} ${kind} space${count === 1 ? "" : "s"}`), 196 player.x < GOAL ? "" : "Flag reached", 197 198 ]; 199 if (guided) { 200 lines.push(!player.onGround ? "Hint: wait for the current jump to finish." 201 : guidedJump(player, jumpsLeft) ? "Hint: jump now; the next running tick would enter a pit." 202 : "Hint: run for one tick, then reassess."); 203 } 204 return lines.join("\n"); 205 } 206 207 const QUESTION = "What should the player do now?"; 208 const OPTIONS = [ 209 { id: "run", description: "Run" }, 210 { id: "jump", description: "Jump" }, 211 ]; 212 return { W, H, GROUND, 213 get PITS() { return pits; }, 214 get MAX_JUMPS() { return pits.length + 1; }, 215 GOAL, START_X, RUN_VX, JUMP_VY, GRAV, 216 JUMP_TICKS, JUMP_DISTANCE, floorAt, advance, guidedJump, stateText, asciiText, runLengthText, 217 DEFAULT_PITS, PIT_W, MIN_FIRST, STEP, MAX_LAST, makePits, validatePits, setPits, 218 QUESTION, OPTIONS }; 219 })(); 220 221 if (typeof module !== "undefined") module.exports = GameRules;