/** * UNO * Pokemon Showdown - http://pokemonshowdown.com/ * * This plugin allows rooms to run games of scripted UNO * * @license MIT license */ 'use strict'; const maxTime = 60; // seconds const rgbGradients = { 'Green': "rgba(0, 122, 0, 1), rgba(0, 185, 0, 0.9)", 'Yellow': "rgba(255, 225, 0, 1), rgba(255, 255, 85, 0.9)", 'Blue': "rgba(40, 40, 255, 1), rgba(125, 125, 255, 0.9)", 'Red': "rgba(255, 0, 0, 1), rgba(255, 125, 125, 0.9)", 'Black': "rgba(0, 0, 0, 1), rgba(0, 0, 0, 0.55)", }; const textColors = { 'Green': "rgb(0, 128, 0)", 'Yellow': "rgb(175, 165, 40)", 'Blue': "rgb(75, 75, 255)", 'Red': "rgb(255, 0, 0)", 'Black': 'inherit', }; const textShadow = 'text-shadow: 1px 0px black, -1px 0px black, 0px -1px black, 0px 1px black, 2px -2px black;'; /** @typedef {'Green' | 'Yellow' | 'Red' | 'Blue' | 'Black'} Color */ /** @typedef {{value: string, color: Color, changedColor?: Color, name: string}} Card */ /** * @param {Card} card * @param {boolean} fullsize * @return {string} */ function cardHTML(card, fullsize) { let surface = card.value.replace(/[^A-Z0-9+]/g, ""); let background = rgbGradients[card.color]; if (surface === 'R') surface = ''; return ``; } /** * @return {Card[]} */ function createDeck() { /** @type {Color[]} */ const colors = ['Red', 'Blue', 'Green', 'Yellow']; const values = ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'Reverse', 'Skip', '+2']; let basic = /** @type {Card[]} */ ([]); for (const color of colors) { basic.push(...values.map(v => { /** @type {Card} */ let c = {value: v, color: color, name: color + " " + v}; return c; })); } return [ // two copies of the basic stuff (total 96) ...basic, ...basic, // The four 0s ...[0, 1, 2, 3].map(v => { /** @type {Card} */ let c = {color: colors[v], value: '0', name: colors[v] + ' 0'}; return c; }), // Wild cards ...[0, 1, 2, 3].map(v => { /** @type {Card} */ let c = {color: 'Black', value: 'Wild', name: 'Wild'}; return c; }), // Wild +4 cards ...[0, 1, 2, 3].map(v => { /** @type {Card} */ let c = {color: 'Black', value: '+4', name: 'Wild +4'}; return c; }), ]; // 108 cards } class UnoGame extends Rooms.RoomGame { /** * @param {ChatRoom | GameRoom} room * @param {number} cap * @param {boolean} suppressMessages */ constructor(room, cap, suppressMessages) { super(room); // TypeScript bug: no `T extends RoomGamePlayer` /** @type {{[userid: string]: UnoGamePlayer}} */ this.playerTable = Object.create(null); // TypeScript bug: no `T extends RoomGamePlayer` /** @type {UnoGamePlayer[]} */ this.players = []; if (room.gameNumber) { room.gameNumber++; } else { room.gameNumber = 1; } /** @type {number} */ this.playerCap = cap; this.allowRenames = true; /** @type {number} */ this.maxTime = maxTime; /** @type {NodeJS.Timer?} */ this.timer = null; /** @type {NodeJS.Timer?} */ this.autostartTimer = null; this.gameid = /** @type {ID} */ ('uno'); this.title = 'UNO'; /** @type {string} */ this.state = 'signups'; /** @type {string} */ this.currentPlayerid = ''; /** @type {Card[]} */ this.deck = Dex.shuffle(createDeck()); /** @type {Card[]} */ this.discards = []; /** @type {Card?} */ this.topCard = null; /** @type {string?} */ this.awaitUno = null; /** @type {string?} */ this.unoId = null; this.direction = 1; this.suppressMessages = suppressMessages || false; this.spectators = Object.create(null); this.sendToRoom(`|uhtml|uno-${this.room.gameNumber}|

