Files
Pete/internal/web/static/js/hangman.js
prosolis fe2195e85f games: a gallows you can bet on
Hangman, and it plays for chips — which the plan had down as a free game, on
the grounds that trivia has no euro coupling in gogobee. But a free game in a
casino reads as a demo, so it stakes like everything else.

The idea that makes it a casino game rather than hangman with a wager stapled
on: the gallows is the payout meter. A wrong guess draws a limb *and* takes a
tenth off what a win is worth, because those are the same event and showing
them as one is the entire reason to bet on this. Short phrases pay 2.6x (fewer
letters, less to go on), long ones 1.6x — the floor is 1x, so a win never hands
back less than the stake, and the rake still comes out of winnings only.

State.Pays() is the number the felt quotes and the number settle() lands on.
They were briefly two sums, and the table spent an afternoon advertising a
pre-rake payout it didn't honour.

Two things the storage layer had already decided for us, and one it hadn't:
game_live_hands is keyed on the player, so "one game at a time" holds across
games for free (a live hangman 409s a blackjack deal, stake intact). But
table() unmarshalled every live row as a blackjack hand, which does not fail on
a hangman row — it quietly yields an empty hand. It dispatches on the game now.

commit() is the settle path both games share, and casinoRoutes() the one route
list, since the dev rig wires its own mux and a second copy is a copy that stops
including the newest game.

Driven in a browser, win and loss: 200 at 2.34x paid 455 and the bar landed on
it; six wrong took the stake and no more; a reload mid-phrase brought back the
board, the limbs, the multiple, the spent keys and the chips on the spot. The
browser found the two bugs a Go test can't — a lives counter under the house
rack, and a word wrapping early because the rack's clearance was on the whole
column instead of the one row beside it.
2026-07-14 01:19:05 -07:00

