semif-api-rocm

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

room-rules.js (21113B)


      1 "use strict";
      2 
      3 // Shared by the browser and offline tests: room facts come from the rules.
      4 // Turn-based puzzle room on a grid. The player has a position and a facing;
      5 // each decision is ONE action — step forward, turn left, turn right. The model
      6 // never sees the map: the FPP state text reports what is adjacent (touch
      7 // range), what the FOV cone sees (bearings) and carry status, so navigation
      8 // is dead-reckoning from prose.
      9 const RoomRules = (() => {
     10   const W = 11, H = 9;
     11   const DIV = 5;               // dividing wall column; the locked door is its only gap
     12   const MAX_STEPS = 80;        // step budget per attempt (turns count)
     13 
     14   const DIRS = [
     15     { dx: 0, dy: -1, name: "NORTH", arrow: "^" },
     16     { dx: 1, dy: 0, name: "EAST", arrow: ">" },
     17     { dx: 0, dy: 1, name: "SOUTH", arrow: "v" },
     18     { dx: -1, dy: 0, name: "WEST", arrow: "<" },
     19   ];
     20 
     21 
     22   // mulberry32: small, deterministic, good enough for shuffling wall bits.
     23   function rng(seed) {
     24     let a = seed >>> 0;
     25     return () => {
     26       a = (a + 0x6D2B79F5) | 0;
     27       let t = Math.imul(a ^ (a >>> 15), 1 | a);
     28       t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
     29       return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
     30     };
     31   }
     32   const randInt = (r, lo, hi) => lo + Math.floor(r() * (hi - lo + 1)); // inclusive
     33 
     34   const blocked = (cell, doorPassable) =>
     35     cell === "#" || (cell === "D" && !doorPassable);
     36 
     37   // BFS over floor cells; K/E passable, D passable only when doorPassable.
     38   function reachable(grid, from, to, doorPassable) {
     39     if (from.x === to.x && from.y === to.y) return true;
     40     const seen = new Set([from.x + "," + from.y]);
     41     const queue = [from];
     42     while (queue.length) {
     43       const cur = queue.shift();
     44       for (const d of DIRS) {
     45         const nx = cur.x + d.dx, ny = cur.y + d.dy;
     46         const id = nx + "," + ny;
     47         if (seen.has(id) || ny < 0 || ny >= H || nx < 0 || nx >= W) continue;
     48         if (blocked(grid[ny][nx], doorPassable)) continue;
     49         if (nx === to.x && ny === to.y) return true;
     50         seen.add(id);
     51         queue.push({ x: nx, y: ny });
     52       }
     53     }
     54     return false;
     55   }
     56 
     57   // Hand-authored layout used if seeded generation ever fails all attempts.
     58   // start (1,1) · key (2,7) · door (5,5) · exit (9,7); always solvable.
     59   const FALLBACK = {
     60     grid: [
     61       "###########",
     62       "#.....#...#",
     63       "#.....#...#",
     64       "#..#..#...#",
     65       "#..#..#...#",
     66       "#..#..D...#",
     67       "#..#..#...#",
     68       "#.K#..#..E#",
     69       "###########",
     70     ],
     71     start: { x: 1, y: 1 }, key: { x: 2, y: 7 },
     72     exit: { x: 9, y: 7 }, doorRow: 5,
     73   };
     74 
     75   // Seeded layout: border + a dividing wall with one locked door, a few random
     76   // wall segments inside each zone, then entities. Accepted only when the key
     77   // is reachable without the door AND the exit is reachable with it — so the
     78   // intended solve (key → door → exit) always exists.
     79   function makeLayout(seed) {
     80     for (let attempt = 0; attempt < 200; attempt++) {
     81       const r = rng((seed + attempt * 0x9E3779B9) >>> 0);
     82       const grid = Array.from({ length: H }, (_, y) =>
     83         Array.from({ length: W }, (_, x) =>
     84           (x === 0 || y === 0 || x === W - 1 || y === H - 1 || x === DIV) ? "#" : "."));
     85       const doorRow = randInt(r, 1, H - 2);
     86       grid[doorRow][DIV] = "D";
     87       const segs = randInt(r, 3, 5);
     88       for (let s = 0; s < segs; s++) {
     89         const horiz = r() < 0.5;
     90         const len = randInt(r, 1, 3);
     91         const [zx0, zx1] = r() < 0.5 ? [1, DIV - 1] : [DIV + 1, W - 2];
     92         const x0 = randInt(r, zx0, zx1 - (horiz ? len - 1 : 0));
     93         const y0 = randInt(r, 1, H - 2 - (horiz ? 0 : len - 1));
     94         for (let i = 0; i < len; i++) {
     95           const x = x0 + (horiz ? i : 0), y = y0 + (horiz ? 0 : i);
     96           if (grid[y][x] === ".") grid[y][x] = "#";
     97         }
     98       }
     99       const cellsIn = (x0, x1) => {
    100         const out = [];
    101         for (let y = 1; y < H - 1; y++)
    102           for (let x = x0; x <= x1; x++)
    103             if (grid[y][x] === ".") out.push({ x, y });
    104         return out;
    105       };
    106       const left = cellsIn(1, DIV - 1), right = cellsIn(DIV + 1, W - 2);
    107       if (left.length < 2 || right.length < 1) continue;
    108       const start = left[randInt(r, 0, left.length - 1)];
    109       const keyPool = left.filter((c) => c.x !== start.x || c.y !== start.y);
    110       const key = keyPool[randInt(r, 0, keyPool.length - 1)];
    111       const exit = right[randInt(r, 0, right.length - 1)];
    112       grid[key.y][key.x] = "K";
    113       grid[exit.y][exit.x] = "E";
    114       if (!reachable(grid, start, key, false)) continue;  // key on the near side
    115       if (!reachable(grid, start, exit, true)) continue;  // exit via the door
    116       return { grid: grid.map((row) => row.join("")), start, key, exit, doorRow, seed };
    117     }
    118     return { ...FALLBACK, seed };
    119   }
    120 
    121   function newState(layout) {
    122     return {
    123       x: layout.start.x, y: layout.start.y, dir: 0, // 0 = NORTH
    124       hasKey: false, doorOpen: false, steps: 0,
    125     };
    126   }
    127 
    128   // side: "left" | "right". Turning costs a step. There is deliberately no
    129   // event log in the observation: for a single-pass reader a list of recent
    130   // actions is few-shot imitation bait — the list ends with what was just
    131   // done, which is the pattern to continue. Outcomes reach the PLANNER via
    132   // the transcript, which is a different reader with different machinery.
    133   function turn(state, side) {
    134     const next = { ...state };
    135     next.dir = (state.dir + (side === "left" ? 3 : 1)) % 4;
    136     next.steps += 1;
    137     return next;
    138   }
    139 
    140   // Step into the faced cell. Outcomes: moved | bump | locked | opened | key | win
    141   function forward(state, layout) {
    142     const next = { ...state };
    143     const d = DIRS[state.dir];
    144     const nx = state.x + d.dx, ny = state.y + d.dy;
    145     const cell = layout.grid[ny][nx];
    146     next.steps += 1;
    147     if (cell === "#") return { state: next, outcome: "bump" };
    148     if (cell === "D" && !next.hasKey) return { state: next, outcome: "locked" };
    149     next.x = nx; next.y = ny;
    150     if (cell === "D" && !next.doorOpen) next.doorOpen = true;
    151     if (cell === "K" && !next.hasKey) {
    152       next.hasKey = true;
    153       return { state: next, outcome: "key" };
    154     }
    155     if (cell === "E") return { state: next, outcome: "win" };
    156     return { state: next, outcome: cell === "D" ? "opened" : "moved" };
    157   }
    158 
    159   // Camera geometry shared by the first-person renderer and the FPP state
    160   // text: PLANE = tan(60°) gives a 120° FOV (2*atan(PLANE)), so an object
    161   // projects into view within ±60° of facing — the same cone the canvas
    162   // draws. Wide on purpose: the decision model should almost never be
    163   // looking at "nothing".
    164   const PLANE = Math.tan(Math.PI / 3);
    165   // 8-way egocentric bearing of a cell: index 0 is always dead ahead, 2 is
    166   // always to the right, whatever the actor's orientation.
    167   const SECTORS = ["ahead", "ahead-right", "right", "behind-right",
    168     "behind", "behind-left", "left", "ahead-left"];
    169 
    170   // DDA cast from the player's cell center along (rdx, rdy). Returns the
    171   // distance to, side of, and content of the first sight-blocking cell — a
    172   // wall, or the door while locked (an open door is transparent). This is
    173   // the single definition of "what blocks sight": the renderer's wall
    174   // columns, sprite clipping, and the text state's visibility all use it.
    175   // Classic tie-break (y-step at exact corner ties): corner handling lives
    176   // in occluded(), which samples several rays — one per point of the
    177   // target cell — the way the renderer samples one per screen column.
    178   function cast(state, layout, rdx, rdy) {
    179     const px = state.x + 0.5, py = state.y + 0.5;
    180     let mx = Math.floor(px), my = Math.floor(py);
    181     const ddx = Math.abs(rdx) < 1e-9 ? 1e30 : Math.abs(1 / rdx);
    182     const ddy = Math.abs(rdy) < 1e-9 ? 1e30 : Math.abs(1 / rdy);
    183     let stepX, stepY, sdx, sdy;
    184     if (rdx < 0) { stepX = -1; sdx = (px - mx) * ddx; } else { stepX = 1; sdx = (mx + 1.0 - px) * ddx; }
    185     if (rdy < 0) { stepY = -1; sdy = (py - my) * ddy; } else { stepY = 1; sdy = (my + 1.0 - py) * ddy; }
    186     let side = 0, cell = ".", x = mx, y = my;
    187     for (let n = 0; n < 64; n++) {
    188       if (sdx < sdy) { sdx += ddx; mx += stepX; side = 0; }
    189       else { sdy += ddy; my += stepY; side = 1; }
    190       cell = (my >= 0 && my < H && mx >= 0 && mx < W) ? layout.grid[my][mx] : "#";
    191       x = mx; y = my;
    192       if (cell === "#" || (cell === "D" && !state.doorOpen)) break;
    193     }
    194     return { dist: Math.max(0.05, side === 0 ? sdx - ddx : sdy - ddy), side, cell, x, y };
    195   }
    196 
    197   // Camera-plane projection of a cell center: tx = lateral offset, ty =
    198   // depth along facing (ty <= 0.15 means behind the view plane).
    199   function project(state, cell) {
    200     const dir = DIRS[state.dir];
    201     const planeX = -dir.dy * PLANE, planeY = dir.dx * PLANE;
    202     const relX = cell.x + 0.5 - (state.x + 0.5), relY = cell.y + 0.5 - (state.y + 0.5);
    203     const invDet = 1 / (planeX * dir.dy - dir.dx * planeY || 1e-9);
    204     return { tx: invDet * (dir.dy * relX - dir.dx * relY),
    205              ty: invDet * (-planeY * relX + planeX * relY) };
    206   }
    207 
    208   // 8-way egocentric bearing of a cell: "ahead" is always dead ahead,
    209   // "right" is always to the right, whatever the actor's orientation.
    210   function sector(state, cell) {
    211     const eighth = Math.round(Math.atan2(cell.x - state.x, -(cell.y - state.y)) / (Math.PI / 4));
    212     return SECTORS[(((eighth - state.dir * 2) % 8) + 8) % 8];
    213   }
    214 
    215   // Bearing for the In-view/Known lines, with exact-cardinal precision: when
    216   // the cell lies exactly on a cardinal line in the facing frame ("directly
    217   // ahead/behind/left/right"), say so — under tank controls that means one
    218   // turn and then forward closes distance. Otherwise the plain eighth.
    219   function bearing(state, cell) {
    220     const dx = cell.x - state.x, dy = cell.y - state.y;
    221     const dir = DIRS[state.dir], right = DIRS[(state.dir + 1) % 4];
    222     const fwdC = dx * dir.dx + dy * dir.dy;
    223     const rightC = dx * right.dx + dy * right.dy;
    224     if (rightC === 0) return "directly " + (fwdC > 0 ? "ahead" : "behind");
    225     if (fwdC === 0) return "directly " + (rightC > 0 ? "right" : "left");
    226     return sector(state, cell);
    227   }
    228 
    229   // One sightline to a point inside the target cell: clear if it enters the
    230   // target strictly before the first blocker, or if the target itself is
    231   // that blocker (the locked door is visible — it must not occlude itself).
    232   // Entry is the box crossing — the MAX of the two slab crossings — because
    233   // min() credits entry at a corner the ray merely touches from the side.
    234   function lineClear(state, layout, tx, ty, cell) {
    235     const px = state.x + 0.5, py = state.y + 0.5;
    236     const rx = tx - px, ry = ty - py;
    237     const norm = Math.hypot(rx, ry);
    238     const rdx = rx / norm, rdy = ry / norm;
    239     const hit = cast(state, layout, rdx, rdy);
    240     const blocking = hit.cell === "#" || (hit.cell === "D" && !state.doorOpen);
    241     if (!blocking) return true;                       // no blocker: ran past the target
    242     if (hit.x === cell.x && hit.y === cell.y) return true;   // target itself blocks = seen
    243     const ex = rdx > 0 ? (cell.x - px) / rdx : rdx < 0 ? (cell.x + 1 - px) / rdx : -Infinity;
    244     const ey = rdy > 0 ? (cell.y - py) / rdy : rdy < 0 ? (cell.y + 1 - py) / rdy : -Infinity;
    245     return Math.max(ex, ey) + 1e-9 < hit.dist;       // entered the cell before the blocker
    246   }
    247 
    248   // True when every sightline from the actor to the cell is crossed. The
    249   // renderer draws a sprite when ANY of its per-column rays reaches it, so
    250   // the text samples a 3x3 grid of points across the cell the same way:
    251   // one clean ray means visible; hidden only when all nine are crossed.
    252   // A single center ray is stricter than the render at corner grazes (it
    253   // can be blocked by a wall the sprite visibly peeks past) — that mismatch
    254   // both hid the door standing on the key and leaked the exit in earlier
    255   // single-ray fixes.
    256   function occluded(state, layout, cell) {
    257     for (const fx of [0.3, 0.5, 0.7])
    258       for (const fy of [0.3, 0.5, 0.7])
    259         if (lineClear(state, layout, cell.x + fx, cell.y + fy, cell)) return false;
    260     return true;
    261   }
    262 
    263   // Objects a human would currently see: within the FOV cone and not
    264   // wall-occluded, nearest first. Bearing only — distances live in the
    265   // rays, where they serve collision. Text and canvas share cast/project,
    266   // and occluded() samples the cell the way the renderer samples its
    267   // columns, so "In view" agrees with what the renderer draws.
    268   function visibleObjects(state, layout) {
    269     const door = findDoor(layout);
    270     const items = [];
    271     if (!state.hasKey) items.push({ cell: layout.key, name: "the key" });
    272     if (door && !state.doorOpen) items.push({ cell: door, name: "a locked door" });
    273     items.push({ cell: layout.exit, name: "the exit" });
    274     const seen = [];
    275     for (const it of items) {
    276       const { tx, ty } = project(state, it.cell);
    277       if (ty <= 0.15) continue;                          // behind the view plane
    278       // cam = tx/ty is the renderer's column coordinate: rays span cam ∈
    279       // [-1, 1], so |cam| <= 1 is exactly "the sprite falls on screen".
    280       if (Math.abs(tx / ty) > 1) continue;               // outside the cone
    281       if (occluded(state, layout, it.cell)) continue;
    282       const norm = Math.hypot(it.cell.x - state.x, it.cell.y - state.y);
    283       seen.push({ name: it.name, bearing: bearing(state, it.cell), dist: norm });
    284     }
    285     seen.sort((a, b) => a.dist - b.dist);
    286     return seen.map(({ name, bearing: b }) => ({ name, bearing: b }));
    287   }
    288 
    289   function findDoor(layout) {
    290     for (let y = 0; y < H; y++) for (let x = 0; x < W; x++)
    291       if (layout.grid[y][x] === "D") return { x, y };
    292     return null;
    293   }
    294 
    295   // Adjacent-cell descriptions: one line per direction, ALWAYS all four —
    296   // "open floor" included, so no direction ever goes unreported. Egocentric
    297   // labels only — ahead/right/behind/left — matching the bearing vocabulary.
    298   // No compass line exists to anchor, and none is needed: "Ahead:" defines
    299   // the frame word itself.
    300   function adjacentLines(state, layout) {
    301     const order = [["Ahead", state.dir], ["Right", (state.dir + 1) % 4],
    302                    ["Behind", (state.dir + 2) % 4], ["Left", (state.dir + 3) % 4]];
    303     return order.map(([word, di]) => {
    304       const d = DIRS[di];
    305       const c = layout.grid[state.y + d.dy][state.x + d.dx];
    306       const name =
    307         c === "#" ? "a wall"
    308         : c === "K" && !state.hasKey ? "the key"
    309         : c === "D" && !state.doorOpen ? "a locked door"
    310         : c === "E" ? "the exit"
    311         : "open floor";
    312       return `${word}: ${name}`;
    313     });
    314   }
    315 
    316   // "In view" line shared by the ruled and neutral FPP texts — always
    317   // present (a stable schema beats a variable one for a single-pass
    318   // reader), bearing only, nearest first.
    319   function inViewLine(state, layout) {
    320     const seen = visibleObjects(state, layout);
    321     return "In view: " + (seen.length
    322       ? seen.map((o) => `${o.name} ${o.bearing}`).join("; ") + "."
    323       : "nothing.");
    324   }
    325 
    326   // Discovered-landmark bearings. The caller threads a `seen` set through
    327   // and adds whatever the cone reveals (the game loop folds each
    328   // observation in, so the state that first reveals a POI is also the last
    329   // one without its bearing). A POI appears here only while it is present
    330   // (key until carried, the locked door until opened, the exit always)
    331   // AND out of view — In view owns the visible ones, so each object is
    332   // described exactly once. Bearing only; no claim about why it matters.
    333   function knownLine(state, layout, seen = new Set()) {
    334     const door = findDoor(layout);
    335     const inView = new Set(visibleObjects(state, layout).map((o) => o.name));
    336     const items = [];
    337     if (seen.has("key") && !state.hasKey && !inView.has("the key"))
    338       items.push({ cell: layout.key, name: "the key" });
    339     if (seen.has("door") && !state.doorOpen && door && !inView.has("a locked door"))
    340       items.push({ cell: door, name: "a locked door" });
    341     if (seen.has("exit") && !inView.has("the exit"))
    342       items.push({ cell: layout.exit, name: "the exit" });
    343     if (!items.length) return "Known: nothing.";
    344     items.sort((a, b) =>
    345       Math.hypot(a.cell.x - state.x, a.cell.y - state.y) - Math.hypot(b.cell.x - state.x, b.cell.y - state.y));
    346     // A bearing the actor cannot actually walk reads as an instruction to a
    347     // single-pass reader — mark it, so a blocked compass never masquerades
    348     // as a walkable direction. Unblocked bearings are guaranteed clear
    349     // straight lines; blocked ones say "not this way, go around".
    350     return "Known: " + items.map((o) =>
    351       `${o.name} ${bearing(state, o.cell)}${occluded(state, layout, o.cell) ? " (blocked)" : ""}`).join("; ") + ".";
    352   }
    353 
    354   // First-person state: all four adjacent cells, the FOV cone, known-landmark
    355   // bearings, carry status and a step count. The model must build its own
    356   // map from this prose.
    357   // Every direction word is relative to facing — there is no compass line.
    358   function fppText(state, layout, guided, seen) {
    359     const lines = [
    360       "You are in a walled room. Reach the exit.",
    361       "You can step forward into the space you face, or turn left or right.",
    362       "A locked door blocks the room: find the key, then pass the door to reach the exit.",
    363       "",
    364       // Ordered for a single-pass reader: standing context first, immediate
    365       // sensory evidence last (the freshest tokens weigh most).
    366       "Current State:",
    367       `Steps taken: ${state.steps}/${MAX_STEPS}`,
    368       "",
    369       knownLine(state, layout, seen),
    370       `You carry: ${state.hasKey ? "the key" : "no key"}`,
    371       "",
    372       inViewLine(state, layout),
    373       "",
    374       ...adjacentLines(state, layout),
    375     ];
    376     if (guided) {
    377       const [target, label] = !state.hasKey ? [layout.key, "the key"]
    378         : state.doorOpen ? [layout.exit, "the exit"]
    379         : [{ x: DIV, y: layout.doorRow }, "the door"];
    380       // Relative like everything else in this state: no compass line exists.
    381       lines.push("", `Hint: ${label} is roughly ${sector(state, target)} of you.`);
    382     }
    383     return lines.join("\n");
    384   }
    385 
    386   // The easy mode: hand the model the whole layout. Player cell is the facing
    387   // arrow (^ > v <) so the grid stays one glyph per cell.
    388   function mapText(state, layout) {
    389     const rows = [];
    390     for (let y = 0; y < H; y++) {
    391       const row = [];
    392       for (let x = 0; x < W; x++) {
    393         if (x === state.x && y === state.y) { row.push(DIRS[state.dir].arrow); continue; }
    394         let c = layout.grid[y][x];
    395         if (c === "K" && state.hasKey) c = ".";
    396         if (c === "D" && state.doorOpen) c = ".";   // an opened door is just floor
    397         row.push(c);
    398       }
    399       rows.push(row.join(" "));
    400     }
    401     return [
    402       "Top-down map of the room. Reach the exit.",
    403       "Legend: [`#`: wall, `.`: floor, `D`: locked door, `K`: key, `E`: exit, `^ > v <`: you, facing that way]",
    404       "Stepping onto the key picks it up; the locked door opens once you carry the key.",
    405       "",
    406       ...rows,
    407       "",
    408       `Steps taken: ${state.steps}/${MAX_STEPS}`,
    409     ].join("\n");
    410   }
    411 
    412   // mode: "guided" | "fpp" | "map" — guided = FPP prose + compass hint.
    413   function stateText(state, layout, mode = "fpp", seen) {
    414     if (mode === "map") return mapText(state, layout);
    415     return fppText(state, layout, mode === "guided", seen);
    416   }
    417 
    418   // Neutral by design: the question must not leak the goal. It rides along
    419   // with the state into every decision prompt AND every transcript turn the
    420   // planner reads — "progress toward the exit" would tell the actor what the
    421   // rules are supposed to make it discover.
    422   const QUESTION = "Which action should the actor take?";
    423   const OPTIONS = [
    424     // Names only, deliberately: the mechanics (key + locked door, tank
    425     // controls) are the planner's job to discover and state as rules, not
    426     // ours to pre-chew in every decision prompt.
    427     { id: "forward", description: "Move one step ahead." },
    428     { id: "left", description: "Turn in place 90 degrees to the left." },
    429     { id: "right", description: "Turn in place 90 degrees to the right." },
    430   ];
    431 
    432   // Action options for the CURRENT state: forward is offered only when the
    433   // faced cell is enterable. A no-op input (a wall, or a locked door without
    434   // the key) is not a decision the actor can meaningfully make — and with no
    435   // event log it would produce no feedback either, just a silently burned
    436   // step. Turns and the insufficient escape hatch are always available.
    437   function optionsFor(state, layout) {
    438     const d = DIRS[state.dir];
    439     const cell = layout.grid[state.y + d.dy][state.x + d.dx];
    440     const enterable = cell !== "#" && !(cell === "D" && !state.hasKey);
    441     return enterable ? OPTIONS : OPTIONS.filter((o) => o.id !== "forward");
    442   }
    443 
    444   return { W, H, DIV, MAX_STEPS, DIRS, PLANE,
    445     FALLBACK, rng, randInt, reachable, makeLayout, newState, turn, forward,
    446     stateText, fppText, mapText, adjacentLines, cast, project, sector,
    447     visibleObjects, inViewLine, knownLine, QUESTION, OPTIONS, optionsFor };
    448 })();
    449 
    450 if (typeof module !== "undefined") module.exports = RoomRules;