Files
Pete/internal/web/static/js/solitaire.js
prosolis 39ed293f4f games: the word you owe the table, and the hand you were already holding
Three things, and the first one was a bug.

Your own hand didn't move until the lap ended. bump() keeps the bots'
fans honest and has always refused seat zero, and nothing else touched
yours — so a +4 landing on you at the top of a lap put four backs into
your hand and then nothing, and the cards themselves turned up seconds
later when the script finished and paint() finally ran. You spent the
whole lap looking at a hand you no longer held. The engine now stamps
your hand onto every event that changes it (Event.Hand, seat zero only,
which is the one hand the browser is already entitled to see) and the
table redraws as the cards land. Measured in the running app: 2 -> 3
cards at 414ms into a 1791ms lap.

You couldn't call UNO, and not because the button was missing: going
down to one card *was* the call. discard() fired the uno event by
itself, which made it a thing that happened to you rather than a thing
you did, and a rule nobody can fail is not a rule. So now you say it or
you don't (Move.Uno), and if you don't, every bot still in the game gets
one look at you before any of them plays — because a bot that has moved
on is a bot that has stopped watching your hand. It runs the other way
too, and that half is the fun one: a bot forgets often enough to be
worth watching for, and when it does it says *nothing*. No event, no
badge, no tell on the felt except the count beside its fan reading
"1 card". Catch it and it takes two; call a seat that had nothing to
hide and you take two yourself, which is what stops the catch button
from being a thing you simply mash.

Which cards owe the call is the engine's answer, not a count of your
hand: No Mercy's "discard all" takes every card of its colour with it,
so a six-card hand can land on one, and a browser subtracting one from
six walks you into a catch it never warned you about.

And the room was silent. Every sound in here is *made* — an oscillator,
a burst of filtered noise, an envelope — the same bargain the weather
engine takes with its clouds. A card is a slap of noise through a
bandpass, a chip is two detuned sines with a knock on the front, a win
is four notes going up. No asset files, no round trips, and a sound can
be pitched and detuned per call instead of being the same wav three
hundred times. Hooked into the FX layer rather than into the games, so
every table that throws a chip or turns a card got it at once.

The multiples moved, and the test that exists to catch that caught it.
The naive strategy now calls UNO, because calling is a button and not a
strategy — what these tiers price is bad card play, not a player who
ignores the felt shouting at them — and on that footing the normal
tables come back to where they were (40.1 / 28.5 / 23.1). No Mercy Full
House did not: it was paying a *negative* house edge, which is the house
paying you to sit down. Re-priced 3.8 -> 3.5.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 13:15:11 -07:00

764 lines
28 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);
});
}
// noises is the board's soundtrack, walked off the *same* STEP_MS ladder the
// animation is walked off. That matters more than which sound goes where: a
// card you hear land a step before it lands is worse than a card that lands in
// silence, so this counts its way through the script exactly as planOf and
// flashHome do, and any change to their timing has to be made here too.
function noises(events) {
var at = 0;
(events || []).forEach(function (e) {
switch (e.kind) {
case "draw":
FX.sfx("card", { delay: at / 1000 });
break;
case "recycle":
FX.sfx("shuffle", { delay: at / 1000 });
break;
case "move":
FX.sfx("deal", { delay: (at + MOVE_MS) / 1000 });
at += STEP_MS;
break;
case "home":
// A card reaching a foundation is the only move that pays, so it is the
// only one that gets a note on the end of it.
FX.sfx("deal", { delay: (at + MOVE_MS) / 1000 });
FX.sfx("blip", { delay: (at + MOVE_MS + 60) / 1000 });
at += STEP_MS;
break;
}
});
}
// ---- 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 });
else if (v.net > 0) FX.sfx("win");
else if (v.net < 0) FX.sfx("lose");
else FX.sfx("push");
}
// 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);
noises(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();
});
})();