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;