const { mClient } = require('..'); async function getMember(client, userId) { const guild = client.guilds.cache.get(process.env.D_GuildID); if (!guild) { return null; } return ( guild.members.cache.get(userId) || await guild.members.fetch(userId).catch(() => null) ); } function getParticipantWeight(participant, now = Date.now()) { // Never selected before -> strongly favor them if (!participant.lastExpertAt) { return 6; } const lastExpertAt = new Date(participant.lastExpertAt).getTime(); // Invalid date safety if (Number.isNaN(lastExpertAt)) { return 6; } const daysSinceExpert = Math.max( 0, (now - lastExpertAt) / (24 * 60 * 60 * 1000) ); /* * Square-root weighting prevents very old participants * from completely dominating the random selection. * * Examples: * 1 day -> ~1.41 * 7 days -> ~2.83 * 30 days -> ~5.57 */ return Math.sqrt(daysSinceExpert + 1); } function weightedRandom(participants) { if (!participants.length) { return null; } const weightedParticipants = participants.map(participant => ({ participant, weight: getParticipantWeight(participant) })); const totalWeight = weightedParticipants.reduce( (total, item) => total + item.weight, 0 ); let random = Math.random() * totalWeight; for (const item of weightedParticipants) { random -= item.weight; if (random <= 0) { return item.participant; } } // Floating-point safety fallback return weightedParticipants[weightedParticipants.length - 1].participant; } async function selectWgeParticipant( participateColl, { excludeUserId = null } = {} ) { const filter = { status: true }; if (excludeUserId) { filter.userID = { $ne: excludeUserId }; } const participants = await participateColl .find(filter) .toArray(); return weightedRandom(participants); } module.exports = { name: 'timerComplete', async execute(timer, client) { const db = mClient.db('neo-db'); const wgeColl = db.collection('timer_wge'); const participateColl = db.collection('items_wge'); const channelColl = db.collection('config_channels'); const guild = client.guilds.cache.get(process.env.D_GuildID); if (!guild) { console.error('timerComplete: Guild not found.'); return; } // Notify previous expert const previousMember = await getMember( client, timer.assignedUser ); if (previousMember) { await previousMember.send( `❌ Timer ran out: **${timer.topic}** ...Re-assigning randomly...` ).catch(error => { console.error( `timerComplete: Failed to DM ${timer.assignedUser}:`, error ); }); } // Get configured WGE channel const wgeChannelConfig = await channelColl.findOne({ purpose: 'wge', guildID: process.env.D_GuildID }); if (!wgeChannelConfig) { console.error( 'timerComplete: WGE channel configuration not found.' ); return; } const channel = client.channels.cache.get( wgeChannelConfig.channelID ); if (!channel) { console.error('timerComplete: WGE channel not found.'); return; } // Weighted random selection. // Previous expert is explicitly excluded. const selectedParticipant = await selectWgeParticipant( participateColl, { excludeUserId: timer.assignedUser } ); if (!selectedParticipant) { await channel.send({ content: `The timer for <@${timer.assignedUser}> has run out, ` + `but there are currently no other participating users available for reassignment.` }); return; } const newMember = await guild.members .fetch(selectedParticipant.userID) .catch(() => null); if (!newMember) { console.error( `timerComplete: Could not fetch selected user ${selectedParticipant.userID}.` ); return; } const newAssignedUser = newMember.user; const wgeDefaultAssignedTime = 7 * 24 * 60 * 60 * 1000; const assignedAt = new Date(); /* * Update participant statistics. * * $inc automatically creates expertCount with value 1 * if the field does not exist yet. */ await participateColl.updateOne( { _id: selectedParticipant._id }, { $set: { lastExpertAt: assignedAt }, $inc: { expertCount: 1 } } ); // Remove the expired timer await wgeColl.deleteOne({ _id: timer._id }); // Create new timer await wgeColl.insertOne({ assignedUser: newAssignedUser.id, assigningUser: timer.assigningUser, startedAt: Date.now(), timeRemaining: wgeDefaultAssignedTime, state: 'running', notifications: { half: false, day24: false, complete: false }, topic: timer.topic, debug: false, createdAt: assignedAt }); await channel.send({ content: `The timer for <@${timer.assignedUser}> has run out! Assigning randomly...\n` + `<@${newAssignedUser.id}> is the next expert! Their topic is still **${timer.topic}**` }); console.log( `timerComplete: Reassigned WGE from ${timer.assignedUser} to ${newAssignedUser.id}.` ); } };