// 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 = '' + glyph + "";
}
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/' + FULL + "";
perCardEl.textContent = "—";
breakEvenEl.textContent = "";
return;
}
homeEl.innerHTML = v.home + '/' + FULL + "";
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();
});
})();