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>
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 — couldnt 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>
+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;
}
}