feat: add production campaigns, gameplay labs, and review documentation
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
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] });
|
||||
});
|
||||
Reference in New Issue
Block a user