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:
@@ -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