Explorar el Código

refactor: offload entropy solver to child process, include failed solvers in results

- Move long-running entropy computation (14s) to child_process.fork()
  so it never blocks the API event loop. Fast 3-solver results still
  computed inline on cache miss for immediate response.
- Cache pre-warming now happens in daily and play-again routes too
  (was only in guess), so by the time the player finishes their last
  guess all 4 solvers are usually cached.
- Failed solvers (>6 attempts) now appear on the results page instead
  of being hidden. They contribute 10 attempts to the difficulty score.
- Fix build:bundle sed to use correct relative import depth for files
  in dist/server/src/routes/ (../../../ instead of ../../).
- Revert solver grid to 2x2 layout.

Co-Authored-By: Claude <noreply@anthropic.com>
Oleg Panashchenko hace 1 semana
padre
commit
242beda1c9

+ 4 - 2
client/src/screens/Screen3Results.tsx

@@ -133,9 +133,11 @@ export function Screen3Results({ won, attemptCount, revealedWord, stats, replays
           <p style={{ fontSize: '0.85rem', color: '#787c7e', marginBottom: '12px' }}>
             {r.solverCaption}
           </p>
-          <div style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '280px', margin: '0 auto' }}>
+          <div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px', justifyContent: 'center', maxWidth: '320px', margin: '0 auto' }}>
             {replays.map((rp) => (
-              <MiniBoard key={rp.solver} replay={rp} targetWord={revealedWord} />
+              <div key={rp.solver} style={{ flex: '0 0 calc(50% - 8px)' }}>
+                <MiniBoard replay={rp} targetWord={revealedWord} />
+              </div>
             ))}
           </div>
         </div>

+ 1 - 1
package.json

