11 Commits

Author SHA1 Message Date
99d1370461 1.15.4
All checks were successful
Deploy to Production / deploy (push) Successful in 23s
2026-02-19 11:20:55 +01:00
01b01b727f refactor: cleanup unused files, consolidate tests, optimize worker pool 2026-02-19 11:20:27 +01:00
46bde27514 v1.15.3: Add missing DifficultyMap.vue component
All checks were successful
Deploy to Production / deploy (push) Successful in 21s
2026-02-19 10:56:58 +01:00
739c2b21d7 chore: update package-lock.json to 1.15.2
Some checks failed
Deploy to Production / deploy (push) Failing after 19s
2026-02-19 10:43:26 +01:00
0d4ef75934 v1.15.2: Restore difficulty map visibility and fix i18n crash
Some checks failed
Deploy to Production / deploy (push) Failing after 20s
2026-02-19 10:25:58 +01:00
b6e685d351 fix: restore index.html
All checks were successful
Deploy to Production / deploy (push) Successful in 7s
2026-02-19 08:21:24 +01:00
44b0f6443f fix: allow dynamic container name to prevent conflicts
Some checks failed
Deploy to Production / deploy (push) Failing after 4s
2026-02-19 07:08:27 +01:00
a926727b51 Merge branch 'main' of gitea.7u.pl:gkucmierz/nonograms
Some checks failed
Deploy to Production / deploy (push) Failing after 22s
2026-02-19 06:56:10 +01:00
4782d20493 chore: bump version and fix boost button 2026-02-19 06:54:25 +01:00
182658774e Delete index.html
Some checks failed
Deploy to Production / deploy (push) Failing after 4s
2026-02-19 05:50:00 +00:00
635fdb089d Delete update_i18n_guide.cjs
All checks were successful
Deploy to Production / deploy (push) Successful in 7s
2026-02-19 05:49:09 +00:00
20 changed files with 536 additions and 496 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
# Optional: Set a custom container name to run multiple instances
# CONTAINER_NAME=nonograms-dev

View File

