faience / js /upload.js
RemiFabre's picture
Deploy browser player — run4/ckpt-037888 elo 2361.0
955e8d0
Raw
History Blame Contribute Delete
5.19 kB
/* 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();
}