Restore v0.1 and v0.2 archives with separate progress, publish the curved v0.3 campaign, and document the original 24-level design with reproducible evidence. Refine board presentation and movement, and keep video lab tooling within its own directory.
80 lines
5.4 KiB
JavaScript
80 lines
5.4 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { PIGMENTS, LEVEL_THEMES, contrast, themeForScore } from '../src/design-tokens.js';
|
|
import { Game, clone, validateLevel, available } from '../src/core.js';
|
|
import { LEVELS } from '../src/levels.js';
|
|
import { drawExit, drawScene, CanvasPainter, exitGeometry } from '../src/renderer.js';
|
|
import { layout } from '../src/geometry.js';
|
|
test('new pigments import without changing route solutions; out-of-range values are rejected',()=>{
|
|
for(let color=0;color<12;color++){const level=clone(LEVELS[0]);level.tiles[0].color=color;assert.deepEqual(validateLevel(level),[]);assert.deepEqual(available(level).map(t=>t.id),available(LEVELS[0]).map(t=>t.id));}
|
|
for(const color of [-1,12,1.5]){const level=clone(LEVELS[0]);level.tiles[0].color=color;assert.ok(validateLevel(level).length);}
|
|
});
|
|
test('scene text and all directional arrows remain readable across curated themes',()=>{
|
|
for(const p of PIGMENTS)assert.ok(contrast(p.arrow,p.color)>=3,p.name);
|
|
for(const t of LEVEL_THEMES){assert.ok(contrast(t.ink,t.background)>=4.5,t.name);assert.ok(t.palette.length>=3&&t.palette.length<=4);for(const i of t.palette)assert.ok(PIGMENTS[i]);}
|
|
assert.deepEqual([29,30,49,50,69,70].map(n=>themeForScore(n).id),['lagoon','apricot','apricot','lavender','lavender','rose']);
|
|
});
|
|
test('theme assignments are stable after removal and undo; all new colors fall back from the four-frame atlas',()=>{
|
|
const game=new Game(LEVELS[0]),view=layout(game.level.cells,390,390),theme=LEVEL_THEMES[1];
|
|
const capture=()=>{const sprites=[];drawScene({polygon(){},line(){},circle(){},sprite(i,x,y){sprites.push([i,x,y]);return true;}},game.level,game.tiles,view,{theme});return sprites;};
|
|
const before=capture();game.tap(available(game.level,game.tiles)[0].id);const after=capture();for(const entry of after)assert.ok(before.some(b=>JSON.stringify(b)===JSON.stringify(entry)));game.undo();assert.deepEqual(capture(),before);
|
|
const painter=new CanvasPainter({});painter.atlas={complete:true,naturalWidth:1293};for(let i=4;i<12;i++)assert.equal(painter.sprite(i,0,0,10),false);
|
|
});
|
|
test('exit fragments stay near the actual board port, finish cleanly, and respect reduced motion',()=>{
|
|
const game=new Game(LEVELS[0]),route=game.tap(available(game.level,game.tiles)[0].id),view=layout(game.level.cells,390,390);
|
|
const shapes=[],p={polygon(pts,c){shapes.push({pts,c});},line(){},circle(){}};
|
|
const edge=exitGeometry(route,view).edge;
|
|
drawExit(p,route,view,.95,{...route.tile,color:10});assert.equal(shapes.length,6);
|
|
for(const s of shapes){assert.equal(s.c,PIGMENTS[10].color);for(const point of s.pts)assert.ok(Math.hypot(point.x-edge.x,point.y-edge.y)<view.size);}
|
|
shapes.length=0;drawExit(p,route,view,1,route.tile);assert.equal(shapes.length,0);
|
|
drawExit(p,route,view,.95,route.tile,true);assert.equal(shapes.length,0);
|
|
});
|
|
|
|
test('edge exits are brisk and longer routes preserve a bounded readable travel phase',async()=>{
|
|
const {exitTiming}=await import('../src/exit-animation.js');
|
|
const edge={status:'exit',steps:[{q:0,r:0,enter:null,exit:0}],end:{q:0,r:-1}};
|
|
const short=exitTiming(edge);
|
|
const long=exitTiming({...edge,steps:[edge.steps[0],{q:0,r:-1,enter:3,exit:0},{q:0,r:-2,enter:3,exit:0}]});
|
|
assert.ok(short.duration>260&&short.duration<400);
|
|
assert.ok(long.duration>short.duration&&long.duration<1200);
|
|
for(const timing of [short,long])assert.ok(Math.abs(timing.duration*(1-timing.travelFraction)-180)<.001);
|
|
});
|
|
|
|
|
|
test('motion ramps gently at both ends, never reverses or overshoots',async()=>{
|
|
const {smoothMotion}=await import('../src/exit-animation.js');
|
|
const positions=Array.from({length:101},(_,i)=>smoothMotion(i/100));
|
|
const steps=positions.slice(1).map((x,i)=>x-positions[i]);
|
|
assert.equal(positions[0],0);assert.equal(positions.at(-1),1);
|
|
assert.ok(steps.every(d=>d>=0));
|
|
assert.ok(steps[0]<steps[49]/10&&steps.at(-1)<steps[49]/10);
|
|
assert.equal(smoothMotion(-1),0);assert.equal(smoothMotion(2),1);
|
|
});
|
|
|
|
|
|
test('curved and long routes share the same cruising speed without a duration cap',async()=>{
|
|
const {exitTiming,travelProgress}=await import('../src/exit-animation.js');
|
|
for(const distance of [1,5,15,30]){
|
|
const timing=exitTiming({travelPoints:[{x:0,y:0},{x:distance,y:0}],steps:[]});
|
|
assert.equal(timing.travelMs,distance*120+Math.min(80,distance*40));
|
|
const t=timing.travelMs*.5,dt=1;
|
|
const speed=(travelProgress((t+dt)/timing.travelMs,timing.travelMs)-travelProgress(t/timing.travelMs,timing.travelMs))*distance;
|
|
assert.ok(Math.abs(speed-1/120)<1e-9);
|
|
assert.equal(travelProgress(0,timing.travelMs),0);assert.equal(travelProgress(1,timing.travelMs),1);
|
|
assert.ok(Math.abs(timing.duration-timing.travelMs-180)<1e-9);
|
|
}
|
|
});
|
|
|
|
test('showcase boards open with a long curved route and can be cleared completely',async()=>{
|
|
const {createStudyLevel}=await import('../src/study-levels.js');
|
|
const {available,trace,Game,validateLevel}=await import('../src/core.js');
|
|
for(const [radius,minLength] of [[3,5],[5,8],[6,11]]){
|
|
const level=createStudyLevel(radius),game=new Game(level);
|
|
assert.deepEqual(validateLevel(level),[]);
|
|
const routes=available(level,level.tiles).map(t=>trace(level,t,level.tiles));
|
|
assert.ok(routes.some(r=>r.steps.length>=minLength&&r.steps.some(s=>s.enter!==null&&(s.enter+3)%6!==s.exit)));
|
|
while(game.tiles.length){const tile=available(game.level,game.tiles)[0];assert.ok(tile);game.tap(tile.id);}
|
|
assert.ok(game.won);
|
|
}
|
|
});
|