Pārlūkot izejas kodu

feat: restore same-day daily game on return

Persist currentGame.wordId immediately when a new game starts (raw/12) and restore the day's game from localStorage: prefill guesses while in progress, show results when finished. Persist the 5th-guess hint and revealed word.

The results screen now shows a read-only guess grid with statistics moved to the bottom (raw/11). Also add "грант" to the RU guessable list.

Co-Authored-By: Claude <noreply@anthropic.com>
Oleg Panashchenko 1 mēnesi atpakaļ
vecāks
revīzija
bf6470d09d

+ 61 - 18
client/src/App.tsx

@@ -22,29 +22,62 @@ export default function App() {
   const {
     guesses, gameStatus, message, hint, lastDifficulty, revealedWord, replays,
     submitGuess, dismissMessage, resetForNewGame,
-  } = useGame(state.currentGame?.guesses ?? []);
+  } = useGame(
+    state.currentGame?.guesses ?? [],
+    state.currentGame?.hint,
+    state.currentGame?.revealedWord,
+  );
   const initialised = useRef(false);
 
-  // Initialize from persisted state
+  // Initialize from persisted state — compare stored word with today's daily word
   useEffect(() => {
     if (initialised.current) return;
     initialised.current = true;
 
-    if (state.currentGame) {
-      setWordId(state.currentGame.wordId);
-      setScreen('game');
-      return;
-    }
+    const stored = state.currentGame;
 
     if (!state.rulesSeen) {
+      // First visit — show rules. Still fetch daily in background.
       setScreen('rules');
+      getDaily().then((res) => {
+        if (stored && stored.wordId === res.wordId) {
+          setWordId(res.wordId);
+          if (stored.outcome) setScreen('results');
+          else setScreen('game');
+        } else {
+          if (stored) updateState({ currentGame: undefined });
+          setWordId(res.wordId);
+        }
+      }).catch(() => {
+        if (stored) {
+          setWordId(stored.wordId);
+          setScreen(stored.outcome ? 'results' : 'game');
+        }
+      });
       return;
     }
 
-    setScreen('game');
+    // Returning visitor — fetch daily and compare
     getDaily()
-      .then((res) => setWordId(res.wordId))
-      .catch(() => {});
+      .then((res) => {
+        if (stored && stored.wordId === res.wordId) {
+          // Same word as stored — restore game or show results
+          setWordId(res.wordId);
+          setScreen(stored.outcome ? 'results' : 'game');
+        } else {
+          // New word — fresh start
+          if (stored) updateState({ currentGame: undefined });
+          setWordId(res.wordId);
+          setScreen('game');
+        }
+      })
+      .catch(() => {
+        // Offline — restore from stored if available
+        if (stored) {
+          setWordId(stored.wordId);
+          setScreen(stored.outcome ? 'results' : 'game');
+        }
+      });
   }, [state]);
 
   // Compute letter colors for keyboard
