2 Commit-ok 11be3fd9b0 ... 71318a9b06

Szerző SHA1 Üzenet Dátum
  Oleg Panashchenko 71318a9b06 feat: hint mode on empty solver board 1 hete
  Oleg Panashchenko b6c0726e24 feat: add Wordle Solver at /solver and /ru/solver 1 hete

+ 7 - 0
client/src/App.tsx

@@ -5,10 +5,17 @@ import { getDaily, postPlayAgain } from './api.js';
 import { Screen1Rules } from './screens/Screen1Rules.js';
 import { Screen2Game } from './screens/Screen2Game.js';
 import { Screen3Results } from './screens/Screen3Results.js';
+import { ScreenSolver } from './screens/ScreenSolver.js';
 
 type Screen = 'rules' | 'game' | 'results';
 
 export default function App() {
+  // Route to solver if path matches
+  const pathname = window.location.pathname.replace(/\/$/, '');
+  if (pathname === '/solver' || pathname === '/ru/solver') {
+    return <ScreenSolver />;
+  }
+
   const { state, updateState } = usePersistence();
   const [screen, setScreen] = useState<Screen>('game');
   const [wordId, setWordId] = useState<number | null>(null);

+ 8 - 0
client/src/api.ts

@@ -35,3 +35,11 @@ export function postPlayAgain(body: { skillMetric: number }): Promise<import('@w
     body: JSON.stringify(body),
   });
 }
+
+export function postSolver(body: import('@wordle/shared').SolverRequest): Promise<import('@wordle/shared').SolverResponse> {
+  return fetchJSON(`${base()}/solver`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  });
+}

+ 11 - 5
client/src/components/LangSwitch.tsx

@@ -1,12 +1,18 @@
 import { detectLang } from '../messages/index.js';
 
-const TO: Record<string, { label: string; href: string }> = {
-  en: { label: 'en', href: '/ru' },
-  ru: { label: 'ru', href: '/' },
-};
+interface LangSwitchProps {
+  /** Override target paths. [enTarget, ruTarget]. Default: ['/', '/ru'] */
+  targets?: [string, string];
+}
 
