bend_puzzle/test/source-smoke-test.js
2026-07-30 15:13:53 +09:00

163 lines
30 KiB
JavaScript

'use strict';
const cp=require('child_process');
const fs=require('fs');
const {root,path,vm,app,html,css,worker,appLogicSource,assert,functionSource,loadBendPuzzle,loadAppLogic,starterPuzzle,read}=require('./helpers/app-source');
const serverSource=read('server.js');
const packageVersion=JSON.parse(read('package.json')).version,appVersion=packageVersion.split('.').slice(0,2).join('.');
for(const file of ['app.js','app-logic.js','puzzle-core.js','puzzle-worker.js','field-persistence.js','field-persistence-worker.js','server.js'])cp.execFileSync(process.execPath,['--check',path.join(root,file)],{stdio:'inherit'});
for(const marker of [
`APP_VERSION='${appVersion}',SAVE_SCHEMA=31,STORAGE_SCHEMA=30,IDB_LAYOUT_VERSION=8,FIELD_STORAGE_FORMAT=2,GAMEPLAY_DATA_VERSION=3,WORLD_GENERATION='v47-field-reset-20260728-interaction-fix'`,
'SPECIAL_CELL_MIN_LEVEL=5,SPECIAL_CELL_DEBUG_ALL_LEVELS=false',
'shapeCandidatesForLevel','addSpecialCellPattern','addWarpSpecial','addLockSpecial','addCrossingSpecial',
'specialPathValid','crossingsSatisfied','activateCrossing','pathRenderSegments','pathStrokePieces','pathProgressAtCell','specialCellInfoMap','normalizeSpecialCells'
])assert(app.includes(marker),`Missing v47 marker: ${marker}`);
assert(app.includes('const AppLogic=globalThis.BendAppLogic')&&app.includes('AppLogic.shapeCandidatesForLevel')&&app.includes('AppLogic.stateForStorage')&&app.includes('AppLogic.collectConnectedLineComponent'),'Application does not consume the shared pure-logic module');
assert(html.includes(`style.css?v=${appVersion}`)&&html.includes(`puzzle-core.js?v=${appVersion}-5`)&&html.includes(`app-logic.js?v=${appVersion}`)&&html.includes(`field-persistence.js?v=${appVersion}`)&&html.includes(`app.js?v=${appVersion}`),'Web assets do not match package/app/generator version');
assert(worker.includes(`puzzle-core.js?v=${appVersion}-5`),'Worker imports an old puzzle-core asset');
assert(functionSource('createPuzzleWorker').includes('message.error?job.reject')&&!functionSource('createPuzzleWorker').includes('message.error?generatePuzzleOnMainThread'),'Algorithmic worker failures are retried redundantly on the main thread');
assert(html.includes(`<small>v${appVersion}</small>`)&&html.includes(`v${appVersion}</title>`),'Visible version does not match package/app version');
assert(html.includes('id="viewport" tabindex="-1"')&&['modal','storeModal','inventoryModal','timeAttackModal'].every(id=>html.includes(`id="${id}" aria-hidden="true" inert`)),'Hidden dialogs are not inert or the viewport is not programmatically focusable');
assert(functionSource('closeDialogRoot').includes('focusOutsideDialog(root,preferredFocus)')&&functionSource('closeDialogRoot').indexOf('focusOutsideDialog')<functionSource('closeDialogRoot').indexOf("setAttribute('aria-hidden','true')"),'Dialog hiding occurs before focus leaves the dialog');
assert(functionSource('openDialogRoot').includes('setDialogInert(root,false)')&&functionSource('closeDialogRoot').includes('setDialogInert(root,true)'),'Dialog inert state is not synchronized with visibility');
assert(!app.includes('labyrinth-seed')&&!app.includes('giantCompactShapes')&&!app.includes("anomaly==='giant'"),'Retired special multi-section boards remain active');
assert(!app.includes('anomalyAt')&&!app.includes('anomalyScoreMultiplier')&&!app.includes('renderAnomalyOverlays')&&!css.includes('.anomaly'),'Retired anomaly code remains');
assert(!serverSource.includes('migrateLegacyPlayer')&&!serverSource.includes('value.metas')&&!serverSource.includes('value.states'),'Cloud server still reads or migrates the retired monolithic player format');
assert(html.includes('id="clearFeed"')&&css.includes('.clear-feed-item'),'Shared clear feed is missing above the minimap');
assert(serverSource.includes("const WORLD_FILE = path.join(DATA_DIR, 'shared-world.json')")&&serverSource.includes("url.pathname==='/api/cloud/profile'")&&serverSource.includes('withWorldQueue')&&serverSource.includes('clearEvents'),'Shared-world storage, profile naming, serialization, or clear feed API is missing');
assert(functionSource('checkSolvedAndExpand').indexOf('pullCloudWorld(true)')<functionSource('checkSolvedAndExpand').indexOf('st.solved=true')&&functionSource('checkSolvedAndExpand').indexOf('pushCloudPending()')<functionSource('checkSolvedAndExpand').indexOf('expandMeta('),'Clear publication is not server-validated before shared expansion');
assert(functionSource('mergeSnapshotIntoData').includes('authoritativeWorld')&&functionSource('pullCloudWorld').includes('initial&&previousRevision===0&&targetRevision>0')&&functionSource('clearSharedWorldJournalRow').includes('cloudOutboxDeleteKeys'),'Initial shared-world adoption does not replace stale local world rows or clean their outbox entries');
assert(functionSource('noteCloudRow').includes("kind==='state'&&data?.states?.[id]?.solved!==true")&&functionSource('currentCloudPending').includes("solved===true"),'Unfinished personal paths can still enter the shared durable outbox');
assert(functionSource('sharedExpansionRepairDelay').includes('SHARED_EXPANSION_GRACE_MS')&&functionSource('repairExpansions').includes('sharedExpansionRepairDelay(st)<=0'),'Non-solving clients can race the solver while publishing newly generated boards');
assert(app.includes('solvedById')&&appLogicSource.includes('solvedById')&&serverSource.includes('state.solvedById=player.playerId'),'Shared solver identity is not persisted independently of the display name');
assert(serverSource.includes('rowRevision=Math.max')&&serverSource.includes('serverTime()*1000'),'Server row revisions are not comparable with client revisions');
assert(!app.includes('nearestUnselectedEndpointAtClient'),'Unselected endpoint clicks are still intercepted before dragging');
assert(functionSource('extendPointerTo').includes('renderDragFrame(b)')&&functionSource('renderDragFrame').includes('liveEndpointPoint')&&!functionSource('renderDragFrame').includes('pathValid')&&!app.includes('path-live-tail'),'Pointer-following line does not use the lightweight live-endpoint renderer');
assert(!app.includes('pathChangeMotion')&&!app.includes('renderDragTipMotion')&&!css.includes('.path-growth')&&!css.includes('.path-retraction'),'Line growth or retraction animation remains');
assert(css.includes('.turn-count{fill:#fff;font-family:var(--dot-font)'),'Turn counts do not explicitly use the dot-styled game font');
assert(functionSource('addWarpSpecial').includes('invalidateSpecialCellCaches'),'Warp insertion does not invalidate turn-analysis caches');
assert(functionSource('specialCellInfoMap').includes('description')&&functionSource('specialInfoFromEvent').includes('b.specialInfo')&&html.includes('id="specialTooltip"'),'Special-cell hover descriptions are missing');
for(const selector of ['.special-cross','.special-warp','.special-key','.special-door'])assert(css.includes(selector),`Missing special-cell style ${selector}`);
assert(functionSource('makeSpecialMarker').includes("class:'key-ring'")&&!functionSource('makeSpecialMarker').includes(String.raw`textContent='\u9375'`),'Key special cell still uses a kanji glyph');
assert(css.includes('.board-card.solved .special-cell-layer'),'Solved boards do not hide special cells');
assert(functionSource('selectBoard').includes('setActiveBoard(b.id)')&&functionSource('setActiveBoard').includes('previous.drawing?.pointerId==null'),'Board selection does not preserve an active pointer draw while reconciling inactive keyboard state');
assert(!css.includes('.board-card:not(.active)'),'Unselected-board styling remains active');
assert(css.includes('.board-card.hud-current:not(.solved) .board-label')&&functionSource('boardPlayHudVisible').includes('hudBoardId===b?.id')&&functionSource('renderBoardNow').includes("card.classList.toggle('hud-current',hudVisible)"),'Board HUD is not limited to an actively played board');
assert(functionSource('gateFromCell').includes('maxPixels')&&functionSource('extendPointerTo').includes('active.startGate,20'),'Opposite gate selection is not distance-limited');
assert(functionSource('renderBoardNow').includes('pathStrokePieces(segments,startColor,endColor)')&&functionSource('pathColorAtCell').includes('pathProgressAtCell'),'Line colors are not blended along cumulative route length');
assert(functionSource('updateSelectedProgress').includes('b.meta.level')&&!functionSource('updateSelectedProgress').includes('filled'),'Top HUD includes information other than level');
assert(functionSource('renderBoardNow').includes('label.replaceChildren')&&functionSource('makeBoard').includes('label.append(boardActions)'),'Board HUD does not contain the level and attached actions');
assert(html.includes('id="noiseCanvas" width="80" height="64"')&&functionSource('paintNoiseBackground').includes("perfCount('noiseFrames')")&&!css.includes('starTwinkle'),'Low-resolution noise background is missing or the retired starfield remains');
assert(functionSource('unresolvedExpansionCandidates').includes('gateFrontierCandidates(meta)')&&!functionSource('unresolvedExpansionCandidates').includes('frontierCandidates(meta)'),'Normal expansion still creates non-gate frontier boards');
assert(functionSource('syncBoundaryConnections').includes('boundaryColorSource(meta,gi,hit)'),'Connected gate colors are not canonicalized');
assert(!app.includes('level-min-10')&&!app.includes('level-max-10')&&!app.includes('fieldEffects')&&!app.includes('fieldOverlayCanvas')&&!serverSource.includes('/api/player/place-field'),'Difficulty adjustment items or their field implementation remain active');
assert(functionSource('makeBoard').includes('cellShape')&&functionSource('makeBoard').includes('boardCellsPath')&&!functionSource('makeBoard').includes('cellHits'),'Detailed boards do not use compound SVG paths or still allocate per-cell hit nodes');
assert(functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)')&&!app.includes('INTERACTIVE_DETAIL_CELL_BUDGET'),'Visible puzzles are not all selected for detailed rendering');
assert(functionSource('shouldTeleportToUnsolvedBoard').includes('return false')&&!app.includes('function promoteStaticBoard(')&&!functionSource('bindBoard').includes('centerMeta('),'Board input can still promote a summary or teleport the camera');
assert(html.includes('id="fpsCounter"')&&functionSource('refreshFpsCounter').includes('FPS ${fpsLastValue}')&&app.includes('AUXILIARY_FPS=30')&&app.includes('DRAG_TARGET_FPS=60'),'Lightweight FPS display or split interaction budgets are missing');
assert(css.includes('.gem-particle{position:fixed')&&css.includes('width:28px;height:28px')&&functionSource('completionEffect').includes('1800'),'Completion gems are not enlarged or retained long enough');
assert(css.includes('#customEmojiCursor{')&&css.includes('overflow:visible')&&css.includes('#customEmojiCursor.flag-cursor{width:32px;height:32px;overflow:hidden'),'Non-flag custom cursors are still clipped or flag clipping is no longer isolated');
assert(functionSource('extendOne').includes('warpedDuringExtend=true')&&!functionSource('extendOne').includes('safeRelease(b.svg,pointerId)'),'Warp traversal still releases pointer capture');
assert(functionSource('extendPointerTo').includes('pointerOffset'),'Warp continuation does not remap the pointer to the exit');
assert(!app.includes('hysteresisDragPoint')&&!app.includes('DRAG_AXIS_LOCK_DISTANCE')&&!app.includes('POINTER_SNAP_RELEASE'),'Retired drawing hysteresis remains active');
assert(functionSource('scheduleBoardDragFrame').includes('requestAnimationFrame')&&functionSource('queueBoardPointerMove').includes('scheduleBoardDragFrame')&&functionSource('bindBoard').includes('queueBoardPointerMove')&&functionSource('processBoardDragFrame').includes('const move=b.pendingPointerMove'),'Pointer movement is not frame-batched with ordered samples');
assert(functionSource('queueCameraInteraction').includes('requestAnimationFrame')&&functionSource('movePan').includes('queueCameraInteraction'),'Camera panning is not frame-coalesced');
assert(functionSource('leftFieldPanAllowed').includes("classList?.contains('solved')")&&functionSource('beginPan').includes('e.button!==2&&!leftFieldPanAllowed(e)'),'Left-drag field panning is not isolated to solved or undiscovered space');
assert(functionSource('processBoardDragFrame').includes('edgePanVelocity')&&functionSource('processBoardDragFrame').includes('applyCamera(true)'),'Drag edge auto-pan is missing');
assert(!functionSource('makeBoard').includes('darkness')&&!css.includes('.darkness'),'Retired darkness rendering remains');
assert(functionSource('positionBoardLabel').includes('hudPlacementCandidates')&&functionSource('positionBoardLabel').includes("placement.side==='S'"),'HUD does not move to a free edge');
assert(functionSource('positionBoardLabel').includes("b.label.style.width=innerWidth+'px'")&&functionSource('positionBoardLabel').includes("b.label.style.top=(PAD-22)+'px'"),'Top/bottom HUD does not span the board edge or clear upper gates');
assert(functionSource('gateHitBox').includes("side==='N'")&&functionSource('makeBoard').includes('...gateHitBox(gp,g.side)'),'Gate hit areas are not constrained to the owning board');
assert(!app.includes('sharedGateVisible'),'No-op shared-gate visibility wrapper remains');
assert(functionSource('isSolved').includes('crossingsSatisfied(st,p)'),'Crossing is not a prerequisite for normal board completion');
assert(functionSource('crossingsSatisfied').includes('crossingStateAtCell')&&functionSource('activateCrossing').includes('path.cells.push(cell)'),'Crossing is not derived from the live overlapping line state');
assert(functionSource('extendOne').includes('lockForDoor')&&functionSource('extendOne').includes('pathHasLockKey'),'Door traversal does not require the same line to touch the key');
assert(functionSource('extendOne').includes('warpPairForCell')&&functionSource('extendOne').includes('path.cells.push(cell,[...warpExit])'),'Warp traversal does not move to its paired cell');
assert(functionSource('resetSelectedBoard').includes('specialProgress={crossings:[]}'),'Reset does not clear crossing progress');
assert(functionSource('resetSelectedBoard').includes('renderBoardNow(b)')&&!functionSource('resetSelectedBoard').includes('syncBoundaryConnections'),'Reset is not immediate or still recreates inherited routes');
assert(!app.toLowerCase().includes('undolastreset')&&!html.toLowerCase().includes('undo'),'Visible reset undo remains active');
assert(app.includes('STORE_CHANCE=1/30'),'Store appearance rate is not 1/30');
assert(app.includes('MINIMAP_VIEW_CHUNKS_X=42'),'Minimap does not use the wider scale');
assert(app.includes('SOUND_GAIN_MULTIPLIER=3.6')&&functionSource('soundTone').includes('Math.min(.28'),'Sound effects were not amplified');
assert(app.includes('UNIQUE_SOLUTION_MIN_LEVEL=6')&&functionSource('placeChildAtFrontierAttempt').includes('level>=UNIQUE_SOLUTION_MIN_LEVEL'),'Unique-solution selection does not begin at level 6');
assert(functionSource('shapeCandidatesForArea').includes('nearbyShapeFamilyCounts')&&appLogicSource.includes('generatedShapeFamilyKey')&&appLogicSource.includes('balancedShapeCandidates'),'Area-local board shape balancing is missing');
assert(!html.includes('&#x77E2;&#x5370;&#x30AD;&#x30FC;&#xFF1A;&#x7DDA;&#x3092;&#x4F38;&#x3070;&#x3059;')&&css.includes('.control-chips span{padding:9px 12px')&&css.includes('font-size:13px'),'Help controls are too small or still list arrow keys');
assert(css.includes('.path.invalid{stroke:#cfa2a7')&&css.includes('.unfilled-warning-cells{fill:#3b3430')&&!css.includes('.num.turn-warning{fill:#ffbd69;animation'),'Rule warnings remain overly aggressive');
assert(functionSource('addCrossingSpecial').includes('neighbors.some(candidate=>!valid.has')&&functionSource('addCrossingSpecial').includes('gateCells.has(key)'),'Crossing cells can still appear on a board edge or gate cell');
assert(functionSource('updateZoomPresentation').includes('world-overview')&&functionSource('drawWorldOverview').includes('overviewCanvas')&&html.includes('id="overviewCanvas"')&&css.includes('#viewport.canvas-overview #world'),'Canvas overview mode is missing');
assert(!functionSource('drawWorldOverview').includes('drawWorldOverviewPaths')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines'),'Far zoom does not use the minimap long-line renderer');
assert(functionSource('rebuildWorldOverviewCache').includes('drawMapBoardCells')&&functionSource('rebuildWorldOverviewCache').includes('drawMapLongLines')&&!app.includes('function drawWorldOverviewShops('),'Zoomed-out rendering does not share the minimap renderer');
assert(functionSource('makeStaticBoard').includes('renderedConnectedLineWidth(meta,index)')&&!functionSource('makeStaticBoard').includes('state.solved')&&css.includes('.static-summary-path{'),'Unsolved nearby boards do not preserve route thickness');
assert(functionSource('updateTimeAttackUi').includes("classList.toggle('starting'")&&css.includes('@keyframes timeAttackStartEmphasis'),'Time-attack start clock emphasis is missing');
const yellowFaces=app.match(/const YELLOW_FACE_CURSOR_SOURCE=`([\s\S]*?)`;/)?.[1]?.split('\n')||[];
assert(yellowFaces.length===101&&yellowFaces.some(row=>row.startsWith('1FAE9|'))&&yellowFaces.some(row=>row.startsWith('1FAEA|')),'Complete Unicode Emoji 17.0 yellow-face cursor catalog is missing');
assert(app.includes('MAX_FACE_CURSOR_PRICE=50000')&&app.includes('cost=MIN_CURSOR_PRICE+((index*61+37)%100)*MIN_CURSOR_PRICE')&&functionSource('storeItemPrice').includes('Math.min(MAX_FACE_CURSOR_PRICE,adjusted)'),'Yellow-face cursor prices are not deterministic random values spanning 500-50000 gems');
const flagCodes=app.match(/const FLAG_REGION_CODES=`([^`]+)`\.split\(' '\)/)?.[1]?.split(' ')||[];
assert(flagCodes.length===259&&new Set(flagCodes).size===259&&app.includes("['gbeng','England'],['gbsct','Scotland'],['gbwls','Wales']"),'Complete Unicode Emoji 17.0 flag cursor catalog is missing');
const oecdCodes=app.match(/const OECD_FLAG_CODES=new Set\('([^']+)'\.split\(' '\)\)/)?.[1]?.split(' ')||[];
assert(oecdCodes.length===38&&new Set(oecdCodes).size===38&&app.includes('OECD_FLAG_CURSOR_BASE_PRICE=20000')&&app.includes('FLAG_CURSOR_BASE_PRICE=10000'),'Flag cursor base prices or the 38-country OECD tier are missing');
const flagAssetDir=path.join(root,'assets','flags'),flagAssets=fs.readdirSync(flagAssetDir).filter(name=>name.endsWith('.svg'));
assert(flagAssets.length===262&&fs.existsSync(path.join(flagAssetDir,'LICENSE-TWEMOJI.txt')),'Bundled cross-platform flag SVG catalog or attribution is incomplete');
assert(functionSource('syncCursorAppearance').includes("image.src=selected.flagAsset")&&functionSource('setItemIcon').includes("image.src=item.flagAsset")&&functionSource('syncCursorAppearance').includes('nativeSupported')&&css.includes('cursor:none!important')&&css.includes('#customEmojiCursor.flag-cursor{width:32px;height:32px;overflow:hidden')&&css.includes('border-radius:50%'),'Native/DOM SVG-backed circular flag cursor rendering is missing');
assert(functionSource('buildDragCache').includes('endpoint-cursor-image')&&functionSource('buildDragCache').includes('DRAG_FLAG_CLIP_RADIUS')&&functionSource('buildDragCache').includes('x:-DRAG_FLAG_CURSOR_SIZE/2')&&functionSource('buildDragCache').includes("class:'drag-tip-group'")&&functionSource('updateDragCursorDesign').includes('activeCustomCursorItem')&&functionSource('renderDragFrame').includes('cache.tipGroup.style.transform')&&functionSource('bindBoard').includes('queueMicrotask(()=>updateCustomCursorFromPointer(e))')&&!css.includes('body.is-drawing #customEmojiCursor{display:none!important}'),'Grabbed cursor sizing, centering, clipping, or smooth cursor continuity is missing');
assert(css.includes('@font-face{font-family:"DotGothic16Local"')&&css.includes('--emoji-font:')&&css.includes('body,button,input,select,textarea{font-family:var(--dot-font)}')&&!css.includes(':root{--dot-font:"DotGothic16"')&&html.includes('id="customEmojiCursor"'),'Bundled Japanese dot font is overridden or emoji-specific isolation is missing');
assert(!html.includes('&#x6240;&#x6301;&#x30B8;&#x30A7;&#x30E0;')&&!functionSource('completionEffect').includes('ジェム')&&!functionSource('updateScoreLensBadge').includes('予想ジェム'),'Standalone gem terminology remains in the reward UI');
assert(functionSource('renderStorePanel').includes('storeInventoryItems(meta,store)')&&functionSource('purchaseStoreItem').includes('storeInventoryItems(meta,store).some'),'Store UI or purchase validation bypasses its seeded thirteen-item inventory');
assert(functionSource('seededStoreItemIds').includes('.slice(0,12)')&&functionSource('seededStoreItemIds').includes('.slice(0,1)')&&functionSource('maybeOpenStore').includes('itemIds:seededStoreItemIds(meta.seed)'),'Stores do not persist twelve seeded cursors and one seeded non-cursor item');
assert(functionSource('renderStorePanel').includes("{title:'アイテム'")&&functionSource('renderStorePanel').includes("{title:'カーソル'")&&functionSource('renderStorePanel').includes('if(category.cursor)card.append(icon,buy)')&&css.includes('.store-cursor-list{grid-template-columns:repeat(6'),'Shop is not split into item and horizontal twelve-cursor sections');
assert(functionSource('renderInventoryPanel').includes("'inventory-cursor-grid'")&&functionSource('renderInventoryPanel').includes("option.classList.toggle('selected'")&&functionSource('useInventoryItemLoaded').includes("previousCursor===item.cursorStyle?'default':item.cursorStyle"),'Persistent click-to-toggle cursor inventory is missing');
assert(functionSource('makeStaticBoard').includes("openStoreMeta(meta)")&&!functionSource('beginPan').includes('overviewShopAtClient'),'Distant overview still exposes a shop-only hit target instead of matching the minimap');
assert(functionSource('discardUnmovedCreatedPath').includes('path.cells.length!==1')&&functionSource('bindBoard').includes('discardUnmovedCreatedPath(b)'),'Cancelled pickup creation can leave an orphan handle');
assert(functionSource('renderDragFrame').includes('refreshDragNumberColors(b)'),'Number colors do not update in the live pickup renderer');
assert(functionSource('detachPathFromStartGate').includes('path.detachedStart=true')&&functionSource('renderBoardNow').includes("'data-endpoint-side':'start'")&&!app.includes('whitePickupEnd')&&!functionSource('finalizeAtGate').includes("'#fff'"),'Two-ended colored pickup support is incomplete');
assert(!functionSource('openTipMergePlan').includes('a.detachedStart||o.detachedStart')&&functionSource('openTipMergePlan').includes('tipDistance!==0')&&!app.includes('function gateConnectionSteps'),'Same-cell pickup joining or exact-cell gate snapping is incomplete');
assert(functionSource('updateScoreLensBadge').includes('projectedScoreForBoard')&&functionSource('scoreLensVisibleForMeta').includes('SCORE_LENS_ZOOM_THRESHOLD')&&functionSource('useInventoryItemLoaded').includes('data.scoreLensEnabled=!previous')&&/id:'score-lens'[^\n]+scoreLens:true[^\n]+icon:/.test(app)&&!app.includes('SCORE_LENS_RADIUS')&&css.includes('.score-lens-badge'),'Persistent ON/OFF score lens display is missing or still asks for a position');
assert(functionSource('renderStorePanel').includes('formatScore(price)')&&!functionSource('renderStorePanel').includes('price-data.score')&&!functionSource('purchaseStoreItem').includes('price-data.score'),'Store buttons do not always show the actual item price');
assert(functionSource('zoomAt').includes('MIN_CAMERA_SCALE'),'Camera cannot zoom out to overview scale');
assert(functionSource('addObstaclePattern').includes('puzzle.difficulty=sourcePuzzle.difficulty'),'Obstacle generation changes the displayed level and section constraint');
assert(functionSource('addObstaclePattern').includes('largePuzzleBoost')&&functionSource('obstacleCellLimit').includes('Math.floor(total*.2)')&&functionSource('addCrossingSpecial').includes('obstacleCellLimit(p)'),'Large-puzzle obstacle scaling or the strict 20% cap is missing');
assert(functionSource('scoreFromThickness').includes('hardMultiplier')&&functionSource('scoreFromThickness').includes('largeMultiplier'),'Hard and large puzzle score scaling is missing');
const storeDescriptions=[...app.slice(app.indexOf('const STORE_ITEM_BASE='),app.indexOf('const YELLOW_FACE_CURSOR_SOURCE=')).matchAll(/description:'([^']*)'/g)].map(match=>match[1].replace(/\\u3002/g,'。'));
assert(storeDescriptions.length===1&&storeDescriptions.every(description=>(description.match(/。/g)||[]).length===1),'Current non-cursor store items are missing or their descriptions are not exactly one sentence');
assert([...appLogicSource].every(ch=>ch.charCodeAt(0)<128),'app-logic.js contains non-ASCII source');
const BendPuzzle=loadBendPuzzle(),AppLogic=loadAppLogic();
assert(BendPuzzle?.GENERATOR_VERSION===5,'Generator version mismatch');
const starter=starterPuzzle();assert(BendPuzzle.solverDifficulty(starter)===1,'Bundled origin puzzle is not level 1');
const generatedA=BendPuzzle.generatePuzzle([[0,0]],123456,2,0,0),generatedB=BendPuzzle.generatePuzzle([[0,0]],123456,2,0,0);
assert(JSON.stringify(generatedA)===JSON.stringify(generatedB),'Puzzle generation is not deterministic');
const multi=BendPuzzle.generatePuzzle([[0,0],[1,0],[0,1]],987654,6,12,-9);
assert(multi.valid.length===75,'Multi-section normal puzzle generation failed');
assert(multi.difficulty>5&&multi.n.filter(clue=>clue[2]>=4).length>=Math.ceil(multi.n.length*.25),'A demanding high-level puzzle was incorrectly collapsed to level 5');
assert(multi.complexity.rawRating===BendPuzzle.solutionComplexity(multi).rating&&BendPuzzle.difficultyFitsRegion(multi.difficulty,6),'Raw complexity is not retained or regional difficulty classification escaped its band');
const highCluePuzzle=BendPuzzle.generatePuzzle([[2,0],[0,1],[1,1],[2,1],[0,2],[1,2],[0,3],[1,3]],7496588,9,27,-1);
assert(highCluePuzzle.difficulty>5&&highCluePuzzle.n.filter(clue=>clue[2]>=4).length>=Math.ceil(highCluePuzzle.n.length*.25),'A valid level 6-10 puzzle lacks meaningful bend clues');
assert(multi.solution.some(path=>path.cells.some((cell,index)=>index>0&&(Math.floor(cell[0]/5)!==Math.floor(path.cells[index-1][0]/5)||Math.floor(cell[1]/5)!==Math.floor(path.cells[index-1][1]/5)))),'Normal multi-section puzzle does not connect sections');
const shapesBySize=new Map();for(const shape of BendPuzzle.SHAPES){const size=shape.length;if(!shapesBySize.has(size))shapesBySize.set(size,[]);shapesBySize.get(size).push(shape)}
const shapeDeps={shapesBySize,hash32:BendPuzzle.hash32,rngFrom:BendPuzzle.rngFrom,shuffle:BendPuzzle.shuffle};
const familyA=[[0,0],[1,0],[2,0]],familyARotated=[[0,0],[0,1],[0,2]],familyB=[[0,0],[1,0],[0,1]];
assert(AppLogic.generatedShapeFamilyKey(familyA)===AppLogic.generatedShapeFamilyKey(familyARotated)&&AppLogic.generatedShapeFamilyKey(familyA)!==AppLogic.generatedShapeFamilyKey(familyB),'Shape-family normalization does not combine rotations/reflections correctly');
const balancedFamilies=AppLogic.balancedShapeCandidates([familyA,familyARotated,familyB],12345,{hash32:BendPuzzle.hash32,rngFrom:BendPuzzle.rngFrom,shuffle:BendPuzzle.shuffle}).slice(0,2).map(AppLogic.generatedShapeFamilyKey);
assert(new Set(balancedFamilies).size===2,'Shape balancing still exhausts one orientation-rich family before another family');
for(let level=1;level<=10;level++){
const range=AppLogic.sectionCountRange(level);assert(range.max===level&&range.min===Math.max(1,level-3),`Level ${level} section range is invalid`);
const candidates=AppLogic.shapeCandidatesForLevel(0x470000+level,level,8,shapeDeps);assert(candidates.length>0,`Level ${level} has no section shapes`);
for(const shape of candidates){assert(shape.length>=range.min&&shape.length<=range.max,`Level ${level} generated ${shape.length} sections outside ${range.min}-${range.max}`);const set=new Set(shape.map(([x,y])=>`${x},${y}`));let reached=new Set([`${shape[0][0]},${shape[0][1]}`]),changed=true;while(changed){changed=false;for(const[x,y]of shape)if(!reached.has(`${x},${y}`)&&[[1,0],[-1,0],[0,1],[0,-1]].some(([dx,dy])=>reached.has(`${x+dx},${y+dy}`))){reached.add(`${x},${y}`);changed=true}}assert(reached.size===set.size,'Generated section shape is disconnected')}
}
const normalizeContext={AppLogic,LINE_COLORS:Array(10).fill('#000'),isPlainObject:value=>!!value&&typeof value==='object'&&!Array.isArray(value),ckey:(r,c)=>`${r},${c}`,sameCell:(a,b)=>a[0]===b[0]&&a[1]===b[1],manhattan:(a,b)=>Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1]),solverDifficulty:BendPuzzle.solverDifficulty,deepClone:value=>JSON.parse(JSON.stringify(value))};
vm.createContext(normalizeContext);
vm.runInContext([functionSource('normalizePath'),functionSource('normalizeSpecialCells'),functionSource('repairWarpNumberClues'),functionSource('normalizeStoredPuzzle'),functionSource('puzzleForStorage'),'this.logic={normalizeStoredPuzzle,puzzleForStorage}'].join('\n'),normalizeContext);
const normalized=normalizeContext.logic.normalizeStoredPuzzle(starter,[[0,0]],1);assert(normalized,'Starter fails stored-puzzle validation');
const stored=normalizeContext.logic.puzzleForStorage(normalized);assert(stored.solution.length===starter.solution.length,'Stored puzzle round-trip lost its solution');
assert(functionSource('gateCandidateAtPoint').includes('gateCandidatesInCell')&&functionSource('gateStartCandidate').includes('gateCandidateAtPoint')&&functionSource('bindBoard').includes('gateStartCandidate(b,point,hintCell,directGate)'),'Gate and gate-cell input do not share one selector');
assert(functionSource('placeChildAtFrontierAttempt').includes('puzzleSupportsConnectionRequirements')&&functionSource('placeChildAtFrontierAttempt').includes('fallbackShape=[[0,0]]')&&functionSource('placeChildAtFrontier').includes('frontierGeometryStillViable')&&functionSource('expandMetaNow').includes('missingGateConnections(meta)'),'Expansion lacks validated safe fallback or actual connection verification');
assert(functionSource('placeChildAtFrontierAttempt').includes('fixedPortProfilesForRequirements')&&functionSource('placeChildAtFrontierAttempt').includes('portSeed')&&functionSource('placeChildAtFrontierAttempt').includes('specialSeed')&&functionSource('placeChildAtFrontierAttempt').includes('generatedPuzzleIssue'),'Failed boards are not fully regenerated with provisional gates and special cells');
assert(functionSource('closedVoidRepairCandidates').includes('closedVoidRepair:true')&&functionSource('repairExpansions').includes('closedVoidRepairCandidates().filter')&&functionSource('repairExpansions').includes('.slice(0,2)')&&functionSource('pendingExpansionCount').includes('closedVoidRepairCandidates().length'),'Saved fields do not detect and repair enclosed missing puzzle squares');
assert(functionSource('generatePuzzleAsync').includes('generationOptions')&&worker.includes('generationOptions || null'),'Generation options are not passed through the worker');
assert(functionSource('reopenMissingGateExpansions').includes('st.expanded=false')&&functionSource('reopenMissingGateExpansions').includes('missingGateConnections(meta)'),'Persisted false-positive expansion states are not reopened safely');
console.log(`BEND FIELD v${appVersion} source and shared-logic smoke test passed`);