/** * The Studio: Artist of the Day plugin * This is a daily activity where users nominate the featured artist for the day, which is selected randomly once voting has ended. * Only works in a room with the id 'thestudio' */ 'use strict'; // This plugin is still a work in progress, so don't hold back if there are things you find need changing! (Especially UX things.) const fs = require('fs'); const path = require('path'); const YEAR = 365 * 24 * 60 * 60 * 1000; const AOTDS_FILE = path.resolve(__dirname, '../config/chat-plugins/thestudio.tsv'); const theStudio = Rooms.get('thestudio'); // Persistence let winners = []; // Don't load the file if the room doesn't exist. if (theStudio) { fs.readFile(AOTDS_FILE, (err, data) => { if (err) return; data = ('' + data).split("\n"); for (let i = 0; i < data.length; i++) { if (!data[i] || data[i] === '\r') continue; let [artist, nominator, quote, song, link, image, time] = data[i].trim().split("\t"); if (artist === "Artist") continue; time = Number(time) || 0; winners.push({artist: artist, nominator: nominator, quote: quote, song: song, link: link, image: image, time: time}); } }); } function saveWinners() { let buf = "Artist\tNominator\tQuote\tSong\tLink\tImage\tTimestamp\n"; for (let i = 0; i < winners.length; i++) { const {artist, nominator, quote, song, link, image, time} = winners[i]; buf += `${artist || ''}\t${nominator || ''}\t${quote || ''}\t${song || ''}\t${link || ''}\t${image || ''}\t${time || 0}\n`; } fs.writeFile(AOTDS_FILE, buf, () => {}); } // These are passed to the constructor and used as ArtistOfTheDayVote.nominations. It's defined here to allow prenoms. let nominations = new Map(); let removedNominations = new Map(); function toArtistId(artist) { // toId would return '' for foreign/sadistic artists return artist.toLowerCase().replace(/\s/g, '').replace(/\b&\b/g, ''); } function addNomination(user, artist) { const id = toArtistId(artist); if (winners.length && toArtistId(winners[winners.length - 1].artist) === id) return user.sendTo(theStudio, "This artist is already the current Artist of the Day."); for (let value of removedNominations.values()) { if (toId(user) in value.userids || user.latestIp in value.ips) return user.sendTo(theStudio, "Since your nomination has been removed by staff, you cannot submit another artist until the next round."); } if (nominations.has(toArtistId(artist))) return user.sendTo(theStudio, "This artist has already been nominated."); for (let [key, value] of nominations) { if (toId(user) in value.userids || user.latestIp in value.ips) { user.sendTo(theStudio, `Your previous vote for ${value.artist} will be removed.`); nominations.delete(key); } } let obj = {}; obj[user.userid] = user.name; nominations.set(toArtistId(artist), {artist: artist, name: user.name, userids: Object.assign(obj, user.prevNames), ips: Object.assign({}, user.ips)}); user.sendTo(theStudio, `Your nomination for ${artist} was successfully submitted.`); if (theStudio.aotdVote) theStudio.aotdVote.display(true); } function removeNomination(name) { name = toId(name); let success = false; nominations.forEach((value, key) => { if (name in value.userids) { removedNominations.set(key, value); nominations.delete(key); success = true; } }); if (theStudio.aotdVote) theStudio.aotdVote.display(true); return success; } function appendWinner(artist, nominator) { winners.push({artist: artist, nominator: nominator, time: Date.now()}); saveWinners(); } function setWinnerProperty(properties) { if (!winners.length) return; for (let i in properties) { winners[winners.length - 1][i] = properties[i]; } saveWinners(); } function generateAotd() { if (!winners.length) return false; let aotd = winners[winners.length - 1]; let output = Chat.html `

The Artist of the Day is ${aotd.artist || "Various Artists"}.`; if (aotd.quote) output += Chat.html `
"${aotd.quote}"`; output += `

