| /* Sharing games: the page's side of the research bargain. | |
| * | |
| * Faïence is a research project, and the games people play are the research | |
| * material. So when a game ends (and when a game is abandoned mid-way), this | |
| * module sends the record that js/record.js builds to a small collector | |
| * (web/ingest/, a separate Space) which replays it in the real engine and | |
| * commits it to the public dataset. The About panel says all of this in the | |
| * page, and Settings holds the switch: sharing is ON by default, visibly, and | |
| * turning it off is respected everywhere in this file. | |
| * | |
| * Two promises this module keeps: | |
| * | |
| * 1. **A game never depends on the upload.** Everything here is fire and | |
| * forget: sendBeacon on the way out of the page, a fetch whose failure | |
| * is swallowed everywhere else, and no caller ever awaits either. If | |
| * the collector is asleep, blocked or gone, the game plays on and the | |
| * record waits in localStorage for the visitor's next visit. | |
| * 2. **The record carries no identity.** It is the moves, the deals, the | |
| * net and the score, exactly what the Save button offers as a file. The | |
| * collector stores nothing else (it never logs IPs or user agents), and | |
| * everything it stores is public, so a player can read the whole take. | |
| * | |
| * The dev guard mirrors analytics.js: localhost plays thousands of games in | |
| * the test suites and none of them are players, so none of them may send. | |
| */ | |
| export const INGEST_BASE = "https://remifabre-faience-ingest.hf.space"; | |
| export const DATASET_URL = "https://huggingface.co/datasets/RemiFabre/faience-games"; | |
| const SHARE_KEY = "faience.share"; | |
| const OUTBOX_KEY = "faience.outbox"; | |
| const OUTBOX_MAX = 8; // a courtesy buffer, not an archive: oldest games drop first | |
| /** Whether sharing is on. Anything but a stored "off" means yes: the default. */ | |
| export function sharingOn() { | |
| try { | |
| return localStorage.getItem(SHARE_KEY) !== "off"; | |
| } catch (err) { | |
| return true; | |
| } | |
| } | |
| export function setSharing(on) { | |
| try { | |
| localStorage.setItem(SHARE_KEY, on ? "on" : "off"); | |
| } catch (err) { | |
| /* private mode; the choice just will not survive a reload */ | |
| } | |
| } | |
| /** True while the page is somewhere no data should ever be sent from: | |
| * local development, the test suites, and any staging copy of the site. */ | |
| function devHost() { | |
| const host = location.hostname; | |
| return host === "localhost" || host === "127.0.0.1" || host === "" || host.includes("staging"); | |
| } | |
| function readOutbox() { | |
| try { | |
| const kept = JSON.parse(localStorage.getItem(OUTBOX_KEY) || "[]"); | |
| return Array.isArray(kept) ? kept : []; | |
| } catch (err) { | |
| return []; | |
| } | |
| } | |
| function writeOutbox(entries) { | |
| try { | |
| localStorage.setItem(OUTBOX_KEY, JSON.stringify(entries.slice(-OUTBOX_MAX))); | |
| } catch (err) { | |
| /* full or forbidden storage loses the retry, never the game */ | |
| } | |
| } | |
| /** | |
| * Queue `record` and try to deliver the whole outbox. The normal path for a | |
| * finished game: the collector's reply confirms delivery, so what fails | |
| * (asleep, offline, blocked) stays queued for the next call on any visit. | |
| */ | |
| export function shareRecord(record) { | |
| if (!sharingOn() || devHost() || !record || !record.moves || !record.moves.length) return; | |
| writeOutbox([...readOutbox(), JSON.stringify(record)]); | |
| flushOutbox(); | |
| } | |
| let flushing = false; | |
| export async function flushOutbox() { | |
| if (flushing || !sharingOn() || devHost()) return; | |
| const entries = readOutbox(); | |
| if (!entries.length) return; | |
| flushing = true; | |
| try { | |
| const kept = []; | |
| for (const body of entries) { | |
| try { | |
| // no Content-Type header: the default text/plain keeps this a simple | |
| // CORS request, same as the beacon, and the collector reads the body | |
| // not the label. The collector answers 204 once it has the record | |
| // (duplicates are deduplicated there, by content). | |
| const reply = await fetch(INGEST_BASE + "/game", { method: "POST", body }); | |
| if (!reply.ok) kept.push(body); | |
| } catch (err) { | |
| kept.push(body); | |
| } | |
| } | |
| writeOutbox(kept); | |
| } finally { | |
| flushing = false; | |
| } | |
| } | |
| /** | |
| * The way out of a closing page: sendBeacon is built for exactly this moment | |
| * and survives the navigation. Delivery cannot be confirmed, so the record is | |
| * also queued; if the beacon did land, the collector deduplicates the retry. | |
| */ | |
| export function shareOnLeave(record) { | |
| if (!sharingOn() || devHost() || !record || !record.moves || !record.moves.length) return; | |
| const body = JSON.stringify(record); | |
| writeOutbox([...readOutbox(), body]); | |
| try { | |
| navigator.sendBeacon(INGEST_BASE + "/game", body); | |
| } catch (err) { | |
| /* the queue already has it */ | |
| } | |
| } | |
| /** | |
| * A page load costs the collector one /health ping, so a Space that fell | |
| * asleep is awake long before the first game here could finish. Fire-and- | |
| * forget by construction; also the moment to deliver anything left over. | |
| */ | |
| export function warmCollector() { | |
| if (!sharingOn() || devHost()) return; | |
| try { | |
| fetch(INGEST_BASE + "/health", { mode: "no-cors" }).catch(() => {}); | |
| } catch (err) { | |
| /* nothing to do: the retry path covers it */ | |
| } | |
| flushOutbox(); | |
| } | |