// Snake (index.html) on every track, judged against the pure model: the page must draw, frame for frame, what the // course's own program draws for the same seed, stored best and inputs. import { test, expect, TRACKS } from '../lib/app.mjs' import { framesFrom } from '../lib/drawing.mjs' import { FRAME } from '../lib/clock.mjs' import { SEED } from '../lib/seed.mjs' import { sessionView, newGame, movesEvery, FRAME_MS } from '../lib/oracle.mjs' import { browserPlatform } from '../../js/solutions/browser.js' // What the reference glue (js/solutions/browser.js) reads from a stored string: its own loadBest, given a window // whose localStorage holds `raw`. // What the reference glue (js/solutions/browser.js) reads from a stored string: its own loadBest, given a window // whose localStorage holds `raw`. The rule: only plain decimal digits with a value of at most 2147483647 count. const referenceLoadBest = (raw) => browserPlatform({ getContext: () => ({}) }, { localStorage: { getItem: () => raw } }).loadBest() // Stored strings that saveBest never writes. Number() would read most of them as a number; the glue must not. const SPELLINGS = ['007', '2147483647', '+7', ' 7', '7 ', '0x10', '1e2', '7.0', '2147483648'] // No known differences between the tracks. If one appears, list it here as {track: {raw: reason}} and report it. const KNOWN = {} const BEST_KEY = 'pure-snake-best' const HEAD = '#7cb342' const DIRS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] } const KEY_OF = { Up: 'ArrowUp', Down: 'ArrowDown', Left: 'ArrowLeft', Right: 'ArrowRight' } const OPPOSITE = { Up: 'Down', Down: 'Up', Left: 'Right', Right: 'Left' } // One page frame can hold several scenes (several game frames due in one animation frame); the last one is the picture. const lastScene = (shapes) => { const i = shapes.findLastIndex((s) => s.tag === 'Rect' && s.x === 0 && s.y === 0 && s.w === 400) return shapes.slice(Math.max(i, 0)) } const headOf = (shapes) => { const r = lastScene(shapes).find((s) => s.tag === 'Rect' && s.color === HEAD) return r ? { x: (r.x - 1) / 20, y: (r.y - 1) / 20 } : null } const textsOf = (shapes) => lastScene(shapes).filter((s) => s.tag === 'Text').map((s) => s.text) const headMoved = (before) => (s) => s.game.snake[0].x !== before.x || s.game.snake[0].y !== before.y || s.game.status !== 'Playing' // A greedy player for the scripted game: head for the apple, never into a wall or the body, never reverse. const choose = (g) => { const h = g.snake[0] const body = g.snake.slice(0, -1) const safe = Object.entries(DIRS).filter(([key, [dx, dy]]) => { const dir = key.slice(5) const p = { x: h.x + dx, y: h.y + dy } return dir !== OPPOSITE[g.dir] && p.x >= 0 && p.y >= 0 && p.x < g.width && p.y < g.height && !body.some((b) => b.x === p.x && b.y === p.y) }) const dist = ([, [dx, dy]]) => Math.abs(h.x + dx - g.apple.x) + Math.abs(h.y + dy - g.apple.y) const [best] = safe.sort((a, b) => dist(a) - dist(b)) return best ? best[0].slice(5) : g.dir } /** * Play the scripted game until `done(session)`: after every step, press the key the greedy player wants (checking the * page against the model after the key), then run the clock to the next step (checking again). Returns, per step, * the score before it and how many game frames it took, counted from the page's frames (each game frame draws once, * and so does each key that turns the snake). */ const playUntil = async (a, done, maxSteps = 200) => { await a.expectInSync('before the scripted game') const steps = [] for (let i = 0; i < maxSteps && !done(a.state); i++) { const g = a.state.game const want = choose(g) const seenBefore = a.seen const keys = want !== g.nextDir ? 1 : 0 if (keys) { await a.press(KEY_OF[want]) await a.expectInSync(`after ${KEY_OF[want]} at step ${i}`) } await a.advanceUntil(headMoved(g.snake[0])) await a.expectInSync(`step ${i}`) steps.push({ score: g.score, frames: a.seen - seenBefore - keys }) } expect(done(a.state), 'the scripted game reached its goal').toBe(true) return steps } for (const track of TRACKS) { test.describe(`${track}: snake`, () => { test('first frame equals the new session\'s scene', async ({ app }) => { const a = await (await app(track, 'snake')).start() expect(await a.expectInSync()).toEqual(await a.expected(sessionView({ game: newGame(SEED), best: 0, improved: false }))) }) test('one step every 200 ms of fake time at score 0', async ({ app, page }) => { const a = await (await app(track, 'snake')).start() await a.advance(1000) await a.expectInSync() const frames = await framesFrom(page, 0) const moves = frames.filter((f, i) => i > 0 && JSON.stringify(headOf(f.shapes)) !== JSON.stringify(headOf(frames[i - 1].shapes))) // A step every movesEvery(0) = 8 game frames of 25 ms, seen on the first animation frame at or after it. expect(movesEvery(0) * FRAME_MS).toBe(200) expect(moves.map((f) => f.at - a.clock.t0)).toEqual([200, 400, 600, 800].map((t) => Math.ceil(t / FRAME) * FRAME)) expect(moves.map((f) => headOf(f.shapes).x)).toEqual([11, 12, 13, 14]) }) test('a scripted game eats three apples, matching the model after every key, and speeds up after the third', async ({ app }) => { const a = await (await app(track, 'snake')).start() const steps = await playUntil(a, (s) => s.game.score >= 3) const after = await playUntil(a, (s) => s.game.score >= 4 || s.game.status !== 'Playing', 6) const all = [...steps, ...after] // Every step took movesEvery(score) game frames on the page: 8 at scores 0-2, 7 from the third apple on. expect(all.map((s) => s.frames)).toEqual(all.map((s) => movesEvery(s.score))) expect(all.filter((s) => s.score === 3).length, 'steps observed at score 3').toBeGreaterThan(0) expect(textsOf(a.lastPageFrame)).toContain(`Score: ${a.state.game.score}`) }) test('Up then Left inside one step turns up, then left (turn queue)', async ({ app }) => { const a = await (await app(track, 'snake')).start() await a.advanceUntil(headMoved(a.state.game.snake[0])) const h0 = headOf(await a.expectInSync()) await a.press('ArrowUp') await a.expectInSync('after ArrowUp') await a.press('ArrowLeft') await a.expectInSync('after ArrowLeft') await a.advanceUntil(headMoved(a.state.game.snake[0])) const h1 = headOf(await a.expectInSync('first step')) await a.advanceUntil(headMoved(a.state.game.snake[0])) const h2 = headOf(await a.expectInSync('second step')) expect([h1, h2]).toEqual([{ x: h0.x, y: h0.y - 1 }, { x: h0.x - 1, y: h0.y - 1 }]) }) test('Space shows Paused and freezes the scene, Space resumes', async ({ app, page }) => { const a = await (await app(track, 'snake')).start() await a.advance(300) await a.press(' ') const frozen = await a.expectInSync('paused') expect(textsOf(frozen)).toEqual(expect.arrayContaining(['Paused', 'Press Space to resume'])) const from = a.seen await a.advance(1000) await a.expectInSync('paused for 1 s') const during = await framesFrom(page, from) expect(during.length, 'the page keeps drawing while paused').toBeGreaterThan(30) for (const f of during) expect(f.shapes).toEqual(during[0].shapes) expect(headOf(during.at(-1).shapes)).toEqual(headOf(frozen)) await a.press(' ') await a.advance(400) const resumed = await a.expectInSync('resumed') expect(textsOf(resumed)).not.toContain('Paused') expect(headOf(resumed)).not.toEqual(headOf(frozen)) }) test('a crash shows Game Over, R restarts', async ({ app }) => { const a = await (await app(track, 'snake')).start() await a.advanceUntil((s) => s.game.status === 'GameOver') const over = await a.expectInSync('crashed') expect(textsOf(over)).toEqual(expect.arrayContaining(['Game Over', 'Press R to restart'])) await a.advance(500) await a.expectInSync('after the crash') // Restart starts a new game from the game's current seed (which every apple has moved on), not from SEED. const seed = a.state.game.seed await a.press('r') const again = await a.expectInSync('after R') expect(lastScene(again)).toEqual(await a.expected(sessionView({ game: newGame(seed), best: a.state.best, improved: false }))) await a.advance(500) await a.expectInSync('playing again') }) test('a 10 s jump of the fake clock in one frame advances the game by 250 ms only', async ({ app }) => { const a = await (await app(track, 'snake')).start() await a.advance(500) const h1 = headOf(await a.expectInSync()) await a.jump(10_000) const h2 = headOf(await a.expectInSync('after the jump')) // 250 ms are 10 game frames: one or two steps. Unclamped, 10 s would be 50 steps: into the wall. expect(Math.abs(h2.x - h1.x) + Math.abs(h2.y - h1.y)).toBeGreaterThanOrEqual(1) expect(Math.abs(h2.x - h1.x) + Math.abs(h2.y - h1.y)).toBeLessThanOrEqual(2) expect(a.state.game.status).toBe('Playing') await a.advance(400) await a.expectInSync('after the jump, running on') }) test('the best score is saved on improvement and shown after a reload; a stored best of 37 too', async ({ app, page }) => { const a = await (await app(track, 'snake')).start() await playUntil(a, (s) => s.game.score >= 1) expect(a.model.saves).toEqual([1]) expect(await page.evaluate((k) => localStorage.getItem(k), BEST_KEY)).toBe('1') await a.reload({ best: 1 }) const shown = await a.expectInSync('after reload') expect(shown).toEqual(await a.expected(sessionView({ game: newGame(SEED), best: 1, improved: false }))) expect(textsOf(shown)).toContain('Best: 1') await page.evaluate((k) => localStorage.setItem(k, '37'), BEST_KEY) await a.reload({ best: 37 }) expect(textsOf(await a.expectInSync('stored 37'))).toContain('Best: 37') await a.advance(300) await a.expectInSync('stored 37, running') }) test('garbage in localStorage reads as Best: 0 and the game runs', async ({ app, page }) => { const a = await (await app(track, 'snake')).start() for (const raw of ['banana', '-5', '3.5', 'NaN', '1e999', '99999999999999999999', '']) { await page.evaluate(([k, v]) => localStorage.setItem(k, v), [BEST_KEY, raw]) await a.reload({ best: 0 }) expect(textsOf(await a.expectInSync(`stored ${JSON.stringify(raw)}`))).toContain('Best: 0') await a.advance(250) await a.expectInSync(`stored ${JSON.stringify(raw)}, running`) } }) for (const raw of SPELLINGS) { test(`a stored best spelled ${JSON.stringify(raw)} reads as the reference glue reads it`, async ({ app, page }) => { test.fail(Boolean(KNOWN[track]?.[raw]), KNOWN[track]?.[raw]) const want = referenceLoadBest(raw) await page.addInitScript(([k, v]) => localStorage.setItem(k, v), [BEST_KEY, raw]) const a = await (await app(track, 'snake')).start({ best: want }) expect(textsOf(await a.expectInSync(`stored ${JSON.stringify(raw)}`))).toContain(`Best: ${want}`) }) } test('a page whose localStorage throws still runs, shows Best: 0, and survives a new best', async ({ app, page }) => { await page.addInitScript(() => { Object.defineProperty(window, 'localStorage', { configurable: true, get() { throw new DOMException('blocked by the test', 'SecurityError') } }) }) const a = await (await app(track, 'snake')).start() expect(textsOf(await a.expectInSync())).toContain('Best: 0') await playUntil(a, (s) => s.game.score >= 1) expect(a.model.saves).toEqual([1]) expect(textsOf(a.lastPageFrame)).toContain('Best: 1') await a.advance(300) await a.expectInSync('running after the failed save') }) }) }