533 lines
18 KiB
JavaScript
533 lines
18 KiB
JavaScript
import moment from 'moment';
|
|
import read from 'read';
|
|
import request from 'async-request';
|
|
import WebSocket from 'ws';
|
|
|
|
import Player from './Player';
|
|
import Pusher from './Pusher';
|
|
import Searcher from './Searcher';
|
|
import Scraper from './Scraper';
|
|
|
|
import { accounts } from './accounts.json';
|
|
import { LOG_TYPES } from './constants';
|
|
import Logger, { toUSD } from './helper';
|
|
|
|
const { log } = console;
|
|
|
|
import {
|
|
AUTOPLAY,
|
|
DISCRETE_MODE,
|
|
ENABLE_DISCORD,
|
|
ENABLE_GOOGLE,
|
|
ENABLE_PUSH,
|
|
MASTER_BEARER_TOKEN,
|
|
MAX_PERCENTAGE_OF_REMAINING,
|
|
MAX_TEST_PLAYERS,
|
|
MAX_TEST_QUESTIONS,
|
|
MIN_AMOUNT_THRESHOLD,
|
|
NEXT_GAME_TIME_BUFFER,
|
|
PREDICT_SAVAGE,
|
|
SAFE_WINNINGS_MODE,
|
|
SERVER_URL,
|
|
SPLIT_LAST_ROUND,
|
|
SPLIT_MODE,
|
|
SPLIT_MODES,
|
|
STAGGER_ANSWERS,
|
|
STAGGER_AT_INTERVAL,
|
|
TEST_MODE,
|
|
TEST_SERVER,
|
|
TIME_EXPIRE_BUFFER,
|
|
USE_EXTRA_LIFE_MIN_PRIZE,
|
|
} from './config';
|
|
|
|
const clearLine = () => {
|
|
process.stdout.clearLine();
|
|
process.stdout.cursorTo(0);
|
|
};
|
|
|
|
const writeLine = (msg) => process.stdout.write(msg);
|
|
|
|
export default class Trivia {
|
|
constructor() {
|
|
this.game = null;
|
|
this.socket = null;
|
|
this.players = [];
|
|
this.masterPingTimeout = null;
|
|
this.masterPingInterval = null;
|
|
this.logger = null;
|
|
this.scraper = null;
|
|
this.pusher = null;
|
|
this.searcher = null;
|
|
}
|
|
|
|
async run() {
|
|
log('Checking for game schedule...');
|
|
await this.getGame();
|
|
if (this.game.active) {
|
|
const testFlag = TEST_MODE ? '-TEST' : '';
|
|
const filename = moment(this.game.startTime).format('YYYY-MM-DD-HHmmss');
|
|
this.logger = new Logger(`game-${filename}${testFlag}.txt`);
|
|
await this.logger.open();
|
|
this.logger.log(`Game live at: ${this.game.broadcast.socketUrl}`);
|
|
|
|
this.players = accounts.map(account => new Player(account));
|
|
//this.players = this.players.slice(Math.max(this.players.length - 124, 0));
|
|
this.players = this.players.slice(0, 124);
|
|
this.players.forEach(player => player.logger = this.logger);
|
|
|
|
if (TEST_MODE) {
|
|
this.players = this.players.slice(0, MAX_TEST_PLAYERS);
|
|
}
|
|
|
|
this.connect();
|
|
} else {
|
|
log('Game is not live!');
|
|
this.scheduleGame(this.game.nextShowTime);
|
|
}
|
|
}
|
|
|
|
scheduleGame(nextShowTime) {
|
|
const now = new Date().getTime();
|
|
let timeDeltaMS = new Date(nextShowTime).getTime() - now;
|
|
|
|
const countdown = setInterval(() => {
|
|
timeDeltaMS -= 1000;
|
|
clearLine();
|
|
this.showTimeRemaining(timeDeltaMS)
|
|
}, 1000);
|
|
|
|
setTimeout(() => {
|
|
clearInterval(countdown);
|
|
clearLine();
|
|
this.run();
|
|
}, timeDeltaMS);
|
|
}
|
|
|
|
showTimeRemaining(timeDeltaMS) {
|
|
const nextShowTime = moment.duration(timeDeltaMS);
|
|
const hours = nextShowTime.hours();
|
|
const minutes = nextShowTime.minutes();
|
|
const seconds = nextShowTime.seconds();
|
|
|
|
writeLine(`Next game is in ${hours} hours, ${minutes} minutes, and ${seconds} seconds for ${this.game.nextShowPrize}!`);
|
|
}
|
|
|
|
ping() {
|
|
if (!this.game.active) return;
|
|
if (!this.socket) return;
|
|
try {
|
|
this.socket.ping();
|
|
this.masterPingTimeout = setTimeout(() => {
|
|
this.socket.terminate();
|
|
this.logger.log('Master connection closed. Attempting to reconnect...', LOG_TYPES.ERROR);
|
|
this.connect();
|
|
}, 2000);
|
|
} catch (error) {
|
|
this.logger.log('Master connection closed. Attempting to reconnect...', LOG_TYPES.ERROR);
|
|
}
|
|
}
|
|
|
|
pong() {
|
|
clearTimeout(this.masterPingTimeout);
|
|
}
|
|
|
|
async getGame() {
|
|
if (TEST_MODE) {
|
|
log('Test mode is enabled. Using test server...');
|
|
|
|
const getRandomId = () => Math.floor(10000 + Math.random() * 90000);
|
|
this.game = {
|
|
active: true,
|
|
broadcast: {
|
|
broadcastId: getRandomId(),
|
|
socketUrl: TEST_SERVER,
|
|
},
|
|
prize: 0,
|
|
prizePerPlayer: 0,
|
|
startTime: new Date().toJSON().toString(),
|
|
};
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await request(`https://${SERVER_URL}/shows/now`, {
|
|
headers: {
|
|
Authorization: `Bearer ${MASTER_BEARER_TOKEN}`,
|
|
},
|
|
});
|
|
this.game = JSON.parse(response.body);
|
|
} catch (error) {
|
|
log(`There was an error getting the game: ${error}`);
|
|
}
|
|
}
|
|
|
|
async connect() {
|
|
if (!this.game.active) return;
|
|
|
|
const { broadcast } = this.game;
|
|
const { socketUrl } = broadcast;
|
|
|
|
const options = {
|
|
headers: {
|
|
Authorization: `Bearer ${MASTER_BEARER_TOKEN}`,
|
|
},
|
|
};
|
|
this.socket = new WebSocket(socketUrl, options);
|
|
this.socket.on('error', err => this.logger.log(`An error occurred: ${err}`, LOG_TYPES.ERROR));
|
|
this.socket.on('pong', this.pong.bind(this));
|
|
this.socket.on('open', this.handleOpen.bind(this));
|
|
this.socket.on('close', this.handleClose.bind(this));
|
|
this.socket.on('message', this.handleMessage.bind(this));
|
|
|
|
if (ENABLE_DISCORD) {
|
|
this.scraper = new Scraper(this.game, this.logger);
|
|
this.scraper.connect();
|
|
}
|
|
|
|
if (ENABLE_PUSH) {
|
|
this.pusher = new Pusher(this.logger);
|
|
this.pusher.connect();
|
|
this.pusher.send('HQ Game Started', `${this.players.length} of your players are playing for ${toUSD(this.game.prize)} tonight!`);
|
|
this.pusher.sendAll('HQ Game Started', `Tonight's game is for ${toUSD(this.game.prize)}!`);
|
|
|
|
if (ENABLE_DISCORD) {
|
|
this.scraper.pusher = this.pusher;
|
|
}
|
|
}
|
|
|
|
if (ENABLE_GOOGLE) {
|
|
this.searcher = new Searcher(this.logger);
|
|
|
|
if (ENABLE_DISCORD) {
|
|
this.scraper.searcher = this.searcher;
|
|
}
|
|
}
|
|
}
|
|
|
|
async handleOpen() {
|
|
const { broadcast } = this.game;
|
|
this.masterPingInterval = setInterval(this.ping.bind(this), 10000);
|
|
this.logger.log('Master connected to websocket!', LOG_TYPES.SUCCESS);
|
|
await this.players.forEach(player => player.connect(this, broadcast));
|
|
await this.play();
|
|
}
|
|
|
|
handleClose() {
|
|
this.socket = null;
|
|
clearInterval(this.masterPingInterval);
|
|
clearTimeout(this.masterPingTimeout);
|
|
if (this.scraper) {
|
|
this.scraper.close();
|
|
}
|
|
this.logger.log('\nGame is finished!\n');
|
|
|
|
if (AUTOPLAY) {
|
|
const waitTime = moment.duration(NEXT_GAME_TIME_BUFFER).humanize();
|
|
this.logger.log(`Waiting ${waitTime} before checking for next game!`);
|
|
setTimeout(this.run.bind(this), NEXT_GAME_TIME_BUFFER);
|
|
} else {
|
|
this.logger.log('Thanks for playing!');
|
|
}
|
|
this.logger.close();
|
|
}
|
|
|
|
async handleMessage(data) {
|
|
const parsed = JSON.parse(data);
|
|
|
|
switch (parsed.type) {
|
|
case 'broadcastStats':
|
|
this.logger.logSilent(`Broadcast status: ${data}`);
|
|
break;
|
|
case 'gameStatus':
|
|
this.game.questionCount = TEST_MODE ? MAX_TEST_QUESTIONS : parsed.questionCount;
|
|
this.game.savageCount = 0;
|
|
this.logger.logSilent(`Game Status: ${data}`);
|
|
break;
|
|
case 'question':
|
|
this.game.questionNumber = parsed.questionNumber;
|
|
this.showQuestion(parsed);
|
|
if (this.scraper) {
|
|
this.scraper.startQuestion(parsed);
|
|
}
|
|
this.logger.logSilent(`Question: ${data}`);
|
|
break;
|
|
case 'questionFinished':
|
|
this.logger.logSilent(`Question Finished: ${data}`);
|
|
if (this.game.questionNumber >= this.game.questionCount) {
|
|
this.endGame();
|
|
}
|
|
break;
|
|
case 'questionClosed':
|
|
this.logger.logSilent(`Question Closed: ${data}`);
|
|
if (this.scraper) {
|
|
this.scraper.endQuestion(parsed);
|
|
}
|
|
break;
|
|
case 'questionSummary':
|
|
this.checkAnswers(parsed);
|
|
this.logger.logSilent(`Question Summary: ${data}`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
async showQuestion({ answers, category, timeLeftMs, question, questionId, questionNumber }) {
|
|
this.logger.log(`\n${'='.repeat(50)}\n`);
|
|
this.logger.log(`Question ${questionNumber} of ${this.game.questionCount}:`);
|
|
this.logger.log(`\n${question}`);
|
|
this.logger.log(`\n1) ${answers[0].text}`);
|
|
this.logger.log(`2) ${answers[1].text}`);
|
|
this.logger.log(`3) ${answers[2].text}\n`);
|
|
|
|
if (this.players.length > 0) {
|
|
let players = this.players.slice(0);
|
|
if (PREDICT_SAVAGE) {
|
|
let possiblySavage = false;
|
|
/*if (this.game.savageCount === 0 && [4,5].includes(questionNumber)) {
|
|
possiblySavage = true;
|
|
}*/
|
|
if ([0,1].includes(this.game.savageCount) && [8,9,10].includes(questionNumber)) {
|
|
possiblySavage = true;
|
|
}
|
|
if (possiblySavage) {
|
|
const savageQuotient = Math.ceil((this.game.questionCount + 1 - questionNumber) / 12 * 10);
|
|
const reservePlayerNumber = Math.floor(players.length / savageQuotient);
|
|
this.logger.log(`Possible Savage Question: reserving ${reservePlayerNumber} players to split.`);
|
|
const reservePlayers = players.splice(0, reservePlayerNumber);
|
|
setTimeout(() => {
|
|
this.sendAnswers.call(this, questionId, answers, [1, 2, 3], reservePlayers);
|
|
}, timeLeftMs - TIME_EXPIRE_BUFFER);
|
|
}
|
|
}
|
|
|
|
if (STAGGER_ANSWERS) {
|
|
this.logger.logSilent('Stagger Answers is enabled. No manual input allowed.');
|
|
|
|
let ticker = 1;
|
|
let remainingPlayers = players.slice(0);
|
|
const timeLeftSeconds = timeLeftMs / 1000;
|
|
const staggerTime = timeLeftSeconds - STAGGER_AT_INTERVAL - (TIME_EXPIRE_BUFFER / 1000);
|
|
const staggerPlayersAmount = Math.floor(remainingPlayers.length / staggerTime);
|
|
this.logger.logSilent(`Stagger Variables: "${timeLeftMs} time left MS" "${staggerTime} stagger time" "${staggerPlayersAmount} stagger players amount" "${remainingPlayers.length} remaining players"`);
|
|
|
|
const staggerInterval = setInterval(() => {
|
|
ticker++;
|
|
this.logger.logSilent(`Ticker ${ticker} - Stagger At Interval ${STAGGER_AT_INTERVAL}`);
|
|
if (ticker >= STAGGER_AT_INTERVAL) {
|
|
if (remainingPlayers.length > staggerPlayersAmount) {
|
|
const playersToSend = remainingPlayers.splice(0, staggerPlayersAmount);
|
|
this.logger.logSilent(`${playersToSend.length} playersToSend`);
|
|
this.autoSend.call(this, questionId, answers, playersToSend);
|
|
}
|
|
}
|
|
}, 1000);
|
|
const stopSending = setTimeout(() => {
|
|
clearInterval(staggerInterval);
|
|
this.logger.logSilent(`Time expired. Sending remaining staggered players ${remainingPlayers.length}!`);
|
|
this.autoSend.call(this, questionId, answers, remainingPlayers);
|
|
}, timeLeftMs - TIME_EXPIRE_BUFFER);
|
|
} else {
|
|
let ticker = 1;
|
|
const staggerInterval = setInterval(() => {
|
|
ticker++;
|
|
}, 1000);
|
|
let previousChoices = null;
|
|
read({
|
|
prompt: 'My Answer Choice:',
|
|
default: '1,2,3',
|
|
input: process.stdin,
|
|
output: process.stdout,
|
|
timeout: timeLeftMs - TIME_EXPIRE_BUFFER,
|
|
}, (error, result) => {
|
|
if (error) {
|
|
process.stdout.write('\n');
|
|
this.logger.log('Time buffer expired. Auto-sending answers!');
|
|
this.autoSend(questionId, answers, players);
|
|
return;
|
|
}
|
|
|
|
previousChoices = result;
|
|
read({
|
|
prompt: 'Confirm Choice:',
|
|
default: previousChoices || '1,2,3',
|
|
input: process.stdin,
|
|
output: process.stdout,
|
|
timeout: timeLeftMs - (ticker * 1000) - TIME_EXPIRE_BUFFER,
|
|
}, (error, result) => {
|
|
if (error) {
|
|
process.stdout.write('\n');
|
|
this.logger.log('Time buffer expired. Auto-sending answers!');
|
|
this.autoSend(questionId, answers, players);
|
|
return;
|
|
}
|
|
|
|
let choices = result.trim().split(/,| |;/);
|
|
if (!choices && previousChoices) {
|
|
choices = previousChoices.trim().split(/,| |;/);
|
|
}
|
|
|
|
switch (choices.length) {
|
|
case 1:
|
|
this.sendAnswers(questionId, answers, [choices[0]], players);
|
|
break;
|
|
case 2:
|
|
this.sendAnswers(questionId, answers, [choices[0], choices[1]], players);
|
|
break;
|
|
case 3:
|
|
this.sendAnswers(questionId, answers, [choices[0], choices[1], choices[2]], players);
|
|
break;
|
|
default:
|
|
this.logger.log('You sent too many answer choices!', LOG_TYPES.ERROR);
|
|
this.autoSend(questionId, answers, players);
|
|
break;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
checkAnswers(questionSummary) {
|
|
const { answerCounts, advancingPlayersCount, eliminatedPlayersCount, id: questionId } = questionSummary;
|
|
const answerIndex = answerCounts.findIndex(answer => answer.correct);
|
|
const correctAnswer = answerCounts[answerIndex];
|
|
const percentAdvancing = Math.round(advancingPlayersCount / (advancingPlayersCount + eliminatedPlayersCount) * 100);
|
|
this.game.currentPlayersRemaining = advancingPlayersCount;
|
|
|
|
if (advancingPlayersCount > 0) {
|
|
this.game.prizePerPlayer = (this.game.prize / advancingPlayersCount);
|
|
}
|
|
|
|
if (percentAdvancing <= 33) {
|
|
this.game.savageCount++;
|
|
}
|
|
|
|
if (this.scraper) {
|
|
this.scraper.showSummary(questionSummary, answerIndex);
|
|
}
|
|
|
|
const answerText = correctAnswer.text || correctAnswer.answer || 'undefined';
|
|
this.logger.log(`The correct answer was ${answerIndex + 1}) ${answerText}!`, LOG_TYPES.INFO);
|
|
|
|
const wrongPlayers = [];
|
|
this.players.forEach(player => {
|
|
if (player.lastAnswerId !== correctAnswer.answerId) {
|
|
const potentialPot = this.game.prizePerPlayer * this.players.length;
|
|
if (player.hasExtraLife && potentialPot >= USE_EXTRA_LIFE_MIN_PRIZE && this.game.questionNumber < this.game.questionCount) {
|
|
player.sendExtraLife(questionId);
|
|
} else {
|
|
wrongPlayers.push(player.userId);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (wrongPlayers.length > 0) {
|
|
if (wrongPlayers.length === this.players.length) {
|
|
this.logger.log(`All remaining ${wrongPlayers.length} players have been eliminated!`, LOG_TYPES.ERROR);
|
|
} else {
|
|
this.logger.log(`${wrongPlayers.length} players have been eliminated!`, LOG_TYPES.WARNING);
|
|
}
|
|
} else if (this.players.length > 0) {
|
|
this.logger.log(`No players have been eliminated!`, LOG_TYPES.SUCCESS);
|
|
}
|
|
|
|
wrongPlayers.forEach(userId => this.removePlayer(userId));
|
|
this.logger.log(`You have ${this.players.length} players still in the game.`);
|
|
|
|
if (advancingPlayersCount > 0) {
|
|
const prizePerPlayer = toUSD(this.game.prizePerPlayer);
|
|
const total = toUSD(this.game.prizePerPlayer * this.players.length);
|
|
if (this.game.questionNumber >= this.game.questionCount) {
|
|
if (this.game.prizePerPlayer * this.players.length > 0) {
|
|
this.logger.log(`\nYou won ${total} (${prizePerPlayer} per player)`, LOG_TYPES.SUCCESS);
|
|
} else {
|
|
this.logger.log(`\nNo winnings this time! Total prize was ${total} (${prizePerPlayer} per player)`)
|
|
}
|
|
} else {
|
|
this.logger.log(`\nPotential Winnings: ${total} (${prizePerPlayer} per player remaining)`);
|
|
}
|
|
|
|
if (this.pusher && this.game.questionNumber >= this.game.questionCount) {
|
|
if (this.players.length > 0) {
|
|
this.pusher.send('HQ Game Summary', `${this.players.length} of your players won ${prizePerPlayer} for a total of ${total}!`);
|
|
} else {
|
|
this.pusher.send('HQ Game Summary', 'All of your players were eliminated! No winnings this time.');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
autoSend(questionId, answers, players = this.players) {
|
|
const prizePerPlayer = this.game.prizePerPlayer || 0;
|
|
const potentialPot = prizePerPlayer * this.players.length;
|
|
let splitMode = SPLIT_MODE;
|
|
|
|
if (SAFE_WINNINGS_MODE && potentialPot > MIN_AMOUNT_THRESHOLD) {
|
|
splitMode = SPLIT_MODES.EVENLY;
|
|
this.logger.logSilent(`Safe Winnings Mode activated because ${potentialPot} is greater than ${MIN_AMOUNT_THRESHOLD}.`);
|
|
}
|
|
|
|
if (DISCRETE_MODE) {
|
|
if (this.game.currentPlayersRemaining && this.game.currentPlayersRemaining > 0) {
|
|
const percentRemaining = Math.floor(this.players.length / this.game.currentPlayersRemaining * 100);
|
|
if (percentRemaining > MAX_PERCENTAGE_OF_REMAINING) {
|
|
splitMode = SPLIT_MODES.EVENLY;
|
|
this.logger.logSilent(`Discrete Mode activated because ${percentRemaining}% is greater than ${MAX_PERCENTAGE_OF_REMAINING}%.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (SPLIT_LAST_ROUND && this.game.questionNumber === this.game.questionCount) {
|
|
splitMode = SPLIT_MODES.EVENLY;
|
|
this.logger.logSilent(`Split Last Round enabled... splitting last answers evenly.`);
|
|
}
|
|
|
|
switch (splitMode) {
|
|
case SPLIT_MODES.WEIGHTS:
|
|
case SPLIT_MODES.GOOGLE:
|
|
case SPLIT_MODES.SUGGESTION:
|
|
if (this.scraper) {
|
|
const suggestionArray = this.scraper.getSuggestionArray();
|
|
const suggestion = suggestionArray.reduce((acc, suggestion, index) => {
|
|
if (suggestion) {
|
|
acc.push(index + 1);
|
|
}
|
|
return acc;
|
|
}, []);
|
|
this.sendAnswers(questionId, answers, suggestion, players);
|
|
break;
|
|
}
|
|
case SPLIT_MODES.EVENLY:
|
|
default:
|
|
players.forEach((player, index) => {
|
|
const choice = (index % 3) + 1;
|
|
player.sendAnswer(questionId, answers[choice - 1], choice);
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
|
|
sendAnswers(questionId, answers, choices, players = this.players) {
|
|
players.forEach((player, index) => {
|
|
const choice = choices[index % choices.length];
|
|
player.sendAnswer(questionId, answers[choice - 1], choice);
|
|
});
|
|
}
|
|
|
|
removePlayer(userId) {
|
|
const player = this.players.find(player => player.userId === userId);
|
|
if (player) {
|
|
player.removePlayer();
|
|
}
|
|
|
|
this.players = this.players.filter(player => player.userId !== userId);
|
|
}
|
|
|
|
endGame() {
|
|
this.socket.close();
|
|
}
|
|
|
|
async play() {
|
|
this.logger.log('Ready to play!');
|
|
}
|
|
}
|