-export function LangSwitch() {
+export function LangSwitch({ targets }: LangSwitchProps) {
   const lang = detectLang();
+  const [enTarget, ruTarget] = targets ?? ['/', '/ru'];
+
+  const TO: Record<string, { label: string; href: string }> = {
+    en: { label: 'en', href: ruTarget },
+    ru: { label: 'ru', href: enTarget },
+  };
   const { label, href } = TO[lang];
 
   return (

+ 267 - 0
client/src/components/SolverBoard.tsx

@@ -0,0 +1,267 @@
+import { useState, useCallback, useEffect, useRef } from 'react';
+import { getMessages } from '../messages/index.js';
+import { Keyboard } from './Keyboard.js';
+
+const COLOR_MAP: Record<string, string> = {
+  g: '#6aaa64',
+  y: '#c9b458',
+  x: '#787c7e',
+};
+
+const CURSOR_COLOR = '#6aaa64';
+const COLOR_CYCLE: Array<'x' | 'y' | 'g'> = ['x', 'y', 'g'];
+
+type CellColor = 'x' | 'y' | 'g';
+
+interface CellState {
+  letter: string;
+  color: CellColor;
+}
+
+interface SolverBoardProps {
+  onUpdate: (rows: Array<{ guess: string; result: string }>) => void;
+  loading?: boolean;
+}
+
+function emptyBoard(): CellState[][] {
+  return Array.from({ length: 6 }, () =>
+    Array.from({ length: 5 }, () => ({ letter: '', color: 'x' as CellColor })),
+  );
+}
+
+function buildRows(board: CellState[][]): Array<{ guess: string; result: string }> {
+  const rows: Array<{ guess: string; result: string }> = [];
+  for (const row of board) {
+    if (row.every((c) => c.letter !== '')) {
+      rows.push({
+        guess: row.map((c) => c.letter).join(''),
+        result: row.map((c) => c.color).join(''),
+      });
+    }
+  }
+  return rows;
+}
+
+function hasPartialRow(board: CellState[][]): boolean {
+  for (const row of board) {
+    const filled = row.filter((c) => c.letter !== '').length;
+    if (filled > 0 && filled < 5) return true;
+  }
+  return false;
+}
+
+export function SolverBoard({ onUpdate, loading }: SolverBoardProps) {
+  const [board, setBoard] = useState<CellState[][]>(emptyBoard);
+  const [cursor, setCursor] = useState<{ row: number; col: number }>({ row: 0, col: 0 });
+
+  // Refs for latest values used in keyboard handler (stable closure)
+  const cursorRef = useRef(cursor);
+  cursorRef.current = cursor;
+  const boardRef = useRef(board);
+  boardRef.current = board;
+  const loadingRef = useRef(loading);
+  loadingRef.current = loading;
+
+  const partial = hasPartialRow(board);
+  const updateDisabled = loading || partial;
+
+  const triggerUpdate = useCallback(() => {
+    const b = boardRef.current;
+    const r = buildRows(b);
+    if (hasPartialRow(b) || loadingRef.current) return;
+    onUpdate(r);
+  }, [onUpdate]);
+
+  // ── Cell mutation helpers ──
+
+  const setCell = useCallback((row: number, col: number, update: Partial<CellState>) => {
+    setBoard((prev) => {
+      const next = prev.map((r) => r.map((c) => ({ ...c })));
+      if (update.letter !== undefined) next[row][col].letter = update.letter;
+      if (update.color !== undefined) next[row][col].color = update.color;
+      return next;
+    });
+  }, []);
+
+  const cycleColor = useCallback((row: number, col: number) => {
+    setBoard((prev) => {
+      const next = prev.map((r) => r.map((c) => ({ ...c })));
+      const current = next[row][col].color;
+      const idx = COLOR_CYCLE.indexOf(current);
+      next[row][col].color = COLOR_CYCLE[(idx + 1) % COLOR_CYCLE.length];
+      return next;
+    });
+  }, []);
+
+  const moveCursor = useCallback((row: number, col: number) => {
+    setCursor({ row: Math.max(0, Math.min(5, row)), col: Math.max(0, Math.min(4, col)) });
+  }, []);
+
+  // ── Shared key handler (on-screen keyboard + physical keyboard) ──
+
+  const handleKey = useCallback(
+    (key: string) => {
+      const { row, col } = cursorRef.current;
+
+      if (key === 'Enter') {
+        triggerUpdate();
+        return;
+      }
+
+      if (key === 'Backspace') {
+        setCell(row, col, { letter: '', color: 'x' });
+        if (col > 0) {
+          moveCursor(row, col - 1);
+        } else if (row > 0) {
+          moveCursor(row - 1, 4);
+        }
+        return;
+      }
+
+      if (key === 'ArrowLeft') {
+        moveCursor(row, col - 1);
+        return;
+      }
+      if (key === 'ArrowRight') {
+        moveCursor(row, col + 1);
+        return;
+      }
+      if (key === 'ArrowUp') {
+        moveCursor(row - 1, col);
+        return;
+      }
+      if (key === 'ArrowDown') {
+        moveCursor(row + 1, col);
+        return;
+      }
+
+      // Letter key (EN + RU, no ё)
+      if (key.length === 1 && /^[a-zA-Zа-яА-Я]$/.test(key) && key.toLowerCase() !== 'ё') {
+        const letter = key.toLowerCase();
+        setCell(row, col, { letter, color: 'x' });
+        if (col < 4) {
+          moveCursor(row, col + 1);
+        }
+      }
+    },
+    [triggerUpdate, setCell, moveCursor],
+  );
+
+  // ── Physical keyboard ──
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      // Ignore if user is typing in an input field
+      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
+
+      const key = e.key;
+      // Only handle single letters and special keys
+      if (
+        key === 'Enter' ||
+        key === 'Backspace' ||
+        key.startsWith('Arrow') ||
+        (key.length === 1 && /^[a-zA-Zа-яА-Я]$/.test(key) && key.toLowerCase() !== 'ё')
+      ) {
+        e.preventDefault();
+        handleKey(key);
+      }
+    };
+
+    document.addEventListener('keydown', handleKeyDown);
+    return () => document.removeEventListener('keydown', handleKeyDown);
+  }, [handleKey]);
+
+  // ── Render ──
+
+  const m = getMessages();
+
+  return (
+    <div>
+      <style>{`
+        @keyframes wl-solver-blink {
+          0%, 100% { opacity: 1; }
+          50% { opacity: 0; }
+        }
+      `}</style>
+
+      {/* Grid */}
+      <div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'center' }}>
+        {board.map((row, rowIdx) => (
+          <div key={rowIdx} style={{ display: 'flex', gap: '4px' }}>
+            {row.map((cell, colIdx) => {
+              const hasLetter = cell.letter !== '';
+              const bg = hasLetter ? COLOR_MAP[cell.color] : 'transparent';
+              const isFocused = cursor.row === rowIdx && cursor.col === colIdx;
+
+              return (
+                <div
+                  key={colIdx}
+                  onClick={() => {
+                    if (isFocused && hasLetter) {
+                      cycleColor(rowIdx, colIdx);
+                    } else {
+                      moveCursor(rowIdx, colIdx);
+                    }
+                  }}
+                  style={{
+                    width: '52px',
+                    height: '52px',
+                    border: `2px solid ${hasLetter ? 'transparent' : '#d3d6da'}`,
+                    backgroundColor: bg,
+                    display: 'flex',
+                    alignItems: 'center',
+                    justifyContent: 'center',
+                    fontSize: '1.5rem',
+                    fontWeight: 'bold',
+                    color: hasLetter ? '#fff' : '#000',
+                    textTransform: 'uppercase',
+                    fontFamily: 'monospace',
+                    position: 'relative',
+                    cursor: 'pointer',
+                    userSelect: 'none',
+                  }}
+                >
+                  {cell.letter}
+                  {isFocused && (
+                    <div
+                      style={{
+                        position: 'absolute',
+                        bottom: '4px',
+                        left: '8px',
+                        right: '8px',
+                        height: '3px',
+                        backgroundColor: CURSOR_COLOR,
+                        borderRadius: '1px',
+                        animation: 'wl-solver-blink 1s step-end infinite',
+                      }}
+                    />
+                  )}
+                </div>
+              );
+            })}
+          </div>
+        ))}
+      </div>
+
+      {/* Hint */}
+      <div
+        style={{
+          textAlign: 'center',
+          marginTop: '10px',
+          fontSize: '0.75rem',
+          color: '#787c7e',
+          fontFamily: 'sans-serif',
+        }}
+      >
+        {m.solver.tapHint}
+      </div>
+
+      {/* On-screen keyboard */}
+      <Keyboard
+        letterColors={{}}
+        onKeyPress={handleKey}
+        enterDisabled={updateDisabled}
+      />
+    </div>
+  );
+}

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

