semif-api-rocm

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

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 });