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:
@@ -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 day’s 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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user