word-waltz / commit
build fix
cspell.json | 3 +- src/components/WordleBoard.ts | 181 +++++++++++++++++++++++++++++++++++++++++- src/main.ts | 15 +++- src/utils/wordList.ts | 1 + 4 files changed, 193 insertions(+), 7 deletions(-) diff --git a/cspell.json b/cspell.json index b59a05e..dc55e84 100644 --- a/cspell.json +++ b/cspell.json @@ -23,6 +23,7 @@ "ignorePaths": [ "node_modules", "dist", - "package-lock.json" + "package-lock.json", + "src/utils/wordList.ts" ] } \ No newline at end of file diff --git a/src/components/WordleBoard.ts b/src/components/WordleBoard.ts index 2cd9e08..3a468b9 100644 --- a/src/components/WordleBoard.ts +++ b/src/components/WordleBoard.ts @@ -5,6 +5,11 @@ * Each cell is colored based on tile state: 'correct', 'present', or 'absent'. */ +import { mountKeyboard, updateKeyboard } from './Keyboard.js'; +import type { LetterState } from './Keyboard.js'; +import { isValidWord } from '../utils/wordList.js'; +import { calculateScore } from '../utils/scoring.js'; + interface RoundResult { solved: boolean; guesses: string[]; @@ -16,8 +21,178 @@ interface WordleBoardProps { onRoundEnd: (result: RoundResult) => void; } -// TODO: implement board rendering and guess logic +const MAX_GUESSES = 6; +const WORD_LEN = 5; +const PRAISE = ['Genius!', 'Magnificent!', 'Impressive!', 'Splendid!', 'Great!', 'Lucky!']; + +type TileState = LetterState | 'filled' | ''; + +const STATE_PRIORITY: Record<LetterState, number> = { correct: 3, present: 2, absent: 1 }; + +function evaluate(guess: string, secret: string): TileState[] { + const result: TileState[] = Array(WORD_LEN).fill('absent'); + const remaining = secret.split(''); + + // Pass 1 — exact matches + for (let i = 0; i < WORD_LEN; i++) { + if (guess[i] === secret[i]) { + result[i] = 'correct'; + remaining[i] = ''; + } + } + + // Pass 2 — right letter, wrong position + for (let i = 0; i < WORD_LEN; i++) { + if (result[i] === 'correct') continue; + const idx = remaining.indexOf(guess[i]); + if (idx !== -1) { + result[i] = 'present'; + remaining[idx] = ''; + } + } + return result; +} + +function mergeLetterState(cur: LetterState | undefined, next: LetterState): LetterState { + if (!cur) return next; + return STATE_PRIORITY[next] > STATE_PRIORITY[cur] ? next : cur; +} + export function mountWordleBoard(container: HTMLElement, props: WordleBoardProps): () => void { - // TODO - return function cleanup() { }; + const { secretWord, onRoundEnd } = props; + const secret = secretWord.toUpperCase(); + + // --- DOM --- + const root = document.createElement('div'); + root.className = 'wordle-board'; + + const grid = document.createElement('div'); + grid.className = 'wordle-board__grid'; + + // Build 6×5 tile cells + const tiles: HTMLElement[][] = []; + for (let r = 0; r < MAX_GUESSES; r++) { + const row = document.createElement('div'); + row.className = 'wordle-board__row'; + const rowTiles: HTMLElement[] = []; + for (let c = 0; c < WORD_LEN; c++) { + const tile = document.createElement('div'); + tile.className = 'wordle-board__tile'; + row.appendChild(tile); + rowTiles.push(tile); + } + grid.appendChild(row); + tiles.push(rowTiles); + } + + const message = document.createElement('p'); + message.className = 'wordle-board__message'; + + const keyboardEl = document.createElement('div'); + + root.appendChild(grid); + root.appendChild(message); + root.appendChild(keyboardEl); + container.appendChild(root); + + // --- State --- + let currentRow = 0; + let currentInput = ''; + let done = false; + const letterStates: Record<string, LetterState> = {}; + const guesses: string[] = []; + + // Mount keyboard — it sends keys back via onKey + const cleanupKeyboard = mountKeyboard(keyboardEl, { + onKey: handleKey, + letterStates, + }); + + // --- Input handling --- + function handleKey(key: string): void { + if (done) return; + if (key === 'Backspace') { + currentInput = currentInput.slice(0, -1); + redrawCurrentRow(); + } else if (key === 'Enter') { + submitGuess(); + } else if (/^[A-Z]$/.test(key) && currentInput.length < WORD_LEN) { + currentInput += key; + redrawCurrentRow(); + } + } + + function redrawCurrentRow(): void { + tiles[currentRow].forEach((tile, i) => { + const letter = currentInput[i] ?? ''; + tile.textContent = letter; + tile.className = 'wordle-board__tile' + (letter ? ' wordle-board__tile--filled' : ''); + }); + } + + function submitGuess(): void { + if (currentInput.length !== WORD_LEN) { + flash('Not enough letters'); + return; + } + const guess = currentInput; + if (!isValidWord(guess)) { + flash('Not in word list'); + return; + } + + const states = evaluate(guess, secret); + + // Color tiles + tiles[currentRow].forEach((tile, i) => { + tile.textContent = guess[i]; + tile.className = `wordle-board__tile wordle-board__tile--${states[i]}`; + }); + + // Merge letter states for keyboard + states.forEach((s, i) => { + if (s === 'correct' || s === 'present' || s === 'absent') { + letterStates[guess[i]] = mergeLetterState(letterStates[guess[i]], s); + } + }); + updateKeyboard(keyboardEl, letterStates); + + guesses.push(guess); + currentRow++; + currentInput = ''; + + const solved = states.every((s) => s === 'correct'); + if (solved) { + done = true; + message.textContent = PRAISE[Math.min(guesses.length - 1, PRAISE.length - 1)]; + scheduleRoundEnd(true); + } else if (currentRow >= MAX_GUESSES) { + done = true; + message.textContent = `The word was ${secret}`; + scheduleRoundEnd(false); + } + } + + function flash(text: string): void { + message.textContent = text; + setTimeout(() => { message.textContent = ''; }, 1200); + } + + let endTimer: ReturnType<typeof setTimeout> | null = null; + + function scheduleRoundEnd(solved: boolean): void { + endTimer = setTimeout(() => { + onRoundEnd({ + solved, + guesses, + score: calculateScore(guesses.length, solved), + }); + }, 1600); + } + + return function cleanup() { + if (endTimer !== null) clearTimeout(endTimer); + cleanupKeyboard(); + container.removeChild(root); + }; } diff --git a/src/main.ts b/src/main.ts index d4a8844..827cb65 100644 --- a/src/main.ts +++ b/src/main.ts @@ -206,16 +206,25 @@ function showNewGameScreen(app: HTMLElement): void { }; const envelope = await encrypt(JSON.stringify(initialState), cryptoKey); - await createGame(gameId, envelope); + + // Race the Firestore write against a 10 s timeout so we surface + // errors (e.g. database not created, network issue) instead of + // hanging on "Creating…" forever. + const timeout = new Promise<never>((_, reject) => + setTimeout(() => reject(new Error('Firestore write timed out — check that the Firestore database exists in the Firebase console and that security rules allow writes.')), 10_000), + ); + await Promise.race([createGame(gameId, envelope), timeout]); + setRole(gameId, 'a'); // Navigate — key is in the fragment and is never sent to the server window.location.href = buildLink(gameId, base64urlKey); } catch (err) { - console.error(err); + console.error('Game creation failed:', err); btn.disabled = false; btn.textContent = 'Create game'; - showError(app, 'Failed to create game. Please try again.'); + const msg = err instanceof Error ? err.message : 'Failed to create game. Please try again.'; + showError(app, msg); } }); } diff --git a/src/utils/wordList.ts b/src/utils/wordList.ts index 40b5d65..914c730 100644 --- a/src/utils/wordList.ts +++ b/src/utils/wordList.ts @@ -1,3 +1,4 @@ +// cspell:disable const WORDS = new Set<string>([ "aalii", "abaca",