纠正仓库内容为衍生项目,标明 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
+1
View File
@@ -0,0 +1 @@
import fs from 'node:fs';import {measure} from './score.mjs';import {graph} from '../rules.mjs';import crypto from 'node:crypto';const old=JSON.parse(fs.readFileSync('../campaign-200/design.json')),rows=[];function shape(l){const g=graph(l);let c=g.adj.map(a=>String(a.length));for(let i=0;i<4;i++)c=g.adj.map((a,j)=>crypto.createHash('sha256').update(c[j]+'|'+a.map(k=>c[k]).sort().join(',')).digest('hex').slice(0,12));return l.cells.length+':'+c.sort().join();}for(const n of old){const m=measure(n.level,n.author);rows.push({oldOrder:n.level.order,oldPosition:n.level.chapter+'-'+((n.level.order-1)%10+1),id:n.level.id,title:n.level.title,geometry:n.level.geometry,tutorial:n.level.tutorial||null,features:n.level.features,markerOverlap:n.level.cells.filter(c=>c.direction&&c.parity).map(c=>c.id),structuralFamily:shape(n.level),...m});}const groups=new Map();for(const r of rows){const a=groups.get(r.structuralFamily)||[];a.push(r.oldOrder);groups.set(r.structuralFamily,a);}const duplicateFamilies=[...groups.values()].filter(a=>a.length>1);fs.writeFileSync('reports/old-scores.json',JSON.stringify({method:'可复现推理代理,非真人成功率;结构簇是候选相似形,不等于同构证明',rows,duplicateFamilies},null,2));console.log(JSON.stringify({levels:rows.length,median:rows.map(r=>r.score).sort((a,b)=>a-b)[100],lowUnder20:rows.filter(r=>r.score<20).length,overlaps:rows.filter(r=>r.markerOverlap.length).map(r=>r.oldOrder),sameFamily1and61:rows[0].structuralFamily===rows[60].structuralFamily,bands:Array.from({length:10},(_,i)=>{const a=rows.slice(i*20,i*20+20);return {range:[i*20+1,i*20+20],mean:a.reduce((s,r)=>s+r.score,0)/20,min:Math.min(...a.map(r=>r.score)),max:Math.max(...a.map(r=>r.score))};})}));
+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关评分修订版</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关 · 一笔走遍每一格</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);fs.writeFileSync(path.join(root,'NumberSwipe-200关评分修订版.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关评分修订版',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]:'',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.');
+1
View File
@@ -0,0 +1 @@
import fs from 'node:fs';import {solve} from '../rules.mjs';import {measure} from './score.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 number=c.clue;delete c.clue;let count=2;try{count=solve(l,{limit:2,nodeLimit:1200000}).length;}catch{}if(count!==1)c.clue=number;else removed++;}const n=notes.find(n=>n.level.id===l.id);n.level=l;n.objective=measure(l,n.author);l.objectiveScore=n.objective.score;plan.levels[l.order-1].measuredScore=l.objectiveScore;}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,'redundant numbers, refreshed all scores');
+2
View File
@@ -0,0 +1,2 @@
import {units} from '../rules.mjs';
export function signature(l){const variants=[];for(let turn=0;turn<(l.geometry==='hex'?6:4);turn++)for(const mirror of [1,-1])for(const swap of [false,true]){const tf=([x,y])=>{if(l.geometry==='hex'){if(mirror===-1)[x,y]=[y,x];for(let j=0;j<turn;j++)[x,y]=[-y,x+y];}else{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];}
+21
View File
@@ -0,0 +1,21 @@
import fs from 'node:fs';import {graph,solve,violations,constraints,directionAt,assertStructure,units} from '../rules.mjs';import {measure} from './score.mjs';import {signature} from './fingerprint.mjs';
let seed=2026101502;const rnd=()=>{seed=(seed*1664525+1013904223)>>>0;return seed/4294967296;},pick=a=>a[Math.floor(rnd()*a.length)],shuffle=a=>[...a].sort(()=>rnd()-.5);
function cellsFor(n,geometry,serial){if(geometry==='hex'){const width=n<=24?4:n<=30?5:6,height=Math.ceil(n/width);let pts=[];for(let q=0;q<width;q++)for(let r=0;r<height;r++)pts.push([q,r]);while(pts.length>n){const boundary=pts.filter(([q,r])=>q===0||r===0||q===width-1||r===height-1);const gone=pick(serial%4===0?pts:boundary);pts=pts.filter(p=>p!==gone);}return pts.map(([q,r],i)=>({id:'c'+i,q,r}));}
const shapes=geometry==='blocks'?(serial%3===0?[[[0,0],[1,0],[0,1]],[[0,0],[1,0]]]:[[[0,0],[1,0]],...(serial%2===0?[[[0,0],[0,1]]]:[])]):[];const extra=shapes.reduce((a,s)=>a+s.length-1,0),total=n+extra,width=total<=25?5:total<=36?6:7,height=Math.ceil(total/width);let cells=[];for(let y=0;y<height;y++)for(let x=0;x<width;x++)cells.push({x,y,w:1,h:1});while(cells.length>total){const pool=cells.filter(c=>serial%3===0||c.x===0||c.y===0||c.x===width-1||c.y===height-1);const gone=pick(pool);cells=cells.filter(c=>c!==gone);}for(const template of shapes){const matches=[];for(const a of cells)for(let rot=0;rot<2;rot++){const shape=template.map(([x,y])=>rot?[y,x]:[x,y]);const group=shape.map(([dx,dy])=>cells.find(c=>c.x===a.x+dx&&c.y===a.y+dy&&c.w===1&&c.h===1&&!c.shape));if(group.every(Boolean))matches.push({a,shape,group});}if(!matches.length)return null;const {a,shape,group}=pick(matches);a.w=Math.max(...shape.map(p=>p[0]))+1;a.h=Math.max(...shape.map(p=>p[1]))+1;if(shape.length!==a.w*a.h)a.shape=shape;cells=cells.filter(c=>c===a||!group.includes(c));}return cells.map((c,i)=>({...c,id:'c'+i}));}
function authorPath(l){let g;try{g=graph(l);assertStructure(l);}catch{return null;}const n=l.cells.length,seen=new Set(),p=[];let visits=0;function viable(cur){const left=new Set(Array.from({length:n},(_,i)=>i).filter(i=>!seen.has(i)));left.add(cur);const q=[cur],reached=new Set(q);let leaves=0;for(let k=0;k<q.length;k++){const i=q[k],next=g.adj[i].filter(j=>left.has(j));if(!next.length&&left.size>1)return false;if(i!==cur&&next.length===1&&++leaves>1)return false;for(const j of next)if(!reached.has(j)){reached.add(j);q.push(j);}}return reached.size===left.size;}
function walk(i){if(++visits>60000)return false;seen.add(i);p.push(i);if(p.length===n)return true;if(viable(i)){const choices=g.adj[i].filter(j=>!seen.has(j)).map(j=>({j,v:g.adj[j].filter(k=>!seen.has(k)).length+rnd()*3})).sort((a,b)=>a.v-b.v);for(const {j}of choices)if(walk(j))return true;}p.pop();seen.delete(i);return false;}for(const i of shuffle(Array.from({length:n},(_,i)=>i)).slice(0,8)){if(walk(i))return p.map(i=>g.cells[i].id);seen.clear();p.length=0;if(visits>60000)break;}return null;}
function spatial(l,a){let turns=0,maxRun=0,run=0,last=null,qrev=0,rrev=0,qsign=0,rsign=0;for(let i=1;i<a.length;i++){const x=l.cells.find(c=>c.id===a[i-1]),y=l.cells.find(c=>c.id===a[i]),dx=l.geometry==='hex'?y.q-x.q:Math.sign(y.x-x.x),dy=l.geometry==='hex'?y.r-x.r:Math.sign(y.y-x.y),key=[dx,dy].join();if(last&&last!==key)turns++;run=last===key?run+1:1;maxRun=Math.max(maxRun,run);last=key;if(dx){const s=Math.sign(dx);if(qsign&&qsign!==s)qrev++;qsign=s;}if(dy){const s=Math.sign(dy);if(rsign&&rsign!==s)rrev++;rsign=s;}}return {turnRate:turns/(a.length-2),maxRun,qReversals:qrev,rReversals:rrev};}
function configure(geometry,cells,a,features,serial){const l={mode:'campaign-200-r2',geometry,cells:structuredClone(cells),features:[...features],contentRevision:1};const at=id=>l.cells.find(c=>c.id===id),g=graph(l),pos=new Map(a.map((id,i)=>[id,i]));at(a[0]).clue=1;if(features.includes('alternation'))a.forEach((id,i)=>at(id).category=i%2?'triangle':'circle');
if(features.includes('direction')){const candidates=shuffle(a.slice(1,-1).filter(id=>units(at(id)).length===1&&directionAt(l,a,pos.get(id))));for(const id of candidates.slice(0,serial%3===0?3:2))at(id).direction=directionAt(l,a,pos.get(id));if(!l.cells.some(c=>c.direction))return null;}
if(features.includes('parity')){const pool=shuffle(a.slice(2,-2).filter(id=>!at(id).direction));for(const id of pool.slice(0,serial%4===0?2:1))at(id).parity=(pos.get(id)+1)%2?'odd':'even';}
if(features.includes('rhythm-door')){const pool=a.map((id,i)=>i).filter(i=>i>=4&&i<a.length-2&&i%2===0);if(!pool.length)return null;const i=pick(pool);l.doors=[{edge:[a[i],a[i+1]],initialOpen:false}];}
if(features.includes('walls')){const pool=[];for(let i=0;i<cells.length;i++)for(const j of g.adj[i])if(i<j){const x=cells[i].id,y=cells[j].id;if(Math.abs(pos.get(x)-pos.get(y))===1)continue;if(features.includes('alternation')&&at(x).category===at(y).category)continue;pool.push([x,y]);}if(!pool.length)return null;l.walls=shuffle(pool).slice(0,serial%3===0?3:2);}
return l;}
function fit(l,a){const pool=shuffle(a.slice(2,-2).filter(id=>{const c=l.cells.find(c=>c.id===id);return !c.direction&&!c.parity;}));const cap=Math.ceil(l.cells.length*.25);for(let round=0;round<=cap;round++){let sols;try{sols=solve(l,{limit:2,nodeLimit:250000});}catch{sols=null;}if(sols?.length===1){if(sols[0].join()!==a.join())return null;break;}if(sols?.length===0||round===cap)return null;const alternatives=(sols||[]).filter(p=>p.join()!==a.join());let choices=pool.filter(id=>l.cells.find(c=>c.id===id).clue===undefined);if(alternatives.length)choices=choices.filter(id=>alternatives.some(p=>p[a.indexOf(id)]!==id));if(!choices.length)return null;const id=pick(choices);l.cells.find(c=>c.id===id).clue=a.indexOf(id)+1;}
for(const c of shuffle(l.cells.filter(c=>c.clue!==undefined&&c.clue!==1))){const number=c.clue;delete c.clue;let count=2;try{count=solve(l,{limit:2,nodeLimit:200000}).length;}catch{}if(count!==1)c.clue=number;}
// Remove redundant atoms, but never silently drop an entire requested mechanism.
for(let pass=0;pass<2;pass++){let changed=false;for(const r of constraints(l)){let sols;try{sols=solve(l,{omit:r.key,limit:2,nodeLimit:250000});}catch{return null;}if(sols.length===1){if(r.kind==='direction')delete l.cells.find(c=>c.id===r.cell).direction;else if(r.kind==='parity')delete l.cells.find(c=>c.id===r.cell).parity;else if(r.kind==='wall')l.walls.splice(Number(r.key.split(':')[1]),1);else return null;changed=true;break;}}if(!changed)break;}
for(const feature of l.features){if(feature==='direction'&&!l.cells.some(c=>c.direction)||feature==='parity'&&!l.cells.some(c=>c.parity)||feature==='walls'&&!l.walls?.length)return null;}
const witnesses=[];for(const r of constraints(l)){let sols;try{sols=solve(l,{omit:r.key,limit:2,nodeLimit:350000});}catch{return null;}if(sols.length<2)return null;const p=sols.find(p=>p.join()!==a.join());if(!p||violations(l,p,{omit:r.key}).length)return null;witnesses.push({constraint:r.key,path:p});}return witnesses;}
const file='reports/moderate-bank.json';let bank=fs.existsSync(file)?JSON.parse(fs.readFileSync(file)):[];const combos=[['square',['direction','walls']],['blocks',['big','parity','walls']],['blocks',['big','direction','parity']],['blocks',['big','alternation','walls']],['blocks',['big','direction','alternation']],['hex',['alternation','walls']],['hex',['parity','walls']],['hex',['alternation','walls','rhythm-door']],['square',['direction','rhythm-door']]];for(let index=bank.length;index<40;index++){const n=[24,26,28,30][index%4];let accepted=null;for(let attempt=0;attempt<2000;attempt++){const [geometry,features]=combos[(index+Math.floor(attempt/300))%combos.length];seed=202610150+index*991+attempt*37;const cells=cellsFor(n,geometry,index*711+attempt);if(!cells)continue;const a=authorPath({geometry,features:[],cells});if(!a)continue;const space=spatial({geometry,cells},a);if(geometry==='hex'&&(space.qReversals<2||space.rReversals<2||space.maxRun>5||space.turnRate<.45))continue;const l=configure(geometry,cells,a,features,index*711+attempt);if(!l)continue;const ws=fit(l,a);if(!ws)continue;const m=measure(l,a);if(m.score<52||m.score>70||m.decisions<2||m.nonlocalBranches<1)continue;accepted={level:l,author:a,witnesses:ws,objective:m,spatial:space,source:'略难候选新制作',attempts:attempt+1};break;}if(!accepted)throw Error('Moderate failed '+index);bank.push(accepted);fs.writeFileSync(file,JSON.stringify(bank,null,2));console.log('moderate',bank.length,n,accepted.level.geometry,'score',accepted.objective.score,'tries',accepted.attempts);}
+23
View File
@@ -0,0 +1,23 @@
import fs from 'node:fs';import {graph,solve,violations,constraints,directionAt,assertStructure,units} from '../rules.mjs';import {measure} from './score.mjs';import {signature} from './fingerprint.mjs';
let seed=2026101502;const rnd=()=>{seed=(seed*1664525+1013904223)>>>0;return seed/4294967296;},pick=a=>a[Math.floor(rnd()*a.length)],shuffle=a=>[...a].sort(()=>rnd()-.5);
function cellsFor(n,geometry,serial){if(geometry==='hex'){const width=n<=24?4:n<=30?5:6,height=Math.ceil(n/width);let pts=[];for(let q=0;q<width;q++)for(let r=0;r<height;r++)pts.push([q,r]);while(pts.length>n){const boundary=pts.filter(([q,r])=>q===0||r===0||q===width-1||r===height-1);const gone=pick(serial%4===0?pts:boundary);pts=pts.filter(p=>p!==gone);}return pts.map(([q,r],i)=>({id:'c'+i,q,r}));}
const shapes=geometry==='blocks'?(serial%3===0?[[[0,0],[1,0],[0,1]],[[0,0],[1,0]]]:[[[0,0],[1,0]],...(serial%2===0?[[[0,0],[0,1]]]:[])]):[];const extra=shapes.reduce((a,s)=>a+s.length-1,0),total=n+extra,width=total<=25?5:total<=36?6:7,height=Math.ceil(total/width);let cells=[];for(let y=0;y<height;y++)for(let x=0;x<width;x++)cells.push({x,y,w:1,h:1});while(cells.length>total){const pool=cells.filter(c=>serial%3===0||c.x===0||c.y===0||c.x===width-1||c.y===height-1);const gone=pick(pool);cells=cells.filter(c=>c!==gone);}for(const template of shapes){const matches=[];for(const a of cells)for(let rot=0;rot<2;rot++){const shape=template.map(([x,y])=>rot?[y,x]:[x,y]);const group=shape.map(([dx,dy])=>cells.find(c=>c.x===a.x+dx&&c.y===a.y+dy&&c.w===1&&c.h===1&&!c.shape));if(group.every(Boolean))matches.push({a,shape,group});}if(!matches.length)return null;const {a,shape,group}=pick(matches);a.w=Math.max(...shape.map(p=>p[0]))+1;a.h=Math.max(...shape.map(p=>p[1]))+1;if(shape.length!==a.w*a.h)a.shape=shape;cells=cells.filter(c=>c===a||!group.includes(c));}return cells.map((c,i)=>({...c,id:'c'+i}));}
function authorPath(l){let g;try{g=graph(l);assertStructure(l);}catch{return null;}const n=l.cells.length,seen=new Set(),p=[];let visits=0;function viable(cur){const left=new Set(Array.from({length:n},(_,i)=>i).filter(i=>!seen.has(i)));left.add(cur);const q=[cur],reached=new Set(q);let leaves=0;for(let k=0;k<q.length;k++){const i=q[k],next=g.adj[i].filter(j=>left.has(j));if(!next.length&&left.size>1)return false;if(i!==cur&&next.length===1&&++leaves>1)return false;for(const j of next)if(!reached.has(j)){reached.add(j);q.push(j);}}return reached.size===left.size;}
function walk(i){if(++visits>60000)return false;seen.add(i);p.push(i);if(p.length===n)return true;if(viable(i)){const choices=g.adj[i].filter(j=>!seen.has(j)).map(j=>({j,v:g.adj[j].filter(k=>!seen.has(k)).length+rnd()*3})).sort((a,b)=>a.v-b.v);for(const {j}of choices)if(walk(j))return true;}p.pop();seen.delete(i);return false;}for(const i of shuffle(Array.from({length:n},(_,i)=>i)).slice(0,8)){if(walk(i))return p.map(i=>g.cells[i].id);seen.clear();p.length=0;if(visits>60000)break;}return null;}
function spatial(l,a){let turns=0,maxRun=0,run=0,last=null,qrev=0,rrev=0,qsign=0,rsign=0;for(let i=1;i<a.length;i++){const x=l.cells.find(c=>c.id===a[i-1]),y=l.cells.find(c=>c.id===a[i]),dx=l.geometry==='hex'?y.q-x.q:Math.sign(y.x-x.x),dy=l.geometry==='hex'?y.r-x.r:Math.sign(y.y-x.y),key=[dx,dy].join();if(last&&last!==key)turns++;run=last===key?run+1:1;maxRun=Math.max(maxRun,run);last=key;if(dx){const s=Math.sign(dx);if(qsign&&qsign!==s)qrev++;qsign=s;}if(dy){const s=Math.sign(dy);if(rsign&&rsign!==s)rrev++;rsign=s;}}return {turnRate:turns/(a.length-2),maxRun,qReversals:qrev,rReversals:rrev};}
function configure(geometry,cells,a,features,serial){const l={mode:'campaign-200-r2',geometry,cells:structuredClone(cells),features:[...features],contentRevision:1};const at=id=>l.cells.find(c=>c.id===id),g=graph(l),pos=new Map(a.map((id,i)=>[id,i]));at(a[0]).clue=1;if(features.includes('alternation'))a.forEach((id,i)=>at(id).category=i%2?'triangle':'circle');
if(features.includes('direction')){const candidates=shuffle(a.slice(1,-1).filter(id=>units(at(id)).length===1&&directionAt(l,a,pos.get(id))));for(const id of candidates.slice(0,serial%3===0?3:2))at(id).direction=directionAt(l,a,pos.get(id));if(!l.cells.some(c=>c.direction))return null;}
if(features.includes('parity')){const pool=shuffle(a.slice(2,-2).filter(id=>!at(id).direction));for(const id of pool.slice(0,serial%4===0?2:1))at(id).parity=(pos.get(id)+1)%2?'odd':'even';}
if(features.includes('rhythm-door')){const pool=a.map((id,i)=>i).filter(i=>i>=4&&i<a.length-2&&i%2===0);if(!pool.length)return null;const i=pick(pool);l.doors=[{edge:[a[i],a[i+1]],initialOpen:false}];}
if(features.includes('walls')){const pool=[];for(let i=0;i<cells.length;i++)for(const j of g.adj[i])if(i<j){const x=cells[i].id,y=cells[j].id;if(Math.abs(pos.get(x)-pos.get(y))===1)continue;if(features.includes('alternation')&&at(x).category===at(y).category)continue;pool.push([x,y]);}if(!pool.length)return null;l.walls=shuffle(pool).slice(0,serial%3===0?3:2);}
return l;}
function fit(l,a){const pool=shuffle(a.slice(2,-2).filter(id=>{const c=l.cells.find(c=>c.id===id);return !c.direction&&!c.parity;}));const cap=Math.ceil(l.cells.length*.25);for(let round=0;round<=cap;round++){let sols;try{sols=solve(l,{limit:2,nodeLimit:250000});}catch{sols=null;}if(sols?.length===1){if(sols[0].join()!==a.join())return null;break;}if(sols?.length===0||round===cap)return null;const alternatives=(sols||[]).filter(p=>p.join()!==a.join());let choices=pool.filter(id=>l.cells.find(c=>c.id===id).clue===undefined);if(alternatives.length)choices=choices.filter(id=>alternatives.some(p=>p[a.indexOf(id)]!==id));if(!choices.length)return null;const id=pick(choices);l.cells.find(c=>c.id===id).clue=a.indexOf(id)+1;}
for(const c of shuffle(l.cells.filter(c=>c.clue!==undefined&&c.clue!==1))){const number=c.clue;delete c.clue;let count=2;try{count=solve(l,{limit:2,nodeLimit:200000}).length;}catch{}if(count!==1)c.clue=number;}
// Remove redundant atoms, but never silently drop an entire requested mechanism.
for(let pass=0;pass<2;pass++){let changed=false;for(const r of constraints(l)){let sols;try{sols=solve(l,{omit:r.key,limit:2,nodeLimit:250000});}catch{return null;}if(sols.length===1){if(r.kind==='direction')delete l.cells.find(c=>c.id===r.cell).direction;else if(r.kind==='parity')delete l.cells.find(c=>c.id===r.cell).parity;else if(r.kind==='wall')l.walls.splice(Number(r.key.split(':')[1]),1);else return null;changed=true;break;}}if(!changed)break;}
for(const feature of l.features){if(feature==='direction'&&!l.cells.some(c=>c.direction)||feature==='parity'&&!l.cells.some(c=>c.parity)||feature==='walls'&&!l.walls?.length)return null;}
const witnesses=[];for(const r of constraints(l)){let sols;try{sols=solve(l,{omit:r.key,limit:2,nodeLimit:350000});}catch{return null;}if(sols.length<2)return null;const p=sols.find(p=>p.join()!==a.join());if(!p||violations(l,p,{omit:r.key}).length)return null;witnesses.push({constraint:r.key,path:p});}return witnesses;}
const bankFile='reports/new-bank.json';let bank=fs.existsSync(bankFile)?JSON.parse(fs.readFileSync(bankFile)):[];const known=new Set(bank.map(n=>signature(n.level)));const configs=[['square',['direction','walls']],['square',['direction','walls','rhythm-door']],['blocks',['big','parity','walls']],['blocks',['big','direction','parity','walls']],['blocks',['big','direction','parity','rhythm-door']],['blocks',['big','alternation','walls']],['blocks',['big','direction','alternation','walls']],['blocks',['big','direction','alternation','rhythm-door']],['hex',['alternation','walls']],['hex',['parity','walls']],['hex',['alternation','walls','rhythm-door']],['hex',['parity','walls','rhythm-door']]];
const want=Number(process.argv[2]||150);for(let index=bank.length;index<want;index++){const sizeTier=index<25?0:index<60?1:2,n=(sizeTier===0?[20,22,24]:sizeTier===1?[24,26,28]:[28,30,32,36])[index%(sizeTier===2?4:3)],floor=sizeTier===0?35:sizeTier===1?48:60;let accepted=null;const geometryTarget=index%12;
for(let attempt=0;attempt<1800;attempt++){const [geometry,features]=configs[(geometryTarget+Math.floor(attempt/300))%configs.length];const serial=index*2003+attempt;seed=20261015+serial*73;const cells=cellsFor(n,geometry,serial);if(!cells)continue;const blank={geometry,features:[],cells},a=authorPath(blank);if(!a)continue;const space=spatial(blank,a);if(geometry==='hex'&&(space.qReversals<2||space.rReversals<2||space.maxRun>5||space.turnRate<.45))continue;const l=configure(geometry,cells,a,features,serial);if(!l)continue;const ws=fit(l,a);if(!ws)continue;const sig=signature(l);if(known.has(sig))continue;const m=measure(l,a);if(m.score<floor||m.decisions<3||m.nonlocalBranches<2)continue;accepted={level:l,author:a,witnesses:ws,objective:m,spatial:space,attempts:attempt+1,source:'r2新制作'};known.add(sig);break;}
if(!accepted)throw Error('无法生成难度层 '+index+' N='+n);bank.push(accepted);fs.writeFileSync(bankFile,JSON.stringify(bank,null,2));console.log('bank',bank.length,'N',n,accepted.level.geometry,accepted.level.features.join('+'),'score',accepted.objective.score,'decisions',accepted.objective.decisions,'tries',accepted.attempts);}
+1
View File
@@ -0,0 +1 @@
import './score-page.mjs';
+20
View File
@@ -0,0 +1,20 @@
import fs from 'node:fs';import crypto from 'node:crypto';import {solve,constraints,violations,graph} from '../rules.mjs';import {measure} from './score.mjs';import {signature as computeSignature} from './fingerprint.mjs';const signatureCache=new WeakMap();function signature(l){if(!signatureCache.has(l))signatureCache.set(l,computeSignature(l));return signatureCache.get(l);}const familyCache=new WeakMap();function family(l){if(!familyCache.has(l))familyCache.set(l,computeFamily(l));return familyCache.get(l);}
const oldNotes=JSON.parse(fs.readFileSync('../campaign-200/design.json')),oldScores=JSON.parse(fs.readFileSync('reports/old-scores.json')).rows,bank=[...JSON.parse(fs.readFileSync('reports/new-bank.json')),...JSON.parse(fs.readFileSync('reports/moderate-bank.json'))],tutorialSource=JSON.parse(fs.readFileSync('../campaign-200/tutorials.json'));
const names={basic:'第一笔',walls:'墙不能穿过',straight:'左右直行',vertical:'上下直行',corner:'按拐角进出',colour:'蓝橙交替',parity:'奇偶步号',"rhythm-door":'门随脚步切换',hex:'六向共边',"unknown-start":'从哪里出发'};
const tutorialSlots={1:'basic',5:'walls',9:'straight',11:'vertical',13:'corner',17:'colour',23:'parity',29:'rhythm-door',35:'hex',45:'unknown-start'};
const messages={basic:'从1开始,每格一次,共边才能走。',walls:'黑黄墙不能穿过,请从另一侧绕行。',straight:'横通道必须从左、右两侧进出,不能拐弯。',vertical:'竖通道必须从上、下两侧进出。',corner:'拐角连接哪两边,就从哪两边进出,可反向。',colour:'蓝色、橙色交替走,相邻同色也不能连续经过。',parity:'奇/偶要求这一格的访问步号为奇数/偶数。',"rhythm-door":'门随落格切换;开时通过后保持开启,撤销穿门才恢复切换。',hex:'六边形有六个共边方向,每格一步。',"unknown-start":'没有数字1时要找起点;先看数字线索,找出第一步。'};
function annotate(l,a){return constraints(l).map(c=>{const paths=solve(l,{omit:c.key,limit:2,nodeLimit:800000});if(paths.length<2)throw Error('教学机制冗余');return {constraint:c.key,path:paths.find(p=>p.join()!==a.join())};});}
function tutorial(key){let l=structuredClone(tutorialSource[key]);if(key==='hex'){l={geometry:'hex',features:[],cells:Array.from({length:6},(_,i)=>({id:'t'+i,q:i%3,r:Math.floor(i/3)}))};const a=solve(l,{limit:1})[0];for(const step of [1,3,4,6])l.cells.find(c=>c.id===a[step-1]).clue=step;for(const c of l.cells.filter(c=>c.clue!==1)){const step=c.clue;if(step===undefined)continue;delete c.clue;if(solve(l,{limit:2}).length!==1)c.clue=step;}}
if(key==='unknown-start'){l={geometry:'square',features:[],cells:Array.from({length:8},(_,i)=>({id:'t'+i,x:i%3,y:Math.floor(i/3),w:1,h:1}))};const paths=solve(l,{limit:Infinity});let chosen=null;for(const a of paths){const copy=structuredClone(l);for(const step of [2,4,8])copy.cells.find(c=>c.id===a[step-1]).clue=step;if(solve(copy,{limit:2}).length===1){chosen=copy;break;}}if(!chosen)throw Error('unknown teaching');l=chosen;}
l.tutorial=key;l.caption=messages[key];const paths=solve(l,{limit:2});if(paths.length!==1)throw Error('教学不唯一 '+key);const a=paths[0];return {level:l,author:a,witnesses:annotate(l,a),objective:measure(l,a),source:'极简首次教学',spatial:{}};}
function computeFamily(l){const g=graph(l);const adj=g.adj.map((a,i)=>a.filter(j=>!(l.walls||[]).some(e=>e.includes(g.cells[i].id)&&e.includes(g.cells[j].id))&&(!l.features.includes('alternation')||g.cells[i].category!==g.cells[j].category)));let c=adj.map(a=>String(a.length));for(let i=0;i<5;i++)c=adj.map((a,j)=>crypto.createHash('sha256').update(c[j]+'|'+a.map(k=>c[k]).sort().join(',')).digest('hex').slice(0,12));return l.cells.length+':'+c.sort().join();}
const player=JSON.parse(fs.readFileSync('reports/player-anchors.json'));const anchored=new Map([...player.needHint.map(i=>[i,'需要提示']),...player.moderate.map(i=>[i,'略难']),...player.likedUnknownStart.map(i=>[i,'喜欢的未知起点'])]);let pool=[];for(const n of oldNotes){const m=oldScores.find(m=>m.id===n.level.id);if(n.level.tutorial||m.markerOverlap.length||n.level.geometry==='hex'||m.score<12)continue;if(!n.level.cells.some(c=>c.direction||c.parity||c.clue>1)&&m.score<17)continue;pool.push({...structuredClone(n),objective:m,source:'旧关保留 '+n.level.order,oldOrder:n.level.order,playerBand:anchored.get(n.level.order)||null});}pool.push(...bank.map((n,i)=>({...structuredClone(n),bankIndex:i,source:'新大盘面 '+(i+1)})));
const unlock={walls:5,direction:9,alternation:17,parity:23,'rhythm-door':29,hex:35,'unknown-start':45};const used=new Set(),families=new Map(),selected=[],failures=[];let prior=null;
function eligible(n,order){const l=n.level;if([73,76,77,111,85,136,149,158].includes(n.oldOrder))return false;if(used.has(n))return false;for(const f of l.features)if(unlock[f]&&order<unlock[f])return false;if(l.geometry==='hex'&&order<35)return false;if(!l.cells.some(c=>c.clue===1)&&![65,105,145,185].includes(order))return false;if(order<11&&l.cells.some(c=>c.direction==='du')||order<13&&l.cells.some(c=>c.direction&&!['lr','du'].includes(c.direction)))return false;const fp=signature(l);if(selected.some(n=>!n.level.tutorial&&signature(n.level)===fp))return false;if((families.get(family(l))||0)>=2)return false;return true;}
function isDeep(i){const local=(i-1)%10+1;return [3,8].includes(local)&&!([11,15,19].includes(Math.ceil(i/10))&&local===3);}
function target(i){const local=(i-1)%10+1;if(i>100)return isDeep(i)?82:(i<141?62:67)+(local===9?-6:local===10?-2:0);const base=i<17?20:i<35?32:i<61?42:57;return base+(local===8?8:local===9?-10:local===10?-5:0);}
function minimum(i){const local=(i-1)%10+1,relief=local===9||local===10;return i<17?10:i<35?18:i<61?28:i<101?(relief?35:42):i<141?(relief?48:52):(relief?52:56);}
const fixedAnchors={38:73,48:76,58:77,65:111,78:85,105:136,109:149,119:158};for(let order=1;order<=200;order++){let n;const key=tutorialSlots[order];if(key)n=tutorial(key);else if(fixedAnchors[order]){n=pool.find(n=>n.oldOrder===fixedAnchors[order]&&!used.has(n));if(!n)throw Error('Missing player anchor '+order);used.add(n);families.set(family(n.level),(families.get(family(n.level))||0)+1);}else if([145,185].includes(order)){n=JSON.parse(fs.readFileSync('reports/unknown-bank.json'))[order===145?0:1];if(!n)throw Error('unknown not ready');}else{let minSize=order>=141?((order-1)%10>=8?24:28):order>=101?24:order>=81?20:order>=61?12:0;const need=minimum(order);let options=pool.filter(n=>eligible(n,order)&&n.objective.score>=need&&n.level.cells.length>=minSize&&(order<=100||(isDeep(order)?n.objective.score>=73:n.objective.score<=72)));if(!options.length){failures.push({order,need,minSize,poolRemaining:pool.filter(n=>eligible(n,order)).length});throw Error('缺少符合门槛的关卡 '+JSON.stringify(failures.at(-1)));}options.sort((a,b)=>{function cost(n){const l=n.level,repeat=prior&&l.geometry===prior.geometry&&l.features.join()===prior.features.join()?3:0;const oversize=Math.max(0,l.cells.length-(order<61?14:order<101?24:order<141?28:32))*.8;return Math.abs(n.objective.score-target(order))+repeat+oversize;}return cost(a)-cost(b);});n=options[0];used.add(n);families.set(family(n.level),(families.get(family(n.level))||0)+1);}
n=structuredClone(n);const l=n.level;l.previousId=l.id||null;l.id='r2-'+String(order).padStart(3,'0');l.order=order;l.chapter=Math.ceil(order/10);l.chapterTitle='第'+l.chapter+'章';l.mode='campaign-200-r2';l.contentRevision=2;l.playerBand=n.playerBand||null;l.estimatedBand=key?'教学':l.playerBand||(!l.cells.some(c=>c.clue===1)?'未知起点候选':order>100?(isDeep(order)?'深想候选':'略难候选'):'渐进练习');l.role=key?'引导':(order-1)%10===7?'峰值':(order-1)%10===8?'回落':(order-1)%10===9?'收束':'练习';l.targetDifficulty=target(order);l.objectiveScore=n.objective.score;l.title=key?'引导 · '+names[key]:n.source.startsWith('旧')?l.title:({square:'方格',blocks:'拼块',hex:'六边'}[l.geometry]+' · '+(l.role==='峰值'?'交错挑战':l.role==='回落'?'换一种思路':'出口布局')+' '+order);l.caption=key?messages[key]:'';n.metrics={noImmediateConflictChoices:n.objective.decisions,longestSameDirectionTransfers:n.objective.longestForcedRun,scope:'评分见objective,非真人实测'};n.planningIntent=key?messages[key]:'保留至少'+minimum(order)+'分的可复现推理负担,以区域出口、延迟排错和多规则协作选路。';n.difficultyStatus='reasoner-v1代理分,等待真人校准';selected.push(n);prior=l;}
const chapters=Array.from({length:20},(_,i)=>({id:i+1,title:'第'+(i+1)+'章 · '+(i<4?'理解与应用':i<10?'交错推理':i<14?'区域规划':'综合挑战')}));for(const n of selected)n.level.chapterTitle=chapters[n.level.chapter-1].title;
const pack={mode:'campaign-200-r2',version:'scored-2026-10-15-r2',title:'数字一笔 · 200关评分修订版',chapters,levels:selected.map(n=>n.level),playOrder:selected.map(n=>n.level.id)};const plan={version:pack.version,scoreVersion:'reasoner-v1',curvePolicy:'解锁受约束的评分重排;首现教学后快速恢复挑战;每章8峰值9回落10收束。高分不是真人困难概率。',chapters,levels:selected.map(n=>({displayOrder:n.level.order,levelId:n.level.id,chapter:n.level.chapter,title:n.level.title,features:n.level.features,geometry:n.level.geometry,nodeCountTarget:n.level.cells.length,role:n.level.role,targetDifficulty:n.level.targetDifficulty,measuredScore:n.objective.score,playerBand:n.level.playerBand,estimatedBand:n.level.estimatedBand,teachingGoal:n.planningIntent,tutorial:n.level.tutorial||null}))};fs.writeFileSync('levels.json',JSON.stringify(pack,null,2));fs.writeFileSync('design.json',JSON.stringify(selected,null,2));fs.writeFileSync('plan.json',JSON.stringify(plan,null,2));fs.writeFileSync('reports/reorder-map.json',JSON.stringify(selected.map(n=>({order:n.level.order,oldOrder:n.oldOrder||null,source:n.source,score:n.objective.score,nodes:n.level.cells.length})),null,2));console.log('Selected',selected.length,'new',selected.filter(n=>n.source.startsWith('新')).length,'old',selected.filter(n=>n.source.startsWith('旧')).length,'max nodes',Math.max(...selected.map(n=>n.level.cells.length)));console.log('bands',Array.from({length:10},(_,i)=>{const a=selected.slice(i*20,i*20+20);return [i*20+1,Math.round(a.reduce((s,n)=>s+n.objective.score,0)/20),Math.min(...a.filter(n=>!n.level.tutorial).map(n=>n.objective.score))];}));
File diff suppressed because one or more lines are too long
+7
View File
@@ -0,0 +1,7 @@
import {graph,violations,edgeBlocked,sideBetween,solve} from '../rules.mjs';
export function measure(l,answer=null,{depthLimit=8,stateLimit=12000}={}){const g=graph(l),author=answer||solve(l)[0],n=g.cells.length,cat=new Map(l.cells.map(c=>[c.id,c.category])),cells=new Map(l.cells.map(c=>[c.id,c]));const staticAdj=g.adj.map((a,i)=>a.filter(j=>!(l.walls||[]).some(e=>e.includes(g.cells[i].id)&&e.includes(g.cells[j].id))&&(!l.features.includes('alternation')||cat.get(g.cells[i].id)!==cat.get(g.cells[j].id))));let probes=0,capped=false;
function candidates(p){const source=p.length?g.adj[g.ids.get(p.at(-1))].map(j=>g.cells[j].id):l.cells.filter(c=>!g.clues.has(1)||c.clue===1).map(c=>c.id);return source.filter(id=>{if(p.includes(id))return false;const c=cells.get(id);if(c.clue!==undefined&&c.clue!==p.length+1)return false;if(p.length&&(edgeBlocked(l,p,p.at(-1),id)||l.features.includes('alternation')&&cat.get(p.at(-1))===cat.get(id)))return false;if(c.direction&&p.length&&!c.direction.includes(sideBetween(l,id,p.at(-1))))return false;return !violations(l,[...p,id]).length;});}
function simple(p){if(violations(l,p).length)return false;if(p.length===n)return true;const cur=g.ids.get(p.at(-1)),available=new Set(g.cells.map((c,i)=>i).filter(i=>!p.includes(g.cells[i].id)));available.add(cur);const q=[cur],seen=new Set(q);let leaves=0;for(let k=0;k<q.length;k++){const i=q[k],next=staticAdj[i].filter(j=>available.has(j));if(!next.length)return false;if(i!==cur&&next.length===1){if(++leaves>1)return false;if(g.cells[i].direction)return false;}for(const j of next)if(!seen.has(j)){seen.add(j);q.push(j);}}if(seen.size!==available.size)return false;for(const c of l.cells)if(c.clue!==undefined&&!p.includes(c.id)){const target=g.ids.get(c.id),dist=new Map([[cur,0]]),walk=[cur];for(let k=0;k<walk.length&&!dist.has(target);k++)for(const j of staticAdj[walk[k]])if(available.has(j)&&!dist.has(j)){dist.set(j,dist.get(walk[k])+1);walk.push(j);}if(!dist.has(target)||dist.get(target)>c.clue-p.length)return false;}return true;}
function probe(p,d,budget){if(++probes>budget){capped=true;return {depth:d,survived:true,capped:true};}if(!simple(p))return {depth:d,survived:false};if(d>=depthLimit)return {depth:d,survived:true};let best={depth:d,survived:false};for(const id of candidates(p)){const r=probe([...p,id],d+1,budget);if(r.depth>best.depth)best=r;if(r.survived)return r;}return best;}
let decisions=0,forced=0,longestForced=0,run=0,wrongBranches=0,hard=0;const traps=[],branches=[];for(let k=0;k<author.length;k++){const prefix=author.slice(0,k),all=candidates(prefix),plausible=all.filter(id=>simple([...prefix,id]));if(plausible.length<=1){forced++;longestForced=Math.max(longestForced,++run);}else{decisions++;run=0;}for(const id of plausible.filter(id=>id!==author[k])){const before=probes,r=probe([...prefix,id],0,probes+stateLimit);wrongBranches++;if(r.depth>=3)hard++;traps.push(r.depth);branches.push({step:k+1,tempting:id,correct:author[k],survivesAtLeast:r.depth,depthCapped:r.survived,probeStates:probes-before});}}
const avg=traps.length?traps.reduce((a,b)=>a+b,0)/traps.length:0;const raw=2*Math.log2(1+n/3)+5*Math.log2(1+decisions)+4*Math.log2(1+hard)+6*avg+3*Math.log2(1+probes/10)-5*forced/n;const score=Math.max(0,Math.min(100,Math.round(raw)));let turns=0,last=null;for(let i=1;i<author.length;i++){const a=cells.get(author[i-1]),b=cells.get(author[i]),v=l.geometry==='hex'?[b.q-a.q,b.r-a.r]:[Math.sign(b.x-a.x),Math.sign(b.y-a.y)];const key=v.join();if(last&&key!==last)turns++;last=key;}return {version:'reasoner-v1',score,nodeCount:n,decisions,forcedFraction:forced/n,longestForcedRun:longestForced,wrongBranches,nonlocalBranches:hard,meanWrongDepth:Number(avg.toFixed(2)),maxWrongDepth:Math.max(0,...traps),probeStates:probes,depthLimit,stateLimit,capped,turns,branches};}
+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=17852;
const server=http.createServer((req,res)=>{
const route=new URL(req.url,'http://localhost').pathname;
if(!['/','/index.html','/SCORES.html','/DIFFICULTY.html','/DESIGN.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,route==='/'?'index.html':route.slice(1)));
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;});
+21
View File
@@ -0,0 +1,21 @@
import fs from 'node:fs';import {graph,solve,violations,constraints,directionAt,assertStructure,units} from '../rules.mjs';import {measure} from './score.mjs';import {signature} from './fingerprint.mjs';
let seed=2026101502;const rnd=()=>{seed=(seed*1664525+1013904223)>>>0;return seed/4294967296;},pick=a=>a[Math.floor(rnd()*a.length)],shuffle=a=>[...a].sort(()=>rnd()-.5);
function cellsFor(n,geometry,serial){if(geometry==='hex'){const width=n<=24?4:n<=30?5:6,height=Math.ceil(n/width);let pts=[];for(let q=0;q<width;q++)for(let r=0;r<height;r++)pts.push([q,r]);while(pts.length>n){const boundary=pts.filter(([q,r])=>q===0||r===0||q===width-1||r===height-1);const gone=pick(serial%4===0?pts:boundary);pts=pts.filter(p=>p!==gone);}return pts.map(([q,r],i)=>({id:'c'+i,q,r}));}
const shapes=geometry==='blocks'?(serial%3===0?[[[0,0],[1,0],[0,1]],[[0,0],[1,0]]]:[[[0,0],[1,0]],...(serial%2===0?[[[0,0],[0,1]]]:[])]):[];const extra=shapes.reduce((a,s)=>a+s.length-1,0),total=n+extra,width=total<=25?5:total<=36?6:7,height=Math.ceil(total/width);let cells=[];for(let y=0;y<height;y++)for(let x=0;x<width;x++)cells.push({x,y,w:1,h:1});while(cells.length>total){const pool=cells.filter(c=>serial%3===0||c.x===0||c.y===0||c.x===width-1||c.y===height-1);const gone=pick(pool);cells=cells.filter(c=>c!==gone);}for(const template of shapes){const matches=[];for(const a of cells)for(let rot=0;rot<2;rot++){const shape=template.map(([x,y])=>rot?[y,x]:[x,y]);const group=shape.map(([dx,dy])=>cells.find(c=>c.x===a.x+dx&&c.y===a.y+dy&&c.w===1&&c.h===1&&!c.shape));if(group.every(Boolean))matches.push({a,shape,group});}if(!matches.length)return null;const {a,shape,group}=pick(matches);a.w=Math.max(...shape.map(p=>p[0]))+1;a.h=Math.max(...shape.map(p=>p[1]))+1;if(shape.length!==a.w*a.h)a.shape=shape;cells=cells.filter(c=>c===a||!group.includes(c));}return cells.map((c,i)=>({...c,id:'c'+i}));}
function authorPath(l){let g;try{g=graph(l);assertStructure(l);}catch{return null;}const n=l.cells.length,seen=new Set(),p=[];let visits=0;function viable(cur){const left=new Set(Array.from({length:n},(_,i)=>i).filter(i=>!seen.has(i)));left.add(cur);const q=[cur],reached=new Set(q);let leaves=0;for(let k=0;k<q.length;k++){const i=q[k],next=g.adj[i].filter(j=>left.has(j));if(!next.length&&left.size>1)return false;if(i!==cur&&next.length===1&&++leaves>1)return false;for(const j of next)if(!reached.has(j)){reached.add(j);q.push(j);}}return reached.size===left.size;}
function walk(i){if(++visits>60000)return false;seen.add(i);p.push(i);if(p.length===n)return true;if(viable(i)){const choices=g.adj[i].filter(j=>!seen.has(j)).map(j=>({j,v:g.adj[j].filter(k=>!seen.has(k)).length+rnd()*3})).sort((a,b)=>a.v-b.v);for(const {j}of choices)if(walk(j))return true;}p.pop();seen.delete(i);return false;}for(const i of shuffle(Array.from({length:n},(_,i)=>i)).slice(0,8)){if(walk(i))return p.map(i=>g.cells[i].id);seen.clear();p.length=0;if(visits>60000)break;}return null;}
function spatial(l,a){let turns=0,maxRun=0,run=0,last=null,qrev=0,rrev=0,qsign=0,rsign=0;for(let i=1;i<a.length;i++){const x=l.cells.find(c=>c.id===a[i-1]),y=l.cells.find(c=>c.id===a[i]),dx=l.geometry==='hex'?y.q-x.q:Math.sign(y.x-x.x),dy=l.geometry==='hex'?y.r-x.r:Math.sign(y.y-x.y),key=[dx,dy].join();if(last&&last!==key)turns++;run=last===key?run+1:1;maxRun=Math.max(maxRun,run);last=key;if(dx){const s=Math.sign(dx);if(qsign&&qsign!==s)qrev++;qsign=s;}if(dy){const s=Math.sign(dy);if(rsign&&rsign!==s)rrev++;rsign=s;}}return {turnRate:turns/(a.length-2),maxRun,qReversals:qrev,rReversals:rrev};}
function configure(geometry,cells,a,features,serial){const l={mode:'campaign-200-r2',geometry,cells:structuredClone(cells),features:[...features],contentRevision:1};const at=id=>l.cells.find(c=>c.id===id),g=graph(l),pos=new Map(a.map((id,i)=>[id,i]));at(a[0]).clue=1;if(features.includes('alternation'))a.forEach((id,i)=>at(id).category=i%2?'triangle':'circle');
if(features.includes('direction')){const candidates=shuffle(a.slice(1,-1).filter(id=>units(at(id)).length===1&&directionAt(l,a,pos.get(id))));for(const id of candidates.slice(0,serial%3===0?3:2))at(id).direction=directionAt(l,a,pos.get(id));if(!l.cells.some(c=>c.direction))return null;}
if(features.includes('parity')){const pool=shuffle(a.slice(2,-2).filter(id=>!at(id).direction));for(const id of pool.slice(0,serial%4===0?2:1))at(id).parity=(pos.get(id)+1)%2?'odd':'even';}
if(features.includes('rhythm-door')){const pool=a.map((id,i)=>i).filter(i=>i>=4&&i<a.length-2&&i%2===0);if(!pool.length)return null;const i=pick(pool);l.doors=[{edge:[a[i],a[i+1]],initialOpen:false}];}
if(features.includes('walls')){const pool=[];for(let i=0;i<cells.length;i++)for(const j of g.adj[i])if(i<j){const x=cells[i].id,y=cells[j].id;if(Math.abs(pos.get(x)-pos.get(y))===1)continue;if(features.includes('alternation')&&at(x).category===at(y).category)continue;pool.push([x,y]);}if(!pool.length)return null;l.walls=shuffle(pool).slice(0,serial%3===0?3:2);}
return l;}
function fit(l,a){const pool=shuffle(a.slice(2,-2).filter(id=>{const c=l.cells.find(c=>c.id===id);return !c.direction&&!c.parity;}));const cap=Math.ceil(l.cells.length*.25);for(let round=0;round<=cap;round++){let sols;try{sols=solve(l,{limit:2,nodeLimit:250000});}catch{sols=null;}if(sols?.length===1){if(sols[0].join()!==a.join())return null;break;}if(sols?.length===0||round===cap)return null;const alternatives=(sols||[]).filter(p=>p.join()!==a.join());let choices=pool.filter(id=>l.cells.find(c=>c.id===id).clue===undefined);if(alternatives.length)choices=choices.filter(id=>alternatives.some(p=>p[a.indexOf(id)]!==id));if(!choices.length)return null;const id=pick(choices);l.cells.find(c=>c.id===id).clue=a.indexOf(id)+1;}
for(const c of shuffle(l.cells.filter(c=>c.clue!==undefined&&c.clue!==1))){const number=c.clue;delete c.clue;let count=2;try{count=solve(l,{limit:2,nodeLimit:200000}).length;}catch{}if(count!==1)c.clue=number;}
// Remove redundant atoms, but never silently drop an entire requested mechanism.
for(let pass=0;pass<2;pass++){let changed=false;for(const r of constraints(l)){let sols;try{sols=solve(l,{omit:r.key,limit:2,nodeLimit:250000});}catch{return null;}if(sols.length===1){if(r.kind==='direction')delete l.cells.find(c=>c.id===r.cell).direction;else if(r.kind==='parity')delete l.cells.find(c=>c.id===r.cell).parity;else if(r.kind==='wall')l.walls.splice(Number(r.key.split(':')[1]),1);else return null;changed=true;break;}}if(!changed)break;}
for(const feature of l.features){if(feature==='direction'&&!l.cells.some(c=>c.direction)||feature==='parity'&&!l.cells.some(c=>c.parity)||feature==='walls'&&!l.walls?.length)return null;}
const witnesses=[];for(const r of constraints(l)){let sols;try{sols=solve(l,{omit:r.key,limit:2,nodeLimit:350000});}catch{return null;}if(sols.length<2)return null;const p=sols.find(p=>p.join()!==a.join());if(!p||violations(l,p,{omit:r.key}).length)return null;witnesses.push({constraint:r.key,path:p});}return witnesses;}
const file='reports/unknown-bank.json';const bank=fs.existsSync(file)?JSON.parse(fs.readFileSync(file)):[];for(let index=bank.length;index<2;index++){const n=index?24:20;let accepted=null;for(let attempt=0;attempt<2000;attempt++){seed=20261015+index*99991+attempt*83;const geometry=index?'hex':'blocks',features=index?['parity','walls','rhythm-door']:['big','direction','parity','walls'];const cells=cellsFor(n,geometry,attempt);if(!cells)continue;const a=authorPath({geometry,features:[],cells});if(!a)continue;const l=configure(geometry,cells,a,features,attempt);if(!l)continue;delete l.cells.find(c=>c.clue===1).clue;const space=spatial(l,a);if(geometry==='hex'&&(space.qReversals<2||space.rReversals<2||space.maxRun>5))continue;const ws=fit(l,a);if(!ws)continue;const objective=measure(l,a);if(objective.score<60)continue;accepted={level:l,author:a,witnesses:ws,objective,spatial:space,source:'新增未知起点',playerBand:'未知起点候选'};break;}if(!accepted)throw Error('Unknown bank failed');bank.push(accepted);fs.writeFileSync(file,JSON.stringify(bank,null,2));console.log('unknown bank',index+1,n,'cells score',accepted.objective.score);}
+4
View File
@@ -0,0 +1,4 @@
import fs from 'node:fs';import crypto from 'node:crypto';import {solve,violations,constraints,assertStructure} from '../rules.mjs';import {measure} from './score.mjs';import {signature} from './fingerprint.mjs';const pack=JSON.parse(fs.readFileSync('levels.json')),notes=JSON.parse(fs.readFileSync('design.json')),rows=[],seen=new Set(),introduced=new Set();let checks=0;function check(ok,s){checks++;if(!ok)throw Error(s);}check(pack.levels.length===200,'必须200关');const teacherPositions={walls:5,direction:9,alternation:17,parity:23,'rhythm-door':29,hex:35};let lastUnknown=-100;for(const n of notes){const l=n.level;assertStructure(l);check(!l.cells.some(c=>c.direction&&c.parity),'奇偶方向同格 '+l.order);check(!['big','irregular'].includes(l.tutorial),'不应有大块教学');for(const f of [...l.features,...(l.geometry==='hex'?['hex']:[])])if(teacherPositions[f]&&!introduced.has(f)){check(l.order===teacherPositions[f]&&Boolean(l.tutorial),'首次机制教学 '+f);introduced.add(f);}const answers=solve(l,{limit:2,nodeLimit:1200000});check(answers.length===1&&answers[0].join()===n.author.join(),'非唯一或作者错误 '+l.order);for(const r of constraints(l)){const alt=solve(l,{omit:r.key,limit:2,nodeLimit:1500000});check(alt.length===2,'冗余机制 '+l.order+' '+r.key);const w=n.witnesses.find(w=>w.constraint===r.key);check(w&&!violations(l,w.path,{omit:r.key}).length&&violations(l,w.path).some(v=>v.key===r.key),'反例错误 '+l.order);}
const score=measure(l,n.author);check(score.score===l.objectiveScore,'评分不一致 '+l.order);if(!l.tutorial){const fp=signature(l);check(!seen.has(fp),'镜像重复 '+l.order);seen.add(fp);}if(!l.cells.some(c=>c.clue===1)){check(l.order-lastUnknown>=20,'无起点太密集');lastUnknown=l.order;}if(l.geometry==='hex'&&!l.tutorial){check(Math.max(...l.cells.map(c=>c.q))-Math.min(...l.cells.map(c=>c.q))>=3&&Math.max(...l.cells.map(c=>c.r))-Math.min(...l.cells.map(c=>c.r))>=3,'六边条带');check(n.spatial.qReversals>=2&&n.spatial.rReversals>=2&&n.spatial.maxRun<=5,'六边单调路线');}if(l.order>100&&!l.playerBand&&!l.tutorial){check(l.cells.length>=24,'后段棋盘太小');if(l.estimatedBand==='略难候选')check(score.score>=48&&score.score<=72,'略难候选分档');if(l.estimatedBand==='深想候选')check(score.score>=73,'深想候选分档');}rows.push({order:l.order,title:l.title,geometry:l.geometry,nodes:l.cells.length,features:l.features,objectiveScore:score.score,decisions:score.decisions,nonlocalBranches:score.nonlocalBranches,meanWrongDepth:score.meanWrongDepth,unknownStart:!l.cells.some(c=>c.clue===1),playerBand:l.playerBand,estimatedBand:l.estimatedBand,source:n.source});}
check(rows.filter(r=>r.unknownStart).length===5,'未知起点数量');for(const filename of ['index.html','NumberSwipe-200关DEMO.html']){const html=fs.readFileSync(filename,'utf8');check(!html.includes('"author":')&&!html.includes('"witnesses":'),'试玩混入答案');check(!/<script[^>]+src=|fetch\(/.test(html),'不是离线单文件');check(html.includes('.caption,#constraints{display:none!important}'),'常驻提示未隐藏');}
const baseline=JSON.parse(fs.readFileSync('reports/previous-before.json'));const changed=baseline.filter(b=>!fs.existsSync(b.path)||crypto.createHash('sha256').update(fs.readFileSync(b.path)).digest('hex')!==b.sha256);check(changed.length===0,'旧200关被修改');const unknown=rows.filter(r=>r.unknownStart).map(r=>r.order);const report={checkedAt:new Date().toISOString(),passed:200,total:200,checks,old200FilesChecked:baseline.length,old200FilesChanged:changed,unknownPositions:unknown,maxNodes:Math.max(...rows.map(r=>r.nodes)),scope:'唯一性、必要性、评分重算、教学时机、重复和UI风险;不是真人通关率预测',rows};fs.writeFileSync('reports/validation.json',JSON.stringify(report,null,2));console.log('PASS 200/200;',checks,'checks; unknown',unknown.join(','),'max nodes',report.maxNodes);fs.writeFileSync('reports/VALIDATION.md','# 评分修订版验收\n\n200关唯一解与每项机制必要;检查'+checks+'项。原200关'+baseline.length+'个文件校验值不变。\n\n未知起点:'+unknown.join('、')+';最大'+report.maxNodes+'格。\n');