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<GuessResult[]>([...puzzle.history]); const [currentGuess, setCurrentGuess] = useState<string>(""); const [dailyStats, setDailyStats] = useLocalStorage<{ [key: number]: DailyStats }>("dailyTries", {}); const [todayStats, setTodayStats] = useState<DailyStats>({ tries: 1, won: false }); const [hint, setHint] = useState<JSX.Element | string>( <p> Try to guarantee a win in <strong>2 guesses</strong>! </p> ); const tableRef = useRef<HTMLTableElement>(null); const game = useMemo(() => wasm.Game.new(COMMON_WORDS, puzzle.history), []); const [gameTree, setGameTree] = useState<GuessResult[][]>(); 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(<p>Not quite! The answer could've been <strong>{game.possible_word().toUpperCase()}</strong>. (Enter to try again)</p>); 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<string, Clue>(); 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 ( <Row key={i} wordLength={WORD_LENGTH} rowState={ lockedIn ? RowState.LockedIn : i === guesses.length ? RowState.Editing : RowState.Pending } cluedLetters={cluedLetters} isPartOfPuzzle={isPartOfPuzzle} /> ); }); return ( <div className="Game" style={{ display: props.hidden ? "none" : "block" }}> <table className="Game-rows" tabIndex={0} aria-label="Table of guesses" ref={tableRef} > <tbody>{tableRows}</tbody> </table> <p role="alert" style={{ userSelect: "none", whiteSpace: "pre-wrap", }} > {hint || `\u00a0`} </p> <Keyboard layout={props.keyboardLayout} letterInfo={letterInfo} onKey={onKey} /> <p> {gameState === GameState.Won && ( <div class="win-buttons"> <button onClick={() => { const emoji = props.colorBlind ? ["⬛", "🟦", "🟧"] : ["⬛", "🟨", "🟩"]; const score = todayStats.tries === 1 ? "my first try!" : `try #${todayStats.tries}!`; share( "Result copied to clipboard!", `I solved ${gameName} #${puzzle.seed} on ${score}\n` + guesses .slice(puzzle.history.length) .map((result) => clue(result) .map((c) => emoji[c.clue ?? 0]) .join("") ) .join("\n") ); }} > Share results </button> <button onClick={() => setGameTree(game.explore_game_tree())}> Explore game tree </button> </div> )} </p> {gameTree && ( <div className="game-tree"> <p>Here are all the ways the game could've responded:</p> {gameTree?.map((option) => <div className="game-tree-option"> {option.map((guessResult, i) => { const cluedLetters = clue(guessResult); return ( <Row key={i} wordLength={WORD_LENGTH} rowState={RowState.LockedIn} cluedLetters={cluedLetters} isPartOfPuzzle={false} /> ); })} </div> )} </div> )} <p> Today's puzzle (#{puzzle.seed}) has {puzzle.solutions} path{puzzle.solutions === 1 ? '' : 's'} to a win. </p> </div> ); }