Files
HexTrails/tests/campaign-evidence.test.js
T

185 lines
10 KiB
JavaScript

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