diff --git a/server/src/rewards/definitions/cameraWhiplash.js b/server/src/rewards/definitions/cameraWhiplash.js index a076acd2..23f316ed 100644 --- a/server/src/rewards/definitions/cameraWhiplash.js +++ b/server/src/rewards/definitions/cameraWhiplash.js @@ -1,5 +1,6 @@ const STEP_MS = 220; -const STEPS = 40; +const DURATION_MS = 30 * 1000; +const STEPS = Math.ceil(DURATION_MS / STEP_MS); module.exports = { id: 'cameraWhiplash', @@ -29,13 +30,23 @@ module.exports = { }); if (step >= STEPS) { clearInterval(timer); + rovers.forEach((rover) => { + try { + ctx.issueCommand(String(rover.id), { + type: 'servo', + servo: { angle: 0 }, + }); + } catch (err) { + ctx.logger.warn('cameraWhiplash servo reset failed', { roverId: rover.id, error: err.message }); + } + }); } }, STEP_MS); ctx.sendAlert({ color: '#00bcd4', title: 'Camera Wiggle', - message: `Wobble running on ${rovers.length} rover(s).`, + message: `Wobble running for 30 seconds on ${rovers.length} rover(s).`, }); }, }; diff --git a/server/src/rewards/definitions/chatSpam.js b/server/src/rewards/definitions/chatSpam.js index c87a153f..0a96e92c 100644 --- a/server/src/rewards/definitions/chatSpam.js +++ b/server/src/rewards/definitions/chatSpam.js @@ -1,32 +1,48 @@ const LETTERS = 'abcdefghijklmnopqrstuvwxyz'; +const BURST_COUNT = 18; +const MIN_BURST_SIZE = 2; +const MAX_BURST_SIZE = 8; +const MIN_BURST_DELAY_MS = 80; +const MAX_BURST_DELAY_MS = 420; function randLetter() { return LETTERS[Math.floor(Math.random() * LETTERS.length)]; } +function randInt(min, max) { + return min + Math.floor(Math.random() * (max - min + 1)); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + module.exports = { id: 'chatSpam', name: 'Chat Spam', goal: 320, async run(ctx) { const nickname = randLetter(); - const messageCount = 100; - for (let i = 0; i < messageCount; i += 1) { - const len = 3 + Math.floor(Math.random() * 14); - let text = ''; - for (let j = 0; j < len; j += 1) { - text += randLetter(); - } - try { - ctx.sendExternalMessage({ - nickname, - role: 'spectator', - roverId: null, - text, - }); - } catch { - // ignore spam errors + for (let burst = 0; burst < BURST_COUNT; burst += 1) { + const burstSize = randInt(MIN_BURST_SIZE, MAX_BURST_SIZE); + for (let i = 0; i < burstSize; i += 1) { + const len = 3 + Math.floor(Math.random() * 14); + let text = ''; + for (let j = 0; j < len; j += 1) { + text += randLetter(); + } + try { + ctx.sendExternalMessage({ + nickname, + role: 'spectator', + roverId: null, + text, + }); + } catch { + // ignore spam errors + } } + await sleep(randInt(MIN_BURST_DELAY_MS, MAX_BURST_DELAY_MS)); } }, }; diff --git a/server/src/rewards/definitions/ghostTypingSpam.js b/server/src/rewards/definitions/ghostTypingSpam.js index 56ffc037..976f9ed2 100644 --- a/server/src/rewards/definitions/ghostTypingSpam.js +++ b/server/src/rewards/definitions/ghostTypingSpam.js @@ -1,16 +1,23 @@ const NAMES = ['ross', 'david', 'chirpet', 'caydu', 'meow', 'wawa']; -const BURSTS = 200; -const TICK_MS = 180; +const BURSTS = 120; +const MIN_BURST_DELAY_MS = 70; +const MAX_BURST_DELAY_MS = 350; + +function randInt(min, max) { + return min + Math.floor(Math.random() * (max - min + 1)); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} module.exports = { id: 'ghostTypingSpam', name: 'Typing Spam', goal: 220, async run(ctx) { - let tick = 0; const active = new Set(); - const timer = setInterval(() => { - tick += 1; + for (let burst = 0; burst < BURSTS; burst += 1) { const name = NAMES[Math.floor(Math.random() * NAMES.length)] + String(Math.floor(Math.random() * 10)); const on = Math.random() > 0.35; try { @@ -24,17 +31,16 @@ module.exports = { } catch { // ignore } - if (tick >= BURSTS) { - clearInterval(timer); - active.forEach((ghost) => { - try { - ctx.sendExternalTyping({ nickname: ghost, role: 'spectator', roverId: null, isTyping: false }); - } catch { - // ignore - } - }); + await sleep(randInt(MIN_BURST_DELAY_MS, MAX_BURST_DELAY_MS)); + } + + active.forEach((ghost) => { + try { + ctx.sendExternalTyping({ nickname: ghost, role: 'spectator', roverId: null, isTyping: false }); + } catch { + // ignore } - }, TICK_MS); + }); ctx.sendAlert({ color: '#9c27b0', title: 'Typing Spam', message: 'Ghost typing burst started.' }); }, diff --git a/server/src/rewards/definitions/modeJam.js b/server/src/rewards/definitions/modeJam.js index ee714ed3..69e56ad1 100644 --- a/server/src/rewards/definitions/modeJam.js +++ b/server/src/rewards/definitions/modeJam.js @@ -1,4 +1,5 @@ -const DURATION_MS = 300 * 1000; +const MIN_DURATION_MS = 5 * 60 * 1000; +const MAX_DURATION_MS = 10 * 60 * 1000; let activeTimer = null; @@ -36,7 +37,7 @@ async function restore(ctx, effect = {}) { function scheduleRestore(ctx, effect = {}) { clearTimer(); - const endsAt = Number(effect.endsAt || Date.now() + DURATION_MS); + const endsAt = Number(effect.endsAt || Date.now() + MIN_DURATION_MS); const remaining = Math.max(0, endsAt - Date.now()); const next = { ...effect, endsAt }; ctx.saveEffect('modeJam', next); @@ -49,12 +50,15 @@ function scheduleRestore(ctx, effect = {}) { module.exports = { id: 'modeJam', - name: 'Admin Mode', - goal: 1000, + name: 'Admin Lock', + goal: 900, async run(ctx) { + const durationMs = + MIN_DURATION_MS + Math.floor(Math.random() * (MAX_DURATION_MS - MIN_DURATION_MS + 1)); + const endsAt = Date.now() + durationMs; const prevMode = ctx.getMode(); const prevReason = ctx.getAdminReasonText(); - const jamReason = `Locked by button box until ${new Date(Date.now() + DURATION_MS).toLocaleTimeString()}`; + const jamReason = `Locked by button box until ${new Date(endsAt).toLocaleTimeString()}`; try { ctx.setMode('admin', 'buttonbox:modeJam'); @@ -70,7 +74,7 @@ module.exports = { scheduleRestore(ctx, { prevMode, prevReason, - endsAt: Date.now() + DURATION_MS, + endsAt, }); ctx.sendAlert({ color: '#ff5722', title: 'Admin Mode', message: 'Server forced into admin mode temporarily.' }); diff --git a/server/src/services/buttonBoxService.js b/server/src/services/buttonBoxService.js index 990948cb..3d556fac 100644 --- a/server/src/services/buttonBoxService.js +++ b/server/src/services/buttonBoxService.js @@ -129,9 +129,10 @@ function ensureRewardAssignments() { }); } -function assignNewReward(button) { +function assignNewReward(button, options = {}) { const allRewards = listRewards(); if (!allRewards.length) throw new Error('No rewards configured'); + const excludeRewardId = typeof options.excludeRewardId === 'string' ? options.excludeRewardId : null; const usedByOtherButtons = new Set( state.buttons @@ -139,8 +140,15 @@ function assignNewReward(button) { .map((entry) => entry.rewardId) .filter((rewardId) => typeof rewardId === 'string' && rewardId.length > 0), ); - const uniqueCandidates = allRewards.filter((reward) => !usedByOtherButtons.has(reward.id)); - const pool = uniqueCandidates.length ? uniqueCandidates : allRewards; + const uniqueCandidates = allRewards.filter( + (reward) => !usedByOtherButtons.has(reward.id) && (!excludeRewardId || reward.id !== excludeRewardId), + ); + const fallbackUniqueCandidates = allRewards.filter((reward) => !usedByOtherButtons.has(reward.id)); + const pool = uniqueCandidates.length + ? uniqueCandidates + : fallbackUniqueCandidates.length + ? fallbackUniqueCandidates + : allRewards.filter((reward) => !excludeRewardId || reward.id !== excludeRewardId); const reward = pool[Math.floor(Math.random() * pool.length)] || null; if (!reward) throw new Error('No rewards configured'); @@ -260,7 +268,7 @@ async function runRewardForButton(button) { }); button.lastRewardAt = Date.now(); button.count = 0; - assignNewReward(button); + assignNewReward(button, { excludeRewardId: reward.id }); } async function applyPress(buttonId) {