/** * Scavengers Games Plugin * Pokemon Showdown - http://pokemonshowdown.com/ * * This plugin stores the different possible game modes and twists that take place in scavengers room * * @license MIT license */ import {ScavengerHunt, ScavengerHuntPlayer} from './scavengers'; import {Utils} from '../../lib'; export type TwistEvent = (this: ScavengerHunt, ...args: any[]) => void; interface Twist { name: string; id: string; isGameMode?: true; desc?: string; [eventid: string]: string | number | TwistEvent | boolean | undefined; } type GameModeFunction = (this: ScavengerGameTemplate, ...args: any[]) => void; interface GameMode { name: string; id: string; mod: Twist; round?: number; leaderboard?: true; teamAnnounce?: GameModeFunction; getPlayerTeam?: GameModeFunction; advanceTeam?: GameModeFunction; [k: string]: any; } function toSeconds(time: string) { // hhmmss => ss const parts = time.split(':').reverse(); return parts.map((value, index) => parseInt(value) * Math.pow(60, index)).reduce((a, b) => a + b); } class Leaderboard { data: {[userid: string]: AnyObject}; constructor() { this.data = {}; } addPoints(name: string, aspect: string, points: number, noUpdate?: boolean) { const userid: string = toID(name); if (!userid || userid === 'constructor' || !points) return this; if (!this.data[userid]) this.data[userid] = {name: name}; if (!this.data[userid][aspect]) this.data[userid][aspect] = 0; this.data[userid][aspect] += points; if (!noUpdate) this.data[userid].name = name; // always keep the last used name return this; // allow chaining } visualize(sortBy: string): Promise<({rank: number} & AnyObject)[]>; visualize(sortBy: string, userid: string): Promise<({rank: number} & AnyObject) | undefined>; visualize(sortBy: string, userid?: string) { // FIXME: this is not how promises work // return a promise for async sorting - make this less exploitable return new Promise((resolve, reject) => { let lowestScore = Infinity; let lastPlacement = 1; const ladder = Utils.sortBy( Object.entries(this.data).filter(([u, bit]) => sortBy in bit), ([u, bit]) => -bit[sortBy] ).map(([u, bit], i) => { if (bit[sortBy] !== lowestScore) { lowestScore = bit[sortBy]; lastPlacement = i + 1; } return { rank: lastPlacement, ...bit, } as {rank: number} & AnyObject; }); // identify ties if (userid) { const rank = ladder.find(entry => toID(entry.name) === userid); resolve(rank); } else { resolve(ladder); } }); } async htmlLadder(): Promise { const data = await this.visualize('points'); const display = `
${data.map(line => ``).join('') }
RankNamePoints
${line.rank}${line.name}${line.points}
`; return display; } } const TWISTS: {[k: string]: Twist} = { perfectscore: { name: 'Perfect Score', id: 'perfectscore', desc: "Players who finish the hunt without submitting a single wrong answer get a shoutout!", onLeave(player) { if (!this.leftGame) this.leftGame = []; this.leftGame.push(player.id); }, onSubmitPriority: 1, onSubmit(player, value) { const currentQuestion = player.currentQuestion; if (!player.answers) player.answers = {}; if (!player.answers[currentQuestion]) player.answers[currentQuestion] = []; if (player.answers[currentQuestion].includes(value)) return; player.answers[currentQuestion].push(value); }, onComplete(player, time, blitz) { const isPerfect = !this.leftGame?.includes(player.id) && Object.values(player.answers).every((attempts: any) => attempts.length <= 1); return {name: player.name, time, blitz, isPerfect}; }, onAfterEndPriority: 1, onAfterEnd() { const perfect = this.completed.filter(entry => entry.isPerfect).map(entry => entry.name); if (perfect.length) { this.announce(Utils.html`${Chat.toListString(perfect)} ${perfect.length > 1 ? 'have' : 'has'} completed the hunt without a single wrong answer!`); } }, }, bonusround: { name: 'Bonus Round', id: 'bonusround', desc: "Players can choose whether or not they choose to complete the 4th question.", onAfterLoad() { if (this.questions.length === 3) { this.announce('This twist requires at least four questions. Please reset the hunt and make it again.'); this.huntLocked = true; } else { this.questions[this.questions.length - 1].hint += ' (You may choose to skip this question using ``/scavenge skip``.)'; } }, onAnySubmit(player) { if (this.huntLocked) { player.sendRoom('The hunt was not set up correctly. Please wait for the host to reset the hunt and create a new one.'); return true; } }, onSubmitPriority: 1, onSubmit(player, value) { const currentQuestion = player.currentQuestion; if (value === 'skip' && currentQuestion + 1 === this.questions.length) { player.sendRoom('You have opted to skip the current question.'); player.skippedQuestion = true; this.onComplete(player); return true; } }, onComplete(player, time, blitz) { const noSkip = !player.skippedQuestion; return {name: player.name, time, blitz, noSkip}; }, onAfterEndPriority: 1, onAfterEnd() { const noSkip = this.completed.filter(entry => entry.noSkip).map(entry => entry.name); if (noSkip.length) { this.announce(Utils.html`${Chat.toListString(noSkip)} ${noSkip.length > 1 ? 'have' : 'has'} completed the hunt without skipping the last question!`); } }, }, incognito: { name: 'Incognito', id: 'incognito', desc: "Upon answering the last question correctly, the player's finishing time will not be announced in the room! Results will only be known at the end of the hunt.", onCorrectAnswer(player, value) { if (player.currentQuestion + 1 >= this.questions.length) { this.runEvent('PreComplete', player); player.sendRoom(`Congratulations! You have gotten the correct answer.`); player.sendRoom(`This is a special style where finishes aren't announced! To see your placement, wait for the hunt to end. Until then, it's your secret that you finished!`); return false; } }, onPreComplete(player) { const now = Date.now(); const time = Chat.toDurationString(now - this.startTime, {hhmmss: true}); const canBlitz = this.completed.length < 3; const blitz = now - this.startTime <= 60000 && canBlitz && (this.room.settings.scavSettings?.blitzPoints?.[this.gameType] || this.gameType === 'official'); const result = this.runEvent('Complete', player, time, blitz) || {name: player.name, time, blitz}; this.preCompleted = this.preCompleted ? [...this.preCompleted, result] : [result]; player.completed = true; player.destroy(); }, onEnd() { this.completed = this.preCompleted || []; }, }, spamfilter: { name: 'Spam Filter', id: 'spamfilter', desc: 'Every wrong answer adds 30 seconds to your final time!', onIncorrectAnswer(player: ScavengerHuntPlayer, value: string) { if (!player.incorrect) player.incorrect = []; const id = `${player.currentQuestion}-${value}`; if (player.incorrect.includes(id)) return; player.incorrect.push(id); }, onComplete(player, time, blitz) { const seconds = toSeconds(time); if (!player.incorrect) return {name: player.name, total: seconds, blitz, time, original_time: time}; const total = seconds + (30 * player.incorrect.length); const finalTime = Chat.toDurationString(total * 1000, {hhmmss: true}); if (total > 60) blitz = false; return {name: player.name, total, blitz, time: finalTime, original_time: time}; }, onConfirmCompletion(player, time, blitz, place, result) { blitz = result.blitz; time = result.time; const deductionMessage = player.incorrect?.length ? Chat.count(player.incorrect, 'incorrect guesses', 'incorrect guess') : "Perfect!"; return `${Utils.escapeHTML(player.name)} has finished the hunt! (Final Time: ${time} - ${deductionMessage}${(blitz ? " - BLITZ" : "")})`; }, onEnd() { Utils.sortBy(this.completed, entry => entry.total); }, }, blindincognito: { name: 'Blind Incognito', id: 'blindincognito', desc: "Upon completing the last question, neither you nor other players will know if the last question is correct! You may be in for a nasty surprise when the hunt ends!", onAnySubmit(player, value) { if (player.precompleted) { player.sendRoom(`That may or may not be the right answer - if you aren't confident, you can try again!`); return true; } }, onCorrectAnswer(player, value) { if (player.currentQuestion + 1 >= this.questions.length) { this.runEvent('PreComplete', player); player.sendRoom(`That may or may not be the right answer - if you aren't confident, you can try again!`); return true; } }, onIncorrectAnswer(player, value) { if (player.currentQuestion + 1 >= this.questions.length) { player.sendRoom(`That may or may not be the right answer - if you aren't confident, you can try again!`); return false; } }, onPreComplete(player) { const now = Date.now(); const time = Chat.toDurationString(now - this.startTime, {hhmmss: true}); const canBlitz = this.completed.length < 3; const blitz = now - this.startTime <= 60000 && canBlitz && (this.room.settings.scavSettings?.blitzPoints?.[this.gameType] || this.gameType === 'official'); const result = this.runEvent('Complete', player, time, blitz) || {name: player.name, time, blitz}; this.preCompleted = this.preCompleted ? [...this.preCompleted, result] : [result]; player.precompleted = true; }, onEnd() { this.completed = this.preCompleted || []; }, }, }; const MODES: {[k: string]: GameMode | string} = { ko: 'kogames', kogames: { name: 'KO Games', id: 'kogames', mod: { name: 'KO Games', id: 'KO Games', isGameMode: true, onLoad() { this.allowRenames = false; // don't let people change their name in the middle of the hunt. }, onJoin(user: User) { const game = this.room.scavgame!; if (game.playerlist && !game.playerlist.includes(user.id)) { user.sendTo(this.room, 'You are not allowed to join this scavenger hunt.'); return true; } }, onAfterEnd() { const game = this.room.scavgame!; if (!this.completed.length) { this.announce('No one has completed the hunt - the round has been void.'); return; } game.round++; // elimination if (!game.playerlist) { game.playerlist = this.completed.map(entry => toID(entry.name)); this.announce(`Round ${game.round} - ${Chat.toListString(this.completed.map(p => `${p.name}`))} have successfully completed the last hunt and have moved on to the next round!`); } else { let eliminated = []; const completed = this.completed.map(entry => toID(entry.name)) as string[]; if (completed.length === game.playerlist.length) { eliminated.push(completed.pop()); // eliminate one game.playerlist = game.playerlist.filter(userid => completed.includes(userid)); } else { eliminated = game.playerlist.filter(userid => !completed.includes(userid)); for (const username of eliminated) { const userid = toID(username); game.playerlist = game.playerlist.filter(pid => pid !== userid); } } this.announce(`Round ${game.round} - ${Chat.toListString(eliminated.map(n => `${n}`))} ${Chat.plural(eliminated, 'have', 'has')} been eliminated! ${Chat.toListString(game.playerlist.map(p => `${this.playerTable[p].name}`))} have successfully completed the last hunt and have moved on to the next round!`); } // process end of game if (game.playerlist.length === 1) { const winningUser = Users.get(game.playerlist[0]); const winner = winningUser ? winningUser.name : game.playerlist[0]; this.announce(`Congratulations to the winner - ${winner}!`); game.destroy(); } else if (!game.playerlist.length) { this.announce('Everyone has been eliminated! Better luck next time!'); game.destroy(); } }, }, round: 0, playerlist: null, }, scav: 'scavengergames', scavgames: 'scavengergames', scavengergames: { name: 'Scavenger Games', id: 'scavengergames', mod: { name: 'Scavenger Games', id: 'scavengergames', isGameMode: true, onLoad() { this.allowRenames = false; // don't let people change their name in the middle of the hunt. this.setTimer(1); }, onJoin(user) { const game = this.room.scavgame!; if (game.playerlist && !game.playerlist.includes(user.id)) { user.sendTo(this.room, 'You are not allowed to join this scavenger hunt.'); return true; } }, onAfterEnd() { const game = this.room.scavgame!; if (!this.completed.length) { this.announce('No one has completed the hunt - the round has been void.'); return; } game.round++; let eliminated: string[] = []; if (!game.playerlist) { game.playerlist = this.completed.map(entry => toID(entry.name) as string); } else { const completed = this.completed.map(entry => toID(entry.name) as string); eliminated = game.playerlist.filter(userid => !completed.includes(userid)); for (const username of eliminated) { const userid = toID(username); game.playerlist = game.playerlist.filter(pid => pid !== userid); } } this.announce(`Round ${game.round} - ${Chat.toListString(eliminated.map(n => `${n}`))} ${eliminated.length ? `${Chat.plural(eliminated, 'have', 'has')} been eliminated!` : ''} ${Chat.toListString(game.playerlist.map(p => `${this.playerTable[p].name}`))} have successfully completed the last hunt and have moved on to the next round!`); // process end of game if (game.playerlist.length === 1) { const winningUser = Users.get(game.playerlist[0]); const winner = winningUser ? winningUser.name : game.playerlist[0]; this.announce(`Congratulations to the winner - ${winner}!`); game.destroy(); } else if (!game.playerlist.length) { this.announce('Everyone has been eliminated! Better luck next time!'); game.destroy(); } }, }, round: 0, playerlist: null, }, pr: 'pointrally', pointrally: { name: 'Point Rally', id: 'pointrally', pointDistribution: [50, 40, 32, 25, 20, 15, 10], mod: { name: 'Point Rally', id: 'pointrally', isGameMode: true, onLoad() { const game = this.room.scavgame!; this.announce(`Round ${++game.round}`); }, async onAfterEnd() { const game = this.room.scavgame!; for (const [i, completed] of this.completed.map(e => e.name).entries()) { const points = (game.pointDistribution[i] || game.pointDistribution[game.pointDistribution.length - 1]); game.leaderboard.addPoints(completed, 'points', points); } // post leaderboard const room = this.room; const html = await game.leaderboard.htmlLadder() as string; room.add(`|raw|${html}`).update(); }, }, round: 0, leaderboard: true, }, js: 'jumpstart', jump: 'jumpstart', jumpstart: { name: 'Jump Start', id: 'jumpstart', jumpstart: [60, 40, 30, 20, 10], round: 0, mod: { name: 'Jump Start', id: 'jumpstart', isGameMode: true, onLoad() { const game = this.room.scavgame!; if (game.round === 0) return; const maxTime = Math.max(...game.jumpstart); this.jumpstartTimers = []; this.answerLock = true; for (const [i, time] of game.jumpstart.entries()) { if (!game.completed[i]) break; this.jumpstartTimers[i] = setTimeout(() => { const target = game.completed.shift(); if (!target) return; const staffHost = Users.get(this.staffHostId); const targetUser = Users.get(target); if (targetUser) { if (staffHost) staffHost.sendTo(this.room, `${targetUser.name} has received their first hint early.`); targetUser.sendTo( this.room, `|raw|The first hint to the next hunt is: ${Chat.formatText(this.questions[0].hint)}` ); targetUser.sendTo( this.room, `|notify|Early Hint|The first hint to the next hunt is: ${Chat.formatText(this.questions[0].hint)}` ); } }, (maxTime - time) * 1000 + 5000); } // when the jump starts are all given to eligible players this.jumpstartTimers[this.jumpstartTimers.length] = setTimeout(() => { this.answerLock = false; const message = this.getCreationMessage(true); this.room.add(message).update(); this.announce('You may start guessing!'); this.startTime = Date.now(); }, 1000 * (maxTime + 5)); }, onJoin(user) { if (this.answerLock) { user.sendTo(this.room, `The hunt is not open for guesses yet!`); return true; } }, onViewHunt(user) { if (this.answerLock && !(this.hosts.some(h => h.id === user.id) || user.id === this.staffHostId)) { return true; } }, onCreateCallback() { if (this.answerLock) { return `|raw|
${['official', 'unrated'].includes(this.gameType) ? 'An' : 'A'} ${this.gameType} ` + `Scavenger Hunt by ${Utils.escapeHTML(Chat.toListString(this.hosts.map(h => h.name)))} ` + `has been started${(this.hosts.some(h => h.id === this.staffHostId) ? '' : ` by ${Utils.escapeHTML(this.staffHostName)}`)}.` + `
The first hint is currently being handed out to early finishers.`; } }, onEnd(reset) { if (this.jumpstartTimers) { for (const timer of this.jumpstartTimers) { clearTimeout(timer); } } const game = this.room.scavgame!; if (!reset) { if (game.round === 0) { game.completed = this.completed.map(entry => toID(entry.name)); game.round++; } else { game.destroy(); } } }, }, }, ts: 'teamscavs', tscav: 'teamscavs', teamscavengers: 'teamscavs', teamscavs: { name: 'Team Scavs', id: 'teamscavs', /* { [team name: string]: { name: string[], players: UserID[], answers: string[], question: number, completed: boolean } } */ teams: {}, teamAnnounce(player: ScavengerHuntPlayer | User, message: string) { const team = this.getPlayerTeam(player); for (const userid of team.players) { const user = Users.getExact(userid); if (!user?.connected) continue; // user is offline user.sendTo(this.room, `|raw|
${message}
`); } }, getPlayerTeam(player: ScavengerHuntPlayer | User) { const game = this.room.scavgame!; for (const teamID in game.teams) { const team = game.teams[teamID]; if (team.players.includes(player.id)) { return team; } } return null; }, advanceTeam(answerer: ScavengerHuntPlayer, isFinished?: boolean) { const hunt = this.room.getGame(ScavengerHunt)!; const team = this.getPlayerTeam(answerer); team.question++; const question = hunt.getQuestion(team.question); for (const userid of team.players) { const user = Users.getExact(userid); if (!user) continue; user.sendTo(this.room, question); } team.answers = []; }, mod: { name: 'Team Scavs', id: 'teamscavs', isGameMode: true, onLoad() { // don't let people change their name in the middle of the hunt. this.allowRenames = false; }, onViewHunt(user) { const game = this.room.scavgame!; const team = game.getPlayerTeam(user); const player = this.playerTable[user.id]; if (!player || !team) return; if (player.currentQuestion !== team.question - 1) player.currentQuestion = team.question - 1; if (team.completed) player.completed = true; }, onSendQuestion(player) { const game = this.room.scavgame!; const team = game.getPlayerTeam(player); if (!team) return; if (player.currentQuestion !== team.question - 1) player.currentQuestion = team.question - 1; if (team.completed) { player.completed = true; player.sendRoom('Your team has already completed the hunt!'); return true; } }, onConnect(user) { const player = this.playerTable[user.id]; if (!player) return; const game = this.room.scavgame!; const team = game.getPlayerTeam(player); if (team) { if (player.currentQuestion !== team.question - 1) player.currentQuestion = team.question - 1; if (team.completed) player.completed = true; } }, onJoin(user) { const game = this.room.scavgame!; const team = game.getPlayerTeam(user); if (!team) { user.sendTo(this.room, 'You are not allowed to join this scavenger hunt as you are not on any of the teams.'); return true; } }, onAfterLoadPriority: -9999, onAfterLoad() { const game = this.room.scavgame!; if (Object.keys(game.teams).length === 0) { this.announce('Teams have not been set up yet. Please reset the hunt.'); } }, // -1 so that blind incog takes precedence of this onCorrectAnswerPriority: -1, onCorrectAnswer(player, value) { const game = this.room.scavgame!; if (player.currentQuestion + 1 < this.questions.length) { game.teamAnnounce( player, Utils.html`${player.name} has gotten the correct answer (${value}) for question #${player.currentQuestion + 1}.` ); game.advanceTeam(player); return false; } }, onAnySubmit(player, value) { const game = this.room.scavgame!; const team = game.getPlayerTeam(player); if (!team) return true; // handle players who get kicked during the hunt. if (player.currentQuestion !== team.question - 1) player.currentQuestion = team.question - 1; if (team.completed) player.completed = true; if (player.completed) return; if (team.answers.includes(value)) return; game.teamAnnounce(player, Utils.html`${player.name} has guessed "${value}".`); team.answers.push(value); }, onCompletePriority: 2, onComplete(player, time, blitz) { const game = this.room.scavgame!; const team = game.getPlayerTeam(player); return {name: team.name, time, blitz}; }, // workaround that gives the answer after verifying that completion should not be hidden onConfirmCompletion(player, time, blitz) { const game = this.room.scavgame!; const team = game.getPlayerTeam(player); team.completed = true; team.question++; game.teamAnnounce( player, Utils.html`${player.name} has gotten the correct answer for question #${player.currentQuestion}. Your team has completed the hunt!` ); }, onEnd() { const game = this.room.scavgame!; for (const teamID in game.teams) { const team = game.teams[teamID]; team.answers = []; team.question = 1; team.completed = false; } }, }, }, }; export class ScavengerGameTemplate { room: Room; playerlist: null | string[]; timer: NodeJS.Timer | null; [k: string]: any; constructor(room: Room) { this.room = room; this.playerlist = null; this.timer = null; } destroy(force?: boolean) { if (this.timer) clearTimeout(this.timer); const game = this.room.getGame(ScavengerHunt); if (force && game) game.onEnd(false); this.room.scavgame = null; } eliminate(userid: string) { if (!this.playerlist?.includes(userid)) return false; this.playerlist = this.playerlist.filter(pid => pid !== userid); if (this.leaderboard) delete this.leaderboard.data[userid]; return true; } announce(msg: string) { this.room.add(`|raw|
${msg}
`).update(); } } const LoadGame = function (room: Room, gameid: string) { let game = MODES[gameid]; if (!game) return false; // invalid id if (typeof game === 'string') game = MODES[game]; const base = new ScavengerGameTemplate(room); const scavgame = Object.assign(base, Utils.deepClone(game)); // initialize leaderboard if required if (scavgame.leaderboard) { scavgame.leaderboard = new Leaderboard(); } return scavgame; }; export const ScavMods = { LoadGame, twists: TWISTS, modes: MODES, };