@@ -61,4 +61,20 @@ export const en = {
   keyBackspace: 'Backspace',
   keyArrowLeft: 'ArrowLeft',
   keyArrowRight: 'ArrowRight',
+
+  /** Solver screen */
+  solver: {
+    title: 'Wordle Solver',
+    updateButton: 'Update',
+    suggestedGuess: 'Suggested',
+    theWordIs: 'The word is',
+    noMatches: 'No matching words',
+    bestLetters: 'Best Letters',
+    possibleWords: 'Possible Words',
+    resultsCount: (n: number) => `${n} word${n === 1 ? '' : 's'} found`,
+    andNMore: (n: number) => `…and ${n} more`,
+    needOneRow: 'Enter at least one guess',
+    backToGame: '← Game',
+    tapHint: 'Letters start gray · tap focused tile to cycle: gray→yellow→green',
+  },
 };

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

@@ -42,6 +42,20 @@ export interface Messages {
   keyBackspace: string;
   keyArrowLeft: string;
   keyArrowRight: string;
+  solver: {
+    title: string;
+    updateButton: string;
+    suggestedGuess: string;
+    theWordIs: string;
+    noMatches: string;
+    bestLetters: string;
+    possibleWords: string;
+    resultsCount: (n: number) => string;
+    andNMore: (n: number) => string;
+    needOneRow: string;
+    backToGame: string;
+    tapHint: string;
+  };
 }
 
 /** Detect language from URL path. /ru/... → 'ru', otherwise 'en'. */

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

@@ -62,4 +62,28 @@ export const ru = {
   keyBackspace: 'Backspace',
   keyArrowLeft: 'ArrowLeft',
   keyArrowRight: 'ArrowRight',
+
+  /** Solver screen */
+  solver: {
+    title: 'Вордли-помощник',
+    updateButton: 'Обновить',
+    suggestedGuess: 'Совет',
+    theWordIs: 'Это слово',
+    noMatches: 'Нет подходящих слов',
+    bestLetters: 'Лучшие буквы',
+    possibleWords: 'Возможные слова',
+    resultsCount: (n: number) => {
+      const mod10 = n % 10;
+      const mod100 = n % 100;
+      let form: string;
+      if (mod10 === 1 && mod100 !== 11) form = 'слово';
+      else if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) form = 'слова';
+      else form = 'слов';
+      return `Найдено ${n} ${form}`;
+    },
+    andNMore: (n: number) => `…и ещё ${n}`,
+    needOneRow: 'Введите хотя бы одну попытку',
+    backToGame: '← Игра',
+    tapHint: 'Буквы начинаются серым · нажмите ещё раз: серый→жёлтый→зелёный',
+  },
 };

+ 167 - 0
client/src/screens/ScreenSolver.tsx

