Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb057b5159 | ||
|
|
ab92259e3e |
@@ -11,6 +11,11 @@ const ActionBar = {
|
||||
mount(state, hooks) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
// Whoever was already on the clock when this page loaded — see tankUnknownFor.
|
||||
this.tankUnknownFor = state.bettingOpen &&
|
||||
state.actingSeatIndex !== null && state.actingSeatIndex !== undefined
|
||||
? state.actingSeatIndex
|
||||
: null;
|
||||
this.watchHeight();
|
||||
this.render();
|
||||
},
|
||||
@@ -207,6 +212,14 @@ const ActionBar = {
|
||||
tankStartedAt: null,
|
||||
tankInterval: null,
|
||||
|
||||
// The seat that was already being asked to act when this page loaded, if there was
|
||||
// one. Its clock is unknowable: the action moved to them before this session
|
||||
// started — maybe two minutes ago, maybe last night — and starting from zero would
|
||||
// have the bar say they had just begun thinking. Which it did, every time a dealer
|
||||
// refreshed mid-hand. Cleared the moment the action moves, because from then on the
|
||||
// clock is measuring something it actually saw.
|
||||
tankUnknownFor: null,
|
||||
|
||||
// Restarts only when the action actually moves — a re-render for any other reason
|
||||
// (a chip edit, a name change) must not put a thinking player back to zero.
|
||||
syncTankTimer() {
|
||||
@@ -220,6 +233,8 @@ const ActionBar = {
|
||||
}
|
||||
|
||||
if (this.tankSeatIndex !== index) {
|
||||
// Once the action has moved under our own eyes, nothing is unknown any more.
|
||||
if (this.tankSeatIndex !== null) this.tankUnknownFor = null;
|
||||
this.tankSeatIndex = index;
|
||||
this.tankStartedAt = Date.now();
|
||||
}
|
||||
@@ -243,13 +258,33 @@ const ActionBar = {
|
||||
const el = document.getElementById("actionTank");
|
||||
if (!el || this.tankStartedAt === null) return;
|
||||
|
||||
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) {
|
||||
el.textContent = "—";
|
||||
el.title = "The action was already on this seat when the page loaded, so there's nothing to time from yet. The clock starts when the action next moves.";
|
||||
el.classList.remove("tankNudge", "tankSlow");
|
||||
return;
|
||||
}
|
||||
|
||||
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
|
||||
const mins = Math.floor(seconds / 60);
|
||||
el.textContent = `${mins}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
el.title = "How long this player has been thinking";
|
||||
el.textContent = this.formatTank(seconds);
|
||||
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
|
||||
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
|
||||
},
|
||||
|
||||
// "0:07", "4:01", and past the hour "1:05:03" rather than "65:03" — a table left
|
||||
// open on a hand does reach that, and sixty-five minutes written as though it were
|
||||
// a minutes-and-seconds reading is a number the eye gets wrong.
|
||||
formatTank(seconds) {
|
||||
const pad = value => String(value).padStart(2, "0");
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const rest = seconds % 60;
|
||||
return hours > 0
|
||||
? `${hours}:${pad(minutes)}:${pad(rest)}`
|
||||
: `${minutes}:${pad(rest)}`;
|
||||
},
|
||||
|
||||
// Shown only once someone is all-in and the money has actually split — a single
|
||||
// undivided pot needs no announcement, and a line that's always there stops being
|
||||
// read.
|
||||
|
||||
@@ -19,7 +19,7 @@ const HandHistoryExport = {
|
||||
// text, so the one thing anybody opens this file to do — total a column and see
|
||||
// who owes what — doesn't work without cleaning every cell first. The commas were
|
||||
// quoted correctly, so the file parsed; it just wouldn't add up.
|
||||
toCsv(handHistory, standings = []) {
|
||||
toCsv(handHistory, standings = [], { notes = "" } = {}) {
|
||||
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
|
||||
// handHistory is stored newest-first; the export reads better oldest-first.
|
||||
[...handHistory].reverse().forEach(entry => {
|
||||
@@ -41,7 +41,12 @@ const HandHistoryExport = {
|
||||
rows.push(["Session Standings"]);
|
||||
// Bought in and chips now are what a cash-out is settled from; paid in and
|
||||
// won describe the hands. Both belong in the file the dealer hands over.
|
||||
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]);
|
||||
//
|
||||
// Notes go last so the money columns keep the shape a spreadsheet expects.
|
||||
// "IOU: $20 to Ann" is exactly the kind of thing that has to be in the file
|
||||
// somebody settles up from, and it was only ever visible inside that seat's
|
||||
// own editor.
|
||||
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]);
|
||||
standings.forEach(row => {
|
||||
rows.push([
|
||||
row.name,
|
||||
@@ -51,6 +56,7 @@ const HandHistoryExport = {
|
||||
Money.forSheet(row.boughtIn || 0),
|
||||
Money.forSheet(row.chipsNow || 0),
|
||||
Money.forSheet(row.cashPosition || 0),
|
||||
row.notes || "",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -76,6 +82,17 @@ const HandHistoryExport = {
|
||||
}
|
||||
}
|
||||
|
||||
// The table's own notes — side bets, IOUs, the promo running until midnight.
|
||||
// The dealer typed them because they matter to the night, and the export is the
|
||||
// thing that outlives the browser tab; leaving them behind meant the one place
|
||||
// the context was written down was the one place it couldn't be handed over.
|
||||
// csvField quotes the line breaks, so a multi-line note survives as one cell.
|
||||
if (notes && notes.trim()) {
|
||||
rows.push([]);
|
||||
rows.push(["Table Notes"]);
|
||||
rows.push([notes.trim()]);
|
||||
}
|
||||
|
||||
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
|
||||
},
|
||||
|
||||
@@ -86,8 +103,8 @@ const HandHistoryExport = {
|
||||
return `holdem-hand-history-${stamp}.csv`;
|
||||
},
|
||||
|
||||
download(handHistory, standings = []) {
|
||||
const csv = this.toCsv(handHistory, standings);
|
||||
download(handHistory, standings = [], options = {}) {
|
||||
const csv = this.toCsv(handHistory, standings, options);
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
|
||||
@@ -580,7 +580,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
|
||||
if ((state.handHistory || []).length === 0) return;
|
||||
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
|
||||
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state),
|
||||
{ notes: state.notes });
|
||||
});
|
||||
|
||||
// The load happens on the first line of this file, long before there's a Toast to
|
||||
|
||||
@@ -532,6 +532,14 @@ const HoldemState = {
|
||||
// window is almost always.
|
||||
const inFrontOfThem = seat ? (Number(seat.amountOverride) || 0) + (Number(seat.committed) || 0) : 0;
|
||||
|
||||
// Whatever the dealer wrote against this seat — "IOU: $20 to Ann",
|
||||
// "Venmo: @ann". It's carried here so the export can hand it over with
|
||||
// the money it belongs to; the standings panel itself doesn't show it.
|
||||
const notes = (seat && Array.isArray(seat.customFields) ? seat.customFields : [])
|
||||
.filter(field => (field.label || "").trim() || (field.value || "").trim())
|
||||
.map(field => `${(field.label || "").trim()}: ${(field.value || "").trim()}`.replace(/^: | :$/, ""))
|
||||
.join("; ");
|
||||
|
||||
return {
|
||||
...t,
|
||||
in: Money.round(t.in),
|
||||
@@ -540,6 +548,7 @@ const HoldemState = {
|
||||
boughtIn,
|
||||
chipsNow,
|
||||
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
|
||||
notes,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.net - a.net);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// The things the dealer wrote down, in the file they hand over.
|
||||
//
|
||||
// The export already carried the hands, the standings and the settle-up. It didn't
|
||||
// carry either of the two places a dealer types context — the per-seat fields and the
|
||||
// table's own notes — so the one place an IOU was written down was the one place it
|
||||
// couldn't leave.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HandHistoryExport, HoldemState, makeTable } = require("./load");
|
||||
|
||||
const tableWithNotes = () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 });
|
||||
["Ann", "Ben", "Cal"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].buyIns = 200;
|
||||
});
|
||||
state.seats[0].customFields = [
|
||||
{ label: "Seat rent", value: "paid to 2am" },
|
||||
{ label: "Venmo", value: "@ann" },
|
||||
];
|
||||
state.seats[1].customFields = [{ label: "IOU", value: "$20 to Ann" }];
|
||||
return state;
|
||||
};
|
||||
|
||||
test("a seat's own fields ride along with its standings row", () => {
|
||||
const state = tableWithNotes();
|
||||
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
|
||||
|
||||
assert.match(csv, /Seat rent: paid to 2am; Venmo: @ann/);
|
||||
assert.match(csv, /IOU: \$20 to Ann/);
|
||||
});
|
||||
|
||||
test("the money columns keep their order, with notes on the end", () => {
|
||||
const state = tableWithNotes();
|
||||
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
|
||||
|
||||
assert.match(csv, /Player,Paid In,Won,Net,Bought In,Chips Now,Up\/Down,Notes/);
|
||||
});
|
||||
|
||||
test("a seat with nothing written against it gets an empty cell, not the word undefined", () => {
|
||||
const state = tableWithNotes();
|
||||
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
|
||||
const calRow = csv.split("\r\n").find(line => line.startsWith("Cal,"));
|
||||
|
||||
assert.ok(calRow, "Cal should have a row");
|
||||
assert.ok(calRow.endsWith(","), `expected an empty last cell, got ${calRow}`);
|
||||
assert.ok(!/undefined|null/.test(calRow));
|
||||
});
|
||||
|
||||
test("a blank label or value doesn't produce a stray colon", () => {
|
||||
const state = tableWithNotes();
|
||||
state.seats[2].customFields = [{ label: "Note", value: "" }, { label: "", value: "" }];
|
||||
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state));
|
||||
const calRow = csv.split("\r\n").find(line => line.startsWith("Cal,"));
|
||||
|
||||
assert.match(calRow, /Note/);
|
||||
assert.ok(!/;\s*;/.test(calRow), "an entirely blank field shouldn't take a slot");
|
||||
});
|
||||
|
||||
test("the table's notes are in the file", () => {
|
||||
const state = tableWithNotes();
|
||||
state.notes = "High-hand promo until midnight.";
|
||||
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes: state.notes });
|
||||
|
||||
assert.match(csv, /Table Notes/);
|
||||
assert.match(csv, /High-hand promo until midnight\./);
|
||||
});
|
||||
|
||||
test("a multi-line note survives as one cell", () => {
|
||||
// csvField quotes anything with a line break in it, so the note doesn't turn into
|
||||
// rows a spreadsheet reads as more players.
|
||||
const state = tableWithNotes();
|
||||
const notes = "Line one\nLine two, with a comma\nLine \"three\"";
|
||||
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes });
|
||||
|
||||
assert.match(csv, /"Line one\nLine two, with a comma\nLine ""three"""/);
|
||||
});
|
||||
|
||||
test("no notes means no section, not an empty heading", () => {
|
||||
const state = tableWithNotes();
|
||||
const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes: " " });
|
||||
|
||||
assert.ok(!/Table Notes/.test(csv));
|
||||
});
|
||||
|
||||
test("the export still works with no standings at all", () => {
|
||||
assert.doesNotThrow(() => HandHistoryExport.toCsv([], [], { notes: "just a note" }));
|
||||
const csv = HandHistoryExport.toCsv([], [], { notes: "just a note" });
|
||||
assert.match(csv, /Table Notes/);
|
||||
});
|
||||
|
||||
test("asking for the standings doesn't change the seats", () => {
|
||||
const state = tableWithNotes();
|
||||
const before = JSON.stringify(state.seats);
|
||||
HoldemState.sessionStandings(state);
|
||||
assert.equal(JSON.stringify(state.seats), before);
|
||||
});
|
||||
@@ -96,11 +96,26 @@ describe("the exported CSV", () => {
|
||||
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `pot cell "${cell}" isn't a number`);
|
||||
});
|
||||
|
||||
// Standings rows: every column after the name has to be numeric.
|
||||
// Standings rows: the money columns have to be numeric. Named rather than
|
||||
// "everything after the first", which is what this used to say — the row now
|
||||
// ends with a Notes column carrying whatever the dealer typed against the seat,
|
||||
// and that is text by definition. The point of this test is that a column
|
||||
// somebody totals adds up, so it checks the columns somebody totals.
|
||||
const standingsStart = lines.findIndex(l => l.startsWith("Player,"));
|
||||
const header = splitRow(lines[standingsStart]);
|
||||
const moneyColumns = ["Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]
|
||||
.map(name => {
|
||||
const index = header.indexOf(name);
|
||||
assert.ok(index > 0, `the standings header should still have a ${name} column`);
|
||||
return index;
|
||||
});
|
||||
|
||||
lines.slice(standingsStart + 1).filter(Boolean).forEach(line => {
|
||||
splitRow(line).slice(1).forEach(cell => {
|
||||
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `standings cell "${cell}" isn't a number`);
|
||||
const cells = splitRow(line);
|
||||
moneyColumns.forEach(index => {
|
||||
const cell = cells[index];
|
||||
assert.ok(cell !== "" && !Number.isNaN(Number(cell)),
|
||||
`standings cell "${cell}" in column ${header[index]} isn't a number`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// How the think-clock reads.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const path = require("path");
|
||||
|
||||
const ActionBar = require(path.join(
|
||||
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"));
|
||||
|
||||
test("seconds under a minute", () => {
|
||||
assert.equal(ActionBar.formatTank(0), "0:00");
|
||||
assert.equal(ActionBar.formatTank(7), "0:07");
|
||||
assert.equal(ActionBar.formatTank(59), "0:59");
|
||||
});
|
||||
|
||||
test("minutes and seconds", () => {
|
||||
assert.equal(ActionBar.formatTank(60), "1:00");
|
||||
assert.equal(ActionBar.formatTank(241), "4:01");
|
||||
assert.equal(ActionBar.formatTank(3599), "59:59");
|
||||
});
|
||||
|
||||
test("past the hour it says the hour", () => {
|
||||
// A table left open on a hand reaches this, and "65:03" is a number the eye reads
|
||||
// as minutes and seconds when it isn't.
|
||||
assert.equal(ActionBar.formatTank(3600), "1:00:00");
|
||||
assert.equal(ActionBar.formatTank(3903), "1:05:03");
|
||||
assert.equal(ActionBar.formatTank(45296), "12:34:56");
|
||||
});
|
||||
|
||||
test("the minutes are padded once there are hours, and not before", () => {
|
||||
// "1:5:03" would be wrong; "05:03" on its own would be odd.
|
||||
assert.equal(ActionBar.formatTank(3903), "1:05:03");
|
||||
assert.equal(ActionBar.formatTank(303), "5:03");
|
||||
});
|
||||
|
||||
test("a day is still a readable number rather than a wrapped one", () => {
|
||||
assert.equal(ActionBar.formatTank(90000), "25:00:00");
|
||||
});
|
||||
Reference in New Issue
Block a user