import { h, JSX } from 'preact'; import { useState, useRef, useMemo, useCallback, useEffect } from 'preact/hooks'; import * as wasm from 'web-optimle'; import { clue, describeClue, type Clue, type GuessResult } from './clue'; import { gameName, speak } from './util'; import { COMMON_WORDS, UNCOMMON_WORDS } from './dictionary'; import * as PUZZLES from './puzzles.json'; import { Row, RowState } from './Row'; import { Keyboard } from './Keyboard'; import { useLocalStorage } from './localstorage'; const MAX_GUESSES = 2; const WORD_LENGTH = 5; const EPOCH = new Date(2022, 1, 27); enum GameState { Playing, Won, Lost, } interface GameProps { hidden: boolean; colorBlind: boolean; keyboardLayout: string; } type History = GuessResult[]; type DailyStats = { tries: number, won: boolean, winningGuesses?: GuessResult[], }; export function Game(props: GameProps) { const day = useMemo(() => { let date = new Date(); date.setHours(date.getHours() - 6); // treat 6 AM as the start of the day date.setHours(0); // now set it to midnight of the day return Math.round((date.getTime() - EPOCH.getTime()) / (24 * 60 * 60 * 1000)); }, []); const puzzle = PUZZLES[day]; const [gameState, setGameState] = useState(GameState.Playing); const [guesses, setGuesses] = useState([...puzzle.history]); const [currentGuess, setCurrentGuess] = useState(""); const [dailyStats, setDailyStats] = useLocalStorage<{ [key: number]: DailyStats }>("dailyTries", {}); const [todayStats, setTodayStats] = useState({ tries: 1, won: false }); const [hint, setHint] = useState(

Try to guarantee a win in 2 guesses!

); const tableRef = useRef(null); const game = useMemo(() => wasm.Game.new(COMMON_WORDS, puzzle.history), []); const [gameTree, setGameTree] = useState(); const reset = useCallback(() => { game.reset(); setGuesses([...puzzle.history]); setCurrentGuess(""); setGameState(GameState.Playing); setTodayStats((oldStats) => ({ ...oldStats, tries: oldStats.tries + 1, })); }, []); useEffect(() => { const storedTodayStats = dailyStats[day]; if (storedTodayStats !== undefined) { setTodayStats(storedTodayStats); if (storedTodayStats.won) { setGameState(GameState.Won); setHint("You already won today's puzzle!"); if (storedTodayStats.winningGuesses) { setGuesses([ ...puzzle.history, ...storedTodayStats.winningGuesses, ]); storedTodayStats.winningGuesses.forEach( (result) => game.guess(result.word) ); } } } }, []); useEffect(() => { setDailyStats({ ...dailyStats, [day]: todayStats, }); }, [todayStats]); async function share(copiedHint: string, text?: string) { const url = window.location.origin + window.location.pathname; const body = `${text}\n${url}`; if ( /android|iphone|ipad|ipod|webos/i.test(navigator.userAgent) && !/firefox/i.test(navigator.userAgent) ) { try { await navigator.share({ text: body }); return; } catch (e) { console.warn("navigator.share failed:", e); } } try { await navigator.clipboard.writeText(body); setHint(copiedHint); return; } catch (e) { console.warn("navigator.clipboard.writeText failed:", e); } setHint(url); } const onKey = (key: string) => { if (gameState !== GameState.Playing) { if (key === "Enter" && gameState === GameState.Lost) { reset(); } return; } if (guesses.length === MAX_GUESSES) return; if (/^[a-z]$/i.test(key)) { // We're about to update the current guess, but // we can't rely on the value updating in this render const newGuess = currentGuess + key.toLowerCase(); setCurrentGuess((guess) => (guess + key.toLowerCase()).slice(0, WORD_LENGTH) ); tableRef.current?.focus(); if (newGuess.length === WORD_LENGTH && !COMMON_WORDS.includes(newGuess) && UNCOMMON_WORDS.includes(newGuess) ) { setHint(`(The word ${newGuess.toUpperCase()} isn't in the secret list)`); } else { setHint(""); } } else if (key === "Backspace") { setCurrentGuess((guess) => guess.slice(0, -1)); setHint(""); } else if (key === "Enter") { if (currentGuess.length !== WORD_LENGTH) { setHint("Too short"); return; } if (!COMMON_WORDS.includes(currentGuess) && !UNCOMMON_WORDS.includes(currentGuess) ) { setHint("Not a valid word"); return; } const matches: number[] = game.guess(currentGuess); const result: GuessResult = { word: currentGuess, matches: matches, } setGuesses((guesses) => guesses.concat([result])); setCurrentGuess(""); if (matches.every((m) => m === 2)) { if (todayStats.tries === 1) { setHint("You won on your first try! Amazing job."); } else { setHint(`You won in ${todayStats.tries} tries! Great job.`); } setGameState(GameState.Won); setTodayStats((oldStats) => ({ ...oldStats, won: true, winningGuesses: [ ...guesses.slice(puzzle.history.length), result, ], })) } else if (guesses.length + 1 === puzzle.history.length + MAX_GUESSES) { setHint(

Not quite! The answer could've been {game.possible_word().toUpperCase()}. (Enter to try again)

); setGameState(GameState.Lost); } else { speak(describeClue(clue(result))); } } }; useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (!e.ctrlKey && !e.metaKey) { onKey(e.key); } if (e.key === "Backspace") { e.preventDefault(); } }; document.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("keydown", onKeyDown); }; }, [currentGuess, gameState]); let letterInfo = new Map(); const tableRows = Array(puzzle.history.length + MAX_GUESSES) .fill(undefined) .map((_, i) => { const isPartOfPuzzle = i < puzzle.history.length; const result = [ ...guesses, { word: currentGuess, matches: [0, 0, 0, 0, 0] }, ][i] ?? { word: "", matches: [0, 0, 0, 0, 0] }; const cluedLetters = clue(result); const lockedIn = i < guesses.length; if (lockedIn) { for (const { clue, letter } of cluedLetters) { if (clue === undefined) break; const old = letterInfo.get(letter); if (old === undefined || clue > old) { letterInfo.set(letter, clue); } } } return ( ); }); return (
{tableRows}

{hint || `\u00a0`}

{gameState === GameState.Won && (

)}

{gameTree && (

Here are all the ways the game could've responded:

{gameTree?.map((option) =>
{option.map((guessResult, i) => { const cluedLetters = clue(guessResult); return ( ); })}
)}
)}

Today's puzzle (#{puzzle.seed}) has {puzzle.solutions} path{puzzle.solutions === 1 ? '' : 's'} to a win.

); }