diff --git a/tests/bonanza-review-hardening.test.mjs b/tests/bonanza-review-hardening.test.mjs index b864bca9..25d2f713 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\.10$/m); + assert.match(source, /^\/\/ @version\s+1\.5\.11$/m); assert.doesNotMatch(source, /pollChatFallback/); assert.doesNotMatch(source, /onlyguardians/i); assert.match(source, /async function getLatestMainChatReplayBoundary\(\)/); @@ -72,6 +72,19 @@ test("winner-count commands are host-only", () => { assert.doesNotMatch(maxWinners, /isHostOrAdmin/); }); +test("host virtual BON stays isolated from sponsor gifts", () => { + assert.match(source, /hostWalletAtStart: null/); + assert.match(source, /hostWalletAtStart: giveawayData\.hostWalletAtStart/); + assert.match(source, /function getHostVirtualBon\(data = giveawayData\)/); + assert.match(source, /Math\.floor\(walletAtStart\) - hostCommitted/); + assert.match(source, /My Virtual BON/); + assert.match(source, /sponsor gifts do not affect this balance/); + assert.match(source, /const virtualBon = getHostVirtualBon\(giveawayData\)/); + assert.match(source, /amount > virtualBon/); + assert.match(source, /giveawayData\.hostAdded = \(giveawayData\.hostAdded \|\| 0\) \+ amount/); + assert.match(source, /updateHostPanelUI\(\)/); +}); + test("staff emergency controls remain available", () => { const required = [ /rig\(ctx\)[\s\S]*?isHostOrAdmin/, diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 61c7fb15..2d74421a 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.10 +// @version 1.5.11 // @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 @@ -1680,6 +1680,38 @@ body.host-panel-dragging * { white-space: normal; word-break: break-word; } +.host-command-panel__virtual-bon { + border: 1px solid rgba(255, 192, 10, 0.38); + border-radius: 6px; + background: rgba(255, 192, 10, 0.07); + padding: 9px 10px; + min-width: 0; +} +.host-command-panel__virtual-bon-main { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} +.host-command-panel__virtual-bon-label { + color: #ffa200; + font-size: 12px; + font-weight: 700; +} +.host-command-panel__virtual-bon-value { + color: #ffc00a; + font-size: 15px; + font-weight: 800; + text-align: right; +} +.host-command-panel__virtual-bon-detail { + display: block; + margin-top: 3px; + color: #aaa; + font-size: 10px; + line-height: 1.3; +} + .host-command-panel .host-command-panel__row { margin-bottom: 6px; min-width: 0; @@ -3108,6 +3140,7 @@ body.host-panel-dragging * { customMessage: giveawayData.customMessage, donationPercent: giveawayData.donationPercent, hostAdded: giveawayData.hostAdded, + hostWalletAtStart: giveawayData.hostWalletAtStart, initialPotVerifiedAtStart: giveawayData.initialPotVerifiedAtStart, reminderSchedule: giveawayData.reminderSchedule, reminderNum: giveawayData.reminderNum, @@ -3431,6 +3464,10 @@ body.host-panel-dragging * { giveawayData.sponsorGiftMessages = Array.isArray(giveawayData.sponsorGiftMessages) ? giveawayData.sponsorGiftMessages : []; + const restoredHostWalletAtStart = Number(giveawayData.hostWalletAtStart); + giveawayData.hostWalletAtStart = ( + Number.isFinite(restoredHostWalletAtStart) && restoredHostWalletAtStart >= 0 + ) ? Math.floor(restoredHostWalletAtStart) : null; // Old active snapshots must not expose a start-time draw. A committed // settlement must keep its exact draw so crash/reload recovery cannot // select a different winner. @@ -3950,6 +3987,7 @@ body.host-panel-dragging * { customMessage: customMessageInput.value, donationPercent: normalizeDonationPercent(donationPercentInput ? donationPercentInput.value : 0), hostAdded: amountInt, + hostWalletAtStart: null, initialPotVerifiedAtStart: amountInt, reminderSchedule : schedule, reminderNum : schedule.length, @@ -3984,6 +4022,10 @@ body.host-panel-dragging * { return; } else { + // Freeze the host's own pre-giveaway wallet. Sponsor gifts increase the + // real DarkPeers wallet and the giveaway pot together, but must not make + // the host appear to have more personal BON available to commit. + giveawayData.hostWalletAtStart = Math.max(0, Math.floor(currentBon)); giveawayData.initialPotVerifiedAtStart = giveawayData.amount; recomputeEffectiveWinners(giveawayData); initializeScaledWinnersAnnouncementState(giveawayData); @@ -7543,6 +7585,16 @@ body.host-panel-dragging * { return; } + // The host's virtual BON is deliberately frozen against sponsor gifts. + // Reject impossible top-ups locally before spending another DarkPeers request. + const virtualBon = getHostVirtualBon(giveawayData); + if (Number.isFinite(virtualBon) && amount > virtualBon) { + reply( + `[b][color=red]You only have ${fmtBONCurrency(virtualBon)} virtual BON available to add from your own balance.[/color][/b]` + ); + return; + } + // Serialize the balance check and mutation as one host transaction. hostAddBonInFlight = true; try { @@ -7582,6 +7634,7 @@ body.host-panel-dragging * { // ✅ host-only tracking (excludes sponsors) giveawayData.hostAdded = (giveawayData.hostAdded || 0) + amount; + updateHostPanelUI(); const newEffectiveWinners = recomputeEffectiveWinners(giveawayData); const winnersDelta = Math.max(0, newEffectiveWinners - prevEffectiveWinners); @@ -7622,7 +7675,7 @@ body.host-panel-dragging * { } else { logEvent( "Host BON top-up recorded", - `Host added ${fmtBONCurrency(amount)} BON | pot=${fmtBONCurrency(newTotal)} BON | verified wallet=${fmtBONCurrency(currentBon)} BON` + `Host added ${fmtBONCurrency(amount)} BON | pot=${fmtBONCurrency(newTotal)} BON | verified wallet=${fmtBONCurrency(currentBon)} BON | virtual remaining=${fmtBONCurrency(getHostVirtualBon(giveawayData) ?? 0)} BON` ); } } finally { @@ -13229,6 +13282,15 @@ body.host-panel-dragging * { return line; } + function getHostVirtualBon(data = giveawayData) { + if (!data) return null; + const walletAtStart = Number(data.hostWalletAtStart); + if (!Number.isFinite(walletAtStart) || walletAtStart < 0) return null; + + const hostCommitted = Math.max(0, Math.floor(Number(data.hostAdded) || 0)); + return Math.max(0, Math.floor(walletAtStart) - hostCommitted); + } + function isCurrentUserGiveawayHost() { const navDisplayName = getTopNavUserLink()?.textContent || ""; const selfNames = [getLoggedInUsername(), navDisplayName].map((name) => normUserKey(name)).filter(Boolean); @@ -13572,10 +13634,51 @@ body.host-panel-dragging * { }); } + function renderHostVirtualBonCard() { + if (!hostCommandPanelBody) return; + + let card = hostCommandPanelBody.querySelector(".host-command-panel__virtual-bon"); + if (!card) { + card = document.createElement("section"); + card.className = "host-command-panel__virtual-bon"; + card.innerHTML = ` +
+ My Virtual BON + — +
+ `; + hostCommandPanelBody.prepend(card); + } + + const value = card.querySelector(".host-command-panel__virtual-bon-value"); + const detail = card.querySelector(".host-command-panel__virtual-bon-detail"); + if (!value || !detail) return; + + if (!giveawayData) { + value.textContent = "—"; + detail.textContent = "Start a giveaway to track your own available BON."; + return; + } + + const virtualBon = getHostVirtualBon(giveawayData); + if (!Number.isFinite(virtualBon)) { + value.textContent = "Unavailable"; + detail.textContent = "This giveaway predates virtual BON tracking."; + return; + } + + const walletAtStart = Math.max(0, Math.floor(Number(giveawayData.hostWalletAtStart) || 0)); + const hostCommitted = Math.max(0, Math.floor(Number(giveawayData.hostAdded) || 0)); + value.textContent = `${fmtBONCurrency(virtualBon)} BON`; + detail.textContent = + `Start: ${fmtBONCurrency(walletAtStart)} BON · committed by you: ${fmtBONCurrency(hostCommitted)} BON · sponsor gifts do not affect this balance.`; + } + function renderHostPanelCommands() { if (!hostCommandPanelBody) return; hostPanelCommandState.clear(); hostCommandPanelBody.innerHTML = ""; + renderHostVirtualBonCard(); const panelList = getPanelCommandList(); if (!panelList || !Array.isArray(panelList.commands)) { @@ -14080,6 +14183,7 @@ body.host-panel-dragging * { function updateHostPanelUI() { const visible = true; + renderHostVirtualBonCard(); if (hostPanelToggleBtn) hostPanelToggleBtn.style.display = visible ? "inline-flex" : "none"; if (hostCommandPanel && !visible) setHostPanelOpen(false); diff --git a/userscripts/giveaway/src/00-preamble.js b/userscripts/giveaway/src/00-preamble.js index 1984355e..310ae887 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.10 +// @version 1.5.11 // @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 diff --git a/userscripts/giveaway/src/04-ui-template.js b/userscripts/giveaway/src/04-ui-template.js index 879701f4..bae25419 100644 --- a/userscripts/giveaway/src/04-ui-template.js +++ b/userscripts/giveaway/src/04-ui-template.js @@ -636,6 +636,38 @@ body.host-panel-dragging * { white-space: normal; word-break: break-word; } +.host-command-panel__virtual-bon { + border: 1px solid rgba(255, 192, 10, 0.38); + border-radius: 6px; + background: rgba(255, 192, 10, 0.07); + padding: 9px 10px; + min-width: 0; +} +.host-command-panel__virtual-bon-main { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} +.host-command-panel__virtual-bon-label { + color: #ffa200; + font-size: 12px; + font-weight: 700; +} +.host-command-panel__virtual-bon-value { + color: #ffc00a; + font-size: 15px; + font-weight: 800; + text-align: right; +} +.host-command-panel__virtual-bon-detail { + display: block; + margin-top: 3px; + color: #aaa; + font-size: 10px; + line-height: 1.3; +} + .host-command-panel .host-command-panel__row { margin-bottom: 6px; min-width: 0; diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index b6da6ec7..d073b202 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1344,6 +1344,7 @@ customMessage: giveawayData.customMessage, donationPercent: giveawayData.donationPercent, hostAdded: giveawayData.hostAdded, + hostWalletAtStart: giveawayData.hostWalletAtStart, initialPotVerifiedAtStart: giveawayData.initialPotVerifiedAtStart, reminderSchedule: giveawayData.reminderSchedule, reminderNum: giveawayData.reminderNum, @@ -1667,6 +1668,10 @@ giveawayData.sponsorGiftMessages = Array.isArray(giveawayData.sponsorGiftMessages) ? giveawayData.sponsorGiftMessages : []; + const restoredHostWalletAtStart = Number(giveawayData.hostWalletAtStart); + giveawayData.hostWalletAtStart = ( + Number.isFinite(restoredHostWalletAtStart) && restoredHostWalletAtStart >= 0 + ) ? Math.floor(restoredHostWalletAtStart) : null; // Old active snapshots must not expose a start-time draw. A committed // settlement must keep its exact draw so crash/reload recovery cannot // select a different winner. diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index e212075a..7a94cf77 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -148,6 +148,7 @@ customMessage: customMessageInput.value, donationPercent: normalizeDonationPercent(donationPercentInput ? donationPercentInput.value : 0), hostAdded: amountInt, + hostWalletAtStart: null, initialPotVerifiedAtStart: amountInt, reminderSchedule : schedule, reminderNum : schedule.length, @@ -182,6 +183,10 @@ return; } else { + // Freeze the host's own pre-giveaway wallet. Sponsor gifts increase the + // real DarkPeers wallet and the giveaway pot together, but must not make + // the host appear to have more personal BON available to commit. + giveawayData.hostWalletAtStart = Math.max(0, Math.floor(currentBon)); giveawayData.initialPotVerifiedAtStart = giveawayData.amount; recomputeEffectiveWinners(giveawayData); initializeScaledWinnersAnnouncementState(giveawayData); diff --git a/userscripts/giveaway/src/10-command-handling.js b/userscripts/giveaway/src/10-command-handling.js index 15b2de9c..38a72ac8 100644 --- a/userscripts/giveaway/src/10-command-handling.js +++ b/userscripts/giveaway/src/10-command-handling.js @@ -1057,6 +1057,16 @@ return; } + // The host's virtual BON is deliberately frozen against sponsor gifts. + // Reject impossible top-ups locally before spending another DarkPeers request. + const virtualBon = getHostVirtualBon(giveawayData); + if (Number.isFinite(virtualBon) && amount > virtualBon) { + reply( + `[b][color=red]You only have ${fmtBONCurrency(virtualBon)} virtual BON available to add from your own balance.[/color][/b]` + ); + return; + } + // Serialize the balance check and mutation as one host transaction. hostAddBonInFlight = true; try { @@ -1096,6 +1106,7 @@ // ✅ host-only tracking (excludes sponsors) giveawayData.hostAdded = (giveawayData.hostAdded || 0) + amount; + updateHostPanelUI(); const newEffectiveWinners = recomputeEffectiveWinners(giveawayData); const winnersDelta = Math.max(0, newEffectiveWinners - prevEffectiveWinners); @@ -1136,7 +1147,7 @@ } else { logEvent( "Host BON top-up recorded", - `Host added ${fmtBONCurrency(amount)} BON | pot=${fmtBONCurrency(newTotal)} BON | verified wallet=${fmtBONCurrency(currentBon)} BON` + `Host added ${fmtBONCurrency(amount)} BON | pot=${fmtBONCurrency(newTotal)} BON | verified wallet=${fmtBONCurrency(currentBon)} BON | virtual remaining=${fmtBONCurrency(getHostVirtualBon(giveawayData) ?? 0)} BON` ); } } finally { diff --git a/userscripts/giveaway/src/13-menu-validation.js b/userscripts/giveaway/src/13-menu-validation.js index 565a682a..4bac091c 100644 --- a/userscripts/giveaway/src/13-menu-validation.js +++ b/userscripts/giveaway/src/13-menu-validation.js @@ -370,6 +370,15 @@ return line; } + function getHostVirtualBon(data = giveawayData) { + if (!data) return null; + const walletAtStart = Number(data.hostWalletAtStart); + if (!Number.isFinite(walletAtStart) || walletAtStart < 0) return null; + + const hostCommitted = Math.max(0, Math.floor(Number(data.hostAdded) || 0)); + return Math.max(0, Math.floor(walletAtStart) - hostCommitted); + } + function isCurrentUserGiveawayHost() { const navDisplayName = getTopNavUserLink()?.textContent || ""; const selfNames = [getLoggedInUsername(), navDisplayName].map((name) => normUserKey(name)).filter(Boolean); @@ -713,10 +722,51 @@ }); } + function renderHostVirtualBonCard() { + if (!hostCommandPanelBody) return; + + let card = hostCommandPanelBody.querySelector(".host-command-panel__virtual-bon"); + if (!card) { + card = document.createElement("section"); + card.className = "host-command-panel__virtual-bon"; + card.innerHTML = ` +
+ My Virtual BON + — +
+ `; + hostCommandPanelBody.prepend(card); + } + + const value = card.querySelector(".host-command-panel__virtual-bon-value"); + const detail = card.querySelector(".host-command-panel__virtual-bon-detail"); + if (!value || !detail) return; + + if (!giveawayData) { + value.textContent = "—"; + detail.textContent = "Start a giveaway to track your own available BON."; + return; + } + + const virtualBon = getHostVirtualBon(giveawayData); + if (!Number.isFinite(virtualBon)) { + value.textContent = "Unavailable"; + detail.textContent = "This giveaway predates virtual BON tracking."; + return; + } + + const walletAtStart = Math.max(0, Math.floor(Number(giveawayData.hostWalletAtStart) || 0)); + const hostCommitted = Math.max(0, Math.floor(Number(giveawayData.hostAdded) || 0)); + value.textContent = `${fmtBONCurrency(virtualBon)} BON`; + detail.textContent = + `Start: ${fmtBONCurrency(walletAtStart)} BON · committed by you: ${fmtBONCurrency(hostCommitted)} BON · sponsor gifts do not affect this balance.`; + } + function renderHostPanelCommands() { if (!hostCommandPanelBody) return; hostPanelCommandState.clear(); hostCommandPanelBody.innerHTML = ""; + renderHostVirtualBonCard(); const panelList = getPanelCommandList(); if (!panelList || !Array.isArray(panelList.commands)) { @@ -1221,6 +1271,7 @@ function updateHostPanelUI() { const visible = true; + renderHostVirtualBonCard(); if (hostPanelToggleBtn) hostPanelToggleBtn.style.display = visible ? "inline-flex" : "none"; if (hostCommandPanel && !visible) setHostPanelOpen(false);