feat: add production campaigns, gameplay labs, and review documentation
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ART_DIRECTIONS, directionTheme, artDirection, appearanceTile } from '../src/art-directions.js';
|
||||
import { contrast } from '../src/design-tokens.js';
|
||||
import { drawScene, drawTile, drawExit } from '../src/renderer.js';
|
||||
import { createStudyLevel } from '../src/study-levels.js';
|
||||
import { available, trace } from '../src/core.js';
|
||||
import { layout } from '../src/geometry.js';
|
||||
test('each of four art directions has five readable themes and independent token colors',()=>{
|
||||
assert.equal(ART_DIRECTIONS.length,4);assert.equal(new Set(ART_DIRECTIONS.map(d=>d.id)).size,4);
|
||||
for(const d of ART_DIRECTIONS){
|
||||
assert.deepEqual(d.themes.map(t=>t.label),['轻松','进阶','烧脑','挑战','每周挑战']);
|
||||
for(const t of d.themes){assert.ok(contrast(t.background,t.ink)>=4.5);assert.equal(directionTheme(d,t.id).artStyle,d.style);}
|
||||
for(const p of d.pigments)assert.ok(contrast(p.color,p.arrow)>=3);
|
||||
}
|
||||
assert.equal(artDirection('invalid').id,'calm');
|
||||
});
|
||||
test('art styles change actual geometry without changing level state, including moving tokens',()=>{
|
||||
const level=createStudyLevel(3),copy=JSON.stringify(level),view=layout(level.cells,520,600),tile=available(level,level.tiles)[0],route=trace(level,tile,level.tiles);
|
||||
const signatures=[];
|
||||
for(const d of ART_DIRECTIONS){
|
||||
const commands=[],p={polygon:(points,color)=>{assert.ok(color);assert.ok(points.every(p=>Number.isFinite(p.x)&&Number.isFinite(p.y)));commands.push(['polygon',points.length,color]);},circle:(x,y,r,color)=>{assert.ok(Number.isFinite(r)&&r>=0);commands.push(['circle',r,color]);},line:(points,color,width)=>{assert.ok(width>0);commands.push(['line',points.length,color]);}};
|
||||
const theme=directionTheme(d,'lagoon'),t=appearanceTile({color:0,dir:1},theme);
|
||||
drawTile(p,50,50,40,t);signatures.push(JSON.stringify(commands));
|
||||
drawScene(p,level,level.tiles,view,{theme});drawExit(p,route,view,.4,t);drawExit(p,route,view,.99,t);
|
||||
}
|
||||
assert.equal(new Set(signatures).size,4);assert.equal(JSON.stringify(level),copy);
|
||||
});
|
||||
test('vivid worlds retain strong chroma and each new theme has its own seven-color mix',()=>{
|
||||
const vivid=artDirection('vivid');
|
||||
assert.equal(vivid.themes[0].background,'#B8F34A');
|
||||
for(const t of vivid.themes){
|
||||
const rgb=t.background.slice(1).match(/../g).map(v=>parseInt(v,16)/255),hi=Math.max(...rgb),lo=Math.min(...rgb),l=(hi+lo)/2,s=(hi-lo)/(1-Math.abs(2*l-1));
|
||||
assert.ok(s>=.65&&l<=.65,`${t.name} should keep the saturated poster treatment`);
|
||||
}
|
||||
for(const d of ART_DIRECTIONS.slice(1)){
|
||||
assert.equal(new Set(d.themes.map(t=>directionTheme(d,t.id).palette.slice().sort((a,b)=>a-b).join(','))).size,5);
|
||||
assert.equal(new Set(d.themes.map(t=>t.materials.board)).size,5);
|
||||
for(const t of d.themes)assert.ok(contrast(t.materials.track,t.materials.board)>=3);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { campaignLaunch, campaignPage, levelIndexFromInput, productionPresentation, createAnalysisCache, featuredTrackEmphasis } from '../src/campaign-browser.js';
|
||||
import { hexagon, rotateBoard } from '../src/core.js';
|
||||
|
||||
const small={id:'old',chapterSize:8,chapters:['旧章'],levels:Array.from({length:8},(_,i)=>({id:`old-${i}`}))};
|
||||
const campaign={id:'v0.5',chapterSize:50,chapters:Array.from({length:20},(_,i)=>`旅程 ${i+1}`),levels:Array.from({length:1000},(_,i)=>({id:`new-${i}`}))};
|
||||
|
||||
test('campaign URLs locate an exact level without mixing progress or accepting invalid indices',()=>{
|
||||
const editions=[small,campaign];
|
||||
assert.deepEqual(campaignLaunch('?campaign=v0.5&level=1000',editions,small),{campaign,levelIndex:999});
|
||||
assert.deepEqual(campaignLaunch('?campaign=unknown&level=8',editions,small),{campaign:small,levelIndex:7});
|
||||
for(const level of ['0','1001','2.5','Infinity','-1','1e2',''])assert.equal(campaignLaunch(`?campaign=v0.5&level=${level}`,editions,small).levelIndex,null);
|
||||
assert.equal(campaignLaunch('',editions,small).campaign,small);
|
||||
});
|
||||
|
||||
test('a thousand-level campaign renders only the requested chapter, including its last level',()=>{
|
||||
const first=campaignPage(campaign),last=campaignPage(campaign,19);
|
||||
assert.deepEqual([first.start,first.end,first.levels.length],[0,50,50]);
|
||||
assert.deepEqual([last.start,last.end,last.levels.length],[950,1000,50]);
|
||||
assert.equal(last.levels.at(-1).index,999);
|
||||
assert.equal(campaignPage(campaign,999).chapter,19);
|
||||
assert.equal(campaignPage(campaign,-1).chapter,0);
|
||||
assert.equal(campaignPage(small).levels.length,8);
|
||||
});
|
||||
|
||||
test('direct level entry accepts whole level numbers within this campaign only',()=>{
|
||||
assert.equal(levelIndexFromInput(' 1000 ',1000),999);
|
||||
for(const value of ['0','1001','2.2','1e2','a',''])assert.equal(levelIndexFromInput(value,1000),null);
|
||||
});
|
||||
|
||||
test('production difficulty and journey rewards remain separate, with no old route-length score',()=>{
|
||||
const info=productionPresentation({beat:'花瓣巡游',lesson:'放出整片花瓣',production:{difficulty:{tier:1,label:'轻松',basis:'structural-estimate'},reward:{longestSteps:59,longestBends:37,longRoutes:4}}});
|
||||
assert.equal(info.label,'轻松');
|
||||
assert.equal(info.themeScore,12);
|
||||
assert.equal(info.reward,'最长巡游 59 格 · 37 次转弯 · 4 条长路');
|
||||
assert.match(info.estimate,/结构预估/);
|
||||
assert.equal(productionPresentation({}),null);
|
||||
});
|
||||
|
||||
test('analysis is reused for an immutable layout while distinct or edited layouts are reanalyzed',()=>{
|
||||
let calls=0;const getAnalysis=createAnalysisCache(level=>({tiles:level.tiles.length,call:++calls}));
|
||||
const level={tiles:[{}]},copy={tiles:[{}]};
|
||||
const first=getAnalysis(level);assert.equal(getAnalysis(level),first);assert.equal(calls,1);
|
||||
assert.equal(getAnalysis(copy).call,2);
|
||||
});
|
||||
|
||||
test('featured route drawing follows the board through all rotations and never highlights a different pair',()=>{
|
||||
const level={cells:hexagon(2),routeDesign:{featured:[{q:1,r:-1,a:0,b:3}]}};
|
||||
for(let turn=0;turn<6;turn++){
|
||||
const board=rotateBoard(level,turn),emphasis=featuredTrackEmphasis(level,turn);
|
||||
const highlighted=[];
|
||||
for(const cell of board.cells)for(let a=0;a<6;a++)if(a<cell.tracks[a]&&emphasis(cell,a,cell.tracks[a]))highlighted.push({cell,a,b:cell.tracks[a]});
|
||||
assert.equal(highlighted.length,1);
|
||||
const {cell,a,b}=highlighted[0];assert.equal(emphasis(cell,b,a),true);
|
||||
assert.equal(emphasis(cell,a,(b+1)%6),false);
|
||||
assert.equal(featuredTrackEmphasis(level,turn+6),emphasis);
|
||||
}
|
||||
assert.equal(featuredTrackEmphasis({}),null);
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
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);
|
||||
});
|
||||
@@ -12,9 +12,9 @@ const storage=entries=>{
|
||||
return {getItem:key=>items.get(key)??null,setItem:(key,value)=>items.set(key,value)};
|
||||
};
|
||||
|
||||
test('three selectable editions retain their own level counts, order and chapters',()=>{
|
||||
test('five selectable editions retain their own level counts, order and chapters',()=>{
|
||||
assert.deepEqual(CAMPAIGN_VERSIONS.map(c=>[c.id,c.levels.length,c.chapterSize,c.chapters.length]),[
|
||||
['v0.1',24,8,3],['v0.2',48,6,8],['v0.3',48,6,8],
|
||||
['v0.1',24,8,3],['v0.2',48,6,8],['v0.3',48,6,8],['v0.4',20,5,4],['v0.5',1000,50,20],
|
||||
]);
|
||||
assert.equal(campaignVersion('v0.1').levels[0].name,'转一转,路就通了');
|
||||
assert.equal(campaignVersion('v0.1').levels.at(-1).name,'六角大满贯');
|
||||
@@ -34,8 +34,8 @@ test('browser archive module matches the exact historical JSON bytes and provena
|
||||
}
|
||||
});
|
||||
|
||||
test('all 72 historical layouts solve and replay with the current free rotation rules',()=>{
|
||||
for(const c of CAMPAIGN_VERSIONS.slice(0,2))for(const [i,level] of c.levels.entries()){
|
||||
test('all 120 earlier layouts solve and replay with the current free rotation rules',()=>{
|
||||
for(const c of CAMPAIGN_VERSIONS.slice(0,3))for(const [i,level] of c.levels.entries()){
|
||||
const label=`${c.id} level ${i+1}`;
|
||||
assert.deepEqual(validateLevel(level),[],label);
|
||||
const plan=solveRotations(level),game=new Game(level);
|
||||
@@ -51,12 +51,12 @@ test('all 72 historical layouts solve and replay with the current free rotation
|
||||
}
|
||||
});
|
||||
|
||||
test('new players default to v0.3 while an explicit version choice survives reload',()=>{
|
||||
const s=storage();assert.equal(selectedCampaign(s).id,'v0.3');
|
||||
test('new players default to v0.5 while an explicit version choice survives reload',()=>{
|
||||
const s=storage();assert.equal(selectedCampaign(s).id,'v0.5');
|
||||
assert.ok(writeCampaignProgress(s,campaignVersion('v0.1'),{current:23,completed:[0,22]}));
|
||||
assert.equal(selectedCampaign(s).id,'v0.1');
|
||||
assert.deepEqual(readCampaignProgress(s,selectedCampaign(s)),{current:23,completed:[0,22]});
|
||||
s.setItem(CAMPAIGN_SELECTION_KEY,'v9.9');assert.equal(selectedCampaign(s).id,'v0.3');
|
||||
s.setItem(CAMPAIGN_SELECTION_KEY,'v9.9');assert.equal(selectedCampaign(s).id,'v0.5');
|
||||
});
|
||||
|
||||
test('versions keep progress separate even when v0.2 and v0.3 reuse level IDs',()=>{
|
||||
@@ -77,7 +77,7 @@ test('each edition migrates only its own legacy slot without changing the old da
|
||||
'hex-trails-observe-v3-r2':JSON.stringify({current:6,completed:[6]}),
|
||||
};
|
||||
const s=storage(old);
|
||||
for(const [i,c] of CAMPAIGN_VERSIONS.entries()){
|
||||
for(const [i,c] of CAMPAIGN_VERSIONS.slice(0,3).entries()){
|
||||
const key=Object.keys(old)[i],expected=JSON.parse(old[key]);
|
||||
assert.deepEqual(readCampaignProgress(s,c),expected);
|
||||
writeCampaignProgress(s,c,{current:1,completed:[1]});
|
||||
@@ -98,8 +98,21 @@ test('invalid saves are bounded to each edition and do not resurrect legacy prog
|
||||
test('unavailable browser storage still permits loading a campaign',()=>{
|
||||
const denied={getItem(){throw new Error('denied');},setItem(){throw new Error('denied');}};
|
||||
for(const s of [undefined,denied]){
|
||||
assert.equal(selectedCampaign(s).id,'v0.3');
|
||||
assert.equal(selectedCampaign(s).id,'v0.5');
|
||||
assert.deepEqual(readCampaignProgress(s,campaignVersion('v0.1')),{current:0,completed:[]});
|
||||
assert.equal(writeCampaignProgress(s,campaignVersion('v0.1'),{current:1,completed:[0]}),false);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('the production campaign starts independently and retains its thousandth level',()=>{
|
||||
const old=JSON.stringify({current:19,completed:[0,19]});
|
||||
const s=storage({[CAMPAIGN_SELECTION_KEY]:'v0.4',[campaignProgressKey('v0.4')]:old});
|
||||
const production=campaignVersion('v0.5');
|
||||
assert.equal(selectedCampaign(s).id,'v0.4');
|
||||
assert.deepEqual(readCampaignProgress(s,production),{current:0,completed:[]});
|
||||
writeCampaignProgress(s,production,{current:999,completed:[0,998,999]});
|
||||
assert.deepEqual(readCampaignProgress(s,production),{current:999,completed:[0,998,999]});
|
||||
assert.equal(s.getItem(campaignProgressKey('v0.4')),old);
|
||||
assert.equal(selectedCampaign(s).id,'v0.5');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { generate, rotate } from '../labs/car-loop/model.js';
|
||||
import { rotationPlan, drainOrientation, batchSeeds } from '../labs/car-loop/batch.js';
|
||||
|
||||
// Run the real application frame loop with a minimal DOM and a no-op Canvas.
|
||||
// Virtual time exercises dispatch, rotation, merging and arrival together.
|
||||
test('automatic demonstration delivers every car in all sample sizes', async () => {
|
||||
const saved = new Map(['document','location','requestAnimationFrame'].map(k=>[k,globalThis[k]]));
|
||||
const elements = new Map();
|
||||
const context = new Proxy({}, {get: (o,k)=>o[k] || (()=>{}), set:(o,k,v)=>(o[k]=v,true)});
|
||||
const element = () => ({style:{setProperty(){}},dataset:{},classList:{toggle(){}},hidden:true,checked:true,
|
||||
setAttribute(){},addEventListener(name,fn){this[name]=fn;},replaceChildren(...children){this.children=children;},getContext(){return context;}});
|
||||
const get = id => {if(!elements.has(id))elements.set(id,element());return elements.get(id);};
|
||||
const shapes = ['hex','square'].map(kind=>({...element(),dataset:{kind}}));
|
||||
let frame;
|
||||
try {
|
||||
globalThis.document = {getElementById:get,createElement:element,body:element(),querySelectorAll:s=>s==='[data-kind]'?shapes:[]};
|
||||
globalThis.location = {search:'?kind=hex'};
|
||||
globalThis.requestAnimationFrame = fn => {frame=fn;};
|
||||
await import('../labs/car-loop/app.js');
|
||||
get('speed').oninput({target:{value:'3'}});
|
||||
let now=performance.now();
|
||||
for(const kind of ['hex','square'])for(const scenario of [0,1,2,3]) {
|
||||
shapes.find(s=>s.dataset.kind===kind).onclick();
|
||||
get('scenario').onchange({target:{value:String(scenario)}});
|
||||
const total=Number(get('remaining').textContent);
|
||||
get('auto').onclick();
|
||||
for(let i=0;i<30000 && get('win').hidden;i++){now+=50;frame(now);}
|
||||
assert.equal(Number(get('arrived').textContent),total,`${kind} ${scenario}: ${get('status').textContent}`);
|
||||
assert.equal(get('win').hidden,false);
|
||||
}
|
||||
// Start auto after manually clicking every car that has an exit route.
|
||||
shapes[0].onclick(); get('scenario').onchange({target:{value:'2'}});
|
||||
for(const button of [...get('car-list').children])button.click();
|
||||
get('auto').onclick();
|
||||
for(let i=0;i<30000 && get('win').hidden;i++){now+=50;frame(now);}
|
||||
assert.equal(Number(get('arrived').textContent),22);
|
||||
shapes[1].onclick(); get('scenario').onchange({target:{value:'1'}});
|
||||
for(const button of [...get('car-list').children])button.click();
|
||||
assert.equal(Number(get('remaining').textContent),0);
|
||||
get('auto').onclick();
|
||||
assert.equal(get('auto').textContent,'Ⅱ 暂停演示');
|
||||
for(let i=0;i<30000 && get('win').hidden;i++){now+=50;frame(now);}
|
||||
assert.equal(Number(get('arrived').textContent),16);
|
||||
// Batch mode exhausts only the current orientation and waits for a player turn.
|
||||
get('mode').onchange({target:{value:'batch'}});
|
||||
for (const kind of ['hex','square']) for (const scenario of [0,1,2]) {
|
||||
shapes.find(s=>s.dataset.kind===kind).onclick();
|
||||
get('scenario').onchange({target:{value:String(scenario)}});
|
||||
const board = generate(kind,batchSeeds[kind][scenario],16);
|
||||
const plan = rotationPlan(board);
|
||||
const first = drainOrientation(board);
|
||||
get('hint').onclick();
|
||||
const started = Number(get('transit').textContent);
|
||||
assert.ok(started > 0 && started <= first.departed.length,'the first safe departures start immediately');
|
||||
assert.equal(Number(get('remaining').textContent),16-started,'booked cars remain visibly parked until their slot');
|
||||
assert.match(get('batch-result').textContent,new RegExp(`待发 ${first.departed.length-started}`));
|
||||
for(let i=0;i<4000 && get('hint-label').textContent==='放行中';i++){
|
||||
if (i % 30 === 0) get('speed').oninput({target:{value:String([1,3,1.5][(i/30)%3])}});
|
||||
now += [16,33,80][i%3]; frame(now);
|
||||
}
|
||||
assert.equal(Number(get('remaining').textContent),first.remaining.length);
|
||||
assert.equal(get('turn-count').textContent,'已转 0 次');
|
||||
assert.equal(get('hint-label').textContent,'需转盘');
|
||||
const left = get('remaining').textContent;
|
||||
for(let i=0;i<120;i++){now+=50;frame(now);}
|
||||
assert.equal(get('remaining').textContent,left,'never rotate or restart automatically');
|
||||
let turns = 0;
|
||||
for (const direction of plan.turns) {
|
||||
const parkedBeforeTurn = get('remaining').textContent;
|
||||
get(direction === -1 ? 'left' : 'right').onclick(); turns++;
|
||||
for(let i=0;i<12;i++){now+=50;frame(now);}
|
||||
assert.equal(get('remaining').textContent,parkedBeforeTurn);
|
||||
assert.notEqual(get('hint-label').textContent,'放行中','turning alone does not launch');
|
||||
get('hint').onclick();
|
||||
for(let i=0;i<4000 && get('hint-label').textContent==='放行中';i++){now+=50;frame(now);}
|
||||
}
|
||||
for(let i=0;i<2000 && get('win').hidden;i++){now+=50;frame(now);}
|
||||
assert.equal(Number(get('arrived').textContent),16,`${kind} batch ${scenario}`);
|
||||
assert.equal(get('turn-count').textContent,`已转 ${turns} 次`);
|
||||
assert.equal(get('win').hidden,false);
|
||||
}
|
||||
// Taking the less efficient first direction must still finish without a traffic jam.
|
||||
for (const kind of ['hex','square']) for (const scenario of [0,1,2]) for (const firstTurn of [-1,1]) {
|
||||
shapes.find(s=>s.dataset.kind===kind).onclick(); get('scenario').onchange({target:{value:String(scenario)}});
|
||||
let board=generate(kind,batchSeeds[kind][scenario],16), remaining=drainOrientation(board).remaining;
|
||||
const run=()=>{get('hint').onclick();for(let i=0;i<4000&&get('hint-label').textContent==='放行中';i++){now+=50;frame(now);}assert.notEqual(get('hint-label').textContent,'放行中',`${kind} ${scenario} ${firstTurn} stalled`);};
|
||||
run();
|
||||
board=rotate(board,firstTurn);
|
||||
const continuation=rotationPlan(board,remaining);
|
||||
for(const turn of [firstTurn,...continuation.turns]) {
|
||||
get(turn===-1?'left':'right').onclick();for(let i=0;i<12;i++){now+=50;frame(now);}run();
|
||||
}
|
||||
for(let i=0;i<2000&&get('win').hidden;i++){now+=50;frame(now);}
|
||||
assert.equal(Number(get('arrived').textContent),16);
|
||||
}
|
||||
// Resetting and switching modes must cancel outstanding departure slots.
|
||||
shapes[0].onclick(); get('scenario').onchange({target:{value:'1'}}); get('hint').onclick();
|
||||
get('reset').onclick();
|
||||
for(let i=0;i<400;i++){now+=50;frame(now);}
|
||||
assert.equal(Number(get('remaining').textContent),16);
|
||||
assert.equal(Number(get('transit').textContent),0);
|
||||
assert.equal(Number(get('arrived').textContent),0);
|
||||
get('hint').onclick(); get('mode').onchange({target:{value:'free'}});
|
||||
for(let i=0;i<400;i++){now+=50;frame(now);}
|
||||
assert.equal(Number(get('remaining').textContent),16);
|
||||
assert.equal(Number(get('transit').textContent),0);
|
||||
} finally {
|
||||
for(const [key,value] of saved)if(value===undefined)delete globalThis[key];else globalThis[key]=value;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { generate, rotate, trace } from '../labs/car-loop/model.js';
|
||||
import { drainOrientation, rotationPlan, batchSeeds } from '../labs/car-loop/batch.js';
|
||||
|
||||
for (const kind of ['hex', 'square']) test(`${kind}: batch levels require meaningful rotation choices`, () => {
|
||||
for (const [index,seed] of batchSeeds[kind].entries()) {
|
||||
const board = generate(kind,seed,16), plan = rotationPlan(board);
|
||||
const first = drainOrientation(board);
|
||||
assert.ok(first.departed.length >= 2);
|
||||
assert.ok(first.remaining.length > 0);
|
||||
assert.ok(first.remaining.every(c=>trace(board,c,first.remaining).status !== 'exit'));
|
||||
assert.ok(plan.turns.length >= 2 && plan.turns.length <= 4);
|
||||
const left = 1 + rotationPlan(rotate(board,-1),first.remaining).turns.length;
|
||||
const right = 1 + rotationPlan(rotate(board,1),first.remaining).turns.length;
|
||||
assert.notEqual(left,right,`level ${index}: first direction must affect turn cost`);
|
||||
if (index === 1) assert.ok(plan.turns.includes(-1) && plan.turns.includes(1),'second challenge rewards reversing direction');
|
||||
let current=board, remaining=first.remaining;
|
||||
for (const turn of plan.turns) {current=rotate(current,turn);remaining=drainOrientation(current,remaining).remaining;}
|
||||
assert.equal(remaining.length,0);
|
||||
assert.equal(plan.waves.reduce((sum,n)=>sum+n,0),16);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { generate, solve, trace, rotate, curve, center, directions, port } from '../labs/car-loop/model.js';
|
||||
|
||||
for (const kind of ['square', 'hex']) {
|
||||
test(`${kind}: all six authored samples/variants fully solve with actual curved travel`, () => {
|
||||
for (const [index, seed] of [71, 233, 907].entries()) for (const offset of [0, 37]) {
|
||||
const board = generate(kind, seed + offset, [10, 16, 22][index]);
|
||||
const solution = solve(board);
|
||||
assert.equal(board.cars.length, [10, 16, 22][index]);
|
||||
assert.equal(solution.solvable, true);
|
||||
assert.equal(solution.paths.length, board.cars.length);
|
||||
assert.ok(solution.paths.filter(p => p.bends >= 2).length >= board.cars.length / 2);
|
||||
assert.ok(Math.max(...solution.paths.map(p => p.bends)) >= 5);
|
||||
}
|
||||
});
|
||||
test(`${kind}: full rotation preserves board and parked cars`, () => {
|
||||
const board = generate(kind, 233, 16);
|
||||
let state = board;
|
||||
for (let i = 0; i < directions(kind).length; i++) state = rotate(state);
|
||||
assert.deepEqual(state, board);
|
||||
assert.equal(rotate(board).cars, board.cars);
|
||||
});
|
||||
test(`${kind}: neighboring road endpoints coincide`, () => {
|
||||
for (const [d, [x, y]] of directions(kind).entries()) {
|
||||
const n = directions(kind).length;
|
||||
const a = curve(kind, { x: 0, y: 0 }, (d + n / 2) % n, d).at(-1);
|
||||
const b = curve(kind, { x, y }, (d + n / 2) % n, d)[0];
|
||||
assert.ok(Math.hypot(a.x - b.x, a.y - b.y) < 1e-10);
|
||||
}
|
||||
});
|
||||
}
|
||||
test('square: crossing stays straight and collision stops before the parked car', () => {
|
||||
const cells = Array.from({ length: 3 }, (_, y) => ({ x: 0, y, tracks: [2, 3, 0, 1] }));
|
||||
const car = { id: 'a', x: 0, y: 2, dir: 0 }, blocker = { id: 'b', x: 0, y: 0, dir: 0 };
|
||||
const board = { kind: 'square', cells, cars: [car, blocker] };
|
||||
const blocked = trace(board, car);
|
||||
assert.equal(blocked.status, 'blocked'); assert.equal(blocked.blocker, 'b');
|
||||
assert.ok(Math.abs(blocked.points.at(-1).y - .72) < 1e-6);
|
||||
const clear = trace(board, car, [car]);
|
||||
assert.equal(clear.status, 'exit'); assert.equal(clear.bends, 0);
|
||||
});
|
||||
test('square: closed paired route is reported as a loop instead of hanging', () => {
|
||||
const cells = [
|
||||
{ x: 0, y: 0, tracks: [3, 2, 1, 0] },
|
||||
{ x: 1, y: 0, tracks: [1, 0, 3, 2] },
|
||||
{ x: 1, y: 1, tracks: [3, 2, 1, 0] },
|
||||
{ x: 0, y: 1, tracks: [1, 0, 3, 2] },
|
||||
];
|
||||
const car = { id: 'a', x: 0, y: 0, dir: 1 };
|
||||
assert.equal(trace({ kind: 'square', cells, cars: [car] }, car).status, 'loop');
|
||||
});
|
||||
|
||||
import { createPerimeter } from '../labs/car-loop/perimeter.js';
|
||||
for (const kind of ['square', 'hex']) {
|
||||
test(`${kind}: clockwise polygon lane closes, and every boundary exit joins it`, () => {
|
||||
const size = kind === 'square' ? 68 : 74;
|
||||
const lane = createPerimeter(kind, 384, 430, size), board = generate(kind, 233, 16);
|
||||
assert.deepEqual(lane.at(0), lane.at(Math.PI * 2));
|
||||
let area = 0;
|
||||
for (let i = 1; i < lane.points.length; i++) area += lane.points[i-1].x * lane.points[i].y - lane.points[i].x * lane.points[i-1].y;
|
||||
assert.ok(area > 0, 'clockwise in screen coordinates');
|
||||
const occupied = new Set(board.cells.map(c => `${c.x},${c.y}`));
|
||||
for (const cell of board.cells) directions(kind).forEach(([dx,dy], d) => {
|
||||
if (occupied.has(`${cell.x+dx},${cell.y+dy}`)) return;
|
||||
const c = center(kind, cell.x, cell.y), a = -Math.PI/2 + d * Math.PI*2/directions(kind).length;
|
||||
const direction = {x:Math.cos(a),y:Math.sin(a)};
|
||||
const origin = {x:384+(c.x+direction.x/2)*size,y:430+(c.y+direction.y/2)*size};
|
||||
const hit = lane.intersect(origin,direction), p = lane.at(hit.parameter);
|
||||
assert.ok(hit.distance > 0 && hit.distance < 80, 'short perimeter ramp');
|
||||
assert.ok(Math.hypot(p.x-origin.x-direction.x*hit.distance,p.y-origin.y-direction.y*hit.distance)<1e-6);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
import { measure, at, advance, hasPriority, passageOrder } from '../labs/car-loop/motion.js';
|
||||
test('cars launch concurrently on independent roads, without a departure queue', () => {
|
||||
const a = measure([{x:0,y:0},{x:400,y:0}]);
|
||||
const b = measure([{x:0,y:100},{x:400,y:100}]);
|
||||
assert.equal(advance(a,0,30,[at(b,0)],[],40),30);
|
||||
assert.equal(advance(b,0,30,[at(a,30)],[],40),30);
|
||||
});
|
||||
test('fast movement stops at physical contact and resumes from that position when the blocker leaves', () => {
|
||||
const path = measure([{x:0,y:0},{x:400,y:0}]);
|
||||
const stopped = advance(path,0,300,[{x:100,y:0}],[],40);
|
||||
assert.ok(stopped > 0 && stopped <= 60 && stopped >= 58.5);
|
||||
assert.equal(advance(path,stopped,300,[{x:100,y:0}],[],40),stopped);
|
||||
assert.equal(advance(path,stopped,300,[],[],40),300);
|
||||
});
|
||||
test('crossing vehicles wait at the junction, then both pass without clipping or deadlock', () => {
|
||||
const a = {car:{id:'a'},order:0,path:measure([{x:0,y:0},{x:300,y:0}]),boardLength:300,distance:0};
|
||||
const b = {car:{id:'b'},order:1,path:measure([{x:150,y:-150},{x:150,y:150}]),boardLength:300,distance:0};
|
||||
let waited = false;
|
||||
for(let frame=0;frame<500;frame++) for(const car of [a,b]) {
|
||||
const other = car === a ? b : a;
|
||||
const next = advance(car.path,car.distance,Math.min(car.path.length,car.distance+8),[at(other.path,other.distance)],hasPriority(other,car)?[other]:[],40);
|
||||
if(car===b && next===car.distance && next<car.path.length) waited=true;
|
||||
car.distance=next;
|
||||
assert.ok(Math.hypot(at(a.path,a.distance).x-at(b.path,b.distance).x,at(a.path,a.distance).y-at(b.path,b.distance).y)>=40);
|
||||
}
|
||||
assert.ok(waited); assert.equal(a.distance,300); assert.equal(b.distance,300);
|
||||
});
|
||||
test('enhanced square samples contain substantially longer actual multi-bend routes', () => {
|
||||
for(const [i,seed] of [59,107,107].entries()) {
|
||||
const board=generate('square',seed,[10,16,22][i]), solution=solve(board);
|
||||
assert.ok(solution.solvable);
|
||||
const mean=solution.paths.reduce((sum,p)=>sum+p.bends,0)/board.cars.length;
|
||||
assert.ok(mean>=7, `mean ${mean}`);
|
||||
assert.ok(Math.max(...solution.paths.map(p=>p.bends))>=12);
|
||||
}
|
||||
});
|
||||
test('all 16 cars on the complex square board can drive simultaneously and clear', () => {
|
||||
const board=generate('square',107,16), order=passageOrder(board,board.cars,trace);
|
||||
let actors=board.cars.map(car=>{
|
||||
const route=trace(board,car,[]), p=route.points.at(-1), a=-Math.PI/2+route.exitDir*Math.PI/2;
|
||||
const path=measure([...route.points,{x:p.x+Math.cos(a)*2,y:p.y+Math.sin(a)*2}]);
|
||||
return {car,order:order.get(car.id),path,boardLength:path.length,distance:0};
|
||||
});
|
||||
let finished=0;
|
||||
for(let frame=0;frame<12000 && actors.length;frame++) {
|
||||
for(const actor of actors) {
|
||||
const others=actors.filter(other=>other!==actor);
|
||||
actor.distance=advance(actor.path,actor.distance,Math.min(actor.path.length,actor.distance+.12),others.map(other=>at(other.path,other.distance)),others.filter(other=>hasPriority(other,actor)),.72);
|
||||
}
|
||||
const gone=actors.filter(actor=>actor.distance>=actor.path.length-1e-6);
|
||||
finished+=gone.length; actors=actors.filter(actor=>!gone.includes(actor));
|
||||
}
|
||||
assert.equal(finished,16, `stuck: ${actors.map(a=>a.car.id+':'+a.distance).join(',')}`);
|
||||
});
|
||||
|
||||
import { generateSample } from '../labs/car-loop/model.js';
|
||||
test('changing square layouts preserves the long-route selection floor', () => {
|
||||
for (const scenario of [0,1,2]) {
|
||||
const board = generateSample('square',scenario,1), solution = solve(board);
|
||||
assert.ok(solution.solvable);
|
||||
assert.ok(solution.paths.reduce((sum,p)=>sum+p.bends,0)/board.cars.length>=6);
|
||||
assert.ok(Math.max(...solution.paths.map(p=>p.bends))>=12);
|
||||
}
|
||||
});
|
||||
|
||||
test('desktop samples contain 96 cars and solve across every road orientation', () => {
|
||||
for (const kind of ['square', 'hex']) {
|
||||
const board = generateSample(kind, 3);
|
||||
assert.equal(board.cells.length, kind === 'square' ? 225 : 169);
|
||||
assert.equal(board.cars.length, 96);
|
||||
for (let turn = 0; turn < directions(kind).length; turn++) {
|
||||
const solution = solve(rotate(board, turn));
|
||||
assert.equal(solution.solvable, true);
|
||||
assert.equal(solution.paths.length, 96);
|
||||
}
|
||||
const perimeter = createPerimeter(kind, 660, 690, kind === 'square' ? 68 : 74, 7);
|
||||
for (const car of board.cars) {
|
||||
const route = trace(board, car, []);
|
||||
if (route.status !== 'exit') continue;
|
||||
const end = route.points.at(-1), d = port(kind, route.exitDir), size = kind === 'square' ? 68 : 74;
|
||||
const hit = perimeter.intersect({x:660 + end.x * size, y:690 + end.y * size}, {x:d.x * 2, y:d.y * 2});
|
||||
assert.ok(hit.distance >= 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
import { departure } from '../labs/car-loop/routes.js';
|
||||
import { activeCorridors } from '../labs/car-loop/motion.js';
|
||||
|
||||
test('hex desktop red c88 exits without waiting for parked cars c28 and c84', () => {
|
||||
const board = generateSample('hex', 3), size = 74;
|
||||
const screen = p => ({ x: p.x * size, y: p.y * size });
|
||||
const perimeter = createPerimeter('hex', 0, 0, size, 7);
|
||||
const order = passageOrder(board, board.cars, trace);
|
||||
const routes = board.cars.map(car => ({
|
||||
car, ...departure(trace(board, car, []), 'hex', screen, perimeter),
|
||||
distance: 0, order: order.get(car.id), stage: 'parked',
|
||||
}));
|
||||
const red = routes.find(r => r.car.id === 'c88'); red.stage = 'board';
|
||||
const parked = routes.filter(r => r !== red);
|
||||
const obstacles = parked.map(r => screen(center('hex', r.car.x, r.car.y)));
|
||||
const target = red.path.length - 34;
|
||||
// Reproduce the former phantom reservation within the first 9 pixels.
|
||||
const phantom = parked.find(r => r.car.id === 'c84');
|
||||
assert.ok(advance(red.path, 0, target, obstacles, [phantom], size * .72 + 1) < 10);
|
||||
const corridors = activeCorridors(routes, red);
|
||||
assert.equal(corridors.length, 0);
|
||||
assert.equal(advance(red.path, 0, target, obstacles, corridors, size * .72 + 1), target);
|
||||
// Once the other car really launches, its conflict reservation is legitimate.
|
||||
phantom.stage = 'board';
|
||||
assert.deepEqual(activeCorridors(routes, red), [phantom]);
|
||||
phantom.distance = phantom.boardLength;
|
||||
assert.equal(activeCorridors(routes, red).length, 0);
|
||||
});
|
||||
|
||||
test('all clear sample exits reach the actual perimeter merge with parked neighbors', () => {
|
||||
let checked = 0;
|
||||
for (const kind of ['hex', 'square']) for (const scenario of [0, 1, 2, 3]) {
|
||||
const sample = generateSample(kind, scenario), size = kind === 'hex' ? 74 : 68;
|
||||
const screen = p => ({ x: p.x * size, y: p.y * size });
|
||||
const perimeter = createPerimeter(kind, 0, 0, size, scenario === 3 ? 7 : 3);
|
||||
for (let turn = 0; turn < directions(kind).length; turn++) {
|
||||
const board = rotate(sample, turn);
|
||||
for (const car of board.cars) {
|
||||
if (trace(board, car).status !== 'exit') continue;
|
||||
const route = departure(trace(board, car, []), kind, screen, perimeter);
|
||||
const obstacles = board.cars.filter(o => o !== car).map(o => screen(center(kind, o.x, o.y)));
|
||||
const target = route.path.length - 34;
|
||||
assert.equal(advance(route.path, 0, target, obstacles, [], size * .72 + 1), target,
|
||||
`${kind} sample ${scenario} turn ${turn} ${car.id}`);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(checked, 206);
|
||||
});
|
||||
|
||||
import { resolveGridlock } from '../labs/car-loop/motion.js';
|
||||
test('late arrivals already inside a crossing back off and both pass without collision', () => {
|
||||
for (const swapped of [false, true]) for (const rearBlocked of [false, true]) {
|
||||
const actors = [
|
||||
{ path: measure([{x:-120,y:0},{x:120,y:0}]), order: swapped ? 1 : 0 },
|
||||
{ path: measure([{x:0,y:-120},{x:0,y:120}]), order: swapped ? 0 : 1 },
|
||||
].map(a => ({...a, stage:'board', boardLength:240, distance:84, stopped:true, blockedFor:1}));
|
||||
const position = a => at(a.path,a.distance);
|
||||
const clearance = 50;
|
||||
// Both forward moves are physically blocked, irrespective of reservation order.
|
||||
for (const actor of actors) assert.equal(advance(actor.path,84,90,[position(actors.find(o=>o!==actor))],[],clearance),84);
|
||||
const fixed = rearBlocked ? [swapped ? {x:-87,y:0} : {x:0,y:-87}] : [];
|
||||
let retreats = 0;
|
||||
for (let frame=0;frame<1200 && actors.some(a=>a.stage==='board');frame++) {
|
||||
const reversed = resolveGridlock(actors,fixed,position,clearance,3);
|
||||
retreats += reversed.size;
|
||||
for (const actor of actors) {
|
||||
if (actor.stage !== 'board' || reversed.has(actor)) continue;
|
||||
const target = Math.min(actor.path.length,actor.distance+4);
|
||||
const next = advance(actor.path,actor.distance,target,[...fixed,...actors.filter(o=>o!==actor&&o.stage==='board').map(position)],activeCorridors(actors,actor),clearance);
|
||||
actor.stopped = next < target - .1;
|
||||
actor.blockedFor = actor.stopped ? actor.blockedFor + 1/60 : 0;
|
||||
actor.distance = next;
|
||||
if (next === actor.path.length) actor.stage = 'done';
|
||||
}
|
||||
for (const actor of actors) for (const obstacle of fixed) {
|
||||
const p = position(actor);
|
||||
assert.ok(Math.hypot(p.x-obstacle.x,p.y-obstacle.y)>=clearance-1e-6);
|
||||
}
|
||||
if (actors.every(a=>a.stage==='board')) {
|
||||
const [p,q]=actors.map(position);
|
||||
assert.ok(Math.hypot(p.x-q.x,p.y-q.y)>=clearance-1e-6);
|
||||
}
|
||||
}
|
||||
assert.ok(retreats > 0);
|
||||
assert.ok(actors.every(a=>a.stage==='done'));
|
||||
}
|
||||
});
|
||||
|
||||
import { journey, journeyPosition, scheduleDepartures, tripsConflict } from '../labs/car-loop/schedule.js';
|
||||
import { garagePath } from '../labs/car-loop/routes.js';
|
||||
import { batchSeeds, rotationPlan, drainOrientation } from '../labs/car-loop/batch.js';
|
||||
|
||||
function straightTrip(id, points) {
|
||||
const path = measure(points);
|
||||
return { car: { id }, journey: { path, boardLength: path.length, times: path.lengths.map(d => d / 240), duration: path.length / 240 } };
|
||||
}
|
||||
|
||||
test('timed departures overlap independent routes and reserve crossings only when occupied', () => {
|
||||
const a = straightTrip('a', [{ x: -200, y: 0 }, { x: 200, y: 0 }]);
|
||||
const parallel = straightTrip('b', [{ x: -200, y: 100 }, { x: 200, y: 100 }]);
|
||||
const earlyCrossing = straightTrip('c', [{ x: 0, y: -50 }, { x: 0, y: 350 }]);
|
||||
for (const other of [parallel, earlyCrossing]) {
|
||||
assert.deepEqual(scheduleDepartures([a, other], { clearance: 60 }).map(t => t.start), [0, 0]);
|
||||
}
|
||||
const crossing = straightTrip('d', [{ x: 0, y: -200 }, { x: 0, y: 200 }]);
|
||||
assert.equal(tripsConflict({ ...a, start: 0 }, { ...crossing, start: 0 }, 0, 60), true,
|
||||
'swept crossing is detected even though both segment endpoints are clear');
|
||||
const [first, second] = scheduleDepartures([a, crossing], { clearance: 60 });
|
||||
assert.ok(second.start > 0 && second.start < first.journey.duration, 'conflicting trips still overlap in time');
|
||||
for (let time = 0; time < first.journey.duration; time += .003) {
|
||||
const p = journeyPosition(first.journey, time), q = journeyPosition(second.journey, time - second.start);
|
||||
assert.ok(Math.hypot(p.x - q.x, p.y - q.y) >= 60);
|
||||
}
|
||||
const later = scheduleDepartures([crossing], { now: .3, active: [first], clearance: 60 })[0];
|
||||
assert.ok(later.start > .3, 'previously running traffic also reserves the crossing');
|
||||
assert.throws(() => scheduleDepartures([a], { clearance: 60, parked: [{ id: 'fixed', x: 0, y: 0 }] }), /parked car/);
|
||||
});
|
||||
|
||||
test('all batch trips maintain physical clearance through curves, merges and the garage across waves', () => {
|
||||
let overlap = false, checked = 0;
|
||||
for (const kind of ['hex', 'square']) for (const seed of batchSeeds[kind]) {
|
||||
let board = generate(kind, seed, 16), cars = board.cars, now = 0, active = [];
|
||||
const size = kind === 'hex' ? 74 : 68, clearance = size * .92 + 2, parkedClearance = size * .8 + 1;
|
||||
const screen = p => ({ x: 384 + p.x * size, y: 360 + p.y * size });
|
||||
const perimeter = createPerimeter(kind, 384, 360, size), finish = garagePath(perimeter, 768);
|
||||
const plan = rotationPlan(board);
|
||||
for (const [wave, turn] of [0, ...plan.turns].entries()) {
|
||||
board = rotate(board, turn);
|
||||
const ids = drainOrientation(board, cars).departed;
|
||||
const candidates = ids.map(id => {
|
||||
const car = cars.find(c => c.id === id), route = departure(trace(board, car, []), kind, screen, perimeter);
|
||||
return { car, journey: journey(route, perimeter, finish) };
|
||||
});
|
||||
const planned = scheduleDepartures(candidates, {
|
||||
now, active, clearance, parkedClearance,
|
||||
parked: cars.map(c => ({ id: c.id, ...screen(center(kind, c.x, c.y)) })),
|
||||
});
|
||||
assert.equal(planned.length, ids.length);
|
||||
active.push(...planned);
|
||||
cars = cars.filter(c => !ids.includes(c.id));
|
||||
const internalEnd = Math.max(now, ...planned.map(t => t.start + t.journey.boardLength / 240 + 27 / 180));
|
||||
const end = wave === plan.turns.length ? Math.max(...active.map(t => t.start + t.journey.duration)) : internalEnd + .9;
|
||||
const lastDistances = new Map();
|
||||
for (let time = now; time < end; time += .02) {
|
||||
const present = active.filter(t => time < t.start + t.journey.duration).map(t => ({
|
||||
id: t.car.id, moving: time >= t.start, ...journeyPosition(t.journey, time - t.start),
|
||||
}));
|
||||
if (present.filter(p => p.moving).length > 1) overlap = true;
|
||||
for (const p of present) {
|
||||
if (p.moving && p.distance > 0 && lastDistances.has(p.id)) assert.ok(p.distance > lastDistances.get(p.id), 'no stopping or reversing after departure');
|
||||
lastDistances.set(p.id, p.distance);
|
||||
}
|
||||
present.push(...cars.map(c => ({ id: c.id, moving: false, ...screen(center(kind, c.x, c.y)) })));
|
||||
for (let i = 0; i < present.length; i++) for (let j = i + 1; j < present.length; j++) {
|
||||
const a = present[i], b = present[j], gap = a.moving && b.moving ? clearance : parkedClearance;
|
||||
assert.ok(Math.hypot(a.x - b.x, a.y - b.y) >= gap - 1e-6, `${kind} ${seed} wave ${wave}: ${a.id}/${b.id} at ${time}`);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
now = end;
|
||||
active = active.filter(t => now < t.start + t.journey.duration);
|
||||
}
|
||||
assert.equal(cars.length, 0);
|
||||
assert.equal(active.length, 0);
|
||||
}
|
||||
assert.ok(overlap);
|
||||
assert.ok(checked > 10000);
|
||||
});
|
||||
@@ -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] });
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FLOWER_LEVEL } from '../src/flower-study.js';
|
||||
import { Game, solveRotations } from '../src/core.js';
|
||||
import { layout, center } from '../src/geometry.js';
|
||||
|
||||
// Exercise actual pointer handlers and animation frames: model-only queue tests
|
||||
// cannot catch an application that drops clicks before they reach TapQueue.
|
||||
test('flower controls retain the main game rapid-tap behaviour', async t => {
|
||||
const globals = ['document', 'performance', 'devicePixelRatio', 'matchMedia', 'Image', 'ResizeObserver', 'requestAnimationFrame', 'cancelAnimationFrame'];
|
||||
const saved = new Map(globals.map(k => [k, Object.getOwnPropertyDescriptor(globalThis, k)]));
|
||||
const elements = new Map(), frames = new Map(), actions = [];
|
||||
let now = 0, frameId = 0;
|
||||
const rect = { left: 0, top: 0, width: 700, height: 640 };
|
||||
const view = layout(FLOWER_LEVEL.cells, rect.width, rect.height);
|
||||
const context = new Proxy({}, { get: (o, k) => o[k] ?? (() => {}), set: (o, k, v) => (o[k] = v, true) });
|
||||
const get = id => {
|
||||
if (!elements.has(id)) elements.set(id, {
|
||||
checked: false, disabled: false, textContent: '',
|
||||
getBoundingClientRect: () => rect, getContext: () => context,
|
||||
addEventListener(name, fn) { this[name] = fn; },
|
||||
click() { if (!this.disabled) this.onclick?.(); },
|
||||
});
|
||||
return elements.get(id);
|
||||
};
|
||||
const originalTap = Game.prototype.tap;
|
||||
const clickTile = id => {
|
||||
const p = center(FLOWER_LEVEL.tiles.find(t => t.id === id), view);
|
||||
get('board').pointerup({ clientX: p.x, clientY: p.y });
|
||||
};
|
||||
const advance = () => {
|
||||
now += 30000;
|
||||
for (const [id, callback] of [...frames]) { frames.delete(id); callback(now); }
|
||||
};
|
||||
const remaining = () => Number(get('remaining').textContent);
|
||||
const reset = () => { get('reset').click(); actions.length = 0; };
|
||||
try {
|
||||
const replacements = {
|
||||
document: { getElementById: get, addEventListener() {} },
|
||||
performance: { now: () => now }, devicePixelRatio: 1, matchMedia: () => ({ matches: false }),
|
||||
Image: class {}, ResizeObserver: class { constructor(callback) { this.callback = callback; } observe() { this.callback(); } },
|
||||
requestAnimationFrame: callback => { frames.set(++frameId, callback); return frameId; },
|
||||
cancelAnimationFrame: id => frames.delete(id),
|
||||
};
|
||||
for (const [key, value] of Object.entries(replacements)) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
Game.prototype.tap = function(id) {
|
||||
const result = originalTap.call(this, id);
|
||||
actions.push({ id, status: result.status });
|
||||
return result;
|
||||
};
|
||||
await import('../labs/flower/app.js');
|
||||
|
||||
await t.test('a rapid burst runs in click order against the latest board and ignores repeat clicks', () => {
|
||||
const firstWave = solveRotations(FLOWER_LEVEL).actions.slice(0, 8).map(a => a.id);
|
||||
for (const id of firstWave) { clickTile(id); clickTile(id); }
|
||||
assert.equal(remaining(), 9, 'first departure starts immediately');
|
||||
assert.match(get('progress').textContent, /待出发 7/);
|
||||
assert.equal(get('pattern-only').disabled, true);
|
||||
for (let i = 0; i < firstWave.length; i++) advance();
|
||||
assert.deepEqual(actions, firstWave.map(id => ({ id, status: 'exit' })));
|
||||
assert.equal(remaining(), 2, 'dependent departures re-evaluate after preceding pieces leave');
|
||||
assert.doesNotMatch(get('progress').textContent, /待出发/);
|
||||
get('undo').click();
|
||||
assert.equal(remaining(), 3);
|
||||
advance();
|
||||
assert.equal(remaining(), 3, 'undo does not replay an old queued click');
|
||||
});
|
||||
|
||||
await t.test('a queued collision cancels later clicks and leaves failed probes undoable', () => {
|
||||
reset();
|
||||
for (const id of ['petal-0', 'petal-1', 'petal-2']) clickTile(id);
|
||||
advance();
|
||||
assert.deepEqual(actions, [{ id: 'petal-0', status: 'exit' }, { id: 'petal-1', status: 'blocked' }]);
|
||||
assert.doesNotMatch(get('progress').textContent, /待出发/);
|
||||
advance();
|
||||
assert.equal(remaining(), 9);
|
||||
get('undo').click();
|
||||
assert.equal(remaining(), 9, 'undo the failed probe first');
|
||||
get('undo').click();
|
||||
assert.equal(remaining(), 10);
|
||||
});
|
||||
|
||||
await t.test('reset discards animation and pending input without leaking into the restarted level', () => {
|
||||
reset();
|
||||
for (const id of ['petal-0', 'petal-2', 'bloom-2']) clickTile(id);
|
||||
reset(); advance();
|
||||
assert.equal(remaining(), 10);
|
||||
assert.deepEqual(actions, []);
|
||||
assert.doesNotMatch(get('progress').textContent, /待出发/);
|
||||
});
|
||||
|
||||
await t.test('clicks during a board turn wait for it to finish and then dispatch normally', () => {
|
||||
reset();
|
||||
get('right').click();
|
||||
clickTile('petal-0'); clickTile('petal-2');
|
||||
assert.equal(remaining(), 10);
|
||||
assert.match(get('progress').textContent, /待出发 2/);
|
||||
advance();
|
||||
assert.equal(remaining(), 9);
|
||||
advance(); advance();
|
||||
assert.equal(remaining(), 8);
|
||||
assert.deepEqual(actions, [{ id: 'petal-0', status: 'exit' }, { id: 'petal-2', status: 'exit' }]);
|
||||
assert.equal(get('right').disabled, false);
|
||||
});
|
||||
} finally {
|
||||
Game.prototype.tap = originalTap;
|
||||
for (const [key, descriptor] of saved) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete globalThis[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FLOWER_LEVEL, FLOWER_PETALS, flowerTrackEmphasis } from '../src/flower-study.js';
|
||||
import { Game, analyze, available, trace, rotateBoard, validateLevel } from '../src/core.js';
|
||||
import { inspectExperience } from '../src/experience-generator.js';
|
||||
|
||||
const analysis = analyze(FLOWER_LEVEL);
|
||||
test('the woven flower has twelve petals, interior departures and four full-length journeys', () => {
|
||||
const l = FLOWER_LEVEL, review = inspectExperience(l, analysis.actions);
|
||||
assert.deepEqual(validateLevel(l), []);
|
||||
assert.equal(l.cells.length, 91);
|
||||
assert.equal(l.tiles.length, 10);
|
||||
assert.equal(FLOWER_PETALS.length, 12);
|
||||
assert.equal(analysis.free, 2);
|
||||
assert.equal(analysis.rotations, 1);
|
||||
assert.ok(analysis.solvable && !analysis.exhausted && review.won);
|
||||
assert.equal(review.hidden.length, 0);
|
||||
assert.equal(analysis.pathUsage.curvedRemovals, 8);
|
||||
assert.deepEqual(analysis.pathUsage.paths.slice(-4).map(p => [p.bends, p.cells]), Array(4).fill([37, 59]));
|
||||
assert.ok(analysis.pathUsage.paths.slice(2, 6).every(p => p.bends >= 8 && p.cells >= 13));
|
||||
});
|
||||
|
||||
test('all six orientations keep every piece on visible, open flower routes', () => {
|
||||
for (let turn = 0; turn < 6; turn++) {
|
||||
const level = rotateBoard(FLOWER_LEVEL, turn), emphasis = flowerTrackEmphasis(turn);
|
||||
for (const tile of level.tiles) {
|
||||
const route = trace(level, tile, level.tiles, true);
|
||||
assert.equal(route.status, 'exit');
|
||||
for (const step of route.steps) {
|
||||
assert.ok(emphasis(step, step.exit), `${turn}: ${tile.id} outgoing track`);
|
||||
if (step.enter !== null) assert.ok(emphasis(step, step.enter), `${turn}: ${tile.id} incoming track`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the dense flower and its three woven rings participate in the reference solution', () => {
|
||||
const emphasis = flowerTrackEmphasis(), featured = new Set(), used = new Set();
|
||||
const pairKey = (q, r, a, b) => `${q},${r}:${Math.min(a, b)},${Math.max(a, b)}`;
|
||||
for (const cell of FLOWER_LEVEL.cells) for (let d = 0; d < 6; d++) {
|
||||
if (d < cell.tracks[d] && emphasis(cell, d)) featured.add(pairKey(cell.q, cell.r, d, cell.tracks[d]));
|
||||
}
|
||||
assert.equal(featured.size, 177);
|
||||
assert.ok(featured.size / (FLOWER_LEVEL.cells.length * 3) > .64);
|
||||
const g = new Game(FLOWER_LEVEL);
|
||||
for (const action of analysis.actions) {
|
||||
if (action.type === 'rotate') { g.rotate(action.direction); continue; }
|
||||
const route = g.tap(action.id);
|
||||
assert.equal(route.status, 'exit');
|
||||
for (const step of route.steps) {
|
||||
// The starting piece travels only the outward half of its paired track.
|
||||
const cell = g.level.cells.find(c => c.q === step.q && c.r === step.r);
|
||||
const ports = [step.enter ?? cell.tracks[step.exit], step.exit].map(d => (d - g.orientation + 6) % 6);
|
||||
let q = step.q, r = step.r;
|
||||
for (let turn = 0; turn < (6 - g.orientation) % 6; turn++) [q, r] = [-r || 0, q + r || 0];
|
||||
used.add(pairKey(q, r, ...ports));
|
||||
}
|
||||
}
|
||||
assert.deepEqual(used, featured);
|
||||
});
|
||||
|
||||
test('a turn unlocks the last pair while arrows stay fixed, and undo restores the connection', () => {
|
||||
const g = new Game(FLOWER_LEVEL);
|
||||
for (const action of analysis.actions) {
|
||||
if (action.type === 'rotate') break;
|
||||
assert.equal(g.tap(action.id).status, 'exit');
|
||||
}
|
||||
assert.equal(available(g.level, g.tiles).length, 0);
|
||||
const remaining = structuredClone(g.tiles), centre = g.level.cells.find(c => c.q === 0 && c.r === 0).tracks;
|
||||
assert.equal(g.tap(remaining[0].id).status, 'blocked');
|
||||
assert.equal(g.moves, 2);
|
||||
assert.equal(g.lost, false);
|
||||
assert.equal(g.rotate(1).status, 'rotated');
|
||||
assert.deepEqual(g.tiles, remaining);
|
||||
assert.notDeepEqual(g.level.cells.find(c => c.q === 0 && c.r === 0).tracks, centre);
|
||||
assert.equal(available(g.level, g.tiles).length, 2);
|
||||
assert.ok(g.undo());
|
||||
assert.deepEqual(g.tiles, remaining);
|
||||
assert.deepEqual(g.level.cells.find(c => c.q === 0 && c.r === 0).tracks, centre);
|
||||
});
|
||||
|
||||
test('alternative starting orientations and removal orders still clear the flower', () => {
|
||||
for (let turn = 0; turn < 6; turn++) for (const reverse of [false, true]) {
|
||||
const g = new Game(rotateBoard(FLOWER_LEVEL, turn));
|
||||
for (let step = 0; step < FLOWER_LEVEL.tiles.length; step++) {
|
||||
let free = available(g.level, g.tiles);
|
||||
if (!free.length) { g.rotate(reverse ? -1 : 1); free = available(g.level, g.tiles); }
|
||||
assert.ok(free.length);
|
||||
assert.equal(g.tap((reverse ? free.at(-1) : free[0]).id).status, 'exit');
|
||||
}
|
||||
assert.ok(g.won);
|
||||
assert.equal(g.moves, 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Game, solveRotations, validateLevel, trace, rotateBoard, available } from '../src/core.js';
|
||||
import { PRODUCTION_CAMPAIGN, PRODUCTION_LEVELS } from '../src/levels-v05.js';
|
||||
import { constructProductionCandidate, inspectCandidate } from '../src/production-generator.js';
|
||||
import { canonicalLevelSignature } from '../src/campaign-evidence.js';
|
||||
import { countBends, pointsInward } from '../src/experience-generator.js';
|
||||
|
||||
const manifest = JSON.parse(await readFile(new URL('../levels/v0.5-recipes.json', import.meta.url), 'utf8'));
|
||||
const report = JSON.parse(await readFile(new URL('../levels/v0.5-analysis.json', import.meta.url), 'utf8'));
|
||||
const digest = text => createHash('sha256').update(text).digest('hex');
|
||||
|
||||
test('1000 published layouts have matching data, unique whole-scene structures and complete formal replays', async () => {
|
||||
assert.equal(PRODUCTION_LEVELS.length, 1000);
|
||||
assert.deepEqual(PRODUCTION_LEVELS, JSON.parse(await readFile(new URL('../levels/v0.5.json', import.meta.url), 'utf8')));
|
||||
assert.equal(PRODUCTION_CAMPAIGN.chapters.length * PRODUCTION_CAMPAIGN.chapterSize, 1000);
|
||||
const unique = new Set();
|
||||
for (const [index, level] of PRODUCTION_LEVELS.entries()) {
|
||||
assert.deepEqual(validateLevel(level), [], level.id);
|
||||
const hash = digest(canonicalLevelSignature(level));
|
||||
assert.ok(!unique.has(hash), level.id); unique.add(hash);
|
||||
assert.equal(hash, manifest.recipes[index].layoutHash);
|
||||
assert.equal(level.chapter, Math.floor(index / 50) + 1);
|
||||
const solution = solveRotations(level);
|
||||
assert.ok(solution.solvable && !solution.exhausted, level.id);
|
||||
assert.equal(solution.rotations, level.production.difficulty.requiredRotations, level.id);
|
||||
for (const actions of [solution.actions, level.experience.actions, ...report.levels[index].policies.map(policy => policy.actions)]) {
|
||||
const game = new Game(level);
|
||||
for (const action of actions) assert.equal(action.type === 'rotate' ? game.rotate(action.direction).status : game.tap(action.id).status, action.type === 'rotate' ? 'rotated' : 'exit', level.id);
|
||||
assert.ok(game.won, level.id); assert.equal(game.moves, 0, level.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('each advertised experience has actual route and release evidence', () => {
|
||||
for (const [index, level] of PRODUCTION_LEVELS.entries()) {
|
||||
const slot = manifest.recipes[index].slot, row = report.levels[index], hero = level.tiles[0];
|
||||
assert.ok(!inspectCandidate(level, slot).rejected, level.id);
|
||||
assert.ok(row.authored.won && row.reference.won, level.id);
|
||||
assert.equal(row.policies.length, 20);
|
||||
assert.ok(row.policies.every(p => p.won), level.id);
|
||||
const corePolicies = row.policies.slice(0, 6);
|
||||
if (index > 1) assert.ok(corePolicies.filter(p => p.longPaths > 0).length >= 5, level.id);
|
||||
const isPrimaryLong = p => p.paths.some(path => path.id === hero.id && path.bends >= slot.minHeroBends);
|
||||
assert.ok(corePolicies.filter(isPrimaryLong).length >= 4, level.id);
|
||||
assert.ok(row.policies.filter(p => p.longPaths > 0).length >= 16, level.id);
|
||||
assert.ok(row.policies.filter(isPrimaryLong).length >= 10, level.id);
|
||||
assert.ok(row.shapeUsage.traveledPairs >= 4, level.id);
|
||||
assert.ok(row.shapeUsage.heroFeaturedPairs >= Math.min(6, slot.minHeroBends), level.id);
|
||||
assert.ok(row.shapeUsage.heroFeaturedRatio >= .6, level.id);
|
||||
if (level.experience.kind === 'tour') assert.equal(available(level).length, level.tiles.length, level.id);
|
||||
if (level.experience.kind === 'contrast') {
|
||||
assert.ok(pointsInward(hero), level.id); assert.equal(trace(level, hero).status, 'blocked', level.id);
|
||||
const after = trace(rotateBoard(level), hero); assert.equal(after.status, 'exit', level.id);
|
||||
assert.ok(countBends(after) >= slot.minHeroBends, level.id);
|
||||
assert.ok(row.difficulty.requiredRotations >= 1, level.id);
|
||||
assert.ok(corePolicies.filter(p => p.fulfilledReveals.some(e => e.id === hero.id && e.bends >= slot.minHeroBends)).length >= (slot.number <= 30 ? 4 : 3), level.id);
|
||||
assert.ok(row.policies.filter(p => p.fulfilledReveals.some(e => e.id === hero.id && e.bends >= slot.minHeroBends)).length >= 6, level.id);
|
||||
}
|
||||
if (level.experience.kind === 'gate') {
|
||||
assert.ok(corePolicies.filter(p => p.fulfilledUnlocks.some(e => e.id === hero.id && e.onRoute && e.bends >= slot.minHeroBends)).length >= 4, level.id);
|
||||
assert.ok(row.policies.filter(p => p.fulfilledUnlocks.some(e => e.id === hero.id && e.onRoute && e.bends >= slot.minHeroBends)).length >= 8, level.id);
|
||||
assert.equal(trace(level, level.tiles.find(t => t.id === 't1')).status, 'exit', level.id);
|
||||
assert.equal(trace(level, hero, level.tiles.filter(t => t.id !== 't1')).status, 'exit', level.id);
|
||||
}
|
||||
if (['turn', 'stages'].includes(level.experience.kind)) assert.ok(row.difficulty.requiredRotations > 0, level.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('construction recipes reproduce early lessons and samples across every chapter', () => {
|
||||
const indices = [...new Set([...Array.from({ length: 10 }, (_, i) => i), ...Array.from({ length: 20 }, (_, i) => i * 50 + 49), 188, 360, 774, 797, 825, 848, 890, 964])];
|
||||
for (const index of indices) {
|
||||
const recipe = manifest.recipes[index], original = PRODUCTION_LEVELS[index];
|
||||
const { level } = constructProductionCandidate(recipe.slot, recipe.attempt);
|
||||
assert.ok(level, recipe.id);
|
||||
assert.deepEqual(level.cells, original.cells, recipe.id);
|
||||
assert.deepEqual(level.tiles, original.tiles, recipe.id);
|
||||
assert.deepEqual(level.experience.actions, original.experience.actions, recipe.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('the reviewed chapter ending has a woven breather before the layered flower', () => {
|
||||
assert.equal(PRODUCTION_LEVELS[848].production.family, 'weave');
|
||||
assert.equal(PRODUCTION_LEVELS[848].experience.kind, 'tour');
|
||||
assert.equal(PRODUCTION_LEVELS[849].production.family, 'petals');
|
||||
assert.equal(PRODUCTION_LEVELS[849].experience.kind, 'stages');
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Game, available, solveRotations } from '../src/core.js';
|
||||
import { campaignVersion } from '../src/campaign-versions.js';
|
||||
import { layout, center } from '../src/geometry.js';
|
||||
import { CanvasPainter } from '../src/renderer.js';
|
||||
|
||||
// Run the actual main-game pointer handlers and frame loop with virtual time.
|
||||
// Checking TapQueue alone misses the regression where taps wait for animation.
|
||||
test('main game starts rapid departures together and safely finishes or resets them',async t=>{
|
||||
const keys=['document','window','location','performance','localStorage','devicePixelRatio','matchMedia','Image','ResizeObserver','MutationObserver','requestAnimationFrame','cancelAnimationFrame','setTimeout','clearTimeout'];
|
||||
const saved=new Map(keys.map(k=>[k,Object.getOwnPropertyDescriptor(globalThis,k)]));
|
||||
const elements=new Map(),frames=new Map(),timers=new Map(),actions=[];
|
||||
let now=0,serial=0,sprites=[];
|
||||
const rect={left:0,top:0,width:700,height:700};
|
||||
const context=new Proxy({clearRect(){sprites=[];}},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});
|
||||
function element(){
|
||||
const listeners=new Map();
|
||||
return {style:{setProperty(){}},dataset:{},classList:{add(){},remove(){},toggle(){},contains(){return false;}},
|
||||
textContent:'',innerHTML:'',hidden:false,open:false,disabled:false,
|
||||
addEventListener(name,fn){if(!listeners.has(name))listeners.set(name,[]);listeners.get(name).push(fn);},
|
||||
emit(name,event={}){for(const fn of listeners.get(name)||[])fn(event);},
|
||||
getBoundingClientRect:()=>rect,getContext:()=>context,setPointerCapture(){},
|
||||
querySelector:()=>get('dialog-close'),querySelectorAll:()=>[element(),element(),element()],
|
||||
insertBefore(){},setAttribute(){},focus(){},
|
||||
showModal(){this.open=true;},close(){this.open=false;this.emit('close');},
|
||||
click(){if(!this.disabled)this.onclick?.();},
|
||||
};
|
||||
}
|
||||
function get(id){if(!elements.has(id))elements.set(id,element());return elements.get(id);}
|
||||
get('board').parentElement=element();
|
||||
const slots=new Map([['hex-trails-campaign-version','v0.1']]);
|
||||
const storage={getItem:key=>slots.get(key)??null,setItem:(key,value)=>slots.set(key,value)};
|
||||
const level=campaignVersion('v0.1').levels[0],view=layout(level.cells,700,700);
|
||||
const originalTap=Game.prototype.tap,originalSprite=CanvasPainter.prototype.sprite;
|
||||
const clickTile=id=>{
|
||||
const p=center(level.tiles.find(t=>t.id===id),view),event={clientX:p.x,clientY:p.y,pointerId:1};
|
||||
get('board').emit('pointerdown',event);get('board').emit('pointerup',event);
|
||||
};
|
||||
const advance=ms=>{
|
||||
now+=ms;
|
||||
const pending=[...frames.values()];frames.clear();for(const callback of pending)callback(now);
|
||||
for(const [id,timer] of [...timers])if(timer.at<=now){timers.delete(id);timer.fn();}
|
||||
};
|
||||
const remaining=()=>Number(get('moves').textContent);
|
||||
const reset=()=>{get('reset-button').click();actions.length=0;};
|
||||
try{
|
||||
const replacements={document:{getElementById:get,createElement:element,querySelectorAll:()=>[],addEventListener(){},body:element()},
|
||||
window:{localStorage:storage},localStorage:storage,location:{search:''},performance:{now:()=>now},devicePixelRatio:1,
|
||||
matchMedia:()=>({matches:false}),Image:class {},ResizeObserver:class {observe(){}},MutationObserver:class {observe(){}},
|
||||
requestAnimationFrame:fn=>{frames.set(++serial,fn);return serial;},cancelAnimationFrame:id=>frames.delete(id),
|
||||
setTimeout:(fn,delay)=>{timers.set(++serial,{fn,at:now+delay});return serial;},clearTimeout:id=>timers.delete(id)};
|
||||
for(const [key,value] of Object.entries(replacements))Object.defineProperty(globalThis,key,{configurable:true,writable:true,value});
|
||||
Game.prototype.tap=function(id){const result=originalTap.call(this,id);actions.push({id,status:result.status});return result;};
|
||||
CanvasPainter.prototype.sprite=function(color,x,y,r){sprites.push({color,x,y,r});return true;};
|
||||
await import('../src/app.js');
|
||||
|
||||
await t.test('three rapid taps all start before any frame finishes, without duplicate sprites',()=>{
|
||||
const free=available(level).map(t=>t.id);assert.equal(free.length,3);
|
||||
for(const id of free){clickTile(id);clickTile(id);}
|
||||
assert.equal(remaining(),2,'all three logical departures happen immediately');
|
||||
assert.deepEqual(actions,free.map(id=>({id,status:'exit'})));
|
||||
advance(40);
|
||||
assert.equal(sprites.length,5,'three moving tiles and two parked tiles remain visible');
|
||||
for(const id of free){
|
||||
const at=center(level.tiles.find(t=>t.id===id),view);
|
||||
assert.ok(sprites.every(p=>Math.hypot(p.x-at.x,p.y-at.y)>1),'each departed tile has already moved');
|
||||
}
|
||||
assert.equal(get('undo-button').disabled,true);
|
||||
advance(30000);get('undo-button').click();assert.equal(remaining(),3);
|
||||
advance(30000);assert.equal(remaining(),3,'undo does not replay a queued tap');
|
||||
});
|
||||
|
||||
await t.test('a newly unblocked tile can depart while its former blocker is still animating',()=>{
|
||||
reset();clickTile('t0');clickTile('t1');
|
||||
assert.deepEqual(actions,[{id:'t0',status:'exit'},{id:'t1',status:'exit'}]);
|
||||
assert.equal(remaining(),3);
|
||||
});
|
||||
|
||||
await t.test('failed probes keep their collision feedback and do not hold up other departures',()=>{
|
||||
reset();clickTile('t1');clickTile('t1');clickTile('t2');clickTile('t4');
|
||||
assert.deepEqual(actions,[{id:'t1',status:'blocked'},{id:'t2',status:'exit'},{id:'t4',status:'exit'}]);
|
||||
assert.equal(remaining(),3);
|
||||
advance(700);assert.equal(remaining(),3);assert.equal(sprites.length,3,'failed tile is drawn once alongside the two parked tiles');
|
||||
advance(30000);get('undo-button').click();assert.equal(remaining(),4);
|
||||
get('undo-button').click();assert.equal(remaining(),5);
|
||||
get('undo-button').click();assert.equal(remaining(),5,'failed probe is undoable without removing a tile');
|
||||
});
|
||||
|
||||
await t.test('reset cancels every active departure and pending frame',()=>{
|
||||
reset();clickTile('t0');clickTile('t2');reset();advance(30000);
|
||||
assert.equal(remaining(),5);assert.equal(get('result').open,false);assert.deepEqual(actions,[]);
|
||||
assert.equal(frames.size,0);
|
||||
});
|
||||
|
||||
await t.test('rotation holds input until tracks settle, then releases the whole burst immediately',()=>{
|
||||
reset();get('rotate-right').click();clickTile('t3');clickTile('t2');
|
||||
assert.deepEqual(actions,[]);
|
||||
advance(300);
|
||||
assert.equal(actions.length,2,'both clicks dispatch in the same frame after rotation');
|
||||
reset();advance(30000);
|
||||
});
|
||||
|
||||
await t.test('the result waits for every departure, and switching levels cancels old animations',()=>{
|
||||
reset();
|
||||
const plan=solveRotations(level);
|
||||
for(const action of plan.actions){
|
||||
if(action.type==='rotate'){advance(30000);get(action.direction===1?'rotate-right':'rotate-left').click();advance(300);}
|
||||
else clickTile(action.id);
|
||||
}
|
||||
assert.equal(remaining(),0);assert.equal(get('result').open,false);
|
||||
advance(30000);assert.equal(get('result').open,true);
|
||||
reset();clickTile('t0');clickTile('t2');
|
||||
get('dialog-content').emit('click',{target:{closest:selector=>selector==='[data-pick]'?{dataset:{pick:'1'}}:null}});
|
||||
const next=campaignVersion('v0.1').levels[1];assert.equal(remaining(),next.tiles.length);
|
||||
advance(30000);assert.equal(remaining(),next.tiles.length);assert.equal(get('result').open,false);
|
||||
});
|
||||
}finally{
|
||||
Game.prototype.tap=originalTap;CanvasPainter.prototype.sprite=originalSprite;
|
||||
for(const [key,descriptor] of saved){if(descriptor)Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { canRotate, opposite, rotateBoard, trace, validateLevel } from '../src/core.js';
|
||||
import { createRouteBoard, ROUTE_FAMILIES } from '../src/route-grammar.js';
|
||||
|
||||
const pairKey = (q, r, a, b) => `${q},${r}:${Math.min(a, b)},${Math.max(a, b)}`;
|
||||
const boardKey = board => board.cells.map(cell => `${cell.q},${cell.r}:${cell.tracks.join('')}`).sort().join('|');
|
||||
const featuredKey = board => board.routeDesign.featured.map(pair => pairKey(pair.q, pair.r, pair.a, pair.b)).sort().join('|');
|
||||
|
||||
for (const { id: family } of ROUTE_FAMILIES) {
|
||||
test(`${family}: complete rotating boards contain real open hero paths at every supported size`, () => {
|
||||
for (const radius of [2, 3, 4, 5]) for (const seed of [0, 1, 7, 42, 71, 95]) {
|
||||
const board = createRouteBoard(seed, { family, radius });
|
||||
const label = `${family}, radius ${radius}, seed ${seed}`;
|
||||
assert.deepEqual(validateLevel(board), [], label);
|
||||
assert.equal(board.cells.length, 1 + 3 * radius * (radius + 1), label);
|
||||
assert.equal(canRotate(board), true, label);
|
||||
assert.equal(boardKey(rotateBoard(board, 6)), boardKey(board), label);
|
||||
assert.deepEqual(board.tiles, [], label);
|
||||
assert.ok(board.routeDesign.heroStarts.length > 0, label);
|
||||
|
||||
const boardCells = new Map(board.cells.map(cell => [`${cell.q},${cell.r}`, cell]));
|
||||
const covered = new Set();
|
||||
let longestBends = 0;
|
||||
for (const [index, hero] of board.routeDesign.heroStarts.entries()) {
|
||||
const tile = { ...hero, id: `hero-${index}`, color: 0 };
|
||||
// Exercise the production tracer, including swept geometry, not the
|
||||
// grammar's own inexpensive route scan used to propose candidates.
|
||||
const route = trace(board, tile, [tile]);
|
||||
assert.equal(route.status, 'exit', label);
|
||||
assert.equal(route.steps.length, hero.steps, label);
|
||||
const bends = route.steps.filter(step => step.enter !== null && opposite(step.enter) !== step.exit).length;
|
||||
assert.equal(bends, hero.bends, label);
|
||||
longestBends = Math.max(longestBends, bends);
|
||||
for (const step of route.steps) if (step.enter !== null) covered.add(pairKey(step.q, step.r, step.enter, step.exit));
|
||||
}
|
||||
assert.ok(longestBends >= 7, `${label}: actual open path should sustain a long curved run`);
|
||||
assert.ok(board.routeDesign.featured.length > 0, label);
|
||||
for (const pair of board.routeDesign.featured) {
|
||||
const cell = boardCells.get(`${pair.q},${pair.r}`);
|
||||
assert.equal(cell?.tracks[pair.a], pair.b, `${label}: emphasized rail must be an actual pair`);
|
||||
assert.ok(covered.has(pairKey(pair.q, pair.r, pair.a, pair.b)), `${label}: emphasized rail must have a supplied open-path witness`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(`${family}: variant changes the authored route and remains deterministic`, () => {
|
||||
const original = createRouteBoard(42, { family, radius: 3, variant: 0 });
|
||||
const variant = createRouteBoard(42, { family, radius: 3, variant: 1 });
|
||||
assert.deepEqual(createRouteBoard(42, { family, radius: 3, variant: 0 }), original);
|
||||
assert.notEqual(featuredKey(original), featuredKey(variant), 'A variant changes real featured routing, not only background fill');
|
||||
assert.notEqual(boardKey(original), boardKey(rotateBoard(original)), 'The grammar must allow rotation to change paths');
|
||||
});
|
||||
}
|
||||
|
||||
test('the six route families are geometrically different even after whole-board rotation', () => {
|
||||
const boards = ROUTE_FAMILIES.map(({ id }) => createRouteBoard(42, { family: id, radius: 3 }));
|
||||
const canonical = boards.map(board => Array.from({ length: 6 }, (_, turn) => boardKey(rotateBoard(board, turn))).sort()[0]);
|
||||
assert.equal(new Set(canonical).size, ROUTE_FAMILIES.length);
|
||||
});
|
||||
|
||||
test('route grammar rejects unsupported board requests', () => {
|
||||
assert.throws(() => createRouteBoard(1, { family: 'unknown' }), /Unknown route family/);
|
||||
for (const radius of [1, 6, 2.5]) assert.throws(() => createRouteBoard(1, { radius }), /radius/);
|
||||
assert.throws(() => createRouteBoard(1, { variant: 0.5 }), /variant/);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Game, rotateBoard, solveRotations, trace, validateLevel } from '../src/core.js';
|
||||
import { createRouteBoard, ROUTE_FAMILIES } from '../src/route-grammar.js';
|
||||
import { createStageSeed } from '../src/stage-seed.js';
|
||||
|
||||
for (const { id: family } of ROUTE_FAMILIES) {
|
||||
test(`${family}: two-turn lock is witnessed by actual state changes and reversible insertion`, () => {
|
||||
for (const radius of [3, 4, 5]) {
|
||||
const seed = family === 'switchback' ? ({ 3: 2, 4: 0, 5: 3 })[radius] : family === 'spiral' && radius === 3 ? 1 : 0;
|
||||
const base = createRouteBoard(seed, { family, radius });
|
||||
const snapshot = JSON.stringify(base);
|
||||
const lock = createStageSeed(base, { seed });
|
||||
assert.ok(lock, `${family}, radius ${radius}, seed ${seed}`);
|
||||
assert.equal(JSON.stringify(base), snapshot, 'The helper must not alter its input route');
|
||||
assert.equal(lock.minimumRotations, 2);
|
||||
assert.ok(lock.heroRoute.bends >= 7);
|
||||
assert.ok(lock.heroRoute.featuredPairs >= 6);
|
||||
assert.ok(lock.heroRoute.featuredRatio >= .6);
|
||||
|
||||
const level = { ...base, tiles: lock.tiles, moveLimit: lock.tiles.length };
|
||||
assert.deepEqual(validateLevel(level), []);
|
||||
assert.equal(solveRotations(level).rotations, 2, 'Both one-turn directions must fail to clear the lock');
|
||||
const [a, b, c] = lock.tiles;
|
||||
assert.ok(lock.tiles.every(tile => trace(level, tile).status === 'blocked'));
|
||||
const turned = rotateBoard(level);
|
||||
assert.equal(trace(turned, b).status, 'exit');
|
||||
assert.equal(trace(turned, a, [a, c]).blocker, c.id);
|
||||
assert.equal(trace(turned, c, [a, c]).blocker, a.id);
|
||||
assert.equal(trace(level, a, [a, c]).status, 'exit');
|
||||
assert.equal(trace(level, c, [c]).status, 'exit');
|
||||
|
||||
let inserted = [];
|
||||
for (const { id, orientation } of lock.reverseInsertion) {
|
||||
const tile = lock.tiles.find(candidate => candidate.id === id);
|
||||
inserted = [...inserted, tile];
|
||||
assert.equal(trace(rotateBoard(base, orientation), tile, inserted).status, 'exit');
|
||||
}
|
||||
const game = new Game(level);
|
||||
for (const action of lock.actions) {
|
||||
if (action.type === 'rotate') game.rotate(action.direction);
|
||||
else assert.equal(game.tap(action.id).status, 'exit');
|
||||
}
|
||||
assert.equal(game.tiles.length, 0, 'The authored two-turn witness must fully clear the board');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('stage search is deterministic and an unmet constraint returns no lock', () => {
|
||||
const base = createRouteBoard(0, { family: 'orbits', radius: 3 });
|
||||
assert.deepEqual(createStageSeed(base, { seed: 17 }), createStageSeed(base, { seed: 17 }));
|
||||
assert.equal(createStageSeed(base, { minHeroBends: 999 }), null);
|
||||
assert.equal(createStageSeed(base, { maxChecks: 0 }), null);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Game } from '../src/core.js';
|
||||
import { campaignVersion } from '../src/campaign-versions.js';
|
||||
import { center, layout } from '../src/geometry.js';
|
||||
import { CanvasPainter } from '../src/renderer.js';
|
||||
import { tutorialProgressKey } from '../src/tutorial-progress.js';
|
||||
|
||||
const campaign = campaignVersion('v0.5');
|
||||
const officialKey = 'hex-trails-campaign-v0.5';
|
||||
const teachingKey = tutorialProgressKey('v0.5');
|
||||
let importId = 0;
|
||||
|
||||
// Exercise app.js pointer handlers, result buttons and persistence with a virtual clock.
|
||||
async function withApp({search = '?campaign=v0.5', entries = {}}, run) {
|
||||
const keys = ['document', 'window', 'location', 'performance', 'localStorage', 'devicePixelRatio', 'matchMedia', 'Image', 'ResizeObserver', 'MutationObserver', 'requestAnimationFrame', 'cancelAnimationFrame', 'setTimeout', 'clearTimeout'];
|
||||
const saved = new Map(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
const elements = new Map(), frames = new Map(), timers = new Map(), slots = new Map(Object.entries(entries));
|
||||
const actions = [], writes = [];
|
||||
let now = 0, serial = 0;
|
||||
const rect = {left: 0, top: 0, width: 700, height: 700};
|
||||
const context = new Proxy({}, {get: (object, key) => object[key] ?? (() => {}), set: (object, key, value) => (object[key] = value, true)});
|
||||
function element() {
|
||||
const listeners = new Map(), classes = new Set();
|
||||
return {
|
||||
style: {setProperty() {}}, dataset: {}, textContent: '', innerHTML: '', hidden: false, open: false, disabled: false,
|
||||
classList: {
|
||||
add(...names) { for (const name of names) classes.add(name); },
|
||||
remove(...names) { for (const name of names) classes.delete(name); },
|
||||
contains: name => classes.has(name),
|
||||
toggle(name, force) { const added = force ?? !classes.has(name); if (added) classes.add(name); else classes.delete(name); return added; },
|
||||
},
|
||||
addEventListener(name, callback) { if (!listeners.has(name)) listeners.set(name, []); listeners.get(name).push(callback); },
|
||||
emit(name, event = {}) { for (const callback of listeners.get(name) ?? []) callback(event); },
|
||||
getBoundingClientRect: () => rect, getContext: () => context, setPointerCapture() {},
|
||||
querySelector: () => get('dialog-close'), querySelectorAll: () => [element(), element(), element()],
|
||||
insertBefore() {}, setAttribute() {}, focus() {},
|
||||
showModal() { this.open = true; }, close() { this.open = false; this.emit('close'); },
|
||||
click() { if (!this.disabled) this.onclick?.(); },
|
||||
};
|
||||
}
|
||||
function get(id) { if (!elements.has(id)) elements.set(id, element()); return elements.get(id); }
|
||||
get('board').parentElement = element();
|
||||
const storage = {getItem: key => slots.get(key) ?? null, setItem(key, value) { slots.set(key, value); writes.push(key); }};
|
||||
const advance = ms => {
|
||||
now += ms;
|
||||
const pending = [...frames.values()]; frames.clear();
|
||||
for (const callback of pending) callback(now);
|
||||
for (const [id, timer] of [...timers]) if (timer.at <= now) { timers.delete(id); timer.callback(); }
|
||||
};
|
||||
const clickTile = (level, id) => {
|
||||
const tile = level.tiles.find(tile => tile.id === id);
|
||||
assert.ok(tile, `fixture contains ${id}`);
|
||||
const point = center(tile, layout(level.cells, rect.width, rect.height));
|
||||
const event = {clientX: point.x, clientY: point.y, pointerId: 1};
|
||||
get('board').emit('pointerdown', event); get('board').emit('pointerup', event);
|
||||
};
|
||||
const originalTap = Game.prototype.tap, originalRotate = Game.prototype.rotate, originalSprite = CanvasPainter.prototype.sprite;
|
||||
const replacements = {
|
||||
document: {getElementById: get, createElement: element, querySelectorAll: () => [], addEventListener() {}, body: element()},
|
||||
window: {localStorage: storage, scrollTo() {}}, localStorage: storage, location: {search}, performance: {now: () => now}, devicePixelRatio: 1,
|
||||
matchMedia: () => ({matches: false}), Image: class {}, ResizeObserver: class {observe() {}}, MutationObserver: class {observe() {}},
|
||||
requestAnimationFrame: callback => { frames.set(++serial, callback); return serial; }, cancelAnimationFrame: id => frames.delete(id),
|
||||
setTimeout: (callback, delay) => { timers.set(++serial, {callback, at: now + delay}); return serial; }, clearTimeout: id => timers.delete(id),
|
||||
};
|
||||
try {
|
||||
for (const [key, value] of Object.entries(replacements)) Object.defineProperty(globalThis, key, {configurable: true, writable: true, value});
|
||||
Game.prototype.tap = function(id) {
|
||||
const result = originalTap.call(this, id);
|
||||
actions.push({type: 'tap', id, status: result.status, at: now, moves: this.moves});
|
||||
return result;
|
||||
};
|
||||
Game.prototype.rotate = function(direction) {
|
||||
const before = JSON.stringify(this.tiles), result = originalRotate.call(this, direction);
|
||||
actions.push({type: 'rotate', direction, status: result.status, before, after: JSON.stringify(this.tiles), moves: this.moves});
|
||||
return result;
|
||||
};
|
||||
CanvasPainter.prototype.sprite = () => true;
|
||||
await import(`../src/app.js?tutorial-app-test=${++importId}`);
|
||||
await run({get, advance, clickTile, actions, writes, slots, read: key => slots.has(key) ? JSON.parse(slots.get(key)) : null});
|
||||
} finally {
|
||||
Game.prototype.tap = originalTap; Game.prototype.rotate = originalRotate; CanvasPainter.prototype.sprite = originalSprite;
|
||||
for (const [key, descriptor] of saved) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else delete globalThis[key]; }
|
||||
}
|
||||
}
|
||||
|
||||
test('three actual tutorial pointer flows teach departure, clearing a blocker and rotation before LEVEL 01', async () => {
|
||||
await withApp({}, ({get, advance, clickTile, actions, writes, read}) => {
|
||||
const [first, second, third] = campaign.tutorials;
|
||||
const remaining = () => Number(get('moves').textContent);
|
||||
assert.equal(get('level-eyebrow').textContent, '入门 1 / 3');
|
||||
assert.equal(remaining(), 1);
|
||||
assert.equal(get('rotate-left').disabled, true); assert.equal(get('rotate-right').disabled, true);
|
||||
assert.equal(get('board-toast').textContent, first.lesson);
|
||||
assert.equal(get('board-toast').classList.contains('visible'), true);
|
||||
advance(6500);
|
||||
assert.equal(get('board-toast').classList.contains('visible'), false, 'teaching uses the existing timed lesson notice');
|
||||
assert.deepEqual(writes, [], 'loading the introduction alone writes no progress');
|
||||
clickTile(first, 'traveler');
|
||||
assert.equal(actions.at(-1).status, 'exit'); assert.equal(remaining(), 0);
|
||||
assert.equal(get('result').open, false, 'completion waits for the visible departure');
|
||||
advance(30000);
|
||||
assert.equal(get('result').open, true);
|
||||
assert.deepEqual(read(teachingKey), {current: 1, completed: [0]});
|
||||
assert.equal(read(officialKey), null);
|
||||
|
||||
get('next-level').click();
|
||||
assert.equal(get('level-eyebrow').textContent, '入门 2 / 3'); assert.equal(remaining(), 2);
|
||||
clickTile(second, 'traveler');
|
||||
assert.equal(actions.at(-1).status, 'blocked'); assert.equal(actions.at(-1).moves, 2);
|
||||
assert.equal(remaining(), 2, 'trying the blocked tile costs no departure or piece');
|
||||
advance(1000);
|
||||
clickTile(second, 'gate'); clickTile(second, 'traveler');
|
||||
assert.deepEqual(actions.slice(-2).map(action => [action.id, action.status]), [['gate', 'exit'], ['traveler', 'exit']]);
|
||||
assert.equal(actions.at(-1).at, actions.at(-2).at, 'the newly opened tile departs without waiting for the gate animation');
|
||||
assert.equal(remaining(), 0); assert.equal(get('result').open, false);
|
||||
advance(30000);
|
||||
assert.deepEqual(read(teachingKey), {current: 2, completed: [0, 1]});
|
||||
assert.equal(read(officialKey), null);
|
||||
|
||||
get('next-level').click();
|
||||
assert.equal(get('level-eyebrow').textContent, '入门 3 / 3'); assert.equal(remaining(), 2);
|
||||
assert.equal(get('rotate-right').disabled, false);
|
||||
get('rotate-right').click();
|
||||
const rotation = actions.at(-1);
|
||||
assert.equal(rotation.status, 'rotated'); assert.equal(rotation.before, rotation.after, 'only the tracks turn; arrows and pieces stay put');
|
||||
assert.equal(rotation.moves, 2, 'rotation is free');
|
||||
advance(5000);
|
||||
assert.match(get('board-toast').textContent, /出口打开了/);
|
||||
assert.equal(get('board-toast').classList.contains('visible'), false, 'rotation feedback keeps the ordinary toast timeout');
|
||||
clickTile(third, 'north'); clickTile(third, 'south');
|
||||
assert.deepEqual(actions.slice(-2).map(action => action.status), ['exit', 'exit']);
|
||||
assert.equal(remaining(), 0); advance(30000);
|
||||
assert.equal(get('result').open, true);
|
||||
assert.deepEqual(read(teachingKey), {current: 2, completed: [0, 1, 2]});
|
||||
assert.equal(read(officialKey), null, 'all three lessons are separate from formal completion');
|
||||
assert.ok(writes.every(key => key === teachingKey));
|
||||
assert.match(get('result').innerHTML, /开始第 1 关/);
|
||||
get('next-level').click();
|
||||
assert.equal(get('level-eyebrow').textContent, 'LEVEL 01');
|
||||
assert.equal(remaining(), campaign.levels[0].tiles.length);
|
||||
assert.deepEqual(read(officialKey), {current: 0, completed: []});
|
||||
assert.equal(get('progress-label').textContent, '0 / 1000');
|
||||
assert.equal(get('result').open, false);
|
||||
});
|
||||
});
|
||||
|
||||
test('replaying lesson 3 leaves an existing level 20 save intact and never completes level 20', async () => {
|
||||
const previous = {current: 19, completed: [0, 1]}, previousRaw = JSON.stringify(previous);
|
||||
await withApp({search: '?campaign=v0.5&tutorial=3', entries: {[officialKey]: previousRaw}}, ({get, advance, clickTile, slots, read}) => {
|
||||
const third = campaign.tutorials[2];
|
||||
assert.equal(get('level-eyebrow').textContent, '入门 3 / 3');
|
||||
assert.equal(slots.get(officialKey), previousRaw);
|
||||
for (let replay = 0; replay < 2; replay += 1) {
|
||||
get('rotate-left').click(); advance(1000);
|
||||
clickTile(third, 'north'); clickTile(third, 'south'); advance(30000);
|
||||
assert.equal(get('result').open, true);
|
||||
assert.equal(slots.get(officialKey), previousRaw, 'finishing or replaying a lesson does not save the current official level as completed');
|
||||
assert.equal(get('progress-label').textContent, '2 / 1000');
|
||||
if (!replay) {
|
||||
get('replay-level').click();
|
||||
assert.equal(get('level-eyebrow').textContent, '入门 3 / 3');
|
||||
assert.equal(Number(get('moves').textContent), 2);
|
||||
assert.equal(slots.get(officialKey), previousRaw);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(read(teachingKey), {current: 2, completed: [2]});
|
||||
get('next-level').click();
|
||||
assert.equal(get('level-eyebrow').textContent, 'LEVEL 01');
|
||||
assert.deepEqual(read(officialKey), {current: 0, completed: [0, 1]});
|
||||
assert.equal(read(officialKey).completed.includes(19), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('the map can reopen a tutorial from an explicit official level without changing official progress', async () => {
|
||||
const previous = {current: 19, completed: [0, 1]}, previousRaw = JSON.stringify(previous);
|
||||
await withApp({search: '?campaign=v0.5&level=20', entries: {[officialKey]: previousRaw}}, ({get, advance, clickTile, slots, read}) => {
|
||||
assert.equal(get('level-eyebrow').textContent, 'LEVEL 20');
|
||||
get('map-button').click();
|
||||
assert.equal(get('info-dialog').open, true);
|
||||
for (let index = 0; index < 3; index += 1) assert.ok(get('dialog-content').innerHTML.includes(`data-tutorial-pick="${index}"`));
|
||||
get('dialog-content').emit('click', {target: {closest: selector => selector === '[data-tutorial-pick]' ? {dataset: {tutorialPick: '1'}} : null}});
|
||||
assert.equal(get('info-dialog').open, false);
|
||||
assert.equal(get('level-eyebrow').textContent, '入门 2 / 3');
|
||||
assert.equal(slots.get(officialKey), previousRaw);
|
||||
clickTile(campaign.tutorials[1], 'gate'); clickTile(campaign.tutorials[1], 'traveler'); advance(30000);
|
||||
assert.equal(get('result').open, true);
|
||||
assert.deepEqual(read(teachingKey), {current: 2, completed: [1]});
|
||||
assert.equal(slots.get(officialKey), previousRaw);
|
||||
});
|
||||
});
|
||||
|
||||
test('switching editions into the introduction remembers the edition across reloads', async () => {
|
||||
let reloadedEntries;
|
||||
await withApp({search: '', entries: {
|
||||
'hex-trails-campaign-version': 'v0.3',
|
||||
'hex-trails-campaign-v0.3': JSON.stringify({current: 6, completed: [0]}),
|
||||
}}, ({get, slots, read}) => {
|
||||
assert.equal(get('level-eyebrow').textContent, 'LEVEL 07');
|
||||
get('map-button').click();
|
||||
get('dialog-content').emit('change', {target: {id: 'campaign-version', value: 'v0.5'}});
|
||||
assert.equal(get('level-eyebrow').textContent, '入门 1 / 3');
|
||||
assert.equal(slots.get('hex-trails-campaign-version'), 'v0.5');
|
||||
assert.equal(read(officialKey), null, 'selecting the edition does not create official progress');
|
||||
assert.deepEqual(read('hex-trails-campaign-v0.3'), {current: 6, completed: [0]});
|
||||
reloadedEntries = Object.fromEntries(slots);
|
||||
});
|
||||
await withApp({search: '', entries: reloadedEntries}, ({get, slots}) => {
|
||||
assert.equal(get('level-eyebrow').textContent, '入门 1 / 3');
|
||||
assert.equal(slots.get('hex-trails-campaign-version'), 'v0.5');
|
||||
});
|
||||
});
|
||||
|
||||
test('entering the workshop from an introductory lesson uses a supported rotatable campaign board', async () => {
|
||||
await withApp({search: '?campaign=v0.5&tutorial=1'}, ({get, actions}) => {
|
||||
assert.equal(get('level-eyebrow').textContent, '入门 1 / 3');
|
||||
assert.equal(get('rotate-right').disabled, true);
|
||||
get('edit-tab').click();
|
||||
assert.equal(get('level-eyebrow').textContent, 'YOUR CREATION');
|
||||
const expectedRadius = Math.max(...campaign.levels[0].cells.map(cell => Math.max(Math.abs(cell.q), Math.abs(cell.r), Math.abs(cell.q + cell.r))));
|
||||
assert.equal(get('editor-radius').value, String(expectedRadius));
|
||||
assert.ok(expectedRadius >= 2 && expectedRadius <= 6, 'the editor can represent the selected board size');
|
||||
assert.equal(Number(get('editor-moves').value), campaign.levels[0].moveLimit);
|
||||
get('trial-button').click();
|
||||
assert.equal(get('level-eyebrow').textContent, 'PLAYTEST');
|
||||
assert.equal(Number(get('moves').textContent), campaign.levels[0].moveLimit);
|
||||
assert.equal(get('rotate-right').disabled, false, 'the tutorial rotation lock is not inherited by workshop playtests');
|
||||
get('rotate-right').click();
|
||||
assert.equal(actions.at(-1).status, 'rotated');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readTutorialProgress, tutorialLaunch, tutorialProgressKey, writeTutorialProgress } from '../src/tutorial-progress.js';
|
||||
|
||||
function storage(entries = {}) {
|
||||
const items = new Map(Object.entries(entries));
|
||||
const writes = [];
|
||||
return {
|
||||
items,
|
||||
writes,
|
||||
getItem: key => items.get(key) ?? null,
|
||||
setItem(key, value) { writes.push(key); items.set(key, value); },
|
||||
};
|
||||
}
|
||||
|
||||
const campaignId = 'v0.5';
|
||||
const fresh = {current: 0, completed: []};
|
||||
const key = tutorialProgressKey(campaignId);
|
||||
|
||||
test('new visitors start with tutorial 1 without writing any progress', () => {
|
||||
const saved = storage();
|
||||
const progress = readTutorialProgress(saved, campaignId, 3);
|
||||
assert.deepEqual(progress, fresh);
|
||||
assert.equal(tutorialLaunch('', 3, progress, fresh), 0);
|
||||
assert.equal(tutorialLaunch('?campaign=v0.5', 3, progress, fresh), 0);
|
||||
assert.deepEqual(saved.writes, []);
|
||||
assert.equal(key, 'hex-trails-tutorial-v1-v0.5');
|
||||
});
|
||||
|
||||
test('tutorial saves retain valid indices and discard malformed or duplicate entries', () => {
|
||||
const saved = storage({[key]: JSON.stringify({current: 2, completed: [-1, 0, 0, 2, 3, 1.5, '1', null]})});
|
||||
assert.deepEqual(readTutorialProgress(saved, campaignId, 3), {current: 2, completed: [0, 2]});
|
||||
for (const current of [-1, 3, 1.5, '2', null]) {
|
||||
saved.items.set(key, JSON.stringify({current, completed: {0: 1}}));
|
||||
assert.deepEqual(readTutorialProgress(saved, campaignId, 3), fresh);
|
||||
}
|
||||
assert.deepEqual(saved.writes, []);
|
||||
});
|
||||
|
||||
test('bad JSON and non-object saves are harmless and never rewritten during reading', () => {
|
||||
for (const raw of ['{', 'null', '[]', 'true', '"text"', '42']) {
|
||||
const saved = storage({[key]: raw});
|
||||
assert.deepEqual(readTutorialProgress(saved, campaignId, 3), fresh);
|
||||
assert.equal(saved.getItem(key), raw);
|
||||
assert.deepEqual(saved.writes, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('tutorial writes stay separate from campaign progress, historical saves and version selection', () => {
|
||||
const original = {
|
||||
'hex-trails-campaign-v0.5': JSON.stringify({current: 999, completed: [0, 998, 999]}),
|
||||
'hex-trails-observe-v3-r2': JSON.stringify({current: 6, completed: [6]}),
|
||||
'hex-trails-campaign-version': 'v0.4',
|
||||
};
|
||||
const saved = storage(original);
|
||||
assert.equal(writeTutorialProgress(saved, campaignId, 3, {current: 2, completed: [0, 0, 1, 8]}), true);
|
||||
assert.deepEqual(readTutorialProgress(saved, campaignId, 3), {current: 2, completed: [0, 1]});
|
||||
assert.deepEqual(saved.writes, [key]);
|
||||
for (const [name, value] of Object.entries(original)) assert.equal(saved.getItem(name), value);
|
||||
assert.deepEqual(readTutorialProgress(saved, 'v0.4', 3), fresh);
|
||||
});
|
||||
|
||||
test('unavailable storage never blocks loading or playing tutorials', () => {
|
||||
const denied = {getItem() { throw new Error('denied'); }, setItem() { throw new Error('denied'); }};
|
||||
for (const saved of [undefined, null, {}, denied]) {
|
||||
assert.deepEqual(readTutorialProgress(saved, campaignId, 3), fresh);
|
||||
assert.equal(writeTutorialProgress(saved, campaignId, 3, {current: 1, completed: [0]}), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit tutorial links can replay completed lessons and take priority over level links', () => {
|
||||
const done = {current: 2, completed: [0, 1, 2]};
|
||||
const experienced = {current: 999, completed: [0, 999]};
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
assert.equal(tutorialLaunch(`?tutorial=${index + 1}&level=1000`, 3, done, experienced), index);
|
||||
}
|
||||
assert.equal(tutorialLaunch(new URLSearchParams('campaign=v0.5&tutorial=2'), 3, fresh, fresh), 1);
|
||||
});
|
||||
|
||||
test('every explicit campaign level parameter bypasses automatic tutorials', () => {
|
||||
for (const search of ['?level=1', '?level=1000', '?level=', '?level=bad', '?tutorial=9&level=1']) {
|
||||
assert.equal(tutorialLaunch(search, 3, fresh, fresh), null, search);
|
||||
}
|
||||
});
|
||||
|
||||
test('existing campaign players skip automatic tutorials but may request them explicitly', () => {
|
||||
for (const campaignProgress of [{current: 1, completed: []}, {current: 0, completed: [0]}, {current: 999, completed: [999]}]) {
|
||||
assert.equal(tutorialLaunch('', 3, fresh, campaignProgress), null);
|
||||
assert.equal(tutorialLaunch('?tutorial=1', 3, fresh, campaignProgress), 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('unfinished tutorials resume at the saved lesson or the next incomplete lesson', () => {
|
||||
assert.equal(tutorialLaunch('', 3, {current: 1, completed: [0]}, fresh), 1);
|
||||
assert.equal(tutorialLaunch('', 3, {current: 2, completed: [0]}, fresh), 2);
|
||||
assert.equal(tutorialLaunch('', 3, {current: 0, completed: [0]}, fresh), 1);
|
||||
assert.equal(tutorialLaunch('', 3, {current: 2, completed: [0, 2]}, fresh), 1);
|
||||
assert.equal(tutorialLaunch('', 3, {current: 99, completed: [0, 0, -1, 4]}, fresh), 1);
|
||||
assert.equal(tutorialLaunch('', 3, {current: 2, completed: [0, 1, 2]}, fresh), null);
|
||||
});
|
||||
|
||||
test('invalid tutorial parameters follow the normal launch rules', () => {
|
||||
for (const value of ['', '0', '4', '-1', '1.5', '1e0', 'Infinity', 'abc']) {
|
||||
assert.equal(tutorialLaunch(`?tutorial=${value}`, 3, {current: 1, completed: [0]}, fresh), 1, value);
|
||||
assert.equal(tutorialLaunch(`?tutorial=${value}`, 3, fresh, {current: 2, completed: []}), null, value);
|
||||
}
|
||||
});
|
||||
|
||||
test('campaigns without tutorials do not read, write or launch tutorial state', () => {
|
||||
const unreachable = {
|
||||
getItem() { assert.fail('should not read a tutorial slot'); },
|
||||
setItem() { assert.fail('should not write a tutorial slot'); },
|
||||
};
|
||||
for (const count of [0, -1, 1.5, undefined, NaN]) {
|
||||
assert.deepEqual(readTutorialProgress(unreachable, campaignId, count), fresh);
|
||||
assert.equal(writeTutorialProgress(unreachable, campaignId, count, fresh), false);
|
||||
assert.equal(tutorialLaunch('?tutorial=1', count, fresh, fresh), null);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user