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.`); } } } });