semif-api-rocm

SemIf HTTP API and rocm flake
Log | Files | Refs | README | LICENSE

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;