202 lines
15 KiB
JavaScript
202 lines
15 KiB
JavaScript
'use strict';
|
|
(function(root,factory){
|
|
const shared=root.BendPuzzle||(typeof module==='object'&&module.exports?require('./puzzle-core'):null),api=factory(shared);
|
|
if(typeof module==='object'&&module.exports)module.exports=api;
|
|
root.BendAppLogic=api;
|
|
})(typeof globalThis!=='undefined'?globalThis:this,function(shared){
|
|
if(typeof shared?.key2!=='function')throw new Error('BendPuzzle coordinate helpers are not loaded');
|
|
const key2=shared.key2;
|
|
function sectionCountRange(level){
|
|
const max=Math.max(1,Math.min(10,Math.round(level)||1));
|
|
if(max<=3)return{min:1,max:1};
|
|
return{min:Math.max(1,max-3),max};
|
|
}
|
|
function normalizeGeneratedShape(chunks){
|
|
const minX=Math.min(...chunks.map(cell=>cell[0])),minY=Math.min(...chunks.map(cell=>cell[1]));
|
|
return chunks.map(([x,y])=>[x-minX,y-minY]).sort((a,b)=>a[1]-b[1]||a[0]-b[0]);
|
|
}
|
|
function generatedShapeKey(chunks){return normalizeGeneratedShape(chunks).map(cell=>cell.join(',')).join(';')}
|
|
function transformedGeneratedShape(chunks,rotation,flip){
|
|
let cells=chunks.map(([x,y])=>[flip?-x:x,y]);
|
|
for(let step=0;step<rotation;step++)cells=cells.map(([x,y])=>[-y,x]);
|
|
return normalizeGeneratedShape(cells);
|
|
}
|
|
function generatedShapeFamilyKey(chunks){
|
|
let best=null;
|
|
for(let flip=0;flip<2;flip++)for(let rotation=0;rotation<4;rotation++){
|
|
const key=generatedShapeKey(transformedGeneratedShape(chunks,rotation,flip===1));
|
|
if(best==null||key<best)best=key;
|
|
}
|
|
return best||'';
|
|
}
|
|
function balancedShapeCandidates(candidates,seed,deps){
|
|
const{hash32,rngFrom,shuffle}=deps||{};
|
|
if(typeof hash32!=='function'||typeof rngFrom!=='function'||typeof shuffle!=='function')throw new TypeError('balancedShapeCandidates requires random dependencies');
|
|
const groups=new Map();
|
|
for(const shape of candidates||[]){const family=generatedShapeFamilyKey(shape);if(!groups.has(family))groups.set(family,[]);groups.get(family).push(shape)}
|
|
const rng=rngFrom(hash32((seed>>>0)^0x6a09e667)),buckets=shuffle([...groups.values()],rng).map(group=>shuffle([...group],rng)),result=[];
|
|
for(let round=0,remaining=true;remaining;round++){
|
|
remaining=false;
|
|
for(const bucket of buckets)if(round<bucket.length){result.push(bucket[round]);remaining=true}
|
|
}
|
|
return result;
|
|
}
|
|
function growConnectedShape(rng,size){
|
|
if(size<=1)return[[0,0]];
|
|
const cells=[[0,0]],set=new Set(['0,0']);
|
|
while(cells.length<size){
|
|
const frontier=[],seen=new Set();
|
|
for(const[x,y]of cells)for(const[dx,dy]of[[1,0],[-1,0],[0,1],[0,-1]]){
|
|
const cell=[x+dx,y+dy],key=key2(...cell);if(set.has(key)||seen.has(key))continue;seen.add(key);frontier.push(cell);
|
|
}
|
|
frontier.sort((a,b)=>{
|
|
const ac=Math.abs(a[0])+Math.abs(a[1]),bc=Math.abs(b[0])+Math.abs(b[1]);
|
|
return ac-bc||rng()-.5;
|
|
});
|
|
const compactPool=frontier.slice(0,Math.max(1,Math.ceil(frontier.length*.65))),chosen=compactPool[Math.floor(rng()*compactPool.length)];
|
|
set.add(key2(...chosen));cells.push(chosen);
|
|
}
|
|
return normalizeGeneratedShape(cells);
|
|
}
|
|
function stableHash(parts){
|
|
const text=(Array.isArray(parts)?parts:[parts]).map(value=>value==null?'':typeof value==='object'?JSON.stringify(value):String(value)).join('\u241f');
|
|
let hash=0x811c9dc5;
|
|
for(let index=0;index<text.length;index++){hash^=text.charCodeAt(index);hash=Math.imul(hash,0x01000193)}
|
|
hash^=hash>>>16;hash=Math.imul(hash,0x7feb352d);hash^=hash>>>15;hash=Math.imul(hash,0x846ca68b);hash^=hash>>>16;
|
|
return hash>>>0;
|
|
}
|
|
function seededUnit(parts){return stableHash(parts)/0x100000000}
|
|
function boundedCoefficient(parts){return .8+seededUnit(parts)*.4}
|
|
function deterministicStorePrice(baseCost,worldSeed,x,y,priceVersion=1){
|
|
const coefficient=boundedCoefficient(['store-price',worldSeed,x,y,priceVersion]);
|
|
return{coefficient,price:Math.max(1,Math.round(Math.max(0,Number(baseCost)||0)*coefficient))};
|
|
}
|
|
function scoreLensMultiplier(activeCount){
|
|
const count=Math.max(0,Math.floor(Number(activeCount)||0));
|
|
return count<=0?1:count===1?1.25:count===2?1.4:1.5;
|
|
}
|
|
function completionIdentity(meta,state){
|
|
const paths=(state?.paths||[]).map(path=>({
|
|
startGate:path.startGate,endGate:path.endGate,
|
|
cells:(path.cells||[]).map(cell=>[cell[0],cell[1]])
|
|
})).sort((left,right)=>left.startGate-right.startGate||left.endGate-right.endGate);
|
|
return stableHash(['completion',meta?.id,meta?.seed,meta?.generatorVersion,paths]);
|
|
}
|
|
function deterministicBoardReward(baseScore,{worldSeed=0,meta,state,timeAttackModifier=1,scoreLensCount=0}={}){
|
|
const identity=completionIdentity(meta,state),coefficient=boundedCoefficient(['board-reward',worldSeed,meta?.id,meta?.seed,meta?.generatorVersion,identity]),
|
|
lensMultiplier=scoreLensMultiplier(scoreLensCount),timeMultiplier=Math.max(0,Number(timeAttackModifier)||1),
|
|
award=Math.max(0,Math.round(Math.max(0,Number(baseScore)||0)*coefficient*timeMultiplier*lensMultiplier));
|
|
return{identity,coefficient,lensMultiplier,timeMultiplier,award};
|
|
}
|
|
function specialSchedule(level,validCellCount,seed,encounteredTypes=[],recentTypes=[]){
|
|
const boundedLevel=Math.max(1,Math.min(10,Math.floor(Number(level)||1))),cells=Math.max(1,Math.floor(Number(validCellCount)||1));
|
|
if(boundedLevel<5)return{types:[],setCount:0,maxCells:0};
|
|
const types=['warp','lock','crossing','internalGate'],encountered=new Set(encounteredTypes||[]),recent=(recentTypes||[]).filter(type=>types.includes(type)).slice(-3),
|
|
unseen=types.filter(type=>!encountered.has(type));
|
|
if(unseen.length){
|
|
const introduced=[...unseen].sort((a,b)=>stableHash([seed,'introduction',a])-stableHash([seed,'introduction',b])||a.localeCompare(b))[0];
|
|
return{types:[introduced],setCount:1,maxCells:introduced==='crossing'?1:2,introduction:true};
|
|
}
|
|
const lastType=recent.at(-1),rotated=types.filter(type=>type!==lastType),pool=rotated.length?rotated:types,
|
|
ordered=[...pool].sort((a,b)=>stableHash([seed,b])-stableHash([seed,a])||a.localeCompare(b)),
|
|
typeCount=boundedLevel<=6?1:boundedLevel<=8?Math.min(2,1+(stableHash([seed,'type-count'])%2)):boundedLevel===9?2:2+(stableHash([seed,'type-count'])%2),
|
|
maxByArea=Math.max(1,Math.floor(cells/18)),setCount=Math.max(1,Math.min(maxByArea,boundedLevel<=6?1:boundedLevel===7?2:boundedLevel===8?3:boundedLevel===9?4:5));
|
|
return{types:ordered.slice(0,Math.min(typeCount,ordered.length)),setCount,maxCells:Math.max(2,Math.min(cells,2+setCount*2)),introduction:false};
|
|
}
|
|
function interactionBurden(puzzle){
|
|
const paths=puzzle?.solution||[],totalPathLength=paths.reduce((sum,path)=>sum+(path.cells?.length||0),0),pickups=paths.length,
|
|
gateTravel=(puzzle?.g||[]).reduce((sum,gate,index,gates)=>index?sum+Math.abs(gate[0]-gates[index-1][0])+Math.abs(gate[1]-gates[index-1][1]):sum,0),
|
|
specialSwitches=(puzzle?.specialCells?.crossings?.length||0)+(puzzle?.specialCells?.warps?.length||0)+(puzzle?.specialCells?.locks?.length||0)+(puzzle?.specialCells?.internalGates?.length||0),
|
|
occupancy=new Map();let ambiguousAdjacency=0;
|
|
paths.forEach((path,pathIndex)=>(path.cells||[]).forEach(cell=>occupancy.set(key2(cell[0],cell[1]),pathIndex)));
|
|
for(const[key,pathIndex]of occupancy){const[row,column]=key.split(',').map(Number);for(const[dr,dc]of[[1,0],[0,1]]){const neighbor=occupancy.get(key2(row+dr,column+dc));if(neighbor!=null&&neighbor!==pathIndex)ambiguousAdjacency++}}
|
|
const raw=totalPathLength*.16+pickups*2+Math.min(20,gateTravel*.08)+ambiguousAdjacency*.35+specialSwitches*1.5;
|
|
return{totalPathLength,pickups,gateTravel,ambiguousAdjacency,specialSwitches,score:Math.min(40,raw)};
|
|
}
|
|
function sectionCountWeight(level,size){
|
|
const range=sectionCountRange(level),span=Math.max(1,range.max-range.min),position=(size-range.min)/span;
|
|
return 1+Math.max(0,Math.min(1,position))*(1.4+Math.max(1,Math.min(10,Math.round(level)||1))*.32);
|
|
}
|
|
function sectionSizeOrder(seed,level,deps){
|
|
const{hash32,rngFrom}=deps||{};
|
|
if(typeof hash32!=='function'||typeof rngFrom!=='function')throw new TypeError('sectionSizeOrder requires random dependencies');
|
|
const range=sectionCountRange(level),rng=rngFrom(hash32(seed^0x5a17c9e3)),ranked=[];
|
|
for(let size=range.min;size<=range.max;size++){
|
|
const weight=sectionCountWeight(level,size),sample=Math.max(Number.EPSILON,rng());
|
|
ranked.push({size,key:-Math.log(sample)/weight});
|
|
}
|
|
return ranked.sort((a,b)=>a.key-b.key||b.size-a.size).map(entry=>entry.size);
|
|
}
|
|
function shapeCandidatesForLevel(seed,level,attemptsPerSize,deps){
|
|
const{shapesBySize,hash32,rngFrom,shuffle}=deps||{};
|
|
if(!shapesBySize||typeof shapesBySize.get!=='function'||typeof hash32!=='function'||typeof rngFrom!=='function'||typeof shuffle!=='function')throw new TypeError('shapeCandidatesForLevel requires shape-generation dependencies');
|
|
const attempts=Number.isInteger(attemptsPerSize)&&attemptsPerSize>=0?attemptsPerSize:12,
|
|
sizes=sectionSizeOrder(seed,level,{hash32,rngFrom}),candidates=[],seen=new Set();
|
|
for(const size of sizes){
|
|
for(const known of shapesBySize.get(size)||[]){const key=generatedShapeKey(known);if(!seen.has(key)){seen.add(key);candidates.push(known)}}
|
|
for(let attempt=0;attempt<attempts;attempt++){
|
|
const rng=rngFrom(hash32(seed^Math.imul(size+17,0x45d9f3b)^attempt)),shape=growConnectedShape(rng,size),key=generatedShapeKey(shape);
|
|
if(!seen.has(key)){seen.add(key);candidates.push(shape)}
|
|
}
|
|
}
|
|
return balancedShapeCandidates(candidates,hash32((seed>>>0)^0x3c6ef372),{hash32,rngFrom,shuffle});
|
|
}
|
|
|
|
function analyzePathTurns(path,gates,warpPairs,includeEnd=true,internalGateIndexes=[]){
|
|
if(!path?.cells?.length||includeEnd&&path.endGate==null)return{count:0,cells:[]};
|
|
const sideDelta={N:[-1,0],S:[1,0],W:[0,-1],E:[0,1]},same=(a,b)=>a&&b&&a[0]===b[0]&&a[1]===b[1],warpMap=new Map(),internal=new Set(internalGateIndexes||[]);
|
|
for(const pair of Array.isArray(warpPairs)?warpPairs:[]){if(pair?.a&&pair?.b){warpMap.set(pair.a.join(','),pair.b);warpMap.set(pair.b.join(','),pair.a)}}
|
|
const isWarp=(a,b)=>same(warpMap.get(a?.join(',')),b),outside=gate=>{const delta=sideDelta[gate?.[2]]||[0,0];return[(gate?.[0]||0)+delta[0],(gate?.[1]||0)+delta[1]]};
|
|
const segments=[];let start=0;
|
|
for(let index=1;index<path.cells.length;index++)if(isWarp(path.cells[index-1],path.cells[index])){segments.push(path.cells.slice(start,index));start=index}
|
|
segments.push(path.cells.slice(start));
|
|
const cells=[];
|
|
segments.forEach((segment,segmentIndex)=>{
|
|
if(!segment.length)return;const first=segmentIndex===0,last=segmentIndex===segments.length-1,nodes=[],hasStartOutside=first&&!internal.has(path.startGate),hasEndOutside=last&&includeEnd&&!internal.has(path.endGate);
|
|
if(hasStartOutside)nodes.push(outside(gates?.[path.startGate]));nodes.push(...segment);if(hasEndOutside)nodes.push(outside(gates?.[path.endGate]));
|
|
const offset=hasStartOutside?1:0;
|
|
for(let index=1;index<nodes.length-1;index++){
|
|
const previous=nodes[index-1],cell=nodes[index],next=nodes[index+1],before=[cell[0]-previous[0],cell[1]-previous[1]],after=[next[0]-cell[0],next[1]-cell[1]];
|
|
if(before[0]!==after[0]||before[1]!==after[1]){const sourceIndex=index-offset;if(sourceIndex>=0&&sourceIndex<segment.length)cells.push(segment[sourceIndex])}
|
|
}
|
|
});
|
|
return{count:cells.length,cells};
|
|
}
|
|
function stateForStorage(state,options){
|
|
const scoreVersion=options?.scoreVersion||0,normalizeState=options?.normalizeState,
|
|
source=state&&typeof state==='object'?state:typeof normalizeState==='function'?normalizeState(null):{paths:[],specialProgress:{crossings:[]}};
|
|
return{paths:source.paths||[],specialProgress:source.specialProgress||{crossings:[]},solved:source.solved===true,expanded:source.expanded===true,expansionRetryRound:Number.isInteger(source.expansionRetryRound)&&source.expansionRetryRound>=0?source.expansionRetryRound:0,solvedBy:source.solvedBy||null,solvedById:source.solvedById||null,solvedAt:Number.isFinite(source.solvedAt)&&source.solvedAt>0?source.solvedAt:null,scoreAwarded:source.scoreAwarded||0,scoreVersion:source.scoreVersion||scoreVersion,rewardIdentity:source.rewardIdentity||null,rewardCoefficient:source.rewardCoefficient||null,store:source.store||null,rev:source.rev||0,revAuthor:source.revAuthor||''};
|
|
}
|
|
function collectConnectedLineComponent(meta,pathIndex,cache,environment){
|
|
const targetCache=cache&&typeof cache.has==='function'&&typeof cache.set==='function'?cache:new Map(),data=environment?.data,metaState=environment?.metaState,matchingNeighborGate=environment?.matchingNeighborGate;
|
|
if(!data?.metas||!data?.states||typeof metaState!=='function'||typeof matchingNeighborGate!=='function')throw new TypeError('collectConnectedLineComponent requires line-graph dependencies');
|
|
const startKey=`${meta?.id}:${pathIndex}`;if(targetCache.has(startKey))return targetCache.get(startKey);
|
|
const members=[],visited=new Set(),stack=[[meta?.id,pathIndex]];let length=0;
|
|
while(stack.length){
|
|
const[id,index]=stack.pop(),key=`${id}:${index}`;if(visited.has(key))continue;
|
|
const currentMeta=data.metas[id],state=data.states[id];if(!currentMeta?.puzzle||!state)continue;
|
|
const path=metaState(id).paths[index];if(!path)continue;
|
|
visited.add(key);members.push([currentMeta,index]);length+=path.cells.length;
|
|
for(const gateIndex of[...(path.detachedStart?[]:[path.startGate]),path.endGate]){
|
|
if(gateIndex==null)continue;let hit;try{hit=matchingNeighborGate(currentMeta,gateIndex)}catch(_){hit=null}if(!hit)continue;
|
|
const neighborState=metaState(hit.meta.id),neighborIndex=neighborState.paths.findIndex(candidate=>!candidate.detachedStart&&candidate.startGate===hit.gateIndex||candidate.endGate===hit.gateIndex);
|
|
if(neighborIndex>=0)stack.push([hit.meta.id,neighborIndex]);
|
|
}
|
|
}
|
|
const component={members,length:Math.max(1,length)};
|
|
for(const key of visited)targetCache.set(key,component);targetCache.set(startKey,component);return component;
|
|
}
|
|
function connectedLineLength(meta,pathIndex,cache,environment){return collectConnectedLineComponent(meta,pathIndex,cache,environment).length}
|
|
function lineStrokeWidth(length){return 3+12*(1-Math.exp(-(Math.max(1,length)-1)/90))}
|
|
function renderedConnectedLineWidth(meta,pathIndex,widthCache,environment,componentCache){
|
|
const targetCache=widthCache&&typeof widthCache.has==='function'&&typeof widthCache.set==='function'?widthCache:new Map(),startKey=`${meta?.id}:${pathIndex}`;if(targetCache.has(startKey))return targetCache.get(startKey);
|
|
const component=collectConnectedLineComponent(meta,pathIndex,componentCache,environment),width=lineStrokeWidth(component.length);
|
|
for(const[currentMeta,index]of component.members)targetCache.set(`${currentMeta.id}:${index}`,width);targetCache.set(startKey,width);return width;
|
|
}
|
|
return Object.freeze({
|
|
sectionCountRange,normalizeGeneratedShape,generatedShapeKey,generatedShapeFamilyKey,balancedShapeCandidates,growConnectedShape,shapeCandidatesForLevel,
|
|
stableHash,deterministicStorePrice,deterministicBoardReward,
|
|
specialSchedule,interactionBurden,
|
|
analyzePathTurns,stateForStorage,collectConnectedLineComponent,connectedLineLength,lineStrokeWidth,renderedConnectedLineWidth
|
|
});
|
|
});
|