584 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The hangman table.
//
// Same bargain as the blackjack table: the browser holds no game. It sends a
// letter, and the server answers with the board you're allowed to see — the
// phrase with the letters you haven't earned still blank — plus the script of
// what just happened. The phrase itself only ever arrives once the game is over
// and it no longer decides anything.
//
// The gallows is the meter. It counts your lives down and your winnings down at
// the same time, which is why a miss draws a limb *and* knocks the multiple back
// in the same beat: they are the same event, and showing them as one thing is
// the whole reason to bet on this rather than play it on paper.
(function () {
"use strict";
var root = document.querySelector("[data-hangman]");
if (!root) return;
var FX = window.PeteFX;
var boardEl = root.querySelector("[data-board]");
var gallowsEl = root.querySelector("[data-gallows]");
var wrongEl = root.querySelector("[data-wrong]");
var wrongLbl = root.querySelector("[data-wrong-label]");
var multEl = root.querySelector("[data-multiple]");
var meterEl = root.querySelector("[data-meter]");
var standsEl = root.querySelector("[data-stands]");
var standsLbl = root.querySelector("[data-stands-label]");
var livesEl = root.querySelector("[data-lives]");
var verdictEl = root.querySelector("[data-verdict]");
var betting = root.querySelector("[data-betting]");
var guessing = root.querySelector("[data-guessing]");
var keysEl = root.querySelector("[data-keyboard]");
var betAmount = root.querySelector("[data-bet-amount]");
var startBtn = root.querySelector("[data-start]");
var solveIn = root.querySelector("[data-solve-input]");
var solveBtn = root.querySelector("[data-solve]");
var msgEl = root.querySelector("[data-table-msg]");
var gameMsgEl = root.querySelector("[data-game-msg]");
// The three places a chip can be, exactly as at the other table.
var purseEl = document.querySelector("[data-chips]");
var spotEl = root.querySelector("[data-spot]");
var stackEl = root.querySelector("[data-stack]");
var spotTotalEl = root.querySelector("[data-spot-total]");
var houseEl = root.querySelector("[data-house]");
var bet = 0; // what you're building between games
var staked = 0; // what is actually on the spot
var busy = false;
var game = null; // the board as the server last described it
var tier = "medium";
var FLIP_MS = 320;
var MISS_MS = 520;
var reduced = FX.reduced;
function pace(ms) { return reduced ? 0 : ms; }
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
function say(text, tone, where) {
var el = where || msgEl;
if (!el) return;
if (!text) { el.classList.add("hidden"); return; }
el.textContent = text;
el.classList.remove("hidden");
el.style.color = tone === "bad" ? "#cc3d4a" : "";
}
// ---- the board -------------------------------------------------------------
// renderBoard lays the phrase out as tiles. A tile is a letter you have to earn;
// a space or a piece of punctuation is scaffolding and gets no tile, because a
// row of blanks with the word breaks hidden is a puzzle about typography.
//
// Words are kept whole: the board wraps between words, never inside one.
function renderBoard(cells) {
boardEl.innerHTML = "";
if (!cells) return;
var word = document.createElement("div");
word.className = "pete-word";
cells.forEach(function (c, i) {
if (!c.slot && (c.ch === " " || c.ch === "")) {
// A space: end the word and start the next one.
if (word.childNodes.length) boardEl.appendChild(word);
word = document.createElement("div");
word.className = "pete-word";
return;
}
var t = document.createElement("span");
t.className = "pete-tile";
t.dataset.at = String(i);
if (!c.slot) {
t.dataset.punct = "1"; // an exclamation mark is not a blank to fill
t.textContent = c.ch;
} else {
t.dataset.up = c.ch ? "1" : "0";
t.textContent = c.ch || "";
}
word.appendChild(t);
});
if (word.childNodes.length) boardEl.appendChild(word);
}
// turnUp flips the tiles a hit just earned, one after the other so a letter that
// appears three times reads as three finds rather than one repaint.
function turnUp(at, ch) {
if (!at || !at.length) return Promise.resolve();
at.forEach(function (i, n) {
var t = boardEl.querySelector('.pete-tile[data-at="' + i + '"]');
if (!t) return;
setTimeout(function () {
// Left as it comes: the tile is uppercased in CSS, and doing it here too
// would mean the resume path (which paints the phrase's own casing) and
// this one put different text in the same tile.
t.textContent = ch;
t.dataset.up = "1";
t.classList.add("pete-tile-hit");
}, pace(n * 90));
});
return wait(FLIP_MS + (at.length - 1) * 90);
}
// ---- the gallows -----------------------------------------------------------
// drawGallows shows the first n parts. Each one draws itself in along its own
// length rather than fading up — the difference between a limb being *drawn* and
// a limb appearing is the whole character of the game.
function drawGallows(n, animateLast) {
var parts = gallowsEl.querySelectorAll("[data-part]");
parts.forEach(function (p, i) {
var on = i < n;
var was = p.dataset.on === "1";
p.dataset.on = on ? "1" : "0";
if (on && !was && animateLast && i === n - 1 && !reduced) {
// Restart the draw-in animation on the part that was just earned.
p.classList.remove("pete-part-draw");
void p.getBoundingClientRect();
p.classList.add("pete-part-draw");
} else if (on && !animateLast) {
p.classList.remove("pete-part-draw");
}
});
}
function shake() {
if (reduced) return;
gallowsEl.classList.remove("pete-shake");
void gallowsEl.getBoundingClientRect();
gallowsEl.classList.add("pete-shake");
}
// ---- the meter -------------------------------------------------------------
function renderMeter(v) {
if (!v) {
multEl.textContent = "—";
standsEl.textContent = "—";
standsLbl.textContent = "if you get it";
livesEl.textContent = "";
meterEl.dataset.cold = "0";
return;
}
multEl.textContent = v.multiple.toFixed(2) + "×";
standsEl.textContent = (v.stands || 0).toLocaleString();
standsLbl.textContent = v.phase === "done" ? "was on it" : "if you get it";
livesEl.textContent = v.lives + (v.lives === 1 ? " life left" : " lives left");
// The meter goes cold once the multiple is down at its floor: from here a win
// hands back the stake and nothing more.
meterEl.dataset.cold = v.multiple <= 1.001 ? "1" : "0";
}
// knock ticks the multiple down to its new value, so the number falls rather
// than simply being different.
function knock(v) {
if (reduced) { renderMeter(v); return; }
var from = parseFloat(multEl.textContent) || v.multiple;
var to = v.multiple;
var t0 = performance.now();
meterEl.dataset.hit = "1";
setTimeout(function () { meterEl.dataset.hit = "0"; }, 400);
(function step(now) {
var p = Math.min(1, (now - t0) / 380);
var eased = 1 - Math.pow(1 - p, 3);
multEl.textContent = (from + (to - from) * eased).toFixed(2) + "×";
if (p < 1) requestAnimationFrame(step);
else renderMeter(v);
})(t0);
}
// ---- the keyboard ----------------------------------------------------------
var ROWS = ["qwertyuiop", "asdfghjkl", "zxcvbnm", "0123456789"];
function buildKeys() {
keysEl.innerHTML = "";
ROWS.forEach(function (row, r) {
var line = document.createElement("div");
line.className = "pete-key-row";
if (r === 3) line.dataset.digits = "1";
row.split("").forEach(function (ch) {
var b = document.createElement("button");
b.type = "button";
b.className = "pete-key";
b.dataset.key = ch;
b.textContent = ch.toUpperCase();
b.addEventListener("click", function () { guessLetter(ch); });
line.appendChild(b);
});
keysEl.appendChild(line);
});
}
// paintKeys marks every letter that's been tried, and how it went. A key that
// has been spent should look spent — otherwise you spend it again.
function paintKeys(v) {
var tried = (v && v.tried) || [];
var wrong = (v && v.wrong) || [];
keysEl.querySelectorAll(".pete-key").forEach(function (b) {
var ch = b.dataset.key;
var used = tried.indexOf(ch) !== -1;
b.disabled = used || !v || v.phase !== "playing";
b.dataset.state = !used ? "" : wrong.indexOf(ch) !== -1 ? "miss" : "hit";
});
}
function renderWrong(v) {
wrongEl.innerHTML = "";
var wrong = (v && v.wrong) || [];
// A wrong *solve* is recorded as a miss with no letter on it — it cost a life
// and it's on the gallows, but there's no key to grey out for it.
var letters = wrong.filter(function (c) { return c !== "·"; });
wrongLbl.classList.toggle("hidden", letters.length === 0);
letters.forEach(function (ch) {
var s = document.createElement("span");
s.className = "pete-missed";
s.textContent = ch.toUpperCase();
wrongEl.appendChild(s);
});
}
// ---- the money on the felt -------------------------------------------------
// Lifted wholesale from the blackjack table, and deliberately identical: a chip
// has to behave the same way in both rooms or it isn't a chip, it's a widget.
function renderStack(amount) {
staked = amount || 0;
stackEl.innerHTML = "";
spotEl.dataset.live = staked > 0 ? "1" : "0";
if (!staked) { spotTotalEl.classList.add("hidden"); return; }
FX.chipsFor(staked).forEach(function (d, i) {
var c = FX.disc(d);
c.style.setProperty("--i", i);
c.style.setProperty("--spin", FX.jitter(i, 12).toFixed(1) + "deg");
c.style.animationDelay = pace(i * 40) + "ms";
stackEl.appendChild(c);
});
spotTotalEl.textContent = staked.toLocaleString();
spotTotalEl.classList.remove("hidden");
}
function pour(from, to, amount, opts) {
if (amount <= 0) return Promise.resolve();
var base = staked;
var chips = FX.chipsFor(amount, 8);
var run = 0;
return FX.flyMany(from, to, chips, Object.assign({
onLand: function (d, i) {
run += d;
renderStack(base + (i === chips.length - 1 ? amount : run));
},
}, opts || {}));
}
function stake(amount, from) {
return pour(from || purseEl, spotEl, amount);
}
function settleChips(final) {
var payout = final.payout || 0;
var back = payout - final.bet;
if (payout <= 0) {
var lost = FX.chipsFor(final.bet, 8);
var chain = FX.flyMany(spotEl, houseEl, lost, { gap: 45, lift: 0.6, fade: true });
renderStack(0);
return chain;
}
var pay = pour(houseEl, spotEl, back, { gap: 60 });
return pay
.then(function () { return wait(back > 0 ? 380 : 200); })
.then(function () {
var home = FX.flyMany(spotEl, purseEl, FX.chipsFor(payout, 8), { gap: 40, lift: 0.8 });
renderStack(0);
return home;
});
}
// ---- phases ----------------------------------------------------------------
var VERDICTS = {
solved: "Got it! 🎉",
filled: "That's the lot!",
hung: "Hung.",
};
function verdict(v) {
var text = VERDICTS[v.outcome] || "";
if (!text) { verdictEl.classList.add("hidden"); return; }
if (v.outcome === "hung" && v.phrase) text = "Hung. It was “" + v.phrase + "”.";
if (v.net > 0) text += " +" + v.net.toLocaleString();
else if (v.net < 0) text += " " + v.net.toLocaleString();
verdictEl.textContent = text;
verdictEl.classList.remove("hidden");
// Confetti for a phrase guessed outright — the one call you make on your own
// rather than by grinding the alphabet.
if (v.outcome === "solved" && v.net > 0) FX.burst(verdictEl, { count: 30 });
}
function setPhase(v) {
game = v;
var live = !!v && v.phase === "playing";
betting.classList.toggle("hidden", live);
guessing.classList.toggle("hidden", !live);
paintKeys(v);
if (!live && solveIn) solveIn.value = "";
if (!v || !v.outcome) verdictEl.classList.add("hidden");
}
// paint puts a board up with no animation: the resume path, after a reload or a
// redeploy. Your stake is still on the spot because the server still has it.
function paint(v) {
if (!v) {
renderBoard(null);
drawGallows(0, false);
renderWrong(null);
renderMeter(null);
renderStack(0);
setPhase(null);
return;
}
renderBoard(v.cells);
drawGallows((v.wrong || []).length, false);
renderWrong(v);
renderMeter(v);
renderStack(v.phase === "done" ? 0 : v.bet);
setPhase(v);
}
// ---- the script ------------------------------------------------------------
// play walks the server's events. Same rule as the other table: on a live game
// the money is already right (your stake left your pile when you pressed Play,
// and it's on the spot), but on a settling one the chip bar is held back until
// the chips have physically come home.
function play(view, money) {
var events = view.hang_events || [];
var final = view.hangman;
var settles = !!final && final.phase === "done";
var chain = Promise.resolve();
if (!settles) money();
if (final && final.bet > staked) {
var extra = final.bet - staked;
chain = chain.then(function () { return stake(extra); });
}
events.forEach(function (e) {
chain = chain.then(function () {
switch (e.kind) {
case "start":
verdictEl.classList.add("hidden");
renderBoard(final && final.cells);
drawGallows(0, false);
renderWrong(null);
renderMeter(final);
return;
case "hit":
return turnUp(e.at, e.letter);
case "miss":
// The limb, the shake and the multiple falling are one event, because
// they are one event: this is what a wrong guess costs, all of it.
drawGallows(countMisses(events, e), true);
shake();
if (final) knock(final);
return wait(MISS_MS);
case "solve":
return wait(220);
case "settle":
return;
}
});
});
return chain.then(function () {
if (!final) { paint(null); money(); return; }
renderWrong(final);
if (!settles) {
renderMeter(final);
setPhase(final);
return;
}
// Over: the board goes fully face up (the server has finally sent the
// phrase), then the money moves, and only then does the bar catch up.
guessing.classList.add("hidden");
renderBoard(final.cells);
renderMeter(final);
verdict(final);
return settleChips(final)
.then(money)
.then(function () { return standing(final.bet); })
.then(function () { setPhase(final); });
});
}
// countMisses works out how many limbs should be on the gallows by the time
// this miss has been played — the misses already on the board when the request
// went out, plus every miss in this batch up to and including this one.
function countMisses(events, upTo) {
var before = game ? (game.wrong || []).length : 0;
var n = 0;
for (var i = 0; i < events.length; i++) {
if (events[i].kind === "miss") n++;
if (events[i] === upTo) break;
}
return before + n;
}
// standing puts the stake back on the spot for the next phrase, the way the
// blackjack table leaves your bet up.
function standing(amount) {
var money = window.PeteGames.view();
if (!amount || !money || money.chips < amount) {
bet = 0;
showBet();
return;
}
bet = amount;
showBet();
return stake(amount);
}
// ---- talking to the table ---------------------------------------------------
function send(path, body, where) {
if (busy) return;
busy = true;
say("", null, where);
return window.PeteGames.post(path, body)
.then(function (view) {
return play(view, function () { window.PeteGames.apply(view); });
})
.catch(function (err) {
say(err.message, "bad", where);
return window.PeteGames.refresh().then(function (v) {
if (v && !v.hangman) renderStack(0);
});
})
.then(function () { busy = false; });
}
function guessLetter(ch) {
if (busy || !game || game.phase !== "playing") return;
if ((game.tried || []).indexOf(ch) !== -1) return;
send("/api/games/hangman/guess", { letter: ch }, gameMsgEl);
}
// ---- betting ----------------------------------------------------------------
function showBet() {
betAmount.textContent = bet.toLocaleString();
var money = window.PeteGames.view();
if (startBtn) startBtn.disabled = bet <= 0 || !money || money.chips < bet;
}
function pickTier(slug) {
tier = slug;
root.querySelectorAll("[data-tier]").forEach(function (b) {
b.dataset.on = b.dataset.tier === slug ? "1" : "0";
});
}
root.querySelectorAll("[data-tier]").forEach(function (b) {
b.addEventListener("click", function () {
if (busy) return;
pickTier(b.dataset.tier);
});
});
root.querySelectorAll("[data-chip]").forEach(function (btn) {
btn.addEventListener("click", function () {
if (busy) return;
var d = parseInt(btn.dataset.chip, 10);
var money = window.PeteGames.view();
if (money && bet + d > money.chips) {
say("You haven't got that many chips.", "bad");
return;
}
bet += d;
showBet();
var target = bet;
staked = bet;
FX.fly(btn, spotEl, { denom: d }).then(function () {
if (bet >= target) renderStack(target);
});
});
});
var clearBtn = root.querySelector("[data-bet-clear]");
if (clearBtn) {
clearBtn.addEventListener("click", function () {
if (busy || !staked) { bet = 0; showBet(); return; }
FX.flyMany(spotEl, purseEl, FX.chipsFor(staked, 8), { gap: 40, lift: 0.7 });
bet = 0;
renderStack(0);
showBet();
});
}
if (startBtn) {
startBtn.addEventListener("click", function () {
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
send("/api/games/hangman/start", { bet: bet, tier: tier });
});
}
function solve() {
if (busy || !game || game.phase !== "playing") return;
var attempt = (solveIn.value || "").trim();
if (!attempt) { say("Say what it is, then.", "bad", gameMsgEl); return; }
send("/api/games/hangman/guess", { solve: attempt }, gameMsgEl);
}
if (solveBtn) solveBtn.addEventListener("click", solve);
if (solveIn) {
solveIn.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); solve(); }
});
}
// Type a letter to guess it — but not while you're typing a solution into the
// box, which is the whole reason this checks what has focus.
document.addEventListener("keydown", function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return;
if (!game || game.phase !== "playing" || busy) return;
var ch = (e.key || "").toLowerCase();
if (!/^[a-z0-9]$/.test(ch)) return;
e.preventDefault();
guessLetter(ch);
});
buildKeys();
pickTier(tier);
var resumed = false;
window.PeteGames.onUpdate(function (v) {
if (!resumed) {
resumed = true;
if (v.hangman) {
paint(v.hangman);
if (v.hangman.phase === "done") verdict(v.hangman);
} else {
paint(null);
}
}
showBet();
});
})();