A new game of UNO is starting!


Or use /uno join to join the game.

${(this.suppressMessages ? `

Game messages will be shown to only players. If you would like to spectate the game, use /uno spectate

` : '')}
`, true); } onUpdateConnection() {} /** * @param {User} user * @param {Connection} connection */ onConnect(user, connection) { if (this.state === 'signups') { connection.sendTo(this.room, `|uhtml|uno-${this.room.gameNumber}|

A new game of UNO is starting!


Or use /uno join to join the game.

${(this.suppressMessages ? `

Game messages will be shown to only players. If you would like to spectate the game, use /uno spectate

` : '')}
`); } else if (this.onSendHand(user) === false) { connection.sendTo(this.room, `|uhtml|uno-${this.room.gameNumber}|

A UNO game is currently in progress.

${(this.suppressMessages ? `

Game messages will be shown to only players. If you would like to spectate the game, use /uno spectate

` : '')}
`); } } /** * @return {false | void} */ onStart() { if (this.playerCount < 2) return false; if (this.autostartTimer) clearTimeout(this.autostartTimer); this.sendToRoom(`|uhtmlchange|uno-${this.room.gameNumber}|

The game of UNO has started.

${(this.suppressMessages ? `

Game messages will be shown to only players. If you would like to spectate the game, use /uno spectate

` : '')}
`, true); this.state = 'play'; this.onNextPlayer(); // determines the first player // give cards to the players for (let i in this.playerTable) { this.playerTable[i].hand.push(...this.drawCard(7)); } // top card of the deck. do { this.topCard = this.drawCard(1)[0]; this.discards.unshift(this.topCard); } while (this.topCard.color === 'Black'); this.sendToRoom(`|raw|The top card is ${this.topCard.name}.`); this.onRunEffect(this.topCard.value, true); this.nextTurn(true); } /** * @param {User} user * @return {boolean} */ joinGame(user) { if (this.state === 'signups' && this.addPlayer(user)) { this.sendToRoom(`${user.name} has joined the game of UNO.`); return true; } return false; } /** * @param {User} user * @return {boolean} */ leaveGame(user) { if (!(user.id in this.playerTable)) return false; if (this.state === 'signups' && this.removePlayer(user)) { this.sendToRoom(`${user.name} has left the game of UNO.`); return true; } return false; } /** * Overwrite the default makePlayer so it makes an UnoGamePlayer instead. * @param {User} user * @return {UnoGamePlayer} */ makePlayer(user) { return new UnoGamePlayer(user, this); } /** * @param {User} user * @param {string} oldUserid * @param {boolean} isJoining * @param {boolean} isForceRenamed * @return {false | void} */ onRename(user, oldUserid, isJoining, isForceRenamed) { if (!(oldUserid in this.playerTable) || user.id === oldUserid) return false; if (!user.named && !isForceRenamed) { user.games.delete(this.roomid); user.updateSearch(); return; // dont set users to their guest accounts. } this.playerTable[user.id] = this.playerTable[oldUserid]; if (user.id !== oldUserid) delete this.playerTable[oldUserid]; // only run if it's a rename that involves a change of userid // update the user's name information this.playerTable[user.id].name = user.name; this.playerTable[user.id].id = user.id; if (this.awaitUno && this.awaitUno === oldUserid) this.awaitUno = user.id; if (this.currentPlayerid === oldUserid) this.currentPlayerid = user.id; } /** * @param {string} userid * @return {string | false} */ eliminate(userid) { if (!(userid in this.playerTable)) return false; let name = this.playerTable[userid].name; if (this.playerCount === 2) { this.removePlayer(this.playerTable[userid]); this.onWin(this.playerTable[Object.keys(this.playerTable)[0]]); return name; } // handle current player... if (userid === this.currentPlayerid) { if (this.state === 'color') { if (!this.topCard) { // should never happen throw new Error(`No top card in the discard pile.`); } this.topCard.changedColor = this.discards[1].changedColor || this.discards[1].color; this.sendToRoom(`|raw|${Chat.escapeHTML(name)} has not picked a color, the color will stay as ${this.topCard.changedColor}.`); } if (this.timer) clearTimeout(this.timer); this.nextTurn(); } if (this.awaitUno === userid) this.awaitUno = null; // put that player's cards into the discard pile to prevent cards from being permanently lost this.discards.push(...this.playerTable[userid].hand); this.removePlayer(this.playerTable[userid]); return name; } /** * @param {string} msg * @param {boolean} [overrideSuppress] */ sendToRoom(msg, overrideSuppress = false) { if (!this.suppressMessages || overrideSuppress) { this.room.add(msg).update(); } else { // send to the players first for (let i in this.playerTable) { this.playerTable[i].sendRoom(msg); } // send to spectators for (let i in this.spectators) { if (i in this.playerTable) continue; // don't double send to users already in the game. let user = Users.getExact(i); if (user) user.sendTo(this.roomid, msg); } } } /** * @param {boolean} [showCards] * @return {string[]} */ getPlayers(showCards) { let playerList = Object.keys(this.playerTable); if (!showCards) { return playerList.sort().map(id => Chat.escapeHTML(this.playerTable[id].name)); } if (this.direction === -1) playerList = playerList.reverse(); return playerList.map(id => `${(this.currentPlayerid === id ? '' : '')}${Chat.escapeHTML(this.playerTable[id].name)} (${this.playerTable[id].hand.length}) ${(this.currentPlayerid === id ? '' : "")}`); } /** * @return {Promise} */ onAwaitUno() { return new Promise((resolve, reject) => { if (!this.awaitUno) return resolve(); this.state = "uno"; // the throttle for sending messages is at 600ms for non-authed users, // wait 750ms before sending the next person's turn. // this allows games to be fairer, so the next player would not spam the pass command blindly // to force the player to draw 2 cards. // this also makes games with uno bots not always turn in the bot's favour. // without a delayed turn, 3 bots playing will always result in a endless game setTimeout(() => resolve(), 750); }); } /** * @param {boolean} [starting] */ nextTurn(starting) { this.onAwaitUno() .then(() => { if (!starting) this.onNextPlayer(); if (this.timer) clearTimeout(this.timer); let player = this.playerTable[this.currentPlayerid]; this.sendToRoom(`|c:|${(Math.floor(Date.now() / 1000))}|~|${player.name}'s turn.`); this.state = 'play'; if (player.cardLock) player.cardLock = null; player.sendDisplay(); this.timer = setTimeout(() => { this.sendToRoom(`${player.name} has been automatically disqualified.`); this.eliminate(this.currentPlayerid); }, this.maxTime * 1000); }); } onNextPlayer() { // if none is set if (!this.currentPlayerid) { let userList = Object.keys(this.playerTable); this.currentPlayerid = userList[Math.floor(this.playerCount * Math.random())]; } this.currentPlayerid = this.getNextPlayer(); } /** * @return {string} */ getNextPlayer() { let userList = Object.keys(this.playerTable); let player = userList[(userList.indexOf(this.currentPlayerid) + this.direction)]; if (!player) { player = this.direction === 1 ? userList[0] : userList[this.playerCount - 1]; } return player; } /** * @param {UnoGamePlayer} player * @return {boolean | void} */ onDraw(player) { if (this.currentPlayerid !== player.id || this.state !== 'play') return false; if (player.cardLock) return true; this.onCheckUno(); this.sendToRoom(`${player.name} has drawn a card.`); let card = this.onDrawCard(player, 1); player.sendDisplay(); player.cardLock = card[0].name; } /** * @param {UnoGamePlayer} player * @param {string} cardName * @return {false | string | void} */ onPlay(player, cardName) { if (this.currentPlayerid !== player.id || this.state !== 'play') return false; let card = player.hasCard(cardName); if (!card) return "You do not have that card."; // check for legal play if (!this.topCard) { // should never happen throw new Error(`No top card in the discard pile.`); } if (player.cardLock && player.cardLock !== cardName) return `You can only play ${player.cardLock} after drawing.`; if (card.color !== 'Black' && card.color !== (this.topCard.changedColor || this.topCard.color) && card.value !== this.topCard.value) return `You cannot play this card - you can only play: Wild cards, ${(this.topCard.changedColor ? 'and' : '')} ${(this.topCard.changedColor || this.topCard.color)} cards${this.topCard.changedColor ? "" : ` and ${this.topCard.value}'s`}.`; if (card.value === '+4' && !player.canPlayWildFour()) return "You cannot play Wild +4 when you still have a card with the same color as the top card."; if (this.timer) clearTimeout(this.timer); // reset the autodq timer. this.onCheckUno(); // update the game information. this.topCard = card; player.removeCard(cardName); this.discards.unshift(card); // update the unoId here, so when the display is sent to the player when the play is made if (player.hand.length === 1) { this.awaitUno = player.id; this.unoId = Math.floor(Math.random() * 100).toString(); } player.sendDisplay(); // update display without the card in it for purposes such as choosing colors this.sendToRoom(`|raw|${Chat.escapeHTML(player.name)} has played a ${card.name}.`); // handle hand size if (!player.hand.length) { this.onWin(player); return; } // continue with effects and next player this.onRunEffect(card.value); if (this.state === 'play') this.nextTurn(); } /** * @param {string} value * @param {boolean} [initialize] */ onRunEffect(value, initialize) { const colorDisplay = `|uhtml|uno-hand|
`; switch (value) { case 'Reverse': this.direction *= -1; this.sendToRoom("The direction of the game has changed."); if (!initialize && this.playerCount === 2) this.onNextPlayer(); // in 2 player games, reverse sends the turn back to the player. break; case 'Skip': this.onNextPlayer(); this.sendToRoom(this.playerTable[this.currentPlayerid].name + "'s turn has been skipped."); break; case '+2': this.onNextPlayer(); this.sendToRoom(this.playerTable[this.currentPlayerid].name + " has been forced to draw 2 cards."); this.onDrawCard(this.playerTable[this.currentPlayerid], 2); break; case '+4': this.playerTable[this.currentPlayerid].sendRoom(colorDisplay); this.state = 'color'; // apply to the next in line, since the current player still has to choose the color let next = this.getNextPlayer(); this.sendToRoom(this.playerTable[next].name + " has been forced to draw 4 cards."); this.onDrawCard(this.playerTable[next], 4); this.isPlusFour = true; this.timer = setTimeout(() => { this.sendToRoom(`${this.playerTable[this.currentPlayerid].name} has been automatically disqualified.`); this.eliminate(this.currentPlayerid); }, this.maxTime * 1000); break; case 'Wild': this.playerTable[this.currentPlayerid].sendRoom(colorDisplay); this.state = 'color'; this.timer = setTimeout(() => { this.sendToRoom(`${this.playerTable[this.currentPlayerid].name} has been automatically disqualified.`); this.eliminate(this.currentPlayerid); }, this.maxTime * 1000); break; } if (initialize) this.onNextPlayer(); } /** * @param {UnoGamePlayer} player * @param {Color} color * @return {false | void} */ onSelectColor(player, color) { if (!['Red', 'Blue', 'Green', 'Yellow'].includes(color) || player.id !== this.currentPlayerid || this.state !== 'color') return false; if (!this.topCard) { // should never happen throw new Error(`No top card in the discard pile.`); } this.topCard.changedColor = color; this.sendToRoom(`The color has been changed to ${color}.`); if (this.timer) clearTimeout(this.timer); // send the display of their cards again player.sendDisplay(); if (this.isPlusFour) { this.isPlusFour = false; this.onNextPlayer(); // handle the skipping here. } this.nextTurn(); } /** * @param {UnoGamePlayer} player * @param {number} count * @return {Card[]} */ onDrawCard(player, count) { if (typeof count === 'string') count = parseInt(count); if (!count || isNaN(count) || count < 1) count = 1; let drawnCards = /** @type {Card[]} */ (this.drawCard(count)); player.hand.push(...drawnCards); player.sendRoom(`|raw|You have drawn the following card${Chat.plural(drawnCards)}: ${drawnCards.map(card => `${card.name}`).join(', ')}.`); return drawnCards; } /** * @param {number} count * @return {Card[]} */ drawCard(count) { if (typeof count === 'string') count = parseInt(count); if (!count || isNaN(count) || count < 1) count = 1; let drawnCards = /** @type {Card[]} */ ([]); for (let i = 0; i < count; i++) { if (!this.deck.length) { this.deck = this.discards.length ? Dex.shuffle(this.discards) : Dex.shuffle(createDeck()); // shuffle the cards back into the deck, or if there are no discards, add another deck into the game. this.discards = []; // clear discard pile } drawnCards.push(this.deck[this.deck.length - 1]); this.deck.pop(); } return drawnCards; } /** * @param {UnoGamePlayer} player * @param {string} unoId * @return {false | void} */ onUno(player, unoId) { // uno id makes spamming /uno uno impossible if (this.unoId !== unoId || player.id !== this.awaitUno) return false; this.sendToRoom(Chat.html`|raw|UNO! ${player.name} is down to their last card!`); this.awaitUno = null; this.unoId = null; } onCheckUno() { if (this.awaitUno) { // if the previous player hasn't hit UNO before the next player plays something, they are forced to draw 2 cards; if (this.awaitUno !== this.currentPlayerid) { this.sendToRoom(`${this.playerTable[this.awaitUno].name} forgot to say UNO! and is forced to draw 2 cards.`); this.onDrawCard(this.playerTable[this.awaitUno], 2); } this.awaitUno = null; this.unoId = null; } } /** * @param {User} user * @return {false | void} */ onSendHand(user) { if (!(user.id in this.playerTable) || this.state === 'signups') return false; this.playerTable[user.id].sendDisplay(); } /** * @param {UnoGamePlayer} player */ onWin(player) { this.sendToRoom(Chat.html`|raw|
Congratulations to ${player.name} for winning the game of UNO!
`, true); this.destroy(); } destroy() { if (this.timer) clearTimeout(this.timer); if (this.autostartTimer) clearTimeout(this.autostartTimer); this.sendToRoom(`|uhtmlchange|uno-${this.room.gameNumber}|
The game of UNO has ended.
`, true); // deallocate games for each player. for (let i in this.playerTable) { this.playerTable[i].destroy(); } delete this.room.game; } } class UnoGamePlayer extends Rooms.RoomGamePlayer { /** * @param {User} user * @param {UnoGame} game */ constructor(user, game) { super(user, game); this.hand = /** @type {Card[]} */ ([]); this.game = game; /** @type {string?} */ this.cardLock = null; } /** * @return {boolean} */ canPlayWildFour() { if (!this.game.topCard) { // should never happen throw new Error(`No top card in the discard pile.`); } let color = (this.game.topCard.changedColor || this.game.topCard.color); if (this.hand.some(c => c.color === color)) return false; return true; } /** * @param {string} cardName */ hasCard(cardName) { return this.hand.find(card => card.name === cardName); } /** * @param {string} cardName */ removeCard(cardName) { for (const [i, card] of this.hand.entries()) { if (card.name === cardName) { this.hand.splice(i, 1); break; } } } /** * @return {string[]} */ buildHand() { return this.hand.sort((a, b) => a.color.localeCompare(b.color) || a.value.localeCompare(b.value)) .map((c, i) => cardHTML(c, i === this.hand.length - 1)); } sendDisplay() { let hand = this.buildHand().join(''); let players = `

