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