better buttonboxing

This commit is contained in:
legop3
2026-06-29 02:18:53 -04:00
parent 8843bd6acf
commit f118f6456a
22 changed files with 350 additions and 76 deletions
@@ -3,6 +3,7 @@
module.exports = {
id: 'assignmentRoulette',
name: 'Rover Reassignment',
description: 'Scrambles who is driving which rover.',
goal: 300,
async run(ctx) {
const moved = ctx.rerollAssignments();
@@ -1,12 +1,13 @@
// Reward Definition: Camera Whiplash
// Purpose: Defines the camera-whiplash deterrence reward and timing/strength settings. Scope: Supplies reusable reward metadata and execution parameters for chaos triggers.
const STEP_MS = 220;
const DURATION_MS = 30 * 1000;
const DURATION_MS = 60 * 1000;
const STEPS = Math.ceil(DURATION_MS / STEP_MS);
module.exports = {
id: 'cameraWhiplash',
name: 'Camera Wiggle',
description: 'Makes the rover cameras wiggle around.',
goal: 100,
async run(ctx) {
const rovers = ctx
@@ -22,6 +22,7 @@ function sleep(ms) {
module.exports = {
id: 'chatSpam',
name: 'Chat Spam',
description: 'Floods chat with nonsense messages.',
goal: 320,
async run(ctx) {
const nickname = randLetter();
@@ -116,6 +116,7 @@ async function startDarkness(ctx, effect) {
module.exports = {
id: 'darkness',
name: 'Darkness',
description: 'Disables room lights and headlights for 15 minutes.',
isHeadlightBlocked,
goal: 400,
async run(ctx) {
@@ -3,7 +3,9 @@
module.exports = {
id: 'discordPingEveryone',
name: 'PING @EVERYONE',
goal: 20000,
description: 'Pings @everyone in Discord.',
dailyLimited: true,
goal: 4000,
async run(ctx) {
ctx.publishEvent({
source: 'buttonBoxReward',
@@ -3,7 +3,9 @@
module.exports = {
id: 'discordStalkerPing',
name: 'Discord Ping',
goal: 9000,
description: 'Pings the stalker role in Discord.',
dailyLimited: true,
goal: 2000,
async run(ctx) {
ctx.publishEvent({
source: 'buttonBoxReward',
@@ -5,6 +5,7 @@ const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
module.exports = {
id: 'dockPanic',
name: 'All Dock',
description: 'Forces all rovers into docking mode.',
goal: 180,
async run(ctx) {
const rovers = ctx.listOnlineRovers();
@@ -16,6 +16,7 @@ function sleep(ms) {
module.exports = {
id: 'ghostTypingSpam',
name: 'Typing Spam',
description: 'Fills chat with fake typing indicators.',
goal: 220,
async run(ctx) {
const active = new Set();
@@ -1,7 +1,7 @@
// Reward Definition: Light Strobe
// Purpose: Defines the light-strobe deterrence reward and activation contract. Scope: Encapsulates reward identity, labels, and effect parameters for runtime dispatch.
const STROBE_MS = 30 * 1000;
const TICK_MS = 200;
const TICK_MS = 500;
let activeTimer = null;
@@ -40,7 +40,8 @@ function startStrobe(ctx, effect = {}) {
module.exports = {
id: 'lightStrobe',
name: 'Light Strobe',
goal: 300,
description: 'Makes the room lights flash rapidly for 30 seconds.',
goal: 400,
async run(ctx) {
startStrobe(ctx, { endsAt: Date.now() + STROBE_MS, on: false });
ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'All room controls strobing for 30 seconds.' });
+2 -1
View File
@@ -53,7 +53,8 @@ function scheduleRestore(ctx, effect = {}) {
module.exports = {
id: 'modeJam',
name: 'Admin Lock',
goal: 900,
description: 'Temporarily puts the server in admin mode.',
goal: 3000,
async run(ctx) {
const durationMs =
MIN_DURATION_MS + Math.floor(Math.random() * (MAX_DURATION_MS - MIN_DURATION_MS + 1));
+133 -45
View File
@@ -25,6 +25,137 @@ function createButtonBoxCore(deps) {
store,
} = deps;
function getTodayKey(now = Date.now()) {
/*
The daily cap is intentionally based on the server's local calendar day.
This matches the operational reality of the deployed server and avoids
storing per-user timezone state for a physical shared button box.
*/
const date = new Date(now);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function getDailyLimit(button) {
/*
Ceil lets a reward complete in five daily windows even when its goal is
not evenly divisible by five. The minimum of one keeps tiny goals usable.
*/
if (button?.dailyLimited !== true) {
return null;
}
const goal = Number.isFinite(button?.goal) ? Math.max(1, Math.floor(button.goal)) : 1;
return Math.max(1, Math.ceil(goal / 5));
}
function resetDailyBucketIfNeeded(button, now = Date.now()) {
const today = getTodayKey(now);
if (button.dailyDate !== today) {
/*
Date rollover is handled lazily on the next press/add-count request.
That keeps the persisted state correct without needing a background
timer that would sit around on the development machine.
*/
button.dailyDate = today;
button.dailyCount = 0;
}
if (!Number.isFinite(button.dailyCount) || button.dailyCount < 0) {
button.dailyCount = 0;
}
return today;
}
function buildIncrementDescription({ appliedCount, limited }) {
if (limited && appliedCount <= 0) {
return 'Daily limit reached';
}
return 'Progress added';
}
function buildIncrementPayload({ button, requestedCount, appliedCount, limited, now }) {
const dailyLimit = getDailyLimit(button);
return {
buttonId: button.id,
count: button.count,
goal: button.goal,
dailyLimited: button.dailyLimited === true,
limited,
appliedCount,
requestedCount,
dailyCount: button.dailyCount,
dailyLimit,
description: buildIncrementDescription({ appliedCount, limited }),
ts: now,
};
}
function cloneButtonForResponse(button) {
/*
HTTP callers and overseer tools receive a direct button response instead
of waiting for session sync. Include the derived daily limit there too so
every public button shape describes the same daily-cap state.
*/
return {
...store.clone(button),
dailyLimit: getDailyLimit(button),
};
}
async function applyProgress(buttonId, requestedCount = 1) {
const state = store.getState();
const button = state.buttons.find((entry) => entry.id === buttonId);
if (!button) {
throw new Error('Unknown button');
}
const now = Date.now();
resetDailyBucketIfNeeded(button, now);
const requested = Math.max(1, Math.floor(Number(requestedCount) || 0));
const dailyLimited = button.dailyLimited === true;
const dailyLimit = getDailyLimit(button);
/*
Most rewards are intentionally uncapped so button-box chaos can still be
built up quickly. The daily bucket only constrains rewards that opt into
it, currently the Discord pings that can bother people off-site.
*/
const dailyRemaining = dailyLimited ? Math.max(0, dailyLimit - button.dailyCount) : requested;
const appliedCount = dailyLimited ? Math.min(requested, dailyRemaining) : requested;
const limited = dailyLimited && appliedCount < requested;
if (appliedCount > 0) {
button.count += appliedCount;
if (dailyLimited) {
button.dailyCount += appliedCount;
}
button.lastIncrementAt = now;
}
store.writeState();
io.emit('buttonBox:increment', buildIncrementPayload({
button,
requestedCount: requested,
appliedCount,
limited,
now,
}));
while (button.count >= button.goal) {
await runRewardForButton(button);
/*
Reward assignment resets the daily bucket because the new reward should
start clean instead of inheriting the just-completed reward's cap usage.
*/
store.writeState();
}
publishUpdated();
return cloneButtonForResponse(button);
}
function publishUpdated() {
publishEvent({
source: 'buttonBox',
@@ -117,55 +248,12 @@ function createButtonBoxCore(deps) {
}
async function applyPress(buttonId) {
const state = store.getState();
const button = state.buttons.find((entry) => entry.id === buttonId);
if (!button) {
throw new Error('Unknown button');
}
button.count += 1;
button.lastIncrementAt = Date.now();
store.writeState();
io.emit('buttonBox:increment', {
buttonId,
count: button.count,
ts: button.lastIncrementAt,
});
if (button.count >= button.goal) {
await runRewardForButton(button);
store.writeState();
}
publishUpdated();
return store.clone(button);
return applyProgress(buttonId, 1);
}
async function addCount(buttonId, amount = 1) {
const state = store.getState();
const button = state.buttons.find((entry) => entry.id === buttonId);
if (!button) {
throw new Error('Unknown button');
}
const inc = Math.max(1, Math.floor(Number(amount) || 0));
button.count += inc;
button.lastIncrementAt = Date.now();
store.writeState();
io.emit('buttonBox:increment', {
buttonId,
count: button.count,
ts: button.lastIncrementAt,
});
while (button.count >= button.goal) {
await runRewardForButton(button);
store.writeState();
}
publishUpdated();
return store.clone(button);
return applyProgress(buttonId, inc);
}
async function recoverEffects() {
+45 -1
View File
@@ -22,8 +22,12 @@ function createButtonBoxStore(deps) {
count: 0,
rewardId: null,
rewardName: null,
rewardDescription: null,
dailyLimited: false,
rewardNumber: null,
goal: null,
dailyDate: null,
dailyCount: 0,
lastIncrementAt: null,
lastRewardAt: null,
};
@@ -67,9 +71,18 @@ function createButtonBoxStore(deps) {
button.rewardId = reward.id;
button.rewardName = reward.name || null;
button.rewardDescription = reward.description || null;
button.dailyLimited = reward.dailyLimited === true;
button.rewardNumber = reward.number;
button.goal = reward.goal;
button.count = 0;
/*
A new reward starts a fresh earning window for the button. Resetting the
daily bucket here prevents leftover progress from the previous reward from
making the newly assigned reward appear capped before anyone presses it.
*/
button.dailyDate = null;
button.dailyCount = 0;
}
function ensureRewardAssignments() {
@@ -80,11 +93,19 @@ function createButtonBoxStore(deps) {
if (reward && !seen.has(reward.id)) {
seen.add(reward.id);
button.rewardName = reward.name || null;
button.rewardDescription = reward.description || null;
button.dailyLimited = reward.dailyLimited === true;
button.rewardNumber = reward.number;
button.goal = reward.goal;
if (!Number.isFinite(button.count) || button.count < 0) {
button.count = 0;
}
if (typeof button.dailyDate !== 'string' || !button.dailyDate) {
button.dailyDate = null;
}
if (!Number.isFinite(button.dailyCount) || button.dailyCount < 0) {
button.dailyCount = 0;
}
return;
}
assignNewReward(button);
@@ -104,8 +125,12 @@ function createButtonBoxStore(deps) {
count: Number.isFinite(loaded.count) ? Math.max(0, Math.floor(loaded.count)) : 0,
rewardId: typeof loaded.rewardId === 'string' ? loaded.rewardId : null,
rewardName: typeof loaded.rewardName === 'string' ? loaded.rewardName : null,
rewardDescription: typeof loaded.rewardDescription === 'string' ? loaded.rewardDescription : null,
dailyLimited: loaded.dailyLimited === true,
rewardNumber: Number.isFinite(loaded.rewardNumber) ? Math.floor(loaded.rewardNumber) : null,
goal: Number.isFinite(loaded.goal) ? Math.max(1, Math.floor(loaded.goal)) : null,
dailyDate: typeof loaded.dailyDate === 'string' && loaded.dailyDate ? loaded.dailyDate : null,
dailyCount: Number.isFinite(loaded.dailyCount) ? Math.max(0, Math.floor(loaded.dailyCount)) : 0,
lastIncrementAt: Number.isFinite(loaded.lastIncrementAt) ? loaded.lastIncrementAt : null,
lastRewardAt: Number.isFinite(loaded.lastRewardAt) ? loaded.lastRewardAt : null,
};
@@ -153,8 +178,27 @@ function createButtonBoxStore(deps) {
return state;
}
function getDailyLimit(button) {
/*
The persisted file stores only the source values. The public snapshot adds
this derived limit so clients can render daily status without duplicating
button-box reward math in React components.
*/
if (button?.dailyLimited !== true) {
return null;
}
const goal = Number.isFinite(button?.goal) ? Math.max(1, Math.floor(button.goal)) : 1;
return Math.max(1, Math.ceil(goal / 5));
}
function getStateClone() {
return clone({ buttons: getState().buttons });
const current = getState();
return clone({
buttons: current.buttons.map((button) => ({
...button,
dailyLimit: getDailyLimit(button),
})),
});
}
return {