@@ -82,7 +82,7 @@ define(['./workbox-7a5e81cd'], (function (workbox) { 'use strict';
*/
workbox.precacheAndRoute([{
"url": "index.html",
"revision": "0.n1n8rjsg38"
"revision": "0.b79gmi6tt88"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@@ -2,7 +2,7 @@ version: '3.8'
services:
nonograms:
container_name: nonograms
container_name: ${CONTAINER_NAME:-nonograms}
build:
context: .
dockerfile: Dockerfile

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "vue-nonograms-solid",
"version": "1.15.0",
"version": "1.15.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vue-nonograms-solid",
"version": "1.15.0",
"version": "1.15.4",
"dependencies": {
"@capacitor/android": "^8.1.0",
"@capacitor/cli": "^8.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "vue-nonograms-solid",
"version": "1.15.0",
"version": "1.15.4",
"homepage": "https://nonograms.7u.pl/",
"type": "module",
"scripts": {

View File

@@ -1,9 +1,10 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import { usePuzzleStore } from '@/stores/puzzle';
import { useI18n } from '@/composables/useI18n';
import { calculateDifficulty } from '@/utils/puzzleUtils';
import { HelpCircle } from 'lucide-vue-next';
import DifficultyMap from './DifficultyMap.vue';
const emit = defineEmits(['close', 'open-simulation']);
const store = usePuzzleStore();
@@ -12,161 +13,11 @@ const { t } = useI18n();
const customSize = ref(10);
const fillRate = ref(50);
const errorMsg = ref('');
const difficultyCanvas = ref(null);
const isDragging = ref(false);
const cachedBackground = ref(null);
const drawMap = () => {
const canvas = difficultyCanvas.value;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
// Clear
ctx.clearRect(0, 0, width, height);
// Use cached background if available
if (cachedBackground.value) {
ctx.putImageData(cachedBackground.value, 0, 0);
} else {
// Draw Gradient Background (Heavy calculation)
const imgData = ctx.createImageData(width, height);
const data = imgData.data;
// Ranges:
// X: Fill Rate 10% -> 90%
// Y: Size 5 -> 80
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const normalizedX = x / width;
const normalizedY = 1 - (y / height); // 0 at bottom, 1 at top
const fRate = 0.1 + normalizedX * 0.8; // 0.1 to 0.9
const sSize = 5 + normalizedY * 75; // 5 to 80
const { value } = calculateDifficulty(fRate, sSize);
// Color Mapping
const hue = 120 * (1 - value / 100);
const [r, g, b] = hslToRgb(hue / 360, 1, 0.5);
const index = (y * width + x) * 4;
data[index] = r;
data[index + 1] = g;
data[index + 2] = b;
data[index + 3] = 255; // Alpha
}
}
ctx.putImageData(imgData, 0, 0);
cachedBackground.value = imgData;
}
// Draw current position
// Map current fillRate/size to x,y
// Fill: 10..90. Size: 5..80.
const currentFill = Math.max(10, Math.min(90, fillRate.value));
const currentSize = Math.max(5, Math.min(80, customSize.value));
const posX = ((currentFill - 10) / 80) * width;
const posY = (1 - (currentSize - 5) / 75) * height;
// Draw Crosshair/Circle
ctx.beginPath();
ctx.arc(posX, posY, 6, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = '#000';
ctx.stroke();
};
const hslToRgb = (h, s, l) => {
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
} else {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
};
const updateFromEvent = (e) => {
const canvas = difficultyCanvas.value;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
// Handle Touch or Mouse
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
let x = clientX - rect.left;
let y = clientY - rect.top;
// Clamp
x = Math.max(0, Math.min(rect.width, x));
y = Math.max(0, Math.min(rect.height, y));
// Reverse Map
// x / width -> fillRate (10..90)
// 1 - y / height -> size (5..80)
const normalizedX = x / rect.width;
const normalizedY = 1 - (y / rect.height);
const newFill = 10 + normalizedX * 80;
const newSize = 5 + normalizedY * 75;
fillRate.value = Math.round(newFill);
customSize.value = Math.round(newSize);
};
const startDrag = (e) => {
isDragging.value = true;
updateFromEvent(e);
// Add global listeners for mouse to handle dragging outside canvas
window.addEventListener('mousemove', onDrag);
window.addEventListener('mouseup', stopDrag);
};
const onDrag = (e) => {
if (!isDragging.value) return;
updateFromEvent(e);
};
const stopDrag = () => {
isDragging.value = false;
window.removeEventListener('mousemove', onDrag);
window.removeEventListener('mouseup', stopDrag);
};
onUnmounted(() => {
window.removeEventListener('mousemove', onDrag);
window.removeEventListener('mouseup', stopDrag);
});
const showAdvanced = ref(false);
const showAdvanced = ref(true);
const toggleAdvanced = () => {
showAdvanced.value = !showAdvanced.value;
if (showAdvanced.value) {
// Reset cache when opening to ensure size is correct if canvas resized
cachedBackground.value = null;
nextTick(drawMap);
}
};
onMounted(() => {
@@ -179,14 +30,6 @@ onMounted(() => {
if (savedFillRate && !isNaN(savedFillRate)) {
fillRate.value = Math.max(10, Math.min(90, Number(savedFillRate)));
}
// Don't draw map initially if hidden
});
watch([customSize, fillRate], () => {
if (showAdvanced.value) {
drawMap();
}
});
watch(customSize, (newVal) => {
@@ -245,7 +88,7 @@ const confirm = () => {
<div class="range-value">{{ customSize }}</div>
<input
type="range"
v-model="customSize"
v-model.number="customSize"
min="5"
max="80"
step="1"
@@ -264,7 +107,7 @@ const confirm = () => {
<div class="range-value">{{ fillRate }}%</div>
<input
type="range"
v-model="fillRate"
v-model.number="fillRate"
min="10"
max="90"
step="1"
@@ -278,15 +121,14 @@ const confirm = () => {
</div>
<div class="map-section" v-if="showAdvanced">
<canvas
ref="difficultyCanvas"
width="400"
height="400"
@mousedown="startDrag"
@touchstart.prevent="startDrag"
@touchmove.prevent="onDrag"
@touchend="stopDrag"
></canvas>
<DifficultyMap
v-model:size="customSize"
v-model:density="fillRate"
:interactive="true"
:width="400"
:height="400"
class="difficulty-map-canvas"
/>
</div>
</div>

View File

@@ -0,0 +1,293 @@
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { calculateDifficulty } from '@/utils/puzzleUtils';
import { useI18n } from '@/composables/useI18n';
const props = defineProps({
size: {
type: Number,
required: true
},
density: {
type: Number,
required: true
},
actualDifficulty: {
type: Number,
default: null
},
interactive: {
type: Boolean,
default: false
},
width: {
type: Number,
default: 300
},
height: {
type: Number,
default: 200
}
});
const emit = defineEmits(['update:size', 'update:density']);
const { t } = useI18n();
const canvasRef = ref(null);
let cachedBackground = null;
const isDragging = ref(false);
// Constants for ranges
const MIN_SIZE = 5;
const MAX_SIZE = 80;
const MIN_DENSITY = 10;
const MAX_DENSITY = 90;
const hslToRgb = (h, s, l) => {
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
} else {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
};
const drawMap = () => {
const canvas = canvasRef.value;
if (!canvas) return;
try {
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
// Clear
ctx.clearRect(0, 0, width, height);
// Use cached background if available
if (cachedBackground) {
ctx.drawImage(cachedBackground, 0, 0, width, height);
} else {
// Draw Gradient Background (Optimized)
// Use a smaller buffer to reduce calculations
const bufferWidth = 40;
const bufferHeight = 40;
const bufferCanvas = document.createElement('canvas');
bufferCanvas.width = bufferWidth;
bufferCanvas.height = bufferHeight;
const bufferCtx = bufferCanvas.getContext('2d');
const imgData = bufferCtx.createImageData(bufferWidth, bufferHeight);
const data = imgData.data;
for (let y = 0; y < bufferHeight; y++) {
for (let x = 0; x < bufferWidth; x++) {
const normalizedX = x / bufferWidth;
const normalizedY = 1 - (y / bufferHeight); // 0 at bottom, 1 at top
const fRate = (MIN_DENSITY + normalizedX * (MAX_DENSITY - MIN_DENSITY)) / 100; // 0.1 to 0.9
const sSize = MIN_SIZE + normalizedY * (MAX_SIZE - MIN_SIZE); // 5 to 80
const { value } = calculateDifficulty(fRate, sSize);
// Color Mapping
const hue = 120 * (1 - value / 100);
const [r, g, b] = hslToRgb(hue / 360, 1, 0.5);
const index = (y * bufferWidth + x) * 4;
data[index] = r;
data[index + 1] = g;
data[index + 2] = b;
data[index + 3] = 255; // Alpha
}
}
bufferCtx.putImageData(imgData, 0, 0);
// Draw scaled up
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bufferCanvas, 0, 0, width, height);
cachedBackground = bufferCanvas;
}
// Draw current position
// Clamp values
const currentFill = Math.max(MIN_DENSITY, Math.min(MAX_DENSITY, props.density));
const currentSize = Math.max(MIN_SIZE, Math.min(MAX_SIZE, props.size));
const posX = ((currentFill - MIN_DENSITY) / (MAX_DENSITY - MIN_DENSITY)) * width;
const posY = (1 - (currentSize - MIN_SIZE) / (MAX_SIZE - MIN_SIZE)) * height;
// Draw Point
ctx.beginPath();
ctx.arc(posX, posY, 6, 0, Math.PI * 2);
// Use actual difficulty color if provided, otherwise white
if (props.actualDifficulty !== null) {
const hue = 120 * (1 - Math.max(0, Math.min(100, props.actualDifficulty)) / 100);
const [r, g, b] = hslToRgb(hue / 360, 1, 0.5);
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.lineWidth = 3; // Thicker border for visibility
ctx.strokeStyle = '#fff'; // White border to make it pop
} else {
ctx.fillStyle = '#fff';
ctx.lineWidth = 2;
ctx.strokeStyle = '#000';
}
ctx.fill();
ctx.stroke();
} catch (e) {
console.error("Error drawing difficulty map:", e);
}
};
const updateFromEvent = (e) => {
if (!props.interactive) return;
const canvas = canvasRef.value;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
// Handle Touch or Mouse
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
let x = clientX - rect.left;
let y = clientY - rect.top;
// Clamp
x = Math.max(0, Math.min(rect.width, x));
y = Math.max(0, Math.min(rect.height, y));
const normalizedX = x / rect.width;
const normalizedY = 1 - (y / rect.height);
const newFill = MIN_DENSITY + normalizedX * (MAX_DENSITY - MIN_DENSITY);
const newSize = MIN_SIZE + normalizedY * (MAX_SIZE - MIN_SIZE);
emit('update:density', Math.round(newFill));
emit('update:size', Math.round(newSize));
};
const startDrag = (e) => {
if (!props.interactive) return;
isDragging.value = true;
updateFromEvent(e);
window.addEventListener('mousemove', onDrag);
window.addEventListener('touchmove', onDrag, { passive: false });
window.addEventListener('mouseup', stopDrag);
window.addEventListener('touchend', stopDrag);
};
const onDrag = (e) => {
if (!isDragging.value) return;
if (e.cancelable) e.preventDefault(); // Prevent scrolling on touch
updateFromEvent(e);
};
const stopDrag = () => {
isDragging.value = false;
window.removeEventListener('mousemove', onDrag);
window.removeEventListener('touchmove', onDrag);
window.removeEventListener('mouseup', stopDrag);
window.removeEventListener('touchend', stopDrag);
};
onMounted(() => {
nextTick(drawMap);
});
onUnmounted(() => {
stopDrag(); // Cleanup just in case
});
watch(() => [props.size, props.density, props.actualDifficulty, props.width, props.height], () => {
requestAnimationFrame(drawMap);
});
</script>
<template>
<div class="difficulty-map-container">
<canvas
ref="canvasRef"
:width="width"
:height="height"
@mousedown="startDrag"
@touchstart="startDrag"
:class="{ 'interactive': interactive }"
></canvas>
<div class="axis-labels">
<span class="y-label">{{ t('difficultyMap.size') }}</span>
<span class="x-label">{{ t('difficultyMap.density') }}</span>
</div>
</div>
</template>
<style scoped>
.difficulty-map-container {
position: relative;
display: inline-block;
margin: 10px 0;
}
canvas {
border: 1px solid var(--panel-border, #444);
border-radius: 8px;
background: transparent;
cursor: default;
display: block; /* Remove inline gap */
width: 100%; /* Responsive */
max-width: 100%;
}
canvas.interactive {
cursor: crosshair;
}
.axis-labels {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.y-label {
position: absolute;
left: 5px;
top: 5px;
font-size: 10px;
color: rgba(0,0,0,0.5);
transform-origin: top left;
font-weight: bold;
text-shadow: 0 0 2px rgba(255,255,255,0.8);
}
.x-label {
position: absolute;
right: 5px;
bottom: 5px;
font-size: 10px;
color: rgba(0,0,0,0.5);
font-weight: bold;
text-shadow: 0 0 2px rgba(255,255,255,0.8);
}
</style>

View File

@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { usePuzzleStore } from '@/stores/puzzle';
import { useI18n } from '@/composables/useI18n';
import { getWorkerPool } from '@/utils/workerPool';
import DifficultyMap from './DifficultyMap.vue';
import { Upload, Image as ImageIcon, X, AlertTriangle, Camera, RefreshCw } from 'lucide-vue-next';
const emit = defineEmits(['close']);
@@ -386,6 +387,7 @@ const capturePhoto = () => {
onUnmounted(() => {
stopCameraStream();
getWorkerPool().cancelAll();
});
</script>
@@ -492,6 +494,17 @@ onUnmounted(() => {
</div>
<div v-if="imageLoaded" class="stats-panel">
<div class="difficulty-map-wrapper">
<DifficultyMap
:size="maxDimension"
:density="threshold"
:actual-difficulty="difficulty > 0 ? difficulty : null"
:interactive="false"
:width="200"
:height="200"
class="difficulty-map-mini"
/>
</div>
<div v-if="processing" class="loading-stats">
<div class="spinner"></div>
<span>{{ t('image.calculatingSolvability') || 'Calculating solvability...' }} {{ processingProgress }}%</span>
@@ -531,6 +544,24 @@ onUnmounted(() => {
</template>
<style scoped>
.difficulty-map-wrapper {
display: flex;
justify-content: center;
margin-bottom: 15px;
background: #000;
border-radius: 8px;
padding: 10px;
border: 1px solid var(--border-color);
}
.difficulty-map-mini :deep(canvas) {
width: 100% !important;
max-width: 200px;
height: auto !important;
aspect-ratio: 1;
border-radius: 4px;
}
.loading-stats {
display: flex;
align-items: center;

View File

@@ -197,7 +197,9 @@ const messages = {
'simulation.table.size': 'Rozmiar',
'simulation.table.density': 'Gęstość',
'simulation.table.solved': 'Rozwiązano (Logika)',
'simulation.empty': 'Naciśnij Start, aby uruchomić symulację Monte Carlo'
'simulation.empty': 'Naciśnij Start, aby uruchomić symulację Monte Carlo',
'difficultyMap.size': 'Rozmiar',
'difficultyMap.density': 'Gęstość'
},
en: {
'app.title': 'Nonograms',
@@ -395,7 +397,9 @@ const messages = {
'simulation.table.solved': 'Solved (Logic)',
'simulation.empty': 'Press Start to run Monte Carlo simulation',
'custom.hideMap': 'Hide difficulty map',
'custom.showMap': 'Show difficulty map'
'custom.showMap': 'Show difficulty map',
'difficultyMap.size': 'Size',
'difficultyMap.density': 'Density'
},
zh: {
'app.title': 'Nonograms',

View File

@@ -1,4 +1,4 @@
import { ref, computed, onUnmounted } from 'vue';
import { ref, computed, onUnmounted, watch } from 'vue';
import { usePuzzleStore } from '@/stores/puzzle';
import { useI18n } from '@/composables/useI18n';
@@ -18,6 +18,24 @@ export function useSolver() {
let worker = null;
let requestId = 0;
// Reset solver state when game resets or changes
watch(() => store.currentLevelId, () => {
resetSolverState();
});
watch(() => store.moves, (newVal) => {
if (newVal === 0) {
resetSolverState();
}
});
function resetSolverState() {
pause();
isStuck.value = false;
statusText.value = t('guide.waiting');
isProcessing.value = false;
}
function step() {
if (store.isGameWon) {
pause();

View File

@@ -432,6 +432,9 @@ export const usePuzzleStore = defineStore('puzzle', () => {
guideUsageCount,
currentDensity,
markGuideUsed,
markBoostUsed,
hasUsedBoost,
boostUsageCount,
startInteraction,
endInteraction,
completedRows,

View File

@@ -1,27 +0,0 @@
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);
});
});

View File

@@ -1,44 +0,0 @@
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<rows; r++) {
if (grid[r][c] === 1) current++;
else if (current > 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.difficultyScore).toBeDefined();
});
});

View File

@@ -61,39 +61,43 @@ export function generateRandomGrid(size, density = 0.5) {
return grid;
}
// Data derived from Monte Carlo Simulation (Logical Solver)
// Format: { size: [solved_pct_at_0.1, ..., solved_pct_at_0.9] }
const SIM_DATA = {
5: [88, 76, 71, 80, 90, 98, 99, 100, 100],
10: [58, 25, 18, 44, 81, 99, 100, 100, 100],
15: [36, 7, 3, 11, 67, 99, 100, 100, 100],
20: [24, 3, 0, 3, 48, 99, 100, 100, 100],
25: [13, 1, 0, 1, 21, 99, 100, 100, 100],
30: [9, 0, 0, 0, 7, 99, 100, 100, 100],
35: [5, 0, 0, 0, 5, 97, 100, 100, 100],
40: [3, 0, 0, 0, 2, 91, 100, 100, 100],
45: [2, 0, 0, 0, 1, 84, 100, 100, 100],
50: [1, 0, 0, 0, 0, 65, 100, 100, 100],
55: [1, 0, 0, 0, 0, 55, 100, 100, 100],
60: [0, 0, 0, 0, 0, 35, 100, 100, 100],
65: [0, 0, 0, 0, 0, 20, 100, 100, 100],
70: [0, 0, 0, 0, 0, 11, 100, 100, 100],
75: [0, 0, 0, 0, 0, 12, 100, 100, 100],
80: [0, 0, 0, 0, 0, 4, 100, 100, 100]
};
const SIM_SIZES = Object.keys(SIM_DATA).map(Number).sort((a, b) => a - b);
export function calculateDifficulty(density, size = 10) {
// Data derived from Monte Carlo Simulation (Logical Solver)
// Format: { size: [solved_pct_at_0.1, ..., solved_pct_at_0.9] }
const SIM_DATA = {
5: [88, 76, 71, 80, 90, 98, 99, 100, 100],
10: [58, 25, 18, 44, 81, 99, 100, 100, 100],
15: [36, 7, 3, 11, 67, 99, 100, 100, 100],
20: [24, 3, 0, 3, 48, 99, 100, 100, 100],
25: [13, 1, 0, 1, 21, 99, 100, 100, 100],
30: [9, 0, 0, 0, 7, 99, 100, 100, 100],
35: [5, 0, 0, 0, 5, 97, 100, 100, 100],
40: [3, 0, 0, 0, 2, 91, 100, 100, 100],
45: [2, 0, 0, 0, 1, 84, 100, 100, 100],
50: [1, 0, 0, 0, 0, 65, 100, 100, 100],
55: [1, 0, 0, 0, 0, 55, 100, 100, 100],
60: [0, 0, 0, 0, 0, 35, 100, 100, 100],
65: [0, 0, 0, 0, 0, 20, 100, 100, 100],
70: [0, 0, 0, 0, 0, 11, 100, 100, 100],
75: [0, 0, 0, 0, 0, 12, 100, 100, 100],
80: [0, 0, 0, 0, 0, 4, 100, 100, 100]
};
density = Number(density);
size = Number(size);
// 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];
let sLower = SIM_SIZES[0];
let sUpper = SIM_SIZES[SIM_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];
for (let i = 0; i < SIM_SIZES.length - 1; i++) {
if (s >= SIM_SIZES[i] && s <= SIM_SIZES[i+1]) {
sLower = SIM_SIZES[i];
sUpper = SIM_SIZES[i+1];
break;
}
}

View File

@@ -1,49 +0,0 @@
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<size; r++) {
const row = [];
for(let c=0; c<size; c++) row.push(Math.random() > 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);
}
});
});

View File

@@ -446,6 +446,7 @@ export function solvePuzzle(rowHints, colHints, onProgress, initialGrid = null,
lookaheadUsed: maxDepth > 0,
iterations,
maxDepth,
backtracks
backtracks,
solution: grid
};
}

View File

@@ -1,3 +1,8 @@
import { describe, it, expect } from 'vitest';
import { solvePuzzle } from './solver';
import { calculateHints, generateRandomGrid } from './puzzleUtils';
describe('Solver', () => {
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.
@@ -61,4 +66,81 @@
expect(result.percentSolved).toBe(100);
}
});
// Merged from repro_solver.test.js
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);
});
// Merged from debug_solver.test.js
it('should solve the broken grid (debug case)', () => {
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);
});
// Merged from large_grid_solver.test.js
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<rows; r++) {
if (grid[r][c] === 1) current++;
else if (current > 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); // Removed console.log callback to reduce noise
// console.log('Result:', result);
expect(result.percentSolved).toBeGreaterThan(0);
expect(result.difficultyScore).toBeDefined();
});
});

View File

@@ -1,5 +1,5 @@
import { calculateHints } from '../utils/puzzleUtils.js';
import { solveLine } from '../utils/solver.js';
import { solveLine, solvePuzzle } from '../utils/solver.js';
const messages = {
pl: {
@@ -140,7 +140,54 @@ const handleStep = (playerGrid, solution, locale) => {
const handleBoost = (playerGrid, solution, locale) => {
const size = solution.length;
// Find first unknown cell and reveal it
// 1. Try to use the Solver (DFS) to find a logical move
try {
const { rowHints, colHints } = calculateHints(solution);
// Map Store format (0=Unk, 1=Fill, 2=Cross) to Solver format (-1=Unk, 1=Fill, 0=Empty)
const solverGrid = playerGrid.map(row => row.map(cell => {
if (cell === 0) return -1;
if (cell === 1) return 1;
if (cell === 2) return 0;
return -1;
}));
// Run full solver (logicOnly=false allows DFS/guessing)
// We pass solverGrid as initial state to respect user's moves
const result = solvePuzzle(rowHints, colHints, null, solverGrid, false);
if (result && result.solution) {
const solvedGrid = result.solution;
// Find the first cell that is Unknown in playerGrid but Known in solvedGrid
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (playerGrid[r][c] === 0) { // Unknown in Player
const solvedVal = solvedGrid[r][c]; // -1=Unk, 0=Empty, 1=Filled
if (solvedVal !== -1) {
// Found a logical deduction!
const newState = solvedVal === 1 ? 1 : 2; // 1->Filled, 0->Cross
const stateLabel = t(locale, newState === 1 ? 'worker.state.filled' : 'worker.state.empty');
return {
type: 'move',
r,
c,
state: newState,
statusText: t(locale, 'worker.boosted', { row: r + 1, col: c + 1, state: stateLabel })
};
}
}
}
}
}
} catch (e) {
console.warn('Boost Solver failed, falling back to simple reveal:', e);
}
// 2. Fallback: If solver failed (e.g. contradiction due to user error),
// or no new info found, use the "Cheat" method (reveal from true solution).
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (playerGrid[r][c] === 0) {

View File

@@ -1,60 +0,0 @@
const fs = require('fs');
const filePath = 'src/composables/useI18n.js';
let content = fs.readFileSync(filePath, 'utf8');
// 1. Add key to all language objects
const lines = content.split('\n');
const newLines = [];
let insideLang = false;
let currentLang = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const langStartMatch = line.match(/^\s{2}(['"]?[\w-]+['"]?): \{/);
if (langStartMatch) {
insideLang = true;
currentLang = langStartMatch[1].replace(/['"]/g, '');
}
if (insideLang && (line.trim() === '},' || line.trim() === '}')) {
let translation = 'GUIDE';
if (currentLang === 'pl') translation = 'PRZEWODNIK';
if (currentLang === 'es') translation = 'GUÍA';
if (currentLang === 'fr') translation = 'GUIDE';
if (currentLang === 'de') translation = 'ANLEITUNG';
if (currentLang === 'it') translation = 'GUIDA';
if (currentLang === 'pt' || currentLang === 'pt-br') translation = 'GUIA';
if (currentLang === 'ru') translation = 'РУКОВОДСТВО';
if (currentLang === 'zh') translation = '指南';
// Ensure previous line has comma
if (newLines.length > 0) {
const lastLine = newLines[newLines.length - 1];
if (!lastLine.trim().endsWith(',') && !lastLine.trim().endsWith('{')) {
newLines[newLines.length - 1] = lastLine + ',';
}
}
newLines.push(` 'nav.guide': '${translation}'`);
insideLang = false;
currentLang = null;
}
newLines.push(line);
}
content = newLines.join('\n');
// 2. Add to requiredKeys
// Find "const requiredKeys = ["
// We know it ends with 'nav.newGame' now.
content = content.replace(
"'nav.newGame'",
"'nav.newGame','nav.guide'"
);
fs.writeFileSync(filePath, content);
console.log('Updated useI18n.js with nav.guide');

View File

@@ -1,107 +0,0 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 1. Parse useI18n.js to get keys
const i18nPath = path.join(__dirname, 'src/composables/useI18n.js');
const i18nContent = fs.readFileSync(i18nPath, 'utf8');
function extractKeys(lang) {
// Find start of lang block: " en: {"
const startRegex = new RegExp(`\\s+${lang}:\\s*\\{`);
const startMatch = i18nContent.match(startRegex);
if (!startMatch) return new Set();
const startIndex = startMatch.index + startMatch[0].length;
let braceCount = 1;
let inString = false;
let stringChar = '';
let endIndex = -1;
for (let i = startIndex; i < i18nContent.length; i++) {
const char = i18nContent[i];
if (inString) {
if (char === stringChar && i18nContent[i-1] !== '\\') {
inString = false;
}
} else {
if (char === "'" || char === '"' || char === '`') {
inString = true;
stringChar = char;
} else if (char === '{') {
braceCount++;
} else if (char === '}') {
braceCount--;
if (braceCount === 0) {
endIndex = i;
break;
}
}
}
}
if (endIndex === -1) return new Set();
const block = i18nContent.substring(startIndex, endIndex);
const keys = new Set();
const keyRegex = /['"]([\w.-]+)['"]\s*:/g;
let match;
while ((match = keyRegex.exec(block)) !== null) {
keys.add(match[1]);
}
return keys;
}
const enKeys = extractKeys('en');
console.log(`Found ${enKeys.size} keys in en block.`);
if (enKeys.has('image.title')) {
console.log("'image.title' IS present in en block.");
} else {
console.log("'image.title' is MISSING in en block.");
}
// 2. Scan src for usages
try {
const grepOutput = execSync(`grep -r "t(['\\"]" src | grep -v "node_modules"`, { encoding: 'utf8' });
const usedKeys = new Set();
const usageRegex = /t\(['"]([\w.-]+)['"]/g;
const lines = grepOutput.split('\n');
for (const line of lines) {
let m;
while ((m = usageRegex.exec(line)) !== null) {
usedKeys.add(m[1]);
}
}
console.log(`Found ${usedKeys.size} used keys in src.`);
// 3. Compare
const missingKeys = [];
for (const key of usedKeys) {
// Skip dynamic keys or composed keys if any (heuristic)
if (key.includes('${')) continue;
if (!enKeys.has(key)) {
missingKeys.push(key);
}
}
if (missingKeys.length > 0) {
console.log("Missing translations in en:");
missingKeys.forEach(k => console.log(` - ${k}`));
} else {
console.log("No missing translations found in en.");
}
} catch (e) {
console.error("Error running grep:", e);
}