import test from 'node:test'; import assert from 'node:assert/strict'; import { Game } from '../src/core.js'; import { campaignVersion } from '../src/campaign-versions.js'; import { center, layout } from '../src/geometry.js'; import { CanvasPainter } from '../src/renderer.js'; import { tutorialProgressKey } from '../src/tutorial-progress.js'; const campaign = campaignVersion('v0.5'); const officialKey = 'hex-trails-campaign-v0.5'; const teachingKey = tutorialProgressKey('v0.5'); let importId = 0; // Exercise app.js pointer handlers, result buttons and persistence with a virtual clock. async function withApp({search = '?campaign=v0.5', entries = {}}, run) { const keys = ['document', 'window', 'location', 'performance', 'localStorage', 'devicePixelRatio', 'matchMedia', 'Image', 'ResizeObserver', 'MutationObserver', 'requestAnimationFrame', 'cancelAnimationFrame', 'setTimeout', 'clearTimeout']; const saved = new Map(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); const elements = new Map(), frames = new Map(), timers = new Map(), slots = new Map(Object.entries(entries)); const actions = [], writes = []; let now = 0, serial = 0; const rect = {left: 0, top: 0, width: 700, height: 700}; const context = new Proxy({}, {get: (object, key) => object[key] ?? (() => {}), set: (object, key, value) => (object[key] = value, true)}); function element() { const listeners = new Map(), classes = new Set(); return { style: {setProperty() {}}, dataset: {}, textContent: '', innerHTML: '', hidden: false, open: false, disabled: false, classList: { add(...names) { for (const name of names) classes.add(name); }, remove(...names) { for (const name of names) classes.delete(name); }, contains: name => classes.has(name), toggle(name, force) { const added = force ?? !classes.has(name); if (added) classes.add(name); else classes.delete(name); return added; }, }, addEventListener(name, callback) { if (!listeners.has(name)) listeners.set(name, []); listeners.get(name).push(callback); }, emit(name, event = {}) { for (const callback of listeners.get(name) ?? []) callback(event); }, getBoundingClientRect: () => rect, getContext: () => context, setPointerCapture() {}, querySelector: () => get('dialog-close'), querySelectorAll: () => [element(), element(), element()], insertBefore() {}, setAttribute() {}, focus() {}, showModal() { this.open = true; }, close() { this.open = false; this.emit('close'); }, click() { if (!this.disabled) this.onclick?.(); }, }; } function get(id) { if (!elements.has(id)) elements.set(id, element()); return elements.get(id); } get('board').parentElement = element(); const storage = {getItem: key => slots.get(key) ?? null, setItem(key, value) { slots.set(key, value); writes.push(key); }}; const advance = ms => { now += ms; const pending = [...frames.values()]; frames.clear(); for (const callback of pending) callback(now); for (const [id, timer] of [...timers]) if (timer.at <= now) { timers.delete(id); timer.callback(); } }; const clickTile = (level, id) => { const tile = level.tiles.find(tile => tile.id === id); assert.ok(tile, `fixture contains ${id}`); const point = center(tile, layout(level.cells, rect.width, rect.height)); const event = {clientX: point.x, clientY: point.y, pointerId: 1}; get('board').emit('pointerdown', event); get('board').emit('pointerup', event); }; const originalTap = Game.prototype.tap, originalRotate = Game.prototype.rotate, originalSprite = CanvasPainter.prototype.sprite; const replacements = { document: {getElementById: get, createElement: element, querySelectorAll: () => [], addEventListener() {}, body: element()}, window: {localStorage: storage, scrollTo() {}}, localStorage: storage, location: {search}, performance: {now: () => now}, devicePixelRatio: 1, matchMedia: () => ({matches: false}), Image: class {}, ResizeObserver: class {observe() {}}, MutationObserver: class {observe() {}}, requestAnimationFrame: callback => { frames.set(++serial, callback); return serial; }, cancelAnimationFrame: id => frames.delete(id), setTimeout: (callback, delay) => { timers.set(++serial, {callback, at: now + delay}); return serial; }, clearTimeout: id => timers.delete(id), }; try { for (const [key, value] of Object.entries(replacements)) Object.defineProperty(globalThis, key, {configurable: true, writable: true, value}); Game.prototype.tap = function(id) { const result = originalTap.call(this, id); actions.push({type: 'tap', id, status: result.status, at: now, moves: this.moves}); return result; }; Game.prototype.rotate = function(direction) { const before = JSON.stringify(this.tiles), result = originalRotate.call(this, direction); actions.push({type: 'rotate', direction, status: result.status, before, after: JSON.stringify(this.tiles), moves: this.moves}); return result; }; CanvasPainter.prototype.sprite = () => true; await import(`../src/app.js?tutorial-app-test=${++importId}`); await run({get, advance, clickTile, actions, writes, slots, read: key => slots.has(key) ? JSON.parse(slots.get(key)) : null}); } finally { Game.prototype.tap = originalTap; Game.prototype.rotate = originalRotate; CanvasPainter.prototype.sprite = originalSprite; for (const [key, descriptor] of saved) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else delete globalThis[key]; } } } test('three actual tutorial pointer flows teach departure, clearing a blocker and rotation before LEVEL 01', async () => { await withApp({}, ({get, advance, clickTile, actions, writes, read}) => { const [first, second, third] = campaign.tutorials; const remaining = () => Number(get('moves').textContent); assert.equal(get('level-eyebrow').textContent, '入门 1 / 3'); assert.equal(remaining(), 1); assert.equal(get('rotate-left').disabled, true); assert.equal(get('rotate-right').disabled, true); assert.equal(get('board-toast').textContent, first.lesson); assert.equal(get('board-toast').classList.contains('visible'), true); advance(6500); assert.equal(get('board-toast').classList.contains('visible'), false, 'teaching uses the existing timed lesson notice'); assert.deepEqual(writes, [], 'loading the introduction alone writes no progress'); clickTile(first, 'traveler'); assert.equal(actions.at(-1).status, 'exit'); assert.equal(remaining(), 0); assert.equal(get('result').open, false, 'completion waits for the visible departure'); advance(30000); assert.equal(get('result').open, true); assert.deepEqual(read(teachingKey), {current: 1, completed: [0]}); assert.equal(read(officialKey), null); get('next-level').click(); assert.equal(get('level-eyebrow').textContent, '入门 2 / 3'); assert.equal(remaining(), 2); clickTile(second, 'traveler'); assert.equal(actions.at(-1).status, 'blocked'); assert.equal(actions.at(-1).moves, 2); assert.equal(remaining(), 2, 'trying the blocked tile costs no departure or piece'); advance(1000); clickTile(second, 'gate'); clickTile(second, 'traveler'); assert.deepEqual(actions.slice(-2).map(action => [action.id, action.status]), [['gate', 'exit'], ['traveler', 'exit']]); assert.equal(actions.at(-1).at, actions.at(-2).at, 'the newly opened tile departs without waiting for the gate animation'); assert.equal(remaining(), 0); assert.equal(get('result').open, false); advance(30000); assert.deepEqual(read(teachingKey), {current: 2, completed: [0, 1]}); assert.equal(read(officialKey), null); get('next-level').click(); assert.equal(get('level-eyebrow').textContent, '入门 3 / 3'); assert.equal(remaining(), 2); assert.equal(get('rotate-right').disabled, false); get('rotate-right').click(); const rotation = actions.at(-1); assert.equal(rotation.status, 'rotated'); assert.equal(rotation.before, rotation.after, 'only the tracks turn; arrows and pieces stay put'); assert.equal(rotation.moves, 2, 'rotation is free'); advance(5000); assert.match(get('board-toast').textContent, /出口打开了/); assert.equal(get('board-toast').classList.contains('visible'), false, 'rotation feedback keeps the ordinary toast timeout'); clickTile(third, 'north'); clickTile(third, 'south'); assert.deepEqual(actions.slice(-2).map(action => action.status), ['exit', 'exit']); assert.equal(remaining(), 0); advance(30000); assert.equal(get('result').open, true); assert.deepEqual(read(teachingKey), {current: 2, completed: [0, 1, 2]}); assert.equal(read(officialKey), null, 'all three lessons are separate from formal completion'); assert.ok(writes.every(key => key === teachingKey)); assert.match(get('result').innerHTML, /开始第 1 关/); get('next-level').click(); assert.equal(get('level-eyebrow').textContent, 'LEVEL 01'); assert.equal(remaining(), campaign.levels[0].tiles.length); assert.deepEqual(read(officialKey), {current: 0, completed: []}); assert.equal(get('progress-label').textContent, '0 / 1000'); assert.equal(get('result').open, false); }); }); test('replaying lesson 3 leaves an existing level 20 save intact and never completes level 20', async () => { const previous = {current: 19, completed: [0, 1]}, previousRaw = JSON.stringify(previous); await withApp({search: '?campaign=v0.5&tutorial=3', entries: {[officialKey]: previousRaw}}, ({get, advance, clickTile, slots, read}) => { const third = campaign.tutorials[2]; assert.equal(get('level-eyebrow').textContent, '入门 3 / 3'); assert.equal(slots.get(officialKey), previousRaw); for (let replay = 0; replay < 2; replay += 1) { get('rotate-left').click(); advance(1000); clickTile(third, 'north'); clickTile(third, 'south'); advance(30000); assert.equal(get('result').open, true); assert.equal(slots.get(officialKey), previousRaw, 'finishing or replaying a lesson does not save the current official level as completed'); assert.equal(get('progress-label').textContent, '2 / 1000'); if (!replay) { get('replay-level').click(); assert.equal(get('level-eyebrow').textContent, '入门 3 / 3'); assert.equal(Number(get('moves').textContent), 2); assert.equal(slots.get(officialKey), previousRaw); } } assert.deepEqual(read(teachingKey), {current: 2, completed: [2]}); get('next-level').click(); assert.equal(get('level-eyebrow').textContent, 'LEVEL 01'); assert.deepEqual(read(officialKey), {current: 0, completed: [0, 1]}); assert.equal(read(officialKey).completed.includes(19), false); }); }); test('the map can reopen a tutorial from an explicit official level without changing official progress', async () => { const previous = {current: 19, completed: [0, 1]}, previousRaw = JSON.stringify(previous); await withApp({search: '?campaign=v0.5&level=20', entries: {[officialKey]: previousRaw}}, ({get, advance, clickTile, slots, read}) => { assert.equal(get('level-eyebrow').textContent, 'LEVEL 20'); get('map-button').click(); assert.equal(get('info-dialog').open, true); for (let index = 0; index < 3; index += 1) assert.ok(get('dialog-content').innerHTML.includes(`data-tutorial-pick="${index}"`)); get('dialog-content').emit('click', {target: {closest: selector => selector === '[data-tutorial-pick]' ? {dataset: {tutorialPick: '1'}} : null}}); assert.equal(get('info-dialog').open, false); assert.equal(get('level-eyebrow').textContent, '入门 2 / 3'); assert.equal(slots.get(officialKey), previousRaw); clickTile(campaign.tutorials[1], 'gate'); clickTile(campaign.tutorials[1], 'traveler'); advance(30000); assert.equal(get('result').open, true); assert.deepEqual(read(teachingKey), {current: 2, completed: [1]}); assert.equal(slots.get(officialKey), previousRaw); }); }); test('switching editions into the introduction remembers the edition across reloads', async () => { let reloadedEntries; await withApp({search: '', entries: { 'hex-trails-campaign-version': 'v0.3', 'hex-trails-campaign-v0.3': JSON.stringify({current: 6, completed: [0]}), }}, ({get, slots, read}) => { assert.equal(get('level-eyebrow').textContent, 'LEVEL 07'); get('map-button').click(); get('dialog-content').emit('change', {target: {id: 'campaign-version', value: 'v0.5'}}); assert.equal(get('level-eyebrow').textContent, '入门 1 / 3'); assert.equal(slots.get('hex-trails-campaign-version'), 'v0.5'); assert.equal(read(officialKey), null, 'selecting the edition does not create official progress'); assert.deepEqual(read('hex-trails-campaign-v0.3'), {current: 6, completed: [0]}); reloadedEntries = Object.fromEntries(slots); }); await withApp({search: '', entries: reloadedEntries}, ({get, slots}) => { assert.equal(get('level-eyebrow').textContent, '入门 1 / 3'); assert.equal(slots.get('hex-trails-campaign-version'), 'v0.5'); }); }); test('entering the workshop from an introductory lesson uses a supported rotatable campaign board', async () => { await withApp({search: '?campaign=v0.5&tutorial=1'}, ({get, actions}) => { assert.equal(get('level-eyebrow').textContent, '入门 1 / 3'); assert.equal(get('rotate-right').disabled, true); get('edit-tab').click(); assert.equal(get('level-eyebrow').textContent, 'YOUR CREATION'); const expectedRadius = Math.max(...campaign.levels[0].cells.map(cell => Math.max(Math.abs(cell.q), Math.abs(cell.r), Math.abs(cell.q + cell.r)))); assert.equal(get('editor-radius').value, String(expectedRadius)); assert.ok(expectedRadius >= 2 && expectedRadius <= 6, 'the editor can represent the selected board size'); assert.equal(Number(get('editor-moves').value), campaign.levels[0].moveLimit); get('trial-button').click(); assert.equal(get('level-eyebrow').textContent, 'PLAYTEST'); assert.equal(Number(get('moves').textContent), campaign.levels[0].moveLimit); assert.equal(get('rotate-right').disabled, false, 'the tutorial rotation lock is not inherited by workshop playtests'); get('rotate-right').click(); assert.equal(actions.at(-1).status, 'rotated'); }); });