test_self.cjs (9465B)
1 // Run: node --test test_self.cjs 2 // The no-rules platformer: same physics and terrain encodings as game-rules.js, 3 // but observations carry no how-to-play prose, options add an "insufficient" 4 // escape hatch, and /plan derives rules from the action transcript. 5 const { test } = require('node:test'); 6 const assert = require('node:assert/strict'); 7 const fs = require('node:fs'); 8 const vm = require('node:vm'); 9 const G = require('../semif-api/src/semif_api/web/game-rules.js'); 10 11 // self-rules.js reads the GameRules and Planner globals at load time. 12 global.GameRules = G; 13 global.Planner = { INSUFFICIENT_ID: 'insufficient' }; 14 const S = require('../semif-api/src/semif_api/web/self-rules.js'); 15 16 const grounded = (x) => ({ x, y: G.GROUND, vy: 0, onGround: true }); 17 18 // planner.js runs in the browser as a global; load it in a VM sandbox to test 19 // the trigger policy without a DOM. 20 function loadPlanner() { 21 const src = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/planner.js'), 'utf8'); 22 const sandbox = { fetch: () => { throw Error('no network in tests'); } }; 23 vm.createContext(sandbox); 24 vm.runInContext(src + '\nthis.exports = Planner;', sandbox); 25 return sandbox.exports; 26 } 27 28 test('bare prose state keeps only neutral observations, no rules or advice', () => { 29 const state = S.stateText(grounded(12), 4, 'prose'); 30 assert.match(state, /Player: standing on ground, facing right/); 31 assert.match(state, /Jumps remaining: 4/); 32 assert.match(state, /Ahead, from nearest to farthest/); 33 assert.match(state, /1 ground space\n3 hole spaces\n14 ground spaces/); 34 // Everything below is how-to-play prose this demo must never send: 35 assert.doesNotMatch(state, /Reach the flag|Jump at the edge|If the next space|Run moves|act again|Hint:|fail/i); 36 assert.doesNotMatch(state, /\d+\.\d+/); 37 }); 38 39 test('one space from the flag, the bare state says so directly', () => { 40 const near = S.stateText(grounded(G.GOAL - 1), 2, 'prose'); 41 assert.match(near, /The flag is right in front of you!/); 42 assert.doesNotMatch(near, /Ahead, from nearest/); // the listing would be empty 43 const far = S.stateText(grounded(G.GOAL - 5), 2, 'prose'); 44 assert.match(far, /Ahead, from nearest to farthest/); 45 }); 46 47 test('bare prose terrain listing reconstructs every space ahead', () => { 48 for (let x = G.START_X; x < G.GOAL; x++) { 49 const state = S.stateText(grounded(x), 4, 'prose'); 50 const spaces = []; 51 for (const match of state.matchAll(/^(\d+) (ground|hole) spaces?$/gm)) { 52 spaces.push(...Array(Number(match[1])).fill(match[2])); 53 } 54 spaces.push('ground'); // flag's space 55 assert.equal(spaces.length, G.GOAL - x); 56 spaces.forEach((kind, i) => assert.equal(kind === 'ground', G.floorAt(x + i + 1))); 57 } 58 }); 59 60 test('bare prose carries no flag/goal line while the run is unfinished', () => { 61 assert.doesNotMatch(S.stateText(grounded(G.GOAL - 1), 1, 'prose'), /Flag/); 62 assert.match(S.stateText(grounded(G.GOAL), 1, 'prose'), /Flag reached/); 63 }); 64 65 test('run-length and ASCII bare states share the encoding with the ruled modes', () => { 66 for (const x of [G.START_X, 9, 13, G.GOAL - 1]) { 67 // Identical terrain encoding: only the prose around the payload differs. 68 assert.equal(S.runLengthText(grounded(x), 3).split('\n').at(-1), 69 G.runLengthText(grounded(x), 3).split('\n').at(-1)); 70 assert.equal(S.asciiText(grounded(x), 3).split('\n').at(-1), 71 G.asciiText(grounded(x), 3).split('\n').at(-1)); 72 } 73 }); 74 75 test('bare symbolic legends are neutral — they name terrain, never outcomes', () => { 76 const rl = S.runLengthText(grounded(3), 4); 77 const ascii = S.asciiText(grounded(3), 4); 78 assert.match(rl, /Legend:.*player.*ground tiles.*hole tiles.*flag/); 79 assert.match(ascii, /Legend:.*player.*ground.*hole.*flag/); 80 for (const state of [rl, ascii]) { 81 assert.doesNotMatch(state, /Reach the flag|Jump at the edge|Run moves|will make you fail|Hint:/i); 82 assert.doesNotMatch(state, /fail/i); 83 assert.doesNotMatch(state, /\d+\.\d+/); 84 } 85 }); 86 87 test('options are the actions plus the insufficient escape hatch', () => { 88 assert.deepEqual(S.OPTIONS, [ 89 { id: 'run', description: 'Run' }, 90 { id: 'jump', description: 'Jump' }, 91 { id: 'insufficient', description: 'Insufficient evidence to decide' }, 92 ]); 93 assert.equal(S.QUESTION, 'What should the player do now?'); 94 }); 95 96 test('PLAN_GOAL is a single factual statement of the goal', () => { 97 assert.match(S.PLAN_GOAL, /flag/); 98 assert.doesNotMatch(S.PLAN_GOAL, /\n/); // one line, not an essay 99 }); 100 101 test('Planner.context composes objective, trigger, and previous rules', () => { 102 const Planner = loadPlanner(); 103 const first = Planner.context('Reach the flag.', 'the actor fell below the level', ''); 104 assert.match(first, /Objective: Reach the flag\./); 105 assert.match(first, /Trigger: the actor fell below the level/); 106 assert.doesNotMatch(first, /Previous rules/); // nothing in effect yet 107 const again = Planner.context('Reach the flag.', 'the actor fell below the level', 108 '1. Run when the next tile is ground.'); 109 // Repeat triggers must surface the previous plan as the failure mode — 110 // the planner must restructure, not reword. 111 assert.match(again, /Previous rules — the actor failed while these were in force\. /); 112 assert.match(again, /indicts the rules \(their facts, priorities, or framing\), not the actor's comprehension of them\./); 113 assert.match(again, /Never resubmit a reworded or lightly edited version/); 114 assert.match(again, /1\. Run when the next tile is ground\./); 115 }); 116 117 test('planner trigger fires only on a confident insufficient choice', () => { 118 const Planner = loadPlanner(); 119 assert.equal(Planner.INSUFFICIENT_THRESHOLD, 0.99); 120 const result = (probs) => ({ option_ids: ['run', 'jump', 'insufficient'], probabilities: probs }); 121 assert.equal(Planner.triggered(result([0.001, 0.001, 0.998])), true); 122 assert.equal(Planner.triggered(result([0.001, 0.001, 0.99])), true); 123 assert.equal(Planner.triggered(result([0.05, 0.05, 0.9])), false); // below threshold 124 assert.equal(Planner.triggered(result([0.5, 0.4, 0.1])), false); // insufficient loses 125 const [id, p] = Planner.best(result([0.2, 0.5, 0.3])); 126 assert.equal(id, 'jump'); 127 assert.equal(p, 0.5); 128 }); 129 130 test('planner transcript records user turns and caps its length', () => { 131 const Planner = loadPlanner(); 132 const transcript = Planner.fresh(); 133 for (let i = 0; i < Planner.TRANSCRIPT_KEEP + 4; i++) { 134 Planner.record(transcript, `action ${i}`); 135 } 136 assert.equal(transcript.length, Planner.TRANSCRIPT_KEEP); 137 assert.equal(transcript[0].content, 'action 4'); // oldest overflow dropped 138 assert.ok(transcript.every((turn) => turn.role === 'user')); 139 }); 140 141 test('demo is hosted as its own tab with the scripts ordered by dependency', () => { 142 const html = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/index.html'), 'utf8'); 143 assert.match(html, /id="tab-self"/); 144 assert.match(html, /id="panel-self"[^>]*hidden/); 145 assert.match(html, /<textarea id="self-rules-view"[^>]*class="rules-edit"/); // learned-rules editor ships 146 assert.match(html, /id="self-stats"/); // completion-stats pane ships 147 // planner.js (defines Planner) must load before self-rules.js (reads Planner 148 // at load time), which loads before self-game.js (reads SelfRules). 149 assert.match(html, /<script src="planner\.js"><\/script>\s*<script src="self-rules\.js"><\/script>\s*<script src="self-game\.js"><\/script>/); 150 assert.doesNotMatch(html, /[\x00-\x08\x0B\x0C\x0E-\x1F]/); // no stray control chars 151 }); 152 153 test('no-rules game loop script parses and wires the plan triggers', () => { 154 const js = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/self-game.js'), 'utf8'); 155 new vm.Script(js); // throws on any syntax error (it is a top-level IIFE) 156 assert.match(js, /Planner\.triggered/); 157 assert.match(js, /Planner\.context\(SelfRules\.PLAN_GOAL/); // composed context, no per-game prompt 158 assert.match(js, /Rules:\\n\$\{learnedRules\}\\n\\n\$\{base\}/); // rules lead, state follows 159 assert.match(js, /replanAndRetry\("insufficient"\)/); // …and so does a confident insufficient 160 assert.match(js, /function bareState\(\)/); // transcript records the BARE state, 161 assert.match(js, /pending = \{ n, state: bare, choice \}/); // rules reach the planner once, via /plan context 162 assert.match(js, /\/plan/); 163 assert.match(js, /replanAndRetry\("denied"\)/); // impossible-action streak plans too 164 assert.match(js, /setPlanningStatus\(reason === "fell"/); // planning status waves 165 assert.match(js, /function reset\(\) \{[\s\S]*?clearLog\(\);/); // reset clears the side pane 166 const css = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/style.css'), 'utf8'); 167 assert.match(css, /@keyframes planHue/); // rainbow cycle… 168 assert.match(css, /@keyframes planBob/); // …and the wave bob 169 assert.match(css, /\.status\.planning span/); 170 assert.match(js, /self-rules-view/); 171 // Completion stats: failures, wall time from Start, planner token totals. 172 assert.match(js, /self-stats/); 173 assert.match(js, /stats\.failures/); 174 assert.match(js, /startedAt/); 175 assert.match(js, /completion_tokens/); 176 // The pit fall and the confident-insufficient choice are the two triggers. 177 assert.match(js, /replanAndRetry\("insufficient"\)/); // insufficient now restarts too 178 assert.match(js, /replanAndRetry\("fell"\)/); 179 });