39 lines
13 KiB
JavaScript
39 lines
13 KiB
JavaScript
import {attachTutorials} from './tutorials.mjs';
|
|
import fs from 'node:fs';import path from 'node:path';import {fileURLToPath} from 'node:url';import {solve,violations,constraints,directionAt,assertStructure,graph,canAppend} from '../rules.mjs';
|
|
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'),plan=JSON.parse(fs.readFileSync(path.join(root,'../design/demo-50/demo-design.json'),'utf8'));let seed=1;const rand=()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/4294967296;},pick=a=>a[Math.floor(rand()*a.length)];function shuffle(a){const b=[...a];for(let i=b.length-1;i>0;i--){const j=Math.floor(rand()*(i+1));[b[i],b[j]]=[b[j],b[i]];}return b;}
|
|
function patch(n,variant){const width=n<=6?3:n<=12?(variant%3===1?4:3):4,height=Math.ceil(n/width);let units=[];for(let y=0;y<height;y++)for(let x=0;x<width;x++)units.push([x,y]);const remove=units.length-n;
|
|
let priority=variant%4===0?[...units].reverse():shuffle(units.filter(([x,y])=>x===0||x===width-1||y===0||y===height-1));if(variant%4===3&&n>12)priority=shuffle(units);for(const p of priority.slice(0,remove))units=units.filter(u=>u!==p);
|
|
if(variant%2)units=units.map(([x,y])=>[width-1-x,y]);if(variant%5===2)units=units.map(([x,y])=>[y,x]);return units.map(([x,y],i)=>({id:'c'+i,x,y,w:1,h:1}));}
|
|
function makeCells(spec,variant){const n=spec.nodeCountTarget,num=spec.displayOrder;if(spec.geometry==='hex'){const width=n<=12?2:3;let coords=[];for(let q=0;coords.length<n;q++)for(let r=0;r<width&&coords.length<n;r++)coords.push({id:'c'+coords.length,q,r});if(variant%3===1)coords=coords.map(c=>({...c,q:c.q+Math.floor(c.r/2)}));if(variant%3===2)coords=coords.map(c=>({...c,q:-c.q,r:-c.r}));return coords;}
|
|
if(spec.geometry==='square')return patch(n,variant);
|
|
const specialL=[5,23,43].includes(num)||num>=41;const two=[18,28,41,42,43,46].includes(num);const templates=specialL?[[[0,0],[1,0],[0,1]],...(two?[[[0,0],[1,0]]]:[])]:[[[0,0],[1,0],[0,1],[1,1]],...(two?[[[0,0],[1,0]]]:[])];
|
|
const extra=templates.reduce((s,t)=>s+t.length-1,0);let cells=patch(n+extra,variant),taken=new Set();for(const shape of templates){let choices=[];for(const a of cells){if(taken.has(a.id)||a.shape||a.w!==1||a.h!==1)continue;for(const rotation of [0,1]){const rotated=shape.map(([x,y])=>rotation?[y,x]:[x,y]);const matches=rotated.map(([x,y])=>cells.find(c=>!taken.has(c.id)&&c.x===a.x+x&&c.y===a.y+y&&c.w===1&&c.h===1&&!c.shape));if(matches.every(Boolean))choices.push({a,rotated,matches});}}
|
|
if(!choices.length)return null;const {a,rotated,matches}=pick(choices);const xs=rotated.map(p=>p[0]),ys=rotated.map(p=>p[1]);a.w=Math.max(...xs)+1;a.h=Math.max(...ys)+1;if(rotated.length!==a.w*a.h)a.shape=rotated;for(const c of matches)if(c!==a)taken.add(c.id);cells=cells.filter(c=>!taken.has(c.id));}
|
|
return cells.map((c,i)=>({...c,id:'c'+i}));}
|
|
const baseCache=new Map();function base(spec,variant){const cells=makeCells(spec,variant);if(!cells)return null;const key=JSON.stringify(cells);if(baseCache.has(key))return baseCache.get(key);const l={mode:'base',features:[],geometry:spec.geometry,cells};try{assertStructure(l);const paths=solve(l,{limit:15001,nodeLimit:4000000});if(!paths.length){baseCache.set(key,null);return null;}const item={cells,paths,truncated:paths.length>=15001};baseCache.set(key,item);return item;}catch{baseCache.set(key,null);return null;}}
|
|
const wallCounts={8:2,18:2,24:2,32:2,34:2,38:2,42:2,43:2,44:3,47:2,48:2,50:2};
|
|
function setup(spec,cells,author){const num=spec.displayOrder,n=cells.length,l={id:spec.levelId,contentRevision:1,order:num,mode:'campaign-50',geometry:spec.geometry,features:[...spec.features],title:spec.title,chapter:spec.chapter,chapterTitle:plan.chapters[spec.chapter-1].title,role:spec.role,targetDifficulty:spec.targetDifficulty,caption:caption(spec),cells:structuredClone(cells)};if(!spec.unknownStart)l.cells.find(c=>c.id===author[0]).clue=1;if(num===1)l.cells.find(c=>c.id===author.at(-1)).clue=n;
|
|
const g=graph(l),positions=new Map(author.map((id,i)=>[id,i]));
|
|
if(l.features.includes('alternation'))for(let i=0;i<n;i++)l.cells.find(c=>c.id===author[i]).category=i%2?'triangle':'circle';
|
|
if(l.features.includes('direction')){const pool={lr:[],du:[],ru:[],dr:[],dl:[],lu:[]};for(let i=1;i<n-1;i++)pool[directionAt(l,author,i)].push(author[i]);const pattern=num===11?['lr']:num===12?['du']:num===13?['corner']:num===14?['corner','corner']:num===47?['lr','du','corner']:['lr','corner'];const selected=[];for(const want of pattern){let kinds=want==='corner'?['ru','dr','dl','lu'].filter(k=>pool[k].length):[want];if(!kinds.length)return null;const kind=pick(kinds);if(!pool[kind]?.length)return null;const id=pick(pool[kind]);pool[kind].splice(pool[kind].indexOf(id),1);l.cells.find(c=>c.id===id).direction=kind;selected.push(kind);}if(num===14&&selected[0]===selected[1])return null;}
|
|
if(l.features.includes('rhythm-door')){const options=[];for(let i=2;i<n-1;i+=2)if(num<28||i>=Math.floor(n/3))options.push(i);if(!options.length)return null;const i=pick(options);l.doors=[{edge:[author[i],author[i+1]],initialOpen:false}];}
|
|
if(l.features.includes('parity')){const count=[24,38,43,46,48].includes(num)?2:1;const pool=shuffle(author.slice(2,-1).filter(id=>!(l.doors||[]).some(d=>d.edge.includes(id))));if(pool.length<count)return null;let selected=pool.slice(0,count);if(num===21){const even=pool.filter(id=>(positions.get(id)+1)%2===0);if(!even.length)return null;selected=[pick(even)];}if(num===22){const odd=pool.filter(id=>(positions.get(id)+1)%2===1);if(!odd.length)return null;selected=[pick(odd)];}for(const id of selected)l.cells.find(c=>c.id===id).parity=(positions.get(id)+1)%2?'odd':'even';if(count===2&&num===24&&new Set(selected.map(id=>l.cells.find(c=>c.id===id).parity)).size<2)return null;}
|
|
if(l.features.includes('walls')){const options=[];for(let a=0;a<n;a++)for(const b of g.adj[a])if(a<b){const x=g.cells[a].id,y=g.cells[b].id;if(Math.abs(positions.get(x)-positions.get(y))===1)continue;if(l.features.includes('alternation')&&l.cells[a].category===l.cells[b].category)continue;options.push([x,y]);}const count=wallCounts[num]||1;if(options.length<count)return null;l.walls=shuffle(options).slice(0,count);}
|
|
if(l.geometry==='blocks'){const large=l.cells.filter(c=>c.w*c.h>1);if(large.some(c=>positions.get(c.id)===0||positions.get(c.id)===n-1))return null;}
|
|
return l;}
|
|
function caption(s){const n=s.displayOrder;if(n===1)return '从1开始,走遍全部格,最后到5。';if(n===2)return '走错沿来时路逐格退回,松手也能继续画。';if(n===3)return '整块大格只算一步。';if(n===5)return '异形也是整块一步,只看真实共边。';if(n===6)return '黑黄墙封住这条边,整局不会打开。';if(n===11)return '横通道只从左右进出,也可反向走。';if(n===12)return '竖通道只从上下进出。';if(n===13)return '拐角连接哪两边,就从哪两边进出。';if(n===16)return '蓝→橙→蓝,每一步换颜色。';if(n===21)return '“偶”要求本格的访问步号是偶数。';if(n===22)return '“奇”要求本格的访问步号是奇数。';if(n===26)return '空盘门关,落下起点后门开;通过前每格切换一次,通过后保持开启。';if(n===31)return '六边形有六个共边方向,墙仍不能穿过。';if(n===37)return '这关没有1,先观察起点。';return '先观察,再画;松手可继续,走错可回退。';}
|
|
function chooseClues(l,author,basePaths){let paths=basePaths.filter(p=>l.cells.every(c=>c.clue===undefined||p[c.clue-1]===c.id)),atoms=constraints(l),keys=atoms.map(r=>r.key);const masks=paths.map(p=>new Set(violations(l,p,{ignoreClues:true}).map(x=>x.key)));let survivors=paths.map((_,i)=>i).filter(i=>!masks[i].size),witnesses=keys.map(key=>paths.map((_,i)=>i).filter(i=>masks[i].size===1&&masks[i].has(key)));if(witnesses.some(w=>!w.length)||!survivors.length)return null;
|
|
const candidates=shuffle(author.map((id,i)=>({id,step:i+1})).filter(c=>c.step!==1&&c.step!==author.length&&!l.cells.find(x=>x.id===c.id).parity&&!l.cells.find(x=>x.id===c.id).direction));let added=[];
|
|
function addOne(contenders){let best=null;for(const c of candidates.filter(c=>!added.includes(c))){const matches=i=>paths[i][c.step-1]===c.id,kept=survivors.filter(matches),ws=witnesses.map(w=>w.filter(matches));if(ws.some(w=>!w.length))continue;const rejected=contenders.filter(p=>p[c.step-1]!==c.id).length;if(!rejected)continue;if(!best||rejected>best.rejected||rejected===best.rejected&&kept.length<best.kept.length)best={c,kept,ws,rejected};}if(!best)return false;added.push(best.c);l.cells.find(c=>c.id===best.c.id).clue=best.c.step;survivors=best.kept;witnesses=best.ws;return true;}
|
|
while(survivors.length>1){if(!addOne(survivors.map(i=>paths[i]))||added.length>4)return null;}
|
|
// Geometry enumeration may be sampled; every accepted puzzle is fully solved afterwards.
|
|
for(let pass=0;pass<6;pass++){let full;try{full=solve(l,{limit:2,nodeLimit:1500000});}catch{return null;}if(full.length===1&&full[0].join()===author.join())break;if(!full.length||!addOne(full.filter(p=>p.join()!==author.join()))||added.length>4)return null;if(pass===5)return null;}
|
|
for(const c of shuffle(l.cells.filter(c=>c.clue!==undefined&&c.clue!==1&&!(l.order===1&&c.clue===l.cells.length)))){const old=c.clue;delete c.clue;let count;try{count=solve(l,{limit:2,nodeLimit:1500000}).length;}catch{count=0;}if(count!==1)c.clue=old;}
|
|
const saved=keys.map((key,i)=>({constraint:key,path:paths[witnesses[i][0]]}));if(saved.some(w=>!w.path||violations(l,w.path,{omit:w.constraint}).length||!violations(l,w.path).length))return null;return saved;}
|
|
function metrics(l,p){const g=graph(l);let choices=0,maxRun=0,run=0,last=null;for(let i=0;i<p.length-1;i++){const prefix=p.slice(0,i+1);const options=g.adj[g.ids.get(p[i])].map(j=>g.cells[j].id).filter(id=>canAppend(l,prefix,id)&&!violations(l,[...prefix,id]).length);if(options.length>1)choices++;const a=g.cells[g.ids.get(p[i])],b=g.cells[g.ids.get(p[i+1])];const key=l.geometry==='hex'?`${Math.sign(b.q-a.q)},${Math.sign(b.r-a.r)}`:`${Math.sign(b.x-a.x)},${Math.sign(b.y-a.y)}`;run=key===last?run+1:1;last=key;maxRun=Math.max(maxRun,run);}return {noImmediateConflictChoices:choices,longestSameDirectionTransfers:maxRun,scope:'结构代理,不是人类难度或完整规则推理的有效分叉'};}
|
|
const checkpoint=path.join(root,'design.json');let existing=fs.existsSync(checkpoint)?JSON.parse(fs.readFileSync(checkpoint,'utf8')):[];const force=process.argv.includes('--fresh');if(force)existing=[];const notes=[];
|
|
for(const spec of plan.levels){if(existing.some(x=>x.level.id===spec.levelId)){notes.push(existing.find(x=>x.level.id===spec.levelId));console.log('reuse',spec.displayOrder);continue;}seed=20261014+spec.displayOrder*997;let accepted=null;const triedShapes=new Set();
|
|
for(let attempt=0;attempt<4500;attempt++){const variant=Math.floor(attempt/70),item=base(spec,variant);if(!item)continue;const author=pick(item.paths),l=setup(spec,item.cells,author);if(!l)continue;const witnesses=chooseClues(l,author,item.paths);if(!witnesses)continue;try{assertStructure(l);}catch{continue;}const m=metrics(l,author);const minimum=spec.displayOrder<=3?0:spec.role==='释放'||spec.role==='收束'?1:spec.targetDifficulty>=60?3:spec.targetDifficulty>=40?2:1;if(m.noImmediateConflictChoices<minimum)continue;if(l.cells.length>10&&m.longestSameDirectionTransfers/l.cells.length>.5)continue;accepted={level:l,author,witnesses,metrics:m,candidateAttempts:attempt+1,basePathSampleSize:item.paths.length,baseEnumerationTruncated:item.truncated,designKey:spec.designKey,planningIntent:spec.teachingGoal,difficultyStatus:'目标曲线,待玩家校准'};break;}
|
|
if(!accepted)throw Error('无法制作第'+spec.displayOrder+'关 '+spec.title);notes.push(accepted);fs.writeFileSync(checkpoint,JSON.stringify(notes,null,2));console.log('made',spec.displayOrder,spec.title,'N='+accepted.level.cells.length,'keys='+constraints(accepted.level).length,'clues='+accepted.level.cells.filter(c=>c.clue!==undefined).length,'choices='+accepted.metrics.noImmediateConflictChoices,'attempts='+accepted.candidateAttempts);}
|
|
const levels=notes.map(x=>x.level);const pack={mode:'campaign-50',version:'demo-2026-10-14-v1',title:'数字一笔 · 50关DEMO',chapters:plan.chapters,rule:'每格一次,数字是访问步号。每关只启用显示的规则;大格整块一步,墙不能穿过,双色每步换色,奇偶看步号,通道按图示两边进出,门通过前每落一格切换,通过后保持开启。',levels};attachTutorials(pack,notes,root);fs.writeFileSync(checkpoint,JSON.stringify(notes,null,2));fs.writeFileSync(path.join(root,'levels.json'),JSON.stringify(pack,null,2));fs.writeFileSync(path.join(root,'order-a.json'),JSON.stringify({id:'A',levelIds:plan.orders.A.levelIds},null,2));fs.writeFileSync(path.join(root,'order-b.json'),JSON.stringify({id:'B',levelIds:plan.orders.B.levelIds},null,2));console.log('Generated complete campaign: '+levels.length);
|