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