feat: add production campaigns, gameplay labs, and review documentation
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user