ScottWuMath / server /web /app.js
AdithyaSK's picture
AdithyaSK HF Staff
Rename to Scott Wu Math; add eval results; mobile layout fixes
4de8125 verified
Raw History Blame Contribute Delete
15.5 kB
// The play page. It holds one OpenEnv WebSocket session (/ws): "reset" deals, "step" submits the
// final expression. Combining cards on the table only builds that expression; the server grades it.
(function () {
const $ = (id) => document.getElementById(id);
const LEVEL_TITLES = { "24": "24", "three-digit": "Three-digit", year: "Year", "four-digit": "Four-digit", custom: "Your number", video: "From the video" };
const SYM = { "+": "+", "-": "−", "*": "×", "/": "÷" };
const PREC = { "+": 1, "-": 1, "*": 2, "/": 2 };
// ---- exact fractions ----------------------------------------------------
const gcd = (a, b) => { a = Math.abs(a); b = Math.abs(b); while (b) [a, b] = [b, a % b]; return a || 1; };
const frac = (n, d = 1) => { if (d < 0) { n = -n; d = -d; } const g = gcd(n, d); return { n: n / g, d: d / g }; };
const apply = (op, a, b) => {
if (op === "+") return frac(a.n * b.d + b.n * a.d, a.d * b.d);
if (op === "-") return frac(a.n * b.d - b.n * a.d, a.d * b.d);
if (op === "*") return frac(a.n * b.n, a.d * b.d);
if (b.n === 0) return null;
return frac(a.n * b.d, a.d * b.n);
};
const show = (f) => (f.d === 1 ? String(f.n) : `${f.n}/${f.d}`);
const num = (n) => (Math.abs(n) < 10000 ? String(n) : n.toLocaleString("en-US"));
const pretty = (f) => (f.d === 1 ? num(f.n) : `${f.n}/${f.d}`);
// Same parenthesisation as the server's to_text.
function text(tree, symbols) {
const walk = (t) => {
if (typeof t === "number") return [String(t), 3];
const [op, l, r] = t;
let [lt, lp] = walk(l), [rt, rp] = walk(r);
if (lp < PREC[op]) lt = `(${lt})`;
if (rp < PREC[op] || (rp === PREC[op] && (op === "-" || op === "/"))) rt = `(${rt})`;
return [`${lt} ${symbols ? SYM[op] : op} ${rt}`, PREC[op]];
};
return walk(tree)[0];
}
// ---- state ----------------------------------------------------------------
const S = {
ws: null, ready: null, pending: null,
level: "year", videoIndex: 0,
task: null, piles: [], history: [], steps: [],
selected: null, op: null, done: false, busy: false,
startedAt: 0, timer: null, nextId: 0,
};
// ---- OpenEnv session --------------------------------------------------------
function connect() {
if (S.ws && S.ws.readyState <= 1) return S.ready;
const ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws");
S.ws = ws;
S.ready = new Promise((resolve, reject) => {
ws.onopen = () => resolve();
ws.onerror = () => reject(new Error("Could not reach the environment server."));
});
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
const p = S.pending; S.pending = null;
if (!p) return;
if (msg.type === "error") p.reject(new Error((msg.data && msg.data.message) || "Server error"));
else p.resolve(msg.data);
};
ws.onclose = () => {
if (S.pending) { S.pending.reject(new Error("The session closed. Deal again.")); S.pending = null; }
S.ws = null;
};
return S.ready;
}
async function send(type, data) {
await connect();
return new Promise((resolve, reject) => {
S.pending = { resolve, reject };
S.ws.send(JSON.stringify({ type, data }));
});
}
// ---- dealing -----------------------------------------------------------------
async function deal() {
if (S.busy) return;
let data;
if (S.level === "video") {
data = { split: "video", index: S.videoIndex };
S.videoIndex = (S.videoIndex + 1) % 2;
} else if (S.level === "custom") {
const raw = $("custom-target").value.replace(/[,\s_]/g, "");
if (!/^\d{1,6}$/.test(raw)) { setStatus("Type a whole number for the target first.", "warn"); $("custom-target").focus(); return; }
data = { target: Number(raw) };
} else if (S.level === "year") {
data = { level: "year", target: new Date().getFullYear() };
} else {
data = { level: S.level };
}
S.busy = true;
setStatus("Dealing…");
try {
const res = await send("reset", data);
start(res.observation);
} catch (e) {
setStatus(e.message, "warn");
} finally {
S.busy = false;
}
}
function start(obs) {
S.task = obs;
S.piles = obs.cards.map((c) => ({ id: S.nextId++, value: frac(c.rank), tree: c.rank, card: c, count: 1 }));
S.history = []; S.steps = []; S.selected = null; S.op = null; S.done = false;
$("target").textContent = num(obs.target);
$("task-id").textContent = obs.task_id.startsWith("video-") ? "As dealt in the video" : `${obs.cards.length} cards`;
$("prompt").textContent = obs.system_prompt + "\n\n" + obs.prompt;
$("expr").value = "";
$("result").hidden = true;
$("table").classList.remove("over");
S.startedAt = performance.now();
clearInterval(S.timer);
S.timer = setInterval(tick, 250);
tick();
render();
setStatus("");
}
function tick() {
const s = Math.floor((performance.now() - S.startedAt) / 1000);
$("timer").textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
// ---- combining on the table ---------------------------------------------------
function pick(id) {
if (S.done || !S.task) return;
if (S.selected === null) { S.selected = id; S.op = null; }
else if (S.selected === id) { S.selected = null; S.op = null; }
else if (S.op) { combine(S.selected, S.op, id); return; }
else S.selected = id;
render();
}
function chooseOp(op) {
if (S.done || S.selected === null) { setStatus("Pick a card first.", "hint"); return; }
S.op = S.op === op ? null : op;
render();
}
function combine(aId, op, bId) {
const a = S.piles.find((p) => p.id === aId), b = S.piles.find((p) => p.id === bId);
const value = apply(op, a.value, b.value);
if (!value) { setStatus("Can't divide by zero.", "warn"); S.op = null; render(); return; }
S.history.push({ piles: S.piles.slice(), steps: S.steps.slice() });
const made = { id: S.nextId++, value, tree: [op, a.tree, b.tree], card: null, count: a.count + b.count, fresh: true };
S.steps.push(`${show(a.value)} ${SYM[op]} ${show(b.value)} = ${show(value)}`);
const at = Math.min(S.piles.indexOf(a), S.piles.indexOf(b));
S.piles = S.piles.filter((p) => p !== a && p !== b);
S.piles.splice(Math.min(at, S.piles.length), 0, made);
S.selected = S.piles.length > 1 ? made.id : null;
S.op = null;
render();
if (S.piles.length === 1) {
const last = S.piles[0];
if (last.value.d === 1 && last.value.n === S.task.target) submit(text(last.tree, false));
else setStatus(`All cards used: ${pretty(last.value)}, not ${num(S.task.target)}. Undo, or submit it as it is.`, "warn", true);
} else setStatus("");
}
function undo() {
if (S.done || !S.history.length) return;
const prev = S.history.pop();
S.piles = prev.piles; S.steps = prev.steps; S.selected = null; S.op = null;
render(); setStatus("");
}
function restart() {
if (S.done || !S.history.length) return;
const first = S.history[0];
S.history = []; S.piles = first.piles; S.steps = []; S.selected = null; S.op = null;
render(); setStatus("");
}
// ---- submitting ---------------------------------------------------------------
async function submit(expression) {
if (S.done || S.busy || !S.task) return;
S.busy = true;
setStatus("Checking…");
try {
const res = await send("step", { expression });
finish(res, expression);
} catch (e) {
setStatus(e.message, "warn");
} finally {
S.busy = false;
}
}
function finish(res, expression) {
S.done = true;
clearInterval(S.timer);
const obs = res.observation, r = obs.result || {}, check = r.check || {};
const secs = obs.elapsed_s != null ? obs.elapsed_s : (performance.now() - S.startedAt) / 1000;
$("table").classList.add("over");
let head, cls;
if (!expression) { head = "Here is one way"; cls = "neutral"; }
else if (r.exact) { head = `Exact — ${num(obs.target)} in ${fmtTime(secs)}`; cls = "good"; }
else if (r.valid) { head = `${check.value} — off by ${offBy(check.value, obs.target)}`; cls = "near"; }
else { head = r.error ? capital(r.error) : "Not a valid answer"; cls = "bad"; }
const out = $("result");
out.className = "result " + cls;
out.replaceChildren();
const top = document.createElement("div");
top.className = "result-head";
top.append(span("result-title", head), span("result-reward", `reward ${Number(res.reward ?? 0).toFixed(2)}`));
out.append(top);
if (expression && check.canonical) out.append(kv("You", prettyExpr(check.canonical)));
if (obs.solution) {
out.append(kv(r.exact ? "Another way" : "A solution", prettyExpr(obs.solution)));
const ol = document.createElement("ol");
ol.className = "steps compact";
for (const s of obs.solution_steps) ol.append(span("", s, "li"));
out.append(ol);
} else if (!r.exact) {
out.append(kv("A solution", "None found for this hand."));
}
const again = document.createElement("button");
again.className = "btn primary";
again.textContent = "Deal again";
again.onclick = deal;
out.append(again);
out.hidden = false;
record(S.task, !!r.exact, secs);
render();
setStatus("");
out.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
// ---- rendering ----------------------------------------------------------------
function render() {
const piles = $("piles");
piles.replaceChildren();
piles.dataset.count = S.piles.length;
if (S.task) piles.style.setProperty("--n", S.task.cards.length);
for (const p of S.piles) {
const node = p.card ? Cards.playingCard(p.card) : Cards.resultCard(pretty(p.value), text(p.tree, true), p.count);
const btn = document.createElement("button");
btn.className = "pile" + (p.id === S.selected ? " selected" : "") + (p.fresh ? " fresh" : "");
btn.type = "button";
btn.disabled = S.done;
btn.append(node);
btn.setAttribute("aria-pressed", p.id === S.selected ? "true" : "false");
btn.onclick = () => pick(p.id);
piles.append(btn);
p.fresh = false;
}
for (const b of document.querySelectorAll("#ops button")) {
b.disabled = S.done || S.selected === null || S.piles.length < 2;
b.classList.toggle("on", b.dataset.op === S.op);
}
$("undo").disabled = S.done || !S.history.length;
$("restart").disabled = S.done || !S.history.length;
$("giveup").disabled = S.done || !S.task;
$("typed").querySelector("button").disabled = S.done || !S.task;
$("expr").disabled = S.done || !S.task;
const steps = $("steps");
steps.replaceChildren(...S.steps.map((s) => span("", s, "li")));
$("steps-empty").hidden = S.steps.length > 0;
const groups = S.piles.filter((p) => !p.card);
$("expression").textContent = S.piles.length === 1 ? text(S.piles[0].tree, true) : groups.length ? groups.map((p) => text(p.tree, true)).join(" · ") : "—";
for (const b of document.querySelectorAll(".modes button")) b.setAttribute("aria-selected", b.dataset.level === S.level ? "true" : "false");
$("custom").hidden = S.level !== "custom";
renderStats();
}
function setStatus(msg, kind, withSubmit) {
const node = $("status");
node.className = "status" + (kind ? " " + kind : "");
node.replaceChildren(document.createTextNode(msg));
if (withSubmit) {
const b = document.createElement("button");
b.className = "btn small";
b.textContent = "Submit anyway";
b.onclick = () => submit(text(S.piles[0].tree, false));
node.append(" ", b);
}
}
// ---- per-browser record --------------------------------------------------------
const STORE = "scott-wu-math-stats-v1";
function load() { try { return JSON.parse(localStorage.getItem(STORE)) || {}; } catch { return {}; } }
function record(task, solved, secs) {
const all = load(), key = task.task_id.startsWith("video-") ? "video" : task.level;
const s = all[key] || { played: 0, solved: 0, best: null };
s.played += 1;
if (solved) { s.solved += 1; s.best = s.best == null ? secs : Math.min(s.best, secs); }
all[key] = s;
try { localStorage.setItem(STORE, JSON.stringify(all)); } catch {}
}
function renderStats() {
const all = load(), body = $("stats").querySelector("tbody");
body.replaceChildren();
for (const key of Object.keys(LEVEL_TITLES)) {
const s = all[key];
if (!s) continue;
const tr = document.createElement("tr");
tr.append(span("", LEVEL_TITLES[key], "td"), span("num", `${s.solved}/${s.played}`, "td"), span("num", s.best == null ? "—" : fmtTime(s.best), "td"));
body.append(tr);
}
$("stats").hidden = !body.children.length;
$("stats").closest("section").hidden = !body.children.length;
}
// ---- helpers ------------------------------------------------------------------
function span(cls, t, tag = "span") { const n = document.createElement(tag); if (cls) n.className = cls; n.textContent = t; return n; }
function kv(k, v) { const d = document.createElement("div"); d.className = "kv"; d.append(span("k", k), span("v", v)); return d; }
function prettyExpr(s) { return s.replace(/\*/g, "×").replace(/\//g, "÷").replace(/ - /g, " − "); }
function capital(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
function fmtTime(secs) { const s = Math.round(secs); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; }
function offBy(valueText, target) {
const [n, d] = valueText.split("/").map(Number);
const diff = Math.abs(n / (d || 1) - target);
return Number.isInteger(diff) ? num(diff) : diff.toFixed(2);
}
// ---- wiring -------------------------------------------------------------------
for (const b of document.querySelectorAll(".modes button")) {
b.onclick = () => {
S.level = b.dataset.level;
render();
if (S.level === "custom") { $("custom-target").focus(); return; }
deal();
};
}
for (const b of document.querySelectorAll("#ops button")) b.onclick = () => chooseOp(b.dataset.op);
$("custom").onsubmit = (e) => { e.preventDefault(); deal(); };
$("typed").onsubmit = (e) => { e.preventDefault(); const v = $("expr").value.trim(); if (v) submit(v); };
$("undo").onclick = undo;
$("restart").onclick = restart;
$("giveup").onclick = () => submit("");
$("deal").onclick = deal;
$("rules-link").onclick = (e) => { e.preventDefault(); $("rules").showModal(); };
document.addEventListener("keydown", (e) => {
if (e.target.closest("input, textarea, dialog") || e.metaKey || e.ctrlKey || e.altKey) return;
const ops = { "+": "+", "-": "-", "*": "*", x: "*", X: "*", "/": "/" };
if (ops[e.key]) { e.preventDefault(); chooseOp(ops[e.key]); }
else if (e.key === "Backspace") { e.preventDefault(); undo(); }
else if (e.key === "Escape") { S.selected = null; S.op = null; render(); }
else if (e.key === "Enter" && S.done) { e.preventDefault(); deal(); }
});
// Start on the level in the URL (#24, #four-digit, …), else the year, as in the video.
const fromHash = decodeURIComponent(location.hash.slice(1));
if (LEVEL_TITLES[fromHash]) S.level = fromHash;
render();
if (S.level !== "custom") deal();
})();