diff --git a/fgfm.TODO b/fgfm.TODO index bd75783..6548e0b 100755 --- a/fgfm.TODO +++ b/fgfm.TODO @@ -1,4 +1,5 @@ TODO: + ☐ Add cooldowns ☐ Move vrmode timer to this bot, delete from SLCB ☐ Support a CLI flag to delay showing queue until command is issued ☐ Room vid requests / import @@ -18,7 +19,6 @@ TODO: - Once countdown finishes, switch to intro scene and play - Switch to fgfm once intro finishes ☐ Support for $pause -- not sure how to pull this off - ☐ Add cooldowns ☐ Tool to output list of video ID's / descriptions ☐ Stream alerts for chat ☐ Rotating background images (leftside) diff --git a/fgfm.js b/fgfm.js index b62e82a..d927def 100755 --- a/fgfm.js +++ b/fgfm.js @@ -2,11 +2,16 @@ * FG.fm Automation */ -// Import modules +// Import 3rd party packages const irc = require('irc'); const schedule = require('node-schedule'); -const util = require('./lib/util'); +const md5 = require('md5'); + +// Import local packages const GHOBS = require('./lib/ghobs'); +const FGFM = require('./lib/fgfm'); +const cooldowns = require('./lib/cooldowns'); +const util = require('./lib/util'); // Read internal configuration let config = require('./config.json'); @@ -14,22 +19,12 @@ config.vods = require(config.vodConfigFile); config.rooms = require(config.roomConfigFile); let snesGames = require('./conf/snesgames.json'); -// Set up initial state -let state = { - "videoQueue": [], - "recentlyPlayed": [], - "currentVideo": null, - "videoTimer": null, - "lastCommercialShownAt": Date.now(), - "commercialPlaying": false -}; - // Main screen turn on const obs = new GHOBS(config); obs.init() - .then(() => {return twitchInit(config.twitch)}) - .then(twitch => {return streamInit(config, twitch)}) - .catch(console.error); +.then(() => twitchInit(config.twitch)) +.then(twitch => streamInit(config, twitch)) +.catch(console.error); // Connect to twitch, set up basic event listeners const twitchInit = (config) => { @@ -75,504 +70,398 @@ const twitchInit = (config) => { // Initialize Stream automation const streamInit = (config, twitch) => { - return new Promise((resolve, reject) => { - // Set up initial queue + start playback - const init = () => { - // Set up the initial queue by randomly choosing the configured amount of vods included in shuffling - state.videoQueue = config.vods.alttp.filter(e => e.includeInShuffle === true).sort(util.randSort).slice(0, config.initialQueueSize); - console.log(`Initial video queue: ${state.videoQueue.map((c, i) => `[${i+1}] ${c.chatName}`).join(' | ')}`); - // Start queue playback - state.currentVideo = state.videoQueue.shift(); - showVideo(state.currentVideo); - } - - // Show a gameplay vod - const showVideo = video => { - console.log(`Showing video: ${video.chatName}`); - - obs.playVideoInScene(video, config.defaultSceneName, nextVideo) - .then(timer => { - // track timer so we can cancel callback later on if necessary - state.videoTimer = timer; - - // update activity label and show/hide appropriately - if (video.hasOwnProperty('label') && video.label !== false) { - obs.showActivity(video.label); - } else { - obs.hideActivity(); - } - }) - .catch(console.error); - }; - - const addRoomVideo = (room, loop) => { - let loops = 1; - if (typeof loop === 'undefined' || loop === true) { - loops = Math.floor(config.roomVidPlaytime / room.videoData.length); - } - console.log(`Adding room video for ${room.dungeonName} - ${room.roomName} to the queue (${loops} loops)`); - - let video = { - "filePath": `${config.roomVidsBasePath}${room.winPath}`, - "sceneItem": (room.videoData.width === 960) ? "4x3ph" : "16x9ph", - "length": room.videoData.length, - "label": room.roomName, - "chatName": room.roomName, - "loops": loops, - "requestedBy": room.requestedBy - }; - - state.videoQueue.push(video); - }; - - // Picks the next video in the queue (shuffles if empty) - // Also handles "commercial breaks" if enabled - const nextVideo = () => { - // Show a "commercial break" if it's been long enough since the last one - let secondsSinceLastCommercial = (Date.now() - state.lastCommercialShownAt) / 1000; - if (config.commercialsEnabled === true && secondsSinceLastCommercial >= config.commercialInterval) { - state.commercialPlaying = true; - console.log(`It has been ${secondsSinceLastCommercial} seconds since the last commercial break!`); - // Random chance for it to be "everybody wow" - let memeId = false; - if ((Math.floor(Math.random() * 100) + 1) <= config.auwChance) { - console.log(`Showing AUW!`); - memeId = 'auw'; - } - showMeme(memeId).then(() => { - state.lastCommercialShownAt = Date.now(); - state.commercialPlaying = false; - nextVideo(); - }).catch(console.error); - - return; - } - - // Keep track of recently played videos - if (state.recentlyPlayed.length === config.recentlyPlayedMemory) { - state.recentlyPlayed.shift(); - } - state.recentlyPlayed.push(state.currentVideo.id); - - // If a commercial is playing, wait until it's done to switch - while (state.commercialPlaying === true) {} - - // play the next video in the queue, or pick one at random if the queue is empty - if (state.videoQueue.length > 0) { - state.currentVideo = state.videoQueue.shift(); - } else { - // Random chance for room grind to be played for an amount of time instead of another video be shuffled to - if ((Math.floor(Math.random() * 100) + 1) <= config.roomGrindChance) { - console.log(`Room grind selected!`); - // show room-grind source - obs.showRoomGrind(config.roomGrindPlaytime, () => {nextVideo()}) - .then(timer => { - state.videoTimer = timer; - }) - .catch(console.error); - - return; - } - - // Random chance for room videos to be added - if ((Math.floor(Math.random() * 100) + 1) <= config.roomShuffleChance) { - console.log(`Room vids selected!`); - - let roomVid = config.rooms.sort(util.randSort).slice(0, 1).shift(); - roomVid.requestedBy = 'shuffle'; - - addRoomVideo(roomVid); - - // play the first one - state.currentVideo = state.videoQueue.shift(); - } else { - // filter recently played from shuffle - let freshVods = config.vods.alttp.filter(e => { - return e.includeInShuffle === true && !state.recentlyPlayed.includes(e.id); - }); - state.currentVideo = freshVods.sort(util.randSort).slice(0, 1).shift(); - } - } - - showVideo(state.currentVideo); - }; - - // "Commercials" - const showCommercial = (video, callback) => { - return new Promise((resolve, reject) => { - let handleFinish = () => { - // unmute songrequest audio - //twitch.editorChat.say(config.twitch.channel, `!volume ${config.defaultSRVolume}`); - if (typeof callback !== 'undefined') callback(); - }; - - obs.playVideoInScene(video, config.commercialSceneName, handleFinish) - .then(timer => { - // mute songrequest audio - //twitch.editorChat.say(config.twitch.channel, `!volume 0`); - resolve(timer); - }) - .catch(reject); - }); - }; - - // Memes-By-Id - const showMeme = (id) => { - return new Promise((resolve, reject) => { - // find the vod in memes - let video = config.vods.memes.find(e => e.id === id); - if (!video) { - reject(`No meme found matching ID ${id}`); - } - - let handleFinish = () => { - if (id === 'auw') { - obs.hide("owen", config.commercialSceneName); - } - resolve(); - }; - - showCommercial(video, handleFinish) - .then(videoHasStarted => { - // in the case of 'auw', show owen + tell chat what's up - if (id === 'auw') { - obs.show("owen", config.commercialSceneName); - twitch.botChat.say(config.twitch.channel, 'Everybody OwenWow'); - } - }) - .catch(console.error); - }); - }; - - // Twitch Chat Commands - twitch.botChat.addListener('message', (from, to, message) => { - // Ignore everything from blacklisted users - if (config.twitch.blacklistedUsers.includes(from)) return; - - // Listen for commands that start with the designated prefix - if (message.startsWith(config.twitch.cmdPrefix)) { - - let commandParts = message.slice(config.twitch.cmdPrefix.length).split(' '); - let commandNoPrefix = commandParts[0] || ''; - - // ADMIN COMMANDS - if (config.twitch.admins.includes(from)) { - - // SHOW/HIDE SOURCE - if (commandNoPrefix === 'show' || commandNoPrefix === 'hide') { - let newVisibility = (commandNoPrefix === 'show'); - - let sceneItem = commandParts[1] || false; - if (!sceneItem) { - twitch.botChat.say(to, `A scene item name is required!`); - return; - } - - let sceneOrGroup = commandParts[2] || obs.currentScene; - obs.setVisible(sceneItem, sceneOrGroup, newVisibility).catch(console.error); - - // TOGGLE SOURCE VISIBILITY - } else if (commandNoPrefix === 't') { - let sceneItem = commandParts[1] || false; - if (!sceneItem) { - twitch.botChat.say(to, `A scene item name is required!`); - return; - } - - obs.toggleVisible(sceneItem).catch(console.error); - - // EVERYBODY WOW - } else if (commandNoPrefix === 'auw') { - state.commercialPlaying = true; - showMeme('auw').then(() => state.commercialPlaying = false).catch(console.error); - - // MEMES ON-DEMAND - } else if (commandNoPrefix === 'meme') { - let memeId = commandParts[1] || false; - if (memeId) { - console.log(`${memeId} meme requested`); - if ( config.vods.memes.findIndex(e => e.id === memeId) === -1) { - twitch.botChat.say(to, `No meme with that ID exists!`); - return; - } - } else { - memeId = config.vods.memes.sort(util.randSort)[0].id; - console.log(`${memeId} meme randomly selected`); - } - - state.commercialPlaying = true; - showMeme(memeId).then(() => state.commercialPlaying = false).catch(console.error); - - // SWITCH SCENES - } else if (commandNoPrefix === 'switch') { - - let newScene = commandParts[1] || false; - if (!newScene) { - twitch.botChat.say(to, `A scene name is required!`); - return; - } - - obs.switchToScene(newScene).catch(console.error); - - // SET ON-SCREEN ACTIVITY - } else if (commandNoPrefix === 'setact') { - let newActivity = commandParts.slice(1).join(' '); - if (!newActivity) { - twitch.botChat.say(to, `Please provide a new activity`); - return; - } - - obs.showActivity(newActivity).catch(console.error); - - // REBOOT - } else if (commandNoPrefix === 'reboot') { - console.log('Received request from admin to reboot...'); - twitch.botChat.say(to, 'Rebooting...'); - process.exit(0); - - // SKIP - } else if (commandNoPrefix === 'skip') { - clearTimeout(state.videoTimer); - obs.hide(state.currentVideo.sceneItem, config.defaultSceneName).then(nextVideo).catch(console.error); - - // CLEAR QUEUE - } else if (commandNoPrefix === 'clear') { - state.videoQueue = []; - - // ADD - } else if (commandNoPrefix === 'add') { - let requestedVideoId = commandParts[1] || false; - if (requestedVideoId === false) { - twitch.botChat.say(to, `Missing video ID`); - return; - } - - // make sure request vid isn't in the queue already - if (state.videoQueue.findIndex(e => e.id == requestedVideoId) !== -1) { - twitch.botChat.say(to, `That video is in the queue already!`); - return; - } - - // search for req'd vid by id in config.vods.alttp - let vodIndex = config.vods.alttp.findIndex(e => e.id == requestedVideoId); - if (vodIndex === -1) { - twitch.botChat.say(to, `A video with that ID does not exist!`); - return; - } - - // add to queue if it exists - state.videoQueue.push(config.vods.alttp[vodIndex]); - twitch.botChat.say(to, `${config.vods.alttp[vodIndex].chatName} has been added to the queue [${state.videoQueue.length}]`); - return; - - // START VOTE - } else if (commandNoPrefix === 'startvote') { - videoVoteJob.reschedule(`*/${config.videoPollIntervalMinutes} * * * *`); - twitch.botChat.say(to, `Video Queue Voting will start in ${config.videoPollIntervalMinutes} minutes!`); - - // PAUSE VOTE - } else if (commandNoPrefix === 'pausevote') { - clearInterval(rtvInterval); - videoVoteJob.cancel(); - twitch.botChat.say(to, `Video Queue Voting has been paused.`); - } - } - //////////////// - - // USER COMMANDS - // - // VOTE FOR VIDEO - if (commandNoPrefix === 'vote') { - let userVote = commandParts[1] || false; - - if (userVote === false) { - rockTheVote(); - return; - } - - userVote = Number.parseInt(userVote); - - if (!Number.isInteger(userVote) || userVote < 1 || userVote > currentChoices.length) { - return twitch.botChat.say(to, `@${from}, please choose an option from 1 - ${currentChoices.length}!`); - } - - // Check for uniqueness of vote - // if it's not unique, update the vote - let prevVote = userVotes.findIndex(e => e.from === from); - if (prevVote !== -1) { - if (userVotes[prevVote].vote !== userVote) { - // update vote and inform the user - userVotes[prevVote].vote = userVote; - twitch.botChat.say(to, `@${from}, your vote has been updated!`); - } else { - twitch.botChat.say(to, `@${from}, your vote is already in!`); - } - } else { - // log user vote - userVotes.push({"from": from, "vote": userVote}); - twitch.botChat.say(to, `@${from}, your vote has been logged!`); - } - - - // QUEUE STATUS - } else if (commandNoPrefix === 'queue') { - if (state.videoQueue.length > 0) { - let chatQueue = state.videoQueue.slice(0, 10).map((c, i) => { - return `[${i+1}] ${c.chatName}`; - }); - twitch.botChat.say(to, chatQueue.join(' | ')); - } else { - twitch.botChat.say(to, `No videos currently in queue!`); - } - - - // CURRENT VIDEO - } else if (commandNoPrefix === 'current') { - twitch.botChat.say(to, `Now Playing: ${state.currentVideo.chatName}`); - - - // NEXT VIDEO - } else if (commandNoPrefix === 'next') { - if (state.videoQueue.length > 0) { - twitch.botChat.say(to, `Next Video: ${state.videoQueue[0].chatName}`); - } else { - twitch.botChat.say(to, `No videos currently in queue!`); - } - - - // VIDEO REQUEST - } else if (commandNoPrefix === 'vr') { - let requestedVideoId = commandParts[1] || false; - if (requestedVideoId === false) { - twitch.botChat.say(to, `Useage: ${config.twitch.cmdPrefix}vr | Videos: https://pastebin.com/qv0wDkvB`); - return; - } - - // make sure request vid isn't in the queue already - if (state.videoQueue.findIndex(e => e.id === requestedVideoId) !== -1) { - twitch.botChat.say(to, `That video is in the queue already!`); - return; - } - - // search for req'd vid by id in config.vods.alttp - let vodIndex = config.vods.alttp.findIndex(e => e.id === requestedVideoId); - if (vodIndex === -1) { - twitch.botChat.say(to, `A video with that ID does not exist!`); - return; - } - - config.vods.alttp[vodIndex].requestedBy = from; - - // add to queue if it exists - state.videoQueue.push(config.vods.alttp[vodIndex]); - twitch.botChat.say(to, `${config.vods.alttp[vodIndex].chatName} has been added to the queue [${state.videoQueue.length}]`); - return; - - // ROOM VID REQUESTS - } else if (commandNoPrefix === 'room') { - let roomId = commandParts[1] || false; - let room; - - if (roomId !== false) { - let roomIndex = config.rooms.findIndex(e => e.id === parseInt(roomId)); - - if (roomIndex === -1) { - twitch.botChat.say(to, `No room found matching that ID!`); - return; - } - - room = config.rooms[roomIndex]; - } else { - // choose a room at random - // room = config.rooms.sort(util.randSort).slice(0, 1).shift(); - twitch.botChat.say(to, `Useage: ${config.twitch.cmdPrefix}room | Rooms: https://goo.gl/qoNmuH`); - return; - } - - room.requestedBy = from; - - addRoomVideo(room); - twitch.botChat.say(to, `Added ${room.dungeonName||'?'} - ${room.roomName||'?'} to the queue [${state.videoQueue.length}]!`); - - // RNGAMES - } else if (commandNoPrefix === 'rngames') { - twitch.botChat.say(to, snesGames.sort(util.randSort).slice(0, 10).join(' | ')); - } - //////////////// - } - }); + // All your comfy are belong to us + const director = new FGFM({config: config, obs: obs}); + director.init(); - // @TODO: Modularize timed events - console.log(`Initializing stream timers...`); - let userVotes = currentChoices = []; - let rockTheVote = () => {}; - let rtvInterval = setInterval(() => {rockTheVote()}, 300000); - let videoVoteJob = new schedule.Job(async () => { - // Tally votes from previous election (if there was one), add the winner to the queue - let winner; - if (currentChoices.length > 0) { - if (userVotes.length === 0) { - // choose a random element from currentChoices - winner = util.randElement(currentChoices); - console.log(`VIDEO CHOSEN RANDOMLY: ${winner.chatName}`); - twitch.botChat.say(config.twitch.channel, `No Votes Logged -- Next Video Chosen at Random: ${winner.chatName}`); - } else { - // tally and sort votes - let voteTallies = []; - await util.asyncForEach(userVotes, async (vote) => { - tallyIndex = voteTallies.findIndex(e => e.id === vote.vote); - if (tallyIndex !== -1) { - voteTallies[tallyIndex].count++; - } else { - voteTallies.push({id: vote.vote, count: 1}); - } - }); - voteTallies.sort((a, b) => { - if (a.count < b.count) { - return -1; - } - if (a.count > b.count) { - return 1; - } - // a must be equal to b - return 0; - }); + // Chat commands + const commands = { + admin: { - console.log(`Voting Results: ${JSON.stringify(voteTallies)}`); - winner = currentChoices[voteTallies[0].id-1]; - console.log(`WINNER OF THE VOTE: ${winner.chatName}`); - twitch.botChat.say(config.twitch.channel, `Winner of the Video Vote: ${winner.chatName}`); - - // clear user votes - userVotes = []; + changevis: (cmd, newVisibility) => { + let sceneItem = command.args[1] || false; + if (!sceneItem) { + twitch.botChat.say(cmd.to, `A scene item name is required!`); + return; } - state.videoQueue.push(winner); + let sceneOrGroup = command.args[2] || obs.currentScene; + obs.setVisible(sceneItem, sceneOrGroup, newVisibility).catch(console.error); + }, + + + show: (cmd) => { + commands.admin.changevis(cmd, true); + }, + + + hide: (cmd) => { + commands.admin.changevis(cmd, false); + }, + + + t: (cmd) => { + let sceneItem = cmd.args[1] || false; + if (!sceneItem) { + twitch.botChat.say(cmd.to, `A scene item name is required!`); + return; + } + + obs.toggleVisible(sceneItem).catch(console.error); + }, + + + auw: (cmd) => { + director.showMeme('auw'); + }, + + + meme: (cmd) => { + let memeId = cmd.args[1] || false; + if (memeId) { + console.log(`${memeId} meme requested by ${cmd.from}`); + if ( config.vods.memes.findIndex(e => e.id === memeId) === -1) { + twitch.botChat.say(cmd.to, `No meme with that ID exists!`); + return; + } + } else { + memeId = config.vods.memes.sort(util.randSort)[0].id; + console.log(`${memeId} meme randomly selected`); + } + + director.showMeme(memeId); + }, + + + switch: (cmd) => { + let newScene = cmd.args[1] || false; + if (!newScene) { + twitch.botChat.say(cmd.to, `A scene name is required!`); + return; + } + + obs.switchToScene(newScene).catch(console.error); + }, + + + setact: (cmd) => { + let newActivity = cmd.args.slice(1).join(' '); + if (!newActivity) { + twitch.botChat.say(cmd.to, `Please provide a new activity`); + return; + } + + obs.showActivity(newActivity).catch(console.error); + }, + + + add: (cmd) => { + // @TODO: DRY this out with the checks in vr + let requestedVideoId = cmd.args[1] || false; + if (requestedVideoId === false) { + twitch.botChat.say(cmd.to, `Missing video ID`); + return; + } + + // make sure request vid isn't in the queue already + // @TODO: Move into FGFM + if (director.state.videoQueue.findIndex(e => e.id == requestedVideoId) !== -1) { + twitch.botChat.say(cmd.to, `That video is in the queue already!`); + return; + } + + // search for req'd vid by id in config.vods.alttp + let vodIndex = config.vods.alttp.findIndex(e => e.id == requestedVideoId); + if (vodIndex === -1) { + twitch.botChat.say(cmd.to, `A video with that ID does not exist!`); + return; + } + + // add to queue if it exists + // @TODO: Move into FGFM + if (director.addVideo(config.vods.alttp[vodIndex])) { + twitch.botChat.say(cmd.to, `${config.vods.alttp[vodIndex].chatName} has been added to the queue [${director.state.videoQueue.length}]`); + } else { + twitch.botChat.say(cmd.to, `Video could not be added to queue!`); + } + }, + + + skip: (cmd) => { + director.skip(); + }, + + + clear: (cmd) => { + director.clearQueue(); + }, + + + startvote: (cmd) => { + videoVoteJob.reschedule(`*/${config.videoPollIntervalMinutes} * * * *`); + twitch.botChat.say(cmd.to, `Video Queue Voting will start in ${config.videoPollIntervalMinutes} minutes!`); + }, + + + pausevote: (cmd) => { + clearInterval(rtvInterval); + videoVoteJob.cancel(); + twitch.botChat.say(cmd.to, `Video Queue Voting has been paused.`); + }, + + + reboot: (cmd) => { + console.log('Received request from admin to reboot...'); + twitch.botChat.say(cmd.to, 'Rebooting...'); + process.exit(0); // requires process manager with autoreboot to work } - - // choose more random videos from config.vods.alttp (that aren't already in the queue) - let vodsNotInQueue = config.vods.alttp.filter(e => { - let inQueue = state.videoQueue.findIndex(q => q.id === e.id) !== -1; - return !inQueue; - }); - currentChoices = vodsNotInQueue.sort(util.randSort).slice(0, config.videoPollSize); + }, - // Poll the chat - let chatChoices = currentChoices.map((c, i) => { - return `[${i+1}] ${c.chatName}`; - }); + user: { - rockTheVote = () => { - twitch.botChat.say(config.twitch.channel, `Vote for which video you'd like to add to the queue using ${config.twitch.cmdPrefix}vote #: ${chatChoices.join(' | ')}`) - }; - clearInterval(rtvInterval); - rockTheVote(); - rtvInterval = setInterval(() => {rockTheVote()}, 300000); + vote: (cmd) => { + let userVote = cmd.args[1] || false; + + if (userVote === false) { + rockTheVote(); + return; + } + + userVote = Number.parseInt(userVote); + + if (!Number.isInteger(userVote) || userVote < 1 || userVote > currentChoices.length) { + return twitch.botChat.say(cmd.to, `@${from}, please choose an option from 1 - ${currentChoices.length}!`); + } + + // Check for uniqueness of vote + // if it's not unique, update the vote + let prevVote = userVotes.findIndex(e => e.from === from); + if (prevVote !== -1) { + if (userVotes[prevVote].vote !== userVote) { + // update vote and inform the user + userVotes[prevVote].vote = userVote; + twitch.botChat.say(cmd.to, `@${from}, your vote has been updated!`); + } else { + twitch.botChat.say(cmd.to, `@${from}, your vote is already in!`); + } + } else { + // log user vote + userVotes.push({"from": from, "vote": userVote}); + twitch.botChat.say(cmd.to, `@${from}, your vote has been logged!`); + } + }, + + + queue: (cmd) => { + // @TODO: Move into FGFM + if (director.state.videoQueue.length > 0) { + let chatQueue = director.state.videoQueue.slice(0, 10).map((c, i) => { + return `[${i+1}] ${c.chatName}`; + }); + twitch.botChat.say(cmd.to, chatQueue.join(' | ')); + } else { + twitch.botChat.say(cmd.to, `No videos currently in queue!`); + } + }, + + + current: (cmd) => { + // @TODO: Move into FGFM + twitch.botChat.say(cmd.to, `Now Playing: ${director.state.currentVideo.chatName}`); + }, + + + next: (cmd) => { + // @TODO: Move into FGFM + if (director.state.videoQueue.length > 0) { + twitch.botChat.say(cmd.to, `Next Video: ${director.state.videoQueue[0].chatName}`); + } else { + twitch.botChat.say(cmd.to, `No videos currently in queue!`); + } + }, + + + vr: (cmd) => { + let requestedVideoId = cmd.args[1] || false; + if (requestedVideoId === false) { + twitch.botChat.say(cmd.to, `Useage: ${config.twitch.cmdPrefix}vr | Videos: https://pastebin.com/qv0wDkvB`); + return; + } + + // make sure request vid isn't in the queue already + // @TODO: Move into FGFM + if (director.state.videoQueue.findIndex(e => e.id === requestedVideoId) !== -1) { + twitch.botChat.say(cmd.to, `That video is in the queue already!`); + return; + } + + // search for req'd vid by id in config.vods.alttp + let vodIndex = config.vods.alttp.findIndex(e => e.id === requestedVideoId); + if (vodIndex === -1) { + twitch.botChat.say(cmd.to, `A video with that ID does not exist!`); + return; + } + + config.vods.alttp[vodIndex].requestedBy = cmd.from; + + // add to queue if it exists + // @TODO: Return queue position from addVideo + if (director.addVideo(config.vods.alttp[vodIndex])) { + twitch.botChat.say(cmd.to, `${config.vods.alttp[vodIndex].chatName} has been added to the queue [${director.state.videoQueue.length}]`); + } else { + twitch.botChat.say(cmd.to, `${config.vods.alttp[vodIndex].chatName} could not be added to the queue!`); + } + }, + + + room: (cmd) => { + let roomId = cmd.args[1] || false; + let room; + + if (roomId !== false) { + let roomIndex = config.rooms.findIndex(e => e.id === parseInt(roomId)); + + if (roomIndex === -1) { + twitch.botChat.say(cmd.to, `No room found matching that ID!`); + return; + } + + room = config.rooms[roomIndex]; + } else { + twitch.botChat.say(cmd.to, `Useage: ${config.twitch.cmdPrefix}room | Rooms: https://goo.gl/qoNmuH`); + return; + } + + room.requestedBy = cmd.from; + + director.addRoomVideo(room); + // @TODO: Return new queue position from addRoomVideo and use below + twitch.botChat.say(cmd.to, `Added ${room.dungeonName||'?'} - ${room.roomName||'?'} to the queue [${director.state.videoQueue.length}]!`); + }, + + + rngames: (cmd) => { + twitch.botChat.say(cmd.to, snesGames.sort(util.randSort).slice(0, 10).join(' | ')); + } + } + }; + + // Listen for the above commands + twitch.botChat.addListener('message', (from, to, message) => { + // Ignore everything from blacklisted users + if (config.twitch.blacklistedUsers.includes(from)) return; + + // Ignore commands that don't start with the designated prefix + if (!message.startsWith(config.twitch.cmdPrefix)) return; + + // Remove command prefix for parsing + let noPrefix = message.slice(config.twitch.cmdPrefix.length); + + // Ignore blank commands + if (noPrefix.length === 0) return; + + // Parse command arguments + let args = noPrefix.split(' '); + let key = args[0] || ''; + + // Ignore messages without a command + if (!key || key.length === 0) return; + + // Ignore unrecognized commands + if (!commands.admin.hasOwnProperty(key) && !commands.user.hasOwnProperty(key)) return; + + // Check if the command is on cooldown for this user in this channel (admins bypass this) + let cooldownKey = md5(from+to+key); + cooldowns.get(cooldownKey, config.twitch.defaultUserCooldown) + .then(onCooldown => { + if (onCooldown === false || config.twitch.admins.includes(from)) { + let command = {message: message, from: from, to: to, key: key, args: args}; + + // Handle admin commands + if (commands.admin.hasOwnProperty(command.key) && config.twitch.admins.includes(from)) { + return commands.admin[command.key](command); + } + + // Handle all other user commands + if (commands.user.hasOwnProperty(command.key)) { + // Place this command on cooldown for the user + cooldowns.set(cooldownKey, config.twitch.defaultUserCooldown); + return commands.user[command.key](command); + } + } + }) + .catch(console.error); + }); + + // @TODO: Modularize timed events + //console.log(`Initializing stream timers...`); + let userVotes = currentChoices = []; + let rockTheVote = () => {}; + // @TODO: Move this interval to config + let rtvInterval = setInterval(() => {rockTheVote()}, 300000); + let videoVoteJob = new schedule.Job(async () => { + // Tally votes from previous election (if there was one), add the winner to the queue + let winner; + if (currentChoices.length > 0) { + if (userVotes.length === 0) { + // choose a random element from currentChoices + winner = util.randElement(currentChoices); + console.log(`VIDEO CHOSEN RANDOMLY: ${winner.chatName}`); + twitch.botChat.say(config.twitch.channel, `No Votes Logged -- Next Video Chosen at Random: ${winner.chatName}`); + } else { + // tally and sort votes + let voteTallies = []; + await util.asyncForEach(userVotes, async (vote) => { + tallyIndex = voteTallies.findIndex(e => e.id === vote.vote); + if (tallyIndex !== -1) { + voteTallies[tallyIndex].count++; + } else { + voteTallies.push({id: vote.vote, count: 1}); + } + }); + voteTallies.sort((a, b) => { + if (a.count < b.count) { + return -1; + } + if (a.count > b.count) { + return 1; + } + // a must be equal to b + return 0; + }); + + console.log(`Voting Results: ${JSON.stringify(voteTallies)}`); + winner = currentChoices[voteTallies[0].id-1]; + console.log(`WINNER OF THE VOTE: ${winner.chatName}`); + twitch.botChat.say(config.twitch.channel, `Winner of the Video Vote: ${winner.chatName}`); + + // clear user votes + userVotes = []; + } + + director.addVideo(winner); + } + + // choose more random videos from config.vods.alttp (that aren't already in the queue) + // @TODO: Move into FGFM + let vodsNotInQueue = config.vods.alttp.filter(e => { + let inQueue = director.state.videoQueue.findIndex(q => q.id === e.id) !== -1; + return !inQueue; + }); + currentChoices = vodsNotInQueue.sort(util.randSort).slice(0, config.videoPollSize); + + // Poll the chat + let chatChoices = currentChoices.map((c, i) => { + return `[${i+1}] ${c.chatName}`; }); - init(); - resolve(obs); + rockTheVote = () => { + twitch.botChat.say(config.twitch.channel, `Vote for which video you'd like to add to the queue using ${config.twitch.cmdPrefix}vote #: ${chatChoices.join(' | ')}`) + }; + clearInterval(rtvInterval); + rockTheVote(); + rtvInterval = setInterval(() => {rockTheVote()}, 300000); }); }; diff --git a/lib/cooldowns.js b/lib/cooldowns.js index 475877c..994f06e 100755 --- a/lib/cooldowns.js +++ b/lib/cooldowns.js @@ -3,13 +3,10 @@ module.exports = { set: placeOnCooldown }; -const memcache = require('memcache'), - md5 = require('md5'), - keyPrefix = 'cd'; - -const cache = new memcache.Client(); -cache.on('error', console.error); -cache.connect(); +const NodeCache = require('node-cache'); +const cache = new NodeCache({checkperiod: 1}); +const md5 = require('md5'); +const keyPrefix = 'cd'; // Given a cooldownTime in seconds and a command, returns false if the command is not on cooldown // returns the time in seconds until the command will be ready again otherwise @@ -40,9 +37,9 @@ function isOnCooldown(command, cooldownTime, callback) function placeOnCooldown(command, cooldownTime) { let key = keyPrefix + md5(command); - return cache.set(key, Date.now(), handleCacheSet, cooldownTime); + return cache.set(key, Date.now(), cooldownTime, handleCacheSet); } function handleCacheSet(error, result) {} -process.on('exit', (code) => {cache.close()}); \ No newline at end of file +process.on('exit', (code) => {cache.close()}); diff --git a/lib/fgfm.js b/lib/fgfm.js index e69de29..84ec9e4 100755 --- a/lib/fgfm.js +++ b/lib/fgfm.js @@ -0,0 +1,197 @@ +const util = require('./util'); + +function FGFM(config) { + // Set up initial state + this.config = config.config; + this.obs = config.obs; + this.state = { + videoQueue: [], + recentlyPlayed: [], + currentVideo: null, + videoTimer: null, + lastCommercialShownAt: Date.now(), + commercialPlaying: false + }; + + // Set up initial queue + start playback + this.init = () => { + // Set up the initial queue by randomly choosing the configured amount of vods included in shuffling + this.state.videoQueue = this.config.vods.alttp.filter(e => e.includeInShuffle === true).sort(util.randSort).slice(0, this.config.initialQueueSize); + + // Start queue playback + this.state.currentVideo = this.state.videoQueue.shift(); + this.showVideo(this.state.currentVideo); + }; + + // Shows.. a... video + this.showVideo = video => { + console.log(`Showing video: ${video.chatName}`); + + this.obs.playVideoInScene(video, this.config.defaultSceneName, this.nextVideo) + .then(timer => { + // track timer so we can cancel callback later on if necessary + this.state.videoTimer = timer; + + // update activity label and show/hide appropriately + if (video.hasOwnProperty('label') && video.label !== false) { + this.obs.showActivity(video.label); + } else { + this.obs.hideActivity(); + } + }) + .catch(console.error); + }; + + // Adds a gameplay vod to the queue + this.addVideo = video => { + return this.state.videoQueue.push(video); + }; + + // Adds a room to the queue and handles looping setup + this.addRoomVideo = (room, loop) => { + let loops = 1; + if (typeof loop === 'undefined' || loop === true) { + loops = Math.floor(this.config.roomVidPlaytime / room.videoData.length); + } + console.log(`Adding room video for ${room.dungeonName} - ${room.roomName} to the queue (${loops} loops)`); + + let video = { + filePath: `${this.config.roomVidsBasePath}${room.winPath}`, + sceneItem: (room.videoData.width === 960) ? "4x3ph" : "16x9ph", + length: room.videoData.length, + label: room.roomName, + chatName: room.roomName, + loops: loops, + requestedBy: room.requestedBy + }; + + this.state.videoQueue.push(video); + }; + + // Picks the next video in the queue (shuffles if empty) + // Also handles "commercial breaks" if enabled + this.nextVideo = () => { + // Show a "commercial break" if it's been long enough since the last one + let secondsSinceLastCommercial = (Date.now() - this.state.lastCommercialShownAt) / 1000; + if (this.config.commercialsEnabled === true && secondsSinceLastCommercial >= this.config.commercialInterval) { + console.log(`It has been ${secondsSinceLastCommercial} seconds since the last commercial break!`); + // Random chance for it to be "everybody wow" + let memeId = false; + if ((Math.floor(Math.random() * 100) + 1) <= this.config.auwChance) { + console.log(`Showing AUW!`); + memeId = 'auw'; + } + + this.showMeme(memeId) + .then(() => { + this.state.lastCommercialShownAt = Date.now(); + this.nextVideo(); + }) + .catch(console.error); + + return; + } + + // Keep track of recently played videos + if (this.state.recentlyPlayed.length === this.config.recentlyPlayedMemory) { + this.state.recentlyPlayed.shift(); + } + this.state.recentlyPlayed.push(this.state.currentVideo.id); + + // If a commercial is playing, wait until it's done to switch + while (this.state.commercialPlaying === true) {} + + // play the next video in the queue, or pick one at random if the queue is empty + if (this.state.videoQueue.length > 0) { + this.state.currentVideo = this.state.videoQueue.shift(); + } else { + // Random chance for room grind to be played for an amount of time instead of another video be shuffled to + if ((Math.floor(Math.random() * 100) + 1) <= this.config.roomGrindChance) { + console.log(`Room grind selected!`); + // show room-grind source + this.obs.showRoomGrind(this.config.roomGrindPlaytime, () => {this.nextVideo()}) + .then(timer => { + this.state.videoTimer = timer; + }) + .catch(console.error); + + return; + } + + // Random chance for room videos to be added + if ((Math.floor(Math.random() * 100) + 1) <= this.config.roomShuffleChance) { + console.log(`Room vids selected!`); + + this.addRoomVideo(this.config.rooms.sort(util.randSort).slice(0, 1).shift()); + + // play the first one + this.state.currentVideo = this.state.videoQueue.shift(); + } else { + // filter recently played from shuffle + let freshVods = this.config.vods.alttp.filter(e => { + return e.includeInShuffle === true && !this.state.recentlyPlayed.includes(e.id); + }); + this.state.currentVideo = freshVods.sort(util.randSort).slice(0, 1).shift(); + } + } + + this.showVideo(this.state.currentVideo); + }; + + // "Commercials" + this.showCommercial = (video, callback) => { + return new Promise((resolve, reject) => { + let handleFinish = () => { + this.state.commercialPlaying = false; + if (typeof callback !== 'undefined') callback(); + } + + this.obs.playVideoInScene(video, this.config.commercialSceneName, handleFinish) + .then(timer => { + this.state.commercialPlaying = true; + resolve(timer); + }) + .catch(reject); + }); + }; + + // Memes-By-Id + this.showMeme = id => { + return new Promise((resolve, reject) => { + // find the vod in memes + let video = this.config.vods.memes.find(e => e.id === id); + if (!video) { + reject(`No meme found matching ID ${id}`); + } + + let handleFinish = () => { + if (id === 'auw') { + this.obs.hide("owen", this.config.commercialSceneName); + } + resolve(); + }; + + this.showCommercial(video, handleFinish) + .then(videoHasStarted => { + // in the case of 'auw', show owen + if (id === 'auw') { + this.obs.show("owen", this.config.commercialSceneName); + } + }) + .catch(console.error); + }); + }; + + // Skip the current video and play the next + this.skip = () => { + clearTimeout(this.state.videoTimer); + this.obs.hide(this.state.currentVideo.sceneItem, this.config.defaultSceneName).then(this.nextVideo).catch(console.error); + }; + + // Clears.. the... queue + this.clearQueue = () => { + this.state.videoQueue = []; + } +} + +module.exports = FGFM; diff --git a/package-lock.json b/package-lock.json index 83cf239..504393d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,6 +106,11 @@ "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=" }, + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=" + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -406,6 +411,15 @@ "resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz", "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==" }, + "node-cache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-4.2.0.tgz", + "integrity": "sha512-obRu6/f7S024ysheAjoYFEEBqqDWv4LOMNJEuO8vMeEw2AT4z+NCzO4hlc2lhI4vATzbCQv6kke9FVdx0RbCOw==", + "requires": { + "clone": "2.1.2", + "lodash": "4.17.11" + } + }, "node-icu-charset-detector": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/node-icu-charset-detector/-/node-icu-charset-detector-0.2.0.tgz", diff --git a/package.json b/package.json index ef32809..166e1e7 100755 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "md5": "^2.2.1", "memcache": "^0.3.0", "moment": "^2.22.2", + "node-cache": "^4.2.0", "node-opus": "^0.2.9", "node-schedule": "^1.3.0", "obs-websocket-js": "^1.2.0",