@@ -0,0 +1,167 @@
+import { useState, useCallback } from 'react';
+import type { SolverResponse } from '@wordle/shared';
+import { SolverBoard } from '../components/SolverBoard.js';
+import { LangSwitch } from '../components/LangSwitch.js';
+import { getMessages, detectLang } from '../messages/index.js';
+import { postSolver } from '../api.js';
+
+export function ScreenSolver() {
+  const m = getMessages();
+  const lang = detectLang();
+  const gamePath = lang === 'ru' ? '/ru/' : '/';
+
+  const [loading, setLoading] = useState(false);
+  const [result, setResult] = useState<SolverResponse | null>(null);
+  const [error, setError] = useState<string | null>(null);
+
+  const handleUpdate = useCallback(
+    async (rows: Array<{ guess: string; result: string }>) => {
+      setLoading(true);
+      setError(null);
+      try {
+        const res = await postSolver({ rows });
+        setResult(res);
+      } catch (err) {
+        setError(err instanceof Error ? err.message : 'Request failed');
+        setResult(null);
+      } finally {
+        setLoading(false);
+      }
+    },
+    [],
+  );
+
+  return (
+    <div style={{ maxWidth: '400px', margin: '20px auto', padding: '8px', fontFamily: 'sans-serif', position: 'relative' }}>
+      <LangSwitch targets={['/solver', '/ru/solver']} />
+
+      {/* Back to game */}
+      <a
+        href={gamePath}
+        style={{
+          position: 'absolute',
+          top: '8px',
+          left: '12px',
+          fontSize: '0.8rem',
+          color: '#787c7e',
+          textDecoration: 'none',
+          fontFamily: 'sans-serif',
+        }}
+      >
+        {m.solver.backToGame}
+      </a>
+
+      <h1 style={{ textAlign: 'center', fontSize: '1.6rem', marginBottom: '16px', marginTop: '8px' }}>
+        {m.solver.title}
+      </h1>
+
+      <SolverBoard onUpdate={handleUpdate} loading={loading} />
+
+      {/* Error */}
+      {error && (
+        <div style={{ textAlign: 'center', marginTop: '16px', color: '#d32f2f', fontSize: '0.9rem' }}>
+          {error}
+        </div>
+      )}
+
+      {/* Results */}
+      {result && (
+        <div style={{ marginTop: '20px' }}>
+          {/* Hint-only mode (empty board submission) */}
+          {result.hint ? (
+            <div style={{ textAlign: 'center' }}>
+              <p style={{ fontSize: '1rem', color: '#333' }}>
+                <span style={{ color: '#787c7e' }}>{m.solver.suggestedGuess}: </span>
+                <span style={{ fontWeight: 'bold' }}>{result.hint.toUpperCase()}</span>
+              </p>
+            </div>
+          ) : (
+            <>
+              {/* Summary */}
+          <div style={{ textAlign: 'center', marginBottom: '16px' }}>
+            <p style={{ fontSize: '0.9rem', color: '#555', margin: '0 0 4px' }}>
+              {m.solver.resultsCount(result.totalCount)}
+            </p>
+            {result.totalCount === 0 ? (
+              <p style={{ fontSize: '1rem', fontWeight: 'bold', color: '#d32f2f' }}>{m.solver.noMatches}</p>
+            ) : result.totalCount === 1 && result.suggestedGuess ? (
+              <p style={{ fontSize: '1rem', fontWeight: 'bold', color: '#6aaa64' }}>
+                {m.solver.theWordIs}: {result.suggestedGuess.toUpperCase()}
+              </p>
+            ) : result.suggestedGuess ? (
+              <p style={{ fontSize: '1rem', color: '#333' }}>
+                <span style={{ color: '#787c7e' }}>{m.solver.suggestedGuess}: </span>
+                <span style={{ fontWeight: 'bold' }}>{result.suggestedGuess.toUpperCase()}</span>
+              </p>
+            ) : null}
+          </div>
+
+          {/* Possible Words */}
+          {result.words.length > 0 && (
+            <div style={{ marginBottom: '16px' }}>
+              <h3 style={{ fontSize: '0.9rem', color: '#555', marginBottom: '8px', textAlign: 'center' }}>
+                {m.solver.possibleWords}
+              </h3>
+              <div
+                style={{
+                  maxHeight: '200px',
+                  overflowY: 'auto',
+                  fontFamily: 'monospace',
+                  fontSize: '0.85rem',
+                  color: '#333',
+                  textAlign: 'center',
+                  lineHeight: '1.6',
+                  wordBreak: 'break-all',
+                }}
+              >
+                {result.words.map((w, i) => (
+                  <span key={w}>
+                    {w.toUpperCase()}
+                    {i < result.words.length - 1 ? ' ' : ''}
+                  </span>
+                ))}
+              </div>
+              {result.totalCount > result.words.length && (
+                <p style={{ textAlign: 'center', fontSize: '0.8rem', color: '#787c7e', marginTop: '4px' }}>
+                  {m.solver.andNMore(result.totalCount - result.words.length)}
+                </p>
+              )}
+            </div>
+          )}
+
+          {/* Best Letters */}
+          {result.bestLetters.length > 0 && (
+            <div>
+              <h3 style={{ fontSize: '0.9rem', color: '#555', marginBottom: '8px', textAlign: 'center' }}>
+                {m.solver.bestLetters}
+              </h3>
+              <div style={{ maxWidth: '280px', margin: '0 auto' }}>
+                {result.bestLetters.map((bl) => (
+                  <div key={bl.letter} style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '3px' }}>
+                    <span style={{ width: '20px', textAlign: 'right', fontWeight: 'bold', fontSize: '0.85rem', color: '#555', textTransform: 'uppercase', fontFamily: 'monospace' }}>
+                      {bl.letter}
+                    </span>
+                    <div style={{ flex: 1, height: '16px', backgroundColor: '#e0e0e0', borderRadius: '2px' }}>
+                      <div
+                        style={{
+                          height: '100%',
+                          backgroundColor: '#6aaa64',
+                          width: `${Math.max(bl.pct, bl.pct > 0 ? 2 : 0)}%`,
+                          borderRadius: '2px',
+                          minWidth: bl.pct > 0 ? '8px' : '0px',
+                        }}
+                      />
+                    </div>
+                    <span style={{ width: '32px', fontSize: '0.75rem', color: '#787c7e', textAlign: 'right' }}>{bl.pct}%</span>
+                  </div>
+                ))}
+              </div>
+            </div>
+          )}
+            </>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}

+ 328 - 0
raw/10-feature-wordle-solver.md

@@ -0,0 +1,328 @@
+# Feature: Wordle Solver Tool
+
+Implement a Wordle solver at `/solver` (EN) and `/ru/solver` (RU), using a 6×5
+game-board grid as the primary input mechanism.
+
+---
+
+## 1. Routing
+
+- `/solver` → English solver (uses `word-bank.json`)
+- `/ru/solver` → Russian solver (uses `word-bank-ru.json`)
+- `detectLang()` recognizes `/solver` as EN, `/ru/solver` as RU.
+- The solver is a client-side screen served by the existing SPA.
+
+---
+
+## 2. Architecture: Server-Side Filtering
+
+Dictionaries will grow (more languages, variable word lengths). All filtering runs on
+the server to keep the SPA lean.
+
+```
+POST /api/solver         (EN)
+POST /ru/api/solver      (RU)
+```
+
+**Request:**
+```json
+{
+  "rows": [
+    { "guess": "ocean", "result": "gyyxx" },
+    { "guess": "train", "result": "xxgxy" }
+  ]
+}
+```
+
+- `rows`: array of guess+result objects, one per row the user has filled in. Ordered from
+  first guess to most recent. May be empty or contain up to 6 entries.
+  - `guess`: 5-letter word the user tried (lowercase).
+  - `result`: 5-char feedback string. Each character is `g` (green — correct letter,
+    correct position), `y` (yellow — correct letter, wrong position), or `x` (gray —
+    letter not in the target word).
+
+**Response (200):**
+```json
+{
+  "words": ["crane", "crone", "crony"],
+  "totalCount": 3,
+  "suggestedGuess": "crane",
+  "bestLetters": [
+    { "letter": "r", "pct": 100 },
+    { "letter": "n", "pct": 100 }
+  ]
+}
+```
+
+- `words`: matching candidates, sorted alphabetically. Capped at 200.
+- `totalCount`: true count (may exceed `words.length`).
+- `suggestedGuess`: best next guess from the candidate set (`null` if 0 candidates).
+  If exactly 1 candidate, it is that word.
+- `bestLetters`: top 15 letters by frequency across candidates, with percentage (0–100).
+
+**Validation (400):**
+- If `rows` is empty (no complete guesses submitted): `{ "error": "At least one complete guess row required" }`.
+  The UI ensures every letter has a color (gray by default), so every fully-typed row is complete.
+
+---
+
+## 3. UI: 6×5 Game-Board Grid
+
+### 3.1 Board Layout
+
+```
+┌───────────────────────────────┐
+│  [LangSwitch]                 │
+│  Wordle Solver                │
+│                               │
+│  ┌───┬───┬───┬───┬───┐       │
+│  │ C │ R │ A │ N │ E │  ← 1  │  ← gray row of tiles
+│  ├───┼───┼───┼───┼───┤       │     (C,A,E gray, R yellow, N green)
+│  │   │   │   │   │   │  ← 2  │
+│  ├───┼───┼───┼───┼───┤       │
+│  │   │   │   │   │   │  ← 3  │
+│  ├───┼───┼───┼───┼───┤       │
+│  │   │   │   │   │   │  ← 4  │
+│  ├───┼───┼───┼───┼───┤       │
+│  │   │   │   │   │   │  ← 5  │
+│  ├───┼───┼───┼───┼───┤       │
+│  │   │   │   │   │   │  ← 6  │
+│  └───┴───┴───┴───┴───┘       │
+│                               │
+│  [  Update  ]                 │
+│                               │
+│  ─── Results ───              │
+│  ...                          │
+└───────────────────────────────┘
+```
+
+- 6 rows × 5 columns of tiles.
+- Each tile: 52×52px (same as game grid).
+- Empty tile: white background, light border.
+- Letter typed (default gray): white letter on `#787c7e` gray background.
+- Yellow: white letter on `#c9b458` background.
+- Green: white letter on `#6aaa64` background.
+- Every letter has a color from the moment it's typed — gray is the default.
+
+### 3.2 Cursor
+
+- One tile at a time has the **cursor** (blinking underline, same as game screen).
+- Cursor is always in the **lowest non-empty row**, or row 1 if all rows empty.
+- Initially cursor is at row 1, column 1.
+
+### 3.3 Tile Interaction (Tap)
+
+Tapping a tile does two things depending on context:
+
+| Condition | Action |
+|-----------|--------|
+| Tile is **not** focused (cursor elsewhere) | Move cursor to this tile |
+| Tile **is** focused AND has a letter | Cycle color: *gray → yellow → green → gray → …* |
+| Tile **is** focused AND is empty | No-op (stay in place) |
+
+Letters default to gray when typed. To mark a letter as yellow or green, tap the focused
+tile to cycle its color. To edit a letter: tap the tile once (focuses it), then type the
+replacement letter.
+
+### 3.4 Keyboard Input
+
+The on-screen keyboard from the game is reused. In addition to the 3 letter rows and
+nav row, the solver adds a small help row above the keyboard:
+
+```
+[ Tap tile: focus ] [ Tap focused tile: cycle color ] [ Update ]
+```
+
+Or more minimal: just a hint text above the keyboard:
+"Tap a tile to focus it. Tap an already focused tile to cycle its color."
+
+**Letter keys:**
+- If the cursor is at a tile with no letter → fills that tile with the typed letter,
+  colors it gray by default, advances cursor to the next column.
+- If the cursor is at a tile that already has a letter → replaces the letter, resets
+  color to gray, keeps cursor in place (user might want to cycle the color next).
+- If cursor is past column 5 → stays at column 5 (row is full).
+
+**Backspace:**
+- Clears the letter AND color at cursor position.
+- Moves cursor left one column.
+- If already at column 1 → moves to row above, column 5 (if previous row exists).
+
+**Enter:**
+- Triggers Update (same as clicking the Update button), if enabled.
+
+**Arrow keys:**
+- Left/Right: move cursor within current row (clamped).
+- Up: move to same column in previous row (if it exists). If that row is empty,
+  cursor goes to last filled column in that row, or column 1.
+- Down: move to same column in next row (if it exists).
+
+### 3.5 Physical Keyboard
+
+Mirrors the on-screen keyboard behavior:
+- `[a-zA-Zа-яА-Я]` → type letter at cursor (defaults to gray).
+- `Backspace` → clear tile, move left. If all 5 tiles in a row are cleared, the row becomes empty.
+- `ArrowLeft/Right/Up/Down` → move cursor.
+- `Enter` → Update (if enabled).
+
+### 3.6 Update Button
+
+- Rendered below the board.
+- **Disabled** (greyed out) when:
+  - No letters have been entered anywhere on the board (all 30 tiles empty), OR
+  - Any row has 1–4 letters (a partially filled row). The user must either complete that
+    row to 5 letters or clear it entirely (Backspace each tile).
+- **Enabled** when: at least one row is fully typed (all 5 letters) AND no partial rows exist.
+- On click: gathers all fully-typed rows, builds the `{ rows: [{guess, result}] }` payload,
+  sends `POST /api/solver`, displays results.
+- While request is in flight: button shows spinner, is disabled (prevents double-submit).
+
+### 3.7 Row Submission
+
+Rows are either completely filled (all 5 tiles have letters) or completely empty —
+no partial rows. The Update button stays disabled while any row has 1–4 letters.
+Since every letter is gray by default, a row is "complete" as soon as the 5th letter
+is typed.
+
+Each filled row produces one `{ guess, result }` object:
+- `guess`: the 5 letters, joined into a lowercase string.
+- `result`: the 5 colors, each mapped to `g` / `y` / `x`, joined into a 5-char string.
+
+Rows are sent in the order they appear on the board (row 1 first).
+
+---
+
+## 4. Results Panel
+
+Appears below the Update button after a successful API response.
+
+### 4.1 Results Summary
+- **N words found** (localized).
+- **Suggested: CRANE** (or "The word is: CRANE" if N=1, or "No matching words" if N=0).
+
+### 4.2 Best Letters
+- Top 15 letters by frequency among candidates.
+- Each row: letter, green horizontal bar proportional to percentage, percentage number.
+- Sorted by frequency descending.
+
+### 4.3 Possible Words
+- Scrollable list in monospace font, alphabetical order.
+- Max height to keep results visible without excessive scrolling.
+- If `totalCount > words.length`: show "…and N more" at the bottom.
+
+---
+
+## 5. i18n
+
+Add to `Messages` interface and both language files:
+
+| Key | EN | RU |
+|-----|----|----|
+| `solver.title` | Wordle Solver | Вордли-помощник |
+| `solver.updateButton` | Update | Обновить |
+| `solver.suggestedGuess` | Suggested | Совет |
+| `solver.theWordIs` | The word is | Это слово |
+| `solver.noMatches` | No matching words | Нет подходящих слов |
+| `solver.bestLetters` | Best Letters | Лучшие буквы |
+| `solver.possibleWords` | Possible Words | Возможные слова |
+| `solver.resultsCount` | `{n} words found` | `Найдено слов: {n}` |
+| `solver.andNMore` | `…and {n} more` | `…и ещё {n}` |
+| `solver.needOneRow` | Enter at least one guess | Введите хотя бы одну попытку |
+| `solver.backToGame` | ← Game | ← Игра |
+| `solver.tapHint` | Letters start gray · tap focused tile to cycle: gray→yellow→green | Буквы начинаются серым · нажмите ещё раз: серый→жёлтый→зелёный |
+
+---
+
+## 6. New / Modified Files
+
+```
+shared/src/api.ts                      — SolverRequest, SolverResponse types
+server/src/routes/solver.ts            — POST /api/solver (filtering + scoring logic)
+server/src/index.ts                    — mount solver route
+client/src/api.ts                      — postSolver() client function
+client/src/messages/index.ts           — solver i18n keys
+client/src/messages/en.ts              — EN strings
+client/src/messages/ru.ts              — RU strings
+client/src/screens/ScreenSolver.tsx    — solver screen (board + results)
+client/src/components/SolverBoard.tsx  — 6×5 editable board with cursor + color cycling
+client/src/App.tsx                     — route /solver, /ru/solver to solver screen
+```
+
+The existing `Keyboard` component is reused as-is. `SolverBoard` is a new component
+(significantly different from the game `Grid` — editable, per-tile color cycling, 6-row
+cursor navigation).
+
+---
+
+## 7. Filtering Logic (Server-Side)
+
+Each row `{ guess, result }` yields constraints. The server merges constraints from all rows
+into a consolidated filter.
+
+**Per-row constraint extraction:**
+```
+greens[i]  = guess[i]  for each i where result[i] == 'g'
+yellows   += guess[i]  for each i where result[i] == 'y'
+grays     += guess[i]  for each i where result[i] == 'x'
+```
+
+**Merge rules across rows:**
+- Green: later rows override earlier rows for the same position (they reflect more
+  recent information).
+- Yellow: accumulate all yellow letters across rows. If a letter appears as green in
+  ANY row, remove it from yellows.
+- Gray: accumulate all gray letters. If a letter appears as green or yellow in ANY row,
+  remove it from grays (green/yellow take precedence).
+
+**Candidate filtering:**
+```
+function filterCandidates(words, rows):
+  extract greens, yellows, grays from rows as above
+
+  candidates = []
+  for word in words:
+    // Green: positional match
+    reject if any greens[i] is set and word[i] != greens[i]
+
+    // Gray: excluded letters
+    for letter in grays:
+      if letter in word: reject
+
+    // Yellow: from each row, extract per-position constraints.
+    // A yellow at (row k, position i) means: target contains row.guess[i],
+    // but NOT at position i. Accumulate forbidden positions and multiplicity.
+    // Then:
+    for each (letter, forbiddenPos) in yellowConstraints:
+      if word[forbiddenPos] == letter: reject
+    for each (letter, minCount) in yellowMultiplicity:
+      if countIn(word, letter) < minCount: reject
+
+    candidates.append(word)
+  return candidates
+```
+
+**Suggested guess scoring:** sum positional letter frequencies across all candidates; pick
+the candidate with the highest score.
+
+---
+
+## 8. Acceptance Criteria
+
+1. `/solver` → 6×5 board, empty tiles, cursor at row 1 col 1, Update disabled.
+2. `/ru/solver` → same board, Russian labels, works with Russian dictionary.
+3. Tap a tile → cursor moves there.
+4. Type a letter → fills the tile in gray, cursor advances to next column.
+5. Tap a focused tile with a letter → color cycles: gray → yellow → green → gray.
+6. Backspace → clears tile, cursor moves left. Clearing all 5 tiles makes the row empty.
+7. Arrow keys navigate within/across rows.
+8. No letters anywhere OR a partial row exists (1-4 letters) → Update greyed/disabled.
+9. ≥1 fully-typed row AND no partial rows → Update enabled.
+10. Update → sends `{ rows: [{guess, result}, ...] }` → API returns candidates.
+11. Rows are either fully filled (5 letters) or empty. No partial rows are sent.
+12. Suggested guess / "The word is" / "No matching words" shown correctly.
+13. Best Letters panel shows frequency bars.
+14. Possible Words list scrolls, capped at 200.
+15. LangSwitch toggles between `/solver` and `/ru/solver`.
+16. "← Game" returns to `/` or `/ru/`.
+17. Loading spinner during API call.
+18. Mobile-responsive at 320px.

+ 2 - 0
server/src/index.ts

@@ -8,6 +8,7 @@ import { createDailyRouter } from './routes/daily.js';
 import { createGuessRouter } from './routes/guess.js';
 import { createPlayAgainRouter } from './routes/play-again.js';
 import { createValidateRouter } from './routes/validate.js';
+import { createSolverRouter } from './routes/solver.js';
 
 const __dirname = dirname(fileURLToPath(import.meta.url));
 
@@ -28,6 +29,7 @@ function mountApi(prefix: string, bank: WordBank, cache: Map<number, CachedResul
     [prefix + '/validate', createValidateRouter(bank)],
     [prefix + '/guess', createGuessRouter(bank, cache)],
     [prefix + '/play-again', createPlayAgainRouter(bank, cache)],
+    [prefix + '/solver', createSolverRouter(bank)],
   ] as const;
 }
 

+ 232 - 0
server/src/routes/solver.ts

@@ -0,0 +1,232 @@
+import { Router, Request, Response } from 'express';
+import type { WordBank, SolverResponse } from '@wordle/shared';
+
+export function createSolverRouter(wordBank: WordBank): Router {
+  const router = Router();
+  const { guessable } = wordBank;
+
+  router.post('/', (req: Request, res: Response) => {
+    const { rows } = (req.body ?? {}) as { rows?: { guess: string; result: string }[] };
+
+    if (!Array.isArray(rows)) {
+      res.status(400).json({ error: 'Invalid request: rows must be an array' });
+      return;
+    }
+
+    // Empty board — return best starting word as a hint
+    if (rows.length === 0) {
+      const posFreq: Map<string, number>[] = Array.from({ length: 5 }, () => new Map());
+      for (const word of guessable) {
+        for (let i = 0; i < 5; i++) {
+          const letter = word[i];
+          posFreq[i].set(letter, (posFreq[i].get(letter) ?? 0) + 1);
+        }
+      }
+      let bestWord = '';
+      let bestScore = -1;
+      for (const word of guessable) {
+        let score = 0;
+        for (let i = 0; i < 5; i++) {
+          score += posFreq[i].get(word[i]) ?? 0;
+        }
+        if (score > bestScore) {
+          bestScore = score;
+          bestWord = word;
+        }
+      }
+      res.json({ words: [], totalCount: 0, suggestedGuess: null, bestLetters: [], hint: bestWord } satisfies SolverResponse);
+      return;
+    }
+
+    // Validate rows
+    for (const row of rows) {
+      if (
+        typeof row.guess !== 'string' ||
+        row.guess.length !== 5 ||
+        typeof row.result !== 'string' ||
+        row.result.length !== 5
+      ) {
+        res.status(400).json({ error: 'Each row must have guess (5 letters) and result (5 chars: g/y/x)' });
+        return;
+      }
+      if (!/^[a-zA-Zа-яА-Я]{5}$/.test(row.guess)) {
+        res.status(400).json({ error: `Invalid guess: ${row.guess}` });
+        return;
+      }
+      if (!/^[gyx]{5}$/i.test(row.result)) {
+        res.status(400).json({ error: `Invalid result: ${row.result}` });
+        return;
+      }
+    }
+
+    // ── Extract constraints ──────────────────────────────────────────
+
+    const greens = new Map<number, string>(); // position → letter
+    const yellowForbidden = new Map<string, Set<number>>(); // letter → forbidden positions
+    const yellowMinCount = new Map<string, number>(); // letter → min occurrences in target
+    const grayCandidates = new Set<string>(); // letters seen as gray anywhere
+    const allGreenYellow = new Set<string>(); // letters seen as g or y in ANY row
+
+    for (const row of rows) {
+      const g = row.guess.toLowerCase();
+      const r = row.result.toLowerCase();
+
+      // Per-row counts for green+yellow (min number of each letter in target)
+      const rowCounts = new Map<string, number>();
+
+      for (let i = 0; i < 5; i++) {
+        const letter = g[i];
+        const fb = r[i];
+
+        if (fb === 'g') {
+          greens.set(i, letter);
+          allGreenYellow.add(letter);
+          rowCounts.set(letter, (rowCounts.get(letter) ?? 0) + 1);
+        } else if (fb === 'y') {
+          allGreenYellow.add(letter);
+          rowCounts.set(letter, (rowCounts.get(letter) ?? 0) + 1);
+          if (!yellowForbidden.has(letter)) {
+            yellowForbidden.set(letter, new Set());
+          }
+          yellowForbidden.get(letter)!.add(i);
+        } else {
+          grayCandidates.add(letter);
+        }
+      }
+
+      // Merge per-row min counts (take max across rows)
+      for (const [letter, count] of rowCounts) {
+        yellowMinCount.set(letter, Math.max(yellowMinCount.get(letter) ?? 0, count));
+      }
+    }
+
+    // Remove from gray set: any letter that also appears as green or yellow
+    const graySet = new Set<string>();
+    for (const letter of grayCandidates) {
+      if (!allGreenYellow.has(letter)) {
+        graySet.add(letter);
+      }
+    }
+
+    // ── Filter candidates ────────────────────────────────────────────
+
+    const candidates: string[] = [];
+    for (const word of guessable) {
+      // Green positional check
+      let ok = true;
+      for (const [pos, letter] of greens) {
+        if (word[pos] !== letter) {
+          ok = false;
+          break;
+        }
+      }
+      if (!ok) continue;
+
+      // Gray exclusion check
+      for (const letter of graySet) {
+        if (word.includes(letter)) {
+          ok = false;
+          break;
+        }
+      }
+      if (!ok) continue;
+
+      // Yellow forbidden positions
+      for (const [letter, positions] of yellowForbidden) {
+        for (const pos of positions) {
+          if (word[pos] === letter) {
+            ok = false;
+            break;
+          }
+        }
+        if (!ok) break;
+      }
+      if (!ok) continue;
+
+      // Yellow minimum count
+      for (const [letter, minCount] of yellowMinCount) {
+        let count = 0;
+        for (let i = 0; i < 5; i++) {
+          if (word[i] === letter) count++;
+        }
+        if (count < minCount) {
+          ok = false;
+          break;
+        }
+      }
+      if (!ok) continue;
+
+      candidates.push(word);
+    }
+
+    // Sort alphabetically
+    candidates.sort();
+
+    const totalCount = candidates.length;
+
+    // ── Suggested guess ──────────────────────────────────────────────
+    // Score: sum of positional letter frequencies across candidates
+
+    let suggestedGuess: string | null = null;
+    if (candidates.length === 1) {
+      suggestedGuess = candidates[0];
+    } else if (candidates.length > 1) {
+      // Positional frequency table
+      const posFreq: Map<string, number>[] = Array.from({ length: 5 }, () => new Map());
+      for (const word of candidates) {
+        for (let i = 0; i < 5; i++) {
+          const letter = word[i];
+          posFreq[i].set(letter, (posFreq[i].get(letter) ?? 0) + 1);
+        }
+      }
+
+      let bestScore = -1;
+      for (const word of candidates) {
+        let score = 0;
+        for (let i = 0; i < 5; i++) {
+          score += posFreq[i].get(word[i]) ?? 0;
+        }
+        if (score > bestScore) {
+          bestScore = score;
+          suggestedGuess = word;
+        }
+      }
+    }
+
+    // ── Best letters ─────────────────────────────────────────────────
+    // Count how many candidates contain each letter (once per word)
+
+    const letterCounts = new Map<string, number>();
+    for (const word of candidates) {
+      const seen = new Set<string>();
+      for (let i = 0; i < 5; i++) {
+        const letter = word[i];
+        if (!seen.has(letter)) {
+          seen.add(letter);
+          letterCounts.set(letter, (letterCounts.get(letter) ?? 0) + 1);
+        }
+      }
+    }
+
+    const bestLetters = Array.from(letterCounts.entries())
+      .map(([letter, count]) => ({
+        letter,
+        pct: Math.round((count / totalCount) * 100),
+      }))
+      .sort((a, b) => b.pct - a.pct || a.letter.localeCompare(b.letter))
+      .slice(0, 15);
+
+    // ── Response ─────────────────────────────────────────────────────
+
+    const response: SolverResponse = {
+      words: candidates.slice(0, 200),
+      totalCount,
+      suggestedGuess,
+      bestLetters,
+    };
+
+    res.json(response);
+  });
+
+  return router;
+}

+ 27 - 0
shared/src/api.ts

@@ -44,6 +44,33 @@ export interface ValidateResponse {
   valid: boolean;
 }
 
+/** POST /api/solver request — a single guess+feedback row */
+export interface SolverRow {
+  guess: string;
+  result: string; // 5-char string of g/y/x
+}
+
+/** POST /api/solver request */
+export interface SolverRequest {
+  rows: SolverRow[];
+}
+
+/** A letter with its frequency percentage among candidates */
+export interface BestLetter {
+  letter: string;
+  pct: number;
+}
+
+/** POST /api/solver response */
+export interface SolverResponse {
+  words: string[];
+  totalCount: number;
+  suggestedGuess: string | null;
+  bestLetters: BestLetter[];
+  /** Starting-word hint, present only for empty-board submissions */
+  hint?: string;
+}
+
 /** Standard API error shape */
 export interface ApiError {
   error: string;