From 8931b8ce919267c1e9cbb4332d586103f7311f45 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:56:53 +0100 Subject: [PATCH 1/2] revert(bonanza): restore pre-#29 behavior at version 1.5.16 Rollback opening verification and 45-minute duration hard cap introduced by #29. Preserve subsequent repo README and bump the Tampermonkey version to ensure users receive the rollback. --- AGENTS.md | 156 --- docs/runbooks/bonanza-operations.md | 4 +- tests/bonanza-duration-cap.test.mjs | 325 ------ tests/bonanza-duration-recovery.test.mjs | 354 ------ tests/bonanza-opening-recovery.test.mjs | 1001 ----------------- tests/bonanza-review-hardening.test.mjs | 4 +- .../DarkPeers_BONanza_Giveaway.user.js | 917 ++------------- userscripts/giveaway/src/00-preamble.js | 35 +- .../giveaway/src/01-global-constants.js | 3 +- userscripts/giveaway/src/04-ui-template.js | 4 +- userscripts/giveaway/src/05-initialization.js | 391 +------ userscripts/giveaway/src/06-lifecycle.js | 300 +---- .../giveaway/src/09-sponsorship-polling.js | 79 +- .../giveaway/src/10-command-handling.js | 59 +- userscripts/giveaway/src/12-utilities.js | 48 +- 15 files changed, 208 insertions(+), 3472 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 tests/bonanza-duration-cap.test.mjs delete mode 100644 tests/bonanza-duration-recovery.test.mjs delete mode 100644 tests/bonanza-opening-recovery.test.mjs diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 9a167cd1..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,156 +0,0 @@ -# Repository instructions for coding agents - -These instructions apply to the whole repository. Read them before editing code. -Keep changes focused on the affected component; this repository contains -multiple userscripts, not just BONanza. - -## DarkPeers BONanza Giveaway (important) - -- **Single source of truth:** `userscripts/giveaway/src/*.js` (15 ordered - fragments). Never edit - `userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js` directly. - That file is **generated and committed** to Git for Tampermonkey. -- To modify the script, edit the relevant fragment. The file - `scripts/build-bonanza.mjs` defines the **exact concatenation order**. - Fragments are not independent JavaScript modules and must keep the - existing single-IIFE runtime intact. -- Update metadata, version notes and `@version` in - `userscripts/giveaway/src/00-preamble.js`, not in the generated file. - When bumping the version, adjust tests that assert the precise version. - Do not change `@namespace` or the update/download URLs unintentionally. -- From the repository root, **after every source change**, run: - - ```bash - node scripts/build-bonanza.mjs - node scripts/build-bonanza.mjs --check - node --check userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js - node --test tests/bonanza-*.test.mjs - git diff --check - ``` - -- Commit both the changed fragments and the regenerated - `DarkPeers_BONanza_Giveaway.user.js` (plus any tests/docs) in the same PR. - A source-only commit will fail CI because the generated file is stale. - Do not patch the generated file by hand, even to repair a review finding: - fix the source, then rebuild. -- The **Validate BONanza** GitHub Action verifies the tracked bundle, checks - syntax and runs tests on PRs and relevant pushes to `main`. It does not - commit the generated file for you. -- On relevant pushes to `main`, **Publish BONanza to Gist** rebuilds, - fails if the committed bundle differs, and then publishes the userscript - plus a generated `.meta.js` update manifest. No deployment occurs merely - from opening a PR. Do not manually publish or maintain the Gist. -- The script can transfer real BON. Preserve safety gates around ownership, - recovery, gift history, settlement and transfers. Add regression tests for - affected behavior, and use rehearsal mode for validation that would - otherwise post public chat messages or move real currency/points. Never - initiate a live payout for a routine code test. - -## Opening / sponsor accounting invariants - -- Before posting a LIVE opening, require a trustworthy positive Main Chat - message cursor. If it is unavailable, abort BEFORE posting (otherwise an - identical old announcement might falsely verify the new opening). -- Before posting the public giveaway opening, persist and read back a - **pending opening checkpoint** with the exact prepared announcement, - pre-send timestamp, Main Chat cursor and a bounded publication window. - When the send resolves, persist its completion timestamp to further - narrow that window. Reconciliation must prove the **entire** server-side - timestamp-resolution bucket lies inside the original opening attempt: - same-second overlap or later identical announcements are NOT sufficient. - ANY opening API failure, including non-OK HTTP responses, must never - trigger the chatbox fallback: the server may already have published - the opening, so reconcile the original checkpoint instead. - A timeout/ownership loss is ambiguous: do not erase the checkpoint, - re-post blindly, or settle BON. - Restore must verify the exact own-user announcement in authoritative - Main Chat history before enabling entries or any sponsor/payout action. -- The 45-minute absolute deadline is anchored to the conservative - **pre-send** time, so server response latency cannot extend the event. - **Sponsor eligibility must NOT use that time.** It begins strictly after - the independently verified publication timestamp bucket. Each Gift - History timestamp bucket must START at/after `sponsorStartTs`, not - merely overlap it; uncalibrated history must be deferred altogether. - Omit ambiguous boundary gifts rather than risking inflation of a real - BON payout. Persist `sponsorStartTs` and - use it for both live Gift History polling and reload reconciliation. -- After acquiring the exclusive Web Lock, read saved recovery storage AGAIN - before mutating state or posting. Another tab may have saved an uncertain - opening while waiting for the lock; do not overwrite any such state. - **Restores must also re-read and revalidate the COMPLETE latest snapshot - under that lock**, discarding the pre-lock in-memory object: another tab - could have persisted payout/settlement progress while ownership was queued. - Pre-lock `__expiredBeyondGrace`, `__legacyOpeningUnverified`, proof and - host data are stale hints, NEVER grounds for rejecting before the lock. - Classify only the latest post-lock snapshot. - Never replace a newer committed settlement with stale recovery data. - The recovery reader reports `readOk` separately from `raw`: a denied - localStorage read, corrupt legacy JSON or migration failure MUST block the - start, never appear as empty storage. An empty STRING in an existing - recovery slot is still an occupied (corrupt) record. Keep pre-lock reads - read-only, performing any legacy migration under exclusive ownership. - A legacy key containing an empty string is PRESENT, not absent: test - `getItem() === null` to prove absence. Even valid JSON without an - identifiable host (such as `{}` or `{"giveawayData":{}}`) remains an - occupied, unidentified record. Fail closed and preserve it for manual - review: ONLY a positively identified foreign host may be ignored. - ALWAYS inspect the old legacy slot even when namespaced recovery exists. - Corrupt, unidentified or different SAME-HOST snapshots must block automatic - recovery/settlement; identical same-host records are safe to read, as is - a positively identified different host. On post-lock recovery, first - verify byte-identical same-host snapshots, then retire their duplicate - legacy slot with before/after read-back checks BEFORE writing a new - confirmed opening checkpoint. If cleanup fails, abort without payout; - pre-lock discovery must remain read-only. Also verify legacy removal - succeeded after migrating a single old-format record: a successful - namespaced write does not prove the old key was deleted. Abort restore - if deletion is denied or silently ignored, before a pending opening - is confirmed. Never delete a legacy record until its namespaced copy - has been verified durably. - `loadGiveawaySnapshot()` must never - delete, migrate, or alter recovery storage before acquiring the Web Lock; - stale/expired data needs manual review, not speculative cleanup. -- **Preflight is not an opening.** Before a durable `openingCheckpoint` - exists, the in-memory configuration MUST NOT be snapshotted by pagehide, - beforeunload or other background persistence. A BFCache handoff during - balance/history/cursor awaits must not generate a false legacy giveaway - or allow the abandoned tab to post. -- Malformed, unreadable or incomplete recovery data may contain settlement - evidence. Preserve its raw storage for manual investigation; a parse error - is not permission to delete it or start a replacement. -- Reverify every locally **confirmed** LIVE opening during restore against - the exact original Main Chat message ID, authenticated author, full - announcement text, bounded send window and sponsor boundary. If that - authoritative evidence is unavailable, preserve the snapshot without - enabling entries or BON transfers. -- With no calibrated Gift History offset, neither UTC nor browser-local - timestamps are trustworthy: defer ALL gifts as retryable and block - final canonical settlement until calibration succeeds. -- Leave unresolved opening checkpoints intact for later recovery; never - overwrite them with a new giveaway or auto-settle a recovered ancient - opening beyond the one-hour expiry grace. **Pre-checkpoint legacy snapshots - without proven publication and a conservative pre-send opening time are - quarantined unchanged for MANUAL review**, including already-expired or - committed settlement state. Never cap their deadlines using a post-send - `startTime` or automatically resume entries and BON settlement. Never - overwrite such a preserved snapshot with a new start. -- A Gift History clock offset calibrated against precise notification/chat - events and second-precision history rows has **fractional calibration - uncertainty**. Persist an upper error bound with the inferred offset and - subtract it from the calibrated bucket start BEFORE checking - `sponsorStartTs`. This also applies to the boundary-evidence path and - across reloads. Keep all BON-moving operations blocked while publication - or sponsor timing remains unverified. -- In **Silent Mode**, ordinary command replies (staff responses, errors, - `!winners`, `!addbon` etc.) use private messages. **`!reminder` is NOT - an ordinary reply:** it explicitly requests an announcement for everyone. - Both manual `!reminder` and scheduled automatic reminders MUST broadcast - to Main Chat even in Silent Mode. Rehearsal Mode still redirects both - reminder types privately to the host and never transfers real BON. - -Full source layout and examples: -[`userscripts/giveaway/src/README.md`](userscripts/giveaway/src/README.md). - -If repository conventions or workflows change, update **both** this file -and the component's README in the same PR so future agents get accurate -instructions. diff --git a/docs/runbooks/bonanza-operations.md b/docs/runbooks/bonanza-operations.md index f3c5a683..dcc2077c 100644 --- a/docs/runbooks/bonanza-operations.md +++ b/docs/runbooks/bonanza-operations.md @@ -4,7 +4,7 @@ This is the operational reference for hosting, rehearsing and recovering a DarkP ## If the pot or sponsor accounting looks wrong -1. Extend the giveaway time if entries are still open, without exceeding 45 minutes in total from public opening. +1. Extend the giveaway time if entries are still open. 2. Do not settle while the accounting warning is visible. 3. Do not manually resend winner gifts or BON Pool contributions unless the persisted settlement state explicitly says they were never attempted. 4. Reload Main Chat. The userscript restores the giveaway and reconciles sponsor accounting against persistent DarkPeers Gift History. @@ -25,8 +25,6 @@ DarkPeers staff may use operational recovery controls: - rig / unrig - time add / remove - addtime / removetime - -The 45-minute maximum applies to the initial form duration, saved presets and every host/staff extension. An extension cannot push the deadline beyond 45 minutes after the original public opening, even after a reload. - naughty Every non-host staff action is identified publicly in the command output with the staff username. diff --git a/tests/bonanza-duration-cap.test.mjs b/tests/bonanza-duration-cap.test.mjs deleted file mode 100644 index f09240a2..00000000 --- a/tests/bonanza-duration-cap.test.mjs +++ /dev/null @@ -1,325 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import test from "node:test"; -import vm from "node:vm"; - -const root = new URL("../", import.meta.url); -const read = (path) => readFileSync(new URL(path, root), "utf8"); -const prefix = "userscripts/giveaway/src/"; -const utilities = read(prefix + "12-utilities.js"); -const commands = read(prefix + "10-command-handling.js"); -const lifecycle = read(prefix + "06-lifecycle.js"); -const initialization = read(prefix + "05-initialization.js"); -const ui = read(prefix + "04-ui-template.js"); - -function namedFunction(source, name) { - const start = source.indexOf(` function ${name}(`); - assert.notEqual(start, -1, `Missing ${name}`); - const closing = source.indexOf("\n }\n", start); - assert.notEqual(closing, -1, `Missing closing brace for ${name}`); - return source.slice(start, closing + "\n }\n".length); -} - -const limitFunctions = [ - namedFunction(utilities, "parseGiveawayMinutes"), - namedFunction(utilities, "getGiveawayMaxEndTs"), - namedFunction(commands, "makeStaffAttributedReply"), - namedFunction(commands, "hostAdjustTime") -].join("\n"); - -function makeHarness(startedAt, initialEndTs, { silentMode = false } = {}) { - const replies = []; - const publicMessages = []; - const privateMessages = []; - let schedules = 0; - let snapshots = 0; - const countdownHeader = { textContent: "" }; - const runtime = vm.createContext({ - MAX_GIVEAWAY_MINUTES: 45, - GENERAL_SETTINGS: { silent_mode: silentMode }, - isHostOrAdmin: (author, _fancyName, host) => author === host || author === "staff", - isNonHostStaffAction: (author, _fancyName, host) => author === "staff" && author !== host, - sanitizeNick: name => name, - sendMessage: message => { publicMessages.push(message); return true; }, - rebuildSchedule: () => { schedules++; }, - snapshotGiveaway: () => { snapshots++; }, - parseTime: (ms) => String(ms), - countdownHeader - }); - vm.runInContext(limitFunctions, runtime); - - const giveawayData = { host: "maghuro", openedAt: startedAt, endTs: initialEndTs }; - const adjust = (sign, minutes, author = "maghuro", source = "panel") => { - runtime.hostAdjustTime(sign)({ - author, fancyName: "", args: [String(minutes)], giveawayData, source, - reply: (message) => { - replies.push(message); - if (source === "chat") { - if (silentMode) privateMessages.push({ author, message }); - else publicMessages.push(message); - } - } - }); - }; - return { - runtime, giveawayData, replies, publicMessages, privateMessages, adjust, - get schedules() { return schedules; }, - get snapshots() { return snapshots; }, - countdownHeader - }; -} - -test("only whole durations from 1 through 45 minutes are accepted", () => { - const { runtime } = makeHarness(1_000_000, 2_000_000); - for (const value of ["1", "12", "45"]) { - assert.equal(runtime.parseGiveawayMinutes(value), Number(value)); - } - for (const value of ["", "0", "46", "99", "-1", "45.5", "1e2", "30abc", "Infinity", "999999999999999999999"]) { - assert.equal(runtime.parseGiveawayMinutes(value), null, value); - } - assert.equal(runtime.getGiveawayMaxEndTs(1_000_000), 1_000_000 + 45 * 60_000); - assert.equal(runtime.getGiveawayMaxEndTs(null), null); - assert.equal(runtime.getGiveawayMaxEndTs("not-a-timestamp"), null); -}); - -test("an extension may reach 45 minutes exactly but never cross the opening-based deadline", () => { - const startedAt = Date.now() - 10 * 60_000; - const h = makeHarness(startedAt, startedAt + 40 * 60_000); - h.adjust(1, 5); - assert.equal(h.giveawayData.endTs, startedAt + 45 * 60_000); - assert.equal(h.snapshots, 1); - assert.equal(h.schedules, 1); - - h.adjust(1, 0.01); - assert.equal(h.giveawayData.endTs, startedAt + 45 * 60_000); - assert.equal(h.snapshots, 1); - assert.equal(h.schedules, 1); - assert.match(h.replies.at(-1), /maximum of 45 minutes/); -}); - -test("the cap concerns total duration, not remaining time", () => { - const startedAt = Date.now() - 30 * 60_000; - const h = makeHarness(startedAt, startedAt + 40 * 60_000); - h.adjust(1, 10); // Only ten minutes are left, but the total would become 50. - assert.equal(h.giveawayData.endTs, startedAt + 40 * 60_000); - assert.equal(h.snapshots, 0); - h.adjust(-1, 1); - assert.equal(h.giveawayData.endTs, startedAt + 39 * 60_000); - assert.equal(h.snapshots, 1); -}); - -test("time adjustment fails closed without a trustworthy public-opening timestamp", () => { - const h = makeHarness(null, Date.now() + 10 * 60_000); - const original = h.giveawayData.endTs; - h.adjust(1, 1); - assert.equal(h.giveawayData.endTs, original); - assert.equal(h.snapshots, 0); - assert.match(h.replies.at(-1), /opening time is unavailable/); - h.adjust(1, 1, "another-user"); - assert.equal(h.replies.length, 1); // Non-hosts cannot change the deadline. -}); - -test("UI, start path, both time commands, and recovered snapshots share the hard cap", () => { - assert.match(ui, /id="timerNum"[\s\S]*?max="45"/); - assert.match(initialization, /Math\.min\(n, MAX_GIVEAWAY_MINUTES\)/); - assert.match(initialization, /Math\.min\(minutes, MAX_GIVEAWAY_MINUTES\)/); - assert.match(lifecycle, /const totalTimeMin = parseGiveawayMinutes\(timerInput\?\.value\)/); - assert.ok( - lifecycle.indexOf("const totalTimeMin = parseGiveawayMinutes") < - lifecycle.indexOf("await acquireTabLock()") - ); - assert.match(lifecycle, /giveawayData\.openedAt = openingSendStartedAt/); - assert.match(commands, /const addMinutes = hostAdjustTime\(\+1\)/); - assert.match(commands, /const removeMinutes = hostAdjustTime\(-1\)/); - assert.match(commands, /const maxEndTs = getGiveawayMaxEndTs\(giveawayData\.openedAt\)/); - assert.match(initialization, /openedAt: giveawayData\.openedAt/); - assert.match(initialization, /const correctedDeadline = !committedSettlement && maxEndTs !== null && endTs > maxEndTs/); - assert.match(initialization, /snap\.giveawayData\.endTs = maxEndTs/); -}); - -test("opening time is durably capped from before the asynchronous chat send", () => { - const captured = lifecycle.indexOf("const openingSendStartedAt = Date.now();"); - const checkpoint = lifecycle.indexOf("openingCheckpoint = {", captured); - const persist = lifecycle.indexOf("snapshotGiveaway({ force: true, verifyWrite: true })", checkpoint); - const send = lifecycle.indexOf("const introSent = await sendMessage(", persist); - const verify = lifecycle.indexOf("await verifyPendingGiveawayOpening(", send); - assert.ok(captured >= 0 && checkpoint > captured && persist > checkpoint); - assert.ok(send > persist && verify > send); - assert.match(lifecycle.slice(captured, send), /giveawayData\.endTs = openingSendStartedAt \+ totalTimeMs/); - assert.match(lifecycle.slice(captured, send), /giveawayData\.openedAt = openingSendStartedAt/); - assert.match(lifecycle.slice(send, verify), /noFallbackOnApiException: true/); -}); - -test("unconfirmed openings are preserved instead of being reset or paid", () => { - const start = lifecycle.indexOf("if (!verifiedOpening)"); - const end = lifecycle.indexOf("giveawayData.sponsorStartTs = verifiedOpening.sponsorStartTs", start); - assert.ok(start >= 0 && end > start); - const path = lifecycle.slice(start, end); - assert.match(path, /releaseTabLock\(\)/); - assert.match(path, /return;/); - assert.doesNotMatch(path, /resetGiveaway\(\)/); - assert.match(path, /opening unconfirmed/i); -}); - -test("a rejected !addtime in chat is announced publicly without changing the timer", () => { - const openedAt = Date.now() - 5 * 60_000; - const h = makeHarness(openedAt, openedAt + 40 * 60_000); - h.adjust(1, 6, "maghuro", "chat"); - assert.equal(h.giveawayData.endTs, openedAt + 40 * 60_000); - assert.equal(h.snapshots, 0); - assert.equal(h.schedules, 0); - assert.equal(h.replies.length, 1, "chat warning uses normal command reply routing"); - assert.equal(h.publicMessages.length, 1); - assert.match(h.publicMessages[0], /Time limit exceeded!/); - assert.match(h.publicMessages[0], /45 minutes total/); - assert.match(h.publicMessages[0], /No time was added/); -}); - -test("staff gets a public, attributed cap warning in chat", () => { - const openedAt = Date.now() - 10 * 60_000; - const h = makeHarness(openedAt, openedAt + 45 * 60_000); - h.adjust(1, 1, "staff", "chat"); - assert.equal(h.giveawayData.endTs, openedAt + 45 * 60_000); - assert.equal(h.snapshots, 0); - assert.equal(h.publicMessages.length, 1); - assert.match(h.publicMessages[0], /Staff action by staff/); - assert.match(h.publicMessages[0], /Time limit exceeded!/); -}); - -test("panel commands keep their previous rejection reply instead of a new chat warning", () => { - const openedAt = Date.now() - 5 * 60_000; - const h = makeHarness(openedAt, openedAt + 40 * 60_000); - h.adjust(1, 10, "maghuro", "panel"); - assert.equal(h.publicMessages.length, 0); - assert.equal(h.replies.length, 1); - assert.match(h.replies[0], /Time change refused/); - assert.doesNotMatch(h.replies[0], /Time limit exceeded!/); -}); - -test("allowed chat extensions still change the deadline and use the existing reply path", () => { - const openedAt = Date.now() - 5 * 60_000; - const h = makeHarness(openedAt, openedAt + 40 * 60_000); - h.adjust(1, 5, "maghuro", "chat"); - assert.equal(h.giveawayData.endTs, openedAt + 45 * 60_000); - assert.equal(h.snapshots, 1); - assert.equal(h.publicMessages.length, 1); - assert.equal(h.replies.length, 1); - assert.match(h.replies[0], /Added/); -}); - -test("unprivileged users cannot trigger public cap notices or mutate the giveaway", () => { - const openedAt = Date.now() - 5 * 60_000; - const h = makeHarness(openedAt, openedAt + 40 * 60_000); - h.adjust(1, 10, "random-user", "chat"); - assert.equal(h.giveawayData.endTs, openedAt + 40 * 60_000); - assert.equal(h.snapshots, 0); - assert.equal(h.publicMessages.length, 0); - assert.equal(h.replies.length, 0); -}); - -test("both chat aliases use the public over-limit rejection path", () => { - assert.match(commands, /addtime:\s*hostAdjustTime\(\+1\)/); - assert.match(commands, /const addMinutes = hostAdjustTime\(\+1\)/); - assert.match(commands, /if \(action === "add"\)\s*\{\s*addMinutes\(ctxWithArg\)/); - assert.match(commands, /source:\s*"chat"/); - assert.match(commands, /if \(ctx\.source === "chat"\)/); - assert.match(commands, /GENERAL_SETTINGS\.silent_mode/); -}); - -test("Rehearsal Mode delivers the same 45-minute warning privately, without a public chat send", async () => { - const openedAt = Date.now() - 5 * 60_000; - const data = { host: "maghuro", openedAt, endTs: openedAt + 40 * 60_000 }; - const privateMessages = []; - let changed = 0; - const context = vm.createContext({ - MAX_GIVEAWAY_MINUTES: 45, - REHEARSAL_MODE: true, - GENERAL_SETTINGS: { silent_mode: true }, - DEBUG_SETTINGS: { disable_chat_output: false, verify_sendmessage: false, log_chat_messages: false }, - giveawayData: data, - getLoggedInUsername: () => "maghuro", - sanitizeNick: name => name, - logEvent() {}, - prepareOutgoingMessage: text => text, - formatRehearsalPrivateOutput: text => "[REHEARSAL] " + text, - sendPrivateMessage: (user, text) => { - privateMessages.push({ user, text }); - return Promise.resolve(true); - }, - isHostOrAdmin: (user, _, host) => user === host, - isNonHostStaffAction: () => false, - rebuildSchedule: () => { changed++; }, - snapshotGiveaway: () => { changed++; } - }); - vm.runInContext([ - namedFunction(utilities, "getGiveawayMaxEndTs"), - (() => { - const start = utilities.indexOf(" async function sendMessage("); - assert.ok(start !== -1, "Missing async sendMessage"); - const marker = "\n }\n"; - const end = utilities.indexOf(marker, start); - assert.ok(end > start, "Missing end of sendMessage"); - return utilities.slice(start, end + marker.length); - })(), - namedFunction(commands, "makeStaffAttributedReply"), - namedFunction(commands, "hostAdjustTime") - ].join("\n"), context); - - // Command originates from chat, but rehearsal mode redirects outgoing - // script-generated chat output to the host's private inbox. - context.hostAdjustTime(+1)({ - author: "maghuro", fancyName: "", giveawayData: data, - source: "chat", args: ["10"], reply: message => context.sendMessage(message) - }); - await Promise.resolve(); - assert.equal(data.endTs, openedAt + 40 * 60_000); - assert.equal(changed, 0); - assert.equal(privateMessages.length, 1); - assert.equal(privateMessages[0].user, "maghuro"); - assert.match(privateMessages[0].text, /\[REHEARSAL\]/); - assert.match(privateMessages[0].text, /Time limit exceeded!/); - assert.match(privateMessages[0].text, /45 minutes total/); - assert.match(privateMessages[0].text, /No time was added/); -}); - -test("host over-limit feedback follows Silent Mode and is private to the invoker", () => { - const openedAt = Date.now() - 10 * 60_000; - const h = makeHarness(openedAt, openedAt + 40 * 60_000, { silentMode: true }); - h.adjust(1, 10, "maghuro", "chat"); - assert.equal(h.giveawayData.endTs, openedAt + 40 * 60_000); - assert.equal(h.snapshots, 0); - assert.equal(h.publicMessages.length, 0); - assert.equal(h.privateMessages.length, 1); - assert.equal(h.privateMessages[0].author, "maghuro"); - assert.match(h.privateMessages[0].message, /Time limit exceeded!/); - assert.match(h.privateMessages[0].message, /No time was added/); -}); - -test("staff over-limit feedback is also private in Silent Mode", () => { - const openedAt = Date.now() - 10 * 60_000; - const h = makeHarness(openedAt, openedAt + 45 * 60_000, { silentMode: true }); - h.adjust(1, 1, "staff", "chat"); - assert.equal(h.giveawayData.endTs, openedAt + 45 * 60_000); - assert.equal(h.snapshots, 0); - assert.equal(h.publicMessages.length, 0); - assert.equal(h.privateMessages.length, 1); - assert.equal(h.privateMessages[0].author, "staff"); - assert.match(h.privateMessages[0].message, /Time limit exceeded!/); -}); - -test("staff success and validation messages remain private in Silent Mode", () => { - const openedAt = Date.now() - 10 * 60_000; - const h = makeHarness(openedAt, openedAt + 40 * 60_000, { silentMode: true }); - h.adjust(1, 5, "staff", "chat"); - assert.equal(h.giveawayData.endTs, openedAt + 45 * 60_000); - assert.equal(h.snapshots, 1); - assert.equal(h.publicMessages.length, 0); - assert.equal(h.privateMessages.length, 1); - assert.match(h.privateMessages[0].message, /Added/); - - h.adjust(1, "bad", "staff", "chat"); - assert.equal(h.giveawayData.endTs, openedAt + 45 * 60_000); - assert.equal(h.publicMessages.length, 0); - assert.equal(h.privateMessages.length, 2); - assert.match(h.privateMessages[1].message, /Usage:/); -}); diff --git a/tests/bonanza-duration-recovery.test.mjs b/tests/bonanza-duration-recovery.test.mjs deleted file mode 100644 index 84eebd8a..00000000 --- a/tests/bonanza-duration-recovery.test.mjs +++ /dev/null @@ -1,354 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import test from "node:test"; -import vm from "node:vm"; - -const root = new URL("../", import.meta.url); -const read = (path) => readFileSync(new URL(path, root), "utf8"); -const initialization = read("userscripts/giveaway/src/05-initialization.js"); -const utilities = read("userscripts/giveaway/src/12-utilities.js"); - -function extractNamedFunction(source, name) { - const start = source.indexOf(` function ${name}(`); - assert.notEqual(start, -1, `Missing function ${name}`); - const end = source.indexOf("\n }\n", start); - assert.notEqual(end, -1, `Missing end of ${name}`); - return source.slice(start, end + "\n }\n".length); -} - -// Run the real recovery predicate, not a reimplementation of the logic. -const functions = [ - extractNamedFunction(utilities, "getGiveawayMaxEndTs"), - extractNamedFunction(utilities, "getReminderLimits"), - extractNamedFunction(utilities, "getReminderSchedule"), - extractNamedFunction(utilities, "nextReminderMs"), - extractNamedFunction(initialization, "loadGiveawaySnapshot") -].join("\n"); - -const MINUTE = 60_000; -const NOW = 1_800_000_000_000; - -function recoveryHarness({ - ageMinutes, - advertisedMinutes = 90, - reminderCount = 1, - // Current verified snapshots may still contain legacy 90-minute values. - // Snapshots with no checkpoint must instead stay quarantined for manual review. - openingCheckpoint = { status: "confirmed" }, - settlement = null, - closingIntent = false, - closingNoticeSent = false -}) { - const openedAt = NOW - ageMinutes * MINUTE; - const originalEnd = openedAt + advertisedMinutes * MINUTE; - const clears = []; - const warnings = []; - const snap = { - startTime: openedAt, // Keep the backward-compatibility timestamp field. - giveawayData: { - host: "maghuro", - rehearsalMode: false, - endTs: originalEnd, - openedAt, - sponsorStartTs: openedAt, - openingCheckpoint, - reminderNum: reminderCount, - reminderSchedule: [Math.round(advertisedMinutes / (reminderCount + 1) * MINUTE)], - reminderFreqSec: advertisedMinutes * 60 / (reminderCount + 1), - nextReminderSec: advertisedMinutes * 60 / (reminderCount + 1), - totalSeconds: advertisedMinutes * 60, - settlement, - closingIntent, - closingNoticeSent - } - }; - const context = vm.createContext({ - Date: { now: () => NOW }, - MAX_GIVEAWAY_MINUTES: 45, - MAX_REMINDERS: 6, - EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS: 60 * MINUTE, - REHEARSAL_MODE: false, - normalizeUserKey: (value) => String(value || "").toLowerCase(), - getLoggedInUsername: () => "maghuro", - readActiveGiveawayRawForHost: () => ({ readOk: true, raw: JSON.stringify(snap) }), - clearGiveawaySnapshot: () => { clears.push(true); }, - console: { warn: (message) => warnings.push(message) } - }); - vm.runInContext(functions, context); - return { - restore: () => context.loadGiveawaySnapshot(), - nextReminderMs: (schedule, msLeft) => context.nextReminderMs(schedule, msLeft), - clears, warnings, originalEnd, cappedEnd: openedAt + 45 * MINUTE - }; -} - -test("stale legacy giveaway is not resurrected when capped deadline exceeds recovery grace", () => { - // Under the old 90-minute deadline this ended 20 minutes ago and was - // recoverable; its compliant 45-minute deadline was already 65 minutes ago. - const h = recoveryHarness({ ageMinutes: 110 }); - const state = h.restore(); - assert.ok(state, "Expired data is preserved for manual review"); - assert.equal(state.__expiredBeyondGrace, true); - assert.equal(h.clears.length, 0); - assert.ok(h.warnings.some(w => w.includes("preserved for manual review"))); -}); - -test("long-abandoned giveaway cannot settle unexpectedly after a code upgrade", () => { - const h = recoveryHarness({ ageMinutes: 3 * 24 * 60, advertisedMinutes: 120 }); - const old = h.restore(); - assert.ok(old); - assert.equal(old.__expiredBeyondGrace, true); - assert.equal(h.clears.length, 0); -}); - -test("recoverable legacy snapshot is capped and recognized as already expired", () => { - const h = recoveryHarness({ ageMinutes: 50 }); - const result = h.restore(); - assert.ok(result); - assert.equal(result.giveawayData.endTs, h.cappedEnd); - assert.equal(result.__expiredAtLoad, true); - assert.equal(h.clears.length, 0); -}); - -test("a still-active legacy snapshot retains only its allowed remaining time", () => { - const h = recoveryHarness({ ageMinutes: 30 }); - const result = h.restore(); - assert.ok(result); - assert.equal(result.giveawayData.endTs, h.cappedEnd); - assert.equal(result.__expiredAtLoad, false); - assert.equal(h.clears.length, 0); -}); - -test("the ordinary expiry grace boundary still applies to the capped deadline", () => { - const withinGrace = recoveryHarness({ ageMinutes: 105 }); - assert.ok(withinGrace.restore()); - assert.equal(withinGrace.clears.length, 0); - - const afterGrace = recoveryHarness({ ageMinutes: 105 + 1 / MINUTE }); - const expired = afterGrace.restore(); - assert.ok(expired); - assert.equal(expired.__expiredBeyondGrace, true); - assert.equal(afterGrace.clears.length, 0); -}); - -test("committed settlement remains recoverable without rewriting its deadline", () => { - const h = recoveryHarness({ ageMinutes: 150, settlement: { committed: true } }); - const result = h.restore(); - assert.ok(result); - assert.equal(result.giveawayData.endTs, h.originalEnd); - assert.equal(result.__expiredAtLoad, true); - assert.equal(h.clears.length, 0); -}); - -test("latched in-progress settlement is retained after the normal grace", () => { - const h = recoveryHarness({ ageMinutes: 150, closingIntent: true }); - const result = h.restore(); - assert.ok(result); - assert.equal(result.giveawayData.endTs, h.cappedEnd); - assert.equal(result.__expiredAtLoad, true); - assert.equal(h.clears.length, 0); -}); - -test("capped active legacy giveaway rebuilds a single reminder at the new midpoint", () => { - const h = recoveryHarness({ ageMinutes: 5, advertisedMinutes: 90, reminderCount: 1 }); - const result = h.restore(); - assert.ok(result); - assert.equal(result.giveawayData.endTs, h.cappedEnd); - assert.deepEqual(Array.from(result.giveawayData.reminderSchedule), [22.5 * MINUTE]); - assert.equal(result.giveawayData.reminderNum, 1); - assert.equal(result.giveawayData.totalSeconds, 45 * 60); - assert.equal(result.giveawayData.reminderFreqSec, 22.5 * 60); - assert.equal(result.giveawayData.nextReminderSec, 22.5 * 60); - assert.equal(h.nextReminderMs(result.giveawayData.reminderSchedule, 40 * MINUTE), 17.5 * MINUTE); -}); - -test("capped restore drops already-passed reminder thresholds instead of replaying them", () => { - const h = recoveryHarness({ ageMinutes: 30, advertisedMinutes: 90, reminderCount: 1 }); - const result = h.restore(); - assert.ok(result); - assert.equal(result.__expiredAtLoad, false); - assert.equal(h.nextReminderMs(result.giveawayData.reminderSchedule, 15 * MINUTE), null); - assert.equal(result.giveawayData.reminderSchedule.length, 0); -}); - -test("capped restore preserves multiple reminder count and recalculates the actual offsets", () => { - const h = recoveryHarness({ ageMinutes: 5, advertisedMinutes: 90, reminderCount: 3 }); - const result = h.restore(); - assert.ok(result); - assert.deepEqual(Array.from(result.giveawayData.reminderSchedule), [ - Math.round(33.75 * MINUTE), - Math.round(22.5 * MINUTE), - Math.round(11.25 * MINUTE) - ]); - assert.equal(result.giveawayData.reminderNum, 3); - assert.equal(result.giveawayData.reminderFreqSec, 11.25 * 60); -}); - -test("capped restore clamps a legacy excessive reminder count to the safe configured maximum", () => { - const h = recoveryHarness({ ageMinutes: 5, reminderCount: 99 }); - const result = h.restore(); - assert.ok(result); - assert.equal(result.giveawayData.reminderNum, 6); - assert.equal(result.giveawayData.reminderSchedule.length, 6); -}); - -test("already compliant snapshots are not rescheduled on restore", () => { - const h = recoveryHarness({ ageMinutes: 5, advertisedMinutes: 30, reminderCount: 1 }); - const result = h.restore(); - assert.ok(result); - assert.equal(result.giveawayData.endTs, h.originalEnd); - assert.deepEqual(Array.from(result.giveawayData.reminderSchedule), [15 * MINUTE]); - assert.equal(result.giveawayData.totalSeconds, 30 * 60); -}); - -test("committed settlement retains original reminders and deadline intact", () => { - const h = recoveryHarness({ - ageMinutes: 150, advertisedMinutes: 90, - reminderCount: 1, settlement: { committed: true } - }); - const result = h.restore(); - assert.ok(result); - assert.equal(result.giveawayData.endTs, h.originalEnd); - assert.deepEqual(Array.from(result.giveawayData.reminderSchedule), [45 * MINUTE]); -}); - -test("restored reminder count also hydrates the UI for later host/staff time changes", () => { - assert.match( - initialization, - /if \(remNumInput\) \{[\s\S]*?remNumInput\.value = String\(Number\.isFinite\(savedReminderCount\)/ - ); -}); - -test("ancient uncertain opening retains recovery checkpoint without auto-settlement", () => { - const h = recoveryHarness({ - ageMinutes: 3 * 24 * 60, - advertisedMinutes: 90, - openingCheckpoint: { - status: "pending", - startedAt: NOW - 3 * 24 * 60 * MINUTE, - preparedMessage: "Public giveaway intro" - } - }); - const pending = h.restore(); - assert.ok(pending); - assert.equal(pending.giveawayData.openingCheckpoint.status, "pending"); - assert.equal(pending.__expiredAtLoad, true); - assert.equal(pending.giveawayData.endTs, h.cappedEnd); - assert.equal(h.clears.length, 0, "Never erase a possibly-public opening without authoritative reconciliation"); -}); - -test("pre-checkpoint active legacy snapshots never auto-resume or rewrite the deadline", () => { - for (const ageMinutes of [5, 30, 50, 110, 3 * 24 * 60]) { - const h = recoveryHarness({ - ageMinutes, - advertisedMinutes: 90, - openingCheckpoint: null - }); - const state = h.restore(); - assert.ok(state, "Old recoverable data must remain accessible for manual review"); - assert.equal(state.__legacyOpeningUnverified, true); - assert.equal(state.giveawayData.endTs, h.originalEnd, - "Cannot cap an old post-send timestamp as if it were the public opening"); - assert.equal(h.clears.length, 0, "Never erase unverified old giveaway data"); - assert.ok(h.warnings.some(w => w.includes("Unverified legacy opening"))); - } -}); - -test("pre-checkpoint committed and latched payouts stay intact but are not auto-resumed", () => { - for (const settlementState of [ - { settlement: { committed: true, phase: "settling" } }, - { closingIntent: true } - ]) { - const h = recoveryHarness({ - ageMinutes: 150, - advertisedMinutes: 90, - openingCheckpoint: null, - ...settlementState - }); - const snap = h.restore(); - assert.ok(snap); - assert.equal(snap.__legacyOpeningUnverified, true); - assert.equal(snap.giveawayData.endTs, h.originalEnd); - assert.equal(h.clears.length, 0); - } -}); - -test("malformed or incomplete confirmed proof also freezes legacy settlement", () => { - for (const proof of [{ status: "unknown" }, { status: "confirmed" }]) { - const h = recoveryHarness({ ageMinutes: 5, openingCheckpoint: proof }); - const snap = h.restore(); - if (proof.status === "confirmed") { - // The valid new-format fixture includes both openedAt and sponsorStartTs. - assert.equal(snap.__legacyOpeningUnverified, undefined); - } else { - assert.equal(snap.__legacyOpeningUnverified, true); - } - } -}); - -test("legacy recovery is quarantined using the locked snapshot before any mutation", () => { - assert.match(initialization, /snap\.__legacyOpeningUnverified = true/); - const restoreStart = initialization.indexOf("async function restoreGiveawayFromSnapshot("); - const lock = initialization.indexOf("if (!await acquireTabLock())", restoreStart); - const loaded = initialization.indexOf("const lockedSnapshot = loadGiveawaySnapshot()", lock); - const hydrate = initialization.indexOf("// 1) Restore giveaway data", loaded); - assert.ok(restoreStart >= 0 && lock > restoreStart && loaded > lock && hydrate > loaded); - assert.doesNotMatch(initialization.slice(restoreStart, lock), /snap\.__legacyOpeningUnverified/); - const guard = initialization.slice(loaded, hydrate); - assert.match(guard, /lockedSnapshot.__legacyOpeningUnverified === true/); - assert.match(guard, /releaseTabLock\(\)/); - assert.match(guard, /return false;/); -}); - -test("malformed recovery data is preserved without any silent deletion", () => { - const clears = []; - const runtime = vm.createContext({ - REHEARSAL_MODE: false, - MAX_GIVEAWAY_MINUTES: 45, - MAX_REMINDERS: 6, - EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS: 60 * MINUTE, - normalizeUserKey: x => String(x || "").toLowerCase(), - getLoggedInUsername: () => "maghuro", - readActiveGiveawayRawForHost: () => ({ - readOk: true, raw: "{malformed recovery" - }), - clearGiveawaySnapshot: () => clears.push(true), - console: { warn() {} } - }); - vm.runInContext(functions, runtime); - assert.equal(runtime.loadGiveawaySnapshot(), null); - assert.equal(clears.length, 0); -}); - -test("unreadable recovery storage cannot trigger deletion or auto-restore", () => { - const clears = []; - const runtime = vm.createContext({ - REHEARSAL_MODE: false, - normalizeUserKey: x => String(x || "").toLowerCase(), - getLoggedInUsername: () => "maghuro", - readActiveGiveawayRawForHost: () => ({ readOk: false, raw: null }), - clearGiveawaySnapshot: () => clears.push(true), - console: { warn() {} } - }); - vm.runInContext(functions, runtime); - assert.equal(runtime.loadGiveawaySnapshot(), null); - assert.equal(clears.length, 0); -}); - -test("pre-lock snapshot loading never deletes expired recovery data", () => { - const h = recoveryHarness({ ageMinutes: 180, advertisedMinutes: 90 }); - const preserved = h.restore(); - assert.ok(preserved); - assert.equal(preserved.__expiredBeyondGrace, true); - assert.equal(h.clears.length, 0); - const loader = initialization.slice( - initialization.indexOf("function loadGiveawaySnapshot("), - initialization.indexOf("function clearSponsorAccountingRecoveryTimer(") - ); - assert.doesNotMatch(loader, /clearGiveawaySnapshot\(/); - assert.match(loader, /migrateLegacy: false/); - const restoreStart = initialization.indexOf("async function restoreGiveawayFromSnapshot("); - const lock = initialization.indexOf("if (!await acquireTabLock())", restoreStart); - assert.doesNotMatch(initialization.slice(restoreStart, lock), /snap\.__expiredBeyondGrace === true/); - assert.match(initialization, /lockedSnapshot.__expiredBeyondGrace === true/); -}); diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs deleted file mode 100644 index 67c1a6ce..00000000 --- a/tests/bonanza-opening-recovery.test.mjs +++ /dev/null @@ -1,1001 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import test from "node:test"; -import vm from "node:vm"; - -const root = new URL("../", import.meta.url); -const read = p => readFileSync(new URL(p, root), "utf8"); -const lifecycle = read("userscripts/giveaway/src/06-lifecycle.js"); -const initialization = read("userscripts/giveaway/src/05-initialization.js"); -const utilities = read("userscripts/giveaway/src/12-utilities.js"); -const commands = read("userscripts/giveaway/src/10-command-handling.js"); -const sponsorship = read("userscripts/giveaway/src/09-sponsorship-polling.js"); - -function extract(source, signature) { - const start = source.indexOf(" " + signature); - assert.notEqual(start, -1, "Missing " + signature); - const delimiter = "\n }\n"; - const end = source.indexOf(delimiter, start); - assert.notEqual(end, -1, "Unclosed " + signature); - return source.slice(start, end + delimiter.length); -} - -const verifyOpening = extract(lifecycle, "async function verifyPendingGiveawayOpening("); -const openingSend = extract(utilities, "async function sendMessage("); -const MINUTE = 60_000; -const START = Date.UTC(2026, 9, 10, 11, 0, 0, 500); -const sameSecond = new Date(START - 500).toISOString().replace(".000Z", "Z"); -const nextSecond = new Date(START + 500).toISOString().replace(".000Z", "Z"); -const preparedMessage = "🎁 I am hosting a giveaway for 100 BON"; - -function createHarness({ records = [], responses = null, owned = () => true } = {}) { - const calls = []; - const checks = { - status: "pending", - startedAt: START, - afterMessageId: 100, - windowEndAt: START + 10_000, - preparedMessage - }; - const runtime = vm.createContext({ - URL, - location: { origin: "https://darkpeers.org" }, - OT_USER_ID: 17, - DARKPEERS_MAIN_CHATROOM_ID: 1, - OPENING_POST_MAX_PUBLICATION_WINDOW_MS: 10_000, - ownsTabLock: owned, - optionalFiniteNumber: value => value == null ? null : Number(value), - unit3dTimestampResolutionMs: raw => String(raw).includes(".") ? 1 : 1000, - fetchWithTimeout: async url => { - calls.push(String(url)); - const result = responses ? responses.shift() : records; - if (result instanceof Error) throw result; - return { - ok: true, - json: async () => ({ data: result }) - }; - }, - setTimeout: fn => fn(), - console: { warn() {} } - }); - vm.runInContext(verifyOpening, runtime); - return { run: () => runtime.verifyPendingGiveawayOpening(checks), calls, checks }; -} - -const goodMessage = (overrides = {}) => ({ - id: 101, - user_id: 17, - message: preparedMessage, - created_at: nextSecond, - ...overrides -}); - -test("opening reconciliation requires authenticated author, exact announcement and cursor", async () => { - const h = createHarness({ records: [ - goodMessage({ id: 100 }), - goodMessage({ user_id: 18 }), - goodMessage({ message: "another giveaway" }), - goodMessage() - ] }); - const result = await h.run(); - assert.ok(result); - assert.equal(result.messageId, 101); - assert.equal(result.sponsorStartTs, START + 1500); - assert.match(h.calls[0], /after_id=100/); -}); - -test("a timestamp bucket overlapping the pre-send boundary stays unresolved", async () => { - // Same-second history can represent a message posted before this attempt. - const h = createHarness({ records: [goodMessage({ created_at: sameSecond })] }); - assert.equal(await h.run(), null); - assert.equal(h.calls.length, 3); -}); - -test("verified sponsorship starts strictly AFTER a fully proven publication bucket", async () => { - const h = createHarness({ records: [goodMessage()] }); - const result = await h.run(); - assert.ok(result); - assert.equal(result.sponsorStartTs, START + 1500); - assert.ok(result.sponsorStartTs > START); -}); - -test("a later identical host message cannot confirm an old opening", async () => { - // Cursor and identity alone are insufficient: the same announcement can - // be posted later by the host while the original send was never published. - const later = new Date(START + 15_000).toISOString(); - const h = createHarness({ records: [goodMessage({ created_at: later, id: 102 })] }); - assert.equal(await h.run(), null); -}); - -test("opening publication buckets crossing the upper send window are rejected", async () => { - const upperBucket = new Date(START + 9_500).toISOString() - .replace(".000Z", "Z"); - const h = createHarness({ records: [ - goodMessage({ created_at: upperBucket }) - ] }); - // 11:00:10.000 .. 11:00:11.000 overlaps 11:00:10.500 bound. - assert.equal(await h.run(), null); -}); - -test("a timestamp bucket fully inside the persisted opening window is accepted", async () => { - const h = createHarness({ records: [ - goodMessage({ created_at: new Date(START + 9900).toISOString() }) - ] }); - const result = await h.run(); - assert.ok(result); - assert.equal(result.sponsorStartTs, START + 9901); -}); - -test("response completion narrows the publication window for confirmed and failed sends", async () => { - const accepted = createHarness({ records: [goodMessage()] }); - accepted.checks.sendCompletedAt = START + 1700; - assert.ok(await accepted.run()); - - const rejected = createHarness({ records: [goodMessage()] }); - rejected.checks.sendCompletedAt = START + 1200; - assert.equal(await rejected.run(), null); -}); - -test("an invalid persisted upper boundary never widens recovery into later history", async () => { - const h = createHarness({ records: [goodMessage()] }); - h.checks.windowEndAt = START - 1; - assert.equal(await h.run(), null); - assert.equal(h.calls.length, 0); -}); - -test("ambiguous opening does not accept someone else's message or mismatched text", async () => { - const h = createHarness({ records: [ - goodMessage({ user_id: 20 }), - goodMessage({ message: "old intro" }) - ] }); - assert.equal(await h.run(), null); - assert.equal(h.calls.length, 3); -}); - -test("a missing announcement is not treated as definitive non-publication", async () => { - const h = createHarness({ records: [] }); - assert.equal(await h.run(), null); - assert.equal(h.calls.length, 3); -}); - -test("failed authoritative chat reads preserve ambiguous opening", async () => { - const h = createHarness({ responses: [ - new Error("timeout"), new Error("timeout"), new Error("timeout") - ] }); - assert.equal(await h.run(), null); - assert.equal(h.calls.length, 3); -}); - -test("ownership loss during reconciliation aborts without verification", async () => { - let count = 0; - const h = createHarness({ - records: [goodMessage()], - owned: () => ++count === 1 - }); - assert.equal(await h.run(), null); -}); - -test("reconciliation refuses matching messages entirely before the send boundary", async () => { - const timestamp = new Date(START - 2500).toISOString().replace(".000Z", "Z"); - const h = createHarness({ records: [goodMessage({ created_at: timestamp })] }); - assert.equal(await h.run(), null); -}); - -test("ambiguous opening API timeouts do not trigger chatbox fallback duplicates", async () => { - let fallback = 0; - const runtime = vm.createContext({ - REHEARSAL_MODE: false, - DEBUG_SETTINGS: { - disable_chat_output: false, - suppressApiMessages: false, - log_chat_messages: false, - verify_sendmessage: false - }, - OT_USER_ID: 17, - OT_CHATROOM_ID: 1, - OT_CSRF_TOKEN: "token", - prepareOutgoingMessage: text => text, - trySendViaApi: async () => { throw new Error("POST timed out"); }, - sendViaChatbox: () => { fallback++; return true; }, - console: { warn() {} } - }); - vm.runInContext(openingSend, runtime); - assert.equal(await runtime.sendMessage("opening", { - prepared: true, noFallbackOnApiException: true - }), false); - assert.equal(fallback, 0); - assert.equal(await runtime.sendMessage("ordinary", { prepared: true }), true); - assert.equal(fallback, 1); -}); - -test("recovery checkpoint and independent sponsor boundary survive snapshots", () => { - assert.match(initialization, /openingCheckpoint: giveawayData\.openingCheckpoint/); - assert.match(initialization, /sponsorStartTs: giveawayData\.sponsorStartTs/); - assert.match(initialization, /verifyPendingGiveawayOpening\(/); - const start = initialization.indexOf('if (snap.giveawayData?.openingCheckpoint?.status === "pending")'); - const restore = initialization.indexOf("// 1) Restore giveaway data", start); - assert.ok(start >= 0 && restore > start); - const guard = initialization.slice(start, restore); - assert.match(guard, /if \(!verified\)/); - assert.match(guard, /releaseTabLock\(\)/); - assert.match(guard, /EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS/); - assert.match(guard, /localStorage\.setItem\(storageKey, verifiedRaw\)/); - assert.match(guard, /read-back mismatch/); - assert.doesNotMatch(guard, /endGiveaway\(/); - assert.match(initialization, /giveawayStartTime: new Date\(\s*giveawayData\.sponsorStartTs \?\?/); -}); - -test("winners and host top-ups route command announcements privately under Silent Mode", () => { - const winnerHandler = commands.slice( - commands.indexOf(" winners(ctx) {"), - commands.indexOf(" maxwinners(ctx) {") - ); - assert.match(winnerHandler, /reply\(announcement\)/); - assert.doesNotMatch(winnerHandler, /sendMessage\(announcement\)/); - - const topUp = commands.slice( - commands.indexOf(" async function hostAddBon("), - commands.indexOf(" function rebuildSchedule()") - ); - assert.match(topUp, /sendCommandResponse\(author, potAnnouncement, \{/); - assert.doesNotMatch(topUp, /await sendMessage\(\s*`\$\{bridgeMarker/); - assert.match(utilities, /async function sendCommandResponse\(username, messageStr, options = \{\}\)/); - assert.match(utilities, /return sendPrivateMessage\(username, messageStr, options\)/); -}); - -test("recoverable uncertain opening blocks a second start from overwriting its snapshot", () => { - const start = lifecycle.indexOf("async function startGiveaway()"); - const startLock = lifecycle.indexOf("await acquireTabLock()", start); - const safety = lifecycle.slice(start, startLock); - assert.match(safety, /readActiveGiveawayRawForHost\(getLoggedInUsername\(\)/); - assert.match(safety, /!proof \|\| proof\.status !== "confirmed"/); - assert.match(safety, /GIVEAWAY START BLOCKED/); -}); - -test("manual !reminder uses the public broadcast path, regardless of Silent Mode", () => { - assert.match(commands, /sendReminder\(\{ force: true \}\)/); - assert.doesNotMatch(commands, /commandAuthor: ctx\.author/); - const reminder = extract(utilities, "function sendReminder("); - assert.match(reminder, /return sendMessage\(msg\)/); - assert.doesNotMatch(reminder, /sendCommandResponse\(/); -}); - -function reminderHarness({ silent = false, rehearsal = false } = {}) { - const publicMessages = []; - const privateMessages = []; - const ctx = vm.createContext({ - giveawayData: { - host: "maghuro", amount: 100, startNum: 1, endNum: 20, - customMessage: "Good luck!", donationPercent: 0, totalEntries: 20 - }, - REHEARSAL_MODE: rehearsal, - GENERAL_SETTINGS: { silent_mode: silent }, - DEBUG_SETTINGS: { - disable_chat_output: false, suppressApiMessages: false, - log_chat_messages: false, verify_sendmessage: false - }, - OT_USER_ID: 17, OT_CHATROOM_ID: 1, OT_CSRF_TOKEN: "token", - reminderRetryTimeout: null, - isGiveawaySettling: () => false, - getGiveawayRemainingMs: () => 10 * MINUTE, - shouldSendReminder: () => true, - silentNote: () => "", - rigNote: () => "", - normalizeDonationPercent: () => 0, - riggedMode: false, - BRIDGE_MARKERS: { START: "start" }, - bridgeMarker: () => "🎁", - fmtBONCurrency: amount => String(amount), - cleanPotString: amount => amount, - buildWinnersAnnouncementLine: () => "1 winner", - parseTime: () => "10:00", - getGiftSyntaxHostName: () => "maghuro", - buildDonationContext: () => "", - GIFT_HINT_COLOR: "#abcdef", - getLoggedInUsername: () => "maghuro", - prepareOutgoingMessage: text => text, - trySendViaApi: async text => { publicMessages.push(text); return true; }, - sendPrivateMessage: async (user, text) => { - privateMessages.push({ user, text }); - return true; - }, - formatRehearsalPrivateOutput: text => "[REHEARSAL] " + text, - sanitizeNick: x => x, - logEvent() {} - }); - vm.runInContext([ - extract(utilities, "async function sendMessage("), - extract(utilities, "function sendReminder(") - ].join("\n"), ctx); - return { send: options => ctx.sendReminder(options), publicMessages, privateMessages }; -} - -test("!reminder broadcasts the actual reminder to Main Chat with Silent Mode enabled", async () => { - const h = reminderHarness({ silent: true }); - assert.equal(await h.send({ force: true }), true); - assert.equal(h.publicMessages.length, 1); - assert.match(h.publicMessages[0], /Ongoing giveaway for/); - assert.match(h.publicMessages[0], /Time left:/); - assert.equal(h.privateMessages.length, 0); -}); - -test("manual and scheduled reminders share the public chat route in normal mode", async () => { - const h = reminderHarness(); - assert.equal(await h.send({ force: true }), true); - assert.equal(await h.send(), true); - assert.equal(h.publicMessages.length, 2); - assert.equal(h.privateMessages.length, 0); -}); - -test("Rehearsal Mode keeps manually requested reminders private to the host", async () => { - const h = reminderHarness({ silent: true, rehearsal: true }); - assert.equal(await h.send({ force: true }), true); - assert.equal(h.publicMessages.length, 0); - assert.equal(h.privateMessages.length, 1); - assert.equal(h.privateMessages[0].user, "maghuro"); - assert.match(h.privateMessages[0].text, /\[REHEARSAL\]/); - assert.match(h.privateMessages[0].text, /Ongoing giveaway for/); -}); - -test("missing pre-send Main Chat cursor blocks live opening instead of matching an older post", async () => { - for (const cursor of [null, 0, -1]) { - const h = createHarness({ records: [goodMessage()] }); - h.checks.afterMessageId = cursor; - assert.equal(await h.run(), null, "Bad cursor " + cursor + " must fail closed"); - assert.equal(h.calls.length, 0, "No ambiguous authoritative match without a pre-send cursor"); - } - const start = lifecycle.indexOf("const afterMessageId = REHEARSAL_MODE"); - const post = lifecycle.indexOf("const introSent = await sendMessage(", start); - const guard = lifecycle.slice(start, post); - assert.match(guard, /!Number\.isSafeInteger\(afterMessageId\)/); - assert.match(guard, /afterMessageId <= 0/); - assert.match(guard, /resetGiveaway\(\)/); - assert.ok(guard.indexOf("resetGiveaway()") < guard.indexOf("openingCheckpoint = {")); -}); - -test("chat reconciliation does not accept missing IDs or records preceding the cursor", async () => { - const h = createHarness({ records: [ - goodMessage({ id: null }), - goodMessage({ id: 100 }) - ] }); - assert.equal(await h.run(), null); -}); - -test("a confirmed opening enables sponsor accounting only after canonical reconciliation", () => { - const anchor = lifecycle.indexOf("giveawayData.sponsorStartTs = verifiedOpening.sponsorStartTs"); - const code = lifecycle.slice(anchor, lifecycle.indexOf("chatReplayIgnoreBeforeTs = null;", anchor)); - const reconcile = code.indexOf("await tracker.reconcileCanonicalSponsorAccounting("); - const verifyPersist = code.indexOf("snapshotGiveaway({ force: true, verifyWrite: true })", reconcile); - const confirmed = code.indexOf("setSponsorAccountingVerificationState(", verifyPersist); - const deferred = code.indexOf("scheduleSponsorAccountingRecovery(tracker)"); - assert.ok(reconcile >= 0, "Must reconcile canonical Gift History after verified opening"); - assert.ok(verifyPersist > reconcile, "Must persist canonical reconciliation"); - assert.ok(confirmed > verifyPersist, "Cannot enable accounting before persistence"); - assert.ok(deferred > confirmed, "On failure recovery remains retryable"); - assert.match(code, /if \(giveawayData\.__sponsorAccountingVerified === true\)/); - assert.match(code, /scheduleSponsorAccountingRecovery\(tracker\)/); -}); - -test("the bounded send window is persisted before POST and completion before verification", () => { - const beginning = lifecycle.indexOf("const openingSendStartedAt = Date.now();"); - const storeHardEnd = lifecycle.indexOf( - "windowEndAt: openingSendStartedAt + OPENING_POST_MAX_PUBLICATION_WINDOW_MS", beginning - ); - const firstPersistence = lifecycle.indexOf( - "snapshotGiveaway({ force: true, verifyWrite: true })", storeHardEnd - ); - const posted = lifecycle.indexOf("const introSent = await sendMessage(", firstPersistence); - const finished = lifecycle.indexOf( - "giveawayData.openingCheckpoint.sendCompletedAt = Date.now();", posted - ); - const persistedCompletion = lifecycle.indexOf( - "snapshotGiveaway({ force: true, verifyWrite: true })", finished - ); - const verified = lifecycle.indexOf("await verifyPendingGiveawayOpening(", posted); - assert.ok(beginning >= 0 && storeHardEnd > beginning); - assert.ok(firstPersistence > storeHardEnd && posted > firstPersistence); - assert.ok(finished > posted && persistedCompletion > finished && verified > persistedCompletion); - assert.match(lifecycle, /timestamp < startedAt/); - assert.match(lifecycle, /\(timestamp \+ resolutionMs\) > windowEnd/); -}); - -function sponsorHistoryFilterHarness({ calibrated = true } = {}) { - const start = sponsorship.indexOf(" async filterHistoryRowsByWindow("); - const end = sponsorship.indexOf("\n async processGiftHistoryRows(", start); - assert.ok(start >= 0 && end > start, "SponsorTracker filter extraction failed"); - const method = sponsorship.slice(start, end); - const ctx = vm.createContext({ - optionalFiniteNumber: val => val === undefined || val === null ? null : Number(val), - DEBUG_SETTINGS: { log_chat_messages: false }, - unit3dTimestampResolutionMs: () => 1000, - normalizeUserKey: x => String(x || "").toLowerCase(), - logEvent() {}, - sanitizeNick: x => x, - fmtBONCurrency: x => x - }); - vm.runInContext(`class HistoryFilterTest { ${method} } - this.HistoryFilterTest = HistoryFilterTest;`, ctx); - const tracker = new ctx.HistoryFilterTest(); - tracker.data = { endTs: 5000 }; - tracker.maxAcceptedCreatedAtTs = null; - tracker.giftHistoryClockOffsetMs = calibrated ? 0 : null; - tracker.giftHistorySeenKeys = new Set(); - tracker.ensureGiftHistoryClockOffset = async () => calibrated ? 0 : null; - tracker.fetchRecentChatGiftEvents = async () => []; - return tracker; -} -function giftAt(time, key) { - return { - historyKey: key, - sender: "sponsor", recipient: "host", amount: 100, - createdAtTs: time, createdAtAltTs: time, - rawTimestamp: "2026-10-10 11:00:02", - timestampResolutionMs: 1000 - }; -} - -test("calibrated Gift History excludes a coarse gift overlapping the sponsor-opening boundary", async () => { - const tracker = sponsorHistoryFilterHarness({ calibrated: true }); - const result = await tracker.filterHistoryRowsByWindow([ - giftAt(2000, "before"), - giftAt(3101, "after"), - giftAt(3500, "later") - ], 2000.5, 4500); - assert.equal(result.accepted.length, 2); - assert.equal(result.accepted[0].historyKey, "after"); - assert.equal(result.accepted[1].historyKey, "later"); -}); - -test("uncalibrated Gift History defers ALL gifts rather than guessing a timezone", async () => { - const tracker = sponsorHistoryFilterHarness({ calibrated: false }); - const result = await tracker.filterHistoryRowsByWindow([ - giftAt(2000, "before"), - giftAt(3101, "apparently-after") - ], 2000.5, 4500); - assert.equal(result.accepted.length, 0); - assert.deepEqual(Array.from(result.retryableKeys).sort(), ["apparently-after", "before"]); -}); - -test("uncalibrated interpretations remain unverified even with no closing cutoff", async () => { - const tracker = sponsorHistoryFilterHarness({ calibrated: false }); - tracker.data.endTs = null; - const result = await tracker.filterHistoryRowsByWindow([ - giftAt(3101, "unresolved") - ], 2000.5, null); - assert.equal(result.accepted.length, 0); - assert.deepEqual(Array.from(result.retryableKeys), ["unresolved"]); -}); - -test("even without a closing timestamp, an overlapping pre-opening gift is not sponsored", async () => { - const tracker = sponsorHistoryFilterHarness(); - tracker.data.endTs = null; - const result = await tracker.filterHistoryRowsByWindow([ - giftAt(1999, "old"), - giftAt(3101, "new") - ], 2000.5, null); - assert.equal(result.accepted.length, 1); - assert.equal(result.accepted[0].historyKey, "new"); -}); - -test("non-OK opening POST never retries the same announcement via chatbox", async () => { - let publicApiCalls = 0; - let fallbackCalls = 0; - const sandbox = vm.createContext({ - REHEARSAL_MODE: false, - DEBUG_SETTINGS: { - disable_chat_output: false, suppressApiMessages: false, - verify_sendmessage: false, log_chat_messages: false - }, - OT_USER_ID: 17, OT_CHATROOM_ID: 1, OT_CSRF_TOKEN: "csrf", - prepareOutgoingMessage: x => x, - fetchWithTimeout: async () => { - publicApiCalls++; - return { ok: false, status: 500, text: async () => '{"error":"server error"}' }; - }, - sendViaChatbox: () => { fallbackCalls++; return true; }, - console: { error() {}, warn() {} } - }); - vm.runInContext([ - extract(utilities, "async function trySendViaApi("), - openingSend - ].join("\n"), sandbox); - assert.equal(await sandbox.sendMessage("opening", { - prepared: true, noFallbackOnApiException: true - }), false); - assert.equal(publicApiCalls, 1); - assert.equal(fallbackCalls, 0); - assert.equal(await sandbox.sendMessage("ordinary", { prepared: true }), true); - assert.equal(publicApiCalls, 2); - assert.equal(fallbackCalls, 1); -}); - -test("opening never falls back when the API is disabled or returns false before posting", async () => { - for (const suppressed of [true, false]) { - let fallbackCalls = 0; - const sandbox = vm.createContext({ - REHEARSAL_MODE: false, - DEBUG_SETTINGS: { - disable_chat_output: false, suppressApiMessages: suppressed, - verify_sendmessage: false, log_chat_messages: false - }, - OT_USER_ID: 17, OT_CHATROOM_ID: 1, OT_CSRF_TOKEN: "csrf", - prepareOutgoingMessage: x => x, - trySendViaApi: async () => false, - sendViaChatbox: () => { fallbackCalls++; return true; }, - console: { warn() {} } - }); - vm.runInContext(openingSend, sandbox); - assert.equal(await sandbox.sendMessage("opening", { - prepared: true, noFallbackOnApiException: true - }), false); - assert.equal(fallbackCalls, 0); - } -}); - -test("calibration fractional bias cannot turn a pre-opening gift into a sponsor", async () => { - const tracker = sponsorHistoryFilterHarness({ calibrated: true }); - // A calibration anchor at .750s produces offset -750ms for a coarse - // Gift History .000s bucket. Without uncertainty the gift falsely moves - // 750ms later and falls beyond the 3000.5ms opening boundary. - tracker.giftHistoryClockOffsetMs = -750; - tracker.giftHistoryClockOffsetUncertaintyMs = 1000; - tracker.data.endTs = 10000; - const result = await tracker.filterHistoryRowsByWindow([ - giftAt(2500, "before"), - giftAt(4250, "after") - ], 3000.5, 10000); - assert.deepEqual(Array.from(result.accepted, x => x.historyKey), ["after"]); -}); - -test("the inferred calibration error is propagated and conserved", () => { - const start = sponsorship.indexOf(" inferGiftHistoryClockOffset("); - const end = sponsorship.indexOf("\n matchGiftEventsToRows(", start); - assert.ok(start >= 0 && end > start); - const method = sponsorship.slice(start, end); - const context = vm.createContext({ - optionalFiniteNumber: x => x == null ? null : Number(x), - normalizeUserKey: x => String(x || "").toLowerCase(), - unit3dTimestampResolutionMs: () => 1000 - }); - vm.runInContext( - `class CalibrationTest { ${method} } this.CalibrationTest = CalibrationTest;`, - context - ); - const tracker = new context.CalibrationTest(); - tracker.giftHistoryClockOffsetMs = null; - const offset = tracker.inferGiftHistoryClockOffset( - [{ gifter: "sponsor", recipient: "host", rawAmount: 100, createdAtTs: 2750 }], - [{ sender: "sponsor", recipient: "host", amount: 100, createdAtAltTs: 2000, - rawTimestamp: "2026-10-10 11:00:02", timestampResolutionMs: 1000 }] - ); - assert.equal(offset, -750); - assert.ok(tracker.giftHistoryClockOffsetUncertaintyMs >= 1000); -}); - -test("clock-offset uncertainty survives sponsor snapshot and restore", () => { - const load = initialization; - const sponsorshipTrackerStart = sponsorship.slice( - sponsorship.indexOf(" class SponsorTracker {"), - sponsorship.indexOf(" /* ---- poll for any chat messages since last cursor ---- */") - ); - assert.match(sponsorshipTrackerStart, /giftHistoryClockOffsetUncertaintyMs = null/); - assert.match(load, /giftHistoryClockOffsetUncertaintyMs:\s*optionalFiniteNumber\(window\.__activeTracker\.giftHistoryClockOffsetUncertaintyMs\)/); - assert.match(load, /giftHistoryClockOffsetUncertaintyMs: savedTracker/); -}); - -test("starting a new giveaway repeats recovery snapshot check inside the acquired lock", () => { - const start = lifecycle.indexOf("async function startGiveaway()"); - const beforeLock = lifecycle.indexOf("const prior = readActiveGiveawayRawForHost(", start); - const locked = lifecycle.indexOf("if (!await acquireTabLock())", start); - const afterLock = lifecycle.indexOf("const savedAfterLock = readActiveGiveawayRawForHost(", locked); - const firstMutation = lifecycle.indexOf("if (sponsorsInterval)", afterLock); - const guard = lifecycle.slice(afterLock, firstMutation); - assert.ok(start >= 0 && beforeLock > start && locked > beforeLock); - assert.ok(afterLock > locked && firstMutation > afterLock); - assert.match(guard, /if \(!savedAfterLock\.readOk \|\| savedAfterLock\.hasStoredState \|\| !savedAfterLock\.storageKey\)/); - assert.match(guard, /releaseTabLock\(\)/); - assert.match(guard, /return;/); -}); - -test("authoritatively confirmed opening must reverify identical original message ID", async () => { - const valid = createHarness({ records: [goodMessage()] }); - valid.checks.status = "confirmed"; - valid.checks.messageId = 101; - const result = await valid.run(); - assert.ok(result); - assert.equal(result.messageId, 101); - - const altered = createHarness({ records: [goodMessage({ id: 102 })] }); - altered.checks.status = "confirmed"; - altered.checks.messageId = 101; - assert.equal(await altered.run(), null); - - const missing = createHarness({ records: [goodMessage()] }); - missing.checks.status = "confirmed"; - assert.equal(await missing.run(), null); -}); - -test("confirmed restore verifies Main Chat before reloading entries or settlement", () => { - const begin = initialization.indexOf("async function restoreGiveawayFromSnapshot("); - const afterLock = initialization.indexOf("if (!await acquireTabLock())", begin); - const check = initialization.indexOf("restoredCheckpoint?.status === \"confirmed\"", afterLock); - const verify = initialization.indexOf("await verifyPendingGiveawayOpening(restoredCheckpoint)", check); - const restoreEntries = initialization.indexOf("// 1) Restore giveaway data", verify); - assert.ok(begin >= 0 && afterLock > begin && check > afterLock); - assert.ok(verify > check && restoreEntries > verify); - const guard = initialization.slice(check, restoreEntries); - assert.match(guard, /verified\.messageId !== optionalFiniteNumber\(restoredCheckpoint\.messageId\)/); - assert.match(guard, /verified\.sponsorStartTs !== savedSponsorStart/); - assert.match(guard, /releaseTabLock\(\)/); - assert.match(guard, /return false;/); -}); - -test("unverified history keys remain retryable across polling and block canonical payout", () => { - assert.match(sponsorship, /if \(min !== null && offset === null\)/); - assert.match(sponsorship, /retryableKeys: new Set\(/); - assert.match(sponsorship, /if \(!item\.historyKey \|\| retryableBoundaryKeys\.has\(item\.historyKey\)\) continue/); - assert.match(sponsorship, /if \(filtered\.retryableKeys\.size\)/); - assert.match(sponsorship, /Gift History reconciliation found timestamp-boundary rows/); -}); - -function recoveryReadHarness({ namespaced = null, legacy = null, throwsOn = "" } = {}) { - const storage = new Map(); - const readCounts = new Map(); - if (namespaced !== null) storage.set("recovery::maghuro", namespaced); - if (legacy !== null) storage.set("recovery", legacy); - const events = []; - const ctx = vm.createContext({ - REHEARSAL_MODE: false, - LS_ACTIVE_GIVEAWAY_LEGACY: "recovery", - REHEARSAL_STORAGE_SUFFIX: "", - normalizeUserKey: s => String(s || "").toLowerCase(), - getLoggedInUsername: () => "maghuro", - getActiveGiveawayStorageKey: () => "recovery::maghuro", - localStorage: { - getItem: key => { - if (throwsOn === "read") throw Error("read denied"); - const count = (readCounts.get(key) || 0) + 1; - readCounts.set(key, count); - if (throwsOn === "verify" && key === "recovery::maghuro" && count > 1) throw Error("read-back denied"); - return storage.has(key) ? storage.get(key) : null; - }, - setItem: (key, value) => { - if (throwsOn === "write") throw Error("write denied"); - storage.set(key, value); - events.push("write:" + key); - }, - removeItem: key => { - if (throwsOn === "remove") throw Error("remove denied"); - if (throwsOn !== "remove-noop") storage.delete(key); - } - }, - console: { warn() {} } - }); - vm.runInContext([ - extract(initialization, "function readActiveGiveawayRawForHost(") - ].join("\n"), ctx); - return { read: (...args) => ctx.readActiveGiveawayRawForHost(...args), events, storage }; -} - -test("recovery storage access failures never masquerade as empty storage", () => { - const denied = recoveryReadHarness({ throwsOn: "read" }).read("maghuro", { migrateLegacy: true }); - assert.equal(denied.readOk, false); - assert.equal(denied.raw, null); - - const malformed = recoveryReadHarness({ legacy: "{bad json" }).read("maghuro", { migrateLegacy: true }); - assert.equal(malformed.readOk, false); - assert.equal(malformed.raw, null); - - const failedWrite = recoveryReadHarness({ - legacy: JSON.stringify({ giveawayData: { host: "maghuro" } }), - throwsOn: "write" - }).read("maghuro", { migrateLegacy: true }); - assert.equal(failedWrite.readOk, false); -}); - -test("only positively established empty recovery storage allows start prechecks", () => { - const absent = recoveryReadHarness().read("maghuro", { migrateLegacy: true }); - assert.equal(absent.readOk, true); - assert.equal(absent.raw, null); - const existing = recoveryReadHarness({ - namespaced: JSON.stringify({ giveawayData: { host: "maghuro" } }) - }).read("maghuro", { migrateLegacy: true }); - assert.equal(existing.readOk, true); - assert.ok(existing.raw); - const start = lifecycle.slice( - lifecycle.indexOf("async function startGiveaway()"), - lifecycle.indexOf("if (sponsorsInterval)") - ); - assert.match(start, /if \(!prior\.readOk \|\| !prior\.storageKey\)/); - assert.match(start, /if \(!savedAfterLock\.readOk \|\| savedAfterLock\.hasStoredState/); -}); - -test("preflight state cannot be snapshotted by pagehide or beforeunload", () => { - const persistentWrites = []; - const sandbox = vm.createContext({ - giveawayData: { host: "maghuro", openedAt: null, endTs: 123456 }, - canMutateActiveGiveaway: () => true, - window: {}, - localStorage: { setItem: (...args) => persistentWrites.push(args) } - }); - vm.runInContext(extract(initialization, "function snapshotGiveaway("), sandbox); - assert.equal(sandbox.snapshotGiveaway({ force: true, verifyWrite: true }), false); - assert.equal(sandbox.snapshotGiveaway(), false); - assert.equal(persistentWrites.length, 0); - const start = lifecycle.slice( - lifecycle.indexOf("async function startGiveaway()"), - lifecycle.indexOf("async function resetGiveaway(") - ); - assert.match(start, /if \(!ownsTabLock\(\)\) return;/); - const hide = initialization.slice( - initialization.indexOf("function handleGiveawayPageHide("), - initialization.indexOf("function handleGiveawayPageShow(") - ); - assert.match(hide, /snapshotGiveaway\(\)/); - assert.match(initialization, /if \(!giveawayData \|\|[\s\S]*?!proof \|\|/); -}); - -test("empty but present recovery key is occupied and blocks a new opening", () => { - const empty = recoveryReadHarness({ namespaced: "" }).read("maghuro", { migrateLegacy: true }); - assert.equal(empty.readOk, true); - assert.equal(empty.hasStoredState, true); - assert.equal(empty.raw, ""); - const trulyEmpty = recoveryReadHarness().read("maghuro", { migrateLegacy: true }); - assert.equal(trulyEmpty.readOk, true); - assert.equal(trulyEmpty.hasStoredState, false); - const start = lifecycle.slice( - lifecycle.indexOf("async function startGiveaway()"), - lifecycle.indexOf("if (sponsorsInterval)") - ); - assert.match(start, /if \(prior\.hasStoredState\)/); - assert.match(start, /if \(!savedAfterLock\.readOk \|\| savedAfterLock\.hasStoredState/); -}); - -test("empty legacy key is occupied, unreadable and never treated as vacant", () => { - const empty = recoveryReadHarness({ legacy: "" }).read("maghuro", { migrateLegacy: false }); - assert.equal(empty.readOk, false, "Corrupt present legacy key cannot permit a new start"); - assert.equal(empty.raw, null); - const afterLock = recoveryReadHarness({ legacy: "" }).read("maghuro", { migrateLegacy: true }); - assert.equal(afterLock.readOk, false); - assert.equal(afterLock.raw, null); -}); - -test("initial legacy discovery is strictly read-only; migration waits for lock", () => { - const raw = JSON.stringify({ giveawayData: { host: "maghuro" } }); - const h = recoveryReadHarness({ legacy: raw }); - const before = h.read("maghuro", { migrateLegacy: false }); - assert.equal(before.readOk, true); - assert.equal(before.hasStoredState, true); - assert.equal(before.raw, raw); - assert.deepEqual(h.events, []); - const after = h.read("maghuro", { migrateLegacy: true }); - assert.equal(after.readOk, true); - assert.equal(after.migratedLegacy, true); - assert.deepEqual(h.events, ["write:recovery::maghuro"]); - const loadStart = initialization.indexOf("function loadGiveawaySnapshot()"); - const loadEnd = initialization.indexOf("function clearSponsorAccountingRecoveryTimer()", loadStart); - const loader = initialization.slice(loadStart, loadEnd); - assert.match(loader, /migrateLegacy: false/); - assert.doesNotMatch(loader, /migrateLegacy: true/); -}); - -test("restore discards pre-lock object and freshly validates durable state under exclusive ownership", () => { - const start = initialization.indexOf("async function restoreGiveawayFromSnapshot("); - const acquire = initialization.indexOf("if (!await acquireTabLock())", start); - const read = initialization.indexOf("const lockedRead = readActiveGiveawayRawForHost(", acquire); - const validateRead = initialization.indexOf("!lockedRead.readOk", read); - const fresh = initialization.indexOf("const lockedSnapshot = loadGiveawaySnapshot()", read); - const freshness = initialization.indexOf("snap = lockedSnapshot;", fresh); - const verified = initialization.indexOf("const restoredCheckpoint = snap.giveawayData?.openingCheckpoint", freshness); - const hydrate = initialization.indexOf("// 1) Restore giveaway data", verified); - assert.ok(start >= 0 && acquire > start && read > acquire); - assert.ok(validateRead > read && fresh > validateRead && freshness > fresh); - assert.ok(verified > freshness && hydrate > verified); - const guard = initialization.slice(read, freshness); - assert.match(guard, /migrateLegacy: true/); - assert.match(guard, /!lockedRead.hasStoredState/); - assert.match(guard, /!ownsTabLock\(\)/); - assert.match(guard, /releaseTabLock\(\)/); - assert.match(guard, /lockedSnapshot.__legacyOpeningUnverified === true/); -}); - -test("fresh restore rejects deleted or corrupted recovery even when caller holds old snapshot", () => { - const begin = initialization.indexOf("const lockedRead = readActiveGiveawayRawForHost("); - const end = initialization.indexOf("snap = lockedSnapshot;", begin); - const body = initialization.slice(begin, end); - assert.match(body, /!lockedRead.readOk/); - assert.match(body, /!lockedRead.hasStoredState/); - assert.match(body, /!lockedRead.raw/); - assert.match(body, /!lockedSnapshot/); - assert.match(body, /normalizeUserKey\(lockedSnapshot.giveawayData.host\)/); - assert.match(body, /lockedSnapshot.giveawayData.rehearsalMode === true/); -}); - -test("valid but hostless legacy records remain occupied and block new starts", () => { - const examples = [ - "{}", '{"giveawayData":{}}', '{"giveawayData":{"host":""}}', - '{"giveawayData":{"host":null}}', "null", '"unexpected"', - '{"giveawayData":{"host":[]}}', '{"giveawayData":{"host":["maghuro"]}}', - '{"giveawayData":{"host":{}}}', '{"giveawayData":{"host":{"user":"maghuro"}}}', - '{"giveawayData":{"host":123}}', '{"giveawayData":{"host":true}}' - ]; - for (const raw of examples) { - for (const migrateLegacy of [false, true]) { - const h = recoveryReadHarness({ legacy: raw }); - const state = h.read("maghuro", { migrateLegacy }); - assert.equal(state.readOk, false, `Unidentified record must fail closed: ${raw}`); - assert.equal(state.hasStoredState, true); - assert.equal(state.raw, raw); - assert.equal(state.migratedLegacy, false); - assert.deepEqual(h.events, [], "Unidentified state must not be migrated"); - } - } - const foreignRaw = JSON.stringify({ giveawayData: { host: "different-host" } }); - for (const migrateLegacy of [false, true]) { - const h = recoveryReadHarness({ legacy: foreignRaw }); - const state = h.read("maghuro", { migrateLegacy }); - assert.equal(state.readOk, true); - assert.equal(state.hasStoredState, false); - assert.equal(state.raw, null); - assert.deepEqual(h.events, []); - } -}); - -test("restore rejects stale recovery flags only after refreshing under exclusive lock", () => { - const start = initialization.indexOf("async function restoreGiveawayFromSnapshot("); - const lock = initialization.indexOf("if (!await acquireTabLock())", start); - const read = initialization.indexOf("const lockedRead = readActiveGiveawayRawForHost(", lock); - const fresh = initialization.indexOf("const lockedSnapshot = loadGiveawaySnapshot()", read); - const replace = initialization.indexOf("snap = lockedSnapshot;", fresh); - const verify = initialization.indexOf("const restoredCheckpoint = snap.giveawayData?.openingCheckpoint", replace); - assert.ok(start >= 0 && lock > start && read > lock && fresh > read); - assert.ok(replace > fresh && verify > replace); - const prelock = initialization.slice(start, lock); - assert.doesNotMatch(prelock, - /__expiredBeyondGrace|__legacyOpeningUnverified|openingCheckpoint|restoreExpectedHost|snap\.giveawayData/); - const lockedGuard = initialization.slice(fresh, replace); - assert.match(lockedGuard, /lockedSnapshot.__expiredBeyondGrace === true/); - assert.match(lockedGuard, /lockedSnapshot.__legacyOpeningUnverified === true/); - assert.match(lockedGuard, /normalizeUserKey\(lockedSnapshot.giveawayData.host\)/); - assert.match(lockedGuard, /lockedSnapshot.giveawayData.rehearsalMode === true/); - assert.match(lockedGuard, /releaseTabLock\(\)/); - assert.match(lockedGuard, /return false;/); -}); - -test("coexisting namespaced recovery rejects corrupt and inconsistent legacy evidence", () => { - const current = JSON.stringify({ - giveawayData: { host: "maghuro", settlement: { committed: true, ledger: ["already-paid"] } } - }); - const conflicting = [ - "", "{invalid", "{}", '{"giveawayData":{}}', - '{"giveawayData":{"host":[]}}', - JSON.stringify({ giveawayData: { host: "maghuro", settlement: { committed: false } } }) - ]; - for (const legacy of conflicting) { - for (const migrateLegacy of [false, true]) { - const h = recoveryReadHarness({ namespaced: current, legacy }); - const state = h.read("maghuro", { migrateLegacy }); - assert.equal(state.readOk, false, "Unresolved old evidence cannot enable real settlement"); - assert.equal(state.hasStoredState, true); - assert.equal(state.migratedLegacy, false); - assert.deepEqual(h.events, [], "Keep both records intact for manual review"); - } - } -}); - -test("single legacy slot migration verifies cleanup and survives an immediate post-confirmation reload", () => { - const pending = JSON.stringify({ - giveawayData: { host: "maghuro", openingCheckpoint: { status: "pending" } } - }); - const h = recoveryReadHarness({ legacy: pending }); - const discovery = h.read("maghuro", { migrateLegacy: false }); - assert.equal(discovery.readOk, true); - assert.equal(discovery.raw, pending); - assert.equal(h.storage.has("recovery::maghuro"), false, "Discovery cannot migrate"); - assert.equal(h.storage.get("recovery"), pending, "Discovery cannot delete"); - - const migrated = h.read("maghuro", { migrateLegacy: true }); - assert.equal(migrated.readOk, true); - assert.equal(migrated.migratedLegacy, true); - assert.equal(migrated.raw, pending); - assert.equal(h.storage.get("recovery::maghuro"), pending, "Namespaced checkpoint must survive"); - assert.equal(h.storage.has("recovery"), false, "Migration must remove the old key"); - - // An interrupted restore after writing the confirmed opening has no - // leftover legacy record against which to conflict on the next load. - const confirmed = JSON.stringify({ - giveawayData: { host: "maghuro", openingCheckpoint: { status: "confirmed" } } - }); - h.storage.set("recovery::maghuro", confirmed); - const restarted = h.read("maghuro", { migrateLegacy: false }); - assert.equal(restarted.readOk, true); - assert.equal(restarted.raw, confirmed); -}); - -test("single legacy slot migration fails closed on denied or silent removal", () => { - const pending = JSON.stringify({ - giveawayData: { host: "maghuro", openingCheckpoint: { status: "pending" } } - }); - for (const failure of ["remove", "remove-noop"]) { - const h = recoveryReadHarness({ legacy: pending, throwsOn: failure }); - const result = h.read("maghuro", { migrateLegacy: true }); - assert.equal(result.readOk, false, `Restore must abort when legacy deletion fails: ${failure}`); - assert.equal(result.hasStoredState, true); - assert.equal(result.migratedLegacy, false); - assert.equal(h.storage.get("recovery"), pending, "Preserve legacy evidence"); - assert.equal(h.storage.get("recovery::maghuro"), pending, "Preserve migrated checkpoint"); - assert.equal(h.storage.has("recovery"), true, "Do not advance confirmation while old slot remains"); - - // A subsequent successful migration can retire these two identical - // copies instead of losing the original pending ledger state. - const retry = recoveryReadHarness({ namespaced: pending, legacy: pending }); - const recovered = retry.read("maghuro", { migrateLegacy: true }); - assert.equal(recovered.readOk, true); - assert.equal(retry.storage.has("recovery"), false); - } -}); - -test("duplicate same-host recovery remains read-only until exclusive-locked migration", () => { - const identical = JSON.stringify({ giveawayData: { host: "maghuro", openingCheckpoint: { status: "pending" } } }); - const h = recoveryReadHarness({ namespaced: identical, legacy: identical }); - const pre = h.read("maghuro", { migrateLegacy: false }); - assert.equal(pre.readOk, true); - assert.equal(pre.raw, identical); - assert.equal(h.storage.get("recovery"), identical); - assert.deepEqual(h.events, [], "Pre-lock discovery cannot modify legacy recovery"); - - const locked = h.read("maghuro", { migrateLegacy: true }); - assert.equal(locked.readOk, true); - assert.equal(locked.raw, identical); - assert.equal(locked.migratedLegacy, false); - assert.equal(h.storage.get("recovery::maghuro"), identical); - assert.equal(h.storage.has("recovery"), false, "Retire redundant legacy before confirming the opening"); - - // An immediate crash after the pending-to-confirmed checkpoint update - // must not strand recovery against a stale pending legacy slot. - const confirmed = JSON.stringify({ giveawayData: { host: "maghuro", openingCheckpoint: { status: "confirmed" } } }); - h.storage.set("recovery::maghuro", confirmed); - const restarted = h.read("maghuro", { migrateLegacy: false }); - assert.equal(restarted.readOk, true); - assert.equal(restarted.raw, confirmed); - assert.equal(restarted.hasStoredState, true); -}); - -test("duplicate cleanup failures fail closed without overwriting verified recovery", () => { - const identical = JSON.stringify({ giveawayData: { host: "maghuro" } }); - for (const throwsOn of ["remove", "verify", "remove-noop"]) { - const h = recoveryReadHarness({ namespaced: identical, legacy: identical, throwsOn }); - const state = h.read("maghuro", { migrateLegacy: true }); - assert.equal(state.readOk, false, `Cleanup must fail closed: ${throwsOn}`); - assert.equal(state.hasStoredState, true); - assert.equal(h.storage.get("recovery::maghuro"), identical); - assert.equal(h.storage.get("recovery"), identical); - } -}); - -test("duplicate cleanup precedes recovered pending-opening confirmation", () => { - const reader = initialization.slice( - initialization.indexOf("function readActiveGiveawayRawForHost("), - initialization.indexOf("/** Read and validate the shared cross-tab lock.") - ); - assert.match(reader, /if \(migrateLegacy\) \{/); - assert.match(reader, /Duplicate legacy recovery cleanup not confirmed/); - const restoreStart = initialization.indexOf("async function restoreGiveawayFromSnapshot("); - const lockedRead = initialization.indexOf("const lockedRead = readActiveGiveawayRawForHost(", restoreStart); - const update = initialization.indexOf("localStorage.setItem(storageKey, verifiedRaw)", lockedRead); - assert.ok(update > lockedRead, "Cleanup is required during locked read before the confirmed checkpoint write"); -}); - -test("positively foreign legacy evidence does not mask valid namespaced recovery", () => { - const current = JSON.stringify({ giveawayData: { host: "maghuro" } }); - const legacy = JSON.stringify({ giveawayData: { host: "another-host" } }); - for (const migrateLegacy of [false, true]) { - const h = recoveryReadHarness({ namespaced: current, legacy }); - const state = h.read("maghuro", { migrateLegacy }); - assert.equal(state.readOk, true); - assert.equal(state.hasStoredState, true); - assert.equal(state.raw, current); - assert.equal(state.migratedLegacy, false); - assert.deepEqual(h.events, []); - } -}); diff --git a/tests/bonanza-review-hardening.test.mjs b/tests/bonanza-review-hardening.test.mjs index 1a646bfd..4f36c363 100644 --- a/tests/bonanza-review-hardening.test.mjs +++ b/tests/bonanza-review-hardening.test.mjs @@ -9,7 +9,7 @@ const sourcePath = new URL( const source = readFileSync(sourcePath, "utf8"); test("review hardening invariants stay present", () => { - assert.match(source, /^\/\/ @version\s+1\.5\.15$/m); + assert.match(source, /^\/\/ @version\s+1\.5\.16$/m); assert.doesNotMatch(source, /pollChatFallback/); assert.doesNotMatch(source, /onlyguardians/i); assert.match(source, /async function getLatestMainChatReplayBoundary\(\)/); @@ -20,7 +20,7 @@ test("review hardening invariants stay present", () => { assert.match(source, /Rehearsal BON Pool contribution suppressed/); assert.equal( (source.match(/getLatestChatMessageId\(DARKPEERS_MAIN_CHATROOM_ID\)/g) || []).length, - 5 // Including the pre-send opening cursor for ambiguous POST recovery + 4 ); assert.equal( (source.match(/getLatestChatMessageId\(DARKPEERS_CHATROOM_ID\)/g) || []).length, diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 863b14d6..a9b500fd 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -2,7 +2,7 @@ // @name DarkPeers BONanza Giveaway | Maghuro Fork // @namespace https://darkpeers.org/users/maghuro // @description BON giveaways on DarkPeers with an optional direct contribution to the BON Pool -// @version 1.5.15 +// @version 1.5.16 // @author 🤖 T.R.A.V.I.S., Maghuro & M.A.E.S.T.R.O. // @homepageURL https://darkpeers.org/users/maghuro // @updateURL https://gist.githubusercontent.com/maghuro/da2dbfec94951990cbc54e75a9aee318/raw/DarkPeers_BONanza_Giveaway.meta.js @@ -20,6 +20,8 @@ // DarkPeers-only fork of "Blutopia BON Giveaway" v6.2.2 by Nums (GPL-3.0-or-later). // Changes in this fork: +// - v1.5.16 rolls back PR #29 to the previously working v1.5.14 runtime. +// The 45-minute cap and opening-recovery changes are deliberately removed. // - All non-DarkPeers tracker support, the upload.cx extra commands and the // openuserjs update check have been removed. // - A host-selected percentage (0-50%, steps of 5) of the final pot is @@ -244,37 +246,6 @@ // fallback now preserves exclusive ownership, uses the chatbox-only slash-command // path so an ambiguous chat-API timeout can never be mistaken for a definite // no-send, and restores only the exact unsent terminal marker after durable read-back. -// - v1.5.15 enforces DarkPeers' 45-minute maximum from public opening across -// the form, presets, host/staff time commands and restored active snapshots. -// Rejected !addtime / !time add attempts explain the 45-minute total -// limit without changing the deadline. Silent Mode sends all host/staff -// command replies privately, but explicit !reminder requests still post -// publicly just like automatic reminders. Rehearsal routes both to host PMs. -// Opening delivery is checkpointed before the POST and verified against -// Main Chat; a timeout preserves recovery rather than resetting. Sponsor -// eligibility begins after the verified publication timestamp bucket, -// separately from the conservative 45-minute pre-send deadline. Live -// openings require a valid Main Chat cursor before any public POST. -// Only exact announcements whose complete server timestamp bucket lies -// within a persisted, bounded opening attempt can confirm publication; -// later repeated or overlapping same-second announcements are rejected. -// Gift History timestamps also must start no earlier than the verified -// sponsor opening boundary; HTTP error/5xx opening POSTs must never -// trigger a second announcement via chatbox fallback. -// A second tab must recheck saved recovery AFTER acquiring its Web Lock. -// Confirmed LIVE openings are authoritatively rechecked on every restore. -// When Gift History calibration is unavailable, ALL ambiguous gift rows -// remain uncounted/retryable until reliable calibration succeeds. -// Legacy snapshots without verified opening evidence are retained only -// for manual recovery (no automatic 45-minute rewrite or BON payout). -// Coarse Gift History calibration uncertainty is tracked, saved across -// reloads, and subtracted before sponsor-start eligibility checks. -// Recovery storage read failures block new giveaways even when raw data -// appears absent; even empty-but-present recovery keys are occupied. -// Before the exclusive Web Lock, reading must not migrate legacy keys. -// Preflight before the persisted opening checkpoint is -// NEVER snapshotted, including on BFCache/pagehide. Corrupt recovery -// snapshots are preserved rather than silently deleted. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. @@ -301,7 +272,6 @@ // - MIN_ACTION_GAP_MS blocks ultra-fast repeat triggers (usually bots/double-sends) // - REPEAT_COMMAND_COOLDOWNS_MS prevents the same command from being spammed for identical output // - strikes increase lockout length for repeat offenders (decays over time) - const MAX_GIVEAWAY_MINUTES = 45; // DarkPeers rule: hard total cap from public opening const MIN_ACTION_GAP_MS = 900; // ignore triggers faster than this per user const ENTRY_FEEDBACK_COOLDOWN_MS = 8000; // throttle duplicate/out-of-range feedback per user const STRIKE_WINDOW_MS = 10 * 60 * 1000; // 10 minutes @@ -880,7 +850,7 @@ maxwinners: { label: "Max Winners", section: "giveaway", description: "Set max scaled winners.", usage: `!maxwinners 1-${MAX_WINNERS}`, requiresGiveaway: true, hostOnly: true, args: [{ name: "count", label: "Max", type: "int", required: true, min: 1, max: MAX_WINNERS, placeholder: "max" }] }, scale: { label: "Scale", section: "info", description: "Show scaling progress.", usage: "!scale", requiresGiveaway: true }, - addtime: { label: "Add Time", section: "giveaway", description: "Add minutes (45-minute total cap).", usage: "!addtime ", requiresGiveaway: true, hostOnly: true, + addtime: { label: "Add Time", section: "giveaway", description: "Add giveaway minutes.", usage: "!addtime ", requiresGiveaway: true, hostOnly: true, args: [{ name: "minutes", label: "Min", type: "int", required: true, min: 1, placeholder: "minutes" }] }, removetime: { label: "Remove Time", section: "giveaway", description: "Remove giveaway minutes.", usage: "!removetime ", requiresGiveaway: true, hostOnly: true, args: [{ name: "minutes", label: "Min", type: "int", required: true, min: 1, placeholder: "minutes" }] }, @@ -1203,9 +1173,7 @@ type="number" inputmode="numeric" min="1" - max="45" step="1" - title="Maximum giveaway duration: 45 minutes" value="5" autocomplete="off" > @@ -1466,7 +1434,7 @@
  • !time add N / remove N  - Adjust remaining minutes (45 min total maximum) + Adjust remaining minutes
  • !rig/!unrig  Toggle rigging (visual only)
  • !end [host] Host: !end. Staff: !end <host>
  • @@ -2364,8 +2332,8 @@ body.host-panel-dragging * { remNumInput = document.getElementById("reminderNum"); reminderEvery = document.getElementById("reminderEvery"); - // Enforce whole-number minutes and DarkPeers' 45-minute maximum in the UI. - // The start handler separately validates the value against script/DOM bypasses. + // Enforce whole-number minutes in the Time field (no decimals / exponent notation) + // (No page refresh required; prevents accidental fractional minutes.) (function enforceWholeMinutesOnTimerField() { if (!timerInput) return; @@ -2384,12 +2352,10 @@ body.host-panel-dragging * { } if (/^\d+$/.test(v)) { - const n = Number(v); - if (Number.isSafeInteger(n) && n >= 1) { - const bounded = String(Math.min(n, MAX_GIVEAWAY_MINUTES)); - timerInput.value = bounded; + const n = parseInt(v, 10); + if (Number.isFinite(n) && n >= 1) { timerInput.setCustomValidity(""); - lastValid = bounded; + lastValid = v; return; } } @@ -2812,11 +2778,7 @@ body.host-panel-dragging * { if (coinInput && preset.amount) coinInput.value = preset.amount; if (startInput && preset.startNum) startInput.value = preset.startNum; if (endInput && preset.endNum) endInput.value = preset.endNum; - if (timerInput && preset.timer) { - const minutes = Number(preset.timer); - timerInput.value = Number.isSafeInteger(minutes) && minutes >= 1 - ? String(Math.min(minutes, MAX_GIVEAWAY_MINUTES)) : "5"; - } + if (timerInput && preset.timer) timerInput.value = preset.timer; if (remNumInput && preset.reminders != null) remNumInput.value = preset.reminders; if (winnersInput && preset.winners) winnersInput.value = preset.winners; if (scaleWinnersToggleInput) { @@ -2918,108 +2880,36 @@ body.host-panel-dragging * { function readActiveGiveawayRawForHost(hostName = "", { migrateLegacy = false } = {}) { const requestedHostKey = normalizeUserKey(hostName || getLoggedInUsername()); const storageKey = getActiveGiveawayStorageKey(requestedHostKey); - if (!requestedHostKey || !storageKey) { - return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: false }; - } + if (!requestedHostKey || !storageKey) return { storageKey, raw: null, migratedLegacy: false }; - // Namespaced recovery never proves the old hostname-wide slot empty. - // Both slots may contain distinct settlement evidence for real BON. - let hasStoredState = false; try { const namespacedRaw = localStorage.getItem(storageKey); - const hasNamespacedState = namespacedRaw !== null; - hasStoredState = hasNamespacedState; - const namespacedResult = { - storageKey, raw: namespacedRaw, hasStoredState: hasNamespacedState, - migratedLegacy: false, readOk: true - }; - - // Rehearsal is isolated from the LIVE legacy recovery key. - if (REHEARSAL_MODE) return namespacedResult; + if (namespacedRaw) return { storageKey, raw: namespacedRaw, migratedLegacy: false }; + // Backwards-compatible migration from pre-v1.3.24 hostname-wide storage. + // A legacy snapshot belonging to another account is deliberately left + // untouched; the current account now has its own namespace and cannot + // overwrite that foreign recovery state. Rehearsal storage never + // imports a live legacy snapshot. + if (REHEARSAL_MODE) { + return { storageKey, raw: null, migratedLegacy: false }; + } const legacyRaw = localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY); - // Only null means absent, even if the current namespace is occupied. - if (legacyRaw === null) return namespacedResult; - hasStoredState = true; + if (!legacyRaw) return { storageKey, raw: null, migratedLegacy: false }; const legacy = JSON.parse(legacyRaw); - const legacyHost = legacy?.giveawayData?.host; - // Reject non-string host identities rather than coercing a - // malicious/broken array, object or number into a username. - const legacyHostKey = typeof legacyHost === "string" - ? normalizeUserKey(legacyHost) : ""; - - if (!legacyHostKey) { - console.warn("[BON Giveaway] Unidentified legacy recovery is occupied; manual review required."); - return { - storageKey, raw: legacyRaw, hasStoredState: true, - migratedLegacy: false, readOk: false - }; - } - - if (legacyHostKey !== requestedHostKey) { - // Only a positively identified different host can be ignored. - return namespacedResult; + const legacyHostKey = normalizeUserKey(legacy?.giveawayData?.host); + if (!legacyHostKey || legacyHostKey !== requestedHostKey) { + return { storageKey, raw: null, migratedLegacy: false }; } - if (hasNamespacedState) { - // Same host is NOT the same settlement. A stale copy can - // omit already-paid ledger entries. Never auto-restore while - // conflicting same-host records are present. - if (namespacedRaw !== legacyRaw) { - console.warn("[BON Giveaway] Conflicting same-host recovery slots; manual review required."); - return { - storageKey, raw: namespacedRaw, hasStoredState: true, - migratedLegacy: false, readOk: false - }; - } - if (migrateLegacy) { - // Both records were byte-identical. Under the exclusive - // restore lock, retire the redundant old copy BEFORE - // a pending -> confirmed write can make it conflict. - // Verify BOTH values are unchanged before deletion. - if (localStorage.getItem(storageKey) !== namespacedRaw || - localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY) !== legacyRaw) { - throw new Error("Duplicate recovery state changed before cleanup"); - } - localStorage.removeItem(LS_ACTIVE_GIVEAWAY_LEGACY); - if (localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY) !== null) { - throw new Error("Duplicate legacy recovery cleanup not confirmed"); - } - } - return namespacedResult; - } - - // Only the locked restore path migrates the single identified - // legacy record. Never delete it before read-back confirmation. if (migrateLegacy) { localStorage.setItem(storageKey, legacyRaw); - if (localStorage.getItem(storageKey) !== legacyRaw) { - throw new Error("Legacy migration read-back mismatch"); - } - // A successful write alone is not a completed migration. - // Confirm the legacy key really disappeared BEFORE any - // checkpoint mutation can diverge the two same-host copies. - // If removal is denied or silently ignored, fail closed. - if (localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY) !== legacyRaw) { - throw new Error("Legacy recovery changed before removal"); - } localStorage.removeItem(LS_ACTIVE_GIVEAWAY_LEGACY); - if (localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY) !== null) { - throw new Error("Single-slot legacy migration cleanup not confirmed"); - } } - return { - storageKey, raw: legacyRaw, hasStoredState: true, - migratedLegacy: !!migrateLegacy, readOk: true - }; - } catch (error) { - // Storage or JSON failure is NOT evidence of vacant recovery. - console.warn("[BON Giveaway] Active recovery storage read failed:", error); - return { - storageKey, raw: null, hasStoredState, - migratedLegacy: false, readOk: false - }; + return { storageKey, raw: legacyRaw, migratedLegacy: !!migrateLegacy }; + } catch { + return { storageKey, raw: null, migratedLegacy: false }; } } @@ -3243,13 +3133,7 @@ body.host-panel-dragging * { * Called at key mutation points (new entry, start, addbon, sponsor, time adjust). */ function snapshotGiveaway({ force = false, verifyWrite = false } = {}) { - // Preflight state exists only in memory while balance, Gift History and - // chat cursor checks run. Never persist it as a legacy giveaway when - // pagehide/unload fires BEFORE the opening checkpoint is saved. - const proof = giveawayData?.openingCheckpoint; - if (!giveawayData || - !proof || (proof.status !== "pending" && proof.status !== "confirmed") || - !canMutateActiveGiveaway() || (giveawayData.__ending && !force)) return false; + if (!giveawayData || !canMutateActiveGiveaway() || (giveawayData.__ending && !force)) return false; try { const snapshot = { giveawayData: { @@ -3261,12 +3145,6 @@ body.host-panel-dragging * { totalEntries: giveawayData.totalEntries, winningNumber: giveawayData.winningNumber, totalSeconds: giveawayData.totalSeconds, - openedAt: giveawayData.openedAt, - // Deadline and sponsor-window boundaries are different. - sponsorStartTs: giveawayData.sponsorStartTs, - openingCheckpoint: giveawayData.openingCheckpoint && - typeof giveawayData.openingCheckpoint === "object" - ? { ...giveawayData.openingCheckpoint } : null, endTs: giveawayData.endTs, winnersNum: giveawayData.winnersNum, baseWinnersAtStart: giveawayData.baseWinnersAtStart, @@ -3312,8 +3190,6 @@ body.host-panel-dragging * { lastMsgId: Math.max(0, Math.floor(Number(window.__activeTracker.lastMsgId) || 0)), cursorInitialized: !!window.__activeTracker.cursorInitialized, giftHistoryClockOffsetMs: optionalFiniteNumber(window.__activeTracker.giftHistoryClockOffsetMs), - giftHistoryClockOffsetUncertaintyMs: - optionalFiniteNumber(window.__activeTracker.giftHistoryClockOffsetUncertaintyMs), giftHistoryInitialized: !!window.__activeTracker.giftHistoryInitialized, giftHistorySeenKeys: Array.from(window.__activeTracker.giftHistorySeenKeys || []).slice(-250), historyFallbackActive: !!window.__activeTracker.historyFallbackActive, @@ -3386,13 +3262,7 @@ body.host-panel-dragging * { const loggedInHostKey = normalizeUserKey(getLoggedInUsername()); if (!loggedInHostKey) return null; - // UI bootstrap runs BEFORE Web Lock acquisition: never migrate or - // remove legacy keys here. Only the post-lock restore read may do so. - const stored = readActiveGiveawayRawForHost(loggedInHostKey, { migrateLegacy: false }); - if (!stored.readOk) { - console.warn("[BON Giveaway] Recovery storage unreadable; leaving any existing state untouched."); - return null; - } + const stored = readActiveGiveawayRawForHost(loggedInHostKey, { migrateLegacy: true }); if (!stored.raw) return null; const snap = JSON.parse(stored.raw); if (!snap || !snap.giveawayData) return null; @@ -3411,75 +3281,14 @@ body.host-panel-dragging * { return null; } - // Pre-checkpoint releases never proved that an opening reached - // Main Chat, and their startTime was recorded AFTER the send. - // Preserve the original raw snapshot, including old/expired and - // partially-paid records, but NEVER auto-resume or auto-settle it. - // Rewriting its 45-minute deadline from that late startTime would - // also violate the absolute public-opening cap. - const checkpoint = snap.giveawayData.openingCheckpoint; - if (!checkpoint || ( - checkpoint.status !== "pending" && - checkpoint.status !== "confirmed" - ) || ( - checkpoint.status === "confirmed" && - (!Number.isFinite(Number(snap.giveawayData.openedAt)) || - Number(snap.giveawayData.openedAt) <= 0 || - !Number.isFinite(Number(snap.giveawayData.sponsorStartTs)) || - Number(snap.giveawayData.sponsorStartTs) < - Number(snap.giveawayData.openedAt)) - )) { - console.warn( - "[BON Giveaway] Unverified legacy opening retained for manual review; " + - "automatic resume, deadline migration and BON settlement are blocked." - ); - snap.__legacyOpeningUnverified = true; - return snap; - } - const endTs = Number(snap.giveawayData.endTs); if (!Number.isFinite(endTs) || endTs <= 0) { - // Invalid saved data can reflect an incomplete write or an - // in-progress payout. Preserve it for manual inspection; - // never erase potentially recoverable settlement evidence. - console.warn("[BON Giveaway] Invalid saved deadline; snapshot preserved for manual recovery."); + clearGiveawaySnapshot(); return null; } - // An older release may have saved a deadline beyond the DarkPeers cap. - // Restrict only an uncommitted draw: committed settlement timestamps - // are immutable and must survive crash/reload unchanged. - const startTs = Number(snap.giveawayData.openedAt ?? snap.startTime); - const maxEndTs = getGiveawayMaxEndTs(startTs); + const overdueMs = Date.now() - endTs; const committedSettlement = snap.giveawayData?.settlement?.committed === true; - const correctedDeadline = !committedSettlement && maxEndTs !== null && endTs > maxEndTs; - if (correctedDeadline) { - snap.giveawayData.endTs = maxEndTs; - - // Legacy reminder offsets belong to the *old* deadline. Rebuild - // them against the capped original duration, preserving the - // configured reminder count. countdownTimer/nextReminderMs will - // discard thresholds already elapsed without replaying them. - const requestedReminders = Number(snap.giveawayData.reminderNum); - const reminderCount = Number.isFinite(requestedReminders) - ? Math.max(0, Math.min( - Math.floor(requestedReminders), - getReminderLimits(MAX_GIVEAWAY_MINUTES)[0] - )) - : 0; - snap.giveawayData.reminderSchedule = getReminderSchedule(MAX_GIVEAWAY_MINUTES, reminderCount); - snap.giveawayData.reminderNum = reminderCount; - const cadenceSec = reminderCount > 0 - ? MAX_GIVEAWAY_MINUTES * 60 / (reminderCount + 1) - : 0; - snap.giveawayData.reminderFreqSec = cadenceSec; - snap.giveawayData.nextReminderSec = cadenceSec; - snap.giveawayData.totalSeconds = MAX_GIVEAWAY_MINUTES * 60; - console.warn("[BON Giveaway] Recovered deadline and reminder schedule capped at 45 minutes after public opening."); - } - // Apply the normal one-hour restore grace to the *capped* deadline. - // Otherwise ancient giveaways could unexpectedly resume settlement. - const overdueMs = Date.now() - Number(snap.giveawayData.endTs); const closingSettlementLatched = snap.giveawayData?.closingIntent === true || snap.giveawayData?.closingNoticeSent === true || @@ -3488,16 +3297,11 @@ body.host-panel-dragging * { if ( overdueMs > EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS && !committedSettlement && - !closingSettlementLatched && - snap.giveawayData?.openingCheckpoint?.status !== "pending" + !closingSettlementLatched ) { - // This loader also runs BEFORE acquiring the Web Lock. - // Deleting a stale recovery slot here could erase another - // tab's in-progress settlement or manual recovery evidence. - // Preserve it and block auto-resume, never clear it on read. - console.warn("[BON Giveaway] Expired active snapshot preserved for manual review; automatic settlement grace exceeded."); - snap.__expiredBeyondGrace = true; - return snap; + console.warn("[BON Giveaway] Discarding stale expired active snapshot; automatic settlement grace exceeded."); + clearGiveawaySnapshot(); + return null; } // Once settlement is committed OR the closing latch has been persisted, @@ -3506,11 +3310,8 @@ body.host-panel-dragging * { // active-giveaway grace window. snap.__expiredAtLoad = overdueMs >= 0 || closingSettlementLatched; return snap; - } catch (error) { - // Malformed or temporarily unreadable snapshots must not be - // silently discarded; a subsequent clean read or manual analysis - // may be needed to prevent lost or duplicate BON payouts. - console.warn("[BON Giveaway] Unable to parse recovery snapshot; retaining stored data:", error); + } catch { + clearGiveawaySnapshot(); return null; } } @@ -3606,22 +3407,21 @@ body.host-panel-dragging * { } async function restoreGiveawayFromSnapshot(snap) { - // The earlier snapshot is only a discovery hint. Another tab may - // persist a newer settlement while we await the Web Lock. Classify - // recovery status, expiry, checkpoint and host ONLY after rereading - // the latest saved state under exclusive ownership. - if (!snap) return false; + if (!snap || !snap.giveawayData) return false; // Validate the live DarkPeers identity/Main Chat context BEFORE claiming // ownership or touching the recoverable snapshot. A transient/wrong chat // context must never destroy or mutate persisted giveaway state. cacheChatContext(); const restoreHost = getLoggedInUsername(); + const restoreExpectedHost = String(snap.giveawayData.host || "").trim(); const restoreUserId = Math.floor(Number(OT_USER_ID)); const restorePublicRoom = Math.floor(Number(OT_CHATROOM_ID)); const restoreExpectedRoom = Math.floor(Number(DARKPEERS_MAIN_CHATROOM_ID)); const restoreContextOk = !!restoreHost && + !!restoreExpectedHost && + normalizeUserKey(restoreHost) === normalizeUserKey(restoreExpectedHost) && Number.isFinite(restoreUserId) && restoreUserId > 0 && Number.isFinite(restorePublicRoom) && @@ -3633,6 +3433,7 @@ body.host-panel-dragging * { "[BON Giveaway] Restore deferred: DarkPeers identity/Main Chat context is not trustworthy.", { restoreHost, + expectedHost: restoreExpectedHost, userId: restoreUserId, publicRoom: restorePublicRoom, expectedRoom: restoreExpectedRoom, @@ -3656,135 +3457,8 @@ body.host-panel-dragging * { } try { - // The caller's snap was read before awaiting the Web Lock. - // Another tab may have committed/persisted payout state while we - // were queued, then released its lock. Never restore that stale - // object and overwrite newer payout/checkpoint information. - // Refresh storage and re-apply all current restore validation - // WHILE holding exclusive ownership. - const lockedRead = readActiveGiveawayRawForHost(restoreHost, { - migrateLegacy: true - }); - if (!ownsTabLock() || !lockedRead.readOk || - !lockedRead.hasStoredState || !lockedRead.raw) { - console.warn("[BON Giveaway] Restore blocked: recovery storage changed or could not be read under lock."); - releaseTabLock(); - return false; - } - const lockedSnapshot = loadGiveawaySnapshot(); - if (!ownsTabLock() || !lockedSnapshot || - lockedSnapshot.__legacyOpeningUnverified === true || - lockedSnapshot.__expiredBeyondGrace === true || - !lockedSnapshot.giveawayData || - normalizeUserKey(lockedSnapshot.giveawayData.host) !== - normalizeUserKey(restoreHost) || - (lockedSnapshot.giveawayData.rehearsalMode === true) !== REHEARSAL_MODE) { - console.warn("[BON Giveaway] Restore blocked: locked snapshot is absent, unverified or belongs to another mode/host."); - if (lockedSnapshot?.__legacyOpeningUnverified === true || - lockedSnapshot?.__expiredBeyondGrace === true) { - window.alert( - "GIVEAWAY RECOVERY REQUIRES MANUAL REVIEW: The latest saved state " + - "cannot safely resume entries or BON settlement. " + - "The original recovery data has been preserved." - ); - } - releaseTabLock(); - return false; - } - snap = lockedSnapshot; - - // Local storage alone is not authoritative evidence of a public - // opening. For a previously confirmed LIVE giveaway, prove that - // the exact original message ID/host/text/timestamp still exists - // in Main Chat before restoring entries, pot or settlement. The - // verifier checks the original bounded POST window and cursor. - const restoredCheckpoint = snap.giveawayData?.openingCheckpoint; - if (!REHEARSAL_MODE && restoredCheckpoint?.status === "confirmed") { - const verified = await verifyPendingGiveawayOpening(restoredCheckpoint); - const savedSponsorStart = optionalFiniteNumber( - snap.giveawayData.sponsorStartTs - ); - if (!verified || - verified.messageId !== optionalFiniteNumber(restoredCheckpoint.messageId) || - savedSponsorStart === null || - verified.sponsorStartTs !== savedSponsorStart) { - logEvent("Confirmed opening recovery deferred", - "Authoritative Main Chat could not reprove the exact saved opening and sponsor boundary."); - window.alert( - "GIVEAWAY RECOVERY PAUSED: The saved opening could not " + - "be verified again against Main Chat. No entries, " + - "sponsor updates or BON settlement will be resumed." - ); - releaseTabLock(); - return false; - } - } - - // An opening may have reached Main Chat even if its POST response was - // lost. It was durably checkpointed BEFORE sending; never resume - // entries, sponsor accounting or settlement until the exact public - // opening is verified from authoritative chat history. - if (snap.giveawayData?.openingCheckpoint?.status === "pending") { - const verified = await verifyPendingGiveawayOpening( - snap.giveawayData.openingCheckpoint - ); - if (!verified) { - logEvent("Opening recovery deferred", - "Main Chat could not prove the uncertain opening; existing checkpoint preserved."); - window.alert( - "GIVEAWAY RECOVERY PAUSED: The public opening could not be verified. " + - "No giveaway settlement or BON transfer will run. Retry from Main Chat later." - ); - releaseTabLock(); - return false; - } - - // Never resurrect a very old uncertain opening into automatic - // settlement. A human must investigate after recovery grace. - const overdue = Date.now() - Number(snap.giveawayData.endTs); - if (overdue > EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS) { - logEvent("Opening recovery requires manual review", - "Opening verified but its automatic settlement grace has expired."); - window.alert( - "GIVEAWAY RECOVERY PAUSED: An old public opening was verified, " + - "but automatic settlement is unsafe after the recovery grace. " + - "Keep the saved recovery state for manual review." - ); - releaseTabLock(); - return false; - } - - snap.giveawayData.sponsorStartTs = verified.sponsorStartTs; - snap.giveawayData.openingCheckpoint.status = "confirmed"; - snap.giveawayData.openingCheckpoint.messageId = verified.messageId; - // Persist the verified boundary before any sponsor reconciliation. - // Do not call snapshotGiveaway yet: entries haven't been restored - // into memory, so serializing them now would overwrite recovery. - const storageKey = getActiveGiveawayStorageKey(snap.giveawayData.host); - if (!storageKey) { - releaseTabLock(); - return false; - } - const persisted = { ...snap }; - delete persisted.__expiredAtLoad; - const verifiedRaw = JSON.stringify(persisted); - try { - localStorage.setItem(storageKey, verifiedRaw); - if (localStorage.getItem(storageKey) !== verifiedRaw) { - throw new Error("Verified opening checkpoint read-back mismatch"); - } - } catch (error) { - console.warn("[BON Giveaway] Could not persist verified opening:", error); - releaseTabLock(); - return false; - } - } - // 1) Restore giveaway data giveawayData = snap.giveawayData; - const restoredOpenTs = Number(giveawayData.openedAt ?? snap.startTime); - giveawayData.openedAt = getGiveawayMaxEndTs(restoredOpenTs) !== null - ? restoredOpenTs : null; // Restore starts fail-closed for sponsor accounting. Entries and time // may resume, but BON mutation and settlement stay blocked until the @@ -3906,19 +3580,11 @@ body.host-panel-dragging * { updateRigToggleUI(); // 4) Restore start time - giveawayStartTime = giveawayData.openedAt !== null - ? new Date(giveawayData.openedAt) : new Date(); + giveawayStartTime = snap.startTime ? new Date(snap.startTime) : new Date(); lastKnownGiveawayHostKey = normUserKey(giveawayData.host) || lastKnownGiveawayHostKey; - // 5) Lock form fields. Keep the restored reminder count reflected - // in the UI: a later !addtime/!removetime rebuilds from this field. - if (remNumInput) { - const savedReminderCount = Number(giveawayData.reminderNum); - remNumInput.value = String(Number.isFinite(savedReminderCount) - ? Math.max(0, Math.min(MAX_REMINDERS, Math.floor(savedReminderCount))) - : 0); - } + // 5) Lock form fields startButton.disabled = false; coinInput.disabled = true; startInput.disabled = true; @@ -3986,18 +3652,13 @@ body.host-panel-dragging * { const savedTracker = (snap.sponsorTracker && typeof snap.sponsorTracker === "object") ? snap.sponsorTracker : null; const tracker = new SponsorTracker({ chatroomId, - giveawayStartTime: new Date( - giveawayData.sponsorStartTs ?? giveawayStartTime.getTime() - ), + giveawayStartTime, giveawayData, lastMsgId: savedTracker ? savedTracker.lastMsgId : 0, cursorInitialized: savedTracker ? !!savedTracker.cursorInitialized : false, giftHistoryClockOffsetMs: savedTracker ? optionalFiniteNumber(savedTracker.giftHistoryClockOffsetMs) : null, - giftHistoryClockOffsetUncertaintyMs: savedTracker - ? optionalFiniteNumber(savedTracker.giftHistoryClockOffsetUncertaintyMs) - : null, giftHistoryInitialized: savedTracker ? !!savedTracker.giftHistoryInitialized : false, giftHistorySeenKeys: savedTracker && Array.isArray(savedTracker.giftHistorySeenKeys) ? savedTracker.giftHistorySeenKeys @@ -4194,100 +3855,6 @@ body.host-panel-dragging * { // SECTION 6: Giveaway Lifecycle // ─────────────────────────────────────────────────────────── - // The opening POST has a 7s HTTP timeout. Allow limited transport overhead - // for recovering an ambiguous result, but NEVER accept an announcement - // sent minutes/hours later as this giveaway's opening. - const OPENING_POST_MAX_PUBLICATION_WINDOW_MS = 10000; - - // Reconcile an opening announcement against authoritative Main Chat history. - // An HTTP timeout is NOT evidence the server rejected the POST. Never - // release a pending opening snapshot merely because sendMessage returned - // false; an announced giveaway may already exist publicly. - async function verifyPendingGiveawayOpening(checkpoint) { - if (!checkpoint || - (checkpoint.status !== "pending" && checkpoint.status !== "confirmed") || - !checkpoint.preparedMessage || !Number.isFinite(Number(checkpoint.startedAt))) { - return null; - } - - const afterId = optionalFiniteNumber(checkpoint.afterMessageId); - // A missing pre-send cursor makes same-second identical announcements - // indistinguishable. Never auto-confirm such a snapshot or payout. - if (!Number.isSafeInteger(afterId) || afterId <= 0) return null; - const startedAt = Number(checkpoint.startedAt); - const persistedHardEnd = optionalFiniteNumber(checkpoint.windowEndAt); - const completedAt = optionalFiniteNumber(checkpoint.sendCompletedAt); - const absoluteHardEnd = startedAt + OPENING_POST_MAX_PUBLICATION_WINDOW_MS; - // An absent completion time means the tab may have crashed mid-POST: - // the pre-persisted hard window is still authoritative. A recorded - // completion can only NARROW the window, never extend it. - const windowEnd = Math.min( - absoluteHardEnd, - persistedHardEnd === null ? absoluteHardEnd : persistedHardEnd, - completedAt === null ? absoluteHardEnd : completedAt - ); - if (!Number.isFinite(startedAt) || !Number.isFinite(windowEnd) || - windowEnd <= startedAt) return null; - const senderId = Math.floor(Number(OT_USER_ID)); - if (!Number.isFinite(senderId) || senderId <= 0) return null; - - for (let attempt = 0; attempt < 3; attempt++) { - if (!ownsTabLock()) return null; - try { - const url = new URL(`/api/chat/messages/${DARKPEERS_MAIN_CHATROOM_ID}`, location.origin); - if (afterId !== null) url.searchParams.set("after_id", String(afterId)); - const response = await fetchWithTimeout( - url, { credentials: "include", cache: "no-store" }, 5000 - ); - if (!ownsTabLock()) return null; - if (!response?.ok) throw new Error("Opening verification chat read failed"); - const payload = await response.json(); - if (!ownsTabLock()) return null; - if (!Array.isArray(payload?.data)) throw new Error("Malformed Main Chat messages"); - - for (const message of payload.data) { - const id = optionalFiniteNumber(message?.id); - if (!Number.isSafeInteger(id) || id <= afterId) continue; - // A confirmed checkpoint must identify the SAME original - // announcement after reload. Never replace it with a later - // identical post, even inside the bounded send window. - if (checkpoint.status === "confirmed" && - id !== optionalFiniteNumber(checkpoint.messageId)) continue; - const authorId = Math.floor(Number(message?.user_id ?? message?.user?.id)); - if (authorId !== senderId) continue; - if (String(message?.message ?? "") !== checkpoint.preparedMessage) continue; - - const rawTs = String(message?.created_at || ""); - const timestamp = Date.parse(rawTs); - const resolutionMs = unit3dTimestampResolutionMs(rawTs); - // Require the ENTIRE server timestamp bucket to be inside - // the original, bounded opening POST window. A bucket that - // overlaps the pre-send boundary is ambiguous and must not - // confirm an unrelated same-second message. An identical - // message sent after this attempt cannot confirm it either. - if (!Number.isFinite(timestamp) || - !Number.isFinite(resolutionMs) || resolutionMs <= 0 || - timestamp < startedAt || - (timestamp + resolutionMs) > windowEnd) continue; - - return { - messageId: id, - // Exclude the entire potentially ambiguous publication - // timestamp bucket from sponsorship. The 45-minute - // deadline remains anchored to the earlier send start. - sponsorStartTs: Math.max(startedAt, timestamp + resolutionMs) - }; - } - } catch (error) { - console.warn("[BON Giveaway] Opening reconciliation deferred:", error); - } - if (attempt < 2) { - await new Promise(resolve => setTimeout(resolve, 700)); - } - } - return null; - } - async function startGiveaway() { clearWinnersStatusUI(); @@ -4297,57 +3864,11 @@ body.host-panel-dragging * { return; } - // Validate in code, not just through the HTML max attribute: DevTools, - // presets or scripted property changes cannot start a longer giveaway. - const totalTimeMin = parseGiveawayMinutes(timerInput?.value); - if (totalTimeMin === null) { - window.alert(`DarkPeers giveaways must last between 1 and ${MAX_GIVEAWAY_MINUTES} whole minutes.`); - timerInput?.focus(); - return; - } - if (!giveawayForm.checkValidity()) { giveawayForm.reportValidity(); return; } - // Never overwrite a recoverable opening whose public POST was ambiguous. - // The pending snapshot is the only path to reconcile entrants/sponsors - // after a reload. A second start could otherwise destroy that evidence. - // Pre-lock validation is read-only. Any migration of a legacy key - // happens only after exclusive ownership has been acquired. - const prior = readActiveGiveawayRawForHost(getLoggedInUsername(), { - migrateLegacy: false - }); - // localStorage failures must block starts, never look like absence. - if (!prior.readOk || !prior.storageKey) { - window.alert("GIVEAWAY START BLOCKED: Cannot read recovery storage reliably. No opening was posted."); - return; - } - if (prior.hasStoredState) { - try { - const saved = JSON.parse(prior.raw); - const proof = saved?.giveawayData?.openingCheckpoint; - if (saved?.giveawayData && (!proof || proof.status !== "confirmed" || - !Number.isFinite(Number(saved.giveawayData.openedAt)) || - Number(saved.giveawayData.openedAt) <= 0 || - !Number.isFinite(Number(saved.giveawayData.sponsorStartTs)) || - Number(saved.giveawayData.sponsorStartTs) < - Number(saved.giveawayData.openedAt))) { - window.alert( - "GIVEAWAY START BLOCKED: A saved opening still requires " + - "verification or manual review. The original recovery " + - "snapshot must not be overwritten by a new giveaway." - ); - return; - } - } catch { - // Unknown existing recovery data must not be overwritten. - window.alert("GIVEAWAY START BLOCKED: Existing recovery data could not be read safely."); - return; - } - } - clearGiveawayLog(); // Normalize and validate the giveaway amount separately so we tolerate @@ -4381,26 +3902,6 @@ body.host-panel-dragging * { return; } - // The check above ran BEFORE ownership was acquired. Two tabs can - // both see empty storage; the second may acquire the lock only after - // the first has posted (or checkpointed) an ambiguous opening. Read - // storage AGAIN while owning the lock. Any remaining snapshot is - // recoverable state and must not be replaced by a second opening. - const savedAfterLock = readActiveGiveawayRawForHost(getLoggedInUsername(), { - migrateLegacy: true - }); - if (!savedAfterLock.readOk || savedAfterLock.hasStoredState || !savedAfterLock.storageKey) { - logEvent("Start blocked (post-lock recovery recheck)", - "Saved giveaway state appeared while awaiting the exclusive lock; no new opening was posted."); - window.alert( - "GIVEAWAY START BLOCKED: A previous giveaway was saved while " + - "waiting for this tab's lock. Recover or resolve it before " + - "starting another. No new opening has been posted." - ); - releaseTabLock(); - return; - } - if (sponsorsInterval) { clearInterval(sponsorsInterval); sponsorsInterval = null; } if (observer) { observer.disconnect(); observer = null; } @@ -4456,7 +3957,8 @@ body.host-panel-dragging * { //startButton.parentElement.hidden = true; entriesWrapper.hidden = false; - const totalTimeMs = totalTimeMin * 60000; + let totalTimeMin = totalMinutes(); + let totalTimeMs = totalTimeMin * 60000; let reminderNum = Math.min(Number(remNumInput.value), getReminderLimits(totalTimeMin)[0]); if (isNaN(reminderNum) || reminderNum < 0) reminderNum = 0; const schedule = getReminderSchedule(totalTimeMin, reminderNum); @@ -4489,7 +3991,6 @@ body.host-panel-dragging * { totalEntries: parseInt(endInput.value, 10) - parseInt(startInput.value, 10) + 1, winningNumber: null, totalSeconds: totalTimeMs / 1000, - openedAt: null, timeLeft: totalTimeMs / 1000, endTs: Date.now() + (totalTimeMs), winnersNum, @@ -4515,9 +4016,6 @@ body.host-panel-dragging * { updateRigToggleUI(); const currentBon = await getVerifiedHostBalance({ requireServer: true, maxAgeMs: 0 }); - // A pagehide while preflight was awaiting balance invalidates this - // document. Don't continue into the public opening workflow. - if (!ownsTabLock()) return; if (currentBon == null) { const startErr = "Unable to verify current BON balance."; @@ -4604,7 +4102,6 @@ body.host-panel-dragging * { giveawayData }); const sponsorBaselineReady = await tracker.bootstrapGiftHistory(); - if (!ownsTabLock()) return; if (!sponsorBaselineReady) { logEvent( "Start aborted (Gift History baseline)", @@ -4618,123 +4115,37 @@ body.host-panel-dragging * { return; } - // Read a Main Chat cursor BEFORE preparing the durable checkpoint. - // UNIT3D may ignore after_id; the verifier also filters by author, - // exact prepared message and publication-time window. - const afterMessageId = REHEARSAL_MODE - ? null : await getLatestChatMessageId(DARKPEERS_MAIN_CHATROOM_ID); - if (!ownsTabLock()) return; - if (!REHEARSAL_MODE && - (!Number.isSafeInteger(afterMessageId) || afterMessageId <= 0)) { - logEvent("Opening blocked (Main Chat cursor)", - "Unable to obtain a trustworthy pre-send Main Chat message ID. No opening was posted."); + const introSent = await sendMessage(introMessage, { + requireExclusiveGiveawayOwnership: true + }); + if (introSent === false) { + logEvent( + "Start aborted (opening announcement)", + "The public opening message could not be accepted for sending. Timers, entries and sponsor tracking were not started." + ); window.alert( - "GIVEAWAY ERROR: Cannot verify the latest Main Chat message. " + - "Nothing has been posted. Please try again when DarkPeers is responding." + "GIVEAWAY ERROR: The opening message could not be sent safely to DarkPeers Main Chat. " + + "The giveaway was not started." ); resetGiveaway(); return; } - // Deadline: conservative PRE-SEND time. Sponsorship: independently - // verified PUBLICATION time (never the earlier request timestamp). - const openingSendStartedAt = Date.now(); - const preparedOpening = prepareOutgoingMessage(introMessage); - giveawayStartTime = new Date(openingSendStartedAt); - giveawayData.openedAt = openingSendStartedAt; - giveawayData.endTs = openingSendStartedAt + totalTimeMs; - giveawayData.__sponsorAccountingVerified = false; - giveawayData.openingCheckpoint = { - status: "pending", - startedAt: openingSendStartedAt, - afterMessageId, - windowEndAt: openingSendStartedAt + OPENING_POST_MAX_PUBLICATION_WINDOW_MS, - preparedMessage: preparedOpening - }; + // Public opening is the temporal boundary. The pre-opening Gift History + // snapshot means every subsequently appearing received gift is new. + giveawayStartTime = new Date(); + tracker.giveawayStartTs = giveawayStartTime.getTime(); + // The advertised duration begins at the same public-opening boundary, + // not while balance/history preflight or chat delivery is still running. + giveawayData.endTs = giveawayStartTime.getTime() + totalTimeMs; + giveawayData.timeLeft = totalTimeMs / 1000; window.__activeTracker = tracker; - // If this write/read-back fails, DO NOT send anything publicly. - if (!snapshotGiveaway({ force: true, verifyWrite: true })) { - logEvent("Opening blocked", "Could not persist the opening checkpoint before posting."); - window.alert("GIVEAWAY ERROR: Unable to save the opening recovery checkpoint. No message was sent."); - resetGiveaway(); - return; - } - - const introSent = await sendMessage(preparedOpening, { - prepared: true, - requireExclusiveGiveawayOwnership: true, - noFallbackOnApiException: true - }); - if (!ownsTabLock()) return; - // Constrain reconciliation to the ACTUAL time of completion if - // this document survived the POST. If it did not, recovery uses - // the previously persisted bounded upper limit instead. - giveawayData.openingCheckpoint.sendCompletedAt = Date.now(); - if (!snapshotGiveaway({ force: true, verifyWrite: true })) { - logEvent("Opening delivery checkpoint not durable", - "Could not preserve the end of the opening attempt; recovery remains pending."); - window.alert("GIVEAWAY OPENING PAUSED: Delivery timing could not be stored. Reload to retry verification."); - releaseTabLock(); - return; - } - - // Rehearsal announcements deliberately go to a private host message; - // real openings require authoritative Main Chat confirmation. - const verifiedOpening = introSent !== false && REHEARSAL_MODE - ? { messageId: null, sponsorStartTs: openingSendStartedAt } - : await verifyPendingGiveawayOpening(giveawayData.openingCheckpoint); - - if (!verifiedOpening) { - logEvent("Opening unconfirmed (checkpoint retained)", - "Main Chat publication could not be verified; payout and sponsor tracking remain disabled until recovery verifies the exact opening."); - window.alert( - "GIVEAWAY OPENING UNCONFIRMED: The opening message might already be public. " + - "A recovery checkpoint was saved. Reload Main Chat to retry verification. " + - "Do NOT start another giveaway or send BON manually." - ); - // Keep the snapshot. Never reset or re-send an ambiguous opening. - releaseTabLock(); - return; - } - - giveawayData.sponsorStartTs = verifiedOpening.sponsorStartTs; - giveawayData.openingCheckpoint.status = "confirmed"; - giveawayData.openingCheckpoint.messageId = verifiedOpening.messageId; - tracker.giveawayStartTs = verifiedOpening.sponsorStartTs; - syncGiveawayTimeLeft(giveawayData); - if (!snapshotGiveaway({ force: true, verifyWrite: true })) { - logEvent("Opening confirmation not durable", "Recovery checkpoint retained; settlement remains blocked."); - window.alert("GIVEAWAY OPENING PAUSED: Confirmed opening could not be saved. Reload to recover."); - releaseTabLock(); - return; - } - // The pre-send checkpoint deliberately starts with accounting - // disabled. Once publication has been verified, reconcile the - // canonical Gift History BEFORE re-enabling pot mutations. Otherwise - // !addbon stays blocked forever on a fresh, otherwise healthy start. - try { - await tracker.reconcileCanonicalSponsorAccounting({ repairStats: false }); - if (!snapshotGiveaway({ force: true, verifyWrite: true })) { - throw new Error("Opening sponsor reconciliation snapshot did not persist"); - } - setSponsorAccountingVerificationState( - true, "Opening and canonical Gift History sponsor boundary verified." - ); - } catch (error) { - setSponsorAccountingVerificationState( - false, "Sponsor reconciliation deferred after public opening: " + - String(error?.message || error) - ); - scheduleSponsorAccountingRecovery(tracker); - } - - if (giveawayData.__sponsorAccountingVerified === true) { - try { await tracker.poll(); } catch (error) { console.error(error); } - sponsorsInterval = setInterval( - () => tracker.poll(), SPONSOR_GIFT_HISTORY_POLL_MS - ); - } + try { await tracker.poll(); } catch (e) { console.error(e); } + sponsorsInterval = setInterval( + () => tracker.poll(), + SPONSOR_GIFT_HISTORY_POLL_MS + ); chatReplayIgnoreBeforeTs = null; if (observer) { @@ -5882,7 +5293,6 @@ body.host-panel-dragging * { lastMsgId = 0, cursorInitialized = false, giftHistoryClockOffsetMs = null, - giftHistoryClockOffsetUncertaintyMs = null, giftHistoryInitialized = false, giftHistorySeenKeys = [], historyFallbackActive = false, @@ -5895,12 +5305,6 @@ body.host-panel-dragging * { this.lastMsgId = Math.max(0, Math.floor(Number(lastMsgId) || 0)); // persisted API cursor this.cursorInitialized = !!cursorInitialized; this.giftHistoryClockOffsetMs = optionalFiniteNumber(giftHistoryClockOffsetMs); - // Earlier versions stored a point-estimate without its fractional - // calibration error. DarkPeers Gift History has second precision, - // so reserve at least one complete second of unknown offset. - this.giftHistoryClockOffsetUncertaintyMs = Math.max( - 1000, optionalFiniteNumber(giftHistoryClockOffsetUncertaintyMs) ?? 1000 - ); this.giftHistoryInitialized = !!giftHistoryInitialized; this.giftHistorySeenKeys = new Set( (Array.isArray(giftHistorySeenKeys) ? giftHistorySeenKeys : []) @@ -6303,26 +5707,6 @@ body.host-panel-dragging * { offset = await this.ensureGiftHistoryClockOffset(rows); } - // Gift History exposes site-local and browser/UTC interpretations. - // Without an authoritative calibration we CANNOT pick whichever - // interpretation falls after the public opening: the other one may - // place the same gift before it. Keep every row retryable, without - // modifying sponsor totals or marking gift-history keys as seen. - // The canonical settlement reconciliation treats retryable rows as - // a hard failure, so no BON payouts can proceed from this state. - if (min !== null && offset === null) { - logEvent( - "Sponsor accounting paused (clock uncalibrated)", - "Gift History time offset is unknown; deferred all gift rows instead of choosing an ambiguous timestamp interpretation." - ); - return { - accepted: [], - retryableKeys: new Set( - rows.map(item => item?.historyKey).filter(Boolean) - ) - }; - } - // Re-read the closing latch after the await above. If endGiveaway() // closed the window while this ordinary poll was already in flight, // this pass must immediately inherit that cutoff. @@ -6360,12 +5744,8 @@ body.host-panel-dragging * { : Number(item?.createdAtTs); if (offset !== null && Number.isFinite(wallTs)) { - const start = wallTs - offset - - Math.max(1000, this.giftHistoryClockOffsetUncertaintyMs ?? 1000); - // A gift bucket overlapping public opening is ambiguous: - // only an interval STARTING at/after the confirmed sponsor - // boundary can be safely attributed to this giveaway. - return (min === null || start >= min) ? [start] : []; + const start = wallTs - offset; + return (min === null || (start + resolutionMs) > min) ? [start] : []; } return [ @@ -6373,7 +5753,7 @@ body.host-panel-dragging * { Number(item?.createdAtAltTs) ].filter(ts => Number.isFinite(ts) && - (min === null || ts >= min) + (min === null || (ts + resolutionMs) > min) ); }; @@ -6452,17 +5832,8 @@ body.host-panel-dragging * { : Number(item?.createdAtTs); if (offset !== null && Number.isFinite(wallTs)) { - // The learned offset subtracts an exact event time from - // coarse Gift History time. This shifts all bucket starts - // forward by the anchor event's fractional second (and, - // for clustered anchors, possibly more). Use the EARLIEST - // possible start, including stored calibration uncertainty. const eventTs = wallTs - offset; - const earliestEventTs = eventTs - - Math.max(1000, this.giftHistoryClockOffsetUncertaintyMs ?? 1000); - // Entire Gift History timestamp bucket must be after - // confirmed publication, not merely overlap its boundary. - const afterOpen = min === null || earliestEventTs >= min; + const afterOpen = min === null || (eventTs + resolutionMs) > min; if (!afterOpen) continue; if (max === null) { @@ -6495,7 +5866,7 @@ body.host-panel-dragging * { ].filter(Number.isFinite); const openingCandidates = candidates.filter(ts => - min === null || ts >= min + min === null || (ts + resolutionMs) > min ); if (!openingCandidates.length) continue; @@ -6846,27 +6217,21 @@ body.host-panel-dragging * { if (!Number.isFinite(historyWallTs)) continue; const offset = historyWallTs - Number(event.createdAtTs); - const resolutionMs = Math.max( - 1, Number(item?.timestampResolutionMs) || - unit3dTimestampResolutionMs(item?.rawTimestamp) - ); - if (Math.abs(offset) <= MAX_PLAUSIBLE_OFFSET_MS) { - samples.push({ offset, resolutionMs }); - } + if (Math.abs(offset) <= MAX_PLAUSIBLE_OFFSET_MS) samples.push(offset); } if (!samples.length) return persisted; // Choose the densest 2-minute cluster, then its median. This rejects an // unrelated historical exact-value row without hardcoding any timezone. - samples.sort((a, b) => a.offset - b.offset); + samples.sort((a, b) => a - b); const CLUSTER_MS = 120_000; let best = []; for (let i = 0; i < samples.length; i++) { const cluster = []; for (let j = i; j < samples.length; j++) { - if (samples[j].offset - samples[i].offset > CLUSTER_MS) break; + if (samples[j] - samples[i] > CLUSTER_MS) break; cluster.push(samples[j]); } if (cluster.length > best.length) best = cluster; @@ -6875,20 +6240,8 @@ body.host-panel-dragging * { if (!best.length) return persisted; const mid = Math.floor(best.length / 2); const inferred = best.length % 2 - ? best[mid].offset - : Math.round((best[mid - 1].offset + best[mid].offset) / 2); - - // If the chosen offset is calibrated from a coarse timestamp, - // the true timezone offset can be up to one entire source bucket - // GREATER than the inferred point estimate. Include differences - // within the accepted anchor cluster, too. This upper error - // bound is persisted with the point estimate across page reloads. - const offsetUncertaintyMs = Math.max( - 1000, - ...best.map(sample => - Math.max(0, sample.offset + sample.resolutionMs - inferred) - ) - ); + ? best[mid] + : Math.round((best[mid - 1] + best[mid]) / 2); // If we already learned a clock offset, don't replace it with a lone, // materially different sample. Multiple agreeing anchors may update it @@ -6902,7 +6255,6 @@ body.host-panel-dragging * { } this.giftHistoryClockOffsetMs = inferred; - this.giftHistoryClockOffsetUncertaintyMs = offsetUncertaintyMs; return inferred; } @@ -7579,19 +6931,16 @@ body.host-panel-dragging * { function makeStaffAttributedReply(ctx) { const reply = ctx && typeof ctx.reply === "function" ? ctx.reply : sendMessage; - // Silent Mode sends ALL command responses privately to the invoker, - // including staff errors/denials. Staff actions are attributed in the - // public chat only when the host has not enabled Silent Mode. - if (GENERAL_SETTINGS.silent_mode || - !ctx || !isNonHostStaffAction(ctx.author, ctx.fancyName, ctx.giveawayData?.host)) { + if (!ctx || !isNonHostStaffAction(ctx.author, ctx.fancyName, ctx.giveawayData?.host)) { return reply; } const prefix = `👮 [b][color=#5DE2E7]Staff action by ${sanitizeNick(ctx.author)}:[/color][/b] `; - // Public staff attribution remains on when Silent Mode is disabled. - // Rehearsal Mode still redirects script output privately to the host. + // Staff intervention is operationally significant and must stay public + // even when the host enabled Silent Mode. Rehearsal Mode still suppresses + // sendMessage itself, so testing cannot leak messages into live chat. return (message) => sendMessage(prefix + message); } @@ -7651,7 +7000,7 @@ body.host-panel-dragging * { } const action = (args[0] || "").toLowerCase(); // "add" / "remove" - const minutes = Number(args[1]); + const minutes = parseFloat(args[1]); const isPriv = normalizeUserKey(author) === normalizeUserKey(giveawayData.host) || isAdmin(fancyName); if (!isPriv) return; // silently ignore non-host/non-admin @@ -8161,7 +7510,7 @@ body.host-panel-dragging * { const announcement = `[b][color=${SCALING_ACCENT_COLOR}]Host adjustment:[/color][/b] ` + `[b]Winners ${direction}[/b]: [b][color=#5DE2E7]${prevEffective} → ${newCount} (${sign}${delta})[/color][/b].`; - reply(announcement); + sendMessage(announcement); flashWinnersUI(); logEvent("Host adjusted winners", `${prevEffective} -> ${newCount} (${sign}${delta})`); } @@ -8484,14 +7833,11 @@ body.host-panel-dragging * { snapshotGiveaway({ force: true }); - // Command-generated pot announcements must honor Silent Mode. - // Route privately to the invoking host there; preserve ownership - // checking in both public and private delivery paths. - const potAnnouncement = `${bridgeMarker(BRIDGE_MARKERS.POT, "💰")} ` + - [addedPart, totalPart, donationPart, scalingPart].filter(Boolean).join(" "); - const announced = await sendCommandResponse(author, potAnnouncement, { - requireExclusiveGiveawayOwnership: true - }); + const announced = await sendMessage( + `${bridgeMarker(BRIDGE_MARKERS.POT, "💰")} ` + + [addedPart, totalPart, donationPart, scalingPart].filter(Boolean).join(" "), + { requireExclusiveGiveawayOwnership: true } + ); if (announced === false) { logEvent( @@ -8529,39 +7875,16 @@ body.host-panel-dragging * { if (!isHostOrAdmin(author, fancyName, giveawayData.host)) return; const actionReply = makeStaffAttributedReply(ctx); - const mins = Number(args[0]); - if (!Number.isFinite(mins) || mins <= 0) { + const mins = parseFloat(args[0]); + if (isNaN(mins) || mins <= 0) { actionReply( "[color=red]Usage:[/color] !time add|remove or !addtime|!removetime " ); return; } - // Limit the TOTAL duration since the public opening, not merely the - // remaining countdown. Host and staff use this same enforcement path. - const maxEndTs = getGiveawayMaxEndTs(giveawayData.openedAt); - const oldEndTs = Number(giveawayData.endTs); - const nextEndTs = oldEndTs + sign * mins * 60_000; - if (maxEndTs === null || !Number.isFinite(oldEndTs) || !Number.isFinite(nextEndTs)) { - actionReply("[color=red]Time change refused:[/color] a trustworthy giveaway opening time is unavailable."); - return; - } - if (nextEndTs > maxEndTs) { - if (ctx.source === "chat") { - // Use the ordinary command-response route. Silent Mode - // sends this privately to the command author; Rehearsal - // Mode redirects outgoing messages privately to the host. - actionReply( - `🚫 [color=red][b]Time limit exceeded![/b][/color] ` + - `DarkPeers giveaways can last at most [b]${MAX_GIVEAWAY_MINUTES} minutes total[/b] ` + - `from opening. [b]No time was added.[/b]` - ); - } else { - actionReply(`[color=red]Time change refused:[/color] DarkPeers allows a maximum of ${MAX_GIVEAWAY_MINUTES} minutes from the original opening.`); - } - return; - } - giveawayData.endTs = nextEndTs; + const deltaMs = sign * mins * 60_000; + giveawayData.endTs += deltaMs; // move the deadline rebuildSchedule(); // rebuild reminder schedule @@ -11794,11 +11117,7 @@ body.host-panel-dragging * { `✨[b][color=#FB4F4F]Gift the host to add to the pot! [color=${GIFT_HINT_COLOR}]/gift ${getGiftSyntaxHostName()} AMOUNT MESSAGE[/color][/color][/b]✨` + silentLine + rigLine; - // Both scheduled reminders and explicit !reminder requests are - // announcements to ALL entrants, not replies to the host command. - // Silent Mode must not make the requested reminder invisible. - // Rehearsal Mode still redirects sendMessage() to the host's PM. - return sendMessage(msg); + sendMessage(msg); } // ───────────── HTTP-based BON gifting helper ───────────── @@ -12730,11 +12049,11 @@ body.host-panel-dragging * { return sendMessage(`/msg ${to} ${body}`, options); } - async function sendCommandResponse(username, messageStr, options = {}) { + async function sendCommandResponse(username, messageStr) { if (GENERAL_SETTINGS.silent_mode) { - return sendPrivateMessage(username, messageStr, options); + return sendPrivateMessage(username, messageStr); } - return sendMessage(messageStr, options); + return sendMessage(messageStr); } /** Prepare a message for sending: obfuscate "giveaway" and apply number formatting. */ @@ -12861,31 +12180,12 @@ body.host-panel-dragging * { if (!forceChatboxOnly && !DEBUG_SETTINGS.suppressApiMessages) { try { if (await trySendViaApi(messageStr)) return true; - // A 5xx/non-OK API response might still follow a committed - // public POST. An opening must NEVER post again via chatbox: - // the durable checkpoint is the only safe recovery path. - if (options?.noFallbackOnApiException === true) return false; } catch (e) { - // An opening POST may already have committed before its reply - // timed out. Never issue a second, chatbox-based announcement - // during that uncertainty; the persisted opening checkpoint - // must be reconciled from authoritative Main Chat first. - if (options?.noFallbackOnApiException === true) { - if (DEBUG_SETTINGS.log_chat_messages) { - console.warn("Opening API result ambiguous; leaving checkpoint for verification:", e); - } - return false; - } if (DEBUG_SETTINGS.log_chat_messages) console.warn("API send failed, falling back to chatbox method:", e); if (DEBUG_SETTINGS.verify_sendmessage) console.debug("sendMessage: API send failed, falling back to chatbox method"); } } - // Opening output is single-shot. Even when the API was deliberately - // disabled, missing credentials, or returned non-OK, never silently - // publish a second opening through chatbox. - if (options?.noFallbackOnApiException === true) return false; - // Settlement output is authoritative. If the API attempt yielded while a // BFCache/pagehide handoff quarantined this document, never let the stale // continuation escape through the synchronous chatbox fallback. @@ -13754,22 +13054,9 @@ body.host-panel-dragging * { } - function parseGiveawayMinutes(value) { - const raw = String(value ?? "").trim(); - if (!/^\d+$/.test(raw)) return null; - const minutes = Number(raw); - return Number.isSafeInteger(minutes) && minutes >= 1 && minutes <= MAX_GIVEAWAY_MINUTES - ? minutes : null; - } - - function getGiveawayMaxEndTs(startTime) { - const startedAt = Number(startTime); - return Number.isFinite(startedAt) && startedAt > 0 - ? startedAt + MAX_GIVEAWAY_MINUTES * 60_000 : null; - } - function totalMinutes () { - return parseGiveawayMinutes(timerInput?.value) ?? 0; + const t = parseInt(String(timerInput?.value ?? ""), 10); + return (Number.isFinite(t) && t > 0) ? t : 0; } function nextReminderMs(schedule, msLeft) { diff --git a/userscripts/giveaway/src/00-preamble.js b/userscripts/giveaway/src/00-preamble.js index 75f7149d..e6898108 100644 --- a/userscripts/giveaway/src/00-preamble.js +++ b/userscripts/giveaway/src/00-preamble.js @@ -2,7 +2,7 @@ // @name DarkPeers BONanza Giveaway | Maghuro Fork // @namespace https://darkpeers.org/users/maghuro // @description BON giveaways on DarkPeers with an optional direct contribution to the BON Pool -// @version 1.5.15 +// @version 1.5.16 // @author 🤖 T.R.A.V.I.S., Maghuro & M.A.E.S.T.R.O. // @homepageURL https://darkpeers.org/users/maghuro // @updateURL https://gist.githubusercontent.com/maghuro/da2dbfec94951990cbc54e75a9aee318/raw/DarkPeers_BONanza_Giveaway.meta.js @@ -20,6 +20,8 @@ // DarkPeers-only fork of "Blutopia BON Giveaway" v6.2.2 by Nums (GPL-3.0-or-later). // Changes in this fork: +// - v1.5.16 rolls back PR #29 to the previously working v1.5.14 runtime. +// The 45-minute cap and opening-recovery changes are deliberately removed. // - All non-DarkPeers tracker support, the upload.cx extra commands and the // openuserjs update check have been removed. // - A host-selected percentage (0-50%, steps of 5) of the final pot is @@ -244,37 +246,6 @@ // fallback now preserves exclusive ownership, uses the chatbox-only slash-command // path so an ambiguous chat-API timeout can never be mistaken for a definite // no-send, and restores only the exact unsent terminal marker after durable read-back. -// - v1.5.15 enforces DarkPeers' 45-minute maximum from public opening across -// the form, presets, host/staff time commands and restored active snapshots. -// Rejected !addtime / !time add attempts explain the 45-minute total -// limit without changing the deadline. Silent Mode sends all host/staff -// command replies privately, but explicit !reminder requests still post -// publicly just like automatic reminders. Rehearsal routes both to host PMs. -// Opening delivery is checkpointed before the POST and verified against -// Main Chat; a timeout preserves recovery rather than resetting. Sponsor -// eligibility begins after the verified publication timestamp bucket, -// separately from the conservative 45-minute pre-send deadline. Live -// openings require a valid Main Chat cursor before any public POST. -// Only exact announcements whose complete server timestamp bucket lies -// within a persisted, bounded opening attempt can confirm publication; -// later repeated or overlapping same-second announcements are rejected. -// Gift History timestamps also must start no earlier than the verified -// sponsor opening boundary; HTTP error/5xx opening POSTs must never -// trigger a second announcement via chatbox fallback. -// A second tab must recheck saved recovery AFTER acquiring its Web Lock. -// Confirmed LIVE openings are authoritatively rechecked on every restore. -// When Gift History calibration is unavailable, ALL ambiguous gift rows -// remain uncounted/retryable until reliable calibration succeeds. -// Legacy snapshots without verified opening evidence are retained only -// for manual recovery (no automatic 45-minute rewrite or BON payout). -// Coarse Gift History calibration uncertainty is tracked, saved across -// reloads, and subtracted before sponsor-start eligibility checks. -// Recovery storage read failures block new giveaways even when raw data -// appears absent; even empty-but-present recovery keys are occupied. -// Before the exclusive Web Lock, reading must not migrate legacy keys. -// Preflight before the persisted opening checkpoint is -// NEVER snapshotted, including on BFCache/pagehide. Corrupt recovery -// snapshots are preserved rather than silently deleted. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. diff --git a/userscripts/giveaway/src/01-global-constants.js b/userscripts/giveaway/src/01-global-constants.js index 31f5844b..981310e2 100644 --- a/userscripts/giveaway/src/01-global-constants.js +++ b/userscripts/giveaway/src/01-global-constants.js @@ -8,7 +8,6 @@ // - MIN_ACTION_GAP_MS blocks ultra-fast repeat triggers (usually bots/double-sends) // - REPEAT_COMMAND_COOLDOWNS_MS prevents the same command from being spammed for identical output // - strikes increase lockout length for repeat offenders (decays over time) - const MAX_GIVEAWAY_MINUTES = 45; // DarkPeers rule: hard total cap from public opening const MIN_ACTION_GAP_MS = 900; // ignore triggers faster than this per user const ENTRY_FEEDBACK_COOLDOWN_MS = 8000; // throttle duplicate/out-of-range feedback per user const STRIKE_WINDOW_MS = 10 * 60 * 1000; // 10 minutes @@ -587,7 +586,7 @@ maxwinners: { label: "Max Winners", section: "giveaway", description: "Set max scaled winners.", usage: `!maxwinners 1-${MAX_WINNERS}`, requiresGiveaway: true, hostOnly: true, args: [{ name: "count", label: "Max", type: "int", required: true, min: 1, max: MAX_WINNERS, placeholder: "max" }] }, scale: { label: "Scale", section: "info", description: "Show scaling progress.", usage: "!scale", requiresGiveaway: true }, - addtime: { label: "Add Time", section: "giveaway", description: "Add minutes (45-minute total cap).", usage: "!addtime ", requiresGiveaway: true, hostOnly: true, + addtime: { label: "Add Time", section: "giveaway", description: "Add giveaway minutes.", usage: "!addtime ", requiresGiveaway: true, hostOnly: true, args: [{ name: "minutes", label: "Min", type: "int", required: true, min: 1, placeholder: "minutes" }] }, removetime: { label: "Remove Time", section: "giveaway", description: "Remove giveaway minutes.", usage: "!removetime ", requiresGiveaway: true, hostOnly: true, args: [{ name: "minutes", label: "Min", type: "int", required: true, min: 1, placeholder: "minutes" }] }, diff --git a/userscripts/giveaway/src/04-ui-template.js b/userscripts/giveaway/src/04-ui-template.js index 68599f4c..bae25419 100644 --- a/userscripts/giveaway/src/04-ui-template.js +++ b/userscripts/giveaway/src/04-ui-template.js @@ -114,9 +114,7 @@ type="number" inputmode="numeric" min="1" - max="45" step="1" - title="Maximum giveaway duration: 45 minutes" value="5" autocomplete="off" > @@ -377,7 +375,7 @@
  • !time add N / remove N  - Adjust remaining minutes (45 min total maximum) + Adjust remaining minutes
  • !rig/!unrig  Toggle rigging (visual only)
  • !end [host] Host: !end. Staff: !end <host>
  • diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index 43cca3aa..d073b202 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -521,8 +521,8 @@ remNumInput = document.getElementById("reminderNum"); reminderEvery = document.getElementById("reminderEvery"); - // Enforce whole-number minutes and DarkPeers' 45-minute maximum in the UI. - // The start handler separately validates the value against script/DOM bypasses. + // Enforce whole-number minutes in the Time field (no decimals / exponent notation) + // (No page refresh required; prevents accidental fractional minutes.) (function enforceWholeMinutesOnTimerField() { if (!timerInput) return; @@ -541,12 +541,10 @@ } if (/^\d+$/.test(v)) { - const n = Number(v); - if (Number.isSafeInteger(n) && n >= 1) { - const bounded = String(Math.min(n, MAX_GIVEAWAY_MINUTES)); - timerInput.value = bounded; + const n = parseInt(v, 10); + if (Number.isFinite(n) && n >= 1) { timerInput.setCustomValidity(""); - lastValid = bounded; + lastValid = v; return; } } @@ -969,11 +967,7 @@ if (coinInput && preset.amount) coinInput.value = preset.amount; if (startInput && preset.startNum) startInput.value = preset.startNum; if (endInput && preset.endNum) endInput.value = preset.endNum; - if (timerInput && preset.timer) { - const minutes = Number(preset.timer); - timerInput.value = Number.isSafeInteger(minutes) && minutes >= 1 - ? String(Math.min(minutes, MAX_GIVEAWAY_MINUTES)) : "5"; - } + if (timerInput && preset.timer) timerInput.value = preset.timer; if (remNumInput && preset.reminders != null) remNumInput.value = preset.reminders; if (winnersInput && preset.winners) winnersInput.value = preset.winners; if (scaleWinnersToggleInput) { @@ -1075,108 +1069,36 @@ function readActiveGiveawayRawForHost(hostName = "", { migrateLegacy = false } = {}) { const requestedHostKey = normalizeUserKey(hostName || getLoggedInUsername()); const storageKey = getActiveGiveawayStorageKey(requestedHostKey); - if (!requestedHostKey || !storageKey) { - return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: false }; - } + if (!requestedHostKey || !storageKey) return { storageKey, raw: null, migratedLegacy: false }; - // Namespaced recovery never proves the old hostname-wide slot empty. - // Both slots may contain distinct settlement evidence for real BON. - let hasStoredState = false; try { const namespacedRaw = localStorage.getItem(storageKey); - const hasNamespacedState = namespacedRaw !== null; - hasStoredState = hasNamespacedState; - const namespacedResult = { - storageKey, raw: namespacedRaw, hasStoredState: hasNamespacedState, - migratedLegacy: false, readOk: true - }; - - // Rehearsal is isolated from the LIVE legacy recovery key. - if (REHEARSAL_MODE) return namespacedResult; - + if (namespacedRaw) return { storageKey, raw: namespacedRaw, migratedLegacy: false }; + + // Backwards-compatible migration from pre-v1.3.24 hostname-wide storage. + // A legacy snapshot belonging to another account is deliberately left + // untouched; the current account now has its own namespace and cannot + // overwrite that foreign recovery state. Rehearsal storage never + // imports a live legacy snapshot. + if (REHEARSAL_MODE) { + return { storageKey, raw: null, migratedLegacy: false }; + } const legacyRaw = localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY); - // Only null means absent, even if the current namespace is occupied. - if (legacyRaw === null) return namespacedResult; - hasStoredState = true; + if (!legacyRaw) return { storageKey, raw: null, migratedLegacy: false }; const legacy = JSON.parse(legacyRaw); - const legacyHost = legacy?.giveawayData?.host; - // Reject non-string host identities rather than coercing a - // malicious/broken array, object or number into a username. - const legacyHostKey = typeof legacyHost === "string" - ? normalizeUserKey(legacyHost) : ""; - - if (!legacyHostKey) { - console.warn("[BON Giveaway] Unidentified legacy recovery is occupied; manual review required."); - return { - storageKey, raw: legacyRaw, hasStoredState: true, - migratedLegacy: false, readOk: false - }; - } - - if (legacyHostKey !== requestedHostKey) { - // Only a positively identified different host can be ignored. - return namespacedResult; - } - - if (hasNamespacedState) { - // Same host is NOT the same settlement. A stale copy can - // omit already-paid ledger entries. Never auto-restore while - // conflicting same-host records are present. - if (namespacedRaw !== legacyRaw) { - console.warn("[BON Giveaway] Conflicting same-host recovery slots; manual review required."); - return { - storageKey, raw: namespacedRaw, hasStoredState: true, - migratedLegacy: false, readOk: false - }; - } - if (migrateLegacy) { - // Both records were byte-identical. Under the exclusive - // restore lock, retire the redundant old copy BEFORE - // a pending -> confirmed write can make it conflict. - // Verify BOTH values are unchanged before deletion. - if (localStorage.getItem(storageKey) !== namespacedRaw || - localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY) !== legacyRaw) { - throw new Error("Duplicate recovery state changed before cleanup"); - } - localStorage.removeItem(LS_ACTIVE_GIVEAWAY_LEGACY); - if (localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY) !== null) { - throw new Error("Duplicate legacy recovery cleanup not confirmed"); - } - } - return namespacedResult; + const legacyHostKey = normalizeUserKey(legacy?.giveawayData?.host); + if (!legacyHostKey || legacyHostKey !== requestedHostKey) { + return { storageKey, raw: null, migratedLegacy: false }; } - // Only the locked restore path migrates the single identified - // legacy record. Never delete it before read-back confirmation. if (migrateLegacy) { localStorage.setItem(storageKey, legacyRaw); - if (localStorage.getItem(storageKey) !== legacyRaw) { - throw new Error("Legacy migration read-back mismatch"); - } - // A successful write alone is not a completed migration. - // Confirm the legacy key really disappeared BEFORE any - // checkpoint mutation can diverge the two same-host copies. - // If removal is denied or silently ignored, fail closed. - if (localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY) !== legacyRaw) { - throw new Error("Legacy recovery changed before removal"); - } localStorage.removeItem(LS_ACTIVE_GIVEAWAY_LEGACY); - if (localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY) !== null) { - throw new Error("Single-slot legacy migration cleanup not confirmed"); - } } - return { - storageKey, raw: legacyRaw, hasStoredState: true, - migratedLegacy: !!migrateLegacy, readOk: true - }; - } catch (error) { - // Storage or JSON failure is NOT evidence of vacant recovery. - console.warn("[BON Giveaway] Active recovery storage read failed:", error); - return { - storageKey, raw: null, hasStoredState, - migratedLegacy: false, readOk: false - }; + return { storageKey, raw: legacyRaw, migratedLegacy: !!migrateLegacy }; + } catch { + return { storageKey, raw: null, migratedLegacy: false }; } } @@ -1400,13 +1322,7 @@ * Called at key mutation points (new entry, start, addbon, sponsor, time adjust). */ function snapshotGiveaway({ force = false, verifyWrite = false } = {}) { - // Preflight state exists only in memory while balance, Gift History and - // chat cursor checks run. Never persist it as a legacy giveaway when - // pagehide/unload fires BEFORE the opening checkpoint is saved. - const proof = giveawayData?.openingCheckpoint; - if (!giveawayData || - !proof || (proof.status !== "pending" && proof.status !== "confirmed") || - !canMutateActiveGiveaway() || (giveawayData.__ending && !force)) return false; + if (!giveawayData || !canMutateActiveGiveaway() || (giveawayData.__ending && !force)) return false; try { const snapshot = { giveawayData: { @@ -1418,12 +1334,6 @@ totalEntries: giveawayData.totalEntries, winningNumber: giveawayData.winningNumber, totalSeconds: giveawayData.totalSeconds, - openedAt: giveawayData.openedAt, - // Deadline and sponsor-window boundaries are different. - sponsorStartTs: giveawayData.sponsorStartTs, - openingCheckpoint: giveawayData.openingCheckpoint && - typeof giveawayData.openingCheckpoint === "object" - ? { ...giveawayData.openingCheckpoint } : null, endTs: giveawayData.endTs, winnersNum: giveawayData.winnersNum, baseWinnersAtStart: giveawayData.baseWinnersAtStart, @@ -1469,8 +1379,6 @@ lastMsgId: Math.max(0, Math.floor(Number(window.__activeTracker.lastMsgId) || 0)), cursorInitialized: !!window.__activeTracker.cursorInitialized, giftHistoryClockOffsetMs: optionalFiniteNumber(window.__activeTracker.giftHistoryClockOffsetMs), - giftHistoryClockOffsetUncertaintyMs: - optionalFiniteNumber(window.__activeTracker.giftHistoryClockOffsetUncertaintyMs), giftHistoryInitialized: !!window.__activeTracker.giftHistoryInitialized, giftHistorySeenKeys: Array.from(window.__activeTracker.giftHistorySeenKeys || []).slice(-250), historyFallbackActive: !!window.__activeTracker.historyFallbackActive, @@ -1543,13 +1451,7 @@ const loggedInHostKey = normalizeUserKey(getLoggedInUsername()); if (!loggedInHostKey) return null; - // UI bootstrap runs BEFORE Web Lock acquisition: never migrate or - // remove legacy keys here. Only the post-lock restore read may do so. - const stored = readActiveGiveawayRawForHost(loggedInHostKey, { migrateLegacy: false }); - if (!stored.readOk) { - console.warn("[BON Giveaway] Recovery storage unreadable; leaving any existing state untouched."); - return null; - } + const stored = readActiveGiveawayRawForHost(loggedInHostKey, { migrateLegacy: true }); if (!stored.raw) return null; const snap = JSON.parse(stored.raw); if (!snap || !snap.giveawayData) return null; @@ -1568,75 +1470,14 @@ return null; } - // Pre-checkpoint releases never proved that an opening reached - // Main Chat, and their startTime was recorded AFTER the send. - // Preserve the original raw snapshot, including old/expired and - // partially-paid records, but NEVER auto-resume or auto-settle it. - // Rewriting its 45-minute deadline from that late startTime would - // also violate the absolute public-opening cap. - const checkpoint = snap.giveawayData.openingCheckpoint; - if (!checkpoint || ( - checkpoint.status !== "pending" && - checkpoint.status !== "confirmed" - ) || ( - checkpoint.status === "confirmed" && - (!Number.isFinite(Number(snap.giveawayData.openedAt)) || - Number(snap.giveawayData.openedAt) <= 0 || - !Number.isFinite(Number(snap.giveawayData.sponsorStartTs)) || - Number(snap.giveawayData.sponsorStartTs) < - Number(snap.giveawayData.openedAt)) - )) { - console.warn( - "[BON Giveaway] Unverified legacy opening retained for manual review; " + - "automatic resume, deadline migration and BON settlement are blocked." - ); - snap.__legacyOpeningUnverified = true; - return snap; - } - const endTs = Number(snap.giveawayData.endTs); if (!Number.isFinite(endTs) || endTs <= 0) { - // Invalid saved data can reflect an incomplete write or an - // in-progress payout. Preserve it for manual inspection; - // never erase potentially recoverable settlement evidence. - console.warn("[BON Giveaway] Invalid saved deadline; snapshot preserved for manual recovery."); + clearGiveawaySnapshot(); return null; } - // An older release may have saved a deadline beyond the DarkPeers cap. - // Restrict only an uncommitted draw: committed settlement timestamps - // are immutable and must survive crash/reload unchanged. - const startTs = Number(snap.giveawayData.openedAt ?? snap.startTime); - const maxEndTs = getGiveawayMaxEndTs(startTs); + const overdueMs = Date.now() - endTs; const committedSettlement = snap.giveawayData?.settlement?.committed === true; - const correctedDeadline = !committedSettlement && maxEndTs !== null && endTs > maxEndTs; - if (correctedDeadline) { - snap.giveawayData.endTs = maxEndTs; - - // Legacy reminder offsets belong to the *old* deadline. Rebuild - // them against the capped original duration, preserving the - // configured reminder count. countdownTimer/nextReminderMs will - // discard thresholds already elapsed without replaying them. - const requestedReminders = Number(snap.giveawayData.reminderNum); - const reminderCount = Number.isFinite(requestedReminders) - ? Math.max(0, Math.min( - Math.floor(requestedReminders), - getReminderLimits(MAX_GIVEAWAY_MINUTES)[0] - )) - : 0; - snap.giveawayData.reminderSchedule = getReminderSchedule(MAX_GIVEAWAY_MINUTES, reminderCount); - snap.giveawayData.reminderNum = reminderCount; - const cadenceSec = reminderCount > 0 - ? MAX_GIVEAWAY_MINUTES * 60 / (reminderCount + 1) - : 0; - snap.giveawayData.reminderFreqSec = cadenceSec; - snap.giveawayData.nextReminderSec = cadenceSec; - snap.giveawayData.totalSeconds = MAX_GIVEAWAY_MINUTES * 60; - console.warn("[BON Giveaway] Recovered deadline and reminder schedule capped at 45 minutes after public opening."); - } - // Apply the normal one-hour restore grace to the *capped* deadline. - // Otherwise ancient giveaways could unexpectedly resume settlement. - const overdueMs = Date.now() - Number(snap.giveawayData.endTs); const closingSettlementLatched = snap.giveawayData?.closingIntent === true || snap.giveawayData?.closingNoticeSent === true || @@ -1645,16 +1486,11 @@ if ( overdueMs > EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS && !committedSettlement && - !closingSettlementLatched && - snap.giveawayData?.openingCheckpoint?.status !== "pending" + !closingSettlementLatched ) { - // This loader also runs BEFORE acquiring the Web Lock. - // Deleting a stale recovery slot here could erase another - // tab's in-progress settlement or manual recovery evidence. - // Preserve it and block auto-resume, never clear it on read. - console.warn("[BON Giveaway] Expired active snapshot preserved for manual review; automatic settlement grace exceeded."); - snap.__expiredBeyondGrace = true; - return snap; + console.warn("[BON Giveaway] Discarding stale expired active snapshot; automatic settlement grace exceeded."); + clearGiveawaySnapshot(); + return null; } // Once settlement is committed OR the closing latch has been persisted, @@ -1663,11 +1499,8 @@ // active-giveaway grace window. snap.__expiredAtLoad = overdueMs >= 0 || closingSettlementLatched; return snap; - } catch (error) { - // Malformed or temporarily unreadable snapshots must not be - // silently discarded; a subsequent clean read or manual analysis - // may be needed to prevent lost or duplicate BON payouts. - console.warn("[BON Giveaway] Unable to parse recovery snapshot; retaining stored data:", error); + } catch { + clearGiveawaySnapshot(); return null; } } @@ -1763,22 +1596,21 @@ } async function restoreGiveawayFromSnapshot(snap) { - // The earlier snapshot is only a discovery hint. Another tab may - // persist a newer settlement while we await the Web Lock. Classify - // recovery status, expiry, checkpoint and host ONLY after rereading - // the latest saved state under exclusive ownership. - if (!snap) return false; + if (!snap || !snap.giveawayData) return false; // Validate the live DarkPeers identity/Main Chat context BEFORE claiming // ownership or touching the recoverable snapshot. A transient/wrong chat // context must never destroy or mutate persisted giveaway state. cacheChatContext(); const restoreHost = getLoggedInUsername(); + const restoreExpectedHost = String(snap.giveawayData.host || "").trim(); const restoreUserId = Math.floor(Number(OT_USER_ID)); const restorePublicRoom = Math.floor(Number(OT_CHATROOM_ID)); const restoreExpectedRoom = Math.floor(Number(DARKPEERS_MAIN_CHATROOM_ID)); const restoreContextOk = !!restoreHost && + !!restoreExpectedHost && + normalizeUserKey(restoreHost) === normalizeUserKey(restoreExpectedHost) && Number.isFinite(restoreUserId) && restoreUserId > 0 && Number.isFinite(restorePublicRoom) && @@ -1790,6 +1622,7 @@ "[BON Giveaway] Restore deferred: DarkPeers identity/Main Chat context is not trustworthy.", { restoreHost, + expectedHost: restoreExpectedHost, userId: restoreUserId, publicRoom: restorePublicRoom, expectedRoom: restoreExpectedRoom, @@ -1813,135 +1646,8 @@ } try { - // The caller's snap was read before awaiting the Web Lock. - // Another tab may have committed/persisted payout state while we - // were queued, then released its lock. Never restore that stale - // object and overwrite newer payout/checkpoint information. - // Refresh storage and re-apply all current restore validation - // WHILE holding exclusive ownership. - const lockedRead = readActiveGiveawayRawForHost(restoreHost, { - migrateLegacy: true - }); - if (!ownsTabLock() || !lockedRead.readOk || - !lockedRead.hasStoredState || !lockedRead.raw) { - console.warn("[BON Giveaway] Restore blocked: recovery storage changed or could not be read under lock."); - releaseTabLock(); - return false; - } - const lockedSnapshot = loadGiveawaySnapshot(); - if (!ownsTabLock() || !lockedSnapshot || - lockedSnapshot.__legacyOpeningUnverified === true || - lockedSnapshot.__expiredBeyondGrace === true || - !lockedSnapshot.giveawayData || - normalizeUserKey(lockedSnapshot.giveawayData.host) !== - normalizeUserKey(restoreHost) || - (lockedSnapshot.giveawayData.rehearsalMode === true) !== REHEARSAL_MODE) { - console.warn("[BON Giveaway] Restore blocked: locked snapshot is absent, unverified or belongs to another mode/host."); - if (lockedSnapshot?.__legacyOpeningUnverified === true || - lockedSnapshot?.__expiredBeyondGrace === true) { - window.alert( - "GIVEAWAY RECOVERY REQUIRES MANUAL REVIEW: The latest saved state " + - "cannot safely resume entries or BON settlement. " + - "The original recovery data has been preserved." - ); - } - releaseTabLock(); - return false; - } - snap = lockedSnapshot; - - // Local storage alone is not authoritative evidence of a public - // opening. For a previously confirmed LIVE giveaway, prove that - // the exact original message ID/host/text/timestamp still exists - // in Main Chat before restoring entries, pot or settlement. The - // verifier checks the original bounded POST window and cursor. - const restoredCheckpoint = snap.giveawayData?.openingCheckpoint; - if (!REHEARSAL_MODE && restoredCheckpoint?.status === "confirmed") { - const verified = await verifyPendingGiveawayOpening(restoredCheckpoint); - const savedSponsorStart = optionalFiniteNumber( - snap.giveawayData.sponsorStartTs - ); - if (!verified || - verified.messageId !== optionalFiniteNumber(restoredCheckpoint.messageId) || - savedSponsorStart === null || - verified.sponsorStartTs !== savedSponsorStart) { - logEvent("Confirmed opening recovery deferred", - "Authoritative Main Chat could not reprove the exact saved opening and sponsor boundary."); - window.alert( - "GIVEAWAY RECOVERY PAUSED: The saved opening could not " + - "be verified again against Main Chat. No entries, " + - "sponsor updates or BON settlement will be resumed." - ); - releaseTabLock(); - return false; - } - } - - // An opening may have reached Main Chat even if its POST response was - // lost. It was durably checkpointed BEFORE sending; never resume - // entries, sponsor accounting or settlement until the exact public - // opening is verified from authoritative chat history. - if (snap.giveawayData?.openingCheckpoint?.status === "pending") { - const verified = await verifyPendingGiveawayOpening( - snap.giveawayData.openingCheckpoint - ); - if (!verified) { - logEvent("Opening recovery deferred", - "Main Chat could not prove the uncertain opening; existing checkpoint preserved."); - window.alert( - "GIVEAWAY RECOVERY PAUSED: The public opening could not be verified. " + - "No giveaway settlement or BON transfer will run. Retry from Main Chat later." - ); - releaseTabLock(); - return false; - } - - // Never resurrect a very old uncertain opening into automatic - // settlement. A human must investigate after recovery grace. - const overdue = Date.now() - Number(snap.giveawayData.endTs); - if (overdue > EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS) { - logEvent("Opening recovery requires manual review", - "Opening verified but its automatic settlement grace has expired."); - window.alert( - "GIVEAWAY RECOVERY PAUSED: An old public opening was verified, " + - "but automatic settlement is unsafe after the recovery grace. " + - "Keep the saved recovery state for manual review." - ); - releaseTabLock(); - return false; - } - - snap.giveawayData.sponsorStartTs = verified.sponsorStartTs; - snap.giveawayData.openingCheckpoint.status = "confirmed"; - snap.giveawayData.openingCheckpoint.messageId = verified.messageId; - // Persist the verified boundary before any sponsor reconciliation. - // Do not call snapshotGiveaway yet: entries haven't been restored - // into memory, so serializing them now would overwrite recovery. - const storageKey = getActiveGiveawayStorageKey(snap.giveawayData.host); - if (!storageKey) { - releaseTabLock(); - return false; - } - const persisted = { ...snap }; - delete persisted.__expiredAtLoad; - const verifiedRaw = JSON.stringify(persisted); - try { - localStorage.setItem(storageKey, verifiedRaw); - if (localStorage.getItem(storageKey) !== verifiedRaw) { - throw new Error("Verified opening checkpoint read-back mismatch"); - } - } catch (error) { - console.warn("[BON Giveaway] Could not persist verified opening:", error); - releaseTabLock(); - return false; - } - } - // 1) Restore giveaway data giveawayData = snap.giveawayData; - const restoredOpenTs = Number(giveawayData.openedAt ?? snap.startTime); - giveawayData.openedAt = getGiveawayMaxEndTs(restoredOpenTs) !== null - ? restoredOpenTs : null; // Restore starts fail-closed for sponsor accounting. Entries and time // may resume, but BON mutation and settlement stay blocked until the @@ -2063,19 +1769,11 @@ updateRigToggleUI(); // 4) Restore start time - giveawayStartTime = giveawayData.openedAt !== null - ? new Date(giveawayData.openedAt) : new Date(); + giveawayStartTime = snap.startTime ? new Date(snap.startTime) : new Date(); lastKnownGiveawayHostKey = normUserKey(giveawayData.host) || lastKnownGiveawayHostKey; - // 5) Lock form fields. Keep the restored reminder count reflected - // in the UI: a later !addtime/!removetime rebuilds from this field. - if (remNumInput) { - const savedReminderCount = Number(giveawayData.reminderNum); - remNumInput.value = String(Number.isFinite(savedReminderCount) - ? Math.max(0, Math.min(MAX_REMINDERS, Math.floor(savedReminderCount))) - : 0); - } + // 5) Lock form fields startButton.disabled = false; coinInput.disabled = true; startInput.disabled = true; @@ -2143,18 +1841,13 @@ const savedTracker = (snap.sponsorTracker && typeof snap.sponsorTracker === "object") ? snap.sponsorTracker : null; const tracker = new SponsorTracker({ chatroomId, - giveawayStartTime: new Date( - giveawayData.sponsorStartTs ?? giveawayStartTime.getTime() - ), + giveawayStartTime, giveawayData, lastMsgId: savedTracker ? savedTracker.lastMsgId : 0, cursorInitialized: savedTracker ? !!savedTracker.cursorInitialized : false, giftHistoryClockOffsetMs: savedTracker ? optionalFiniteNumber(savedTracker.giftHistoryClockOffsetMs) : null, - giftHistoryClockOffsetUncertaintyMs: savedTracker - ? optionalFiniteNumber(savedTracker.giftHistoryClockOffsetUncertaintyMs) - : null, giftHistoryInitialized: savedTracker ? !!savedTracker.giftHistoryInitialized : false, giftHistorySeenKeys: savedTracker && Array.isArray(savedTracker.giftHistorySeenKeys) ? savedTracker.giftHistorySeenKeys diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index bf273f9f..7a94cf77 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -1,100 +1,6 @@ // SECTION 6: Giveaway Lifecycle // ─────────────────────────────────────────────────────────── - // The opening POST has a 7s HTTP timeout. Allow limited transport overhead - // for recovering an ambiguous result, but NEVER accept an announcement - // sent minutes/hours later as this giveaway's opening. - const OPENING_POST_MAX_PUBLICATION_WINDOW_MS = 10000; - - // Reconcile an opening announcement against authoritative Main Chat history. - // An HTTP timeout is NOT evidence the server rejected the POST. Never - // release a pending opening snapshot merely because sendMessage returned - // false; an announced giveaway may already exist publicly. - async function verifyPendingGiveawayOpening(checkpoint) { - if (!checkpoint || - (checkpoint.status !== "pending" && checkpoint.status !== "confirmed") || - !checkpoint.preparedMessage || !Number.isFinite(Number(checkpoint.startedAt))) { - return null; - } - - const afterId = optionalFiniteNumber(checkpoint.afterMessageId); - // A missing pre-send cursor makes same-second identical announcements - // indistinguishable. Never auto-confirm such a snapshot or payout. - if (!Number.isSafeInteger(afterId) || afterId <= 0) return null; - const startedAt = Number(checkpoint.startedAt); - const persistedHardEnd = optionalFiniteNumber(checkpoint.windowEndAt); - const completedAt = optionalFiniteNumber(checkpoint.sendCompletedAt); - const absoluteHardEnd = startedAt + OPENING_POST_MAX_PUBLICATION_WINDOW_MS; - // An absent completion time means the tab may have crashed mid-POST: - // the pre-persisted hard window is still authoritative. A recorded - // completion can only NARROW the window, never extend it. - const windowEnd = Math.min( - absoluteHardEnd, - persistedHardEnd === null ? absoluteHardEnd : persistedHardEnd, - completedAt === null ? absoluteHardEnd : completedAt - ); - if (!Number.isFinite(startedAt) || !Number.isFinite(windowEnd) || - windowEnd <= startedAt) return null; - const senderId = Math.floor(Number(OT_USER_ID)); - if (!Number.isFinite(senderId) || senderId <= 0) return null; - - for (let attempt = 0; attempt < 3; attempt++) { - if (!ownsTabLock()) return null; - try { - const url = new URL(`/api/chat/messages/${DARKPEERS_MAIN_CHATROOM_ID}`, location.origin); - if (afterId !== null) url.searchParams.set("after_id", String(afterId)); - const response = await fetchWithTimeout( - url, { credentials: "include", cache: "no-store" }, 5000 - ); - if (!ownsTabLock()) return null; - if (!response?.ok) throw new Error("Opening verification chat read failed"); - const payload = await response.json(); - if (!ownsTabLock()) return null; - if (!Array.isArray(payload?.data)) throw new Error("Malformed Main Chat messages"); - - for (const message of payload.data) { - const id = optionalFiniteNumber(message?.id); - if (!Number.isSafeInteger(id) || id <= afterId) continue; - // A confirmed checkpoint must identify the SAME original - // announcement after reload. Never replace it with a later - // identical post, even inside the bounded send window. - if (checkpoint.status === "confirmed" && - id !== optionalFiniteNumber(checkpoint.messageId)) continue; - const authorId = Math.floor(Number(message?.user_id ?? message?.user?.id)); - if (authorId !== senderId) continue; - if (String(message?.message ?? "") !== checkpoint.preparedMessage) continue; - - const rawTs = String(message?.created_at || ""); - const timestamp = Date.parse(rawTs); - const resolutionMs = unit3dTimestampResolutionMs(rawTs); - // Require the ENTIRE server timestamp bucket to be inside - // the original, bounded opening POST window. A bucket that - // overlaps the pre-send boundary is ambiguous and must not - // confirm an unrelated same-second message. An identical - // message sent after this attempt cannot confirm it either. - if (!Number.isFinite(timestamp) || - !Number.isFinite(resolutionMs) || resolutionMs <= 0 || - timestamp < startedAt || - (timestamp + resolutionMs) > windowEnd) continue; - - return { - messageId: id, - // Exclude the entire potentially ambiguous publication - // timestamp bucket from sponsorship. The 45-minute - // deadline remains anchored to the earlier send start. - sponsorStartTs: Math.max(startedAt, timestamp + resolutionMs) - }; - } - } catch (error) { - console.warn("[BON Giveaway] Opening reconciliation deferred:", error); - } - if (attempt < 2) { - await new Promise(resolve => setTimeout(resolve, 700)); - } - } - return null; - } - async function startGiveaway() { clearWinnersStatusUI(); @@ -104,57 +10,11 @@ return; } - // Validate in code, not just through the HTML max attribute: DevTools, - // presets or scripted property changes cannot start a longer giveaway. - const totalTimeMin = parseGiveawayMinutes(timerInput?.value); - if (totalTimeMin === null) { - window.alert(`DarkPeers giveaways must last between 1 and ${MAX_GIVEAWAY_MINUTES} whole minutes.`); - timerInput?.focus(); - return; - } - if (!giveawayForm.checkValidity()) { giveawayForm.reportValidity(); return; } - // Never overwrite a recoverable opening whose public POST was ambiguous. - // The pending snapshot is the only path to reconcile entrants/sponsors - // after a reload. A second start could otherwise destroy that evidence. - // Pre-lock validation is read-only. Any migration of a legacy key - // happens only after exclusive ownership has been acquired. - const prior = readActiveGiveawayRawForHost(getLoggedInUsername(), { - migrateLegacy: false - }); - // localStorage failures must block starts, never look like absence. - if (!prior.readOk || !prior.storageKey) { - window.alert("GIVEAWAY START BLOCKED: Cannot read recovery storage reliably. No opening was posted."); - return; - } - if (prior.hasStoredState) { - try { - const saved = JSON.parse(prior.raw); - const proof = saved?.giveawayData?.openingCheckpoint; - if (saved?.giveawayData && (!proof || proof.status !== "confirmed" || - !Number.isFinite(Number(saved.giveawayData.openedAt)) || - Number(saved.giveawayData.openedAt) <= 0 || - !Number.isFinite(Number(saved.giveawayData.sponsorStartTs)) || - Number(saved.giveawayData.sponsorStartTs) < - Number(saved.giveawayData.openedAt))) { - window.alert( - "GIVEAWAY START BLOCKED: A saved opening still requires " + - "verification or manual review. The original recovery " + - "snapshot must not be overwritten by a new giveaway." - ); - return; - } - } catch { - // Unknown existing recovery data must not be overwritten. - window.alert("GIVEAWAY START BLOCKED: Existing recovery data could not be read safely."); - return; - } - } - clearGiveawayLog(); // Normalize and validate the giveaway amount separately so we tolerate @@ -188,26 +48,6 @@ return; } - // The check above ran BEFORE ownership was acquired. Two tabs can - // both see empty storage; the second may acquire the lock only after - // the first has posted (or checkpointed) an ambiguous opening. Read - // storage AGAIN while owning the lock. Any remaining snapshot is - // recoverable state and must not be replaced by a second opening. - const savedAfterLock = readActiveGiveawayRawForHost(getLoggedInUsername(), { - migrateLegacy: true - }); - if (!savedAfterLock.readOk || savedAfterLock.hasStoredState || !savedAfterLock.storageKey) { - logEvent("Start blocked (post-lock recovery recheck)", - "Saved giveaway state appeared while awaiting the exclusive lock; no new opening was posted."); - window.alert( - "GIVEAWAY START BLOCKED: A previous giveaway was saved while " + - "waiting for this tab's lock. Recover or resolve it before " + - "starting another. No new opening has been posted." - ); - releaseTabLock(); - return; - } - if (sponsorsInterval) { clearInterval(sponsorsInterval); sponsorsInterval = null; } if (observer) { observer.disconnect(); observer = null; } @@ -263,7 +103,8 @@ //startButton.parentElement.hidden = true; entriesWrapper.hidden = false; - const totalTimeMs = totalTimeMin * 60000; + let totalTimeMin = totalMinutes(); + let totalTimeMs = totalTimeMin * 60000; let reminderNum = Math.min(Number(remNumInput.value), getReminderLimits(totalTimeMin)[0]); if (isNaN(reminderNum) || reminderNum < 0) reminderNum = 0; const schedule = getReminderSchedule(totalTimeMin, reminderNum); @@ -296,7 +137,6 @@ totalEntries: parseInt(endInput.value, 10) - parseInt(startInput.value, 10) + 1, winningNumber: null, totalSeconds: totalTimeMs / 1000, - openedAt: null, timeLeft: totalTimeMs / 1000, endTs: Date.now() + (totalTimeMs), winnersNum, @@ -322,9 +162,6 @@ updateRigToggleUI(); const currentBon = await getVerifiedHostBalance({ requireServer: true, maxAgeMs: 0 }); - // A pagehide while preflight was awaiting balance invalidates this - // document. Don't continue into the public opening workflow. - if (!ownsTabLock()) return; if (currentBon == null) { const startErr = "Unable to verify current BON balance."; @@ -411,7 +248,6 @@ giveawayData }); const sponsorBaselineReady = await tracker.bootstrapGiftHistory(); - if (!ownsTabLock()) return; if (!sponsorBaselineReady) { logEvent( "Start aborted (Gift History baseline)", @@ -425,123 +261,37 @@ return; } - // Read a Main Chat cursor BEFORE preparing the durable checkpoint. - // UNIT3D may ignore after_id; the verifier also filters by author, - // exact prepared message and publication-time window. - const afterMessageId = REHEARSAL_MODE - ? null : await getLatestChatMessageId(DARKPEERS_MAIN_CHATROOM_ID); - if (!ownsTabLock()) return; - if (!REHEARSAL_MODE && - (!Number.isSafeInteger(afterMessageId) || afterMessageId <= 0)) { - logEvent("Opening blocked (Main Chat cursor)", - "Unable to obtain a trustworthy pre-send Main Chat message ID. No opening was posted."); + const introSent = await sendMessage(introMessage, { + requireExclusiveGiveawayOwnership: true + }); + if (introSent === false) { + logEvent( + "Start aborted (opening announcement)", + "The public opening message could not be accepted for sending. Timers, entries and sponsor tracking were not started." + ); window.alert( - "GIVEAWAY ERROR: Cannot verify the latest Main Chat message. " + - "Nothing has been posted. Please try again when DarkPeers is responding." + "GIVEAWAY ERROR: The opening message could not be sent safely to DarkPeers Main Chat. " + + "The giveaway was not started." ); resetGiveaway(); return; } - // Deadline: conservative PRE-SEND time. Sponsorship: independently - // verified PUBLICATION time (never the earlier request timestamp). - const openingSendStartedAt = Date.now(); - const preparedOpening = prepareOutgoingMessage(introMessage); - giveawayStartTime = new Date(openingSendStartedAt); - giveawayData.openedAt = openingSendStartedAt; - giveawayData.endTs = openingSendStartedAt + totalTimeMs; - giveawayData.__sponsorAccountingVerified = false; - giveawayData.openingCheckpoint = { - status: "pending", - startedAt: openingSendStartedAt, - afterMessageId, - windowEndAt: openingSendStartedAt + OPENING_POST_MAX_PUBLICATION_WINDOW_MS, - preparedMessage: preparedOpening - }; + // Public opening is the temporal boundary. The pre-opening Gift History + // snapshot means every subsequently appearing received gift is new. + giveawayStartTime = new Date(); + tracker.giveawayStartTs = giveawayStartTime.getTime(); + // The advertised duration begins at the same public-opening boundary, + // not while balance/history preflight or chat delivery is still running. + giveawayData.endTs = giveawayStartTime.getTime() + totalTimeMs; + giveawayData.timeLeft = totalTimeMs / 1000; window.__activeTracker = tracker; - // If this write/read-back fails, DO NOT send anything publicly. - if (!snapshotGiveaway({ force: true, verifyWrite: true })) { - logEvent("Opening blocked", "Could not persist the opening checkpoint before posting."); - window.alert("GIVEAWAY ERROR: Unable to save the opening recovery checkpoint. No message was sent."); - resetGiveaway(); - return; - } - - const introSent = await sendMessage(preparedOpening, { - prepared: true, - requireExclusiveGiveawayOwnership: true, - noFallbackOnApiException: true - }); - if (!ownsTabLock()) return; - // Constrain reconciliation to the ACTUAL time of completion if - // this document survived the POST. If it did not, recovery uses - // the previously persisted bounded upper limit instead. - giveawayData.openingCheckpoint.sendCompletedAt = Date.now(); - if (!snapshotGiveaway({ force: true, verifyWrite: true })) { - logEvent("Opening delivery checkpoint not durable", - "Could not preserve the end of the opening attempt; recovery remains pending."); - window.alert("GIVEAWAY OPENING PAUSED: Delivery timing could not be stored. Reload to retry verification."); - releaseTabLock(); - return; - } - - // Rehearsal announcements deliberately go to a private host message; - // real openings require authoritative Main Chat confirmation. - const verifiedOpening = introSent !== false && REHEARSAL_MODE - ? { messageId: null, sponsorStartTs: openingSendStartedAt } - : await verifyPendingGiveawayOpening(giveawayData.openingCheckpoint); - - if (!verifiedOpening) { - logEvent("Opening unconfirmed (checkpoint retained)", - "Main Chat publication could not be verified; payout and sponsor tracking remain disabled until recovery verifies the exact opening."); - window.alert( - "GIVEAWAY OPENING UNCONFIRMED: The opening message might already be public. " + - "A recovery checkpoint was saved. Reload Main Chat to retry verification. " + - "Do NOT start another giveaway or send BON manually." - ); - // Keep the snapshot. Never reset or re-send an ambiguous opening. - releaseTabLock(); - return; - } - giveawayData.sponsorStartTs = verifiedOpening.sponsorStartTs; - giveawayData.openingCheckpoint.status = "confirmed"; - giveawayData.openingCheckpoint.messageId = verifiedOpening.messageId; - tracker.giveawayStartTs = verifiedOpening.sponsorStartTs; - syncGiveawayTimeLeft(giveawayData); - if (!snapshotGiveaway({ force: true, verifyWrite: true })) { - logEvent("Opening confirmation not durable", "Recovery checkpoint retained; settlement remains blocked."); - window.alert("GIVEAWAY OPENING PAUSED: Confirmed opening could not be saved. Reload to recover."); - releaseTabLock(); - return; - } - - // The pre-send checkpoint deliberately starts with accounting - // disabled. Once publication has been verified, reconcile the - // canonical Gift History BEFORE re-enabling pot mutations. Otherwise - // !addbon stays blocked forever on a fresh, otherwise healthy start. - try { - await tracker.reconcileCanonicalSponsorAccounting({ repairStats: false }); - if (!snapshotGiveaway({ force: true, verifyWrite: true })) { - throw new Error("Opening sponsor reconciliation snapshot did not persist"); - } - setSponsorAccountingVerificationState( - true, "Opening and canonical Gift History sponsor boundary verified." - ); - } catch (error) { - setSponsorAccountingVerificationState( - false, "Sponsor reconciliation deferred after public opening: " + - String(error?.message || error) - ); - scheduleSponsorAccountingRecovery(tracker); - } - - if (giveawayData.__sponsorAccountingVerified === true) { - try { await tracker.poll(); } catch (error) { console.error(error); } - sponsorsInterval = setInterval( - () => tracker.poll(), SPONSOR_GIFT_HISTORY_POLL_MS - ); - } + try { await tracker.poll(); } catch (e) { console.error(e); } + sponsorsInterval = setInterval( + () => tracker.poll(), + SPONSOR_GIFT_HISTORY_POLL_MS + ); chatReplayIgnoreBeforeTs = null; if (observer) { diff --git a/userscripts/giveaway/src/09-sponsorship-polling.js b/userscripts/giveaway/src/09-sponsorship-polling.js index b255f7ee..9e4384fc 100644 --- a/userscripts/giveaway/src/09-sponsorship-polling.js +++ b/userscripts/giveaway/src/09-sponsorship-polling.js @@ -418,7 +418,6 @@ lastMsgId = 0, cursorInitialized = false, giftHistoryClockOffsetMs = null, - giftHistoryClockOffsetUncertaintyMs = null, giftHistoryInitialized = false, giftHistorySeenKeys = [], historyFallbackActive = false, @@ -431,12 +430,6 @@ this.lastMsgId = Math.max(0, Math.floor(Number(lastMsgId) || 0)); // persisted API cursor this.cursorInitialized = !!cursorInitialized; this.giftHistoryClockOffsetMs = optionalFiniteNumber(giftHistoryClockOffsetMs); - // Earlier versions stored a point-estimate without its fractional - // calibration error. DarkPeers Gift History has second precision, - // so reserve at least one complete second of unknown offset. - this.giftHistoryClockOffsetUncertaintyMs = Math.max( - 1000, optionalFiniteNumber(giftHistoryClockOffsetUncertaintyMs) ?? 1000 - ); this.giftHistoryInitialized = !!giftHistoryInitialized; this.giftHistorySeenKeys = new Set( (Array.isArray(giftHistorySeenKeys) ? giftHistorySeenKeys : []) @@ -839,26 +832,6 @@ offset = await this.ensureGiftHistoryClockOffset(rows); } - // Gift History exposes site-local and browser/UTC interpretations. - // Without an authoritative calibration we CANNOT pick whichever - // interpretation falls after the public opening: the other one may - // place the same gift before it. Keep every row retryable, without - // modifying sponsor totals or marking gift-history keys as seen. - // The canonical settlement reconciliation treats retryable rows as - // a hard failure, so no BON payouts can proceed from this state. - if (min !== null && offset === null) { - logEvent( - "Sponsor accounting paused (clock uncalibrated)", - "Gift History time offset is unknown; deferred all gift rows instead of choosing an ambiguous timestamp interpretation." - ); - return { - accepted: [], - retryableKeys: new Set( - rows.map(item => item?.historyKey).filter(Boolean) - ) - }; - } - // Re-read the closing latch after the await above. If endGiveaway() // closed the window while this ordinary poll was already in flight, // this pass must immediately inherit that cutoff. @@ -896,12 +869,8 @@ : Number(item?.createdAtTs); if (offset !== null && Number.isFinite(wallTs)) { - const start = wallTs - offset - - Math.max(1000, this.giftHistoryClockOffsetUncertaintyMs ?? 1000); - // A gift bucket overlapping public opening is ambiguous: - // only an interval STARTING at/after the confirmed sponsor - // boundary can be safely attributed to this giveaway. - return (min === null || start >= min) ? [start] : []; + const start = wallTs - offset; + return (min === null || (start + resolutionMs) > min) ? [start] : []; } return [ @@ -909,7 +878,7 @@ Number(item?.createdAtAltTs) ].filter(ts => Number.isFinite(ts) && - (min === null || ts >= min) + (min === null || (ts + resolutionMs) > min) ); }; @@ -988,17 +957,8 @@ : Number(item?.createdAtTs); if (offset !== null && Number.isFinite(wallTs)) { - // The learned offset subtracts an exact event time from - // coarse Gift History time. This shifts all bucket starts - // forward by the anchor event's fractional second (and, - // for clustered anchors, possibly more). Use the EARLIEST - // possible start, including stored calibration uncertainty. const eventTs = wallTs - offset; - const earliestEventTs = eventTs - - Math.max(1000, this.giftHistoryClockOffsetUncertaintyMs ?? 1000); - // Entire Gift History timestamp bucket must be after - // confirmed publication, not merely overlap its boundary. - const afterOpen = min === null || earliestEventTs >= min; + const afterOpen = min === null || (eventTs + resolutionMs) > min; if (!afterOpen) continue; if (max === null) { @@ -1031,7 +991,7 @@ ].filter(Number.isFinite); const openingCandidates = candidates.filter(ts => - min === null || ts >= min + min === null || (ts + resolutionMs) > min ); if (!openingCandidates.length) continue; @@ -1382,27 +1342,21 @@ if (!Number.isFinite(historyWallTs)) continue; const offset = historyWallTs - Number(event.createdAtTs); - const resolutionMs = Math.max( - 1, Number(item?.timestampResolutionMs) || - unit3dTimestampResolutionMs(item?.rawTimestamp) - ); - if (Math.abs(offset) <= MAX_PLAUSIBLE_OFFSET_MS) { - samples.push({ offset, resolutionMs }); - } + if (Math.abs(offset) <= MAX_PLAUSIBLE_OFFSET_MS) samples.push(offset); } if (!samples.length) return persisted; // Choose the densest 2-minute cluster, then its median. This rejects an // unrelated historical exact-value row without hardcoding any timezone. - samples.sort((a, b) => a.offset - b.offset); + samples.sort((a, b) => a - b); const CLUSTER_MS = 120_000; let best = []; for (let i = 0; i < samples.length; i++) { const cluster = []; for (let j = i; j < samples.length; j++) { - if (samples[j].offset - samples[i].offset > CLUSTER_MS) break; + if (samples[j] - samples[i] > CLUSTER_MS) break; cluster.push(samples[j]); } if (cluster.length > best.length) best = cluster; @@ -1411,20 +1365,8 @@ if (!best.length) return persisted; const mid = Math.floor(best.length / 2); const inferred = best.length % 2 - ? best[mid].offset - : Math.round((best[mid - 1].offset + best[mid].offset) / 2); - - // If the chosen offset is calibrated from a coarse timestamp, - // the true timezone offset can be up to one entire source bucket - // GREATER than the inferred point estimate. Include differences - // within the accepted anchor cluster, too. This upper error - // bound is persisted with the point estimate across page reloads. - const offsetUncertaintyMs = Math.max( - 1000, - ...best.map(sample => - Math.max(0, sample.offset + sample.resolutionMs - inferred) - ) - ); + ? best[mid] + : Math.round((best[mid - 1] + best[mid]) / 2); // If we already learned a clock offset, don't replace it with a lone, // materially different sample. Multiple agreeing anchors may update it @@ -1438,7 +1380,6 @@ } this.giftHistoryClockOffsetMs = inferred; - this.giftHistoryClockOffsetUncertaintyMs = offsetUncertaintyMs; return inferred; } diff --git a/userscripts/giveaway/src/10-command-handling.js b/userscripts/giveaway/src/10-command-handling.js index 003e17ee..38a72ac8 100644 --- a/userscripts/giveaway/src/10-command-handling.js +++ b/userscripts/giveaway/src/10-command-handling.js @@ -231,19 +231,16 @@ function makeStaffAttributedReply(ctx) { const reply = ctx && typeof ctx.reply === "function" ? ctx.reply : sendMessage; - // Silent Mode sends ALL command responses privately to the invoker, - // including staff errors/denials. Staff actions are attributed in the - // public chat only when the host has not enabled Silent Mode. - if (GENERAL_SETTINGS.silent_mode || - !ctx || !isNonHostStaffAction(ctx.author, ctx.fancyName, ctx.giveawayData?.host)) { + if (!ctx || !isNonHostStaffAction(ctx.author, ctx.fancyName, ctx.giveawayData?.host)) { return reply; } const prefix = `👮 [b][color=#5DE2E7]Staff action by ${sanitizeNick(ctx.author)}:[/color][/b] `; - // Public staff attribution remains on when Silent Mode is disabled. - // Rehearsal Mode still redirects script output privately to the host. + // Staff intervention is operationally significant and must stay public + // even when the host enabled Silent Mode. Rehearsal Mode still suppresses + // sendMessage itself, so testing cannot leak messages into live chat. return (message) => sendMessage(prefix + message); } @@ -303,7 +300,7 @@ } const action = (args[0] || "").toLowerCase(); // "add" / "remove" - const minutes = Number(args[1]); + const minutes = parseFloat(args[1]); const isPriv = normalizeUserKey(author) === normalizeUserKey(giveawayData.host) || isAdmin(fancyName); if (!isPriv) return; // silently ignore non-host/non-admin @@ -813,7 +810,7 @@ const announcement = `[b][color=${SCALING_ACCENT_COLOR}]Host adjustment:[/color][/b] ` + `[b]Winners ${direction}[/b]: [b][color=#5DE2E7]${prevEffective} → ${newCount} (${sign}${delta})[/color][/b].`; - reply(announcement); + sendMessage(announcement); flashWinnersUI(); logEvent("Host adjusted winners", `${prevEffective} -> ${newCount} (${sign}${delta})`); } @@ -1136,14 +1133,11 @@ snapshotGiveaway({ force: true }); - // Command-generated pot announcements must honor Silent Mode. - // Route privately to the invoking host there; preserve ownership - // checking in both public and private delivery paths. - const potAnnouncement = `${bridgeMarker(BRIDGE_MARKERS.POT, "💰")} ` + - [addedPart, totalPart, donationPart, scalingPart].filter(Boolean).join(" "); - const announced = await sendCommandResponse(author, potAnnouncement, { - requireExclusiveGiveawayOwnership: true - }); + const announced = await sendMessage( + `${bridgeMarker(BRIDGE_MARKERS.POT, "💰")} ` + + [addedPart, totalPart, donationPart, scalingPart].filter(Boolean).join(" "), + { requireExclusiveGiveawayOwnership: true } + ); if (announced === false) { logEvent( @@ -1181,39 +1175,16 @@ if (!isHostOrAdmin(author, fancyName, giveawayData.host)) return; const actionReply = makeStaffAttributedReply(ctx); - const mins = Number(args[0]); - if (!Number.isFinite(mins) || mins <= 0) { + const mins = parseFloat(args[0]); + if (isNaN(mins) || mins <= 0) { actionReply( "[color=red]Usage:[/color] !time add|remove or !addtime|!removetime " ); return; } - // Limit the TOTAL duration since the public opening, not merely the - // remaining countdown. Host and staff use this same enforcement path. - const maxEndTs = getGiveawayMaxEndTs(giveawayData.openedAt); - const oldEndTs = Number(giveawayData.endTs); - const nextEndTs = oldEndTs + sign * mins * 60_000; - if (maxEndTs === null || !Number.isFinite(oldEndTs) || !Number.isFinite(nextEndTs)) { - actionReply("[color=red]Time change refused:[/color] a trustworthy giveaway opening time is unavailable."); - return; - } - if (nextEndTs > maxEndTs) { - if (ctx.source === "chat") { - // Use the ordinary command-response route. Silent Mode - // sends this privately to the command author; Rehearsal - // Mode redirects outgoing messages privately to the host. - actionReply( - `🚫 [color=red][b]Time limit exceeded![/b][/color] ` + - `DarkPeers giveaways can last at most [b]${MAX_GIVEAWAY_MINUTES} minutes total[/b] ` + - `from opening. [b]No time was added.[/b]` - ); - } else { - actionReply(`[color=red]Time change refused:[/color] DarkPeers allows a maximum of ${MAX_GIVEAWAY_MINUTES} minutes from the original opening.`); - } - return; - } - giveawayData.endTs = nextEndTs; + const deltaMs = sign * mins * 60_000; + giveawayData.endTs += deltaMs; // move the deadline rebuildSchedule(); // rebuild reminder schedule diff --git a/userscripts/giveaway/src/12-utilities.js b/userscripts/giveaway/src/12-utilities.js index f522ed2a..7d084a93 100644 --- a/userscripts/giveaway/src/12-utilities.js +++ b/userscripts/giveaway/src/12-utilities.js @@ -687,11 +687,7 @@ `✨[b][color=#FB4F4F]Gift the host to add to the pot! [color=${GIFT_HINT_COLOR}]/gift ${getGiftSyntaxHostName()} AMOUNT MESSAGE[/color][/color][/b]✨` + silentLine + rigLine; - // Both scheduled reminders and explicit !reminder requests are - // announcements to ALL entrants, not replies to the host command. - // Silent Mode must not make the requested reminder invisible. - // Rehearsal Mode still redirects sendMessage() to the host's PM. - return sendMessage(msg); + sendMessage(msg); } // ───────────── HTTP-based BON gifting helper ───────────── @@ -1623,11 +1619,11 @@ return sendMessage(`/msg ${to} ${body}`, options); } - async function sendCommandResponse(username, messageStr, options = {}) { + async function sendCommandResponse(username, messageStr) { if (GENERAL_SETTINGS.silent_mode) { - return sendPrivateMessage(username, messageStr, options); + return sendPrivateMessage(username, messageStr); } - return sendMessage(messageStr, options); + return sendMessage(messageStr); } /** Prepare a message for sending: obfuscate "giveaway" and apply number formatting. */ @@ -1754,31 +1750,12 @@ if (!forceChatboxOnly && !DEBUG_SETTINGS.suppressApiMessages) { try { if (await trySendViaApi(messageStr)) return true; - // A 5xx/non-OK API response might still follow a committed - // public POST. An opening must NEVER post again via chatbox: - // the durable checkpoint is the only safe recovery path. - if (options?.noFallbackOnApiException === true) return false; } catch (e) { - // An opening POST may already have committed before its reply - // timed out. Never issue a second, chatbox-based announcement - // during that uncertainty; the persisted opening checkpoint - // must be reconciled from authoritative Main Chat first. - if (options?.noFallbackOnApiException === true) { - if (DEBUG_SETTINGS.log_chat_messages) { - console.warn("Opening API result ambiguous; leaving checkpoint for verification:", e); - } - return false; - } if (DEBUG_SETTINGS.log_chat_messages) console.warn("API send failed, falling back to chatbox method:", e); if (DEBUG_SETTINGS.verify_sendmessage) console.debug("sendMessage: API send failed, falling back to chatbox method"); } } - // Opening output is single-shot. Even when the API was deliberately - // disabled, missing credentials, or returned non-OK, never silently - // publish a second opening through chatbox. - if (options?.noFallbackOnApiException === true) return false; - // Settlement output is authoritative. If the API attempt yielded while a // BFCache/pagehide handoff quarantined this document, never let the stale // continuation escape through the synchronous chatbox fallback. @@ -2647,22 +2624,9 @@ } - function parseGiveawayMinutes(value) { - const raw = String(value ?? "").trim(); - if (!/^\d+$/.test(raw)) return null; - const minutes = Number(raw); - return Number.isSafeInteger(minutes) && minutes >= 1 && minutes <= MAX_GIVEAWAY_MINUTES - ? minutes : null; - } - - function getGiveawayMaxEndTs(startTime) { - const startedAt = Number(startTime); - return Number.isFinite(startedAt) && startedAt > 0 - ? startedAt + MAX_GIVEAWAY_MINUTES * 60_000 : null; - } - function totalMinutes () { - return parseGiveawayMinutes(timerInput?.value) ?? 0; + const t = parseInt(String(timerInput?.value ?? ""), 10); + return (Number.isFinite(t) && t > 0) ? t : 0; } function nextReminderMs(schedule, msLeft) { From 7a3339e4bedc94c6a76af3e02ff67c6a178e85c2 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:57:24 +0100 Subject: [PATCH 2/2] revert(bonanza): restore pre-#29 source README --- userscripts/giveaway/src/README.md | 130 +---------------------------- 1 file changed, 2 insertions(+), 128 deletions(-) diff --git a/userscripts/giveaway/src/README.md b/userscripts/giveaway/src/README.md index 24053cde..389aa06c 100644 --- a/userscripts/giveaway/src/README.md +++ b/userscripts/giveaway/src/README.md @@ -36,132 +36,6 @@ Ordered source: 14. `13-menu-validation.js` 15. `14-internal-namespaces.js` -The validation workflow rejects stale generated output. The publishing workflow rebuilds, verifies and publishes the generated script. +The validation workflow rejects stale generated output. The publishing workflow rebuilds and verifies the userscript before sending the single generated file to the secret Gist. -## Opening and chat safety contract - -An opening has two independent boundaries: `openedAt` is captured **before** -the public opening message is sent (and determines the maximum 45-minute -deadline), while `sponsorStartTs` is derived from the server-confirmed -announcement timestamp, conservatively beyond its timestamp resolution -bucket. Gift History rows are eligible only when the ENTIRE original -timestamp bucket starts at/after `sponsorStartTs`, in calibrated and -fallback timestamp paths. A valid Main Chat message cursor is mandatory before any LIVE opening; -without one, abort without posting. A pre-send `openingCheckpoint` -is durably stored before posting, including a fixed upper time boundary. -On return, the send completion timestamp is also persisted to tighten -the admissible window. A matching server message must have its **whole -timestamp-resolution bucket** inside the original bounded POST attempt: -overlapping same-second buckets and later identical posts remain ambiguous. -If delivery is ambiguous, preserve it and retry verification on reload; -even a non-OK opening POST response must NOT trigger a second chatbox -opening, since the server may have stored the original message. Do not -silently discard the snapshot or perform settlement. - -Recheck the saved state **after** acquiring the exclusive Web Lock; two -tabs may have seen empty storage before one posts an uncertain opening. -For restoration, discard the earlier loaded object and re-read/revalidate -the latest complete persisted giveaway under the lock before restoring any -entries, sponsors or settlement. This prevents a waiting tab from replacing -the other tab's newly committed payout checkpoint. A pre-lock expired flag, -unverified-opening flag or saved host may already be stale; do not reject on -those before the post-lock read, which is authoritative. -Treat `readOk: false` as a hard stop: storage-read errors, malformed -legacy JSON and migration failures are NEVER equivalent to empty storage. -Even an empty string in an existing recovery slot is occupied, not vacant. -Pre-lock checks only READ; any legacy-key migration happens after locking. -An empty legacy value is occupied/corrupt (only `null` means absent). -Valid legacy JSON without a usable host (`{}`, `{"giveawayData":{}}`, etc.) -is likewise occupied and unidentified: block new live openings, retain the -original bytes, and require manual review. A known DIFFERENT host may be ignored. -Even with an existing namespaced snapshot, always inspect the legacy slot: -block any malformed/unidentified record or inconsistent same-host settlement -state (only byte-identical duplicates are safe). Keep conflicting records -for manual investigation rather than trusting an older payout ledger. -After acquiring the exclusive lock, verify and retire a redundant identical -legacy slot BEFORE changing the namespaced opening checkpoint, with before- -and after-deletion read-back checks. Otherwise a crash could strand recovery. -A cleanup failure blocks automatic restoration. Single-slot migrations also -require verified namespaced writes, an unchanged legacy source, and a -confirmed legacy deletion before returning success. Denied or silent -removeItem failures block restore before opening confirmation. -The initial snapshot loader must not clear even expired state: preserve it -for manual review instead of altering shared recovery before locking. -Snapshots are forbidden during balance/history/cursor preflight, before a -persisted opening checkpoint exists, including pagehide/BFCache/unload. -Keep malformed or unreadable recovery records intact for manual review -instead of deleting possible BON settlement evidence. -Reverify previously confirmed LIVE openings from authoritative Main Chat -on every restore, requiring the same original message ID, host, exact -text, send window and sponsor boundary. If absent or inconsistent, leave -the snapshot untouched and pause recovery, including BON settlement. - -Uncalibrated Gift History rows are NEVER eligible from a guessed local -or UTC interpretation. Keep their keys retryable until reliable clock -calibration succeeds, and fail final canonical reconciliation otherwise. - -A new giveaway must not overwrite a pending or legacy unverified snapshot. -Recovered opening checkpoints older than the normal settlement grace require -manual review rather than automatic BON transfers. Pre-checkpoint legacy -snapshots have neither verified public-opening evidence nor a conservative -pre-send timestamp: retain the ORIGINAL record for manual investigation, -without automatically capping a deadline from the later post-send timestamp, -resuming entrants, or moving BON, even if settlement was already committed. - -Clock calibration against exact notification events can shift the apparent -start of a second-precision Gift History bucket. Preserve the learned -calibration uncertainty in the snapshot, and apply the conservative EARLIEST -bucket start before checking the verified `sponsorStartTs`; this rule also -applies when reconstructing boundary chat evidence. Never unify the two -opening timestamps or bypass the fail-closed verification gates. - -Silent Mode routes normal host and staff command replies privately, -including winner/pot adjustment messages. **`!reminder` is an explicit -broadcast request, not a private command reply:** both the manual reminder -and the scheduled automatic reminder must appear in Main Chat even in -Silent Mode. Rehearsal Mode redirects both to the host's private messages -and keeps BON transfers simulated. - -## Maintenance rule (source of truth) - -**Edit `userscripts/giveaway/src/*.js`, never the generated `.user.js` directly.** -The bundle is a byte-for-byte concatenation of the 15 fragments in the -order declared in `scripts/build-bonanza.mjs`. If you need to change the -runtime or metadata (`@version`, `@updateURL`, etc.), edit the appropriate -fragment (typically `00-preamble.js`), then rebuild. When the fragment list -changes, update the build script as well. - -From the **repository root**, before committing: - -```bash -node scripts/build-bonanza.mjs -node scripts/build-bonanza.mjs --check -node --check userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js -node --test tests/bonanza-*.test.mjs -git diff --check -``` - -**Commit the edited source, relevant tests/docs and the regenerated -`userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js` together.** -The bundle is tracked in Git; it is *not* automatically updated in the -repository on a pull request. The validation workflow runs `--check` and -fails if the committed bundle is stale. - -On pushes to `main`, `.github/workflows/publish-bonanza-gist.yml` -**rebuilds and requires a clean bundle diff** before publishing the -generated userscript and an independently generated `.meta.js` manifest -to the configured Gist. A stale committed bundle fails publication; the -workflow does not commit generated files back to GitHub. Do not manually -maintain the Gist or its `.meta.js`. - -For Tampermonkey auto-updates, bump `@version` in `00-preamble.js` when -releasing changes, and update any tests asserting the exact version. -Preserve the existing `@namespace`, `@updateURL` and `@downloadURL` -unless deliberately migrating the script's update identity. - -Because the giveaway can move real BON, cover relevant start, restore, -timeout, settlement and idempotency boundaries with regression tests, -and prefer rehearsal mode for browser-level checks. Do not trigger a live -giveaway or actual BON transfers simply to validate a code change. - -See [the repository's agent instructions](../../../AGENTS.md). +When changing BONanza code, edit the relevant source fragment, rebuild, and commit both the source change and the generated userscript.