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
@@ -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');