Files

97 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));