import test from 'node:test'; import assert from 'node:assert/strict'; import { Game, clone, hexagon, rotateBoard, rotateTracks, trace } from '../src/core.js'; import { countBends, generateExperienceLevel } from '../src/experience-generator.js'; import { assessLevel, canonicalLevelSignature, experienceSignature } from '../src/campaign-evidence.js'; const tile = (id, q, r, dir = 0) => ({ id, q, r, dir, color: 0 }); const level = (cells, tiles) => ({ version: 1, name: 'Evidence fixture', cells, tiles, moveLimit: tiles.length }); const loopFixture = () => { const bends = [{ q: 0, r: 0, tracks: [3, 2, 1, 0, 5, 4] }, { q: 1, r: -1, tracks: [1, 0, 5, 4, 3, 2] }, { q: 1, r: 0, tracks: [5, 2, 1, 4, 3, 0] }]; return level(hexagon(1).map(cell => bends.find(bend => bend.q === cell.q && bend.r === cell.r) || cell), [tile('hero', 0, 0, 1)]); }; const tourFixture = () => generateExperienceLevel(9400010, { radius: 2, target: 4, allFree: true, maxBends: 9, minHeroBends: 6, clearBlockers: true, }); const contrastFixture = () => { const tracks = [[5, 2, 1, 4, 3, 0], [1, 0, 3, 2, 5, 4], [1, 0, 3, 2, 5, 4], [1, 0, 3, 2, 5, 4], [5, 2, 1, 4, 3, 0], [2, 4, 0, 5, 1, 3], [4, 5, 3, 2, 0, 1]]; return { ...level(hexagon(1).map((cell, index) => ({ ...cell, tracks: tracks[index] })), [tile('t0', 1, 0, 0), tile('t1', -1, 0, 2), tile('t2', 0, 1, 3)]), experience: { feature: { id: 't1' } } }; }; function replayAndCompare(input, evidence) { const game = new Game(input), actual = []; for (const [index, action] of evidence.actions.entries()) { if (action.type === 'rotate') assert.equal(game.rotate(action.direction).status, 'rotated'); else { const result = game.tap(action.id); assert.equal(result.status, 'exit'); actual.push({ id: action.id, action: index, orientation: game.orientation, bends: countBends(result), cells: result.steps.length }); } } assert.equal(evidence.won, game.won); assert.deepEqual(evidence.paths.map(({ stage, ...path }) => path), actual); } test('a long all-free tour has low structural difficulty and separately reported viewing work', () => { const input = tourFixture(), result = assessLevel(input); assert.equal(result.solvable, true); assert.equal(result.exhausted, false); assert.ok(result.reference.maxBends >= 6); assert.equal(result.difficulty.tier, 1); assert.equal(result.difficulty.score, 0); assert.equal(result.difficulty.basis, 'structural-estimate'); assert.equal(result.difficulty.playerCalibrated, false); assert.ok(result.workload.routeCells > input.tiles.length); assert.equal(result.workload.animationSeconds, null); assert.equal(result.reward.longExposure.total, 6); assert.equal(result.reward.longExposure.seen, 6); }); test('the independent minimum-turn solution and all six strategies execute under Game rules', () => { const input = loopFixture(), original = clone(input); assert.equal(trace(input, input.tiles[0]).status, 'loop'); const result = assessLevel(input, { actions: [{ type: 'rotate', direction: 1 }, { type: 'tap', id: 'hero' }] }); assert.equal(result.solvable, true); assert.equal(result.difficulty.minimumRotations, 1); assert.equal(result.policies.length, 6); assert.equal(new Set(result.policies.map(policy => policy.name)).size, 6); for (const replay of [result.reference, result.solverReference, ...result.policies]) { assert.equal(replay.won, true); assert.ok(replay.actions.length <= input.tiles.length * 8 + 12); replayAndCompare(input, replay); } assert.deepEqual(input, original); }); test('deduplication preserves whole-level rotations/reflections but distinguishes rotating only the base', () => { const input = tourFixture(), signature = canonicalLevelSignature(input); let turned = clone(input); for (let n = 0; n < 6; n++) { turned = { ...turned, cells: turned.cells.map(cell => ({ ...cell, q: -cell.r || 0, r: cell.q + cell.r || 0, tracks: rotateTracks(cell.tracks) })), tiles: turned.tiles.map(item => ({ ...item, q: -item.r || 0, r: item.q + item.r || 0, dir: (item.dir + 1) % 6 })), }; assert.equal(canonicalLevelSignature(turned), signature); } const reflected = { ...input, cells: input.cells.map(cell => { const tracks = []; cell.tracks.forEach((partner, port) => { tracks[(6 - port) % 6] = (6 - partner) % 6; }); return { ...cell, q: -cell.q || 0, r: cell.q + cell.r || 0, tracks }; }), tiles: input.tiles.map(item => ({ ...item, q: -item.q || 0, r: item.q + item.r || 0, dir: (6 - item.dir) % 6 })), }; assert.equal(canonicalLevelSignature(reflected), signature); assert.notEqual(canonicalLevelSignature(rotateBoard(input)), signature); const cosmetic = { ...clone(input), name: 'Another title', id: 'another-id' }; cosmetic.cells.reverse(); cosmetic.tiles.reverse(); cosmetic.tiles.forEach((item, index) => { item.id = `renamed-${index}`; item.color = 9; }); assert.equal(canonicalLevelSignature(cosmetic), signature); cosmetic.rotationEnabled = false; assert.notEqual(canonicalLevelSignature(cosmetic), signature); }); test('an authored one-turn inward reveal reports policies that safely bypass that reveal', () => { const input = contrastFixture(); const actions = [{ type: 'rotate', direction: 1 }, { type: 'tap', id: 't1' }, { type: 'tap', id: 't2' }, { type: 'tap', id: 't0' }]; const result = assessLevel(input, { actions }); assert.equal(result.reference.won, true); assert.ok(result.reference.reveals.some(event => event.id === 't1')); assert.ok(result.reward.contrastBypasses.includes('outer-first')); assert.ok(result.reward.contrastExposure.seen < result.reward.contrastExposure.total); assert.equal(result.difficulty.minimumRotations, 0); assert.deepEqual(result.referenceActions, actions); assert.notDeepEqual(result.referenceActions, result.solverActions); assert.equal(result.reward.heroContrastExposure.seen, 0, 'A short inward reveal must not count as a long hero reward'); for (const replay of result.policies) replayAndCompare(input, replay); }); test('another piece\'s long inward reveal cannot satisfy the designated hero contrast', () => { const input = generateExperienceLevel(9421815, { radius: 2, target: 5, allFree: false, minHeroBends: 6, maxBends: 9, clearBlockers: false, startRotation: 1, }); const actions = [{ type: 'rotate', direction: -1 }, ...['t4', 't3', 't2', 't1', 't0'].map(id => ({ type: 'tap', id }))]; input.experience = { feature: { id: 't2' } }; const other = assessLevel(input, { actions }); assert.ok(other.reference.paths.find(path => path.id === 't2').bends >= 6, 'The designated hero itself still has a long route'); assert.ok(other.reward.contrastExposure.seen > 0, 'The other piece t4 actually receives an inward reveal'); assert.equal(other.reward.contrastExposure.scope, 'any-reference-contrast-piece'); assert.equal(other.reward.heroContrastExposure.seen, 0); assert.equal(other.reward.heroContrastExposure.referenceEligible, false); input.experience.feature.id = 't4'; const designated = assessLevel(input, { actions }); assert.equal(designated.reward.heroContrastExposure.seen, 6); assert.equal(designated.reward.heroContrastExposure.referenceEligible, true); }); test('hero gate exposure records a fulfilled long route opened by a blocker on that route', () => { const input = generateExperienceLevel(9440081, { radius: 3, target: 7, allFree: false, minHeroBends: 8, maxBends: 14, clearBlockers: true, startRotation: 0, }); input.experience = { feature: { id: 't1' } }; const actions = ['t2', 't3', 't4', 't5', 't6', 't0', 't1'].map(id => ({ type: 'tap', id })); const result = assessLevel(input, { actions }); assert.equal(result.reward.heroGateExposure.referenceEligible, true); assert.equal(result.reward.heroGateExposure.seen, 2); assert.equal(result.reward.heroGateExposure.thresholdBends, 6); assert.ok(result.policies.find(policy => policy.name === 'long-first').fulfilledUnlocks.some(event => event.id === 't1' && event.bends >= 6 && !event.onRoute), 'A side-graze release exists but must not count as a gate on the route'); for (const policy of result.policies) { for (const event of policy.fulfilledUnlocks.filter(event => event.id === 't1' && event.onRoute && event.bends >= 6)) { const path = policy.paths.find(path => path.id === 't1'); assert.equal(path.orientation, event.orientation); assert.equal(path.bends, event.bends); assert.ok(path.action > event.action); } } }); test('unsolvable loops, insufficient allowances, and invalid supplied actions are not publishable replays', () => { const loop = { ...loopFixture(), rotationEnabled: false }; const stuck = assessLevel(loop); assert.equal(stuck.solvable, false); assert.equal(stuck.exhausted, false); assert.ok(stuck.policies.every(policy => !policy.won && policy.stopped === 'no-available-route')); const short = tourFixture(); short.moveLimit = 1; const shortage = assessLevel(short); assert.equal(shortage.solvable, false); assert.ok(shortage.policies.every(policy => !policy.won)); const badReference = assessLevel(tourFixture(), { actions: [{ type: 'tap', id: 'missing' }] }); assert.equal(badReference.solvable, true, 'The independent solver can still prove that the level has a valid solution'); assert.equal(badReference.reference.valid, false); assert.equal(badReference.reference.won, false); assert.equal(badReference.solverReference.won, true); assert.equal(assessLevel({}).valid, false); }); test('policy replay and coarse pacing signatures are deterministic, with no claim of exhaustive coverage', () => { const input = contrastFixture(), a = assessLevel(input, { policyRuns: 8 }), b = assessLevel(input, { policyRuns: 8 }); assert.deepEqual(a, b); assert.equal(experienceSignature(a), experienceSignature(b)); assert.equal(a.policies.length, 8); assert.equal(a.reward.coverage, 'bounded-policies-not-exhaustive-player-behavior'); assert.notEqual(experienceSignature(a), experienceSignature(assessLevel(tourFixture()))); const noSample = assessLevel(input, { policyRuns: 0 }); assert.equal(noSample.reward.longExposure.rate, null); assert.throws(() => assessLevel(input, { policyRuns: -1 }), RangeError); });