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:
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { getJSON, postJSON } from '$lib/api.js';
|
||||
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
|
||||
|
||||
let { variant = '5', onstatus } = $props();
|
||||
|
||||
@@ -40,6 +41,7 @@
|
||||
}
|
||||
loading = false;
|
||||
requestAnimationFrame(() => (ready = true));
|
||||
syncNow(); // reconcile with the server in the background (signed-in only)
|
||||
}
|
||||
|
||||
function restore() {
|
||||
@@ -59,6 +61,30 @@
|
||||
try { localStorage.setItem(stateKey, JSON.stringify({ guesses, cols, status, answer, why })); } catch { /* ignore */ }
|
||||
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() {
|
||||
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); }
|
||||
else if (guesses.length >= maxGuesses) { status = 'lost'; answer = res.answer; why = res.why; recordStat(false); }
|
||||
persist();
|
||||
syncSoon(); // push this guess (and any completion) to the server, debounced
|
||||
} catch {
|
||||
flash('Hmm — couldn’t check that. Try again.');
|
||||
} finally {
|
||||
@@ -119,6 +146,7 @@
|
||||
}
|
||||
let stats = $derived.by(() => {
|
||||
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; }
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { getJSON } from '$lib/api.js';
|
||||
import { lineFrom, matchWord, cellFromPoint } from '$lib/wordsearch.js';
|
||||
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
|
||||
|
||||
let { size = 'med', onstatus } = $props();
|
||||
|
||||
@@ -32,10 +33,30 @@
|
||||
const status = $derived(words.length && foundWords.length === words.length ? 'done' : 'playing');
|
||||
const selSet = $derived(new Set(sel.map(([r, c]) => r + ',' + c)));
|
||||
const cellColor = $derived.by(() => {
|
||||
const m = new Map();
|
||||
for (const w of foundWords) for (const [r, c] of w.cells) m.set(r + ',' + c, w.ci);
|
||||
const m = new Map(); // "r,c" -> [colour index, ...] (all words covering it)
|
||||
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;
|
||||
});
|
||||
|
||||
// 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 m = new Map();
|
||||
for (const w of foundWords) m.set(w.word, w.ci);
|
||||
@@ -60,20 +81,30 @@
|
||||
if (seq !== loadSeq) return;
|
||||
loading = false;
|
||||
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() {
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
|
||||
if (s && Array.isArray(s.foundWords)) {
|
||||
// Keep only finds whose stored cells still spell their word in the CURRENT
|
||||
// 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;
|
||||
foundWords = validFinds(s.foundWords);
|
||||
startTime = s.startTime || 0;
|
||||
resultMs = valid.length === words.length ? (s.ms || 0) : 0;
|
||||
resultMs = foundWords.length === words.length ? (s.ms || 0) : 0;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
onstatus?.(summary());
|
||||
@@ -83,6 +114,27 @@
|
||||
catch { /* ignore */ }
|
||||
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 cellAt(e) {
|
||||
@@ -115,6 +167,7 @@
|
||||
okFlash = true; setTimeout(() => (okFlash = false), 500);
|
||||
if (foundWords.length === words.length) finish();
|
||||
persist();
|
||||
syncSoon(); // push this find (and completion) to the server, debounced
|
||||
}
|
||||
|
||||
function finish() {
|
||||
@@ -157,7 +210,7 @@
|
||||
{#each rowStr.split('') as ch, c (c)}
|
||||
{@const key = r + ',' + c}
|
||||
<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}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -350,6 +350,13 @@ class WordGuessRequest(BaseModel):
|
||||
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):
|
||||
word: str
|
||||
|
||||
@@ -1567,6 +1574,42 @@ def create_app() -> FastAPI:
|
||||
raise HTTPException(status_code=400, detail=res["error"])
|
||||
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 ---
|
||||
@app.get("/api/admin/word/lookup")
|
||||
def admin_word_lookup(word: str, request: Request) -> dict:
|
||||
|
||||
@@ -300,6 +300,16 @@ CREATE TABLE IF NOT EXISTS daily_puzzles (
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user