games: the clock beats the walk button, and the rack isn't betting
The trivia ladder handled a walk before it looked at the clock, so the timeout only ever bit if the browser volunteered it. Sit on a question, look it up, answer if you find it and walk if you don't, and you never lose a ladder. The clock is now the first thing that happens to a move. The house's chip rack was wired up as bet buttons on blackjack and hangman: it's four spans with data-chip on them and nothing said the handler only wanted the real ones. Clicking the house's money raised your bet. Hangman had two definitions of "a letter you'd guess" — unicode in the engine, ASCII in the renderer — and a phrase with an accent in it would have had no tile to fill and no key to fill it with. One definition now. Plus: trivia's countdown no longer freezes at zero when the server turns down a timeout report it was early for, questions whose wrong answer decodes into the right one are dropped at the door, and hangman bets on PeteFX's spot like every other table instead of its own copy of it.
This commit is contained in:
@@ -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
|
// 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.
|
// row of blanks with the word breaks hidden is a puzzle about typography.
|
||||||
for i, r := range rs {
|
for i, r := range rs {
|
||||||
if !isGuessable(r) {
|
if !Guessable(r) {
|
||||||
s.Shown[i] = true
|
s.Shown[i] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s, []Event{{Kind: "start"}}, nil
|
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.
|
// 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)
|
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +193,7 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
|
|||||||
// applyLetter guesses one letter.
|
// applyLetter guesses one letter.
|
||||||
func applyLetter(s State, letter string) (State, []Event, error) {
|
func applyLetter(s State, letter string) (State, []Event, error) {
|
||||||
rs := []rune(strings.ToLower(strings.TrimSpace(letter)))
|
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
|
return s, nil, ErrNotALetter
|
||||||
}
|
}
|
||||||
g := rs[0]
|
g := rs[0]
|
||||||
@@ -373,7 +378,7 @@ func (s State) clone() State {
|
|||||||
func normalize(s string) string {
|
func normalize(s string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, r := range strings.ToLower(s) {
|
for _, r := range strings.ToLower(s) {
|
||||||
if isGuessable(r) {
|
if Guessable(r) {
|
||||||
b.WriteRune(r)
|
b.WriteRune(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,19 +241,6 @@ func ApplyMove(s State, m Move, now time.Time) (State, []Event, error) {
|
|||||||
}
|
}
|
||||||
s = s.clone()
|
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()
|
q := s.Live()
|
||||||
if len(q.Answers) == 0 {
|
if len(q.Answers) == 0 {
|
||||||
return s, nil, ErrUnknownMove
|
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
|
// 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
|
// you the speed bonus would make "leave it open in another tab and go and
|
||||||
// look it up" the strongest way to play.
|
// 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() {
|
if elapsed > s.Tier.Clock() {
|
||||||
evs := []Event{{Kind: "timeout", Choice: -1, Correct: q.Correct}}
|
evs := []Event{{Kind: "timeout", Choice: -1, Correct: q.Correct}}
|
||||||
s.settle(OutcomeTimeout, &evs)
|
s.settle(OutcomeTimeout, &evs)
|
||||||
return s, evs, nil
|
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) {
|
if m.Choice < 0 || m.Choice >= len(q.Answers) {
|
||||||
return s, nil, ErrUnknownMove
|
return s, nil, ErrUnknownMove
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,3 +314,39 @@ func TestGarbageMovesAreRefused(t *testing.T) {
|
|||||||
t.Fatal("a refused move should leave the game alone")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
// the right answer sits in the bank never reaches a player.
|
||||||
answers := make([]string, 0, 4)
|
answers := make([]string, 0, 4)
|
||||||
answers = append(answers, correct)
|
answers = append(answers, correct)
|
||||||
|
dupe := false
|
||||||
for _, w := range r.Incorrect {
|
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{
|
qs = append(qs, trivia.Question{
|
||||||
Category: clean(r.Category),
|
Category: clean(r.Category),
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func viewHangman(g hangman.State) hangmanView {
|
|||||||
Net: g.Net(),
|
Net: g.Net(),
|
||||||
}
|
}
|
||||||
for i, r := range g.Runes {
|
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] {
|
if i < len(g.Shown) && g.Shown[i] {
|
||||||
c.Ch = string(r)
|
c.Ch = string(r)
|
||||||
}
|
}
|
||||||
@@ -89,13 +89,6 @@ func viewHangman(g hangman.State) hangmanView {
|
|||||||
return v
|
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:
|
// 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
|
// the chips are staked first, in the same statement that checks they exist, so
|
||||||
// two starts fired at once cannot bet the same chip.
|
// two starts fired at once cannot bet the same chip.
|
||||||
|
|||||||
@@ -339,7 +339,9 @@
|
|||||||
if (dealBtn) dealBtn.disabled = bet <= 0 || !money || money.chips < bet;
|
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 () {
|
btn.addEventListener("click", function () {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
var d = parseInt(btn.dataset.chip, 10);
|
var d = parseInt(btn.dataset.chip, 10);
|
||||||
|
|||||||
@@ -42,12 +42,17 @@
|
|||||||
// The three places a chip can be, exactly as at the other table.
|
// The three places a chip can be, exactly as at the other table.
|
||||||
var purseEl = document.querySelector("[data-chips]");
|
var purseEl = document.querySelector("[data-chips]");
|
||||||
var spotEl = root.querySelector("[data-spot]");
|
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 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 bet = 0; // what you're building between games
|
||||||
var staked = 0; // what is actually on the spot
|
|
||||||
var busy = false;
|
var busy = false;
|
||||||
var game = null; // the board as the server last described it
|
var game = null; // the board as the server last described it
|
||||||
var tier = "medium";
|
var tier = "medium";
|
||||||
@@ -244,41 +249,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- the money on the felt -------------------------------------------------
|
// ---- the money on the felt -------------------------------------------------
|
||||||
// Lifted wholesale from the blackjack table, and deliberately identical: a chip
|
// The spot is PeteFX's, the same one every other table in the room bets onto: a
|
||||||
// has to behave the same way in both rooms or it isn't a chip, it's a widget.
|
// 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) {
|
function stake(amount, from) {
|
||||||
return pour(from || purseEl, spotEl, amount);
|
return spot.pour(from || purseEl, amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
function settleChips(final) {
|
function settleChips(final) {
|
||||||
@@ -286,19 +262,14 @@
|
|||||||
var back = payout - final.bet;
|
var back = payout - final.bet;
|
||||||
|
|
||||||
if (payout <= 0) {
|
if (payout <= 0) {
|
||||||
var lost = FX.chipsFor(final.bet, 8);
|
// The house takes it. The stack goes to the rack and doesn't come back.
|
||||||
var chain = FX.flyMany(spotEl, houseEl, lost, { gap: 45, lift: 0.6, fade: true });
|
return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
|
||||||
renderStack(0);
|
|
||||||
return chain;
|
|
||||||
}
|
}
|
||||||
var pay = pour(houseEl, spotEl, back, { gap: 60 });
|
// Paid into the spot beside your stake, then the whole lot swept home.
|
||||||
return pay
|
return spot
|
||||||
|
.pour(houseEl, back, { gap: 60 })
|
||||||
.then(function () { return wait(back > 0 ? 380 : 200); })
|
.then(function () { return wait(back > 0 ? 380 : 200); })
|
||||||
.then(function () {
|
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
|
||||||
var home = FX.flyMany(spotEl, purseEl, FX.chipsFor(payout, 8), { gap: 40, lift: 0.8 });
|
|
||||||
renderStack(0);
|
|
||||||
return home;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- phases ----------------------------------------------------------------
|
// ---- phases ----------------------------------------------------------------
|
||||||
@@ -341,7 +312,7 @@
|
|||||||
drawGallows(0, false);
|
drawGallows(0, false);
|
||||||
renderWrong(null);
|
renderWrong(null);
|
||||||
renderMeter(null);
|
renderMeter(null);
|
||||||
renderStack(0);
|
spot.render(0);
|
||||||
setPhase(null);
|
setPhase(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -349,7 +320,7 @@
|
|||||||
drawGallows((v.wrong || []).length, false);
|
drawGallows((v.wrong || []).length, false);
|
||||||
renderWrong(v);
|
renderWrong(v);
|
||||||
renderMeter(v);
|
renderMeter(v);
|
||||||
renderStack(v.phase === "done" ? 0 : v.bet);
|
spot.render(v.phase === "done" ? 0 : v.bet);
|
||||||
setPhase(v);
|
setPhase(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,8 +338,8 @@
|
|||||||
|
|
||||||
if (!settles) money();
|
if (!settles) money();
|
||||||
|
|
||||||
if (final && final.bet > staked) {
|
if (final && final.bet > spot.amount) {
|
||||||
var extra = final.bet - staked;
|
var extra = final.bet - spot.amount;
|
||||||
chain = chain.then(function () { return stake(extra); });
|
chain = chain.then(function () { return stake(extra); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,7 +437,7 @@
|
|||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
say(err.message, "bad", where);
|
say(err.message, "bad", where);
|
||||||
return window.PeteGames.refresh().then(function (v) {
|
return window.PeteGames.refresh().then(function (v) {
|
||||||
if (v && !v.hangman) renderStack(0);
|
if (v && !v.hangman) spot.render(0);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.then(function () { busy = false; });
|
.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 () {
|
btn.addEventListener("click", function () {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
var d = parseInt(btn.dataset.chip, 10);
|
var d = parseInt(btn.dataset.chip, 10);
|
||||||
@@ -513,9 +486,9 @@
|
|||||||
showBet();
|
showBet();
|
||||||
|
|
||||||
var target = bet;
|
var target = bet;
|
||||||
staked = bet;
|
spot.amount = bet;
|
||||||
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
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]");
|
var clearBtn = root.querySelector("[data-bet-clear]");
|
||||||
if (clearBtn) {
|
if (clearBtn) {
|
||||||
clearBtn.addEventListener("click", function () {
|
clearBtn.addEventListener("click", function () {
|
||||||
if (busy || !staked) { bet = 0; showBet(); return; }
|
if (busy || !spot.amount) { bet = 0; showBet(); return; }
|
||||||
FX.flyMany(spotEl, purseEl, FX.chipsFor(staked, 8), { gap: 40, lift: 0.7 });
|
spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
||||||
bet = 0;
|
bet = 0;
|
||||||
renderStack(0);
|
|
||||||
showBet();
|
showBet();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,12 @@
|
|||||||
// and the server (which has been holding the real clock all along) agreeing.
|
// and the server (which has been holding the real clock all along) agreeing.
|
||||||
function timeUp() {
|
function timeUp() {
|
||||||
stopClock();
|
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;
|
timedOut = true;
|
||||||
lockAnswers();
|
lockAnswers();
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user