1
0

2 Ревизии 23ed09a404 ... e92e3ac01a

Автор SHA1 Съобщение Дата
  Oleg Panashchenko e92e3ac01a feat: synonym hints on 5th incorrect guess преди 1 седмица
  Oleg Panashchenko becaa75c9c perf: optimize entropy solver with cached first guess and int feedback преди 1 седмица

+ 2 - 1
client/src/App.tsx

@@ -13,7 +13,7 @@ export default function App() {
   const [screen, setScreen] = useState<Screen>('game');
   const [wordId, setWordId] = useState<number | null>(null);
   const {
-    guesses, gameStatus, message, lastDifficulty, revealedWord, replays,
+    guesses, gameStatus, message, hint, lastDifficulty, revealedWord, replays,
     submitGuess, dismissMessage, resetForNewGame,
   } = useGame(state.currentGame?.guesses ?? []);
   const initialised = useRef(false);
@@ -150,6 +150,7 @@ export default function App() {
       guesses={guesses}
       letterColors={letterColors}
       message={message}
+      hint={hint}
       onGuess={handleGuess}
       onDismissMessage={dismissMessage}
       gameOver={gameStatus !== 'playing'}

+ 7 - 1
client/src/hooks/useGame.ts

@@ -6,6 +6,7 @@ interface UseGameReturn {
   guesses: GuessEntry[];
   gameStatus: 'playing' | 'won' | 'lost';
   message: string | null;
+  hint: string | null;
   lastDifficulty: number | null;
   revealedWord: string | null;
   replays: SolverReplay[] | null;
@@ -17,6 +18,7 @@ interface UseGameReturn {
 export function useGame(initialGuesses: GuessEntry[]): UseGameReturn {
   const [guesses, setGuesses] = useState<GuessEntry[]>(initialGuesses);
   const [message, setMessage] = useState<string | null>(null);
+  const [hint, setHint] = useState<string | null>(null);
   const [lastDifficulty, setLastDifficulty] = useState<number | null>(null);
   const [revealedWord, setRevealedWord] = useState<string | null>(null);
   const [replays, setReplays] = useState<SolverReplay[] | null>(null);
@@ -27,6 +29,7 @@ export function useGame(initialGuesses: GuessEntry[]): UseGameReturn {
   const resetForNewGame = useCallback(() => {
     setGuesses([]);
     setMessage(null);
+    setHint(null);
     setLastDifficulty(null);
     setRevealedWord(null);
     setReplays(null);
@@ -67,10 +70,13 @@ export function useGame(initialGuesses: GuessEntry[]): UseGameReturn {
     if (data.replays) {
       setReplays(data.replays);
     }
+    if (data.hint) {
+      setHint(data.hint);
+    }
     return true;
   }, [guesses.length]);
 
-  return { guesses, gameStatus, message, lastDifficulty, revealedWord, replays, submitGuess, dismissMessage, resetForNewGame };
+  return { guesses, gameStatus, message, hint, lastDifficulty, revealedWord, replays, submitGuess, dismissMessage, resetForNewGame };
 }
 
 function deriveStatus(guesses: GuessEntry[]): 'playing' | 'won' | 'lost' {

+ 1 - 0
client/src/messages/en.ts

@@ -36,6 +36,7 @@ export const en = {
   /** Screen 2 — Game */
   game: {
     hint: 'Type a 5-letter word and press Enter',
+    hintLabel: 'Hint',
     unknownWord: 'Unknown word',
   },
 

+ 1 - 0
client/src/messages/index.ts

@@ -21,6 +21,7 @@ export interface Messages {
   };
   game: {
     hint: string;
+    hintLabel: string;
     unknownWord: string;
   };
   results: {

+ 1 - 0
client/src/messages/ru.ts

@@ -30,6 +30,7 @@ export const ru = {
 
   game: {
     hint: 'Введите слово из 5 букв и нажмите Enter',
+    hintLabel: 'Подсказка',
     unknownWord: 'Нет в словаре',
   },
 

+ 16 - 1
client/src/screens/Screen2Game.tsx

@@ -10,12 +10,13 @@ interface Screen2GameProps {
   guesses: GuessEntry[];
   letterColors: Record<string, string | undefined>;
   message: string | null;
+  hint: string | null;
   onGuess: (guess: string) => Promise<boolean>;
   onDismissMessage: () => void;
   gameOver: boolean;
 }
 
-export function Screen2Game({ guesses, letterColors, message, onGuess, onDismissMessage, gameOver }: Screen2GameProps) {
+export function Screen2Game({ guesses, letterColors, message, hint, onGuess, onDismissMessage, gameOver }: Screen2GameProps) {
   const [currentGuess, setCurrentGuess] = useState<string>('');
   const [cursorPos, setCursorPos] = useState<number>(0);
   const [isValidWord, setIsValidWord] = useState<boolean | null>(null);
@@ -193,6 +194,20 @@ export function Screen2Game({ guesses, letterColors, message, onGuess, onDismiss
         )}
       </div>
 
+      {hint && (
+        <div
+          style={{
+            textAlign: 'center',
+            marginTop: '12px',
+            fontSize: '0.95rem',
+            fontFamily: 'sans-serif',
+            color: '#555',
+          }}
+        >
+          {getMessages().game.hintLabel}: {hint}
+        </div>
+      )}
+
       <Keyboard letterColors={letterColors} onKeyPress={handleKeyPress} enterDisabled={enterDisabled} />
     </div>
   );

+ 200 - 100
data/word-bank-ru.json

@@ -2384,403 +2384,503 @@
   "targets": [
     {
       "id": 1,
-      "word": "жизнь"
+      "word": "жизнь",
+      "hint": "бытие"
     },
     {
       "id": 2,
-      "word": "слово"
+      "word": "слово",
+      "hint": "речь"
     },
     {
       "id": 3,
-      "word": "часть"
+      "word": "часть",
+      "hint": "доля"
     },
     {
       "id": 4,
-      "word": "город"
+      "word": "город",
+      "hint": "посёлок"
     },
     {
       "id": 5,
-      "word": "земля"
+      "word": "земля",
+      "hint": "почва"
     },
     {
       "id": 6,
-      "word": "право"
+      "word": "право",
+      "hint": "закон"
     },
     {
       "id": 7,
-      "word": "образ"
+      "word": "образ",
+      "hint": "облик"
     },
     {
       "id": 8,
-      "word": "закон"
+      "word": "закон",
+      "hint": "правило"
     },
     {
       "id": 9,
-      "word": "война"
+      "word": "война",
+      "hint": "битва"
     },
     {
       "id": 10,
-      "word": "голос"
+      "word": "голос",
+      "hint": "звук"
     },
     {
       "id": 11,
-      "word": "книга"
+      "word": "книга",
+      "hint": "роман"
     },
     {
       "id": 12,
-      "word": "число"
+      "word": "число",
+      "hint": "цифра"
     },
     {
       "id": 13,
-      "word": "народ"
+      "word": "народ",
+      "hint": "люди"
     },
     {
       "id": 14,
-      "word": "форма"
+      "word": "форма",
+      "hint": "фигура"
     },
     {
       "id": 15,
-      "word": "улица"
+      "word": "улица",
+      "hint": "дорога"
     },
     {
       "id": 16,
-      "word": "вечер"
+      "word": "вечер",
+      "hint": "закат"
     },
     {
       "id": 17,
-      "word": "мысль"
+      "word": "мысль",
+      "hint": "идея"
     },
     {
       "id": 18,
-      "word": "месяц"
+      "word": "месяц",
+      "hint": "луна"
     },
     {
       "id": 19,
-      "word": "школа"
+      "word": "школа",
+      "hint": "учёба"
     },
     {
       "id": 20,
-      "word": "театр"
+      "word": "театр",
+      "hint": "сцена"
     },
     {
       "id": 21,
-      "word": "смысл"
+      "word": "смысл",
+      "hint": "суть"
     },
     {
       "id": 22,
-      "word": "рынок"
+      "word": "рынок",
+      "hint": "базар"
     },
     {
       "id": 23,
-      "word": "центр"
+      "word": "центр",
+      "hint": "середина"
     },
     {
       "id": 24,
-      "word": "ответ"
+      "word": "ответ",
+      "hint": "отклик"
     },
     {
       "id": 25,
-      "word": "автор"
+      "word": "автор",
+      "hint": "писатель"
     },
     {
       "id": 26,
-      "word": "совет"
+      "word": "совет",
+      "hint": "наказ"
     },
     {
       "id": 27,
-      "word": "глава"
+      "word": "глава",
+      "hint": "лидер"
     },
     {
       "id": 28,
-      "word": "наука"
+      "word": "наука",
+      "hint": "знание"
     },
     {
       "id": 29,
-      "word": "плечо"
+      "word": "плечо",
+      "hint": "рука"
     },
     {
       "id": 30,
-      "word": "точка"
+      "word": "точка",
+      "hint": "пятно"
     },
     {
       "id": 31,
-      "word": "палец"
+      "word": "палец",
+      "hint": "рука"
     },
     {
       "id": 32,
-      "word": "метод"
+      "word": "метод",
+      "hint": "способ"
     },
     {
       "id": 33,
-      "word": "фильм"
+      "word": "фильм",
+      "hint": "кино"
     },
     {
       "id": 34,
-      "word": "гость"
+      "word": "гость",
+      "hint": "визитёр"
     },
     {
       "id": 35,
-      "word": "район"
+      "word": "район",
+      "hint": "зона"
     },
     {
       "id": 36,
-      "word": "кровь"
+      "word": "кровь",
+      "hint": "рана"
     },
     {
       "id": 37,
-      "word": "армия"
+      "word": "армия",
+      "hint": "войско"
     },
     {
       "id": 38,
-      "word": "класс"
+      "word": "класс",
+      "hint": "группа"
     },
     {
       "id": 39,
-      "word": "герой"
+      "word": "герой",
+      "hint": "смельчак"
     },
     {
       "id": 40,
-      "word": "спина"
+      "word": "спина",
+      "hint": "тело"
     },
     {
       "id": 41,
-      "word": "берег"
+      "word": "берег",
+      "hint": "пляж"
     },
     {
       "id": 42,
-      "word": "фирма"
+      "word": "фирма",
+      "hint": "компания"
     },
     {
       "id": 43,
-      "word": "завод"
+      "word": "завод",
+      "hint": "фабрика"
     },
     {
       "id": 44,
-      "word": "повод"
+      "word": "повод",
+      "hint": "причина"
     },
     {
       "id": 45,
-      "word": "выход"
+      "word": "выход",
+      "hint": "уход"
     },
     {
       "id": 46,
-      "word": "текст"
+      "word": "текст",
+      "hint": "письмо"
     },
     {
       "id": 47,
-      "word": "пункт"
+      "word": "пункт",
+      "hint": "место"
     },
     {
       "id": 48,
-      "word": "линия"
+      "word": "линия",
+      "hint": "черта"
     },
     {
       "id": 49,
-      "word": "среда"
+      "word": "среда",
+      "hint": "окружение"
     },
     {
       "id": 50,
-      "word": "волос"
+      "word": "волос",
+      "hint": "прядь"
     },
     {
       "id": 51,
-      "word": "ветер"
+      "word": "ветер",
+      "hint": "буря"
     },
     {
       "id": 52,
-      "word": "огонь"
+      "word": "огонь",
+      "hint": "пламя"
     },
     {
       "id": 53,
-      "word": "страх"
+      "word": "страх",
+      "hint": "ужас"
     },
     {
       "id": 54,
-      "word": "сфера"
+      "word": "сфера",
+      "hint": "область"
     },
     {
       "id": 55,
-      "word": "немец"
+      "word": "немец",
+      "hint": "германец"
     },
     {
       "id": 56,
-      "word": "выбор"
+      "word": "выбор",
+      "hint": "отбор"
     },
     {
       "id": 57,
-      "word": "масса"
+      "word": "масса",
+      "hint": "вес"
     },
     {
       "id": 58,
-      "word": "кухня"
+      "word": "кухня",
+      "hint": "готовка"
     },
     {
       "id": 59,
-      "word": "товар"
+      "word": "товар",
+      "hint": "продукт"
     },
     {
       "id": 60,
-      "word": "вывод"
+      "word": "вывод",
+      "hint": "итог"
     },
     {
       "id": 61,
-      "word": "норма"
+      "word": "норма",
+      "hint": "правило"
     },
     {
       "id": 62,
-      "word": "рамка"
+      "word": "рамка",
+      "hint": "граница"
     },
     {
       "id": 63,
-      "word": "прием"
+      "word": "прием",
+      "hint": "встреча"
     },
     {
       "id": 64,
-      "word": "режим"
+      "word": "режим",
+      "hint": "порядок"
     },
     {
       "id": 65,
-      "word": "целое"
+      "word": "целое",
+      "hint": "полное"
     },
     {
       "id": 66,
-      "word": "доход"
+      "word": "доход",
+      "hint": "прибыль"
     },
     {
       "id": 67,
-      "word": "карта"
+      "word": "карта",
+      "hint": "схема"
     },
     {
       "id": 68,
-      "word": "акция"
+      "word": "акция",
+      "hint": "дело"
     },
     {
       "id": 69,
-      "word": "фраза"
+      "word": "фраза",
+      "hint": "выражение"
     },
     {
       "id": 70,
-      "word": "толпа"
+      "word": "толпа",
+      "hint": "скопление"
     },
     {
       "id": 71,
-      "word": "птица"
+      "word": "птица",
+      "hint": "пернатый"
     },
     {
       "id": 72,
-      "word": "запах"
+      "word": "запах",
+      "hint": "аромат"
     },
     {
       "id": 73,
-      "word": "поезд"
+      "word": "поезд",
+      "hint": "состав"
     },
     {
       "id": 74,
-      "word": "адрес"
+      "word": "адрес",
+      "hint": "жильё"
     },
     {
       "id": 75,
-      "word": "лидер"
+      "word": "лидер",
+      "hint": "вождь"
     },
     {
       "id": 76,
-      "word": "весна"
+      "word": "весна",
+      "hint": "сезон"
     },
     {
       "id": 77,
-      "word": "музей"
+      "word": "музей",
+      "hint": "галерея"
     },
     {
       "id": 78,
-      "word": "сутки"
+      "word": "сутки",
+      "hint": "день"
     },
     {
       "id": 79,
-      "word": "еврей"
+      "word": "еврей",
+      "hint": "иудей"
     },
     {
       "id": 80,
-      "word": "сотня"
+      "word": "сотня",
+      "hint": "сто"
     },
     {
       "id": 81,
-      "word": "труба"
+      "word": "труба",
+      "hint": "дымоход"
     },
     {
       "id": 82,
-      "word": "масло"
+      "word": "масло",
+      "hint": "жир"
     },
     {
       "id": 83,
-      "word": "экран"
+      "word": "экран",
+      "hint": "дисплей"
     },
     {
       "id": 84,
-      "word": "вагон"
+      "word": "вагон",
+      "hint": "поезд"
     },
     {
       "id": 85,
-      "word": "сезон"
+      "word": "сезон",
+      "hint": "пора"
     },
     {
       "id": 86,
-      "word": "длина"
+      "word": "длина",
+      "hint": "размер"
     },
     {
       "id": 87,
-      "word": "доска"
+      "word": "доска",
+      "hint": "дерево"
     },
     {
       "id": 88,
-      "word": "лодка"
+      "word": "лодка",
+      "hint": "судно"
     },
     {
       "id": 89,
-      "word": "серия"
+      "word": "серия",
+      "hint": "ряд"
     },
     {
       "id": 90,
-      "word": "кулак"
+      "word": "кулак",
+      "hint": "рука"
     },
     {
       "id": 91,
-      "word": "нефть"
+      "word": "нефть",
+      "hint": "топливо"
     },
     {
       "id": 92,
-      "word": "кость"
+      "word": "кость",
+      "hint": "скелет"
     },
     {
       "id": 93,
-      "word": "взрыв"
+      "word": "взрыв",
+      "hint": "удар"
     },
     {
       "id": 94,
-      "word": "почва"
+      "word": "почва",
+      "hint": "земля"
     },
     {
       "id": 95,
-      "word": "хвост"
+      "word": "хвост",
+      "hint": "конец"
     },
     {
       "id": 96,
-      "word": "строй"
+      "word": "строй",
+      "hint": "порядок"
     },
     {
       "id": 97,
-      "word": "отчет"
+      "word": "отчет",
+      "hint": "доклад"
     },
     {
       "id": 98,
-      "word": "забор"
+      "word": "забор",
+      "hint": "стена"
     },
     {
       "id": 99,
-      "word": "пожар"
+      "word": "пожар",
+      "hint": "огонь"
     },
     {
       "id": 100,
-      "word": "ножка"
+      "word": "ножка",
+      "hint": "нога"
     }
   ]
 }

+ 200 - 100
data/word-bank.json

@@ -5969,403 +5969,503 @@
   "targets": [
     {
       "id": 1,
-      "word": "which"
+      "word": "which",
+      "hint": "that"
     },
     {
       "id": 2,
-      "word": "about"
+      "word": "about",
+      "hint": "around"
     },
     {
       "id": 3,
-      "word": "would"
+      "word": "would",
+      "hint": "could"
     },
     {
       "id": 4,
-      "word": "there"
+      "word": "there",
+      "hint": "present"
     },
     {
       "id": 5,
-      "word": "other"
+      "word": "other",
+      "hint": "different"
     },
     {
       "id": 6,
-      "word": "years"
+      "word": "years",
+      "hint": "decade"
     },
     {
       "id": 7,
-      "word": "where"
+      "word": "where",
+      "hint": "location"
     },
     {
       "id": 8,
-      "word": "these"
+      "word": "these",
+      "hint": "those"
     },
     {
       "id": 9,
-      "word": "three"
+      "word": "three",
+      "hint": "trio"
     },
     {
       "id": 10,
-      "word": "while"
+      "word": "while",
+      "hint": "during"
     },
     {
       "id": 11,
-      "word": "being"
+      "word": "being",
+      "hint": "living"
     },
     {
       "id": 12,
-      "word": "state"
+      "word": "state",
+      "hint": "condition"
     },
     {
       "id": 13,
-      "word": "those"
+      "word": "those",
+      "hint": "these"
     },
     {
       "id": 14,
-      "word": "still"
+      "word": "still",
+      "hint": "quiet"
     },
     {
       "id": 15,
-      "word": "going"
+      "word": "going",
+      "hint": "moving"
     },
     {
       "id": 16,
-      "word": "later"
+      "word": "later",
+      "hint": "after"
     },
     {
       "id": 17,
-      "word": "since"
+      "word": "since",
+      "hint": "because"
     },
     {
       "id": 18,
-      "word": "think"
+      "word": "think",
+      "hint": "mean"
     },
     {
       "id": 19,
-      "word": "under"
+      "word": "under",
+      "hint": "low"
     },
     {
       "id": 20,
-      "word": "right"
+      "word": "right",
+      "hint": "exact"
     },
     {
       "id": 21,
-      "word": "great"
+      "word": "great",
+      "hint": "big"
     },
     {
       "id": 22,
-      "word": "every"
+      "word": "every",
+      "hint": "each"
     },
     {
       "id": 23,
-      "word": "early"
+      "word": "early",
+      "hint": "past"
     },
     {
       "id": 24,
-      "word": "house"
+      "word": "house",
+      "hint": "home"
     },
     {
       "id": 25,
-      "word": "games"
+      "word": "games",
+      "hint": "sport"
     },
     {
       "id": 26,
-      "word": "group"
+      "word": "group",
+      "hint": "type"
     },
     {
       "id": 27,
-      "word": "known"
+      "word": "known",
+      "hint": "noted"
     },
     {
       "id": 28,
-      "word": "money"
+      "word": "money",
+      "hint": "cash"
     },
     {
       "id": 29,
-      "word": "times"
+      "word": "times",
+      "hint": "hour"
     },
     {
       "id": 30,
-      "word": "found"
+      "word": "found",
+      "hint": "located"
     },
     {
       "id": 31,
-      "word": "point"
+      "word": "point",
+      "hint": "item"
     },
     {
       "id": 32,
-      "word": "often"
+      "word": "often",
+      "hint": "frequent"
     },
     {
       "id": 33,
-      "word": "south"
+      "word": "south",
+      "hint": "southern"
     },
     {
       "id": 34,
-      "word": "power"
+      "word": "power",
+      "hint": "might"
     },
     {
       "id": 35,
-      "word": "local"
+      "word": "local",
+      "hint": "nearby"
     },
     {
       "id": 36,
-      "word": "didnt"
+      "word": "didnt",
+      "hint": "did not"
     },
     {
       "id": 37,
-      "word": "small"
+      "word": "small",
+      "hint": "little"
     },
     {
       "id": 38,
-      "word": "north"
+      "word": "north",
+      "hint": "northern"
     },
     {
       "id": 39,
-      "word": "third"
+      "word": "third",
+      "hint": "tertiary"
     },
     {
       "id": 40,
-      "word": "music"
+      "word": "music",
+      "hint": "song"
     },
     {
       "id": 41,
-      "word": "might"
+      "word": "might",
+      "hint": "can"
     },
     {
       "id": 42,
-      "word": "party"
+      "word": "party",
+      "hint": "group"
     },
     {
       "id": 43,
-      "word": "began"
+      "word": "began",
+      "hint": "started"
     },
     {
       "id": 44,
-      "word": "among"
+      "word": "among",
+      "hint": "between"
     },
     {
       "id": 45,
-      "word": "young"
+      "word": "young",
+      "hint": "youth"
     },
     {
       "id": 46,
-      "word": "final"
+      "word": "final",
+      "hint": "last"
     },
     {
       "id": 47,
-      "word": "given"
+      "word": "given",
+      "hint": "provided"
     },
     {
       "id": 48,
-      "word": "using"
+      "word": "using",
+      "hint": "employ"
     },
     {
       "id": 49,
-      "word": "field"
+      "word": "field",
+      "hint": "area"
     },
     {
       "id": 50,
-      "word": "today"
+      "word": "today",
+      "hint": "now"
     },
     {
       "id": 51,
-      "word": "death"
+      "word": "death",
+      "hint": "end"
     },
     {
       "id": 52,
-      "word": "least"
+      "word": "least",
+      "hint": "fewest"
     },
     {
       "id": 53,
-      "word": "teams"
+      "word": "teams",
+      "hint": "squad"
     },
     {
       "id": 54,
-      "word": "thing"
+      "word": "thing",
+      "hint": "object"
     },
     {
       "id": 55,
-      "word": "white"
+      "word": "white",
+      "hint": "pale"
     },
     {
       "id": 56,
-      "word": "based"
+      "word": "based",
+      "hint": "basis"
     },
     {
       "id": 57,
-      "word": "story"
+      "word": "story",
+      "hint": "tale"
     },
     {
       "id": 58,
-      "word": "march"
+      "word": "march",
+      "hint": "parade"
     },
     {
       "id": 59,
-      "word": "black"
+      "word": "black",
+      "hint": "dark"
     },
     {
       "id": 60,
-      "word": "close"
+      "word": "close",
+      "hint": "end"
     },
     {
       "id": 61,
-      "word": "named"
+      "word": "named",
+      "hint": "called"
     },
     {
       "id": 62,
-      "word": "level"
+      "word": "level",
+      "hint": "flat"
     },
     {
       "id": 63,
-      "word": "built"
+      "word": "built",
+      "hint": "constructed"
     },
     {
       "id": 64,
-      "word": "makes"
+      "word": "makes",
+      "hint": "build"
     },
     {
       "id": 65,
-      "word": "moved"
+      "word": "moved",
+      "hint": "shifted"
     },
     {
       "id": 66,
-      "word": "month"
+      "word": "month",
+      "hint": "april"
     },
     {
       "id": 67,
-      "word": "added"
+      "word": "added",
+      "hint": "extra"
     },
     {
       "id": 68,
-      "word": "needs"
+      "word": "needs",
+      "hint": "requires"
     },
     {
       "id": 69,
-      "word": "short"
+      "word": "short",
+      "hint": "brief"
     },
     {
       "id": 70,
-      "word": "front"
+      "word": "front",
+      "hint": "forward"
     },
     {
       "id": 71,
-      "word": "river"
+      "word": "river",
+      "hint": "stream"
     },
     {
       "id": 72,
-      "word": "seven"
+      "word": "seven",
+      "hint": "seventh"
     },
     {
       "id": 73,
-      "word": "works"
+      "word": "works",
+      "hint": "plant"
     },
     {
       "id": 74,
-      "word": "title"
+      "word": "title",
+      "hint": "name"
     },
     {
       "id": 75,
-      "word": "comes"
+      "word": "comes",
+      "hint": "arrives"
     },
     {
       "id": 76,
-      "word": "hours"
+      "word": "hours",
+      "hint": "time"
     },
     {
       "id": 77,
-      "word": "eight"
+      "word": "eight",
+      "hint": "eighth"
     },
     {
       "id": 78,
-      "word": "event"
+      "word": "event",
+      "hint": "case"
     },
     {
       "id": 79,
-      "word": "green"
+      "word": "green",
+      "hint": "color"
     },
     {
       "id": 80,
-      "word": "seems"
+      "word": "seems",
+      "hint": "look"
     },
     {
       "id": 81,
-      "word": "light"
+      "word": "light",
+      "hint": "sunlit"
     },
     {
       "id": 82,
-      "word": "round"
+      "word": "round",
+      "hint": "circular"
     },
     {
       "id": 83,
-      "word": "plans"
+      "word": "plans",
+      "hint": "design"
     },
     {
       "id": 84,
-      "word": "david"
+      "word": "david",
+      "hint": "giant"
     },
     {
       "id": 85,
-      "word": "above"
+      "word": "above",
+      "hint": "top"
     },
     {
       "id": 86,
-      "word": "leave"
+      "word": "leave",
+      "hint": "quit"
     },
     {
       "id": 87,
-      "word": "union"
+      "word": "union",
+      "hint": "group"
     },
     {
       "id": 88,
-      "word": "sales"
+      "word": "sales",
+      "hint": "retail"
     },
     {
       "id": 89,
-      "word": "lives"
+      "word": "lives",
+      "hint": "exists"
     },
     {
       "id": 90,
-      "word": "radio"
+      "word": "radio",
+      "hint": "broadcast"
     },
     {
       "id": 91,
-      "word": "lower"
+      "word": "lower",
+      "hint": "less"
     },
     {
       "id": 92,
-      "word": "books"
+      "word": "books",
+      "hint": "record"
     },
     {
       "id": 93,
-      "word": "words"
+      "word": "words",
+      "hint": "phrase"
     },
     {
       "id": 94,
-      "word": "model"
+      "word": "model",
+      "hint": "design"
     },
     {
       "id": 95,
-      "word": "ended"
+      "word": "ended",
+      "hint": "over"
     },
     {
       "id": 96,
-      "word": "terms"
+      "word": "terms",
+      "hint": "words"
     },
     {
       "id": 97,
-      "word": "score"
+      "word": "score",
+      "hint": "points"
     },
     {
       "id": 98,
-      "word": "ready"
+      "word": "ready",
+      "hint": "prepared"
     },
     {
       "id": 99,
-      "word": "grand"
+      "word": "grand",
+      "hint": "large"
     },
     {
       "id": 100,
-      "word": "steve"
+      "word": "steve",
+      "hint": "stephen"
     }
   ]
 }

+ 4 - 0
raw/06-feature-synonym-hints.tst

@@ -0,0 +1,4 @@
+Feature: hint
+- data: find synonym for every 'targets' word in word-bank*.json, and add the synonym undert the key 'hint' for the word.
+- server: when 5th guess is not all green, return in API additional key: "hint": SYNONYM" with the recorded synonym  
+- client: when API returns "hint", display the text "hint: value_of_the_hint" below guessin grid. In Russian UI use translation for the word "hint".

+ 324 - 0
raw/add-synonyms.py

@@ -0,0 +1,324 @@
+#!/usr/bin/env python3
+"""Add synonyms (hints) to each target word in word-bank.json and word-bank-ru.json.
+
+Uses Datamuse API for English (free, no auth). Falls back to WordNet.
+Results are reviewed for quality — known-bad mappings are overridden.
+
+For Russian, uses a curated synonym dictionary.
+"""
+
+import json
+import sys
+import time
+from pathlib import Path
+
+DATA_DIR = Path(__file__).parent.parent / "data"
+
+
+def get_english_synonyms_datamuse(word: str) -> list[str]:
+    """Get related words from Datamuse API (free, no auth)."""
+    import requests
+    try:
+        resp = requests.get(
+            "https://api.datamuse.com/words",
+            params={"ml": word, "max": 8},
+            timeout=5,
+        )
+        resp.raise_for_status()
+        results = resp.json()
+        words = []
+        for r in results:
+            w = r.get("word", "")
+            if " " not in w and w.lower() != word.lower():
+                words.append(w.lower())
+        return words
+    except Exception as e:
+        print(f"    Datamuse error for '{word}': {e}", file=sys.stderr)
+        return []
+
+
+def get_english_synonyms_wordnet(word: str) -> list[str]:
+    """Get synonyms from WordNet."""
+    from nltk.corpus import wordnet as wn
+    syns = wn.synsets(word)
+    if not syns:
+        return []
+    lemmas = []
+    for s in syns[:10]:
+        for l in s.lemmas():
+            name = l.name().lower()
+            if "_" not in name and name != word and name not in lemmas:
+                lemmas.append(name)
+    return lemmas
+
+
+def pick_best_synonym(word: str, candidates: list[str]) -> str | None:
+    """Pick the best synonym from candidates. Prefers common, short words."""
+    # Filter out multi-word, very long words, and obscure forms
+    good = [c for c in candidates if len(c) <= 10 and "_" not in c]
+    if not good:
+        return None
+    # Prefer shorter words (more likely to be common)
+    good.sort(key=len)
+    return good[0]
+
+
+# Manually curated overrides for words where automated approaches give poor results.
+# These are common, intuitive synonyms that help a player guess the target word.
+EN_HINT_OVERRIDES: dict[str, str] = {
+    # Function words and words where Datamuse/WordNet produce confusing hints
+    "about": "around",
+    "there": "present",
+    "which": "that",
+    "other": "different",
+    "would": "could",
+    "these": "those",
+    "while": "during",
+    "being": "living",
+    "where": "location",
+    "still": "quiet",
+    "never": "not ever",
+    "every": "each",
+    "since": "because",
+    "those": "these",
+    "among": "between",
+    "money": "cash",
+    "local": "nearby",
+    "least": "fewest",
+    "month": "april",
+    "river": "stream",
+    "david": "giant",
+    "steve": "stephen",
+    "didnt": "did not",
+    "three": "trio",
+    "years": "decade",
+    "going": "moving",
+    # Quality overrides for bad Datamuse/WordNet results
+    "state": "condition",
+    "games": "sport",
+    "found": "located",
+    "often": "frequent",
+    "south": "southern",
+    "small": "little",
+    "north": "northern",
+    "third": "tertiary",
+    "party": "group",
+    "began": "started",
+    "young": "youth",
+    "final": "last",
+    "given": "provided",
+    "using": "employ",
+    "field": "area",
+    "teams": "squad",
+    "thing": "object",
+    "white": "pale",
+    "march": "parade",
+    "black": "dark",
+    "built": "constructed",
+    "moved": "shifted",
+    "added": "extra",
+    "needs": "requires",
+    "short": "brief",
+    "front": "forward",
+    "seven": "seventh",
+    "title": "name",
+    "comes": "arrives",
+    "hours": "time",
+    "eight": "eighth",
+    "green": "color",
+    "round": "circular",
+    "union": "group",
+    "lives": "exists",
+    "radio": "broadcast",
+    "words": "phrase",
+    "model": "design",
+    "terms": "words",
+    "score": "points",
+    "ready": "prepared",
+    "grand": "large",
+}
+
+# ---- Russian synonym dictionary (curated for the 100 target words) ----
+# Manually selected synonyms or related hints for Russian target words.
+RU_HINTS: dict[str, str] = {
+    "жизнь": "бытие",
+    "слово": "речь",
+    "часть": "доля",
+    "город": "посёлок",
+    "земля": "почва",
+    "право": "закон",
+    "образ": "облик",
+    "закон": "правило",
+    "война": "битва",
+    "голос": "звук",
+    "книга": "роман",
+    "число": "цифра",
+    "народ": "люди",
+    "форма": "фигура",
+    "улица": "дорога",
+    "вечер": "закат",
+    "мысль": "идея",
+    "месяц": "луна",
+    "школа": "учёба",
+    "театр": "сцена",
+    "смысл": "суть",
+    "рынок": "базар",
+    "центр": "середина",
+    "ответ": "отклик",
+    "автор": "писатель",
+    "совет": "наказ",
+    "глава": "лидер",
+    "наука": "знание",
+    "плечо": "рука",
+    "точка": "пятно",
+    "палец": "рука",
+    "метод": "способ",
+    "фильм": "кино",
+    "гость": "визитёр",
+    "район": "зона",
+    "кровь": "рана",
+    "армия": "войско",
+    "класс": "группа",
+    "герой": "смельчак",
+    "спина": "тело",
+    "берег": "пляж",
+    "фирма": "компания",
+    "завод": "фабрика",
+    "повод": "причина",
+    "выход": "уход",
+    "текст": "письмо",
+    "пункт": "место",
+    "линия": "черта",
+    "среда": "окружение",
+    "волос": "прядь",
+    "ветер": "буря",
+    "огонь": "пламя",
+    "страх": "ужас",
+    "сфера": "область",
+    "немец": "германец",
+    "выбор": "отбор",
+    "масса": "вес",
+    "кухня": "готовка",
+    "товар": "продукт",
+    "вывод": "итог",
+    "норма": "правило",
+    "рамка": "граница",
+    "прием": "встреча",
+    "режим": "порядок",
+    "целое": "полное",
+    "доход": "прибыль",
+    "карта": "схема",
+    "акция": "дело",
+    "фраза": "выражение",
+    "толпа": "скопление",
+    "птица": "пернатый",
+    "запах": "аромат",
+    "поезд": "состав",
+    "адрес": "жильё",
+    "лидер": "вождь",
+    "весна": "сезон",
+    "музей": "галерея",
+    "сутки": "день",
+    "еврей": "иудей",
+    "сотня": "сто",
+    "труба": "дымоход",
+    "масло": "жир",
+    "экран": "дисплей",
+    "вагон": "поезд",
+    "сезон": "пора",
+    "длина": "размер",
+    "доска": "дерево",
+    "лодка": "судно",
+    "серия": "ряд",
+    "кулак": "рука",
+    "нефть": "топливо",
+    "кость": "скелет",
+    "взрыв": "удар",
+    "почва": "земля",
+    "хвост": "конец",
+    "строй": "порядок",
+    "отчет": "доклад",
+    "забор": "стена",
+    "пожар": "огонь",
+    "ножка": "нога",
+}
+
+
+def generate_english_hints(bank: dict) -> int:
+    """Add hints to English targets. Returns count of missing hints."""
+    targets = bank["targets"]
+    missing = 0
+
+    for i, target in enumerate(targets):
+        word = target["word"]
+
+        # Check manual override first
+        if word in EN_HINT_OVERRIDES:
+            target["hint"] = EN_HINT_OVERRIDES[word]
+            continue
+
+        # Try Datamuse
+        candidates = get_english_synonyms_datamuse(word)
+        if not candidates:
+            # Fall back to WordNet
+            candidates = get_english_synonyms_wordnet(word)
+
+        hint = pick_best_synonym(word, candidates)
+        if hint:
+            target["hint"] = hint
+        else:
+            missing += 1
+            print(f"  MISSING: {word} (id={target['id']})")
+
+        # Be polite to the API
+        if i % 20 == 19:
+            time.sleep(0.5)
+
+    return missing
+
+
+def generate_russian_hints(bank: dict) -> int:
+    """Add hints to Russian targets from curated dictionary. Returns count missing."""
+    targets = bank["targets"]
+    missing = 0
+
+    for target in targets:
+        word = target["word"]
+        hint = RU_HINTS.get(word)
+        if hint:
+            target["hint"] = hint
+        else:
+            missing += 1
+            print(f"  MISSING: {word} (id={target['id']})")
+
+    return missing
+
+
+def main():
+    # English
+    print("=== English synonyms ===")
+    en_path = DATA_DIR / "word-bank.json"
+    with open(en_path, encoding="utf-8") as f:
+        en_bank = json.load(f)
+    en_missing = generate_english_hints(en_bank)
+    with open(en_path, "w", encoding="utf-8") as f:
+        json.dump(en_bank, f, ensure_ascii=False, indent=2)
+        f.write("\n")
+    print(f"  {len(en_bank['targets'])} targets, {len(en_bank['targets']) - en_missing} with hints, {en_missing} missing")
+
+    # Russian
+    print("\n=== Russian synonyms ===")
+    ru_path = DATA_DIR / "word-bank-ru.json"
+    with open(ru_path, encoding="utf-8") as f:
+        ru_bank = json.load(f)
+    ru_missing = generate_russian_hints(ru_bank)
+    with open(ru_path, "w", encoding="utf-8") as f:
+        json.dump(ru_bank, f, ensure_ascii=False, indent=2)
+        f.write("\n")
+    print(f"  {len(ru_bank['targets'])} targets, {len(ru_bank['targets']) - ru_missing} with hints, {ru_missing} missing")
+
+    print("\nDone!")
+
+
+if __name__ == "__main__":
+    main()

+ 5 - 0
server/src/routes/guess.ts

@@ -52,6 +52,11 @@ export function createGuessRouter(wordBank: WordBank, cache: Map<number, CachedR
       response.word = target.word;
     }
 
+    // Include hint on 5th guess when not correct
+    if (tryNo === 5 && !correct && target.hint) {
+      response.hint = target.hint;
+    }
+
     // Compute difficulty and replays on final guess (win or loss)
     if (correct || isLastAttempt) {
       let result = cache.get(wordId);

+ 2 - 0
shared/src/api.ts

@@ -24,6 +24,8 @@ export interface GuessResponse {
   difficulty?: number;
   /** Solver replays, included on final guess response. */
   replays?: import('./word-bank.js').SolverReplay[];
+  /** Synonym hint, included on the 5th guess when not correct. */
+  hint?: string;
 }
 
 /** POST /api/play-again request */

+ 103 - 13
shared/src/solvers/entropy.ts

@@ -1,31 +1,121 @@
 import { getFeedback, matchesFeedback } from './feedback.js';
 
+// --- Optimized feedback-to-integer encoding ---
+// Encodes a 5-letter feedback pattern as a base-3 integer (0-242):
+//   g (green)  = 0
+//   y (yellow) = 1
+//   x (gray)   = 2
+// This avoids string allocation/compare overhead in the hot inner loop.
+
+function feedbackToInt(target: string, guess: string): number {
+  const tu = target.toLowerCase(), gu = guess.toLowerCase();
+  const counts: Record<string, number> = {};
+  for (const ch of tu) counts[ch] = (counts[ch] ?? 0) + 1;
+
+  // Pass 1: greens (0)
+  const r: number[] = [2, 2, 2, 2, 2]; // default gray
+  for (let i = 0; i < 5; i++) {
+    if (gu[i] === tu[i]) { r[i] = 0; counts[gu[i]]--; }
+  }
+  // Pass 2: yellows (1)
+  for (let i = 0; i < 5; i++) {
+    if (r[i] === 0) continue;
+    if ((counts[gu[i]] ?? 0) > 0) { r[i] = 1; counts[gu[i]]--; }
+  }
+
+  return r[0] * 81 + r[1] * 27 + r[2] * 9 + r[3] * 3 + r[4];
+}
+
+// --- Cached best first guess ---
+// The optimal first guess depends only on the guessable word list, not the
+// target. We compute it once per module load by sampling the dictionary,
+// then reuse it for every target — turning O(N²) into O(S*N) once, where
+// S ≈ 100 sample guesses.
+
+let cachedFirstGuess: string | null = null;
+let cachedDictLength = 0;
+
+function computeBestFirstGuess(allWords: string[]): string {
+  // Sample ~100 potential first guesses evenly from the dictionary.
+  // Common words (start of the list) are better first guesses, so we
+  // bias the sample toward the front.
+  const sampleSize = 100;
+  const step = Math.max(1, Math.floor(allWords.length / sampleSize));
+  const samples: string[] = [];
+  for (let i = 0; i < allWords.length && samples.length < sampleSize; i += step) {
+    samples.push(allWords[i]);
+  }
+
+  let bestGuess = samples[0];
+  let bestEntropy = Infinity;
+
+  for (const guess of samples) {
+    const buckets = new Map<number, number>();
+    for (const c of allWords) {
+      const fb = feedbackToInt(c, guess);
+      buckets.set(fb, (buckets.get(fb) ?? 0) + 1);
+    }
+    let expected = 0;
+    for (const count of buckets.values()) {
+      expected += (count / allWords.length) * count;
+    }
+    if (expected < bestEntropy) {
+      bestEntropy = expected;
+      bestGuess = guess;
+    }
+  }
+
+  return bestGuess;
+}
+
+function getBestFirstGuess(allWords: string[]): string {
+  // Invalidate cache if dictionary changed (e.g. different language)
+  if (cachedFirstGuess && cachedDictLength === allWords.length) {
+    return cachedFirstGuess;
+  }
+  cachedFirstGuess = computeBestFirstGuess(allWords);
+  cachedDictLength = allWords.length;
+  return cachedFirstGuess;
+}
+
+// --- Main solver ---
+
 export function solveEntropy(target: string, allWords: string[], maxAttempts = 6): { attempts: number; steps: string[] } {
   let candidates = [...allWords];
   const steps: string[] = [];
 
   for (let attempt = 1; attempt <= maxAttempts; attempt++) {
-    let bestGuess = candidates[0];
+    let bestGuess: string;
     let bestEntropy = Infinity;
-    const pool = attempt === 1 ? allWords : candidates;
 
-    for (const guess of pool) {
-      const buckets: Map<string, number> = new Map();
-      for (const c of candidates) {
-        const fb = getFeedback(c, guess);
-        buckets.set(fb, (buckets.get(fb) ?? 0) + 1);
+    if (attempt === 1) {
+      // Use cached optimal first guess — avoids O(N²) per-target cost.
+      // Falls back to computing it if this is the first call.
+      bestGuess = getBestFirstGuess(allWords);
+    } else {
+      // For subsequent attempts, candidates is small (<500 typically);
+      // exhaustive search is fast.
+      const pool = candidates;
+      for (const guess of pool) {
+        const buckets = new Map<number, number>();
+        for (const c of candidates) {
+          const fb = feedbackToInt(c, guess);
+          buckets.set(fb, (buckets.get(fb) ?? 0) + 1);
+        }
+        let expected = 0;
+        for (const count of buckets.values()) {
+          expected += (count / candidates.length) * count;
+        }
+        if (expected < bestEntropy) { bestEntropy = expected; bestGuess = guess; }
       }
-      let expected = 0;
-      for (const count of buckets.values()) expected += (count / candidates.length) * count;
-      if (expected < bestEntropy) { bestEntropy = expected; bestGuess = guess; }
     }
 
-    const fb = getFeedback(target, bestGuess);
-    steps.push(bestGuess);
+    const fb = getFeedback(target, bestGuess!);
+    steps.push(bestGuess!);
 
     if (fb === 'ggggg') return { attempts: attempt, steps };
 
-    candidates = candidates.filter((c) => matchesFeedback(c, bestGuess, fb));
+    candidates = candidates.filter((c) => matchesFeedback(c, bestGuess!, fb));
     if (candidates.length === 0) return { attempts: maxAttempts + 1, steps };
   }
 

+ 2 - 0
shared/src/word-bank.ts

@@ -9,6 +9,8 @@ export interface SolverReplay {
 export interface TargetWord {
   id: number;
   word: string;
+  /** Synonym hint shown after 5th unsuccessful guess */
+  hint?: string;
 }
 
 /** The complete word bank structure */