From 2cd32d4a3e681947c7da2faaae51f8df3672b23a Mon Sep 17 00:00:00 2001 From: Grzegorz Kucmierz Date: Fri, 13 Feb 2026 05:18:55 +0100 Subject: [PATCH] Optimize simulation with logic-only solver, fix rectangular grid support, and improve worker pool --- src/App.vue | 6 +- src/components/Cell.vue | 1 + src/components/GameBoard.vue | 23 +- src/components/Hints.vue | 19 +- src/components/ImageImportModal.vue | 49 ++- src/components/NavBar.vue | 15 +- src/components/SimulationView.vue | 5 +- src/stores/puzzle.js | 34 +- src/utils/debug_solver.test.js | 27 ++ src/utils/large_grid_solver.test.js | 44 +++ src/utils/repro_solver.test.js | 49 +++ src/utils/solver.js | 501 +++++++++++++++------------- src/utils/solver.test.js | 64 ++++ src/utils/workerPool.js | 73 +++- src/workers/solver.worker.js | 13 +- 15 files changed, 641 insertions(+), 282 deletions(-) create mode 100644 src/utils/debug_solver.test.js create mode 100644 src/utils/large_grid_solver.test.js create mode 100644 src/utils/repro_solver.test.js create mode 100644 src/utils/solver.test.js diff --git a/src/App.vue b/src/App.vue index e843a56..2a79d75 100644 --- a/src/App.vue +++ b/src/App.vue @@ -26,6 +26,7 @@ const installDismissed = ref(false); const isCoarsePointer = ref(false); const isStandalone = ref(false); const isIos = ref(false); +const isDev = ref(false); const themePreference = ref('system'); const appVersion = __APP_VERSION__; let displayModeMedia = null; @@ -65,7 +66,7 @@ const updateStandalone = () => { const handleBeforeInstallPrompt = (e) => { e.preventDefault(); deferredPrompt.value = e; - if (!isStandalone.value) { + if (!isStandalone.value && !isDev.value) { canInstall.value = true; } }; @@ -118,6 +119,7 @@ onMounted(() => { isCoarsePointer.value = window.matchMedia('(pointer: coarse)').matches; const ua = navigator.userAgent.toLowerCase(); isIos.value = /ipad|iphone|ipod/.test(ua) || (ua.includes('mac') && navigator.maxTouchPoints > 1); + isDev.value = window.location.port !== '' && window.location.port !== '80' && window.location.port !== '443'; const storedTheme = typeof localStorage !== 'undefined' ? localStorage.getItem('theme') : null; if (storedTheme === 'light' || storedTheme === 'dark' || storedTheme === 'system') { themePreference.value = storedTheme; @@ -170,7 +172,7 @@ onUnmounted(() => { /> -
+
App Icon
diff --git a/src/components/Cell.vue b/src/components/Cell.vue index 3fa6c2d..5b6606d 100644 --- a/src/components/Cell.vue +++ b/src/components/Cell.vue @@ -93,6 +93,7 @@ const handlePointerCancel = (e) => { height: var(--cell-size); background-color: var(--cell-empty); border: 1px solid var(--glass-border); + box-sizing: border-box; cursor: pointer; display: flex; justify-content: center; diff --git a/src/components/GameBoard.vue b/src/components/GameBoard.vue index 4a50cf5..142528f 100644 --- a/src/components/GameBoard.vue +++ b/src/components/GameBoard.vue @@ -10,6 +10,10 @@ const store = usePuzzleStore(); const { rowHints, colHints } = useHints(computed(() => store.solution)); const { startDrag, onMouseEnter, stopDrag } = useNonogram(); +// Compute grid dimensions from hints +const gridRows = computed(() => rowHints.value.length); +const gridCols = computed(() => colHints.value.length); + const cellSize = ref(30); const rowHintsRef = ref(null); const activeRow = ref(null); @@ -143,13 +147,16 @@ const computeCellSize = () => { // Ensure we don't have negative space const availableForGrid = Math.max(0, containerWidth - hintWidth); - const size = Math.floor((availableForGrid - gridPad * 2 - (store.size - 1) * gap) / store.size); + // Calculate cell size based on width availability (columns) + // Vertical scrolling is acceptable, so we don't constrain by height (rows) + const cols = Math.max(1, gridCols.value); + const size = Math.floor((availableForGrid - gridPad * 2 - (cols - 1) * gap) / cols); if (isDesktop) { // Desktop: Allow overflow, use comfortable size cellSize.value = 30; } else { - // Mobile: Fit to screen + // Mobile: Fit to screen width // Keep min 18, max 36 cellSize.value = Math.max(18, Math.min(36, size)); } @@ -240,17 +247,17 @@ watch(() => store.size, async () => {
- + - +
store.size, async () => { :r="r" :c="c" :class="{ - 'guide-right': (c + 1) % 5 === 0 && c !== store.size - 1, - 'guide-bottom': (r + 1) % 5 === 0 && r !== store.size - 1 + 'guide-right': (c + 1) % 5 === 0 && c !== gridCols - 1, + 'guide-bottom': (r + 1) % 5 === 0 && r !== gridRows - 1 }" @start-drag="startDrag" @enter-cell="handleCellEnter" diff --git a/src/components/Hints.vue b/src/components/Hints.vue index 94e2914..398689d 100644 --- a/src/components/Hints.vue +++ b/src/components/Hints.vue @@ -58,13 +58,13 @@ defineProps({ .hints-container.col { padding-bottom: var(--grid-padding); - align-items: flex-end; + /* align-items: flex-end; - Removed to ensure uniform column height */ padding-left: var(--grid-padding); padding-right: var(--grid-padding); } .hints-container.row { - align-items: flex-end; + /* align-items: flex-end; - Removed to ensure row hints fill the cell height */ padding: var(--grid-padding) var(--grid-padding) var(--grid-padding) 0; width: max-content; } @@ -99,6 +99,21 @@ defineProps({ padding: 2px; } +@media (max-width: 768px) { + .hint-num { + font-size: 0.7rem; + padding: 1px; + } + + .col .hint-group { + padding: 2px 0; + } + + .row .hint-group { + padding: 0 4px; + } +} + /* Alternating Colors within the group */ .hint-num.hint-alt { color: var(--accent-cyan); diff --git a/src/components/ImageImportModal.vue b/src/components/ImageImportModal.vue index 6ad1d7c..e0fd4b3 100644 --- a/src/components/ImageImportModal.vue +++ b/src/components/ImageImportModal.vue @@ -225,16 +225,36 @@ const calculateStats = async () => { try { const pool = getWorkerPool(); - pool.clearQueue(); // Clear pending tasks + pool.cancelAll(); // Force stop previous calculations for immediate responsiveness - const result = await pool.run({ - id: requestId, - grid: generatedGrid.value - }, (progress) => { - if (currentStatsRequestId === requestId) { - processingProgress.value = progress; + // Demonstrate parallel execution capability + // We split the problem into two branches: assuming first cell is EMPTY (0) vs FILLED (1) + // This doubles the search power by utilizing 2 workers immediately. + + // Ensure we send plain objects to workers, not Vue proxies + const rawGrid = JSON.parse(JSON.stringify(generatedGrid.value)); + const rows = rawGrid.length; + const cols = rawGrid[0].length; + + // Create initial states + const gridA = Array(rows).fill().map(() => Array(cols).fill(-1)); + gridA[0][0] = 0; // Branch A: Assume (0,0) is Empty + + const gridB = Array(rows).fill().map(() => Array(cols).fill(-1)); + gridB[0][0] = 1; // Branch B: Assume (0,0) is Filled + + const tasks = [ + { + data: { id: requestId, grid: rawGrid, initialGrid: gridA }, + onProgress: (p) => { if (currentStatsRequestId === requestId) processingProgress.value = Math.max(processingProgress.value, p); } + }, + { + data: { id: requestId, grid: rawGrid, initialGrid: gridB }, + onProgress: (p) => { if (currentStatsRequestId === requestId) processingProgress.value = Math.max(processingProgress.value, p); } } - }); + ]; + + const result = await pool.runRace(tasks); if (result.id === currentStatsRequestId) { solvability.value = result.solvability; @@ -242,12 +262,14 @@ const calculateStats = async () => { difficultyLabel.value = result.difficultyLabel; } } catch (err) { - if (err.message !== 'Cancelled') { + if (err.message !== 'Cancelled' && err.message !== 'Terminated') { console.error('Worker error:', err); if (currentStatsRequestId === requestId) { solvability.value = 0; difficulty.value = 0; - difficultyLabel.value = 'unknown'; + // If translation key is missing, this might show 'difficulty.error' + // Ensure we have a fallback or the key exists + difficultyLabel.value = 'error'; } } } finally { @@ -266,6 +288,11 @@ watch([maxDimension, threshold], () => { debounceTimer = setTimeout(() => { updateGrid(); }, 50); + } else { + // If no image loaded, just update the display values + // Assuming square aspect ratio for preview + gridRows.value = maxDimension.value; + gridCols.value = maxDimension.value; } }); @@ -543,6 +570,8 @@ onUnmounted(() => { position: relative; box-shadow: 0 0 30px rgba(0, 0, 0, 0.5); color: var(--text-color); + max-height: 90vh; + overflow-y: auto; } .close-btn { diff --git a/src/components/NavBar.vue b/src/components/NavBar.vue index afe2199..4b52571 100644 --- a/src/components/NavBar.vue +++ b/src/components/NavBar.vue @@ -2,7 +2,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'; import { usePuzzleStore } from '@/stores/puzzle'; import { useI18n } from '@/composables/useI18n'; -import { Gamepad2, Palette, CircleHelp, Sun, Moon, Menu, X, ChevronDown, ChevronUp, Monitor, Image as ImageIcon } from 'lucide-vue-next'; +import { Gamepad2, Palette, CircleHelp, Sun, Moon, Menu, X, ChevronDown, ChevronUp, Monitor, Image as ImageIcon, Sparkles, Shuffle, Grid3X3, Grid2X2, Grid, MousePointer2 } from 'lucide-vue-next'; const store = usePuzzleStore(); const { t, locale, setLocale, locales } = useI18n(); @@ -16,6 +16,13 @@ const isMobileMenuOpen = ref(false); const langMenuRef = ref(null); const searchTerm = ref(''); +const getLevelIcon = (id) => { + if (id === 'easy') return Grid2X2; + if (id === 'medium') return Grid3X3; + if (id === 'hard') return Grid; + return Gamepad2; +}; + // Map language codes to country codes for flag-icons const langToCountry = { en: 'gb', @@ -257,12 +264,15 @@ watch(isMobileMenuOpen, (val) => { class="dropdown-item" @click="selectLevel(lvl.id)" > + {{ lvl.label }}
@@ -357,12 +367,15 @@ watch(isMobileMenuOpen, (val) => { class="mobile-sub-item" @click="selectLevel(lvl.id)" > + {{ lvl.label }}
diff --git a/src/components/SimulationView.vue b/src/components/SimulationView.vue index a5cffef..ecc014d 100644 --- a/src/components/SimulationView.vue +++ b/src/components/SimulationView.vue @@ -71,11 +71,12 @@ const startSimulation = async () => { for (let i = 0; i < SAMPLES_PER_POINT; i++) { const grid = generateRandomGrid(size, density); const { rowHints, colHints } = calculateHints(grid); - const { percentSolved } = solvePuzzle(rowHints, colHints); + // Use logicOnly=true for fast simulation + const { percentSolved } = solvePuzzle(rowHints, colHints, null, null, true); totalSolved += percentSolved; // Yield to UI every few samples to keep it responsive - if (i % 2 === 0) await new Promise(r => setTimeout(r, 0)); + if (i % 10 === 0) await new Promise(r => setTimeout(r, 0)); } const avgSolved = totalSolved / SAMPLES_PER_POINT; diff --git a/src/stores/puzzle.js b/src/stores/puzzle.js index 995dfc1..470f8a7 100644 --- a/src/stores/puzzle.js +++ b/src/stores/puzzle.js @@ -32,12 +32,15 @@ export const usePuzzleStore = defineStore('puzzle', () => { let count = 0; if (solution.value.length === 0 || playerGrid.value.length === 0) return 0; - for (let r = 0; r < size.value; r++) { - for (let c = 0; c < size.value; c++) { + const rows = solution.value.length; + const cols = solution.value[0].length; + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { // Zliczamy tylko poprawne wypełnienia (czarne), // ale w nonogramach postęp to często: (poprawne_czarne - bledne_czarne) / total_czarne // Zróbmy prostą wersję: % poprawnie zaznaczonych czarnych - błędnie zaznaczone czarne - if (playerGrid.value[r][c] === 1) { + if (playerGrid.value[r] && playerGrid.value[r][c] === 1) { if (solution.value[r][c] === 1) count++; else count--; // kara za błąd } @@ -96,7 +99,11 @@ export const usePuzzleStore = defineStore('puzzle', () => { function initFromImage(grid) { stopTimer(); currentLevelId.value = 'custom_image'; - size.value = grid.length; + // Use the larger dimension for size to ensure loops cover everything if square-assumption exists + // But ideally we should support rectangular. + // For now, size.value is used in resetGrid loop. + // Let's update resetGrid to handle rectangular. + size.value = Math.max(grid.length, grid[0].length); solution.value = grid; resetGrid(); @@ -111,13 +118,13 @@ export const usePuzzleStore = defineStore('puzzle', () => { elapsedTime.value = 0; startTimer(); saveState(); - } - function resetGrid() { - playerGrid.value = Array(size.value).fill().map(() => Array(size.value).fill(0)); - moves.value = 0; + const rows = solution.value.length; + const cols = solution.value[0].length; + playerGrid.value = Array(rows).fill().map(() => Array(cols).fill(0)); history.value = []; - currentTransaction.value = null; + moves.value = 0; + } currentTransaction.value = null; } function startInteraction() { @@ -218,11 +225,14 @@ export const usePuzzleStore = defineStore('puzzle', () => { // Calculate expected hints from solution (truth) // We do this dynamically to ensure we always check against the rules of the board + const rows = solution.value.length; + const cols = solution.value[0].length; + const solutionRows = solution.value; - const solutionCols = Array(size.value).fill().map((_, c) => solution.value.map(r => r[c])); + const solutionCols = Array(cols).fill().map((_, c) => solution.value.map(r => r[c])); // Check Rows - for (let r = 0; r < size.value; r++) { + for (let r = 0; r < rows; r++) { const targetHints = calculateLineHints(solutionRows[r]); const playerLine = playerGrid.value[r]; if (!validateLine(playerLine, targetHints)) { @@ -233,7 +243,7 @@ export const usePuzzleStore = defineStore('puzzle', () => { if (correct) { // Check Columns - for (let c = 0; c < size.value; c++) { + for (let c = 0; c < cols; c++) { const targetHints = calculateLineHints(solutionCols[c]); const playerLine = playerGrid.value.map(row => row[c]); if (!validateLine(playerLine, targetHints)) { diff --git a/src/utils/debug_solver.test.js b/src/utils/debug_solver.test.js new file mode 100644 index 0000000..3ec605b --- /dev/null +++ b/src/utils/debug_solver.test.js @@ -0,0 +1,27 @@ + +import { describe, it, expect } from 'vitest'; +import { solvePuzzle } from './solver'; +import { calculateHints } from './puzzleUtils'; + +describe('Debug Solver', () => { + it('should solve the broken grid', () => { + const grid = [ + [0,1,1,1,0,0,1,0,1,1], + [1,1,1,0,0,1,1,1,0,0], + [1,0,1,0,1,0,0,1,0,0], + [1,0,0,0,1,1,1,1,0,1], + [1,1,0,1,0,0,0,1,0,1], + [1,0,1,0,1,0,0,0,1,0], + [1,1,1,0,0,1,1,0,0,0], + [0,1,0,0,1,0,1,0,0,0], + [0,0,0,1,1,0,0,0,1,0], + [1,0,1,1,0,0,1,0,1,1] + ]; + + const { rowHints, colHints } = calculateHints(grid); + const result = solvePuzzle(rowHints, colHints); + + console.log('Solve Result:', result); + expect(result.percentSolved).toBe(100); + }); +}); diff --git a/src/utils/large_grid_solver.test.js b/src/utils/large_grid_solver.test.js new file mode 100644 index 0000000..43a6299 --- /dev/null +++ b/src/utils/large_grid_solver.test.js @@ -0,0 +1,44 @@ + +import { describe, it, expect } from 'vitest'; +import { solvePuzzle } from './solver.js'; + +describe('Large Grid Solver', () => { + it('should solve a large 55x28 grid without crashing', () => { + const rows = 28; + const cols = 55; + // Create a simple pattern: checkerboard or lines + const grid = Array(rows).fill().map((_, r) => + Array(cols).fill().map((_, c) => (r + c) % 2 === 0 ? 1 : 0) + ); + + // Calculate hints + const rowHints = grid.map(row => { + const hints = []; + let current = 0; + row.forEach(cell => { + if (cell === 1) current++; + else if (current > 0) { hints.push(current); current = 0; } + }); + if (current > 0) hints.push(current); + return hints.length ? hints : [0]; + }); + + const colHints = Array(cols).fill().map((_, c) => { + const hints = []; + let current = 0; + for(let r=0; r 0) { hints.push(current); current = 0; } + } + if (current > 0) hints.push(current); + return hints.length ? hints : [0]; + }); + + console.log('Starting solve...'); + const result = solvePuzzle(rowHints, colHints, (p) => console.log(`Progress: ${p}%`)); + console.log('Result:', result); + + expect(result.percentSolved).toBeGreaterThan(0); + expect(result.difficulty).toBeDefined(); + }); +}); diff --git a/src/utils/repro_solver.test.js b/src/utils/repro_solver.test.js new file mode 100644 index 0000000..58a4472 --- /dev/null +++ b/src/utils/repro_solver.test.js @@ -0,0 +1,49 @@ + +import { describe, it, expect } from 'vitest'; +import { solvePuzzle } from './solver'; +import { calculateHints } from './puzzleUtils'; + +describe('Solver Repro', () => { + it('should solve a simple generated puzzle', () => { + const grid = [ + [1, 0, 1, 1, 0], + [1, 1, 0, 0, 1], + [0, 0, 1, 0, 0], + [1, 1, 1, 1, 1], + [0, 1, 0, 1, 0] + ]; + const { rowHints, colHints } = calculateHints(grid); + + const result = solvePuzzle(rowHints, colHints); + expect(result.percentSolved).toBe(100); + }); + + it('should not fail on random valid lines', () => { + // Test solveLine indirectly via solvePuzzle on small grids + for (let i = 0; i < 100; i++) { + const size = 10; + const grid = []; + for(let r=0; r 0.5 ? 1 : 0); + grid.push(row); + } + + const { rowHints, colHints } = calculateHints(grid); + const result = solvePuzzle(rowHints, colHints); + + // It might not be 100% solvable without guessing (logic only), + // but since our solver HAS backtracking, it MUST be 100% solvable + // (unless timeout/max depth reached, but for 10x10 it should solve). + + // If it returns 0% or low %, it implies it failed to find the solution + // or found a contradiction (which shouldn't happen for valid hints). + + if (result.percentSolved < 100) { + console.log('Failed Grid:', JSON.stringify(grid)); + console.log('Result:', result); + } + expect(result.percentSolved).toBe(100); + } + }); +}); diff --git a/src/utils/solver.js b/src/utils/solver.js index 811378e..90c2635 100644 --- a/src/utils/solver.js +++ b/src/utils/solver.js @@ -6,6 +6,9 @@ * 1: Filled */ +// Memoized helper for checking if hints fit +const memo = new Map(); + /** * Solves a single line (row or column) based on hints and current knowledge. * Uses the "Left-Right Overlap" algorithm to find common filled cells. @@ -19,6 +22,9 @@ function solveLine(currentLine, hints) { const length = currentLine.length; // If no hints, all must be empty + // Clear memo for this line solve + memo.clear(); + if (hints.length === 0 || (hints.length === 1 && hints[0] === 0)) { return Array(length).fill(0); } @@ -45,11 +51,6 @@ function solveLine(currentLine, hints) { while (currentIdx <= length - block) { if (canPlace(currentLine, currentIdx, block)) { // Verify we can fit remaining blocks - // Simple heuristic: do we have enough space? - // A full recursive check is better but slower. - // For "Logical Solver" we assume valid placement is possible if we respect current constraints. - // However, strictly, we need to know if there is *any* valid arrangement starting here. - // Let's use a recursive check with memoization for "can fit rest". if (canFitRest(currentLine, currentIdx + block + 1, hints, hIndex + 1)) { leftPositions.push(currentIdx); currentIdx += block + 1; // Move past this block + 1 space @@ -61,12 +62,10 @@ function solveLine(currentLine, hints) { if (leftPositions.length <= hIndex) return null; // Impossible } + // Clear memo for right-side calculation (different line/hints) + memo.clear(); + // 2. Calculate Right-Most Positions (by reversing line and hints) - // This is symmetrical to Left-Most. - // Instead of implementing reverse logic, we can just reverse inputs, run left-most, and reverse back. - // But we need to respect the "currentLine" constraints which might be asymmetric. - - // Actually, "Right-Most" is just "Left-Most" on the reversed grid. const reversedLine = [...currentLine].reverse(); const reversedHints = [...hints].reverse(); const rightPositionsReversed = []; @@ -88,8 +87,6 @@ function solveLine(currentLine, hints) { } // Convert reversed positions to actual indices - // index in reversed = length - 1 - (original_index + block_size - 1) - // original_start = length - 1 - (reversed_start + block_size - 1) = length - reversed_start - block_size const rightPositions = rightPositionsReversed.map((rStart, i) => { const block = reversedHints[i]; return length - rStart - block; @@ -106,13 +103,6 @@ function solveLine(currentLine, hints) { const block = hints[i]; // If overlap exists: [r, l + block - 1] - // Example: Block 5. Left: 2, Right: 4. - // Left: ..XXXXX... - // Right: ....XXXXX. - // Overlap: ..XXX... - // Indices: max(l, r) to min(l+block, r+block) - 1 ? - // Range is [r, l + block - 1] (inclusive) - if (r < l + block) { for (let k = r; k < l + block; k++) { newLine[k] = 1; @@ -121,15 +111,6 @@ function solveLine(currentLine, hints) { } // Determine Empty cells? - // A cell is empty if it is not covered by ANY block in ANY valid configuration. - // This is harder with just L/R limits. - // However, we can use the "Simple Glue" logic: - // If a cell is outside the range [LeftLimit[i], RightLimit[i] + block] for ALL i, it's empty. - // Wait, indices are not strictly partitioned. Block 1 could be at 0 or 10. - // But logic dictates order. - // Range of block i is [LeftPositions[i], RightPositions[i] + hints[i]]. - // If a cell k is not in ANY of these ranges, it is 0. - // Mask of possible filled cells const possibleFilled = Array(length).fill(false); for (let i = 0; i < hints.length; i++) { @@ -148,8 +129,7 @@ function solveLine(currentLine, hints) { } // Memoized helper for checking if hints fit -const memo = new Map(); -function canFitRest(line, startIndex, hints, hintIndex) { +export function canFitRest(line, startIndex, hints, hintIndex) { // Optimization: If hints are empty, we just need to check if remaining line has no '1's if (hintIndex >= hints.length) { for (let i = startIndex; i < line.length; i++) { @@ -158,23 +138,32 @@ function canFitRest(line, startIndex, hints, hintIndex) { return true; } - // Key for memoization (primitive approach) - // In a full solver, we'd pass a cache. For single line, maybe overkill, but safe. - // let key = `${startIndex}-${hintIndex}`; - // Skipping memo for now as line lengths are small (<80) and recursion depth is low. - + // Memoization key + const key = `${startIndex}-${hintIndex}`; + if (memo.has(key)) return memo.get(key); + const remainingLen = line.length - startIndex; // Min space needed: sum of hints + (hints.length - 1) spaces - // Calculate lazily or precalc? let minSpace = 0; for(let i=hintIndex; i 0 && line[i-1] === 1) valid = false; // Should have been handled by caller or skip - // Wait, the caller (loop) iterates i. - // If i > startIndex, we implied space at i-1. - // If line[i-1] is 1, we can't place here if we skipped it. - // Actually, if we skip a '1', that's invalid. - // So we can't just skip '1's. + // Boundary before + if (i > 0 && line[i-1] === 1) valid = false; - // Correct logic: - // We iterate i. If we pass a '1' at index < i, that 1 is orphaned -> Invalid path. - // So we can only scan forward as long as we don't skip a '1'. - - let skippedOne = false; - for (let x = startIndex; x < i; x++) { - if (line[x] === 1) { skippedOne = true; break; } - } - if (skippedOne) break; // Cannot go further right, we left a 1 behind. - - // Boundary after + // Boundary after (check implicit in next block placement or end of line, but we need to check i+block cell) if (i + block < line.length && line[i+block] === 1) valid = false; if (valid) { // Recurse - if (canFitRest(line, i + block + 1, hints, hintIndex + 1)) return true; + if (canFitRest(line, i + block + 1, hints, hintIndex + 1)) { + memo.set(key, true); + return true; + } } } + memo.set(key, false); return false; } - /** - * Solves the puzzle using logical iteration. - * @param {number[][]} rowHints - * @param {number[][]} colHints - * @param {number[][]} initialGrid - Optional starting state - * @returns {object} { grid: number[][], changed: boolean } - */ -function solveLogically(rowHints, colHints, initialGrid) { - const rows = rowHints.length; - const cols = colHints.length; - - // Initialize grid with -1 if not provided - let grid = initialGrid ? initialGrid.map(row => [...row]) : Array(rows).fill(null).map(() => Array(cols).fill(-1)); - - let changed = true; - let iterations = 0; - const MAX_ITER = 100; // Safety break - - while (changed && iterations < MAX_ITER) { - changed = false; - iterations++; - - // Rows - for (let r = 0; r < rows; r++) { - const newLine = solveLine(grid[r], rowHints[r]); - if (!newLine) return { grid, contradiction: true }; // Contradiction found - - for (let c = 0; c < cols; c++) { - if (grid[r][c] !== newLine[c]) { - // If we try to overwrite a known value with a different one -> Contradiction - if (grid[r][c] !== -1 && grid[r][c] !== newLine[c]) return { grid, contradiction: true }; - - grid[r][c] = newLine[c]; - changed = true; - } - } - } - - // Cols - for (let c = 0; c < cols; c++) { - const currentCol = grid.map(row => row[c]); - const newCol = solveLine(currentCol, colHints[c]); - if (!newCol) return { grid, contradiction: true }; // Contradiction found - - for (let r = 0; r < rows; r++) { - if (grid[r][c] !== newCol[r]) { - if (grid[r][c] !== -1 && grid[r][c] !== newCol[r]) return { grid, contradiction: true }; - - grid[r][c] = newCol[r]; - changed = true; - } - } - } - } - - return { grid, changed: iterations > 1, iterations, contradiction: false }; -} - -/** - * Main solver function that attempts to solve the puzzle using logic and lookahead. + * Main solver function that attempts to solve the puzzle using logic and backtracking (DFS). + * Uses an efficient propagation queue and undo stack to avoid deep copying the grid. + * * @param {number[][]} rowHints * @param {number[][]} colHints * @param {function} onProgress - Optional callback for progress reporting (percent) + * @param {number[][]} initialGrid - Optional initial grid state + * @param {boolean} logicOnly - If true, stops after logical propagation (no guessing) * @returns {object} result */ -export function solvePuzzle(rowHints, colHints, onProgress) { +export function solvePuzzle(rowHints, colHints, onProgress, initialGrid = null, logicOnly = false) { const rows = rowHints.length; const cols = colHints.length; const totalCells = rows * cols; - // 1. Basic Logical Solve - let { grid, iterations, contradiction } = solveLogically(rowHints, colHints); + // Grid initialization: -1 (Unknown), 0 (Empty), 1 (Filled) + // Use initialGrid if provided (deep copy to be safe) + const grid = initialGrid + ? initialGrid.map(row => [...row]) + : Array(rows).fill().map(() => Array(cols).fill(-1)); - // Count solved + // Stats + let iterations = 0; // Total calls to solve() + let maxDepth = 0; // Max recursion depth + let backtracks = 0; // Failed guesses + let logicSteps = 0; // Cells filled by propagation + let lastProgressTime = 0; + + function reportProgress() { + if (!onProgress) return; + const now = Date.now(); + if (now - lastProgressTime >= 50) { + let filled = 0; + for(let r=0; r=0; i--) { + const {r, c, old} = changes[i]; + grid[r][c] = old; + } + } + + // Helper: Propagate logic constraints until fixed point or contradiction + // Returns list of changes made, or null if contradiction found + function propagate() { + const changes = []; + + try { + while(queue.size > 0) { + reportProgress(); + + const item = queue.values().next().value; + queue.delete(item); + + const type = item[0]; + const idx = parseInt(item.slice(1)); + + let currentLine, hints; + if (type === 'r') { + currentLine = grid[idx]; // Reference for row (fast) + hints = rowHints[idx]; + } else { + currentLine = grid.map(row => row[idx]); // Copy for col (slower) + hints = colHints[idx]; + } + + const newLine = solveLine(currentLine, hints); + + if (!newLine) throw 'contradiction'; + + // Apply changes + for(let i=0; i Contradiction + if (currentLine[i] !== -1 && currentLine[i] !== newLine[i]) throw 'contradiction'; + + if (currentLine[i] === -1) { + const r = type === 'r' ? idx : i; + const c = type === 'r' ? i : idx; + + // Double check if already set by orthogonal update in same loop? + // (Should be handled by -1 check above, as grid is shared) + if (grid[r][c] === -1) { + grid[r][c] = newLine[i]; + changes.push({r, c, old: -1}); + logicSteps++; + + // Add orthogonal line to queue + if (type === 'r') queue.add(`c${c}`); + else queue.add(`r${r}`); + } else if (grid[r][c] !== newLine[i]) { + console.log('Contradiction: Orthogonal Conflict at', r, c, 'Grid:', grid[r][c], 'New:', newLine[i]); + throw 'contradiction'; + } + } + } + } + } + } catch (e) { + // Revert changes from this failed propagation + undo(changes); + return null; + } + return changes; + } + + // DFS Solver + function solve(depth) { + maxDepth = Math.max(maxDepth, depth); + iterations++; + + reportProgress(); + + // 1. Propagate Logic + const changes = propagate(); + if (!changes) return false; // Contradiction + + // 2. Find Best Branch Candidate + let bestR = -1, bestC = -1; + let minUnknowns = Infinity; + let isComplete = true; + + // Scan for unknowns and pick heuristic + // Heuristic: Line with fewest unknowns (most constrained) + for(let r=0; r 0) { + isComplete = false; + if (unknowns < minUnknowns) { + minUnknowns = unknowns; + bestR = r; + bestC = firstUnknownC; + } + if (minUnknowns === 1) break; // Optimal + } + } + + if (isComplete) return true; // Solved! + + // 3. Branching (Guessing) + // Try 1 + grid[bestR][bestC] = 1; + queue.add(`r${bestR}`); + queue.add(`c${bestC}`); + + if (solve(depth + 1)) return true; + + // Backtrack from 1 + grid[bestR][bestC] = -1; // Undo guess + // (Recursive call already undid its propagation changes) + + // Try 0 + grid[bestR][bestC] = 0; + queue.add(`r${bestR}`); + queue.add(`c${bestC}`); + + if (solve(depth + 1)) return true; + + // Backtrack from 0 + grid[bestR][bestC] = -1; // Undo guess + backtracks++; + + // Undo propagation from this level + undo(changes); + return false; + } + + // Start Solving + if (logicOnly) { + // Just logical propagation without guessing + if (initialGrid) { + // If resuming, ensure queue has all lines to check consistency + queue.clear(); + for(let r=0; r r.forEach(c => { if(c !== -1) solvedCount++; })); let percentSolved = (solvedCount / totalCells) * 100; if (onProgress) onProgress(Math.floor(percentSolved)); - // Difficulty calculation - // Base: complexity of grid + // Difficulty Calculation + // Logic: + // - Base: 0-20% based on size/density + // - Logic: 0-30% based on iterations needed (depth 0) + // - Guessing: 0-50% based on backtracks/depth + let difficultyScore = 0; + const effectiveSize = Math.sqrt(totalCells); - // If simple logic failed to solve completely, try Lookahead (Smash) - let lookaheadUsed = false; - - if (percentSolved < 100 && !contradiction) { - // Lookahead loop - // Find an unknown cell, try 0 and 1. If one leads to contradiction, the other is true. - let progress = true; - while (progress && percentSolved < 100) { - progress = false; - - // Find unknown cells (optimize: sort by most constrained?) - // For now, just scan. - let candidates = []; - for(let r=0; r lastReportedPercent || checkedCount % 10 === 0) { - lastReportedPercent = currentScanPercent; - onProgress(currentScanPercent); - } - } - - // Try assuming 1 - // We need to clone the grid for simulation - const gridCopy1 = grid.map(row => [...row]); - gridCopy1[r][c] = 1; - const res1 = solveLogically(rowHints, colHints, gridCopy1); - - // Try assuming 0 - const gridCopy0 = grid.map(row => [...row]); - gridCopy0[r][c] = 0; - const res0 = solveLogically(rowHints, colHints, gridCopy0); - - let deduced = null; - - if (res1.contradiction && !res0.contradiction) { - deduced = 0; // Must be 0 - } else if (!res1.contradiction && res0.contradiction) { - deduced = 1; // Must be 1 - } - - if (deduced !== null) { - grid[r][c] = deduced; - progress = true; - lookaheadUsed = true; - difficultyScore += 5; // Penalty for requiring lookahead - - // Run logic again to propagate this new info - const updated = solveLogically(rowHints, colHints, grid); - if (updated.contradiction) break; // Should not happen if logic is sound - grid = updated.grid; - - break; // Restart loop to use new info - } - } - - // Recalculate percent (this is for loop exit condition) - solvedCount = 0; - grid.forEach(row => row.forEach(c => { if(c !== -1) solvedCount++; })); - percentSolved = (solvedCount / totalCells) * 100; - // Note: we don't report percentSolved here because we want the spinner to show SCAN progress (0-100% of current pass) - // If we reported percentSolved, the user might see the spinner jump from 100% (scan done) to 5% (solved amount), which is confusing. - } - } - - // Final Difficulty Calculation - // Factors: - // 1. Size (rows * cols) - // 2. Iterations (how many passes of line logic) - // 3. Lookahead (did we need it?) - - const effectiveSize = Math.sqrt(rows * cols); - // iterations is usually 2-20. - // difficultyScore accumulates lookahead steps. - - // Normalize iterations - const iterScore = Math.min(20, iterations) * 2; - - // Base difficulty - let totalScore = effectiveSize + iterScore + difficultyScore; - - // If not fully solved, massive penalty if (percentSolved < 100) { - // Unsolvable by logic+lookahead - // This is "Extreme" or "Guessing Required" - totalScore = 100; // Cap at max + difficultyScore = 100; // Unsolvable (or timed out/too hard) } else { - // Solved - // Normalize score 0-100 (approximately) - // Max theoretical "normal" score ~ 80 (size 80) + 40 (iter) + 20 (lookahead) = 140? - // Let's scale it. - totalScore = Math.min(100, totalScore); + if (maxDepth === 0) { + // Pure logic + difficultyScore = Math.min(30, effectiveSize); + } else { + // Required guessing + // Simple heuristic: 30 + backtracks * 5 + depth * 2 + difficultyScore = 30 + (backtracks * 2) + (maxDepth * 5); + difficultyScore = Math.min(100, difficultyScore); + } } return { percentSolved, - difficultyScore: totalScore, - lookaheadUsed, - iterations + difficultyScore: Math.round(difficultyScore), + lookaheadUsed: maxDepth > 0, + iterations, + maxDepth, + backtracks }; } diff --git a/src/utils/solver.test.js b/src/utils/solver.test.js new file mode 100644 index 0000000..bcdd272 --- /dev/null +++ b/src/utils/solver.test.js @@ -0,0 +1,64 @@ + it('solves a puzzle requiring guessing (Backtracking)', () => { + // A puzzle that logic alone cannot start usually has multiple solutions or requires a guess. + // Example: The "domino" or "ambiguous" pattern, but we need a unique solution that requires lookahead. + // Or just a very hard unique puzzle. + // A simple case where line logic is stuck but global constraints solve it. + // + // 0 1 1 0 + // 1 0 0 1 + // 1 0 0 1 + // 0 1 1 0 + // Hints: + // R: 2, 1 1, 1 1, 2 + // C: 2, 1 1, 1 1, 2 + // This is a "ring". It might be solvable by logic if corners are forced. + // Let's try a known "hard" small pattern. + // + // 0 0 0 + // 0 1 0 + // 0 0 0 + // R: 0, 1, 0 + // C: 0, 1, 0 + // Logic solves this instantly. + + // Let's trust the logic works for general backtracking by forcing a guess. + // We can mock solveLine to return "no change" to force backtracking? No, integration test is better. + + // Let's just ensure it returns a valid result structure for a solvable puzzle. + const rowHints = [[1], [1], [1]]; + const colHints = [[1], [1], [1]]; + // 3x3 diagonal? + // 1 0 0 + // 0 1 0 + // 0 0 1 + // Hints: 1, 1, 1 + // Cols: 1, 1, 1 + // This has multiple solutions (diagonal or anti-diagonal or others). + // Our solver should find ONE of them and return 100%. + + const result = solvePuzzle(rowHints, colHints); + expect(result.percentSolved).toBe(100); + // It might use lookahead because logic can't decide. + // Actually for this specific case, logic does nothing (all empty or all full are not possible, but many perms). + // So it MUST branch. + expect(result.maxDepth).toBeGreaterThan(0); + }); + + it('stress test: should solve 100 random valid 10x10 grids', () => { + // This ensures the solver is robust and doesn't fail on valid puzzles. + // Using a fixed seed or just running a loop. + for (let i = 0; i < 100; i++) { + const size = 10; + const grid = generateRandomGrid(size, 0.5); + const { rowHints, colHints } = calculateHints(grid); + + const result = solvePuzzle(rowHints, colHints); + + if (result.percentSolved < 100) { + console.error('Failed Grid:', JSON.stringify(grid)); + console.error('Result:', result); + } + expect(result.percentSolved).toBe(100); + } + }); +}); diff --git a/src/utils/workerPool.js b/src/utils/workerPool.js index f6d7c7e..db43651 100644 --- a/src/utils/workerPool.js +++ b/src/utils/workerPool.js @@ -28,6 +28,46 @@ class WorkerPool { }); } + runRace(tasks) { + return new Promise((resolve, reject) => { + let activeCount = tasks.length; + let resolved = false; + + tasks.forEach(taskData => { + this.run(taskData.data, taskData.onProgress) + .then(result => { + if (resolved) return; + + // Heuristic: If solved 100%, we have a winner + if (result.solvability === 100) { + resolved = true; + resolve(result); + // Cancel others (optional but good for perf) + // We can't easily cancel *specific* other tasks in this pool implementation without IDs + // But since this is a "Race", we assume the caller will handle cleanup or we just let them finish + } else { + // If not fully solved, we wait for others? + // Or maybe we collect all results and pick best? + // For "Race", we usually want the first *Success*. + // If all fail (finish without 100%), we reject or return best. + activeCount--; + if (activeCount === 0) { + // All finished, none 100%. Return the last one (or logic to pick best) + resolve(result); + } + } + }) + .catch(err => { + if (resolved) return; + activeCount--; + if (activeCount === 0) { + reject(new Error('All workers failed')); + } + }); + }); + }); + } + execute(workerObj, task) { workerObj.busy = true; workerObj.currentTask = task; @@ -45,7 +85,12 @@ class WorkerPool { return; // Don't resolve yet } - workerObj.currentTask.resolve(e.data); + if (e.data.error) { + workerObj.currentTask.reject(new Error(e.data.error)); + } else { + workerObj.currentTask.resolve(e.data); + } + workerObj.currentTask = null; workerObj.busy = false; this.active--; @@ -81,6 +126,32 @@ class WorkerPool { this.queue = []; } + cancelAll() { + this.clearQueue(); + + // Terminate and restart busy workers + this.workers.forEach((w, index) => { + if (w.busy) { + w.worker.terminate(); + + if (w.currentTask) { + w.currentTask.reject(new Error('Terminated')); + } + + // Create replacement + const newWorker = new SolverWorker(); + newWorker.onmessage = (e) => this.handleWorkerMessage(newWorker, e); + newWorker.onerror = (e) => this.handleWorkerError(newWorker, e); + + // Replace in array + this.workers[index] = { worker: newWorker, busy: false, id: w.id }; + } + }); + + // Reset active count since all busy workers were replaced with idle ones + this.active = 0; + } + terminate() { this.workers.forEach(w => w.worker.terminate()); this.workers = []; diff --git a/src/workers/solver.worker.js b/src/workers/solver.worker.js index 2139c72..c58d0d5 100644 --- a/src/workers/solver.worker.js +++ b/src/workers/solver.worker.js @@ -2,7 +2,7 @@ import { calculateHints } from '../utils/puzzleUtils'; import { solvePuzzle } from '../utils/solver'; self.onmessage = (e) => { - const { id, grid } = e.data; + const { id, grid, initialGrid } = e.data; try { if (!grid || grid.length === 0) { @@ -12,10 +12,11 @@ self.onmessage = (e) => { const rows = grid.length; const cols = grid[0].length; - const size = Math.max(rows, cols); - const density = grid.flat().filter(c => c === 1).length / (rows * cols); - - // 1. Calculate Hints + // Use initialGrid if provided, otherwise assume we are starting fresh + // BUT wait, 'grid' passed here is usually the 0/1 grid from Image Import (target pattern). + // 'initialGrid' would be the partial solution state (-1/0/1). + + // 1. Calculate Hints from the TARGET grid (the image) const { rowHints, colHints } = calculateHints(grid); // 2. Run Solver (Logic + Lookahead) @@ -27,7 +28,7 @@ self.onmessage = (e) => { }); }; - const { percentSolved, difficultyScore, lookaheadUsed } = solvePuzzle(rowHints, colHints, onProgress); + const { percentSolved, difficultyScore, lookaheadUsed } = solvePuzzle(rowHints, colHints, onProgress, initialGrid); // 3. Determine Level let value = difficultyScore;