`
: ''
}
`;
// should be after statsArea.innerHTML = '...'. todo make full stats ui update partial
updateChargeStatsDisplay(intervalMs);
};
updateDataButtons = () => {
const hasImageData = state.imageLoaded && state.imageData;
saveBtn.disabled = !hasImageData;
saveToFileBtn.disabled = !hasImageData;
};
updateDataButtons();
function showMoveArtworkPanel() {
// Create move artwork control panel like status panels
const movePanel = document.createElement('div');
movePanel.id = 'moveArtworkPanel';
movePanel.className = 'wplace-move-panel';
movePanel.innerHTML = `
Move Artwork
1px
`;
// No overlay - append directly to body like other panels
document.body.appendChild(movePanel);
// Make the panel draggable
makeDraggable(movePanel);
// Add event listeners for movement
let currentMessageTimeout = null;
function moveArtwork(deltaX, deltaY) {
if (state.startPosition && state.region) {
const newX = state.startPosition.x + deltaX;
const newY = state.startPosition.y + deltaY;
console.log(`๐ Moving artwork from (${state.startPosition.x}, ${state.startPosition.y}) to (${newX}, ${newY})`);
state.startPosition.x = newX;
state.startPosition.y = newY;
// Clear any existing message timeout and alerts immediately
if (currentMessageTimeout) {
clearTimeout(currentMessageTimeout);
currentMessageTimeout = null;
}
Utils.hideAlert(); // Hide current alert immediately
// Update overlay position if available
if (overlayManager) {
overlayManager.setPosition(state.startPosition, state.region)
.then(() => {
console.log('โ Overlay position updated');
Utils.showAlert(`Moved to position (${newX}, ${newY})`, 'success');
// Set new timeout for this message
currentMessageTimeout = setTimeout(() => {
Utils.hideAlert();
currentMessageTimeout = null;
}, 1500); // Message disappears after 1.5 seconds
})
.catch(err => {
console.error('โ Failed to update overlay:', err);
Utils.showAlert('Failed to update overlay position', 'error');
});
} else {
Utils.showAlert(`Position updated to (${newX}, ${newY})`, 'success');
// Set new timeout for this message
currentMessageTimeout = setTimeout(() => {
Utils.hideAlert();
currentMessageTimeout = null;
}, 1500);
}
} else {
Utils.showAlert('Please select a position first before moving artwork', 'warning');
}
}
document.getElementById('moveUp').addEventListener('click', () => moveArtwork(0, -1));
document.getElementById('moveDown').addEventListener('click', () => moveArtwork(0, 1));
document.getElementById('moveLeft').addEventListener('click', () => moveArtwork(-1, 0));
document.getElementById('moveRight').addEventListener('click', () => moveArtwork(1, 0));
// Close panel
function closeMovePanel() {
if (currentMessageTimeout) {
clearTimeout(currentMessageTimeout);
currentMessageTimeout = null;
}
Utils.hideAlert();
document.body.removeChild(movePanel);
}
document.getElementById('closeMovePanel').addEventListener('click', closeMovePanel);
}
function showResizeDialog(processor) {
let baseProcessor = processor;
let width, height;
if (state.originalImage?.dataUrl) {
baseProcessor = new ImageProcessor(state.originalImage.dataUrl);
width = state.originalImage.width;
height = state.originalImage.height;
} else {
const dims = processor.getDimensions();
width = dims.width;
height = dims.height;
}
const aspectRatio = width / height;
const rs = state.resizeSettings;
widthSlider.max = width * 2;
heightSlider.max = height * 2;
let initialW = width;
let initialH = height;
if (
rs &&
Number.isFinite(rs.width) &&
Number.isFinite(rs.height) &&
rs.width > 0 &&
rs.height > 0
) {
initialW = rs.width;
initialH = rs.height;
}
// Clamp to slider ranges
initialW = Math.max(
parseInt(widthSlider.min, 10) || 10,
Math.min(initialW, parseInt(widthSlider.max, 10))
);
initialH = Math.max(
parseInt(heightSlider.min, 10) || 10,
Math.min(initialH, parseInt(heightSlider.max, 10))
);
widthSlider.value = initialW;
heightSlider.value = initialH;
widthValue.textContent = initialW;
heightValue.textContent = initialH;
zoomSlider.value = 1;
if (zoomValue) zoomValue.textContent = '100%';
paintWhiteToggle.checked = state.paintWhitePixels;
paintTransparentToggle.checked = state.paintTransparentPixels;
let _previewTimer = null;
let _previewJobId = 0;
let _isDraggingSize = false;
let _zoomLevel = 1;
let _ditherWorkBuf = null;
let _ditherEligibleBuf = null;
const ensureDitherBuffers = (n) => {
if (!_ditherWorkBuf || _ditherWorkBuf.length !== n * 3)
_ditherWorkBuf = new Float32Array(n * 3);
if (!_ditherEligibleBuf || _ditherEligibleBuf.length !== n)
_ditherEligibleBuf = new Uint8Array(n);
return { work: _ditherWorkBuf, eligible: _ditherEligibleBuf };
};
let _maskImageData = null;
let _maskData = null;
let _dirty = null;
const _resetDirty = () => {
_dirty = { minX: Infinity, minY: Infinity, maxX: -1, maxY: -1 };
};
const _markDirty = (x, y) => {
if (!_dirty) _resetDirty();
if (x < _dirty.minX) _dirty.minX = x;
if (y < _dirty.minY) _dirty.minY = y;
if (x > _dirty.maxX) _dirty.maxX = x;
if (y > _dirty.maxY) _dirty.maxY = y;
};
const _flushDirty = () => {
if (!_dirty || _dirty.maxX < _dirty.minX || _dirty.maxY < _dirty.minY) return;
const x = Math.max(0, _dirty.minX);
const y = Math.max(0, _dirty.minY);
const w = Math.min(maskCanvas.width - x, _dirty.maxX - x + 1);
const h = Math.min(maskCanvas.height - y, _dirty.maxY - y + 1);
if (w > 0 && h > 0) maskCtx.putImageData(_maskImageData, 0, 0, x, y, w, h);
_resetDirty();
};
const _ensureMaskOverlayBuffers = (w, h, rebuildFromMask = false) => {
if (!_maskImageData || _maskImageData.width !== w || _maskImageData.height !== h) {
_maskImageData = maskCtx.createImageData(w, h);
_maskData = _maskImageData.data;
rebuildFromMask = true;
}
if (rebuildFromMask) {
const m = state.resizeIgnoreMask;
const md = _maskData;
md.fill(0);
if (m) {
for (let i = 0; i < m.length; i++)
if (m[i]) {
const p = i * 4;
md[p] = 255;
md[p + 1] = 0;
md[p + 2] = 0;
md[p + 3] = 150;
}
}
maskCtx.putImageData(_maskImageData, 0, 0);
_resetDirty();
}
};
const ensureMaskSize = (w, h) => {
if (!state.resizeIgnoreMask || state.resizeIgnoreMask.length !== w * h) {
state.resizeIgnoreMask = new Uint8Array(w * h);
}
baseCanvas.width = w;
baseCanvas.height = h;
maskCanvas.width = w;
maskCanvas.height = h;
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
// Ensure overlay buffers exist and rebuild from mask when dimensions change
_ensureMaskOverlayBuffers(w, h, true);
};
_updateResizePreview = async () => {
const jobId = ++_previewJobId;
const newWidth = parseInt(widthSlider.value, 10);
const newHeight = parseInt(heightSlider.value, 10);
_zoomLevel = parseFloat(zoomSlider.value);
widthValue.textContent = newWidth;
heightValue.textContent = newHeight;
ensureMaskSize(newWidth, newHeight);
canvasStack.style.width = newWidth + 'px';
canvasStack.style.height = newHeight + 'px';
baseCtx.imageSmoothingEnabled = false;
if (!state.availableColors || state.availableColors.length === 0) {
if (baseProcessor !== processor && (!baseProcessor.img || !baseProcessor.canvas)) {
await baseProcessor.load();
}
baseCtx.clearRect(0, 0, newWidth, newHeight);
baseCtx.drawImage(baseProcessor.img, 0, 0, newWidth, newHeight);
// Draw existing mask overlay buffer
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
if (_maskImageData) maskCtx.putImageData(_maskImageData, 0, 0);
updateZoomLayout();
return;
}
if (baseProcessor !== processor && (!baseProcessor.img || !baseProcessor.canvas)) {
await baseProcessor.load();
}
baseCtx.clearRect(0, 0, newWidth, newHeight);
baseCtx.drawImage(baseProcessor.img, 0, 0, newWidth, newHeight);
const imgData = baseCtx.getImageData(0, 0, newWidth, newHeight);
const data = imgData.data;
const tThresh = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
const applyFSDither = () => {
const w = newWidth,
h = newHeight;
const n = w * h;
const { work, eligible } = ensureDitherBuffers(n);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y * w + x;
const i4 = idx * 4;
const r = data[i4],
g = data[i4 + 1],
b = data[i4 + 2],
a = data[i4 + 3];
const isEligible =
(state.paintTransparentPixels || a >= tThresh) &&
(state.paintWhitePixels || !Utils.isWhitePixel(r, g, b));
eligible[idx] = isEligible ? 1 : 0;
work[idx * 3] = r;
work[idx * 3 + 1] = g;
work[idx * 3 + 2] = b;
if (!isEligible) {
data[i4 + 3] = 0; // transparent in preview overlay
}
}
}
const diffuse = (nx, ny, er, eg, eb, factor) => {
if (nx < 0 || nx >= w || ny < 0 || ny >= h) return;
const nidx = ny * w + nx;
if (!eligible[nidx]) return;
const base = nidx * 3;
work[base] = Math.min(255, Math.max(0, work[base] + er * factor));
work[base + 1] = Math.min(255, Math.max(0, work[base + 1] + eg * factor));
work[base + 2] = Math.min(255, Math.max(0, work[base + 2] + eb * factor));
};
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y * w + x;
if (!eligible[idx]) continue;
const base = idx * 3;
const r0 = work[base],
g0 = work[base + 1],
b0 = work[base + 2];
const [nr, ng, nb] = Utils.findClosestPaletteColor(
r0,
g0,
b0,
state.activeColorPalette
);
const i4 = idx * 4;
data[i4] = nr;
data[i4 + 1] = ng;
data[i4 + 2] = nb;
data[i4 + 3] = 255;
const er = r0 - nr;
const eg = g0 - ng;
const eb = b0 - nb;
diffuse(x + 1, y, er, eg, eb, 7 / 16);
diffuse(x - 1, y + 1, er, eg, eb, 3 / 16);
diffuse(x, y + 1, er, eg, eb, 5 / 16);
diffuse(x + 1, y + 1, er, eg, eb, 1 / 16);
}
}
};
// Skip expensive dithering while user is dragging sliders
if (state.ditheringEnabled && !_isDraggingSize) {
applyFSDither();
} else {
for (let i = 0; i < data.length; i += 4) {
const r = data[i],
g = data[i + 1],
b = data[i + 2],
a = data[i + 3];
if (
(!state.paintTransparentPixels && a < tThresh) ||
(!state.paintWhitePixels && Utils.isWhitePixel(r, g, b))
) {
data[i + 3] = 0;
continue;
}
const [nr, ng, nb] = Utils.findClosestPaletteColor(r, g, b, state.activeColorPalette);
data[i] = nr;
data[i + 1] = ng;
data[i + 2] = nb;
data[i + 3] = 255;
}
}
if (jobId !== _previewJobId) return;
baseCtx.putImageData(imgData, 0, 0);
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
if (_maskImageData) maskCtx.putImageData(_maskImageData, 0, 0);
updateZoomLayout();
};
const onWidthInput = () => {
if (keepAspect.checked) {
heightSlider.value = Math.round(parseInt(widthSlider.value, 10) / aspectRatio);
}
_updateResizePreview();
const curW = parseInt(widthSlider.value, 10);
const curH = parseInt(heightSlider.value, 10);
state.resizeSettings = {
baseWidth: width,
baseHeight: height,
width: curW,
height: curH,
};
saveBotSettings();
// Auto-fit after size changes
const fit = typeof computeFitZoom === 'function' ? computeFitZoom() : 1;
if (!isNaN(fit) && isFinite(fit)) applyZoom(fit);
};
const onHeightInput = () => {
if (keepAspect.checked) {
widthSlider.value = Math.round(parseInt(heightSlider.value, 10) * aspectRatio);
}
_updateResizePreview();
const curW = parseInt(widthSlider.value, 10);
const curH = parseInt(heightSlider.value, 10);
state.resizeSettings = {
baseWidth: width,
baseHeight: height,
width: curW,
height: curH,
};
saveBotSettings();
// Auto-fit after size changes
const fit = typeof computeFitZoom === 'function' ? computeFitZoom() : 1;
if (!isNaN(fit) && isFinite(fit)) applyZoom(fit);
};
paintWhiteToggle.onchange = (e) => {
state.paintWhitePixels = e.target.checked;
_updateResizePreview();
saveBotSettings();
};
paintTransparentToggle.onchange = (e) => {
state.paintTransparentPixels = e.target.checked;
_updateResizePreview();
saveBotSettings();
};
let panX = 0,
panY = 0;
const clampPan = () => {
const wrapRect = panStage?.getBoundingClientRect() || {
width: 0,
height: 0,
};
const w = (baseCanvas.width || 1) * _zoomLevel;
const h = (baseCanvas.height || 1) * _zoomLevel;
if (w <= wrapRect.width) {
panX = Math.floor((wrapRect.width - w) / 2);
} else {
const minX = wrapRect.width - w;
panX = Math.min(0, Math.max(minX, panX));
}
if (h <= wrapRect.height) {
panY = Math.floor((wrapRect.height - h) / 2);
} else {
const minY = wrapRect.height - h;
panY = Math.min(0, Math.max(minY, panY));
}
};
let _panRaf = 0;
const applyPan = () => {
if (_panRaf) return;
_panRaf = requestAnimationFrame(() => {
clampPan();
canvasStack.style.transform = `translate3d(${Math.round(
panX
)}px, ${Math.round(panY)}px, 0) scale(${_zoomLevel})`;
_panRaf = 0;
});
};
const updateZoomLayout = () => {
const w = baseCanvas.width || 1,
h = baseCanvas.height || 1;
baseCanvas.style.width = w + 'px';
baseCanvas.style.height = h + 'px';
maskCanvas.style.width = w + 'px';
maskCanvas.style.height = h + 'px';
canvasStack.style.width = w + 'px';
canvasStack.style.height = h + 'px';
applyPan();
};
const applyZoom = (z) => {
_zoomLevel = Math.max(0.05, Math.min(20, z || 1));
zoomSlider.value = _zoomLevel;
updateZoomLayout();
if (zoomValue) zoomValue.textContent = `${Math.round(_zoomLevel * 100)}%`;
};
zoomSlider.addEventListener('input', () => {
applyZoom(parseFloat(zoomSlider.value));
});
if (zoomInBtn)
zoomInBtn.addEventListener('click', () => applyZoom(parseFloat(zoomSlider.value) + 0.1));
if (zoomOutBtn)
zoomOutBtn.addEventListener('click', () => applyZoom(parseFloat(zoomSlider.value) - 0.1));
const computeFitZoom = () => {
const wrapRect = panStage?.getBoundingClientRect();
if (!wrapRect) return 1;
const w = baseCanvas.width || 1;
const h = baseCanvas.height || 1;
const margin = 10;
const scaleX = (wrapRect.width - margin) / w;
const scaleY = (wrapRect.height - margin) / h;
return Math.max(0.05, Math.min(20, Math.min(scaleX, scaleY)));
};
if (zoomFitBtn)
zoomFitBtn.addEventListener('click', () => {
applyZoom(computeFitZoom());
centerInView();
});
if (zoomActualBtn)
zoomActualBtn.addEventListener('click', () => {
applyZoom(1);
centerInView();
});
const centerInView = () => {
if (!panStage) return;
const rect = panStage.getBoundingClientRect();
const w = (baseCanvas.width || 1) * _zoomLevel;
const h = (baseCanvas.height || 1) * _zoomLevel;
panX = Math.floor((rect.width - w) / 2);
panY = Math.floor((rect.height - h) / 2);
applyPan();
};
let isPanning = false;
let startX = 0,
startY = 0,
startPanX = 0,
startPanY = 0;
let allowPan = false; // Space key
let panMode = false; // Explicit pan mode toggle for touch/one-button mice
const isPanMouseButton = (e) => e.button === 1 || e.button === 2;
const setCursor = (val) => {
if (panStage) panStage.style.cursor = val;
};
const isPanActive = (e) => panMode || allowPan || isPanMouseButton(e);
const updatePanModeBtn = () => {
if (!panModeBtn) return;
panModeBtn.classList.toggle('active', panMode);
panModeBtn.setAttribute('aria-pressed', panMode ? 'true' : 'false');
};
if (panModeBtn) {
updatePanModeBtn();
panModeBtn.addEventListener('click', () => {
panMode = !panMode;
updatePanModeBtn();
setCursor(panMode ? 'grab' : '');
});
}
if (panStage) {
panStage.addEventListener('contextmenu', (e) => {
if (allowPan) e.preventDefault();
});
window.addEventListener('keydown', (e) => {
if (e.code === 'Space') {
allowPan = true;
setCursor('grab');
}
});
window.addEventListener('keyup', (e) => {
if (e.code === 'Space') {
allowPan = false;
if (!isPanning) setCursor('');
}
});
panStage.addEventListener('mousedown', (e) => {
if (!isPanActive(e)) return;
e.preventDefault();
isPanning = true;
startX = e.clientX;
startY = e.clientY;
startPanX = panX;
startPanY = panY;
setCursor('grabbing');
});
window.addEventListener('mousemove', (e) => {
if (!isPanning) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
panX = startPanX + dx;
panY = startPanY + dy;
applyPan();
});
window.addEventListener('mouseup', () => {
if (isPanning) {
isPanning = false;
setCursor(allowPan ? 'grab' : '');
}
});
panStage.addEventListener(
'wheel',
(e) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const rect = panStage.getBoundingClientRect();
const cx = e.clientX - rect.left - panX;
const cy = e.clientY - rect.top - panY;
const before = _zoomLevel;
const step = Math.max(0.05, Math.min(0.5, Math.abs(e.deltaY) > 20 ? 0.2 : 0.1));
const next = Math.max(0.05, Math.min(20, before + (e.deltaY > 0 ? -step : step)));
if (next === before) return;
const scale = next / before;
panX = panX - cx * (scale - 1);
panY = panY - cy * (scale - 1);
applyZoom(next);
},
{ passive: false }
);
let lastTouchDist = null;
let touchStartTime = 0;
let doubleTapTimer = null;
panStage.addEventListener(
'touchstart',
(e) => {
if (e.touches.length === 1) {
const t = e.touches[0];
isPanning = true;
startX = t.clientX;
startY = t.clientY;
startPanX = panX;
startPanY = panY;
setCursor('grabbing');
const now = Date.now();
if (now - touchStartTime < 300) {
// double tap -> toggle 100%/fit
const z = Math.abs(_zoomLevel - 1) < 0.01 ? computeFitZoom() : 1;
applyZoom(z);
centerInView();
if (doubleTapTimer) clearTimeout(doubleTapTimer);
} else {
touchStartTime = now;
doubleTapTimer = setTimeout(() => {
doubleTapTimer = null;
}, 320);
}
} else if (e.touches.length === 2) {
// Pinch start
const [a, b] = e.touches;
lastTouchDist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
}
},
{ passive: true }
);
panStage.addEventListener(
'touchmove',
(e) => {
if (e.touches.length === 1 && isPanning) {
const t = e.touches[0];
const dx = t.clientX - startX;
const dy = t.clientY - startY;
panX = startPanX + dx;
panY = startPanY + dy;
applyPan();
} else if (e.touches.length === 2 && lastTouchDist != null) {
e.preventDefault();
const [a, b] = e.touches;
const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
const rect = panStage.getBoundingClientRect();
const centerX = (a.clientX + b.clientX) / 2 - rect.left - panX;
const centerY = (a.clientY + b.clientY) / 2 - rect.top - panY;
const before = _zoomLevel;
const scale = dist / (lastTouchDist || dist);
const next = Math.max(0.05, Math.min(20, before * scale));
if (next !== before) {
panX = panX - centerX * (next / before - 1);
panY = panY - centerY * (next / before - 1);
applyZoom(next);
}
lastTouchDist = dist;
}
},
{ passive: false }
);
panStage.addEventListener('touchend', () => {
isPanning = false;
lastTouchDist = null;
setCursor(panMode || allowPan ? 'grab' : '');
});
}
const schedulePreview = () => {
if (_previewTimer) clearTimeout(_previewTimer);
const run = () => {
_previewTimer = null;
_updateResizePreview();
};
if (window.requestIdleCallback) {
_previewTimer = setTimeout(() => requestIdleCallback(run, { timeout: 150 }), 50);
} else {
_previewTimer = setTimeout(() => requestAnimationFrame(run), 50);
}
};
// Track dragging to reduce work and skip dithering during drag
const markDragStart = () => {
_isDraggingSize = true;
};
const markDragEnd = () => {
_isDraggingSize = false;
schedulePreview();
};
widthSlider.addEventListener('pointerdown', markDragStart);
heightSlider.addEventListener('pointerdown', markDragStart);
widthSlider.addEventListener('pointerup', markDragEnd);
heightSlider.addEventListener('pointerup', markDragEnd);
widthSlider.addEventListener('input', () => {
onWidthInput();
schedulePreview();
});
heightSlider.addEventListener('input', () => {
onHeightInput();
schedulePreview();
});
// Mask painting UX: brush size, modes, row/column fills, and precise coords
let draggingMask = false;
let lastPaintX = -1,
lastPaintY = -1;
let brushSize = 1;
let rowColSize = 1;
let maskMode = 'ignore'; // 'ignore' | 'unignore' | 'toggle'
const brushEl = resizeContainer.querySelector('#maskBrushSize');
const brushValEl = resizeContainer.querySelector('#maskBrushSizeValue');
const btnIgnore = resizeContainer.querySelector('#maskModeIgnore');
const btnUnignore = resizeContainer.querySelector('#maskModeUnignore');
const btnToggle = resizeContainer.querySelector('#maskModeToggle');
const clearIgnoredBtnEl = resizeContainer.querySelector('#clearIgnoredBtn');
const invertMaskBtn = resizeContainer.querySelector('#invertMaskBtn');
const rowColSizeEl = resizeContainer.querySelector('#rowColSize');
const rowColSizeValEl = resizeContainer.querySelector('#rowColSizeValue');
const updateModeButtons = () => {
const map = [
[btnIgnore, 'ignore'],
[btnUnignore, 'unignore'],
[btnToggle, 'toggle'],
];
for (const [el, m] of map) {
if (!el) continue;
const active = maskMode === m;
el.classList.toggle('active', active);
el.setAttribute('aria-pressed', active ? 'true' : 'false');
}
};
const setMode = (mode) => {
maskMode = mode;
updateModeButtons();
};
if (brushEl && brushValEl) {
brushEl.addEventListener('input', () => {
brushSize = parseInt(brushEl.value, 10) || 1;
brushValEl.textContent = brushSize;
});
brushValEl.textContent = brushEl.value;
brushSize = parseInt(brushEl.value, 10) || 1;
}
if (rowColSizeEl && rowColSizeValEl) {
rowColSizeEl.addEventListener('input', () => {
rowColSize = parseInt(rowColSizeEl.value, 10) || 1;
rowColSizeValEl.textContent = rowColSize;
});
rowColSizeValEl.textContent = rowColSizeEl.value;
rowColSize = parseInt(rowColSizeEl.value, 10) || 1;
}
if (btnIgnore) btnIgnore.addEventListener('click', () => setMode('ignore'));
if (btnUnignore) btnUnignore.addEventListener('click', () => setMode('unignore'));
if (btnToggle) btnToggle.addEventListener('click', () => setMode('toggle'));
// Initialize button state (default to toggle mode)
updateModeButtons();
const mapClientToPixel = (clientX, clientY) => {
// Compute without rounding until final step to avoid drift at higher zoom
const rect = baseCanvas.getBoundingClientRect();
const scaleX = rect.width / baseCanvas.width;
const scaleY = rect.height / baseCanvas.height;
const dx = (clientX - rect.left) / scaleX;
const dy = (clientY - rect.top) / scaleY;
const x = Math.floor(dx);
const y = Math.floor(dy);
return { x, y };
};
const ensureMask = (w, h) => {
if (!state.resizeIgnoreMask || state.resizeIgnoreMask.length !== w * h) {
state.resizeIgnoreMask = new Uint8Array(w * h);
}
};
const paintCircle = (cx, cy, radius, value) => {
const w = baseCanvas.width,
h = baseCanvas.height;
ensureMask(w, h);
const r2 = radius * radius;
for (let yy = cy - radius; yy <= cy + radius; yy++) {
if (yy < 0 || yy >= h) continue;
for (let xx = cx - radius; xx <= cx + radius; xx++) {
if (xx < 0 || xx >= w) continue;
const dx = xx - cx,
dy = yy - cy;
if (dx * dx + dy * dy <= r2) {
const idx = yy * w + xx;
let val = state.resizeIgnoreMask[idx];
if (maskMode === 'toggle') {
val = val ? 0 : 1;
} else if (maskMode === 'ignore') {
val = 1;
} else {
val = 0;
}
state.resizeIgnoreMask[idx] = val;
if (_maskData) {
const p = idx * 4;
if (val) {
_maskData[p] = 255;
_maskData[p + 1] = 0;
_maskData[p + 2] = 0;
_maskData[p + 3] = 150;
} else {
_maskData[p] = 0;
_maskData[p + 1] = 0;
_maskData[p + 2] = 0;
_maskData[p + 3] = 0;
}
_markDirty(xx, yy);
}
}
}
}
};
const paintRow = (y, value) => {
const w = baseCanvas.width,
h = baseCanvas.height;
ensureMask(w, h);
if (y < 0 || y >= h) return;
// Paint multiple rows based on rowColSize
const halfSize = Math.floor(rowColSize / 2);
const startY = Math.max(0, y - halfSize);
const endY = Math.min(h - 1, y + halfSize);
for (let rowY = startY; rowY <= endY; rowY++) {
for (let x = 0; x < w; x++) {
const idx = rowY * w + x;
let val = state.resizeIgnoreMask[idx];
if (maskMode === 'toggle') {
val = val ? 0 : 1;
} else if (maskMode === 'ignore') {
val = 1;
} else {
val = 0;
}
state.resizeIgnoreMask[idx] = val;
if (_maskData) {
const p = idx * 4;
if (val) {
_maskData[p] = 255;
_maskData[p + 1] = 0;
_maskData[p + 2] = 0;
_maskData[p + 3] = 150;
} else {
_maskData[p] = 0;
_maskData[p + 1] = 0;
_maskData[p + 2] = 0;
_maskData[p + 3] = 0;
}
}
}
if (_maskData) {
_markDirty(0, rowY);
_markDirty(w - 1, rowY);
}
}
};
const paintColumn = (x, value) => {
const w = baseCanvas.width,
h = baseCanvas.height;
ensureMask(w, h);
if (x < 0 || x >= w) return;
// Paint multiple columns based on rowColSize
const halfSize = Math.floor(rowColSize / 2);
const startX = Math.max(0, x - halfSize);
const endX = Math.min(w - 1, x + halfSize);
for (let colX = startX; colX <= endX; colX++) {
for (let y = 0; y < h; y++) {
const idx = y * w + colX;
let val = state.resizeIgnoreMask[idx];
if (maskMode === 'toggle') {
val = val ? 0 : 1;
} else if (maskMode === 'ignore') {
val = 1;
} else {
val = 0;
}
state.resizeIgnoreMask[idx] = val;
if (_maskData) {
const p = idx * 4;
if (val) {
_maskData[p] = 255;
_maskData[p + 1] = 0;
_maskData[p + 2] = 0;
_maskData[p + 3] = 150;
} else {
_maskData[p] = 0;
_maskData[p + 1] = 0;
_maskData[p + 2] = 0;
_maskData[p + 3] = 0;
}
}
}
if (_maskData) {
_markDirty(colX, 0);
_markDirty(colX, h - 1);
}
}
};
const redrawMaskOverlay = () => {
// Only flush the dirty region; full rebuild happens on size change
_flushDirty();
};
const handlePaint = (e) => {
// Suppress painting while panning
if ((e.buttons & 4) === 4 || (e.buttons & 2) === 2 || allowPan) return;
const { x, y } = mapClientToPixel(e.clientX, e.clientY);
const w = baseCanvas.width,
h = baseCanvas.height;
if (x < 0 || y < 0 || x >= w || y >= h) return;
const radius = Math.max(1, Math.floor(brushSize / 2));
if (e.shiftKey) {
paintRow(y);
} else if (e.altKey) {
paintColumn(x);
} else {
paintCircle(x, y, radius);
}
lastPaintX = x;
lastPaintY = y;
redrawMaskOverlay();
};
maskCanvas.addEventListener('mousedown', (e) => {
if (e.button === 1 || e.button === 2 || allowPan) return; // let pan handler manage
draggingMask = true;
handlePaint(e);
});
// Avoid hijacking touch gestures for panning/zooming
maskCanvas.addEventListener(
'touchstart',
(e) => {
/* let panStage handle */
},
{ passive: true }
);
maskCanvas.addEventListener(
'touchmove',
(e) => {
/* let panStage handle */
},
{ passive: true }
);
maskCanvas.addEventListener(
'touchend',
(e) => {
/* let panStage handle */
},
{ passive: true }
);
window.addEventListener('mousemove', (e) => {
if (draggingMask) handlePaint(e);
});
window.addEventListener('mouseup', () => {
if (draggingMask) {
draggingMask = false;
saveBotSettings();
}
});
if (clearIgnoredBtnEl)
clearIgnoredBtnEl.addEventListener('click', () => {
const w = baseCanvas.width,
h = baseCanvas.height;
if (state.resizeIgnoreMask) state.resizeIgnoreMask.fill(0);
_ensureMaskOverlayBuffers(w, h, true);
_updateResizePreview();
saveBotSettings();
});
if (invertMaskBtn)
invertMaskBtn.addEventListener('click', () => {
if (!state.resizeIgnoreMask) return;
for (let i = 0; i < state.resizeIgnoreMask.length; i++)
state.resizeIgnoreMask[i] = state.resizeIgnoreMask[i] ? 0 : 1;
const w = baseCanvas.width,
h = baseCanvas.height;
_ensureMaskOverlayBuffers(w, h, true);
_updateResizePreview();
saveBotSettings();
});
confirmResize.onclick = async () => {
const newWidth = parseInt(widthSlider.value, 10);
const newHeight = parseInt(heightSlider.value, 10);
// Generate the final paletted image data
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
tempCanvas.width = newWidth;
tempCanvas.height = newHeight;
tempCtx.imageSmoothingEnabled = false;
if (baseProcessor !== processor && (!baseProcessor.img || !baseProcessor.canvas)) {
await baseProcessor.load();
}
tempCtx.drawImage(baseProcessor.img, 0, 0, newWidth, newHeight);
const imgData = tempCtx.getImageData(0, 0, newWidth, newHeight);
const data = imgData.data;
const tThresh2 = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
let totalValidPixels = 0;
const mask =
state.resizeIgnoreMask && state.resizeIgnoreMask.length === newWidth * newHeight
? state.resizeIgnoreMask
: null;
const applyFSDitherFinal = async () => {
const w = newWidth,
h = newHeight;
const n = w * h;
const { work, eligible } = ensureDitherBuffers(n);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y * w + x;
const i4 = idx * 4;
const r = data[i4],
g = data[i4 + 1],
b = data[i4 + 2],
a = data[i4 + 3];
const masked = mask && mask[idx];
const isEligible =
!masked &&
(state.paintTransparentPixels || a >= tThresh2) &&
(state.paintWhitePixels || !Utils.isWhitePixel(r, g, b));
eligible[idx] = isEligible ? 1 : 0;
work[idx * 3] = r;
work[idx * 3 + 1] = g;
work[idx * 3 + 2] = b;
if (!isEligible) {
data[i4 + 3] = 0;
}
}
// Yield to keep UI responsive
if ((y & 15) === 0) await Promise.resolve();
}
const diffuse = (nx, ny, er, eg, eb, factor) => {
if (nx < 0 || nx >= w || ny < 0 || ny >= h) return;
const nidx = ny * w + nx;
if (!eligible[nidx]) return;
const base = nidx * 3;
work[base] = Math.min(255, Math.max(0, work[base] + er * factor));
work[base + 1] = Math.min(255, Math.max(0, work[base + 1] + eg * factor));
work[base + 2] = Math.min(255, Math.max(0, work[base + 2] + eb * factor));
};
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y * w + x;
if (!eligible[idx]) continue;
const base = idx * 3;
const r0 = work[base],
g0 = work[base + 1],
b0 = work[base + 2];
const [nr, ng, nb] = Utils.findClosestPaletteColor(
r0,
g0,
b0,
state.activeColorPalette
);
const i4 = idx * 4;
data[i4] = nr;
data[i4 + 1] = ng;
data[i4 + 2] = nb;
data[i4 + 3] = 255;
totalValidPixels++;
const er = r0 - nr;
const eg = g0 - ng;
const eb = b0 - nb;
diffuse(x + 1, y, er, eg, eb, 7 / 16);
diffuse(x - 1, y + 1, er, eg, eb, 3 / 16);
diffuse(x, y + 1, er, eg, eb, 5 / 16);
diffuse(x + 1, y + 1, er, eg, eb, 1 / 16);
}
// Yield every row to reduce jank
await Promise.resolve();
}
};
if (state.ditheringEnabled) {
await applyFSDitherFinal();
} else {
for (let i = 0; i < data.length; i += 4) {
const r = data[i],
g = data[i + 1],
b = data[i + 2],
a = data[i + 3];
const masked = mask && mask[i >> 2];
const isTransparent = (!state.paintTransparentPixels && a < tThresh2) || masked;
const isWhiteAndSkipped = !state.paintWhitePixels && Utils.isWhitePixel(r, g, b);
if (isTransparent || isWhiteAndSkipped) {
data[i + 3] = 0; // overlay transparency
continue;
}
totalValidPixels++;
const [nr, ng, nb] = Utils.findClosestPaletteColor(r, g, b, state.activeColorPalette);
data[i] = nr;
data[i + 1] = ng;
data[i + 2] = nb;
data[i + 3] = 255;
}
}
tempCtx.putImageData(imgData, 0, 0);
// Save the final pixel data for painting
// Persist the paletted (and possibly dithered) pixels so painting uses the same output seen in overlay
const palettedPixels = new Uint8ClampedArray(imgData.data);
state.imageData.pixels = palettedPixels;
state.imageData.width = newWidth;
state.imageData.height = newHeight;
state.imageData.totalPixels = totalValidPixels;
state.totalPixels = totalValidPixels;
state.paintedPixels = 0;
state.resizeSettings = {
baseWidth: width,
baseHeight: height,
width: newWidth,
height: newHeight,
};
saveBotSettings();
const finalImageBitmap = await createImageBitmap(tempCanvas);
await overlayManager.setImage(finalImageBitmap);
overlayManager.enable();
toggleOverlayBtn.classList.add('active');
toggleOverlayBtn.setAttribute('aria-pressed', 'true');
// Keep state.imageData.processor as the original-based source; painting uses paletted pixels already stored
await updateStats();
updateUI('resizeSuccess', 'success', {
width: newWidth,
height: newHeight,
});
closeResizeDialog();
};
downloadPreviewBtn.onclick = () => {
try {
const w = baseCanvas.width,
h = baseCanvas.height;
const out = document.createElement('canvas');
out.width = w;
out.height = h;
const octx = out.getContext('2d');
octx.imageSmoothingEnabled = false;
octx.drawImage(baseCanvas, 0, 0);
octx.drawImage(maskCanvas, 0, 0);
const link = document.createElement('a');
link.download = 'wplace-preview.png';
link.href = out.toDataURL();
link.click();
} catch (e) {
console.warn('Failed to download preview:', e);
}
};
cancelResize.onclick = closeResizeDialog;
resizeOverlay.style.display = 'block';
resizeContainer.style.display = 'block';
// Reinitialize color palette with current available colors
initializeColorPalette(resizeContainer);
_updateResizePreview();
_resizeDialogCleanup = () => {
try {
zoomSlider.replaceWith(zoomSlider.cloneNode(true));
} catch { }
try {
if (zoomInBtn) zoomInBtn.replaceWith(zoomInBtn.cloneNode(true));
} catch { }
try {
if (zoomOutBtn) zoomOutBtn.replaceWith(zoomOutBtn.cloneNode(true));
} catch { }
};
setTimeout(() => {
if (typeof computeFitZoom === 'function') {
const z = computeFitZoom();
if (!isNaN(z) && isFinite(z)) {
applyZoom(z);
centerInView();
}
} else {
centerInView();
}
}, 0);
}
function closeResizeDialog() {
try {
if (typeof _resizeDialogCleanup === 'function') {
_resizeDialogCleanup();
}
} catch { }
resizeOverlay.style.display = 'none';
resizeContainer.style.display = 'none';
_updateResizePreview = () => { };
try {
if (typeof cancelAnimationFrame === 'function' && _panRaf) {
cancelAnimationFrame(_panRaf);
}
} catch { }
try {
if (_previewTimer) {
clearTimeout(_previewTimer);
_previewTimer = null;
}
} catch { }
_maskImageData = null;
_maskData = null;
_dirty = null;
_ditherWorkBuf = null;
_ditherEligibleBuf = null;
_resizeDialogCleanup = null;
}
if (uploadBtn) {
uploadBtn.addEventListener('click', async () => {
const availableColors = Utils.extractAvailableColors();
if (availableColors === null || availableColors.length < 10) {
updateUI('noColorsFound', 'error');
Utils.showAlert(Utils.t('noColorsFound'), 'error');
return;
}
if (!state.colorsChecked) {
state.availableColors = availableColors;
state.colorsChecked = true;
updateUI('colorsFound', 'success', { count: availableColors.length });
await updateStats();
selectPosBtn.disabled = false;
// Only enable resize button if image is also loaded
if (state.imageLoaded) {
resizeBtn.disabled = false;
}
}
try {
updateUI('loadingImage', 'default');
const imageSrc = await Utils.createImageUploader();
if (!imageSrc) {
updateUI('colorsFound', 'success', {
count: state.availableColors.length,
});
return;
}
const processor = new ImageProcessor(imageSrc);
await processor.load();
const { width, height } = processor.getDimensions();
const pixels = processor.getPixelData();
let totalValidPixels = 0;
for (let i = 0; i < pixels.length; i += 4) {
const isTransparent =
!state.paintTransparentPixels &&
pixels[i + 3] < (state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD);
const isWhiteAndSkipped =
!state.paintWhitePixels &&
Utils.isWhitePixel(pixels[i], pixels[i + 1], pixels[i + 2]);
if (!isTransparent && !isWhiteAndSkipped) {
totalValidPixels++;
}
}
state.imageData = {
width,
height,
pixels,
totalPixels: totalValidPixels,
processor,
};
state.totalPixels = totalValidPixels;
state.paintedPixels = 0;
state.imageLoaded = true;
// Keep existing lastPosition to continue from where we left off
// state.lastPosition = { x: 0, y: 0 }; // REMOVED: Don't reset position
// Initialize painted map for tracking
Utils.initializePaintedMap(width, height);
// New image: clear previous resize settings
state.resizeSettings = null;
// Also clear any previous ignore mask
state.resizeIgnoreMask = null;
// Save original image for this browser (dataUrl + dims)
state.originalImage = { dataUrl: imageSrc, width, height };
saveBotSettings();
// Use the original image for the overlay initially
const imageBitmap = await createImageBitmap(processor.img);
await overlayManager.setImage(imageBitmap);
overlayManager.enable();
toggleOverlayBtn.disabled = false;
toggleOverlayBtn.classList.add('active');
toggleOverlayBtn.setAttribute('aria-pressed', 'true');
// Only enable resize button if colors have also been captured
if (state.colorsChecked) {
resizeBtn.disabled = false;
}
saveBtn.disabled = false;
if (state.startPosition) {
startBtn.disabled = false;
}
await updateStats();
updateDataButtons();
updateUI('imageLoaded', 'success', { count: totalValidPixels });
} catch {
updateUI('imageError', 'error');
}
});
}
// Load Extracted button event listener - for loading Art-Extractor JSON files
const loadExtractedBtn = document.getElementById('loadExtractedBtn');
if (loadExtractedBtn) {
loadExtractedBtn.addEventListener('click', async () => {
try {
updateUI('loadingImage', 'default');
const fileData = await Utils.loadExtractedFileData();
if (!fileData) {
updateUI('ready', 'default');
return;
}
console.log('๐ Loading extracted artwork from Art-Extractor...');
console.log('๐ [DEBUG] Loaded file data structure:', {
hasState: !!fileData.state,
hasImageData: !!fileData.imageData,
topLevelKeys: Object.keys(fileData),
stateKeys: fileData.state ? Object.keys(fileData.state) : 'N/A',
imageDataKeys: fileData.imageData ? Object.keys(fileData.imageData) : 'N/A',
fileDataType: typeof fileData,
stateType: typeof fileData.state,
imageDataType: typeof fileData.imageData
});
// Validate the data structure before restoring
if (!fileData || typeof fileData !== 'object') {
throw new Error('Invalid file format: File data is not a valid object');
}
if (!fileData.state || typeof fileData.state !== 'object') {
console.error('โ State validation failed. FileData:', fileData);
throw new Error('Invalid file format: Missing or invalid state object. Please ensure you exported from Art-Extractor correctly.');
}
if (!fileData.imageData || typeof fileData.imageData !== 'object') {
console.error('โ ImageData validation failed. FileData:', fileData);
throw new Error('Invalid file format: Missing or invalid imageData object. Please ensure you completed the area scan in Art-Extractor.');
}
if (!fileData.imageData.pixels || !Array.isArray(fileData.imageData.pixels) || fileData.imageData.pixels.length === 0) {
console.error('โ Pixel data validation failed. ImageData:', fileData.imageData);
throw new Error('Invalid file format: No pixel data found. Please scan an area in Art-Extractor before exporting.');
}
// Ensure critical fields are present for Art-Extractor compatibility
if (!fileData.state.availableColors) {
console.warn('โ ๏ธ No availableColors in file, using defaults');
fileData.state.availableColors = [];
}
// Use the existing restoreProgress function but with special handling
const restoreSuccess = await Utils.restoreProgress(fileData);
if (!restoreSuccess) {
throw new Error('Failed to restore progress data');
}
// After loading, we need to enter position selection mode like when uploading images
if (state.imageLoaded) {
console.log('๐ฏ Extracted artwork loaded, entering position selection mode...');
// Clear position data since Art-Extractor exports may have null positions for manual placement
state.startPosition = null;
state.region = null;
state.selectingPosition = true;
// For extracted files, force enable all necessary flags for full functionality
state.colorsChecked = true;
state.imageLoaded = true;
// Ensure image processor is available for extracted files
if (state.imageData && !state.imageData.processor) {
try {
// Create a temporary canvas to generate a data URL for the processor
const canvas = document.createElement('canvas');
canvas.width = state.imageData.width;
canvas.height = state.imageData.height;
const ctx = canvas.getContext('2d');
// Create image data from pixels
const imageData = new ImageData(
new Uint8ClampedArray(state.imageData.pixels),
state.imageData.width,
state.imageData.height
);
ctx.putImageData(imageData, 0, 0);
// Create processor with canvas data URL using the correct class
const dataUrl = canvas.toDataURL();
state.imageData.processor = new window.WPlaceImageProcessor(dataUrl);
// CRITICAL: Load the processor before using it
await state.imageData.processor.load();
console.log('๐ง Created and loaded WPlaceImageProcessor for extracted artwork');
} catch (error) {
console.error('โ Failed to create image processor for extracted artwork:', error);
// Fallback: use global processor if available
if (window.globalImageProcessor) {
console.log('๐ Using global image processor as fallback');
state.imageData.processor = window.globalImageProcessor;
}
}
}
console.log('๐จ Force-enabled all flags for extracted JSON to access resize panel');
// For extracted images, ensure overlay is enabled but don't set position yet
try {
if (overlayManager && state.imageData) {
// Create image bitmap from the restored image data
const canvas = new OffscreenCanvas(state.imageData.width, state.imageData.height);
const ctx = canvas.getContext('2d');
const imageData = new ImageData(
new Uint8ClampedArray(state.imageData.pixels),
state.imageData.width,
state.imageData.height
);
ctx.putImageData(imageData, 0, 0);
const imageBitmap = await canvas.transferToImageBitmap();
await overlayManager.setImage(imageBitmap);
overlayManager.enable();
console.log('โ Overlay enabled for extracted artwork');
}
} catch (overlayError) {
console.warn('โ ๏ธ Could not set overlay for extracted artwork:', overlayError);
}
// For extracted images, don't try to set overlay position until user selects one
// The overlay should already be enabled from the restoreProgress function
// Update UI with custom message for extracted artwork
Utils.showAlert('๐จ Extracted artwork loaded! Please click on the canvas to select where to place it.', 'info');
updateUI('ready', 'default'); // Use 'ready' instead of 'waitingPosition' to avoid translation issues
// Enable relevant buttons
selectPosBtn.disabled = false;
// Always enable resize button for extracted files since they have image processor
resizeBtn.disabled = false;
console.log('๐ง Resize button enabled for extracted artwork');
// Enable move artwork button (will be fully enabled after position is set)
const moveArtworkBtn = document.getElementById('moveArtworkBtn');
if (moveArtworkBtn) {
moveArtworkBtn.disabled = false;
}
// Set up position selection like the regular selectPosBtn
const tempFetch = async (url, options) => {
if (
typeof url === 'string' &&
url.includes('/s0/pixel/') &&
options &&
options.method === 'POST'
) {
let coords;
try {
const body = JSON.parse(options.body);
coords = body.coords;
} catch (e) {
return window.originalFetch(url, options);
}
const tileMatches = url.match(/\/s0\/pixel\/(\-?\d+)\/(\-?\d+)/);
if (tileMatches && coords && coords.length >= 2) {
const tileX = parseInt(tileMatches[1]);
const tileY = parseInt(tileMatches[2]);
const pixelX = coords[0];
const pixelY = coords[1];
state.startPosition = { x: pixelX, y: pixelY };
state.region = { x: tileX, y: tileY };
state.selectingPosition = false;
console.log('๐ฏ Position selected for extracted artwork:', {
startPosition: state.startPosition,
region: state.region
});
// Restore original fetch
window.fetch = window.originalFetch;
// Update overlay position with validation
try {
if (state.startPosition && state.region && overlayManager) {
await overlayManager.setPosition(state.startPosition, state.region);
console.log('โ Overlay position updated successfully');
} else {
console.warn('โ ๏ธ Cannot set overlay position: missing startPosition or region');
}
} catch (positionError) {
console.error('โ Failed to set overlay position:', positionError);
}
startBtn.disabled = false;
selectPosBtn.textContent = Utils.t('selectPosition');
// Enable Move Artwork button when position is set
const moveArtworkBtn = document.getElementById('moveArtworkBtn');
if (moveArtworkBtn) {
moveArtworkBtn.disabled = false;
}
updateUI('ready', 'success');
Utils.showAlert('Position selected! Ready to start painting.', 'success');
}
}
return window.originalFetch(url, options);
};
// Store original fetch and replace with position capture
if (!window.originalFetch) {
window.originalFetch = window.fetch;
}
window.fetch = tempFetch;
}
} catch (error) {
console.error('โ Failed to load extracted artwork:', error);
Utils.showAlert(`Failed to load extracted artwork: ${error.message}`, 'error');
updateUI('ready', 'default');
}
});
}
if (resizeBtn) {
resizeBtn.addEventListener('click', () => {
console.log('๐ [DEBUG] Resize button clicked. State check:', {
imageLoaded: state.imageLoaded,
hasProcessor: !!(state.imageData && state.imageData.processor),
colorsChecked: state.colorsChecked,
hasAvailableColors: !!(state.availableColors && state.availableColors.length > 0)
});
if (state.imageLoaded && state.imageData && state.imageData.processor) {
showResizeDialog(state.imageData.processor);
} else {
let message = 'Please upload an image and check colors first.';
if (!state.imageLoaded) message = 'Please upload an image first.';
else if (!state.imageData || !state.imageData.processor) message = 'Image processor not available. Please reload the image.';
Utils.showAlert(message, 'warning');
}
});
}
// Move Artwork button event listener
const moveArtworkBtn = document.getElementById('moveArtworkBtn');
if (moveArtworkBtn) {
moveArtworkBtn.addEventListener('click', () => {
if (state.imageLoaded && (state.startPosition || state.selectingPosition)) {
showMoveArtworkPanel();
} else if (!state.imageLoaded) {
Utils.showAlert('Please upload or load an image first', 'warning');
} else {
Utils.showAlert('Please select a position for the artwork first', 'warning');
}
});
}
if (selectPosBtn) {
selectPosBtn.addEventListener('click', async () => {
if (state.selectingPosition) return;
state.selectingPosition = true;
state.startPosition = null;
state.region = null;
startBtn.disabled = true;
// Disable Move Artwork button when selecting new position
const moveArtworkBtn = document.getElementById('moveArtworkBtn');
if (moveArtworkBtn) {
moveArtworkBtn.disabled = true;
}
Utils.showAlert(Utils.t('selectPositionAlert'), 'info');
updateUI('waitingPosition', 'default');
const tempFetch = async (url, options) => {
if (
typeof url === 'string' &&
url.includes('https://backend.wplace.live/s0/pixel/') &&
options?.method?.toUpperCase() === 'POST'
) {
try {
const response = await originalFetch(url, options);
const clonedResponse = response.clone();
const data = await clonedResponse.json();
if (data?.painted === 1) {
const regionMatch = url.match(/\/pixel\/(\d+)\/(\d+)/);
if (regionMatch && regionMatch.length >= 3) {
state.region = {
x: Number.parseInt(regionMatch[1]),
y: Number.parseInt(regionMatch[2]),
};
}
const payload = JSON.parse(options.body);
if (payload?.coords && Array.isArray(payload.coords)) {
state.startPosition = {
x: payload.coords[0],
y: payload.coords[1],
};
// Keep existing lastPosition to continue from where we left off
// state.lastPosition = { x: 0, y: 0 }; // REMOVED: Don't reset position
// Update overlay position with validation
try {
if (state.startPosition && state.region && overlayManager) {
await overlayManager.setPosition(state.startPosition, state.region);
console.log('โ Regular overlay position updated successfully');
} else {
console.warn('โ ๏ธ Cannot set regular overlay position: missing startPosition or region');
}
} catch (positionError) {
console.error('โ Failed to set regular overlay position:', positionError);
}
if (state.imageLoaded) {
startBtn.disabled = false;
// Enable Move Artwork button when position is set
const moveArtworkBtn = document.getElementById('moveArtworkBtn');
if (moveArtworkBtn) {
moveArtworkBtn.disabled = false;
}
}
window.fetch = originalFetch;
state.selectingPosition = false;
updateUI('positionSet', 'success');
}
}
return response;
} catch {
return originalFetch(url, options);
}
}
return originalFetch(url, options);
};
const originalFetch = window.fetch;
window.fetch = tempFetch;
setTimeout(() => {
if (state.selectingPosition) {
window.fetch = originalFetch;
state.selectingPosition = false;
updateUI('positionTimeout', 'error');
Utils.showAlert(Utils.t('positionTimeout'), 'error');
}
}, 120000);
});
}
async function startPainting() {
if (!state.imageLoaded || !state.startPosition || !state.region) {
updateUI('missingRequirements', 'error');
return;
}
await ensureToken();
if (!getTurnstileToken()) return;
// Only reset painted pixels on first start of session (when pre-filtering hasn't been done)
if (!state.preFilteringDone) {
const savedPaintedPixels = state.paintedPixels; // Store original value
state.paintedPixels = 0;
console.log(`๐ First start this session - reset progress counter for accurate tracking (was: ${savedPaintedPixels})`);
await updateStats(); // Update UI to show 0 progress
} else {
console.log('๐ Continuing session - pre-filtering already done, keeping current progress');
}
state.running = true;
state.stopFlag = false;
startBtn.disabled = true;
stopBtn.disabled = false;
uploadBtn.disabled = true;
selectPosBtn.disabled = true;
resizeBtn.disabled = true;
saveBtn.disabled = true;
toggleOverlayBtn.disabled = true;
updateUI('startPaintingMsg', 'success');
try {
await getAccounts();
await processImage();
} catch (e) {
console.error('Unexpected error:', e);
updateUI('paintingError', 'error');
} finally {
state.running = false;
stopBtn.disabled = true;
saveBtn.disabled = false;
if (state.stopFlag) {
startBtn.disabled = false;
} else {
startBtn.disabled = true;
uploadBtn.disabled = false;
const loadExtractedBtn = document.getElementById('loadExtractedBtn');
if (loadExtractedBtn) loadExtractedBtn.disabled = false;
selectPosBtn.disabled = false;
resizeBtn.disabled = false;
}
toggleOverlayBtn.disabled = false;
}
}
if (startBtn) {
startBtn.addEventListener('click', startPainting);
}
if (stopBtn) {
stopBtn.addEventListener('click', () => {
state.stopFlag = true;
state.running = false;
stopBtn.disabled = true;
updateUI('paintingStoppedByUser', 'warning');
if (state.imageLoaded && state.paintedPixels > 0) {
Utils.saveProgress();
Utils.showAlert(Utils.t('autoSaved'), 'success');
}
});
}
const checkSavedProgress = () => {
const savedData = Utils.loadProgress();
if (savedData && savedData.state.paintedPixels > 0) {
const savedDate = new Date(savedData.timestamp).toLocaleString();
const progress = Math.round(
(savedData.state.paintedPixels / savedData.state.totalPixels) * 100
);
Utils.showAlert(
`${Utils.t('savedDataFound')}\n\n` +
`Saved: ${savedDate}\n` +
`Progress: ${savedData.state.paintedPixels}/${savedData.state.totalPixels} pixels (${progress}%)\n` +
`${Utils.t('clickLoadToContinue')}`,
'info'
);
}
};
setTimeout(checkSavedProgress, 1000);
if (cooldownSlider && cooldownInput && cooldownValue && cooldownDecrease && cooldownIncrease) {
const updateCooldown = (newValue) => {
const threshold = Math.max(1, Math.min(state.maxCharges || 999, parseInt(newValue)));
state.cooldownChargeThreshold = threshold;
// Update both controls (value shows in input, label shows unit only)
cooldownSlider.value = threshold;
cooldownInput.value = threshold;
cooldownValue.textContent = `${Utils.t('charges')}`;
saveBotSettings();
NotificationManager.resetEdgeTracking(); // prevent spurious notify after threshold change
};
// Slider event listener
cooldownSlider.addEventListener('input', (e) => {
updateCooldown(e.target.value);
});
// Number input event listener
cooldownInput.addEventListener('input', (e) => {
updateCooldown(e.target.value);
});
// Decrease button
cooldownDecrease.addEventListener('click', () => {
updateCooldown(parseInt(cooldownInput.value) - 1);
});
// Increase button
cooldownIncrease.addEventListener('click', () => {
updateCooldown(parseInt(cooldownInput.value) + 1);
});
// Add scroll-to-adjust for cooldown slider
Utils.createScrollToAdjust(cooldownSlider, updateCooldown, 1, state.maxCharges, 1);
}
loadBotSettings();
// Ensure notification poller reflects current settings
NotificationManager.syncFromState();
}
function getMsToTargetCharges(current, target, cooldown, intervalMs = 0) {
const remainingCharges = target - current;
return Math.max(0, remainingCharges * cooldown - intervalMs);
}
function updateChargesThresholdUI(intervalMs) {
if (state.stopFlag) return;
const threshold = state.cooldownChargeThreshold;
const remainingMs = getMsToTargetCharges(
state.preciseCurrentCharges,
threshold,
state.cooldown,
intervalMs
);
const timeText = Utils.msToTimeText(remainingMs);
updateUI(
'noChargesThreshold',
'warning',
{
threshold,
current: state.displayCharges,
time: timeText,
},
true
);
}
function generateCoordinates(width, height, mode, direction, snake, blockWidth, blockHeight, startFromX = 0, startFromY = 0) {
const coords = [];
console.log(
'Generating coordinates with \n mode:',
mode,
'\n direction:',
direction,
'\n snake:',
snake,
'\n blockWidth:',
blockWidth,
'\n blockHeight:',
blockHeight,
'\n startFromX:',
startFromX,
'\n startFromY:',
startFromY
);
// --------- Standard 4 corners traversal ----------
let xStart, xEnd, xStep;
let yStart, yEnd, yStep;
switch (direction) {
case 'top-left':
xStart = 0;
xEnd = width;
xStep = 1;
yStart = 0;
yEnd = height;
yStep = 1;
break;
case 'top-right':
xStart = width - 1;
xEnd = -1;
xStep = -1;
yStart = 0;
yEnd = height;
yStep = 1;
break;
case 'bottom-left':
xStart = 0;
xEnd = width;
xStep = 1;
yStart = height - 1;
yEnd = -1;
yStep = -1;
break;
case 'bottom-right':
xStart = width - 1;
xEnd = -1;
xStep = -1;
yStart = height - 1;
yEnd = -1;
yStep = -1;
break;
default:
throw new Error(`Unknown direction: ${direction}`);
}
// --------- Traversal modes ----------
if (mode === 'rows') {
for (let y = yStart; y !== yEnd; y += yStep) {
if (snake && (y - yStart) % 2 !== 0) {
for (let x = xEnd - xStep; x !== xStart - xStep; x -= xStep) {
coords.push([x, y]);
}
} else {
for (let x = xStart; x !== xEnd; x += xStep) {
coords.push([x, y]);
}
}
}
} else if (mode === 'columns') {
for (let x = xStart; x !== xEnd; x += xStep) {
if (snake && (x - xStart) % 2 !== 0) {
for (let y = yEnd - yStep; y !== yStart - yStep; y -= yStep) {
coords.push([x, y]);
}
} else {
for (let y = yStart; y !== yEnd; y += yStep) {
coords.push([x, y]);
}
}
}
} else if (mode === 'circle-out') {
const cx = Math.floor(width / 2);
const cy = Math.floor(height / 2);
const maxRadius = Math.ceil(Math.sqrt(cx * cx + cy * cy));
for (let r = 0; r <= maxRadius; r++) {
for (let y = cy - r; y <= cy + r; y++) {
for (let x = cx - r; x <= cx + r; x++) {
if (x >= 0 && x < width && y >= 0 && y < height) {
const dist = Math.max(Math.abs(x - cx), Math.abs(y - cy));
if (dist === r) coords.push([x, y]);
}
}
}
}
} else if (mode === 'circle-in') {
const cx = Math.floor(width / 2);
const cy = Math.floor(height / 2);
const maxRadius = Math.ceil(Math.sqrt(cx * cx + cy * cy));
for (let r = maxRadius; r >= 0; r--) {
for (let y = cy - r; y <= cy + r; y++) {
for (let x = cx - r; x <= cx + r; x++) {
if (x >= 0 && x < width && y >= 0 && y < height) {
const dist = Math.max(Math.abs(x - cx), Math.abs(y - cy));
if (dist === r) coords.push([x, y]);
}
}
}
}
} else if (mode === 'blocks' || mode === 'shuffle-blocks') {
const blocks = [];
for (let by = 0; by < height; by += blockHeight) {
for (let bx = 0; bx < width; bx += blockWidth) {
const block = [];
for (let y = by; y < Math.min(by + blockHeight, height); y++) {
for (let x = bx; x < Math.min(bx + blockWidth, width); x++) {
block.push([x, y]);
}
}
blocks.push(block);
}
}
if (mode === 'shuffle-blocks') {
// Simple Fisher-Yates shuffle
for (let i = blocks.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[blocks[i], blocks[j]] = [blocks[j], blocks[i]];
}
}
// Concatenate all blocks
for (const block of blocks) {
coords.push(...block);
}
} else {
throw new Error(`Unknown mode: ${mode}`);
}
// Filter coordinates to start from the specified position
if (startFromX > 0 || startFromY > 0) {
console.log(`๐ Filtering coordinates to resume from position (${startFromX}, ${startFromY})`);
let startIndex = -1;
// Find the starting position in the coordinate list
for (let i = 0; i < coords.length; i++) {
const [x, y] = coords[i];
if (x === startFromX && y === startFromY) {
startIndex = i;
break;
}
}
if (startIndex >= 0) {
// Resume from the found position (skip all previous coordinates)
const filteredCoords = coords.slice(startIndex);
console.log(`โ๏ธ Resuming: skipped ${startIndex} coordinates, continuing with ${filteredCoords.length} remaining`);
return filteredCoords;
} else {
console.warn(`โ ๏ธ Resume position (${startFromX}, ${startFromY}) not found in coordinate list, starting from beginning`);
}
}
return coords;
}
async function flushPixelBatch(pixelBatch) {
if (!pixelBatch || pixelBatch.pixels.length === 0) return true;
const batchSize = pixelBatch.pixels.length;
console.log(
`๐ฆ Sending batch with ${batchSize} pixels (region: ${pixelBatch.regionX},${pixelBatch.regionY})`
);
const success = await sendBatchWithRetry(
pixelBatch.pixels,
pixelBatch.regionX,
pixelBatch.regionY
);
if (success) {
pixelBatch.pixels.forEach((p) => {
state.paintedPixels++;
Utils.markPixelPainted(p.x, p.y, pixelBatch.regionX, pixelBatch.regionY);
});
// IMPORTANT: Decrement charges locally to match Acc-Switch.js behavior
state.displayCharges = Math.max(0, state.displayCharges - batchSize);
state.preciseCurrentCharges = Math.max(0, state.preciseCurrentCharges - batchSize);
state.fullChargeData = {
...state.fullChargeData,
spentSinceShot: state.fullChargeData.spentSinceShot + batchSize,
};
await updateStats();
// Update account list with new charges
await updateCurrentAccountInList();
// Progress tracking removed from UI to reduce visual clutter
Utils.performSmartSave();
if (CONFIG.PAINTING_SPEED_ENABLED && state.paintingSpeed > 0 && batchSize > 0) {
const delayPerPixel = 1000 / state.paintingSpeed;
const totalDelay = Math.max(100, delayPerPixel * batchSize);
await Utils.sleep(totalDelay);
}
} else {
console.error(`โ Batch failed permanently after retries. Stopping painting.`);
state.stopFlag = true;
updateUI('paintingBatchFailed', 'error');
}
pixelBatch.pixels = [];
return success;
}
async function processImage() {
console.log('๐ Starting auto-swap enabled painting workflow');
try {
// Main painting cycle - repeats until image complete or stopped
while (!state.stopFlag) {
console.log('๐ Phase 1: Starting painting session');
const paintingResult = await executePaintingSession();
if (paintingResult === 'completed') {
console.log('๐ Image painting completed!');
break;
}
if (paintingResult === 'stopped') {
console.log('โน๏ธ Painting stopped by user');
break;
}
if (paintingResult === 'charges_depleted') {
if (!CONFIG.autoSwap) {
// Original workflow: cooldown period
console.log('โฑ๏ธ Phase 2: Entering cooldown period (auto-swap disabled)');
const cooldownResult = await executeCooldownPeriod();
if (cooldownResult === 'stopped') {
console.log('โน๏ธ Cooldown stopped by user');
break;
}
// Phase 3: Regenerate token for next painting session
console.log('๐ Phase 3: Regenerating token for next session');
const tokenResult = await regenerateTokenForNewSession();
if (!tokenResult) {
console.log('โ Failed to regenerate token, stopping');
state.stopFlag = true;
break;
}
} else {
// Auto-swap workflow: simplified logic
console.log('๐ Auto-swap enabled: checking account switching options');
// Retrieve fresh accounts list each time to avoid stale data
const accounts = JSON.parse(localStorage.getItem("accounts")) || [];
console.log(`๐ Retrieved ${accounts.length} accounts from localStorage`);
if (accounts.length <= 1) {
console.log('๐ Only one account available, using standard cooldown');
const cooldownResult = await executeCooldownPeriod();
if (cooldownResult === 'stopped') break;
const tokenResult = await regenerateTokenForNewSession();
if (!tokenResult) {
state.stopFlag = true;
break;
}
} else {
// Debug current state
console.log(`๐ Account Status - Current ID: ${state.originalAccountOrder[state.currentActiveIndex]?.orderId}, Total IDs: ${state.originalAccountOrder.length}`);
// Check if we're at the last ID in the sequence
const isLastAccount = state.currentActiveIndex >= state.originalAccountOrder.length - 1;
console.log(`๐ Is last ID? ${isLastAccount} (ID ${state.originalAccountOrder[state.currentActiveIndex]?.orderId} of ${state.originalAccountOrder.length})`);
if (!isLastAccount) {
// Switch to next ID immediately (no cooldown)
const nextId = state.originalAccountOrder[state.currentActiveIndex + 1]?.orderId;
console.log(`๐ Switching to next ID ${nextId} (${state.currentActiveIndex + 2}/${state.originalAccountOrder.length})`);
const switchResult = await switchToNextAccount(accounts);
if (!switchResult) {
console.log('โ Account switch failed, stopping');
state.stopFlag = true;
break;
}
// Continue painting with new account immediately
continue;
} else {
// Last account reached - use cooldown then switch to ID 1 (first account)
console.log('โฑ๏ธ Last account reached, entering cooldown period');
console.log(`๐ Current ID: ${state.originalAccountOrder[state.currentActiveIndex]?.orderId}, Last ID: ${state.originalAccountOrder[state.originalAccountOrder.length - 1]?.orderId}`);
const cooldownResult = await executeCooldownPeriod();
if (cooldownResult === 'stopped') break;
// After cooldown, switch to ID 1 (first account in order)
console.log('๐ Cooldown complete, switching to ID 1 (first account)');
console.log(`๐ Before switch - Current ID: ${state.originalAccountOrder[state.currentActiveIndex]?.orderId}, Target: ID 1`);
const firstAccountInfo = state.originalAccountOrder[0]; // ID 1 is always at index 0
const firstAccountToken = firstAccountInfo?.token;
if (!firstAccountToken) {
console.log('โ First account (ID 1) token not found, stopping');
state.stopFlag = true;
break;
}
// Reset to first position (ID 1)
state.currentActiveIndex = 0;
// Update accountIndex to match the position in accounts array
const accountsIndex = accounts.findIndex(token => token === firstAccountToken);
if (accountsIndex !== -1) {
state.accountIndex = accountsIndex;
}
const switchResult = await switchToSpecificAccount(firstAccountToken, 0);
if (!switchResult) {
console.log('โ Switch to ID 1 failed, stopping');
state.stopFlag = true;
break;
}
console.log(`โ Successfully switched to ID 1 (${firstAccountInfo.displayName}). Cycle restarted.`);
// Continue painting with first account
continue;
}
}
}
}
console.log('๐ Cycle complete, starting next painting session');
}
} finally {
await finalizePaintingProcess();
}
}
// Phase 1: Execute a complete painting session using all available charges
async function executePaintingSession() {
console.log('๐จ Starting painting session - using all charges until 0');
const { width, height, pixels } = state.imageData;
const { x: startX, y: startY } = state.startPosition;
const { x: regionX, y: regionY } = state.region;
// Check if we're working with restored data by looking for existing availableColors
const isRestoredData = state.availableColors && state.availableColors.length > 0 && state.colorsChecked;
if (!isRestoredData) {
// Wait for original tiles to load if needed
const tilesReady = await overlayManager.waitForTiles(
regionX,
regionY,
width,
height,
startX,
startY,
10000 // timeout 10s
);
if (!tilesReady) {
updateUI('overlayTilesNotLoaded', 'error');
state.stopFlag = true;
return 'stopped';
}
}
let pixelBatch = null;
let skippedPixels = {
transparent: 0,
white: 0,
alreadyPainted: 0,
colorUnavailable: 0,
};
// IMPORTANT: Check charges once at start, then paint until depleted
console.log('๐ Checking initial charges for painting session');
const initialChargeCheck = await WPlaceService.getCharges();
state.displayCharges = Math.floor(initialChargeCheck.charges);
state.preciseCurrentCharges = initialChargeCheck.charges;
state.cooldown = initialChargeCheck.cooldown;
await updateStats();
if (state.displayCharges <= 0) {
console.log('โก No charges available, skipping painting session');
return 'charges_depleted';
}
console.log(`๐ Starting with ${state.displayCharges} charges - painting until depleted`);
// Paint pixels until we run out of charges or complete the image
try {
const coords = generateCoordinates(
width,
height,
state.coordinateMode,
state.coordinateDirection,
state.coordinateSnake,
state.blockWidth,
state.blockHeight,
state.lastPosition.x,
state.lastPosition.y
);
// OPTIMIZATION: Pre-filter already painted pixels (happens only once per session)
let eligibleCoords = [];
let alreadyPaintedCount = 0;
// Log resume information if applicable
if (state.lastPosition.x > 0 || state.lastPosition.y > 0) {
console.log(`๐ Resuming painting from position (${state.lastPosition.x}, ${state.lastPosition.y})`);
console.log(`๐ Current progress: ${state.paintedPixels} pixels painted`);
}
if (!state.preFilteringDone) {
console.log('๐ Pre-filtering already painted pixels (one-time detection for this session)...');
for (const [x, y] of coords) {
const targetPixelInfo = checkPixelEligibility(x, y);
if (!targetPixelInfo.eligible) {
if (targetPixelInfo.reason !== 'alreadyPainted') {
skippedPixels[targetPixelInfo.reason]++;
}
continue;
}
// Check if already painted (only once per session)
let absX = startX + x;
let absY = startY + y;
let adderX = Math.floor(absX / 1000);
let adderY = Math.floor(absY / 1000);
let pixelX = absX % 1000;
let pixelY = absY % 1000;
try {
const tilePixelRGBA = await overlayManager.getTilePixelColor(
regionX + adderX,
regionY + adderY,
pixelX,
pixelY
);
if (tilePixelRGBA && Array.isArray(tilePixelRGBA)) {
const mappedCanvasColor = Utils.resolveColor(
tilePixelRGBA.slice(0, 3),
state.availableColors,
!state.paintUnavailablePixels // Use same parameter as target pixel
);
const isMatch = mappedCanvasColor.id === targetPixelInfo.mappedColorId;
if (isMatch) {
alreadyPaintedCount++;
// Add detected already-painted pixels to progress (after reset to 0)
state.paintedPixels++;
Utils.markPixelPainted(x, y, regionX + adderX, regionY + adderY);
continue; // Skip already painted pixels
}
}
} catch (e) {
// If we can't check, include the pixel (better to attempt than skip)
}
// Add eligible unpainted pixel to list
eligibleCoords.push([x, y, targetPixelInfo]);
}
// Mark pre-filtering as done for this session
state.preFilteringDone = true;
// Log pre-filtering results
if (alreadyPaintedCount > 0) {
console.log(`โ Pre-filter complete: ${alreadyPaintedCount} already painted pixels detected and added to progress`);
console.log('โน๏ธ This detection will not happen again until a new image/save is loaded');
// Update UI to reflect the new progress immediately
await updateStats();
}
skippedPixels.alreadyPainted = alreadyPaintedCount;
} else {
// Pre-filtering already done this session, just filter for basic eligibility
console.log('๐ Using existing pre-filter results (already done this session)');
for (const [x, y] of coords) {
const targetPixelInfo = checkPixelEligibility(x, y);
if (!targetPixelInfo.eligible) {
if (targetPixelInfo.reason !== 'alreadyPainted') {
skippedPixels[targetPixelInfo.reason]++;
}
continue;
}
// Only include pixels that haven't been marked as painted yet
let absX = startX + x;
let absY = startY + y;
let adderX = Math.floor(absX / 1000);
let adderY = Math.floor(absY / 1000);
if (!Utils.isPixelPainted(x, y, regionX + adderX, regionY + adderY)) {
eligibleCoords.push([x, y, targetPixelInfo]);
}
}
}
// Paint eligible pixels (already pre-filtered, no duplicate checks)
outerLoop: for (const [x, y, targetPixelInfo] of eligibleCoords) {
if (state.stopFlag) {
if (pixelBatch && pixelBatch.pixels.length > 0) {
console.log(`๐ฏ Sending last batch before stop with ${pixelBatch.pixels.length} pixels`);
await flushPixelBatch(pixelBatch);
}
state.lastPosition = { x, y };
// Removed 'Paused at' message from main panel to reduce UI clutter
return 'stopped';
}
// Check if we have charges left (local count, no API call)
if (state.displayCharges <= 0) {
// console.log("Try to buy paint charges");
// await purchase("paint_charges");
await updateStats();
if (state.displayCharges <= 0) {
console.log('โก No charges left (local count), ending painting session');
if (pixelBatch && pixelBatch.pixels.length > 0) {
console.log(`๐ฏ Sending final batch with ${pixelBatch.pixels.length} pixels`);
await flushPixelBatch(pixelBatch);
}
state.lastPosition = { x, y };
return 'charges_depleted';
}
// else {
// console.log(`๐ Charges after purchase: ${state.displayCharges}, continuing painting`);
// }
}
let absX = startX + x;
let absY = startY + y;
let adderX = Math.floor(absX / 1000);
let adderY = Math.floor(absY / 1000);
let pixelX = absX % 1000;
let pixelY = absY % 1000;
// CRITICAL FIX: Check if pixel is already painted (both locally and on canvas)
if (Utils.isPixelPainted(x, y, regionX + adderX, regionY + adderY)) {
console.log(`โญ๏ธ Skipping already painted pixel at (${x}, ${y}) - marked in local map`);
continue; // Skip already painted pixels
}
// REAL-TIME CANVAS CHECK: Like Acc-Switch, verify against actual canvas state
try {
const existingColorRGBA = await overlayManager.getTilePixelColor(
regionX + adderX,
regionY + adderY,
pixelX,
pixelY
).catch(() => null);
if (existingColorRGBA && Array.isArray(existingColorRGBA)) {
const [er, eg, eb] = existingColorRGBA;
const existingMappedColor = Utils.resolveColor(
[er, eg, eb],
state.availableColors,
!state.paintUnavailablePixels
);
const isAlreadyPainted = existingMappedColor.id === targetPixelInfo.mappedColorId;
if (isAlreadyPainted) {
console.log(`โญ๏ธ Skipping already painted pixel at (${x}, ${y}) - verified on canvas (existing: ${existingMappedColor.id}, target: ${targetPixelInfo.mappedColorId})`);
// Mark it as painted in local map to avoid future checks
Utils.markPixelPainted(x, y, regionX + adderX, regionY + adderY);
state.paintedPixels++;
continue; // Skip already painted pixels
}
}
} catch (e) {
// If we can't check the canvas, proceed with painting (better to attempt than skip)
console.warn(`โ ๏ธ Could not verify canvas state for pixel (${x}, ${y}), proceeding with paint:`, e.message);
}
const targetMappedColorId = targetPixelInfo.mappedColorId;
// Set up pixel batch for new region if needed
if (
!pixelBatch ||
pixelBatch.regionX !== regionX + adderX ||
pixelBatch.regionY !== regionY + adderY
) {
if (pixelBatch && pixelBatch.pixels.length > 0) {
console.log(`๐ Sending region-change batch with ${pixelBatch.pixels.length} pixels`);
const success = await flushPixelBatch(pixelBatch);
if (!success) {
console.error(`โ Batch failed permanently after retries. Stopping painting.`);
state.stopFlag = true;
return 'stopped';
}
await updateStats();
}
pixelBatch = {
regionX: regionX + adderX,
regionY: regionY + adderY,
pixels: [],
};
}
// Add pixel to batch (no need to check again - already pre-filtered)
pixelBatch.pixels.push({
x: pixelX,
y: pixelY,
color: targetMappedColorId,
localX: x,
localY: y,
});
// Send batch if it's full
const maxBatchSize = calculateBatchSize();
if (pixelBatch.pixels.length >= maxBatchSize) {
console.log(`๐ฆ Sending batch with ${pixelBatch.pixels.length} pixels`);
const success = await flushPixelBatch(pixelBatch);
if (!success) {
console.error(`โ Batch failed permanently after retries. Stopping painting.`);
state.stopFlag = true;
return 'stopped';
}
pixelBatch.pixels = [];
await updateStats();
}
}
// Send final batch if any pixels remain
if (pixelBatch && pixelBatch.pixels.length > 0 && !state.stopFlag) {
console.log(`๐ Sending final batch with ${pixelBatch.pixels.length} pixels`);
const success = await flushPixelBatch(pixelBatch);
if (!success) {
console.warn(`โ ๏ธ Final batch failed with ${pixelBatch.pixels.length} pixels`);
}
}
// If we completed the entire coordinate loop, image is complete
return state.stopFlag ? 'stopped' : 'completed';
} finally {
// Log skip statistics for this session
console.log(`๐ Session Statistics:`);
console.log(` New pixels painted: ${state.paintedPixels - (skippedPixels.alreadyPainted || 0)}`);
console.log(` Already painted detected: ${skippedPixels.alreadyPainted}`);
console.log(` Total progress: ${state.paintedPixels}`);
console.log(` Pre-filtered - Transparent: ${skippedPixels.transparent}`);
console.log(` Pre-filtered - White: ${skippedPixels.white}`);
console.log(` Pre-filtered - Color Unavailable: ${skippedPixels.colorUnavailable}`);
}
}
// Phase 2: Execute cooldown period - wait for target charges (NO token regeneration)
async function executeCooldownPeriod() {
console.log('โฑ๏ธ Entering cooldown period - waiting for target charges');
console.log('๐ซ NO token regeneration during cooldown (even if expired/invalid)');
// Check initial charges to calculate wait time
let chargeCheckCount = 0;
// const maxChargeChecks = 10; // REMOVED: No limit on API calls during cooldown
while (!state.stopFlag) {
chargeCheckCount++;
const { charges, cooldown } = await WPlaceService.getCharges();
state.displayCharges = Math.floor(charges);
state.preciseCurrentCharges = charges;
state.cooldown = cooldown;
if (state.displayCharges >= state.cooldownChargeThreshold) {
console.log(`โ Cooldown target reached: ${state.displayCharges}/${state.cooldownChargeThreshold}`);
NotificationManager.maybeNotifyChargesReached(true);
await updateStats();
return 'target_reached';
}
updateUI('noChargesThreshold', 'warning', {
time: Utils.msToTimeText(state.cooldown),
threshold: state.cooldownChargeThreshold,
current: state.displayCharges,
});
await updateStats();
// Smart delay calculation to reduce API calls
const chargesNeeded = state.cooldownChargeThreshold - state.displayCharges;
const estimatedWaitTime = chargesNeeded * state.cooldown;
// Use longer delays during cooldown to prevent rate limiting
let delayTime;
if (chargeCheckCount < 3) {
// First few checks - shorter delay
delayTime = Math.max(3000, state.cooldown); // 3 seconds minimum
} else if (estimatedWaitTime > 60000) {
// Long wait expected - check every 15 seconds
delayTime = 10000;
} else if (estimatedWaitTime > 30000) {
// Medium wait - check every 15 seconds
delayTime = 15000;
} else {
// Close to target - check every 5 seconds
delayTime = 5000;
}
console.log(`โฑ๏ธ Cooldown check ${chargeCheckCount}: ${state.displayCharges}/${state.cooldownChargeThreshold} charges, waiting 10s before next check`);
await Utils.sleep(10000);
// REMOVED: No limit on charge checks - bot will wait infinitely until charges are available
// if (chargeCheckCount >= maxChargeChecks) {
// console.warn('โ ๏ธ Max charge checks reached during cooldown, continuing anyway');
// break;
// }
}
return 'stopped';
}
// Phase 3: Regenerate token for new painting session
async function regenerateTokenForNewSession() {
console.log('๐ Regenerating token for new painting session');
try {
// Force regenerate token for new session
await ensureToken(true); // forceRefresh = true
if (!getTurnstileToken()) {
console.error('โ Failed to generate token for new session');
return false;
}
console.log('โ Token regenerated successfully for new session');
return true;
} catch (error) {
console.error('โ Token regeneration failed:', error);
return false;
}
}
// Finalize painting process cleanup
async function finalizePaintingProcess() {
console.log('๐งน Finalizing painting process');
if (window._chargesInterval) {
clearInterval(window._chargesInterval);
window._chargesInterval = null;
}
if (state.stopFlag) {
// Save progress when stopped to preserve painted map
Utils.saveProgress();
} else {
updateUI('paintingComplete', 'success', { count: state.paintedPixels });
state.lastPosition = { x: 0, y: 0 }; // Only reset when truly complete
Utils.saveProgress(); // Save final complete state
overlayManager.clear();
const toggleOverlayBtn = document.getElementById('toggleOverlayBtn');
if (toggleOverlayBtn) {
toggleOverlayBtn.classList.remove('active');
toggleOverlayBtn.disabled = true;
}
}
}
// Helper function to check pixel eligibility (shared by painting functions)
function checkPixelEligibility(x, y) {
const { width, height, pixels } = state.imageData;
const transparencyThreshold = state.customTransparencyThreshold || CONFIG.TRANSPARENCY_THRESHOLD;
// CRITICAL FIX: Check module availability before processing
if (!Utils || typeof Utils.isWhitePixel !== 'function') {
console.error('โ Utils module not available for pixel eligibility check');
return {
eligible: false,
reason: 'moduleUnavailable',
};
}
const idx = (y * width + x) * 4;
const r = pixels[idx],
g = pixels[idx + 1],
b = pixels[idx + 2],
a = pixels[idx + 3];
if (!state.paintTransparentPixels && a < transparencyThreshold)
return {
eligible: false,
reason: 'transparent',
};
if (!state.paintWhitePixels && Utils.isWhitePixel(r, g, b))
return {
eligible: false,
reason: 'white',
};
let targetRgb = Utils.isWhitePixel(r, g, b)
? [255, 255, 255]
: Utils.findClosestPaletteColor(r, g, b, state.activeColorPalette);
const mappedTargetColorId = Utils.resolveColor(
targetRgb,
state.availableColors,
!state.paintUnavailablePixels
);
if (!state.paintUnavailablePixels && !mappedTargetColorId.id) {
return {
eligible: false,
reason: 'colorUnavailable',
r,
g,
b,
a,
mappedColorId: mappedTargetColorId.id,
};
}
return { eligible: true, r, g, b, a, mappedColorId: mappedTargetColorId.id };
}
// Helper function to skip pixel and log the reason (minimized logging)
function skipPixel(reason, id, rgb, x, y, skippedPixels) {
// Minimize logging to prevent console flooding - only log non-routine skips
if (reason !== 'transparent' && reason !== 'alreadyPainted') {
console.log(`Skipped pixel for ${reason} (id: ${id}, (${rgb.join(', ')})) at (${x}, ${y})`);
}
skippedPixels[reason]++;
}
function calculateBatchSize() {
let targetBatchSize;
if (state.batchMode === 'random') {
// Generate random batch size within the specified range
const min = Math.max(1, state.randomBatchMin);
const max = Math.max(min, state.randomBatchMax);
targetBatchSize = Math.floor(Math.random() * (max - min + 1)) + min;
console.log(`๐ฒ Random batch size generated: ${targetBatchSize} (range: ${min}-${max})`);
} else {
// Normal mode - use the fixed paintingSpeed value
targetBatchSize = state.paintingSpeed;
}
// Always limit by available charges
const maxAllowed = state.displayCharges;
const finalBatchSize = Math.min(targetBatchSize, maxAllowed);
return finalBatchSize;
}
// Helper function to retry batch until success with exponential backoff
async function sendBatchWithRetry(pixels, regionX, regionY, maxRetries = MAX_BATCH_RETRIES) {
let attempt = 0;
while (attempt < maxRetries && !state.stopFlag) {
attempt++;
console.log(
`๐ Attempting to send batch (attempt ${attempt}/${maxRetries}) for region ${regionX},${regionY} with ${pixels.length} pixels`
);
const result = await sendPixelBatch(pixels, regionX, regionY);
if (result === true) {
console.log(`โ Batch succeeded on attempt ${attempt}`);
return true;
} else if (result === 'token_error') {
console.log(`๐ Token error on attempt ${attempt} - no token available during processing`);
console.log(`โ Stopping batch processing - tokens must be generated at startup/start button only`);
updateUI('captchaFailed', 'error');
return false; // Stop processing entirely - don't regenerate during processing
} else if (result === 'token_regenerated') {
console.log(`๐ Token regenerated on attempt ${attempt} after 403 error - retrying batch`);
updateUI('paintingPaused', 'warning', { message: 'Token refreshed, resuming...' });
// Don't count token regeneration as a failed attempt, retry immediately
attempt--;
await Utils.sleep(500); // Brief pause before retry
continue;
} else if (result === 'token_regeneration_failed') {
console.log(`โ Token regeneration failed on attempt ${attempt} after 403 error`);
updateUI('captchaFailed', 'error');
return false; // Stop processing if we can't get a valid token
} else if (result === 'invalid_token_error') {
console.log(`๐ Invalid token detected on attempt ${attempt}, regenerating...`);
updateUI('captchaSolving', 'warning');
try {
await handleCaptcha(true); // Allow generation for invalid token cases
// Don't count token regeneration as a failed attempt
attempt--;
continue;
} catch (e) {
console.error(`โ Token regeneration failed after invalid token on attempt ${attempt}:`, e);
updateUI('captchaFailed', 'error');
// Wait longer before retrying after token failure
await Utils.sleep(5000);
}
} else {
console.warn(`โ ๏ธ Batch failed on attempt ${attempt}, retrying...`);
// Exponential backoff with jitter
const baseDelay = Math.min(1000 * Math.pow(2, attempt - 1), 30000); // Max 30s
const jitter = Math.random() * 1000; // Add up to 1s random delay
await Utils.sleep(baseDelay + jitter);
}
}
if (attempt >= maxRetries) {
console.error(
`โ Batch failed after ${maxRetries} attempts (MAX_BATCH_RETRIES=${MAX_BATCH_RETRIES}). This will stop painting to prevent infinite loops.`
);
updateUI('paintingError', 'error');
return false;
}
return false;
}
async function sendPixelBatch(pixelBatch, regionX, regionY) {
let token = getTurnstileToken();
// Don't auto-generate tokens during processing - return error if no token available
if (!token) {
console.warn('โ ๏ธ No token available and auto-generation disabled during processing');
return 'token_error';
}
const coords = new Array(pixelBatch.length * 2);
const colors = new Array(pixelBatch.length);
for (let i = 0; i < pixelBatch.length; i++) {
const pixel = pixelBatch[i];
coords[i * 2] = pixel.x;
coords[i * 2 + 1] = pixel.y;
colors[i] = pixel.color;
}
try {
const payload = { coords, colors, t: token, fp: fpStr32 };
var wasmtoken = await createWasmToken(regionX, regionY, payload);
const res = await fetch(`https://backend.wplace.live/s0/pixel/${regionX}/${regionY}`, {
method: 'POST',
headers: { 'Content-Type': 'text/plain;charset=UTF-8', "x-pawtect-token": wasmtoken },
credentials: 'include',
body: JSON.stringify(payload),
});
if (res.status === 403) {
let data = null;
try {
data = await res.json();
} catch (_) { }
console.error('โ 403 Forbidden. Token invalid during painting - regeneration allowed.');
// 403 errors during painting allow token regeneration per workflow requirements
console.log('๏ฟฝ Token invalid (403) during painting - regenerating token as allowed by workflow');
setTurnstileToken(null);
createTokenPromise();
// Attempt to regenerate token immediately
const newToken = await ensureToken(true);
if (newToken) {
console.log('โ Token regenerated after 403 error, returning regenerate signal');
return 'token_regenerated';
} else {
console.error('โ Failed to regenerate token after 403 error');
return 'token_regeneration_failed';
}
}
const data = await res.json();
return data?.painted === pixelBatch.length;
} catch (e) {
console.error('Batch paint request failed:', e);
return false;
}
}
function saveBotSettings() {
try {
const settings = {
paintingSpeed: state.paintingSpeed,
paintingSpeedEnabled: document.getElementById('enableSpeedToggle')?.checked,
batchMode: state.batchMode, // "normal" or "random"
randomBatchMin: state.randomBatchMin,
randomBatchMax: state.randomBatchMax,
cooldownChargeThreshold: state.cooldownChargeThreshold,
tokenSource: state.tokenSource, // "generator", "hybrid", or "manual"
minimized: state.minimized,
overlayOpacity: state.overlayOpacity,
blueMarbleEnabled: document.getElementById('enableBlueMarbleToggle')?.checked,
ditheringEnabled: state.ditheringEnabled,
colorMatchingAlgorithm: state.colorMatchingAlgorithm,
enableChromaPenalty: state.enableChromaPenalty,
chromaPenaltyWeight: state.chromaPenaltyWeight,
customTransparencyThreshold: state.customTransparencyThreshold,
customWhiteThreshold: state.customWhiteThreshold,
paintWhitePixels: state.paintWhitePixels,
paintTransparentPixels: state.paintTransparentPixels,
resizeSettings: state.resizeSettings,
paintUnavailablePixels: state.paintUnavailablePixels,
coordinateMode: state.coordinateMode,
coordinateDirection: state.coordinateDirection,
coordinateSnake: state.coordinateSnake,
blockWidth: state.blockWidth,
blockHeight: state.blockHeight, // Save ignore mask (as base64) with its dimensions
resizeIgnoreMask:
state.resizeIgnoreMask &&
state.resizeSettings &&
state.resizeSettings.width * state.resizeSettings.height === state.resizeIgnoreMask.length
? {
w: state.resizeSettings.width,
h: state.resizeSettings.height,
data: btoa(String.fromCharCode(...state.resizeIgnoreMask)),
}
: null, // Notifications
notificationsEnabled: state.notificationsEnabled,
notifyOnChargesReached: state.notifyOnChargesReached,
notifyOnlyWhenUnfocused: state.notifyOnlyWhenUnfocused,
notificationIntervalMinutes: state.notificationIntervalMinutes,
originalImage: state.originalImage,
};
CONFIG.PAINTING_SPEED_ENABLED = settings.paintingSpeedEnabled;
// AUTO_CAPTCHA_ENABLED is always true - no need to save/load
localStorage.setItem('wplace-bot-settings', JSON.stringify(settings));
} catch (e) {
console.warn('Could not save bot settings:', e);
}
}
function loadBotSettings() {
try {
const saved = localStorage.getItem('wplace-bot-settings');
if (!saved) return;
const settings = JSON.parse(saved);
state.paintingSpeed = settings.paintingSpeed || CONFIG.PAINTING_SPEED.DEFAULT;
state.batchMode = settings.batchMode || CONFIG.BATCH_MODE; // Default to "normal"
state.randomBatchMin = settings.randomBatchMin || CONFIG.RANDOM_BATCH_RANGE.MIN;
state.randomBatchMax = settings.randomBatchMax || CONFIG.RANDOM_BATCH_RANGE.MAX;
state.cooldownChargeThreshold =
settings.cooldownChargeThreshold || CONFIG.COOLDOWN_CHARGE_THRESHOLD;
state.tokenSource = settings.tokenSource || CONFIG.TOKEN_SOURCE; // Default to "generator"
state.minimized = settings.minimized ?? false;
CONFIG.PAINTING_SPEED_ENABLED = settings.paintingSpeedEnabled ?? false;
CONFIG.AUTO_CAPTCHA_ENABLED = settings.autoCaptchaEnabled ?? false;
state.overlayOpacity = settings.overlayOpacity ?? CONFIG.OVERLAY.OPACITY_DEFAULT;
state.blueMarbleEnabled = settings.blueMarbleEnabled ?? CONFIG.OVERLAY.BLUE_MARBLE_DEFAULT;
state.ditheringEnabled = settings.ditheringEnabled ?? false;
state.colorMatchingAlgorithm = settings.colorMatchingAlgorithm || 'lab';
state.enableChromaPenalty = settings.enableChromaPenalty ?? true;
state.chromaPenaltyWeight = settings.chromaPenaltyWeight ?? 0.15;
state.customTransparencyThreshold =
settings.customTransparencyThreshold ?? CONFIG.TRANSPARENCY_THRESHOLD;
state.customWhiteThreshold = settings.customWhiteThreshold ?? CONFIG.WHITE_THRESHOLD;
state.paintWhitePixels = settings.paintWhitePixels ?? true;
state.paintTransparentPixels = settings.paintTransparentPixels ?? false;
state.resizeSettings = settings.resizeSettings ?? null;
state.originalImage = settings.originalImage ?? null;
state.paintUnavailablePixels = settings.paintUnavailablePixels ?? CONFIG.PAINT_UNAVAILABLE;
state.coordinateMode = settings.coordinateMode ?? CONFIG.COORDINATE_MODE;
state.coordinateDirection = settings.coordinateDirection ?? CONFIG.COORDINATE_DIRECTION;
state.coordinateSnake = settings.coordinateSnake ?? CONFIG.COORDINATE_SNAKE;
state.blockWidth = settings.blockWidth ?? CONFIG.COORDINATE_BLOCK_WIDTH;
state.blockHeight = settings.blockHeight ?? CONFIG.COORDINATE_BLOCK_HEIGHT;
// Notifications
state.notificationsEnabled = settings.notificationsEnabled ?? CONFIG.NOTIFICATIONS.ENABLED;
state.notifyOnChargesReached =
settings.notifyOnChargesReached ?? CONFIG.NOTIFICATIONS.ON_CHARGES_REACHED;
state.notifyOnlyWhenUnfocused =
settings.notifyOnlyWhenUnfocused ?? CONFIG.NOTIFICATIONS.ONLY_WHEN_UNFOCUSED;
state.notificationIntervalMinutes =
settings.notificationIntervalMinutes ?? CONFIG.NOTIFICATIONS.REPEAT_MINUTES;
// Restore ignore mask if dims match current resizeSettings
if (
settings.resizeIgnoreMask &&
settings.resizeIgnoreMask.data &&
state.resizeSettings &&
settings.resizeIgnoreMask.w === state.resizeSettings.width &&
settings.resizeIgnoreMask.h === state.resizeSettings.height
) {
try {
const bin = atob(settings.resizeIgnoreMask.data);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
state.resizeIgnoreMask = arr;
} catch {
state.resizeIgnoreMask = null;
}
} else {
state.resizeIgnoreMask = null;
}
// Initialize coordinate generation UI
const coordinateModeSelect = document.getElementById('coordinateModeSelect');
if (coordinateModeSelect) coordinateModeSelect.value = state.coordinateMode;
const coordinateDirectionSelect = document.getElementById('coordinateDirectionSelect');
if (coordinateDirectionSelect) coordinateDirectionSelect.value = state.coordinateDirection;
const coordinateSnakeToggle = document.getElementById('coordinateSnakeToggle');
if (coordinateSnakeToggle) coordinateSnakeToggle.checked = state.coordinateSnake;
const settingsContainer = document.getElementById('wplace-settings-container');
const directionControls = settingsContainer.querySelector('#directionControls');
const snakeControls = settingsContainer.querySelector('#snakeControls');
const blockControls = settingsContainer.querySelector('#blockControls');
Utils.updateCoordinateUI({
mode: state.coordinateMode,
directionControls,
snakeControls,
blockControls,
});
const paintUnavailablePixelsToggle = document.getElementById('paintUnavailablePixelsToggle');
if (paintUnavailablePixelsToggle) {
paintUnavailablePixelsToggle.checked = state.paintUnavailablePixels;
}
const settingsPaintWhiteToggle = settingsContainer.querySelector('#settingsPaintWhiteToggle');
if (settingsPaintWhiteToggle) {
settingsPaintWhiteToggle.checked = state.paintWhitePixels;
}
const settingsPaintTransparentToggle = settingsContainer.querySelector(
'#settingsPaintTransparentToggle'
);
if (settingsPaintTransparentToggle) {
settingsPaintTransparentToggle.checked = state.paintTransparentPixels;
}
const speedSlider = document.getElementById('speedSlider');
const speedInput = document.getElementById('speedInput');
const speedValue = document.getElementById('speedValue');
if (speedSlider) speedSlider.value = state.paintingSpeed;
if (speedInput) speedInput.value = state.paintingSpeed;
if (speedValue) speedValue.textContent = `pixels`;
const enableSpeedToggle = document.getElementById('enableSpeedToggle');
if (enableSpeedToggle) enableSpeedToggle.checked = CONFIG.PAINTING_SPEED_ENABLED;
// Batch mode UI initialization
const batchModeSelect = document.getElementById('batchModeSelect');
if (batchModeSelect) batchModeSelect.value = state.batchMode;
const normalBatchControls = document.getElementById('normalBatchControls');
const randomBatchControls = document.getElementById('randomBatchControls');
// Show/hide appropriate controls based on batch mode
if (normalBatchControls && randomBatchControls) {
if (state.batchMode === 'random') {
normalBatchControls.style.display = 'none';
randomBatchControls.style.display = 'block';
} else {
normalBatchControls.style.display = 'block';
randomBatchControls.style.display = 'none';
}
}
const randomBatchMin = document.getElementById('randomBatchMin');
if (randomBatchMin) randomBatchMin.value = state.randomBatchMin;
const randomBatchMax = document.getElementById('randomBatchMax');
if (randomBatchMax) randomBatchMax.value = state.randomBatchMax;
// AUTO_CAPTCHA_ENABLED is always true - no toggle to set
const cooldownSlider = document.getElementById('cooldownSlider');
const cooldownInput = document.getElementById('cooldownInput');
const cooldownValue = document.getElementById('cooldownValue');
if (cooldownSlider) cooldownSlider.value = state.cooldownChargeThreshold;
if (cooldownInput) cooldownInput.value = state.cooldownChargeThreshold;
if (cooldownValue) cooldownValue.textContent = `${Utils.t('charges')}`;
const overlayOpacitySlider = document.getElementById('overlayOpacitySlider');
if (overlayOpacitySlider) overlayOpacitySlider.value = state.overlayOpacity;
const overlayOpacityValue = document.getElementById('overlayOpacityValue');
if (overlayOpacityValue)
overlayOpacityValue.textContent = `${Math.round(state.overlayOpacity * 100)}%`;
const enableBlueMarbleToggle = document.getElementById('enableBlueMarbleToggle');
if (enableBlueMarbleToggle) enableBlueMarbleToggle.checked = state.blueMarbleEnabled;
const tokenSourceSelect = document.getElementById('tokenSourceSelect');
if (tokenSourceSelect) tokenSourceSelect.value = state.tokenSource;
const colorAlgorithmSelect = document.getElementById('colorAlgorithmSelect');
if (colorAlgorithmSelect) colorAlgorithmSelect.value = state.colorMatchingAlgorithm;
const enableChromaPenaltyToggle = document.getElementById('enableChromaPenaltyToggle');
if (enableChromaPenaltyToggle) enableChromaPenaltyToggle.checked = state.enableChromaPenalty;
const chromaPenaltyWeightSlider = document.getElementById('chromaPenaltyWeightSlider');
if (chromaPenaltyWeightSlider) chromaPenaltyWeightSlider.value = state.chromaPenaltyWeight;
const chromaWeightValue = document.getElementById('chromaWeightValue');
if (chromaWeightValue) chromaWeightValue.textContent = state.chromaPenaltyWeight;
const transparencyThresholdInput = document.getElementById('transparencyThresholdInput');
if (transparencyThresholdInput)
transparencyThresholdInput.value = state.customTransparencyThreshold;
const whiteThresholdInput = document.getElementById('whiteThresholdInput');
if (whiteThresholdInput) whiteThresholdInput.value = state.customWhiteThreshold;
// Notifications UI
const notifEnabledToggle = document.getElementById('notifEnabledToggle');
if (notifEnabledToggle) notifEnabledToggle.checked = state.notificationsEnabled;
const notifOnChargesToggle = document.getElementById('notifOnChargesToggle');
if (notifOnChargesToggle) notifOnChargesToggle.checked = state.notifyOnChargesReached;
const notifOnlyUnfocusedToggle = document.getElementById('notifOnlyUnfocusedToggle');
if (notifOnlyUnfocusedToggle)
notifOnlyUnfocusedToggle.checked = state.notifyOnlyWhenUnfocused;
const notifIntervalInput = document.getElementById('notifIntervalInput');
if (notifIntervalInput) notifIntervalInput.value = state.notificationIntervalMinutes;
NotificationManager.resetEdgeTracking();
} catch (e) {
console.warn('Could not load bot settings:', e);
}
}
// Initialize Turnstile generator integration
console.log('๐ WPlace Auto-Image with Turnstile Token Generator loaded');
console.log('๐ Turnstile token generator: ALWAYS ENABLED (Background mode)');
console.log('๐ฏ Manual pixel captcha solving: Available as fallback/alternative');
console.log('๐ฑ Turnstile widgets: DISABLED - pure background token generation only!');
// Generate fingerprint string for requests
if (!window.WPlaceTokenManager) {
console.error('โ WPlaceTokenManager not available - dependency loading issue');
return;
}
const fpStr32 = tokenManager._randStr(32);
console.log('๐ Generated fingerprint string for API requests');
// Function to enable file operations after initial startup setup is complete
function enableFileOperations() {
state.initialSetupComplete = true;
const loadBtn = document.querySelector('#loadBtn');
const loadFromFileBtn = document.querySelector('#loadFromFileBtn');
const uploadBtn = document.querySelector('#uploadBtn');
if (loadBtn) {
loadBtn.disabled = false;
loadBtn.title = '';
// Add a subtle animation to indicate the button is now available
loadBtn.style.animation = 'pulse 0.6s ease-in-out';
setTimeout(() => {
if (loadBtn) loadBtn.style.animation = '';
}, 600);
console.log('โ Load Progress button enabled after initial setup');
}
if (loadFromFileBtn) {
loadFromFileBtn.disabled = false;
loadFromFileBtn.title = '';
// Add a subtle animation to indicate the button is now available
loadFromFileBtn.style.animation = 'pulse 0.6s ease-in-out';
setTimeout(() => {
if (loadFromFileBtn) loadFromFileBtn.style.animation = '';
}, 600);
console.log('โ Load from File button enabled after initial setup');
}
if (uploadBtn) {
uploadBtn.disabled = false;
uploadBtn.title = '';
// Add a subtle animation to indicate the button is now available
uploadBtn.style.animation = 'pulse 0.6s ease-in-out';
setTimeout(() => {
if (uploadBtn) uploadBtn.style.animation = '';
}, 600);
console.log('โ Upload Image button enabled after initial setup');
}
// Enable Load Extracted button at the same time as Upload button
const loadExtractedBtn = document.getElementById('loadExtractedBtn');
if (loadExtractedBtn) {
loadExtractedBtn.disabled = false;
loadExtractedBtn.title = '';
console.log('โ Load Extracted button enabled after initial setup');
}
// Show a notification that file operations are now available
Utils.showAlert(Utils.t('fileOperationsAvailable'), 'success');
}
// Optimized token initialization with better timing and error handling
async function initializeTokenGenerator() {
// Skip if already have valid token
if (isTokenValid()) {
console.log('โ Valid token already available, skipping initialization');
updateUI('tokenReady', 'success');
enableFileOperations(); // Enable file operations since initial setup is complete
return;
}
try {
console.log('๐ง Initializing Turnstile token generator...');
updateUI('initializingToken', 'default');
console.log('Attempting to load Turnstile script...');
await Utils.loadTurnstile();
console.log('Turnstile script loaded. Attempting to generate token...');
// Use TokenManager's handleCaptchaWithRetry method instead
const token = await tokenManager.handleCaptchaWithRetry();
if (token) {
setTurnstileToken(token);
console.log('โ Startup token generated successfully');
updateUI('tokenReady', 'success');
Utils.showAlert(Utils.t('tokenGeneratorReady'), 'success');
enableFileOperations(); // Enable file operations since initial setup is complete
} else {
console.warn(
'โ ๏ธ Startup token generation failed (no token received), will retry when needed'
);
updateUI('tokenRetryLater', 'warning');
// Still enable file operations even if initial token generation fails
// Users can load progress and use manual/hybrid modes
enableFileOperations();
}
} catch (error) {
console.error('โ Critical error during Turnstile initialization:', error); // More specific error
updateUI('tokenRetryLater', 'warning');
// Still enable file operations even if initial setup fails
// Users can load progress and use manual/hybrid modes
enableFileOperations();
// Don't show error alert for initialization failures, just log them
}
}
// Load theme preference immediately on startup before creating UI
loadThemePreference();
applyTheme();
var pawtect_chunk = null;
//find module if pawtect_chunk is null
pawtect_chunk ??= await findTokenModule("pawtect_wasm_bg.wasm");
async function createWasmToken(regionX, regionY, payload) {
try {
// Load the Pawtect module and WASM
const mod = await import(new URL('/_app/immutable/chunks/' + pawtect_chunk, location.origin).href);
let wasm;
try {
wasm = await mod._();
console.log('โ WASM initialized successfully');
} catch (wasmError) {
console.error('โ WASM initialization failed:', wasmError);
return null;
}
try {
try {
const me = await fetch(`https://backend.wplace.live/me`, { credentials: 'include' }).then(r => r.ok ? r.json() : null);
if (me?.id) {
mod.i(me.id);
console.log('โ user ID set:', me.id);
}
} catch { }
} catch (userIdError) {
console.log('โ ๏ธ Error setting user ID:', userIdError.message);
}
try {
const testUrl = `https://backend.wplace.live/s0/pixel/${regionX}/${regionY}`;
if (mod.r) {
mod.r(testUrl);
console.log('โ Request URL set:', testUrl);
} else {
console.log('โ ๏ธ request_url function (mod.r) not available');
}
} catch (urlError) {
console.log('โ ๏ธ Error setting request URL:', urlError.message);
}
// Create test payload
console.log('๐ payload:', payload);
// Encode payload
const enc = new TextEncoder();
const dec = new TextDecoder();
const bodyStr = JSON.stringify(payload);
const bytes = enc.encode(bodyStr);
console.log('๐ Payload size:', bytes.length, 'bytes');
console.log('๐ Payload string:', bodyStr);
// Allocate WASM memory with validation
let inPtr;
try {
if (!wasm.__wbindgen_malloc) {
console.error('โ __wbindgen_malloc function not found');
return null;
}
inPtr = wasm.__wbindgen_malloc(bytes.length, 1);
console.log('โ WASM memory allocated, pointer:', inPtr);
// Copy data to WASM memory
const wasmBuffer = new Uint8Array(wasm.memory.buffer, inPtr, bytes.length);
wasmBuffer.set(bytes);
console.log('โ Data copied to WASM memory');
} catch (memError) {
console.error('โ Memory allocation error:', memError);
return null;
}
// Call the WASM function
console.log('๐ Calling get_pawtected_endpoint_payload...');
let outPtr, outLen, token;
try {
const result = wasm.get_pawtected_endpoint_payload(inPtr, bytes.length);
console.log('โ Function called, result type:', typeof result, result);
if (Array.isArray(result) && result.length === 2) {
[outPtr, outLen] = result;
console.log('โ Got output pointer:', outPtr, 'length:', outLen);
// Decode the result
const outputBuffer = new Uint8Array(wasm.memory.buffer, outPtr, outLen);
token = dec.decode(outputBuffer);
console.log('โ Token decoded successfully');
} else {
console.error('โ Unexpected function result format:', result);
return null;
}
} catch (funcError) {
console.error('โ Function call error:', funcError);
console.error('Stack trace:', funcError.stack);
return null;
}
// Cleanup memory
try {
if (wasm.__wbindgen_free && outPtr && outLen) {
wasm.__wbindgen_free(outPtr, outLen, 1);
console.log('โ Output memory freed');
}
if (wasm.__wbindgen_free && inPtr) {
wasm.__wbindgen_free(inPtr, bytes.length, 1);
console.log('โ Input memory freed');
}
} catch (cleanupError) {
console.log('โ ๏ธ Cleanup warning:', cleanupError.message);
}
// Display results
console.log('');
console.log('๐ SUCCESS!');
console.log('๐ Full token:');
console.log(token);
return token;
} catch (error) {
console.error('โ Failed to generate fp parameter:', error);
return null;
}
}
async function findTokenModule(str) {
console.log('๐ Searching for wasm Module...');
const links = Array.from(document.querySelectorAll('link[rel="modulepreload"][href$=".js"]'));
for (const link of links) {
try {
const url = new URL(link.getAttribute("href"), location.origin).href;
const code = await fetch(url).then(r => r.text());
if (code.includes(str)) {
console.log('โ Found wasm Module...');
return url.split('/').pop();
}
} catch { }
}
console.error(`โ Could not find Pawtect chunk: `, error);
}
async function purchase(type) {
// loadThemePreference()
let id;
let chargeMultiplier;
if (type === "max_charges") {
id = 70;
chargeMultiplier = 5;
} else if (type === "paint_charges") {
id = 80;
chargeMultiplier = 30;
} else {
console.error("Error: Invalid purchase type provided.");
return;
}
const { droplets } = await WPlaceService.getCharges();
console.log("There are currently : ", droplets, "droplets.");
try {
const amounts = Math.floor(droplets / 500);
if (amounts < 1) {
console.log("Not enough droplets to purchase.");
return;
}
const payload = {
"product": {
"id": id,
"amount": amounts
}
};
const res = fetch("https://backend.wplace.live/purchase", {
method: "POST",
headers: {
"Content-Type": "text/plain;charset=UTF-8"
},
body: JSON.stringify(payload),
credentials: "include"
});
// POST request completed
const { droplets: newDroplets } = await WPlaceService.getCharges();
if (droplets != newDroplets) {
console.log("Successfully bought", amounts * chargeMultiplier, type.replace('_', ' '), ".");
}
else {
console.log("Failed to buy charges");
}
} catch (e) {
console.error("An error occurred during the purchase:", e);
}
}
async function swapAccountTrigger(token) {
// STRICT GUARD: Only allow account switching during active painting sessions OR controlled refresh
if (!state.running && !state.isFetchingAllAccounts) {
console.warn('๐ Account switching blocked - only allowed during active painting or controlled refresh');
console.warn('๐ Current state.running:', state.running, 'state.isFetchingAllAccounts:', state.isFetchingAllAccounts);
return false;
}
localStorage.removeItem("lp");
if (!token) {
console.error('โ Cannot swap account: token is null or undefined');
return false;
}
console.log(`๐ Triggering account swap with token: ${token.substring(0, 20)}...`);
console.log('๐ค Sending setCookie message to extension...');
try {
window.postMessage({
source: 'my-userscript',
type: 'setCookie',
value: token
}, '*');
console.log('โ setCookie message sent successfully');
return true;
} catch (error) {
console.error('โ Failed to send setCookie message:', error);
return false;
}
}
async function getAccounts() {
return new Promise((resolve, reject) => {
console.log("Requesting accounts from extension...");
// Ask extension for accounts
window.postMessage({
source: "my-userscript",
type: "getAccounts"
}, "*");
function handler(event) {
if (event.source !== window) return;
if (event.data.source !== "extension") return;
if (event.data.type === "accountsData") {
window.removeEventListener("message", handler);
// Save to localStorage
try {
localStorage.setItem("accounts", JSON.stringify(event.data.accounts));
console.log("โ Accounts saved to localStorage:", event.data.accounts);
} catch (e) {
console.error("โ Failed to save accounts:", e);
}
resolve(event.data.accounts);
}
}
window.addEventListener("message", handler);
});
}
async function fetchAccount() {
const { ID, Charges, Max, Droplets } = await WPlaceService.fetchCheck();
console.log("User's ID :", ID);
console.log("User's Charges :", Charges, "/", Max);
console.log("User's Droplets :", Droplets);
}
async function fetchAllAccountDetails() {
if (state.isFetchingAllAccounts) {
Utils.showAlert("Already fetching account details.", "warning");
return;
}
state.isFetchingAllAccounts = true;
const refreshBtn = document.getElementById('refreshAllAccountsBtn');
if (refreshBtn) {
refreshBtn.innerHTML = '';
refreshBtn.disabled = true;
}
const accountsListArea = document.getElementById('accountsListArea');
if (accountsListArea) {
accountsListArea.innerHTML = `
Fetching account details...
`;
}
let originalToken = null;
let originalAccountData = null;
try {
await getAccounts();
const accountsTokens = JSON.parse(localStorage.getItem("accounts")) || [];
if (accountsTokens.length === 0) {
if (accountsListArea) accountsListArea.innerHTML = `
No accounts found.
`;
return;
}
// Store original account info to switch back later
originalAccountData = await WPlaceService.getCharges();
originalToken = accountsTokens.find(token => {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.userId === originalAccountData.id;
} catch (e) {
return false;
}
});
state.allAccountsInfo = [];
renderAccountsList();
// Temporarily disable the strict guard for controlled refresh operation
const originalRunningState = state.running;
state.running = true; // Temporarily enable to allow controlled switching
console.log('๐ Temporarily enabling controlled account switching for refresh');
for (let i = 0; i < accountsTokens.length; i++) {
const token = accountsTokens[i];
console.log(`๐ Fetching details for account ${i + 1}/${accountsTokens.length}`);
// Controlled account switch for data fetching
const swapResult = await swapAccountTrigger(token);
if (!swapResult) {
console.warn(`โ ๏ธ Failed to trigger swap for account ${i + 1}`);
continue;
}
// Wait for account switch to complete
let retries = 0;
let accountData = null;
while (retries < 8 && !accountData) {
// await Utils.sleep(1000);
try {
const fetchedInfo = await WPlaceService.fetchCheck();
const chargesInfo = await WPlaceService.getCharges();
if (fetchedInfo.ID) {
accountData = {
...fetchedInfo,
Charges: Math.floor(chargesInfo.charges),
Max: Math.floor(chargesInfo.max),
Droplets: Math.floor(chargesInfo.droplets)
};
}
} catch (e) {
retries++;
console.warn(`โ ๏ธ Retry ${retries}/8 for account ${i + 1}`);
}
}
if (accountData) {
const actualName = accountData.Username || accountData.name || `User${accountData.ID}`;
const isCurrentAccount = accountData.ID === originalAccountData.id;
state.allAccountsInfo.push({
...accountData,
token,
displayName: actualName,
isCurrent: isCurrentAccount
});
console.log(`โ Fetched details for: ${actualName} (${accountData.Charges}/${accountData.Max} charges)`);
} else {
// Fallback for failed accounts
const fallbackName = `Account ${i + 1}`;
state.allAccountsInfo.push({
token,
ID: `...${token.slice(-4)}`,
displayName: fallbackName,
isCurrent: false,
error: 'Failed to fetch details'
});
console.warn(`โ Failed to fetch details for account ${i + 1}`);
}
renderAccountsList();
}
// Restore original state and switch back to original account
state.running = originalRunningState;
console.log('๐ Restored original running state and switching back to original account');
} catch (error) {
console.error("Error fetching all account details:", error);
if (accountsListArea) accountsListArea.innerHTML = `
Error loading accounts.
`;
} finally {
// Switch back to original account
if (originalToken) {
console.log('๐ Switching back to original account...');
const originalRunningState = state.running;
state.running = true; // Temporarily enable for switch back
await swapAccountTrigger(originalToken);
await Utils.sleep(2000);
// Verify we're back on the original account
try {
const verifyData = await WPlaceService.getCharges();
if (verifyData.id === originalAccountData.id) {
console.log('โ Successfully switched back to original account');
}
} catch (e) {
console.warn('โ ๏ธ Could not verify switch back to original account');
}
state.running = originalRunningState; // Restore state
}
// Update current account info in the list
if (originalAccountData) {
const currentAccountInList = state.allAccountsInfo.find(acc => acc.ID === originalAccountData.id);
if (currentAccountInList) {
currentAccountInList.isCurrent = true;
currentAccountInList.Charges = Math.floor(originalAccountData.charges);
currentAccountInList.Max = Math.floor(originalAccountData.max);
currentAccountInList.Droplets = Math.floor(originalAccountData.droplets);
}
}
// Keep original account order - do NOT rearrange the array
// Instead, assign IDs based on current account being ID 1
const currentAccountIndex = state.allAccountsInfo.findIndex(acc => acc.isCurrent);
if (currentAccountIndex !== -1) {
console.log(`๐ฏ Current account found at array position ${currentAccountIndex}`);
// Assign IDs: current account gets ID 1, others get sequential IDs
state.allAccountsInfo.forEach((acc, index) => {
if (acc.isCurrent) {
acc.orderId = 1; // Current account always gets ID 1
} else {
// Other accounts get IDs based on their position relative to current
const relativePosition = index > currentAccountIndex ?
(index - currentAccountIndex + 1) :
(state.allAccountsInfo.length - currentAccountIndex + index + 1);
acc.orderId = relativePosition;
}
});
// Sort the display order by orderId for UI, but keep original array intact
const sortedForDisplay = [...state.allAccountsInfo].sort((a, b) => a.orderId - b.orderId);
// Create the switching order array (ID 1 first, then sequential)
state.originalAccountOrder = sortedForDisplay;
// Find the current account's position in the switching order (should be 0 since it's ID 1)
state.currentActiveIndex = 0; // Current account (ID 1) is always first in switching order
state.accountIndex = currentAccountIndex; // Keep track of original array position
console.log(`๐ Account IDs assigned:`);
state.allAccountsInfo.forEach((acc) => {
console.log(` Array pos ${state.allAccountsInfo.indexOf(acc)}: ID ${acc.orderId} - ${acc.displayName}${acc.isCurrent ? ' (CURRENT)' : ''}`);
});
console.log(`๐ Switching order (by ID):`);
state.originalAccountOrder.forEach((acc, idx) => {
console.log(` Switch pos ${idx}: ID ${acc.orderId} - ${acc.displayName}${acc.isCurrent ? ' (CURRENT)' : ''}`);
});
} else {
console.warn('โ ๏ธ No current account found, using original order');
state.allAccountsInfo.forEach((acc, index) => {
acc.orderId = index + 1;
});
state.originalAccountOrder = [...state.allAccountsInfo];
state.currentActiveIndex = 0;
state.accountIndex = 0;
}
await updateStats();
renderAccountsList();
state.isFetchingAllAccounts = false;
if (refreshBtn) {
refreshBtn.innerHTML = '';
refreshBtn.disabled = false;
}
console.log('โ Account list refresh completed with full details');
}
}
// Function to update current account charges in the account list
async function updateCurrentAccountInList() {
if (state.allAccountsInfo.length === 0) return;
// Find current account in the list and update its charges
const currentAccountInList = state.allAccountsInfo.find(acc => acc.isCurrent);
if (currentAccountInList) {
const { charges, cooldown } = await WPlaceService.getCharges();
state.displayCharges = Math.floor(charges);
state.preciseCurrentCharges = charges;
await updateStats();
currentAccountInList.Charges = Math.floor(state.displayCharges || state.preciseCurrentCharges || 0);
currentAccountInList.Max = state.maxCharges;
// Re-render the account list to show updated charges
renderAccountsList();
}
}
// Function to update current account spotlight when switching during painting
async function updateCurrentAccountSpotlight() {
if (state.allAccountsInfo.length === 0) return;
await Utils.sleep(500); // Wait a bit for the switch to take effect
try {
const currentAccountData = await WPlaceService.getCharges();
console.log("Current account after switch:", currentAccountData);
console.log(`๐ Switched to account with ID: ${currentAccountData.id}`);
// Update all accounts to not be current
state.allAccountsInfo.forEach(acc => acc.isCurrent = false);
// Find and mark the new current account in the original array
const newCurrentAccount = state.allAccountsInfo.find(acc => acc.ID === currentAccountData.id);
if (newCurrentAccount) {
const currentAccountInfo = await WPlaceService.fetchCheck();
newCurrentAccount.isCurrent = true;
newCurrentAccount.Charges = Math.floor(currentAccountData.charges);
newCurrentAccount.Max = Math.floor(currentAccountData.max);
newCurrentAccount.Droplets = Math.floor(currentAccountData.droplets);
newCurrentAccount.displayName = currentAccountInfo.Username || currentAccountInfo.name || newCurrentAccount.displayName;
console.log(`๐ฏ Updated current account spotlight: ${newCurrentAccount.displayName} (ID ${newCurrentAccount.orderId})`);
// Update the switching order array to reflect the current account change
const switchingOrderIndex = state.originalAccountOrder.findIndex(acc => acc.orderId === newCurrentAccount.orderId);
if (switchingOrderIndex !== -1) {
state.originalAccountOrder[switchingOrderIndex] = newCurrentAccount;
// DON'T update currentActiveIndex here - it's managed by switchToNextAccount()
console.log(`๐ฏ Updated account data at switching order index ${switchingOrderIndex} (ID ${newCurrentAccount.orderId})`);
console.log(`๐ Current state: activeIndex=${state.currentActiveIndex}, expectedIndex=${switchingOrderIndex}`);
}
}
// Re-render the account list to show new current account
renderAccountsList();
} catch (error) {
console.warn('โ ๏ธ Failed to update account spotlight:', error);
}
}
function renderAccountsList() {
const accountsListArea = document.getElementById('accountsListArea');
if (!accountsListArea) return;
accountsListArea.innerHTML = '';
if (state.allAccountsInfo.length === 0) {
accountsListArea.innerHTML = `
No account data. Click to refresh.
`;
return;
}
// Render accounts in switching order (sorted by orderId)
state.originalAccountOrder.forEach((info, switchIndex) => {
const item = document.createElement('div');
// Determine if this is the current account or next in sequence
const isCurrentAccount = info.isCurrent;
const isNextInSequence = state.currentActiveIndex !== -1 &&
((state.currentActiveIndex + 1) % state.originalAccountOrder.length) === switchIndex;
let itemClasses = 'wplace-account-item';
if (isCurrentAccount) {
itemClasses += ' current';
} else if (isNextInSequence) {
itemClasses += ' next-in-sequence';
}
item.className = itemClasses;
// Create ordering number element with order ID
const orderNumber = document.createElement('div');
orderNumber.className = 'wplace-account-number';
orderNumber.textContent = info.orderId; // Always show the assigned order ID
// Add visual indicator for current position in sequence
if (isCurrentAccount) {
orderNumber.style.background = '#2ecc71'; // Green color
orderNumber.style.color = 'white';
orderNumber.style.boxShadow = '0 0 10px rgba(46, 204, 113, 0.8)';
orderNumber.title = `Order ID ${info.orderId} - Currently active account`;
// Add a subtle pulsing effect to the account number for extra visibility
orderNumber.style.animation = 'currentAccountNumberPulse 1.5s ease-in-out infinite alternate';
} else if (isNextInSequence) {
orderNumber.style.background = '#f39c12'; // Orange color
orderNumber.style.color = 'white';
orderNumber.style.boxShadow = '0 0 8px rgba(243, 156, 18, 0.6)';
orderNumber.title = `Order ID ${info.orderId} - Next account in switching sequence`;
} else {
orderNumber.style.background = ''; // Reset to default
orderNumber.style.boxShadow = ''; // Remove any glow
orderNumber.title = `Order ID ${info.orderId}`;
orderNumber.style.animation = ''; // Remove any animation
}
const displayName = info.displayName || `Account ${index + 1}`;
const details = document.createElement('div');
details.className = 'wplace-account-details';
const nameDiv = document.createElement('div');
nameDiv.className = 'wplace-account-name';
nameDiv.textContent = displayName;
nameDiv.title = displayName;
details.appendChild(nameDiv);
let stats;
if (info.error) {
stats = document.createElement('div');
stats.className = 'wplace-account-stats';
stats.style.color = 'red';
stats.textContent = 'Error';
} else {
stats = document.createElement('div');
stats.className = 'wplace-account-stats';
stats.innerHTML = `
${Math.floor(info.Charges || 0)}/${Math.floor(info.Max || 0)} ${Math.floor(info.Droplets || 0)}
`;
}
item.appendChild(orderNumber);
item.appendChild(details);
item.appendChild(stats);
accountsListArea.appendChild(item);
});
}
// Account switching helper functions
async function switchToNextAccount(accounts) {
const previousActiveIndex = state.currentActiveIndex;
// Move to next account in strict ID order (next position in switching order)
const nextIndex = (state.currentActiveIndex + 1) % state.originalAccountOrder.length;
// Get the account info from switching order
const nextAccountInfo = state.originalAccountOrder[nextIndex];
const prevAccountInfo = state.originalAccountOrder[previousActiveIndex];
// Strict validation: ensure ID 1 always goes to ID 2
if (prevAccountInfo?.orderId === 1 && nextAccountInfo?.orderId !== 2) {
console.error(`โ STRICT ORDER VIOLATION: ID 1 must switch to ID 2, but found ID ${nextAccountInfo?.orderId}`);
// Find ID 2 account
const id2Account = state.originalAccountOrder.find(acc => acc.orderId === 2);
if (id2Account) {
const id2Index = state.originalAccountOrder.indexOf(id2Account);
console.log(`๐ Correcting: Forcing switch from ID 1 to ID 2 (index ${id2Index})`);
state.currentActiveIndex = id2Index;
} else {
console.error(`โ FATAL: No ID 2 account found in switching order!`);
return false;
}
} else {
state.currentActiveIndex = nextIndex;
}
// Re-get account info after potential correction
const finalNextAccountInfo = state.originalAccountOrder[state.currentActiveIndex];
console.log(`๐ Switching from ID ${prevAccountInfo?.orderId} (${prevAccountInfo?.displayName || 'Unknown'}) to ID ${finalNextAccountInfo?.orderId} (${finalNextAccountInfo?.displayName || 'Unknown'})`);
console.log(`๐ Index progression: ${previousActiveIndex} โ ${state.currentActiveIndex} (order: ${prevAccountInfo?.orderId} โ ${finalNextAccountInfo?.orderId})`);
// Find the token for this account by matching the account in the original allAccountsInfo array
const originalAccountIndex = state.allAccountsInfo.findIndex(acc => acc.orderId === finalNextAccountInfo?.orderId);
if (originalAccountIndex === -1) {
console.warn(`โ ๏ธ Could not find account with ID ${finalNextAccountInfo?.orderId} in original array`);
return false;
}
const nextToken = finalNextAccountInfo?.token;
console.log(`๐ Next token for ID ${finalNextAccountInfo?.orderId}: ${nextToken ? nextToken.substring(0, 20) + '...' : 'INVALID'}`);
console.log(`๐ Expected account: ${finalNextAccountInfo?.displayName} (ID ${finalNextAccountInfo?.orderId})`);
if (!nextToken) {
console.warn(`โ ๏ธ Invalid token for ID ${finalNextAccountInfo?.orderId}, skipping...`);
return false;
}
// Update accountIndex to match the position in the accounts token array
const accountsIndex = accounts.findIndex(token => token === nextToken);
if (accountsIndex !== -1) {
state.accountIndex = accountsIndex;
console.log(`๐ Found token at accounts array index: ${accountsIndex}`);
console.log(`๐ Token from accounts[${accountsIndex}]: ${accounts[accountsIndex].substring(0, 20)}...`);
} else {
console.warn(`โ ๏ธ Token not found in accounts array for ID ${finalNextAccountInfo?.orderId}`);
console.warn(`๐ Available accounts tokens: ${accounts.map((t, i) => `[${i}] ${t.substring(0, 20)}...`).join(', ')}`);
return false;
}
return await switchToSpecificAccount(nextToken, state.currentActiveIndex);
}
async function switchToSpecificAccount(token, originalOrderIndex) {
// Get account name and ID for better logging
const accountInfo = state.originalAccountOrder && state.originalAccountOrder[originalOrderIndex]
? state.originalAccountOrder[originalOrderIndex]
: null;
const accountName = accountInfo?.displayName || `Account ${originalOrderIndex + 1}`;
const accountId = accountInfo?.orderId || (originalOrderIndex + 1);
console.log(`๐ Attempting to switch to ID ${accountId} (${accountName})`);
console.log(`๐ Using token: ${token.substring(0, 20)}...`);
await swapAccountTrigger(token);
// Give more time for the initial swap to process (like Acc-Switch.js)
// console.log('โณ Waiting 5 seconds for initial swap processing...');
// await new Promise(resolve => setTimeout(resolve, 5000));
let maxRetries = 20; // Match Acc-Switch.js retry count
let retryCount = 0;
let swapSuccess = false;
while (!swapSuccess && retryCount < maxRetries) {
console.log(`โณ Waiting for account swap... (Attempt ${retryCount + 1}/${maxRetries})`);
// Wait for a short period before checking (like Acc-Switch.js)
await new Promise(resolve => setTimeout(resolve, 1000));
try {
// Use the same verification method as Acc-Switch.js
await fetchAccount();
console.log('โ Account swap confirmed.');
swapSuccess = true;
} catch (error) {
console.warn('โ Account swap not yet successful. Retrying...', error);
retryCount++;
// Re-trigger swap every 5 attempts
if (retryCount % 5 === 0) {
console.log('๐ Re-triggering account swap...');
await swapAccountTrigger(token);
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
}
if (swapSuccess) {
const { charges, cooldown } = await WPlaceService.getCharges();
state.displayCharges = Math.floor(charges);
state.preciseCurrentCharges = charges;
state.cooldown = cooldown;
// Don't update state.accountIndex here as it should be managed by the calling function
Utils.performSmartSave();
await updateStats();
// Update account list spotlight to show new current account
await updateCurrentAccountSpotlight();
console.log(`โ Successfully switched to ID ${accountId} (${accountName}) with ${Math.floor(charges)} charges`);
return true;
} else {
console.error(`โ Failed to swap to ID ${accountId} (${accountName}) after multiple retries.`);
return false;
}
}
// Wait for dependencies before initializing UI
async function waitForDependenciesAndInitialize() {
// Wait for all global managers to be available
let attempts = 0;
const maxAttempts = 50; // 5 seconds max wait
while (attempts < maxAttempts) {
const managers = {
utilsManager: window.globalUtilsManager,
imageProcessor: window.globalImageProcessor,
overlayManager: window.globalOverlayManager,
tokenManager: window.globalTokenManager
};
const missingManagers = Object.entries(managers)
.filter(([name, manager]) => !manager)
.map(([name]) => name);
if (missingManagers.length === 0) {
console.log('โ All global managers are available, initializing UI...');
break;
}
console.log(`โณ Waiting for managers: ${missingManagers.join(', ')} (attempt ${attempts + 1}/${maxAttempts})`);
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
if (attempts >= maxAttempts) {
console.warn('โ ๏ธ Some global managers not available after waiting, proceeding anyway...');
console.log('Available managers:', {
utilsManager: !!window.globalUtilsManager,
imageProcessor: !!window.globalImageProcessor,
overlayManager: !!window.globalOverlayManager,
tokenManager: !!window.globalTokenManager
});
}
return createUI();
}
waitForDependenciesAndInitialize().then(() => {
// Generate token automatically after UI is ready
setTimeout(initializeTokenGenerator, 1000);
// Auto-refresh account list on startup
setTimeout(() => {
console.log('๐ Auto-refreshing account list on startup...');
fetchAllAccountDetails();
}, 2000);
// Attach advanced color matching listeners (resize dialog)
const advancedInit = () => {
const chromaSlider = document.getElementById('chromaPenaltyWeightSlider');
const chromaValue = document.getElementById('chromaWeightValue');
const resetBtn = document.getElementById('resetAdvancedColorBtn');
const algoSelect = document.getElementById('colorAlgorithmSelect');
const chromaToggle = document.getElementById('enableChromaPenaltyToggle');
const transInput = document.getElementById('transparencyThresholdInput');
const whiteInput = document.getElementById('whiteThresholdInput');
const ditherToggle = document.getElementById('enableDitheringToggle');
if (algoSelect)
algoSelect.addEventListener('change', (e) => {
state.colorMatchingAlgorithm = e.target.value;
saveBotSettings();
_updateResizePreview();
});
if (chromaToggle)
chromaToggle.addEventListener('change', (e) => {
state.enableChromaPenalty = e.target.checked;
saveBotSettings();
_updateResizePreview();
});
if (chromaSlider && chromaValue)
chromaSlider.addEventListener('input', (e) => {
state.chromaPenaltyWeight = parseFloat(e.target.value) || 0.15;
chromaValue.textContent = state.chromaPenaltyWeight.toFixed(2);
saveBotSettings();
_updateResizePreview();
});
if (transInput)
transInput.addEventListener('change', (e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= 0 && v <= 255) {
state.customTransparencyThreshold = v;
CONFIG.TRANSPARENCY_THRESHOLD = v;
saveBotSettings();
_updateResizePreview();
}
});
if (whiteInput)
whiteInput.addEventListener('change', (e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= 200 && v <= 255) {
state.customWhiteThreshold = v;
CONFIG.WHITE_THRESHOLD = v;
saveBotSettings();
_updateResizePreview();
}
});
if (ditherToggle)
ditherToggle.addEventListener('change', (e) => {
state.ditheringEnabled = e.target.checked;
saveBotSettings();
_updateResizePreview();
});
if (resetBtn)
resetBtn.addEventListener('click', () => {
state.colorMatchingAlgorithm = 'lab';
state.enableChromaPenalty = true;
state.chromaPenaltyWeight = 0.15;
state.customTransparencyThreshold = CONFIG.TRANSPARENCY_THRESHOLD = 100;
state.customWhiteThreshold = CONFIG.WHITE_THRESHOLD = 250;
saveBotSettings();
const a = document.getElementById('colorAlgorithmSelect');
if (a) a.value = 'lab';
const ct = document.getElementById('enableChromaPenaltyToggle');
if (ct) ct.checked = true;
if (chromaSlider) chromaSlider.value = 0.15;
if (chromaValue) chromaValue.textContent = '0.15';
if (transInput) transInput.value = 100;
if (whiteInput) whiteInput.value = 250;
_updateResizePreview();
Utils.showAlert(Utils.t('advancedColorSettingsReset'), 'success');
});
};
// Delay to ensure resize UI built
setTimeout(advancedInit, 500);
// Add cleanup on page unload
window.addEventListener('beforeunload', () => {
Utils.cleanupTurnstile();
});
});
})();