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;