Games: cross-device sync + overlap colour-blend

Two game polish items:

- Word Search: overlapping cells now multiply-blend the crossing words' colours
  (deepening to a darker shade with readable text) instead of the newest colour
  stomping the rest — matches the new interlocking grids.

- Cross-device game-state sync (signed-in): per-puzzle progress + stats now
  follow you between devices. New game_state table; server-side merge on every
  save so two devices converge regardless of push order, tailored per game:
  * Word Search → UNION of finds (monotonic; can't un-find), earliest start,
    best completion time.
  * Word → furthest-progress wins (terminal beats in-progress; more guesses
    beats fewer) — picks one device's game whole, never splices guesses.
  Stats (streak/distribution/best) derived server-side from the synced states,
  so they're consistent instead of per-device counters. Endpoints GET/PUT
  /api/games/state + GET /api/games/stats (signed-in; size-capped). Frontend is
  local-first: games paint instantly from localStorage, then reconcile in the
  background; both game components push debounced on each move and adopt the
  merge. Conflict handling unit-tested + an API two-device convergence test.

235→ tests + 11 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-12 13:35:20 -04:00
parent 2ef0efd909
commit dd0df64d76
7 changed files with 380 additions and 10 deletions
@@ -1,5 +1,6 @@
<script> <script>
import { getJSON, postJSON } from '$lib/api.js'; import { getJSON, postJSON } from '$lib/api.js';
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
let { variant = '5', onstatus } = $props(); let { variant = '5', onstatus } = $props();
@@ -40,6 +41,7 @@
} }
loading = false; loading = false;
requestAnimationFrame(() => (ready = true)); requestAnimationFrame(() => (ready = true));
syncNow(); // reconcile with the server in the background (signed-in only)
} }
function restore() { function restore() {
@@ -59,6 +61,30 @@
try { localStorage.setItem(stateKey, JSON.stringify({ guesses, cols, status, answer, why })); } catch { /* ignore */ } try { localStorage.setItem(stateKey, JSON.stringify({ guesses, cols, status, answer, why })); } catch { /* ignore */ }
onstatus?.(summary()); onstatus?.(summary());
} }
// --- cross-device sync (signed-in only; "furthest progress" merged server-side) ---
let serverStats = $state(null);
let syncTimer;
function adopt(merged) {
if (!merged || !Array.isArray(merged.guesses)) return;
const lr = [status !== 'playing' ? 1 : 0, guesses.length];
const mr = [merged.status && merged.status !== 'playing' ? 1 : 0, merged.guesses.length];
if (mr[0] > lr[0] || (mr[0] === lr[0] && mr[1] > lr[1])) { // server is further along
guesses = merged.guesses;
cols = Array.isArray(merged.cols) ? merged.cols : cols;
status = merged.status || 'playing';
answer = merged.answer ?? answer;
why = merged.why ?? why;
persist();
}
}
async function syncNow() {
const d = date, v = variant;
const merged = await pushGameState('word', v, d, { guesses, cols, status, answer, why });
if (d === date && v === variant) adopt(merged);
serverStats = await fetchGameStats('word', variant); // streak/dist follow you across devices
}
function syncSoon() { clearTimeout(syncTimer); syncTimer = setTimeout(syncNow, 1000); }
function summary() { function summary() {
return { variant, date, status, tries: guesses.length, max: maxGuesses }; return { variant, date, status, tries: guesses.length, max: maxGuesses };
} }
@@ -101,6 +127,7 @@
if (res.solved) { status = 'won'; answer = res.answer; why = res.why; recordStat(true); } if (res.solved) { status = 'won'; answer = res.answer; why = res.why; recordStat(true); }
else if (guesses.length >= maxGuesses) { status = 'lost'; answer = res.answer; why = res.why; recordStat(false); } else if (guesses.length >= maxGuesses) { status = 'lost'; answer = res.answer; why = res.why; recordStat(false); }
persist(); persist();
syncSoon(); // push this guess (and any completion) to the server, debounced
} catch { } catch {
flash('Hmm — couldnt check that. Try again.'); flash('Hmm — couldnt check that. Try again.');
} finally { } finally {
@@ -119,6 +146,7 @@
} }
let stats = $derived.by(() => { let stats = $derived.by(() => {
if (status === 'playing') return null; if (status === 'playing') return null;
if (serverStats) return serverStats; // signed-in: consistent across devices
try { return JSON.parse(localStorage.getItem(statsKey) || 'null'); } catch { return null; } try { return JSON.parse(localStorage.getItem(statsKey) || 'null'); } catch { return null; }
}); });
@@ -1,6 +1,7 @@
<script> <script>
import { getJSON } from '$lib/api.js'; import { getJSON } from '$lib/api.js';
import { lineFrom, matchWord, cellFromPoint } from '$lib/wordsearch.js'; import { lineFrom, matchWord, cellFromPoint } from '$lib/wordsearch.js';
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
let { size = 'med', onstatus } = $props(); let { size = 'med', onstatus } = $props();
@@ -32,10 +33,30 @@
const status = $derived(words.length && foundWords.length === words.length ? 'done' : 'playing'); const status = $derived(words.length && foundWords.length === words.length ? 'done' : 'playing');
const selSet = $derived(new Set(sel.map(([r, c]) => r + ',' + c))); const selSet = $derived(new Set(sel.map(([r, c]) => r + ',' + c)));
const cellColor = $derived.by(() => { const cellColor = $derived.by(() => {
const m = new Map(); const m = new Map(); // "r,c" -> [colour index, ...] (all words covering it)
for (const w of foundWords) for (const [r, c] of w.cells) m.set(r + ',' + c, w.ci); for (const w of foundWords) for (const [r, c] of w.cells) {
const k = r + ',' + c;
if (!m.has(k)) m.set(k, []);
m.get(k).push(w.ci);
}
return m; return m;
}); });
// Multiply-blend the palette colours of every word covering a cell, so where
// words cross the cell DEEPENS into a darker shade instead of one colour
// stomping the others. Single-word cells keep their plain pastel.
function cellStyle(indices) {
let r = 255, g = 255, b = 255;
for (const ci of indices) {
const h = PALETTE[ci];
r = (r * parseInt(h.slice(1, 3), 16)) / 255;
g = (g * parseInt(h.slice(3, 5), 16)) / 255;
b = (b * parseInt(h.slice(5, 7), 16)) / 255;
}
r = Math.round(r); g = Math.round(g); b = Math.round(b);
const lum = 0.299 * r + 0.587 * g + 0.114 * b; // keep text readable as cells darken
return `background:rgb(${r},${g},${b});color:${lum < 140 ? '#fff' : '#2a2f36'}`;
}
const wordColor = $derived.by(() => { const wordColor = $derived.by(() => {
const m = new Map(); const m = new Map();
for (const w of foundWords) m.set(w.word, w.ci); for (const w of foundWords) m.set(w.word, w.ci);
@@ -60,20 +81,30 @@
if (seq !== loadSeq) return; if (seq !== loadSeq) return;
loading = false; loading = false;
requestAnimationFrame(() => (ready = true)); requestAnimationFrame(() => (ready = true));
// Reconcile with the server in the background (signed-in only): pull any
// progress from another device, and pull the cross-device best time.
syncNow();
fetchGameStats('wordsearch', size).then((st) => {
if (st && st.best && (!best || st.best < best)) best = st.best;
});
}
// Keep only finds whose cells still spell their word in the CURRENT grid —
// guards stale highlights if the puzzle/layout changed (and validates finds
// arriving from another device, which is the same date+size grid).
function validFinds(list) {
return (list || []).filter((fw) =>
fw && Array.isArray(fw.cells) && words.includes(fw.word) &&
fw.cells.map(([r, c]) => (grid[r] && grid[r][c]) || '').join('') === fw.word);
} }
function restore() { function restore() {
try { try {
const s = JSON.parse(localStorage.getItem(stateKey) || 'null'); const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
if (s && Array.isArray(s.foundWords)) { if (s && Array.isArray(s.foundWords)) {
// Keep only finds whose stored cells still spell their word in the CURRENT foundWords = validFinds(s.foundWords);
// grid — guards against stale highlights if the day's puzzle changed.
const valid = s.foundWords.filter((fw) =>
fw && Array.isArray(fw.cells) && words.includes(fw.word) &&
fw.cells.map(([r, c]) => (grid[r] && grid[r][c]) || '').join('') === fw.word);
foundWords = valid;
startTime = s.startTime || 0; startTime = s.startTime || 0;
resultMs = valid.length === words.length ? (s.ms || 0) : 0; resultMs = foundWords.length === words.length ? (s.ms || 0) : 0;
} }
} catch { /* ignore */ } } catch { /* ignore */ }
onstatus?.(summary()); onstatus?.(summary());
@@ -83,6 +114,27 @@
catch { /* ignore */ } catch { /* ignore */ }
onstatus?.(summary()); onstatus?.(summary());
} }
// --- cross-device sync (signed-in only; merged server-side) ---
let syncTimer;
function adopt(merged) {
if (!merged) return;
const have = new Set(foundWords.map((w) => w.word));
for (const fw of validFinds(merged.foundWords)) {
if (!have.has(fw.word)) { foundWords = [...foundWords, fw]; have.add(fw.word); }
}
// renumber colours by find order so overlap blends stay consistent
foundWords = foundWords.map((fw, i) => ({ ...fw, ci: i % PALETTE.length }));
if (merged.startTime && (!startTime || merged.startTime < startTime)) startTime = merged.startTime;
if (foundWords.length === words.length && merged.ms) resultMs = Math.min(resultMs || merged.ms, merged.ms);
persist();
}
async function syncNow() {
const d = date, sz = size; // pin against a size switch mid-flight
const merged = await pushGameState('wordsearch', sz, d, { foundWords, startTime, ms: resultMs });
if (d === date && sz === size) adopt(merged); // ignore if the user switched away
}
function syncSoon() { clearTimeout(syncTimer); syncTimer = setTimeout(syncNow, 1200); }
function summary() { return { game: 'wordsearch', size, date, status, found: foundWords.length, total: words.length, ms: resultMs }; } function summary() { return { game: 'wordsearch', size, date, status, found: foundWords.length, total: words.length, ms: resultMs }; }
function cellAt(e) { function cellAt(e) {
@@ -115,6 +167,7 @@
okFlash = true; setTimeout(() => (okFlash = false), 500); okFlash = true; setTimeout(() => (okFlash = false), 500);
if (foundWords.length === words.length) finish(); if (foundWords.length === words.length) finish();
persist(); persist();
syncSoon(); // push this find (and completion) to the server, debounced
} }
function finish() { function finish() {
@@ -157,7 +210,7 @@
{#each rowStr.split('') as ch, c (c)} {#each rowStr.split('') as ch, c (c)}
{@const key = r + ',' + c} {@const key = r + ',' + c}
<div class="cell" class:sel={selSet.has(key)} <div class="cell" class:sel={selSet.has(key)}
style={cellColor.has(key) && !selSet.has(key) ? `background:${PALETTE[cellColor.get(key)]};color:#2a2f36` : ''}>{ch}</div> style={cellColor.has(key) && !selSet.has(key) ? cellStyle(cellColor.get(key)) : ''}>{ch}</div>
{/each} {/each}
{/each} {/each}
</div> </div>
+30
View File
@@ -0,0 +1,30 @@
// Cross-device game-state sync. Local-first: games always paint from
// localStorage instantly; these calls reconcile with the server in the
// background. Signed-out players stay purely local (no-ops here).
import { getJSON, putJSON } from './api.js';
import { auth } from './auth.svelte.js';
// Push this device's state → the server merges it with any other device's
// progress → returns the merged state to adopt. null when signed out / on error
// (the game just stays on its local copy).
export async function pushGameState(game, variant, date, local) {
if (!auth.user) return null;
try {
const r = await putJSON('/api/games/state', { game, variant, date, state: local || {} }, { timeout: 8000 });
return r?.state ?? null;
} catch {
return null;
}
}
// Derived record (streak / distribution / best) computed server-side from the
// player's synced states, so it's consistent across devices. null when signed out.
export async function fetchGameStats(game, variant) {
if (!auth.user) return null;
try {
const q = `game=${encodeURIComponent(game)}&variant=${encodeURIComponent(variant)}`;
return (await getJSON('/api/games/stats?' + q))?.stats ?? null;
} catch {
return null;
}
}
+43
View File
@@ -350,6 +350,13 @@ class WordGuessRequest(BaseModel):
n: int = 1 # this guess's position (1-based); the answer is revealed only at n >= max n: int = 1 # this guess's position (1-based); the answer is revealed only at n >= max
class GameStateBody(BaseModel):
game: str
variant: str
date: str
state: dict = {}
class WordPoolBody(BaseModel): class WordPoolBody(BaseModel):
word: str word: str
@@ -1567,6 +1574,42 @@ def create_app() -> FastAPI:
raise HTTPException(status_code=400, detail=res["error"]) raise HTTPException(status_code=400, detail=res["error"])
return res return res
# --- Cross-device game state sync (signed-in only; merged server-side) ---
def _game_ok(game: str, variant: str) -> bool:
return (game == "word" and variant in games.WORD_VARIANTS) or \
(game == "wordsearch" and variant in games.WS_TIERS)
@app.get("/api/games/state")
def game_state_get(game: str, variant: str, date: str, request: Request) -> dict:
if not _game_ok(game, variant):
raise HTTPException(status_code=404, detail="no such game")
with get_conn() as conn:
user = _current_user(conn, request)
if not user:
return {"state": None}
return {"state": games.load_game_state(conn, user["id"], game, variant, date[:10])}
@app.put("/api/games/state")
def game_state_put(body: GameStateBody, request: Request) -> dict:
if not _game_ok(body.game, body.variant):
raise HTTPException(status_code=404, detail="no such game")
if len(json.dumps(body.state)) > 20000: # a real game state is tiny — reject junk
raise HTTPException(status_code=413, detail="state too large")
with get_conn() as conn:
user = _current_user(conn, request)
if not user:
return {"state": body.state} # signed out → no sync, just echo
merged = games.save_game_state(conn, user["id"], body.game, body.variant, body.date[:10], body.state or {})
return {"state": merged}
@app.get("/api/games/stats")
def game_stats_get(game: str, variant: str, request: Request) -> dict:
if not _game_ok(game, variant):
raise HTTPException(status_code=404, detail="no such game")
with get_conn() as conn:
user = _current_user(conn, request)
return {"stats": games.game_stats(conn, user["id"], game, variant) if user else None}
# --- Admin: Daily Word pool curation --- # --- Admin: Daily Word pool curation ---
@app.get("/api/admin/word/lookup") @app.get("/api/admin/word/lookup")
def admin_word_lookup(word: str, request: Request) -> dict: def admin_word_lookup(word: str, request: Request) -> dict:
+10
View File
@@ -300,6 +300,16 @@ CREATE TABLE IF NOT EXISTS daily_puzzles (
UNIQUE (puzzle_date, game, variant) UNIQUE (puzzle_date, game, variant)
); );
CREATE TABLE IF NOT EXISTS game_state (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
game TEXT NOT NULL, -- 'word' | 'wordsearch'
variant TEXT NOT NULL, -- '5'|'6' | 'small'|'med'|'large'
puzzle_date TEXT NOT NULL,
state_json TEXT NOT NULL, -- per-puzzle progress; merged server-side on save
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, game, variant, puzzle_date)
);
CREATE TABLE IF NOT EXISTS user_follows ( CREATE TABLE IF NOT EXISTS user_follows (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+105
View File
@@ -540,6 +540,111 @@ def _build_grid(words: list[str], size: int, seed: int) -> tuple[list[str], list
return ["".join(row) for row in grid], placed return ["".join(row) for row in grid], placed
# --- Cross-device game state sync -------------------------------------------
# Per-puzzle progress is merged SERVER-SIDE on every save, so two devices
# converge regardless of push order. The merge is tailored to each game's nature.
def _merge_wordsearch(a: dict, b: dict) -> dict:
"""Union the found words (a find is monotonic — you can't un-find one, so the
union is always correct), keep the earliest start and the best (min) time."""
by_word = {}
for fw in list(a.get("foundWords") or []) + list(b.get("foundWords") or []):
w = fw.get("word") if isinstance(fw, dict) else None
if w and w not in by_word:
by_word[w] = fw
starts = [s for s in (a.get("startTime"), b.get("startTime")) if s]
times = [m for m in (a.get("ms"), b.get("ms")) if m]
return {
"foundWords": list(by_word.values()),
"startTime": min(starts) if starts else 0,
"ms": min(times) if times else 0,
}
def _word_rank(s: dict) -> tuple:
return (1 if s.get("status") in ("won", "lost") else 0, len(s.get("guesses") or []))
def _merge_word(a: dict, b: dict) -> dict:
"""Furthest progress wins: a finished game beats in-progress, more guesses
beats fewer. Picks one device's game WHOLE — never splices guess sequences."""
return a if _word_rank(a) >= _word_rank(b) else b
def merge_game_state(game: str, a: dict | None, b: dict | None) -> dict:
if not a:
return dict(b or {})
if not b:
return dict(a or {})
return _merge_wordsearch(a, b) if game == "wordsearch" else _merge_word(a, b)
def load_game_state(conn: sqlite3.Connection, user_id: int, game: str, variant: str, date: str) -> dict | None:
row = conn.execute(
"SELECT state_json FROM game_state WHERE user_id=? AND game=? AND variant=? AND puzzle_date=?",
(user_id, game, variant, date),
).fetchone()
if not row:
return None
try:
return json.loads(row["state_json"])
except (ValueError, TypeError):
return None
def save_game_state(conn: sqlite3.Connection, user_id: int, game: str, variant: str,
date: str, incoming: dict) -> dict:
"""Merge incoming with the stored state (server-authoritative) and persist."""
stored = load_game_state(conn, user_id, game, variant, date)
merged = merge_game_state(game, stored, incoming or {})
conn.execute(
"INSERT INTO game_state (user_id, game, variant, puzzle_date, state_json, updated_at) "
"VALUES (?,?,?,?,?,CURRENT_TIMESTAMP) "
"ON CONFLICT(user_id, game, variant, puzzle_date) DO UPDATE SET "
"state_json=excluded.state_json, updated_at=CURRENT_TIMESTAMP",
(user_id, game, variant, date, json.dumps(merged)),
)
conn.commit()
return merged
def game_stats(conn: sqlite3.Connection, user_id: int, game: str, variant: str) -> dict:
"""Derive the player's record for a variant from their synced states, so
streak / distribution / best are consistent across devices (not per-device counters)."""
rows = conn.execute(
"SELECT puzzle_date, state_json FROM game_state "
"WHERE user_id=? AND game=? AND variant=? ORDER BY puzzle_date DESC",
(user_id, game, variant),
).fetchall()
states = []
for r in rows:
try:
states.append(json.loads(r["state_json"]))
except (ValueError, TypeError):
pass
if game == "wordsearch":
times = [s.get("ms") for s in states if s.get("ms")]
return {"completed": sum(1 for s in states if s.get("ms")), "best": min(times) if times else 0}
played = won = 0
dist: dict[int, int] = {}
streak = 0
streak_open = True # consecutive wins counting back from newest (matches the old local counter)
for s in states:
st = s.get("status")
if st in ("won", "lost"):
played += 1
if st == "won":
won += 1
g = len(s.get("guesses") or [])
dist[g] = dist.get(g, 0) + 1
if streak_open:
streak += 1
elif st == "lost":
streak_open = False
# 'playing' (e.g. today unfinished) neither counts nor breaks the streak
return {"played": played, "won": won, "streak": streak, "dist": dist}
def wordsearch_response(conn: sqlite3.Connection, date: str, size: str = "med") -> dict: def wordsearch_response(conn: sqlite3.Connection, date: str, size: str = "med") -> dict:
"""Public shape for a size tier: theme + placed words + grid. The grid is meant """Public shape for a size tier: theme + placed words + grid. The grid is meant
to be seen the play is finding the words so there's nothing to hide.""" to be seen the play is finding the words so there's nothing to hide."""
+101
View File
@@ -0,0 +1,101 @@
"""Cross-device game-state sync: server-side merge so two devices converge."""
import pytest
from goodnews import games
from goodnews.db import connect, init_db
@pytest.fixture
def conn(tmp_path):
c = connect(str(tmp_path / "t.db"))
init_db(c)
c.execute("INSERT INTO users (email) VALUES ('a@b.c')")
c.commit()
return c
# --- merge logic (the audited core) ---
def test_merge_wordsearch_unions_finds():
a = {"foundWords": [{"word": "CAT", "cells": [[0, 0]], "ci": 0}], "startTime": 100, "ms": 0}
b = {"foundWords": [{"word": "DOG", "cells": [[1, 1]], "ci": 1}], "startTime": 50, "ms": 0}
m = games.merge_game_state("wordsearch", a, b)
assert {f["word"] for f in m["foundWords"]} == {"CAT", "DOG"} # union of finds
assert m["startTime"] == 50 # earliest start
def test_merge_wordsearch_dedupes_and_keeps_best_time():
a = {"foundWords": [{"word": "CAT", "cells": [[0, 0]], "ci": 0}], "ms": 5000}
b = {"foundWords": [{"word": "CAT", "cells": [[0, 0]], "ci": 0}], "ms": 3000}
m = games.merge_game_state("wordsearch", a, b)
assert len(m["foundWords"]) == 1 and m["ms"] == 3000 # same word once, best time
def test_merge_word_furthest_progress_wins():
playing = {"status": "playing", "guesses": ["AAAAA", "BBBBB"]}
won = {"status": "won", "guesses": ["AAAAA", "CCCCC", "DDDDD"]}
assert games.merge_game_state("word", playing, won) == won # terminal beats in-progress
less = {"status": "playing", "guesses": ["A"]}
more = {"status": "playing", "guesses": ["A", "B", "C"]}
assert games.merge_game_state("word", less, more) == more # more guesses beats fewer
def test_merge_handles_missing_sides():
a = {"status": "won", "guesses": ["x"]}
assert games.merge_game_state("word", None, a) == a
assert games.merge_game_state("word", a, None) == a
# --- persistence convergence ---
def test_save_converges_across_devices(conn):
games.save_game_state(conn, 1, "wordsearch", "small", "2026-06-12",
{"foundWords": [{"word": "CAT", "cells": [[0, 0]], "ci": 0}], "startTime": 100})
merged = games.save_game_state(conn, 1, "wordsearch", "small", "2026-06-12",
{"foundWords": [{"word": "DOG", "cells": [[1, 1]], "ci": 1}], "startTime": 50})
assert {f["word"] for f in merged["foundWords"]} == {"CAT", "DOG"}
# stored state reflects the merge (order-independent)
assert games.load_game_state(conn, 1, "wordsearch", "small", "2026-06-12")["startTime"] == 50
# --- derived stats ---
def test_word_stats_streak_and_distribution(conn):
games.save_game_state(conn, 1, "word", "5", "2026-06-12", {"status": "won", "guesses": ["a", "b", "c"]})
games.save_game_state(conn, 1, "word", "5", "2026-06-11", {"status": "won", "guesses": ["a", "b", "c", "d"]})
games.save_game_state(conn, 1, "word", "5", "2026-06-10", {"status": "lost", "guesses": ["a"] * 6})
st = games.game_stats(conn, 1, "word", "5")
assert st["played"] == 3 and st["won"] == 2
assert st["streak"] == 2 # two most-recent wins, then the loss stops it
assert st["dist"] == {3: 1, 4: 1}
def test_wordsearch_stats_best_time(conn):
games.save_game_state(conn, 1, "wordsearch", "med", "2026-06-12", {"foundWords": [], "ms": 4000})
games.save_game_state(conn, 1, "wordsearch", "med", "2026-06-11", {"foundWords": [], "ms": 6000})
st = games.game_stats(conn, 1, "wordsearch", "med")
assert st["completed"] == 2 and st["best"] == 4000
# --- API round-trip (signed-in only; needs the test helpers) ---
def test_game_state_api_roundtrip(tmp_path, monkeypatch):
from test_admin import _make, _signin
from fastapi.testclient import TestClient
app, api = _make(tmp_path, monkeypatch)
# signed out → no sync, echoes the posted state and GET sees nothing stored
anon = TestClient(app)
body = {"game": "wordsearch", "variant": "small", "date": "2026-06-12",
"state": {"foundWords": [{"word": "CAT", "cells": [[0, 0]], "ci": 0}], "startTime": 9}}
assert anon.put("/api/games/state", json=body).json()["state"]["foundWords"][0]["word"] == "CAT"
assert anon.get("/api/games/state?game=wordsearch&variant=small&date=2026-06-12").json()["state"] is None
# signed in: push from "device A", then "device B" → server returns the union
tc = _signin(app, api, "p@x.com")
tc.put("/api/games/state", json=body)
bodyB = {**body, "state": {"foundWords": [{"word": "DOG", "cells": [[1, 1]], "ci": 1}], "startTime": 4}}
merged = tc.put("/api/games/state", json=bodyB).json()["state"]
assert {f["word"] for f in merged["foundWords"]} == {"CAT", "DOG"} and merged["startTime"] == 4
# GET returns the stored merge; unknown game → 404
got = tc.get("/api/games/state?game=wordsearch&variant=small&date=2026-06-12").json()["state"]
assert {f["word"] for f in got["foundWords"]} == {"CAT", "DOG"}
assert tc.get("/api/games/state?game=nope&variant=x&date=2026-06-12").status_code == 404