Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
419852a1f4 |
@@ -155,6 +155,17 @@
|
|||||||
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
|
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
|
||||||
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
|
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -598,6 +598,72 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
Toast.show("Hand history cleared.", { duration: 2500 });
|
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
|
// 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
|
// 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.
|
// tab. Clearing the history clears it too, since there is nothing left to filter.
|
||||||
|
|||||||
@@ -164,13 +164,18 @@ const HoldemState = {
|
|||||||
return this.createDefault();
|
return this.createDefault();
|
||||||
},
|
},
|
||||||
|
|
||||||
load() {
|
// Everything a table has to be put through before it can be trusted, whichever
|
||||||
let raw = null;
|
// direction it arrived from.
|
||||||
try {
|
//
|
||||||
raw = localStorage.getItem(this.STORAGE_KEY);
|
// Pulled out of load() so a table read from a file goes through exactly the same
|
||||||
if (!raw) return this.createDefault();
|
// checks as one read from storage. A second, looser path for imports is how a
|
||||||
const parsed = JSON.parse(raw);
|
// format grows two meanings of the same field.
|
||||||
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
|
//
|
||||||
|
// 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 (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;
|
if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
|
||||||
@@ -215,12 +220,85 @@ const HoldemState = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return parsed;
|
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 {
|
} catch {
|
||||||
// Malformed JSON — a truncated write, another tab, a browser hiccup.
|
// Malformed JSON — a truncated write, another tab, a browser hiccup.
|
||||||
return this.failedLoad(raw);
|
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
|
// 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.
|
// toast is a moment and this is a fact about the rest of the night.
|
||||||
saveFailed: false,
|
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