diff --git a/internal/games/hangman/hangman.go b/internal/games/hangman/hangman.go index a00ffee..5a449a2 100644 --- a/internal/games/hangman/hangman.go +++ b/internal/games/hangman/hangman.go @@ -154,16 +154,21 @@ func start(bet int64, t Tier, phrase string, rakePct float64) (State, []Event, e // Spaces and punctuation are never guessed — they start face up, because a // row of blanks with the word breaks hidden is a puzzle about typography. for i, r := range rs { - if !isGuessable(r) { + if !Guessable(r) { s.Shown[i] = true } } return s, []Event{{Kind: "start"}}, nil } -// isGuessable reports whether a rune is one you'd guess: a letter or a digit. +// Guessable reports whether a rune is one you'd guess: a letter or a digit. // Everything else is scaffolding and is shown from the start. -func isGuessable(r rune) bool { +// +// Exported because the renderer needs the same answer — a rune you'd guess is a +// rune that gets a tile to guess it into. Asking the drawing side to decide that +// for itself is how you get a board with no tile for a letter the engine is +// waiting on, and a phrase that cannot be finished. +func Guessable(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) } @@ -188,7 +193,7 @@ func ApplyMove(s State, m Move) (State, []Event, error) { // applyLetter guesses one letter. func applyLetter(s State, letter string) (State, []Event, error) { rs := []rune(strings.ToLower(strings.TrimSpace(letter))) - if len(rs) != 1 || !isGuessable(rs[0]) { + if len(rs) != 1 || !Guessable(rs[0]) { return s, nil, ErrNotALetter } g := rs[0] @@ -373,7 +378,7 @@ func (s State) clone() State { func normalize(s string) string { var b strings.Builder for _, r := range strings.ToLower(s) { - if isGuessable(r) { + if Guessable(r) { b.WriteRune(r) } } diff --git a/internal/games/trivia/trivia.go b/internal/games/trivia/trivia.go index 817ca8d..5f55b7a 100644 --- a/internal/games/trivia/trivia.go +++ b/internal/games/trivia/trivia.go @@ -241,19 +241,6 @@ func ApplyMove(s State, m Move, now time.Time) (State, []Event, error) { } s = s.clone() - if m.Walk { - // You cannot walk off a rung you haven't climbed. If you could, seeing the - // first question and walking away would be a free look: stake, peek, walk, - // stake again, and keep reshuffling until the question is one you know. - // The first question is therefore the price of sitting down. - if s.Rung == 0 { - return s, nil, ErrNothingBanked - } - evs := []Event{} - s.settle(OutcomeWalked, &evs) - return s, evs, nil - } - q := s.Live() if len(q.Answers) == 0 { return s, nil, ErrUnknownMove @@ -264,12 +251,31 @@ func ApplyMove(s State, m Move, now time.Time) (State, []Event, error) { // Out of time. This is a loss, and it has to be — a timeout that merely cost // you the speed bonus would make "leave it open in another tab and go and // look it up" the strongest way to play. + // + // It is checked before *everything*, walking included. A dead clock that you + // could still walk away from would be no clock at all: you would sit on every + // question for as long as you liked, answer the ones you found and walk off + // the ones you didn't, and never once lose the ladder. The timeout has to be + // the first thing that happens to a move, or it is not a deadline. if elapsed > s.Tier.Clock() { evs := []Event{{Kind: "timeout", Choice: -1, Correct: q.Correct}} s.settle(OutcomeTimeout, &evs) return s, evs, nil } + if m.Walk { + // You cannot walk off a rung you haven't climbed. If you could, seeing the + // first question and walking away would be a free look: stake, peek, walk, + // stake again, and keep reshuffling until the question is one you know. + // The first question is therefore the price of sitting down. + if s.Rung == 0 { + return s, nil, ErrNothingBanked + } + evs := []Event{} + s.settle(OutcomeWalked, &evs) + return s, evs, nil + } + if m.Choice < 0 || m.Choice >= len(q.Answers) { return s, nil, ErrUnknownMove } diff --git a/internal/games/trivia/trivia_test.go b/internal/games/trivia/trivia_test.go index 9b682e2..0dd5d55 100644 --- a/internal/games/trivia/trivia_test.go +++ b/internal/games/trivia/trivia_test.go @@ -314,3 +314,39 @@ func TestGarbageMovesAreRefused(t *testing.T) { t.Fatal("a refused move should leave the game alone") } } + +// The clock has to beat the walk button, or it is not a deadline. +// +// If a dead clock could still be walked away from, the ladder would carry no +// risk at all: sit on every question for as long as you like, answer the ones +// you can look up, and walk off the ones you can't. The timeout has to be the +// first thing that happens to a move. +func TestWalkingOffADeadClockIsATimeout(t *testing.T) { + s := newGame(t, 500, "hard") + s, _ = answerRight(t, s, time.Second) // one rung banked, so a walk is otherwise legal + + late := s.AskedAt.Add(s.Tier.Clock() + time.Second) + out, evs, err := ApplyMove(s, Move{Walk: true}, late) + if err != nil { + t.Fatalf("walking after the clock died should resolve, not error: %v", err) + } + if out.Outcome != OutcomeTimeout { + t.Fatalf("a walk after the clock ran out is a timeout, got %q", out.Outcome) + } + if out.Payout != 0 { + t.Fatalf("a timeout pays nothing, got %d", out.Payout) + } + if len(evs) == 0 || evs[0].Kind != "timeout" { + t.Fatalf("expected the timeout event first, got %+v", evs) + } + + // And the same walk, one tick inside the limit, still banks. + intime := s.AskedAt.Add(s.Tier.Clock() - time.Millisecond) + banked, _, err := ApplyMove(s, Move{Walk: true}, intime) + if err != nil { + t.Fatalf("walk with the clock still running: %v", err) + } + if banked.Outcome != OutcomeWalked || banked.Payout <= 0 { + t.Fatalf("a walk inside the clock banks, got %q paying %d", banked.Outcome, banked.Payout) + } +} diff --git a/internal/opentdb/opentdb.go b/internal/opentdb/opentdb.go index b8775d9..11ce6ef 100644 --- a/internal/opentdb/opentdb.go +++ b/internal/opentdb/opentdb.go @@ -146,8 +146,22 @@ func (c *Client) Fetch(ctx context.Context, difficulty string, n int) ([]trivia. // the right answer sits in the bank never reaches a player. answers := make([]string, 0, 4) answers = append(answers, correct) + dupe := false for _, w := range r.Incorrect { - answers = append(answers, clean(w)) + a := clean(w) + // A wrong answer that reads the same as the right one — usually two + // spellings that collapse once the entities are decoded — is a question + // with two identical buttons on it, and the shuffle can only call one of + // them correct. A player who clicked the right words and was told they + // were wrong has lost the whole ladder to our typography. Drop it. + if a == "" || a == correct { + dupe = true + break + } + answers = append(answers, a) + } + if dupe || len(answers) != 4 { + continue } qs = append(qs, trivia.Question{ Category: clean(r.Category), diff --git a/internal/web/games_hangman.go b/internal/web/games_hangman.go index 4d8e8f3..eeef4d3 100644 --- a/internal/web/games_hangman.go +++ b/internal/web/games_hangman.go @@ -69,7 +69,7 @@ func viewHangman(g hangman.State) hangmanView { Net: g.Net(), } for i, r := range g.Runes { - c := cellView{Slot: isSlot(r)} + c := cellView{Slot: hangman.Guessable(r)} if i < len(g.Shown) && g.Shown[i] { c.Ch = string(r) } @@ -89,13 +89,6 @@ func viewHangman(g hangman.State) hangmanView { return v } -// isSlot mirrors the engine's isGuessable — a rune you'd guess gets a tile to -// guess it into. Kept here rather than exported from the engine because it is a -// question about drawing, and the engine doesn't draw. -func isSlot(r rune) bool { - return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') -} - // handleHangmanStart takes the bet and draws a phrase. Same order as a deal: // the chips are staked first, in the same statement that checks they exist, so // two starts fired at once cannot bet the same chip. diff --git a/internal/web/static/js/blackjack.js b/internal/web/static/js/blackjack.js index ba86c45..ee0ada0 100644 --- a/internal/web/static/js/blackjack.js +++ b/internal/web/static/js/blackjack.js @@ -339,7 +339,9 @@ if (dealBtn) dealBtn.disabled = bet <= 0 || !money || money.chips < bet; } - root.querySelectorAll("[data-chip]").forEach(function (btn) { + // Scoped to buttons: the bare [data-chip] spans in the corner are the house's + // rack, and the house is not betting. + root.querySelectorAll("button[data-chip]").forEach(function (btn) { btn.addEventListener("click", function () { if (busy) return; var d = parseInt(btn.dataset.chip, 10); diff --git a/internal/web/static/js/hangman.js b/internal/web/static/js/hangman.js index 1507508..0ee4846 100644 --- a/internal/web/static/js/hangman.js +++ b/internal/web/static/js/hangman.js @@ -42,12 +42,17 @@ // 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]"); + // The bet spot, and the rule that comes with it: the number under the pile is a + // readout of the pile, never the other way round. + var spot = FX.spot({ + spot: spotEl, + stack: root.querySelector("[data-stack]"), + total: root.querySelector("[data-spot-total]"), + }); + 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"; @@ -244,41 +249,12 @@ } // ---- 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 || {})); - } + // The spot is PeteFX's, the same one every other table in the room bets onto: a + // chip has to behave the same way in both rooms or it isn't a chip, it's a + // widget. function stake(amount, from) { - return pour(from || purseEl, spotEl, amount); + return spot.pour(from || purseEl, amount); } function settleChips(final) { @@ -286,19 +262,14 @@ 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; + // The house takes it. The stack goes to the rack and doesn't come back. + return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true }); } - var pay = pour(houseEl, spotEl, back, { gap: 60 }); - return pay + // Paid into the spot beside your stake, then the whole lot swept home. + return spot + .pour(houseEl, back, { gap: 60 }) .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; - }); + .then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); }); } // ---- phases ---------------------------------------------------------------- @@ -341,7 +312,7 @@ drawGallows(0, false); renderWrong(null); renderMeter(null); - renderStack(0); + spot.render(0); setPhase(null); return; } @@ -349,7 +320,7 @@ drawGallows((v.wrong || []).length, false); renderWrong(v); renderMeter(v); - renderStack(v.phase === "done" ? 0 : v.bet); + spot.render(v.phase === "done" ? 0 : v.bet); setPhase(v); } @@ -367,8 +338,8 @@ if (!settles) money(); - if (final && final.bet > staked) { - var extra = final.bet - staked; + if (final && final.bet > spot.amount) { + var extra = final.bet - spot.amount; chain = chain.then(function () { return stake(extra); }); } @@ -466,7 +437,7 @@ .catch(function (err) { say(err.message, "bad", where); return window.PeteGames.refresh().then(function (v) { - if (v && !v.hangman) renderStack(0); + if (v && !v.hangman) spot.render(0); }); }) .then(function () { busy = false; }); @@ -500,7 +471,9 @@ }); }); - root.querySelectorAll("[data-chip]").forEach(function (btn) { + // Scoped to buttons: the bare [data-chip] spans in the corner are the house's + // rack, and the house is not betting. + root.querySelectorAll("button[data-chip]").forEach(function (btn) { btn.addEventListener("click", function () { if (busy) return; var d = parseInt(btn.dataset.chip, 10); @@ -513,9 +486,9 @@ showBet(); var target = bet; - staked = bet; + spot.amount = bet; FX.fly(btn, spotEl, { denom: d }).then(function () { - if (bet >= target) renderStack(target); + if (bet >= target) spot.render(target); // unless Clear got there first }); }); }); @@ -523,10 +496,9 @@ 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 }); + if (busy || !spot.amount) { bet = 0; showBet(); return; } + spot.sweep(purseEl, null, { gap: 40, lift: 0.7 }); bet = 0; - renderStack(0); showBet(); }); } diff --git a/internal/web/static/js/trivia.js b/internal/web/static/js/trivia.js index d6a7685..1ccdb40 100644 --- a/internal/web/static/js/trivia.js +++ b/internal/web/static/js/trivia.js @@ -127,7 +127,12 @@ // and the server (which has been holding the real clock all along) agreeing. function timeUp() { stopClock(); - if (timedOut || busy || !game || game.phase !== "playing") return; + if (timedOut || !game || game.phase !== "playing") return; + // A move is already in flight. Come back rather than give up: this fires when + // the server has rejected our last timeout report (its clock hadn't run out + // yet) and the refresh has re-armed a countdown that is already at zero. Give + // up here and the clock sits frozen at 0.0s and the question never resolves. + if (busy) { setTimeout(timeUp, 250); return; } timedOut = true; lockAnswers(); setTimeout(function () {