diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index a68e329..c30a69a 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -461,6 +461,17 @@ document.addEventListener("DOMContentLoaded", () => { // The one time the browser refuses to save, say so out loud as well as leaving the // standing warning in the Session panel. Set before the first render so a failure // during start-up is caught too. + // Another tab writing to the same table. Said once, with the way out attached — + // reloading here adopts whatever that tab has done, which is the only resolution + // that doesn't throw somebody's work away. + HoldemState.onOtherTab = () => { + Toast.show( + "This table is open in another tab. Whichever acts last wins.", + { actionLabel: "Reload", onAction: () => window.location.reload(), duration: 20000 }); + Render.renderStorageWarning(); + }; + HoldemState.watchOtherTabs(); + HoldemState.onSaveFailed = () => { Toast.show( "This table isn't being saved — refreshing would lose it. Keep this tab open.", diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 068e8f0..6426453 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -761,13 +761,20 @@ const Render = { renderStorageWarning() { const host = document.getElementById("storageWarning"); if (!host) return; - host.hidden = !HoldemState.saveFailed && !HoldemState.loadFailed; + host.hidden = !HoldemState.saveFailed && !HoldemState.loadFailed && !HoldemState.otherTabActive; if (host.hidden) return; - // Both can be true at once — a browser having trouble with storage tends to - // have it in both directions — and the one that explains an empty table goes - // first, because that's the question the dealer is standing there asking. + // Any of these can be true at once — a browser having trouble with storage + // tends to have it in both directions — and the one that explains an empty + // table goes first, because that's the question the dealer is standing there + // asking. const lines = []; + if (HoldemState.otherTabActive) { + lines.push( + "This table is open in another tab, and both are writing to it — " + + "whichever acts last wins, so work done in the other one can vanish. " + + "Close one, then reload this tab to pick up the table as it stands."); + } if (HoldemState.loadFailed) { lines.push( "The saved table couldn't be read, so this one started empty. " + diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index 97d2484..e53cfbe 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -84,6 +84,30 @@ const HoldemState = { // Where an unreadable save is kept instead of being written over. RECOVERY_KEY: "casino.holdem.state.v1.unreadable", + // True once another tab has written to this table. Two tabs each hold their own + // copy in memory and each save writes the whole thing, so the second one to act + // silently throws the first one's work away — measured: tab A deals and takes a + // call, tab B renames a seat, and the deal, the blinds and the call are gone with + // nothing said in either tab. + // + // Watched rather than merged. Replacing the table under a dealer's hands mid-hand + // would be its own hazard; knowing there are two of them is what they need. + otherTabActive: false, + onOtherTab: null, + + // The browser fires this in every *other* tab, never the one that wrote, so our + // own saves can't trip it. + watchOtherTabs() { + if (typeof window === "undefined" || this.tabWatcher) return; + this.tabWatcher = event => { + if (event.key !== this.STORAGE_KEY) return; + if (this.otherTabActive) return; + this.otherTabActive = true; + if (typeof this.onOtherTab === "function") this.onOtherTab(); + }; + window.addEventListener("storage", this.tabWatcher); + }, + // True when there was a saved table and it couldn't be read. Same pair as // saveFailed above, for the same reason: the UI has to be able to say so. loadFailed: false, diff --git a/tests/holdem/otherTab.test.js b/tests/holdem/otherTab.test.js new file mode 100644 index 0000000..041d230 --- /dev/null +++ b/tests/holdem/otherTab.test.js @@ -0,0 +1,100 @@ +// Noticing that the same table is open twice. +// +// Two tabs each hold their own copy in memory and each save writes the whole thing, +// so the second one to act throws the first one's work away. This doesn't merge them +// — replacing a table under a dealer's hands mid-hand would be its own hazard — it +// notices, which is what lets them decide. +const test = require("node:test"); +const assert = require("node:assert"); +const { HoldemState } = require("./load"); + +function withWindow() { + const listeners = []; + globalThis.window = { + addEventListener(type, fn) { if (type === "storage") listeners.push(fn); }, + removeEventListener(type, fn) { + const i = listeners.indexOf(fn); + if (i >= 0) listeners.splice(i, 1); + }, + }; + return { + listeners, + fire(key) { listeners.forEach(fn => fn({ key })); }, + }; +} + +test.beforeEach(() => { + HoldemState.otherTabActive = false; + HoldemState.onOtherTab = null; + HoldemState.tabWatcher = null; +}); + +test.after(() => { + delete globalThis.window; + HoldemState.otherTabActive = false; + HoldemState.onOtherTab = null; + HoldemState.tabWatcher = null; +}); + +test("a write to this table from elsewhere is noticed", () => { + const win = withWindow(); + let told = 0; + HoldemState.onOtherTab = () => { told++; }; + HoldemState.watchOtherTabs(); + + win.fire(HoldemState.STORAGE_KEY); + + assert.equal(HoldemState.otherTabActive, true); + assert.equal(told, 1); +}); + +test("somebody else's storage key is not our business", () => { + const win = withWindow(); + let told = 0; + HoldemState.onOtherTab = () => { told++; }; + HoldemState.watchOtherTabs(); + + win.fire("some.other.app.state"); + win.fire(HoldemState.RECOVERY_KEY); + + assert.equal(HoldemState.otherTabActive, false); + assert.equal(told, 0); +}); + +test("told once, however many times the other tab acts", () => { + const win = withWindow(); + let told = 0; + HoldemState.onOtherTab = () => { told++; }; + HoldemState.watchOtherTabs(); + + for (let i = 0; i < 10; i++) win.fire(HoldemState.STORAGE_KEY); + + assert.equal(told, 1); +}); + +test("watching twice doesn't listen twice", () => { + const win = withWindow(); + HoldemState.watchOtherTabs(); + HoldemState.watchOtherTabs(); + HoldemState.watchOtherTabs(); + + assert.equal(win.listeners.length, 1); +}); + +test("no listener set is not an error", () => { + const win = withWindow(); + HoldemState.onOtherTab = null; + HoldemState.watchOtherTabs(); + + assert.doesNotThrow(() => win.fire(HoldemState.STORAGE_KEY)); + assert.equal(HoldemState.otherTabActive, true); +}); + +test("no window at all is not an error", () => { + // The same modules are loaded in Node by the test harness. + delete globalThis.window; + HoldemState.tabWatcher = null; + + assert.doesNotThrow(() => HoldemState.watchOtherTabs()); + assert.equal(HoldemState.otherTabActive, false); +});