test_roomself.cjs (17960B)
1 // Run: node --test test_roomself.cjs 2 // The self-learning puzzle room: same turn-based grid as room-rules.js with 3 // every line of how-to-play prose stripped, an "insufficient" escape hatch 4 // that plans at ANY plurality threshold, and a /plan trigger when the step 5 // budget runs out. 6 const { test } = require('node:test'); 7 const assert = require('node:assert/strict'); 8 const fs = require('node:fs'); 9 const vm = require('node:vm'); 10 const R = require('../semif-api/src/semif_api/web/room-rules.js'); 11 12 // room-self-rules.js reads the RoomRules and Planner globals at load time. 13 global.RoomRules = R; 14 global.Planner = { INSUFFICIENT_ID: 'insufficient' }; 15 const SR = require('../semif-api/src/semif_api/web/room-self-rules.js'); 16 17 const layout = R.makeLayout(1); 18 const fresh = () => R.newState(layout); 19 20 // planner.js runs in the browser as a global; load it in a VM sandbox to test 21 // the trigger policy without a DOM. 22 function loadPlanner() { 23 const src = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/planner.js'), 'utf8'); 24 const sandbox = { fetch: () => { throw Error('no network in tests'); } }; 25 vm.createContext(sandbox); 26 vm.runInContext(src + '\nthis.exports = Planner;', sandbox); 27 return sandbox.exports; 28 } 29 30 test('neutral FPP state keeps only observations, no rules or advice', () => { 31 const state = fresh(); 32 const text = SR.stateText(state, layout, 'fpp'); 33 // All four adjacent cells are described, unconditionally; the state opens 34 // with egocentric labels and no compass line of any kind. 35 for (const dir of ['Ahead', 'Right', 'Behind', 'Left']) { 36 assert.match(text, new RegExp(`^${dir}: (open floor|a wall|the key|a locked door|the exit)$`, 'm')); 37 } 38 assert.doesNotMatch(text, /You face|north|east|south|west/i); 39 assert.match(text, /^In view: .+\.$/m); 40 assert.match(text, /You carry: no key/); 41 assert.ok(text.includes(`Steps taken: 0/${R.MAX_STEPS}`)); 42 assert.doesNotMatch(text, /Recent events|No events yet/); // no action history in the observation 43 // Everything below is how-to-play prose this demo must never send: 44 assert.doesNotMatch(text, /Reach the exit|most progress|locked door blocks|find the key|step forward into|Hint:/i); 45 }); 46 47 test('neutral FPP state reflects actions through carry status and steps', () => { 48 let state = R.turn(fresh(), 'right'); // now facing EAST 49 let text = SR.stateText(state, layout, 'fpp'); 50 // No action history in the observation: only the world's own facts change. 51 assert.doesNotMatch(text, /turned right/); 52 // Walk into the key cell if reachable from start; otherwise any forward. 53 const r = R.forward(state, layout); 54 state = r.state; 55 text = SR.stateText(state, layout, 'fpp'); 56 assert.ok(text.includes(`Steps taken: 2/${R.MAX_STEPS}`)); 57 if (r.outcome === 'key') assert.match(text, /You carry: the key/); 58 }); 59 60 test('adjacency lines describe all four cells in egocentric directions', () => { 61 // Hand-built 11x9: player at (2,2) facing EAST; wall north (left), key 62 // south (right), exit west (behind), open floor ahead. 63 const grid = Array.from({ length: R.H }, (_, y) => 64 Array.from({ length: R.W }, (_, x) => 65 (x === 0 || y === 0 || x === R.W - 1 || y === R.H - 1) ? "#" : ".")); 66 grid[1][2] = "#"; grid[3][2] = "K"; grid[2][1] = "E"; 67 const L = { grid: grid.map((r) => r.join("")), start: { x: 2, y: 2 }, 68 key: { x: 2, y: 3 }, exit: { x: 1, y: 2 }, doorRow: 2, seed: 0 }; 69 const state = R.turn(R.newState(L), 'right'); // facing EAST 70 const text = SR.stateText(state, L, 'fpp'); 71 assert.match(text, /^Ahead: open floor$/m); 72 assert.match(text, /^Right: the key$/m); 73 assert.match(text, /^Behind: the exit$/m); 74 assert.match(text, /^Left: a wall$/m); 75 // The cone line is separate: nothing ahead falls inside the 120° cone. 76 assert.match(text, /^In view: nothing\.$/m); 77 // Carried key: its cell reads as open floor. 78 assert.match(SR.stateText({ ...state, hasKey: true }, L, 'fpp'), /^Right: open floor$/m); 79 }); 80 81 test('adjacency lines name the locked and open door; open field is all floor', () => { 82 const grid = Array.from({ length: R.H }, (_, y) => 83 Array.from({ length: R.W }, (_, x) => 84 (x === 0 || y === 0 || x === R.W - 1 || y === R.H - 1) ? "#" : ".")); 85 grid[2][3] = "D"; 86 const L = { grid: grid.map((r) => r.join("")), start: { x: 2, y: 2 }, 87 key: { x: 1, y: 1 }, exit: { x: 8, y: 6 }, doorRow: 2, seed: 0 }; 88 const facing = R.turn(R.newState(L), 'right'); // door directly ahead (east) 89 assert.match(SR.stateText(facing, L, 'fpp'), /^Ahead: a locked door$/m); 90 const opened = SR.stateText({ ...facing, doorOpen: true }, L, 'fpp'); 91 assert.match(opened, /^Ahead: open floor$/m); // an opened door is just floor 92 // An open field: every direction explicitly described, none omitted. 93 const L2 = { ...L, grid: L.grid.map((r, y) => y === 2 ? r.replace("D", ".") : r) }; 94 const text2 = SR.stateText(R.turn(R.newState(L2), 'right'), L2, 'fpp'); 95 for (const dir of ['Ahead', 'Right', 'Behind', 'Left']) { 96 assert.match(text2, new RegExp(`^${dir}: open floor$`, 'm')); 97 } 98 }); 99 100 test('In view lists only FOV-visible objects, bearing only, nearest first', () => { 101 // Player at (5,5) facing NORTH (dir 0): key just left of ahead, door just 102 // right of ahead, exit further ahead-left with a clear line past the key — 103 // all inside the 120° cone. 104 const grid = Array.from({ length: R.H }, (_, y) => 105 Array.from({ length: R.W }, (_, x) => 106 (x === 0 || y === 0 || x === R.W - 1 || y === R.H - 1) ? "#" : ".")); 107 grid[2][4] = "K"; grid[2][6] = "D"; grid[1][3] = "E"; 108 const L = { grid: grid.map((r) => r.join("")), start: { x: 5, y: 5 }, 109 key: { x: 4, y: 2 }, exit: { x: 3, y: 1 }, doorRow: 2, seed: 0 }; 110 const north = R.newState(L); // dir 0 = NORTH 111 assert.deepEqual(R.visibleObjects(north, L), [ 112 { name: "the key", bearing: "ahead" }, 113 { name: "a locked door", bearing: "ahead" }, 114 { name: "the exit", bearing: "ahead-left" }, 115 ]); 116 // Facing SOUTH everything is behind the view plane: the empty form. 117 const south = { ...north, dir: 2 }; 118 assert.deepEqual(R.visibleObjects(south, L), []); 119 assert.match(SR.stateText(south, L, 'fpp'), /^In view: nothing\.$/m); 120 // Carried key leaves the view. 121 const carrying = { ...north, hasKey: true }; 122 assert.deepEqual(R.visibleObjects(carrying, L), [ 123 { name: "a locked door", bearing: "ahead" }, 124 { name: "the exit", bearing: "ahead-left" }, 125 ]); 126 // A ~63° bearing with a clear line of sight is still outside the 120° 127 // cone's ±60° edge. 128 const g = L.grid.map((r) => r.split("")); 129 g[3][9] = "K"; 130 const L3 = { ...L, grid: g.map((r) => r.join("")), key: { x: 9, y: 3 } }; 131 assert.deepEqual(R.visibleObjects(R.newState(L3), L3), [ 132 { name: "a locked door", bearing: "ahead" }, 133 { name: "the exit", bearing: "ahead-left" }, 134 ]); 135 // …and a 45° diagonal now falls INSIDE the widened cone. 136 const g4 = L.grid.map((r) => r.split("")); 137 g4[2][8] = "K"; 138 const L4 = { ...L, grid: g4.map((r) => r.join("")), key: { x: 8, y: 2 } }; 139 const v4 = R.visibleObjects(R.newState(L4), L4); 140 assert.ok(v4.some((o) => o.name === "the key" && o.bearing === "ahead-right")); 141 }); 142 143 test('Known line gives bearings to discovered POIs that are out of view', () => { 144 // Player (5,5) facing NORTH; door (5,2) and exit (5,1) behind the wall at 145 // (5,4); key at (9,7), out of the cone behind-right. (Same fixture as the 146 // occlusion test.) 147 const grid = Array.from({ length: R.H }, (_, y) => 148 Array.from({ length: R.W }, (_, x) => 149 (x === 0 || y === 0 || x === R.W - 1 || y === R.H - 1) ? "#" : ".")); 150 grid[2][5] = "D"; grid[1][5] = "E"; grid[4][5] = "#"; grid[7][9] = "K"; 151 const L = { grid: grid.map((r) => r.join("")), start: { x: 5, y: 5 }, 152 key: { x: 9, y: 7 }, exit: { x: 5, y: 1 }, doorRow: 2, seed: 0 }; 153 const state = R.newState(L); 154 const all = new Set(["key", "door", "exit"]); 155 // Nothing discovered yet: the explicit empty form. 156 assert.match(SR.stateText(state, L, 'fpp', new Set()), /^Known: nothing\.$/m); 157 // All discovered, all out of view: bearings, nearest first. Door and exit 158 // sit dead ahead of the player — exact cardinal alignments read "directly 159 // X" — and the wall at (5,4) blocks the line to both, so they say so. 160 assert.match(SR.stateText(state, L, 'fpp', all), 161 /^Known: a locked door directly ahead \(blocked\); the exit directly ahead \(blocked\); the key behind-right\.$/m); 162 // Wall gone: the door is now in view, so In view owns it — no double entry. 163 const g2 = L.grid.map((r) => r.split("")); g2[4][5] = "."; 164 const L2 = { ...L, grid: g2.map((r) => r.join("")) }; 165 // The exit's line now passes the LOCKED door: still blocked. The key's 166 // line is clear — an unblocked bearing is a guaranteed walkable straight 167 // line. 168 assert.match(SR.stateText(state, L2, 'fpp', all), 169 /^Known: the exit directly ahead \(blocked\); the key behind-right\.$/m); 170 // Door open: it is no longer a POI; the exit shows through it (in view). 171 assert.match(SR.stateText({ ...state, doorOpen: true }, L2, 'fpp', all), 172 /^Known: the key behind-right\.$/m); 173 // Carried key: present-check drops it even though it was discovered. 174 assert.match(SR.stateText({ ...state, hasKey: true }, L, 'fpp', all), 175 /^Known: a locked door directly ahead \(blocked\); the exit directly ahead \(blocked\)\.$/m); 176 }); 177 178 test('corner graze behind the locked door does not leak the exit (default seed)', () => { 179 // Regression: on seed 1, standing east of the door facing it, the ray to 180 // the exit's center passed exactly through the grid corner shared by the 181 // door cell and the wall cell behind it. The DDA tie-break stepped into 182 // the open diagonal past both blockers, and the strict dist < entry 183 // comparison let the epsilon-equal graze slip through — the exit showed 184 // in view through two sets of walls. Corner grazes now block. 185 const L = R.makeLayout(1); 186 let door; 187 for (let y = 0; y < R.H; y++) for (let x = 0; x < R.W; x++) 188 if (L.grid[y][x] === "D") door = { x, y }; 189 assert.ok(door, "seed-1 layout has a door"); 190 const east = { x: door.x - 4, y: door.y, dir: 1, hasKey: false, doorOpen: false, steps: 0 }; 191 const names = R.visibleObjects(east, L).map((o) => o.name); 192 assert.ok(names.includes("a locked door"), "the door itself is visible: " + names); 193 assert.ok(!names.includes("the exit"), "exit must not leak through the graze: " + names); 194 // The companion regression: standing ON the key facing the door (it is 195 // ahead-left, plainly drawn by the renderer) must discover it. A single 196 // center ray is blocked by the corner of (3,4) on this layout; the 197 // renderer and the text both treat a cell as visible when any 198 // sightline reaches it. 199 const onKey = { x: L.key.x, y: L.key.y, dir: 2, hasKey: false, doorOpen: false, steps: 0 }; 200 const seen = R.visibleObjects(onKey, L); 201 const d = seen.find((o) => o.name === "a locked door"); 202 assert.ok(d, "door discovered while standing on the key: " + JSON.stringify(seen)); 203 assert.equal(d.bearing, "ahead-left"); 204 }); 205 206 test('Known line respects walls and the locked door; an open door is transparent', () => { 207 const grid = Array.from({ length: R.H }, (_, y) => 208 Array.from({ length: R.W }, (_, x) => 209 (x === 0 || y === 0 || x === R.W - 1 || y === R.H - 1) ? "#" : ".")); 210 grid[2][5] = "D"; grid[1][5] = "E"; grid[4][5] = "#"; // wall between player and door 211 grid[7][9] = "K"; // key behind-right, out of the cone 212 const L = { grid: grid.map((r) => r.join("")), start: { x: 5, y: 5 }, 213 key: { x: 9, y: 7 }, exit: { x: 5, y: 1 }, doorRow: 2, seed: 0 }; 214 const state = R.newState(L); 215 // The door and exit sit behind the wall at (5,4): nothing to see. 216 assert.deepEqual(R.visibleObjects(state, L), []); 217 // Remove the wall: the locked door occludes the exit behind it. 218 const g2 = L.grid.map((r) => r.split("")); 219 g2[4][5] = "."; 220 const L2 = { ...L, grid: g2.map((r) => r.join("")) }; 221 assert.deepEqual(R.visibleObjects(state, L2), [ 222 { name: "a locked door", bearing: "directly ahead" }, 223 ]); 224 // Open the door: it is no longer a POI — the doorway is just floor — and 225 // the exit beyond it shows through. 226 assert.deepEqual(R.visibleObjects({ ...state, doorOpen: true }, L2), [ 227 { name: "the exit", bearing: "directly ahead" }, 228 ]); 229 }); 230 231 232 test('neutral map state keeps the legend and grid but no instructions', () => { 233 const text = SR.stateText(fresh(), layout, 'map'); 234 assert.match(text, /#.: wall/); // legend names the wall symbol 235 assert.match(text, /locked door/); // legend names the door symbol 236 assert.ok(text.includes(`Steps taken: 0/${R.MAX_STEPS}`)); 237 assert.match(text, /^# # #/m); // top border row of the grid 238 assert.doesNotMatch(text, /Recent events|No events yet/); // no action history here either 239 // Instructional lines from the ruled mapText must be gone: 240 assert.doesNotMatch(text, /Reach the exit|Stepping onto the key/i); 241 }); 242 243 test('options are the actions plus the insufficient escape hatch', () => { 244 assert.deepEqual(SR.OPTIONS.map((o) => o.id), ['forward', 'left', 'right', 'insufficient']); 245 assert.equal(SR.QUESTION, 'What should the player do now?'); 246 // Descriptions are names only, deliberately — mechanics are the planner's 247 // job to discover and state as rules, not ours to pre-chew per decision. 248 assert.equal(SR.OPTIONS[0].description, 'Move one step ahead.'); 249 assert.equal(SR.OPTIONS[1].description, 'Turn in place 90 degrees to the left.'); 250 }); 251 252 test('forward is withheld when the faced cell is a no-op', () => { 253 // Hand-built 11x9: wall directly north of start, locked door to the east. 254 const grid = Array.from({ length: R.H }, (_, y) => 255 Array.from({ length: R.W }, (_, x) => 256 (x === 0 || y === 0 || x === R.W - 1 || y === R.H - 1) ? "#" : ".")); 257 grid[1][2] = "#"; grid[2][3] = "D"; 258 const L = { grid: grid.map((r) => r.join("")), start: { x: 2, y: 2 }, 259 key: { x: 5, y: 5 }, exit: { x: 8, y: 6 }, doorRow: 2, seed: 0 }; 260 const north = R.newState(L); // wall ahead 261 assert.ok(!R.optionsFor(north, L).some((o) => o.id === "forward")); 262 assert.ok(R.optionsFor(north, L).some((o) => o.id === "left")); 263 const east = R.turn(north, 'right'); // locked door ahead, no key 264 assert.ok(!R.optionsFor(east, L).some((o) => o.id === "forward")); 265 // Carrying the key: the door is enterable, forward returns. 266 assert.ok(R.optionsFor({ ...east, hasKey: true }, L).some((o) => o.id === "forward")); 267 // The SL variant always keeps the insufficient escape hatch. 268 const sl = SR.optionsFor(north, L).map((o) => o.id); 269 assert.deepEqual(sl, ['left', 'right', 'insufficient']); 270 }); 271 272 test('PLAN_GOAL is a single factual statement of the goal', () => { 273 assert.match(SR.PLAN_GOAL, /exit/); 274 assert.doesNotMatch(SR.PLAN_GOAL, /\n/); // one line, not an essay 275 }); 276 277 test('planner threshold is a policy knob: 0.99 default, any plurality for the room', () => { 278 const Planner = loadPlanner(); 279 assert.equal(Planner.INSUFFICIENT_THRESHOLD, 0.99); 280 const result = (probs) => ({ option_ids: ['forward', 'left', 'right', 'insufficient'], probabilities: probs }); 281 // Platformer policy: a weak plurality must NOT trigger. 282 assert.equal(Planner.triggered(result([0.30, 0.28, 0.27, 0.15])), false); 283 assert.equal(Planner.triggered(result([0.001, 0.001, 0.008, 0.99])), true); 284 // Room policy (threshold 0): any insufficient plurality triggers. 285 assert.equal(Planner.triggered(result([0.28, 0.20, 0.18, 0.34]), 0), true); 286 assert.equal(Planner.triggered(result([0.40, 0.30, 0.20, 0.10]), 0), false); 287 }); 288 289 test('demo is hosted as its own tab with the scripts ordered by dependency', () => { 290 const html = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/index.html'), 'utf8'); 291 assert.match(html, /id="tab-roomself"/); 292 assert.match(html, /id="panel-roomself"/); 293 assert.match(html, />Platformer SL<\/button>/); // the platformer SL tab is renamed 294 assert.match(html, />Puzzle room SL<\/button>/); 295 assert.match(html, /id="rs-cv"/); 296 assert.match(html, /id="rs-stats"/); // completion-stats pane ships 297 assert.match(html, /<textarea id="rs-rules-view"[^>]*class="rules-edit"/); 298 assert.match(html, /<script src="planner\.js"><\/script>\s*<script src="self-rules\.js">/); 299 assert.match(html, /<script src="room-rules\.js"><\/script>\s*<script src="room\.js"><\/script>\s*<script src="room-self-rules\.js"><\/script>\s*<script src="room-self-game\.js"><\/script>/); 300 assert.doesNotMatch(html, /[\x00-\x08\x0B\x0C\x0E-\x1F]/); // no stray control chars 301 }); 302 303 test('room SL game loop script parses and wires the plan triggers', () => { 304 const js = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/room-self-game.js'), 'utf8'); 305 new vm.Script(js); // throws on any syntax error (it is a top-level IIFE) 306 assert.match(js, /INSUFFICIENT_P = 0/); // any plurality plans 307 assert.match(js, /Planner\.triggered\(result, INSUFFICIENT_P\)/); 308 assert.match(js, /Planner\.context\(SelfRoomRules\.PLAN_GOAL/); 309 assert.match(js, /replanAndRetry\("steps"\)/); // budget exhaustion plans + restarts 310 assert.match(js, /replanAndRetry\("insufficient"\)/); // …and so does a confident insufficient 311 assert.match(js, /Rules:\\n\$\{learnedRules\}\\n\\n\$\{base\}/); // rules lead 312 assert.match(js, /function bareState\(\)/); // transcript records the BARE state, 313 assert.match(js, /pending = \{ state: observed, choice \}/); // rules reach the planner once, via /plan context 314 assert.match(js, /setPlanningStatus/); // planning status waves 315 assert.match(js, /function reset\(\) \{[\s\S]*?clearLog\(\);/); // reset clears the side pane 316 assert.match(js, /rs-state-view/); 317 });