diff --git a/frontend/src/routes/play/+page.svelte b/frontend/src/routes/play/+page.svelte index 71c4ea1..a90ad97 100644 --- a/frontend/src/routes/play/+page.svelte +++ b/frontend/src/routes/play/+page.svelte @@ -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); diff --git a/goodnews/games.py b/goodnews/games.py index 997fa83..bd99cde 100644 --- a/goodnews/games.py +++ b/goodnews/games.py @@ -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)