@@ -70,14 +103,20 @@ export default function App() {
   // Persist game state on changes
   useEffect(() => {
     if (wordId !== null && guesses.length > 0 && gameStatus === 'playing') {
-      updateState({ currentGame: { wordId, guesses } });
+      updateState({ currentGame: { wordId, guesses, hint: hint ?? undefined } });
     }
-  }, [guesses, wordId, gameStatus, updateState]);
+  }, [guesses, wordId, gameStatus, hint, updateState]);
 
   // Handle game end — compute stats and running-average skill metric
   useEffect(() => {
     if (gameStatus !== 'won' && gameStatus !== 'lost') return;
 
+    // Already recorded for this game (e.g., restored from persistence) — don't double-count
+    if (state.currentGame?.outcome) {
+      setScreen('results');
+      return;
+    }
+
     const stats = { ...state.stats };
     stats.gamesPlayed++;
 
@@ -104,7 +143,7 @@ export default function App() {
 
     updateState({
       stats,
-      currentGame: undefined,
+      currentGame: { wordId: wordId!, guesses, hint: hint ?? undefined, revealedWord: revealedWord ?? undefined, outcome: gameStatus },
       skillMetric: newSkill,
     });
     setScreen('results');
@@ -113,22 +152,25 @@ export default function App() {
   const handlePlay = useCallback(() => {
     updateState({ rulesSeen: true });
     setScreen('game');
-    getDaily()
-      .then((res) => setWordId(res.wordId))
-      .catch(() => {});
-  }, [updateState]);
+    if (wordId === null) {
+      getDaily()
+        .then((res) => setWordId(res.wordId))
+        .catch(() => {});
+    }
+  }, [updateState, wordId]);
 
   const handlePlayAgain = useCallback(async () => {
     const skillMetric = state.skillMetric;
     try {
       const res = await postPlayAgain({ skillMetric });
+      updateState({ currentGame: undefined });
       setWordId(res.wordId);
       resetForNewGame();
       setScreen('game');
     } catch {
       // If server unreachable, stay on results
     }
-  }, [state.skillMetric, resetForNewGame]);
+  }, [state.skillMetric, resetForNewGame, updateState]);
 
   if (screen === 'rules') {
     return <Screen1Rules onPlay={handlePlay} />;
@@ -145,6 +187,7 @@ export default function App() {
         won={gameStatus === 'won'}
         attemptCount={guesses.length}
         revealedWord={targetWord}
+        guesses={guesses}
         stats={state.stats}
         replays={replays ?? undefined}
         onPlayAgain={handlePlayAgain}

+ 3 - 3
client/src/hooks/useGame.ts

@@ -15,12 +15,12 @@ interface UseGameReturn {
   resetForNewGame: () => void;
 }
 
-export function useGame(initialGuesses: GuessEntry[]): UseGameReturn {
+export function useGame(initialGuesses: GuessEntry[], initialHint?: string | null, initialRevealedWord?: string | null): UseGameReturn {
   const [guesses, setGuesses] = useState<GuessEntry[]>(initialGuesses);
   const [message, setMessage] = useState<string | null>(null);
-  const [hint, setHint] = useState<string | null>(null);
+  const [hint, setHint] = useState<string | null>(initialHint ?? null);
   const [lastDifficulty, setLastDifficulty] = useState<number | null>(null);
-  const [revealedWord, setRevealedWord] = useState<string | null>(null);
+  const [revealedWord, setRevealedWord] = useState<string | null>(initialRevealedWord ?? null);
   const [replays, setReplays] = useState<SolverReplay[] | null>(null);
 
   const gameStatus = deriveStatus(guesses);

+ 55 - 16
client/src/screens/Screen3Results.tsx

@@ -1,4 +1,4 @@
-import type { GameStats, SolverReplay } from '@wordle/shared';
+import type { GameStats, SolverReplay, GuessEntry } from '@wordle/shared';
 import { getMessages } from '../messages/index.js';
 import { LangSwitch } from '../components/LangSwitch.js';
 
@@ -6,6 +6,7 @@ interface Screen3ResultsProps {
   won: boolean;
   attemptCount: number;
   revealedWord: string | null;
+  guesses: GuessEntry[];
   stats: GameStats;
   replays?: SolverReplay[];
   onPlayAgain?: () => void;
@@ -46,6 +47,34 @@ function computeFeedback(target: string, guess: string): string[] {
   return result;
 }
 
+/** Read-only mini grid showing the player's guesses with their feedback colors. */
+function GameGrid({ guesses }: { guesses: GuessEntry[] }) {
+  return (
+    <div style={{ display: 'flex', flexDirection: 'column', gap: '2px', alignItems: 'center' }}>
+      {guesses.map((entry, rowIdx) => (
+        <div key={rowIdx} style={{ display: 'flex', gap: '2px' }}>
+          {entry.guess.split('').map((letter, colIdx) => {
+            const c = entry.colors[colIdx];
+            return (
+              <div key={colIdx} style={{
+                width: '20px', height: '20px',
+                backgroundColor: MINI_COLORS[c] ?? '#d3d6da',
+                color: '#fff',
+                display: 'flex', alignItems: 'center', justifyContent: 'center',
+                fontSize: '0.55rem', fontWeight: 'bold',
+                textTransform: 'uppercase', fontFamily: 'monospace',
+                borderRadius: '2px',
+              }}>
+                {letter}
+              </div>
+            );
+          })}
+        </div>
+      ))}
+    </div>
+  );
+}
+
 function MiniBoard({ replay, targetWord }: { replay: SolverReplay; targetWord: string }) {
   return (
     <div style={{ marginBottom: '12px' }}>
@@ -103,15 +132,17 @@ function Histogram({ stats }: { stats: GameStats }) {
   );
 }
 
-export function Screen3Results({ won, attemptCount, revealedWord, stats, replays, onPlayAgain }: Screen3ResultsProps) {
+export function Screen3Results({ won, attemptCount, revealedWord, guesses, stats, replays, onPlayAgain }: Screen3ResultsProps) {
   const r = getMessages().results;
 
   return (
-    <div style={{ maxWidth: '400px', margin: '24px auto', padding: '16px', fontFamily: 'sans-serif', textAlign: 'center', position: 'relative' }}>
-      <LangSwitch />
-      <h1 style={{ fontSize: '1.6rem', marginBottom: '12px' }}>
-        {won ? r.congratulations : r.betterLuck}
-      </h1>
+    <div style={{ maxWidth: '400px', margin: '24px auto', padding: '16px', fontFamily: 'sans-serif', textAlign: 'center' }}>
+      <div style={{ display: 'flex', alignItems: 'center', marginBottom: '12px' }}>
+        <h1 style={{ flex: 1, textAlign: 'center', fontSize: '1.6rem', margin: 0 }}>
+          {won ? r.congratulations : r.betterLuck}
+        </h1>
+        <LangSwitch inline />
+      </div>
 
       {won ? (
         <p style={{ fontSize: '1.1rem', marginBottom: '8px' }}>{r.solvedIn(attemptCount)}</p>
@@ -119,16 +150,12 @@ export function Screen3Results({ won, attemptCount, revealedWord, stats, replays
         revealedWord && <p style={{ fontSize: '1.1rem', marginBottom: '8px' }}>{r.theWordWas(revealedWord)}</p>
       )}
 
-      <div style={{ margin: '24px 0' }}>
-        <h2 style={{ fontSize: '1.1rem', marginBottom: '12px' }}>{r.statistics}</h2>
-        <div style={{ display: 'flex', justifyContent: 'center', gap: '24px', marginBottom: '16px' }}>
-          <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.gamesPlayed}</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.played}</div></div>
-          <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.gamesPlayed > 0 ? Math.round((stats.wins / stats.gamesPlayed) * 100) : 0}%</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.winRate}</div></div>
-          <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.currentStreak}</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.streak}</div></div>
-          <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.maxStreak}</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.maxStreak}</div></div>
+      {/* Game grid — read-only replay of the guesses */}
+      {guesses.length > 0 && (
+        <div style={{ margin: '16px 0' }}>
+          <GameGrid guesses={guesses} />
         </div>
-        <Histogram stats={stats} />
-      </div>
+      )}
 
       {replays && replays.length > 0 && revealedWord && (
         <div style={{ margin: '24px 0' }}>
@@ -162,6 +189,18 @@ export function Screen3Results({ won, attemptCount, revealedWord, stats, replays
           {r.playAgain}
         </button>
       )}
+
+      {/* Statistics — at the very bottom */}
+      <div style={{ margin: '24px 0' }}>
+        <h2 style={{ fontSize: '1.1rem', marginBottom: '12px' }}>{r.statistics}</h2>
+        <div style={{ display: 'flex', justifyContent: 'center', gap: '24px', marginBottom: '16px' }}>
+          <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.gamesPlayed}</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.played}</div></div>
+          <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.gamesPlayed > 0 ? Math.round((stats.wins / stats.gamesPlayed) * 100) : 0}%</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.winRate}</div></div>
+          <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.currentStreak}</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.streak}</div></div>
+          <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.maxStreak}</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.maxStreak}</div></div>
+        </div>
+        <Histogram stats={stats} />
+      </div>
     </div>
   );
 }

