// 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 playControls = root.querySelector("[data-play-controls]");
  var wonControls  = root.querySelector("[data-won-controls]");
  var autoBtn      = root.querySelector("[data-auto]");
  var finishBtn    = root.querySelector("[data-finish]");
  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;
    // A won board has nothing left to decide, so the ordinary controls step aside
    // for the one button that finishes it.
    var won = !!v.won;
    wonControls.classList.toggle("hidden", !won);
    playControls.classList.toggle("hidden", won);
    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" }); });

  // The finish button. On a won board a single auto drains every card home in one
  // cascade, and the board settles cleared on the far end of it.
  finishBtn.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 keeps the confetti — but any win now makes it rain.
    if (v.outcome === "cleared") { FX.burst(verdictEl, { count: 40 }); FX.moneyRain({ count: 34 }); }
    else if (v.net > 0) FX.moneyRain();
    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();
  });
})();
