faience / js /record.js
RemiFabre's picture
Deploy browser player — run4/ckpt-037888 elo 2361.0
5d90ebb
Raw
History Blame Contribute Delete
6.47 kB
/* A finished game, written down: the smallest thing that replays exactly.
*
* The point of this file is data collection, so the rule it follows is *record
* only what cannot be recomputed*. Search targets, position encodings, coach
* verdicts and value estimates can all be regenerated offline by replaying the
* game at any depth, so none of them are stored. Three things cannot:
*
* 1. **the chance events.** The bag is shuffled by this page's own RNG
* (mulberry32, see engine.js), and Python's Mersenne Twister cannot
* reproduce it from the same seed. So the deal of every round is written
* down explicitly: the five factories, plus the bag and lid counts, which
* is what a converter needs to check that no tile appeared or vanished.
* The seed is kept as well, because *this* engine does reproduce a game
* from it, which is how the tests verify a record end to end.
* 2. **the moves**, as action ids. The encoding is `source*30 + color*6 +
* dest` in this engine and in the Python one, character for character, and
* the engine parity test holds them to identical ordered legal actions and
* identical 182-float encodings. So an integer here means exactly the same
* move there.
* 3. **the circumstances**: which seat the human held, which net answered,
* how long it was allowed to think, and how many positions it actually
* visited per move (that last one depends on the visitor's machine, so it
* is not recoverable either).
*
* Nothing here sends anything anywhere: this module only builds the value.
* Sending is js/upload.js's job, governed by the sharing switch in Settings
* (on by default) and described honestly in the About panel; the Save and
* Copy buttons hand the very same record to the player, so what is shared
* and what a player can read are one and the same thing.
*/
export const FORMAT = "faience-game/1";
/**
* Build the record of `session` as it stands.
*
* `extra` carries what the session does not know about itself:
* `{net, backend}` — the exported model's metadata and the runtime that ran it.
* Safe to call at any point in a game; `final.finished` says whether the
* position is terminal.
*/
export function buildRecord(session, extra = {}) {
if (!session) return null;
const state = session.state;
const net = extra.net || {};
const info = session.opponentInfo || {};
const moves = session.log
.filter((e) => e.kind === "move")
.map((e) => {
const move = { ply: e.ply, player: e.player, action: e.action_id };
const think = session.log.find((t) => t.kind === "think" && t.ply === e.ply);
if (think) {
if (Number.isFinite(think.sims) && think.sims > 0) move.sims = think.sims;
// the net's own read of the position it moved from, on its [-1, 1]
// scale: the cheapest signal for "the AI did not see this coming"
if (Number.isFinite(think.value)) move.value = round4(think.value);
}
return move;
});
return {
format: FORMAT,
created_at: new Date().toISOString(),
seed: session.seed,
human_seat: session.humanSeat,
human_first: !!session.humanPlaysFirst,
net: {
run: net.run || info.run || null,
checkpoint: net.checkpoint || info.checkpoint || null,
elo: typeof net.elo === "number" ? net.elo : typeof info.elo === "number" ? info.elo : null,
params: net.num_params || null,
backend: extra.backend || null,
},
think_time_s: session.thinkTimeS,
moves,
deals: (session.deals || []).map((d) => ({
round: d.round,
factories: d.factories.map((f) => f.slice()),
bag: d.bag.slice(),
lid: d.lid.slice(),
})),
final: {
finished: !!state.isTerminal,
scores: state.scores.slice(),
outcome: outcomeOf(session),
rounds: state.roundIndex + 1,
exhausted: !!state.exhausted,
},
};
}
function outcomeOf(session) {
const state = session.state;
if (!state.isTerminal) return null;
const mine = state.scores[session.humanSeat];
const theirs = state.scores[session.aiSeat];
if (mine > theirs) return "human";
if (theirs > mine) return "ai";
return "draw";
}
function round4(v) {
return Math.round(v * 1e4) / 1e4;
}
/** `faience-run4-ckpt-037888-seed31337-ai.json` — sortable, self-describing. */
export function recordFilename(record) {
const bits = ["faience"];
if (record.net.run) bits.push(record.net.run);
if (record.net.checkpoint) bits.push(record.net.checkpoint);
bits.push("seed" + record.seed);
if (record.final.outcome) bits.push(record.final.outcome);
return bits.join("-").replace(/[^A-Za-z0-9._-]/g, "") + ".json";
}
/** Indented for a file, so a human can read what they are about to send. */
export const asFile = (record) => JSON.stringify(record, null, 1);
/** One line, for pasting into an issue or a chat. */
export const asLine = (record) => JSON.stringify(record);
/**
* Hand `record` to the player as a download. Returns true if the browser took
* it; the caller says so in the page (this module never touches the status).
*/
export function downloadRecord(record) {
try {
const blob = new Blob([asFile(record)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = recordFilename(record);
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
// let the click start before the URL goes away
setTimeout(() => URL.revokeObjectURL(url), 4000);
return true;
} catch (err) {
return false;
}
}
/** Copy `record` to the clipboard as one line. Resolves true on success. */
export async function copyRecord(record) {
const text = asLine(record);
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
// an older browser, or a page the clipboard API refuses: fall back to the
// oldest trick there is, a hidden field and the document's own copy command
try {
const box = document.createElement("textarea");
box.value = text;
box.setAttribute("readonly", "");
box.style.position = "fixed";
box.style.top = "-1000px";
document.body.appendChild(box);
box.select();
const ok = document.execCommand("copy");
box.remove();
return ok;
} catch (err2) {
return false;
}
}
}