feat: add production campaigns, gameplay labs, and review documentation

This commit is contained in:
cabinw
2026-09-13 12:27:10 +08:00
parent d1dffa9338
commit 4644e975b0
214 changed files with 98994 additions and 134 deletions
+112
View File
@@ -0,0 +1,112 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { FLOWER_LEVEL } from '../src/flower-study.js';
import { Game, solveRotations } from '../src/core.js';
import { layout, center } from '../src/geometry.js';
// Exercise actual pointer handlers and animation frames: model-only queue tests
// cannot catch an application that drops clicks before they reach TapQueue.
test('flower controls retain the main game rapid-tap behaviour', async t => {
const globals = ['document', 'performance', 'devicePixelRatio', 'matchMedia', 'Image', 'ResizeObserver', 'requestAnimationFrame', 'cancelAnimationFrame'];
const saved = new Map(globals.map(k => [k, Object.getOwnPropertyDescriptor(globalThis, k)]));
const elements = new Map(), frames = new Map(), actions = [];
let now = 0, frameId = 0;
const rect = { left: 0, top: 0, width: 700, height: 640 };
const view = layout(FLOWER_LEVEL.cells, rect.width, rect.height);
const context = new Proxy({}, { get: (o, k) => o[k] ?? (() => {}), set: (o, k, v) => (o[k] = v, true) });
const get = id => {
if (!elements.has(id)) elements.set(id, {
checked: false, disabled: false, textContent: '',
getBoundingClientRect: () => rect, getContext: () => context,
addEventListener(name, fn) { this[name] = fn; },
click() { if (!this.disabled) this.onclick?.(); },
});
return elements.get(id);
};
const originalTap = Game.prototype.tap;
const clickTile = id => {
const p = center(FLOWER_LEVEL.tiles.find(t => t.id === id), view);
get('board').pointerup({ clientX: p.x, clientY: p.y });
};
const advance = () => {
now += 30000;
for (const [id, callback] of [...frames]) { frames.delete(id); callback(now); }
};
const remaining = () => Number(get('remaining').textContent);
const reset = () => { get('reset').click(); actions.length = 0; };
try {
const replacements = {
document: { getElementById: get, addEventListener() {} },
performance: { now: () => now }, devicePixelRatio: 1, matchMedia: () => ({ matches: false }),
Image: class {}, ResizeObserver: class { constructor(callback) { this.callback = callback; } observe() { this.callback(); } },
requestAnimationFrame: callback => { frames.set(++frameId, callback); return frameId; },
cancelAnimationFrame: id => frames.delete(id),
};
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({ id, status: result.status });
return result;
};
await import('../labs/flower/app.js');
await t.test('a rapid burst runs in click order against the latest board and ignores repeat clicks', () => {
const firstWave = solveRotations(FLOWER_LEVEL).actions.slice(0, 8).map(a => a.id);
for (const id of firstWave) { clickTile(id); clickTile(id); }
assert.equal(remaining(), 9, 'first departure starts immediately');
assert.match(get('progress').textContent, /待出发 7/);
assert.equal(get('pattern-only').disabled, true);
for (let i = 0; i < firstWave.length; i++) advance();
assert.deepEqual(actions, firstWave.map(id => ({ id, status: 'exit' })));
assert.equal(remaining(), 2, 'dependent departures re-evaluate after preceding pieces leave');
assert.doesNotMatch(get('progress').textContent, /待出发/);
get('undo').click();
assert.equal(remaining(), 3);
advance();
assert.equal(remaining(), 3, 'undo does not replay an old queued click');
});
await t.test('a queued collision cancels later clicks and leaves failed probes undoable', () => {
reset();
for (const id of ['petal-0', 'petal-1', 'petal-2']) clickTile(id);
advance();
assert.deepEqual(actions, [{ id: 'petal-0', status: 'exit' }, { id: 'petal-1', status: 'blocked' }]);
assert.doesNotMatch(get('progress').textContent, /待出发/);
advance();
assert.equal(remaining(), 9);
get('undo').click();
assert.equal(remaining(), 9, 'undo the failed probe first');
get('undo').click();
assert.equal(remaining(), 10);
});
await t.test('reset discards animation and pending input without leaking into the restarted level', () => {
reset();
for (const id of ['petal-0', 'petal-2', 'bloom-2']) clickTile(id);
reset(); advance();
assert.equal(remaining(), 10);
assert.deepEqual(actions, []);
assert.doesNotMatch(get('progress').textContent, /待出发/);
});
await t.test('clicks during a board turn wait for it to finish and then dispatch normally', () => {
reset();
get('right').click();
clickTile('petal-0'); clickTile('petal-2');
assert.equal(remaining(), 10);
assert.match(get('progress').textContent, /待出发 2/);
advance();
assert.equal(remaining(), 9);
advance(); advance();
assert.equal(remaining(), 8);
assert.deepEqual(actions, [{ id: 'petal-0', status: 'exit' }, { id: 'petal-2', status: 'exit' }]);
assert.equal(get('right').disabled, false);
});
} finally {
Game.prototype.tap = originalTap;
for (const [key, descriptor] of saved) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete globalThis[key];
}
}
});