Add secret auto-solver unlocked via Konami code

Logical-only solver (naked + hidden singles, no backtracking) fills one
cell every ~2-3s with a blink animation, so progress is only made when
the deduction is certain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Duran 2026-04-26 13:50:55 +02:00
parent 2243abbb22
commit b2cc21a354
7 changed files with 222 additions and 3 deletions

View File

@ -201,6 +201,22 @@ body {
}
}
/* Auto-solve blink animation */
.cell.auto-blinking .cell-value {
animation: autoFillBlink 0.4s ease-in-out infinite;
}
@keyframes autoFillBlink {
0%, 100% {
opacity: 1;
text-shadow: 0 0 10px rgba(255, 152, 0, 0.7);
}
50% {
opacity: 0.25;
text-shadow: 0 0 0 transparent;
}
}
@keyframes celebrateText {
0% {
transform: scale(1);

View File

@ -7,6 +7,7 @@ import WinModal from './components/WinModal'
import Confetti from './components/Confetti'
import SettingsModal from './components/SettingsModal'
import { useGame } from './hooks/useGame'
import { findCertainMove } from './utils/sudoku'
function App() {
const {
@ -23,6 +24,7 @@ function App() {
toggleDarkMode,
undo,
redo,
autoFill,
} = useGame()
const [showSettings, setShowSettings] = useState(false)
@ -30,6 +32,10 @@ function App() {
const saved = parseInt(localStorage.getItem('sudoku-hue'), 10)
return isNaN(saved) ? 211 : saved
})
const [solveUnlocked, setSolveUnlocked] = useState(false)
const [autoSolving, setAutoSolving] = useState(false)
const [autoBlinkCell, setAutoBlinkCell] = useState(null)
const [autoCooldown, setAutoCooldown] = useState(false)
useEffect(() => {
document.body.style.background = state.darkMode ? '#1a1a2e' : '#f8f9fa'
@ -50,6 +56,71 @@ function App() {
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
useEffect(() => {
if (solveUnlocked) return
const sequence = [
'ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown',
'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight',
'b', 'a',
]
let progress = 0
function onKeyDown(e) {
const expected = sequence[progress]
const key = expected.length === 1 ? e.key.toLowerCase() : e.key
if (key === expected) {
progress++
if (progress === sequence.length) {
setSolveUnlocked(true)
}
} else {
progress = key === sequence[0] ? 1 : 0
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [solveUnlocked])
useEffect(() => {
if (!autoSolving) {
setAutoBlinkCell(null)
setAutoCooldown(false)
}
}, [autoSolving])
useEffect(() => {
if (!autoSolving) return
if (state.isComplete) {
setAutoSolving(false)
return
}
if (autoBlinkCell || autoCooldown) return
const move = findCertainMove(state.board)
if (!move) {
setAutoSolving(false)
return
}
autoFill(move.row, move.col, move.value)
setAutoBlinkCell({ row: move.row, col: move.col })
}, [autoSolving, state.board, state.isComplete, autoBlinkCell, autoCooldown, autoFill])
useEffect(() => {
if (!autoBlinkCell) return
const blinkMs = 1000 + Math.random() * 1000
const t = setTimeout(() => {
setAutoBlinkCell(null)
setAutoCooldown(true)
}, blinkMs)
return () => clearTimeout(t)
}, [autoBlinkCell])
useEffect(() => {
if (!autoCooldown) return
const cooldownMs = 1000 + Math.random() * 1000
const t = setTimeout(() => setAutoCooldown(false), cooldownMs)
return () => clearTimeout(t)
}, [autoCooldown])
return (
<div className={`app${state.darkMode ? ' dark' : ''}`} style={{ '--hue': hue }}>
<header className="app-header">
@ -73,6 +144,7 @@ function App() {
highlightedNumber={state.highlightedNumber}
showErrors={state.showErrors}
celebrating={state.showEffects ? state.celebrating : new Set()}
autoBlinkCell={autoBlinkCell}
selectCell={selectCell}
deselect={deselect}
/>
@ -92,6 +164,9 @@ function App() {
canUndo={state.history.length > 0}
canRedo={state.future.length > 0}
onOpenSettings={() => setShowSettings(true)}
solveUnlocked={solveUnlocked}
autoSolving={autoSolving}
onToggleAutoSolve={() => setAutoSolving(s => !s)}
/>
</main>

View File

@ -2,7 +2,7 @@ import React from 'react'
import Cell from './Cell'
import { checkValue } from '../utils/sudoku'
function Board({ board, puzzle, solution, notes, selected, highlightedNumber, showErrors, celebrating, selectCell, deselect }) {
function Board({ board, puzzle, solution, notes, selected, highlightedNumber, showErrors, celebrating, autoBlinkCell, selectCell, deselect }) {
const selectedValue = selected && board[selected.row][selected.col]
const activeNumber = selected ? selectedValue : highlightedNumber
@ -31,6 +31,8 @@ function Board({ board, puzzle, solution, notes, selected, highlightedNumber, sh
value !== 0 &&
!checkValue(solution, r, c, value)
const isCelebrating = celebrating.has(`${r},${c}`)
const isAutoBlinking =
autoBlinkCell && autoBlinkCell.row === r && autoBlinkCell.col === c
return (
<Cell
@ -45,6 +47,7 @@ function Board({ board, puzzle, solution, notes, selected, highlightedNumber, sh
isSameNumber={isSameNumber}
isError={isError}
isCelebrating={isCelebrating}
isAutoBlinking={isAutoBlinking}
onClick={(e) => e.ctrlKey || e.metaKey ? deselect() : selectCell(r, c)}
/>
)

View File

@ -1,6 +1,6 @@
import React from 'react'
function Cell({ value, notes, isGiven, isSelected, isHighlightedRow, isHighlightedCol, isHighlightedBox, isSameNumber, isError, isCelebrating, onClick }) {
function Cell({ value, notes, isGiven, isSelected, isHighlightedRow, isHighlightedCol, isHighlightedBox, isSameNumber, isError, isCelebrating, isAutoBlinking, onClick }) {
const classes = ['cell']
if (isGiven) classes.push('given')
if (isSelected) classes.push('selected')
@ -10,6 +10,7 @@ function Cell({ value, notes, isGiven, isSelected, isHighlightedRow, isHighlight
if (isSameNumber) classes.push('same-number')
if (isError) classes.push('error')
if (isCelebrating) classes.push('celebrating')
if (isAutoBlinking) classes.push('auto-blinking')
return (
<div className={classes.join(' ')} onClick={onClick}>

View File

@ -8,7 +8,7 @@ const DIFFICULTIES = [
{ value: 'expert', label: 'Expert' },
]
function GameControls({ currentDifficulty, onNewGame, onClear, onUndo, onRedo, canUndo, canRedo, onOpenSettings }) {
function GameControls({ currentDifficulty, onNewGame, onClear, onUndo, onRedo, canUndo, canRedo, onOpenSettings, solveUnlocked, autoSolving, onToggleAutoSolve }) {
const [difficulty, setDifficulty] = useState(currentDifficulty)
const [showConfirm, setShowConfirm] = useState(false)
@ -32,6 +32,11 @@ function GameControls({ currentDifficulty, onNewGame, onClear, onUndo, onRedo, c
<button className="btn btn-primary" onClick={() => setShowConfirm(true)}>
Nova Partida
</button>
{solveUnlocked && (
<button className="btn btn-secondary" onClick={onToggleAutoSolve}>
{autoSolving ? 'Atura' : 'Resol'}
</button>
)}
</div>
<div className="controls-row">
<button className="btn btn-secondary" onClick={onClear} title="Esborra">

View File

@ -243,6 +243,40 @@ function reducer(state, action) {
}
}
case 'AUTO_FILL': {
if (state.isComplete) return state
const { row, col, value } = action
if (state.puzzle[row][col] !== 0) return state
if (state.board[row][col] !== 0) return state
const histUpdate = pushHistory(state)
const newBoard = cloneBoard(state.board)
const newNotes = cloneNotes(state.notes)
newBoard[row][col] = value
newNotes[row][col] = new Set()
const complete = isBoardComplete(newBoard, state.solution)
const prevKeys = findCompletedGroupKeys(state.board, state.solution)
const newKeys = findCompletedGroupKeys(newBoard, state.solution)
const freshKeys = new Set()
for (const key of newKeys) {
if (!prevKeys.has(key)) freshKeys.add(key)
}
const celebrating = cellsFromGroupKeys(freshKeys)
return {
...state,
...histUpdate,
selected: { row, col },
board: newBoard,
notes: newNotes,
isComplete: complete,
isRunning: !complete,
celebrating,
}
}
case 'CLEAR_CELL': {
if (!state.selected || state.isComplete) return state
const { row, col } = state.selected
@ -390,6 +424,10 @@ export function useGame() {
dispatch({ type: 'REDO' })
}, [])
const autoFill = useCallback((row, col, value) => {
dispatch({ type: 'AUTO_FILL', row, col, value })
}, [])
const moveSelection = useCallback((dr, dc) => {
dispatch({ type: 'MOVE_SELECTION', dr, dc })
}, [])
@ -481,6 +519,7 @@ export function useGame() {
toggleDarkMode,
undo,
redo,
autoFill,
}
}

View File

@ -127,3 +127,83 @@ export function isBoardComplete(board, solution) {
}
return true
}
export function findCertainMove(board) {
const candidates = Array.from({ length: 9 }, () =>
Array.from({ length: 9 }, () => null)
)
for (let r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) {
if (board[r][c] !== 0) continue
const cands = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9])
for (let i = 0; i < 9; i++) {
cands.delete(board[r][i])
cands.delete(board[i][c])
}
const br = Math.floor(r / 3) * 3
const bc = Math.floor(c / 3) * 3
for (let rr = br; rr < br + 3; rr++) {
for (let cc = bc; cc < bc + 3; cc++) {
cands.delete(board[rr][cc])
}
}
candidates[r][c] = cands
}
}
for (let r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) {
const cands = candidates[r][c]
if (cands && cands.size === 1) {
return { row: r, col: c, value: [...cands][0] }
}
}
}
for (let r = 0; r < 9; r++) {
for (let n = 1; n <= 9; n++) {
let count = 0, lastCol = -1
for (let c = 0; c < 9; c++) {
if (candidates[r][c] && candidates[r][c].has(n)) {
count++
lastCol = c
}
}
if (count === 1) return { row: r, col: lastCol, value: n }
}
}
for (let c = 0; c < 9; c++) {
for (let n = 1; n <= 9; n++) {
let count = 0, lastRow = -1
for (let r = 0; r < 9; r++) {
if (candidates[r][c] && candidates[r][c].has(n)) {
count++
lastRow = r
}
}
if (count === 1) return { row: lastRow, col: c, value: n }
}
}
for (let br = 0; br < 3; br++) {
for (let bc = 0; bc < 3; bc++) {
for (let n = 1; n <= 9; n++) {
let count = 0, lastR = -1, lastC = -1
for (let r = br * 3; r < br * 3 + 3; r++) {
for (let c = bc * 3; c < bc * 3 + 3; c++) {
if (candidates[r][c] && candidates[r][c].has(n)) {
count++
lastR = r
lastC = c
}
}
}
if (count === 1) return { row: lastR, col: lastC, value: n }
}
}
}
return null
}