import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { EXPERIENCE_LEVELS as levels } from '../src/levels-v04.js'; import { generateExperienceLevel, inspectExperience, pointsInward, countBends } from '../src/experience-generator.js'; import { Game, analyze, available, random, rotateBoard, trace, validateLevel } from '../src/core.js'; import { campaignVersion } from '../src/campaign-versions.js'; import { readCampaignProgress, writeCampaignProgress } from '../src/campaign-progress.js'; const root = new URL('../', import.meta.url); const manifest = JSON.parse(await readFile(new URL('levels/v0.4-seeds.json', root), 'utf8')); const report = JSON.parse(await readFile(new URL('levels/v0.4-analysis.json', root), 'utf8')); test('v0.4 has 20 new reproducible layouts and matching published replay evidence', async () => { assert.equal(levels.length, 20); assert.equal(new Set(levels.map(l => l.id)).size, 20); assert.deepEqual(levels, JSON.parse(await readFile(new URL('levels/v0.4.json', root), 'utf8'))); for (const [i, l] of levels.entries()) { assert.deepEqual(validateLevel(l), [], l.name); const p = manifest.seeds[i], generated = generateExperienceLevel(p.seed, p.options), a = analyze(l); assert.deepEqual(l.cells, generated.cells, l.name); assert.deepEqual(l.tiles, generated.tiles, l.name); assert.equal(l.tiles.length, p.options.target, l.name); assert.equal(l.moveLimit, l.tiles.length, l.name); assert.ok(a.solvable && !a.exhausted, l.name); assert.equal(a.rotations, p.targets.rotations, l.name); assert.deepEqual(inspectExperience(l, a.actions), report.levels[i].reference, l.name); assert.deepEqual(inspectExperience(l, l.experience.actions), report.levels[i].experience, l.name); } }); test('every v0.4 level pays out actual long journeys in both complete plans', () => { for (const [i, l] of levels.entries()) { for (const actions of [analyze(l).actions, l.experience.actions]) { const game = new Game(l), journeys = []; for (const action of actions) { const moves = game.moves; if (action.type === 'rotate') { assert.equal(game.rotate(action.direction).status, 'rotated'); assert.equal(game.moves, moves); assert.ok(available(game.level, game.tiles).length, l.name); } else { const result = game.tap(action.id); assert.equal(result.status, 'exit', l.name); journeys.push(countBends(result)); assert.equal(game.moves, moves - 1); } } assert.ok(game.won, l.name); assert.equal(game.moves, 0, l.name); assert.ok(Math.max(...journeys) >= manifest.seeds[i].targets.minMaxBends, l.name); assert.ok(journeys.filter(n => n >= 6).length >= manifest.seeds[i].targets.minLongPaths, l.name); } } }); test('contrast pieces point inward and one opening turn frees them without prerequisite removals', () => { const contrasts = levels.filter(l => l.experience.kind === 'contrast'); assert.equal(contrasts.length, 5); for (const l of contrasts) { const { feature } = l.experience, game = new Game(l), tile = game.tiles.find(t => t.id === feature.id); assert.ok(pointsInward(tile), l.name); const before = trace(game.level, tile, game.tiles); assert.equal(before.status, 'blocked', l.name); assert.ok(trace(game.level, tile, game.tiles, true).blockers.includes(before.blocker), l.name); const original = JSON.parse(JSON.stringify(game.tiles)); game.tap(tile.id); assert.equal(game.moves, l.tiles.length); assert.deepEqual(game.tiles, original); game.rotate(feature.direction); assert.deepEqual(game.tiles, original, 'Rotating must not move arrows or pieces'); const result = game.tap(tile.id); assert.equal(result.status, 'exit', l.name); assert.equal(countBends(result), feature.bends); assert.ok(game.undo()); assert.deepEqual(game.tiles, original); assert.ok(game.undo()); assert.deepEqual(game.level.cells, l.cells); } }); test('tour levels stay free in any removal order at the opening orientation', () => { for (const l of levels.filter(l => l.experience.kind === 'tour')) { for (const order of [l.tiles, [...l.tiles].reverse()]) { const g = new Game(l); for (const t of order) { assert.equal(available(g.level, g.tiles).length, g.tiles.length, l.name); assert.equal(g.tap(t.id).status, 'exit'); } assert.ok(g.won); } } }); test('short removals release long routes and returning orientations change the available pieces', () => { for (const l of levels.filter(l => l.experience.kind === 'gate')) { const q = inspectExperience(l), f = l.experience.feature; assert.ok(q.unlocks.some(u => u.id === f.id && u.removed === f.removed && u.onRoute && u.bends >= 8), l.name); assert.ok(q.paths.find(p => p.id === f.removed).bends <= 2, l.name); } for (const l of levels.filter(l => l.experience.kind === 'return')) { const q = inspectExperience(l); assert.equal(q.turns.length, 2); assert.equal(q.turns[0].direction, -q.turns[1].direction); assert.ok(q.turns[0].remaining > q.turns[1].remaining); assert.ok(q.turns.every(t => t.opened.length > 0)); } const finale = inspectExperience(levels.at(-1)); assert.ok(finale.paths.slice(-5).filter(p => p.bends >= 10).length >= 3); }); test('alternative rotations, free failed probes and safe removals retain a reachable ending', () => { for (const l of levels) for (let run = 0; run < 6; run++) { const rng = random(l.seed + 1237 * run), g = new Game(l); for (let step = 0; step < l.tiles.length; step++) { for (let turns = Math.floor(rng() * 6); turns > 0; turns--) g.rotate(rng() < .5 ? 1 : -1); const probe = g.tiles.find(t => trace(g.level, t, g.tiles).status !== 'exit'); if (probe) { const before = JSON.stringify(g.tiles), moves = g.moves; g.tap(probe.id); assert.equal(JSON.stringify(g.tiles), before); assert.equal(g.moves, moves); assert.equal(g.lost, false); } let choices = available(g.level, g.tiles); for (let turn = 0; !choices.length && turn < 5; turn++) { g.rotate(1); choices = available(g.level, g.tiles); } assert.ok(choices.length, l.name); assert.equal(g.tap(choices[Math.floor(rng() * choices.length)].id).status, 'exit'); } assert.ok(g.won, l.name); assert.deepEqual(rotateBoard(l, 6).cells, l.cells); } }); test('v0.4 has an independent save slot and never imports v0.3 progress', () => { const items = new Map(), storage = { getItem: k => items.get(k) ?? null, setItem: (k, v) => items.set(k, v) }; const v3 = campaignVersion('v0.3'), v4 = campaignVersion('v0.4'); writeCampaignProgress(storage, v3, { current: 47, completed: [0, 47] }); assert.deepEqual(readCampaignProgress(storage, v4), { current: 0, completed: [] }); writeCampaignProgress(storage, v4, { current: 19, completed: [0, 19] }); assert.deepEqual(readCampaignProgress(storage, v3), { current: 47, completed: [0, 47] }); assert.deepEqual(readCampaignProgress(storage, v4), { current: 19, completed: [0, 19] }); });