[verified] Snake 0.7: queued controls, records, pause and wrap mode

This commit is contained in:
2026-09-12 12:23:09 +01:00
parent eacad97240
commit 5a7081b933
10 changed files with 548 additions and 109 deletions
+28
View File
@@ -0,0 +1,28 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const ux = fs.readFileSync('src/pages/index/index.ux','utf8')
const css = ux.match(/<style>([\s\S]*?)<\/style>/)[1]
function rule(name) {
const m = css.match(new RegExp('\\.'+name+'\\s*\\{([^}]+)\\}'))
assert.ok(m, 'style exists: '+name)
return Object.fromEntries(m[1].split(';').filter(s=>s.includes(':')).map(s=>s.trim().split(/:\s*/)))
}
function px(r,k) { assert.match(r[k] || '', /^\d+px$/, k); return Number(r[k].slice(0,-2)) }
for (const name of ['board','hud','footer','overlay']) {
const r = rule(name), x=px(r,'left'), y=px(r,'top'), w=px(r,'width'), h=px(r,'height')
const dy = Math.max(Math.abs(y-233), Math.abs(y+h-233))
const chord = 2*Math.sqrt(233*233-dy*dy)
const used = 2*Math.max(Math.abs(x-233), Math.abs(x+w-233))
assert.ok(used <= chord-8, `${name}: ${used} > chord ${chord} minus 8px safety`)
console.log(`${name}: y=${y}..${y+h}, width=${w}, chord=${chord.toFixed(2)}, margin=${((chord-used)/2).toFixed(2)}px`)
}
const overlay = rule('overlay'), retry = rule('retry'), modes = rule('modes'), button = rule('modeButton')
assert.ok(px(retry,'width') >= 200 && px(retry,'height') >= 56, 'large retry touch target')
assert.ok(px(button,'height') >= 44 && px(button,'width') >= 100)
assert.ok(2*px(button,'width') <= px(modes,'width'))
const total = ['overText','overSub','retry','overHint','modes'].reduce((n,k)=>n+px(rule(k),'height'),0)
assert.ok(total <= px(overlay,'height'), 'overlay children fit without clipping')
assert.ok(px(retry,'width') <= px(overlay,'width'))
assert.doesNotMatch(ux, /\}\}px/, 'style bindings already contain px: no double px')
assert.equal(JSON.parse(fs.readFileSync('src/manifest.json')).config.designWidth, 466)
console.log('ROUND LAYOUT PASS (numeric geometry, not hardware rendering)')
+3 -3
View File
@@ -6,11 +6,11 @@ const snake = require('../src/common/snake.js');
const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8');
const body = ux.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace(/import\s+snake\s+from\s+'[^']*';?/, '')
.replace(/import\s+\w+\s+from\s+'[^']*';?/g, '')
.replace(/^const\s+\w+\s*=[^\n]*\n/gm, '')
.replace('export default', '');
const sandbox = { snake, Date, Math, String, Number, console, setInterval: () => 0, clearInterval: () => {},
const sandbox = { best: require('../src/common/best.js'), storage: {get() {}, set() {}}, snake, Date, Math, String, Number, console, setInterval: () => 0, clearInterval: () => {},
CELL: 20, GRID: 16, OFFSET: 73 };
vm.createContext(sandbox);
const spec = vm.runInContext('(' + body + ')', sandbox);
@@ -60,7 +60,7 @@ assert.equal(c.model.length, 3);
assert.equal(c.scoreText, 'SCORE 0');
// template uses image components for textures + documented directives/events
assert.match(ux, /<image class="board" src="\/common\/tex\/board\.png">/);
assert.match(ux, /<image class="board" src="\/common\/tex\/board\.png"[^>]*>/);
assert.match(ux, /<image class="seg" src="\{\{ seg\.src \}\}"/);
assert.match(ux, /for="\{\{ seg in snake \}\}"/);
assert.match(ux, /show="\{\{ over \}\}"/);
+24
View File
@@ -0,0 +1,24 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const snake = require('../src/common/snake.js')
let seed=83971
const rand=()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/4294967296}
const dirs=[{x:1,y:0},{x:0,y:1},{x:-1,y:0},{x:0,y:-1}]
let moves=0, seams=0
for(const mode of ['classic','wrap']) for(let game=0;game<1000;game++) {
let cells=snake.initialSnake(),dir=dirs[0]
for(let tick=0;tick<100;tick++) {
dir=snake.turn(dir,dirs[Math.floor(rand()*4)])
const h=cells[0]
const food=rand()<0.3?{x:(h.x+dir.x+16)%16,y:(h.y+dir.y+16)%16}:{x:0,y:0}
const r=snake.step(cells,dir,food,16,mode)
if(!r.alive)break
if(Math.abs(r.snake[0].x-h.x)>1||Math.abs(r.snake[0].y-h.y)>1)seams++
cells=r.snake
assert.equal(new Set(cells.map(c=>c.x+','+c.y)).size,cells.length)
for(const c of cells)assert.ok(c.x>=0&&c.x<16&&c.y>=0&&c.y<16)
for(const name of snake.renderPieces(cells,dir,16))assert.ok(fs.existsSync(require('node:path').join(__dirname,'../src/common/tex',name+'.png')),name)
moves++
}
}
console.log(JSON.stringify({randomizedValidMoves:moves,edgeCrossings:seams}))
+173
View File
@@ -0,0 +1,173 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const snake = require('../src/common/snake.js')
const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8')
function page(storage = { get() {}, set() {} }) {
const timers = new Map(); let id = 0
const context = { best: require('../src/common/best.js'), snake, storage, console, setInterval(fn, ms) { timers.set(++id, {fn, ms}); return id }, clearInterval(id) { timers.delete(id) } }
const body = ux.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/import\s+\w+\s+from\s+'[^']*';?/g, '').replace('export default', 'globalThis.spec =')
vm.runInNewContext(body, context)
const p = Object.assign({}, context.spec, JSON.parse(JSON.stringify(context.spec.private)))
p.onInit(); p.onShow()
p.foodModel = {x: 0, y: 0}
return {p, timers}
}
test('food selection has bounded work even when only one empty cell remains', () => {
const cells = []; for(let y=0;y<16;y++) for(let x=0;x<16;x++) if(x!==15 || y!==15) cells.push({x,y})
const original = Math.random; let calls = 0
try {
Math.random = () => { assert.ok(++calls <= 1, 'at most one random draw'); return 0 }
assert.deepEqual(snake.spawnFood(cells,16), {x:15,y:15})
cells.push({x:15,y:15}); assert.equal(snake.spawnFood(cells,16), null)
} finally {Math.random=original}
})
test('page recreation shares pending best writes instead of racing an older page', () => {
const reads=[], writes=[]
const api = {get(o) {reads.push(o)}, set(o) {writes.push(o)}}
const {p:old} = page(api); reads[0].success('0'); eatTo(old, 5); old.onDestroy()
const {p} = page(api); assert.equal(p.bestText, 'BEST 5'); assert.equal(reads.length, 1)
eatTo(p, 7); assert.equal(writes.length, 1); writes[0].success()
assert.equal(writes[1].value, '7'); writes[1].success(); assert.equal(p.bestText, 'BEST 7')
})
test('retired timer callbacks cannot advance a resumed or restarted run', () => {
const {p, timers} = page(); const retired = [...timers.values()][0].fn
p.onTap(); p.onTap(); retired(); assert.equal(p.model[0].x, 6)
const oldRun = [...timers.values()][0].fn
p.restart(); oldRun(); assert.equal(p.model[0].x, 6)
p.onHide(); const model=p.model; p.restart(); assert.equal(p.model, model, 'hidden retry ignored')
})
test('filling the board wins without null food crashes and retry clears the result', () => {
const {p, timers} = page()
// A serpentine Hamilton path, head (14,0), final food (15,0).
const path = []
for (let y=0;y<16;y++) for (let k=0;k<16;k++) path.push({x:y%2 ? k : 15-k,y})
p.model = path.slice(1); p.dir = {x:1,y:0}; p.foodModel = {x:15,y:0}
p.tick(); assert.equal(p.over, true); assert.equal(p.endText, 'YOU WIN')
assert.equal(p.scoreText, 'SCORE 253'); assert.equal(p.bestText, 'BEST 253')
assert.equal(p.hasFood, false); assert.equal(p.snake.length, 256); assert.equal(timers.size, 0)
p.tick(); p.onShow(); assert.equal(timers.size, 0)
p.restart(); assert.equal(p.hasFood, true); assert.equal(p.endText, 'GAME OVER'); assert.equal(p.model.length, 3)
assert.match(ux, /show="\{\{ hasFood \}\}"/)
assert.match(ux, /\{\{ endText \}\}/)
})
test('select modes while paused or over, resetting safely, and retry keeps mode', () => {
const {p, timers} = page()
assert.equal(p.mode, 'classic')
p.selectMode('wrap'); assert.equal(p.mode, 'classic', 'no mid-run mode change')
p.onTap(); p.selectMode('garbage'); assert.equal(p.mode, 'classic')
p.selectMode('wrap'); assert.equal(p.mode, 'wrap'); assert.equal(p.paused, true); assert.equal(timers.size, 0)
p.onTap(); p.model = [{x:15,y:7},{x:14,y:7},{x:13,y:7}]; p.tick()
assert.equal(p.model[0].x, 0); assert.match(p.snake[1].src, /body_h/)
p.restart(); assert.equal(p.mode, 'wrap'); assert.equal(timers.size, 1)
p.onTap(); p.selectMode('classic'); p.onTap()
p.model = [{x:15,y:7},{x:14,y:7},{x:13,y:7}]; p.tick()
assert.equal(p.over, true); assert.equal(timers.size, 0)
p.onShow(); p.onTap(); assert.equal(p.over, true); assert.equal(timers.size, 0)
p.restart(); assert.equal(p.over, false); assert.equal(timers.size, 1)
for (const cls of ['board','food','seg']) assert.match(ux, new RegExp('class="'+cls+'"[^>]*onclick="onTap"'))
assert.match(ux, /class="retry" onclick="restart"/)
assert.match(ux, /onclick="selectMode\('classic'\)"/)
assert.match(ux, /onclick="selectMode\('wrap'\)"/)
assert.doesNotMatch(ux, /<(?:div|stack)[^>]*onclick=/, 'actions cannot bubble into a second action')
assert.match(ux, /\{\{ bestText \}\}/)
assert.match(ux, /show="\{\{ paused && !over \}\}"/)
})
test('Wrap crosses every edge, still collides with body and renders seam connections', () => {
const cases = [
[[15,7],[14,7],[13,7]], [[0,7],[1,7],[2,7]],
[[7,15],[7,14],[7,13]], [[7,0],[7,1],[7,2]]
]
const dirs = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}]
const expected = [{x:0,y:7},{x:15,y:7},{x:7,y:0},{x:7,y:15}]
cases.forEach((coords,i) => {
const cells = coords.map(([x,y])=>({x,y}))
const r = snake.step(cells, dirs[i], {x:8,y:8}, 16, 'wrap')
assert.equal(r.alive, true); assert.deepEqual(r.snake[0], expected[i])
assert.equal(snake.step(cells, dirs[i], {x:8,y:8}, 16, 'classic').alive, false)
})
assert.equal(snake.step([{x:15,y:7},{x:0,y:7},{x:1,y:7}], dirs[0], {x:8,y:8}, 16, 'wrap').alive, false)
assert.equal(snake.step([{x:15,y:7},{x:15,y:8},{x:0,y:8},{x:0,y:7}], dirs[0], {x:8,y:8}, 16, 'wrap').alive, true)
// At (15,7), neighbor (0,7) is east; neighbor (15,8) is south.
assert.deepEqual(snake.renderPieces([{x:0,y:7},{x:15,y:7},{x:15,y:8}], dirs[0], 16), ['head_e','corner_es','tail_s'])
assert.deepEqual(snake.renderPieces([{x:7,y:0},{x:7,y:15},{x:6,y:15}], dirs[2], 16), ['head_s','corner_sw','tail_w'])
assert.deepEqual(snake.renderPieces([{x:0,y:7},{x:15,y:7}], dirs[0], 16), ['head_e','tail_w'])
})
function eatTo(p, score) {
p.model = [{x:6,y:7}, ...Array.from({length:score+1}, (_,i) => ({x:i%16,y:12+Math.floor(i/16)}))]
p.dir = {x:1,y:0}; p.foodModel = {x:7,y:7}; p.tick()
}
test('late best read cannot lower local record; writes are serialized', () => {
const reads = [], writes = []
const {p} = page({get(o) {reads.push(o)}, set(o) {writes.push(o)}})
eatTo(p, 5); assert.equal(writes.length, 0, 'wait for stored high water before writing')
reads[0].success('3'); assert.equal(p.bestText, 'BEST 5'); assert.equal(writes[0].value, '5')
eatTo(p, 6); eatTo(p, 7); assert.equal(writes.length, 1)
writes[0].success(); assert.equal(writes.length, 2); assert.equal(writes[1].value, '7')
writes[1].success(); p.restart(); assert.equal(p.bestText, 'BEST 7')
})
test('read/write failures retry on later lifecycle activity without erasing a saved record', () => {
const reads = [], writes = []
const {p} = page({get(o) {reads.push(o)}, set(o) {writes.push(o)}})
reads[0].fail('offline', 1); eatTo(p, 5); assert.equal(writes.length, 0)
reads[1].success('8'); assert.equal(p.bestText, 'BEST 8'); assert.equal(writes.length, 0)
eatTo(p, 9); writes[0].fail('full', 1); assert.equal(writes.length, 1)
p.onHide(); p.onShow(); assert.equal(writes.length, 2); assert.equal(writes[1].value, '9')
p.onDestroy(); const text = p.bestText; writes[1].success(); assert.equal(p.bestText, text)
})
test('storage throws and corrupt values never crash or poison gameplay', () => {
const {p} = page({get() {throw Error('offline')}, set() {throw Error('full')}})
eatTo(p, 1); assert.equal(p.bestText, 'BEST 1')
for (const value of ['oops', '-4', '2.5', 'Infinity', '99999', '', '{}']) {
const {p} = page({get(o) {o.success(value)}, set() {throw Error('full')}})
assert.equal(p.bestText, 'BEST 0'); eatTo(p, 1); assert.equal(p.bestText, 'BEST 1')
}
})
test('best score loads raw storage value and persists only new records', () => {
let get; const writes = []
const {p} = page({get(o) { get=o }, set(o) { writes.push(o) }})
assert.ok(get, 'best score requested'); assert.equal(get.default, '0')
get.success('4'); assert.equal(p.bestText, 'BEST 4')
p.model = [{x:6,y:7}, ...Array.from({length:6}, (_,i) => ({x:i,y:12}))]
p.foodModel = {x:7,y:7}; p.tick()
assert.equal(p.bestText, 'BEST 5'); assert.equal(writes.length, 1)
assert.equal(writes[0].value, '5'); assert.equal(writes[0].key, get.key)
writes[0].success(); p.restart(); assert.equal(p.bestText, 'BEST 5')
assert.match(ux, /import storage from '@system.storage'/)
assert.ok(JSON.parse(fs.readFileSync('src/manifest.json')).features.some(f => f.name === 'system.storage'))
})
test('eating updates live score and accelerates to a capped 120ms', () => {
const {p, timers} = page()
assert.equal([...timers.values()][0].ms, 300)
for (let score = 1; score <= 20; score++) {
p.model = [{x:6,y:7}, ...Array.from({length:score+1}, (_,i) => ({x:i%16,y:12+Math.floor(i/16)}))]
p.dir = {x:1,y:0}; p.foodModel = {x:7,y:7}; p.tick()
assert.equal(p.scoreText, 'SCORE ' + score)
assert.equal(timers.size, 1); assert.equal([...timers.values()][0].ms, Math.max(120, 300-score*10))
}
p.restart(); assert.equal([...timers.values()][0].ms, 300)
})
test('pause stops timers, resume is explicit after hide, destroy is final', () => {
const {p, timers} = page()
assert.equal(timers.size, 1); p.onShow(); assert.equal(timers.size, 1)
const stale = [...timers.values()][0].fn
p.onSwipe({direction:'up'}); p.onTap(); assert.equal(p.paused, true); assert.equal(timers.size, 0)
stale(); p.tick(); assert.equal(p.model[0].x, 6)
p.onSwipe({direction:'down'}); p.onTap(); assert.equal(timers.size, 1)
p.tick(); assert.equal(p.model[0].x, 7)
p.onHide(); assert.equal(timers.size, 0); p.onShow(); assert.equal(p.paused, true); assert.equal(timers.size, 0)
p.onTap(); assert.equal(timers.size, 1)
p.onDestroy(); p.onShow(); p.onTap(); assert.equal(timers.size, 0)
assert.doesNotMatch(ux, /onBackPress|transform\s*:/)
assert.match(ux, /class="board"[^>]*onclick="onTap"/)
})
test('two safe turns are queued and consumed one per tick', () => {
const {p} = page()
for (const direction of ['up', 'down', 'up', 'left', 'down', 'garbage']) p.onSwipe({direction})
p.tick(); assert.equal(p.model[0].y, 6); assert.equal(p.model[0].x, 6)
p.tick(); assert.equal(p.model[0].x, 5); assert.equal(p.model[0].y, 6)
p.tick(); assert.equal(p.model[0].x, 4); assert.equal(p.model[0].y, 6)
p.onSwipe({direction:'down'}); p.restart(); p.tick()
assert.equal(p.model[0].x, 7); assert.equal(p.model[0].y, 7)
})