@@ -10,7 +10,7 @@
   "scripts": {
     "build": "npm run build:client && npm run build:bundle",
     "build:client": "cd client && npx vite build",
-    "build:bundle": "rm -rf dist && mkdir -p dist/server/src/routes dist/shared/src dist/client dist/data && cp -r client/dist/* dist/client/ && cp -r server/src/* dist/server/src/ && cp -r shared/src/* dist/shared/src/ && cp data/word-bank.json data/word-bank-ru.json dist/data/ && cp dist-package.json dist/package.json && find dist/server/src -name '*.ts' -exec sed -i \"s|@wordle/shared|../../shared/src/index.js|g\" {} +",
+    "build:bundle": "rm -rf dist && mkdir -p dist/server/src/routes dist/shared/src dist/client dist/data && cp -r client/dist/* dist/client/ && cp -r server/src/* dist/server/src/ && cp -r shared/src/* dist/shared/src/ && cp data/word-bank.json data/word-bank-ru.json dist/data/ && cp dist-package.json dist/package.json && find dist/server/src -maxdepth 1 -name '*.ts' -exec sed -i \"s|@wordle/shared|../../shared/src/index.js|g\" {} + && find dist/server/src/routes -name '*.ts' -exec sed -i \"s|@wordle/shared|../../../shared/src/index.js|g\" {} +",
     "start": "node --import tsx server/src/index.ts",
     "dev": "node --import tsx server/src/index.ts",
     "solve": "cd solver && npm run solve",

+ 1 - 1
server/src/index.ts

@@ -24,7 +24,7 @@ function createCache(): Map<number, CachedResult> {
 
 function mountApi(prefix: string, bank: WordBank, cache: Map<number, CachedResult>) {
   return [
-    [prefix + '/daily', createDailyRouter(bank)],
+    [prefix + '/daily', createDailyRouter(bank, cache)],
     [prefix + '/validate', createValidateRouter(bank)],
     [prefix + '/guess', createGuessRouter(bank, cache)],
     [prefix + '/play-again', createPlayAgainRouter(bank, cache)],

+ 20 - 2
server/src/routes/daily.ts

@@ -1,13 +1,31 @@
 import { Router, Request, Response } from 'express';
-import type { WordBank } from '@wordle/shared';
+import type { WordBank, CachedResult } from '@wordle/shared';
 import { getDailyWordId } from '../daily-word.js';
+import { getTargetWord } from '../validate.js';
+import { computeReplays } from '@wordle/shared';
+import { runSolverInWorker } from '../solver-pool.js';
 
-export function createDailyRouter(wordBank: WordBank): Router {
+export function createDailyRouter(wordBank: WordBank, cache: Map<number, CachedResult>): Router {
   const router = Router();
 
   router.get('/', (_req: Request, res: Response) => {
     const wordId = getDailyWordId(wordBank.targets.length);
     res.json({ wordId });
+
+    // Pre-warm cache so solver replays are ready (or nearly ready) by the time
+    // the player finishes their last guess.
+    if (!cache.has(wordId)) {
+      const target = getTargetWord(wordBank, wordId);
+      if (!target) return;
+
+      cache.set(wordId, computeReplays(target.word, wordBank.guessable));
+
+      const targetWord = target.word;
+      const guessable = wordBank.guessable;
+      runSolverInWorker({ target: targetWord, allWords: guessable, mode: 'full' })
+        .then((full) => { cache.set(wordId, full); })
+        .catch((err) => { console.error('Solver worker failed:', err); });
+    }
   });
 
   return router;

+ 6 - 6
server/src/routes/guess.ts

@@ -2,7 +2,8 @@ import { Router, Request, Response } from 'express';
 import type { WordBank, GuessResponse, CachedResult } from '@wordle/shared';
 import { isValidGuess, getTargetWord } from '../validate.js';
 import { computeFeedback } from '../feedback.js';
-import { computeReplays, computeReplaysFull } from '@wordle/shared';
+import { computeReplays } from '@wordle/shared';
+import { runSolverInWorker } from '../solver-pool.js';
 
 export function createGuessRouter(wordBank: WordBank, cache: Map<number, CachedResult>): Router {
   const router = Router();
@@ -61,13 +62,12 @@ export function createGuessRouter(wordBank: WordBank, cache: Map<number, CachedR
         cache.set(wordId, result);
 
         // Fire background computation with all 4 solvers (including entropy)
-        // Once complete, the cache is upgraded for the next time this word is played
+        // in a worker thread so the ~14s entropy run never blocks the API.
         const targetWord = target.word;
         const guessable = wordBank.guessable;
-        setTimeout(() => {
-          const full = computeReplaysFull(targetWord, guessable);
-          cache.set(wordId, full);
-        }, 0);
+        runSolverInWorker({ target: targetWord, allWords: guessable, mode: 'full' })
+          .then((full) => { cache.set(wordId, full); })
+          .catch((err) => { console.error('Solver worker failed:', err); });
       }
 
       response.difficulty = result.difficulty;

+ 14 - 0
server/src/routes/play-again.ts

@@ -1,6 +1,8 @@
 import { Router, Request, Response } from 'express';
 import type { WordBank, CachedResult } from '@wordle/shared';
 import { selectPlayAgainWord } from '../play-again.js';
+import { computeReplays } from '@wordle/shared';
+import { runSolverInWorker } from '../solver-pool.js';
 
 export function createPlayAgainRouter(wordBank: WordBank, cache: Map<number, CachedResult>): Router {
   const router = Router();
@@ -15,6 +17,18 @@ export function createPlayAgainRouter(wordBank: WordBank, cache: Map<number, Cac
 
     const target = selectPlayAgainWord(wordBank.targets, skillMetric, cache);
     res.json({ wordId: target.id });
+
+    // Pre-warm cache so solver replays are ready (or nearly ready) by the time
+    // the player finishes their last guess.
+    if (!cache.has(target.id)) {
+      cache.set(target.id, computeReplays(target.word, wordBank.guessable));
+
+      const targetWord = target.word;
+      const guessable = wordBank.guessable;
+      runSolverInWorker({ target: targetWord, allWords: guessable, mode: 'full' })
+        .then((full) => { cache.set(target.id, full); })
+        .catch((err) => { console.error('Solver worker failed:', err); });
+    }
   });
 
   return router;

+ 57 - 0
server/src/solver-pool.ts

@@ -0,0 +1,57 @@
+import { fork, type ChildProcess } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import type { CachedResult } from '@wordle/shared';
+
+interface SolverTask {
+  target: string;
+  allWords: string[];
+  mode: 'fast' | 'full';
+}
+
+interface WorkerMessage {
+  ok: boolean;
+  value?: CachedResult;
+  error?: string;
+}
+
+const workerPath = fileURLToPath(new URL('./solver-worker.ts', import.meta.url));
+
+/**
+ * Run solver computation in a forked child process so long-running entropy
+ * calculations never block the main event loop.
+ *
+ * Uses child_process.fork() rather than worker_threads because the solver
+ * modules are TypeScript and need tsx's --import hook — which fork() can
+ * inject via execArgv while worker_threads cannot.
+ *
+ * Spawns a short-lived child per call; ~100 ms overhead is negligible
+ * compared to the 14 s entropy run.
+ */
+export function runSolverInWorker(task: SolverTask): Promise<CachedResult> {
+  return new Promise((resolve, reject) => {
+    let child: ChildProcess | null = null;
+
+    const done = (err: Error | null, result?: CachedResult) => {
+      if (child) { child.kill(); child = null; }
+      if (err) reject(err);
+      else resolve(result!);
+    };
+
+    child = fork(workerPath, [], {
+      execArgv: ['--import', 'tsx'],
+      stdio: 'pipe',
+    });
+
+    child.on('message', (msg: WorkerMessage) => {
+      if (msg.ok) done(null, msg.value);
+      else done(new Error(msg.error ?? 'unknown worker error'));
+    });
+
+    child.on('error', (err) => done(err));
+    child.on('exit', (code, signal) => {
+      if (child) done(new Error(`Worker exited code=${code} signal=${signal}`));
+    });
+
+    child.send(task);
+  });
+}

+ 19 - 0
server/src/solver-worker.ts

@@ -0,0 +1,19 @@
+import type { CachedResult } from '@wordle/shared';
+import { computeReplays, computeReplaysFull } from '@wordle/shared';
+
+interface SolverTask {
+  target: string;
+  allWords: string[];
+  mode: 'fast' | 'full';
+}
+
+process.on('message', (task: SolverTask) => {
+  try {
+    const result: CachedResult = task.mode === 'full'
+      ? computeReplaysFull(task.target, task.allWords)
+      : computeReplays(task.target, task.allWords);
+    process.send!({ ok: true, value: result });
+  } catch (err) {
+    process.send!({ ok: false, error: String(err) });
+  }
+});

+ 5 - 4
shared/src/solvers/index.ts

@@ -42,11 +42,12 @@ function buildReplays(
     ...s.fn(target, allWords, MAX_ATTEMPTS),
   }));
 
-  const passResults = results.filter((r) => r.attempts <= MAX_ATTEMPTS);
-  const avg = passResults.reduce((sum, r) => sum + r.attempts, 0) / passResults.length;
-  const difficulty = Math.round(avg * 100) / 100;
+  // Difficulty: solvers that fail (>6 attempts) count as 10 attempts.
+  const weightedSum = results.reduce((sum, r) => sum + (r.attempts <= MAX_ATTEMPTS ? r.attempts : 10), 0);
+  const difficulty = Math.round((weightedSum / results.length) * 100) / 100;
 
-  const replays: SolverReplay[] = passResults.map((r) => ({
+  // Show all solvers on the results page, even those that failed to solve.
+  const replays: SolverReplay[] = results.map((r) => ({
     solver: r.name,
     steps: r.steps,
   }));