semif-api-rocm

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

test_game.cjs (10360B)


      1 // Run: node --test test_game.cjs
      2 const { test } = require('node:test');
      3 const assert = require('node:assert/strict');
      4 const fs = require('node:fs');
      5 const vm = require('node:vm');
      6 const G = require('../semif-api/src/semif_api/web/game-rules.js');
      7 const grounded = (x) => ({ x, y: G.GROUND, vy: 0, onGround: true });
      8 function completeJump(x) {
      9   let player = { ...grounded(x), onGround: false, vy: G.JUMP_VY };
     10   for (let tick = 1; tick <= 20; tick++) {
     11     const result = G.advance(player, false);
     12     player = result.player;
     13     if (result.fell || player.onGround) return { ...result, tick };
     14   }
     15   throw Error('Jump did not finish');
     16 }
     17 
     18 test('run and jump match the integer movement rules', () => {
     19   assert.equal(G.advance(grounded(G.START_X), true).player.x, G.START_X + 1);
     20   const result = completeJump(G.START_X);
     21   assert.equal(result.tick, 5);
     22   assert.equal(result.player.x, G.START_X + 5);
     23   assert.equal(G.JUMP_TICKS, result.tick);
     24   assert.equal(G.JUMP_DISTANCE, 5);
     25 });
     26 
     27 test('each pit has two safe integer takeoffs; one tick earlier fails', () => {
     28   for (const [start, lastHole] of G.PITS) {
     29     assert.equal(lastHole - start + 1, 3);
     30     assert.equal(completeJump(start - 3).fell, true);
     31     for (const x of [start - 2, start - 1]) {
     32       const result = completeJump(x);
     33       assert.equal(result.fell, false, `x=${x}`);
     34       assert.equal(result.player.onGround, true);
     35       assert.equal(result.tick, G.JUMP_TICKS);
     36       assert.ok(result.player.x >= lastHole + 1);
     37     }
     38     assert.equal(G.advance(grounded(start - 1), true).player.onGround, false);
     39   }
     40 });
     41 
     42 test('a player below the surface cannot land underneath the bank', () => {
     43   const result = G.advance({ x: 16, y: 9, vy: 2.2, onGround: false }, false);
     44   assert.equal(result.player.x, 17);
     45   assert.equal(result.player.onGround, false);
     46   assert.equal(result.fell, true);
     47 });
     48 
     49 test('pit left edges are holes; right edges are solid ground', () => {
     50   for (const [a, b] of G.PITS) {
     51     assert.equal(G.floorAt(a - 1), true);
     52     assert.equal(G.floorAt(a), false);
     53     assert.equal(G.floorAt(b), false);
     54     assert.equal(G.floorAt(b + 1), true);
     55   }
     56 });
     57 
     58 for (const takeoffOffset of [1, 2]) {
     59   test(`full level completes with jumps ${takeoffOffset} units before each pit`, () => {
     60     let player = grounded(G.START_X), jumpsLeft = G.MAX_JUMPS;
     61     for (let tick = 0; tick < 100; tick++) {
     62       const shouldJump = takeoffOffset === 1 ? G.guidedJump(player, jumpsLeft)
     63         : player.onGround && G.PITS.some(([a]) => a - player.x === takeoffOffset);
     64       if (shouldJump) {
     65         jumpsLeft--;
     66         player = { ...player, onGround: false, vy: G.JUMP_VY };
     67       }
     68       const result = G.advance(player, true);
     69       assert.equal(result.fell, false);
     70       player = result.player;
     71       assert.ok(Number.isInteger(player.x));
     72       assert.doesNotMatch(G.stateText(player, jumpsLeft, false), /\d+\.\d+/);
     73       if (player.x >= G.GOAL) {
     74         assert.equal(jumpsLeft, 1);
     75         return;
     76       }
     77     }
     78     assert.fail('Did not reach the flag');
     79   });
     80 }
     81 
     82 test('unguided scene is compact, relative, and advice-free', () => {
     83   const state = G.stateText(grounded(12), 4, false);
     84   assert.match(state, /Run moves you one space forward\./);
     85   assert.match(state, /If the next space is a hole, running makes you fall\./);
     86   assert.match(state, /Jump at the edge of a pit to cross it\./);
     87   assert.match(state, /You can act again after running or landing\./);
     88   assert.doesNotMatch(state, /Jump (?:lands|moves)|5 spaces|5 units|Jumping anywhere else fails/);
     89   assert.match(state, /1 ground space\n3 hole spaces\n14 ground spaces\n3 hole spaces\n14 ground spaces\n3 hole spaces\n13 ground spaces/);
     90   assert.doesNotMatch(state, /Hint:|x =|ticks|\d+\.\d+|jump now|run now/i);
     91   assert.equal(G.stateText(grounded(12), 4, true), state + '\nHint: run for one tick, then reassess.');
     92   assert.deepEqual(G.OPTIONS, [{ id: 'run', description: 'Run' }, { id: 'jump', description: 'Jump' }]);
     93 });
     94 
     95 test('one space from the flag, the ahead-listing becomes a direct statement', () => {
     96   const near = G.stateText(grounded(G.GOAL - 1), 2, false);
     97   assert.match(near, /The flag is right in front of you!/);
     98   assert.doesNotMatch(near, /Ahead, from nearest/);   // an empty header would list nothing
     99   assert.doesNotMatch(near, /^\d+ ground spaces?$/m);
    100   const far = G.stateText(grounded(G.GOAL - 5), 2, false);
    101   assert.match(far, /Ahead, from nearest to farthest/);
    102   assert.doesNotMatch(far, /right in front of you/);
    103 });
    104 
    105 test('ascii mode renders the whole track as one symbolic row with no prose or hints', () => {
    106   const state = G.stateText(grounded(3), 3, 'ascii');
    107   assert.match(state, /Legend:.*player.*floor.*pit.*goal flag/);
    108   assert.match(state, /Jumps remaining: 3/);
    109   assert.doesNotMatch(state, /ground space|hole space|Ahead, from nearest|Hint:/i);
    110   assert.doesNotMatch(state, /\d+\.\d+/);
    111   const cells = state.split('\n').at(-1).split(' ');   // space-separated so each glyph is its own token
    112   assert.equal(cells.length, G.GOAL + 1);
    113   for (let x = 0; x <= G.GOAL; x++) {
    114     const expected = x === 3 ? '*' : x === G.GOAL ? '!' : G.floorAt(x) ? '-' : '#';
    115     assert.equal(cells[x], expected, `col ${x}`);
    116   }
    117 });
    118 
    119 test('run-length mode encodes the terrain ahead as parseable run-length segments', () => {
    120   for (const x of [G.START_X, 6, 9, 13, G.GOAL - 1]) {
    121     const state = G.stateText(grounded(x), 4, 'runlength');
    122     assert.match(state, /Legend:.*player.*floor.*pit.*goal flag/);
    123     assert.match(state, /Jumps remaining: 4/);
    124     assert.doesNotMatch(state, /ground space|hole space|Ahead, from nearest|Hint:/i);
    125     assert.doesNotMatch(state, /\d+\.\d+/);
    126     const toks = state.split('\n').at(-1).slice('[*] > '.length).split(' | ');
    127     assert.equal(toks.at(-1), '[!]');
    128     const kinds = toks.slice(0, -1).flatMap((t) => {
    129       const m = t.match(/^([#-])(\d+)$/);
    130       assert.ok(m, `bad token ${t}`);
    131       return Array(Number(m[2])).fill(m[1] === '#' ? 'hole' : 'ground');
    132     });
    133     assert.equal(kinds.length, G.GOAL - 1 - x);
    134     kinds.forEach((kind, i) =>
    135       assert.equal(kind, G.floorAt(x + 1 + i) ? 'ground' : 'hole', `x=${x} i=${i}`));
    136   }
    137 });
    138 
    139 test('terrain description reconstructs every space ahead without off-by-one errors', () => {
    140   for (let x = G.START_X; x < G.GOAL; x++) {
    141     const state = G.stateText(grounded(x), 4, false);
    142     const spaces = [];
    143     for (const match of state.matchAll(/^(\d+) (ground|hole) spaces?$/gm)) {
    144       spaces.push(...Array(Number(match[1])).fill(match[2]));
    145     }
    146     spaces.push('ground'); // flag's space
    147     assert.equal(spaces.length, G.GOAL - x);
    148     spaces.forEach((kind, i) => assert.equal(kind === 'ground', G.floorAt(x + i + 1)));
    149   }
    150   assert.match(G.stateText(grounded(9), 4, false), /4 ground spaces\n3 hole spaces/);
    151   assert.match(G.stateText(grounded(G.GOAL), 1, false), /Flag reached/);
    152 });
    153 
    154 // Run the guided "jump at the edge" solver to completion on a given layout.
    155 // Restores the previous layout afterwards so global mutation never leaks.
    156 function simulateSolve(layout) {
    157   const restore = G.PITS.map((p) => [...p]);
    158   G.setPits(layout);
    159   try {
    160     let player = grounded(G.START_X), jumpsLeft = G.MAX_JUMPS;
    161     for (let tick = 0; tick < 400; tick++) {
    162       if (G.guidedJump(player, jumpsLeft)) {
    163         jumpsLeft--;
    164         player = { ...player, onGround: false, vy: G.JUMP_VY };
    165       }
    166       const r = G.advance(player, true);
    167       assert.equal(r.fell, false, `fell at x=${player.x} layout=${JSON.stringify(layout)}`);
    168       player = r.player;
    169       assert.ok(Number.isInteger(player.x));
    170       if (player.x >= G.GOAL) { assert.ok(jumpsLeft >= 1, 'needs a spare jump'); return; }
    171     }
    172     assert.fail(`never reached the flag: layout=${JSON.stringify(layout)}`);
    173   } finally { G.setPits(restore); }
    174 }
    175 
    176 test('makePits is deterministic and actually varies the layout', () => {
    177   assert.deepEqual(G.makePits(12345), G.makePits(12345));
    178   const distinct = new Set();
    179   for (let s = 0; s < 2000; s++) distinct.add(G.makePits(s).map((p) => p[0]).join(','));
    180   assert.ok(distinct.size > 100, `only ${distinct.size} distinct layouts over 2000 seeds`);
    181 });
    182 
    183 test('every generated layout is valid and solvable by the edge-jump strategy', () => {
    184   for (let s = 0; s < 500; s++) {
    185     const layout = G.makePits(s);
    186     assert.deepEqual(G.validatePits(layout), [], `invalid at seed ${s}: ${JSON.stringify(layout)}`);
    187   }
    188   for (let s = 0; s < 200; s++) simulateSolve(G.makePits(s));
    189 });
    190 
    191 test('setPits rejects unfair layouts before mutating the live level', () => {
    192   assert.throws(() => G.setPits([[14, 16], [31, 33], [48, 52]])); // pit too wide
    193   assert.throws(() => G.setPits([[2, 4], [31, 33], [48, 50]]));    // on/behind the start
    194   assert.throws(() => G.setPits([[14, 16], [16, 18], [48, 50]]));  // pits too close
    195   assert.deepEqual(G.PITS, G.DEFAULT_PITS);                        // rejection left defaults intact
    196 });
    197 
    198 test('setPits swaps the live layout and floorAt / MAX_JUMPS follow', () => {
    199   const restore = G.PITS.map((p) => [...p]);
    200   try {
    201     const next = [[8, 10], [24, 26], [40, 42]];
    202     G.setPits(next);
    203     assert.deepEqual(G.PITS, next);
    204     assert.equal(G.floorAt(9), false);
    205     assert.equal(G.floorAt(20), true);
    206     assert.equal(G.MAX_JUMPS, 4);
    207   } finally { G.setPits(restore); }
    208   assert.deepEqual(G.PITS, G.DEFAULT_PITS);
    209 });
    210 
    211 test('demo is hosted as a third tab, with the game loop script wiring it up', () => {
    212   const html = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/index.html'), 'utf8');
    213   // A tab that targets a hidden panel — the game no longer lives in its own page.
    214   assert.match(html, /id="tab-game"/);
    215   assert.match(html, /id="panel-game"[^>]*hidden/);
    216   assert.doesNotMatch(html, /game\.html/);
    217   // game-rules.js (defines GameRules) must load before game.js (consumes it).
    218   assert.match(html, /<script src="game-rules\.js"><\/script>\s*<script src="game\.js"><\/script>/);
    219 });
    220 
    221 test('game loop script parses and uses the same action IDs as the prompt', () => {
    222   const js = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/game.js'), 'utf8');
    223   new vm.Script(js);   // throws on any syntax error (it is a top-level IIFE)
    224   assert.match(js, /GameRules/);
    225   assert.match(js, /best\[0\] === "run"/);
    226   assert.match(js, /best\[0\] === "jump"/);
    227   assert.doesNotMatch(js, /best\[0\] === "(?:yes|no)"/);
    228 });