纠正仓库内容为衍生项目,标明 campaign-200-r2 最新版
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import {fileURLToPath} from 'node:url';import {definitions} from './definitions.mjs';
|
||||
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
|
||||
const css=`*{box-sizing:border-box}body{margin:0;background:#f6f5fb;color:#292a43;font-family:system-ui,'Microsoft YaHei',sans-serif}main{max-width:620px;margin:auto;padding:20px 18px 32px}a{color:#6650bf}header{display:flex;justify-content:space-between;align-items:center}h1{font-size:26px;margin:10px 0 4px}h2{font-size:18px;margin:14px 0 8px}p{line-height:1.6;margin:8px 0;color:#66677e}.eyebrow{font-size:12px;letter-spacing:.1em;color:#7e728f}button{font:inherit;min-height:44px;border:1px solid #d8d5e5;border-radius:12px;background:white;color:#47405f;padding:9px 15px;cursor:pointer}button:focus-visible,a:focus-visible,textarea:focus-visible{outline:3px solid #a586ed}button.active{background:#6c56c8;border-color:#6c56c8;color:white}button:disabled{opacity:.4}nav,.controls,#constraints{display:flex;gap:8px;flex-wrap:wrap;margin:10px 0}#difficulties{display:grid;grid-template-columns:repeat(4,1fr)}#levels{margin-bottom:18px}.card{padding:14px;border:1px solid #e4e0ef;border-radius:20px;background:white;box-shadow:0 6px 26px #41336508}.meta{display:flex;justify-content:space-between;align-items:center;font-size:13px;color:#767286}#remaining{background:#ece7ff;color:#6650bf;padding:6px 10px;border-radius:20px;font-weight:700}canvas{display:block;width:100%;height:470px;touch-action:none}#status{min-height:48px;font-size:14px;color:#554b70;line-height:1.7}#caption{font-size:13px}#constraints{margin-bottom:0}.constraint{font-size:13px;min-height:36px;padding:6px 10px;background:#f7f4ff}.controls button{flex:1;padding:10px 8px}.controls button.primary{background:#6c56c8;color:white}details{margin-top:16px;padding:12px;background:#eeebf6;border-radius:12px}summary{cursor:pointer;font-weight:600;min-height:28px}textarea{width:100%;min-height:80px;font:inherit;margin:8px 0;border:1px solid #d5cce6;border-radius:8px;padding:10px}.small{font-size:12px}.cards{display:grid;gap:14px}.cards a{display:block;text-decoration:none;color:inherit}.cards .card:hover{border-color:#8d78cf}.pill{font-size:12px;color:#7c659a}footer{margin-top:22px;font-size:12px;color:#857d93}@media(max-width:400px){main{padding:12px}canvas{height:420px}h1{font-size:23px}button{padding:8px 10px}}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}`;
|
||||
const esc=s=>s.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>');
|
||||
for(const def of definitions){const folder=path.join(root,'modes',def.id),pack=JSON.parse(fs.readFileSync(path.join(folder,'levels.json'),'utf8').replace(/^\uFEFF/,''));
|
||||
fs.copyFileSync(path.join(root,'tools','ui-template.mjs'),path.join(folder,'ui.mjs'));
|
||||
let engine=fs.readFileSync(path.join(folder,'rules.mjs'),'utf8').replaceAll('export ','');let ui=fs.readFileSync(path.join(folder,'ui.mjs'),'utf8').replace(/^import[^\n]+\n/,'');
|
||||
const script=`const PACK=${JSON.stringify(pack).replaceAll('<','\\u003c')};\n${engine}\n${ui}`;
|
||||
const html=`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${def.title} · 衍生玩法验证</title><style>${css}</style><main><header><a href="../../index.html">← 全部玩法</a><span class="eyebrow">路径推理实验室</span></header><h1 id="title"></h1><p class="small">四个难度 · 每档两关 · 全部开放</p><nav id="difficulties" aria-label="难度"></nav><nav id="levels" aria-label="关卡"></nav><section class="card"><div class="meta"><span id="position"></span><span id="remaining" aria-live="polite"></span></div><h2 id="goal"></h2><p id="caption"></p><div id="constraints" aria-label="本关条件"></div><canvas id="board" aria-label="关卡盘面;拖动或点按相邻格画路"></canvas><div id="status" role="status" aria-live="polite"></div></section><div class="controls"><button id="undo">撤销</button><button id="restart">重开</button><button id="hint">指路</button><button id="next" class="primary" hidden>下一关</button></div><p class="small">点按或拖动相邻格 · 松手可继续 · 点已走格回退</p><details open><summary>玩法规则</summary><p id="rule-text"></p><p class="small">固定数字必须在对应步到达。所有格各走一次。永久墙、关闭的门和同色移动会阻止移动;其他条件画错可继续,错误处标红。</p></details><details><summary>记录本关体验</summary><p class="small">用自己的话说明关键分叉为什么只能这样走。导出记录含首次错误、回退和提示次数。</p><textarea id="reasoning" aria-label="推理与试玩反馈" placeholder="我先排除了哪条路线?依据是什么?"></textarea><button id="export">导出试玩记录</button></details><footer>原创候选关 · 难度等待真人试玩校准 · 离线可用</footer></main><script>${script}</script></html>`;
|
||||
fs.writeFileSync(path.join(folder,'play.html'),html);
|
||||
}
|
||||
fs.writeFileSync(path.join(root,'index.html'),`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>一笔路径 · 衍生玩法验证</title><style>${css}</style><main><span class="eyebrow">NUMBER SWIPE · VARIANT LAB</span><h1>一笔路径,五种推理</h1><p>每个玩法 8 关:入门、普通、进阶、难,各 2 关。先观察,再一笔完成。</p><div class="cards"><a href="campaign-50/index.html" class="card"><span class="pill">可玩DEMO · 50关</span><h2>50关统一主线</h2><p>五章渐进 · 奇偶与机制组合 · 全部关卡开放</p></a>${definitions.map((d,i)=>`<a href="modes/${d.id}/play.html" class="card"><span class="pill">0${i+1} · 8 关</span><h2>${d.title}</h2><p>${esc(d.rule)}</p></a>`).join('')}</div><details><summary>设计说明与答案图</summary>${definitions.map(d=>`<p><a href="modes/${d.id}/DESIGN.html">${d.title} · 设计评审</a></p>`).join('')}</details><footer>40 个机制验证候选关 · 无计时、体力、分数 · 所有玩法均可离线打开</footer></main></html>`);
|
||||
console.log('Built 5 standalone pages + index.');
|
||||
@@ -0,0 +1,8 @@
|
||||
export const definitions = [
|
||||
{id:'direction',title:'方向通道',geometry:'square',rule:'每格一次。通道图形连接哪两条边,就必须从这两边进出:横线只通左右,竖线只通上下,拐角只通图示两边,均可反向。通道格不能是起终点。',goals:['横向通道:只从左、右进出','拐角通道:认清连接的两条边','纵向通道:只从上、下进出','两种拐角配合选路','横、竖、拐角共同排除错误出口','通道与绕行区域配合','横竖与多个拐角跨区域配合','起点未知:由通道反推完整路线']},
|
||||
{id:'block-alternation',title:'双色交替',geometry:'blocks',rule:'蓝格 → 橙格 → 蓝格,每一步换颜色。整块只算一步,每块一次;共享边界但颜色相同也不能走。○是蓝格,△是橙格。',goals:['蓝橙交替,整块一步','大格旁同色相邻也不能走','颜色数量决定两端颜色','大格多出口中选择另一种颜色','异形凹口与双色交替配合','跨区时留下另一种颜色的出口','多个大格与异形共同推理','起点未知:从颜色与区域推断路线']},
|
||||
{id:'walls',title:'永久墙壁',geometry:'square',rule:'每格一次,数字必须踩在对应步。黑黄墙封住两格之间的一条共边,整局不会打开,必须绕开。墙不占格,也不是开关。',goals:['认识共边上的永久墙','近在旁边也要绕墙','两堵墙改变出口选择','墙与中段数字配合','多墙留下区域通口','先收尾,再绕墙跨区','多区域墙壁与步号联动','起点未知:墙与数字共同定路']},
|
||||
{id:'rhythm-door',title:'步进开关门',geometry:'square',rule:'只有一道门,没有开关格。空盘门关;每落一格就切换一次,包括起点:第1格后开、第2格后关。移动前看当前门位,落格后再切换。不能原地等待切换,撤销会倒回状态。',goals:['第一步后门开:认识落格切换','第二步后门关:先绕行再通过','门旁近路被当前状态挡住','用中段数字推算门的开关','从另一侧进入门,规划到达步数','绕行改变到门时的状态','长区间内安排一次正确穿门','起点未知:同时推起点与穿门时机']},
|
||||
{id:'hex-alternation',title:'六边融合:双色与墙',geometry:'hex',rule:'六向共边相邻,每格一步。蓝格 → 橙格,每步换颜色;黑黄永久墙另行封住共边。同色或有墙都不能通过。○是蓝格,△是橙格。',goals:['六向双色交替,再看一堵墙','同色边与墙边是两种阻挡','颜色数量与墙共同决定端点','两堵墙配合六向出口选择','区域绕行同时满足颜色和墙','两处区域出口互相牵制','三片区域、颜色与多墙联动','起点未知:用颜色与墙反推全路']}
|
||||
];
|
||||
export const difficulties=['入门','普通','进阶','难'];
|
||||
@@ -0,0 +1,15 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import {fileURLToPath} from 'node:url';import {definitions} from './definitions.mjs';import {units,constraints,directionNames} from './engine-template.mjs';
|
||||
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
|
||||
const esc=s=>String(s).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>');
|
||||
function diagram(l,author=null){let shapes=[],centers=new Map();for(const c of l.cells){if(l.geometry==='hex'){const x=1.5*c.r,y=Math.sqrt(3)*(c.q+c.r/2);centers.set(c.id,[x,y]);let p=[];for(let a=0;a<6;a++)p.push([x+Math.cos(a*Math.PI/3),y+Math.sin(a*Math.PI/3)]);shapes.push({id:c.id,p});}else{const u=units(c);centers.set(c.id,[u.reduce((s,p)=>s+p[0]+.5,0)/u.length,u.reduce((s,p)=>s+p[1]+.5,0)/u.length]);for(const [x,y]of u)shapes.push({id:c.id,p:[[x,y],[x+1,y],[x+1,y+1],[x,y+1]]});}}
|
||||
const all=shapes.flatMap(s=>s.p),xmin=Math.min(...all.map(p=>p[0])),xmax=Math.max(...all.map(p=>p[0])),ymin=Math.min(...all.map(p=>p[1])),ymax=Math.max(...all.map(p=>p[1])),s=Math.min(340/(xmax-xmin),410/(ymax-ymin)),tr=([x,y])=>[(x-xmin)*s+20,(y-ymin)*s+20];const width=(xmax-xmin)*s+40,height=(ymax-ymin)*s+40;
|
||||
let svg=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" role="img" aria-label="${esc(l.title)}">`;
|
||||
for(const item of shapes){const c=l.cells.find(c=>c.id===item.id);svg+=`<polygon points="${item.p.map(p=>tr(p).join(',')).join(' ')}" fill="${c.category==='circle'?'#64acf3':c.category==='triangle'?'#ffae55':'#f1eef9'}" stroke="#aaa1c7" stroke-width="2"/>`;}
|
||||
if(author)svg+=`<polyline points="${author.map(id=>tr(centers.get(id)).join(',')).join(' ')}" fill="none" stroke="#7963c9" stroke-width="5" opacity=".4"/>`;
|
||||
for(const item of [...(l.walls||[]).map(edge=>({edge,kind:'wall'})),...(l.doors||[]).map(d=>({edge:d.edge,kind:'door'}))]){const [a,b]=item.edge.map(id=>tr(centers.get(id))),x=(a[0]+b[0])/2,y=(a[1]+b[1])/2,angle=Math.atan2(b[1]-a[1],b[0]-a[0])*180/Math.PI;svg+=`<line x1="${x}" y1="${y-s*.38}" x2="${x}" y2="${y+s*.38}" stroke="${item.kind==='wall'?'#272638':'#aa4d25'}" stroke-width="8" transform="rotate(${angle} ${x} ${y})"/>`;if(item.kind==='wall')svg+=`<line x1="${x}" y1="${y-s*.38}" x2="${x}" y2="${y+s*.38}" stroke="#ffc542" stroke-width="4" stroke-dasharray="5 4" transform="rotate(${angle} ${x} ${y})"/>`;else svg+=`<text x="${x+8}" y="${y-8}" font-size="12">步进门</text>`;}for(let i=0;i<(l.gates||[]).length;i++){const r=l.gates[i],[a,b]=r.edge.map(id=>tr(centers.get(id))),x=(a[0]+b[0])/2,y=(a[1]+b[1])/2,angle=Math.atan2(b[1]-a[1],b[0]-a[0])*180/Math.PI;svg+=`<line x1="${x}" y1="${y-s*.33}" x2="${x}" y2="${y+s*.33}" stroke="#cf7824" stroke-width="7" transform="rotate(${angle} ${x} ${y})"/><text x="${x+8}" y="${y-8}" font-size="12">门${i+1}</text>`;}
|
||||
for(const c of l.cells){const [x,y]=tr(centers.get(c.id)),w=(l.windows||[]).find(r=>r.cell===c.id),sw=(l.gates||[]).findIndex(r=>r.switchCell===c.id);let label=author?String(author.indexOf(c.id)+1):c.clue===undefined?'':String(c.clue);let tags=[w?`${w.min}–${w.max}`:'',c.direction?({lr:'─',du:'│',ru:'└',dr:'┌',dl:'┐',lu:'┘'})[c.direction]:'',c.category==='circle'?'○':c.category==='triangle'?'△':'',sw>=0?`开${sw+1}`:''].filter(Boolean).join(' ');svg+=`<text x="${x}" y="${y-8}" text-anchor="middle" font-family="system-ui,Microsoft YaHei" font-size="${Math.max(11,Math.min(21,s*.29))}" font-weight="700">${esc(label||tags)}</text><text x="${x}" y="${y+10}" text-anchor="middle" font-family="system-ui" font-size="10" fill="#665c78">${c.id}${label&&tags?' · '+esc(tags):''}</text>`;}
|
||||
return svg+'</svg>';}
|
||||
for(const def of definitions){const folder=path.join(root,'modes',def.id),pack=JSON.parse(fs.readFileSync(path.join(folder,'levels.json'),'utf8')),notes=JSON.parse(fs.readFileSync(path.join(folder,'design.json'),'utf8'));let articles='';for(let i=0;i<8;i++){const l=pack.levels[i],n=notes[i];fs.writeFileSync(path.join(folder,l.id+'-board.svg'),diagram(l));fs.writeFileSync(path.join(folder,l.id+'-solution.svg'),diagram(l,n.author));
|
||||
articles+=`<article><h2>${l.id} · ${l.difficulty} · ${esc(l.title)}</h2><div class="boards"><figure>${diagram(l)}<figcaption>原始盘面 · 格id用于对照JSON</figcaption></figure><details><summary>展开作者解与路线</summary><figure>${diagram(l,n.author)}</figure><p>${n.author.join(' → ')}</p></details></div><h3>推理依据</h3><ul>${n.breakdown.map(t=>`<li>${esc(t)}</li>`).join('')}</ul><p>关键分叉:第 ${n.keyFork.step} 步应走 ${n.keyFork.correct},诱惑路线走 ${n.keyFork.tempting}。排除依据:${esc(n.reason)}。</p><details><summary>逐项机制必要性反例</summary>${n.witnesses.map(w=>`<p>解除 ${w.constraint} 后:${w.path.join(' → ')}</p>`).join('')}</details></article>`;}
|
||||
fs.writeFileSync(path.join(folder,'DESIGN.html'),`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${def.title} · 设计评审</title><style>body{max-width:920px;margin:24px auto;padding:0 18px;background:#f7f5fb;color:#303047;font:15px/1.7 system-ui,'Microsoft YaHei'}article{background:white;border:1px solid #ddd5ed;border-radius:16px;padding:20px;margin:20px 0;break-inside:avoid}.boards{display:flex;gap:24px;flex-wrap:wrap}figure{margin:0;width:380px;max-width:100%}svg{max-height:450px;width:100%}details{cursor:pointer}a{color:#6552aa}@media print{body{background:white}article{border:none;page-break-after:always}} </style><a href="play.html">进入试玩</a><h1>${def.title} · 8关设计评审</h1><p>${esc(def.rule)}</p><p>原始盘面和作者解分开展示。标题是设计任务,真人推理路径与难度仍需试玩验证。数字、窗口、方向、类别和开关显示在格内;步差与先后关系见逐关推理依据。</p>${articles}</html>`);}
|
||||
console.log('Generated 40 original boards, 40 solutions and 5 design review pages.');
|
||||
@@ -0,0 +1,116 @@
|
||||
// Pure rules / geometry / solver. Each mode receives its own standalone copy.
|
||||
export function units(c){return c.shape?c.shape.map(([x,y])=>[c.x+x,c.y+y]):Array.from({length:(c.w||1)*(c.h||1)},(_,i)=>[c.x+i%(c.w||1),c.y+Math.floor(i/(c.w||1))]);}
|
||||
export function graph(level){
|
||||
const cells=level.cells, ids=new Map(cells.map((c,i)=>[c.id,i]));
|
||||
if(ids.size!==cells.length)throw Error('重复格 id');
|
||||
const adj=cells.map(()=>[]), occupancy=new Map();
|
||||
if(level.geometry!=='hex') for(let i=0;i<cells.length;i++)for(const [x,y]of units(cells[i])){const k=x+','+y;if(occupancy.has(k))throw Error('拼块重叠');occupancy.set(k,i);}
|
||||
for(let i=0;i<cells.length;i++)for(let j=i+1;j<cells.length;j++){
|
||||
let connected=false;
|
||||
if(level.geometry==='hex'){let dq=cells[j].q-cells[i].q,dr=cells[j].r-cells[i].r;connected=[[1,0],[-1,0],[0,1],[0,-1],[1,-1],[-1,1]].some(([a,b])=>a===dq&&b===dr);}
|
||||
else connected=units(cells[i]).some(([x,y])=>[[1,0],[-1,0],[0,1],[0,-1]].some(([a,b])=>occupancy.get((x+a)+','+(y+b))===j));
|
||||
if(connected){adj[i].push(j);adj[j].push(i);}
|
||||
}
|
||||
const clues=new Map();for(const c of cells)if(c.clue!==undefined){if(!Number.isInteger(c.clue)||c.clue<1||c.clue>cells.length||clues.has(c.clue))throw Error('数字范围或重复');clues.set(c.clue,ids.get(c.id));}
|
||||
return {cells,ids,adj,clues};
|
||||
}
|
||||
export function constraints(level){
|
||||
const result=[];
|
||||
for(let i=0;i<(level.relations||[]).length;i++)result.push({key:'relation:'+i,kind:'relation',...level.relations[i]});
|
||||
for(let i=0;i<(level.orderChains||[]).length;i++)for(let j=0;j<level.orderChains[i].length-1;j++)result.push({key:'order:'+i+':'+j,kind:'order',from:level.orderChains[i][j],to:level.orderChains[i][j+1]});
|
||||
for(let i=0;i<(level.windows||[]).length;i++)result.push({key:'window:'+i,kind:'window',...level.windows[i]});
|
||||
for(const c of level.cells)if(c.direction)result.push({key:'direction:'+c.id,kind:'direction',cell:c.id,value:c.direction});
|
||||
if(level.mode.includes('alternation'))result.push({key:'alternation',kind:'alternation'});
|
||||
for(let i=0;i<(level.walls||[]).length;i++)result.push({key:'wall:'+i,kind:'wall',edge:level.walls[i]});
|
||||
for(let i=0;i<(level.doors||[]).length;i++)result.push({key:'door:'+i,kind:'door',...level.doors[i]});
|
||||
for(let i=0;i<(level.gates||[]).length;i++)result.push({key:'gate:'+i,kind:'gate',...level.gates[i]});
|
||||
return result;
|
||||
}
|
||||
export function directionAt(level,path,k){
|
||||
if(k===0||k===path.length-1)return null;
|
||||
const [a,b,c]=[path[k-1],path[k],path[k+1]].map(id=>level.cells.find(x=>x.id===id));
|
||||
const side=(x)=>x.x<b.x?'l':x.x>b.x?'r':x.y<b.y?'u':'d';
|
||||
return [side(a),side(c)].sort().join('');
|
||||
}
|
||||
export function doorOpen(door,visitedCount){return Boolean(door.initialOpen)!==Boolean(visitedCount%2);}
|
||||
export const directionNames={lr:'左右直行',du:'上下直行',ru:'上与右拐角',dr:'右与下拐角',dl:'下与左拐角',lu:'左与上拐角'};
|
||||
export function edgeBlocked(level,path,a,b,{omit=null}={}){
|
||||
for(let i=0;i<(level.walls||[]).length;i++)if(omit!=='wall:'+i&&level.walls[i].includes(a)&&level.walls[i].includes(b))return true;
|
||||
for(let i=0;i<(level.doors||[]).length;i++)if(omit!=='door:'+i&&level.doors[i].edge.includes(a)&&level.doors[i].edge.includes(b)&&!doorOpen(level.doors[i],path.length))return true;
|
||||
return false;
|
||||
}
|
||||
export function violations(level,path,{omit=null,complete=path.length===level.cells.length,ignoreClues=false}={}){
|
||||
const positions=new Map(path.map((id,i)=>[id,i+1])),bad=[];
|
||||
if(!ignoreClues)for(const c of level.cells)if(c.clue!==undefined){const at=positions.get(c.id);if(at!==undefined&&at!==c.clue||path.length>=c.clue&&path[c.clue-1]!==c.id)bad.push({key:'clue:'+c.id,cells:[c.id,path[c.clue-1]].filter(Boolean),message:'固定数字步号不符'});}
|
||||
for(const r of constraints(level)){
|
||||
if(r.key===omit)continue;
|
||||
let fail=false,where=[];
|
||||
if(r.kind==='relation'){
|
||||
const a=positions.get(r.from),b=positions.get(r.to);where=[r.from,r.to];
|
||||
fail=b!==undefined&&(a===undefined||b-a!==r.delta)||a!==undefined&&b===undefined&&path.length>=a+r.delta;
|
||||
}else if(r.kind==='order'){where=[r.from,r.to];fail=positions.has(r.to)&&(!positions.has(r.from)||positions.get(r.from)>positions.get(r.to));}
|
||||
else if(r.kind==='window'){where=[r.cell];const p=positions.get(r.cell);fail=p!==undefined?(p<r.min||p>r.max):path.length>=r.max;}
|
||||
else if(r.kind==='direction'){where=[r.cell];const p=positions.get(r.cell);fail=p===1||p!==undefined&&p<path.length&&directionAt(level,path,p-1)!==r.value||complete&&p===path.length;}
|
||||
else if(r.kind==='alternation'){for(let i=1;i<path.length;i++){const a=level.cells.find(c=>c.id===path[i-1]),b=level.cells.find(c=>c.id===path[i]);if(a.category===b.category){fail=true;where.push(a.id,b.id);}}}
|
||||
else if(r.kind==='wall'||r.kind==='door')for(let i=1;i<path.length;i++){if(r.edge.includes(path[i-1])&&r.edge.includes(path[i])&&(r.kind==='wall'||!doorOpen(r,i))){fail=true;where=r.edge;}}
|
||||
else if(r.kind==='gate')for(let i=1;i<path.length;i++){if(r.edge.includes(path[i-1])&&r.edge.includes(path[i])){const s=positions.get(r.switchCell);if(s===undefined||s>i){fail=true;where=r.edge;}}}
|
||||
if(fail)bad.push({key:r.key,cells:where,message:r.kind==='relation'?'相对步差不符':r.kind==='order'?'标记次序不符':r.kind==='window'?'到达窗口不符':r.kind==='direction'?'进出方向不符':r.kind==='wall'?'永久墙不能穿过':r.kind==='door'?'这一步门是关闭的':r.kind==='gate'?'开关尚未打开此门':'蓝橙颜色必须交替'});
|
||||
}
|
||||
return bad;
|
||||
}
|
||||
export function canAppend(level,path,id){
|
||||
const g=graph(level);if(path.includes(id)||!g.ids.has(id))return false;
|
||||
if(!path.length)return !g.clues.has(1)||g.cells[g.clues.get(1)].id===id;
|
||||
const last=path.at(-1);if(edgeBlocked(level,path,last,id))return false;if(!g.adj[g.ids.get(last)].includes(g.ids.get(id)))return false;
|
||||
if(level.mode.includes('alternation')&&g.cells[g.ids.get(last)].category===g.cells[g.ids.get(id)].category)return false;
|
||||
for(const gate of level.gates||[])if(gate.edge.includes(last)&&gate.edge.includes(id)&&!path.includes(gate.switchCell))return false;
|
||||
return true;
|
||||
}
|
||||
export function solve(level,{limit=2,omit=null,ignoreClues=false,prune=true,nodeLimit=Infinity}={}){
|
||||
const g=graph(level),n=g.cells.length,results=[],path=[],seen=new Set();let nodes=0;
|
||||
const active=constraints(level).filter(r=>r.key!==omit),alternating=active.some(r=>r.kind==='alternation');
|
||||
const adj=g.adj.map((a,i)=>a.filter(j=>(!alternating||g.cells[i].category!==g.cells[j].category)&&!active.some(r=>r.kind==='wall'&&r.edge.includes(g.cells[i].id)&&r.edge.includes(g.cells[j].id))));
|
||||
const forced=ignoreClues?new Map():g.clues;
|
||||
function connected(cur){const reached=new Set([cur]),queue=[cur];for(let k=0;k<queue.length;k++)for(const j of adj[queue[k]])if(!seen.has(j)&&!reached.has(j)){reached.add(j);queue.push(j);}return reached.size===n-path.length+1;}
|
||||
function visit(i){if(++nodes>nodeLimit)throw Error('求解节点预算耗尽,不能视为唯一');seen.add(i);path.push(g.cells[i].id);
|
||||
const k=path.length;
|
||||
if(!violations(level,path,{omit,ignoreClues}).length){
|
||||
if(k===n)results.push([...path]);
|
||||
else if(!prune||connected(i)){
|
||||
const f=forced.get(k+1);let next=adj[i].filter(j=>!seen.has(j)&&(f===undefined||j===f));
|
||||
next.sort((a,b)=>adj[a].filter(x=>!seen.has(x)).length-adj[b].filter(x=>!seen.has(x)).length);
|
||||
for(const j of next){if(!ignoreClues&&g.cells[j].clue!==undefined&&g.cells[j].clue!==k+1)continue;
|
||||
let blocked=edgeBlocked(level,path,g.cells[i].id,g.cells[j].id,{omit});for(const r of active)if(r.kind==='gate'&&r.edge.includes(g.cells[i].id)&&r.edge.includes(g.cells[j].id)&&!path.includes(r.switchCell))blocked=true;
|
||||
if(!blocked)visit(j);if(results.length>=limit)break;
|
||||
}
|
||||
}
|
||||
}
|
||||
path.pop();seen.delete(i);
|
||||
}
|
||||
let starts=forced.has(1)?[forced.get(1)]:g.cells.map((_,i)=>i);
|
||||
for(const i of starts){if(!ignoreClues&&g.cells[i].clue!==undefined&&g.cells[i].clue!==1)continue;visit(i);if(results.length>=limit)break;}
|
||||
return results;
|
||||
}
|
||||
export function hint(solution,path){let k=0;while(k<path.length&&path[k]===solution[k])k++;if(k<path.length)return {kind:k?'backtrack':'restart',cell:k?path[k-1]:solution[0]};return k===solution.length?{kind:'none'}:{kind:k?'next':'start',cell:solution[k]};}
|
||||
export function assertStructure(level){
|
||||
const g=graph(level),n=g.cells.length;if(n<3||n>20)throw Error('格数须为3–20');
|
||||
if(level.geometry==='hex'){const keys=new Set();for(const c of g.cells){if(!Number.isInteger(c.q)||!Number.isInteger(c.r))throw Error('六边坐标');const key=c.q+','+c.r;if(keys.has(key))throw Error('六边重叠');keys.add(key);}}
|
||||
else for(const c of g.cells){if(!Number.isInteger(c.x)||!Number.isInteger(c.y))throw Error('坐标');const u=units(c);if(!u.length||new Set(u.map(p=>p.join(','))).size!==u.length)throw Error('异形重复');const seen=new Set([u[0].join(',')]);for(let changed=true;changed;){changed=false;for(const [x,y]of u)if(!seen.has(x+','+y)&&[[1,0],[-1,0],[0,1],[0,-1]].some(([a,b])=>seen.has((x+a)+','+(y+b)))){seen.add(x+','+y);changed=true;}}if(seen.size!==u.length)throw Error('异形不连通');}
|
||||
const reach=new Set([0]),queue=[0];for(let k=0;k<queue.length;k++)for(const j of g.adj[queue[k]])if(!reach.has(j)){reach.add(j);queue.push(j);}if(reach.size!==n)throw Error('盘面不连通');
|
||||
if((level.doors||[]).length>1)throw Error('步进门只允许一道门');
|
||||
const physicalEdges=[...(level.walls||[]),...(level.doors||[]).map(d=>d.edge)].map(e=>[...e].sort().join('|'));if(new Set(physicalEdges).size!==physicalEdges.length)throw Error('墙门共边重复');
|
||||
const pairs=[];
|
||||
for(const r of constraints(level)){
|
||||
for(const id of [r.cell,r.from,r.to,r.switchCell,...(r.edge||[])].filter(Boolean))if(!g.ids.has(id))throw Error('约束引用不存在');
|
||||
if(r.kind==='relation'&&(!Number.isInteger(r.delta)||r.delta<1||r.delta>=n||r.from===r.to))throw Error('步差');
|
||||
if(r.kind==='order')pairs.push([r.from,r.to]);
|
||||
if(r.kind==='window'&&(!Number.isInteger(r.min)||!Number.isInteger(r.max)||r.min<1||r.max>n||r.max-r.min<2||g.cells[g.ids.get(r.cell)].clue!==undefined))throw Error('窗口需允许至少两个同奇偶步号且不与数字重叠');
|
||||
if(r.kind==='direction'&&!Object.keys(directionNames).includes(r.value))throw Error('方向');
|
||||
if((r.kind==='wall'||r.kind==='door')&&(r.edge.length!==2||!g.adj[g.ids.get(r.edge[0])].includes(g.ids.get(r.edge[1]))))throw Error('墙或门不在共边');
|
||||
if(r.kind==='gate'&&(r.edge.length!==2||!g.adj[g.ids.get(r.edge[0])].includes(g.ids.get(r.edge[1]))))throw Error('门不在共边');
|
||||
}
|
||||
if((level.gates||[]).length>3||new Set((level.gates||[]).map(r=>r.switchCell)).size!==(level.gates||[]).length)throw Error('最多三门且开关一对一');
|
||||
if(level.mode.includes('alternation')&&g.cells.some(c=>!['circle','triangle'].includes(c.category)))throw Error('缺少类别');
|
||||
function cycle(v,stack=new Set(),done=new Set()){if(stack.has(v))throw Error('先后链循环');if(done.has(v))return;stack.add(v);for(const [a,b]of pairs)if(a===v)cycle(b,stack,done);stack.delete(v);done.add(v);}for(const [a]of pairs)cycle(a);
|
||||
return g;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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){const count=[6,8,11,12,14,16,18,19][index];let cells=[];const rows=index>=4?3:2;for(let q=0;cells.length<count;q++)for(let r=0;r<rows&&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 kinds=['lr','du','ru','dr','dl','lu'];const pool={};for(const kind of kinds)pool[kind]=[];
|
||||
for(let i=1;i<n-1;i++)pool[directionAt(level,author,i)].push(author[i]);
|
||||
let pattern=index===0?['lr']:index===1?['corner']:index===2?['du']:index===3?['corner','corner']:index<6?['lr','du','corner']:['lr','du','corner','corner'];
|
||||
const selected=[];for(const requested of pattern){const available=requested==='corner'?kinds.slice(2).filter(k=>pool[k].length):[requested];if(!available.length)return null;const kind=pick(available);if(!pool[kind].length)return null;const id=pick(pool[kind]);pool[kind].splice(pool[kind].indexOf(id),1);level.cells.find(c=>c.id===id).direction=kind;selected.push(kind);}
|
||||
if(index===3&&selected[0]===selected[1])return null;
|
||||
}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';if(def.geometry==='hex'){const g=graph(level);let edges=[];for(let a=0;a<n;a++)for(const b of g.adj[a])if(a<b&&Math.abs(author.indexOf(g.cells[a].id)-author.indexOf(g.cells[b].id))!==1&&g.cells[a].category!==g.cells[b].category)edges.push([g.cells[a].id,g.cells[b].id]);const count=index<3?1:index<6?2:3;if(edges.length<count)return null;level.walls=shuffle(edges).slice(0,count);}}
|
||||
else if(def.id==='walls'){const g=graph(level);let edges=[];for(let a=0;a<n;a++)for(const b of g.adj[a])if(a<b&&Math.abs(author.indexOf(g.cells[a].id)-author.indexOf(g.cells[b].id))!==1)edges.push([g.cells[a].id,g.cells[b].id]);const count=index<2?1:index<4?2:index<6?3:4;if(edges.length<count)return null;level.walls=shuffle(edges).slice(0,count);}
|
||||
else if(def.id==='rhythm-door'){const eligible=[];for(let i=0;i<n-1;i+=2)if(index===0?i===2:index<4?i>=2:i>=Math.floor(n/3))eligible.push(i);if(!eligible.length)return null;const e=pick(eligible);level.doors=[{edge:[at(e),at(e+1)],initialOpen:false}];}
|
||||
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} 步,必须${({lr:'左右直行',du:'上下直行',ru:'上与右',dr:'右与下',dl:'下与左',lu:'左与上'})[r.value]}`;if(r.kind==='wall')return `永久墙 ${r.edge.join('—')} 断开表面邻接,需绕行`;if(r.kind==='door'){const crossing=author.findIndex((id,i)=>i>0&&r.edge.includes(id)&&r.edge.includes(author[i-1]));return `第 ${crossing} 格后门开,从 ${author[crossing-1]} 走向 ${author[crossing]},落格后门关;空盘门关、起步也切换`;}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));
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {fileURLToPath} from 'node:url';
|
||||
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'),source='G:\\项目\\NumberSwipe';
|
||||
const before=JSON.parse(fs.readFileSync(path.join(root,'reports','original-before.json'),'utf8').replace(/^\uFEFF/,''));
|
||||
const hash=file=>crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex').toUpperCase();
|
||||
const current=[];function walk(dir){for(const item of fs.readdirSync(dir,{withFileTypes:true})){if(item.isDirectory()&&['.git','node_modules'].includes(item.name))continue;const full=path.join(dir,item.name);if(item.isDirectory())walk(full);else if(item.isFile())current.push(full);}}walk(source);
|
||||
const expected=new Set(before.map(r=>r.path)),changed=[],missing=[];for(const item of before){if(!fs.existsSync(item.path))missing.push(item.path);else if(hash(item.path)!==item.hash)changed.push(item.path);}const added=current.filter(p=>!expected.has(p));
|
||||
const report={checkedAt:new Date().toISOString(),source,checkedFiles:before.length,changed,missing,added,unchanged:!changed.length&&!missing.length&&!added.length,excluded:['.git','node_modules']};fs.writeFileSync(path.join(root,'reports','isolation.json'),JSON.stringify(report,null,2));
|
||||
fs.writeFileSync(path.join(root,'reports','ISOLATION.md'),`# 原项目隔离校验\n\n核对 ${before.length} 个原项目文件的SHA-256与文件清单。\n\n修改 ${changed.length},删除 ${missing.length},新增 ${added.length}。\n\n${report.unchanged?'通过:原项目受检查文件全部保持一致。':'检测到外部修改,请查看isolation.json。'}\n\n.git与node_modules未纳入内容遍历;本任务未执行原项目Git写入或依赖操作。所有任务输出都在NumberSwipe-Variants。\n`);
|
||||
console.log(JSON.stringify(report));if(!report.unchanged)process.exitCode=1;
|
||||
@@ -0,0 +1,78 @@
|
||||
import {graph,units,solve,violations,canAppend,hint,doorOpen,directionNames} from './rules.mjs';
|
||||
// The build embeds this module together with the local rule file and JSON.
|
||||
const pack=PACK;
|
||||
const $=id=>document.getElementById(id),canvas=$('board'),ctx=canvas.getContext('2d');
|
||||
let level,solution,path=[],index=0,selected='入门',polygons=[],centers=new Map(),unit=1,focus=[],drag=false,lastPoint=null,cleared=false;
|
||||
const memoryKey='variant:'+pack.mode;let completed=[];try{completed=JSON.parse(localStorage.getItem(memoryKey)||'[]');}catch{}
|
||||
const labels=new Map();const metrics={};let started=0,record=null;
|
||||
function setStatus(s){$('status').textContent=s;}
|
||||
function modeMarkers(){labels.clear();let ids=[...(level.relations||[]).flatMap(r=>[r.from,r.to]),...(level.orderChains||[]).flat()];for(const id of ids)if(!labels.has(id))labels.set(id,String.fromCharCode(65+labels.size));}
|
||||
function makeRecord(){return {mode:pack.mode,level:level.id,difficulty:level.difficulty,title:level.title,startedAt:new Date().toISOString(),elapsedSeconds:0,firstError:null,backtracks:0,restarts:0,hints:0,completed:false,reasoning:''};}
|
||||
function start(i){index=i;level=pack.levels[i];solution=solve(level)[0];if(!solution)throw Error('没有合法路线');path=[];focus=[];cleared=false;started=performance.now();record=makeRecord();$('reasoning').value='';modeMarkers();$('goal').textContent=level.title;$('caption').textContent=level.caption||'先观察,再画;错了可以退回。';$('title').textContent=pack.title;$('rule-text').textContent=pack.rule;setStatus('从数字 1 开始;没有 1 时,可尝试任意格。');renderConstraints();renderSelectors();draw();}
|
||||
function renderSelectors(){
|
||||
$('difficulties').replaceChildren();for(const d of ['入门','普通','进阶','难']){const b=document.createElement('button');b.textContent=d;b.className=d===selected?'active':'';b.onclick=()=>{selected=d;start(pack.levels.findIndex(l=>l.difficulty===d));};$('difficulties').append(b);}
|
||||
$('levels').replaceChildren();for(let i=0;i<pack.levels.length;i++){const l=pack.levels[i];if(l.difficulty!==selected)continue;const b=document.createElement('button');b.textContent=`第 ${i%2+1} 关${completed.includes(l.id)?' ✓':''}`;b.className=i===index?'active':'';b.onclick=()=>start(i);$('levels').append(b);}
|
||||
$('position').textContent=`${level.difficulty} · ${index%2+1}/2`;
|
||||
}
|
||||
function renderConstraints(){const list=$('constraints');list.replaceChildren();
|
||||
const add=(text,ids)=>{const b=document.createElement('button');b.className='constraint';b.textContent=text;b.onclick=()=>{focus=ids;draw();};list.append(b);};
|
||||
for(const r of level.relations||[])add(`${labels.get(r.from)} → ${labels.get(r.to)} +${r.delta}`,[r.from,r.to]);
|
||||
for(const chain of level.orderChains||[])add(chain.map(id=>labels.get(id)).join(' < '),chain);
|
||||
for(const w of level.windows||[])add(`窗口 ${w.min}–${w.max}`,[w.cell]);
|
||||
for(const c of level.cells)if(c.direction)add(directionNames[c.direction]+' · 可反向',[c.id]);
|
||||
if(level.mode.includes('alternation'))add('🔵 蓝 → 🟠 橙 → 🔵 蓝 · 每步换颜色',[]);
|
||||
for(let i=0;i<(level.walls||[]).length;i++)add('黑黄墙 · 永久封闭',level.walls[i]);
|
||||
for(const d of level.doors||[])add(doorOpen(d,path.length)?'门开 · 再落一格就关':'门关 · 再落一格就开',d.edge);
|
||||
for(let i=0;i<(level.gates||[]).length;i++)add(`开关 ${i+1} → 门 ${i+1}`,[level.gates[i].switchCell,...level.gates[i].edge]);
|
||||
}
|
||||
function localGeometry(){
|
||||
const raw=[],center=new Map();for(const c of level.cells){if(level.geometry==='hex'){
|
||||
// Rotate the axial strip into portrait orientation; adjacency remains axial.
|
||||
const x=1.5*c.r,y=Math.sqrt(3)*(c.q+c.r/2);center.set(c.id,[x,y]);let p=[];for(let a=0;a<6;a++){const angle=a*Math.PI/3;p.push([x+Math.cos(angle),y+Math.sin(angle)]);}raw.push({id:c.id,p});
|
||||
}else{const u=units(c);center.set(c.id,[u.reduce((s,p)=>s+p[0]+.5,0)/u.length,u.reduce((s,p)=>s+p[1]+.5,0)/u.length]);for(const [x,y]of u)raw.push({id:c.id,p:[[x,y],[x+1,y],[x+1,y+1],[x,y+1]]});}}
|
||||
return {raw,center};
|
||||
}
|
||||
function draw(){if(!level)return;const width=canvas.clientWidth,height=canvas.clientHeight,dpr=window.devicePixelRatio||1;canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
|
||||
const {raw,center}=localGeometry(),all=raw.flatMap(r=>r.p),xmin=Math.min(...all.map(p=>p[0])),xmax=Math.max(...all.map(p=>p[0])),ymin=Math.min(...all.map(p=>p[1])),ymax=Math.max(...all.map(p=>p[1]));unit=Math.min((width-28)/(xmax-xmin),(height-24)/(ymax-ymin));const ox=(width-unit*(xmax-xmin))/2-unit*xmin,oy=(height-unit*(ymax-ymin))/2-unit*ymin;
|
||||
const tr=([x,y])=>[ox+x*unit,oy+y*unit];polygons=raw.map(r=>({...r,p:r.p.map(tr)}));centers=new Map([...center].map(([id,p])=>[id,tr(p)]));
|
||||
const bad=violations(level,path),badIds=new Set(bad.flatMap(b=>b.cells));
|
||||
for(const shape of polygons){const c=level.cells.find(c=>c.id===shape.id),visited=path.includes(c.id);ctx.beginPath();shape.p.forEach(([x,y],i)=>i?ctx.lineTo(x,y):ctx.moveTo(x,y));ctx.closePath();ctx.fillStyle=c.category==='circle'?'#64acf3':c.category==='triangle'?'#ffae55':badIds.has(c.id)?'#ffe0dc':visited?'#ddd8ff':'#f0f0f6';ctx.fill();}
|
||||
// Draw only external unit edges, preserving the outline of an entire large block.
|
||||
const edgeCounts=new Map();for(const shape of polygons)for(let i=0;i<shape.p.length;i++){let a=shape.p[i],b=shape.p[(i+1)%shape.p.length];let key=shape.id+':'+[a.map(v=>v.toFixed(3)).join(','),b.map(v=>v.toFixed(3)).join(',')].sort().join('|');edgeCounts.set(key,(edgeCounts.get(key)||0)+1);}
|
||||
for(const shape of polygons)for(let i=0;i<shape.p.length;i++){const a=shape.p[i],b=shape.p[(i+1)%shape.p.length],key=shape.id+':'+[a.map(v=>v.toFixed(3)).join(','),b.map(v=>v.toFixed(3)).join(',')].sort().join('|');if(edgeCounts.get(key)>1)continue;ctx.beginPath();ctx.moveTo(...a);ctx.lineTo(...b);ctx.strokeStyle=focus.includes(shape.id)?'#f59c00':badIds.has(shape.id)?'#c74537':path.includes(shape.id)?'#7762db':'#b4b6cb';ctx.lineWidth=focus.includes(shape.id)?4:2;ctx.stroke();}
|
||||
if(path.length){ctx.beginPath();path.forEach((id,i)=>{const p=centers.get(id);i?ctx.lineTo(...p):ctx.moveTo(...p);});ctx.lineWidth=5;ctx.lineCap='round';ctx.lineJoin='round';ctx.strokeStyle='#7562db';ctx.globalAlpha=.32;ctx.stroke();ctx.globalAlpha=1;}
|
||||
for(const edge of level.walls||[])drawBarrier(edge,'wall');
|
||||
for(const d of level.doors||[])drawBarrier(d.edge,doorOpen(d,path.length)?'open':'closed');
|
||||
for(let i=0;i<(level.gates||[]).length;i++){const gate=level.gates[i],a=centers.get(gate.edge[0]),b=centers.get(gate.edge[1]),mx=(a[0]+b[0])/2,my=(a[1]+b[1])/2,dx=b[0]-a[0],dy=b[1]-a[1],len=Math.hypot(dx,dy),open=path.includes(gate.switchCell);ctx.save();ctx.translate(mx,my);ctx.rotate(Math.atan2(dy,dx));ctx.beginPath();ctx.moveTo(0,-unit*.34);ctx.lineTo(0,unit*.34);ctx.lineWidth=7;ctx.strokeStyle=open?'#349375':'#d98123';if(open)ctx.setLineDash([3,5]);ctx.stroke();ctx.restore();ctx.fillStyle=open?'#25745c':'#8c4917';ctx.font='bold 12px system-ui';ctx.textAlign='center';ctx.fillText(String(i+1),mx+9,my-8);}
|
||||
for(const c of level.cells){const [x,y]=centers.get(c.id),step=path.indexOf(c.id)+1;const font=Math.max(14,Math.min(24,unit*.34));let symbol=labels.get(c.id)||'';const w=(level.windows||[]).find(w=>w.cell===c.id);if(w)symbol=`${w.min}–${w.max}`;if(c.direction)symbol='';if(c.category)symbol=c.category==='circle'?'○':'△';const gateIndex=(level.gates||[]).findIndex(g=>g.switchCell===c.id);if(gateIndex!==-1)symbol=`开${gateIndex+1}`;
|
||||
if(c.direction)drawChannel(c.direction,x,y-(step?font*.72:0),Math.min(unit*.28,step?font*.4:20));
|
||||
ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillStyle=badIds.has(c.id)?'#a33127':'#30304e';ctx.font=`700 ${font}px system-ui`;if(step){ctx.fillText(String(step),x,y+ (symbol?5:0));if(symbol){ctx.font=`600 ${Math.max(11,font*.65)}px system-ui`;ctx.fillText(symbol,x,y-font*.65);}}else if(symbol)ctx.fillText(symbol,x,y);
|
||||
if(c.clue!==undefined){if(!step&&!symbol)ctx.fillText(String(c.clue),x,y);else{ctx.font=`700 ${Math.max(11,font*.65)}px system-ui`;ctx.fillText('需'+c.clue,x,y+font*.8);}}
|
||||
}
|
||||
$('remaining').textContent=cleared?'完成 ✓':`剩余 ${level.cells.length-path.length} 格`;
|
||||
if(level.doors?.length)renderConstraints();
|
||||
if(bad.length)setStatus(bad[0].message+',可以点已走格退回。');
|
||||
$('undo').disabled=!path.length;$('next').hidden=!cleared||index===7;
|
||||
}
|
||||
function drawChannel(kind,x,y,r){const vector={l:[-1,0],r:[1,0],u:[0,-1],d:[0,1]};const [a,b]=kind.split('').map(k=>vector[k]);ctx.beginPath();ctx.moveTo(x+a[0]*r,y+a[1]*r);ctx.lineTo(x,y);ctx.lineTo(x+b[0]*r,y+b[1]*r);ctx.lineCap='round';ctx.lineJoin='round';ctx.lineWidth=9;ctx.strokeStyle='#c1daf7';ctx.stroke();ctx.lineWidth=4;ctx.strokeStyle='#15497b';ctx.stroke();}
|
||||
function drawBarrier(edge,kind){const left=polygons.filter(p=>p.id===edge[0]),right=polygons.filter(p=>p.id===edge[1]);let common=null;for(const a of left)for(const b of right)for(let i=0;i<a.p.length;i++)for(let j=0;j<b.p.length;j++){const p=a.p[i],q=a.p[(i+1)%a.p.length],r=b.p[j],t=b.p[(j+1)%b.p.length];if(Math.hypot(p[0]-t[0],p[1]-t[1])<.01&&Math.hypot(q[0]-r[0],q[1]-r[1])<.01)common=[p,q];}if(!common)return;const [a,b]=common;const point=f=>[a[0]+(b[0]-a[0])*f,a[1]+(b[1]-a[1])*f];ctx.lineCap='butt';ctx.lineWidth=9;ctx.strokeStyle=kind==='open'?'#23895f':kind==='wall'?'#272638':'#aa4d25';ctx.beginPath();if(kind==='open'){ctx.moveTo(...point(.05));ctx.lineTo(...point(.26));ctx.moveTo(...point(.74));ctx.lineTo(...point(.95));}else{ctx.moveTo(...point(.05));ctx.lineTo(...point(.95));}ctx.stroke();if(kind==='wall'){ctx.lineWidth=4;ctx.strokeStyle='#ffc542';ctx.setLineDash([5,4]);ctx.beginPath();ctx.moveTo(...point(.05));ctx.lineTo(...point(.95));ctx.stroke();ctx.setLineDash([]);}if(kind!=='wall'){const [x,y]=point(.5);ctx.font='700 11px system-ui';ctx.textAlign='center';ctx.fillStyle=kind==='open'?'#12623f':'#993b1e';ctx.fillText(kind==='open'?'开':'关',x+10,y-8);}}
|
||||
function inside(x,y,p){let hit=false;for(let i=0,j=p.length-1;i<p.length;j=i++){const [a,b]=p[i],[c,d]=p[j];if((b>y)!==(d>y)&&x<(c-a)*(y-b)/(d-b)+a)hit=!hit;}return hit;}
|
||||
function hit(x,y){return polygons.find(s=>inside(x,y,s.p))?.id;}
|
||||
function markError(id,message){if(!record.firstError)record.firstError={step:path.length+1,cell:id,message};setStatus(message);}
|
||||
function advance(id){if(!id||cleared)return;const at=path.indexOf(id);if(at!==-1){if(at<path.length-1){record.backtracks++;path=path.slice(0,at+1);focus=[];setStatus('已回退,继续观察剩余格。');draw();}return;}
|
||||
if(!canAppend(level,path,id)){const g=graph(level),adjacent=path.length&&g.adj[g.ids.get(path.at(-1))].includes(g.ids.get(id));if(adjacent||!path.length){markError(id,path.length?(level.walls?.some(e=>e.includes(path.at(-1))&&e.includes(id))?'这是永久墙,不能穿过。':level.doors?.some(d=>d.edge.includes(path.at(-1))&&d.edge.includes(id))?'门现在关闭;绕行落格后会切换。':'同色格不能连续经过,请换另一种颜色。'):'请从数字 1 开始。');}return;}
|
||||
if(!path.length)started=performance.now();path.push(id);focus=[];const bad=violations(level,path);if(bad.length)markError(id,bad[0].message);else setStatus('可松手续画;点已走格可回退。');if(path.length===level.cells.length&&!bad.length){cleared=true;record.completed=true;record.elapsedSeconds=Math.round((performance.now()-started)/1000);if(!completed.includes(level.id)){completed.push(level.id);try{localStorage.setItem(memoryKey,JSON.stringify(completed));}catch{}}setStatus('完成!可以进入下一关,或记录你的推理。');renderSelectors();}draw();}
|
||||
function point(e){const r=canvas.getBoundingClientRect();return [e.clientX-r.left,e.clientY-r.top];}
|
||||
canvas.addEventListener('pointerdown',e=>{e.preventDefault();drag=true;canvas.setPointerCapture(e.pointerId);lastPoint=point(e);advance(hit(...lastPoint));});
|
||||
canvas.addEventListener('pointermove',e=>{if(!drag)return;e.preventDefault();const p=point(e),a=lastPoint||p,d=Math.hypot(p[0]-a[0],p[1]-a[1]),steps=Math.ceil(d/Math.max(3,unit*.1));for(let i=1;i<=steps;i++)advance(hit(a[0]+(p[0]-a[0])*i/steps,a[1]+(p[1]-a[1])*i/steps));lastPoint=p;});
|
||||
function end(){drag=false;lastPoint=null;}canvas.addEventListener('pointerup',end);canvas.addEventListener('pointercancel',end);
|
||||
$('undo').onclick=()=>{if(path.length){path.pop();record.backtracks++;record.completed=false;cleared=false;focus=[];setStatus('已撤销一步。');draw();}};
|
||||
$('restart').onclick=()=>{path=[];cleared=false;focus=[];record.restarts++;record.completed=false;started=performance.now();setStatus('已重开。');draw();};
|
||||
$('hint').onclick=()=>{record.hints++;const h=hint(solution,path);focus=h.kind==='none'?[]:[h.cell];setStatus(h.kind==='backtrack'?'请退回黄色边框格,再继续。':h.kind==='restart'?'起点需要调整,请重开并从黄色边框格开始。':h.kind==='none'?'已经完成。':'下一步在黄色边框格;提示不会自动画路。');draw();};
|
||||
$('next').onclick=()=>{selected=pack.levels[index+1].difficulty;start(index+1);};
|
||||
$('export').onclick=()=>{record.elapsedSeconds=record.completed?record.elapsedSeconds:Math.round((performance.now()-started)/1000);record.reasoning=$('reasoning').value;const blob=new Blob([JSON.stringify(record,null,2)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=pack.mode+'-'+level.id+'-试玩记录.json';a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);};
|
||||
window.addEventListener('resize',draw);start(0);
|
||||
// Read-only snapshot used for interaction verification; never supplies an answer.
|
||||
window.variantSnapshot=()=>({mode:pack.mode,level:level.id,path:[...path],violations:violations(level,path),cleared,centers:Object.fromEntries(centers),unit,openedGates:(level.gates||[]).map(g=>path.includes(g.switchCell)),doors:(level.doors||[]).map(d=>doorOpen(d,path.length))});
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import {fileURLToPath} from 'node:url';import {definitions} from './definitions.mjs';
|
||||
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');const result=[];let total=0;
|
||||
for(const def of definitions){const folder=path.join(root,'modes',def.id),rules=await import('../modes/'+def.id+'/rules.mjs'),pack=JSON.parse(fs.readFileSync(path.join(folder,'levels.json'),'utf8')),notes=JSON.parse(fs.readFileSync(path.join(folder,'design.json'),'utf8'));if(pack.levels.length!==8)throw Error('必须8关');const ids=new Set();
|
||||
for(let i=0;i<8;i++){const l=pack.levels[i];if(ids.has(l.id))throw Error('重复关id');ids.add(l.id);rules.assertStructure(l);if(l.difficulty!==['入门','普通','进阶','难'][Math.floor(i/2)])throw Error('难度顺序');
|
||||
const paths=rules.solve(l,{limit:2}),author=notes[i].author;if(paths.length!==1)throw Error('非唯一 '+def.id+' '+l.id);if(paths[0].join()!==author.join())throw Error('作者解不匹配');
|
||||
const necessary=[];for(const r of rules.constraints(l)){const alternatives=rules.solve(l,{limit:2,omit:r.key});if(alternatives.length<2)throw Error('冗余机制 '+def.id+' '+l.id+' '+r.key);const saved=notes[i].witnesses.find(w=>w.constraint===r.key)?.path;if(!saved||rules.violations(l,saved,{omit:r.key}).length||!rules.violations(l,saved).length)throw Error('必要性证据无效');necessary.push({constraint:r.key,solutionsAfterRemoval:'至少2',witness:saved});}
|
||||
const digital=[];for(const c of l.cells.filter(c=>c.clue!==undefined&&c.clue!==1)){const copy=structuredClone(l);delete copy.cells.find(x=>x.id===c.id).clue;const minimal=rules.solve(copy,{limit:2}).length>=2;if(!minimal)throw Error('冗余辅助数字');digital.push({cell:c.id,step:c.clue,necessary:minimal});}
|
||||
if(i<7&&!l.cells.some(c=>c.clue===1)||i===7&&l.cells.some(c=>c.clue===1))throw Error('起点教学策略不符');
|
||||
for(let k=0;k<author.length;k++)if(!rules.canAppend(l,author.slice(0,k),author[k]))throw Error('作者解与操作不一致');
|
||||
result.push({mode:def.id,title:def.title,level:l.id,difficulty:l.difficulty,n:l.cells.length,unique:true,mechanisms:necessary,digital,goal:l.title,authorPath:author,unknownStart:i===7});total++;}
|
||||
const html=fs.readFileSync(path.join(folder,'play.html'),'utf8');if(/<script[^>]+src=|fetch\(|type="module"/.test(html))throw Error('非单文件离线页面');if(html.includes('"author":')||html.includes('"witnesses":'))throw Error('作者资料进入试玩包');
|
||||
}
|
||||
const report={generatedAt:new Date().toISOString(),levels:total,passed:total,modeCount:definitions.length,uniqueAll:true,mechanismsNecessaryAll:true,humanDifficultyCalibrated:false,results:result};
|
||||
fs.writeFileSync(path.join(root,'reports','validation.json'),JSON.stringify(report,null,2));
|
||||
fs.writeFileSync(path.join(root,'reports','VALIDATION.md'),'# 关卡自动校验报告\n\n'+`通过 ${total}/${total} 关,5 个玩法,每档两关。每关完整有序解唯一,逐项解除新机制后均至少出现两条完整解。辅助数字除教学起点外逐项必要。\n\n`+'| 玩法 | 关 | 难度 | 格数 | 必要机制数 | 起点 |\n|---|---|---|---:|---:|---|\n'+result.map(r=>`| ${r.title} | ${r.level} | ${r.difficulty} | ${r.n} | ${r.mechanisms.length} | ${r.unknownStart?'未知':'给定'} |`).join('\n')+'\n\n详细作者解与机制解除反例见 validation.json。自动通过不代表真人难度或推理品质已校准。\n');
|
||||
console.log(`PASS ${total}/${total} levels; all mechanisms necessary; numeric clues minimized.`);
|
||||
Reference in New Issue
Block a user