Ben Blaker Claude Opus 4.8 (1M context) commited on
feat(game): add run stats to the win screen (#71)
Browse filesTally the whole session and surface it on the victory card: time played, total hops, hints used, databases crashed (delete-all-data wipes), and a fail count + rate (missed verdicts + deaths over all attempts). The tally spans every board and survives the delete-all wipe, so it is never reset.
Hints only count when a real hint is revealed (not the shrug), and the label nags with "(seriously..?)" when any were used.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- static/game.html +2 -1
- static/game.js +69 -1
- static/style.css +31 -1
static/game.html
CHANGED
|
@@ -62,7 +62,8 @@
|
|
| 62 |
<div class="sq-victory sq-hidden" role="dialog" aria-modal="true" aria-label="you win">
|
| 63 |
<div class="sq-victory-card">
|
| 64 |
<div class="sq-victory-title">you win!</div>
|
| 65 |
-
<div class="sq-victory-body">Congratulations
|
|
|
|
| 66 |
<button class="sq-victory-close" type="button">keep prompting</button>
|
| 67 |
</div>
|
| 68 |
</div>
|
|
|
|
| 62 |
<div class="sq-victory sq-hidden" role="dialog" aria-modal="true" aria-label="you win">
|
| 63 |
<div class="sq-victory-card">
|
| 64 |
<div class="sq-victory-title">you win!</div>
|
| 65 |
+
<div class="sq-victory-body">Congratulations. I hereby crown you a competent wrangler of small LLMs</div>
|
| 66 |
+
<dl class="sq-victory-stats"></dl>
|
| 67 |
<button class="sq-victory-close" type="button">keep prompting</button>
|
| 68 |
</div>
|
| 69 |
</div>
|
static/game.js
CHANGED
|
@@ -110,6 +110,21 @@
|
|
| 110 |
// Breadcrumb for the cross-level portal: the board to offer a way back to.
|
| 111 |
let returnTo = null;
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
// Each injection target needs its unlock: "build" is free, "small" needs the
|
| 114 |
// shrink, "hackathon" needs the π€ earned by clearing critters. `submitPressable`
|
| 115 |
// is whether a target's lane tile can be shipped right now (unlocked + unshipped).
|
|
@@ -1899,7 +1914,11 @@
|
|
| 1899 |
// "yes": swap the confirm prompt for the actual nudge (re-read so critters
|
| 1900 |
// reflects what's already been collected)
|
| 1901 |
function revealHint() {
|
| 1902 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1903 |
hintQEl.classList.add("sq-hidden");
|
| 1904 |
hintActionsEl.classList.add("sq-hidden");
|
| 1905 |
hintTextEl.classList.remove("sq-hidden");
|
|
@@ -2190,6 +2209,11 @@
|
|
| 2190 |
}
|
| 2191 |
|
| 2192 |
function land(r, c) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2193 |
pos = [r, c];
|
| 2194 |
|
| 2195 |
// a swap tile hops you to another board β costs no budget, adds no word.
|
|
@@ -2388,6 +2412,7 @@
|
|
| 2388 |
// `reason` (a file-bomb dump) flavours the death card; bare die() = ran out of hops.
|
| 2389 |
let deathReason = null; // { file, dumpLine } | null
|
| 2390 |
function die(reason) {
|
|
|
|
| 2391 |
deathReason = reason || null;
|
| 2392 |
state = "dead";
|
| 2393 |
buffered = null;
|
|
@@ -2481,6 +2506,7 @@
|
|
| 2481 |
// completing the board pops the rainbow victory modal (checkOff β showVictory);
|
| 2482 |
// collected out of order (build/small still pending), just carry on.
|
| 2483 |
function winHackathon() {
|
|
|
|
| 2484 |
state = "verdict"; // hold input through the celebration
|
| 2485 |
buffered = null;
|
| 2486 |
setPose("hop-mid"); // the win pose
|
|
@@ -2584,6 +2610,10 @@
|
|
| 2584 |
|
| 2585 |
function showVerdict(res) {
|
| 2586 |
state = "verdict";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2587 |
setPose(res.verdict === "win" ? "hop-mid" : "idle");
|
| 2588 |
hintEl.textContent = "";
|
| 2589 |
|
|
@@ -2810,12 +2840,49 @@
|
|
| 2810 |
// Pops once build + small + hackathon are all collected on the bonus board.
|
| 2811 |
const victoryEl = element.querySelector(".sq-victory");
|
| 2812 |
const victoryCloseBtn = element.querySelector(".sq-victory-close");
|
|
|
|
| 2813 |
let victoryShown = false;
|
| 2814 |
let victoryOpen = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2815 |
function showVictory() {
|
| 2816 |
if (victoryShown) return;
|
| 2817 |
victoryShown = true;
|
| 2818 |
victoryOpen = true;
|
|
|
|
| 2819 |
// clear the verdict card under it (the last "hackathon!" stamp has landed)
|
| 2820 |
overlayEl.classList.add("sq-hidden");
|
| 2821 |
overlayEl.style.opacity = "";
|
|
@@ -2850,6 +2917,7 @@
|
|
| 2850 |
let errorOpen = false;
|
| 2851 |
|
| 2852 |
function deleteAllData() {
|
|
|
|
| 2853 |
state = "verdict"; // freeze the board + input behind the modal
|
| 2854 |
buffered = null;
|
| 2855 |
setDataArmed(false); // the line's complete β drop the red
|
|
|
|
| 110 |
// Breadcrumb for the cross-level portal: the board to offer a way back to.
|
| 111 |
let returnTo = null;
|
| 112 |
|
| 113 |
+
// Run-wide tally surfaced on the win screen β the story of the whole session.
|
| 114 |
+
// Spans every board AND survives the "delete all data" wipe (a crash is itself
|
| 115 |
+
// a stat), so it is deliberately NEVER reset; only `loadGame`/page reload
|
| 116 |
+
// starts it over. `startMs` is stamped on the first hop so the clock measures
|
| 117 |
+
// time spent playing, not time spent reading the welcome card.
|
| 118 |
+
const stats = {
|
| 119 |
+
hops: 0, // every tile-to-tile hop the doodle makes, all boards
|
| 120 |
+
startMs: 0, // performance.now() at the first hop; 0 until then
|
| 121 |
+
hints: 0, // hint reveals ("yes, show me the hint")
|
| 122 |
+
crashes: 0, // "delete all data" 404 wipes triggered
|
| 123 |
+
wins: 0, // targets collected (judge wins + the hackathon finale)
|
| 124 |
+
losses: 0, // judged sentences that missed their target
|
| 125 |
+
deaths: 0, // ran out of hops / face-planted a context bomb
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
// Each injection target needs its unlock: "build" is free, "small" needs the
|
| 129 |
// shrink, "hackathon" needs the π€ earned by clearing critters. `submitPressable`
|
| 130 |
// is whether a target's lane tile can be shipped right now (unlocked + unshipped).
|
|
|
|
| 1914 |
// "yes": swap the confirm prompt for the actual nudge (re-read so critters
|
| 1915 |
// reflects what's already been collected)
|
| 1916 |
function revealHint() {
|
| 1917 |
+
const text = hintText();
|
| 1918 |
+
// only a *real* hint counts toward the win-screen tally β the shrug (shown
|
| 1919 |
+
// once every hinted target on the board is already in) isn't a hint at all.
|
| 1920 |
+
if (text !== SHRUG_HINT) stats.hints += 1;
|
| 1921 |
+
hintTextEl.textContent = text;
|
| 1922 |
hintQEl.classList.add("sq-hidden");
|
| 1923 |
hintActionsEl.classList.add("sq-hidden");
|
| 1924 |
hintTextEl.classList.remove("sq-hidden");
|
|
|
|
| 2209 |
}
|
| 2210 |
|
| 2211 |
function land(r, c) {
|
| 2212 |
+
// every completed hop funnels through here (ground hops, glides, swap +
|
| 2213 |
+
// submit keys), so it's the one place to tally total hops and start the
|
| 2214 |
+
// play clock on the very first one.
|
| 2215 |
+
if (stats.startMs === 0) stats.startMs = performance.now();
|
| 2216 |
+
stats.hops += 1;
|
| 2217 |
pos = [r, c];
|
| 2218 |
|
| 2219 |
// a swap tile hops you to another board β costs no budget, adds no word.
|
|
|
|
| 2412 |
// `reason` (a file-bomb dump) flavours the death card; bare die() = ran out of hops.
|
| 2413 |
let deathReason = null; // { file, dumpLine } | null
|
| 2414 |
function die(reason) {
|
| 2415 |
+
stats.deaths += 1; // a fail, for the win-screen failure rate
|
| 2416 |
deathReason = reason || null;
|
| 2417 |
state = "dead";
|
| 2418 |
buffered = null;
|
|
|
|
| 2506 |
// completing the board pops the rainbow victory modal (checkOff β showVictory);
|
| 2507 |
// collected out of order (build/small still pending), just carry on.
|
| 2508 |
function winHackathon() {
|
| 2509 |
+
stats.wins += 1; // the guaranteed finale still counts as a target collected
|
| 2510 |
state = "verdict"; // hold input through the celebration
|
| 2511 |
buffered = null;
|
| 2512 |
setPose("hop-mid"); // the win pose
|
|
|
|
| 2610 |
|
| 2611 |
function showVerdict(res) {
|
| 2612 |
state = "verdict";
|
| 2613 |
+
// a winning verdict always lands a fresh target (we only ever judge against
|
| 2614 |
+
// the still-uncollected ones); anything else is a miss.
|
| 2615 |
+
if (res.verdict === "win") stats.wins += 1;
|
| 2616 |
+
else stats.losses += 1;
|
| 2617 |
setPose(res.verdict === "win" ? "hop-mid" : "idle");
|
| 2618 |
hintEl.textContent = "";
|
| 2619 |
|
|
|
|
| 2840 |
// Pops once build + small + hackathon are all collected on the bonus board.
|
| 2841 |
const victoryEl = element.querySelector(".sq-victory");
|
| 2842 |
const victoryCloseBtn = element.querySelector(".sq-victory-close");
|
| 2843 |
+
const victoryStatsEl = element.querySelector(".sq-victory-stats");
|
| 2844 |
let victoryShown = false;
|
| 2845 |
let victoryOpen = false;
|
| 2846 |
+
|
| 2847 |
+
// mm:ss for the play clock (drops to h:mm:ss past the hour, just in case).
|
| 2848 |
+
function formatDuration(ms) {
|
| 2849 |
+
const total = Math.max(0, Math.round(ms / 1000));
|
| 2850 |
+
const s = total % 60, m = Math.floor(total / 60) % 60, h = Math.floor(total / 3600);
|
| 2851 |
+
const pad = (n) => String(n).padStart(2, "0");
|
| 2852 |
+
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
| 2853 |
+
}
|
| 2854 |
+
|
| 2855 |
+
// Tally the run into the victory card: time played, hops, hints, databases
|
| 2856 |
+
// crashed, and the fail count + rate (misses + deaths over every attempt).
|
| 2857 |
+
function renderVictoryStats() {
|
| 2858 |
+
const fails = stats.losses + stats.deaths;
|
| 2859 |
+
const attempts = stats.wins + fails;
|
| 2860 |
+
const rate = attempts ? Math.round((fails / attempts) * 100) : 0;
|
| 2861 |
+
const rows = [
|
| 2862 |
+
["time", stats.startMs ? formatDuration(performance.now() - stats.startMs) : "0:00"],
|
| 2863 |
+
["hops", String(stats.hops)],
|
| 2864 |
+
[stats.hints > 0 ? "hints used (seriously..?)" : "hints used", String(stats.hints)],
|
| 2865 |
+
["databases crashed", String(stats.crashes)],
|
| 2866 |
+
["fails", `${fails} (${rate}%)`],
|
| 2867 |
+
];
|
| 2868 |
+
victoryStatsEl.innerHTML = "";
|
| 2869 |
+
for (const [label, value] of rows) {
|
| 2870 |
+
const row = document.createElement("div");
|
| 2871 |
+
row.className = "sq-stat";
|
| 2872 |
+
const dt = document.createElement("dt");
|
| 2873 |
+
dt.textContent = label;
|
| 2874 |
+
const dd = document.createElement("dd");
|
| 2875 |
+
dd.textContent = value;
|
| 2876 |
+
row.append(dt, dd);
|
| 2877 |
+
victoryStatsEl.appendChild(row);
|
| 2878 |
+
}
|
| 2879 |
+
}
|
| 2880 |
+
|
| 2881 |
function showVictory() {
|
| 2882 |
if (victoryShown) return;
|
| 2883 |
victoryShown = true;
|
| 2884 |
victoryOpen = true;
|
| 2885 |
+
renderVictoryStats();
|
| 2886 |
// clear the verdict card under it (the last "hackathon!" stamp has landed)
|
| 2887 |
overlayEl.classList.add("sq-hidden");
|
| 2888 |
overlayEl.style.opacity = "";
|
|
|
|
| 2917 |
let errorOpen = false;
|
| 2918 |
|
| 2919 |
function deleteAllData() {
|
| 2920 |
+
stats.crashes += 1; // a database wiped β counted toward the win-screen tally
|
| 2921 |
state = "verdict"; // freeze the board + input behind the modal
|
| 2922 |
buffered = null;
|
| 2923 |
setDataArmed(false); // the line's complete β drop the red
|
static/style.css
CHANGED
|
@@ -643,7 +643,33 @@
|
|
| 643 |
font-size: 21px;
|
| 644 |
line-height: 1.5;
|
| 645 |
color: var(--ink-soft);
|
| 646 |
-
margin-bottom:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
}
|
| 648 |
.sq-victory-close {
|
| 649 |
font-family: "Patrick Hand", cursive;
|
|
@@ -783,4 +809,8 @@
|
|
| 783 |
.sq-errorbox-card { width: min(380px, 88vw); padding: 24px 22px 22px; }
|
| 784 |
.sq-errorbox-title { font-size: 36px; margin-bottom: 10px; }
|
| 785 |
.sq-errorbox-body { font-size: 18px; margin-bottom: 18px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 786 |
}
|
|
|
|
| 643 |
font-size: 21px;
|
| 644 |
line-height: 1.5;
|
| 645 |
color: var(--ink-soft);
|
| 646 |
+
margin-bottom: 18px;
|
| 647 |
+
}
|
| 648 |
+
/* The run tally β a tidy ledger of the session, ruled like a notebook line. */
|
| 649 |
+
.sq-victory-stats {
|
| 650 |
+
width: min(300px, 100%);
|
| 651 |
+
margin: 0 auto 22px;
|
| 652 |
+
text-align: left;
|
| 653 |
+
font-family: "Patrick Hand", cursive;
|
| 654 |
+
}
|
| 655 |
+
.sq-stat {
|
| 656 |
+
display: flex;
|
| 657 |
+
align-items: baseline;
|
| 658 |
+
justify-content: space-between;
|
| 659 |
+
gap: 12px;
|
| 660 |
+
padding: 5px 2px;
|
| 661 |
+
border-bottom: 2px dotted rgba(28, 27, 24, 0.18);
|
| 662 |
+
}
|
| 663 |
+
.sq-stat:last-child { border-bottom: 0; }
|
| 664 |
+
.sq-stat dt {
|
| 665 |
+
margin: 0;
|
| 666 |
+
font-size: 19px;
|
| 667 |
+
color: var(--ink-soft);
|
| 668 |
+
}
|
| 669 |
+
.sq-stat dd {
|
| 670 |
+
margin: 0;
|
| 671 |
+
font-size: 22px;
|
| 672 |
+
color: var(--ink);
|
| 673 |
}
|
| 674 |
.sq-victory-close {
|
| 675 |
font-family: "Patrick Hand", cursive;
|
|
|
|
| 809 |
.sq-errorbox-card { width: min(380px, 88vw); padding: 24px 22px 22px; }
|
| 810 |
.sq-errorbox-title { font-size: 36px; margin-bottom: 10px; }
|
| 811 |
.sq-errorbox-body { font-size: 18px; margin-bottom: 18px; }
|
| 812 |
+
|
| 813 |
+
.sq-victory-stats { width: min(280px, 100%); margin-bottom: 18px; }
|
| 814 |
+
.sq-stat dt { font-size: 17px; }
|
| 815 |
+
.sq-stat dd { font-size: 20px; }
|
| 816 |
}
|