Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3149b48c14 | ||
|
|
cb057b5159 | ||
|
|
ab92259e3e |
@@ -127,6 +127,10 @@
|
|||||||
<span>Chips in play</span>
|
<span>Chips in play</span>
|
||||||
<span id="bankChipsInPlay"></span>
|
<span id="bankChipsInPlay"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bankReconcileRow" id="bankDiscardedRow" hidden>
|
||||||
|
<span>Taken off the table</span>
|
||||||
|
<span id="bankDiscarded"></span>
|
||||||
|
</div>
|
||||||
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ const ActionBar = {
|
|||||||
mount(state, hooks) {
|
mount(state, hooks) {
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.hooks = hooks;
|
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.watchHeight();
|
||||||
this.render();
|
this.render();
|
||||||
},
|
},
|
||||||
@@ -207,6 +212,14 @@ const ActionBar = {
|
|||||||
tankStartedAt: null,
|
tankStartedAt: null,
|
||||||
tankInterval: 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
|
// 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.
|
// (a chip edit, a name change) must not put a thinking player back to zero.
|
||||||
syncTankTimer() {
|
syncTankTimer() {
|
||||||
@@ -220,6 +233,8 @@ const ActionBar = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.tankSeatIndex !== index) {
|
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.tankSeatIndex = index;
|
||||||
this.tankStartedAt = Date.now();
|
this.tankStartedAt = Date.now();
|
||||||
}
|
}
|
||||||
@@ -243,13 +258,33 @@ const ActionBar = {
|
|||||||
const el = document.getElementById("actionTank");
|
const el = document.getElementById("actionTank");
|
||||||
if (!el || this.tankStartedAt === null) return;
|
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 seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
|
||||||
const mins = Math.floor(seconds / 60);
|
el.title = "How long this player has been thinking";
|
||||||
el.textContent = `${mins}:${String(seconds % 60).padStart(2, "0")}`;
|
el.textContent = this.formatTank(seconds);
|
||||||
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
|
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
|
||||||
el.classList.toggle("tankSlow", 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
|
// 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
|
// undivided pot needs no announcement, and a line that's always there stops being
|
||||||
// read.
|
// read.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const HandHistoryExport = {
|
|||||||
// text, so the one thing anybody opens this file to do — total a column and see
|
// 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
|
// 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.
|
// 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"]];
|
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
|
||||||
// handHistory is stored newest-first; the export reads better oldest-first.
|
// handHistory is stored newest-first; the export reads better oldest-first.
|
||||||
[...handHistory].reverse().forEach(entry => {
|
[...handHistory].reverse().forEach(entry => {
|
||||||
@@ -41,7 +41,12 @@ const HandHistoryExport = {
|
|||||||
rows.push(["Session Standings"]);
|
rows.push(["Session Standings"]);
|
||||||
// Bought in and chips now are what a cash-out is settled from; paid in and
|
// 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.
|
// 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 => {
|
standings.forEach(row => {
|
||||||
rows.push([
|
rows.push([
|
||||||
row.name,
|
row.name,
|
||||||
@@ -51,6 +56,7 @@ const HandHistoryExport = {
|
|||||||
Money.forSheet(row.boughtIn || 0),
|
Money.forSheet(row.boughtIn || 0),
|
||||||
Money.forSheet(row.chipsNow || 0),
|
Money.forSheet(row.chipsNow || 0),
|
||||||
Money.forSheet(row.cashPosition || 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");
|
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`;
|
return `holdem-hand-history-${stamp}.csv`;
|
||||||
},
|
},
|
||||||
|
|
||||||
download(handHistory, standings = []) {
|
download(handHistory, standings = [], options = {}) {
|
||||||
const csv = this.toCsv(handHistory, standings);
|
const csv = this.toCsv(handHistory, standings, options);
|
||||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
|
|||||||
@@ -387,6 +387,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
: "";
|
: "";
|
||||||
|
|
||||||
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
|
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
|
||||||
|
// Written down as well as announced. The toast is a moment; the bank check
|
||||||
|
// is asked at cash-out, and until now it saw only that the chips were gone
|
||||||
|
// and reported them as missing.
|
||||||
|
if (unresolved > Betting.EPSILON_CHIPS) {
|
||||||
|
state.discarded = Money.round((Number(state.discarded) || 0) + unresolved);
|
||||||
|
}
|
||||||
HoldemState.startNextHand(state);
|
HoldemState.startNextHand(state);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -580,7 +586,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
|
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
|
||||||
if ((state.handHistory || []).length === 0) return;
|
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
|
// The load happens on the first line of this file, long before there's a Toast to
|
||||||
|
|||||||
@@ -876,8 +876,13 @@ const Render = {
|
|||||||
note.textContent =
|
note.textContent =
|
||||||
`${Money.format(plan.shortBy)} of winnings has nothing to pay it — there are more chips on the table than cash was taken for. Fix that above before anyone cashes out.`;
|
`${Money.format(plan.shortBy)} of winnings has nothing to pay it — there are more chips on the table than cash was taken for. Fix that above before anyone cashes out.`;
|
||||||
} else {
|
} else {
|
||||||
note.textContent =
|
// If a pot was thrown away on purpose, that's where the gap went, and the
|
||||||
`${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
|
// dealer already answered a dialog about it. Telling them to go and fix it
|
||||||
|
// would be sending them after their own decision.
|
||||||
|
const discarded = Math.max(0, Number(this.state.discarded) || 0);
|
||||||
|
note.textContent = discarded > 0
|
||||||
|
? `${Money.format(plan.overBy)} is owed with nobody to pay it to. ${Money.format(discarded)} was taken off the table when a hand was abandoned, which accounts for ${discarded >= plan.overBy ? "it" : "some of it"}.`
|
||||||
|
: `${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
|
||||||
}
|
}
|
||||||
note.classList.toggle("settleUpOff", !plan.balanced);
|
note.classList.toggle("settleUpOff", !plan.balanced);
|
||||||
},
|
},
|
||||||
@@ -896,9 +901,22 @@ const Render = {
|
|||||||
document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn);
|
document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn);
|
||||||
document.getElementById("bankChipsInPlay").textContent = Money.format(bank.chipsInPlay);
|
document.getElementById("bankChipsInPlay").textContent = Money.format(bank.chipsInPlay);
|
||||||
|
|
||||||
|
// An abandoned pot the dealer confirmed throwing away is money that left the
|
||||||
|
// table on purpose. It gets its own line so the two columns still add up in
|
||||||
|
// front of them, rather than looking like a shortfall they have to explain.
|
||||||
|
const discardedRow = document.getElementById("bankDiscardedRow");
|
||||||
|
if (discardedRow) {
|
||||||
|
discardedRow.hidden = !(bank.discarded > 0);
|
||||||
|
if (bank.discarded > 0) {
|
||||||
|
document.getElementById("bankDiscarded").textContent = Money.format(bank.discarded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const verdict = document.getElementById("bankReconcileVerdict");
|
const verdict = document.getElementById("bankReconcileVerdict");
|
||||||
if (bank.balanced) {
|
if (bank.balanced) {
|
||||||
verdict.textContent = "Balanced — the cash covers every chip on the table.";
|
verdict.textContent = bank.discarded > 0
|
||||||
|
? `Balanced — the cash covers every chip on the table, once the ${Money.format(bank.discarded)} taken off it is counted.`
|
||||||
|
: "Balanced — the cash covers every chip on the table.";
|
||||||
} else if (bank.difference > 0) {
|
} else if (bank.difference > 0) {
|
||||||
verdict.textContent =
|
verdict.textContent =
|
||||||
`${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
|
`${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ const HoldemState = {
|
|||||||
notes: "",
|
notes: "",
|
||||||
handNumber: 1,
|
handNumber: 1,
|
||||||
handHistory: [],
|
handHistory: [],
|
||||||
|
// Chips taken off the table on purpose, so the bank check can tell them
|
||||||
|
// apart from chips that have gone missing.
|
||||||
|
discarded: 0,
|
||||||
smallBlind: 1,
|
smallBlind: 1,
|
||||||
bigBlind: 2,
|
bigBlind: 2,
|
||||||
defaultStack: null,
|
defaultStack: null,
|
||||||
@@ -146,6 +149,7 @@ const HoldemState = {
|
|||||||
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
|
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
|
||||||
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
||||||
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
|
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
|
||||||
|
if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
|
||||||
|
|
||||||
// Tables saved before live betting existed are missing its fields; fill
|
// Tables saved before live betting existed are missing its fields; fill
|
||||||
// them in rather than making the dealer reset a table mid-session.
|
// them in rather than making the dealer reset a table mid-session.
|
||||||
@@ -532,6 +536,14 @@ const HoldemState = {
|
|||||||
// window is almost always.
|
// window is almost always.
|
||||||
const inFrontOfThem = seat ? (Number(seat.amountOverride) || 0) + (Number(seat.committed) || 0) : 0;
|
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 {
|
return {
|
||||||
...t,
|
...t,
|
||||||
in: Money.round(t.in),
|
in: Money.round(t.in),
|
||||||
@@ -540,6 +552,7 @@ const HoldemState = {
|
|||||||
boughtIn,
|
boughtIn,
|
||||||
chipsNow,
|
chipsNow,
|
||||||
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
|
cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn),
|
||||||
|
notes,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.net - a.net);
|
.sort((a, b) => b.net - a.net);
|
||||||
@@ -581,10 +594,19 @@ const HoldemState = {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
const difference = Money.round(chipsInPlay - cashIn);
|
// Chips the dealer deliberately took off the table — an abandoned pot they
|
||||||
|
// were asked about and confirmed. They are gone, so they can't be counted as
|
||||||
|
// in play, but they aren't missing either: the box knows exactly where they
|
||||||
|
// went. Without this the check blamed the tool's own confirmed action on
|
||||||
|
// "someone has paid for chips they haven't been given", which sends a dealer
|
||||||
|
// hunting for a miscount that never happened.
|
||||||
|
const discarded = Math.max(0, Money.round(Number(state.discarded) || 0));
|
||||||
|
|
||||||
|
const difference = Money.round(chipsInPlay + discarded - cashIn);
|
||||||
return {
|
return {
|
||||||
cashIn: Money.round(cashIn),
|
cashIn: Money.round(cashIn),
|
||||||
chipsInPlay: Money.round(chipsInPlay),
|
chipsInPlay: Money.round(chipsInPlay),
|
||||||
|
discarded,
|
||||||
difference,
|
difference,
|
||||||
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
|
// Half a cent of float dust isn't a discrepancy worth alarming anyone over.
|
||||||
balanced: Math.abs(difference) <= Money.EPSILON,
|
balanced: Math.abs(difference) <= Money.EPSILON,
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Chips the dealer took off the table on purpose.
|
||||||
|
//
|
||||||
|
// Starting the next hand with money still in the middle destroys it — the tool asks
|
||||||
|
// first and says so afterwards. What it didn't do was remember, so the bank check saw
|
||||||
|
// only that chips were gone and reported them as missing.
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { HoldemState, Money, makeTable } = require("./load");
|
||||||
|
|
||||||
|
const table = () => {
|
||||||
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200 });
|
||||||
|
state.seats.slice(0, 4).forEach(player => { player.buyIns = 200; });
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("a table with nothing thrown away balances as before", () => {
|
||||||
|
const bank = HoldemState.bankReconciliation(table());
|
||||||
|
|
||||||
|
assert.equal(bank.balanced, true);
|
||||||
|
assert.equal(bank.discarded, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chips that went missing are still reported", () => {
|
||||||
|
// The check exists for this: cash in the box with no chips to match it.
|
||||||
|
const state = table();
|
||||||
|
state.seats[0].stack = 150;
|
||||||
|
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.balanced, false);
|
||||||
|
assert.equal(bank.difference, -50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an abandoned pot is accounted for rather than counted as missing", () => {
|
||||||
|
const state = table();
|
||||||
|
state.seats[1].stack = 199;
|
||||||
|
state.seats[2].stack = 198;
|
||||||
|
state.seats[3].stack = 198;
|
||||||
|
state.discarded = 5;
|
||||||
|
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.chipsInPlay, 795);
|
||||||
|
assert.equal(bank.discarded, 5);
|
||||||
|
assert.equal(bank.balanced, true, "the box knows exactly where that $5 went");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throwing a pot away doesn't hide a real shortfall underneath it", () => {
|
||||||
|
const state = table();
|
||||||
|
state.seats[1].stack = 199;
|
||||||
|
state.seats[2].stack = 198;
|
||||||
|
state.seats[3].stack = 148; // $50 genuinely unaccounted for as well
|
||||||
|
state.discarded = 5;
|
||||||
|
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(bank.balanced, false);
|
||||||
|
assert.equal(bank.difference, -50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a nonsense discarded value can't move the answer", () => {
|
||||||
|
const state = table();
|
||||||
|
state.discarded = -100;
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).discarded, 0);
|
||||||
|
|
||||||
|
state.discarded = "not a number";
|
||||||
|
assert.equal(HoldemState.bankReconciliation(state).discarded, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a table saved before this existed reads as nothing thrown away", () => {
|
||||||
|
const saved = JSON.stringify(table());
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
delete parsed.discarded;
|
||||||
|
|
||||||
|
const store = new Map([[HoldemState.STORAGE_KEY, JSON.stringify(parsed)]]);
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: key => (store.has(key) ? store.get(key) : null),
|
||||||
|
setItem: (key, value) => store.set(key, value),
|
||||||
|
removeItem: key => store.delete(key),
|
||||||
|
};
|
||||||
|
|
||||||
|
const loaded = HoldemState.load();
|
||||||
|
assert.equal(loaded.discarded, 0);
|
||||||
|
assert.equal(HoldemState.bankReconciliation(loaded).balanced, true);
|
||||||
|
delete globalThis.localStorage;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the amounts still add up the way the panel shows them", () => {
|
||||||
|
const state = table();
|
||||||
|
state.seats[1].stack = 199;
|
||||||
|
state.seats[2].stack = 198;
|
||||||
|
state.seats[3].stack = 198;
|
||||||
|
state.discarded = 5;
|
||||||
|
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
assert.equal(Money.round(bank.chipsInPlay + bank.discarded), bank.cashIn);
|
||||||
|
});
|
||||||
@@ -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`);
|
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 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 => {
|
lines.slice(standingsStart + 1).filter(Boolean).forEach(line => {
|
||||||
splitRow(line).slice(1).forEach(cell => {
|
const cells = splitRow(line);
|
||||||
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `standings cell "${cell}" isn't a number`);
|
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