Games: in-progress hub status + distribution-aware word-search placement (Codex)

- Play hub: word cards now surface IN-PROGRESS games too (not just won/lost) so
  "continue on another device" shows at a glance — card reads "5:3…" and the
  selection option says "Continue · 3/6".
- Word Search generator: replace "prefer any crossing" with a SCORED placement —
  score = overlap*4 - local crowding (filled neighbours that aren't crossings) —
  then pick among the best ~20%. Keeps the organic interlocking but spreads words
  across the board instead of clumping around the first-placed (longest) words.
  Every word still placed (tests green). NOTE: changes today's grid layouts, so
  an in-progress word search resets once.

237 pytest + 11 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-12 15:18:04 -04:00
parent de59cf49d8
commit 64339aafb0
2 changed files with 35 additions and 14 deletions
+9 -4
View File
@@ -24,8 +24,11 @@
function readWord(v) {
try {
const s = JSON.parse(localStorage.getItem(`goodnews:word:${v}:${date}`) || 'null');
if (s && (s.status === 'won' || s.status === 'lost')) {
return { status: s.status, tries: (s.guesses || []).length, max: v === '6' ? 7 : 6 };
const tries = (s?.guesses || []).length;
// Surface in-progress too (so "continue on another device" shows on the card),
// not just finished games.
if (s && (s.status === 'won' || s.status === 'lost' || (s.status === 'playing' && tries > 0))) {
return { status: s.status, tries, max: v === '6' ? 7 : 6 };
}
} catch { /* ignore */ }
return null;
@@ -79,7 +82,7 @@
function wordLabel() {
const a = wordStatus['5'], b = wordStatus['6'];
if (!a && !b) return 'Guess the days word';
const part = (s, mx) => s ? (s.status === 'won' ? `${s.tries}/${mx}` : 'X') : '';
const part = (s, mx) => !s ? '' : s.status === 'won' ? `${s.tries}/${mx}` : s.status === 'lost' ? 'X' : `${s.tries}…`;
return `Today · 5:${part(a, 6)} 6:${part(b, 7)}`;
}
function wsHubLabel() {
@@ -93,7 +96,9 @@
function wordOpt(v) {
const s = wordStatus[v];
if (!s) return 'Play';
return s.status === 'won' ? `Solved ${s.tries}/${s.max}` : 'Out of guesses';
if (s.status === 'won') return `Solved ${s.tries}/${s.max}`;
if (s.status === 'lost') return 'Out of guesses';
return `Continue · ${s.tries}/${s.max}`;
}
function wsOpt(sz) {
const s = readWsSize(sz);
+26 -10
View File
@@ -309,6 +309,20 @@ def adjudicate_word_guess(conn: sqlite3.Connection, date: str, variant: str, gue
_DIRS = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
def _neighbour_fill(grid, cells, size: int) -> int:
"""Filled cells in a candidate's footprint+border that AREN'T its own cells —
a crowding measure, so placement can spread words out instead of clumping."""
own = set(cells)
rs = [r for r, _ in cells]
cs = [c for _, c in cells]
cnt = 0
for r in range(max(0, min(rs) - 1), min(size, max(rs) + 2)):
for c in range(max(0, min(cs) - 1), min(size, max(cs) + 2)):
if (r, c) not in own and grid[r][c] is not None:
cnt += 1
return cnt
# Size tiers. The three sizes draw DISJOINT word slices from the day's pool, so
# each is its own fresh puzzle (no repeats across sizes). small+med+large counts
# sum to WS_NEEDED, the minimum unique words a theme must supply.
@@ -513,23 +527,25 @@ def _build_grid(words: list[str], size: int, seed: int) -> tuple[list[str], list
for word in sorted(words, key=len, reverse=True):
if len(word) > size:
continue
# Gather valid placements, then PREFER one that crosses an already-placed
# word (shares a matching letter) so the grid interlocks like a real word
# search — falling back to any valid spot so every word still gets placed.
valid = [] # (overlap_count, cells)
# Gather valid placements and SCORE them: reward crossing an existing word
# (so the grid interlocks like a real puzzle) but penalise crowding, so
# words spread across the board instead of all clustering around the ones
# placed first. Pick at random among the best ~20% to keep organic variety.
scored = [] # (score, cells)
for _ in range(400):
dr, dc = rng.choice(_DIRS)
r0, c0 = rng.randrange(size), rng.randrange(size)
cells = [(r0 + dr * i, c0 + dc * i) for i in range(len(word))]
if any(not (0 <= r < size and 0 <= c < size) for r, c in cells):
continue
if all(grid[r][c] in (None, word[i]) for i, (r, c) in enumerate(cells)):
overlap = sum(1 for i, (r, c) in enumerate(cells) if grid[r][c] == word[i])
valid.append((overlap, cells))
if not valid:
if not all(grid[r][c] in (None, word[i]) for i, (r, c) in enumerate(cells)):
continue
overlap = sum(1 for i, (r, c) in enumerate(cells) if grid[r][c] == word[i])
scored.append((overlap * 4 - _neighbour_fill(grid, cells, size), cells))
if not scored:
continue
crossing = [c for c in valid if c[0] > 0]
_, cells = rng.choice(crossing if crossing else valid)
scored.sort(key=lambda t: t[0], reverse=True)
_, cells = rng.choice(scored[: max(1, len(scored) // 5)])
for i, (r, c) in enumerate(cells):
grid[r][c] = word[i]
placed.append(word)