Files

21 lines
10 KiB
JavaScript

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))];}));