Spaces:
Running
Running
Download server/web/app.js from FineEnvs/ScottWuMath: direct link, hf CLI and curl.
- Browser
- Download file 15.5 kB
-
https://huggingface.co/spaces/FineEnvs/ScottWuMath/resolve/main/server/web/app.js
- Command line
-
hf download hf://spaces/FineEnvs/ScottWuMath/server/web/app.js
-
curl -L -o app.js https://huggingface.co/spaces/FineEnvs/ScottWuMath/resolve/main/server/web/app.js
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(); | |
| })(); | |