`; if (aotd.image) output += Chat.html ``; output += `
`; if (aotd.song) { output += `Song: `; if (aotd.link) { output += Chat.html `${aotd.song}`; } else { output += Chat.escapeHTML(aotd.song); } output += `
`; } output += Chat.html `Nominated by ${aotd.nominator}.`; output += `
`; return output; } function generateWinnerList(year) { let output = `|wide||html|`; if (!winners.length) return output + `No past winners found.`; let now = Date.now(); for (let i = winners.length - 1; i >= 0; i--) { let date = new Date(winners[i].time); if (year) { if (date.getFullYear() !== year) continue; } else if (now - winners[i].time > YEAR) { break; } const pad = num => num < 10 ? '0' + num : num; output += Chat.html `[${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${date.getFullYear()}] ${winners[i].artist}`; if (winners[i].song) { output += `: `; if (winners[i].link) { output += Chat.html `${winners[i].song}`; } else { output += Chat.escapeHTML(winners[i].song); } } output += Chat.html ` (nominated by ${winners[i].nominator})
`; } return output; } function generateNomWindow() { let buffer = ''; if (theStudio.aotdVote) { buffer += `

Nominations for Artist of the Day are in progress! Use /aotd nom to nominate an artist!

`; if (nominations.size) buffer += `Nominations:`; buffer += `
`; return buffer; } class ArtistOfTheDayVote { constructor(room) { this.room = room; this.nominations = nominations; this.removedNominations = removedNominations; this.room.aotdVote = this; } finish() { nominations = new Map(); removedNominations = new Map(); delete this.room.aotdVote; } display(update) { this.room.add(`|uhtml${update ? 'change' : ''}|aotd|${generateNomWindow()}`); } displayTo(connection) { connection.sendTo(this.room, `|uhtml|aotd|${generateNomWindow()}`); } runAotd() { let keys = Array.from(this.nominations.keys()); if (!keys.length) return false; let winner = this.nominations.get(keys[Math.floor(Math.random() * keys.length)]); appendWinner(winner.artist, winner.name); this.room.add(Chat.html `|html|

Nominations for Artist of the Day are over!

Out of ${keys.length} nominations, we randomly selected ${winner.artist} as the winner! (Nomination by ${winner.name})

`); this.finish(); return true; } } let commands = { start: function (target, room, user) { if (room !== theStudio) return this.errorReply('This command can only be used in The Studio.'); if (!this.can('mute', null, room)) return false; if (!this.canTalk()) return; if (room.aotdVote) return this.errorReply("There is already an Artist of the Day nomination in progress."); room.aotdVote = new ArtistOfTheDayVote(room); room.aotdVote.display(false); this.privateModCommand(`(${user.name} has started nominations for the Artist of the Day.)`); }, starthelp: ["/aotd start - Starts nominations for the Artist of the Day. Requires: % @ # & ~"], end: function (target, room, user) { if (room !== theStudio) return this.errorReply('This command can only be used in The Studio.'); if (!this.can('mute', null, room)) return false; if (!this.canTalk()) return; if (!room.aotdVote) return this.errorReply("There is no Artist of the Day nomination in progress."); if (!room.aotdVote.runAotd()) return this.errorReply("Can't select an Artist of the Day without nominations."); this.privateModCommand(`(${user.name} has ended nominations for the Artist of the Day.)`); }, endhelp: ["/aotd end - End nominations for the Artist of the Day and set it to a randomly selected artist. Requires: % @ # & ~"], nom: function (target, room, user) { if (room !== theStudio) return this.errorReply('This command can only be used in The Studio.'); if (!this.canTalk()) return; if (!target) this.parse('/help aotd prenom'); if (!toArtistId(target).length || target.length > 50) return this.sendReply(`'${target}' is not a valid artist name.`); addNomination(user, target); }, nomhelp: ["/aotd nom [artist] - Nominate an artist for the Artist of the Day."], view: function (target, room, user, connection) { if (room !== theStudio) return this.errorReply('This command can only be used in The Studio.'); if (!this.canTalk()) return; if (!room.aotdVote) { if (!user.can('mute', null, room)) return false; return this.sendReply(`|raw|${generateNomWindow()}`); } if (!this.runBroadcast()) return false; if (this.broadcasting) { room.aotdVote.display(); } else { room.aotdVote.displayTo(connection); } }, viewhelp: ["/aotd view - View the current nominations for the Artist of the Day. Requires: % @ * # & ~"], remove: function (target, room, user) { if (room !== theStudio) return this.errorReply('This command can only be used in The Studio.'); if (!this.can('mute', null, room)) return false; if (!this.canTalk()) return; target = this.splitTarget(target); let name = this.targetUsername; let userid = toId(name); if (!userid) return this.errorReply(`'${name}' is not a valid username.`); if (removeNomination(userid)) { this.privateModCommand(`(${user.name} removed ${this.targetUsername}'s nomination for the Artist of the Day.)`); } else { this.sendReply(`User '${name}' has no nomination for the Artist of the Day.`); } }, removehelp: ["/aotd remove [username] - Remove a user's nomination for the Artist of the Day and prevent them from voting again until the next round. Requires: % @ * # & ~"], set: function (target, room, user) { if (room !== theStudio) return this.errorReply('This command can only be used in The Studio.'); if (!target) this.parse('/help aotd set'); if (!room.chatRoomData || !this.can('mute', null, room)) return false; if (!this.canTalk()) return; let params = target.split(target.includes('|') ? '|' : ',').map(param => param.trim()); let changelist = {}; for (let i = 0; i < params.length; i++) { let [key, ...values] = params[i].split(':'); if (!key || !values.length) return this.errorReply(`Syntax error in '${params[i]}'`); key = key.trim(); let value = values.join(':').trim(); if (!['artist', 'quote', 'song', 'link', 'image'].includes(key)) return this.errorReply(`Invalid value for property: ${key}`); switch (key) { case 'artist': if (!toArtistId(value) || value.length > 50) return this.errorReply("Please enter a valid artist name."); break; case 'quote': if (!value.length || value.length > 150) return this.errorReply("Please enter a valid quote."); break; case 'song': if (!value.length || value.length > 50) return this.errorReply("Please enter a valid song name."); break; case 'link': case 'image': if (!/https?:\/\/[^ ]+\//.test(value)) return this.errorReply(`Please enter a valid URL for the ${key} (starting with http:// or https://)`); if (value.length > 200) return this.errorReply("URL too long."); break; default: return this.errorReply(`Invalid value for property: ${key}`); } changelist[key] = value; } let keys = Object.keys(changelist); if (keys.length) { setWinnerProperty(changelist); return this.privateModCommand(`(${user.name} changed the following propert${Chat.plural(keys, 'ies', 'y')} of the Artist of the Day: ${keys.join(', ')})`); } }, sethelp: ["/aotd set property: value[, property: value] - Set the artist, quote, song, link or image for the current Artist of the Day. Requires: % @ * # & ~"], winners: function (target, room, user, connection) { if (room !== theStudio) return this.errorReply('This command can only be used in The Studio.'); if (!this.canTalk()) return false; return connection.popup(generateWinnerList(parseInt(target))); }, winnershelp: ["/aotd winners [year] - Displays a list of previous artists of the day of the past year. Optionally, specify a year to see all winners in that year."], '': function (target, room) { if (room !== theStudio) return this.errorReply('This command can only be used in The Studio.'); if (!this.runBroadcast()) return false; let text = generateAotd(); if (!text) return this.errorReply("No aotd found"); return this.sendReplyBox(text); }, help: function (target, room) { if (room !== theStudio) return this.errorReply('This command can only be used in The Studio.'); if (!this.runBroadcast()) return false; this.sendReply("Use /help aotd to view help for all commands, or /help aotd [command] for help on a specific command."); }, }; exports.commands = { aotd: commands, aotdhelp: [ "The Studio: Artist of the Day plugin commands:", "- /aotd - View the Artist of the Day.", "- /aotd start - Start nominations for the Artist of the Day. Requires: % @ * # & ~", "- /aotd nom [artist] - Nominate an artist for the Artist of the Day.", "- /aotd remove [username] - Remove a user's nomination for the Artist of the Day and prevent them from voting again until the next round. Requires: % @ * # & ~", "- /aotd end - End nominations for the Artist of the Day and set it to a randomly selected artist. Requires: % @ * # & ~", "- /aotd set property: value[, property: value] - Set the artist, quote, song, link or image for the current Artist of the Day. Requires: % @ * # & ~", "- /aotd winners [year] - Displays a list of previous artists of the day of the past year. Optionally, specify a year to see all winners in that year.", ], };