Hold'em dealer: 55 polish rounds, one merge #101
@@ -155,6 +155,17 @@
|
||||
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
|
||||
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
|
||||
</div>
|
||||
|
||||
<h3 class="panelSubhead">The table itself</h3>
|
||||
<p class="fieldHint">
|
||||
The night lives in this browser and nowhere else. Save it to a file to carry it
|
||||
to another device, or to have something to come back to if this one dies.
|
||||
</p>
|
||||
<div class="handHistoryActions">
|
||||
<button id="saveTableBtn" class="holdemBtn holdemBtnSmall">Save table file</button>
|
||||
<button id="loadTableBtn" class="holdemBtn holdemBtnSmall">Load table file</button>
|
||||
<input id="loadTableInput" type="file" accept="application/json,.json" hidden />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
|
||||
@@ -598,6 +598,72 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
Toast.show("Hand history cleared.", { duration: 2500 });
|
||||
});
|
||||
|
||||
document.getElementById("saveTableBtn").addEventListener("click", () => {
|
||||
const blob = new Blob([HoldemState.toBackup(state)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = HoldemState.backupFileName();
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
Toast.show(`Table saved as ${link.download}.`, { duration: 3500 });
|
||||
});
|
||||
|
||||
document.getElementById("loadTableBtn").addEventListener("click", () => {
|
||||
document.getElementById("loadTableInput").click();
|
||||
});
|
||||
|
||||
document.getElementById("loadTableInput").addEventListener("change", async e => {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
// Cleared straight away so picking the same file twice still fires a change.
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
|
||||
let text;
|
||||
try {
|
||||
text = await file.text();
|
||||
} catch {
|
||||
Toast.show("That file couldn't be read.", { duration: 4000 });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = HoldemState.fromBackup(text);
|
||||
if (result.error) {
|
||||
Toast.show(result.error, { duration: 5000 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Loading replaces everything: stacks, buy-ins, the button, the log. Naming
|
||||
// what is about to be lost is the difference between a dealer choosing that
|
||||
// and discovering it — and the current table might be the live one.
|
||||
const here = HoldemState.sessionStandings(state).length;
|
||||
const incoming = HoldemState.sessionStandings(result.table).length;
|
||||
const when = result.savedAt ? new Date(result.savedAt) : null;
|
||||
const saidWhen = when && !Number.isNaN(when.getTime())
|
||||
? ` It was saved ${when.toLocaleString()}.`
|
||||
: "";
|
||||
const ok = await Dialog.confirm({
|
||||
title: "Replace this table?",
|
||||
message:
|
||||
`The file has a table with ${incoming} ${incoming === 1 ? "player" : "players"} in it.${saidWhen} ` +
|
||||
`Loading it replaces the table on screen — ${here} ${here === 1 ? "player" : "players"}, ` +
|
||||
`${(state.handHistory || []).length} logged ${(state.handHistory || []).length === 1 ? "hand" : "hands"}. ` +
|
||||
`Undo will bring it back for a few seconds; after that it is gone.`,
|
||||
confirmLabel: "Load it",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
runUndoable("loading a saved table", () => {
|
||||
// Replace the contents rather than the reference: everything on the page
|
||||
// holds a pointer to this object.
|
||||
Object.keys(state).forEach(key => { delete state[key]; });
|
||||
Object.assign(state, result.table);
|
||||
});
|
||||
});
|
||||
|
||||
// The filter is how the dealer looks at the log, not something about the table, so
|
||||
// it lives on the view: it isn't saved, isn't undoable, and doesn't reach the other
|
||||
// tab. Clearing the history clears it too, since there is nothing left to filter.
|
||||
|
||||
@@ -164,13 +164,18 @@ const HoldemState = {
|
||||
return this.createDefault();
|
||||
},
|
||||
|
||||
load() {
|
||||
let raw = null;
|
||||
try {
|
||||
raw = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (!raw) return this.createDefault();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
|
||||
// Everything a table has to be put through before it can be trusted, whichever
|
||||
// direction it arrived from.
|
||||
//
|
||||
// Pulled out of load() so a table read from a file goes through exactly the same
|
||||
// checks as one read from storage. A second, looser path for imports is how a
|
||||
// format grows two meanings of the same field.
|
||||
//
|
||||
// Returns null for anything that isn't a table at all; every other repair is
|
||||
// silent and forward, the way a save from an older version is treated.
|
||||
migrate(parsed) {
|
||||
if (!parsed || !Array.isArray(parsed.seats)) return null;
|
||||
{
|
||||
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
||||
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
|
||||
if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
|
||||
@@ -215,12 +220,85 @@ const HoldemState = {
|
||||
});
|
||||
|
||||
return parsed;
|
||||
}
|
||||
},
|
||||
|
||||
load() {
|
||||
let raw = null;
|
||||
try {
|
||||
raw = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (!raw) return this.createDefault();
|
||||
const migrated = this.migrate(JSON.parse(raw));
|
||||
if (!migrated) return this.failedLoad(raw);
|
||||
return migrated;
|
||||
} catch {
|
||||
// Malformed JSON — a truncated write, another tab, a browser hiccup.
|
||||
return this.failedLoad(raw);
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Taking the table off this device ------------------------------------
|
||||
//
|
||||
// A night lives in one browser's localStorage and nowhere else. A dealer whose
|
||||
// phone dies, or who wants to carry on from the tablet after the break, loses the
|
||||
// stacks, the buy-ins, where the button is and the whole hand log — and the CSV
|
||||
// export can't bring any of that back, because it records what happened rather
|
||||
// than where the table stands.
|
||||
//
|
||||
// So the table can be written out as a file and read back in. Same shape that goes
|
||||
// into storage, wrapped in enough to recognise it later.
|
||||
BACKUP_KIND: "casino.holdem.table",
|
||||
BACKUP_VERSION: 1,
|
||||
|
||||
toBackup(state) {
|
||||
return JSON.stringify({
|
||||
kind: this.BACKUP_KIND,
|
||||
version: this.BACKUP_VERSION,
|
||||
savedAt: new Date().toISOString(),
|
||||
table: state,
|
||||
}, null, 2);
|
||||
},
|
||||
|
||||
// Reads a file back. Returns { table } on success, or { error } with something a
|
||||
// dealer can act on — never a half-loaded table, because the failure mode there is
|
||||
// a night that looks restored and isn't.
|
||||
fromBackup(text) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return { error: "That file isn't a saved table — it couldn't be read as JSON." };
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { error: "That file isn't a saved table." };
|
||||
}
|
||||
// A bare table, from someone who copied the storage value out by hand, is
|
||||
// still a table. The envelope is a convenience, not a password.
|
||||
const table = parsed.kind === this.BACKUP_KIND || parsed.table ? parsed.table : parsed;
|
||||
if (parsed.kind && parsed.kind !== this.BACKUP_KIND) {
|
||||
return { error: "That file is a save from something else, not a Hold'em table." };
|
||||
}
|
||||
const migrated = this.migrate(table);
|
||||
if (!migrated) {
|
||||
return { error: "That file doesn't have a table in it — no seats were found." };
|
||||
}
|
||||
if (parsed.version > this.BACKUP_VERSION) {
|
||||
// Newer than this build understands. Loading it anyway risks reading a
|
||||
// field that has changed meaning, and the dealer would find out from the
|
||||
// money, so say so instead.
|
||||
return { error: "That table was saved by a newer version of this tool. Update, then load it." };
|
||||
}
|
||||
return { table: migrated, savedAt: parsed.savedAt || null };
|
||||
},
|
||||
|
||||
// What the file is called. Dated, because a dealer who saves twice in a night
|
||||
// should not have to guess which is which.
|
||||
backupFileName(now = new Date()) {
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
return `holdem-table-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
|
||||
`-${pad(now.getHours())}${pad(now.getMinutes())}.json`;
|
||||
},
|
||||
|
||||
// True once a save has failed. The UI reads this to keep saying so, because a
|
||||
// toast is a moment and this is a fact about the rest of the night.
|
||||
saveFailed: false,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// Taking the table off this device.
|
||||
//
|
||||
// A night lives in one browser's localStorage and nowhere else. A dealer whose phone
|
||||
// dies, or who wants to carry on from the tablet after the break, loses the stacks,
|
||||
// the buy-ins, where the button is and the whole hand log — and the CSV export can't
|
||||
// bring any of that back, because it records what happened rather than where the
|
||||
// table stands.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Betting, makeTable } = require("./load");
|
||||
|
||||
const aNight = () => {
|
||||
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 1 });
|
||||
["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].buyIns = 200;
|
||||
});
|
||||
state.seats[2].stack = 340;
|
||||
state.notes = "Cal owes the bar $12";
|
||||
Betting.beginHand(state);
|
||||
HoldemState.recordHand(state, [{ id: state.seats[2].id, label: "Cal", amount: 60 }]);
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("a table written out and read back", () => {
|
||||
test("comes back the same table", () => {
|
||||
const before = aNight();
|
||||
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
|
||||
|
||||
assert.deepEqual(after.seats.map(p => p.name), before.seats.map(p => p.name));
|
||||
assert.deepEqual(after.seats.map(p => p.stack), before.seats.map(p => p.stack));
|
||||
assert.deepEqual(after.seats.map(p => p.buyIns), before.seats.map(p => p.buyIns));
|
||||
assert.equal(after.dealerSeatIndex, before.dealerSeatIndex);
|
||||
assert.equal(after.handNumber, before.handNumber);
|
||||
assert.equal(after.notes, before.notes);
|
||||
assert.equal(after.handHistory.length, before.handHistory.length);
|
||||
});
|
||||
|
||||
test("keeps the things a CSV can't carry", () => {
|
||||
// The whole reason this exists: an export records what happened, not where the
|
||||
// table stands.
|
||||
const before = aNight();
|
||||
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
|
||||
|
||||
assert.equal(after.bettingOpen, before.bettingOpen, "a hand in progress is still in progress");
|
||||
assert.deepEqual(after.postedBlinds, before.postedBlinds, "so the next rotation is right");
|
||||
assert.equal(after.street, before.street);
|
||||
assert.deepEqual(after.seats.map(p => p.committed), before.seats.map(p => p.committed));
|
||||
assert.deepEqual(after.chipLegend, before.chipLegend);
|
||||
});
|
||||
|
||||
test("the standings and the bank check agree across the trip", () => {
|
||||
const before = aNight();
|
||||
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
|
||||
|
||||
assert.deepEqual(HoldemState.sessionStandings(after), HoldemState.sessionStandings(before));
|
||||
assert.deepEqual(HoldemState.bankReconciliation(after), HoldemState.bankReconciliation(before));
|
||||
});
|
||||
|
||||
test("it can be dealt from straight away", () => {
|
||||
const before = aNight();
|
||||
HoldemState.startNextHand(before);
|
||||
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
|
||||
|
||||
Betting.beginHand(after);
|
||||
assert.equal(after.bettingOpen, true);
|
||||
assert.ok(after.actingSeatIndex !== null, "somebody is on the clock");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it refuses", () => {
|
||||
const refuses = (text, because) => {
|
||||
const result = HoldemState.fromBackup(text);
|
||||
assert.ok(result.error, because);
|
||||
assert.equal(result.table, undefined, "and never hands back half a table");
|
||||
};
|
||||
|
||||
test("something that isn't JSON", () => {
|
||||
refuses("not a file", "unreadable");
|
||||
refuses("", "empty");
|
||||
});
|
||||
|
||||
test("JSON that isn't a table", () => {
|
||||
refuses('{"hello":"world"}', "no seats in it");
|
||||
refuses("[1,2,3]", "an array of nothing");
|
||||
refuses("null", "null");
|
||||
});
|
||||
|
||||
test("a save from something else that happens to be JSON", () => {
|
||||
refuses('{"kind":"some.other.tool","table":{"seats":[]}}', "wrong kind");
|
||||
});
|
||||
|
||||
test("a file from a newer version, rather than guessing at it", () => {
|
||||
// Loading it risks reading a field whose meaning has changed, and the dealer
|
||||
// would find out from the money.
|
||||
const backup = JSON.parse(HoldemState.toBackup(aNight()));
|
||||
backup.version = HoldemState.BACKUP_VERSION + 1;
|
||||
const result = HoldemState.fromBackup(JSON.stringify(backup));
|
||||
assert.match(result.error, /newer version/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it accepts anyway", () => {
|
||||
test("a bare table, with no envelope around it", () => {
|
||||
// Somebody who copied the storage value out by hand. The envelope is a
|
||||
// convenience, not a password.
|
||||
const state = aNight();
|
||||
const result = HoldemState.fromBackup(JSON.stringify(state));
|
||||
assert.ok(!result.error);
|
||||
assert.deepEqual(result.table.seats.map(p => p.name), state.seats.map(p => p.name));
|
||||
});
|
||||
|
||||
test("a table from before fields that exist now, migrated on the way in", () => {
|
||||
// Same repairs a save from an older version gets, because it is the same
|
||||
// function — a second, looser path for imports is how a format grows two
|
||||
// meanings of the same field.
|
||||
const state = aNight();
|
||||
const raw = JSON.parse(JSON.stringify(state));
|
||||
delete raw.deadButton;
|
||||
delete raw.postedBlinds;
|
||||
delete raw.dealtLastHand;
|
||||
delete raw.discarded;
|
||||
raw.seats.forEach(p => { delete p.buyIns; });
|
||||
|
||||
const { table, error } = HoldemState.fromBackup(JSON.stringify({
|
||||
kind: HoldemState.BACKUP_KIND, version: 1, table: raw,
|
||||
}));
|
||||
assert.ok(!error);
|
||||
assert.equal(table.deadButton, false);
|
||||
assert.deepEqual(table.dealtLastHand, []);
|
||||
assert.equal(table.discarded, 0);
|
||||
assert.equal(table.seats[0].buyIns, 0);
|
||||
});
|
||||
|
||||
test("the time it was saved comes back with it", () => {
|
||||
const result = HoldemState.fromBackup(HoldemState.toBackup(aNight()));
|
||||
assert.ok(result.savedAt, "so the dealer can tell two files apart");
|
||||
assert.ok(!Number.isNaN(new Date(result.savedAt).getTime()));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the file itself", () => {
|
||||
test("is named with the date and time, so two saves in a night are told apart", () => {
|
||||
const at = new Date(2026, 8, 2, 21, 5);
|
||||
assert.equal(HoldemState.backupFileName(at), "holdem-table-2026-09-02-2105.json");
|
||||
});
|
||||
|
||||
test("says what it is, for anyone finding it in a downloads folder", () => {
|
||||
const parsed = JSON.parse(HoldemState.toBackup(aNight()));
|
||||
assert.equal(parsed.kind, "casino.holdem.table");
|
||||
assert.equal(parsed.version, 1);
|
||||
assert.ok(parsed.table.seats.length > 0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user