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
+118
View File
@@ -0,0 +1,118 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { available, canRotate, Game, opposite, rotateBoard, solveRotations, trace, validateLevel } from '../src/core.js';
import { TUTORIAL_LEVELS } from '../src/tutorial-levels.js';
const bends = route => route.steps.filter(step => step.enter !== null && opposite(step.enter) !== step.exit).length;
function assertSimpleCurvedExit(route) {
assert.equal(route.status, 'exit');
assert.equal(bends(route), 5, 'The short teaching journey has five visible turns.');
assert.equal(new Set(route.steps.map(step => `${step.q},${step.r}`)).size, route.steps.length, 'The route never revisits a cell.');
}
test('three tutorials retain complete 19-cell hexagons and replay through the real Game', () => {
assert.deepEqual(TUTORIAL_LEVELS.map(level => level.id), ['trail-tutorial-01', 'trail-tutorial-02', 'trail-tutorial-03']);
assert.deepEqual(TUTORIAL_LEVELS.map(level => level.cells.length), [19, 19, 19]);
assert.deepEqual(TUTORIAL_LEVELS.map(level => level.tiles.length), [1, 2, 2]);
const expectedCoordinates = [
'-2,0', '-2,1', '-2,2',
'-1,-1', '-1,0', '-1,1', '-1,2',
'0,-2', '0,-1', '0,0', '0,1', '0,2',
'1,-2', '1,-1', '1,0', '1,1',
'2,-2', '2,-1', '2,0',
].sort();
for (const [index, level] of TUTORIAL_LEVELS.entries()) {
assert.deepEqual(validateLevel(level), []);
assert.deepEqual(level.cells.map(cell => `${cell.q},${cell.r}`).sort(), expectedCoordinates, 'Teaching must preserve the whole radius-two board, including every corner.');
assert.equal(canRotate(level), true, 'The complete board geometry stays rotation-symmetric even when lesson controls disable turning.');
assert.equal(level.tutorial.step, index + 1);
assert.equal(level.lesson, level.tutorial.lesson);
assert.ok(level.tutorial.completion);
const solved = solveRotations(level);
assert.equal(solved.solvable, true);
assert.equal(solved.rotations, index === 2 ? 1 : 0);
const game = new Game(level);
for (const action of level.solution) {
const result = action.type === 'tap' ? game.tap(action.id) : game.rotate(action.direction);
assert.equal(result.status, action.type === 'tap' ? 'exit' : 'rotated');
}
assert.equal(game.won, true);
assert.equal(game.moves, 0);
assert.equal(game.mistakes, 0);
}
});
test('the first lesson needs exactly one tap and follows a short visible curved route', () => {
const level = TUTORIAL_LEVELS[0];
assert.equal(level.rotationEnabled, false);
assert.deepEqual(available(level).map(tile => tile.id), ['traveler']);
assertSimpleCurvedExit(trace(level, level.tiles[0]));
const game = new Game(level);
assert.equal(game.rotate().status, 'rotation-disabled');
assert.equal(game.tap('traveler').status, 'exit');
assert.equal(game.won, true);
});
test('the second lesson visibly blocks the upper piece until the lower piece leaves', () => {
const level = TUTORIAL_LEVELS[1];
assert.equal(level.rotationEnabled, false);
assert.deepEqual(level.cells, TUTORIAL_LEVELS[0].cells, 'The second lesson adds one blocker to a familiar route.');
assert.deepEqual(available(level).map(tile => tile.id), ['gate']);
const blocked = trace(level, level.tiles[0]);
assert.equal(blocked.status, 'blocked');
assert.equal(blocked.blocker, 'gate');
assert.ok(trace(level, level.tiles[0], level.tiles, true).blockers.includes('gate'), 'The blocker is on the actual track, not an invisible swept-only collision.');
const game = new Game(level);
assert.equal(game.tap('traveler').status, 'blocked');
assert.equal(game.moves, 2);
assert.equal(game.tiles.length, 2);
assert.equal(game.lost, false);
assert.equal(game.tap('gate').status, 'exit');
assertSimpleCurvedExit(game.tap('traveler'));
assert.equal(game.won, true, 'Both taps are accepted consecutively without waiting for an animation.');
});
test('the third lesson opens both five-bend paths with either single rotation', () => {
const level = TUTORIAL_LEVELS[2];
assert.equal(level.rotationEnabled, true);
assert.equal(canRotate(level), true);
assert.deepEqual(available(level), []);
assert.equal(solveRotations(level).rotations, 1);
for (const tile of level.tiles) {
const route = trace(level, tile);
assert.equal(route.status, 'blocked');
assert.equal(route.blocker, level.tiles.find(other => other.id !== tile.id).id);
assert.ok(trace(level, tile, level.tiles, true).blockers.includes(route.blocker));
}
for (const direction of [-1, 1]) {
const board = rotateBoard(level, direction);
assert.equal(available(board).length, 2);
for (const tile of level.tiles) assertSimpleCurvedExit(trace(board, tile));
for (const ids of [['north', 'south'], ['south', 'north']]) {
const game = new Game(level);
assert.equal(game.lost, false, 'The initial mutual block permits a rotation.');
assert.equal(game.rotate(direction).status, 'rotated');
assert.deepEqual(game.tiles, level.tiles, 'Only the board turns; positions and arrows stay fixed.');
assert.equal(game.moves, 2, 'Rotation does not spend a successful-departure allowance.');
for (const id of ids) assertSimpleCurvedExit(game.tap(id));
assert.equal(game.won, true);
}
}
});
test('every teaching cell uses complete, non-crossing track pairs without emphasis metadata', () => {
for (const level of TUTORIAL_LEVELS) {
assert.equal(level.routeDesign, undefined);
assert.equal(level.featuredTrackEmphasis, undefined);
for (const cell of level.cells) {
const pairs = cell.tracks.flatMap((b, a) => a < b ? [[a, b]] : []);
assert.equal(pairs.length, 3);
for (const [a, b] of pairs) for (const [c, d] of pairs) {
if (a === c) continue;
const inside = port => a < port && port < b;
assert.equal(inside(c), inside(d), `${level.id} cell ${cell.q},${cell.r}: paired rails must not cross.`);
}
}
}
});