feat: add production campaigns, gameplay labs, and review documentation

This commit is contained in:
cabinw
2026-09-13 12:27:10 +08:00
parent d1dffa9338
commit 4644e975b0
214 changed files with 98994 additions and 134 deletions
+112
View File
@@ -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;
}
});