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:
@@ -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
|
||||
|
||||
|
||||
# --- 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:
|
||||
"""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."""
|
||||
|
||||
Reference in New Issue
Block a user