纠正仓库内容为衍生项目,标明 campaign-200-r2 最新版

This commit is contained in:
toshiocn
2026-10-19 16:56:31 +08:00
parent a492c5b80e
commit fdca4f67ea
1396 changed files with 392768 additions and 19530 deletions
+16
View File
@@ -0,0 +1,16 @@
import fs from 'node:fs';import path from 'node:path';import {fileURLToPath} from 'node:url';import {constraints,directionNames,violations} from '../rules.mjs';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'),pack=JSON.parse(fs.readFileSync(path.join(root,'levels.json'),'utf8'));const rules=fs.readFileSync(path.join(root,'rules.mjs'),'utf8').replaceAll('export ',''),ui=fs.readFileSync(path.join(root,'ui.mjs'),'utf8').replace(/^import[^\n]+\n/gm,''),css=fs.readFileSync(path.join(root,'style.css'),'utf8');
const audio=fs.readFileSync(path.join(root,'audio.mjs'),'utf8').replaceAll('export ','');
const html=`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#ffffff"><title>数字一笔 · 200关DEMO</title><style>${css}</style></head><body><div id="app">
<section id="screen-select" class="screen"><header class="select-header"><h1 id="title">数字一笔</h1><p class="sub">200关DEMO · 一笔走遍每一格</p><div class="overall"><div class="overall-bar"><i id="overall-fill"></i></div><span id="progress" class="overall-text">0 / 200</span></div></header><div id="pack-list" class="pack-list"></div><button id="btn-continue" class="primary">继续</button><footer class="select-footer"><button id="btn-feedback" class="chip">试玩反馈</button></footer></section>
<section id="screen-play" class="screen hidden"><header class="topbar"><button id="btn-back" class="icon" aria-label="返回选关"><svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></button><div class="level-info"><div id="position" class="level-title"></div><div id="level-sub" class="level-sub"></div></div><button id="btn-sound" class="sound-button" aria-label="切换音效" aria-pressed="true">音效开</button><button id="btn-rules" class="rules-button" aria-label="查看本关规则">规则</button><div id="remaining" class="remaining" aria-live="polite"><svg class="remaining-icon tile" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="4.5" fill="none" stroke="currentColor" stroke-width="2.4"/></svg><svg class="remaining-icon check" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"/></svg><span id="remaining-n">0</span></div></header><div id="level-progress" class="level-progress" aria-hidden="true"></div><div id="caption" class="caption"></div><div id="constraints" aria-label="当前规则"></div><div id="board-wrap" class="board-wrap"><canvas id="board" aria-label="拖动或点按共边格画路"></canvas></div><div class="feedback-row" aria-label="操作反馈与下一关"><div id="status" class="toast" role="status" aria-live="polite"></div><button id="next" class="primary next-overlay" hidden>下一关 →</button></div><h2 id="goal" class="sr-only"></h2><p class="gesture-help">按住滑动连线 · 沿来时路逐格回退</p><footer class="bottombar"><button id="restart" class="action"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M4 12a8 8 0 1 0 2.5-5.8M4 4v5h5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg><span>重开</span></button><button id="undo" class="action"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M9 5l-5 5 5 5M4 10h10a6 6 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg><span>撤销</span></button><button id="hint" class="action"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M5 19v-7a4 4 0 0 1 4-4h10m-4-4 4 4-4 4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg><span>指路</span></button></footer></section>
<div id="rules-sheet" class="sheet-backdrop" hidden><section class="sheet" role="dialog" aria-label="本关规则"><h2>本关规则</h2><p id="rule-text"></p><div class="sheet-actions"><button id="close-rules" class="action">返回游戏</button></div></section></div>
<div id="feedback-sheet" class="sheet-backdrop" hidden><section class="sheet" role="dialog" aria-label="试玩反馈"><h2>试玩反馈</h2><p>记下有趣、枯燥、太难或规则不清楚的关卡,导出记录后用于调整。</p><textarea id="reasoning" aria-label="本关反馈" placeholder="例如:第28关有趣;第84关太难……"></textarea><div class="sheet-actions"><button id="close-feedback" class="action">关闭</button><button id="export" class="primary">导出全部记录</button></div></section></div>
</div><script>const PACK=${JSON.stringify(pack).replaceAll('<','\\u003c')};\n${rules}\n${audio}\n${ui}</script></body></html>`;
fs.writeFileSync(path.join(root,'index.html'),html);
// Offline release has no runtime file dependencies; the return link needs the original folder.
fs.writeFileSync(path.join(root,'NumberSwipe-200关DEMO.html'),html);
let notes=JSON.parse(fs.readFileSync(path.join(root,'design.json'),'utf8'));for(const item of notes){const l=item.level,author=item.author;item.breakdown=constraints(l).map(r=>r.kind==='parity'?`${r.cell} 为第${author.indexOf(r.cell)+1}步,要求${r.value==='odd'?'奇':'偶'}`:r.kind==='direction'?`${r.cell} 必须${directionNames[r.value]}`:r.kind==='wall'?`墙 ${r.edge.join('—')} 封闭该邻接`:r.kind==='door'?`门 ${r.edge.join('—')} 通过前按已走格数切换,通过后保持开启;正确解从第${author.findIndex((id,i)=>i&&r.edge.includes(id)&&r.edge.includes(author[i-1]))}步末端通过`:'每步蓝橙交替');const first=item.witnesses[0];if(first){const fork=first.path.findIndex((id,i)=>id!==author[i]);item.keyFork={step:fork+1,correct:author[fork],tempting:first.path[fork]};item.reason=violations(l,first.path).map(v=>v.message).join(';');}else{item.keyFork={step:0,correct:author[0],tempting:'数字不符的候选路线'};item.reason='基础数字约束';}}
fs.writeFileSync(path.join(root,'design.json'),JSON.stringify(notes,null,2));
const md=['# 200关DEMO制作记录','', '200关均已制作具体坐标。目标难度来自策划,实际难度等待试玩;推理任务不因自动唯一性而被视为人解质量已通过。',''];for(const item of notes){const l=item.level;md.push(`## ${l.order} · ${l.title}`, '',`章节:${l.chapterTitle};节点${l.cells.length};角色${l.role};策划目标${l.targetDifficulty}。`, '',`作者解:${item.author.join(' → ')}。`, '',`原规划任务:${item.planningIntent}。`, '',`结构记录:无即时冲突选择点${item.metrics.noImmediateConflictChoices};最多连续同向${item.metrics.longestSameDirectionTransfers}次转移。此指标不是人类难度。`, '',...item.breakdown.map(s=>'- '+s), '',...item.witnesses.map(w=>`- 解除 ${w.constraint} 后的完整反例:${w.path.join(' → ')}`),'');}fs.writeFileSync(path.join(root,'DESIGN.md'),md.join('\n'));
console.log('Built index.html + standalone DEMO, and author/ablation design record.');
+15
View File
@@ -0,0 +1,15 @@
import fs from 'node:fs';import path from 'node:path';import {fileURLToPath} from 'node:url';import {outlineUnits,units,constraints,directionNames} from '../rules.mjs';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');const definitions=[{id:'campaign-200',title:'200关DEMO',rule:'每格一次;数字为访问步号,各关显示当前启用规则。'}];
const esc=s=>String(s).replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;');
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]);shapes.push({id:c.id,p:outlineUnits(c)});}}
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.parity?(c.parity==='odd'?'奇':'偶'):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=root,pack=JSON.parse(fs.readFileSync(path.join(folder,'levels.json'),'utf8')),notes=JSON.parse(fs.readFileSync(path.join(folder,'design.json'),'utf8'));let articles='';const ordered=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id));for(let i=0;i<ordered.length;i++){const l=ordered[i],n=notes.find(n=>n.level.id===l.id);fs.writeFileSync(path.join(folder,String(l.order).padStart(2,'0')+'-board.svg'),diagram(l));fs.writeFileSync(path.join(folder,String(l.order).padStart(2,'0')+'-solution.svg'),diagram(l,n.author));
articles+=`<article><h2>${i+1} · ${l.chapterTitle} · ${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="index.html">进入试玩</a><h1>${def.title} · 200关设计评审</h1><p>${esc(def.rule)}</p><p>原始盘面和作者解分开展示。标题是设计任务,真人推理路径与难度仍需试玩验证。数字、窗口、方向、类别和开关显示在格内;步差与先后关系见逐关推理依据。</p>${articles}</html>`);}
console.log('Generated 200 boards, 200 solution SVGs and design review page.');
+2
View File
@@ -0,0 +1,2 @@
import {units} from '../rules.mjs';
export function signature(l){const variants=[];for(let turn=0;turn<4;turn++)for(const mirror of [1,-1])for(const swap of [false,true]){const tf=([x,y])=>{x*=mirror;for(let j=0;j<turn;j++)[x,y]=[-y,x];return [x,y];};const coords=l.cells.map(c=>l.geometry==='hex'?[[c.q,c.r]]:units(c)).map(a=>a.map(tf));const minX=Math.min(...coords.flat().map(a=>a[0])),minY=Math.min(...coords.flat().map(a=>a[1]));const keys=coords.map(a=>a.map(([x,y])=>[x-minX,y-minY].join(',')).sort().join(';'));const order=keys.map((k,i)=>({k,i})).sort((a,b)=>a.k.localeCompare(b.k));const rank=new Map(order.map((o,i)=>[l.cells[o.i].id,i]));const vector={l:[-1,0],r:[1,0],u:[0,-1],d:[0,1]},side=([x,y])=>x<0?'l':x>0?'r':y<0?'u':'d';const nodes=order.map(({i,k})=>{const c=l.cells[i];return [k,c.clue??null,c.parity||null,c.direction?c.direction.split('').map(d=>side(tf(vector[d]))).sort().join(''):null,c.category?(swap?(c.category==='circle'?'triangle':'circle'):c.category):null];});const edges=a=>a.map(e=>e.map(id=>rank.get(id)).sort((a,b)=>a-b)).sort((a,b)=>a.join().localeCompare(b.join()));variants.push(JSON.stringify([l.geometry==='hex'?'hex':'rect',nodes,edges(l.walls||[]),(l.doors||[]).map(d=>[edges([d.edge])[0],d.initialOpen])]));}return variants.sort()[0];}
+11
View File
@@ -0,0 +1,11 @@
import {signature} from './fingerprint.mjs';
import fs from 'node:fs';import path from 'node:path';import {fileURLToPath} from 'node:url';import {base,setup,chooseClues,metrics,setSeed} from './generator-core.mjs';import {solve,violations,constraints,assertStructure,units} from '../rules.mjs';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'),plan=JSON.parse(fs.readFileSync(path.join(root,'plan.json'))),old=JSON.parse(fs.readFileSync(path.join(root,'../campaign-50/design.json'))),tutorials=JSON.parse(fs.readFileSync(path.join(root,'tutorials.json')));
const file=path.join(root,'design.json');let notes=fs.existsSync(file)?JSON.parse(fs.readFileSync(file)):[],seen=new Set(notes.filter(n=>!n.level.tutorial).map(n=>signature(n.level)));
function finish(l,s,author,witnesses,source){l.id=s.levelId;l.order=s.displayOrder;l.chapter=s.chapter;l.chapterTitle=plan.chapters[s.chapter-1].title;l.title=s.title||l.title;l.role=s.role;l.targetDifficulty=s.targetDifficulty;l.mode='campaign-200';l.contentRevision=1;l.caption=s.caption||l.caption||'先观察出口,再按当前规则走遍每一格。';if(s.tutorial)l.tutorial=s.tutorial;for(const c of l.cells.filter(c=>c.clue!==undefined&&c.clue!==1)){const number=c.clue;delete c.clue;if(solve(l,{limit:2}).length!==1)c.clue=number;}const m=metrics(l,author);return {level:l,author,witnesses,metrics:m,planningIntent:s.teachingGoal,difficultyStatus:'策划目标,真人难度待试玩校准',source:source||'新制作',designKey:s.designKey};}
for(const s of plan.levels){if(notes.some(n=>n.level.id===s.levelId))continue;let accepted=null;
if(s.tutorial){const l=structuredClone(tutorials[s.tutorial]),author=solve(l,{limit:2});if(author.length!==1)throw Error('教学不唯一 '+s.displayOrder);const ws=constraints(l).map(c=>{const p=solve(l,{omit:c.key,limit:Infinity}).find(p=>violations(l,p).some(v=>v.key===c.key));if(!p)throw Error('教学机制冗余');return {constraint:c.key,path:p};});accepted=finish(l,s,author[0],ws,'极简教学');}
else if(s.reuseId){const n=old.find(n=>n.level.id===s.reuseId);accepted=finish(structuredClone(n.level),s,n.author,n.witnesses,'重编原关卡 '+n.level.order);if(seen.has(signature(accepted.level)))accepted=null;}
if(!accepted){setSeed(20261015+s.displayOrder*1759);for(let attempt=0;attempt<12000;attempt++){const item=base(s,Math.floor(attempt/55));if(!item)continue;const author=item.paths[Math.floor((attempt*37+s.displayOrder*11)%item.paths.length)],l=setup(s,item.cells,author);if(!l)continue;const ws=chooseClues(l,author,item.paths,s);if(!ws)continue;try{assertStructure(l);}catch{continue;}const sig=signature(l);if(seen.has(sig))continue;const m=metrics(l,author);const min=s.role==='回落'?0:s.targetDifficulty>60?2:s.displayOrder>15?1:0;if(m.noImmediateConflictChoices<min)continue;accepted=finish(l,s,author,ws);accepted.candidateAttempts=attempt+1;break;}}
if(!accepted)throw Error('无法制作 '+s.displayOrder+' '+s.title);notes.push(accepted);if(!accepted.level.tutorial)seen.add(signature(accepted.level));fs.writeFileSync(file,JSON.stringify(notes,null,2));console.log('made',s.displayOrder,accepted.level.title,accepted.level.cells.length,'cells',constraints(accepted.level).length,'rules',accepted.candidateAttempts||'reuse/teach');}
notes.sort((a,b)=>a.level.order-b.level.order);const pack={mode:'campaign-200',version:'200-2026-10-15-v1',title:'数字一笔 · 200关版本',chapters:plan.chapters,rule:'每格一次,共边移动;数字为步号,标记按本关规则执行。',levels:notes.map(n=>n.level),playOrder:notes.map(n=>n.level.id)};fs.writeFileSync(file,JSON.stringify(notes,null,2));fs.writeFileSync(path.join(root,'levels.json'),JSON.stringify(pack,null,2));console.log('Generated',pack.levels.length,'levels');
+35
View File
@@ -0,0 +1,35 @@
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,'plan.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=variant%4===0?2:variant%4===1?3: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=spec.irregular||false;const two=spec.doubleBlock||false;const templates=specialL?[[[0,0],[1,0],[0,1]],...(two?[[[0,0],[1,0]]]:[])]:[[[0,0],[1,0]],...(two?[[[0,0],[0,1]]]:[])];
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:8001,nodeLimit:1800000});if(!paths.length){baseCache.set(key,null);return null;}const item={cells,paths,truncated:paths.length>=8001};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-200',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++){const c=l.cells.find(c=>c.id===author[i]),kind=directionAt(l,author,i);if((c.w||1)*(c.h||1)===1&&!c.shape&&kind&&pool[kind])pool[kind].push(author[i]);};const pattern=spec.directionPattern|| (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(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=spec.parityCount||1;const pool=shuffle(author.slice(spec.unknownStart?0: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=spec.wallCount||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){return s.caption||'先观察出口,再按本关规则走遍每一格。';}
function chooseClues(l,author,basePaths,spec={}){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=spec.noExtraNumbers?[]: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:1600000});}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:1600000}).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:'结构代理,不是人类难度或完整规则推理的有效分叉'};}
export {base,setup,chooseClues,metrics};
export function setSeed(value){seed=value;}
+1
View File
@@ -0,0 +1 @@
import fs from 'node:fs';import {fileURLToPath} from 'node:url';const root=new URL('../',import.meta.url),p=JSON.parse(fs.readFileSync(new URL('levels.json',root))),plan=JSON.parse(fs.readFileSync(new URL('plan.json',root))),n=JSON.parse(fs.readFileSync(new URL('design.json',root)));const names={big:'大块',direction:'方向',alternation:'双色',parity:'奇偶',walls:'墙',"rhythm-door":'门'};const bars=p.levels.map(l=>`<button title="第${l.order}关 ${l.title} · 目标${l.targetDifficulty}" data-order="${l.order}" style="height:${Math.max(3,l.targetDifficulty)}%;background:${l.tutorial?'#F5A524':l.role==='回落'?'#6DBF9C':'#6558F5'}"></button>`).join('');const items=p.levels.map(l=>`<article id="level-${l.order}"><b>${l.order} · ${l.title}</b><small>${l.chapterTitle} · ${l.role} · ${l.cells.length}格 · ${l.geometry}</small><p>${l.caption}</p><span>${l.features.map(f=>names[f]).join('+')||'基本覆盖'} · 目标${l.targetDifficulty}</span></article>`).join('');const html=`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>200关编排与目标曲线</title><style>body{max-width:1080px;margin:24px auto;padding:0 16px;background:#f2f2f6;color:#23243a;font:15px/1.6 system-ui}a{color:#6558f5}.chart{height:220px;display:flex;align-items:end;gap:2px;background:white;padding:16px;overflow:auto}.chart button{flex:1;min-width:3px;border:0;padding:0;cursor:pointer}main{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}article{padding:16px;background:white;border-radius:14px;scroll-margin:24px}small,span{display:block;color:#6b6d80}h1{font-size:26px}</style><h1>200关编排与目标曲线</h1><p><a href="index.html">进入200关试玩</a> · <a href="DESIGN.html">查看逐关作者解与必要性反例</a></p><p>20章×10关。橙色为极简教学,绿色为回落,紫色为练习与挑战。柱高是策划目标,尚未经真人难度校准。点击柱条定位关卡。</p><div class="chart">${bars}</div><h2>逐关任务</h2><main>${items}</main><script>document.querySelectorAll('[data-order]').forEach(b=>b.onclick=()=>document.getElementById('level-'+b.dataset.order).scrollIntoView({behavior:'smooth'}))</script></html>`;fs.writeFileSync(new URL('DIFFICULTY.html',root),html);console.log('Built target difficulty overview.');
+4
View File
@@ -0,0 +1,4 @@
import fs from 'node:fs';import {solve} from '../rules.mjs';
const pack=JSON.parse(fs.readFileSync('levels.json')),notes=JSON.parse(fs.readFileSync('design.json')),plan=JSON.parse(fs.readFileSync('plan.json'));let removed=0;
for(const l of pack.levels){for(const c of l.cells.filter(c=>c.clue!==undefined&&c.clue!==1)){const clue=c.clue;delete c.clue;if(solve(l,{limit:2}).length!==1)c.clue=clue;else{removed++;console.log('removed redundant',l.order,c.id,clue);if(l.order===10){l.caption='从1开始,走遍全部格,收尾时不要留下孤格。';plan.levels[9].caption=l.caption;}}}notes.find(n=>n.level.id===l.id).level=l;}
for(const [name,data]of [['levels.json',pack],['design.json',notes],['plan.json',plan]])fs.writeFileSync(name,JSON.stringify(data,null,2));console.log('Pruned',removed,'auxiliary numbers');
+16
View File
@@ -0,0 +1,16 @@
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
const host='192.168.3.74',port=17851;
const server=http.createServer((req,res)=>{
const route=new URL(req.url,'http://localhost').pathname;
if(!['/','/index.html'].includes(route)){res.writeHead(404,{'Content-Type':'text/plain; charset=utf-8'});res.end('Not found');return;}
if(!['GET','HEAD'].includes(req.method)){res.writeHead(405);res.end();return;}
const html=fs.readFileSync(path.join(root,'index.html'));
res.writeHead(200,{'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store','Content-Length':html.length});
res.end(req.method==='HEAD'?undefined:html);
});
server.listen(port,host,()=>console.log(`NumberSwipe DEMO: http://${host}:${port}/`));
server.on('error',error=>{console.error(error.message);process.exitCode=1;});
+5
View File
@@ -0,0 +1,5 @@
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {fileURLToPath} from 'node:url';import {solve,constraints,violations,assertStructure,canAppend,graph,units,sideBetween} from '../rules.mjs';import {signature} from './fingerprint.mjs';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'),read=name=>JSON.parse(fs.readFileSync(path.join(root,name))),pack=read('levels.json'),notes=read('design.json'),plan=read('plan.json');let checks=0;const rows=[],fingerprints=new Set(),introduced=new Set();function check(ok,message){checks++;if(!ok)throw Error(message);}
check(pack.levels.length===200,'必须200关');check(new Set(pack.levels.map(l=>l.id)).size===200,'ID重复');check(pack.playOrder.join()===pack.levels.map(l=>l.id).join(),'试玩顺序不一致');for(const l of pack.levels){const n=notes.find(n=>n.level.id===l.id),s=plan.levels[l.order-1];check(l.order===s.displayOrder&&l.id===s.levelId,'编号不一致');assertStructure(l);check(l.cells.length===s.nodeCountTarget,'格数错误 '+l.order);const relevant=[...l.features,...(l.geometry==='hex'?['hex']:[]),...(l.cells.some(c=>c.shape)?['irregular']:[]),...(!l.cells.some(c=>c.clue===1)?['unknown-start']:[])];for(const f of relevant){if(!introduced.has(f))check(Boolean(l.tutorial),'首次机制缺少教学 '+l.order+' '+f);introduced.add(f);}if(l.tutorial)check(l.cells.length<=6,'教学不够极简');else{const fp=signature(l);check(!fingerprints.has(fp),'镜像/旋转重复 '+l.order);fingerprints.add(fp);}
const answers=solve(l,{limit:2,nodeLimit:7000000});check(answers.length===1,'非唯一 '+l.order);check(answers[0].join()===n.author.join(),'作者解不匹配');for(let i=0;i<n.author.length;i++)check(canAppend(l,n.author.slice(0,i),n.author[i]),'不能按作者解操作');const ablations=[];for(const c of constraints(l)){const alt=solve(l,{limit:2,omit:c.key,nodeLimit:7000000});check(alt.length>=2,'冗余机制 '+l.order+' '+c.key);const w=n.witnesses.find(w=>w.constraint===c.key);check(w&&!violations(l,w.path,{omit:c.key}).length&&violations(l,w.path).some(v=>v.key===c.key),'必要性反例错误');ablations.push(c.key);}for(const cell of l.cells.filter(c=>c.clue!==undefined&&c.clue!==1)){const copy=structuredClone(l);delete copy.cells.find(c=>c.id===cell.id).clue;check(solve(copy,{limit:2,nodeLimit:7000000}).length>=2,'冗余辅助数字 '+l.order+' '+cell.id);}if(l.doors&&l.cells.length<=12)check(JSON.stringify(solve(l,{prune:false}))===JSON.stringify(answers),'门剪枝错误');rows.push({order:l.order,title:l.title,nodeCount:l.cells.length,geometry:l.geometry,features:l.features,tutorial:l.tutorial||null,unique:true,necessary:ablations.length,targetDifficulty:l.targetDifficulty,role:l.role});}
const before=read('reports/demo53-before.json');const changed=before.filter(b=>!fs.existsSync(b.path)||crypto.createHash('sha256').update(fs.readFileSync(b.path)).digest('hex')!==b.sha256);check(!changed.length,'53关DEMO发生修改 '+changed.map(x=>x.path));const old=JSON.parse(fs.readFileSync(path.join(root,'../reports/original-before.json'),'utf8').replace(/^\uFEFF/,''));check(old.every(b=>fs.existsSync(b.path)&&crypto.createHash('sha256').update(fs.readFileSync(b.path)).digest('hex').toUpperCase()===b.hash),'原项目历史文件被修改');const html=fs.readFileSync(path.join(root,'index.html'),'utf8');check(!/<script[^>]+src=|fetch\(|type="module"/.test(html),'不是离线单文件');check(!html.includes('"author":')&&!html.includes('"witnesses":'),'作者解被编入试玩包');check(html.includes('variant:campaign-200:v1'),'存档未隔离');const report={checkedAt:new Date().toISOString(),passed:200,total:200,checks,allUnique:true,allMechanismsNecessary:true,originalDemoFilesChecked:before.length,originalDemoFilesChanged:changed,scope:'数学、教学时机、去重与隔离;目标难度仍待真人校准',rows};fs.writeFileSync(path.join(root,'reports/validation.json'),JSON.stringify(report,null,2));fs.writeFileSync(path.join(root,'reports/VALIDATION.md'),'# 200关验收\n\n全部200关唯一解,每项机制必要,首次新机制均有极简教学。\n\n检查 '+checks+' 项;原53关DEMO '+before.length+' 文件未变;原项目历史 '+old.length+' 文件未变。\n');console.log('PASS 200/200;',checks,'checks; DEMO53 unchanged',before.length,'files');