97 lines
12 KiB
JavaScript
97 lines
12 KiB
JavaScript
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import {fileURLToPath} from 'node:url';
|
||
import {definitions,difficulties} from './definitions.mjs';
|
||
import {solve,violations,constraints,directionAt,assertStructure,graph} from './engine-template.mjs';
|
||
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
|
||
let seed=20261012;function random(){seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/4294967296;}function pick(a){return a[Math.floor(random()*a.length)];}function shuffle(a){return [...a].sort(()=>random()-.5);}
|
||
function square(index){const dims=[[3,2],[3,3],[4,3],[4,3],[4,4],[4,4],[4,5],[4,5]][index],removed=[[],[[2,2]],[[0,2],[3,2]],[],[[0,3],[3,3]],[],[[0,4],[3,4]],[[3,4]]][index];let cells=[];for(let y=0;y<dims[1];y++)for(let x=0;x<dims[0];x++)if(!removed.some(p=>p[0]===x&&p[1]===y))cells.push({id:'c'+cells.length,x,y,w:1,h:1});return cells;}
|
||
function hex(index){if(index>=4){let coords=[];const heights=index===4?[2,2,2]:index===5?[3,2,2]:[3,2,3];for(let region=0;region<3;region++){for(let q=region*3;q<region*3+2;q++)for(let r=0;r<heights[region];r++)coords.push([q,r]);if(region<2)coords.push([region*3+2,0]);}if(index===7)coords.push([8,1]);return coords.map(([q,r],i)=>({id:'c'+i,q,r}));}const count=[6,8,11,12,14,16,18,19][index];let cells=[];for(let q=0;cells.length<count;q++)for(let r=0;r<2&&cells.length<count;r++)cells.push({id:'c'+cells.length,q,r});if(index===3){cells[5].r=2;cells[5].q=1;}return cells;}
|
||
function blocks(index,variant){let cells=square(Math.min(index+1,7));if(index===7)cells.push({id:'extra',x:3,y:4,w:1,h:1});const owner=new Map(cells.map(c=>[c.x+','+c.y,c]));
|
||
const merges=index<4?1:2;for(let m=0;m<merges;m++){
|
||
const choices=cells.filter(c=>c.w===1&&c.h===1&&!c.shape&&owner.has((c.x+1)+','+c.y)).filter(c=>{let b=owner.get((c.x+1)+','+c.y);return b&&b!==c&&b.w===1&&b.h===1&&!b.shape;});if(!choices.length)break;
|
||
const a=choices[(variant+m*3)%choices.length],b=owner.get((a.x+1)+','+a.y);a.w=2;cells=cells.filter(c=>c!==b);owner.set(b.x+','+b.y,a);
|
||
}
|
||
if(index>=4){const a=cells.find(c=>c.w===2&&!c.shape&&cells.some(b=>b.x===c.x&&b.y===c.y+1&&b.w===1&&b.h===1&&!b.shape));if(a){const b=cells.find(b=>b.x===a.x&&b.y===a.y+1&&b.w===1&&b.h===1&&!b.shape);a.shape=[[0,0],[1,0],[0,1]];a.h=2;cells=cells.filter(c=>c!==b);}}
|
||
return cells.map((c,i)=>({...c,id:'c'+i}));}
|
||
function setup(def,index,cells,author){const level={id:String(index+1).padStart(2,'0'),mode:def.id,geometry:def.geometry,difficulty:difficulties[Math.floor(index/2)],title:def.goals[index],caption:index<2?def.rule:'',cells:structuredClone(cells)};
|
||
if(index!==7)level.cells.find(c=>c.id===author[0]).clue=1;
|
||
const n=author.length;const at=i=>author[Math.max(0,Math.min(n-1,i))];
|
||
if(def.id==='relative'){
|
||
let pairs=index===0?[[1,4]]:index===1?[[1,5]]:index===2?[[2,n-3]]:index===3?[[1,Math.floor(n/2)],[Math.floor(n/2)+1,n-2]]:index===4?[[2,Math.floor(n/2)],[2,n-2]]:index===6?[[1,Math.floor(n/2)],[3,n-2],[Math.floor(n/2),n-1]]:[[2,Math.floor(n/2)+1],[Math.floor(n/2)-1,n-2]];
|
||
if(random()<.6&&index>1)pairs=pairs.map(([a,b])=>[Math.max(1,a+Math.floor(random()*2)),Math.min(n-1,b)]);
|
||
if(index===1){const g=graph(level),near=[];for(let a=1;a<n-2;a++)for(let b=a+3;b<n;b++)if(g.adj[g.ids.get(at(a))].includes(g.ids.get(at(b))))near.push([a,b]);if(!near.length)return null;pairs=[pick(near)];}level.relations=pairs.map(([a,b])=>({from:at(a),to:at(b),delta:b-a}));
|
||
}else if(def.id==='order'){
|
||
const offsets=index===0?[1,4]:index===1?[1,n-2]:[1,Math.floor(n/2),n-2];
|
||
level.orderChains=index<3?[offsets.map(at)]:index===3?[[at(1),at(Math.floor(n/2))],[at(Math.floor(n/2)+1),at(n-2)]]:index===4?[[at(1),at(Math.floor(n/2))],[at(3),at(Math.floor(n/2))]]:index>=6?[[at(1),at(Math.floor(n/2)),at(n-2)],[at(3),at(Math.floor(n/2)+2),at(n-1)]]:[[at(2),at(Math.floor(n/2)),at(n-2)]];
|
||
if(index===1){const g=graph(level),near=author.map((id,i)=>i).filter(i=>i>=3&&g.adj[g.ids.get(at(0))].includes(g.ids.get(at(i))));if(!near.length)return null;const b=pick(near);level.orderChains=[[at(1+Math.floor(random()*(b-1))),at(b)]];}if(index>=6){const markers=shuffle(Array.from({length:n-2},(_,i)=>i+1)).slice(0,6).sort((a,b)=>a-b);level.orderChains=[[at(markers[0]),at(markers[2]),at(markers[4])],[at(markers[1]),at(markers[3]),at(markers[5])]];}
|
||
}else if(def.id==='window'){
|
||
const count=index<3?1:index<6?2:3;let indices=shuffle(Array.from({length:n-3},(_,i)=>i+2)).slice(0,count);
|
||
if(index===5)indices=[Math.floor(n/2)-1,Math.floor(n/2)+1];
|
||
level.windows=indices.map(i=>{const span=index<2?2:index===4?4:2+Math.floor(random()*3);const lo=Math.max(1,Math.min(n-span,i+1-Math.floor(random()*(span+1))));return {cell:at(i),min:lo,max:lo+span};});
|
||
}else if(def.id==='direction'){
|
||
const straight=[],turn=[];for(let i=1;i<n-1;i++)(directionAt(level,author,i)==='straight'?straight:turn).push(author[i]);
|
||
let pattern=index===0?['straight']:index===1?['turn']:index===2?['straight','straight']:index===3?['straight','turn']:index<6?['straight','turn']:['straight','turn','turn'];
|
||
for(const kind of pattern){const arr=kind==='straight'?straight:turn;if(!arr.length)return null;const id=pick(arr);arr.splice(arr.indexOf(id),1);level.cells.find(c=>c.id===id).direction=kind;}
|
||
}else if(def.id.includes('alternation'))for(let i=0;i<n;i++)level.cells.find(c=>c.id===author[i]).category=i%2?'triangle':'circle';
|
||
else if(def.id==='gates'){
|
||
let count=index<3?1:index<6?2:3;const gates=[];const used=new Set();
|
||
const crossed=[];for(let j=0;j<count;j++){let e=Math.floor((j+1)*n/(count+1))+Math.floor(random()*2);e=Math.min(n-2,Math.max(3,e));if(crossed.includes(e))return null;crossed.push(e);let low=(index===4||index===6)?(j?crossed[j-1]+1:1):1;if(index===7&&j>0)low=crossed[0]+1;let high=e-1;if(index===3&&j>0)high=Math.min(high,crossed[0]-1);if(index===7&&j===2)high=Math.min(high,crossed[1]-1);let candidates=Array.from({length:Math.max(0,high-low+1)},(_,k)=>low+k).filter(k=>!used.has(k));if(!candidates.length)return null;let s=index===7&&j===2?pick(candidates.filter(k=>k>crossed[0]) .length?candidates.filter(k=>k>crossed[0]):candidates):pick(candidates);used.add(s);gates.push({edge:[at(e),at(e+1)],switchCell:at(s)});}
|
||
level.gates=gates;
|
||
}
|
||
return level;
|
||
}
|
||
function selectClues(level,author,base,index){
|
||
let paths=base;if(index!==7)paths=paths.filter(p=>p[0]===author[0]);
|
||
const atoms=constraints(level),keys=atoms.map(a=>a.key);if(!keys.length)return null;
|
||
const masks=paths.map(p=>new Set(violations(level,p,{ignoreClues:true}).map(v=>v.key)));
|
||
let survivors=paths.map((_,i)=>i).filter(i=>masks[i].size===0);
|
||
let witnesses=keys.map(key=>paths.map((_,i)=>i).filter(i=>masks[i].size===1&&masks[i].has(key)));
|
||
if(witnesses.some(w=>!w.length))return null;if(level.mode==='window'&&index===1){witnesses=witnesses.map((w,j)=>w.filter(i=>{const r=level.windows[j];return paths[i].indexOf(r.cell)+1<r.min;}));if(witnesses.some(w=>!w.length))return null;}
|
||
const forbidden=new Set([...(level.relations||[]).flatMap(r=>[r.from,r.to]),...(level.windows||[]).map(w=>w.cell)]);
|
||
const candidates=shuffle(author.map((id,i)=>({id,step:i+1})).filter(c=>!forbidden.has(c.id)&&c.step!==1&&c.step!==author.length));
|
||
let chosen=[];while(survivors.length>1){let best=null;
|
||
for(const c of candidates.filter(c=>!chosen.includes(c))){let matched=i=>paths[i][c.step-1]===c.id;const kept=survivors.filter(matched);if(kept.length===survivors.length)continue;const ws=witnesses.map(w=>w.filter(matched));if(ws.some(w=>!w.length))continue;
|
||
if(!best||kept.length<best.kept.length)best={c,kept,ws};
|
||
}
|
||
if(!best)return null;chosen.push(best.c);survivors=best.kept;witnesses=best.ws;
|
||
if(chosen.length> (index<2?3:index<6?4:5))return null;
|
||
}
|
||
for(const c of chosen)level.cells.find(x=>x.id===c.id).clue=c.step;
|
||
if(survivors.length!==1||paths[survivors[0]].join()!==author.join())return null;
|
||
// Remove individually redundant numerical clues, keeping entry start teaching.
|
||
for(const c of shuffle(level.cells.filter(c=>c.clue!==undefined&&(c.clue!==1||index===7)))){const old=c.clue;delete c.clue;const remaining=paths.filter(p=>!violations(level,p).length);if(remaining.length!==1)c.clue=old;}
|
||
const finalWitnesses=keys.map(key=>{const p=paths.find(p=>!violations(level,p,{omit:key}).length&&violations(level,p).length);return {constraint:key,path:p};});
|
||
if(finalWitnesses.some(w=>!w.path))return null;
|
||
return finalWitnesses;
|
||
}
|
||
const cache=new Map();function basePaths(def,index,variant){const cells=def.geometry==='hex'?hex(index):def.geometry==='blocks'?blocks(index,variant):square(index);let key=JSON.stringify(cells);if(!cache.has(key)){const blank={mode:'base',geometry:def.geometry,cells};let paths;try{paths=solve(blank,{limit:60001,nodeLimit:8000000});}catch{return null;}if(paths.length>60000||!paths.length)return null;cache.set(key,{cells,paths});}return cache.get(key);}
|
||
const archive=[];
|
||
for(const def of definitions){const folder=path.join(root,'modes',def.id);fs.mkdirSync(folder,{recursive:true});fs.copyFileSync(path.join(root,'tools','engine-template.mjs'),path.join(folder,'rules.mjs'));let levels=[],notes=[];
|
||
for(let index=0;index<8;index++){
|
||
let accepted=null;for(let attempt=0;attempt<2400;attempt++){
|
||
const base=basePaths(def,index,Math.floor(attempt/120));if(!base)continue;
|
||
const author=pick(base.paths),level=setup(def,index,base.cells,author);if(!level)continue;
|
||
const witnesses=selectClues(level,author,base.paths,index);if(!witnesses)continue;
|
||
try{assertStructure(level);}catch{continue;}
|
||
const solution=solve(level,{limit:2});if(solution.length!==1)throw Error('筛选与求解不一致');
|
||
accepted={level,author,witnesses,basePathCount:base.paths.length,attempt};break;
|
||
}
|
||
if(!accepted)throw Error('无法生成 '+def.id+' '+(index+1));
|
||
const {level,author,witnesses}=accepted;levels.push(level);
|
||
const breakdown=constraints(level).map(r=>{if(r.kind==='relation')return `${r.from}→${r.to}:${r.delta} 次移动,作者步号 ${author.indexOf(r.from)+1}→${author.indexOf(r.to)+1}`;if(r.kind==='order')return `${r.from} 先于 ${r.to},作者步号 ${author.indexOf(r.from)+1}<${author.indexOf(r.to)+1}`;if(r.kind==='window')return `${r.cell} 窗口 ${r.min}–${r.max},作者第 ${author.indexOf(r.cell)+1} 步到达`;if(r.kind==='direction')return `${r.cell} 在第 ${author.indexOf(r.cell)+1} 步,必须${r.value==='straight'?'直行':'转弯'}`;if(r.kind==='gate')return `先在第 ${author.indexOf(r.switchCell)+1} 步踩 ${r.switchCell},第 ${Math.max(...r.edge.map(id=>author.indexOf(id)))+1} 步跨门 ${r.edge.join('—')}`;return '○/△每步交替,按节点数量推断端点类别';});
|
||
const keyWitness=witnesses[0],fork=keyWitness.path.findIndex((id,i)=>id!==author[i]);
|
||
notes.push({...accepted,breakdown,keyFork:{step:fork+1,correct:author[fork],tempting:keyWitness.path[fork]},reason:violations(level,keyWitness.path).map(v=>v.message).join(';'),review:'自动验证通过;难度与人类推理顺序待真人试玩确认'});
|
||
console.log(def.id,level.id,level.difficulty,'N='+level.cells.length,'constraints='+constraints(level).length,'clues='+level.cells.filter(c=>c.clue!==undefined).length,'attempt='+accepted.attempt);
|
||
}
|
||
fs.writeFileSync(path.join(folder,'levels.json'),JSON.stringify({mode:def.id,title:def.title,rule:def.rule,levels},null,2));
|
||
fs.writeFileSync(path.join(folder,'design.json'),JSON.stringify(notes,null,2));archive.push({mode:def.id,title:def.title,count:levels.length});
|
||
let md=`# ${def.title}\n\n${def.rule}\n\n固定数字表示必须在该步到达;每格一次、共边移动、完整路线唯一。所有关卡开放。\n\n这些是机制验证候选关,难度标签按设计任务编排,尚未经真人校准。\n`;
|
||
for(const item of notes){const l=item.level;md+=`\n## ${l.id} · ${l.difficulty} · ${l.title}\n\n格数 ${l.cells.length};基础完整路线 ${item.basePathCount} 条;新约束 ${constraints(l).length} 项。\n\n作者解:${item.author.join(' → ')}。\n\n推理检查点:\n\n${item.breakdown.map(s=>'- '+s).join('\n')}\n\n关键分叉:第 ${item.keyFork.step} 步应选 ${item.keyFork.correct},解除机制后的诱惑路线选择 ${item.keyFork.tempting}。排除依据:${item.reason}。\n\n诱惑路线:${item.witnesses[0].path.join(' → ')}。这是符合几何与辅助数字的完整路线,由新机制排除。\n\n逐项必要性证据:\n\n${item.witnesses.map(w=>'- 解除 '+w.constraint+' 后可走 '+w.path.join(' → ')).join('\n')}\n\n人工评审:请先隐藏作者解,依据盘面复述排除理由;记录是否主要依靠试错。\n`;}
|
||
fs.writeFileSync(path.join(folder,'DESIGN.md'),md);
|
||
}
|
||
fs.writeFileSync(path.join(root,'reports','catalog.json'),JSON.stringify(archive,null,2));
|
||
|
||
|
||
|