I NEED SLEEP

+ Recode AutoSave
+ change some miragte
+ readd turnstile widget
+ change some captcha method
+ Can click save file and download while waiting for charges
This commit is contained in:
maiminhdung
2025-08-25 00:35:20 +07:00
parent 33abf29301
commit 2ab307aaa9
+306 -164
View File
@@ -1089,6 +1089,11 @@
notificationIntervalMinutes: CONFIG.NOTIFICATIONS.REPEAT_MINUTES,
_lastChargesNotifyAt: 0,
_lastChargesBelow: true,
// Smart save tracking
_lastSavePixelCount: 0,
_lastSaveTime: 0,
_saveInProgress: false,
paintedMap: null,
}
let _updateResizePreview = () => { };
@@ -1428,7 +1433,7 @@
const overlayManager = new OverlayManager();
// Optimized Turnstile token handling with caching and retry logic
// Optimized Turnstile token handling with improved caching and retry logic
let turnstileToken = null
let tokenExpiryTime = 0
let tokenGenerationInProgress = false
@@ -1439,26 +1444,35 @@
const MAX_BATCH_RETRIES = 10 // Maximum attempts for batch sending
const TOKEN_LIFETIME = 240000 // 4 minutes (tokens typically last 5 min, use 4 for safety)
function setTurnstileToken(t) {
function setTurnstileToken(token) {
if (_resolveToken) {
_resolveToken(t)
_resolveToken(token)
_resolveToken = null
}
turnstileToken = t
turnstileToken = token
tokenExpiryTime = Date.now() + TOKEN_LIFETIME
retryCount = 0 // Reset retry count on successful token
console.log("✅ Turnstile token set successfully")
}
function isTokenValid() {
return turnstileToken && Date.now() < tokenExpiryTime
}
async function ensureToken() {
// Return cached token if still valid
if (isTokenValid()) {
function invalidateToken() {
turnstileToken = null
tokenExpiryTime = 0
console.log("🗑️ Token invalidated, will force fresh generation")
}
async function ensureToken(forceRefresh = false) {
// Return cached token if still valid and not forcing refresh
if (isTokenValid() && !forceRefresh) {
return turnstileToken;
}
// Invalidate token if forcing refresh
if (forceRefresh) invalidateToken();
// Avoid multiple simultaneous token generations
if (tokenGenerationInProgress) {
console.log("🔄 Token generation already in progress, waiting...");
@@ -1471,44 +1485,59 @@
try {
console.log("🔄 Token expired or missing, generating new one...");
const token = await handleCaptchaWithRetry();
if (token) {
if (token && token.length > 20) {
setTurnstileToken(token);
console.log("✅ Token generated successfully");
console.log("✅ Token captured and cached successfully");
return token;
}
} catch (error) {
console.error("❌ Token generation failed after retries:", error);
updateUI("captchaNeeded", "error");
Utils.showAlert(Utils.t("captchaNeeded"), "error");
console.log("⚠️ Invisible Turnstile failed, forcing browser automation...");
const fallbackToken = await handleCaptchaFallback();
if (fallbackToken && fallbackToken.length > 20) {
setTurnstileToken(fallbackToken);
console.log("✅ Fallback token captured successfully");
return fallbackToken;
}
console.log("❌ All token generation methods failed");
return null;
} finally {
tokenGenerationInProgress = false;
}
return null;
}
async function handleCaptchaWithRetry() {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const token = await handleCaptcha();
if (token && token.length > 20) {
return token;
}
throw new Error("Invalid token received");
} catch (error) {
console.warn(`❌ Token generation attempt ${attempt}/${MAX_RETRIES} failed:`, error);
if (attempt < MAX_RETRIES) {
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 8000); // Exponential backoff, max 8s
console.log(`⏳ Retrying in ${delay}ms...`);
await Utils.sleep(delay);
} else {
throw error;
}
const startTime = Date.now();
try {
const sitekey = Utils.detectSitekey();
console.log("🔑 Generating Turnstile token for sitekey:", sitekey);
if (typeof window !== "undefined" && window.navigator) {
console.log("🧭 UA:", window.navigator.userAgent, "Platform:", window.navigator.platform);
}
const token = await Utils.generatePaintToken(sitekey);
if (token && token.length > 20) {
const elapsed = Math.round(Date.now() - startTime);
console.log(`✅ Turnstile token generated successfully in ${elapsed}ms`);
return token;
} else {
throw new Error("Invalid or empty token received");
}
} catch (error) {
const elapsed = Math.round(Date.now() - startTime);
console.log(`❌ Turnstile token generation failed after ${elapsed}ms:`, error);
throw error;
}
}
async function handleCaptchaFallback() {
// Implementation for fallback token generation would go here
// This is a placeholder for browser automation fallback
console.log("🔄 Attempting fallback token generation...");
return null;
}
function inject(callback) {
const script = document.createElement('script');
script.textContent = `(${callback})();`;
@@ -1620,6 +1649,7 @@
// Turnstile Generator Integration - Optimized with widget reuse and proper cleanup
turnstileLoaded: false,
_turnstileContainer: null,
_turnstileOverlay: null,
_turnstileWidgetId: null,
_lastSitekey: null,
@@ -1643,6 +1673,7 @@
};
return checkReady();
}
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
script.async = true;
@@ -1660,7 +1691,7 @@
});
},
// Create or reuse the turnstile container - completely hidden for token generation
// Create or reuse the turnstile container - completely hidden for token generation
ensureTurnstileContainer() {
if (!this._turnstileContainer || !document.body.contains(this._turnstileContainer)) {
// Clean up old container if it exists
@@ -1688,64 +1719,85 @@
return this._turnstileContainer;
},
// No interactive overlay container needed - pure background operation
// Interactive overlay container for visible widgets when needed
ensureTurnstileOverlayContainer() {
// Return null since we don't want any visible widgets
console.log('🚫 Interactive Turnstile overlay disabled - background mode only');
return null;
if (this._turnstileOverlay && document.body.contains(this._turnstileOverlay)) {
return this._turnstileOverlay;
}
const overlay = document.createElement('div');
overlay.id = 'turnstile-overlay-container';
overlay.style.cssText = `
position: fixed !important;
bottom: 20px !important;
right: 20px !important;
z-index: 99999 !important;
background: rgba(0,0,0,0.9) !important;
border-radius: 12px !important;
padding: 20px !important;
box-shadow: 0 8px 32px rgba(0,0,0,0.4) !important;
backdrop-filter: blur(10px) !important;
border: 1px solid rgba(255,255,255,0.2) !important;
color: white !important;
font-family: 'Segoe UI', sans-serif !important;
display: none !important;
max-width: 350px !important;
min-width: 300px !important;
`;
const title = document.createElement('div');
title.textContent = 'Cloudflare Turnstile — please complete the check if shown';
title.style.cssText = 'font: 600 12px/1.3 "Segoe UI",sans-serif; margin-bottom: 8px; opacity: 0.9;';
const host = document.createElement('div');
host.id = 'turnstile-overlay-host';
host.style.cssText = 'width: 100%; min-height: 70px;';
const hideBtn = document.createElement('button');
hideBtn.textContent = 'Hide';
hideBtn.style.cssText = 'position:absolute; top:6px; right:6px; font-size:11px; background:transparent; color:#fff; border:1px solid rgba(255,255,255,0.2); border-radius:6px; padding:2px 6px; cursor:pointer;';
hideBtn.addEventListener('click', () => overlay.remove());
overlay.appendChild(title);
overlay.appendChild(host);
overlay.appendChild(hideBtn);
document.body.appendChild(overlay);
this._turnstileOverlay = overlay;
return overlay;
},
async executeTurnstile(sitekey, action = 'paint') {
await this.loadTurnstile();
// Try multiple sitekeys if the first one fails
const sitekeys = [
sitekey,
'0x4AAAAAABpqJe8FO0N84q0F'
];
for (let i = 0; i < sitekeys.length; i++) {
const currentSitekey = sitekeys[i];
console.log(`🔧 Trying sitekey ${i + 2}/${sitekeys.length}:`, currentSitekey);
// Try reusing existing widget first
if (this._turnstileWidgetId && this._lastSitekey === currentSitekey && window.turnstile?.execute) {
try {
console.log("🔄 Reusing existing Turnstile widget...");
const token = await Promise.race([
window.turnstile.execute(this._turnstileWidgetId, { action }),
new Promise((_, reject) => setTimeout(() => reject(new Error('Execute timeout')), 15000))
]);
if (token && typeof token === 'string' && token.length > 20) {
console.log("✅ Token generated via widget reuse");
return token;
}
} catch (err) {
console.warn('🔄 Widget reuse failed:', err.message);
// Try reusing existing widget first if sitekey matches
if (this._turnstileWidgetId && this._lastSitekey === sitekey && window.turnstile?.execute) {
try {
console.log("🔄 Reusing existing Turnstile widget...");
const token = await Promise.race([
window.turnstile.execute(this._turnstileWidgetId, { action }),
new Promise((_, reject) => setTimeout(() => reject(new Error('Execute timeout')), 15000))
]);
if (token && token.length > 20) {
console.log("✅ Token generated via widget reuse");
return token;
}
} catch (error) {
console.log(" Widget reuse failed, will create a fresh widget:", error.message);
}
// Try invisible widget
const invisible = await this.createNewTurnstileWidgetInvisible(currentSitekey, action);
if (invisible && typeof invisible === 'string' && invisible.length > 20) {
console.log(`✅ Token generated with sitekey ${i + 1}: ${currentSitekey}`);
return invisible;
}
console.warn(`❌ Sitekey ${i + 1} failed:`, currentSitekey);
// Clean up failed widget before trying next sitekey
if (this._turnstileWidgetId && window.turnstile?.remove) {
try { window.turnstile.remove(this._turnstileWidgetId); } catch {}
}
this._turnstileWidgetId = null;
}
console.log('👀 All sitekeys failed for invisible widgets, trying interactive...');
return await this.createNewTurnstileWidgetInteractive(sitekey, action);
// Try invisible widget first
const invisibleToken = await this.createTurnstileWidget(sitekey, action);
if (invisibleToken && invisibleToken.length > 20) {
return invisibleToken;
}
console.log(" Falling back to interactive Turnstile (visible).");
return await this.createTurnstileWidgetInteractive(sitekey, action);
},
async createNewTurnstileWidgetInvisible(sitekey, action) {
async createTurnstileWidget(sitekey, action) {
return new Promise((resolve) => {
try {
// Force cleanup of any existing widget
@@ -1768,60 +1820,33 @@
return;
}
// Set a timeout to resolve with null if no token is received
const timeout = setTimeout(() => {
console.warn('⏰ Turnstile widget timeout - no token received in 15s');
resolve(null);
}, 15000);
console.log('🔧 Creating invisible Turnstile widget...');
const widgetId = window.turnstile.render(container, {
sitekey,
action,
size: 'compact',
size: 'invisible',
retry: 'auto',
'retry-interval': 8000,
callback: (token) => {
clearTimeout(timeout);
console.log('🔍 Turnstile callback received:', {
type: typeof token,
value: token,
length: token?.length || 0,
isString: typeof token === 'string',
isValid: typeof token === 'string' && token.length > 20
});
// Ensure we have a valid string token
if (typeof token === 'string' && token.length > 20) {
console.log('✅ Valid token received from hidden widget');
resolve(token);
} else {
console.warn('❌ Invalid token received from hidden widget - resolving with null');
resolve(null);
}
},
'error-callback': (error) => {
clearTimeout(timeout);
console.warn('❌ Turnstile error callback:', error);
resolve(null);
},
'timeout-callback': () => {
clearTimeout(timeout);
console.warn('⏰ Turnstile timeout callback triggered');
resolve(null);
console.log('✅ Invisible Turnstile callback');
resolve(token);
},
'error-callback': () => resolve(null),
'timeout-callback': () => resolve(null)
});
this._turnstileWidgetId = widgetId;
this._lastSitekey = sitekey;
if (!widgetId) {
clearTimeout(timeout);
console.warn('❌ Failed to create Turnstile widget - render returned invalid ID');
resolve(null);
} else {
console.log('✅ Turnstile widget created with ID:', widgetId);
return resolve(null);
}
// Execute the widget and race with timeout
Promise.race([
window.turnstile.execute(widgetId, { action }),
new Promise((_, reject) => setTimeout(() => reject(new Error('Invisible execute timeout')), 12000))
]).then(resolve).catch(() => resolve(null));
} catch (e) {
console.error('❌ Invisible Turnstile creation failed:', e);
resolve(null);
@@ -1829,7 +1854,7 @@
});
},
async createNewTurnstileWidgetInteractive(sitekey, action) {
async createTurnstileWidgetInteractive(sitekey, action) {
// Create a visible widget that users can interact with if needed
console.log('🔄 Creating interactive Turnstile widget (visible)');
@@ -1844,42 +1869,27 @@
}
}
const container = this.ensureTurnstileContainer();
container.innerHTML = '';
const overlay = this.ensureTurnstileOverlayContainer();
overlay.style.display = 'block';
// Make container visible for interactive mode
container.style.display = 'block';
container.style.position = 'fixed';
container.style.top = '50%';
container.style.left = '50%';
container.style.transform = 'translate(-50%, -50%)';
container.style.zIndex = '10000';
container.style.backgroundColor = 'white';
container.style.padding = '20px';
container.style.borderRadius = '8px';
container.style.boxShadow = '0 4px 20px rgba(0,0,0,0.3)';
// Add instruction text
container.innerHTML = '<div style="margin-bottom: 15px; color: #333; font-family: Arial, sans-serif;"><strong>CAPTCHA Required</strong><br>Please complete the CAPTCHA below to continue:</div>';
const widgetContainer = document.createElement('div');
container.appendChild(widgetContainer);
const host = overlay.querySelector('#turnstile-overlay-host');
host.innerHTML = '';
// Set a timeout for interactive mode
const timeout = setTimeout(() => {
console.warn('⏰ Interactive Turnstile widget timeout');
container.style.display = 'none';
overlay.style.display = 'none';
resolve(null);
}, 60000); // 60 seconds for user interaction
const widgetId = window.turnstile.render(widgetContainer, {
const widgetId = window.turnstile.render(host, {
sitekey,
action,
size: 'normal',
theme: 'light',
callback: (token) => {
clearTimeout(timeout);
container.style.display = 'none';
overlay.style.display = 'none';
console.log('✅ Interactive Turnstile completed successfully');
if (typeof token === 'string' && token.length > 20) {
@@ -1891,7 +1901,7 @@
},
'error-callback': (error) => {
clearTimeout(timeout);
container.style.display = 'none';
overlay.style.display = 'none';
console.warn('❌ Interactive Turnstile error:', error);
resolve(null);
},
@@ -1902,7 +1912,7 @@
if (!widgetId) {
clearTimeout(timeout);
container.style.display = 'none';
overlay.style.display = 'none';
console.warn('❌ Failed to create interactive Turnstile widget');
resolve(null);
} else {
@@ -1933,9 +1943,13 @@
this._turnstileContainer.remove();
}
if (this._turnstileOverlay && document.body.contains(this._turnstileOverlay)) {
this._turnstileOverlay.remove();
}
this._turnstileWidgetId = null;
this._turnstileContainer = null;
this._turnstileOverlay = null; // Keep for compatibility but won't exist
this._turnstileOverlay = null;
this._lastSitekey = null;
},
@@ -2338,7 +2352,69 @@
return Math.max(timeFromSpeed, timeFromCharges)
},
// --- Painted map packing helpers (compact, efficient storage) ---
// --- Painted pixel tracking helpers ---
initializePaintedMap: (width, height) => {
if (!state.paintedMap || state.paintedMap.length !== height) {
state.paintedMap = Array(height).fill().map(() => Array(width).fill(false));
console.log(`📋 Initialized painted map: ${width}x${height}`);
}
},
markPixelPainted: (x, y, regionX = 0, regionY = 0) => {
const actualX = x + regionX;
const actualY = y + regionY;
if (state.paintedMap && state.paintedMap[actualY] &&
actualX >= 0 && actualX < state.paintedMap[actualY].length) {
state.paintedMap[actualY][actualX] = true;
}
},
isPixelPainted: (x, y, regionX = 0, regionY = 0) => {
const actualX = x + regionX;
const actualY = y + regionY;
if (state.paintedMap && state.paintedMap[actualY] &&
actualX >= 0 && actualX < state.paintedMap[actualY].length) {
return state.paintedMap[actualY][actualX];
}
return false;
},
// Smart save - only save if significant changes
shouldAutoSave: () => {
const now = Date.now();
const pixelsSinceLastSave = state.paintedPixels - state._lastSavePixelCount;
const timeSinceLastSave = now - state._lastSaveTime;
// Save conditions:
// 1. Every 25 pixels (reduced from 50 for more frequent saves)
// 2. At least 30 seconds since last save (prevent spam)
// 3. Not already saving
return !state._saveInProgress &&
pixelsSinceLastSave >= 25 &&
timeSinceLastSave >= 30000;
},
performSmartSave: () => {
if (!Utils.shouldAutoSave()) return false;
state._saveInProgress = true;
const success = Utils.saveProgress();
if (success) {
state._lastSavePixelCount = state.paintedPixels;
state._lastSaveTime = Date.now();
console.log(`💾 Auto-saved at ${state.paintedPixels} pixels`);
}
state._saveInProgress = false;
return success;
},
// --- Data management helpers ---
// Base64 compression helpers for efficient storage
packPaintedMapToBase64: (paintedMap, width, height) => {
if (!paintedMap || !width || !height) return null;
const totalBits = width * height;
@@ -2381,6 +2457,7 @@
return map;
},
// Migration helpers for backward compatibility
migrateProgressToV2: (saved) => {
if (!saved) return saved;
const isV1 = !saved.version || saved.version === '1' || saved.version === '1.0' || saved.version === '1.1';
@@ -2411,8 +2488,16 @@
if (!isV2 && !isV1) return saved; // save this for future
try {
const migrated = { ...saved };
delete migrated.paintedMapPacked;
delete migrated.paintedMap;
// First migrate to v2 if needed
if (isV1) {
const width = migrated.imageData?.width;
const height = migrated.imageData?.height;
if (migrated.paintedMap && width && height) {
const data = Utils.packPaintedMapToBase64(migrated.paintedMap, width, height);
migrated.paintedMapPacked = { width, height, data };
}
delete migrated.paintedMap;
}
migrated.version = '2.1';
return migrated;
} catch (e) {
@@ -2423,9 +2508,22 @@
saveProgress: () => {
try {
// Pack painted map if available
let paintedMapPacked = null;
if (state.paintedMap && state.imageData) {
const data = Utils.packPaintedMapToBase64(state.paintedMap, state.imageData.width, state.imageData.height);
if (data) {
paintedMapPacked = {
width: state.imageData.width,
height: state.imageData.height,
data: data
};
}
}
const progressData = {
timestamp: Date.now(),
version: "2.1",
version: "2.1",
state: {
totalPixels: state.totalPixels,
paintedPixels: state.paintedPixels,
@@ -2444,7 +2542,7 @@
totalPixels: state.imageData.totalPixels,
}
: null,
paintedMapPacked: null,
paintedMapPacked: paintedMapPacked,
}
localStorage.setItem("wplace-bot-progress", JSON.stringify(progressData))
@@ -2483,6 +2581,11 @@
clearProgress: () => {
try {
localStorage.removeItem("wplace-bot-progress")
// Also clear painted map from memory
state.paintedMap = null;
state._lastSavePixelCount = 0;
state._lastSaveTime = 0;
console.log("📋 Progress and painted map cleared");
return true
} catch (error) {
console.error("Error clearing progress:", error)
@@ -2534,9 +2637,22 @@
saveProgressToFile: () => {
try {
// Pack painted map if available
let paintedMapPacked = null;
if (state.paintedMap && state.imageData) {
const data = Utils.packPaintedMapToBase64(state.paintedMap, state.imageData.width, state.imageData.height);
if (data) {
paintedMapPacked = {
width: state.imageData.width,
height: state.imageData.height,
data: data
};
}
}
const progressData = {
timestamp: Date.now(),
version: "2.1",
version: "2.1",
state: {
totalPixels: state.totalPixels,
paintedPixels: state.paintedPixels,
@@ -2555,7 +2671,7 @@
totalPixels: state.imageData.totalPixels,
}
: null,
paintedMapPacked: null,
paintedMapPacked: paintedMapPacked,
}
const filename = `wplace-bot-progress-${new Date().toISOString().slice(0, 19).replace(/:/g, "-")}.json`
@@ -3043,7 +3159,7 @@
// Add additional checks before token generation
if (!window.turnstile) {
throw new Error('Turnstile library not loaded');
await Utils.loadTurnstile();
}
const token = await Utils.generatePaintToken(sitekey);
@@ -6718,6 +6834,10 @@
state.paintedPixels = 0
state.imageLoaded = true
state.lastPosition = { x: 0, y: 0 }
// Initialize painted map for tracking
Utils.initializePaintedMap(width, height);
// New image: clear previous resize settings
state.resizeSettings = null;
// Also clear any previous ignore mask
@@ -6725,7 +6845,6 @@
// Save original image for this browser (dataUrl + dims)
state.originalImage = { dataUrl: imageSrc, width, height };
saveBotSettings();
saveBotSettings();
// Use the original image for the overlay initially
const imageBitmap = await createImageBitmap(processor.img);
@@ -7024,16 +7143,19 @@
const success = await sendBatchWithRetry(pixelBatch.pixels, pixelBatch.regionX, pixelBatch.regionY);
if (success) {
pixelBatch.pixels.forEach((p) => { state.paintedPixels++; });
pixelBatch.pixels.forEach((p) => {
state.paintedPixels++;
// Mark pixel as painted in map
Utils.markPixelPainted(p.x, p.y, pixelBatch.regionX, pixelBatch.regionY);
});
state.currentCharges -= pixelBatch.pixels.length;
updateUI("paintingProgress", "default", {
painted: state.paintedPixels,
total: state.totalPixels,
})
if (state.paintedPixels % 50 === 0) {
Utils.saveProgress()
}
// Use smart save instead of fixed interval
Utils.performSmartSave();
if (CONFIG.PAINTING_SPEED_ENABLED && state.paintingSpeed > 0 && pixelBatch.pixels.length > 0) {
// paintingSpeed now represents batch size, so add a small delay based on batch size
@@ -7095,6 +7217,8 @@
if (success) {
pixelBatch.pixels.forEach((pixel) => {
state.paintedPixels++;
// Mark pixel as painted in map
Utils.markPixelPainted(pixel.x, pixel.y, pixelBatch.regionX, pixelBatch.regionY);
})
state.currentCharges -= pixelBatch.pixels.length;
@@ -7104,9 +7228,8 @@
total: state.totalPixels,
})
if (state.paintedPixels % 50 === 0) {
Utils.saveProgress()
}
// Use smart save instead of fixed interval
Utils.performSmartSave();
if (CONFIG.PAINTING_SPEED_ENABLED && state.paintingSpeed > 0 && pixelBatch.pixels.length > 0) {
const delayPerPixel = 1000 / state.paintingSpeed // ms per pixel
@@ -7135,14 +7258,26 @@
break;
}
// Enable save button during cooldown wait
saveBtn.disabled = false;
updateUI("noChargesThreshold", "warning", {
time: Utils.formatTime(state.cooldown),
threshold: state.cooldownChargeThreshold,
current: state.currentCharges
});
await updateStats();
// Allow auto save during cooldown
Utils.performSmartSave();
await Utils.sleep(state.cooldown);
}
// Disable save button again after exiting wait loop (back to normal painting)
if (!state.stopFlag) {
saveBtn.disabled = true;
}
if (state.stopFlag) break outerLoop;
}
@@ -7153,9 +7288,14 @@
const success = await sendBatchWithRetry(pixelBatch.pixels, pixelBatch.regionX, pixelBatch.regionY);
if (success) {
pixelBatch.pixels.forEach((pixel) => {
state.paintedPixels++
state.paintedPixels++;
// Mark pixel as painted in map
Utils.markPixelPainted(pixel.x, pixel.y, pixelBatch.regionX, pixelBatch.regionY);
})
state.currentCharges -= pixelBatch.pixels.length;
// Final save with painted map
Utils.saveProgress();
if (CONFIG.PAINTING_SPEED_ENABLED && state.paintingSpeed > 0 && pixelBatch.pixels.length > 0) {
const delayPerPixel = 1000 / state.paintingSpeed // ms per pixel
const totalDelay = Math.max(100, delayPerPixel * pixelBatch.pixels.length) // minimum 100ms
@@ -7173,12 +7313,14 @@
if (state.stopFlag) {
updateUI("paintingStopped", "warning")
// Save progress when stopped to preserve painted map
Utils.saveProgress()
} else {
updateUI("paintingComplete", "success", { count: state.paintedPixels })
state.lastPosition = { x: 0, y: 0 }
state.paintedMap = null
Utils.clearProgress()
// Keep painted map until user starts new project
// state.paintedMap = null // Commented out to preserve data
Utils.saveProgress() // Save final complete state
overlayManager.clear();
const toggleOverlayBtn = document.getElementById('toggleOverlayBtn');
if (toggleOverlayBtn) {