Players (${this.game.playerCount}):

${this.game.getPlayers(true).join('
')}`; let draw = ''; let pass = ''; let uno = ``; if (!this.game.topCard) { // should never happen throw new Error(`No top card in the discard pile.`); } let top = `Top Card: ${this.game.topCard.name}`; // clear previous display and show new display this.sendRoom("|uhtmlchange|uno-hand|"); this.sendRoom( `|uhtml|uno-hand|
${this.game.currentPlayerid === this.id ? `` : ""}` + `
${hand}
${top}
${players}
` + `${this.game.currentPlayerid === this.id ? `
${draw}${pass}
${uno}
` : ""}
` ); } } /** @type {ChatCommands} */ const commands = { uno: { // roomowner commands off: 'disable', disable(target, room, user) { if (!this.can('gamemanagement', null, room)) return; if (room.unoDisabled) { return this.errorReply("UNO is already disabled in this room."); } room.unoDisabled = true; if (room.chatRoomData) { room.chatRoomData.unoDisabled = true; Rooms.global.writeChatRoomData(); } return this.sendReply("UNO has been disabled for this room."); }, on: 'enable', enable(target, room, user) { if (!this.can('gamemanagement', null, room)) return; if (!room.unoDisabled) { return this.errorReply("UNO is already enabled in this room."); } delete room.unoDisabled; if (room.chatRoomData) { delete room.chatRoomData.unoDisabled; Rooms.global.writeChatRoomData(); } return this.sendReply("UNO has been enabled for this room."); }, // moderation commands new: 'create', make: 'create', createpublic: 'create', makepublic: 'create', createprivate: 'create', makeprivate: 'create', create(target, room, user, connection, cmd) { if (!this.can('minigame', null, room)) return; if (room.unoDisabled) return this.errorReply("UNO is currently disabled for this room."); if (room.game) return this.errorReply("There is already a game in progress in this room."); let suppressMessages = cmd.includes('private') || !(cmd.includes('public') || room.roomid === 'gamecorner'); let cap = parseInt(target); if (isNaN(cap)) cap = 6; if (cap < 2) cap = 2; room.game = new UnoGame(room, cap, suppressMessages); this.privateModAction(`(A game of UNO was created by ${user.name}.)`); this.modlog('UNO CREATE'); }, start(target, room, user) { if (!this.can('minigame', null, room)) return; const game = /** @type {UnoGame} */ (room.game); if (!game || game.gameid !== 'uno' || game.state !== 'signups') return this.errorReply("There is no UNO game in signups phase in this room."); if (game.onStart()) { this.privateModAction(`(The game of UNO was started by ${user.name}.)`); this.modlog('UNO START'); } }, stop: 'end', end(target, room, user) { if (!this.can('minigame', null, room)) return; if (!room.game || room.game.gameid !== 'uno') return this.errorReply("There is no UNO game going on in this room."); room.game.destroy(); room.add("The game of UNO was forcibly ended.").update(); this.privateModAction(`(The game of UNO was ended by ${user.name}.)`); this.modlog('UNO END'); }, timer(target, room, user) { if (!this.can('minigame', null, room)) return; const game = /** @type {UnoGame} */ (room.game); if (!game || game.gameid !== 'uno') return this.errorReply("There is no UNO game going on in this room."); let amount = parseInt(target); if (!amount || amount < 5 || amount > 300) return this.errorReply("The amount must be a number between 5 and 300."); game.maxTime = amount; if (game.timer) clearTimeout(game.timer); game.timer = setTimeout(() => { game.eliminate(game.currentPlayerid); }, amount * 1000); this.addModAction(`${user.name} has set the UNO automatic disqualification timer to ${amount} seconds.`); this.modlog('UNO TIMER', null, `${amount} seconds`); }, autostart(target, room, user) { if (!this.can('minigame', null, room)) return; const game = /** @type {UnoGame} */ (room.game); if (!game || game.gameid !== 'uno') return this.errorReply("There is no UNO game going on in this room right now."); if (toID(target) === 'off') { if (!game.autostartTimer) return this.errorReply("There is no autostart timer running on."); this.addModAction(`${user.name} has turned off the UNO autostart timer.`); clearTimeout(game.autostartTimer); return; } const amount = parseInt(target); if (!amount || amount < 30 || amount > 600) return this.errorReply("The amount must be a number between 30 and 600 seconds."); if (game.state !== 'signups') return this.errorReply("The game of UNO has already started."); if (game.autostartTimer) clearTimeout(game.autostartTimer); game.autostartTimer = setTimeout(() => { game.onStart(); }, amount * 1000); this.addModAction(`${user.name} has set the UNO autostart timer to ${amount} seconds.`); }, dq: 'disqualify', disqualify(target, room, user) { if (!this.can('minigame', null, room)) return; const game = /** @type {UnoGame} */ (room.game); if (!game || game.gameid !== 'uno') return this.errorReply("There is no UNO game going on in this room right now."); let disqualified = game.eliminate(toID(target)); if (disqualified === false) return this.errorReply(`Unable to disqualify ${target}.`); this.privateModAction(`(${user.name} has disqualified ${disqualified} from the UNO game.)`); this.modlog('UNO DQ', toID(target)); room.add(`${target} has been disqualified from the UNO game.`).update(); }, // player/user commands j: 'unojoin', // TypeScript doesn't like 'join' being defined as a function join: 'unojoin', unojoin(target, room, user) { const game = /** @type {UnoGame} */ (room.game); if (!game || game.gameid !== 'uno') return this.errorReply("There is no UNO game going on in this room right now."); if (!this.canTalk()) return false; if (!game.joinGame(user)) return this.errorReply("Unable to join the game."); return this.sendReply("You have joined the game of UNO."); }, l: 'leave', leave(target, room, user) { const game = /** @type {UnoGame} */ (room.game); if (!game || game.gameid !== 'uno') return this.errorReply("There is no UNO game going on in this room right now."); if (!game.leaveGame(user)) return this.errorReply("Unable to leave the game."); return this.sendReply("You have left the game of UNO."); }, play(target, room, user) { const game = /** @type {UnoGame} */ (room.game); if (!game || game.gameid !== 'uno') return this.errorReply("There is no UNO game going on in this room right now."); /** @type {UnoGamePlayer | undefined} */ let player = game.playerTable[user.id]; if (!player) return this.errorReply(`You are not in the game of UNO.`); let error = game.onPlay(player, target); if (error) this.errorReply(error); }, draw(target, room, user) { const game = /** @type {UnoGame} */ (room.game); if (!game || game.gameid !== 'uno') return this.errorReply("There is no UNO game going on in this room right now."); /** @type {UnoGamePlayer | undefined} */ let player = game.playerTable[user.id]; if (!player) return this.errorReply(`You are not in the game of UNO.`); let error = game.onDraw(player); if (error) return this.errorReply("You have already drawn a card this turn."); }, pass(target, room, user) { const game = /** @type {UnoGame} */ (room.game); if (!game || game.gameid !== 'uno') return this.errorReply("There is no UNO game going on in this room right now."); if (game.currentPlayerid !== user.id) return this.errorReply("It is currently not your turn."); /** @type {UnoGamePlayer | undefined} */ let player = game.playerTable[user.id]; if (!player) return this.errorReply(`You are not in the game of UNO.`); if (!player.cardLock) return this.errorReply("You cannot pass until you draw a card."); if (game.state === 'color') return this.errorReply("You cannot pass until you choose a color."); game.sendToRoom(`${user.name} has passed.`); game.nextTurn(); }, color(target, room, user) { const game = /** @type {UnoGame} */ (room.game); if (!game || game.gameid !== 'uno') return false; /** @type {UnoGamePlayer | undefined} */ let player = game.playerTable[user.id]; if (!player) return this.errorReply(`You are not in the game of UNO.`); /** @type {Color} */ let color; if (target === 'Red' || target === 'Green' || target === 'Blue' || target === 'Yellow' || target === 'Black') { color = target; } else { return this.errorReply(`"${target}" is not a valid color.`); } game.onSelectColor(player, color); }, uno(target, room, user) { const game = /** @type {UnoGame} */ (room.game); if (!game || game.gameid !== 'uno') return false; /** @type {UnoGamePlayer | undefined} */ let player = game.playerTable[user.id]; if (!player) return this.errorReply(`You are not in the game of UNO.`); game.onUno(player, target); }, // information commands '': 'hand', hand(target, room, user) { const game = /** @type {UnoGame} */ (room.game); if (!game || game.gameid !== 'uno') return this.parse("/help uno"); game.onSendHand(user); }, players: 'getusers', users: 'getusers', getplayers: 'getusers', getusers(target, room, user) { const game = /** @type {UnoGame} */ (room.game); if (!game || game.gameid !== 'uno') return this.errorReply("There is no UNO game going on in this room right now."); if (!this.runBroadcast()) return false; this.sendReplyBox(`Players (${game.playerCount}):
${game.getPlayers().join(', ')}`); }, help(target, room, user) { this.parse('/help uno'); }, // suppression commands suppress(target, room, user) { const game = /** @type {UnoGame} */ (room.game); if (!game || game.gameid !== 'uno') return this.errorReply("There is no UNO game going on in this room right now."); if (!this.can('minigame', null, room)) return; target = toID(target); let state = target === 'on' ? true : target === 'off' ? false : undefined; if (state === undefined) return this.sendReply(`Suppression of UNO game messages is currently ${(game.suppressMessages ? 'on' : 'off')}.`); if (state === game.suppressMessages) return this.errorReply(`Suppression of UNO game messages is already ${(game.suppressMessages ? 'on' : 'off')}.`); game.suppressMessages = state; this.addModAction(`${user.name} has turned ${(state ? 'on' : 'off')} suppression of UNO game messages.`); this.modlog('UNO SUPRESS', null, (state ? 'ON' : 'OFF')); }, spectate(target, room, user) { const game = /** @type {UnoGame} */ (room.game); if (!game || game.gameid !== 'uno') return this.errorReply("There is no UNO game going on in this room right now."); if (!game.suppressMessages) return this.errorReply("The current UNO game is not suppressing messages."); if (user.id in game.spectators) return this.errorReply("You are already spectating this game."); game.spectators[user.id] = 1; this.sendReply("You are now spectating this private UNO game."); }, unspectate(target, room, user) { const game = /** @type {UnoGame} */ (room.game); if (!game || game.gameid !== 'uno') return this.errorReply("There is no UNO game going on in this room right now."); if (!game.suppressMessages) return this.errorReply("The current UNO game is not suppressing messages."); if (!(user.id in game.spectators)) return this.errorReply("You are currently not spectating this game."); delete game.spectators[user.id]; this.sendReply("You are no longer spectating this private UNO game."); }, }, unohelp: [ `/uno create [player cap] - creates a new UNO game with an optional player cap (default player cap at 6). Use the command [createpublic] to force a public game or [createprivate] to force a private game. Requires: % @ # & ~`, `/uno timer [amount] - sets an auto disqualification timer for [amount] seconds. Requires: % @ # & ~`, `/uno autostart [amount] - sets an auto starting timer for [amount] seconds. Requires: % @ # & ~`, `/uno end - ends the current game of UNO. Requires: % @ # & ~`, `/uno start - starts the current game of UNO. Requires: % @ # & ~`, `/uno disqualify [player] - disqualifies the player from the game. Requires: % @ # & ~`, `/uno hand - displays your own hand.`, `/uno getusers - displays the players still in the game.`, `/uno [spectate|unspectate] - spectate / unspectate the current private UNO game.`, `/uno suppress [on|off] - Toggles suppression of game messages.`, ], }; exports.commands = commands;