community goal system

This commit is contained in:
legop3
2026-01-16 16:02:56 -05:00
parent 64a9dd3583
commit 90fd0262c7
15 changed files with 517 additions and 137 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"text": "Flip the green slime box",
"updatedAt": 1768597355934,
"updatedBy": "355503317503311872"
}
+1
View File
@@ -17,6 +17,7 @@ require('./src/services/roverConnectionService');
require('./src/services/assignmentService');
require('./src/services/nicknameService');
require('./src/services/chatService');
require('./src/services/communityGoalService');
require('./src/services/videoSessions');
require('./src/services/videoAuthService');
require('./src/services/videoSocketService');
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-poMXYqdO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-hvTkRjRF.css">
<script type="module" crossorigin src="/assets/index-BrK8dCMT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Fk2eqSbH.css">
</head>
<body>
<div id="root"></div>
@@ -0,0 +1,95 @@
const fs = require('fs');
const path = require('path');
const io = require('../globals/io');
const logger = require('../globals/logger').child('communityGoalService');
const { isAdmin } = require('./roleService');
const { publishEvent } = require('./eventBus');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const STORE_PATH = path.join(DATA_DIR, 'community-goal.json');
const MAX_GOAL_LENGTH = 240;
let cache = null;
function loadStore() {
if (cache) return cache;
try {
const raw = fs.readFileSync(STORE_PATH, 'utf8');
cache = JSON.parse(raw);
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load community goal', err.message);
}
cache = null;
}
return cache;
}
function saveStore(next) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
cache = next;
}
function normalizeText(input) {
if (typeof input !== 'string') return '';
return input.replace(/\s+/g, ' ').trim();
}
function getCommunityGoal() {
return loadStore();
}
function setCommunityGoal(text, meta = {}) {
const clean = normalizeText(text);
if (!clean) {
throw new Error('Goal text required');
}
if (clean.length > MAX_GOAL_LENGTH) {
throw new Error(`Goal too long (max ${MAX_GOAL_LENGTH} chars)`);
}
const payload = {
text: clean,
updatedAt: Date.now(),
updatedBy: meta.by || null,
};
saveStore(payload);
publishEvent({ source: 'communityGoal', type: 'communityGoal.updated', payload });
return payload;
}
function clearCommunityGoal(meta = {}) {
const payload = {
text: null,
updatedAt: Date.now(),
updatedBy: meta.by || null,
};
saveStore(payload);
publishEvent({ source: 'communityGoal', type: 'communityGoal.updated', payload });
return payload;
}
io.on('connection', (socket) => {
socket.on('communityGoal:set', ({ text } = {}, cb = () => {}) => {
if (!isAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
const result =
text == null || String(text).trim() === ''
? clearCommunityGoal({ by: socket?.data?.user?.username || socket?.id })
: setCommunityGoal(text, { by: socket?.data?.user?.username || socket?.id });
cb({ success: true, goal: result });
} catch (err) {
cb({ error: err.message });
}
});
});
module.exports = {
getCommunityGoal,
setCommunityGoal,
clearCommunityGoal,
MAX_GOAL_LENGTH,
};
+120 -13
View File
@@ -20,6 +20,7 @@ const { getReplaySources, getDefaultDiscordSources, validateSources } = require(
const { getActiveDrivers } = require('./turnService');
const { getNickname } = require('./nicknameService');
const { tryTriggerReplay } = require('./replayService');
const { getCommunityGoal, setCommunityGoal, clearCommunityGoal } = require('./communityGoalService');
const {
getGuildConfig,
listGuildConfigs,
@@ -54,6 +55,9 @@ const client = new Client({
const channelCache = new Map();
let skippedFirstModeAnnouncement = false;
const PRESENCE_ROTATE_MS = 20000;
let presenceInterval = null;
let presenceShowGoal = false;
function sanitizeMentions(text) {
if (!text) return '';
return String(text)
@@ -158,13 +162,30 @@ function countReady() {
return { ready, total };
}
async function updatePresence() {
if (!client?.user) return;
function truncatePresenceText(text, maxLength) {
if (!text) return '';
if (text.length <= maxLength) return text;
if (maxLength <= 3) return text.slice(0, maxLength);
return `${text.slice(0, maxLength - 3)}...`;
}
function buildPresenceName() {
const { ready, total } = countReady();
const mode = getMode();
const goal = getCommunityGoal();
const goalText = goal?.text ? String(goal.text).trim() : '';
if (presenceShowGoal && goalText) {
const trimmed = truncatePresenceText(goalText, 110);
return `Goal: ${trimmed}`;
}
return `${mode} · ${ready}/${total} Rovers Ready`;
}
async function updatePresence() {
if (!client?.user) return;
try {
await client.user.setPresence({
activities: [{ name: `${mode} · ${ready}/${total} Rovers Ready`, type: ActivityType.Watching }],
activities: [{ name: buildPresenceName(), type: ActivityType.Watching }],
status: 'online',
});
} catch (err) {
@@ -172,6 +193,25 @@ async function updatePresence() {
}
}
function schedulePresenceRotation() {
if (presenceInterval) {
clearInterval(presenceInterval);
presenceInterval = null;
}
const goal = getCommunityGoal();
if (!goal?.text) {
presenceShowGoal = false;
updatePresence();
return;
}
presenceShowGoal = false;
updatePresence();
presenceInterval = setInterval(() => {
presenceShowGoal = !presenceShowGoal;
updatePresence();
}, PRESENCE_ROTATE_MS);
}
async function fetchChannel(id) {
if (!id) return null;
if (channelCache.has(id)) return channelCache.get(id);
@@ -215,6 +255,7 @@ function formatHelp() {
'`rs lock <id>` — lock a rover',
'`rs unlock <id>` — unlock a rover',
'`rs mode <open|turns|admin|lockdown>` — change server mode',
'`rs goal [text|clear]` — show or set community goal',
'`ts` — show time status',
].join('\n');
}
@@ -417,6 +458,57 @@ async function handleModeCommand(message, mode) {
}
}
async function handleGoalCommand(message, tokens) {
const query = tokens.join(' ').trim();
const lower = query.toLowerCase();
if (!query) {
const goal = getCommunityGoal();
const text = goal?.text ? goal.text : null;
await message.reply({
content: text ? `Community goal: ${sanitizeMentions(text)}` : 'No community goal set.',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
if (!isAdminUser(message.author.id)) {
await message.reply({
content: 'Only admins can update the community goal.',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
if (lower === 'clear') {
try {
clearCommunityGoal({ by: message.author?.id || null });
await message.reply({
content: 'Community goal cleared.',
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
await message.reply({
content: sanitizeMentions(`Failed to clear goal: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
return;
}
try {
setCommunityGoal(query, { by: message.author?.id || null });
await message.reply({
content: sanitizeMentions(`Community goal set: ${query}`),
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
await message.reply({
content: sanitizeMentions(`Failed to set goal: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
}
function canManageBridge(message) {
if (isAdminUser(message.author.id)) return true;
if (!message.guild || !message.member) return false;
@@ -600,7 +692,8 @@ async function handleCommand(message) {
action !== 'status' &&
action !== 'help' &&
action !== 'replay' &&
action !== 'bridge'
action !== 'bridge' &&
action !== 'goal'
) {
return; // ignore non-admins for privileged commands
}
@@ -630,6 +723,9 @@ async function handleCommand(message) {
case 'mode':
await handleModeCommand(message, tokens[0]);
break;
case 'goal':
await handleGoalCommand(message, tokens);
break;
default:
await message.reply(formatHelp());
break;
@@ -865,7 +961,7 @@ function handleBusEvent(event) {
case 'mode.changed':
if (!skippedFirstModeAnnouncement) {
skippedFirstModeAnnouncement = true;
updatePresence();
schedulePresenceRotation();
break;
}
announce({
@@ -875,8 +971,19 @@ function handleBusEvent(event) {
title: 'Mode Changed',
description: `Server mode set to **${payload?.mode}**`,
});
updatePresence();
schedulePresenceRotation();
break;
case 'communityGoal.updated': {
const goalText = payload?.text ? sanitizeMentions(String(payload.text)) : null;
announce({
channelId: channels.announcements,
color: 0x8bc34a,
title: 'Community Goal',
description: goalText ? goalText : 'Community goal cleared.',
});
schedulePresenceRotation();
break;
}
case 'rover.locked':
announce({
channelId: channels.announcements,
@@ -884,7 +991,7 @@ function handleBusEvent(event) {
title: 'Rover Locked',
description: `${payload?.roverId} locked${payload?.reason ? ` (${payload.reason})` : ''}.`,
});
updatePresence();
schedulePresenceRotation();
break;
case 'rover.unlocked':
announce({
@@ -894,7 +1001,7 @@ function handleBusEvent(event) {
title: 'Rover Unlocked',
description: `${payload?.roverId} unlocked.`,
});
updatePresence();
schedulePresenceRotation();
break;
case 'rover.online':
announce({
@@ -904,7 +1011,7 @@ function handleBusEvent(event) {
title: 'Rover Online',
description: `${payload?.roverId} is online.`,
});
updatePresence();
schedulePresenceRotation();
break;
case 'rover.offline':
announce({
@@ -914,7 +1021,7 @@ function handleBusEvent(event) {
title: 'Rover Offline',
description: `${payload?.roverId} went offline.`,
});
updatePresence();
schedulePresenceRotation();
break;
case 'rover.dockGuard':
announce({
@@ -997,7 +1104,7 @@ function handleBusEvent(event) {
description: null,
embeds: [buildBatteryStatusEmbed(0xf0b651)],
});
updatePresence();
schedulePresenceRotation();
break;
case 'battery.unlocked':
announce({
@@ -1008,7 +1115,7 @@ function handleBusEvent(event) {
description: null,
embeds: [buildBatteryStatusEmbed(0x4caf50)],
});
updatePresence();
schedulePresenceRotation();
break;
case 'replay.requested':
sendReplayToChannel(payload?.channelId, payload?.requester, payload?.sources || []).catch((err) => {
@@ -1063,7 +1170,7 @@ client.on('messageCreate', async (message) => {
client.once('ready', () => {
logger.info('Discord bot logged in', { tag: client.user?.tag });
updatePresence();
schedulePresenceRotation();
});
subscribe('*', handleBusEvent);
+8
View File
@@ -13,6 +13,8 @@ const { getReplayState, replayEvents } = require('./replayService');
const { getReplaySources } = require('./replaySourceService');
const { getHealthSnapshot } = require('./healthService');
const { loadConfig } = require('../helpers/configLoader');
const { getCommunityGoal } = require('./communityGoalService');
const { subscribe } = require('./eventBus');
const discordInvite = loadConfig().discord?.invite || null;
const kofiLink = loadConfig().kofi?.link || null;
@@ -53,6 +55,7 @@ function buildSession(socket) {
replay: getReplayState(),
replaySources: getReplaySources(),
health: getHealthSnapshot(),
communityGoal: getCommunityGoal(),
users,
discord: {
invite: discordInvite,
@@ -182,6 +185,11 @@ nicknameEvents.on('change', ({ socketId }) => {
}
});
subscribe('communityGoal.updated', () => {
logger.info('Community goal updated; syncing all clients');
syncAll();
});
// sync all sockets 20 seconds
setInterval(() => {
logger.info('Periodic session sync for all clients');