Solitaire, Vegas rules — the only shape solitaire has ever had as a gambling game. You don't win or lose the deal: the stake buys the deck outright, and every card you get home to a foundation pays a fifty-second of the tier's multiple back. Cash the board whenever you like and keep what you've banked, so a board that has gone dead is a decision rather than a wall. No undo: the stake is spent the moment the deck is bought, and an undo would be a way to walk a losing board backwards until it wins. Three deals, and the two dials are the whole difficulty of Klondike. Patient draws one with unlimited passes and pays 1.4x, so it takes 38 cards home to get square. Vegas draws three, three times round, 2.2x, square at 24. Cutthroat draws three and gives you one pass, 3.4x, square at 16 — most of those boards never clear, and you're ahead long before they would. internal/games/klondike is the same pure reducer as the other two, and Pays() is one function for the same reason hangman's is. Two fuzzers hold the deck together: no sequence of moves can lose or duplicate a card, and the board stays well-formed. They earned their keep immediately — the first thing they caught was a recycle that reversed the waste. It flips as a block, so the card drawn first comes out first, and reversing it would have dealt a different game on every pass and quietly broken the seed in the audit log. The browser never sees the stock or a face-down card, which here is most of the deck rather than blackjack's one hole card: a column sends how many cards are under it, never which. The table re-renders and animates the difference. Blackjack plays back a script because a hand only ever grows at one end; solitaire moves runs from anywhere to anywhere and an auto-finish moves eleven cards at once, so a script of "append this card there" would be a second engine over here and it would be the one that's wrong. Instead the board on screen is always exactly the board the server says exists, and each card is played from where it just was to where it now is. The events supply only what a diff can't: where a newly-revealed card came from, and what the board is worth. The rules are mirrored in JS on purpose, and only to light up the columns a held card can go to. Being shown where a card goes is the game teaching you; being told no after you commit is the game scolding you. The server still decides, and a disagreement snaps the board back to what it says. Two things came out into the open rather than being copied, which is the rule this room runs on: casino-cards.js (the deck — faces, pips, the flip) and PeteFX.spot() (the pile of chips and the number under it, which now owns the rule that the number is a readout of the pile). Blackjack uses both. Not yet driven in a browser.
730 lines
27 KiB
JavaScript
730 lines
27 KiB
JavaScript
// The solitaire table.
|
|
//
|
|
// Blackjack plays back a *script*: the server sends one event per card off the
|
|
// shoe and the table deals them out in order. That works because a blackjack hand
|
|
// only ever grows at one end. Solitaire doesn't: a move takes a run from anywhere
|
|
// and puts it anywhere, an auto-finish moves eleven cards at once, and a single
|
|
// move can turn a card over three columns away. A script of "append this card
|
|
// there" would be a second engine over here, and it would be the one that's wrong.
|
|
//
|
|
// So this table re-renders the whole board from the server's view after every
|
|
// move, and then animates the difference — FLIP: measure where every card *was*,
|
|
// re-render, measure where it *is*, and play each card from its old place to its
|
|
// new one. The board on screen is therefore always exactly the board the server
|
|
// says exists, and the animation is derived from it rather than the other way
|
|
// round. The events are still used, for the two things a diff genuinely cannot
|
|
// tell you: where a newly-revealed card came from (out of the stock, or turned
|
|
// over in place), and what the board is now worth.
|
|
//
|
|
// The money follows the same rule as every other table in the room: nothing about
|
|
// it changes without a chip crossing the felt to make it change. Here the stake
|
|
// buys the deck — it goes to the house and does not come back — and the spot in
|
|
// the rail holds what you've *banked*, which grows by one card's worth every time
|
|
// a card reaches a foundation and shrinks again if you take one back off.
|
|
(function () {
|
|
"use strict";
|
|
|
|
var root = document.querySelector("[data-solitaire]");
|
|
if (!root) return;
|
|
|
|
var FX = window.PeteFX;
|
|
var CARDS = window.PeteCards;
|
|
|
|
var stockEl = root.querySelector("[data-stock]");
|
|
var stockCountEl = root.querySelector("[data-stock-count]");
|
|
var stockRecycleEl = root.querySelector("[data-stock-recycle]");
|
|
var wasteEl = root.querySelector("[data-waste]");
|
|
var foundEl = root.querySelector("[data-foundations]");
|
|
var tableauEl = root.querySelector("[data-tableau]");
|
|
var verdictEl = root.querySelector("[data-verdict]");
|
|
var homeEl = root.querySelector("[data-home]");
|
|
var perCardEl = root.querySelector("[data-per-card]");
|
|
var breakEvenEl = root.querySelector("[data-break-even]");
|
|
var meterEl = root.querySelector("[data-meter]");
|
|
|
|
var playing = root.querySelector("[data-playing]");
|
|
var betting = root.querySelector("[data-betting]");
|
|
var autoBtn = root.querySelector("[data-auto]");
|
|
var cashBtn = root.querySelector("[data-cash]");
|
|
var cashAmountEl = root.querySelector("[data-cash-amount]");
|
|
var startBtn = root.querySelector("[data-start]");
|
|
var betAmountEl = root.querySelector("[data-bet-amount]");
|
|
var gameMsg = root.querySelector("[data-game-msg]");
|
|
var tableMsg = root.querySelector("[data-table-msg]");
|
|
|
|
var purseEl = document.querySelector("[data-chips]");
|
|
var spotEl = root.querySelector("[data-spot]");
|
|
var houseEl = root.querySelector("[data-house]");
|
|
|
|
// The spot in the rail. On this table it holds what you've banked, not a bet.
|
|
var spot = FX.spot({
|
|
spot: spotEl,
|
|
stack: root.querySelector("[data-stack]"),
|
|
total: root.querySelector("[data-spot-total]"),
|
|
});
|
|
|
|
var FULL = 52;
|
|
var MOVE_MS = 300; // one card's journey across the board
|
|
var STEP_MS = 95; // the gap between two cards in a cascade
|
|
var reduced = FX.reduced;
|
|
|
|
var bet = 0; // the deck you're building the price of
|
|
var tier = null; // which deal
|
|
var busy = false; // a request is in flight, or cards are still moving
|
|
var board = null; // the board as the server last described it
|
|
var held = null; // {pile, count, cards} — the run in your hand
|
|
|
|
function wait(ms) {
|
|
return new Promise(function (r) { setTimeout(r, reduced ? 0 : ms); });
|
|
}
|
|
|
|
function say(el, text, tone) {
|
|
if (!text) { el.classList.add("hidden"); return; }
|
|
el.textContent = text;
|
|
el.classList.remove("hidden");
|
|
el.style.color = tone === "bad" ? "#cc3d4a" : "";
|
|
}
|
|
|
|
// ---- the rules, as the *browser* understands them --------------------------
|
|
//
|
|
// These mirror the engine, and they are not the engine: the server decides every
|
|
// move and will refuse one this file thought was fine. They exist because you
|
|
// cannot light up the columns a card can go to without knowing which those are,
|
|
// and a table that makes you find that out by being told no is a table that
|
|
// teaches Klondike by refusal. When the two disagree the server wins and the
|
|
// board snaps back to whatever it says — see send().
|
|
|
|
var RANKS = { A: 1, J: 11, Q: 12, K: 13 };
|
|
function rank(c) { return RANKS[c.rank] || parseInt(c.rank, 10); }
|
|
|
|
// isRun: descending by one, alternating colour — the only thing you may lift
|
|
// off a column as a block.
|
|
function isRun(cs) {
|
|
for (var i = 1; i < cs.length; i++) {
|
|
if (rank(cs[i]) !== rank(cs[i - 1]) - 1 || cs[i].red === cs[i - 1].red) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// accepts: what may be put where. A foundation takes its own suit in order from
|
|
// the ace; a column takes a run that descends and alternates from its top card,
|
|
// and an empty column takes a King and nothing else.
|
|
function accepts(pile, cs) {
|
|
if (!board || !cs || !cs.length) return false;
|
|
|
|
if (pile.charAt(0) === "f") {
|
|
var f = board.found[parseInt(pile.slice(1), 10)];
|
|
return !!f && cs.length === 1 && cs[0].suit === f.suit && rank(cs[0]) === f.n + 1;
|
|
}
|
|
|
|
var col = board.table[parseInt(pile.slice(1), 10)];
|
|
if (!col || !isRun(cs)) return false;
|
|
if (!col.up || !col.up.length) return col.down === 0 && rank(cs[0]) === 13;
|
|
var top = col.up[col.up.length - 1];
|
|
return rank(cs[0]) === rank(top) - 1 && cs[0].red !== top.red;
|
|
}
|
|
|
|
// cardsAt is the run you'd be picking up by clicking this card.
|
|
function cardsAt(pile, idx) {
|
|
if (!board) return null;
|
|
if (pile === "waste") {
|
|
return board.waste && board.waste.length ? [board.waste[board.waste.length - 1]] : null;
|
|
}
|
|
if (pile.charAt(0) === "f") {
|
|
var f = board.found[parseInt(pile.slice(1), 10)];
|
|
return f && f.top ? [f.top] : null;
|
|
}
|
|
var col = board.table[parseInt(pile.slice(1), 10)];
|
|
if (!col || !col.up || idx >= col.up.length) return null;
|
|
return col.up.slice(idx);
|
|
}
|
|
|
|
// ---- drawing the board -----------------------------------------------------
|
|
|
|
// face builds a card element wired up for clicking. No deal flight and no tilt:
|
|
// this table animates its own cards from wherever they actually came from, and a
|
|
// column of thirteen tilted cards overlapping by an eighth of an inch reads as a
|
|
// mistake rather than as a hand that was handled.
|
|
function face(c, pile, idx) {
|
|
var el = CARDS.el(c, { deal: false, tilt: false });
|
|
el.dataset.pile = pile;
|
|
el.dataset.idx = String(idx);
|
|
return el;
|
|
}
|
|
|
|
function slot(pile, glyph, red) {
|
|
var el = document.createElement("div");
|
|
el.className = "pete-slot";
|
|
el.dataset.pile = pile;
|
|
if (glyph) {
|
|
el.innerHTML = '<span class="pete-slot-glyph" data-red="' + (red ? "1" : "0") + '">' + glyph + "</span>";
|
|
}
|
|
return el;
|
|
}
|
|
|
|
function render(v) {
|
|
board = v;
|
|
if (!v) {
|
|
wasteEl.innerHTML = "";
|
|
foundEl.innerHTML = "";
|
|
tableauEl.innerHTML = "";
|
|
stockEl.dataset.dead = "1";
|
|
stockCountEl.classList.add("hidden");
|
|
stockRecycleEl.classList.add("hidden");
|
|
meter(null);
|
|
return;
|
|
}
|
|
|
|
// The stock. Empty with a pass left is not the same thing as empty with none:
|
|
// one is a gesture you can still make and the other is a dead pile, and the
|
|
// difference is worth showing rather than leaving to a click that gets refused.
|
|
var canRecycle = v.stock === 0 && v.waste_n > 0 && (v.passes < 0 || v.passes > 1);
|
|
stockEl.dataset.empty = v.stock === 0 ? "1" : "0";
|
|
stockEl.dataset.dead = v.stock === 0 && !canRecycle ? "1" : "0";
|
|
stockCountEl.textContent = String(v.stock);
|
|
stockCountEl.classList.toggle("hidden", v.stock === 0);
|
|
stockRecycleEl.classList.toggle("hidden", !canRecycle);
|
|
|
|
// The waste: the last three, fanned, and only the top one is yours to take.
|
|
wasteEl.innerHTML = "";
|
|
(v.waste || []).forEach(function (c, i, all) {
|
|
var el = face(c, "waste", i);
|
|
if (i === all.length - 1) el.dataset.live = "1";
|
|
wasteEl.appendChild(el);
|
|
});
|
|
|
|
// The foundations. Each is a slot that stays put whether or not there's a card
|
|
// on it, so the board doesn't reflow the moment an ace goes home — and so a
|
|
// drop target has somewhere to be even when it's empty.
|
|
foundEl.innerHTML = "";
|
|
v.found.forEach(function (f, i) {
|
|
var s = slot("f" + i, f.suit, f.red);
|
|
if (f.top) {
|
|
var el = face(f.top, "f" + i, 0);
|
|
el.dataset.live = "1";
|
|
s.appendChild(el);
|
|
}
|
|
foundEl.appendChild(s);
|
|
});
|
|
|
|
// The seven columns.
|
|
tableauEl.innerHTML = "";
|
|
v.table.forEach(function (col, i) {
|
|
var c = document.createElement("div");
|
|
c.className = "pete-col";
|
|
c.dataset.pile = "t" + i;
|
|
|
|
if (!col.down && !(col.up && col.up.length)) {
|
|
c.appendChild(slot("t" + i));
|
|
}
|
|
for (var d = 0; d < col.down; d++) {
|
|
c.appendChild(CARDS.el(null, { deal: false, tilt: false }));
|
|
}
|
|
(col.up || []).forEach(function (card, j) {
|
|
var el = face(card, "t" + i, j);
|
|
// A card is pickable if the run from it down is a run. Anything else is a
|
|
// card you can see and can't lift, and it shouldn't offer.
|
|
if (isRun(col.up.slice(j))) el.dataset.live = "1";
|
|
c.appendChild(el);
|
|
});
|
|
tableauEl.appendChild(c);
|
|
});
|
|
|
|
meter(v);
|
|
controls(v);
|
|
if (held) mark(); // a selection survives a re-render, so redraw what it lit
|
|
}
|
|
|
|
// meter is what the board is worth. Every number in it comes off the server —
|
|
// this file does no arithmetic about money, which is the point.
|
|
function meter(v) {
|
|
if (!v) {
|
|
homeEl.innerHTML = '0<span class="text-white/40">/' + FULL + "</span>";
|
|
perCardEl.textContent = "—";
|
|
breakEvenEl.textContent = "";
|
|
return;
|
|
}
|
|
homeEl.innerHTML = v.home + '<span class="text-white/40">/' + FULL + "</span>";
|
|
perCardEl.textContent = "+" + v.per_card.toFixed(1);
|
|
breakEvenEl.textContent =
|
|
v.home >= v.break_even
|
|
? "You're ahead of the house"
|
|
: v.break_even - v.home + " more to break even";
|
|
meterEl.dataset.cold = v.home === 0 ? "1" : "0";
|
|
}
|
|
|
|
function controls(v) {
|
|
var live = !!v && v.phase === "playing";
|
|
playing.classList.toggle("hidden", !live);
|
|
betting.classList.toggle("hidden", live);
|
|
if (!live) return;
|
|
autoBtn.disabled = !v.can_auto;
|
|
cashAmountEl.textContent = (v.stands || 0).toLocaleString();
|
|
}
|
|
|
|
// ---- FLIP ------------------------------------------------------------------
|
|
//
|
|
// Where every card is, right now. One deck, so a card's label is its identity.
|
|
|
|
function snapshot() {
|
|
var map = {};
|
|
root.querySelectorAll(".pete-card[data-key]").forEach(function (el) {
|
|
map[el.dataset.key] = el.getBoundingClientRect();
|
|
});
|
|
map["#stock"] = stockEl.getBoundingClientRect();
|
|
return map;
|
|
}
|
|
|
|
// plan reads the events for the two things a before/after diff can't tell you:
|
|
// where a card that is *new to the board* came from, and how much of a beat to
|
|
// leave before it moves, so that an auto-finish cascades rather than teleporting.
|
|
function planOf(events) {
|
|
var origins = {}, delays = {}, at = 0;
|
|
(events || []).forEach(function (e) {
|
|
(e.cards || []).forEach(function (c) {
|
|
if (e.kind === "draw") origins[c.label] = "draw";
|
|
if (e.kind === "flip") origins[c.label] = "flip";
|
|
delays[c.label] = at;
|
|
});
|
|
if (e.kind === "move" || e.kind === "home") at += STEP_MS;
|
|
});
|
|
return { origins: origins, delays: delays };
|
|
}
|
|
|
|
// animate plays every card from where it was to where it is.
|
|
function animate(before, plan) {
|
|
if (reduced) return Promise.resolve();
|
|
var waits = [];
|
|
|
|
root.querySelectorAll(".pete-card[data-key]").forEach(function (el) {
|
|
var key = el.dataset.key;
|
|
var now = el.getBoundingClientRect();
|
|
var was = before[key];
|
|
var delay = plan.delays[key] || 0;
|
|
var origin = plan.origins[key];
|
|
|
|
// A card that was already on the board: play it from its old place. This is
|
|
// every ordinary move, and it is also what makes an eleven-card auto-finish
|
|
// animate itself for free.
|
|
if (was && !origin) {
|
|
var dx = was.left - now.left;
|
|
var dy = was.top - now.top;
|
|
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;
|
|
waits.push(slide(el, dx, dy, delay));
|
|
return;
|
|
}
|
|
|
|
// A card that has just been turned over. Out of the stock it flies as well as
|
|
// turns; in a column it turns where it lies.
|
|
if (origin === "draw") {
|
|
var from = before["#stock"];
|
|
waits.push(slide(el, from.left - now.left, from.top - now.top, delay));
|
|
waits.push(turn(el, delay));
|
|
} else if (origin === "flip") {
|
|
waits.push(turn(el, delay));
|
|
}
|
|
});
|
|
|
|
return Promise.all(waits);
|
|
}
|
|
|
|
function slide(el, dx, dy, delay) {
|
|
return el.animate(
|
|
[{ transform: "translate(" + dx + "px," + dy + "px)" }, { transform: "none" }],
|
|
{
|
|
duration: MOVE_MS,
|
|
delay: delay,
|
|
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
|
|
fill: "backwards",
|
|
}
|
|
).finished.catch(noop);
|
|
}
|
|
|
|
// The card turns over on its own axis. The wrapper is doing the travelling, so
|
|
// this has to be the inner face or the two transforms would fight.
|
|
function turn(el, delay) {
|
|
var inner = el.querySelector(".pete-card-inner");
|
|
return inner.animate(
|
|
[{ transform: "rotateY(180deg)" }, { transform: "rotateY(0deg)" }],
|
|
{ duration: MOVE_MS, delay: delay, easing: "cubic-bezier(0.4, 0, 0.2, 1)", fill: "backwards" }
|
|
).finished.catch(noop);
|
|
}
|
|
|
|
// The recycle is the one move where cards *leave* the board, so FLIP has nothing
|
|
// to animate: they're gone from the new render before it can measure them. They
|
|
// get their flight here instead, out of the old DOM, before it's replaced.
|
|
function recycleOut() {
|
|
if (reduced) return Promise.resolve();
|
|
var to = stockEl.getBoundingClientRect();
|
|
var waits = [];
|
|
wasteEl.querySelectorAll(".pete-card").forEach(function (el, i) {
|
|
var now = el.getBoundingClientRect();
|
|
waits.push(
|
|
el.animate(
|
|
[
|
|
{ transform: "none", opacity: 1 },
|
|
{ transform: "translate(" + (to.left - now.left) + "px," + (to.top - now.top) + "px) rotateY(180deg)", opacity: 1 },
|
|
],
|
|
{ duration: 260, delay: i * 50, easing: "cubic-bezier(0.4, 0, 1, 1)", fill: "forwards" }
|
|
).finished.catch(noop)
|
|
);
|
|
});
|
|
return Promise.all(waits);
|
|
}
|
|
|
|
function noop() {}
|
|
|
|
// A card reaching a foundation is the only move in this game that pays you, so
|
|
// it's the only one that makes a noise about it.
|
|
function flashHome(events) {
|
|
if (reduced) return;
|
|
var at = 0;
|
|
(events || []).forEach(function (e) {
|
|
if (e.kind !== "home" || !e.to) {
|
|
if (e.kind === "move") at += STEP_MS;
|
|
return;
|
|
}
|
|
var when = at + MOVE_MS;
|
|
at += STEP_MS;
|
|
setTimeout(function () {
|
|
var pile = foundEl.querySelector('[data-pile="' + e.to + '"]');
|
|
if (!pile) return;
|
|
pile.classList.remove("pete-home-flash");
|
|
void pile.offsetWidth; // restart the animation if it's still running
|
|
pile.classList.add("pete-home-flash");
|
|
}, when);
|
|
});
|
|
}
|
|
|
|
// ---- the money -------------------------------------------------------------
|
|
|
|
// bank moves the spot to what the server says the board is worth. Up is chips
|
|
// out of the house's rack; down — a card taken back off a foundation — is chips
|
|
// going back to it. Either way the pile moves before the number does.
|
|
function bank(pays) {
|
|
var delta = (pays || 0) - spot.amount;
|
|
if (delta === 0) return Promise.resolve();
|
|
if (delta > 0) return spot.pour(houseEl, delta, { gap: 55 });
|
|
return spot.sweep(houseEl, -delta, { gap: 40, lift: 0.6, fade: true });
|
|
}
|
|
|
|
// ---- picking cards up ------------------------------------------------------
|
|
|
|
function mark() {
|
|
root.querySelectorAll('[data-held="1"]').forEach(function (el) { delete el.dataset.held; });
|
|
root.querySelectorAll('[data-drop="1"]').forEach(function (el) { delete el.dataset.drop; });
|
|
if (!held) return;
|
|
|
|
// The run in your hand lifts off the felt.
|
|
root.querySelectorAll('.pete-card[data-pile="' + held.pile + '"]').forEach(function (el) {
|
|
if (held.pile === "waste" || held.pile.charAt(0) === "f") {
|
|
if (el.dataset.live === "1") el.dataset.held = "1";
|
|
} else if (parseInt(el.dataset.idx, 10) >= held.idx) {
|
|
el.dataset.held = "1";
|
|
}
|
|
});
|
|
|
|
// And everywhere it could go lights up. This is the whole reason the rules are
|
|
// mirrored over here: being shown where a card goes is the game teaching you,
|
|
// and being told no after you commit is the game scolding you.
|
|
root.querySelectorAll("[data-pile]").forEach(function (el) {
|
|
var pile = el.dataset.pile;
|
|
if (pile === held.pile || pile === "waste" || el.classList.contains("pete-card")) return;
|
|
if (accepts(pile, held.cards)) el.dataset.drop = "1";
|
|
});
|
|
}
|
|
|
|
function pick(pile, idx) {
|
|
var cs = cardsAt(pile, idx);
|
|
if (!cs || !isRun(cs)) return;
|
|
held = { pile: pile, idx: idx, count: cs.length, cards: cs };
|
|
mark();
|
|
}
|
|
|
|
function drop() {
|
|
held = null;
|
|
mark();
|
|
}
|
|
|
|
function nope(el) {
|
|
if (!el || reduced) return;
|
|
el.classList.remove("pete-nope");
|
|
void el.offsetWidth;
|
|
el.classList.add("pete-nope");
|
|
}
|
|
|
|
// A click on the board. The order matters: if something is in your hand and the
|
|
// thing you clicked will take it, that's a move — otherwise it's you picking up
|
|
// something else. Which means you never have to put a card down before choosing
|
|
// a different one.
|
|
root.querySelector(".pete-felt").addEventListener("click", function (e) {
|
|
if (busy || !board || board.phase !== "playing") return;
|
|
if (e.target.closest("[data-stock]")) return; // the stock has its own handler
|
|
|
|
var pileEl = e.target.closest("[data-pile]");
|
|
if (!pileEl) { drop(); return; }
|
|
|
|
var cardEl = e.target.closest(".pete-card[data-key]");
|
|
var pile = pileEl.dataset.pile;
|
|
|
|
if (held) {
|
|
if (pile === held.pile) { drop(); return; } // clicking your own run puts it down
|
|
if (accepts(pile, held.cards)) {
|
|
var move = { kind: "move", from: held.pile, to: pile, count: held.count };
|
|
drop();
|
|
send(move);
|
|
return;
|
|
}
|
|
// Not a place it goes. If what you clicked is a card you *could* pick up,
|
|
// this was a change of mind rather than a bad move; only shake at a genuine
|
|
// dead end.
|
|
if (!cardEl || cardEl.dataset.live !== "1") { nope(pileEl); return; }
|
|
}
|
|
|
|
if (cardEl && cardEl.dataset.live === "1") {
|
|
pick(pile, parseInt(cardEl.dataset.idx, 10));
|
|
} else {
|
|
drop();
|
|
}
|
|
});
|
|
|
|
// Double-click sends a card home. It's the idiom every solitaire has used for
|
|
// thirty years, and the alternative is asking the player which foundation — a
|
|
// question with exactly one right answer.
|
|
root.addEventListener("dblclick", function (e) {
|
|
if (busy || !board || board.phase !== "playing") return;
|
|
var cardEl = e.target.closest('.pete-card[data-live="1"]');
|
|
if (!cardEl) return;
|
|
var pile = cardEl.dataset.pile;
|
|
if (pile.charAt(0) === "f") return; // it's already home
|
|
e.preventDefault();
|
|
drop();
|
|
send({ kind: "home", from: pile });
|
|
});
|
|
|
|
stockEl.addEventListener("click", function () {
|
|
if (busy || !board || board.phase !== "playing") return;
|
|
if (stockEl.dataset.dead === "1") {
|
|
say(gameMsg, "That was your last pass through the stock.", "bad");
|
|
nope(stockEl);
|
|
return;
|
|
}
|
|
drop();
|
|
send({ kind: "draw" });
|
|
});
|
|
|
|
autoBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); });
|
|
|
|
cashBtn.addEventListener("click", function () {
|
|
drop();
|
|
send({ kind: "concede" });
|
|
});
|
|
|
|
document.addEventListener("keydown", function (e) {
|
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return;
|
|
if (e.key === "Escape") { drop(); return; }
|
|
if (busy || !board || board.phase !== "playing") return;
|
|
|
|
var k = e.key.toLowerCase();
|
|
if (k === " " || k === "d") { e.preventDefault(); stockEl.click(); }
|
|
else if (k === "a" && !autoBtn.disabled) { e.preventDefault(); autoBtn.click(); }
|
|
});
|
|
|
|
// ---- talking to the table --------------------------------------------------
|
|
|
|
var VERDICTS = {
|
|
cleared: "Cleared the board! 🎉",
|
|
cashed: "Board cashed.",
|
|
};
|
|
|
|
function verdict(v) {
|
|
var text = VERDICTS[v.outcome] || "";
|
|
if (!text) { verdictEl.classList.add("hidden"); return; }
|
|
if (v.net > 0) text += " +" + v.net.toLocaleString();
|
|
else if (v.net < 0) text += " " + v.net.toLocaleString();
|
|
verdictEl.textContent = text;
|
|
verdictEl.classList.remove("hidden");
|
|
// Clearing 52 cards out of a Vegas deal is the rarest thing that happens in
|
|
// this room, so it's the one that gets the confetti.
|
|
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 40 });
|
|
}
|
|
|
|
// play walks a server response onto the felt: the board is re-rendered, the
|
|
// cards animate from where they were, and the chips follow.
|
|
//
|
|
// `money` — the chip bar catching up — is held back on a *settling* board until
|
|
// the payout has physically swept home. A counter that pays you before the chips
|
|
// arrive is a counter that has told you the ending.
|
|
function play(view, money) {
|
|
var v = view.solitaire;
|
|
var events = view.sol_events || [];
|
|
var settles = !!v && v.phase === "done";
|
|
var recycled = events.some(function (e) { return e.kind === "recycle"; });
|
|
|
|
var pre = recycled ? recycleOut() : Promise.resolve();
|
|
|
|
return pre
|
|
.then(function () {
|
|
var before = snapshot();
|
|
render(v);
|
|
var plan = planOf(events);
|
|
flashHome(events);
|
|
return animate(before, plan);
|
|
})
|
|
.then(function () {
|
|
if (!v) { money(); return; }
|
|
return bank(v.stands).then(function () {
|
|
if (!settles) { money(); return; }
|
|
// The board is finished. Everything banked comes home, and only then
|
|
// does the number in the bar move.
|
|
verdict(v);
|
|
return wait(300)
|
|
.then(function () { return spot.sweep(purseEl, v.payout, { gap: 40, lift: 0.8 }); })
|
|
.then(money)
|
|
.then(function () { controls(null); showBet(); });
|
|
});
|
|
});
|
|
}
|
|
|
|
function send(move) {
|
|
if (busy) return;
|
|
busy = true;
|
|
say(gameMsg, "");
|
|
return window.PeteGames.post("/api/games/solitaire/move", move)
|
|
.then(function (view) { return play(view, function () { window.PeteGames.apply(view); }); })
|
|
.catch(function (err) {
|
|
say(gameMsg, err.message, "bad");
|
|
// Whatever this file thought the board was, the server is the authority on
|
|
// it. Ask, and draw what it says.
|
|
return window.PeteGames.refresh().then(function (v) {
|
|
if (v) { render(v.solitaire || null); spot.render(v.solitaire ? v.solitaire.stands : 0); }
|
|
});
|
|
})
|
|
.then(function () { busy = false; });
|
|
}
|
|
|
|
// ---- buying a deck ---------------------------------------------------------
|
|
|
|
function showBet() {
|
|
betAmountEl.textContent = bet.toLocaleString();
|
|
var money = window.PeteGames.view();
|
|
startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet;
|
|
}
|
|
|
|
root.querySelectorAll("[data-tier]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
tier = btn.dataset.tier;
|
|
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
|
b.dataset.on = b === btn ? "1" : "0";
|
|
});
|
|
showBet();
|
|
});
|
|
});
|
|
|
|
// The chip you click is the chip that flies. It lands on the spot in the rail,
|
|
// which is where the price of the deck is stacked up before you pay it.
|
|
root.querySelectorAll("[data-chip]").forEach(function (btn) {
|
|
if (!btn.dataset.chip || btn.tagName !== "BUTTON") return;
|
|
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(tableMsg, "You haven't got that many chips.", "bad");
|
|
return;
|
|
}
|
|
bet += d;
|
|
showBet();
|
|
|
|
var target = bet;
|
|
spot.amount = bet;
|
|
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
|
if (bet >= target) spot.render(target); // unless Clear got there first
|
|
});
|
|
});
|
|
});
|
|
|
|
root.querySelector("[data-bet-clear]").addEventListener("click", function () {
|
|
if (busy) return;
|
|
if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
|
bet = 0;
|
|
showBet();
|
|
});
|
|
|
|
startBtn.addEventListener("click", function () {
|
|
if (busy) return;
|
|
if (!tier) { say(tableMsg, "Pick a deal first.", "bad"); return; }
|
|
if (bet <= 0) { say(tableMsg, "Put something down for the deck.", "bad"); return; }
|
|
|
|
busy = true;
|
|
say(tableMsg, "");
|
|
var price = bet;
|
|
|
|
window.PeteGames.post("/api/games/solitaire/start", { bet: price, tier: tier })
|
|
.then(function (view) {
|
|
// The deck is *bought*: the chips on the spot go across to the house and
|
|
// do not come back. Then the spot starts again at nothing, and from here
|
|
// on it holds what the board has earned back.
|
|
return spot
|
|
.sweep(houseEl, price, { gap: 45, lift: 0.6 })
|
|
.then(function () {
|
|
bet = 0;
|
|
showBet();
|
|
window.PeteGames.apply(view);
|
|
return dealOut(view.solitaire);
|
|
});
|
|
})
|
|
.catch(function (err) {
|
|
say(tableMsg, err.message, "bad");
|
|
return window.PeteGames.refresh();
|
|
})
|
|
.then(function () { busy = false; });
|
|
});
|
|
|
|
// dealOut lays the board and flies every card onto it out of the stock, one at a
|
|
// time, across the columns — the way a deal actually goes down.
|
|
function dealOut(v) {
|
|
render(v);
|
|
if (reduced || !v) return Promise.resolve();
|
|
|
|
var from = stockEl.getBoundingClientRect();
|
|
var waits = [];
|
|
|
|
// The order a real deal goes in: one card to each column, then round again,
|
|
// starting a column further along each time.
|
|
var order = 0;
|
|
for (var row = 0; row < 7; row++) {
|
|
for (var col = row; col < 7; col++) {
|
|
var colEl = tableauEl.children[col];
|
|
var cardEl = colEl.children[row];
|
|
if (!cardEl) continue;
|
|
var now = cardEl.getBoundingClientRect();
|
|
waits.push(slide(cardEl, from.left - now.left, from.top - now.top, order * 34));
|
|
if (cardEl.dataset.face === "up") waits.push(turn(cardEl, order * 34));
|
|
order++;
|
|
}
|
|
}
|
|
return Promise.all(waits);
|
|
}
|
|
|
|
// ---- coming in ------------------------------------------------------------
|
|
|
|
// The money bar owns the first fetch. The table picks up whatever it found —
|
|
// including a board left mid-game by a reload or a redeploy, which comes back
|
|
// exactly as it was, right down to what it has banked.
|
|
var resumed = false;
|
|
window.PeteGames.onUpdate(function (v) {
|
|
if (!resumed) {
|
|
resumed = true;
|
|
if (v.solitaire) {
|
|
render(v.solitaire);
|
|
spot.render(v.solitaire.stands);
|
|
} else {
|
|
controls(null);
|
|
}
|
|
}
|
|
showBet();
|
|
});
|
|
})();
|