diff --git a/scripts/simulate_difficulty.js b/scripts/simulate_difficulty.js new file mode 100644 index 0000000..f34676f --- /dev/null +++ b/scripts/simulate_difficulty.js @@ -0,0 +1,75 @@ + +import fs from 'fs'; +import path from 'path'; +import { generateRandomGrid, calculateHints } from '../src/utils/puzzleUtils.js'; +import { solvePuzzle } from '../src/utils/solver.js'; + +const OUTPUT_FILE = 'difficulty_simulation_results.json'; +const CSV_FILE = 'difficulty_simulation_results.csv'; + +// Configuration +const SIZES = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80]; // Steps of 5 up to 50, then 10 +const DENSITIES = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]; +const SAMPLES_PER_POINT = 20; // Adjust based on time/accuracy needs + +console.log('Starting Monte Carlo Simulation for Nonogram Difficulty...'); +console.log(`Config: Sizes=${SIZES.length}, Densities=${DENSITIES.length}, Samples=${SAMPLES_PER_POINT}`); + +const results = []; +const csvRows = ['size,density,avg_solved_percent,min_solved_percent,max_solved_percent,avg_time_ms']; + +const startTime = Date.now(); + +for (const size of SIZES) { + for (const density of DENSITIES) { + let totalSolved = 0; + let minSolved = 100; + let maxSolved = 0; + let totalTime = 0; + + process.stdout.write(`Simulating Size: ${size}x${size}, Density: ${density} ... `); + + for (let i = 0; i < SAMPLES_PER_POINT; i++) { + const t0 = performance.now(); + + // 1. Generate + const grid = generateRandomGrid(size, density); + const { rowHints, colHints } = calculateHints(grid); + + // 2. Solve + const { percentSolved } = solvePuzzle(rowHints, colHints); + + const t1 = performance.now(); + + totalSolved += percentSolved; + minSolved = Math.min(minSolved, percentSolved); + maxSolved = Math.max(maxSolved, percentSolved); + totalTime += (t1 - t0); + } + + const avgSolved = totalSolved / SAMPLES_PER_POINT; + const avgTime = totalTime / SAMPLES_PER_POINT; + + results.push({ + size, + density, + avgSolved, + minSolved, + maxSolved, + avgTime + }); + + csvRows.push(`${size},${density},${avgSolved.toFixed(2)},${minSolved.toFixed(2)},${maxSolved.toFixed(2)},${avgTime.toFixed(2)}`); + + console.log(`Avg Solved: ${avgSolved.toFixed(1)}%`); + } +} + +const totalDuration = (Date.now() - startTime) / 1000; +console.log(`Simulation complete in ${totalDuration.toFixed(1)}s`); + +// Save results +fs.writeFileSync(OUTPUT_FILE, JSON.stringify(results, null, 2)); +fs.writeFileSync(CSV_FILE, csvRows.join('\n')); + +console.log(`Results saved to ${OUTPUT_FILE} and ${CSV_FILE}`); diff --git a/src/App.vue b/src/App.vue index 59e6c42..4180ff4 100644 --- a/src/App.vue +++ b/src/App.vue @@ -8,6 +8,7 @@ import StatusPanel from './components/StatusPanel.vue'; import GuidePanel from './components/GuidePanel.vue'; import WinModal from './components/WinModal.vue'; import CustomGameModal from './components/CustomGameModal.vue'; +import SimulationView from './components/SimulationView.vue'; import FixedBar from './components/FixedBar.vue'; import ReloadPrompt from './components/ReloadPrompt.vue'; @@ -15,6 +16,7 @@ import ReloadPrompt from './components/ReloadPrompt.vue'; const store = usePuzzleStore(); const { t, locale, setLocale, locales } = useI18n(); const showCustomModal = ref(false); +const showSimulation = ref(false); const showGuide = ref(false); const deferredPrompt = ref(null); const canInstall = ref(false); @@ -173,7 +175,8 @@ onUnmounted(() => { - + + diff --git a/src/components/CustomGameModal.vue b/src/components/CustomGameModal.vue index 9fb906d..2ade85d 100644 --- a/src/components/CustomGameModal.vue +++ b/src/components/CustomGameModal.vue @@ -3,8 +3,9 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue'; import { usePuzzleStore } from '@/stores/puzzle'; import { useI18n } from '@/composables/useI18n'; import { calculateDifficulty } from '@/utils/puzzleUtils'; +import { HelpCircle } from 'lucide-vue-next'; -const emit = defineEmits(['close']); +const emit = defineEmits(['close', 'open-simulation']); const store = usePuzzleStore(); const { t } = useI18n(); @@ -284,7 +285,12 @@ const confirm = () => {
-
{{ t('custom.difficulty') }}
+
+
{{ t('custom.difficulty') }}
+ +
{{ t(`difficulty.${difficultyInfo.level}`) }}
({{ difficultyInfo.value }}%)
@@ -457,6 +463,34 @@ input[type="range"]::-moz-range-thumb { gap: 5px; } +.label-row { + display: flex; + align-items: center; + gap: 8px; +} + +.help-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + padding: 4px; + border-radius: 50%; + transition: color 0.3s, background 0.3s; +} + +.help-btn:hover { + color: var(--accent-cyan); + background: rgba(0, 242, 255, 0.1); +} + +.icon-sm { + width: 16px; + height: 16px; +} + .difficulty-row { display: flex; flex-direction: row; diff --git a/src/components/SimulationView.vue b/src/components/SimulationView.vue new file mode 100644 index 0000000..be3e159 --- /dev/null +++ b/src/components/SimulationView.vue @@ -0,0 +1,310 @@ + + + + + + diff --git a/src/utils/puzzleUtils.js b/src/utils/puzzleUtils.js index 593947d..66e3677 100644 --- a/src/utils/puzzleUtils.js +++ b/src/utils/puzzleUtils.js @@ -53,35 +53,78 @@ export function generateRandomGrid(size, density = 0.5) { } export function calculateDifficulty(density, size = 10) { - // Shannon Entropy: H(x) = -x*log2(x) - (1-x)*log2(1-x) - // Normalized to 0-1 range (since max entropy at 0.5 is 1) + // Data derived from Monte Carlo Simulation (Logical Solver) + // Format: { size: [solved_pct_at_0.1, ..., solved_pct_at_0.9] } + // Densities: 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9 + const SIM_DATA = { + 5: [89, 74, 74, 81, 97, 98, 99, 100, 100], + 10: [57, 20, 16, 54, 92, 100, 100, 100, 100], + 15: [37, 10, 2, 12, 68, 100, 100, 100, 100], + 20: [23, 3, 1, 2, 37, 100, 100, 100, 100], + 25: [16, 0, 0, 1, 19, 99, 100, 100, 100], + 30: [8, 0, 0, 0, 5, 99, 100, 100, 100], + 35: [6, 0, 0, 0, 4, 91, 100, 100, 100], + 40: [3, 0, 0, 0, 2, 91, 100, 100, 100], + 45: [2, 0, 0, 0, 1, 82, 100, 100, 100], + 50: [2, 0, 0, 0, 1, 73, 100, 100, 100], + 60: [0, 0, 0, 0, 0, 35, 100, 100, 100], + 70: [0, 0, 0, 0, 0, 16, 100, 100, 100], + 80: [0, 0, 0, 0, 0, 1, 100, 100, 100] + }; + + // Helper to get interpolated value from array + const getSimulatedSolvedPct = (s, d) => { + // Find closest sizes + const sizes = Object.keys(SIM_DATA).map(Number).sort((a, b) => a - b); + let sLower = sizes[0]; + let sUpper = sizes[sizes.length - 1]; + + for (let i = 0; i < sizes.length - 1; i++) { + if (s >= sizes[i] && s <= sizes[i+1]) { + sLower = sizes[i]; + sUpper = sizes[i+1]; + break; + } + } + + // Clamp density to 0.1 - 0.9 + const dClamped = Math.max(0.1, Math.min(0.9, d)); + // Index in array: 0.1 -> 0, 0.9 -> 8 + const dIndex = (dClamped - 0.1) * 10; + const dLowerIdx = Math.floor(dIndex); + const dUpperIdx = Math.ceil(dIndex); + const dFraction = dIndex - dLowerIdx; + + // Bilinear Interpolation + + // 1. Interpolate Density for Lower Size + const rowLower = SIM_DATA[sLower]; + const valLower = rowLower[dLowerIdx] * (1 - dFraction) + (rowLower[dUpperIdx] || rowLower[dLowerIdx]) * dFraction; + + // 2. Interpolate Density for Upper Size + const rowUpper = SIM_DATA[sUpper]; + const valUpper = rowUpper[dLowerIdx] * (1 - dFraction) + (rowUpper[dUpperIdx] || rowUpper[dLowerIdx]) * dFraction; + + // 3. Interpolate Size + if (sLower === sUpper) return valLower; + const sFraction = (s - sLower) / (sUpper - sLower); + + return valLower * (1 - sFraction) + valUpper * sFraction; + }; + + const solvedPct = getSimulatedSolvedPct(size, density); - // Avoid log(0) - if (density <= 0 || density >= 1) return { level: 'easy', value: 0 }; - - const entropy = -density * Math.log2(density) - (1 - density) * Math.log2(1 - density); - - // Difficulty score combines entropy (complexity) and size (scale) - // We use sqrt(size) to dampen the effect of very large grids, - // ensuring that density still plays a major role. - // Normalized against max size (80) - const sizeFactor = Math.sqrt(size / 80); - const score = entropy * sizeFactor * 100; - const value = Math.round(score); + // Difficulty Score: Inverse of Solved Percent + // 100% Solved -> 0 Difficulty + // 0% Solved -> 100 Difficulty + const value = Math.round(100 - solvedPct); // Thresholds let level = 'easy'; - if (value >= 80) level = 'extreme'; - else if (value >= 60) level = 'hardest'; - else if (value >= 40) level = 'harder'; - else if (value >= 20) level = 'medium'; // Using 'medium' key if available, or we need to add it? - // Wait, useI18n only has: easy, harder, hardest, extreme. - // Let's stick to those keys but adjust ranges. - - if (value >= 75) level = 'extreme'; - else if (value >= 50) level = 'hardest'; - else if (value >= 25) level = 'harder'; - else level = 'easy'; + if (value >= 90) level = 'extreme'; // < 10% Solved + else if (value >= 60) level = 'hardest'; // < 40% Solved + else if (value >= 30) level = 'harder'; // < 70% Solved + else level = 'easy'; // > 70% Solved return { level, value }; } diff --git a/src/utils/solver.js b/src/utils/solver.js new file mode 100644 index 0000000..eb18d9b --- /dev/null +++ b/src/utils/solver.js @@ -0,0 +1,278 @@ + +/** + * Represents the state of a cell in the solver. + * -1: Unknown + * 0: Empty + * 1: Filled + */ + +/** + * Solves a single line (row or column) based on hints and current knowledge. + * Uses the "Left-Right Overlap" algorithm to find common filled cells. + * Also identifies definitely empty cells (reachable by no block). + * + * @param {number[]} currentLine - Array of -1, 0, 1 + * @param {number[]} hints - Array of block lengths + * @returns {number[]} - Updated line (or null if contradiction/impossible - though shouldn't happen for valid puzzles) + */ +function solveLine(currentLine, hints) { + const length = currentLine.length; + + // If no hints, all must be empty + if (hints.length === 0 || (hints.length === 1 && hints[0] === 0)) { + return Array(length).fill(0); + } + + // Helper to check if a block can be placed at start index + const canPlace = (line, start, blockSize) => { + if (start + blockSize > line.length) return false; + // Check if any cell in block is 0 (Empty) -> Invalid + for (let i = start; i < start + blockSize; i++) { + if (line[i] === 0) return false; + } + // Check boundaries (must be separated by empty or edge) + if (start > 0 && line[start - 1] === 1) return false; + if (start + blockSize < line.length && line[start + blockSize] === 1) return false; + return true; + }; + + // 1. Calculate Left-Most Positions + const leftPositions = []; + let currentIdx = 0; + for (let hIndex = 0; hIndex < hints.length; hIndex++) { + const block = hints[hIndex]; + // Find first valid position + 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 + break; + } + } + currentIdx++; + } + if (leftPositions.length <= hIndex) return null; // Impossible + } + + // 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 = []; + + currentIdx = 0; + for (let hIndex = 0; hIndex < reversedHints.length; hIndex++) { + const block = reversedHints[hIndex]; + while (currentIdx <= length - block) { + if (canPlace(reversedLine, currentIdx, block)) { + if (canFitRest(reversedLine, currentIdx + block + 1, reversedHints, hIndex + 1)) { + rightPositionsReversed.push(currentIdx); + currentIdx += block + 1; + break; + } + } + currentIdx++; + } + if (rightPositionsReversed.length <= hIndex) return null; + } + + // 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; + }).reverse(); + + + // 3. Intersect + const newLine = [...currentLine]; + + // Fill intersection + for (let i = 0; i < hints.length; i++) { + const l = leftPositions[i]; + const r = rightPositions[i]; + 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; + } + } + } + + // 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++) { + for (let k = leftPositions[i]; k < rightPositions[i] + hints[i]; k++) { + possibleFilled[k] = true; + } + } + + for (let k = 0; k < length; k++) { + if (!possibleFilled[k]) { + newLine[k] = 0; + } + } + + return newLine; +} + +// Memoized helper for checking if hints fit +const memo = new Map(); +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++) { + if (line[i] === 1) return false; + } + 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. + + 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. + + // 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 + 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; + } + } + + return false; +} + + +/** + * Solves the puzzle using logical iteration. + * @param {number[][]} rowHints + * @param {number[][]} colHints + * @returns {object} { solvedGrid: number[][], percentSolved: number } + */ +export function solvePuzzle(rowHints, colHints) { + const rows = rowHints.length; + const cols = colHints.length; + + // Initialize grid with -1 + let grid = 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) { + for (let c = 0; c < cols; c++) { + if (grid[r][c] !== newLine[c]) { + 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) { + for (let r = 0; r < rows; r++) { + if (grid[r][c] !== newCol[r]) { + grid[r][c] = newCol[r]; + changed = true; + } + } + } + } + } + + // Calculate solved % + let solvedCount = 0; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + if (grid[r][c] !== -1) solvedCount++; + } + } + + return { + solvedGrid: grid, + percentSolved: (solvedCount / (rows * cols)) * 100 + }; +}