// The UNO table.
//
// Same bargain as every other table in the room: the browser holds no game. It
// sends one move, and what comes back is that move *and every bot turn it handed
// off to*, as a script of events. So a round trip here is a whole lap of the
// table, and this file's main job is to play that lap back slowly enough that
// you can see what happened to you.
//
// Two rules carried over from the tables that came before, both load-bearing:
//
// The number under the pile is a readout of the pile (PeteFX.spot owns that), and
// the chip bar does not move until the chips that justify it have landed. So a
// settling game holds the money back until the payout has physically swept home
// — a counter that pays you before the last card goes down has told you the
// ending.
//
// And the browser never learns a bot's hand. It gets a *count*. Every fan of
// backs you see up there is drawn from a number, because a number is all that
// crossed the wire.
(function () {
  "use strict";

  var root = document.querySelector("[data-uno]");
  if (!root) return;

  var FX = window.PeteFX;

  var seatsEl   = root.querySelector("[data-seats]");
  var handEl    = root.querySelector("[data-hand]");
  var deckEl    = root.querySelector("[data-deck]");
  var deckCntEl = root.querySelector("[data-deck-count]");
  var discardEl = root.querySelector("[data-discard]");
  var colourEl  = root.querySelector("[data-colour]");
  var turnEl    = root.querySelector("[data-turn-label]");
  var countEl   = root.querySelector("[data-count-label]");
  var verdictEl = root.querySelector("[data-verdict]");
  var paysEl    = root.querySelector("[data-pays]");
  var meterEl   = root.querySelector("[data-meter]");
  var tableEl   = root.querySelector("[data-table-name]");

  var wildEl    = root.querySelector("[data-wild]");
  var gateEl    = root.querySelector("[data-unogate]");
  var callBtn   = root.querySelector("[data-uno-call]");
  var timerEl   = root.querySelector("[data-uno-timer]");
  // Not [data-pending]: that is the chip bar's, it lives inside this root, and it
  // comes first in the document. See the note in uno.html.
  var billEl    = root.querySelector("[data-bill]");
  var betting   = root.querySelector("[data-betting]");
  var playing   = root.querySelector("[data-playing]");
  var drawBtn   = root.querySelector("[data-draw]");
  var passBtn   = root.querySelector("[data-pass]");
  var takeBtn   = root.querySelector("[data-take]");
  var takeN     = root.querySelector("[data-take-n]");
  var hintEl    = root.querySelector("[data-hint]");
  var betAmount = root.querySelector("[data-bet-amount]");
  var startBtn  = root.querySelector("[data-start]");
  var msgEl     = root.querySelector("[data-table-msg]");
  var gameMsgEl = root.querySelector("[data-game-msg]");

  var purseEl = document.querySelector("[data-chips]");
  var spotEl  = root.querySelector("[data-spot]");
  var houseEl = root.querySelector("[data-house]");

  var spot = FX.spot({
    spot:  spotEl,
    stack: root.querySelector("[data-stack]"),
    total: root.querySelector("[data-spot-total]"),
  });

  var bet = 0;     // what you're building between games
  var busy = false;
  var game = null; // the game as the server last described it
  var tier = "table";
  var pendingWild = -1; // the wild you clicked, waiting on a colour
  var played = -1; // the card you just played, so the script lifts that one out
                   // of the hand and not merely the first one that lit up

  // The UNO call. CALL_MS is how long you get to say it once the card is on its
  // way down — long enough that nobody playing the game misses it, short enough
  // that it is still a thing you have to *do*. The engine has the other half of
  // this rule (see call.go); this is only the window.
  var CALL_MS = 2600;
  var gate = null;      // the play waiting on a call: {index, color}
  var gateTimer = 0;

  var reduced = FX.reduced;
  function pace(ms) { return reduced ? 0 : ms; }
  function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }

  function say(text, tone, where) {
    var el = where || msgEl;
    if (!el) return;
    if (!text) { el.classList.add("hidden"); return; }
    el.textContent = text;
    el.classList.remove("hidden");
    el.style.color = tone === "bad" ? "#cc3d4a" : "";
  }

  // ---- drawing the cards -----------------------------------------------------

  // FACES is how each printed face is drawn. The engine sends a word ("skip",
  // "+2", "discard all"); this is the picture of it.
  //
  // The corner is not always the same mark as the middle. "DISCARD ALL" fits
  // across an oval and does not fit in a corner, so the long faces carry a short
  // mark for the corners — which is what a real deck does too. `size` is a class,
  // never a height: a card is sized by --uno-h/--uno-w and nothing else, and a
  // face that sets its own font in pixels is a face that breaks on a phone.
  var FACES = {
    "skip":        { mid: "🚫",    corner: "🚫" },
    "reverse":     { mid: "⇄",     corner: "⇄" },
    "+2":          { mid: "+2",    corner: "+2" },
    "wild":        { mid: "★",     corner: "★" },
    "+4":          { mid: "+4",    corner: "+4" },

    // No Mercy.
    "skip all":    { mid: "SKIP ALL",    corner: "⊘⊘", size: "words" },
    "discard all": { mid: "DISCARD ALL", corner: "≡",  size: "words" },
    "rev +4":      { mid: "⇄+4",   corner: "⇄4", size: "mid" },
    "+6":          { mid: "+6",    corner: "+6" },
    "+10":         { mid: "+10",   corner: "+10", size: "mid" },
    // The roulette names a colour and turns cards over until it comes up. The
    // wheel behind the oval is already the picture of that; the mark is the
    // question the wheel is asking.
    "roulette":    { mid: "?",     corner: "?" },
  };

  function faceOf(c) { return FACES[c.value] || { mid: c.value, corner: c.value }; }

  // The faces that make somebody draw. On a wild these are the cards worth the
  // most and worth watching for, so they get the glow — and it is the one thing
  // that tells the *wild* +4 apart from No Mercy's coloured +4 at a glance.
  var DRAWS = { "+2": true, "+4": true, "+6": true, "+10": true, "rev +4": true };

  // card builds one UNO card. The oval across the middle at an angle is the whole
  // look of the thing — without it a card reads as a coloured button.
  function card(c, opts) {
    opts = opts || {};
    var f = faceOf(c);
    var el = document.createElement(opts.button ? "button" : "div");
    if (opts.button) el.type = "button";
    el.className = "pete-uno-card";
    el.dataset.c = c.wild ? "wild" : c.color;

    var face = document.createElement("span");
    face.className = "pete-uno-face";

    var oval = document.createElement("span");
    oval.className = "pete-uno-oval";
    if (f.size) oval.dataset.size = f.size;
    oval.textContent = f.mid;
    face.appendChild(oval);

    // The corners, as printed.
    ["tl", "br"].forEach(function (at) {
      var corner = document.createElement("span");
      corner.className = "pete-uno-corner";
      corner.dataset.at = at;
      corner.textContent = f.corner;
      corner.setAttribute("aria-hidden", "true");
      face.appendChild(corner);
    });

    // A wild is four quadrants of colour — and once it has been played as a
    // colour, that colour is the one it wears on the pile.
    if (c.wild) {
      var wheel = document.createElement("span");
      wheel.className = "pete-uno-wheel";
      face.appendChild(wheel);
      if (c.color && c.color !== "wild") el.dataset.named = c.color;
    }

    // The wild draws glow. They are the cards that decide a game — a +4 you were
    // holding is four cards somebody else is about to be holding — and in No Mercy
    // there are four of them to tell apart from the coloured +4 sitting next to
    // them in the same hand. The glow is what says "this one is the nasty one".
    if (c.wild && DRAWS[c.value]) el.dataset.glow = "1";

    el.appendChild(face);
    el.setAttribute("aria-label", label(c));
    return el;
  }

  // label is what a screen reader says. Note "+4" is two different cards — No
  // Mercy prints a coloured one next to the wild one — so the name comes from the
  // face *and* whether it's a wild, never from the face alone.
  var SPOKEN = {
    "+2": "draw two", "+4": "draw four", "+6": "draw six", "+10": "draw ten",
    "wild": "wild", "skip": "skip", "reverse": "reverse",
    "skip all": "skip everyone", "discard all": "discard all",
    "rev +4": "reverse and draw four", "roulette": "colour roulette",
  };

  function label(c) {
    var v = SPOKEN[c.value] || c.value;
    if (c.wild) {
      var name = c.value === "wild" ? "wild" : "wild " + v; // not "wild wild"
      return name + (c.color && c.color !== "wild" ? ", played as " + c.color : "");
    }
    return c.color + " " + v;
  }

  function back() {
    var el = document.createElement("div");
    el.className = "pete-uno-card pete-uno-card-back";
    var b = document.createElement("span");
    b.className = "pete-uno-back";
    el.appendChild(b);
    return el;
  }

  // ---- the board -------------------------------------------------------------

  // FAN is the most backs a bot's hand draws, however many it holds. Past this
  // the fan is unreadable and the number beside it is doing the work anyway.
  var FAN = 8;

  function renderSeats(v) {
    seatsEl.innerHTML = "";
    if (!v) return;
    var catchable = {};
    (v.catchable || []).forEach(function (i) { catchable[i] = true; });

    v.seats.forEach(function (s, i) {
      if (s.you) return; // you are the hand at the bottom, not a seat up here
      var el = document.createElement("div");
      el.className = "pete-uno-seat";
      el.dataset.seat = String(i);
      el.dataset.turn = v.turn === i && v.phase !== "done" ? "1" : "0";
      if (s.uno) el.dataset.uno = "1";
      if (s.out) el.dataset.out = "1"; // No Mercy buried them; they are not coming back

      // One card, and it never said so. This button is the only thing on the felt
      // that gives it away — a bot that forgets to call is silent by definition,
      // and the tell was always meant to be the count beside its fan. The button
      // is the table doing you the courtesy of putting a target on it.
      if (catchable[i]) {
        var got = document.createElement("button");
        got.type = "button";
        got.className = "pete-uno-catch";
        got.dataset.catch = String(i);
        got.textContent = "Catch!";
        got.setAttribute("aria-label", "Catch " + s.name + " — one card, never called");
        got.addEventListener("click", function (e) {
          e.stopPropagation();
          catchSeat(i);
        });
        el.appendChild(got);
      }

      var fan = document.createElement("div");
      fan.className = "pete-uno-fan";
      var show = Math.min(s.cards, FAN);
      for (var n = 0; n < show; n++) {
        var b = back();
        b.style.setProperty("--i", n);
        b.style.setProperty("--n", show);
        fan.appendChild(b);
      }
      el.appendChild(fan);

      var name = document.createElement("p");
      name.className = "pete-uno-name";
      name.textContent = s.name;
      el.appendChild(name);

      var count = document.createElement("p");
      count.className = "pete-uno-count";
      count.dataset.count = "";
      count.textContent = s.out ? "Buried" : s.cards + (s.cards === 1 ? " card" : " cards");
      el.appendChild(count);

      seatsEl.appendChild(el);
    });
  }

  function seatEl(i) { return seatsEl.querySelector('[data-seat="' + i + '"]'); }

  // Where a card flies to or from, for a seat. Yours is the hand; a bot's is its
  // fan; the deck is the deck.
  function seatAnchor(i) {
    if (i === 0) return handEl;
    var el = seatEl(i);
    return el ? el.querySelector(".pete-uno-fan") : deckEl;
  }

  // shownHand is the faces currently on screen. paintHand diffs against it so that
  // only cards that are actually new fan in — without it, every redraw re-deals
  // the whole hand in front of you, and a hand gets redrawn several times a lap.
  var shownHand = [];

  function key(c) { return c.color + "/" + c.value; }

  // paintHand draws your fan. `live` is whether the cards are yours to click,
  // which mid-script they are not: the game on screen is one the server has
  // already moved past, and a card clicked during the lap would be a move against
  // a board that no longer exists.
  function paintHand(cards, playable, live) {
    cards = cards || [];
    // What was already on the table stays put. Matched by face and consumed, so a
    // hand holding two red fives doesn't count one of them twice.
    var pool = shownHand.map(key);
    handEl.innerHTML = "";
    var fresh = 0;

    cards.forEach(function (c, i) {
      var el = card(c, { button: true });
      el.dataset.at = String(i);
      var ok = !!live && !!playable[i];
      el.dataset.on = ok ? "1" : "0";
      el.disabled = !ok || busy;
      if (ok) el.addEventListener("click", function () { pick(i, c); });
      var was = pool.indexOf(key(c));
      if (was >= 0) {
        pool.splice(was, 1);
        el.dataset.settled = "1"; // it was already in your hand: don't deal it again
      } else {
        el.style.setProperty("--i", fresh++);
      }
      handEl.appendChild(el);
    });
    shownHand = cards.slice();
  }

  function renderHand(v) {
    if (!v || !v.hand) { handEl.innerHTML = ""; shownHand = []; return; }
    var playable = {};
    (v.playable || []).forEach(function (i) { playable[i] = true; });
    paintHand(v.hand, playable, v.turn === 0 && v.phase !== "done");
  }

  // showHand redraws your fan from the hand the server stamped on the event that
  // just changed it — see Event.Hand in the engine.
  //
  // This is the fix for a hand that used to sit there stale for the whole lap.
  // bump() keeps the *bots'* fans honest and has always refused seat zero, so
  // when a +4 landed on you at the top of a lap you watched four backs fly into
  // your hand and then nothing: the cards themselves only turned up seconds
  // later, when the script ended and paint() finally ran. You were looking at a
  // hand you no longer held.
  function showHand(cards) {
    if (!cards) return;
    paintHand(cards, {}, false);
    // And the line above the fan counts what's in it, so it moves too.
    countEl.textContent = cards.length + (cards.length === 1 ? " card — UNO!" : " cards");
  }

  function renderPiles(v) {
    discardEl.innerHTML = "";
    if (!v) {
      deckCntEl.textContent = "0";
      colourEl.textContent = "";
      root.querySelector(".pete-uno").dataset.c = "";
      return;
    }
    deckCntEl.textContent = String(v.deck);
    var top = card(v.top);
    top.classList.add("pete-uno-top");
    discardEl.appendChild(top);

    // The colour in play, which after a wild is not the colour of the card you
    // are looking at. So it is said in words, and the felt takes a tint of it —
    // this is the single most missable fact on the table.
    colourEl.textContent = v.color;
    colourEl.dataset.c = v.color;
    feltEl.dataset.c = v.color;
    pending(v.phase === "done" ? 0 : v.pending || 0);
  }

  var feltEl = root.querySelector(".pete-uno");

  // pending is the bill a stack has run up. It is on the felt, on the button and
  // in the turn line, because it is the only thing on the table while it stands:
  // a player who misses it plays into a hand that is about to grow by ten.
  function pending(n) {
    if (billEl) {
      billEl.textContent = n > 0 ? "+" + n + " on you" : "";
      billEl.classList.toggle("hidden", n <= 0);
    }
    if (takeN) takeN.textContent = String(n);
  }

  function renderRail(v) {
    if (!v) {
      paysEl.textContent = "—";
      meterEl.dataset.cold = "1";
      tableEl.textContent = "";
      return;
    }
    paysEl.textContent = (v.pays || 0).toLocaleString();
    meterEl.dataset.cold = v.phase === "done" ? "1" : "0";
    tableEl.textContent = v.tier.name + " · " + v.tier.base.toFixed(1) + "×";
  }

  function renderTurn(v) {
    if (!v || v.phase === "done") {
      turnEl.textContent = "";
      countEl.textContent = "";
      return;
    }
    var yours = v.turn === 0;
    var who = yours ? "Your turn" : (v.seats[v.turn] || {}).name + " is thinking…";
    if (yours && v.phase === "drawn") who = "Play it, or keep it";
    if (yours && v.phase === "stack") who = "+" + (v.pending || 0) + " — stack it, or take it";
    turnEl.textContent = who;
    turnEl.dataset.you = yours ? "1" : "0";
    var n = v.hand.length;
    countEl.textContent = n + (n === 1 ? " card — UNO!" : " cards");
  }

  // ---- phases ----------------------------------------------------------------

  var VERDICTS = {
    won:   "You went out! 🎉",
    lost:  "Beaten to it.",
    stuck: "Nobody could move.",
  };

  function verdict(v) {
    var text = VERDICTS[v.outcome] || "";
    if (v.outcome === "lost" && v.winner > 0 && v.seats[v.winner]) {
      text = v.seats[v.winner].name + " went out first.";
    }
    // No Mercy has two endings the normal deck hasn't got: you can lose without
    // anybody going out (the mercy rule got you), and win without going out at all
    // (it got everybody else).
    if (v.outcome === "lost" && v.winner < 0) text = "Buried. Twenty-five cards and you're done.";
    if (v.outcome === "won" && v.seats[0] && v.seats[0].cards > 0) text = "Last one standing! 🎉";
    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");
    // burst() is the room's win sound as well as its confetti; anything else is
    // a night that didn't go your way.
    if (v.outcome === "won") FX.burst(verdictEl, { count: 34 });
    else FX.sfx("lose");
  }

  // controls is the one place that decides what you can click: whose turn the
  // server says it is, and whether a move of yours is still in flight. Both
  // halves matter, and they used to be spread across three functions that each
  // knew half of it — which is how the table came to unlock itself in the middle
  // of a bot's turn.
  function controls() {
    var live = !!game && game.phase !== "done";
    var yours = live && game.turn === 0;
    var drawn = live && game.phase === "drawn";
    var stack = live && game.phase === "stack";
    handEl.querySelectorAll(".pete-uno-card").forEach(function (b) {
      b.disabled = busy || !yours || b.dataset.on !== "1";
    });
    // Catching is free, but it is still a move: it can't go up while one of yours
    // is in flight, and it can't go up on somebody else's turn.
    seatsEl.querySelectorAll("[data-catch]").forEach(function (b) {
      b.disabled = busy || !yours;
    });
    // Under a stack you cannot draw. The deck is not a way out of a bill: the two
    // moves that exist are answering it with a draw card or taking the lot.
    if (drawBtn) drawBtn.disabled = busy || !yours || drawn || stack;
    if (passBtn) passBtn.disabled = busy || !yours;
    if (takeBtn) takeBtn.disabled = busy || !yours;
    if (deckEl) deckEl.disabled = busy || !yours || drawn || stack;
  }

  // HINTS is the line above the buttons. A game with a stack in it has a rule the
  // player has just met for the first time, and the table should say what it is
  // rather than leave them clicking a hand where nothing lights up.
  var HINTS = {
    play:  "Click a card that lights up. Nothing lights up? Draw one.",
    drawn: "You drew a card you can play. Play it, or keep it and pass.",
    stack: "Only draw cards will do here. Answer it, and it lands on somebody else — or take the lot.",
  };

  function setPhase(v) {
    game = v;
    var live = !!v && v.phase !== "done";
    var drawn = live && v.phase === "drawn";
    var stack = live && v.phase === "stack";
    betting.classList.toggle("hidden", live);
    playing.classList.toggle("hidden", !live);
    hideWild();
    hideGate();

    if (drawBtn) drawBtn.classList.toggle("hidden", drawn || stack);
    if (takeBtn) takeBtn.classList.toggle("hidden", !stack);
    // No Mercy has no pass: you drew until you found a card that plays, and
    // playing it is the price of having drawn. The server refuses one anyway;
    // this is the table not offering a button that cannot work.
    var canPass = drawn && !(v && v.tier && v.tier.no_mercy);
    if (passBtn) passBtn.classList.toggle("hidden", !canPass);
    if (hintEl && live) {
      // A seat sitting on one card it never called is the most valuable thing on
      // the table, and it is worth more than whatever the phase was going to say.
      hintEl.textContent = (v.catchable || []).length
        ? "Somebody's down to one card and never said so. Catch them before you play."
        : (HINTS[v.phase] || HINTS.play);
    }
    if (!live && v) rulesFor(v); // it's over: leave the panel where the game was
    controls();
    if (!v || !v.outcome) verdictEl.classList.add("hidden");
  }

  // paint puts the board up with no animation: the resume path, after a reload or
  // a redeploy. Whatever the server says is on the table is on the table.
  function paint(v) {
    renderSeats(v);
    renderPiles(v);
    renderHand(v);
    renderRail(v);
    renderTurn(v);
    spot.render(v && v.phase !== "done" ? v.bet : 0);
    setPhase(v);
  }

  // ---- the script ------------------------------------------------------------

  // throwCard sends a card from one place to another across the felt. It lands
  // with a slap — "card" for one going down on the pile, "deal" (softer, lower)
  // for one coming off the deck into a hand, which is a card being *placed*.
  function throwCard(node, from, to, opts) {
    opts = opts || {};
    return FX.flyNode(node, from, to, {
      duration: opts.duration || 380,
      lift: opts.lift == null ? 0.7 : opts.lift,
      spin: opts.spin == null ? FX.jitter((opts.index || 0) + 3, 14) : opts.spin,
      fromScale: opts.fromScale == null ? 0.9 : opts.fromScale,
      delay: opts.delay || 0,
      index: opts.index || 0,
      sound: opts.sound === undefined ? "card" : opts.sound,
    });
  }

  // bump keeps a bot's fan honest during the script: the server's count is the
  // truth, but between events the fan has to grow and shrink as cards move, or
  // the flight lands on a pile that hasn't changed.
  //
  // Seat zero is not a bot and is not bumped — a fan of backs is no use to you,
  // you want the faces. showHand does yours, from the hand the event carries.
  function bump(seat, left) {
    if (seat === 0 || left == null) return;
    var el = seatEl(seat);
    if (!el) return;
    var fan = el.querySelector(".pete-uno-fan");
    var count = el.querySelector("[data-count]");
    var buried = el.dataset.out === "1"; // an empty hand here is a grave, not a win
    if (count) count.textContent = buried ? "Buried" : left + (left === 1 ? " card" : " cards");
    if (!fan) return;
    var show = Math.min(left, FAN);
    while (fan.children.length > show) fan.removeChild(fan.lastChild);
    while (fan.children.length < show) fan.appendChild(back());
    Array.prototype.forEach.call(fan.children, function (b, i) {
      b.style.setProperty("--i", i);
      b.style.setProperty("--n", fan.children.length);
    });
    el.dataset.uno = left === 1 ? "1" : "0";
  }

  function spotlight(seat) {
    seatsEl.querySelectorAll(".pete-uno-seat").forEach(function (el) {
      el.dataset.turn = parseInt(el.dataset.seat, 10) === seat ? "1" : "0";
    });
  }

  // badge floats a word off a seat: SKIPPED, UNO!, +4. The events already say
  // these things; the badge is what makes them land on somebody.
  function badge(seat, text, tone) {
    var host = seat === 0 ? handEl : seatEl(seat);
    if (!host || reduced) return Promise.resolve();
    var b = document.createElement("span");
    b.className = "pete-uno-badge";
    b.dataset.tone = tone || "";
    b.textContent = text;
    host.appendChild(b);
    return new Promise(function (r) {
      setTimeout(function () { b.remove(); r(); }, 900);
    });
  }

  // play walks the server's script. On a live game the money is already right —
  // your stake left your pile when you pressed Deal, and it's on the spot — so
  // the chip bar can catch up immediately. On a settling one it waits.
  function play(view, money) {
    var events = view.uno_events || [];
    var final = view.uno;
    var settles = !!final && final.phase === "done";
    var chain = Promise.resolve();

    if (!settles) money();

    events.forEach(function (e, n) {
      chain = chain.then(function () {
        switch (e.kind) {
          case "deal":
            // The deal isn't animated card by card: seven cards to each of four
            // seats is 28 flights and a player who wants to play. The hand fans in
            // on its own (CSS), which reads as being dealt without taking as long.
            // The sound does the same job: a riffle, then cards landing, rather
            // than twenty-eight separate slaps.
            FX.sfx("shuffle");
            for (var c = 0; c < 5; c++) FX.sfx("deal", { delay: 0.28 + c * 0.06, v: c });
            paint(final);
            return wait(320);

          case "play": {
            spotlight(e.seat);
            var node = card(e.card);
            var from = seatAnchor(e.seat);
            // Your own card leaves the hand it was in, so the hand has to lose it
            // before the flight or the card is briefly in two places.
            if (e.seat === 0 && played >= 0) {
              var live = handEl.querySelector('.pete-uno-card[data-at="' + played + '"]');
              if (live) live.style.visibility = "hidden";
              played = -1;
            }
            bump(e.seat, e.left);
            return throwCard(node, from, discardEl, { index: n })
              .then(function () {
                discardEl.innerHTML = "";
                var top = card(e.card);
                top.classList.add("pete-uno-top", "pete-uno-land");
                discardEl.appendChild(top);
                if (e.color) {
                  colourEl.textContent = e.color;
                  colourEl.dataset.c = e.color;
                  feltEl.dataset.c = e.color;
                }
                showHand(e.hand); // the card has landed; close the gap it left
                return wait(e.seat === 0 ? 120 : 300);
              });
          }

          case "draw":
          case "forced": {
            spotlight(e.seat);
            var to = seatAnchor(e.seat);
            var backs = [];
            for (var i = 0; i < Math.min(e.n, 4); i++) {
              backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90, sound: "deal" }));
            }
            var punished = e.kind === "forced";
            if (punished) FX.sfx("bad");
            // A forced draw is also how a stack ends: somebody stopped answering
            // and paid the bill, so the bill is gone.
            if (punished) { pending(0); badge(e.seat, "+" + e.n, "bad"); }
            return Promise.all(backs).then(function () {
              bump(e.seat, e.left);
              showHand(e.hand); // your cards, face up, as they land
              deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
              // Your own drawn card comes face up — it's yours, and the server
              // sent its face for exactly this.
              if (e.seat === 0 && e.card) return wait(160);
              return wait(punished ? 380 : 180);
            });
          }

          // ---- the call, and the catch ---------------------------------------
          //
          // Somebody went down to one card without saying so, and somebody else
          // noticed. Two cards off the deck, and the seat that took them wears the
          // name of whoever spotted it — because "caught" with nobody attached is
          // just a mystery.
          case "caught": {
            spotlight(e.seat);
            var mine = e.seat === 0;
            var caughtBy = (game && game.seats && game.seats[e.by]) || {};
            var whom = mine
              ? "Caught! " + (caughtBy.name || "They") + " saw it"
              : "Caught! +" + e.n;
            // Catching a bot is a pointed finger; being caught is a thud.
            FX.sfx(mine ? "bad" : "catch");
            var lands = [];
            var at = seatAnchor(e.seat);
            for (var k = 0; k < e.n; k++) {
              lands.push(throwCard(back(), deckEl, at, { index: k, delay: k * 100, sound: "deal" }));
            }
            badge(e.seat, whom, "bad");
            return Promise.all(lands).then(function () {
              bump(e.seat, e.left);
              showHand(e.hand);
              deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
              return wait(520);
            });
          }

          // You called a seat that had nothing to hide. Two cards, and they're
          // yours: the price of the catch button not being a thing you just mash.
          case "miscall": {
            FX.sfx("bad");
            var wrong = [];
            for (var w = 0; w < e.n; w++) {
              wrong.push(throwCard(back(), deckEl, handEl, { index: w, delay: w * 100, sound: "deal" }));
            }
            badge(0, "They called it. +" + e.n, "bad");
            return Promise.all(wrong).then(function () {
              showHand(e.hand);
              deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
              return wait(520);
            });
          }

          case "stack":
            // The bill has moved on to somebody, and N is what it stands at now —
            // not what was just added to it. A +2 answered with a +10 is a seat
            // looking at twelve cards.
            pending(e.n);
            spotlight(e.seat);
            FX.sfx("bad");
            return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });

          case "skipall":
            // The turn does not move: it comes straight back to the seat that
            // played it, which is the whole card.
            return badge(e.seat, "Everyone skipped").then(function () { return wait(240); });

          case "discard": {
            // A whole colour left a hand at once. If it was your hand, those cards
            // are on the table in front of you — so they go before the flight, or
            // they sit there looking like they were dumped twice.
            if (e.seat === 0 && e.color) {
              handEl.querySelectorAll('.pete-uno-card[data-c="' + e.color + '"]').forEach(function (el) {
                el.style.visibility = "hidden";
              });
            }
            bump(e.seat, e.left);
            var dumpFrom = seatAnchor(e.seat);
            var dumped = [];
            for (var d = 0; d < Math.min(e.n, 4); d++) {
              dumped.push(throwCard(back(), dumpFrom, discardEl, { index: d, delay: d * 70 }));
            }
            badge(e.seat, "−" + e.n + " " + (e.color || ""));
            return Promise.all(dumped).then(function () {
              showHand(e.hand);
              return wait(220);
            });
          }

          case "roulette": {
            // They turn cards over until your colour comes up, and they keep every
            // one of them. N is how deep it went.
            spotlight(e.seat);
            var spinTo = seatAnchor(e.seat);
            var spun = [];
            for (var r = 0; r < Math.min(e.n, 4); r++) {
              spun.push(throwCard(back(), deckEl, spinTo, { index: r, delay: r * 80 }));
            }
            badge(e.seat, "Roulette +" + e.n, "bad");
            return Promise.all(spun).then(function () {
              bump(e.seat, e.left);
              showHand(e.hand);
              deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
              return wait(400);
            });
          }

          case "mercy": {
            // Twenty-five cards, and they are out of the game for good. It is the
            // one beat in this script allowed to take its time.
            var grave = seatEl(e.seat);
            if (grave) grave.dataset.out = "1";
            bump(e.seat, 0);
            showHand(e.hand); // if it was you, the hand goes with you
            pending(0); // a dead seat pays no bill and leaves none behind
            FX.sfx("bad");
            return badge(e.seat, "Buried on " + e.n, "bad").then(function () { return wait(460); });
          }

          case "skip":
            return badge(e.seat, "Skipped", "bad").then(function () { return wait(80); });

          case "reverse":
            feltEl.dataset.rev = feltEl.dataset.rev === "1" ? "0" : "1";
            return badge(e.seat, "Reverse").then(function () { return wait(60); });

          case "uno":
            FX.sfx("uno");
            return badge(e.seat, "UNO!", "uno");

          case "reshuffle":
            // The discard has just gone back under, so the counter has to as
            // well: the draws that follow in this same script count down from it,
            // and counting down from a deck that still reads empty gets nowhere.
            deckCntEl.textContent = String(e.n);
            deckEl.classList.add("pete-uno-shuffle");
            FX.sfx("shuffle");
            return wait(420).then(function () {
              deckEl.classList.remove("pete-uno-shuffle");
            });

          case "pass":
            spotlight(e.seat);
            return wait(140);

          case "settle":
            return;
        }
      });
    });

    return chain.then(function () {
      if (!final) { paint(null); money(); return; }

      if (!settles) {
        paint(final);
        return;
      }

      // Over: the board settles, the money moves, and only then does the bar
      // catch up.
      renderSeats(final);
      renderPiles(final);
      renderHand(final);
      renderTurn(final);
      renderRail(final);
      verdict(final);
      playing.classList.add("hidden");
      return settleChips(final)
        .then(money)
        .then(function () { return standing(final.bet); })
        .then(function () { setPhase(final); });
    });
  }

  // ---- the money -------------------------------------------------------------

  function settleChips(final) {
    var payout = final.payout || 0;
    if (payout <= 0) return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });

    // Not `back`: that is the card back up there, and a number wearing its name
    // is a landmine for whoever next wants a face-down card in here.
    var profit = payout - final.bet;
    return spot
      .pour(houseEl, profit, { gap: 60 })
      .then(function () { return wait(profit > 0 ? 380 : 200); })
      .then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
  }

  // standing puts the stake back on the spot for the next game, the way every
  // other table in the room leaves your bet up. pour grows the pile from what it
  // is told is already there, so the amount is never set first — that bug printed
  // double the stake under trivia's chips for a day.
  function standing(amount) {
    var money = window.PeteGames.view();
    if (!amount || !money || money.chips < amount) {
      bet = 0;
      showBet();
      return;
    }
    bet = amount;
    showBet();
    return spot.pour(purseEl, amount);
  }

  // ---- moves ------------------------------------------------------------------

  function pick(i, c) {
    if (busy || !game || game.phase === "done" || game.turn !== 0) return;
    if (c.wild) { askColour(i); return; } // the colour first; the call comes after
    offer(i, 0);
  }

  // offer is the last thing between a card and the pile. If playing it leaves you
  // holding one card, the table wants a word out of you first.
  //
  // Which cards those are is the server'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 would let you
  // walk into a catch it never warned you about.
  function offer(i, color) {
    if (game && (game.uno_at || []).indexOf(i) >= 0) {
      openGate(i, color);
      return;
    }
    move({ kind: "play", index: i, color: color });
  }

  function openGate(i, color) {
    gate = { index: i, color: color };
    gateEl.classList.remove("hidden");
    if (timerEl) {
      // Restart the drain. The bar is a CSS animation and this element gets
      // reused, so without kicking the browser between the two assignments it
      // stays where the last one finished — a full bar that never moves, which
      // is worse than no bar at all.
      timerEl.style.animation = "none";
      void timerEl.offsetWidth;
      timerEl.style.animation = "";
      timerEl.style.animationDuration = CALL_MS + "ms";
    }
    if (callBtn) callBtn.focus();
    gateTimer = setTimeout(function () { shut(false); }, CALL_MS);
  }

  // shut plays the card the gate was holding, with or without the word. Letting
  // the clock run out is a real answer — it is the answer that gets you caught.
  function shut(called) {
    if (!gate) return;
    var g = gate;
    gate = null;
    clearTimeout(gateTimer);
    gateTimer = 0;
    gateEl.classList.add("hidden");
    move({ kind: "play", index: g.index, color: g.color, uno: called });
  }

  // hideGate drops the gate without playing anything. Only for the paths that tear
  // the board down under it (an error, a resume) — a gate that outlived its game
  // would play a card into the next one.
  function hideGate() {
    if (gateTimer) clearTimeout(gateTimer);
    gateTimer = 0;
    gate = null;
    if (gateEl) gateEl.classList.add("hidden");
  }

  if (callBtn) callBtn.addEventListener("click", function () { shut(true); });

  // Catching a seat that went quiet. It is not a turn — right or wrong, the turn
  // stays with you — but it is a move, so it goes through the same door as one.
  function catchSeat(seat) {
    if (busy || !game || game.phase === "done" || game.turn !== 0) return;
    if ((game.catchable || []).indexOf(seat) < 0) return;
    move({ kind: "catch", seat: seat });
  }

  function askColour(i) {
    pendingWild = i;
    wildEl.classList.remove("hidden");
  }

  function hideWild() {
    pendingWild = -1;
    if (wildEl) wildEl.classList.add("hidden");
  }

  // COLOURS maps the colour a player names to the number the engine calls it.
  // Wild is 0 there on purpose — a move with no colour on it must not quietly
  // mean red — so these start at 1 and this table is the only place that knows.
  var COLOURS = { red: 1, blue: 2, yellow: 3, green: 4 };

  root.querySelectorAll("[data-colour-pick]").forEach(function (b) {
    b.addEventListener("click", function () {
      if (busy || pendingWild < 0) return;
      var i = pendingWild;
      var c = COLOURS[b.dataset.colourPick];
      hideWild();
      offer(i, c); // named the colour; now, is it your last but one?
    });
  });

  var cancelWild = root.querySelector("[data-colour-cancel]");
  if (cancelWild) cancelWild.addEventListener("click", hideWild);

  function move(body) {
    played = body.kind === "play" ? body.index : -1;
    send("/api/games/uno/move", body, gameMsgEl);
  }

  if (drawBtn) drawBtn.addEventListener("click", function () { drawOne(); });
  if (deckEl) deckEl.addEventListener("click", function () { drawOne(); });
  if (passBtn) {
    passBtn.addEventListener("click", function () {
      if (busy || !game || game.turn !== 0 || game.phase !== "drawn") return;
      move({ kind: "pass" });
    });
  }

  function drawOne() {
    if (busy || !game || game.phase !== "play" || game.turn !== 0) return;
    move({ kind: "draw" });
  }

  // Taking the stack: you give in, you eat every card it has run up, and you lose
  // your turn. It is a move of its own, not a draw — see the engine's MoveTake.
  if (takeBtn) {
    takeBtn.addEventListener("click", function () {
      if (busy || !game || game.turn !== 0 || game.phase !== "stack") return;
      move({ kind: "take" });
    });
  }

  // ---- talking to the table ----------------------------------------------------

  // send takes the table away for the whole move — not just the request, but the
  // script that comes back with it. A lap of this table is seconds of animation,
  // and for every one of them the game on screen is a game the server has already
  // moved past. Clicking a card in there would send a move against a board that
  // no longer exists. So busy comes off at the end of the script, not the end of
  // the request, which is what the other tables do too.
  function send(path, body, where) {
    if (busy) return;
    busy = true;
    say("", null, where);
    controls();
    return window.PeteGames.post(path, body)
      .then(function (view) {
        return play(view, function () { window.PeteGames.apply(view); });
      })
      .catch(function (err) {
        say(err.message, "bad", where);
        return window.PeteGames.refresh().then(function (v) {
          if (v && v.uno) paint(v.uno);
          else { paint(null); spot.render(0); }
        });
      })
      .then(function () {
        busy = false;
        controls();
        showBet();
      });
  }

  // ---- betting ------------------------------------------------------------------

  function showBet() {
    betAmount.textContent = bet.toLocaleString();
    var money = window.PeteGames.view();
    if (startBtn) startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet;
  }

  function pickTier(slug) {
    tier = slug;
    root.querySelectorAll("[data-tier]").forEach(function (b) {
      b.dataset.on = b.dataset.tier === slug ? "1" : "0";
    });
    showBet();
  }

  root.querySelectorAll("[data-tier]").forEach(function (b) {
    b.addEventListener("click", function () {
      if (busy) return;
      pickTier(b.dataset.tier);
    });
  });

  // The rules switch. It is a different dial from the table size — the size is
  // what you're paid for, the deck is what you're playing with — so throwing it
  // swaps the three tables for the other three rather than becoming a fourth one.
  var DEFAULT_TABLE = { normal: "table", nomercy: "nm-table" };

  function pickRules(which, slug) {
    root.querySelectorAll("[data-rules]").forEach(function (b) {
      b.dataset.on = b.dataset.rules === which ? "1" : "0";
    });
    root.querySelectorAll("[data-grid]").forEach(function (g) {
      g.classList.toggle("hidden", g.dataset.grid !== which);
    });
    root.querySelectorAll("[data-note]").forEach(function (p) {
      p.classList.toggle("hidden", p.dataset.note !== which);
    });
    pickTier(slug || DEFAULT_TABLE[which]);
  }

  root.querySelectorAll("[data-rules]").forEach(function (b) {
    b.addEventListener("click", function () {
      if (busy) return;
      pickRules(b.dataset.rules);
    });
  });

  // A game that just ended leaves the betting panel on the table it was played at:
  // the commonest thing a player wants after a hand of No Mercy is another one.
  function rulesFor(v) {
    if (!v || !v.tier) return;
    pickRules(v.tier.no_mercy ? "nomercy" : "normal", v.tier.slug);
  }

  // The chip you click is the chip that flies. Scoped to buttons: the bare
  // [data-chip] spans in the rack are the house's, and the house is not betting.
  root.querySelectorAll("button[data-chip]").forEach(function (btn) {
    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("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
      });
    });
  });

  var clearBtn = root.querySelector("[data-bet-clear]");
  if (clearBtn) {
    clearBtn.addEventListener("click", function () {
      if (busy) return;
      if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
      bet = 0;
      showBet();
    });
  }

  if (startBtn) {
    startBtn.addEventListener("click", function () {
      if (busy) return;
      if (bet <= 0) { say("Put something on it first.", "bad"); return; }
      // The stake sits on the spot for the whole game: it is what's riding on you
      // going out first.
      send("/api/games/uno/start", { bet: bet, tier: tier });
    });
  }

  pickRules("normal");

  var resumed = false;
  window.PeteGames.onUpdate(function (v) {
    if (!resumed) {
      resumed = true;
      if (v.uno) {
        paint(v.uno);
        if (v.uno.phase === "done") verdict(v.uno);
      } else {
        paint(null);
      }
    }
    showBet();
  });
})();
