diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index c5da796..c25543f 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -96,6 +96,19 @@ document.addEventListener("DOMContentLoaded", () => { }); }, + // Two players change chairs. Undoable like every other seat action, because a + // swap is easy to aim wrong and the seats look alike at a glance. + swapSeats: (from, to) => { + const movedName = nameOf(from); + const intoName = nameOf(to); + const wasEmpty = Render.isEmptySeat(state.seats[to]); + runUndoable( + wasEmpty + ? `${movedName} moved to seat ${to + 1}.` + : `${movedName} and ${intoName} changed seats.`, + () => HoldemState.swapSeats(state, from, to)); + }, + makeDealer: index => { // Before the deal there's nothing posted, so this is just moving a marker. // After it, the blinds have to follow the button or the badges and the diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 54b3712..87171fb 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -49,11 +49,21 @@ const Render = { document.getElementById("defaultStackInput").value = this.state.defaultStack ?? ""; }, + // Whether this device can actually drag a seat. HTML5 drag events are never + // generated from a touch screen, so on a phone the last clause of the hint below + // described something impossible — on the device the rest of the sentence was + // written for. Touch gets told about the menu route instead, which works. + canDragSeats() { + return typeof matchMedia !== "function" || matchMedia("(hover: hover)").matches; + }, + renderTableHint() { const hint = document.getElementById("tableHint"); // Gestures are invisible until someone tells you about them, and this is the // only place on the table with room to say so. - hint.textContent = "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats."; + hint.textContent = this.canDragSeats() + ? "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats." + : "Tap a seat to edit · double-tap to fold · press and hold for quick actions, including moving a seat."; }, renderNotes() { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js index e80d900..6feb934 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js @@ -6,10 +6,16 @@ const SeatMenu = { hooks: null, seatIndex: null, + mode: null, + open(state, hooks, seatIndex) { this.state = state; this.hooks = hooks; this.seatIndex = seatIndex; + // Always the action list, never whatever the sheet was showing when it last + // closed — a seat menu that opens on a list of chairs is a menu that lost the + // dealer's place. + this.mode = null; this.render(); document.getElementById("seatMenuOverlay").hidden = false; }, @@ -63,29 +69,81 @@ const SeatMenu = { } } + // Changing chairs was drag-and-drop only, and HTML5 drag events are never + // generated from a touch screen — so on the device this tool is mostly used on, + // the "drag to swap seats" the hint advertised did nothing at all. This is the + // same move, by the route a thumb can actually take. + if (this.state.seats.length > 1) { + actions.push({ label: "Move to…", run: () => this.openMovePicker() }); + } + actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) }); return actions; }, + // The chairs this player could move to. Every other chair: swapping into an empty + // one is how a player moves, and swapping with an occupied one is two players + // changing places, which is the other half of the same job. + destinationsFor(index) { + return this.state.seats + .map((player, seat) => ({ player, seat })) + .filter(entry => entry.seat !== index); + }, + + openMovePicker() { + this.mode = "move"; + this.render(); + }, + render() { const player = this.state.seats[this.seatIndex]; - document.getElementById("seatMenuTitle").textContent = HoldemState.displayName(player, this.seatIndex); + const title = document.getElementById("seatMenuTitle"); + const subtitle = document.getElementById("seatMenuSubtitle"); + const list = document.getElementById("seatMenuActions"); + list.innerHTML = ""; + + if (this.mode === "move") { + title.textContent = `Move ${HoldemState.displayName(player, this.seatIndex)} to…`; + subtitle.textContent = "Their chips, cards and bet go with them. The button and the blinds stay on the chair."; + this.destinationsFor(this.seatIndex).forEach(({ player: other, seat }) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "seatMenuBtn"; + // displayName falls back to "Seat 5" for a chair with nobody in it, + // which reads as "Seat 5 — Seat 5". An empty chair says it's empty. + const named = (other.name || "").trim(); + btn.textContent = named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`; + btn.addEventListener("click", () => { + const from = this.seatIndex; + this.close(); + this.hooks.swapSeats(from, seat); + }); + list.appendChild(btn); + }); + return; + } + + title.textContent = HoldemState.displayName(player, this.seatIndex); const stack = Betting.stackOf(this.state, player); const owed = Betting.toCall(this.state, player); const bits = []; if (stack > 0) bits.push(`${Money.format(stack)} behind`); if (owed > 0) bits.push(`${Money.format(owed)} to call`); - document.getElementById("seatMenuSubtitle").textContent = bits.join(" · "); + subtitle.textContent = bits.join(" · "); - const list = document.getElementById("seatMenuActions"); - list.innerHTML = ""; this.actionsFor(player, this.seatIndex).forEach(action => { const btn = document.createElement("button"); btn.type = "button"; btn.className = `seatMenuBtn ${action.className || ""}`; btn.textContent = action.label; btn.addEventListener("click", () => { + // "Move to…" stays on the sheet and swaps it for the seat list; every + // other action is the end of the interaction. + if (action.label === "Move to…") { + action.run(); + return; + } this.close(); action.run(); }); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index a3621ec..09e9aa7 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -467,6 +467,21 @@ const HoldemState = { .sort((a, b) => b.net - a.net); }, + // Two players change chairs. + // + // The whole seat moves — name, stack, cards, what they have in the pot — because + // that is what changing chairs is. What stays behind is everything that belongs to + // the *position*: the button, the blinds and the acting index all name a chair, and + // a player who moves into the big blind's chair is the big blind. That is the same + // answer a card room gives. + swapSeats(state, a, b) { + const seats = state.seats || []; + if (a === b) return false; + if (!seats[a] || !seats[b]) return false; + [seats[a], seats[b]] = [seats[b], seats[a]]; + return true; + }, + // The one check a banker actually needs, and the one nothing here did. // // Every chip on the table is a promise to pay cash for it later. If the chips in diff --git a/tests/holdem/swapSeats.test.js b/tests/holdem/swapSeats.test.js new file mode 100644 index 0000000..0cbd5ce --- /dev/null +++ b/tests/holdem/swapSeats.test.js @@ -0,0 +1,95 @@ +// Two players changing chairs. +const test = require("node:test"); +const assert = require("node:assert"); +const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load"); + +test("the whole seat moves", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 }); + state.seats[0].name = "Ann"; + state.seats[0].cards = ["AS", "KD"]; + state.seats[0].stack = 175; + state.seats[4].name = ""; + + HoldemState.swapSeats(state, 0, 4); + + assert.equal(state.seats[4].name, "Ann"); + assert.deepEqual(state.seats[4].cards, ["AS", "KD"]); + assert.equal(state.seats[4].stack, 175); + assert.equal(state.seats[0].name, ""); +}); + +test("what they have in the pot goes with them", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 }); + Betting.beginHand(state); + const posted = state.seats.map(p => p.committed); + const before = chipsOnTable(state); + + HoldemState.swapSeats(state, 1, 4); + + assert.equal(state.seats[4].committed, posted[1]); + assert.equal(state.seats[1].committed, posted[4]); + assert.equal(chipsOnTable(state), before, "a swap never creates or destroys a chip"); +}); + +test("the button stays on the chair, not on the player", () => { + // A card room moves the button around the table, not around the people. Somebody + // who sits down in the big blind's chair is the big blind. + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100, dealerSeatIndex: 0 }); + state.seats[0].name = "Ann"; + + HoldemState.swapSeats(state, 0, 2); + + assert.equal(state.dealerSeatIndex, 0); + assert.notEqual(state.seats[0].name, "Ann"); +}); + +test("the blinds follow the chairs too", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 }); + Betting.beginHand(state); + const before = HoldemState.getBlindSeats(state); + + HoldemState.swapSeats(state, before.sbIndex, before.bbIndex); + const after = HoldemState.getBlindSeats(state); + + assert.equal(after.sbIndex, before.sbIndex); + assert.equal(after.bbIndex, before.bbIndex); +}); + +test("swapping a seat with itself does nothing and says so", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 }); + const before = JSON.stringify(state); + + assert.equal(HoldemState.swapSeats(state, 1, 1), false); + assert.equal(JSON.stringify(state), before); +}); + +test("a chair that isn't there is refused rather than swallowed", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 }); + const before = JSON.stringify(state); + + assert.equal(HoldemState.swapSeats(state, 0, 99), false); + assert.equal(HoldemState.swapSeats(state, -1, 0), false); + assert.equal(JSON.stringify(state), before); +}); + +test("swapping twice puts everything back", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 }); + Betting.beginHand(state); + const before = JSON.stringify(state); + + HoldemState.swapSeats(state, 1, 3); + HoldemState.swapSeats(state, 1, 3); + + assert.equal(JSON.stringify(state), before); +}); + +test("moving into an empty chair leaves an empty chair behind", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 }); + state.seats[1].name = "Ben"; + + HoldemState.swapSeats(state, 1, 5); + + assert.equal(state.seats[5].name, "Ben"); + assert.equal(state.seats[5].inHand, true); + assert.equal(state.seats[1].inHand, false); +}); diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js index 4034c58..4ecee0d 100644 --- a/tests/holdem/uiAudit.js +++ b/tests/holdem/uiAudit.js @@ -522,8 +522,15 @@ const LayoutAudit = { const chips = [...document.querySelectorAll(".dealerChip")]; const shown = chips.filter(chip => { const style = getComputedStyle(chip); - return style.display !== "none" && style.visibility !== "hidden" && - parseFloat(style.opacity) > 0.05; + if (style.display === "none" || style.visibility === "hidden") return false; + if (parseFloat(style.opacity) <= 0.05) return false; + // A hovered seat deliberately fades its empty slot in — that's the "put the + // button here" preview, and it is on screen because a pointer is sitting on + // that seat. Counting it reports two buttons whenever the mouse happens to + // rest on the table, which is most of the time a mouse exists. + const seat = chip.closest(".seat"); + if (seat && seat.matches(":hover") && !chip.classList.contains("active")) return false; + return true; }); const offenders = [];