+ 2 - 1
data/word-bank-ru.json

@@ -2864,7 +2864,8 @@
     "яруга",
     "ярыга",
     "ястык",
-    "яхонт"
+    "яхонт",
+    "грант"
   ],
   "targets": [
     {

+ 8 - 0
raw/11-feature-daily-word-replay.md

@@ -0,0 +1,8 @@
+Features request
+1. At this time, when user returns to the game same day again, he is presented blank guess screen for word of the day, which he alteady tried/solved. Fix that in the following way.
+Store on a client side current id for word of the day, its current guesses for the word of the day with colored results, and hint if provided. If client code determines that stored id for word of the day matches 
+the one returned from server, do not start solving from scratch, but
+- if solving is in progress, i.e. no 6 guesses yet, not all-green guess, then prefill stored guesses to guess screen, and allow user to continue guessing with the next try
+- if solving is complete, ether successful or failed, do not show the guess screen, but show results screen instead using word of the day data.
+2. Rearrange results screen. Move statistics block to very bottom. Instead of statistics show small read-only game field grid with guesses for current word.
+When returning to the screen through feature 1, the guesses are for word of the day. For regular flow these are guesses for last game.

+ 2 - 0
raw/12-bug-store-current-word-before-first-guess.md

@@ -0,0 +1,2 @@
+bug
+currentGame.wordId is not saved to browser local storage right after starting new game. Instead, it is first saved first guess

+ 6 - 0
shared/src/game.ts

@@ -20,6 +20,12 @@ export interface PersistedState {
   currentGame?: {
     wordId: number;
     guesses: GuessEntry[];
+    /** Synonym hint shown on 5th unsuccessful guess */
+    hint?: string;
+    /** Word revealed by server on loss */
+    revealedWord?: string;
+    /** Set once the game ends (stats already recorded) */
+    outcome?: 'won' | 'lost';
   };
   skillMetric: number;
 }